包丁一本さらしに巻いて

net/httpで作るGo APIサーバー #4

この一連の記事ではnet/httpを主軸に据え、取替可能な部品となるライブラリを利用してAPIサーバーを作成する方法を紹介する。

上記3つの記事でnet/http単体でもある程度の機能を満たせる形でAPIサーバーを構築できる事を示した。但し、現代的なAPIサーバーを構築する上で必要になる部分が幾つか欠けている。今回の記事では欠けているポイントのうちの一つであるHTTPルーターの話を書く。

HTTPルーター

ここで言うHTTPルーターとは以下の事ができるものである事とする。

  • HTTP Method+URLとhttp.Handlerの紐付けができ、クライアントからのHTTPリクエストに対して適切な処理をディスパッチできる
  • URLの一部として含まれるパラメタをパースしhttp.Handler側に渡す機能を持っている

context導入以前

上記のような事ができるHTTPルーターはいくつかあるが、もう一つ観点として「標準ライブラリと組み合わせて使えるか」というものがある。例えば、Goの標準ライブラリとしてcontextが導入される以前はx/net/contextを組み込んだHTTPルーターがそれなりにあった。同様にx/net/contextは使わずに独自構造体でリクエストスコープな値を管理するライブラリもある。自分が良いなと思っていたのは以下のライブラリ(テストヘルパー関数だけどコントリビュートしたこともある)。

xmuxは元はhttprouterというradix treeを使った高速なディスパッチを売りにするライブラリをベースにして、ServeHTTPC(context.Context, w http.ResponseWriter, r *http.Request)という独自のインターフェースを持ち、標準ライブラリとの間にはアダプタを挟んで良い感じでリクエストスコープの値をhttp.Handler的なインターフェースであるxhandler.HandlerCまで渡してくれる。詳細は以下の作者ブログが詳しい。

ただしこの類の独自インターフェース拡張方式、独自リクエストスコープな値用の構造体を持つ方式はcontextが標準ライブラリであるhttp.Requestに追加された後にはもはや役目を終えている感がある。contextに関しては誤解が多い領域なので予め明記しておくが、「goroutineのキャンセルをうまいことやる」という事を主眼に置いた仕組みであり「どんな値もぶち込める便利な場所」ではない。以下の議論/記事をよく読んで、それでもリクエストスコープな値、複数のgoroutineに渡していきたい値を精査した後に持たせる値を決めるのが良いと思う。

ちなみにhttprouterも「V2ではcontext対応するぜ!」と言っているがこのPRが一向にマージされないので自分は使うのを諦めた経緯がある。

context導入以後

今使っているのは以下のライブラリ。

ビルドタグを使ってGo 1.6以前は独自構造体を使う、Go 1.7以降は標準ライブラリのhttp.Requestの中に入っているcontext.Contextを使う、という形になっている。

以下サンプルコード。

	// for gorilla/mux
	router := mux.NewRouter()
	r := router.PathPrefix("/api").Subrouter()
	r.Methods("GET").Path("/hello").Handler(chain.Then(AppHandler{h: app.Greeting}))
	r.Methods("GET").Path("/hello/staticName").Handler(publicChain.Then(AppHandler{h: app.Greeting}))
	r.Methods("GET").Path("/hello/{name}").Handler(chain.Then(AppHandler{h: app.GreetingWithName}))

	if err := http.ListenAndServe(":8080", router); err != nil {
		log.Fatal(err)
	}

PathPrefixを使ってサブルーターも作る事ができるので何度も同じURLを書かなくて良くなり、間違いが減る。また、URLの中で{name}の用にパラメタを設定でき、http.Handlerの中で以下のようにして取得することができる。注意点1として、mux.Varsで取得できるURLパラメタはmap[string]stringなのでその後の処理に応じて適切な型にキャストして利用する必要がある。

