2024/4/24

net/httpでRESTを書く際に思うこと

TechGo

背景

Goを触りはじめておおよそ半年以上経ちました。

業務ではKotlinをメインにバックエンドの実装をしてきましたが、 もう少しライトな開発体験が欲しいという背景からGoでREST APIを遊びがてら書いてみた感想、思うことを書きなぐっておこうかなと思います。 net/httpのサーバサイドのみを対象にしています。

net/httpだけである程度は十分

Kotlinなど、つまりJVMで開発しているとREST APIを作るとなれば何かしらのFWを利用するのがデファクトスタンダードなのかなと思っています。 特にSpring Bootなんかがその筆頭で、その他にもQurakusやMicronaut, ピュアKotlinであればKtorなどが利用されているのかなと思います。

開発のためには何かしらのFWを選定することから始まって(結局SpringBootを選択するのですが)、その上でFWでどうやってやりたいことを実現するか考える。 FWの思想に乗ったアプリケーションであれば良いが、実際の業務ではいつのまにかFWの思想からズレていき...あれFW使っているのにめんどくさくないか?みたいな状況が過去ありました。

その反面net/httpというかGoを使ってREST APIを作っていると、必要なものは揃っているし、私の経験上では十分でもあるなってのが正直な感想です。(普段はマイクロサービスにおけるREST、gRPCとか作っています) ちなみにGo製のFWで言えばgin-gonic/ginやルーターライブラリーであるgo-chi/chi, gorilla/muxなどは軽く触ってきましたが、それを使ったうえでもGo 1.22以降であればnet/httpでいいかなって思っています。

Go 1.22

Goが<1.22であればおそらくnet/httpだけでRESTを作るという考えは私にはありませんでした。 主な理由としては下記です。

  • HTTPメソッドが指定できない
  • パターンにワイルドカードが指定できない
  • カスタムの404,405が返せない

そのためnet/httpを勉強した上でchiを遊びでは使っていました。 1.21時代に業務で書いたREST APIはgorilla/muxを使っていました。

ただ>=1.22以降であれば上2つの問題は対応されました。 もちろんchiやmuxにはより親切な機能がありますが、 個人的にはなくてもいいかなってのが正直なところです。 なので個人的に残る問題というのは3つ目だけです。

404と405

net/httpのIF上ではカスタムの404,405用のHandlerを指定することは不可能だと思います。 仮にカスタムの404、405を返すとなれば下記のようになると思います。

1package main
2
3import (
4 "bytes"
5 "net/http"
6 "net/http/httptest"
7 "testing"
8)
9
10type wrapResponseWriter struct {
11 http.ResponseWriter
12 code int
13}
14
15func (w *wrapResponseWriter) Unwrap() http.ResponseWriter {
16 return w.ResponseWriter
17}
18
19func (w *wrapResponseWriter) WriteHeader(statusCode int) {
20 w.code = statusCode
21}
22
23func (w *wrapResponseWriter) Write(data []byte) (int, error) {
24 w.ResponseWriter.Header().Set("Content-Type", "application/json")
25 w.ResponseWriter.WriteHeader(w.code)
26 switch w.code {
27 case 404:
28 return w.ResponseWriter.Write([]byte(`{"msg":"resource not found"}`))
29 case 405:
30 return w.ResponseWriter.Write([]byte(`{"msg":"method not allowed"}`))
31 default:
32 return w.ResponseWriter.Write(data)
33 }
34}
35
36var wrap = func(next http.Handler) http.Handler {
37 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
38 ww := wrapResponseWriter{ResponseWriter: w}
39 next.ServeHTTP(&ww, r)
40 })
41}
42
43func Test404(t *testing.T) {
44 mux := wrap(http.NewServeMux())
45 w := httptest.NewRecorder()
46 r := httptest.NewRequest("GET", "https://example.com/foo", nil)
47
48 mux.ServeHTTP(w, r)
49
50 if w.Code != 404 {
51 t.Error("status code is not 404")
52 }
53 if !bytes.Equal(w.Body.Bytes(), []byte(`{"msg":"resource not found"}`)) {
54 t.Error("body is unmatched")
55 }
56 if w.Header().Get("Content-Type") != "application/json" {
57 t.Error("content type is not application/json")
58 }
59 t.Logf("got response is %v", w)
60}
1package main
2
3import (
4 "bytes"
5 "net/http"
6 "net/http/httptest"
7 "testing"
8)
9
10type wrapResponseWriter struct {
11 http.ResponseWriter
12 code int
13}
14
15func (w *wrapResponseWriter) Unwrap() http.ResponseWriter {
16 return w.ResponseWriter
17}
18
19func (w *wrapResponseWriter) WriteHeader(statusCode int) {
20 w.code = statusCode
21}
22
23func (w *wrapResponseWriter) Write(data []byte) (int, error) {
24 w.ResponseWriter.Header().Set("Content-Type", "application/json")
25 w.ResponseWriter.WriteHeader(w.code)
26 switch w.code {
27 case 404:
28 return w.ResponseWriter.Write([]byte(`{"msg":"resource not found"}`))
29 case 405:
30 return w.ResponseWriter.Write([]byte(`{"msg":"method not allowed"}`))
31 default:
32 return w.ResponseWriter.Write(data)
33 }
34}
35
36var wrap = func(next http.Handler) http.Handler {
37 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
38 ww := wrapResponseWriter{ResponseWriter: w}
39 next.ServeHTTP(&ww, r)
40 })
41}
42
43func Test404(t *testing.T) {
44 mux := wrap(http.NewServeMux())
45 w := httptest.NewRecorder()
46 r := httptest.NewRequest("GET", "https://example.com/foo", nil)
47
48 mux.ServeHTTP(w, r)
49
50 if w.Code != 404 {
51 t.Error("status code is not 404")
52 }
53 if !bytes.Equal(w.Body.Bytes(), []byte(`{"msg":"resource not found"}`)) {
54 t.Error("body is unmatched")
55 }
56 if w.Header().Get("Content-Type") != "application/json" {
57 t.Error("content type is not application/json")
58 }
59 t.Logf("got response is %v", w)
60}

