Day 068 — 2026-10-08 | ミドルウェア設計パターン: 「next」とは何か——net/http のデコレータ・Gin/Express 型の添字・Koa 型の (ctx, next) を、同じ 6 つの問いで予想してから実物と Go で測る

2026-10-08 🟡 上級者 / Phase 3 概念理解 ミドルウェア設計パターン
フェーズ: 上級者 (Phase 3) | 問題タイプ: 概念理解(予想→実測。小さな実装つき) | テーマ: ミドルウェア設計パターン
Day 067 の次回予告どおり「概念理解」。Day 063〜067 は func(http.Handler) http.Handler の中で置き場所・積む順・ルートごとの違いを扱ってきた。今日はその型そのものを、他の 2 つの流儀(Express/Gin の「配列を添字で進める next」、Koa の「ctx と next を受け取り結果を返す」)と並べ、同じ問いに対して答えがどう割れるかを測る。

📚 背景知識(読んでから問題へ)

ミドルウェアは、どの言語でも「前処理 → 次へ渡す → 後処理」です。違うのは 「次へ渡す」が何でできているかだけで、そこから性質がほぼすべて決まります。

流儀「次」の正体代表(世界の実物)
A. デコレータ起動時に包んだ関数値(next http.Handler)。リクエストごとの状態なしGo net/http、chi、gorilla/handlers、alice
B. 添字で配列を進めるリクエストごとのカーソル(配列+添字)。next() は「添字を進めて次を呼ぶ」Express(router 2.2.0 index.js 156 行 let idx = 0、188 行 function next (err))、Gin(context.go の Next/Abort)
C. ctx と next を受け取り結果を返す呼ぶたびに作る dispatch(i+1)。戻り値(Promise/error)で下流の結果が返ってくるKoa(koa-compose 4.1.0 index.js 31〜47 行)、ASP.NET Core の app.Use(async (context, next) => …)(今回ソース未確認)

Go の標準が A を選んだ理由は、http.Handler というメソッド 1 つのインターフェースに揃えれば、ミドルウェアもハンドラも同じ型で済み、フレームワークが要らないからです。そのかわり A には戻り値が無いので、「下流が失敗した」「下流が何を書いた」を外側が知るには ResponseWriter を包む(Day 060・063)か、panic を回収する(Day 064)しかありません。

今日の問いは次の 6 つです。答えは流儀ごとに割れます。

  1. 入る順・出る順(玉ねぎになるか)
  2. 中間件が next を呼ばずに戻ったら、後ろは走るか
  3. next を 2 回呼んだら
  4. 外側が next の後でステータス・ヘッダを変えられるか
  5. 下流の失敗を外側はどう受け取るか
  6. 最初の 1 バイトはいつ届くか(溜めてから書く型の費用)

迷った他案と採らなかった理由(今日の題材の作り方)

案採らなかった理由
3 流儀を文章の表だけで比べる「next を 2 回呼ぶと?」のような所は記憶で書くと外す。実際、私の予想(Express は下流が 2 回走る)は実測で外れた(下の「実測」)。だから実物(Express 4.19.2/5.2.1・koa-compose 4.1.0)を Node 22.23.2 で動かし、Go 側は 3 流儀を同じ問いのテストにした
Gin・Echo を go get して本物で測る手元のモジュールキャッシュに無く、今日は取得しなかった。Gin は context.go・response_writer.go を 2026-10-08 に GitHub の master から読んで、Next/Abort/abortIndex の 3 つだけを写した(styles.go)。sync.Pool による GCtx の再利用は写していないので、割り当て数は本物の Gin と違う
自分で書く部分を「Gin 型」にするGin 型の Next は 6 行で、書いて学ぶ量が少ない。Koa 型の Compose は「2 回目の next をどう止めるか」「同時に使い回したとき状態をどこに置くか」の 2 つの判断があり、変異で差が出る
前提が変わったら応答を後から書き換えたい要件(ETag・圧縮の後付け・エラー時の本文差し替え)が中心のサービスなら、Go でも C 型(溜めてから書く)を局所的に使う価値が上がる。逆に SSE・大きなダウンロードが中心なら A のままが正しい(問 6)

参照日 2026-10-08。一次資料:

  • Go 1.27.1 手元の go doc net/http.Handler("Handler.ServeHTTP should write reply headers and data to the ResponseWriter and then return.")
  • koa-compose 4.1.0 index.js 36 行: if (i <= index) return Promise.reject(new Error('next() called multiple times'))(手元の node_modules)
  • router 2.2.0(Express 5.2.1 が使う)index.js 156〜300 行、lib/layer.js 142〜170 行。Express 4.19.2 の lib/router/layer.js には then/Promise が 1 か所も無い(grep で 0 件)(handleRequest が Promise の reject を next(error) に回す)
  • Gin context.go(const abortIndex int8 = math.MaxInt8 >> 1、Next は c.index++ の後 for c.index < safeInt8(len(c.handlers)) で回す)・response_writer.go(送信後の WriteHeader は "[WARNING] Headers were already written. Wanted to override status code %d with %d" を出して無視)。https://github.com/gin-gonic/gin の master、2026-10-08 取得

世界の実物との位置づけ

主要な合成方式として、デコレータ(net/http・chi)、添字カーソル(Express・Gin)、ctx+next+戻り値(Koa・ASP.NET Core)、エラーを返すハンドラ(Echo func(echo.HandlerFunc) echo.HandlerFunc、Caddy v2 の ServeHTTP(w, r, next) error)の 4 つがある。今日測ったのは前 3 つで、4 つ目は「扱わなかった範囲」に回した(次回の実装で扱う)。

📝 問題

先に予想を書く(自己評価欄へ。実行前に)

次の表の空欄を、コードを動かす前に埋めてください。Express・Koa の列は Node の本物、A〜C の列は下の Go コードです。

問A. net/http デコレータB. Gin 型C. Koa 型(Go)Express 5.2.1koa-compose 4.1.0
問1 A→B→H の入る順・出る順(H は同期)?????
問1' H が非同期(await sleep の後に応答)だと—(Go は同期)——??
問2 中間件 A が next を呼ばずに戻る?????
問3 A が next を 2 回呼ぶ?????
問4 A が next の後で 201・X-After を付ける?(A と同じ書き方)???
問5 下流が失敗。外側はどう受け取る?????
問6 H が 1 バイト書いて 150 ms 働く。最初の 1 バイトはいつ?(A と同じ)?——

