フューチャー技術ブログ

Go 1.20 HTTP ResponseController

TIGの辻です。Go 1.20 リリース連載の5本目は Core library の HTTP ResponseController のアップデートを紹介します1

何が変わるのか

  • net/http パッケージに新しく ResponseController 型が追加されます
  • 従来から存在する ResponseWriter インタフェースでは実現できなかったHTTPリクエストごとの制御が実現できるようになります
  • ResponseController 型にある SetReadDeadline()SetWriteDeadline() メソッドを利用して、リクエストごとの読み書きのデッドラインを設定できます

何が嬉しいのか

Go のユーザーとして見たときの ResponseController 型の追加による具体的な嬉しいポイントはHTTPハンドラごとに読み書きのデッドラインが設定できることです。サーバー全体ではデフォルトのデッドラインを設定しつつ、特定のハンドラのみデッドラインを伸ばす、など柔軟な設定が可能になります。

ハンドラでの使用例は以下です。

func RequestHandler(w ResponseWriter, r *Request) {
rc := http.NewResponseController(w)
// 大きなレスポンスを送信するため Server.WriteTimeout を無効化する
rc.SetWriteDeadline(time.Time{})
io.Copy(w, bigData)
}

ちなみにサーバー単位のデッドラインは http.Server 構造体の ReadTimeoutWriteTimeout フィールドから設定できます。

なぜこのAPIになったのか

ここからは、この変更に至るまでの背景の一部を、私が感じたことも含めて、記載したいと思います。

実は http.Handler がハンドラ単位で読み書きのデッドラインの設定ができない、という Issue は2016年に起票されています (#16100)。なぜこの課題の解決に7年も要したのでしょうか? この一因として後方互換性との戦いがあったと想像しています。

http.Handler は以下のような ServeHTTP() があるインタフェースです。

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

また http.ResponseWriter も以下のような Header(), Write(), WriteHeader() をもつインタフェースです。

type ResponseWriter interface {
Header() Header
Write([]byte) (int, error)
WriteHeader(statusCode int)
}

ハンドラの読み書きに関するデッドラインを設定できるAPIは HandlerResponseWriter にはありません。それでは ResponseWriter インタフェースに以下のような SetReadDeadline()SetWriteDeadline() APIを追加すればいいのではないか? と思うかもしれません。

type ResponseWriter interface {
Header() Header
Write([]byte) (int, error)
WriteHeader(int)
SetReadDeadline(time.Time) error // ★後方互換性を壊すためインターフェースに新規関数の追加はできない
SetWriteDeadline(time.Time) error // ★後方互換性を壊すためインターフェースに新規関数の追加はできない
}

しかしGo1の互換性ポリシーからこのような破壊的変更はできません。SetReadDeadline()SetWriteDeadline() を持たない型が ResponseWriter インタフェースを満たさなくなってしまいます。真野さんの contextパッケージのWithCancelCauseとCause の記事でも、このインタフェースの後方互換性問題に触れています。

それではどうするか?

インタフェースを拡張したいときに ResponseWriter インタフェースとは別のインタフェースにAPIを定義して、ResponseWriter インタフェースを実装する型に別インタフェースのメソッドを実装させる、というのが1つの手段としてあります。一例はHTTPハンドラがバッファリングしているデータをクライアントにフラッシュできる http.Flusher インタフェースです。

type Flusher interface {
Flush()
}

Goのデフォルトの HTTP/1.x と HTTP/2 の ResponseWriter の実装は Flusher もサポートしており、これは文章化されています。

これを利用すると、たとえば、アプリケーションのハンドラ内では次のように型アサーションを組み合わせながら Flush() を呼び出せます。

func hello(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello, world.")
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}

上記と同様のアプローチで

type ReadDeadliner interface {
SetReadDeadline(deadline time.Time) error
}

のようなインタフェースを定義することもできたでしょう。しかし ResponseWriter 型をラップしたミドルウェアとの相性など、使い勝手が悪いため見送られています。

議論のすえに生み出された解決策が #54136 で、リリースノートに記載されていたアプローチです。すなわち ResponseWriter 型をラップした http.ResponseController 構造体を定義する、ということです。

func NewResponseController(rw ResponseWriter) *ResponseController {
return &ResponseController{rw}
}

type ResponseController struct {
rw ResponseWriter
}

func (c *ResponseController) Flush() error { /* ... */ }
func (c *ResponseController) Hijack() (net.Conn, *bufio.ReadWriter, error) { /* ... */ }
func (c *ResponseController) SetReadDeadline(deadline time.Time) error { /* ... */ }
func (c *ResponseController) SetWriteDeadline(deadline time.Time) error { /* ... */ }

インタフェースではなく構造体を追加している利点として、将来的に *ResponseController に新しいメソッドを追加したい場合に後方互換性が壊れない、という点があるでしょう。

注意点はドキュメントを見るとわかるように NewResponseController() の引数は Handler.ServeHTTP() メソッドに渡された元の値であるか、元の ResponseWriter を返す Unwrap() メソッドを持っている必要があることです。

この意味は

  • Go のデフォルトの ResponseWriter の実装は ResponseWriter インタフェースのメソッドに加えて Flush(), Hijack(), SetReadDeadline(), SetWriteDeadline() も実装している
  • サードパーティでラップされた ResponseWriterFlush(), Hijack(), SetReadDeadline(), SetWriteDeadline() のすべてを実装する必要はなく Unwrap() でもとの ResponseWriter の値を返却すれば良い

と理解しています。ラップする場合は responsecontroller_test.go のテストケースにもあるように基本的には以下のような Unwrap() メソッドを実装することになるでしょう。

type wrapWriter struct {
ResponseWriter
}

func (w wrapWriter) Unwrap() ResponseWriter {
return w.ResponseWriter
}

Unwrap() メソッドの効果は *ResponseController の実装を見るとわかるでしょう。 Hijack() の実装は以下のようになっています。

func (c *ResponseController) Hijack() (net.Conn, *bufio.ReadWriter, error) {
rw := c.rw
for {
switch t := rw.(type) {
case Hijacker:
return t.Hijack()
case rwUnwrapper:
rw = t.Unwrap()
default:
return nil, nil, errNotSupported()
}
}
}

仮にサードパーティが実装している ResponseWriter の値が Hijacker インタフェースを実装していなくても Unwrap() でもとの ResponseWriter の値を返却していれば、その Hijack() メソッドが呼び出されます。for ループで再帰しているのがおしゃれですね2

まとめ

GoのHTTP ResponseControllerのアップデートの概要とその背景を紹介しました。後方互換性との戦いやコミュニティへの配慮が感じられたのではないでしょうか。

次は棚井さんのNew ReverseProxy Rewrite hook を動かしながら理解するです。


  1. 1.なお本文中のGoのソースバージョンは go1.20rc3 です。
  2. 2.ちなみに context パッケージでも似たような for 文で再帰している実装があります。よくある実装パターンの1つでしょう。https://github.com/golang/go/blob/b3160e8bcedb25c5266e047ada01b6f462521401/src/context/context.go#L629-L653