Unengineered Weblog

PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND

net/http.HandlerFunc において冗長になりがちな JSON のデコードを華麗に書く!

愚直に書くとデコードが長ったらしくなる

net/http.HandlerFunc でリクエストとレスポンスを JSON でやり取りする REST API やそれに似たハンドラーを書くとき、JSON の変換を愚直に書くと次のようになる。

// createUser は `POST /user` のハンドラー。 req.Body の JSON より新しいユーザーを作成して、
// レスポンスとして作成したユーザーの JSON を返す。
func createUser(w http.ResponseWriter, req *http.Request) {
    // ↓↓↓ここからリクエストの JSON のデコード↓↓↓
    reqBody, err := io.ReadAll(req.Body)
    if err != nil {
        http.Error(w, err.Error(), 400)
        return
    }
    var user *userctl.createUserParams
    err = json.Unmarshal(reqBody, &user)
    if err != nil {
        http.Error(w, err.Error(), 400)
        return
    }
    // ↑↑↑ここまでリクエストの JSON のデコード↑↑↑
    res, err := userctl.CreateUser(req.Context(), user)
    if err != nil {
        http.Error(w, err.Error(), 500)
        return
    }
    // ↓↓↓ここからレスポンスの JSON のエンコード↓↓↓
    resBody, err := json.Marshal(res)
    if err != nil {
        http.Error(w, err.Error(), 500)
        return
    }
    // ↑↑↑ここまでレスポンスの JSON のエンコード↑↑↑
    w.WriteHeader(200)
    w.Write(resBody)
}

このコードのキモは userctl.CreateUser だけで他の部分は JSONエンコードデコードである。長ったらしいいし、 JSON のデコードエンコード決まりきっているのだから共通化したい。

私なら createUser をこうやって書く

// createUser は `POST /user` のハンドラー。user から新しいユーザーを作成して、
// レスポンスとして作成したユーザーの JSON を返す。
func createUser(w http.ResponseWriter, req *http.Request, user *userctl.createUserParams) {
    res, err := userctl.CreateUser(req.Context(), user)
    if err != nil {
        http.Error(w, err.Error(), 500)
        return
    }
    xhttp.WriteJSON(w, 200, res)
}

これならスッキリして、冗長な部分がほとんどない。ただこの createUserhttp.HandlerFunc ではないので、 http.Handler へ変換する関数を別で用意する。関数の実装はこの記事の最後に載せた。

// xhttp は net/http へのユーティリティパッケージ。
package xhttp

// JSONHandlerFunc は [http.HandlerFunc] と似ているが、req.Body を JSON として
// パースした値 reqbody を引数に追加した関数。すでに req.Body はすでに消費されている。
type JSONHandlerFunc[T any] func(w http.ResponseWriter, req *http.Request, reqbody T)

// JSONHandler は JSONHandlerFunc を http.Handler へ変換する。h は req.Body の読み取りに
// non-nil エラーが返ってきたときや JSON へのパースが失敗したときは f を呼ばず、
// レスポンスとして 400 を返す。
func JSONHandler[T any](f JSONHandlerFunc[T]) (h http.Handler)

// WriteJSON は resbody を JSON へエンコードして指定した HTTP code のレスポンスとして返す。
// JSON へのエンコードが失敗したときは 500 レスポンスを返す。
func WriteJSON(w http.ResponseWriter, code int, resbody any)

この xhttp.JSONHandler を用いると createUser は次のように Server へハンドラーを登録できる。

http.Handle("POST /user", xhttp.JSONHandler(createUser))

ほとんど冗長に感じることはないのではないか。

どうして JSONHandlerFunc はこんな形?

JSON のデコードを短くするには他の方法も色々あるだろう。ただこの方法は次の点で優れている。

JSONHandler の実装が短いこと。

この記事の最後に付録として JSONHandler の実装例を載せた。これは20行だ。 この程度なら初見でもすぐ理解できるだろうし、メンテンナスもそんなに大変ではないだろう。

もし JSONHandler のようなユーティリティ関数の実装が長くなってしまうなら、自分で作らずにサードパーティライブラリを使うべきだろう。ただサードパーティライブラリを使うと依存ライブラリの更新などめんどくさい点も多いので、やたらめったらサードパーティを導入するわけにもいかない。

サードパーティを使わず、でもさくっと自分で管理できるようユーティリティ関数は短く書けることに気を使うべきだろう。

JSONHandlerFunc は net/http.HandlerFunc と使い心地が似ていること。

JSONHandlerFuncHandlerFunc に3つ目の引数が追加されている以外は同じである。だから HandlerFunc の知見はほとんど JSONHandlerFunc に適用可能だ。

もし JSONHandlerFuncHandlerFunc と全然違う形だったら、例えば「リクエストの JSON のみを引数でうけて、返り値をレスポンスの JSON として返す」の形だったらどうなるだろうか。

? func createUser(ctx context.Context, user *userctl.createUserParams) (any, err) {
?     return userctl.CreateUser(req.Context(), user)
? }

これは短く書けているように見えるが、「リクエストヘッダーをどうやって見る?」「レスポンスコードはどうやって指定する?」「レスポンスが JSON ではないときどうする?」のときどう対処しよう。Context にリクエスト情報を含めるなど脱出ハッチを考えることはできるだろう。でもその「脱出ハッチ」のためにドキュメントがやたら長ったらしくなるし、ユーザーは脱出ハッチの方法を学ばないといけない。

net/http のユーティリティ関数を作るときは net/http の既存の関数と形や使い心地を似せること、 http.ResponseWriterhttp.Requst をユーティリティ関数の内部に隠さないこと、が教訓となる。

付録: コードの txtar