👏

GoでAPIクライアントを作るTips

2023/02/16に公開

はじめに

アプリケーションの中で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)

また、その他の要件として、以下を設けます。

  1. 認証処理が必要 (Client Credentials)
  2. goroutine-safe にする
  3. Rate Limitをかけられるようにする
  4. 使用するライブラリは標準/準標準ライブラリにとどめる

構造体の定義・クライアントの初期化

構造体を定義し、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にするため、また一度しか行わなくて済むように、一工夫入れています。

処理の流れは次の通りです。

  1. 今あるトークンが有効ならそれでOK
  2. 他のスレッドでエラーが発生していればそれを返す
  3. トークンを更新するため、Mutexを取得
    1. トークン取得に成功したら、*Client.tokenを更新、*Client.getTokenErrをセットし完了
    2. トークン取得に失敗したら、*Client.tokenをクリア、*Client.getTokenErrをセットしエラー返却
  4. 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