🔀

bodyを2回読んだら壊れた ― Express・Hono・FastAPIで学ぶbody管理の設計思想

に公開
1

はじめに — ミドルウェアでbodyを読んだら壊れた

POS(Point of Sale)連携APIの開発中、冪等性チェックのミドルウェアとログ記録のミドルウェアを組み合わせたとき、本番環境で不可解なエラーが発生しました。

原因はbodyの二重消費です。冪等性ミドルウェアがc.req.json()でbodyを読み取った後、ログミドルウェアが同じbodyを読もうとして失敗していました。Hono環境でのことです。

「bodyを読む」という一見単純な操作が、なぜフレームワークによってまったく異なる結果になるのか。Expressならreq.bodyを何度参照しても問題ありません。しかしHonoでは1回読んだbodyは消えてしまいます。これは単なるバグではなく、フレームワークの設計思想の違いから生まれる現象です。

この記事では、Express・Hono・FastAPIという3つのフレームワークを横断して、「ミドルウェアがbodyに触らない」設計へと進化した理由を解説します。

この記事で分かること:

  • Express → Hono → FastAPI で「bodyの扱い方」がどう進化したか
  • 近年のフレームワークがミドルウェアでbodyを読まない構造的な理由
  • 自分のプロジェクトでbody管理方式を選ぶときの判断軸

対象読者: Express経験者で、HonoやFastAPIの設計思想に興味があるバックエンド開発者

なお、本記事はシリーズの第3弾です。#20ではHono固有のbody消費問題を、#21ではReadableStreamの1回読み制約の仕組みを深掘りしました。本記事はそれらを踏まえて、フレームワーク横断で「なぜそうなっているのか」を解き明かします。

Expressの設計 — body-parserが「全部読んで詰める」時代

結論から言うと、Expressの「bodyを先読みしてオブジェクトに詰める」設計は、Node.js初期のストリームAPIが未成熟だった時代の合理的な解です。

body-parserの動作メカニズム

Expressでは、express.json()ミドルウェア(旧body-parser)がリクエスト到着時にストリームを全読みし、パース結果をreq.bodyに格納します。

// Express: body-parserによる先読みパターン
const express = require('express');
const app = express();

// ミドルウェアがリクエスト到着時にbodyを全読み・パース
app.use(express.json());

app.post('/api/items', (req, res) => {
  // req.body には既にパース済みのオブジェクトが入っている
  console.log(req.body); // { name: "item1", price: 100 }
  res.json({ ok: true });
});

このコードで起きていることはシンプルです。express.json()がリクエストのストリームをすべて読み取り、JSON.parse()でパースし、その結果をreq.bodyに格納します。後続のミドルウェアやハンドラは、req.bodyを参照するだけです。

図: Expressのリクエスト処理フロー。body-parserがストリームを全読みしreq.bodyに格納、後続のミドルウェアとハンドラが共有参照する

この設計が生まれた背景

なぜExpressはこのような設計を採用したのでしょうか。3つの背景があります。

1. Node.jsのストリームAPIが未成熟だった

Express 3.x時代(2012年前後)のNode.jsは、Streams1と呼ばれる初期のストリームAPIを使っていました。pause()が保証なし(advisory-only)で、データがconsumerの準備前に発火する「spew streams」(Isaac Z. Schlueterが当時のストリームの問題を表現した言葉)問題を抱えていました。バックプレッシャーも不在で、メモリ枯渇の危険がありました。

このような状況下では、「ストリームを適切にハンドリングして必要なときだけ読む」という設計は現実的ではありません。「全部読んでオブジェクトに詰める」ほうが、はるかに安全で予測可能だったのです。

2. 同期的プログラミングモデルへの親和性

当時のNode.js開発はコールバック中心でした。ストリームを非同期に少しずつ読むよりも、パース済みのオブジェクトをreq.bodyとして同期的にアクセスできるほうが、開発者にとって自然な体験でした。

3. リクエストサイズが小さかった

2010年代前半のWebアプリケーションでは、JSONリクエストのサイズは数KB程度が一般的です。全バッファリングのメモリコストは問題になりにくい状況でした。

body-parserの歴史的変遷

body-parserの歩みは、Express自体の設計思想の変遷を映し出しています。

  • Express 3.x時代: body-parserがフレームワークに同梱されていました
  • Express 4.0(2014年): モジュラリティのためにコアから分離。npm install body-parserが必要に
  • Express 4.16(2017年): express.json()express.urlencoded()としてコアに再導入(内部ではbody-parserパッケージを使用)

