こんにちはバクラク事業部 PlatformEngineering 部の hira です!
今回は GOCACHEPROG を http.Handler ライクに実装できるパッケージを作ったので紹介します。
GOCACHEPROG の登場で Go のキャッシュロジックを自分たちで実装できるようになりました。
私自身も CI の実行時間を短縮するという目的で実装してみたのですが、 Go 公式からは API 等が公開されておらず実装で手間取ってしまう場面がありました。
もう少し手軽に GOCACHEPROG 実装したいという思いから今回パッケージを作成してみました。
ソースコードは GitHub 上で公開しているので、もしよかったら覗いてみてください!
GOCACHEPROG とは
GOCACHEPROG 環境変数に実行コマンドを渡すことで、cmd/go ツール(以下 go コマンド)の子プロセスとして起動し、子プロセス側にキャッシュ処理を委譲することができます。
# 例: /my/cache/prog でキャッシュ処理を行う GOCACHEPROG="/my/cache/prog" go build ...
元々 go コマンドにはビルドやテスト関連のバイナリをキャッシュする仕組みはあるのですが、キャッシュのロジックを拡張したり、外部ストレージにキャッシュを保存するといったことができませんでした。
GOCACHEPROG の登場により、キャッシュ操作が抽象化されたことで柔軟にキャッシュシステムを実装できるようになりました。
GOCACHEPROG の仕組み
GOCACHEPROG で起動するプロセスがどのように動作するのかについて説明します。
go コマンドと子プロセスは、標準入力・標準出力を通して JSON メッセージでやり取りします。
Go 1.24 時点では以下の 3 つのコマンドがあり、リクエストされたコマンドに応じて子プロセス側でキャッシュ情報を返す、保存する、終了するといった処理を行います。
- get: キャッシュ情報を取得するコマンド
- put: キャッシュデータを保存するコマンド
- close: 子プロセスに終了を伝えるコマンド
go コマンドと子プロセスは以下のような流れで動作します:
- go コマンドは GOCACHEPROG に指定された実行コマンドを子プロセスとして起動
- 起動したプロセスは、自身がサポートする KnownCommands(get, put, close)を応答
- go コマンドは、キャッシュの取得(get)や保存(put)などのリクエストを送信
- 子プロセスは、リクエストされたコマンドに応じてレスポンスを応答
- go コマンドは、終了時に close コマンドを送信
- 子プロセスは close コマンドで受け取ったリクエスト ID を返却して終了

