Dev Study
← アプリケーションの土台
レッスン 3 / 11·約 9 分·第7期 アプリケーションの土台

ハンドラ関数とレスポンス — hello world のどの行が socket に触っているか

リクエストがアプリケーションに届くまで
  1. ブラウザURL入力
  2. DNS名前→IP
  3. ルータ経路選択
  4. ロードバランサ振り分け
  5. Nginx受け口
  6. アプリ処理今ここ
  7. DB保存

前回、バイト列をパースしてハンドラを選ぶところまで見ました。今回はその先、返した値がどうバイト列に戻り接続へ書き出されるかです。

まず結論

フレームワークに書く `return "hello"` は、最後には必ず接続への書き出しになります。第13週『システムコール — アプリとカーネルの境界線』で見たとおり、アプリは自分で線にバイトを流せず、書き出しはカーネルへの依頼です。

そして書き終えるまで、応答は存在しません。`res.end()` を呼ばないハンドラは、実行されているのにクライアントへ1バイトも届きません。エラーも出ず、ただ待たされます。

? 読む前に予測

例外を catch して `console.error` だけを実行し、`res` には何も書かないハンドラ。そのURLにブラウザからアクセスすると、何が起きるでしょうか。

当てることが目的ではありません。外した予測ほど記憶に残るので、直感で選んでください。

抽象原理

ハンドラは、応答を「返す」のではなく「材料を渡す」

前回、バイト列をパースしてどのハンドラを呼ぶかが決まるところまで見ました。ここから先は、ハンドラが作ったものが相手に届くまでの話です。

「この関数が応答を返している」という絵を思い浮かべがちですが、正確ではありません。ハンドラがしているのは、材料を組み立ててサーバ側に渡すことだけです。材料は3つ、ステータス・ヘッダ・本文です。

RFC 9112 の §2.1 が、HTTPメッセージの形をこう定義しています。

RFC 9112 §2.1 Message Format(原文どおり)設定ファイル
HTTP-message = start-line CRLF
*( field-line CRLF )
CRLF
[ message-body ]

応答も同じ形です。最終的に線を流れるのはこの形に組み上がった1本のバイト列であって、オブジェクトでも構造体でもありません。

そして書き出しそのものは、アプリにはできません。第13週『システムコール — アプリとカーネルの境界線』で見たとおり、アプリはユーザーモードで動いていてハードウェアに直接触れません。バイトを線に流すのはカーネルへの依頼です。

具体実物

hello world の各行が、何に相当するか

Node 標準の http で最小のサーバを書くと、こうなります。

srv.js(Node v24.21.0 / macOS)コマンド出力
const http = require("http");
const server = http.createServer((req, res) => { res.end("hello"); });
server.listen(3999, () => { console.log("listening", JSON.stringify(server.address())); });

この3行が、接続を受ける一連の呼び出しのどこに相当するか並べます。

コードの各部分が相当する呼び出し(対応関係の整理。トレースの実出力ではない)表
コードの部分 相当する呼び出し 何が起きるか
http.createServer(fn) —— fn を覚えるだけ
server.listen(3999) socket / bind / listen 端点を作り、ポートを割り当て、受動にする
(接続が来る) accept 新しい fd が返る
(リクエストが届く) read バイト列が読まれ、fn が呼ばれる
res.end("hello") write ステータス行・ヘッダ・本文が書き出される
(keep-alive が切れる) close 接続が閉じられる

この表は strace や dtruss で採取した出力ではありません(手元の macOS では採取していません)。実際にはもっと多くの呼び出しが挟まります。

注目すべきは最後から2行目です。res.end("hello") という1行だけが、書き出しに対応しています。 createServer に渡した関数は、呼ばれるだけでは何も書き出しません。

Node のドキュメントは response.end() をこう説明しています。

This method signals to the server that all of the response headers and body have been sent; that server should consider this message complete. The method, response.end(), MUST be called on each response.

すべての応答で呼ばれなければならない(MUST)。返ってくるバイト列はこうです。

$ curl -i http://localhost:3999/(Node v24.21.0 / macOS / curl 8.7.1)コマンド出力
HTTP/1.1 200 OK
Date: Wed, 16 Sep 2026 04:11:10 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Content-Length: 5
 