予想を書き込む(この端末に保存)

問A. デコレータB. Gin 型C. Koa 型(Go)Express 5.2.1koa-compose
問1 同期
問1' 非同期 H
問2 next を呼ばない
問3 next を 2 回
問4 next の後で 201
問5 下流の失敗
問6 最初の 1 バイト

課題

  1. 上の表を予想で埋める(自己評価欄へ)
  2. compose.go の Compose を実装して、go test -race -count=1 -v ./... を全部通す。約束はコメントのとおり:
    • mws[0] が最も外側。最後の中間件が呼ぶ next は何もせず nil
    • 1 つの中間件が next を 2 回呼んだら ErrNextTwice を返し、下流は 2 度走らない
    • 中間件が返した error は上へそのまま返る
    • 返した関数は複数のリクエストから同時に呼ばれる
  3. -v の出力(O1〜O6 の t.Logf)を、手順 1 の予想と照らし、外れた所を書く
  4. 次の 2 つに答える(短くてよい):
    • D1: Go の標準が「戻り値の無い A 型」を選んで、何を得て何を失ったか。失ったものを Go ではどう補っているか(Day 060・063・064 で使った手を名指しで)
    • D2: Express で「認証ミドルウェアの前の中間件が誤って next を 2 回呼ぶ」と、何が起きうるか。Koa 型・A 型ではどうか

準備

mkdir d68 && cd d68                          # 作業ディレクトリを作って入る
printf 'module d68\n\ngo 1.22\n' > go.mod    # モジュールを作る(標準ライブラリだけ)
# 下の styles.go・compose.go(スタブ)・styles_test.go を保存する
go test -count=1 ./... 2>&1 | grep -c '^--- FAIL'   # スタブで落ちるテスト数(12。O1〜O6 は Koa 列で落ちる)

ファイル 1: styles.go(変えない。3 つの流儀の最小模型)

styles.go 全体(84 行)
package main

import (
	"bytes"
	"net/http"
)

// ── 型 A: net/http の標準形(デコレータ)──────────────────────────
// 中間件は「次のハンドラを受け取り、包んだハンドラを返す関数」。
// 合成は起動時に 1 回。リクエストごとの状態は持たない。

type Middleware func(http.Handler) http.Handler

// Chain は ms[0] が最も外側になるように h を包む。
func Chain(h http.Handler, ms ...Middleware) http.Handler {
	for i := len(ms) - 1; i >= 0; i-- {
		h = ms[i](h)
	}
	return h
}

// ── 型 B: Gin 型(添字で配列を進める next)─────────────────────────
// gin-gonic/gin context.go の Next / Abort / abortIndex を写したもの
// (2026-10-08 に master を取得して確認。Gin の他の機能は持たない)。

type GHandler func(*GCtx)

const abortIndex int8 = 127 >> 1 // Gin: math.MaxInt8 >> 1

type GCtx struct {
	W        http.ResponseWriter
	R        *http.Request
	Errors   []error
	handlers []GHandler
	index    int8
}

func (c *GCtx) Next() {
	c.index++
	for c.index < int8(len(c.handlers)) {
		c.handlers[c.index](c)
		c.index++
	}
}

func (c *GCtx) Abort()          { c.index = abortIndex }
func (c *GCtx) IsAborted() bool { return c.index >= abortIndex }

// GinChain は handlers を 1 本の http.Handler にする。GCtx はリクエストごとに作る。
func GinChain(hs ...GHandler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		c := &GCtx{W: w, R: r, handlers: hs, index: -1}
		c.Next()
	})
}

// ── 型 C: Koa 型(ctx と next を受け取り、error を返す)────────────────
// 応答は ctx に溜め、全員が戻ってから 1 回だけ書く(Koa の respond と同じ順序)。

type KCtx struct {
	R      *http.Request
	Status int
	Header http.Header
	Body   bytes.Buffer
	Log    []string
}

type KMW func(ctx *KCtx, next func() error) error

// KoaHandler は合成済みの関数を http.Handler にする。
func KoaHandler(run func(*KCtx) error) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		ctx := &KCtx{R: r, Status: http.StatusNotFound, Header: http.Header{}}
		if err := run(ctx); err != nil {
			http.Error(w, "internal error", http.StatusInternalServerError)
			return
		}
		for k, v := range ctx.Header {
			w.Header()[k] = v
		}
		w.WriteHeader(ctx.Status)
		w.Write(ctx.Body.Bytes())
	})
}

ファイル 2: compose.go(スタブ。ここを実装する)

package main

import "errors"

var ErrNextTwice = errors.New("next() called multiple times")

// Compose は koa-compose 4.1.0 と同じ約束で mws を 1 本にする。(ここを実装する)
func Compose(mws ...KMW) func(*KCtx) error {
	return func(*KCtx) error { return errors.New("not implemented") }
}

ファイル 3: styles_test.go(変えない。O1〜O6 は観察、K1〜K6 は Compose の約束)

styles_test.go 全体(O1〜O6・K1〜K6、366 行)
package main

import (
	"bufio"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync"
	"testing"
	"time"
)

// ── 道具 ──────────────────────────────────────────────

type logger struct {
	mu sync.Mutex
	l  []string
}

func (g *logger) add(s string) { g.mu.Lock(); g.l = append(g.l, s); g.mu.Unlock() }
func (g *logger) String() string {
	g.mu.Lock()
	defer g.mu.Unlock()
	return strings.Join(g.l, " ")
}

func do(h http.Handler) *httptest.ResponseRecorder {
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
	return rec
}

func want(t *testing.T, name, got, exp string) {
	t.Helper()
	t.Logf("%-9s %s", name, got)
	if got != exp {
		t.Errorf("%s: got %q, want %q", name, got, exp)
	}
}

// 型 A の部品
func decoMW(g *logger, name string) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			g.add(name + ">")
			next.ServeHTTP(w, r)
			g.add("<" + name)
		})
	}
}

// 型 B の部品
func ginMW(g *logger, name string) GHandler {
	return func(c *GCtx) { g.add(name + ">"); c.Next(); g.add("<" + name) }
}

