Goal
Go の http.RoundTripper を使用して、http.Client パッケージのリクエストの前処理・後処理を追加できるようにしてみます。
Background
HTTPクライアントを利用した実装において、リクエストやレスポンスに関連した処理を書きたいことはよくあります。
- リクエスト時にログやトレースを仕込みたい
- メソッドやendpointごとにRate Limitを設けたい
- 特定のステータスコードに対してリトライしたい
このような場合、 http.Client が持つ Transport フィールドを書き換え、リクエスト時に処理を実行してもらうことができます。
http.RoundTripper
http.Client の Transport フィールドは http.RoundTripper 型です。
http.RoundTripper は単一のHTTPトランザクションを実行する RoundTrip メソッドを定義します。
go.dev
type RoundTripper interface {
RoundTrip(*Request) (*Response, error)
}
一度HTTPクライアントで使用したい機能を http.RoundTripper 実装として用意すれば、同一の機能を複数の http.Client で利用することができます。
RoundTripperを利用した前処理・後処理の実行
http.RoundTripper をwrapして前処理・後処理を挿入できる DecorateRoundTripper を実装してみます。
DecorateRoundTripper 構造体は RoundTripper インターフェースを実装しています。
フィールドには前処理を行う before 関数と後処理を行う after 関数を持ちます。
RoundTrip メソッドは、リクエストを送信する前に before 関数を実行し、レスポンスを受信した後に after 関数を実行します。
type DecorateRoundTripper struct {
base http.RoundTripper
before func(*http.Request) error
after func(*http.Response) error
}
func (d *DecorateRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
if d.before != nil {
if err := d.before(req); err != nil {
return nil, err
}
}
resp, err := d.base.RoundTrip(req)
if err != nil {
return nil, err
}
if d.after != nil {
if err := d.after(resp); err != nil {
return nil, err
}
}
return resp, nil
使い方としては http.Client の Transport フィールドにDecorateRoundTripper 参照を渡し、wrapしたいRoundTripperを base に、追加したい前処理・後処理を before after それぞれに渡すだけです。
今回の例ではリクエスト前・リクエスト後にログを出力する処理を入れています。
var cli = http.Client{
Transport: &DecorateRoundTripper{
base: http.DefaultTransport,
before: func(r *http.Request) error {
slog.InfoContext(r.Context(), "before request")
return nil
},
after: func(r *http.Response) error {
slog.InfoContext(r.Request.Context(), "after response")
return nil
},
},
Timeout: 30,
}
http.RoundTrip を実装する上で気をつける点
http.RoundTripper のGoDocにあるように、 http.RoundTripper の実装にはいくつかの注意点があります。
goroutine safeであること
複数のgoroutineからのconcurrent accessに対して安全である必要があります。
何かの状態、例えばグローバルな変数やフィールドなどへの参照・更新がなければ問題ありませんが、それらを取り扱う場合は排他制御の必要があります。
上記の前処理・後処理の例では、ログの出力のみでフィールドなどの変更を行っていないため、問題ありません。
レスポンスを解釈しない
GoDocによると、 RoundTrip 内でのレスポンスの解釈は推奨されていません。
- レスポンスを取得した場合、レスポンスのHTTPステータスコードに関係なくerr == nilを返すこと
- nilでないerrを返す場合は、レスポンスを取得できなかった場合とすること
- リダイレクト、認証、クッキーなど、より高レベルのプロトコルの詳細を処理しようとしないこと
// RoundTrip should not attempt to interpret the response. In
// particular, RoundTrip must return err == nil if it obtained
// a response, regardless of the response's HTTP status code.
// A non-nil err should be reserved for failure to obtain a
// response. Similarly, RoundTrip should not attempt to
// handle higher-level protocol details such as redirects,
// authentication, or cookies.
上記の前処理・後処理の例では、レスポンスを読み取り、読み取った内容によって処理を分岐するなどの実装を行っていないため、問題ありません。
レスポンスの解釈について
この規約によってレスポンスの読み取りができないと思ってしまいますが、一方で `http.RoundTrip` 実装でもある `http.Transport` では、レスポンスの読み取りやその内容によって処理を変更しています。
実装は以下のようになっています。
func (t *Transport) RoundTrip(req *Request) (*Response, error) {
return t.roundTrip(req)
}
これは個人的な解釈ですが、以下のように `http.RoundTripper` としての実装と、 `http.Transport` としての実装を分けるためにこのような書き方をしているのでは、と考えています。
- `RoundTrip` メソッドは `http.RoundTrip` として、リクエストを送信しその結果のレスポンスを単に返すことを目的としており、レスポンスの中身を解釈する責任は負わない
- `roundTrip` メソッドは `http.Transport` の実装として、レスポンスを解釈する処理も行う
リクエストを修正しない
リクエストの修正も推奨されていません。
ただし、リクエストのBodyを消費してcloseすることは許可しています。
また呼び出し側は ResponseBody が閉じられるまで、リクエストを変更したり再利用することができません。
上記の前処理・後処理の例ではリクエストの変更はなく、これも問題ありません。
常にボディを閉じる
http.RoundTripper には常にリクエスト・レスポンスボディを閉じる責務があります。
上記の前処理・後処理の例ではこの責務を base に委譲しています。
DecorateRoundTripper 前処理・後処理で気をつける点
リクエスト・レスポンスボディを読み取る場合、ボディの内容を再利用可能にする
例えばリクエストボディの内容を一度 before で読み取ってしまうと、base.RoundTrip 内でリクエストボディを読み取れなくなってしまいます。
また一度 after でレスポンスボディを読み取ってしまうと、 DecorateRoundTripper を使用するクライアントのレスポンスからボディが読み取れなくなってしまいます。
そのため、前処理・後処理でリクエスト・レスポンスボディを読み取る場合は、一度ボディの内容を退避し、読み取られた後にボディを再設定して、後続の処理でもボディが読み取れるようにするとよいでしょう。
1. c.before 呼び出し前に、ボディの内容を読み取り一次保存
req.GetBody() で req.Body のコピーを取得し、一時保存します。
req.Body 読み取り後に req.GetBody() を呼び出すとコピーを取得することができないので、必ず req.Body 読み取り処理前に実行してください。
c.before でリクエストボディを読み取った後、一次保存したボディのコピーを req.Body に再度設定します。
2. c.after 呼び出し前に、ボディの内容を読み取り一次保存
io.ReadAll で resp.Body のコピーを取得し、一時保存します。
io.ReadAll の読み取りで resp.Body が閉じられるため、 一度保存したレスポンスボディバイナリを使用して bytes.NewReader を初期化し、 io.NopCloser でラップして再度設定します。
func (c *ConsumableDecorateRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
if c.before != nil {
rb, err := req.GetBody()
if err != nil {
return nil, err
}
if err := c.before(req); err != nil {
return nil, err
}
req.Body = rb
}
resp, err := c.base.RoundTrip(req)
if err != nil {
return nil, err
}
if c.after != nil {
rb, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
resp.Body = io.NopCloser(bytes.NewReader(rb))
if err := c.after(resp); err != nil {
return nil, err
}
}
return resp, nil
}
レスポンスボディを読み取る場合、末尾バイトまで読み取りcloseする
http.Response の Body を完了まで読み取り、closeしなかった場合、keep-aliveによってハンドシェイク済みのTCPコネクションを再利用できません。
go.dev
Body io.ReadCloser
そのためレスポンスボディの読み取りが発生したら、Bodyを使い終わった後末尾バイトまで読み取りcloseするとよいでしょう。
after: func(r *http.Response) error {
defer func() {
_, _ = io.Copy(io.Discard, r.Body)
_ = r.Body.Close()
}()
var v Resp
if err := json.NewDecoder(r.Body).Decode(&v); err != nil {
return err
}
fmt.Printf("response: %+v\n", v)
return nil
},
Summary
- Goの
http.RoundTripper を用いて、http.Clientのリクエスト前後に処理を追加できるようにしました。
- 例として
http.RoundTripper をwrapする DecorateRoundTripper を実装し、リクエスト前後にログ出力などの処理を追加しました。
- リクエストやレスポンスボディを再利用可能にしたり、レスポンスボディの完全な読み取りとcloseなど、実装においていくつか注意する点があります。
Appendix
pkg.go.dev
github.com