なまえは まだ ない

思いついたことをアウトプットします

GoでHTTPサーバーを立ち上げる際のいくつかの注意点

前置き

フラーではサーバーサイドエンジニアの共通言語としてGoを採用しており、その最も代表的な開発対象はWeb API、つまりHTTPサーバーになります。 弊社に新しくジョインしてくれたメンバーには、入社時にチュートリアル的な課題をいくつかやってもらっているのですが、その課題も基本的にはWeb APIの開発となります。

Goのnet/httpパッケージはちょっと癖があると言いますか、いくつか気をつけて実装しないといけない部分があります。 どれも公式パッケージのドキュメントを読めば書いてある話ですが、せっかくなのでいくつか実例を混じえて整理してみました。

気をつけるポイント

ResponseWriterのメソッドには呼び出せる順番がある

http.ResponseWriter にはレスポンスを構成するための以下3つのメソッドが宣言されています。

  1. WriteHeader()ステータスコードを設定し、レスポンスヘッダーを送信する
  2. Header() … レスポンスヘッダーを取得、操作する
  3. Write() … レスポンスボディを書き込む

実はこのメソッドの呼出順には決まりがあって、順番を守らずに呼び出されたメソッドは無視されてしまいます。 具体的には次の順番です。

  1. Header() … レスポンスヘッダーを取得、操作する
  2. WriteHeader()ステータスコードを設定し、レスポンスヘッダーを送信する
  3. Write() … レスポンスボディを書き込む

まずはレスポンスヘッダーの設定です。net/httpパッケージのドキュメントには、レスポンスヘッダーは WriteHeader() が呼ばれたタイミングでクライアントに送信されると記載されています。

Header returns the header map that will be sent by WriteHeader. The Header map also is the mechanism with which Handlers can set HTTP trailers. Changing the header map after a call to WriteHeader (or Write) has no effect unless the modified headers are trailers.

なので、例えば次のように WriteHeader() 呼び出しより後にヘッダーを追加しても無視されてしまいます。

func handle(rw http.ResponseWriter, r *http.Request) {
    rw.WriteHeader(http.StatusBadRequest) // ここでもうヘッダーは送信されてしまう
    rw.Header().Add("Cache-Control", "no-cache") // このヘッダーはクライアントに送信されない
    ...
}

次にレスポンスヘッダーの送信です。 WriteHeader() を呼び出すことで、サーバーはクライアントへのレスポンスを開始します。 なお、このメソッドを呼び出さずに Write() を呼び出した場合は、暗黙的に WriteHeader(http.StatusOK) が呼び出され、正常レスポンスと見なされます。 ドキュメントにも次のような記載があります。

If WriteHeader is not called explicitly, the first call to Write will trigger an implicit WriteHeader(http.StatusOK). Thus explicit calls to WriteHeader are mainly used to send error codes.

最後にレスポンスボディの書き込みです。先にも述べたとおり、 WriteHeader() 呼び出し前に Write() を呼び出した場合、暗黙的に WriteHeader(http.StatusOK) を呼び出します。 なので、例えば以下のようなコードを書いてもレスポンスは OK (200) となります。

func handle(rw http.ResponseWriter, r *http.Request) {
    rw.Write([]byte("oh no..."))
    rw.WriteHeader(http.StatusNotFound)
}

ステータスコードは一度しか指定できない

私も長いこと勘違いしていたのですが、 http.ResponseWriter.WriteHeader()ステータスコードを設定するメソッドではなく、レスポンスヘッダーを送信するメソッドです。 ですので、一回のリクエストにつき呼び出して良い WriteHeader() は一回だけという制約があります。例えば以下のように複数回呼び出したとしても、二回目以降は無視されてしまいます。

func handle(rw http.ResponseWriter, r *http.Request) {
    rw.WriteHeader(http.StatusBadRequest)
    rw.WriteHeader(http.StatusNotFound) // この呼び出しは無効となり、クライアントには BadRequest を返す
    rw.Write(...)
}

net/httpパッケージのドキュメントにおいても、この制約はきちんと明記されています。

Only one header may be written.

レスポンスボディを書き込んでる間のエラーはハンドリングしても手遅れ

CodeReviewComments - golang/go Wikiでも言及されているとおり、 受け取ったエラーは基本的にハンドリングして何かしらの対処をすべきです。これを正直に受け取ると、次のようなコードを書きたくなります。