// 型 C の部品
func koaMW(name string) KMW {
	return func(ctx *KCtx, next func() error) error {
		ctx.Log = append(ctx.Log, name+">")
		err := next()
		ctx.Log = append(ctx.Log, "<"+name)
		return err
	}
}

func koaOK(body string) KMW {
	return func(ctx *KCtx, _ func() error) error {
		ctx.Log = append(ctx.Log, "H")
		ctx.Status = 200
		ctx.Body.WriteString(body)
		return nil
	}
}

func runKoa(mws ...KMW) (*KCtx, error) {
	ctx := &KCtx{Status: 404, Header: http.Header{}}
	err := Compose(mws...)(ctx)
	return ctx, err
}

// ── O: 3 つの型を同じ問いで比べる(予想してから -v で見る)──────────────

// O1 入る順・出る順
func TestO1_Order(t *testing.T) {
	g := &logger{}
	do(Chain(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { g.add("H") }), decoMW(g, "A"), decoMW(g, "B")))
	want(t, "deco", g.String(), "A> B> H <B <A")

	g = &logger{}
	do(GinChain(ginMW(g, "A"), ginMW(g, "B"), func(*GCtx) { g.add("H") }))
	want(t, "gin", g.String(), "A> B> H <B <A")

	ctx, err := runKoa(koaMW("A"), koaMW("B"), koaOK("ok"))
	want(t, "koa", fmt.Sprint(strings.Join(ctx.Log, " "), " err=", err), "A> B> H <B <A err=<nil>")
}

// O2 中間件が next を呼ばずに戻ったら、ハンドラは走るか
func TestO2_ForgetNext(t *testing.T) {
	g := &logger{}
	silent := func(http.Handler) http.Handler {
		return http.HandlerFunc(func(http.ResponseWriter, *http.Request) { g.add("A") })
	}
	do(Chain(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { g.add("H") }), silent))
	want(t, "deco", g.String(), "A")

	g = &logger{}
	do(GinChain(func(*GCtx) { g.add("A") }, func(*GCtx) { g.add("H") }))
	want(t, "gin", g.String(), "A H") // Next を呼ばなくても、外側の for が次を呼ぶ

	g = &logger{}
	do(GinChain(func(c *GCtx) { g.add("A"); c.Abort() }, func(*GCtx) { g.add("H") }))
	want(t, "gin+Abort", g.String(), "A")

	ctx, _ := runKoa(func(ctx *KCtx, _ func() error) error { ctx.Log = append(ctx.Log, "A"); return nil }, koaOK("ok"))
	want(t, "koa", strings.Join(ctx.Log, " "), "A")
}

// O3 1 つの中間件が next を 2 回呼んだら
func TestO3_NextTwice(t *testing.T) {
	calls := 0
	twice := func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next.ServeHTTP(w, r); next.ServeHTTP(w, r) })
	}
	rec := do(Chain(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { calls++; io.WriteString(w, "ok") }), twice))
	want(t, "deco", fmt.Sprintf("calls=%d body=%q", calls, rec.Body.String()), `calls=2 body="okok"`)

	calls = 0
	rec = do(GinChain(func(c *GCtx) { c.Next(); c.Next() }, func(c *GCtx) { calls++; io.WriteString(c.W, "ok") }))
	want(t, "gin", fmt.Sprintf("calls=%d body=%q", calls, rec.Body.String()), `calls=1 body="ok"`)

	calls = 0
	_, err := runKoa(func(_ *KCtx, next func() error) error {
		if err := next(); err != nil {
			return err
		}
		return next()
	}, func(*KCtx, func() error) error { calls++; return nil })
	want(t, "koa", fmt.Sprintf("calls=%d err=%v", calls, err), "calls=1 err=next() called multiple times")
}

// O4 外側が next の後でステータスとヘッダを変えられるか
func TestO4_ChangeAfterNext(t *testing.T) {
	after := func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			next.ServeHTTP(w, r)
			w.Header().Set("X-After", "1")
			w.WriteHeader(201)
		})
	}
	srv := httptest.NewServer(Chain(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { io.WriteString(w, "ok") }), after))
	defer srv.Close()
	res, err := http.Get(srv.URL)
	if err != nil {
		t.Fatal(err)
	}
	res.Body.Close()
	want(t, "deco", fmt.Sprintf("%d X-After=%q", res.StatusCode, res.Header.Get("X-After")), `200 X-After=""`)

	h := KoaHandler(Compose(func(ctx *KCtx, next func() error) error {
		err := next()
		ctx.Header.Set("X-After", "1")
		ctx.Status = 201
		return err
	}, koaOK("ok")))
	srv2 := httptest.NewServer(h)
	defer srv2.Close()
	res, err = http.Get(srv2.URL)
	if err != nil {
		t.Fatal(err)
	}
	res.Body.Close()
	want(t, "koa", fmt.Sprintf("%d X-After=%q", res.StatusCode, res.Header.Get("X-After")), `201 X-After="1"`)
}

// O5 下流の失敗を外側がどう受け取るか
func TestO5_Errors(t *testing.T) {
	errBoom := errors.New("boom")
	// 型 A: 戻り値が無い。受け取る経路は panic と recover だけ
	var caught any
	rec := func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			defer func() {
				if v := recover(); v != nil {
					caught = v
					http.Error(w, "internal error", 500)
				}
			}()
			next.ServeHTTP(w, r)
		})
	}
	r := do(Chain(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { panic(errBoom) }), rec))
	want(t, "deco", fmt.Sprintf("%d caught=%v", r.Code, caught), "500 caught=boom")

	// 型 B: c.Errors に積み、外側は Next の後で読む
	r = do(GinChain(func(c *GCtx) {
		c.Next()
		if len(c.Errors) > 0 {
			http.Error(c.W, "internal error", 500)
		}
	}, func(c *GCtx) { c.Errors = append(c.Errors, errBoom) }))
	want(t, "gin", fmt.Sprint(r.Code), "500")

	// 型 C: next() の戻り値
	ctx, err := runKoa(func(ctx *KCtx, next func() error) error {
		if err := next(); err != nil {
			ctx.Status = 500
			ctx.Body.WriteString("handled:" + err.Error())
		}
		return nil
	}, func(*KCtx, func() error) error { return errBoom })
	want(t, "koa", fmt.Sprintf("%d %s err=%v", ctx.Status, ctx.Body.String(), err), "500 handled:boom err=<nil>")
}

