GoでAPIクライアントを作るTips
はじめに
アプリケーションの中でWeb APIを扱うことは多々あるかと思います。
エンドポイントごとに関数を書いても良いのですが、認証やログ出力、レートリミットなどの処理を共通化し、API1本あたりの記述量を減らすことを目指します。
ゴールは次のようなコードで扱える client
を作ることです。
creds := ClientCredential{
ClientID: os.Getenv("CLIENT_ID"),
ClientSecret: os.Getenv("CLIENT_SECRET"),
}
logger := LoggerFunc(func(ctx context.Context, data map[string]interface{}, msg string) {
log.Ctx(ctx).Info().Fields(data).Msg(msg)
})
client := New(creds,
WithRateLimitter(10*time.Second, 10),
WithLogger(logger),
)
ctx := context.Background()
_, _ = client.CreatePet(ctx, &CreatePet{Name: "Hachi"})
_, _ = client.ListPets(ctx)
また、その他の要件として、以下を設けます。
- 認証処理が必要 (Client Credentials)
- goroutine-safe にする
- Rate Limitをかけられるようにする
- 使用するライブラリは標準/準標準ライブラリにとどめる
構造体の定義・クライアントの初期化
構造体を定義し、Factory関数を用意します。
type Client struct {
httpClient *http.Client
credential ClientCredential
token *Token
getTokenErr error
tokenMutex *sync.Mutex
logger Logger
rateLimitter *rate.Limiter
}
// クライアント初期化
func New(creds ClientCredential, opts ...OptFn) *Client {
c := &Client{
httpClient: &http.Client{
Timeout: 60 * time.Second,
},
credential: creds,
logger: &NopLogger{},
rateLimitter: nil,
}
for i := range opts {
opts[i](c)
}
return c
}
ここでのポイントは次の通りです。
- 構造体の各メンバーはPrivateとして、Factory関数を用意
- http.DefaultClientではタイムアウトの設定ができないため、
*http.Client
を自前で持つ - 各種オプションはFunctional Options Patternで設定(後述)
APIごとのメソッド
利用したいAPIごとに、Clientのメソッドを作成します。
func (c *Client) ListPets(ctx context.Context) ([]*Pet, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ListPetsURL, nil)
if err != nil {
return nil, fmt.Errorf("prepare request: %w", err)
}
res, err := c.Do(req)
if err != nil {
return nil, fmt.Errorf("request: %w", err)
}
defer res.Body.Close()
if res.StatusCode >= 400 {
return nil, fmt.Errorf("got error response: %s", res.Status)
}
var resBody []*Pet
err = json.NewDecoder(res.Body).Decode(&resBody)
if err != nil {
return nil, fmt.Errorf("decode response body: %w", err)
}
return resBody, nil
}
ここでのポイントは以下の通りです。
- リクエストの送信に
func (*http.Client) Do()
ではなくfunc (*Client) Do()
を使う
共通リクエスト処理
func (*http.Client) Do(r *http.Request) (*http.Response, error)
に倣い、
func (*Client) Do(r *http.Request) (*http.Response, error)
を作ります。
前述の通り、この関数は各APIコールを行う関数から共通で利用されるものになります。
// 認証情報つきでリクエストを行う
func (c *Client) Do(r *http.Request) (*http.Response, error) {
ctx := r.Context()
// トークンをセット
if err := c.refreshToken(ctx); err != nil {
return nil, fmt.Errorf("refresh token: %w", err)
}
r.Header.Set("X-Authorization", fmt.Sprintf("Bearer %s", c.token.AccessToken))
return c.do(r)
}
// リクエスト時の共通処理
func (c *Client) do(r *http.Request) (*http.Response, error) {
ctx := r.Context()
if c.rateLimitter != nil {
if err := c.rateLimitter.Wait(ctx); err != nil {
return nil, fmt.Errorf("wait for rate limit: %w", err)
}
}
ts := time.Now()
res, err := c.httpClient.Do(r)
if err == nil {
// ログ出力
d := map[string]interface{}{
"method": r.Method,
"url": r.URL,
"status": res.StatusCode,
"duration": time.Since(ts),
}
c.logger.Log(ctx, d, LogMsgRequest)
}
return res, err
}
認証が不要なAPIもあるため、メソッドは Do()
と do()
の二つに分けています。
前者はトークンの取得とそれをヘッダにセットする処理を担い、後者はそれ以外の共通処理とリクエスト送信を担っています。
リクエスト時の共通処理として、レートリミットの対応とログ出力を入れています。それぞれ後述します。
認証処理
認証サーバからClient Credentialsでアクセストークンを取得し、*Client
が保持するトークンを更新します。
この処理はgoroutine-safeにするため、また一度しか行わなくて済むように、一工夫入れています。
処理の流れは次の通りです。
- 今あるトークンが有効ならそれでOK
- 他のスレッドでエラーが発生していればそれを返す
- トークンを更新するため、Mutexを取得
- トークン取得に成功したら、
*Client.token
を更新、*Client.getTokenErr
をセットし完了 - トークン取得に失敗したら、
*Client.token
をクリア、*Client.getTokenErr
をセットしエラー返却
- トークン取得に成功したら、
- Mutexが既にロックされていた場合は、少し待って1に戻る
// トークン更新
func (c *Client) refreshToken(ctx context.Context) error {
i := 0
maxAttempts := 20
for {
if c.token.Valid() {
// 他スレッドでトークンが取得できていた場合
return nil
}
if c.getTokenErr != nil {
// 他スレッドでエラーが発生していた場合
return c.getTokenErr
}
if c.tokenMutex.TryLock() {
// Mutexを獲得し、新しいトークンを発行、保持する
defer c.tokenMutex.Unlock()
if t, err := c.getToken(ctx); err != nil {
c.token = nil
c.getTokenErr = fmt.Errorf("get token: %w", err)
} else {
c.token = t
c.getTokenErr = nil
}
return c.getTokenErr
}
// リトライ回数制限
i += 1
if i > maxAttempts {
return fmt.Errorf("max attempts exceeded")
}
// Sleep with context
w := 100 + rand.Intn(100)
err := SleepWithContext(ctx, time.Duration(w)*time.Millisecond)
if err != nil {
return err
}
}
}
ここでのポイントは次の通りです。
- 複数スレッドが同時にトークン取得をしないようにMutexを使う
- 認証エラーを繰り返すのを防ぐため、エラーが発生した場合はそれも保持する
また他スレッドでのトークン取得を待機するために、タイムアウトを考慮したSleepWithContext()
を入れています。
func SleepWithContext(ctx context.Context, d time.Duration) error {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(d):
return nil
}
}
実際のトークン取得の処理は、func (c *Client) getToken(ctx context.Context) (*Token, error)
に書いていますが、これは利用するAPIによって差の大きいところだと思いますので割愛します。
オプション
ログ出力、レートリミットといったオプション機能について解説します。
各オプションは、クライアント初期化時にFunctional Options Patternで設定できるようにします。
type OptFn func(c *Client)
func WithLogger(l Logger) OptFn {
return func(c *Client) {
c.logger = l
}
}
ログ出力
自分はロガーは基本的に zerolog を使用しているので、これを直接使ってしまってもよかったのですが、
要件「使用するライブラリは標準/準標準ライブラリにとどめる」を満たすため、また特定のライブラリに依存しないようにinterface Logger
を作ります。
type Logger interface {
Log(ctx context.Context, data map[string]interface{}, msg string)
}
type NopLogger struct{}
func (l *NopLogger) Log(ctx context.Context, fields map[string]interface{}, msg string) {}
NopLogger
はクライアント初期化時にロガーが指定されなかった場合に利用します。
例えばzerologを利用する場合は以下のように書きます。
type ZerologAdapter struct{}
func (*ZerologAdapter) Log(ctx context.Context, data map[string]interface{}, msg string) {
log.Ctx(ctx).Info().Fields(data).Msg(msg)
}
func main() {
l := &ZerologAdapter{}
c := New(creds, WithLogger(l))
}
構造体にメンバを持たせる必要がない場合用に、関数を直接渡せる型 LoggerFunc
も用意しています。
これを使うと、冒頭のExampleのように書くことが可能になります。
(関数型にもメソッドが定義できるんです!)
type LoggerFunc func(ctx context.Context, data map[string]interface{}, msg string)
func (f LoggerFunc) Log(ctx context.Context, data map[string]interface{}, msg string) {
f(ctx, data, msg)
}
logger := LoggerFunc(func(ctx context.Context, data map[string]interface{}, msg string) {
log.Ctx(ctx).Info().Fields(data).Msg(msg)
})
client := New(creds, WithLogger(logger))
レートリミット
golang.org/x/time/rate を使ってレートリミットを実装します。
追加場所は次の通りです。
- オプションを追加
func WithRateLimitter(window time.Duration, count int) OptFn {
return func(c *Client) {
c.rateLimitter = rate.NewLimiter(rate.Every(window), count)
}
}
- リクエスト時にWait
// リクエスト時の共通処理
func (c *Client) do(r *http.Request) (*http.Response, error) {
ctx := r.Context()
if c.rateLimitter != nil {
if err := c.rateLimitter.Wait(ctx); err != nil {
return nil, fmt.Errorf("wait for rate limit: %w", err)
}
}
...
}
おわりに
これでAPIクライアントの基礎が出来ました。
あとは利用するAPIごとにメソッドを増やしていくことになります。
これらのコードをベースに、フレームワーク化、OpenAPIからの自動生成を目指してみても面白いかなと思いました。
Discussion