Diary of a Perpetual Student

Perpetual Student: A person who remains at university far beyond the normal period

Go言語でHTTPレスポンスを透過的にキャッシュする

Webアプリケーションの裏側にさらにHTTPサーバが立っていて、レスポンスを返すために裏側のサーバにリクエストを送ってその結果を必要とするような構成があります。裏側のサーバに設定さえたAPIレートリミットへの対応やサーバへの過負荷を避けるため、キャッシュを利用してリクエストが飛びすぎないようにしたいケースがあるでしょう。

Go言語のHTTP ClientにはTransportというパラメータがあり、これを差し替えることで透過的なクライアントサイドのキャッシュ層を導入することができます。

実際にライブラリとして作ってみたのでご紹介します。

github.com

使い方

利用例を用意しています。

https://github.com/Arthur1/http-client-cache/blob/82d0e8e327b9fd37554a135b0915891621689a6b/_example/main.go

まずは以下のように、ファクトリ関数でtransportを生成し、http.ClientのTransportに設定します。

redisCli := redis.NewClient(&redis.Options{Addr: "localhost:6379", DB: 0})
transport := httpclientcache.NewTransport(
    rediscache.New(redisCli), httpclientcache.WithExpiration(5*time.Minute),
)
client := &http.Client{Transport: transport}

このclientのDoメソッドに*http.Requestを渡してあげることで、リクエストが5分間Redisにキャッシュされます。期限内にこのclientを利用して同じHTTPメソッド・本文のリクエストを送るとレスポンスが得られますが、HTTPリクエストを送っているわけではなくキャッシュから取り出しています。

req1, _ := http.NewRequest(http.MethodGet, "https://example.com", nil)
res1, _ := client.Do(req1) // origin response

req2, _ := http.NewRequest(http.MethodGet, "https://example.com", nil)
res2, _ := client.Do(req2) // cached response

実装の詳細

先ほど紹介したライブラリの実装のポイントをかいつまんで説明します。

キャッシュキーを生成する

キャッシュストレージとのやり取りに必要なので、*http.Request(のURL・メソッド・本文)から一意に定まるハッシュキーを生成します。

このとき、リクエストボディが必要になるのですが、io.ReadCloserという型になっており二度Bodyを読むことができません。すなわち、キャッシュキーを作るためにBodyを読んでしまうと、実際にclientがリクエストを送る時にBodyを読めなくなってしまいリクエストに失敗してしまいます。

この問題を防ぐため、以下のようにReadCloserを作り直して代入する必要があります。

body, err = io.ReadAll(req.Body)
if err != nil {
    return "", err
}
req.Body = io.NopCloser(bytes.NewReader(body))

Bodyが取得できたら、あとは他のパラメータと合わせてハッシュを生成しましょう。キーの暗号化要件は求められないため、軽量なハッシュアルゴリズムであるhash/fnvパッケージを利用しています。

http-client-cache/cache/key/key.go at 82d0e8e327b9fd37554a135b0915891621689a6b · Arthur1/http-client-cache · GitHub

キャッシュと読み書きして必要に応じてHTTPリクエストを送るTransportを作る

Transportに求められるinterfaceはRoundTripperで、Requestを引数にとってResponseとerrorを返すメソッドを実装すれば良いです。

type RoundTripper interface {
    RoundTrip(*Request) (*Response, error)
}

フォールバック先のTransportをフィールドに持った構造体を作って、RoundTripperインタフェースを実装しましょう。

  • キャッシュヒット時
    • キャッシュから得たResponseを返す
  • キャッシュミス時
    • フォールバック先のTransportを利用してOriginにリクエストを送る
    • TTLを決めてレスポンスをキャッシュに格納する
    • Originから得たResponseを返す

上記のような手続きを行うRoundTrip()を作ってあげれば良いです。以下のコードでは、細かいところは簡略化しています。

type TransportWithCache struct {
    Base http.RoundTripper
}

func NewTransportWithCache() *TransportWithCache {
    return &TransportWithCache{Base: http.DefaultTransport}
}

