04.3 3層アーキテクチャ・MVC・データアクセス層(DAO)¶
これまでのレッスンでは、HTTPハンドラの中で直接 JSON を組み立てたり、直接 SQL を書いたりしてきました。 実務のAPIサーバーでは、これを役割ごとに層(レイヤー)へ分けます。
- ハンドラ層(Presentation / Controller): HTTPリクエストを受け取り、レスポンスを返す
- サービス層(Service / ビジネスロジック): 業務ルール(バリデーション等)を担う
- DAO層(Data Access Object): DB とのやり取り(SQL)だけを担う
このレッスンのゴール:
- 3層に分けた実装で、リクエストがハンドラ→サービス→DAO→DBの順に流れることを実ログで確認できる
- 層をまたいでも同じ形のデータ(DTO)が一貫して使われることを確認できる
- 「MVC」という呼び方と3層アーキテクチャの対応関係を説明できる
1. 非自明な点①: なぜ層を分けるのか¶
もし1つの関数の中でHTTPリクエストの解析・バリデーション・SQL実行を全部書くと、 「SQLをPostgreSQLからMySQLに変えたい」「バリデーションルールだけ変えたい」といった変更のたびに、 HTTPの処理まで巻き込んで壊すリスクが生まれます。層を分けると、DAO層だけ差し替えれば サービス層・ハンドラ層は無傷で済みます(01.6 で学んだ DIP と同じ考え方です)。
「MVC」(Model-View-Controller)はこの分離のもう1つの呼び方です。このレッスンでは View(画面描画)を持たない API サーバーなので、代わりに「ハンドラ層・サービス層・DAO層」と呼びます (Model = DTO/DAO、Controller = ハンドラ、View = JSONレスポンスそのもの、に相当します)。
2. 非自明な点②: DAO層は §4 DB契約(1セル完結)に従う¶
DAO層は modernc.org/sqlite を使います。GoNB のセル実行モデルに合わせ、DBは1セル完結
(open → defer Close() → 操作、状態共有はディスク上のファイル経由)で使います。
*sql.DB をセルをまたいで生きたまま持ち越しません。
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"github.com/janpfeifer/gonb/gonbui"
_ "modernc.org/sqlite"
)
// ErrUnanswered は、練習問題が未回答のときにプレースホルダ関数が返す特別なエラー。
var ErrUnanswered = errors.New("未回答: この関数はまだ実装されていません")
// openCourseDB は、このコース共通の SQLite 接続を開く(1セル完結で使う)。
func openCourseDB(path string) *sql.DB {
db, err := sql.Open("sqlite", path)
if err != nil {
panic(err)
}
db.SetMaxOpenConns(1)
return db
}
const dbPath = "file:_04_3_three_layer.db"
// ---- Model / DTO 層: 層をまたいで一貫して使うデータの形 ----
// Task は、ハンドラ層・サービス層・DAO層のすべてで共通して使う DTO。
type Task struct {
ID int64 `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
// ---- DAO層: SQLの読み書きだけを担う ----
type TaskDAO struct{}
// Create は新しい Task を1件挿入し、採番された ID を返す。
func (TaskDAO) Create(db *sql.DB, title string) (int64, error) {
res, err := db.Exec(`INSERT INTO tasks (title, done) VALUES (?, 0)`, title)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
// List は全 Task を id 順に返す。
func (TaskDAO) List(db *sql.DB) ([]Task, error) {
rows, err := db.Query(`SELECT id, title, done FROM tasks ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var tasks []Task
for rows.Next() {
var t Task
if err := rows.Scan(&t.ID, &t.Title, &t.Done); err != nil {
return nil, err
}
tasks = append(tasks, t)
}
return tasks, nil
}
// GetByID は id で1件取得する。見つからなければ sql.ErrNoRows を返す。
func (TaskDAO) GetByID(db *sql.DB, id int64) (Task, error) {
var t Task
err := db.QueryRow(`SELECT id, title, done FROM tasks WHERE id = ?`, id).Scan(&t.ID, &t.Title, &t.Done)
return t, err
}
// ---- サービス層: 業務ルール(バリデーション)を担う ----
type TaskService struct {
dao TaskDAO
log *[]string // 呼び出しの流れを記録する(このレッスンの可視化用)
}
// CreateTask はタイトルを検証してから DAO に委譲する。
func (s TaskService) CreateTask(db *sql.DB, title string) (int64, error) {
*s.log = append(*s.log, fmt.Sprintf("サービス層: CreateTask(title=%q) を検証", title))
if strings.TrimSpace(title) == "" {
return 0, errors.New("title must not be empty")
}
*s.log = append(*s.log, "サービス層 → DAO層: Create")
return s.dao.Create(db, title)
}
func (s TaskService) ListTasks(db *sql.DB) ([]Task, error) {
*s.log = append(*s.log, "サービス層 → DAO層: List")
return s.dao.List(db)
}
// ---- ハンドラ層: HTTPリクエスト/レスポンスの変換だけを担う ----
func newTaskServer(db *sql.DB, log *[]string) *httptest.Server {
svc := TaskService{log: log}
mux := http.NewServeMux()
mux.HandleFunc("POST /tasks", func(w http.ResponseWriter, r *http.Request) {
*log = append(*log, "ハンドラ層: POST /tasks を受信")
body, _ := io.ReadAll(r.Body)
var req struct {
Title string `json:"title"`
}
if err := json.Unmarshal(body, &req); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
id, err := svc.CreateTask(db, req.Title)
if err != nil {
w.WriteHeader(http.StatusUnprocessableEntity)
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(Task{ID: id, Title: req.Title, Done: false})
})
mux.HandleFunc("GET /tasks", func(w http.ResponseWriter, r *http.Request) {
*log = append(*log, "ハンドラ層: GET /tasks を受信")
tasks, err := svc.ListTasks(db)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(tasks)
})
return httptest.NewServer(mux)
}
func renderFlow(steps []string) string {
var b strings.Builder
b.WriteString(`<ol>`)
for _, s := range steps {
b.WriteString(fmt.Sprintf(`<li>%s</li>`, s))
}
b.WriteString(`</ol>`)
return b.String()
}
func renderTasks(tasks []Task) string {
var b strings.Builder
b.WriteString(`<table border="1" cellpadding="4" style="border-collapse:collapse">`)
b.WriteString(`<tr><th>id</th><th>title</th><th>done</th></tr>`)
for _, t := range tasks {
b.WriteString(fmt.Sprintf(`<tr><td>%d</td><td>%s</td><td>%v</td></tr>`, t.ID, t.Title, t.Done))
}
b.WriteString(`</table>`)
return b.String()
}
3. タスクテーブルをシードする¶
DROP → CREATE → INSERT を1セルにまとめ、再実行しても同じ初期状態に戻せるようにします(冪等シード)。
%%
db := openCourseDB(dbPath)
defer db.Close()
db.Exec(`DROP TABLE IF EXISTS tasks`)
db.Exec(`CREATE TABLE tasks (id INTEGER PRIMARY KEY, title TEXT, done INTEGER)`)
var count int
db.QueryRow(`SELECT COUNT(*) FROM tasks`).Scan(&count)
fmt.Println("✅ tasks テーブルを初期化しました。行数:", count)
✅ tasks テーブルを初期化しました。行数: 0
4. リクエストの流れを実ログで見る¶
POST /tasks を1回投げます。ハンドラ層 → サービス層 → DAO層の順に処理が渡っていくログを、
TaskService の log スライスに実際に記録しながら表示します。
%%
db := openCourseDB(dbPath)
defer db.Close()
var flowLog []string
srv := newTaskServer(db, &flowLog)
defer srv.Close()
resp, err := http.Post(srv.URL+"/tasks", "application/json", strings.NewReader(`{"title":"牛乳を買う"}`))
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Printf("POST /tasks → status=%d body=%s\n", resp.StatusCode, body)
gonbui.DisplayHTML("<b>リクエストの流れ:</b>" + renderFlow(flowLog))
gonbui.Sync()
POST /tasks → status=201 body={"id":1,"title":"牛乳を買う","done":false}
- ハンドラ層: POST /tasks を受信
- サービス層: CreateTask(title="牛乳を買う") を検証
- サービス層 → DAO層: Create
5. 一貫したDTOが層をまたいで使われることを確認する¶
Task という同じ型が、DAO層の戻り値・サービス層の戻り値・ハンドラ層のJSONレスポンスの
すべてで使われています。GET /tasks を叩き、DBに保存された内容と一致することを確認します。
%%
db := openCourseDB(dbPath)
defer db.Close()
var flowLog []string
srv := newTaskServer(db, &flowLog)
defer srv.Close()
resp, err := http.Get(srv.URL + "/tasks")
if err != nil {
panic(err)
}
defer resp.Body.Close()
var tasks []Task
if err := json.NewDecoder(resp.Body).Decode(&tasks); err != nil {
panic(err)
}
fmt.Printf("GET /tasks → status=%d, 件数=%d\n", resp.StatusCode, len(tasks))
gonbui.DisplayHTML("<b>DAO→サービス→ハンドラを通って返ってきた Task 一覧:</b>" + renderTasks(tasks))
gonbui.Sync()
GET /tasks → status=200, 件数=1
| id | title | done |
|---|---|---|
| 1 | 牛乳を買う | false |
6. 直感・類推: レストランの役割分担¶
3層アーキテクチャは、レストランの役割分担に似ています。
- ホール係(ハンドラ層): お客さんの注文を受け、料理を運ぶ。厨房でどう調理しているかは知らない
- 料理長(サービス層): 注文の内容を確認する(「in stockか」「アレルギー対応できるか」)。 食材がどこから来るかは知らない
- 仕入れ係(DAO層): 実際に倉庫(DB)から食材を出し入れする
ホール係が直接倉庫に行って食材を取ってきたら、役割が混ざって「メニューを変えたいだけなのに ホール係の教育もやり直す」ような事態になります。層を分けることで、倉庫のルール(SQL)が 変わってもホール係(ハンドラ)は影響を受けません。
練習問題 4.3: TaskDAO.MarkDone を実装しよう¶
DAO層に MarkDone(完了フラグを立てる)メソッドを追加してください。
仕様:
func (TaskDAO) MarkDone(db *sql.DB, id int64) error
idに一致する行のdoneを1に更新する- 対象行が無い場合もエラーにしない(
0, nil相当の更新結果を許容する) - SQL エラーが起きたらそのエラーを返す
// YOUR CODE HERE
// TaskDAO に MarkDone メソッドを実装してください。
// (未実装のままチェックセルを実行すると「未回答」と表示されます)
func (TaskDAO) MarkDone(db *sql.DB, id int64) error {
return ErrUnanswered
}
チェックのためのヘルパー¶
答え合わせに使う小さなヘルパー mustEqual を定義します。
(GoNB はローカルパッケージを import できないため、各ノートブックにこの定義を置いています)
import "reflect"
func mustEqual(got, want any, name string) {
if reflect.DeepEqual(got, want) {
fmt.Printf("✅ Passed: %s\n", name)
return
}
panic(fmt.Sprintf("❌ %s\n got = %v (%T)\n want = %v (%T)", name, got, got, want, want))
}
%%
db := openCourseDB(dbPath)
defer db.Close()
dao := TaskDAO{}
id, createErr := dao.Create(db, "牛乳を飲む")
if createErr != nil {
panic(createErr)
}
err := dao.MarkDone(db, id)
if errors.Is(err, ErrUnanswered) {
fmt.Println("⚠️ 未回答: 練習問題を解いてから、このセルを再度実行してください")
} else {
mustEqual(err, nil, "MarkDone はエラーなし")
got, getErr := dao.GetByID(db, id)
mustEqual(getErr, nil, "GetByID はエラーなし")
mustEqual(got.Done, true, "MarkDone 後は done=true")
fmt.Println("🎉 すべてのチェックが通りました")
}
⚠️ 未回答: 練習問題を解いてから、このセルを再度実行してください
まとめ¶
- 3層アーキテクチャは「ハンドラ層(HTTP変換)→サービス層(業務ルール)→DAO層(SQL)」で 関心を分離する。MVC もこの分離の別の呼び方
- 層をまたいでも同じ形の DTO(
Task)を使うことで、各層の責務が混ざらない - DAO層だけを差し替えれば、サービス層・ハンドラ層は変更せずに済む
次は 04.4 で「認証・ウェブセキュリティ」を扱います。