05.1 フレームワークが無いと何が辛いか — 定形処理の重複を実測する¶
Module 4 では net/http の標準ライブラリだけで REST API を組み立てました。
http.NewServeMux() のルーティングも encoding/json のエンコードも、確かに便利でした。
でも、実際にプロダクトで使う API には、ルーティングと JSON 変換だけでは済まない仕事があります。
- リクエストが来たらログに残す
- 認証されているかをチェックする
- リクエストボディをJSONとしてデコードする
- デコードした値をバリデーションする
- 失敗したら決まった形のエラーレスポンスを返す
- 成功したら決まった形の成功レスポンスを返す
このレッスンでは、これらをまだ共通化せず、ハンドラ1つ1つに正直に書きます。 そして「同じ処理が何回、何ハンドラに書かれているか」を実際にコードを実行して数えます。
このレッスンのゴール:
- タスクAPIの4本のハンドラ(一覧・作成・更新・削除)を素の
net/httpで書く - 実リクエストを送り、どの定形処理がどのハンドラで実行されたかを実測する
- 「操作の種類(GET/POST/PUT/DELETE)に関わらず、共通して必要な定形処理がある」ことを表で確認する
- これが 05.2(自作ミニフレームワーク)で解消する対象そのものであることを理解する
非自明ポイント¶
1. ルーティングと JSON 変換だけでは、ハンドラは「素」にならない
http.ServeMux と encoding/json は「URLをどう振り分けるか」「Goの値をどうJSONにするか」しか
面倒を見てくれません。「このリクエストは誰から来たか」「入力は正しいか」「失敗をどう伝えるか」は
ハンドラの外側にある関心事(cross-cutting concern)で、標準ライブラリはここに関知しません。
結果として、これらはハンドラの中に手で書くことになります。
2. ミドルウェア(middleware)とは「共通処理を挟むパターン」の名前
「ログ出力」「認証チェック」は、どのハンドラでもほぼ同じコードになります。この共通コードを ハンドラの外側に括り出し、リクエストの前後に自動的に挟み込む仕組みが「ミドルウェア」です。 このレッスンではあえてその仕組みをまだ作らず、コピペしたままにします — 05.2 で実際に ミドルウェアとして括り出すとき、「何を、どこから、どこへ移したか」がはっきり分かるようにするためです。
3. 「読むだけ(GET)」でも定形処理はゼロにならない
「読み込み専用の操作なら、認証やエラー処理は要らないのでは」と思うかもしれません。実際には
一覧取得(GET /tasks)にも認証チェックとエラー応答が必要です — 操作の種類が変わっても、
ログ出力・認証チェック・エラー応答・成功応答の4つは、ほぼ全ハンドラに共通して必要になります。
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"time"
"github.com/janpfeifer/gonb/gonbui"
)
// ErrUnanswered は、練習問題が未回答のときにプレースホルダ関数が返す特別なエラー。
var ErrUnanswered = errors.New("未回答: この関数はまだ実装されていません")
// Task はこのレッスンで扱う唯一のリソース。
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
func renderTable(headers []string, rows [][]string) string {
var b strings.Builder
b.WriteString(`<table border="1" cellpadding="4" style="border-collapse:collapse"><tr>`)
for _, h := range headers {
b.WriteString(fmt.Sprintf("<th>%s</th>", h))
}
b.WriteString("</tr>")
for _, row := range rows {
b.WriteString("<tr>")
for _, cell := range row {
b.WriteString(fmt.Sprintf("<td>%s</td>", cell))
}
b.WriteString("</tr>")
}
b.WriteString("</table>")
return b.String()
}
// writeJSON はステータスコードと JSON ボディを一緒に書き込む共通ヘルパー。
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
// stepLog は「どのハンドラで、どの定形処理が、何回実行されたか」を数える実測用の記録。
// キーは [ハンドラ名, 定形処理名]。各ハンドラの中で track(...) を呼ぶたびに増える —
// フレームワークが無い世界では、この track 呼び出し(=定形処理そのもの)を、
// ハンドラごとに手でコピペする必要がある。
var stepLog = map[[2]string]int{}
var stepMu sync.Mutex
func track(handler, step string) {
stepMu.Lock()
defer stepMu.Unlock()
stepLog[[2]string{handler, step}]++
}
1. 素の net/http ハンドラを4本書く¶
タスク(Task)に対する一覧・作成・更新・削除の4操作を、それぞれ独立したハンドラとして書きます。
共通化はせず、各ハンドラの中に定形処理をそのまま書くのがこのレッスンの意図的な設計です。
const authToken = "Bearer secret-token"
// newRawServer は net/http 標準ライブラリだけで組み立てた「素の」タスクAPI。
// 4つのハンドラそれぞれが、ログ出力・認証チェック・エラー応答・成功応答を自分の中に書いている。
// POST/PUT はさらに JSONデコード・バリデーションも自分の中に書いている。
func newRawServer() *http.ServeMux {
var mu sync.Mutex
store := map[int]*Task{
1: {ID: 1, Title: "牛乳を買う", Done: false},
}
nextID := 2
mux := http.NewServeMux()
// --- GET /tasks: 一覧 ---------------------------------------------------
mux.HandleFunc("GET /tasks", func(w http.ResponseWriter, r *http.Request) {
const handler = "GET /tasks"
track(handler, "ログ出力")
fmt.Printf("[LOG %s] %s %s\n", time.Now().Format("15:04:05"), r.Method, r.URL.Path)
track(handler, "認証チェック")
if r.Header.Get("Authorization") != authToken {
track(handler, "エラー応答")
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
return
}
mu.Lock()
var list []*Task
for _, t := range store {
list = append(list, t)
}
mu.Unlock()
track(handler, "成功応答")
writeJSON(w, http.StatusOK, list)
})
// --- POST /tasks: 作成 ---------------------------------------------------
mux.HandleFunc("POST /tasks", func(w http.ResponseWriter, r *http.Request) {
const handler = "POST /tasks"
track(handler, "ログ出力")
fmt.Printf("[LOG %s] %s %s\n", time.Now().Format("15:04:05"), r.Method, r.URL.Path)
track(handler, "認証チェック")
if r.Header.Get("Authorization") != authToken {
track(handler, "エラー応答")
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
return
}
track(handler, "JSONデコード")
var body struct {
Title string `json:"title"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
track(handler, "エラー応答")
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
track(handler, "バリデーション")
if body.Title == "" {
track(handler, "エラー応答")
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "title is required"})
return
}
mu.Lock()
t := &Task{ID: nextID, Title: body.Title, Done: false}
store[nextID] = t
nextID++
mu.Unlock()
track(handler, "成功応答")
writeJSON(w, http.StatusCreated, t)
})
// --- PUT /tasks/{id}: 更新 -------------------------------------------------
mux.HandleFunc("PUT /tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
const handler = "PUT /tasks/{id}"
track(handler, "ログ出力")
fmt.Printf("[LOG %s] %s %s\n", time.Now().Format("15:04:05"), r.Method, r.URL.Path)
track(handler, "認証チェック")
if r.Header.Get("Authorization") != authToken {
track(handler, "エラー応答")
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
return
}
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
track(handler, "エラー応答")
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
track(handler, "JSONデコード")
var body struct {
Title string `json:"title"`
Done bool `json:"done"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
track(handler, "エラー応答")
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
track(handler, "バリデーション")
if body.Title == "" {
track(handler, "エラー応答")
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "title is required"})
return
}
mu.Lock()
t, ok := store[id]
if ok {
t.Title = body.Title
t.Done = body.Done
}
mu.Unlock()
if !ok {
track(handler, "エラー応答")
writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"})
return
}
track(handler, "成功応答")
writeJSON(w, http.StatusOK, t)
})
// --- DELETE /tasks/{id}: 削除 -----------------------------------------------
mux.HandleFunc("DELETE /tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
const handler = "DELETE /tasks/{id}"
track(handler, "ログ出力")
fmt.Printf("[LOG %s] %s %s\n", time.Now().Format("15:04:05"), r.Method, r.URL.Path)
track(handler, "認証チェック")
if r.Header.Get("Authorization") != authToken {
track(handler, "エラー応答")
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
return
}
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
track(handler, "エラー応答")
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
mu.Lock()
_, ok := store[id]
if ok {
delete(store, id)
}
mu.Unlock()
if !ok {
track(handler, "エラー応答")
writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"})
return
}
track(handler, "成功応答")
w.WriteHeader(http.StatusNoContent)
})
return mux
}
2. 実行して測る — 8つの実リクエストを送り、定形処理を可視化する¶
httptest.NewServer で実際にサーバーを起動し、http.Client で本物の HTTP リクエストを送ります。
成功パターンと失敗パターンの両方を送ることで、各ハンドラの成功応答とエラー応答の両方の
コードパスを実際に通します。
続けて、その8リクエストで実際に通った track(...) 呼び出しを集計し、
「ハンドラ × 定形処理」の表を作ります(同じセル内で行うのは GoNB の制約のため — 下のコード参照)。
%%
srv := httptest.NewServer(newRawServer())
defer srv.Close()
client := &http.Client{}
type scenario struct {
label string
method string
path string
body string // "" ならボディなし
authOK bool // true: 正しいトークンを送る, false: トークンを付けない(未認証)
}
scenarios := []scenario{
{"1. 一覧を取得する(認証あり)", "GET", "/tasks", "", true},
{"2. 一覧を取得する(認証なし)", "GET", "/tasks", "", false},
{"3. タスクを作成する(正しい入力)", "POST", "/tasks", `{"title":"レポート提出"}`, true},
{"4. タスクを作成する(タイトル空)", "POST", "/tasks", `{"title":""}`, true},
{"5. タスクを更新する(存在するID)", "PUT", "/tasks/1", `{"title":"牛乳とパンを買う","done":true}`, true},
{"6. タスクを更新する(存在しないID)", "PUT", "/tasks/999", `{"title":"更新できない","done":false}`, true},
{"7. タスクを削除する(存在するID)", "DELETE", "/tasks/1", "", true},
{"8. 同じタスクをもう一度削除する", "DELETE", "/tasks/1", "", true},
}
doReq := func(sc scenario) (int, string) {
var bodyReader io.Reader
if sc.body != "" {
bodyReader = strings.NewReader(sc.body)
}
req, err := http.NewRequest(sc.method, srv.URL+sc.path, bodyReader)
if err != nil {
panic(err)
}
if sc.authOK {
req.Header.Set("Authorization", authToken)
}
if sc.body != "" {
req.Header.Set("Content-Type", "application/json")
}
resp, err := client.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
text := strings.TrimSpace(string(b))
if text == "" {
text = "(空)"
}
return resp.StatusCode, text
}
var resultRows [][]string
for _, sc := range scenarios {
status, body := doReq(sc)
fmt.Printf("[%s] %s %s → %d\n", sc.label, sc.method, sc.path, status)
resultRows = append(resultRows, []string{sc.label, sc.method + " " + sc.path, fmt.Sprintf("%d", status), body})
}
gonbui.DisplayHTML(renderTable([]string{"操作", "リクエスト", "ステータス", "レスポンスボディ"}, resultRows))
gonbui.Sync()
// --- 可視化: どのハンドラが、どの定形処理を必要としたか -------------------------
// 🔴 package変数(stepLog)への書き込みと読み出しは、別の %% セルに分けると
// GoNB では反映されないことがあるため、リクエスト送信と同じ %% セル内で集計する。
handlerNames := []string{"GET /tasks", "POST /tasks", "PUT /tasks/{id}", "DELETE /tasks/{id}"}
stepNames := []string{"ログ出力", "認証チェック", "JSONデコード", "バリデーション", "エラー応答", "成功応答"}
var coverageRows [][]string
for _, h := range handlerNames {
row := []string{h}
for _, s := range stepNames {
count := stepLog[[2]string{h, s}]
if count > 0 {
row = append(row, fmt.Sprintf("✓ (%d回)", count))
} else {
row = append(row, "―")
}
}
coverageRows = append(coverageRows, row)
}
coverageHeader := append([]string{"ハンドラ"}, stepNames...)
fmt.Println("表の列:", coverageHeader)
gonbui.DisplayHTML(renderTable(coverageHeader, coverageRows))
gonbui.Sync()
fmt.Println("--- 定形処理カバレッジ(4ハンドラ中いくつで必要だったか) ---")
for _, s := range stepNames {
needed := 0
for _, h := range handlerNames {
if stepLog[[2]string{h, s}] > 0 {
needed++
}
}
fmt.Printf("%s: %d/%dハンドラ\n", s, needed, len(handlerNames))
}
[LOG 17:44:42] GET /tasks
[1. 一覧を取得する(認証あり)] GET /tasks → 200 [LOG 17:44:42] GET /tasks [2. 一覧を取得する(認証なし)] GET /tasks → 401 [LOG 17:44:42] POST /tasks
[3. タスクを作成する(正しい入力)] POST /tasks → 201
[LOG 17:44:42] POST /tasks
[4. タスクを作成する(タイトル空)] POST /tasks → 400
[LOG 17:44:42] PUT /tasks/1 [5. タスクを更新する(存在するID)] PUT /tasks/1 → 200 [LOG 17:44:42] PUT /tasks/999 [6. タスクを更新する(存在しないID)] PUT /tasks/999 → 404
[LOG 17:44:42] DELETE /tasks/1
[7. タスクを削除する(存在するID)] DELETE /tasks/1 → 204
[LOG 17:44:42] DELETE /tasks/1
[8. 同じタスクをもう一度削除する] DELETE /tasks/1 → 404
| 操作 | リクエスト | ステータス | レスポンスボディ |
|---|---|---|---|
| 1. 一覧を取得する(認証あり) | GET /tasks | 200 | [{"id":1,"title":"牛乳を買う","done":false}] |
| 2. 一覧を取得する(認証なし) | GET /tasks | 401 | {"error":"unauthorized"} |
| 3. タスクを作成する(正しい入力) | POST /tasks | 201 | {"id":2,"title":"レポート提出","done":false} |
| 4. タスクを作成する(タイトル空) | POST /tasks | 400 | {"error":"title is required"} |
| 5. タスクを更新する(存在するID) | PUT /tasks/1 | 200 | {"id":1,"title":"牛乳とパンを買う","done":true} |
| 6. タスクを更新する(存在しないID) | PUT /tasks/999 | 404 | {"error":"task not found"} |
| 7. タスクを削除する(存在するID) | DELETE /tasks/1 | 204 | (空) |
| 8. 同じタスクをもう一度削除する | DELETE /tasks/1 | 404 | {"error":"task not found"} |
表の列: [ハンドラ ログ出力 認証チェック JSONデコード バリデーション エラー応答 成功応答]
| ハンドラ | ログ出力 | 認証チェック | JSONデコード | バリデーション | エラー応答 | 成功応答 |
|---|---|---|---|---|---|---|
| GET /tasks | ✓ (2回) | ✓ (2回) | ― | ― | ✓ (1回) | ✓ (1回) |
| POST /tasks | ✓ (2回) | ✓ (2回) | ✓ (2回) | ✓ (2回) | ✓ (1回) | ✓ (1回) |
| PUT /tasks/{id} | ✓ (2回) | ✓ (2回) | ✓ (2回) | ✓ (2回) | ✓ (1回) | ✓ (1回) |
| DELETE /tasks/{id} | ✓ (2回) | ✓ (2回) | ― | ― | ✓ (1回) | ✓ (1回) |
--- 定形処理カバレッジ(4ハンドラ中いくつで必要だったか) --- ログ出力: 4/4ハンドラ 認証チェック: 4/4ハンドラ JSONデコード: 2/4ハンドラ バリデーション: 2/4ハンドラ エラー応答: 4/4ハンドラ 成功応答: 4/4ハンドラ
観察点 — 実際の出力から読み取れること¶
上の2つの表と fmt.Printf の実出力を見比べてください。
- ログ出力・認証チェック・エラー応答・成功応答は、4ハンドラ全て(4/4)で実行されました。 一覧取得のような「読むだけ」の操作でも、認証チェックとエラー応答は必要でした
- JSONデコード・バリデーションは、ボディを受け取る2ハンドラ(POST/PUT、2/4)だけでした
- つまり、操作の種類(GET/POST/PUT/DELETE)に関わらず、少なくとも4つの定形処理は
ほぼ全てのハンドラに共通して必要であり、それが
newRawServerの4つのハンドラ関数の中に それぞれ個別に(コピペで)書かれています - この「同じコードが複数箇所に手書きされている」状態こそが、フレームワークが解消しようとしている 問題そのものです
直感・類推: 空港のチェックインカウンター¶
- ログ出力・認証チェック = どの便のカウンターでも必ず行う「本人確認」と「搭乗記録」。 国内線でも国際線でも、荷物が無くても、これは省略されません
- JSONデコード・バリデーション = 国際線だけで必要な「税関申告書のチェック」。 行き先(操作の種類)によって、追加で必要になる手続きです
- 今のカウンター(素のハンドラ)は、この本人確認の手順を、カウンターごとに別の係員が それぞれ独自にマニュアルを読みながらやっている状態です。全カウンター共通の「受付マニュアル」 (=ミドルウェア)がまだ無いので、同じ手順を毎回コピペで再現しています。
05.2 では、この「受付マニュアル」を実際に1つの部品として作り、各カウンター(ハンドラ)から 本人確認の手順そのものを取り除きます。
練習問題 5.1: requiredBoilerplateSteps を実装しよう¶
このレッスンで実際に測った「ハンドラ × 定形処理」の結果を、関数として一般化してください。
仕様:
// requiredBoilerplateSteps は、hasBody(リクエストボディを受け取る操作かどうか)に応じて、
// そのハンドラに必要な定形処理の一覧を返す。
//
// hasBody = false(GET・DELETEのようにボディが無い操作):
// ["ログ出力", "認証チェック", "エラー応答", "成功応答"]
// hasBody = true(POST・PUTのようにボディがある操作):
// 上記4つに加えて "JSONデコード", "バリデーション" も含む
// (順序は ["ログ出力", "認証チェック", "JSONデコード", "バリデーション", "エラー応答", "成功応答"])
func requiredBoilerplateSteps(hasBody bool) []string
ポイント: このレッスンで実測した通り、hasBody に関わらず常に必要な4つと、
hasBody のときだけ追加で必要になる2つがあります。この関数は、その2種類を
コードとして明示的に表現します。
// YOUR CODE HERE
// requiredBoilerplateSteps を実装してください。
// (未実装のまま nil を返すと、チェックセルは「未回答」と表示します)
func requiredBoilerplateSteps(hasBody bool) []string {
return nil
}
チェックのためのヘルパー¶
import "reflect"
import "fmt"
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))
}
%%
noBody := requiredBoilerplateSteps(false)
if noBody == nil {
fmt.Println("⚠️ 未回答: 練習問題を解いてから、このセルを再度実行してください")
} else {
mustEqual(requiredBoilerplateSteps(false), []string{"ログ出力", "認証チェック", "エラー応答", "成功応答"}, "ボディ無し操作(GET/DELETE)の定形処理")
mustEqual(requiredBoilerplateSteps(true), []string{"ログ出力", "認証チェック", "JSONデコード", "バリデーション", "エラー応答", "成功応答"}, "ボディ有り操作(POST/PUT)の定形処理")
fmt.Println("🎉 すべてのチェックが通りました")
}
⚠️ 未回答: 練習問題を解いてから、このセルを再度実行してください
まとめ¶
net/httpのルーティングとencoding/jsonは「URLの振り分け」と「JSON変換」しか面倒を見ない。 認証チェック・ログ出力・エラー応答の組み立ては、ハンドラごとに手で書く必要がある- 実測の結果、4つの定形処理(ログ出力・認証チェック・エラー応答・成功応答)は操作の種類に 関わらず4/4ハンドラで必要で、JSONを受け取る2つのハンドラだけがさらに2つ(デコード・ バリデーション)を追加で必要とした
- この重複は「コピペを頑張ってなくす」話ではなく、共通処理をハンドラの外側に括り出す仕組み (ミドルウェア)が無いことが原因
答え合わせは 05.1-what-framework-does-solutions.ipynb で行ってください。
次は 05.2 で、この重複を自作のミニWebフレームワーク(ルーティング→ミドルウェア→コンテキスト→
JSON出力→バリデーション)で実際に解消します。