HTTPサーバ
実装:
network/httpserver// 実行:go test ./network/httpserver/
Express も Rails も Go の net/http も、下でやっていることは同じだ。届いた「ただのテキスト」を読んで、テキストを返す。テキストを欠けず順番どおり運ぶ仕事は、下の層(TCP)が請け負ってくれる。この章では生のバイト列を受け取り、HTTP リクエストを手でパースして、レスポンスを手で組む。フレームワークが隠しているものの正体が、200行ほどで丸見えになる。
この章で作るもの
net.Listen("tcp") で待ち受けて、ブラウザや curl から来た HTTP を自分でパースし、 ルーティングして、レスポンスを返す。Go の net/http に頼らず、その中身を作る。
押さえることは3つある。
- HTTP は TCP の上を流れる人間可読なテキスト。特別なバイナリではない
- リクエストの形は「リクエストライン → ヘッダ群 → 空行 → ボディ」の4部構成
- サーバの本体は Accept ループ。接続を得て、1接続ごとに goroutine で捌く
前提: HTTP は何の上に乗っているか
ネットワークは層(レイヤ)でできている。下の層が上の層に「サービス」を提供する。
TCP が「信頼できるバイトストリーム」を用意してくれる。相手と接続を確立し (3-way handshake)、送ったバイトが順序通り・欠落なく届くことを保証する。 その上に乗る HTTP は、だから「届いたテキストを読んで、テキストを返す」だけでいい。 この章は TCP そのものは Go の net に任せ(中身は TCP の章で作った)、HTTP の層を作る。
リクエストはただのテキスト
curl が GET /hello するとき、TCP の上を流れているのはこういうテキスト:
GET /hello?name=world HTTP/1.1\r\n ← リクエストライン(メソッド パス バージョン)
Host: example.com\r\n ← ヘッダ
User-Agent: curl/8.0\r\n ← ヘッダ
\r\n ← 空行(ここでヘッダ終わり)
(ボディ。GET なら無い)\r\n(改行)で区切られた、人間が読めるテキスト。これを手でパースする:
// Request は1つの HTTP リクエスト。
type Request struct {
Method string
Path string // クエリ文字列を除いたパス
Version string // "HTTP/1.1"
Headers map[string]string // ヘッダ名は Canonical 形(Content-Type など)
Body []byte
query map[string]string // ?a=1&b=2 を解釈したもの
}
// ParseRequest は1リクエスト分のバイト列を読んで Request に組み立てる。
// HTTP/1.1 の形は「リクエストライン → ヘッダ群 → 空行 → ボディ」。
func ParseRequest(r *bufio.Reader) (*Request, error) {
// 1. リクエストライン: "GET /path?q=1 HTTP/1.1"
line, err := r.ReadString('\n')
if err != nil {
return nil, fmt.Errorf("httpserver: read request line: %w", err)
}
parts := strings.Fields(strings.TrimSpace(line))
if len(parts) != 3 {
return nil, fmt.Errorf("httpserver: malformed request line %q", line)
}
req := &Request{
Method: parts[0],
Version: parts[2],
Headers: map[string]string{},
query: map[string]string{},
}
req.parseTarget(parts[1])
// 2. ヘッダ群: 空行(\r\n)まで "Key: Value" が続く。
for {
hline, err := r.ReadString('\n')
if err != nil {
return nil, fmt.Errorf("httpserver: read header: %w", err)
}
hline = strings.TrimRight(hline, "\r\n")
if hline == "" {
break // 空行 = ヘッダの終わり
}
colon := strings.IndexByte(hline, ':')
if colon < 0 {
return nil, fmt.Errorf("httpserver: malformed header %q", hline)
}
// ヘッダ名は大小無視なので Canonical 形(Content-Type)に正規化して格納。
name := textproto.CanonicalMIMEHeaderKey(strings.TrimSpace(hline[:colon]))
value := strings.TrimSpace(hline[colon+1:])
req.Headers[name] = value
}
// 3. ボディ: Content-Length があればそのバイト数だけ読む。
if cl := req.Headers["Content-Length"]; cl != "" {
n, err := strconv.Atoi(cl)
if err != nil {
return nil, fmt.Errorf("httpserver: bad Content-Length %q", cl)
}
body := make([]byte, n)
if _, err := io.ReadFull(r, body); err != nil {
return nil, fmt.Errorf("httpserver: read body: %w", err)
}
req.Body = body
}
return req, nil
}
// parseTarget は "/path?a=1&b=2" を Path とクエリに分ける。
func (req *Request) parseTarget(target string) {
if i := strings.IndexByte(target, '?'); i >= 0 {
req.Path = target[:i]
for _, pair := range strings.Split(target[i+1:], "&") {
if pair == "" {
continue
}
k, v, _ := strings.Cut(pair, "=")
req.query[k] = v
}
} else {
req.Path = target
}
}
// Query は ?key=value のクエリ値を返す(無ければ空文字)。
func (req *Request) Query(key string) string { return req.query[key] }コードの読みどころ: ボディの切れ目は Content-Length で知る
TCP はバイトストリームなので、「どこまでが1つのリクエストか」は HTTP 自身が 決めないといけない。ヘッダの後の空行まででヘッダは終わり、その先のボディは Content-Length ヘッダのバイト数だけ読む。これがないと、受け手はいつ読み終えたか 分からない(TCP はただのバイトの流れで、切れ目を教えてくれない)。
レスポンスもただのテキスト
返す側も同じ形式。ステータス行 → ヘッダ → 空行 → ボディ。
// Response は返す HTTP レスポンス。
type Response struct {
Status int
Headers map[string]string
Body []byte
}
// statusText はステータスコードに対応する短い説明。
var statusText = map[int]string{
200: "OK",
400: "Bad Request",
404: "Not Found",
500: "Internal Server Error",
}
// Write はレスポンスを HTTP テキストとして書き出す。
// "HTTP/1.1 <code> <text>" → ヘッダ群 → 空行 → ボディ、という形を手で組む。
func (resp *Response) Write(w io.Writer) error {
text := statusText[resp.Status]
if text == "" {
text = "Unknown"
}
var sb strings.Builder
fmt.Fprintf(&sb, "HTTP/1.1 %d %s\r\n", resp.Status, text)
// Content-Length はボディ長から自動で付ける(受け手が本文の切れ目を知るため)。
fmt.Fprintf(&sb, "Content-Length: %d\r\n", len(resp.Body))
for k, v := range resp.Headers {
fmt.Fprintf(&sb, "%s: %s\r\n", k, v)
}
sb.WriteString("\r\n") // ヘッダとボディの区切り
sb.Write(resp.Body)
_, err := io.WriteString(w, sb.String())
return err
}
// Text は text/plain のレスポンスを作るヘルパー。
func Text(status int, body string) *Response {
return &Response{
Status: status,
Headers: map[string]string{"Content-Type": "text/plain; charset=utf-8"},
Body: []byte(body),
}
}Content-Length を自分で計算して付けているのがポイント。受け手(ブラウザ)が 「本文をあと何バイト読めばいいか」を知るために要る。
サーバの本体: Accept ループ
// Handler はリクエストを受けてレスポンスを返す関数。
type Handler func(*Request) *Response
// Server は「メソッド + パス → ハンドラ」の対応表を持つ最小の HTTP サーバ。
type Server struct {
routes map[string]Handler // キーは "GET /hello" のような文字列
}
// NewServer は空のサーバを返す。
func NewServer() *Server {
return &Server{routes: map[string]Handler{}}
}
// Handle はメソッドとパスにハンドラを登録する。
func (s *Server) Handle(method, path string, h Handler) {
s.routes[method+" "+path] = h
}
// ListenAndServe は addr で待ち受けて Serve する。
func (s *Server) ListenAndServe(addr string) error {
ln, err := net.Listen("tcp", addr)
if err != nil {
return err
}
return s.Serve(ln)
}
// Serve は接続を受け付け続け、1接続ごとに goroutine で処理する。
// これが「サーバ」の本体: Accept でクライアントとの TCP 接続を得て、handleConn に渡す。
func (s *Server) Serve(ln net.Listener) error {
for {
conn, err := ln.Accept()
if err != nil {
return err // リスナが閉じられたら終了
}
go s.handleConn(conn)
}
}Serve の中の for { ln.Accept() } がサーバの心臓。 **Accept は「クライアントとの TCP 接続が1つできるまで待つ」**関数で、接続ができたら その conn を goroutine に渡して次の接続を待つ。だから複数のクライアントを同時に捌ける。
1接続の処理はこれだけだ。生のバイト列からパースし、ルートを引いて、書き戻す:
// handleConn は1つの TCP 接続を処理する。
// 生のバイトストリーム(conn)から HTTP リクエストをパースし、
// ルートを引いてハンドラを呼び、レスポンスを書き戻す。
// このミニ実装は1接続1リクエストで閉じる(keep-alive はしない)。
func (s *Server) handleConn(conn net.Conn) {
defer conn.Close()
req, err := ParseRequest(bufio.NewReader(conn))
if err != nil {
// パースできない = 壊れたリクエスト。400 を返す。
Text(400, "bad request").Write(conn)
return
}
h, ok := s.routes[req.Method+" "+req.Path]
if !ok {
Text(404, "not found").Write(conn)
return
}
resp := h(req)
if resp == nil {
resp = Text(500, "handler returned no response")
}
resp.Write(conn)
}試す: テキストが構造になる
リクエストのテキストを編集すると、パースされた構造(メソッド・パス・クエリ・ヘッダ・ボディ)と、 サーバが返すレスポンスのテキストが見える。壊れたリクエストを入れると 400 になる。
1. 生のリクエスト(TCP で届くテキスト)
2. パースした構造
3. 返すレスポンス
HTTP/1.1 200 OK Content-Type: text/plain Content-Length: 12 hello, world
これはブラウザ内での再現だが、Go 実装は本物の TCP で同じことをしている。 テストでは実際にポートを開き、net.Dial で生の HTTP テキストを流して確認している。 curl http://localhost:... でも叩ける本物のサーバだ。
設計の観点
- 境界はどこで決まるか: バイトの流れを1つのリクエストに切り分ける規則が、この層のほぼ全部になる。空行までがヘッダ、その先は
Content-Lengthの長さぶん、と決めておくから切れる - テキストであることの功罪: 目で読めてデバッグが楽な代わりに、切り出しに走査が要り、同じヘッダを何度も送ることになる。HTTP/2 がバイナリの枠へ移ったのは、この代償を外すため
- 状態を持たないと決める: 1リクエストを独立に扱うと、サーバを何台に増やしても同じように動く。ログイン状態のような続きものは、Cookie やトークンで外から運ぶことになる
- 受け取ったものを信じない: 長さも、ヘッダの数も、ボディの大きさも、相手が決める。上限を置かないと、正しく実装されているのに落ちる
- 接続を張り直す代償: 1リクエストごとに接続を捨てると、TCP の確立を毎回払う。keep-alive は、この1点だけのための仕組みになる
- 下の層に任せた範囲を意識する: 再送も順序も TCP が担うので、この章は「届いたバイトの列」から始められている。何を任せたかを言えると、どこを疑えばいいかが分かる
メリット・デメリットと実例
| 版 | 区切り方 | 1接続あたり | 主な狙い |
|---|---|---|---|
| HTTP/1.0 | テキスト。1往復で閉じる | 1リクエスト | 単純さ |
| HTTP/1.1(この章) | テキスト + Content-Length | keep-alive で複数(この実装は1つ) | 接続の使い回し |
| HTTP/2 | バイナリのフレーム | 多重化して同時に複数 | 待ち行列の解消、ヘッダ圧縮 |
| HTTP/3 | QUIC(UDP)の上 | 同時。接続確立も速い | TCP の行頭ブロッキング解消 |
この自作実装で得られるのは全体像で、フレームワークが隠していた処理が見えることになる。 足りないのは keep-alive、chunked encoding、タイムアウトと各種の上限、そして TLS だ。 最後の3つが無いままでは公開できない。
実例:
- Go の
net/http: この章の素朴版が、keep-alive も HTTP/2 も含めて実装されている。読める分量で実物が確かめられる - すべての Web フレームワーク: Express も Rails も Django も、この層の上に載っている
- 前に立つ部品: レートリミッタやプロキシは、この HTTP サーバの手前に置かれる
裏どり:
Content-Lengthと chunked は排他: 長さが先に分かるなら前者、分からないなら後者で「塊の長さ + 塊」を繰り返す。両方あると解釈が割れてリクエストスマグリングの温床になるので、規格は優先順位を定めている- ヘッダは繰り返しが多い: 同じ Cookie や User-Agent を毎回全部送る。HTTP/2 の HPACK が過去に送ったヘッダを表で参照するのは、この無駄を消すためになる
- 行頭ブロッキングは層で違う: HTTP/2 は HTTP のレベルで多重化したが、下の TCP は1本なのでパケットが1つ落ちると全ストリームが止まる。HTTP/3 が UDP へ降りたのは、この残った詰まりを外すためになる
- 上限は仕様でなく実装が決める: ヘッダの大きさも数も規格に上限はなく、サーバが決める。Go の
net/httpはMaxHeaderBytesを持ち、既定は 1MB になっている Hostヘッダが1.1の分岐点: これが必須になったことで、1つの IP に複数のサイトを載せられるようになった。仮想ホストも、あとのリバースプロキシによる振り分けも、この1行に乗っている
実物との距離: HTTP/2, HTTP/3
この章の HTTP/1.1 は「1接続で1リクエスト、テキストで」だった。実物はさらに進んでいる。
- HTTP/2: 1つの TCP 接続で複数リクエストを同時に運ぶ。テキストをやめてバイナリの枠(フレーム)で区切る。HTTP/2 の章で作る
- HTTP/3: TCP をやめて UDP ベースの QUIC に載せ替え、接続確立を速くした
- TLS: 全部を暗号化する層。TLS ハンドシェイクの章で中身を作る
簡略化したこと
- keep-alive なし: 1接続1リクエストで閉じる
- chunked transfer なし: ボディは Content-Length 必須
- ルーティングは完全一致: パスパラメータ(
/users/:id)やワイルドカードなし - TCP は net 任せ: 3-way handshake や再送は Go が担う。その中身は TCP の章で作った
参考資料
- RFC 9110/9112 — HTTP/1.1 メッセージ形式の規格
- Go net/http のソース — 実物のリクエストパーサ
- Beej's Guide to Network Programming — ソケットプログラミングの定番入門