← レッスン一覧に戻る

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 をセルをまたいで生きたまま持ち越しません。

In [1]:
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セルにまとめ、再実行しても同じ初期状態に戻せるようにします(冪等シード)。

In [2]:
%%
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 スライスに実際に記録しながら表示します。

In [3]:
%%
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}

リクエストの流れ:
  1. ハンドラ層: POST /tasks を受信
  2. サービス層: CreateTask(title="牛乳を買う") を検証
  3. サービス層 → DAO層: Create

5. 一貫したDTOが層をまたいで使われることを確認する¶

Task という同じ型が、DAO層の戻り値・サービス層の戻り値・ハンドラ層のJSONレスポンスの すべてで使われています。GET /tasks を叩き、DBに保存された内容と一致することを確認します。

In [4]:
%%
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
DAO→サービス→ハンドラを通って返ってきた Task 一覧:
idtitledone
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 エラーが起きたらそのエラーを返す
In [5]:
// YOUR CODE HERE
// TaskDAO に MarkDone メソッドを実装してください。
// (未実装のままチェックセルを実行すると「未回答」と表示されます)
func (TaskDAO) MarkDone(db *sql.DB, id int64) error {
	return ErrUnanswered
}

チェックのためのヘルパー¶

答え合わせに使う小さなヘルパー mustEqual を定義します。 (GoNB はローカルパッケージを import できないため、各ノートブックにこの定義を置いています)

In [6]:
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))
}
In [7]:
%%
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 で「認証・ウェブセキュリティ」を扱います。