// O6 溜めてから書く型は、最初の 1 バイトがいつ届くか
func TestO6_FirstByte(t *testing.T) {
	const work = 150 * time.Millisecond
	first := func(h http.Handler) time.Duration {
		srv := httptest.NewServer(h)
		defer srv.Close()
		start := time.Now()
		res, err := http.Get(srv.URL)
		if err != nil {
			t.Fatal(err)
		}
		defer res.Body.Close()
		if _, err := bufio.NewReader(res.Body).ReadByte(); err != nil {
			t.Fatal(err)
		}
		return time.Since(start)
	}
	deco := first(Chain(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		io.WriteString(w, "a")
		http.NewResponseController(w).Flush()
		time.Sleep(work)
	}), decoMW(&logger{}, "A")))
	koa := first(KoaHandler(Compose(koaMW("A"), func(ctx *KCtx, _ func() error) error {
		ctx.Status = 200
		ctx.Body.WriteString("a")
		time.Sleep(work)
		return nil
	})))
	t.Logf("deco      first byte %v", deco.Round(time.Millisecond))
	t.Logf("koa       first byte %v", koa.Round(time.Millisecond))
	if deco >= work || koa < work {
		t.Errorf("deco=%v koa=%v (work=%v)", deco, koa, work)
	}
}

// ── K: Compose(自分で書く部分)の約束 ─────────────────────────────

func TestK1_LastNextIsNoop(t *testing.T) {
	ctx, err := runKoa(koaMW("A"), koaMW("B"))
	want(t, "K1", fmt.Sprint(strings.Join(ctx.Log, " "), " err=", err), "A> B> <B <A err=<nil>")
	_, err = runKoa()
	if err != nil {
		t.Errorf("Compose() with no middleware: %v", err)
	}
}

func TestK2_NextTwiceDoesNotRerun(t *testing.T) {
	runs := 0
	_, err := runKoa(func(_ *KCtx, next func() error) error {
		next()
		return next()
	}, koaMW("B"), func(*KCtx, func() error) error { runs++; return nil })
	if !errors.Is(err, ErrNextTwice) || runs != 1 {
		t.Errorf("err=%v runs=%d, want ErrNextTwice and 1", err, runs)
	}
}

func TestK3_ErrorBubbles(t *testing.T) {
	errX := errors.New("x")
	ctx, err := runKoa(koaMW("A"), func(*KCtx, func() error) error { return errX }, koaOK("unreached"))
	if !errors.Is(err, errX) || strings.Contains(strings.Join(ctx.Log, " "), "H") {
		t.Errorf("err=%v log=%v", err, ctx.Log)
	}
}

func TestK4_ShortCircuit(t *testing.T) {
	ctx, err := runKoa(func(ctx *KCtx, _ func() error) error { ctx.Status = 401; return nil }, koaOK("secret"))
	if err != nil || ctx.Status != 401 || ctx.Body.Len() != 0 {
		t.Errorf("status=%d body=%q err=%v", ctx.Status, ctx.Body.String(), err)
	}
}

// K5 合成した関数を、同時に 200 リクエストで使い回す(-race で流す)
func TestK5_ReusedConcurrently(t *testing.T) {
	run := Compose(koaMW("A"), func(ctx *KCtx, next func() error) error {
		time.Sleep(time.Millisecond)
		return next()
	}, koaOK("ok"))
	var wg sync.WaitGroup
	var mu sync.Mutex
	bad := 0
	for i := 0; i < 200; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			ctx := &KCtx{Status: 404, Header: http.Header{}}
			if err := run(ctx); err != nil || ctx.Status != 200 {
				mu.Lock()
				bad++
				mu.Unlock()
			}
		}()
	}
	wg.Wait()
	if bad != 0 {
		t.Errorf("%d / 200 requests failed", bad)
	}
}

// K6 2 回目の next が「下流の途中から」走らない(Express 5 で実測した飛ばしを起こさない)
func TestK6_NoSkipAfterSlowMiddleware(t *testing.T) {
	ctx, err := runKoa(func(ctx *KCtx, next func() error) error {
		e1 := next()
		e2 := next()
		ctx.Log = append(ctx.Log, fmt.Sprintf("e1=%v e2=%v", e1, e2))
		return nil
	}, func(ctx *KCtx, next func() error) error {
		ctx.Log = append(ctx.Log, "auth")
		return nil // 認証に失敗したので next を呼ばない
	}, koaOK("secret"))
	if err != nil || strings.Contains(strings.Join(ctx.Log, " "), "H") {
		t.Errorf("handler ran after auth refused: log=%v err=%v", ctx.Log, err)
	}
}

// ── 費用(割り当て数は決定的。時間は参考)──────────────────────

func BenchmarkDeco3(b *testing.B) {
	g := func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next.ServeHTTP(w, r) })
	}
	h := Chain(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) }), g, g, g)
	w, r := httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil)
	b.ReportAllocs()
	for i := 0; i < b.N; i++ {
		h.ServeHTTP(w, r)
	}
}

func BenchmarkGin3(b *testing.B) {
	m := func(c *GCtx) { c.Next() }
	h := GinChain(m, m, m, func(c *GCtx) { c.W.WriteHeader(200) })
	w, r := httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil)
	b.ReportAllocs()
	for i := 0; i < b.N; i++ {
		h.ServeHTTP(w, r)
	}
}

func BenchmarkKoa3(b *testing.B) {
	m := func(_ *KCtx, next func() error) error { return next() }
	run := Compose(m, m, m, func(ctx *KCtx, _ func() error) error { ctx.Status = 200; return nil })
	ctx := &KCtx{Header: http.Header{}}
	b.ReportAllocs()
	for i := 0; i < b.N; i++ {
		ctx.Status = 404
		run(ctx)
	}
}

💡 ヒント

ヒント1(方向性)

「2 回目の next を止める」には、どこまで進んだかを覚えておく変数が要ります。その変数をどこに置くか(合成したときの外側か、1 回の呼び出しの中か)が K5 の分かれ目です。

ヒント2(アプローチ)

