net/httpのhandlerを書く時に気をつけたほうが良い順序について

たまに標準ライブラリのみを使ってnet/httpのhandlerを実装することがある。その時の種々の操作で呼び出す順序を気にする必要があるものがある。それのメモ。

先に結論を言ってしまうと以下の順序で記述しましょうという話。

  1. http headerに情報を追加する
  2. http status codeを指定する
  3. response bodyにデータを書き込む

以下は詳細。

handler?

handlerというのは正確にはnet/httpで提供されているinterfaceのことを指す。多くのライブラリやフレームワークがこのインターフェイスをベースにして作られているのでこれを尊重する形式でコードを書いた方が何かと都合が良い(逆に言うとこのインターフェイスを尊重しないコードはやがて負債になることが多い)。

type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

大抵の場合はnet/http.HandlerFuncを使って関数をHandlerに持ち上げて利用することが多い。例えば以下の様に。

fn := func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "hello") }
handler := http.HandlerFunc(fn)

このhandlerの動作チェックをしたい。

handlerのテスト

handlerのテストにはnet/httptestが便利。動作チェックは以下の様なコードを書く。関数自体のチェックにはRecorderが使えるけれど。今回は実際の振る舞いをチェックしたいのでServerを動かす。このあたりは分かっている人には当たり前の話なので飛ばしても良い。

以下は200 OKを返すserverに対してrequestしてみて期待通りのresponseを返すかどうかチェックしているテストコード。

package main_test

import (
    "bytes"
    "fmt"
    "io"
    "net/http"
    "net/http/httptest"
    "strings"
    "testing"
)

// Handler :
func Handler(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusOK)
    fmt.Fprintln(w, "hello")
}

func Test(t *testing.T) {
    handler := http.HandlerFunc(Handler)
    ts := httptest.NewServer(handler)
    defer ts.Close()

    res, err := http.Get(ts.URL)
    if err != nil {
        t.Fatal(err)
    }

    if want, got := http.StatusOK, res.StatusCode; want != got {
        t.Fatalf("http status, want=%d, but got=%d", want, got)
    }

    {
        want := "hello"
        var b bytes.Buffer
        if _, err := io.Copy(&b, res.Body); err != nil {
            t.Fatal(err)
        }
        got := b.String()
        if !strings.Contains(got, want) {
            t.Errorf("body, must be including %s, but got %s", want, got)
        }
    }
}

テストを実行した結果。

PASS
ok      github.com/podhmo/example_handler/00hello   0.002s

このような形でgoのhandlerの動作チェックができる。

handlerを書く時に気をつけたほうが良い順序について

ようやく本題。以下の操作をしたい場合には順序がある。

  1. http headerに情報を追加する
  2. http status codeを指定する
  3. response bodyにデータを書き込む

それ以外の場合には無効になる。

http status code

http status codeは必ずbodyにデータを書き込む前に(Write()が呼ばれる前に)指定する必要がある。

以下はstatusコードを上手く指定できていない例(これは誤った順序のコードになるdiff)。

diff -ur example_handler/00hello/main_test.go example_handler/01ngstatus/main_test.go
--- example_handler/00hello/main_test.go  2019-01-26 14:39:10.583977492 +0900
+++ example_handler/01ngstatus/main_test.go   2019-01-26 14:46:40.767239058 +0900
@@ -12,8 +12,8 @@
 
 // Handler :
 func Handler(w http.ResponseWriter, r *http.Request) {
-  w.WriteHeader(http.StatusOK)
    fmt.Fprintln(w, "hello")
+   w.WriteHeader(http.StatusConflict)
 }
 
 func Test(t *testing.T) {
@@ -26,7 +26,7 @@
        t.Fatal(err)
    }
 
-  if want, got := http.StatusOK, res.StatusCode; want != got {
+   if want, got := http.StatusConflict, res.StatusCode; want != got {
        t.Fatalf("http status, want=%d, but got=%d", want, got)
    }

io.WriterWrite() が呼ばれたタイミングで自動的に200が指定されてしまう。実際しっかり出力を読むとgo自身が警告を出力してお知らせしてくれてたりはする(この記事を書こうとしはじめたときにはhttptest.Recorderの方でテストを書こうとしてしまっていた。そしてそういえばfakeなので実際のコードパスは通らないなーなどと思ったりしていた)。

2019/01/26 14:49:00 http: multiple response.WriteHeader calls
--- FAIL: Test (0.00s)
    main_test.go:30: http status, want=409, but got=200
FAIL
exit status 1
FAIL    github.com/podhmo/example_handler/01ngstatus    0.003s

直近でハマったこととして、goのアプリの内部でgzipに圧縮するという実装にしていた時に、そのmiddlewareがpanic後のrecovery logを出力するmiddlewareよりも中に存在してしまっていて、panic時に200が返ってしまうというようなことがあった。

(あと厳密に言えば、stream処理用のjson.NewEncoderのEncode()が処理の途中でWriteが一度成功したのちにerrorになるとなった場合などに200で壊れたresponseを返すということはあり得るかもしれない。通常はコネクションが切断されるはずなので大丈夫だとは思うけれど)

http header

http headerの付与にも同様に順序がある。ただこれはメソッドの名前を意識すれば分かることかもしれない。status codeを指定するメソッドの名前が WriteHeaderStatus() などではなく WriteHeader() というような名前である理由などを考えると、一種のコミット操作として扱われているんだな−などと後になって気づいたりする。

例えば、リダイレクトなどの操作のために、headerを追加したが、responseには含まれていないなどのようなことが起きたりする。

以下はlocation headerを上手く付与できていない例(これは誤った順序のコードになるdiff)

diff -ur example_handler/00hello/main_test.go example_handler/02ngheader/main_test.go
--- example_handler/00hello/main_test.go  2019-01-26 14:39:10.583977492 +0900
+++ example_handler/02ngheader/main_test.go   2019-01-26 14:55:38.736855261 +0900
@@ -12,7 +12,8 @@
 
 // Handler :
 func Handler(w http.ResponseWriter, r *http.Request) {
-  w.WriteHeader(http.StatusOK)
+   w.WriteHeader(http.StatusSeeOther)
+   w.Header().Add("location", "/app")
    fmt.Fprintln(w, "hello")
 }
 
@@ -26,7 +27,7 @@
        t.Fatal(err)
    }
 
-  if want, got := http.StatusOK, res.StatusCode; want != got {
+   if want, got := http.StatusSeeOther, res.StatusCode; want != got {
        t.Fatalf("http status, want=%d, but got=%d", want, got)
    }

こちらも実際に通信を行うコードならエラーにはなってくれる(httptest/Record派はハマったりする)。

$ go test
--- FAIL: Test (0.00s)
    main_test.go:27: Get http://127.0.0.1:35029: 303 response missing Location header
FAIL
exit status 1
FAIL    github.com/podhmo/example_handler/02ngheader    0.003s

これらに限らずけっこうハマりどころはあったりするので、どこかのタイミングでドキュメントを熟読したりnet/http自体のコードを読んでみたりするのも良いかもしれない(個人的にはすぐに実装の方を読む派)。

gist

gist