hello

ヘッダは4つだけで、Content-Type は付きません。付けと言っていないものは付かないのです。

✓ ここまでの確認

Node の `http.createServer((req, res) => { res.end("hello") })` で、接続へバイトを書き出しているのはどの部分ですか。

抽象原理

書き終えるまで、応答は存在しない

ここから一般化します。「ハンドラが実行された」と「応答が返った」は、別の出来事です。

同じものだと思っていると、次の状況が読めません。ログにはリクエストが記録され console.log も出ているのに、ブラウザは待ち続けている。書き出しに対応する行が実行されていなければ、線には1バイトも流れていないからです。

console.log だけをするハンドラのサーバに、curl -m 5 を投げました。

$ curl -m 5 -v http://127.0.0.1:3998/(res.end を呼ばないハンドラ / Node v24.21.0 / macOS)コマンド出力
* Trying 127.0.0.1:3998...
* Connected to 127.0.0.1 (127.0.0.1) port 3998
> GET / HTTP/1.1
> Host: 127.0.0.1:3998
> User-Agent: curl/8.7.1
> Accept: */*
>
* Request completely sent off
* Operation timed out after 5002 milliseconds with 0 bytes received
* Closing connection
curl: (28) Operation timed out after 5002 milliseconds with 0 bytes received
[curl exit 28]
### server stdout after curl:
request received: GET / 2026-09-16T04:11:18.066Z

最後の行を見てください。ハンドラは実行されています。それでも 0 bytes received で、諦めたのは curl 側です。Node が自分で打ち切ることはないのでしょうか。70秒まで延ばしました。

$ curl -m 70 ...(同じサーバ / 70秒版)コマンド出力
curl: (28) Operation timed out after 70003 milliseconds with 0 bytes received
http=000 time_total=70.003195
[curl exit 28]

70秒経っても切られません。設定の実値を出力させると、こうです。

サーバ起動時に出力した既定値(Node v24.21.0)コマンド出力
defaults: requestTimeout=300000 headersTimeout=60000 keepAliveTimeout=5000 timeout=0

headersTimeout は60秒ですが、70秒待っても効きません。ドキュメントに理由があります。

Limit the amount of time the parser will wait to receive the complete HTTP headers.

「ヘッダを完全に受信するのを待つ時間」の上限です。この実験ではヘッダは既に全部届いています(curl の Request completely sent off がその証拠)。受信の待ち時間なので効きません。

具体実物

戻り値で返す設計 — WSGI と Rack

res に書く形が唯一の作法ではありません。同じことを関数の戻り値で表す設計もあります。PEP 3333 の最小のアプリです。

PEP 3333 “The Application/Framework Side” の例(原文どおり)コマンド出力
HELLO_WORLD = b"Hello world!\n"
 
def simple_app(environ, start_response):
"""Simplest possible application object"""
status = '200 OK'
response_headers = [('Content-type', 'text/plain')]
start_response(status, response_headers)
return [HELLO_WORLD]

start_response でステータスとヘッダを渡し、本文は return [HELLO_WORLD] で返します。仕様はこう規定しています。

When called by the server, the application object must return an iterable yielding zero or more bytestrings.

本文は「0個以上のバイト列を yield するイテラブル」。Rack はより短くまとめます。

A Rack application is a Ruby object that responds to +call+. It takes exactly one argument, the +environment+ (representing an HTTP request) and returns a non-frozen +Array+ of exactly three elements: the +status+, the +headers+, and the +body+ (representing an HTTP response).

戻り値は [status, headers, body] の3要素配列。先の材料3つが、そのまま3要素になっています。

どちらの設計でも、書き出しているのはアプリではありません。 材料を RFC 9112 の形に組み上げて接続へ書き出すのはサーバ側です。

この違いが、応答を取りこぼす経路の違いになります。戻り値で返す設計では、関数が値を返せば材料は渡ります。res に書く設計では、書く呼び出しを実行しないまま抜けられてしまう。上の実機がその状態です。

✓ ここまでの確認

WSGI(PEP 3333)と Rack のアプリは、応答をどうやってサーバ側へ渡しますか。

⇄ 2つを見比べる
具体例 A

res オブジェクトに書く(Node の http)

ハンドラは `res` を受け取り、そこに書きます。戻り値は使われません。`res.end()` が完結の合図で、この呼び出しがあって初めてバイト列が書き出されます。

材料の渡し方と、完結の合図表
材料 Node の http WSGI / Rack
ステータス res.writeHead start_response の第1引数 / 配列の第1要素
ヘッダ res.setHeader / writeHead start_response の第2引数 / 配列の第2要素
本文 res.write / res.end イテラブル / 配列の第3要素
完結の合図 res.end() 関数が値を返したこと
具体例 B

戻り値で返す(WSGI と Rack)

アプリは呼び出し可能なオブジェクトで、材料を戻り値(と WSGI では `start_response`)でサーバへ渡します。書き出すのはサーバ側です。上の表の右列がその対応です。

`res` に書く設計と、戻り値で返す設計。書き方はまるで違いますが、両者が同じことをしていると言えるのはなぜでしょうか。

「この関数が終わったあと、サーバ側の手元に何があるか」を両方で考えてみてください。

抽象原理

図で見る — return から線まで

ハンドラの戻り値が、接続へ書き出されるまで
ハンドラ関数3つの材料を組み立てる
材料を渡す(res に書く / 戻り値で返す)
サーバ側のコード材料を保持する
完結の合図(res.end() / 関数が値を返す)
1本のバイト列開始行 + ヘッダ + 空行 + 本文
アプリはここから先を行えない
書き出しのシステムコールカーネルへの依頼
カーネルが引き受ける
送信バッファ
線(TCP接続)相手に届く
  • ・3段目が作られなければ、4段目から下は一切起きない
  • ・ログに残るのは1段目まで。相手が見ているのは6段目

要点は、1段目と6段目の間に4つの段があることです。両者がずれるのは異常ではなく、間のどこかが進んでいないだけです。

この章のまとめ

  • ハンドラが組み立てるのはステータス・ヘッダ・本文の3材料。線に流すのはカーネルへの依頼(第13週)。
  • 材料は RFC 9112 §2.1 の形の1本のバイト列に直列化される。この形になって初めて応答が存在する。
  • Node の res.end() は完結の合図。ドキュメントは "The method, response.end(), MUST be called on each response." と書いている。
  • 「ハンドラが実行された」と「応答が返った」は別の出来事。 res.end() を呼ばないと、ログは出るのに 0 bytes received で exit 28。70秒でも同じ。
  • headersTimeout(既定 60000)はヘッダの受信を待つ時間なので、この状況に効かない。
  • WSGI は start_response + イテラブル、Rack は [status, headers, body]。作法は違うがバイト列は同じ。

この先の学びにどう繋がるか

次回は「本文」の中身がどう作られるかを見ます。テンプレートも JSX も、結局は文字列を組み立てているだけです。

関連する既存クイズは「サーバーのしくみ V(リバースプロキシとアプリ連携)」と「サーバーのしくみ III(並行処理モデル)」です。

自分の現場で確かめる

読んだだけでは身につきません。手元の環境で実際に見てみてください。

環境:

理解度チェック

読んだ直後は「分かった」と感じやすいものです。実際に解いて確かめてください。

問題 1 / 3正解: 0

「`res.end()` を呼び忘れると Node が既定のタイムアウトで打ち切り 408 を返す」という説明を検証したい。手元の実測で確かめられたのはどれですか。

用語集(5語)
ハンドラ
応答の材料を組み立てる関数。線に流すのはハンドラではなく外側のサーバ側コード。
response.end()
Node の `http` で応答を完結させるメソッド。すべての応答で呼ばれねばならない(MUST)。
WSGI (PEP 3333)
Python の取り決め。アプリは `(environ, start_response)` を受け、本文はバイト列を yield するイテラブルとして返す。
Rack
Ruby の取り決め。アプリは `call` に応答し、ステータス・ヘッダ・本文の3要素配列を返す。
headersTimeout / requestTimeout
Node の `http.Server` の設定。どちらも受信の待ち時間の上限で、既定は 60000 / 300000。