GOCACHEPROG では、シンプルな JSON プロトコルによってキャッシュ操作が抽象化されているため、子プロセス側で柔軟にキャッシュロジックを実装することが可能です。
実装の課題
便利な GOCACHEPROG ですが、いざ実装してみると以下のような点で開発のしづらさを感じました。
- Go 公式では API が公開されていないため、基本的に自前で実装する必要がある
- GOCACHEPROG 特有のルールを理解する必要がある
- 起動時は KnownCommands を返さなければならない
- put コマンドは JSON メッセージとは別でキャッシュデータを io.Reader 型に変換したうえで Body にセットする必要がある
- 各コマンドで共通の処理があった場合に、ミドルウェア的な実装が欲しくなる
上記のような点を解決したいと考え、GOCACHEPROG をより手軽に実装できるパッケージを作ることにしました。
http.Handler ライクに実装できるパッケージ
今回作成したパッケージの主な特徴は以下の 3 点です。
- http.Handler ライクにキャッシュの実装をできる
- GOCACHEPROG 特有のルールや並列処理など複雑な部分はできるだけパッケージ側で隠蔽する
- ミドルウェアの実装をサポート
ハンドラーの実装
get put close のコマンドを処理する関数がハンドラーです。
ハンドラーは以下のようなシグネチャになっていて、このシグネチャを満たせば構造体のメソッドもハンドラーとして登録できます。
func(ctx context.Context, w ResponseWriter, r *Request)
リクエストやレスポンスは Go の cacheprog.go で定義されているものを再定義しています。
type Request struct { ID int64 Command Cmd ActionID []byte `json:",omitempty"` OutputID []byte `json:",omitempty"` Body io.Reader `json:"-"` BodySize int64 `json:",omitempty"` } type Response struct { ID int64 Err string `json:",omitempty"` KnownCommands []Cmd `json:",omitempty"` Miss bool `json:",omitempty"` OutputID []byte `json:",omitempty"` Size int64 `json:",omitempty"` Time *time.Time `json:",omitempty"` DiskPath string `json:",omitempty"` } type ResponseWriter interface { WriteResponse(res Response) }
リポジトリの example 配下で標準のキャッシュと同様にファイルシステムを使ったキャッシュの実装例を公開しています。
今回は get コマンドの実装例のみ記載しますが、その他のコマンドの実装はリポジトリを参照してみてください!
実装例:
type LocalDiskCacheHandler struct { cacheDir string // キャッシュファイルを保存するディレクトリパス } func (h *LocalDiskCacheHandler) HandleGet(ctx context.Context, w cache.ResponseWriter, r *cache.Request) { // ActionIDからキャッシュメタデータファイルのパスを取得 actionPath := h.getActionPath(r.ActionID) // キャッシュメタデータファイルを開く actionFile, err := os.Open(actionPath) if os.IsNotExist(err) { // ファイルが存在しない場合はキャッシュミスとして応答 w.WriteResponse(cache.Response{ ID: r.ID, Miss: true, }) return } else if err != nil { // その他のエラーが発生した場合はエラーレスポンスを返す h.writeErrorResponse(w, r, fmt.Errorf("failed to open action file: %w", err)) return } defer actionFile.Close() // キャッシュメタデータファイルから情報を読み取る var outputID []byte var fileSize int64 var timestampUnix int64 var hexOutputID string // ファイル形式: "<OutputID> <ファイルサイズ> <Unix時間>" _, err = fmt.Fscanf(actionFile, "%s %d %d", &hexOutputID, &fileSize, ×tampUnix) if err != nil { // ファイル形式が不正な場合はエラーレスポンスを返す h.writeErrorResponse(w, r, fmt.Errorf("failed to parse action file: %w", err)) return } // Unix時間をtime.Time型に変換 timestamp := time.Unix(timestampUnix, 0) // OutputIDをバイト配列にデコード outputID, err = hex.DecodeString(hexOutputID) if err != nil { h.writeErrorResponse(w, r, fmt.Errorf("failed to decode output ID: %w", err)) return } // OutputIDからキャッシュデータファイルのパスを取得 objectPath := h.getObjectPath(outputID) // キャッシュデータファイルの情報を取得 fi, err := os.Stat(objectPath) if os.IsNotExist(err) { // ファイルが存在しない場合はキャッシュミス w.WriteResponse(cache.Response{ ID: r.ID, Miss: true, }) return } else if err != nil { // その他のエラーが発生した場合はエラーレスポンス h.writeErrorResponse(w, r, fmt.Errorf("failed to stat object file: %w", err)) return } // ファイルサイズが期待値と異なる場合はキャッシュ破損とみなしキャッシュミス if fi.Size() != fileSize { w.WriteResponse(cache.Response{ ID: r.ID, Miss: true, }) return } // キャッシュヒット時のレスポンスを返す w.WriteResponse(cache.Response{ ID: r.ID, // リクエストIDをそのまま返す OutputID: outputID, // キャッシュデータのハッシュ値 Size: fileSize, // キャッシュデータのサイズ Time: ×tamp, // キャッシュされた時刻 DiskPath: objectPath, // キャッシュデータのファイルパス }) }
実装したハンドラーは以下のように登録します。
cache.HandleGetFunc(handler.HandleGet)
cache.HandlePutFunc(handler.HandlePut)
cache.HandleCloseFunc(handler.HandleClose)
// もしくはコマンドとハンドラーの組み合わせで登録可能
cache.HandleFunc(cache.CmdGet, handler.HandleGet)
cache.HandleFunc(cache.CmdPut, handler.HandlePut)
cache.HandleFunc(cache.CmdClose, handler.HandleClose)
登録したハンドラーは、後述するサーバー起動時に自動的に KnownCommands として送信されます。
ミドルウェアのサポート
ミドルウェアの実装もサポートしているので、ロギングなど各ハンドラーの共通処理はミドルウェアで共通化することが可能です。
ログミドルウェアの実装例:
func LoggingMiddleware() cache.Middleware { return func(next cache.Handler) cache.Handler { return cache.HandlerFunc(func(ctx context.Context, w cache.ResponseWriter, r *cache.Request) { // リクエスト処理前のログ start := time.Now() log.Printf("受信: ID=%d, Command=%s", r.ID, r.Command) // 実際のハンドラーを呼び出し next.Handle(ctx, w, r) // リクエスト処理後のログ duration := time.Since(start) log.Printf("完了: ID=%d, 所要時間=%v", r.ID, duration) }) } }
ミドルウェアは cache.Use を使って登録でき、複数のミドルウェアを登録することも可能です。
cache.Use(LoggingMiddleware(), RecoverMiddleware())
サーバーの起動
サーバーは cache.Serve 関数を呼び出すことで起動できます。 ハンドラー、ミドルウェアの登録も含めると以下のようになります。
func main() { // ハンドラーの作成 handler := diskcache.NewLocalDiskCacheHandler() // ハンドラーの登録 cache.HandleGetFunc(handler.HandleGet) cache.HandlePutFunc(handler.HandlePut) cache.HandleCloseFunc(handler.HandleClose) // ミドルウェアの登録(オプション) cache.Use(LoggingMiddleware()) // サーバーの起動 if err := cache.Serve(); err != nil { log.Printf("failed to Serve: %v", err) os.Exit(1) } }
また、 cache.Serve の内部ではリクエストごとにゴルーチンが起動して並列に処理されるようになっています。 cache.Serve にオプションを渡すことで、最大並列実行数も調整できます。(デフォルトは 8)
err := cache.Serve(cache.WithConcurrency(16)) // 最大並列数 16 で処理
ここまでパッケージについて紹介させていただきました。
パッケージを利用することで、ハンドラー(キャッシュロジック)を実装するだけでいいのでより手軽に GOCACHEPROG を実装できるようになります!
まとめ
今回は Go1.24 で追加された GOCACHEPROG の仕組みと自作パッケージについて紹介させていただきました。
現状は、実装例としてローカルのキャッシュのみ公開している状況なので、リモートキャッシュの実装例などもそのうち追加できたらと思います。
ぜひ、皆さんも GOCACHEPROG を活用して独自のキャッシュシステムの実装に挑戦してみてください!