Express 4.16での再導入について、GitHub Issue #2211でのDouglas Wilsonの発言が象徴的です。bodyのパースはほとんどのHTTPアプリケーションにとって基本的な必要性であり、req.on('data', fn)による手動body読み取りは初心者にとって「completely non-obvious」(まったく自明ではない)と述べています。

メリットとデメリット

メリット:

  • シンプル。どのハンドラからもreq.bodyでアクセスできます
  • ミドルウェアチェーン内でbodyを自由に使えます
  • ストリームの消費問題が発生しません

デメリット:

  • 大きなbodyを全バッファリングするためメモリを消費します
  • bodyが不要なエンドポイント(GETリクエスト等)でもパースが走ります
  • ミドルウェアの実行順序に暗黙的に依存します(body-parserより前にbodyを使おうとするとundefined

これらのデメリットは、2010年代前半のサーバーサイドNode.jsでは大きな問題にはなりませんでした。しかし、実行環境が変わると話が変わってきます。

Honoの設計 — lazy parseと「必要なときだけ読む」思想

結論として、Honoはbodyをミドルウェアで読まない設計を採用しています。その理由は、Web標準(Fetch API)に準拠し、エッジランタイムのリソース制約に対応するためです。

Honoのbodyアクセス方式

Honoでは、bodyにアクセスするためのメソッドがリクエストオブジェクトに用意されています。ハンドラやミドルウェアが明示的に呼んだときに初めてパースが実行される、lazy parse方式です。

// Hono: lazy parseパターン
import { Hono } from 'hono';

const app = new Hono();

// ミドルウェアではbodyに触らない
app.use('*', async (c, next) => {
  const start = Date.now();
  await next();
  console.log(`${Date.now() - start}ms`);
});

app.post('/api/items', async (c) => {
  // ハンドラで初めてbodyをパースする
  const body = await c.req.json();
  return c.json({ ok: true, name: body.name });
});

Honoが提供するbodyアクセスメソッドは以下のとおりです。

  • c.req.json()application/jsonをパース
  • c.req.text() — テキストとして取得
  • c.req.formData() — FormDataとしてパース
  • c.req.parseBody() — multipart/form-dataとapplication/x-www-form-urlencodedの両方を処理
  • c.req.arrayBuffer() — ArrayBufferとして取得
  • c.req.blob() — Blobとして取得

図: Honoのリクエスト処理フロー。ミドルウェアはbody未読のまま通過し、ハンドラで初めてc.req.json()でlazy parseする

なぜlazy parseなのか

Honoの設計判断には3つの背景があります。

1. Web標準準拠

HonoはFetch API(Request/Response)をベースに構築されています。Request.bodyはReadableStreamであり、仕様上1回しか読めません。Yusuke Wada(Hono作者)はCloudflare Blogで、Honoが「Web Standards APIのみを使い、外部ライブラリに依存しない」設計であることを強調しています。

この設計によって、Cloudflare Workers、Deno、Bun、Node.jsのすべてで動作するマルチランタイム対応が実現しています。

2. エッジランタイムのリソース制約

Cloudflare Workersのメモリ制限は128MB/isolate、Free tierのCPU時間はリクエストあたり10msです。この環境では、bodyが不要なリクエストでパースを実行するコストが無視できません。lazy parseによって、必要なときだけリソースを消費する設計が合理的です。

3. body-parseミドルウェアの明示的な廃止

GitHub Issue #362で、Yusuke Wadaはbody-parseミドルウェア(Expressのbody-parserとは別の、Hono固有のミドルウェア)の代わりにc.req.parseBody()をリクエストオブジェクトのメソッドとして実装することを提案しました。ミドルウェアのボイラープレートを排除し、よりクリーンなAPIを提供するためです。この提案はPR #363で実装され、Hono v2.0.0のマイルストーンに組み込まれました。以降、body-parseミドルウェアパターンは非推奨となっています。

二重消費の罠

ここで冒頭の問題に戻ります。lazy parseの設計には、注意すべき落とし穴があります。

ミドルウェアでc.req.json()を呼ぶと、ReadableStreamが消費されます。その後ハンドラで再度c.req.json()を呼ぶとエラーになります。Honoは内部的にbodyCacheでキャッシュを行いますが、同一メソッドの再呼び出しのみ対応です。text()の後にjson()を呼ぶといったクロスメソッドのキャッシュは行われません(GitHub Issue #1499, #2163)。

解決策は2つあります。

// パターン1: clone()でストリームを複製する
app.use('*', async (c, next) => {
  const cloned = c.req.raw.clone();
  const body = await cloned.json();
  console.log('Request body:', body);
  await next();
});

// パターン2: c.set() でパース結果を共有する
app.use('*', async (c, next) => {
  const body = await c.req.json();
  c.set('parsedBody', body);
  await next();
});

app.post('/api/items', (c) => {
  const body = c.get('parsedBody');
  return c.json({ ok: true, name: body.name });
});

ミドルウェアでbodyが必要な場合は、c.req.raw.clone()でストリームを複製するか、c.set()/c.get()でパース結果をミドルウェア間で共有するパターンを使います。ただし、基本方針は「ミドルウェアではbodyに触らない」です。

FastAPIの設計 — 宣言的バリデーションと「bodyはフレームワークが管理する」思想

結論として、FastAPIはbodyのパース・バリデーションをフレームワーク自体が型宣言に基づいて自動実行する設計です。開発者はPydanticモデルを宣言するだけで、bodyの読み取りを意識する必要がありません。

Pydanticモデルによる宣言的body管理

FastAPIのbodyアクセスは、他の2フレームワークとは根本的に異なります。ハンドラの引数にPydanticモデルを型ヒントとして宣言するだけです。

# FastAPI: 宣言的バリデーションパターン
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class Item(BaseModel):
    name: str
    price: float

@app.post("/api/items")
async def create_item(item: Item):
    # bodyは自動的にパース・バリデーション済み
    # item は Item型のオブジェクト(型安全)
    return {"ok": True, "name": item.name}

このコードにはbodyを読み取る処理が一切ありません。フレームワークがハンドラの型宣言を解析し、リクエストbodyを自動的にパース・バリデーションして、Item型のオブジェクトとしてハンドラに注入します。

Sebastián Ramírez(FastAPI作者)は「Pythonには変数をアノテートする標準的な方法が既にある。それをそのまま再利用して、標準的なPythonコードで超シンプルにしたかった」と語っています。

図: FastAPIのリクエスト処理フロー。ASGIミドルウェアはbody未読、フレームワークコアがPydanticモデルで自動パース・バリデーションし、ハンドラにDI注入する

Depends()による依存性注入

FastAPIでは、ミドルウェアの代わりにDepends()による依存性注入がbodyの前処理を担います。

from fastapi import Depends

async def verify_idempotency(item: Item):
    # bodyのバリデーション後に冪等性チェック
    check_idempotency(item.dict())  # 冪等性チェック関数(実装は省略)
    return item

@app.post("/api/items")
async def create_item(item: Item = Depends(verify_idempotency)):
    return {"ok": True, "name": item.name}

Expressの冪等性ミドルウェアがbodyを読む必要があったのとは対照的に、FastAPIではDI経由でバリデーション済みのオブジェクトを受け取って処理します。

Starletteの内部キャッシュ機構

FastAPIの基盤であるStarletteには、bodyの再読み取りに対する内部キャッシュ機構があります。

StarletteのRequestオブジェクトは3つのキャッシュ変数を持ちます。

  • _body: .body()の結果をキャッシュ
  • _json: .json()の結果をキャッシュ
  • _form: .form()の結果をキャッシュ

.body()を複数回呼んでも、2回目以降はキャッシュから返されます。ただし.form()は内部的に.stream()を直接呼ぶため、.form()の後に.body()を呼ぶとRuntimeError: "Stream consumed"が発生します。

ミドルウェアでのbody消費問題

Starlette Issue #495では、ミドルウェアでのbody消費問題が議論されました。ASGIのreceive関数はストリームベースで1回しか読めません。コア・コントリビューターは「リクエストデータをミドルウェアで消費することは問題がある。これはStarletteに限った話ではなく、一般的にどこでも同じ」と述べています。

つまり、Honoと同様にFastAPIでも「ミドルウェアではbodyに触らない」が基本設計です。bodyの前処理が必要な場合は、Depends()による依存性注入を使います。

3つの設計思想を比較する — なぜ「bodyに触らない」方向に進化したのか

結論として、「ミドルウェアがbodyに触らない」方向への進化は、Web標準の普及・エッジ環境の台頭・関心の分離という3つの構造的変化によって引き起こされました。

比較表

Express・Hono・FastAPIのbody管理方式を5軸で比較した表。パースのタイミング、bodyの所有権、ミドルウェアの役割、ストリーム消費、バリデーション方式を並列比較

図: Express・Hono・FastAPIのbody管理方式を5軸で比較した表

テキスト版(画像が表示されない環境向け)
観点 Express Hono FastAPI
パースのタイミング リクエスト到着時(先読み) ハンドラ呼び出し時(遅延) ハンドラ実行前(フレームワーク自動)
bodyの所有権 req.bodyオブジェクト(共有可変) ReadableStream(1回消費) Pydanticモデル(イミュータブル)
MWのbodyアクセス 自由(req.bodyが常に利用可能) 原則禁止(clone/c.set()が必要) 非推奨(DI/Depends()で代替)
ストリーム消費の扱い 全バッファリング(消費問題なし) 1回読み制約(clone必要) 内部キャッシュ(同一メソッドはOK)
バリデーション 手動 or 外部ライブラリ 手動 or Zod等 Pydanticモデルで自動

進化を引き起こした3つの構造的要因

表を眺めると、HonoとFastAPIには「ミドルウェアがbodyに触らない」という共通点があります。これは偶然ではありません。3つの構造的要因が、この方向への進化を必然にしました。

要因1: ストリームの1回読み制約 — Web標準の普及

Fetch APIの普及により、bodyは使い捨てのReadableStreamであることが標準になりました。

WHATWG Fetch Standardでは、Request.bodyはReadableStreamオブジェクトであり、.text().json()を呼ぶとストリームが消費されます。消費後はbodyUsedフラグがtrueになり、再読み取りはできません。

この設計には明確な意図があります。ストリームは流れるデータの抽象化であり、同じデータを2回読むことはストリームモデルに反します。1回読みの制約により、コードのどの部分がbodyの所有者かが明確になり、複数箇所が同じbodyを消費しようとするバグを防止できます。

Expressの「全部読んでオブジェクトに詰める」アプローチは、この1回読み制約と本質的に相性が悪いです。Web標準に準拠するフレームワーク(Hono, FastAPI/Starlette)では、ストリームの性質を尊重した設計が自然な選択になりました。

要因2: エッジコンピューティングとリソース制約

エッジランタイムの普及が、lazy parseを必然にしました。

プラットフォーム メモリ CPU時間(Free)
Cloudflare Workers 128 MB/isolate 10 ms/request
Deno Deploy 512 MB 50 ms
Vercel Edge Functions 128 MB

Cloudflare Workersの128MBというメモリ制限下で、すべてのリクエストのbodyをバッファリングするのはリスクがあります。実際に、20MBのファイルアップロードでWorkerがクラッシュした事例が報告されています。「Memory limit would be exceeded before EOF」というエラーです。

公式ドキュメントでも、大きなbodyのメモリへのバッファリングを避け、TransformStreamを使ったストリーミング処理を推奨しています。bodyを全読みする代わりにストリームとして直接宛先にパイプすれば、128MBの制限内でもギガバイト単位のデータを処理できます。

Honoの最小プリセットhono/tinyはminified時に14KB未満で、Expressの572KB(同条件、Hono公式ドキュメントの比較値)と比較して圧倒的に軽量です。エッジ環境のファイルサイズ制約やコールドスタートを意識した設計であることが分かります。

要因3: 関心の分離 — bodyの管理はビジネスロジック

bodyのパース・バリデーションは、ログ記録や認証のような横断的関心事ではありません。「このエンドポイントのbodyはこの形式で、このバリデーションが必要」というのは、個別のビジネスロジックです。

Expressでは、body-parserというミドルウェアがこのビジネスロジック寄りの責務を担っていました。しかしHonoではハンドラが、FastAPIではフレームワーク自体が型宣言に基づいてこの責務を担います。

この変化は「関心の分離」の原則に沿っています。ミドルウェアは横断的関心事(ログ、認証、CORS等)に集中し、bodyの管理はより適切な場所(ハンドラ or フレームワーク)に任せるという整理です。

「Expressが悪い」わけではない

フレームワーク設計思想の進化タイムライン。Node.js初期のExpressからWeb標準時代のFastAPI、エッジ時代のHonoへ、bodyに触らない設計へ進化した流れ

図: フレームワーク設計思想の進化タイムライン。Node.js初期のExpressからWeb標準時代のFastAPI、エッジ時代のHonoへ、bodyに触らない設計へ進化した流れ

ここまでの議論を「Expressは古くて劣っている」と読むのは誤りです。

Express(2010年)は、Node.jsのストリームAPIが未成熟な時代に、開発者の複雑さを大幅に削減する合理的な設計でした。FastAPI(2018年)はPythonの型ヒントエコシステムの成熟を背景に、宣言的バリデーションという新たなアプローチを切り開いています。Hono(2022年)はFetch APIの普及とエッジランタイムの台頭を受けて、Web標準準拠のlazy parseを採用しています。

環境の変化が、新しい設計を合理的にしたのです。どのフレームワークも、その時点での技術的な制約と機会を踏まえた合理的な判断をしています。

あなたのプロジェクトではどう考えるべきか — 判断軸の提示

ここまでの比較を踏まえて、自分のプロジェクトでbody管理方式をどう選ぶべきかを考えます。3つの判断軸を提示します。

判断軸1: ランタイム環境

最も大きな分岐点は実行環境です。Cloudflare WorkersやVercel Edge Functionsのようなエッジランタイムでは、128MBのメモリ制限やCPU時間の制約があります。この環境ではlazy parseが必須です。body-parser的な全バッファリングはリスクが高すぎます。

一方、Node.jsサーバー(AWS EC2、GCP Cloud Run等)であれば、メモリやCPUの制約は緩いです。Expressのbody-parserによる全バッファリングも十分に許容範囲内です。

判断軸2: ミドルウェアでbodyが必要か

ログ記録、冪等性チェック、リクエストの監査ログなど、ミドルウェアでbodyの内容にアクセスする必要がある場合は、明示的なストリーム管理が求められます。

Expressならこの点は問題になりません。Honoならc.set()パターンやc.req.raw.clone()を使った設計を標準にする必要があります。FastAPIならDepends()で処理します。

判断軸3: 型安全性の要求度

バリデーションの厳密さが強く求められるプロジェクトでは、FastAPI的な宣言的アプローチが有利です。型宣言がバリデーション・ドキュメント・シリアライゼーションを兼ねるため、コードの重複を排除しつつ型安全性を確保できます。

HonoでもZod等のバリデーションライブラリと組み合わせれば同様のことは実現できますが、フレームワークレベルで統合されているFastAPIほどのシームレスさはありません。

図: body管理方式の選択フローチャート。ランタイム環境、ミドルウェアでのbody使用有無、型安全性要求の3軸で分岐し推奨パターンに導く

Expressプロジェクトの場合

body-parserの「全読み」はそのままで問題ない場面がほとんどです。ただし、ストリーミングレスポンスやファイルアップロードなど、body全体をメモリに保持するのが非効率な場面では限界が来ます。そのときは個別のエンドポイントでストリーム処理に切り替える選択肢があります。

Honoプロジェクトの場合

ミドルウェアでbodyを使いたいときは、c.set()パターンをチーム内で標準にすることを推奨します。「ミドルウェアではbodyに触らない」を原則としつつ、必要な場合の解決策を明示しておくことで、冒頭のような二重消費バグを防げます。

まとめ

3つのフレームワークのbody管理方式を振り返ります。

  • Express: リクエスト到着時にbodyを全読みし、req.bodyに格納する先読みバッファリング。Node.jsのストリームAPIが未成熟な時代の合理的な設計
  • Hono: ハンドラで明示的に呼んだときだけbodyをパースするlazy parse。Web標準(Fetch API)準拠とエッジランタイムのリソース制約への対応
  • FastAPI: ハンドラの型宣言に基づいてフレームワークが自動的にパース・バリデーションする宣言的バリデーション。Pythonの型ヒントエコシステムとの統合

「ミドルウェアがbodyに触らない」のは、制限ではありません。Web標準の普及、エッジ環境の台頭、関心の分離という3つの技術的潮流に対する、合理的な設計判断です。Expressの設計が間違っていたのではなく、環境が変わったのです。

bodyをどう管理するかは、フレームワークの設計思想そのものです。自分のプロジェクトの制約(ランタイム、ミドルウェアの要件、型安全性)を見極めて、適切なパターンを選択してください。

1

Discussion

ログインするとコメントできます
1