func handle(rw http.ResponseWriter, r *http.Request) {
    if _, err := rw.Write([]byte("hello world")); err != nil {
        http.Error(rw, err.Error(), http.StatusInternalServerError)
    }
}

ここまでの話を咀嚼できた人ならわかると思いますが、この例のエラーハンドリングで http.Error() を呼び出してもあまり意味がありません。 なぜならこのエラーの原因となった rw.Write() によって(暗黙的な)ステータスコードの書き込みが行われしまっているからです。 コードの例では http.StatusInternalServerError を指定していますが、これは無視されてしまいます。

次のようなコード例を見てみましょう。

package main

import (
    "errors"
    "log"
    "net/http"
)

type FailWriter struct {
    rw http.ResponseWriter
}

func (w *FailWriter) Write(p []byte) (n int, err error) {
    w.rw.Write(p[:0]) // エラーハンドングしてないけど、検証のためなので許して
    return 0, errors.New("unexpected error")
}

func main() {
    http.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) {
        fw := &FailWriter{
            rw: rw,
        }

        if _, err := fw.Write([]byte("hello world")); err != nil {
            http.Error(rw, err.Error(), http.StatusInternalServerError)
        }
    })
    log.Fatal(http.ListenAndServe(":8080", nil))
}

検証のために、 FailWriter という http.ResponseWriter をラップした構造体を用意しています。 FailWriter.Write() メソッドは http.ResponseWriter.Write() を呼び出しますが1バイトも書き込むことなく失敗します。 その結果として、目的のエラーハンドリング箇所に到達することになります。

このサーバーを起動してリクエストをかけてみると次のような結果になります。

$ curl -v "http://127.0.0.1:8080"
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to 127.0.0.1 (127.0.0.1) port 8080 (#0)
> GET / HTTP/1.1
> Host: 127.0.0.1:8080
> User-Agent: curl/7.64.1
> Accept: */*
> 
< HTTP/1.1 200 OK
< Date: Sun, 29 Aug 2021 06:21:59 GMT
< Content-Length: 28
< Content-Type: text/plain; charset=utf-8
< 
unexpected error
* Connection #0 to host 127.0.0.1 left intact
* Closing connection 0

ステータスコードとして200が返ってきました。これは先にも述べたとおり、 rw.WriteHeader() 呼び出し前に rw.Write() を呼び出すと、暗黙的に rw.WriteHeader(http.StatusOK) が呼ばれてしまうからです。 一方でレスポンスボディにはエラーメッセージが書き込まれてしまいました。

厄介なことに、クライアント側としてはステータスコードとして200が返ってきてるので、リクエストが正常に処理されたものとして解釈してしまいます。 これに関してはもはやどうしようもないので、クライアント側で頑張ってもらいましょう。

サーバーサイドでは、せめて次のようにロギングをしてあげるのが無難なところかなと思います。

func handle(rw http.ResponseWriter, r *http.Request) {
    if _, err := rw.Write([]byte("hello world")); err != nil {
        log.Printf("failed to write to response body: %s", err.Error())
    }
}

(オマケ) Request.Body は閉じなくて良い

初学者で頑張って調査をしてくれた人の中には、 *http.Request にも Body があり、それが io.ReadCloser インターフェースを実装していることに気付いてくれる人がいます。 これを受けて、ファイル操作をするときのように次のようなコードを書く場合があります。

func handle(rw http.ResponseWriter, r *http.Request) {
    defer r.Body.Close()
    ...
}

この一文、とても丁寧なのですが実は不要だったりします。 答えはまたしてもnet/httpパッケージのドキュメントにあります。

For server requests, the Request Body is always non-nil but will return EOF immediately when no body is present. The Server will close the request body. The ServeHTTP Handler does not need to.

リクエストボディはサーバーが自動的に閉じてくれるため、ハンドラー内で閉じる処理を入れなくて良いそうです。

まとめ

Goの net/http パッケージを(特にサーバー利用で)利用する上で気をつけるべきポイントをいくつか挙げました。

net/http パッケージは、 *http.Request をサーバーを構築するときとクライアントを構築するときとで共通化してしまっているなど、いくつか初学者を混乱させる要素を持っています。 今絶賛開発が進められているGo 2では net/http パッケージの再デザインも提案の一部に入ってた……とどこかで聞いた記憶があるのですが、あの話どうなったんだろう。

……( ゚д゚)ハッ!

冒頭にも触れましたが、弊社サーバーサイドはGoで開発をしてます! Goエンジニアの皆様、ご応募お待ちしております!

herp.careers

参考文献