koa-compose は 17 行です。dispatch(i) という再帰関数を作り、中間件 i には next = () => dispatch(i+1) を渡します。「i が最後に進んだ位置以下なら拒否」という 1 行で 2 回目を止めています。Express の next は i+1 ではなく共有の添字 idx から次を探すので、2 回目は「いま下流が進んでいる位置の次」から走ります(問 3 の Express 列はここから予想できる)。

ヒント3(コード骨格)
func Compose(mws ...KMW) func(*KCtx) error {
	return func(ctx *KCtx) error {
		index := -1                    // ← 呼び出しごと。外に出すと?
		var dispatch func(i int) error
		dispatch = func(i int) error {
			// (1) i が index 以下なら ErrNextTwice
			// (2) index = i
			// (3) i が末尾なら nil
			// (4) mws[i] に「dispatch(i+1) を呼ぶ関数」を渡して、その error を返す
		}
		return dispatch(0)
	}
}

✅ 模範解答

compose.go

package main

import "errors"

var ErrNextTwice = errors.New("next() called multiple times")

// Compose は koa-compose 4.1.0 と同じ約束で mws を 1 本にする。
//   - mws[0] が最も外側。最後の中間件が呼ぶ next は何もせず nil を返す
//   - 1 つの中間件が next を 2 回呼んだら ErrNextTwice を返す(下流は 2 度走らない)
//   - 中間件が返した error はそのまま上へ返る(外側は next() の戻り値で受け取れる)
//   - 返す関数は複数のリクエストから同時に呼ばれる
func Compose(mws ...KMW) func(*KCtx) error {
	return func(ctx *KCtx) error {
		index := -1
		var dispatch func(i int) error
		dispatch = func(i int) error {
			if i <= index {
				return ErrNextTwice
			}
			index = i
			if i == len(mws) {
				return nil
			}
			return mws[i](ctx, func() error { return dispatch(i + 1) })
		}
		return dispatch(0)
	}
}

予想表の答え(すべて実測。Go 1.27.1/Node 22.23.2、2026-10-08)

問A. net/http デコレータB. Gin 型C. Koa 型(Go)Express 5.2.1(4.19.2 も同じ。違いは問5)koa-compose 4.1.0
問1 同期A> B> H <B <AA> B> H <B <AA> B> H <B <AA> B> H> <H <A <B(**B が async だと <B が最後**。await next() が 1 マイクロタスク遅れるため)—
問1' 非同期 H———A> B> H> <A <B <H(外側の後処理が応答より先に終わる=玉ねぎにならない)A> B> H> <H <B <A(玉ねぎ)
問2 next を呼ばないA(止まる)**A H(止まらない)**。止めるには Abort(): AA(止まる)A、応答 401(止まる)(C と同じ約束)
問3 next を 2 回H が 2 回、本文 "okok"、2 回目の WriteHeader は "superfluous" の警告だけH は 1 回(2 回目の Next は添字が末尾を越えていて何もしない)H は 1 回、ErrNextTwice下流が同期なら H は 1 回。**下流 B が async だと A B> C H B-next**——B が next() を呼ぶ前に C と H が走るA B H、next() called multiple times で reject
問4 next の後で 201**200 のまま、X-After も付かない**(送信済み)同じ(本物の Gin は "[WARNING] Headers were already written" を出して無視)**201、X-After: 1**(溜めてから書く)200 のまま、ERR_HTTP_HEADERS_SENT で例外201(ctx.status は後で送られる)
問5 下流の失敗戻り値が無い。panic→外側の recover で 500(caught=boom)c.Errors に積み、外側が Next の後で読んで 500next() の戻り値で受けて 500 handled:boom5.2.1: async で投げると 4 引数のエラーハンドラへ(500 handled:boom)。**4.19.2: unhandledRejection で応答が返らない**(1.5 秒で打ち切り)try { await next() } catch で 500
問6 最初の 1 バイト2 ms(Flush で先に届く)(A と同じ)151 ms(全員が戻るまで書かない)——(本物の Koa は ctx.body にストリームを入れれば流せる。今日の模型は持たない)

🪜 Step-by-Step 解説

Step 1: 「次」が関数値か、カーソルか、呼ぶたびに作る関数か

A 型の next は起動時に 1 回だけ作った関数値です。リクエストごとの状態が無いので、next.ServeHTTP を 2 回呼べばその関数がそのまま 2 回走る(問 3)。誰も止めません。

B 型の next はリクエストごとのカーソルです。Gin の Next は「添字を 1 進めて、末尾まで for で回す」ので、次の 2 つが起きます。

  • next を呼ばずに戻っても止まらない(問 2): 呼び出し元の for が添字を進めて次を呼ぶ。Gin で認証失敗時に return だけ書くとハンドラが走る。止めるのは c.Abort()(添字を 63 に飛ばす)
  • 2 回目の Next は何もしない(問 3): 1 回目で添字が末尾を越えている

Express も同じカーソル型ですが、ループではなく next() が 1 段ずつ進む作りなので、呼ばなければ止まる(問 2)。そのかわり添字 idx が全員で共有されるので、2 回目の next() は「いま下流が進んでいる位置の次」から走ります。下流の B が await で止まっている間に 2 回目が来ると、B を飛ばして C と H が走る(実測 A B> C H B-next)。B が認証だったら、認証が終わる前にハンドラが応答します。

C 型の next は**呼ぶたびに dispatch(i+1) を作り直す**関数で、i を覚えています。だから「自分は i 番だ」と分かり、2 回目は i <= index で拒否できます。

Step 2: 戻り値があるか——後処理で「結果」を読めるか

A 型の ServeHTTP には戻り値がありません。外側が next の後に読めるのは「関数が戻った」という事実だけで、ステータスも失敗も分からない。だから Go では:

  • ステータス・バイト数 → ResponseWriter を包む(Day 060・063 の statusWriter)
  • 失敗 → panic と recover(Day 064)か、ハンドラ型を func(w, r) error に変える(Echo・Caddy)

C 型は next() が error(Koa は Promise)を返すので、try/catch や if err != nil で普通に受け取れます(問 5)。

Step 3: いつ書くか——直接書くか、溜めてから書くか