// GreetingWithName greeting with name
func (app *App) GreetingWithName(w http.ResponseWriter, r *http.Request) (int, interface{}, error) {
	val := mux.Vars(r)
	res, err := HelloService(r.Context(), val["name"], time.Now())
	if err != nil {
		app.Logger.Printf("error: %s", err)
		e := ErrorResponse{
			Code:    http.StatusInternalServerError,
			Message: "something went wrong",
		}
		return http.StatusInternalServerError, e, err
	}
	app.Logger.Printf("ok: %v", res)
	return http.StatusOK, res, nil
}

注意点2としては、URLパラメタを含むURLと同じ階層で静的なURLを使いたい場合、静的なURLを先に登録する必要がある、という事。ちなみにhttprouterは現時点でURLパラメタと同じ階層に静的なURLを登録できない(これもhttprouter使うのを諦めた理由の一つではある)。

    // /hello/staticNameが先、/hello/{name}は後
	r.Methods("GET").Path("/hello/staticName").Handler(publicChain.Then(AppHandler{h: app.Greeting}))
	r.Methods("GET").Path("/hello/{name}").Handler(chain.Then(AppHandler{h: app.GreetingWithName}))

テストを書く

これが若干悩ましい。何が悩ましいかというと「muxの中でURLパラメタをパースしてcontext.Contextに値をセットするAPIが開発者には公開されていない」という事。パッケージ作成者としてはgorilla/muxが管理するcontext.Contextのキーを外部に公開しないというのは非常に分かるし、推奨される方法だ(意図せず同一のキーで上書きされるのを防ぐため)。しかしこのIssueで提案されているコードはやっぱり冗長だし、何よりテストコードの中に本質的でないURLとhttp.Handlerを書かなければいけないのが良くないのではと思う。URL書き間違えたらテストの意味が無いし、URLのルーティング含めたテストはhttp.Handler単体のテスト(アプリケーション固有ロジックのテスト)とは別途書かれるべきなのではないか、という思いがある。まだ正直この部分は悩み中。

    r := mux.NewRouter()
    r.HandleFunc("/hello/{name}", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintln(w, "Hello, client")
    }))

    ts := httptest.NewServer(r)
    defer ts.Close()

    // Table driven test
    names := []string{"kate", "matt", "emma"}
    for _, name := range names {
        url := ts.URL + "/hello/" + name
        resp, err := http.Get(url)
        if err != nil {
            t.Fatal(err)
        }

        if status := resp.Code; status != http.StatusOK {
            t.Fatalf("wrong status code: got %d want %d", status, http.StatusOK)
        }
    }

一応自分でも「context.Contextに値をセットできるようなPublic Test APIを作成したら良いのでは?」とこのIssueで提案してみたが、作者は上記の方法には興味無さそう(もう少しPushしてみるのはありかもしれない)。

なのでこんな感じのPublic Test APIを付けたforkを現在は利用している。

これを使うとどのように書けるかというと、以下。

func TestGreetingWithName(t *testing.T) {
	app := testNewApp(t)
	data := []struct {
		Name            string
		ExpectedMessage string
		ExpectedName    string
	}{
		{Name: "achiku", ExpectedMessage: "hello", ExpectedName: "achiku"},
		{Name: "moqada", ExpectedMessage: "sup", ExpectedName: "my man"},
	}
	for _, d := range data {
		req := httptest.NewRequest(http.MethodGet, "/api/hello/"+d.Name, nil)
		r := mux.TestSetURLParam(req, map[string]string{"name": d.Name})
		status, res, err := app.GreetingWithName(httptest.NewRecorder(), r)
        ...
}

このようにmux.TestSetURLParam(*http.Request, map[string]string)を使うことでhttp.Requestの中のcontext.Contextにテスト用の値を注入してテストを流せる。まぁ書いてみて思ったけどこれもまだhttp.Requestを作る時にURL書いてるし微妙な気がしてきた。この辺みんなどうやってテストしてるのか聞いてみたい。

合わせて読みたい