様々な言語で提供されているAzure SDKのコアモジュールにはHTTP Pipeline Policyという仕組みが実装されています。
Java版のドキュメントが一番わかりやすかったので、こちらに掲載されている画像を引用して、HTTP Pipeline Policyが何たるかを説明します。
簡単に言うとHTTPクライアントのミドルウェアのようなものです。実際にHTTPリクエストをAPIに投げるまでにパイプラインで様々な前処理を行います。Azure SDKにはログを出力する機能やリクエストに失敗しても一定回数リトライする機能がありますが、これらはPipeline Policyとして実装されています。さらに、ユーザーが定義した独自のPolicyをパイプラインに組み込むことも可能です。
ユーザーがPipelineに登録できるPolicyはPer-call PolicyとPer-retry Policyの2種類あります。Per-call Policyは1回のメソッドの呼び出しにつき1回だけ呼ばれて実行されます。対してPer-retry Policyは、1回のメソッドの呼び出しでもRetry Policyによって複数回APIコールすることがありますが、実際のAPIコール分だけ呼ばれて実行されるものです。
ここからは、Azure SDK for Goの場合のコードを示しながら解説します。
Policyの実装と利用
https://pkg.go.dev/github.com/Azure/azure-sdk-for-go/sdk/azcore/policy#Policyの通り、Policyという型がinterfaceとして公開されています。これは
type Policy interface { Do(req *Request) (*http.Response, error) }
という定義になっています。当ブログの読者はお気づきだと思うのですが、Go言語でHTTPレスポンスを透過的にキャッシュする - Diary of a Perpetual Studentで紹介したRoundTrip interfaceとほぼ同じですね。Requestの部分がGo標準のhttp packageのものではなくsdk/azcore/policy packageで独自に定義されたものになっていますが、これは単なるwrapperで、.Raw()
を用いて簡単にhttp.Requestを取り出すことができます。
例えばリクエスト先のURLをログに書くPolicyは以下のようにして書けます。
type URLLogPolicy struct {} func (p URLLogPolicy) Do(req *policy.Request) (*http.Response, error) { ctx := req.Raw().Context() slog.InfoContext(ctx, "request", slog.String("url", req.Raw().URL.String())) return req.Next() }
細かいですが、PolicyのDoメソッドはgoroutine safeでないと実用上困るので、Policyの不変性を保つためにレシーバはポインタにしない方が望ましいと思います。
Azure SDK独自のRequest型に、パイプラインの次のPolicy(もしくはTransport)を呼ぶメソッドが生えています。Go標準のHTTP Transportを自作するときには親のTransportを構造体内部に持っておくことが多いと思うのですが、Policyではこれをやらなくて済むので手間が省けて便利ですね。
Policyの実装ができたら、Azure SDKのClientを作る際にClientOptionsのPerCallPoliciesもしくはPerRetryPoliciesに渡してあげましょう。
client := armappservice.NewWebAppsClient( subscriptionID, session, &arm.ClientOptions{ ClientOptions: policy.ClientOptions{ PerCallPolicies: []policy.Policy{URLLogPolicy{}}, }, }, )
上記のようにすると、このclientを用いてAPIコールを伴うメソッドを呼び出した時にURLがログに出力されるようになります。
Policyのテスト
さて、Policyができたら次はテストを書きたくなるでしょう。テスト時にはAzure SDKのClient内部で作られているPipelineを手で作るのが一番手っ取り早く確実だと思います。Doの引数に渡るRequestを直接作ってもいいのですが、素朴に作ってしまうとPipelineの次のステージ(テスト時には専らTransportでしょう)が未定義となりエラーになってしまいます。
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "OK") })) defer ts.Close() // 第1, 2引数はテストではそこまで関係ないはずなので適当に指定 pipeline := runtime.NewPipeline("", "", runtime.PipelineOptions{}, &policy.ClientOptions{ PerRetryPolicies: []policy.Policy{URLLogPolicy{}}, }) req1, _ := runtime.NewRequest(context.Background(), http.MethodGet, ts.URL) res, err := pipeline.Do(req1)
カスタムPipelineの活用事例
APIコール数をOpenTelemetry Metrics手動計装
APIコールは費用がかかるものですしレートリミットも設定されていることから、SDKが行ったAPIコールの数を可視化したい需要があるでしょう。
以下のようなPolicyをPerRetryPolicyとして設定することで、成功したAPIコール数をOpenTelemetry Metricsとして計装することができます。
type MonitorPolicy struct { Counter metric.Int64Counter } func (p MonitorPolicy) Do(req *policy.Request) (*http.Response, error) { ctx := req.Raw().Context() res, err := req.Next() if err != nil { return res, err } // 最新のAzure SDK for Goでは、contextにAPI名を入れてくれているのでここから取り出します apiName, ok := ctx.Value(runtime.CtxAPINameKey{}).(string) if !ok { apiName = "unknown" } p.Counter.Add(ctx, 1, metric.WithAttributes(attribute.String("azure.api_name", apiName))) return res, nil }
また、同様にして、レスポンスヘッダに書かれているAPIコールレートリミットのバケット残数をヒストグラムとして計装することもできます。これは読者への課題としましょう。
クライアントサイドでキャッシュする
Go言語でHTTPレスポンスを透過的にキャッシュする - Diary of a Perpetual Studentで紹介したTransportと同じようなものをPipeline Policyとして実装してあげることで、Azure APIの呼び出しを一定期間キャッシュすることができます。実装についてはGitHub - Arthur1/http-client-cache: a Go library for transparent HTTP client-side caching using Transportとほぼ同じなため本エントリでは割愛させていただきます。
さて、以下のドキュメントに記載されている通り、Azure Resource Manager APIのレートリミットポリシーは2024年3月ごろから順次新しいものに変わりました。
この改定によって一般的にはより多くのリクエストを送れるようになったのですが、制限が1時間毎のリクエスト数だったものから1分毎一定量バケットに補充される方法に変わったため、短い時間で大量のリクエストを送るようなケースでは制限が厳しくなってしまいました。そういったアプリケーションではPolicy 1つ導入してクライアントサイドでキャッシュすることによって、APIコール数超過に対して小さな改修で対応することができます。
感想
現代のAzure SDKは開発体験が良い洗練されたライブラリだと感じています。自分が何らかのAPI Clientライブラリを提供するときにはHTTP Pipeline Policyの仕組みをぜひ取り入れたいです。