03.3 net/http でWebサーバーを作る — ServeMux とハンドラ¶
03.2 では「HTTPというプロトコルの解剖」を、既にあるサーバーへのリクエストを通して見ました。 このレッスンでは、自分でサーバーを作る側に回ります。
Go の標準ライブラリ net/http だけで、外部フレームワークなしに Web サーバーを組み立てられます。
このレッスンのゴール:
http.NewServeMux()で「パス・メソッドごとにハンドラを振り分けるルーター」を作れる- ハンドラ関数の基本形(
func(w http.ResponseWriter, r *http.Request))を書ける httptest.NewServerで、実際にポートへ接続して本物の HTTP リクエスト・レスポンスを確認できる- フォームデータ(
url.Values)をサーバー側で受け取れる
1. ServeMux — パスとメソッドで振り分ける「案内係」¶
http.ServeMux は、リクエストのパス(と、Go 1.22 以降はメソッドも)を見て、
どのハンドラ関数を呼ぶかを決める「ルーター」です。
mux := http.NewServeMux()
mux.HandleFunc("GET /hello", helloHandler)
ハンドラ関数は、常に次の形をしています。
func handlerName(w http.ResponseWriter, r *http.Request) {
// r からリクエストの情報を読み、w にレスポンスを書く
}
w http.ResponseWriter— レスポンス(ステータスコード・ヘッダー・ボディ)を書き込む先r *http.Request— リクエスト(メソッド・パス・ヘッダー・ボディ)を読み取る元
2. 非自明ポイント1: "GET /hello" という書き方(Go 1.22 以降の拡張ルーティング)¶
以前の Go では mux.HandleFunc("/hello", handler) のようにパスだけしか指定できませんでした。
Go 1.22 以降は、パターンの先頭に HTTP メソッドを書けるようになりました。
"GET /hello"— GET メソッドかつパスが/helloの時だけこのハンドラが呼ばれる"GET /users/{id}"—{id}はパス変数。ハンドラの中でr.PathValue("id")として取り出せる- メソッドを省略した
"/hello"は、どのメソッドでも一致する(旧来の挙動)
これにより、「同じパスでもメソッドが違えば別の処理」を、if 文で分岐せずルーターだけで表現できます。
3. 非自明ポイント2: サーバーを起動する2つの方法、GoNB で使えるのは片方だけ¶
net/http でサーバーを起動する定番は http.ListenAndServe(":8080", mux) ですが、
この関数はサーバーが止まるまで戻ってきません(ブロックする)。
GoNB のセルは「実行して結果を待つ」ため、ListenAndServe を直書きするとセルが永遠に終わりません。
代わりに、テスト・実験用に作られた httptest.NewServer(mux) を使います。
- 空いている OS のポートを自動で選んで、すぐにサーバーを起動して戻ってくる(ブロックしない)
srv.URLに"http://127.0.0.1:PORT"のようなベース URL が入る- 使い終わったら
defer srv.Close()で必ず後片付けする(ポートを解放し、内部の goroutine を止める)
サーバーの起動・リクエスト送信・Close() は、1つのセルの中で完結させます
(*http.Server をセルをまたいで生きたまま持ち越さない)。
import (
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"github.com/janpfeifer/gonb/gonbui"
)
// ErrUnanswered は、練習問題が未回答のときにプレースホルダ関数が返す特別なエラー。
var ErrUnanswered = errors.New("未回答: この関数はまだ実装されていません")
// UnansweredStr は、string を返す練習問題が未回答のときのプレースホルダ文字列。
const UnansweredStr = "UNANSWERED"
// newCourseMux は、このレッスンで使う3つのハンドラを登録したルーターを返す。
func newCourseMux() *http.ServeMux {
mux := http.NewServeMux()
// GET /hello: パス変数なし。固定のあいさつを返す。
mux.HandleFunc("GET /hello", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, "Hello, GoNB!")
})
// GET /users/{id}: パス変数 {id} を r.PathValue で取り出す。
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "user id = %s\n", id)
})
// POST /form: フォームデータをサーバー側で受け取る。
// r.ParseForm() を呼ぶと、application/x-www-form-urlencoded のボディが
// r.PostForm(url.Values 型 = map[string][]string の薄いラッパー)に格納される。
mux.HandleFunc("POST /form", func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintln(w, "invalid form")
return
}
values := r.PostForm // url.Values
name := values.Get("name")
age := values.Get("age")
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "name=%s age=%s\n", name, age)
})
return mux
}
ここがポイント¶
newCourseMuxは3本のルートを登録するだけの宣言です。まだサーバーは起動していませんPOST /formはurl.Values(r.PostForm)を直接読んでいます。HTML の<form>画面は作りません — ここで見るのは「サーバーがフォームデータをどう受け取るか」だけです/helloは GET 専用、/users/{id}も GET 専用、/formは POST 専用として登録しています。 同じパスに別メソッドでアクセスするとどうなるかは、次の実行結果で確認します
4. 実際に動かしてみる — 本物の HTTP リクエストを送る¶
サーバーを起動し、5種類のリクエストを送ります。サーバーの起動・リクエスト送信・Close() を
1つのセルにまとめるのがポイントです(非自明ポイント2。GoNB は %% 以降を1つの独立した実行として
扱うため、グローバル変数への書き込みも 同じセルの中で読み書きまで完結させる必要があります)。
結果を格納する型・ヘルパー関数・HTML表を組み立てる関数を先に宣言します。
// reqResult は1回のリクエストの結果(ラベル・ステータス・ボディ)を保持する。
type reqResult struct {
label string
status int
body string
}
// results は、送信した全リクエストの結果を溜めていくグローバルなスライス。
var results []reqResult
// doGet は GET リクエストを送り、結果を results に追加する。
func doGet(baseURL, label, path string) {
resp, err := http.Get(baseURL + path)
if err != nil {
panic(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
results = append(results, reqResult{label, resp.StatusCode, strings.TrimSpace(string(b))})
}
// doPostForm は application/x-www-form-urlencoded な POST リクエストを送り、結果を results に追加する。
func doPostForm(baseURL, label, path string, values url.Values) {
resp, err := http.PostForm(baseURL+path, values)
if err != nil {
panic(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
results = append(results, reqResult{label, resp.StatusCode, strings.TrimSpace(string(b))})
}
// resultsTable は results を HTML表に整形する(構造・一覧の可視化)。
func resultsTable() string {
var b strings.Builder
b.WriteString(`<table border="1" cellpadding="4" style="border-collapse:collapse">
<tr><th>リクエスト</th><th>ステータス</th><th>レスポンスボディ</th></tr>`)
for _, r := range results {
b.WriteString(fmt.Sprintf("<tr><td>%s</td><td>%d</td><td>%s</td></tr>", r.label, r.status, r.body))
}
b.WriteString(`</table>`)
return b.String()
}
%%
srv := httptest.NewServer(newCourseMux())
defer srv.Close()
doGet(srv.URL, "GET /hello", "/hello")
doGet(srv.URL, "GET /users/42", "/users/42")
doPostForm(srv.URL, "POST /form (name=Taro, age=30)", "/form", url.Values{"name": {"Taro"}, "age": {"30"}})
doGet(srv.URL, "GET /nope(未登録のパス)", "/nope")
doGet(srv.URL, "GET → 本来 POST 専用の /form(メソッド不一致)", "/form")
for _, r := range results {
fmt.Printf("%-40s -> %3d %s\n", r.label, r.status, r.body)
}
// 可視化: 同じセルの中で HTML表として表示する(観察点: パス・メソッドごとにハンドラが振り分けられる)。
gonbui.DisplayHTML(resultsTable())
gonbui.Sync()
GET /hello -> 200 Hello, GoNB! GET /users/42 -> 200 user id = 42 POST /form (name=Taro, age=30) -> 200 name=Taro age=30 GET /nope(未登録のパス) -> 404 404 page not found GET → 本来 POST 専用の /form(メソッド不一致) -> 405 Method Not Allowed
| リクエスト | ステータス | レスポンスボディ |
|---|---|---|
| GET /hello | 200 | Hello, GoNB! |
| GET /users/42 | 200 | user id = 42 |
| POST /form (name=Taro, age=30) | 200 | name=Taro age=30 |
| GET /nope(未登録のパス) | 404 | 404 page not found |
| GET → 本来 POST 専用の /form(メソッド不一致) | 405 | Method Not Allowed |
5. 可視化を読む¶
表を読んでください:
GET /helloとGET /users/42は、それぞれ別のハンドラが呼ばれ、別のボディを返しています (newCourseMuxの中でパスごとにハンドラを登録したからです)GET /nopeはどのパターンにも一致しないため、ルーターが自動的に 404 を返します (自分でNotFoundハンドラを書いていないのに、です)GET /formは パスは登録されているが、メソッドが違うため 405(Method Not Allowed)になります。 404(パスが無い)と 405(パスはあるがメソッドが違う)は別の意味を持つエラーです
6. 直感・類推: デパートの案内カウンター¶
http.ServeMux は、デパートの案内カウンターのようなものです。
- お客さん(リクエスト)が「◯階の△△売り場に行きたい(パス)、購入したい(メソッド)」と伝える
- 案内係(
ServeMux)は、その組み合わせに合う担当者(ハンドラ)を呼んで案内する - 「そんな売り場は存在しません」→ 404(パスそのものが登録されていない)
- 「その売り場はありますが、返品はここでは受け付けていません(購入だけ)」→ 405 (パスは存在するが、その操作=メソッドには対応していない)
案内係は「どの売り場に何があるか」だけを知っていればよく、各売り場の中身(ハンドラの実装)を
知る必要がありません。これが ServeMux にルーティングを任せる利点です — ハンドラを追加・変更しても、
ルーターの構造自体は変わりません。
練習問題 3.3: statusEcho — パス変数からレスポンスを組み立てよう¶
次の関数を実装してください。
// statusEcho は、パス変数 codeStr(例: "201")を受け取り、
// (実際に使うステータスコード, レスポンスボディ) を返す。
func statusEcho(codeStr string) (int, string)
仕様:
codeStrを整数に変換できたら、(その整数, "status=<codeStr>\n")を返す- 例:
statusEcho("201")→(201, "status=201\n")
- 例:
- 整数に変換できなければ、
(400, "invalid code\n")を返す- 例:
statusEcho("abc")→(400, "invalid code\n")
- 例:
ヒント: strconv.Atoi(codeStr) で文字列を整数に変換できます(失敗すると error が返ります)。
この関数自体は http.ResponseWriter を直接触りません — チェックセルが、この関数を
GET /status/{code} ハンドラに組み込んで、実際の HTTP リクエストとしても確認します。
// YOUR CODE HERE
// strconv.Atoi(codeStr) を使って statusEcho を実装してください。
// (未実装のままチェックセルを実行すると「未回答」と表示されます)
func statusEcho(codeStr string) (int, string) {
return 0, UnansweredStr
}
チェックのためのヘルパー¶
import "reflect"
import "fmt"
func mustEqual(got, want any, name string) {
if reflect.DeepEqual(got, want) {
fmt.Printf("✅ Passed: %s\n", name)
return
}
panic(fmt.Sprintf("❌ %s\n got = %v (%T)\n want = %v (%T)", name, got, got, want, want))
}
%%
status1, body1 := statusEcho("201")
if body1 == UnansweredStr {
fmt.Println("⚠️ 未回答: 練習問題を解いてから、このセルを再度実行してください")
} else {
mustEqual(status1, 201, `statusEcho("201") のステータス`)
mustEqual(body1, "status=201\n", `statusEcho("201") のボディ`)
status2, body2 := statusEcho("abc")
mustEqual(status2, 400, `statusEcho("abc") は 400`)
mustEqual(body2, "invalid code\n", `statusEcho("abc") のボディ`)
// 実際に ServeMux + httptest.NewServer に組み込み、本物の HTTP レスポンスとしても確認する。
checkMux := http.NewServeMux()
checkMux.HandleFunc("GET /status/{code}", func(w http.ResponseWriter, r *http.Request) {
status, body := statusEcho(r.PathValue("code"))
w.WriteHeader(status)
fmt.Fprint(w, body)
})
checkSrv := httptest.NewServer(checkMux)
defer checkSrv.Close()
resp, err := http.Get(checkSrv.URL + "/status/201")
if err != nil {
panic(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
mustEqual(resp.StatusCode, 201, "実際の HTTP レスポンスのステータスも 201")
mustEqual(strings.TrimSpace(string(b)), "status=201", "実際の HTTP レスポンスのボディも一致")
fmt.Println("🎉 すべてのチェックが通りました")
}
⚠️ 未回答: 練習問題を解いてから、このセルを再度実行してください
まとめ¶
http.NewServeMux()+"METHOD /path"パターンで、パス・メソッドごとにハンドラを振り分けられる{id}のようなパス変数はr.PathValue("id")で取り出せるhttptest.NewServerはブロックしないテスト用サーバー。1セル完結で起動・リクエスト・Close()を行う- フォームは
r.ParseForm()→r.PostForm(url.Values)で受け取る - パスが無ければ 404、パスはあるがメソッドが違えば 405 — ルーターが自動で判断する
答え合わせは 03.3-http-server-solutions.ipynb で行ってください。
次は 03.4 で「RPC(遠隔手続き呼び出し)」を学びます。