05.2 自作ミニWebフレームワーク — ルーティング・ミドルウェア・コンテキスト・バリデーション¶
05.1 では、net/http の素のハンドラを並べると同じ定形処理が何度も繰り返されることを見ました。
このレッスンでは、その定形処理を自分でフレームワーク化して解消します。
作るのは次の5つの部品です。
- ルーティング: パス・メソッドごとにハンドラを振り分ける(
http.ServeMuxを土台にする) - ミドルウェア: 全ハンドラ共通の処理(ログ、認証)を、ハンドラ本体から追い出す
- コンテキスト: ミドルウェアが調べた情報(「誰がログインしているか」)を、次のハンドラへ安全に渡す
- JSON出力・テンプレート出力: レスポンスの組み立てを共通ヘルパーに
- バリデーション: 入力チェックを共通化し、失敗したら 422 で理由を返す
このレッスンのゴール:
- 自作フレームワークで実際にAPIを1本立て、リクエストがミドルウェア→ハンドラの順に流れることを実ログで確認する
- 「フレームワークが何を肩代わりしているか」が、たった1つの
Framework型の中に現れることを実感する
1. 非自明ポイント①: ミドルウェアは「ハンドラを包む関数」¶
ミドルウェアは特別な仕組みではなく、ハンドラを受け取って、別のハンドラを返す関数です。
type Middleware func(http.HandlerFunc) http.HandlerFunc
mw(handler) は「handler を実行する前後に何かをする、新しいハンドラ」を返します。
複数のミドルウェアを重ねると、リクエストは外側から内側へ、レスポンスは内側から外側へという
「玉ねぎ(onion)」の順で処理されます。次のミドルウェア(や本体のハンドラ)を呼ぶかどうかは
ミドルウェア自身が決められるのがポイントです — 認証に失敗したミドルウェアは、次を呼ばずに
その場で 401 を返して処理を打ち切れます(短絡)。
2. 非自明ポイント②: コンテキストは「ミドルウェア→ハンドラへの安全な受け渡し」¶
認証ミドルウェアが「誰がログインしているか」を調べたとして、それをどうやってハンドラに伝えるかが 問題になります。グローバル変数は複数リクエストが同時に来ると壊れます(01.6 で見た「共有可変状態」の問題)。
Go はこのために context.Context を用意しています。ミドルウェアは
r = r.WithContext(context.WithValue(r.Context(), key, value)) でリクエストに紐づく値を追加でき、
ハンドラは r.Context().Value(key) でそれを読み出せます。この値はそのリクエストだけのものなので、
同時に来た別のリクエストと混ざりません。
🔴 キーは自作の非公開型にする(type ctxKey string のような専用型)。もし string を直接キーに使うと、
他のパッケージが同じ文字列をキーに使ったときに値が上書きされる事故が起きえます。
import (
"context"
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"github.com/janpfeifer/gonb/gonbui"
)
// ErrUnanswered は、練習問題が未回答のときにプレースホルダ関数が返す特別なエラー。
var ErrUnanswered = errors.New("未回答: この関数はまだ実装されていません")
// ---- コンテキストのキー(非公開の専用型で衝突を防ぐ) ----
type ctxKey string
const ctxKeyUser ctxKey = "user"
// ---- ミドルウェア・フレームワーク本体 ----
// Middleware は「ハンドラを受け取り、別のハンドラを返す」関数。
type Middleware func(http.HandlerFunc) http.HandlerFunc
// Framework は http.ServeMux を土台に、ミドルウェアチェーンを追加するミニフレームワーク。
type Framework struct {
mux *http.ServeMux
global []Middleware
}
func NewFramework() *Framework {
return &Framework{mux: http.NewServeMux()}
}
// Use はすべてのルートに適用するミドルウェアを登録する。
func (f *Framework) Use(mw Middleware) {
f.global = append(f.global, mw)
}
// Handle はルートを1本登録する。extra はこのルートだけに追加で通すミドルウェア(例: 認証)。
// 適用順は「global(全ルート共通)→ extra(このルート限定)→ 本体ハンドラ」。
func (f *Framework) Handle(pattern string, h http.HandlerFunc, extra ...Middleware) {
chain := h
all := append(append([]Middleware{}, f.global...), extra...)
for i := len(all) - 1; i >= 0; i-- {
chain = all[i](chain)
}
f.mux.HandleFunc(pattern, chain)
}
// ServeHTTP を実装しているので、Framework 自体を httptest.NewServer にそのまま渡せる。
func (f *Framework) ServeHTTP(w http.ResponseWriter, r *http.Request) {
f.mux.ServeHTTP(w, r)
}
// ---- 出力ヘルパー(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)
}
// writeHTML は html/template で HTML を組み立ててサーバー側から返す。
// このコースはフロントエンド教材ではないため、ここでの用途は「サーバーが返すHTML文字列を
// テンプレートエンジンで組み立てる」ことの実演に限定する(ブラウザでのDOM操作・CSS装飾は扱わない)。
func writeHTML(w http.ResponseWriter, status int, tmplText string, data any) {
tmpl := template.Must(template.New("view").Parse(tmplText))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
tmpl.Execute(w, data)
}
// ---- ミドルウェア本体 ----
// loggingMiddleware は、通過したことを trace に記録するだけのミドルウェア。
func loggingMiddleware(trace *[]string) Middleware {
return func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
*trace = append(*trace, fmt.Sprintf("→ logging: %s %s", r.Method, r.URL.Path))
next(w, r)
*trace = append(*trace, "← logging")
}
}
}
// authMiddleware は Authorization ヘッダーを検証し、正しければ「誰か」をコンテキストに載せる。
// 不正なら次を呼ばず、その場で 401 を返す(短絡)。
func authMiddleware(trace *[]string) Middleware {
return func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer secret-token" {
*trace = append(*trace, "→ auth: 却下(トークン不正、ハンドラには到達しない)")
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
return
}
*trace = append(*trace, "→ auth: 許可(user=taro をコンテキストに載せる)")
ctx := context.WithValue(r.Context(), ctxKeyUser, "taro")
next(w, r.WithContext(ctx))
*trace = append(*trace, "← auth")
}
}
}
3. バリデーションと422¶
「入力は受け取れた(JSONとして正しい)が、業務ルールに違反している」場合、400(構文エラー)ではなく 422 Unprocessable Entity(意味的に処理できない)を返すのが REST の慣習です(04.1 で学んだ 「ステータスコードは機械が読む契約」の延長です)。
// createTaskRequest は POST /tasks のリクエストボディ。
type createTaskRequest struct {
Title string `json:"title"`
Priority int `json:"priority"`
}
// ValidationError は1件の検証エラー(どのフィールドが、なぜダメか)。
type ValidationError struct {
Field string `json:"field"`
Msg string `json:"msg"`
}
// validateCreateTaskRequest は必須・範囲チェックを行い、違反を全部集めて返す(0件なら合格)。
func validateCreateTaskRequest(req createTaskRequest) []ValidationError {
var errs []ValidationError
if strings.TrimSpace(req.Title) == "" {
errs = append(errs, ValidationError{Field: "title", Msg: "必須です"})
}
if req.Priority < 1 || req.Priority > 5 {
errs = append(errs, ValidationError{Field: "priority", Msg: "1〜5の範囲で指定してください"})
}
return errs
}
// ---- デモ用の簡易ストア(このセルで初期化し、同じ実行の中だけで使う) ----
var tasks = map[int]createTaskRequest{}
var nextTaskID = 1
func createTaskHandler(w http.ResponseWriter, r *http.Request) {
user, _ := r.Context().Value(ctxKeyUser).(string)
var req createTaskRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
if errs := validateCreateTaskRequest(req); len(errs) > 0 {
writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"errors": errs})
return
}
id := nextTaskID
nextTaskID++
tasks[id] = req
writeJSON(w, http.StatusCreated, map[string]any{
"id": id, "title": req.Title, "priority": req.Priority, "created_by": user,
})
}
func getTaskJSONHandler(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
t, ok := tasks[id]
if !ok {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"id": id, "title": t.Title, "priority": t.Priority})
}
// taskViewTmpl は html/template のテンプレート文字列(サーバー側で完結する出力)。
const taskViewTmpl = `<div class="task"><strong>{{.Title}}</strong>(優先度: {{.Priority}})</div>`
func getTaskViewHandler(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeHTML(w, http.StatusBadRequest, `<p>invalid id</p>`, nil)
return
}
t, ok := tasks[id]
if !ok {
writeHTML(w, http.StatusNotFound, `<p>not found</p>`, nil)
return
}
writeHTML(w, http.StatusOK, taskViewTmpl, t)
}
// newApp は全部品を組み立てて Framework を返す。
// - logging はすべてのルートに適用(Use)
// - auth は POST /tasks だけに追加で適用(Handle の extra 引数)
func newApp(trace *[]string) *Framework {
app := NewFramework()
app.Use(loggingMiddleware(trace))
app.Handle("POST /tasks", createTaskHandler, authMiddleware(trace))
app.Handle("GET /tasks/{id}", getTaskJSONHandler)
app.Handle("GET /tasks/{id}/view", getTaskViewHandler)
return app
}
// ---- リクエスト送信ヘルパー ----
func doPostTask(url, authHeader, body string) (int, string) {
req, err := http.NewRequest("POST", url, strings.NewReader(body))
if err != nil {
panic(err)
}
req.Header.Set("Content-Type", "application/json")
if authHeader != "" {
req.Header.Set("Authorization", authHeader)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, strings.TrimSpace(string(b))
}
func doGetTask(url string) (int, string) {
resp, err := http.Get(url)
if err != nil {
panic(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, strings.TrimSpace(string(b))
}
func renderTrace(trace []string) string {
var b strings.Builder
b.WriteString("<ol>")
for _, t := range trace {
b.WriteString(fmt.Sprintf("<li>%s</li>", t))
}
b.WriteString("</ol>")
return b.String()
}
4. 実際に動かす — 5パターンのリクエストを送る¶
サーバーの起動・リクエスト送信・Close() を1つのセルにまとめます(GoNB のセル実行モデルに合わせる、
03.3 以来の契約)。tasks/nextTaskID はこのセルの中でリセットしてから使います
(パッケージ変数への書き込みと読み出しを同じ %% セル内で完結させる)。
%%
trace := []string{}
tasks = map[int]createTaskRequest{}
nextTaskID = 1
app := newApp(&trace)
srv := httptest.NewServer(app)
defer srv.Close()
status1, body1 := doPostTask(srv.URL+"/tasks", "", `{"title":"資料作成","priority":3}`)
status2, body2 := doPostTask(srv.URL+"/tasks", "Bearer secret-token", `{"title":"","priority":3}`)
status3, body3 := doPostTask(srv.URL+"/tasks", "Bearer secret-token", `{"title":"資料作成","priority":3}`)
status4, body4 := doGetTask(srv.URL + "/tasks/1")
status5, body5 := doGetTask(srv.URL + "/tasks/1/view")
fmt.Printf("1) 未認証で POST /tasks → %d %s\n", status1, body1)
fmt.Printf("2) 認証済みだがバリデーション違反 → %d %s\n", status2, body2)
fmt.Printf("3) 認証済み・バリデーション通過 → %d %s\n", status3, body3)
fmt.Printf("4) GET /tasks/1(JSON出力) → %d %s\n", status4, body4)
fmt.Printf("5) GET /tasks/1/view(HTML出力) → %d %s\n", status5, body5)
gonbui.DisplayHTML("<b>ミドルウェアの通過順(トレース):</b>" + renderTrace(trace))
gonbui.Sync()
1) 未認証で POST /tasks → 401 {"error":"unauthorized"}
2) 認証済みだがバリデーション違反 → 422 {"errors":[{"field":"title","msg":"必須です"}]}
3) 認証済み・バリデーション通過 → 201 {"created_by":"taro","id":1,"priority":3,"title":"資料作成"}
4) GET /tasks/1(JSON出力) → 200 {"id":1,"priority":3,"title":"資料作成"}
5) GET /tasks/1/view(HTML出力) → 200 <div class="task"><strong>資料作成</strong>(優先度: 3)</div>
- → logging: POST /tasks
- → auth: 却下(トークン不正、ハンドラには到達しない)
- ← logging
- → logging: POST /tasks
- → auth: 許可(user=taro をコンテキストに載せる)
- ← auth
- ← logging
- → logging: POST /tasks
- → auth: 許可(user=taro をコンテキストに載せる)
- ← auth
- ← logging
- → logging: GET /tasks/1
- ← logging
- → logging: GET /tasks/1/view
- ← logging
5. 観察点 — トレースと実出力から読み取れること¶
- 1(未認証POST): トレースは
→ logging→→ auth: 却下→← loggingの3件で、← authだけが 出てきません。authMiddlewareがnextを呼ばずに401を返したので、authMiddleware自身の 「nextの後」のコード(← authの記録)は実行されない一方、呼び出し元のloggingMiddlewareはnext(=authMiddleware)の戻り値を受け取った後の処理を続けるため← loggingは記録されます — ミドルウェアの「巻き戻り(unwind)」は途中で止められた層だけ欠け、それより外側は普通に続くことが トレースから分かります。いずれにせよハンドラ本体(createTaskHandler)には一度も到達していません - 2(バリデーション違反):
authは許可した(トークンは正しい)ので→ auth: 許可は記録されますが、 ハンドラの中のvalidateCreateTaskRequestがtitleの空文字を検知し、422 とエラー内容 ({"field":"title","msg":"必須です"})を返します - 3(正常系):
201とともにcreated_by:"taro"が返ります。この"taro"は コンテキスト経由で auth ミドルウェアからハンドラへ渡された値です — ハンドラ自身は 「誰がログインしているか」を一切調べていません - 4・5: 同じ
tasks[1]のデータが、JSON(機械可読)とHTML(html/template経由の サーバー側出力)の両方の形式で取り出せます。出力形式の切り替えはヘルパー関数(writeJSON/writeHTML) の選択だけで済み、ハンドラのロジック自体は変わりません
6. 直感・類推: 空港のセキュリティゲート¶
- ミドルウェア = 空港の保安検査場。すべての乗客(リクエスト)が同じゲートを通ります。 問題がある乗客はゲートで止められ、搭乗ロビー(ハンドラ)には進めません(短絡)
- コンテキスト = 保安検査で発行される「搭乗券の半券」。検査を通過した証拠と、そこで確認した情報 (本人確認済みかどうか)が、その乗客専用の紙として次の係員(ハンドラ)に渡ります。 他の乗客の半券と混ざることはありません
- バリデーション+422 = 「搭乗券は本物だが、搭乗締切を過ぎている」ときのお断り。身分証(構文)は 正しくても、ルール(意味)を満たさなければ通せません
フレームワークがやっているのは、この「共通のゲート運用」を1箇所にまとめて、各ハンドラから 繰り返しを消すことです。
練習問題 5.2: validateTitle を実装しよう¶
title フィールド単体の検証ルールを、独立した純粋関数として実装してください。
仕様:
// validateTitle は title を検証する。
func validateTitle(title string) error
strings.TrimSpace(title)が空文字ならerrors.New("title は必須です")を返す- トリム後の文字数が 50 を超えていたら
errors.New("title は50文字以内にしてください")を返す - どちらにも該当しなければ
nilを返す(検証OK)
ヒント: 文字数は len([]rune(...))(日本語を含む可能性があるので、バイト数の len(string) ではなく
ルーン数で数えます)。
// YOUR CODE HERE
// validateTitle を実装してください。
// (未実装のままチェックセルを実行すると「未回答」と表示されます)
func validateTitle(title string) error {
return ErrUnanswered
}
チェックのためのヘルパー¶
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))
}
%%
err1 := validateTitle("")
if errors.Is(err1, ErrUnanswered) {
fmt.Println("⚠️ 未回答: 練習問題を解いてから、このセルを再度実行してください")
} else {
if err1 == nil || err1.Error() != "title は必須です" {
panic(fmt.Sprintf("❌ 空文字は必須エラーになるはず(got err=%v)", err1))
}
fmt.Println("✅ Passed: 空文字は「title は必須です」")
long := strings.Repeat("あ", 51)
err2 := validateTitle(long)
if err2 == nil || err2.Error() != "title は50文字以内にしてください" {
panic(fmt.Sprintf("❌ 51文字は文字数エラーになるはず(got err=%v)", err2))
}
fmt.Println("✅ Passed: 51文字は「title は50文字以内にしてください」")
mustEqual(validateTitle("資料作成"), nil, "妥当な title はエラーなし")
// 実際に1本の小さなハンドラへ組み込み、本物のHTTPレスポンスとしても確認する。
checkMux := http.NewServeMux()
checkMux.HandleFunc("POST /check-title", func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
if verr := validateTitle(string(body)); verr != nil {
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": verr.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"result": "ok"})
})
checkSrv := httptest.NewServer(checkMux)
defer checkSrv.Close()
resp, err := http.Post(checkSrv.URL+"/check-title", "text/plain", strings.NewReader(strings.Repeat("い", 60)))
if err != nil {
panic(err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
mustEqual(resp.StatusCode, 422, "60文字のtitleは実際のHTTPレスポンスでも422")
mustEqual(strings.Contains(string(respBody), "50文字以内"), true, "エラーメッセージがレスポンスボディに含まれる")
fmt.Println("🎉 すべてのチェックが通りました")
}
⚠️ 未回答: 練習問題を解いてから、このセルを再度実行してください
まとめ¶
- ミドルウェアは「ハンドラを受け取り、別のハンドラを返す関数」。次を呼ぶかどうかを自分で決められる (認証失敗時は呼ばず短絡できる)
- コンテキスト(
context.Context)は、ミドルウェアが調べた情報を、そのリクエスト専用の形で 次のハンドラへ安全に渡す。グローバル変数のような競合状態を起こさない - バリデーションは入力チェックを共通化し、失敗を 422 + 理由のJSON として一貫した形で返す
- JSON出力・テンプレート出力は共通ヘルパーに切り出すことで、ハンドラは「何を返すか」だけに集中できる
- これら5つの部品を合わせたものが「フレームワーク」— 05.1 で見た繰り返しは、この
Framework型の中に 一度だけ書けば、あとは各ハンドラで再現しなくて済む
次は 05.3 で、DB・API・このミニフレームワークを統合したプロジェクトを組み立てます。