func (t *TransportWithCache) RoundTrip(req *http.Request) (*http.Response, error) {
    ctx := req.Context()
    key, err := t.cacheKey(req)
    if err != nil {
        // キャッシュキーの生成に失敗したらOriginにアクセスする
        return t.Base.RoundTrip(req)
    }

    cachedRes, ok, err := cache.Get(ctx, key)
    if err != nil {
        // キャッシュからの取得に失敗したらOriginにアクセスする
        return t.Base.RoundTrip(req)
    }
    if ok {
        // キャッシュヒット
        return cachedRes, nil
    }

    // Origin にアクセス
    res, err := t.Base.RoundTrip(req)
    if err != nil {
        return nil, err
    }
    // 200ならキャッシュにセットする
    if res.StatusCode == http.StatusOK {
        cache.Set(ctx, key, res, time.Minute)
    }
    return res, nil
}

http-client-cache/transport.go at 82d0e8e327b9fd37554a135b0915891621689a6b · Arthur1/http-client-cache · GitHub

http.Responseを保存可能な型に変換する

http.Responseはio.ReadCloserなどを含んだ構造体なので、そのままではRedisなどのデータストアに保存することができません。httputil.DumpResponseを利用することで、[]byteに変換することができます。

逆にhttp.Responseを復元する際には、http.ReadResponse関数が有効です。

req, _ := http.NewRequest(http.MethodGet, "https://example.com", nil)
res, _ := &http.Client{}.Do(req)
dumpedRes, _ := httputil.DumpResponse(res, true)
restoredRes, _ := http.ReadResponse(bufio.NewReader(bytes.NewReader(dumpedRes)), req)

http-client-cache/cache/engine/rediscache/redis.go at 82d0e8e327b9fd37554a135b0915891621689a6b · Arthur1/http-client-cache · GitHub

Cache Stampedeを防ぐ

キャッシュが破棄された時に、同時に並行してOriginにアクセスをしてキャッシュに格納しようとしてしまいバックエンドの負荷が高まってしまうCache Stampedeという問題があります。

これを防ぐため、singleflight packageを利用して、同じキャッシュキーのOriginへのリクエストは同時に1つしか飛ばないようにしています。

group.Do()ではhttp.Responseをそのまま返したいところなのですが、複数のgoroutineで同じ結果が共有されてしまうので、どれか1つのgoroutineしかBodyを読めなくなってしまいます。これを回避するため、先ほど紹介したhttputil.DumpResponseでbyte列にしてから返し、それぞれの呼び出し元で復元するようにしています。

+var group singleflight.Group

 func (t *TransportWithCache) RoundTrip(req *http.Request) (*http.Response, error) {
     ctx := req.Context()
     key, err := t.cacheKey(req)
     if err != nil {
         // キャッシュキーの生成に失敗したらOriginにアクセスする
         return t.Base.RoundTrip(req)
     }
 
     cachedRes, ok, err := cache.Get(ctx, key)
     if err != nil {
         // キャッシュからの取得に失敗したらOriginにアクセスする
         return t.Base.RoundTrip(req)
     }
     if ok {
         // キャッシュヒット
         return cachedRes, nil
     }
 
-    // Origin にアクセス
-    res, err := t.Base.RoundTrip(req)
-    if err != nil {
-        return nil, err
-    }
-    // 200ならキャッシュにセットする
-    if res.StatusCode == http.StatusOK {
-         cache.Set(ctx, key, res, time.Minute)
-     }
-    return res, nil
+    maybeDumpedRes, err, _ := group.Do(key, func() (any, error) {
+        // Origin にアクセス
+        res, err := t.Base.RoundTrip(req)
+        if err != nil {
+            return nil, err
+        }
+        // 200ならキャッシュにセットする
+        if res.StatusCode == http.StatusOK {
+            cache.Set(ctx, key, res, time.Minute)
+        }
+        dumpedRes, err := httputil.DumpResponse(res, true)
+        return dumpedRes, err
+    }
+    dumpedRes := maybeDumpedRes.([]byte)
+    return http.ReadResponse(bufio.NewReader(bytes.NewReader(dumpedRes)), req)
 }