フェーズ: 上級者 (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 つです。答えは流儀ごとに割れます。
- 入る順・出る順(玉ねぎになるか)
- 中間件が
nextを呼ばずに戻ったら、後ろは走るか nextを 2 回呼んだら- 外側が
nextの後でステータス・ヘッダを変えられるか - 下流の失敗を外側はどう受け取るか
- 最初の 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.js36 行:if (i <= index) return Promise.reject(new Error('next() called multiple times'))(手元の node_modules) - router 2.2.0(Express 5.2.1 が使う)
index.js156〜300 行、lib/layer.js142〜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.1 | koa-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.1 | koa-compose |
|---|---|---|---|---|---|
| 問1 同期 | |||||
| 問1' 非同期 H | |||||
| 問2 next を呼ばない | |||||
| 問3 next を 2 回 | |||||
| 問4 next の後で 201 | |||||
| 問5 下流の失敗 | |||||
| 問6 最初の 1 バイト |
課題
- 上の表を予想で埋める(自己評価欄へ)
compose.goのComposeを実装して、go test -race -count=1 -v ./...を全部通す。約束はコメントのとおり:mws[0]が最も外側。最後の中間件が呼ぶnextは何もせずnil- 1 つの中間件が
nextを 2 回呼んだらErrNextTwiceを返し、下流は 2 度走らない - 中間件が返した
errorは上へそのまま返る - 返した関数は複数のリクエストから同時に呼ばれる
-vの出力(O1〜O6 のt.Logf)を、手順 1 の予想と照らし、外れた所を書く- 次の 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 <A | A> B> H <B <A | A> B> H <B <A | A> 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(): A | A(止まる) | 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 の後で読んで 500 | next() の戻り値で受けて 500 handled:boom | 5.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 つの判断
- **
indexを呼び出しの中に置く**。合成したときの外(Composeの直下)に置くと、2 リクエスト目からはindexが既に末尾なので**すべてErrNextTwice**、同時実行ではデータ競合(変異 M3 は K5 で 200 本中 199 本が失敗、-raceでも DATA RACE を検出) - **
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 allocs | 16〜22 ns |
| B. Gin 型(GCtx を毎回 new) | 80 B・1 alloc | 91〜158 ns |
| C. Koa 型 | 176 B・7 allocs | 228〜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 <A | async の B があると <A <B | await 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.Handler | func(*gin.Context) | (req, res, next) => … | async (ctx, next) => … | async (context, next) => … |
| 合成の時点 | 起動時に 1 回 | 起動時に配列、実行時にカーソル | 起動時に配列、実行時にカーソル | 実行時に dispatch | 起動時にデリゲートを入れ子に(と理解。未確認) |
| next を呼ばない | 止まる | 止まらない(Abort が要る) | 止まる | 止まる | 止まる(と理解) |
| next を 2 回 | 2 回走る | 何もしない | 位置の次から走る(飛ばしうる) | reject | 未確認 |
| 後から応答を変える | 不可(送信済み) | 不可(警告して無視) | 不可(例外) | 可(溜めてから書く) | 不可(Response.HasStarted。未確認) |
| 下流の失敗 | panic/recover・包み | c.Errors | next(err)・async の reject | try/catch | try/catch(と理解) |
| 1 リクエストの割り当て(3 段) | 0 | 0(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・ServletFilter)**: 表の 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