問 4 と問 6 は同じ設計判断の裏表です。

  • A 型(と Express・Gin)は ResponseWriter にその場で書く。最初の Write でステータス行とヘッダが送られ、後から変えられない(問 4 で 200 のまま)。そのかわり最初の 1 バイトが早い(問 6 で 2 ms)
  • C 型(Koa)は ctx に溜め、全員が戻ってから 1 回書く。外側が後から 201 にできる(問 4)。そのかわり最初の 1 バイトは全員が終わるまで出ない(問 6 で 151 ms=仕事の 150 ms ぶん)。本文が大きいとメモリにも全部載る

Go で「後から変えたい」所だけ C 型にしたいなら、ResponseWriter をバッファする包みを書く(http.TimeoutHandler が中でやっている。Day 061 で「TimeoutHandler 内は Flush 不可」を実測したのはこのため)。

Step 4: Compose の 2 つの判断

  1. **index を呼び出しの中に置く**。合成したときの外(Compose の直下)に置くと、2 リクエスト目からは index が既に末尾なので**すべて ErrNextTwice**、同時実行ではデータ競合(変異 M3 は K5 で 200 本中 199 本が失敗、-race でも DATA RACE を検出)
  2. **next は dispatch(i+1)**(自分の番号+1)。dispatch(index+1)(共有の位置+1)にすると Express と同じ「2 回目が途中から走る」になる(変異 M4 は K2・K6・O3 で検出)

Step 5: 費用(割り当ては決定的、時間は参考)

go test -bench . -benchmem(中間件 3 段+ハンドラ、3 回):

流儀割り当て時間(参考)
A. デコレータ0 B・0 allocs16〜22 ns
B. Gin 型(GCtx を毎回 new)80 B・1 alloc91〜158 ns
C. Koa 型176 B・7 allocs228〜333 ns

時間はロードアベレージ 15〜21 の M1 8GB で測ったので、比較には使わない(3 回で 1.5 倍ぶれている)。割り当ての内訳は C が「index の逃げ+dispatch のクロージャ+next のクロージャ 4 つ+…」で、合成をリクエストごとにやり直す費用です。本物の Gin は sync.Pool で GCtx を使い回すので 1 alloc も消える(模型は写していない)。A は起動時に 1 回合成するので 0。

📏 実測(予想と外れた所)

私自身が先に予想し、外した所を残します(D2 の答えの根拠にもなる)。

