この記事は、LayerX Tech Advent Calendar 2024 の8日目の記事です。
バクラクビジネスカード開発チーム Tech Lead の budougumi0617 です。
今回はGoでWebアプリケーションを作る際に利用するHTTPミドルウェアでリクエストログを出力する際のTipsです。
HTTPハンドラー内のビジネスロジックで取得・導出した情報をリクエストに付与する方法を解説します。
TL;DR
Go言語でWebアプリケーションを開発する際、よくある手法としてミドルウェアでユーザーIDやテナントIDといった情報を持ったリクエストログ(アクセスログ)を出力します。
大抵の場合はリクエストログミドルウェアの前段にかませた認証ミドルウェアで取得した情報を使ってリクエストログにID情報を付与しますが、外部サービスに公開したWebhookのような場合、認証情報が取得できないことがあります。
本記事では、このようなケースでもビジネスロジック中(HTTPハンドラー内の実ロジック)で取得した情報をリクエストログミドルウェアに渡す方法を2つ紹介します。
http.ResponseWriter
をラップするcontext.Context
へのポインタオブジェクトを格納する
どちらもデメリット(懸念事項)があるため、用法用量を守って利用する必要があります。
課題感: Datadogでフィルターを作成したり、ログ分析を行う上ですべてのリクエストログにテナントIDやユーザーIDの情報を含めたい
弊社ではDatadogを用いて本番環境の監視を行っています。また、ログはDWHに格納してSnowflakeやLooker Studioからの分析で利用しています。
通常の(認証済みユーザーによる)リクエストでは、認証ミドルウェアでテナントIDやユーザーIDを特定し、context.Context
オブジェクトに詰め込んだ上でリクエストログ出力時に利用しています 1。
Webhook用エンドポイントのような特殊ケースではミドルウェア層でIDを取得できないことがある
私が所属しているカードチームのAPIサーバには、外部サービスからのWebhookを受け取るエンドポイントがいくつかあります。
具体的な内容は割愛しますが、興味があるかたは @shnjtk の以下の資料をごらんください。
speakerdeck.com
これらWebhookのリクエストは決済リクエストなど、大抵がいずれかのテナントに紐づくリクエストではあります。しかし、他社から受けとったリクエストのため、自社の認証基盤のが発行した認証情報は含まれていません。
また、他社ベースの情報であるため、リクエストボディの中身を単純に見るだけではテナントIDなどはわかりません(他社が発行したIDを元に弊社DBからテナント情報を探すなどの過程が必要)。
つまり、次で説明するようにオーソドックスなミドルウェアの処理の流れの中ではIDを取得できず、リクエストログミドルウェアの段階でcontext.Context
にIDが入っている前提が崩れてしまいます。
テナントIDが無いリクエストログによってアラート運用に支障が出ていた
テナントIDなしのリクエストログでも、後からリクエストIDで追跡すれば他のログ出力と組み合わせてテナント特定は可能です。
しかし、Datadog上のアラート設定などでテナントIDを条件に使っているケースでは、テナントIDがないログが存在することは運用上不便です。
こうした状況で、ビジネスロジック中で導出したユーザーIDやテナントIDなどをどうにかしてリクエストログミドルウェアに渡したいなと考えました。
おさらい: 通常のHTTPミドルウェアにおけるリクエストログ出力とID情報付与の流れ
前述の課題に対するアプローチを紹介する前に、オーソドックスな認証ミドルウェアとリクエストログの組み合わせをおさらいしておきます。
GoでHTTP APIサーバを作成する時はhttp.Handler
インターフェースベースで各エンドポイントを実装します。
認証情報の確認、エラーハンドリング、リクエストログの出力といった共通機能については、利用するフレームワークによって多少の差異はありますが、
func (next http.Handler) http.Handler
形式のミドルウェアを使って実装することになるでしょう。
そして、マルチテナントSasSで用いるAPIサーバのリクエストログでは、ユーザーIDやテナントIDなどの情報も含めることが一般的です。
認証済みリクエストに対して、一般的な構成では以下のようなフローでリクエストログにID情報を付与します。
- 認証ミドルウェア
- リクエストヘッダやCookie、セッションなどから認証情報を取得し、ユーザーIDやテナントIDを
http.Request
オブジェクトにWithContext
メソッドもしくはClone
メソッドで埋め込む。
- リクエストヘッダやCookie、セッションなどから認証情報を取得し、ユーザーIDやテナントIDを
- リクエストログミドルウェア
- 後続の処理を実行後、
Context
オブジェクトからID情報を取り出し、リクエストログへ出力します。
- 後続の処理を実行後、
具体的な実装としては次のような形になります。
https://go.dev/play/p/ey5kGda4O7P
type userIDKey struct{} type tenantIDKey struct{} func AuthMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { userID, tenantID := authenticate(r) // リクエストデータから認証情報を取得 ctx := context.WithValue(r.Context(), userIDKey{}, userID) ctx = context.WithValue(ctx, tenantIDKey{}, tenantID) next.ServeHTTP(w, r.WithContext(ctx)) }) } func RequestLogMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() next.ServeHTTP(w, r) duration := time.Since(start) ctx := r.Context() userID := ctx.Value(userIDKey{}) tenantID := ctx.Value(tenantIDKey{}) log.Printf("Request log user=%v tenant=%v duration=%v\n", userID, tenantID, duration) }) } // AuthMiddleware(RequestLogMiddleware(businessLogicHandler)) という形でミドルウェアを組み合わせる
ログイン済ユーザーが実行した認証済のリクエストに対してはこの構成で問題なくリクエストログにID情報を付与できます。
ここで問題になるのが次の前提知識です。
前提知識1: Goのcontext.Context
はイミュータブル
context.Context
はイミュータブルです。WithValue
で新たなキーと値のペアを付与すると新しいContext
オブジェクトが返りますが、既存Context
オブジェクトを直接書き換えることはできません。
つまり、ハンドラー内部でContext
オブジェクトに値を入れたとしても、呼び出し元のContext
オブジェクトは通常変更されません。
また、ハンドラー内部でContext
オブジェクトからValue
メソッドで取り出した値を変更しても元のContext
オブジェクトには反映されません。
https://go.dev/play/p/181CW1UrxO3
type tenantIDKey struct{} func main() { orgTenantID := "original" ctx := context.WithValue(context.Background(), tenantIDKey{}, orgTenantID) tenantID := ctx.Value(tenantIDKey{}).(string) tenantID = "update" fmt.Printf("change %q\n", tenantID) // change "update" fmt.Printf("result %q\n", ctx.Value(tenantIDKey{}).(string)) //result "original" }
前提知識2: *http.Request
経由では親関数(呼び出し元)へContext
を戻せない
*http.Request
オブジェクトが内包するContext
オブジェクトを上書きすることはできません。
WithContext
メソッドを呼ぶと新しい*http.Request
オブジェクトが返ってきますが、呼び出し元のミドルウェアに新しい*http.Request
オブジェクトを戻すことはできません。
つまり、以下のようなコードはうまくいきません。
func handler(w http.ResponseWriter, r *http.Request) { userID := getUserIDFromRequestBody(r) // 親のContextに値を追加しているわけではない newCtx := context.WithValue(r.Context(), "userID", userID) // 新しい*http.Requestを作成しているが、親の*http.Requestには影響しない newReq := r.WithContext(newCtx) }
これらの前提知識をうまく回避してHTPハンドラー内のロジックからリクエストミドルウェアにIDを渡す2つのアプローチを紹介します。
アプローチ1: http.ResponseWriter
実装をラップしてIDを伝搬する
http.ResponseWriter
インターフェースを満たすオブジェクトにIDを受け渡しするAPIを仕込むのが最初のアプローチです。
ミドルウェアでhttp.ResponseWriter
インターフェースを満たすオブジェクトとしてラップしたオブジェクトを仕込んでおくことで、後続の処理からIDをラップしたオブジェクトにセットできます。
ミドルウェアには ビジネスロジック実行後にhttp.ResponseWriter
インターフェースを満たすオブジェクトを確認することでIDを取得可能です。
具体的な実装例は次のようになります。
https://go.dev/play/p/PuHWRSFBeeN
type ResponseWriterDecorator struct { http.ResponseWriter tenantID string } func (rwd *ResponseWriterDecorator) SetTenantID(tenantID string) { rdw.tenantID = tenantID } func (rwd *ResponseWriterDecorator) TenantID() string { return rdw.tenantID } func RequestLogMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { rwd := &ResponseWriterDecorator{ResponseWriter: w} start := time.Now() next.ServeHTTP(rwd, r) // ハンドラー内でセットされたuserIDをここで取得 tenantID := rwd.TenantID() log.Printf("request log tenant_id=%q duration=%v\n", tenantID, time.Since(start)) }) } func businessLogicHandler(w http.ResponseWriter, r *http.Request) { // ビジネスロジック if rwd, ok := w.(*ResponseWriterDecorator); ok { rwd.SetTenantID("update from businessLogicHandler") } }
この方法の注意点
このアプローチでは、ミドルウェアがrwd.TenantID
メソッド経由で情報を取り出せます。
デメリットとしてはHTTPハンドラー内でhttp.ResponseWriter
をキャストできるところまでIDを持ってこないといけない点があります。
カードチームのHTTPハンドラーもHTTPの処理をする層と具体的なビジネスロジックを実行する層を分離しているため、具体的なコードにすると少し奇妙になります。
package handler import ( usecase ) func businessLogicHandler(w http.ResponseWriter, r *http.Request) { // インプットをHTTPの世界からGoの構造体に変換する処理 input := parseInput(r.Body) // ビジネスロジックを実際に実行する処理 result, tenantID, err := usecase.Execute(input) // エラーでもテナントIDはセットしておきたい。 if rwd, ok := w.(*ResponseWriterDecorator); ok { if tenantID != "" { rwd.SetTenantID(tenantID) } } if err != nil { // エラーレスポンスを返す処理 writeErrorResponse(w, err) return } // ビジネスロジックの結果をHTTPの世界に変換する処理 writeSuccessResponse(w, result) }
また、理想では透過的であるべきミドルウェアの存在をHTTPハンドラーの内部実装が認知している必要があります。
やり方2: Context
オブジェクトへ構造体オブジェクトへのポインタを詰める
もう一つの手段は、Context
オブジェクト内に構造体オブジェクトのポインタを事前にWithValue
メソッドで詰め込んでおくやり方です。
前提知識1で述べた通り、実際のcontext
オブジェクトのValue
に詰めた「値」自体はイミュータブルですが、その値(この場合ポインタが示す先にあるオブジェクト)の中の値は変更可能です。
具体的なサンプルコードは次のとおりです。
https://go.dev/play/p/g3I27Unzy6Y
type requestInfoKey struct{} type RequestInfo struct { TenantID string } func RequestLogMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { info := &RequestInfo{} ctx := context.WithValue(r.Context(), requestInfoKey{}, info) start := time.Now() next.ServeHTTP(w, r.WithContext(ctx)) log.Printf("Access log tenant_id=%v duration=%v\n", info.TenantID, time.Since(start)) }) } func businessLogicHandler(w http.ResponseWriter, r *http.Request) { tenantID := "update from businessLogicHandler" if info, ok := r.Context().Value(requestInfoKey{}).(*RequestInfo); ok { info.TenantID = tenantID } }
ここでは、RequestLogMiddleware
で*RequestInfo
ポインタをContext
オブジェクトに詰めて、後続ハンドラーがRequestInfo.TenantID
フィールドへ値をセットします。リクエストログ出力時にはinfo.TenantID
を確認してログを出力します。
この方法の注意点
この手法は、Context
オブジェクトへ格納するデータをイミュータブルであるべきContext
オブジェクトのValueが指すオブジェクトを更新しています。
何回も値を書き換えるような使い方をするとゴルーチンセーフな前提を崩してしまうため、プロダクションコードではミドルウェアと別の共通ライブラリの仕組みで透過的に実行させるなどの工夫も必要そうです。
まとめ
今回はリクエストログミドルウェアに後続処理から付加情報を渡す方法を2つ紹介しました。
私のチームではアプローチ1の「http.ResponseWriter
をラップする」を採用しました。
https://go.dev/play/p/CyzOXqgHjXU
- レイヤー間のAPIインターフェースが違和感がある形になる
- ミドルウェアで
http.ResponseWriter
をキャストしていることを「知っている」コードができる
上のような懸念はあるものの、リクエストログにID情報がのることの日々の運用のカイゼン効果がとても大きいため、コードの「理想からのズレ」は許容しました。
もっとスマートなやりかたをご存知のかたは教えていただけると幸いです。
明日はコーポレートエンジニアリング室の yuya-takeyama さんの記事です。お楽しみに!
-
厳密にいうと外部に公開しているエンドポイント以外は
GraphQL
もしくはConnect
でやりとりするエンドポイントです。↩