ResponseWriteをラップするMiddlewareをグローバルに設定し、 ラップしたResponseWriterでレスポンスを独自のBodyで書いています。 また自前のHandlerに対してはアンラップするようなMiddlewareを通すようにしておけば良いと思います。

ここらへんの内容は1.22でリリースされていたServeMuxのProposalに書いてありました。 どうやらResponseWriterをラップするのが一般的らしいです。

How would you customise the 405 response? For example, for API servers that need to always return JSON responses. The 404 handler is sort of easy to override with a catch-all / pattern. But for 405 it's harder. You'd have to wrap the ResponseWriter in something that recorded the status code and add some middleware that checked that -- quite tricky to get right. https://github.com/golang/go/issues/61410#issuecomment-1641072070

Again, no good answer to how to customize the 405. You could write a method-less pattern for each pattern that had a method, but that's not very satisfactory. But really, this is a general problem with the Handler signature: you can't find the status code without writing a ResponseWriter wrapper. That's probably pretty common—I know I've written one or two—so maybe it's not a big deal to have to use it to customize the 405 response. https://github.com/golang/go/issues/61410#issuecomment-1641985563

ただ別Issueのjba-sanのコメントを見て私は別に404、405はカスタマイズできなくてもよいかなと思うようになりました。(対応としてもあっているのか自信もないですし)

I'm aware of one use case for serving a custom 404 to a computer: if you want your server to return only JSON. But that's probably unrealistic and also hopeless with the existing net/http package, because there are many places in the code that send a text response. https://github.com/golang/go/issues/65648#issuecomment-1955328807

net/httpをベースとして使うとしたら結局イレギュラーにでもtext応答が紛れ込むことはあるので、 であればOpenAPIを用意してクライアントコードは生成・利用してもらい、そもそも404や405が出るのを技術で避けてもらうほうが良いと思っています。 あとは405を諦めて/で404用のHandlerを登録するのが良いかなとも思っています。

依存関係は少ないほうが嬉しい

別にnet/httpだろうとGin,chi,muxなど好きなものを利用するのが一番だと思います。 ただそれでもGoの標準パッケージのみに依存する開発が楽かもなと思っています。

経験上ライブラリーやFWのアップデートへの追従は少なからずリソースを割いてきましたが、 Goの標準パッケージだけに依存するのであればGoのリリースサイクルに依存するだけです。 それにGo1.xは後方互換性がありますしね。 実際のアプリケーションはGoの標準パッケージのみだけで開発するなど現実的ではないですが、 それでも少ない依存関係でアプリケーションのメンテナンス性を向上させるという観点も重要な要素になると思います。

まとめ

あくまで個人的なnet/httpに思っていること書いてきただけですが私の経験論的範疇においては

  • net/httpで十分(たぶん)
  • もしめんどうなことがあれば好きなライブラリーをいれるし、なんだったらchiぐらいなら移行は簡単そう
  • net/httpだけでRESTを書くならカスタムの404,405は諦め、さらにはAll Catchパターンを使って405も諦める。

こんなかんじです。