予想実測なぜ外れたか
Express で next を 2 回 → 下流が 2 回走る下流が同期なら 1 回。async なら途中を飛ばして走る共有の idx から次を探すので、「同じ位置から 2 回」ではなく「いまの位置の次から」になる(router 2.2.0 index.js 235〜236 行 while (match !== true && idx < stack.length) { layer = stack[idx++])
Express の同期チェーンは玉ねぎ <B <Aasync の B があると <A <Bawait next() は next() が undefined を返しても 1 マイクロタスク待つ
Express 4 でも async の例外はエラーハンドラに届く(遅いだけ)**応答が返らず unhandledRejection**。Node 22 の既定ではプロセスごと落ちる(最初の実行で Error: boom で終了した)Express 4 の Layer は戻り値の Promise を見ない。5 が使う router 2.2.0 の layer.js 155〜162 行で ret.then(null, …next(error)) が足された
Gin で Next を呼ばない → 止まる止まらない(A H)Next の for が呼び出し元にあるので、戻ってきたら次へ進む

変異 7 通り(Compose に入れる)はすべて検出:

変異検出したテスト
M1 回数の検査を消すK2・O3
M2 <= を < にO3(K だけでは生存。葉が自分の next を 2 回呼ぶ形が K に無い)
M3 index を合成時に 1 つだけ持つK5
M4 next を dispatch(index+1) に(Express 型)K2・K6・O3
M5 末尾の next がエラーを返すK1
M6 中間件の error を捨てるK2・K3・O3・O5
M7 index = i を書かないK2・O3

スタブの Compose では 12 本すべて落ちる(O1〜O6 は Koa の列で落ちる)。

🧭 設計思想・なぜこう書くのか

  • 小さいインターフェースに揃える: http.Handler はメソッド 1 つ。ミドルウェアは Handler → Handler の関数なので、標準ライブラリの http.StripPrefix・http.TimeoutHandler・http.MaxBytesHandler も、chi の中間件も、自作も同じ型で混ぜられる。B・C 型はフレームワーク固有の ctx を通すので、Gin の中間件は Echo で使えない
  • 合成は起動時、リクエスト時は関数呼び出しだけ: A は 0 allocs(Step 5)。Go が「実行時の魔法より、起動時に組み立てた値」を好むのと同じ方向
  • 失ったもの: 戻り値。Go はそれを「ResponseWriter を包む」「panic/recover」「http.ResponseController」で補ってきた。どれも書き手の注意に頼る(Day 060 の 6 バグ、Day 064 の 5 バグはすべてここで起きた)。だから Echo・Caddy はハンドラが error を返す型を選んでいる——Go の世界でも A 型が唯一の正解ではない
  • 止め方が明示的: A 型と Koa 型は「next を呼ばない=止まる」。Gin は「Abort() を呼ぶ=止まる」。ゼロ値(何もしない)が安全側に倒れるのは A と Koaで、Day 066 の「ゼロ値を最も厳しく」と同じ考え方になる

🌐 他言語との比較

観点Go net/http(A)Gin(B)Express 5(B)Koa(C)ASP.NET Core(C、今回未確認)
型func(http.Handler) http.Handlerfunc(*gin.Context)(req, res, next) => …async (ctx, next) => …async (context, next) => …
合成の時点起動時に 1 回起動時に配列、実行時にカーソル起動時に配列、実行時にカーソル実行時に dispatch起動時にデリゲートを入れ子に(と理解。未確認)
next を呼ばない止まる止まらない(Abort が要る)止まる止まる止まる(と理解)
next を 2 回2 回走る何もしない位置の次から走る(飛ばしうる)reject未確認
後から応答を変える不可(送信済み)不可(警告して無視)不可(例外)可(溜めてから書く)不可(Response.HasStarted。未確認)
下流の失敗panic/recover・包みc.Errorsnext(err)・async の rejecttry/catchtry/catch(と理解)
1 リクエストの割り当て(3 段)00(Pool。模型は 1)———

🏭 実務での使いどころ

  • Gin の認証ミドルウェアのレビュー: if !ok { c.JSON(401, …); return } はハンドラまで走る。c.AbortWithStatusJSON(401, …) か c.JSON の後に c.Abort() があるかを見る(問 2)。Express 経験者が Gin に来たときに一番踏む
  • Express の async ミドルウェア: Express 4 のまま async/await を使うと、例外が応答を止める(問 5)。4 系を使い続けるなら express-async-errors 等の包みが要る、5 系に上げれば不要(ここは各 README を確認していない)
  • Go でエラーを返すハンドラが欲しくなったら: アプリ内だけ type AppHandler func(w, r) error を作り、境界で http.Handler に変換する(Echo・Caddy と同じ発想)。外側の中間件は A 型のまま混ぜられる——次回の実装
  • 後から応答を変えたい: ETag・圧縮・エラー本文の差し替え。Go では「包んでバッファする」中間件をそのルートだけに置く。SSE・大きなダウンロードの経路には置かない(問 6 の 150 ms がそのまま遅延になる)
  • 言語をまたぐチームでの設計レビュー: 「next を呼ばない/2 回呼ぶ/後で書き換える」の 3 つを表にして、採用中のフレームワークの答えを埋めておくと、移植時の事故が減る

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
どのフレームワークでも「next を呼ばなければ止まる」Express・Koa・net/http がそうだからGin は止まらない。Abort() が必要(O2 で A H)
Express の await next() で下流の完了を待てるKoa と同じ書き方に見えるExpress の next() は undefined を返す。非同期の下流は待てない(A> B> H> <A <B <H)
Go で next の後に WriteHeader すればステータスを変えられる「後処理」という言葉から下流が 1 バイトでも書いた後は無視され、"superfluous response.WriteHeader call" が出るだけ(O4)
溜めてから書く方式は上位互換後から変えられて便利最初の 1 バイトが全員の完了まで出ない(151 ms)。本文はすべてメモリへ
Compose の進み具合は合成時に持てばよい1 リクエストのテストでは通る2 リクエスト目から全部失敗し、同時実行で競合(M3、K5 で検出)
Koa で next() の前の await を忘れても動く同期の言語の感覚で書く下流の完了を待たずに戻り、本文が空のまま応答になる(実測: ログ A A-after(no await)、body=undefined)
next を 2 回呼んでも害は無い単体テストでは同期の下流しか無いA 型は副作用が 2 回、Express は認証を飛ばすことがある

🚧 扱わなかった範囲と理由

  • Echo・Caddy の「error を返すハンドラ」: 4 つ目の流儀。次回の実装で net/http との橋渡しとして扱う(今日は比較の軸を 3 つに絞った)
  • 本物の Gin・Echo を動かすこと: モジュールを取得していない。Gin は Next/Abort/WriteHeader の 3 か所だけ原文を読んで写した。sync.Pool・c.Errors の型(errorMsgs)・HandlersChain の上限(abortIndex=63 段)は模型に無い
  • **Koa 本体(koa パッケージ)**: 手元にあるのは koa-compose だけ。ctx.body にストリームを入れる経路(問 6 の回避策)と respond() の実装は見ていない
  • **ASP.NET Core・Spring(HandlerInterceptor・Servlet Filter)**: 表の ASP.NET 列は記憶で書いた(「未確認」と付けた所)。Spring は Filter(A に近い chain.doFilter)と Interceptor(前後のフック)の 2 本立てで、今日の 3 分類に収まりきらないので外した
  • 時間の比較: ロードアベレージ 15〜21 の M1 8GB で測ったので、時間は参考値のみ。割り当て数だけを根拠にした
  • HTTP/2・Express 4 の同期例外: 同期で投げた例外は Express 4 でもエラーハンドラに届く(はず)が、今日は async だけ測った

➡️ 次のステップ

  • 発展: type AppHandler func(http.ResponseWriter, *http.Request) error を作り、http.Handler へ変換する Adapt を書く。送信後に error が返ってきたらどうするか(もう 500 は送れない)を、Day 064 の ErrAbortHandler と合わせて決める
  • 次回予告: 同テーマで「実装」: Echo/Caddy 型の「error を返すハンドラ」を net/http に橋渡しする(error→ステータスの対応表・送信後の error・A 型の中間件と混ぜる順序)。テーマは 10/25 まで「ミドルウェア設計パターン」のまま

付録: Node で本物を測った台本(exp.js・exp2.js)

私の環境では既存の node_modules(express 4.19.2・5.2.1、koa-compose 4.1.0)を絶対パスで読んだ。手元で再現するなら、空のディレクトリで npm i express@5.2.1 koa-compose@4.1.0 してパスを差し替える(このコマンドは今日は実行していない)。

Node の台本と出力を開く
// 実物の Express(4.19.2 / 5.2.1)と koa-compose 4.1.0 で、合成の性質を測る
const http = require('http');
const E5 = require('/Users/kainumadaishi/.npm/_npx/55d1233f1980185b/node_modules/express');
const E4 = require('/Users/kainumadaishi/Learning/Udemy/TypeScript_超TypeScript 完全ガイド 2024/section13_own_typescript-node/node_modules/express');
const compose = require('/Users/kainumadaishi/.vscode/extensions/oderwat.indent-rainbow-8.3.1/node_modules/koa-compose');
const sleep = ms => new Promise(r => setTimeout(r, ms));
process.on('unhandledRejection', e => console.log('  !! unhandledRejection: ' + e.message));

async function hit(app, path) {
  const srv = http.createServer(app); await new Promise(r => srv.listen(0, '127.0.0.1', r));
  const res = await fetch(`http://127.0.0.1:${srv.address().port}${path}`);
  const body = await res.text(); await sleep(30); srv.close();
  return `${res.status} ${JSON.stringify(body)}`;
}

async function express(E, name) {
  // E1: 同期のハンドラ / 非同期のハンドラで、外側の「後処理」がいつ走るか
  for (const asyncH of [false, true]) {
    const log = []; const app = E();
    app.use((req, res, next) => { log.push('A>'); next(); log.push('<A'); });
    app.use(async (req, res, next) => { log.push('B>'); await next(); log.push('<B'); });
    app.get('/', async (req, res) => { log.push('H>'); if (asyncH) await sleep(5); res.send('ok'); log.push('<H'); });
    const r = await hit(app, '/');
    console.log(`${name} E1 asyncH=${asyncH}: ${log.join(' ')} | ${r}`);
  }
  // E2: next() を 2 回呼ぶ
  { const log = []; const app = E();
    app.use((req, res, next) => { log.push('A'); next(); next(); });
    app.use((req, res, next) => { log.push('B'); next(); });
    app.use((req, res, next) => { log.push('C'); next(); });
    app.get('/', (req, res) => { log.push('H'); if (!res.headersSent) res.send('ok'); });
    const r = await hit(app, '/');
    console.log(`${name} E2 next x2: ${log.join(' ')} | ${r}`); }
  // E3: 外側が next() の後でステータスを変える
  { const app = E(); let err = '';
    app.use((req, res, next) => { next(); try { res.status(201); res.set('X-After', '1'); } catch (e) { err = e.code || e.message; } });
    app.get('/', (req, res) => res.send('ok'));
    const r = await hit(app, '/');
    console.log(`${name} E3 status after next: ${r} err=${err}`); }
  // E4: async ミドルウェアが投げた例外
  { const app = E();
    app.use(async (req, res, next) => { await sleep(1); throw new Error('boom'); });
    app.get('/', (req, res) => res.send('ok'));
    app.use((err, req, res, next) => res.status(500).send('handled:' + err.message));
    const r = await Promise.race([hit(app, '/'), sleep(1500).then(() => 'TIMEOUT(応答なし)')]);
    console.log(`${name} E4 async throw: ${r}`); }
  // E5: next() を呼ばない
  { const app = E(); const log = [];
    app.use((req, res, next) => { log.push('A'); res.status(401).send('no'); });
    app.get('/', (req, res) => { log.push('H'); res.send('ok'); });
    const r = await hit(app, '/');
    console.log(`${name} E5 no next: ${log.join(' ')} | ${r}`); }
}

async function koa() {
  const run = async (mws) => { const ctx = { log: [], status: 404, body: undefined };
    try { await compose(mws)(ctx); } catch (e) { ctx.err = e.message; } return ctx; };
  let c = await run([
    async (ctx, next) => { ctx.log.push('A>'); await next(); ctx.log.push('<A'); },
    async (ctx, next) => { ctx.log.push('B>'); await next(); ctx.log.push('<B'); },
    async (ctx) => { ctx.log.push('H>'); await sleep(5); ctx.body = 'ok'; ctx.status = 200; ctx.log.push('<H'); }]);
  console.log(`koa E1 asyncH=true: ${c.log.join(' ')}`);
  c = await run([
    async (ctx, next) => { ctx.log.push('A'); await next(); await next(); },
    async (ctx, next) => { ctx.log.push('B'); await next(); },
    async (ctx) => { ctx.log.push('H'); }]);
  console.log(`koa E2 next x2: ${c.log.join(' ')} | err=${c.err}`);
  c = await run([
    async (ctx, next) => { await next(); ctx.status = 201; },
    async (ctx) => { ctx.status = 200; ctx.body = 'ok'; }]);
  console.log(`koa E3 status after next: status=${c.status} body=${c.body}`);
  c = await run([
    async (ctx, next) => { try { await next(); } catch (e) { ctx.status = 500; ctx.body = 'handled:' + e.message; } },
    async () => { await sleep(1); throw new Error('boom'); }]);
  console.log(`koa E4 async throw: ${c.status} ${c.body}`);
  c = await run([
    async (ctx, next) => { ctx.log.push('A'); next(); ctx.log.push('A-after(no await)'); },
    async (ctx) => { await sleep(5); ctx.log.push('H'); ctx.body = 'late'; }]);
  console.log(`koa E6 next without await: ${c.log.join(' ')} | body=${c.body}`);
}

(async () => {
  await express(E4, 'express4.19.2');
  await express(E5, 'express5.2.1');
  await koa();
  process.exit(0);
})();
const http = require('http');
const E5 = require('/Users/kainumadaishi/.npm/_npx/55d1233f1980185b/node_modules/express');
const sleep = ms => new Promise(r => setTimeout(r, ms));
(async () => {
  const log = []; const app = E5();
  app.use((req, res, next) => { log.push('A'); next(); next(); });
  app.use(async (req, res, next) => { log.push('B>'); await sleep(5); log.push('B-next'); next(); });
  app.use((req, res, next) => { log.push('C'); next(); });
  app.get('/', (req, res) => { log.push('H'); if (!res.headersSent) res.send('ok'); else log.push('H(headersSent)'); });
  const srv = http.createServer(app); await new Promise(r => srv.listen(0, '127.0.0.1', r));
  const res = await fetch(`http://127.0.0.1:${srv.address().port}/`); const body = await res.text();
  await sleep(50); srv.close();
  console.log(`express5.2.1 E2b next x2 + async B: ${log.join(' ')} | ${res.status} ${body}`);
})();

出力(Node 22.23.2、2026-10-08):

express4.19.2 E1 asyncH=false: A> B> H> <H <A <B | 200 "ok"
express4.19.2 E1 asyncH=true: A> B> H> <A <B <H | 200 "ok"
express4.19.2 E2 next x2: A B C H | 200 "ok"
express4.19.2 E3 status after next: 200 "ok" err=ERR_HTTP_HEADERS_SENT
  !! unhandledRejection: boom
express4.19.2 E4 async throw: TIMEOUT(応答なし)
express4.19.2 E5 no next: A | 401 "no"
express5.2.1 E1 asyncH=false: A> B> H> <H <A <B | 200 "ok"
express5.2.1 E1 asyncH=true: A> B> H> <A <B <H | 200 "ok"
express5.2.1 E2 next x2: A B C H | 200 "ok"
express5.2.1 E3 status after next: 200 "ok" err=ERR_HTTP_HEADERS_SENT
express5.2.1 E4 async throw: 500 "handled:boom"
express5.2.1 E5 no next: A | 401 "no"
koa E1 asyncH=true: A> B> H> <H <B <A
koa E2 next x2: A B H | err=next() called multiple times
koa E3 status after next: status=201 body=ok
koa E4 async throw: 500 handled:boom
koa E6 next without await: A A-after(no await) | body=undefined
express5.2.1 E2b next x2 + async B: A B> C H B-next | 200 ok

🎯 自己評価

予想表で当たった数 / 外した所

D1・D2 の答え

気づき・メモ