Go言語によるAPIクライアント作成のススメ

2024-02-28

はじめに

株式会社 Belong でバックエンドエンジニアをしている ke-no です。
本記事では、Go アプリケーションから様々なサービスと WebAPI での連携をするにあたって API クライアントの実装をする際に抑えておきたいポイントや、実際の実装事例などをご紹介しようと思います。

注意ですが、APIAPI クライアントという言葉は幅広い意味で用いられます。
本記事で扱う APIは WebAPI、API クライアント については、Go アプリケーションから外部の HTTP/HTTPS API サービスを呼び出すためのプログラムとして解説をしていきますので、ご承知おきください。 記事内のコードは執筆時点で最新の Go 1.22 で作成しています。

なぜ API クライアントを作るのか

そもそもですが API 連携をするにあたり、「なぜわざわざ面倒そうな実装をする必要があるのか?」「関数を分けて実装すればロジックも分けられるしメンテナンス性も悪くないのではないか?」などといった疑問の声もあるかと思います。
実際、PoC や規模のそこまで大きくないプロジェクトでは直接 API の呼び出し処理を記述する、あるいは関数だけ分けておくだけでも十分なケースは少なくありません。
しかし、本来外部との連携を行う際に考えるべきことは多くあります。

API 連携時の例を挙げると、まず単純なリクエスト・レスポンスのメディアタイプだけでも数えたくないほどの種類があります。
その他、認証・認可やタイムアウト、エラーハンドリングやリクエスト失敗時のリトライ...など、様々な要素が組み合わさった上で連携は行われます。
これらを全てアプリケーションロジックと同様に混ぜ込む、あるいは別関数として切り出した上で共通化できる部分は共通化して etc..
この状態でモックテストの実施や API のバージョン変更、使用するエンドポイントの変更といったメンテナンス作業が発生した際にコードの品質を保てるでしょうか?
メンバー全員がコードの隅々まで深い理解をしているプロジェクトでもなければ、困難で複雑な形になってしまうことは想像に難くありません。

それよりも、API を呼び出す処理を専用の構造体やクラス (Go で言う struct) に実装し、アプリケーションのメインロジックとは切り離された場所に分離しておくことで、責務分けや共通化のし易さなどによるメンテナンス性の向上が見込めます。

実装時のポイントについて

ここからは具体的に API クライアントを実装していく際の話になります。

API Client の準備

専用のパッケージが切られている前提で進めますが、まずは Client 用の struct を用意しましょう。
Go はパッケージ名が修飾子としても見れる関係で、 struct 名に関してはシンプルに Client だけでも良いケースが多いです。 使用する側からは hubspot.Clientgithub.Client といった形になり、型もあるので何の Client なのかは判別がしやすいです。

Client には最低限下記のフィールドを持たせるようにしておくと扱いやすくなります。(API バージョンなども共通で使用できる場合は Client 自体に持たせると良いです)

  • 呼び出し時のベースとなる url.URL
  • HTTP 通信を行うための http.Client
type Client struct {
    baseURL *url.URL
    client  *http.Client
}

Client を初期化するための関数を作成

次に、作成した Client を初期化するための関数を作成しましょう。
この関数は必ずしも必要というわけではないですが、デフォルト値での初期化や Functional Option Pattern による Option の指定など、柔軟な実装がしやすくなります。
また、API を利用するにあたって必要な設定項目を引数で明示することで、API の呼び出しまでに何が必要で何がオプションなのかを利用側も判別しやすくなります。 こちらも関数名は New または NewClient くらいのシンプルな形式で問題ありません。(個人的には NewClient をよく使います)

意図しない形式で設定値が指定された場合はこの関数内で error を返してあげると良いでしょう。
baseURL は事前に url.Parse したものを渡しておく実装でも問題ありません。

func NewClient(baseURL string, options ...Option) (*Client, error) {
    u, err := url.Parse(baseURL)
    if err != nil {
        return nil, err
    }
    c := &Client{
        baseURL: u,
        client: &http.Client{},
    }
    for _, option := range options {
        option(c)
    }
    return c, nil
}

type Option func(c *Client)

func WithHTTPClient(httpClient *http.Client) Option {
    return func(c *Client) {
        c.client = httpClient
    }
}

http.Client を使用する際の注意ですが、一般的に http.DefaultClient をそのまま使用するのは避けた方が良いとされています。
詳しくは弊社エンジニアの本番環境でデフォルトの HTTP Client を使ってはいけない理由について を参照ください。

認証周りの実装

先述の通り、API には認証がかかっていることが多いです。
基本的には HTTP の Authorization Header を利用するケースが多いと思いますが、その他にもリクエストのクエリパラメータに付加するケースやリクエストフィールドとして含めるケースなど API によって様々な認証方式が採用されています。
今回はオーソドックスな形として Authorization Header に Basic を指定する形で実装をします。

Go では HTTP 通信を行う際に http.Client.Do を呼び出すことでリクエストが実行されます。 Do された後の内部詳細までは本記事では触れませんが、実際には http.Client.Transport が実行されることで送受信処理が行われます。
http.Client.TransportRoundTripper という interface となっており、ここで送受信前後の認証やトレースなどアプリケーションごとに必要な処理を柔軟にカスタマイズできるようになっています。

type BasicAuthTransport struct {
    userName string
    userPass string
    rt http.RoundTripper
}

func (t *BasicAuthTransport) RoundTrip(r *http.Request) (*http.Response, error) {
    r.SetBasicAuth(t.userName, t.userPass)
    return t.rt.RoundTrip(r)
}

t.rt.RoundTrip(r) を呼び出す前は API リクエスト前、呼び出した後はリクエスト後になるため、処理のイメージもしやすいかと思います。 リトライ処理を入れたい場合は、一度 t.rt.RoundTrip(r) を実行した後、リトライの対象かどうかをステータスコードなどから分岐し、再度 RoundTrip の実行を行う流れになります。
API 呼び出し時の通信をモックしたい場合は、この RoundTrip でダミーのレスポンスを返すようにすると実通信が発生することなくテストすることができます。

ClientBasicAuthTransport を設定することも忘れないようにしましょう。
http.DefaultTransport についても直接使用するのではなく Clone してから使うと良いとされていますが、今回は省略します。 これにより通信時に都度 Authorization: Basic の設定が行われ、呼び出しごとに必要な処理が共通化できます。

func NewClient(baseURL string, options ...Option) (*Client, error) {
    u, err := url.Parse(baseURL)
    if err != nil {
        return nil, err
    }
    c := &Client{
        baseURL: u,
        client: &http.Client{
            Transport: &BasicAuthTransport{
                userName: "YOUR_USERNAME",
                userPass: "YOUR_USERPASS",
                rt:       http.DefaultTransport,
            },
        },
    }
    for _, option := range options {
        option(c)
    }
    return c, nil
}

通信処理の実装

あとはリクエスト・レスポンス周りの処理を記載すればベースは完了です。 Go では http.Gethttp.Post など簡潔にリクエストが行える関数が用意されていますが、こちらは今回使用しません。 都度 http.Request を生成・実行する関数を作成し、その関数をエンドポイントごとに呼んでいく形にすると汎用的な作りにできます。

func (c *Client) NewRequestAndDo(ctx context.Context, method string, apiURL *url.URL, body any) (*http.Response, error) {
    var reqBody []byte = nil
    var err error
    if body != nil {
        reqBody, err = json.Marshal(body)
        if err != nil {
            return nil, err
        }
    }
    req, err := http.NewRequestWithContext(ctx, method, apiURL.String(), bytes.NewReader(reqBody))
    if err != nil {
        return nil, err
    }
    req.Header.Set("Content-Type", "application/json")
    return c.client.Do(req)
}

type User struct {
    // User information.
}

func (c *Client) CreateUser(ctx context.Context, user *User) error {
    res, err := c.NewRequestAndDo(ctx, http.MethodPost, c.baseURL.JoinPath("/users"), user)
    if err != nil {
        return err
    }
    defer res.Body.Close()
    return nil
}

弊社の公開している go-hubspot では、さらにもう1階層 GetPost といった関数を挟んでいます。
この辺りは実装の方針にもよるところかと思いますので、一例として捉えて頂ければと思います。

また、今回はレスポンスのハンドリングを実装していませんが、実際は JSON や XML などのレスポンスを取得することが多いと思います。
この辺りは特に処理が異なる部分かと思いますので、ぜひ実践してみてください。

参考として、go-hubspot ではこの辺りでステータスコードのチェックや Decode 処理を行っています。

余談

余談です。 最近 CTO から google/go-github の実装が面白かったんだよねーという話を聞きました。 GitHub の API クライアントですが、GitHub も様々な API が公開されているので実装自体も結構膨大です。 特に注目したのは github.go で行われている初期化周り で、common として作成した共通の Client を各 Category ごとの Servicetype にキャストして使用しており、なるほどと思いました。 使用側からするとあまり変化はないでしょうが、GitHub API くらいの規模になると Service 1 つでもそれなりの大きさになります。

下記に Service の pointer を取ったコードと実行結果を記載しますが、外部からの Service としての見え方は違っても pointer の参照先は同じなため、Client 全体として使用するメモリの大幅な削減が行えています。
ここまで規模の大きい API クライアントを作成する機会はそうないかもしれませんが、引き出しの 1 つとして覚えておくと良いかもしれません。

package main

import (
    "fmt"
    "github.com/google/go-github/v59/github"
)

func main() {
    cli := github.NewClient(nil)
    fmt.Printf("Admin service: %p\n", cli.Admin)
    fmt.Printf("Projects service: %p\n", cli.Projects)
    fmt.Printf("Repositories service: %p\n", cli.Repositories)
    fmt.Printf("PullRequests service: %p\n", cli.PullRequests)
}
$ go run main.go
$ Admin service: 0xc00012a8e0
$ Projects service: 0xc00012a8e0
$ Repositories service: 0xc00012a8e0
$ PullRequests service: 0xc00012a8e0

おわりに

本記事では、API クライアントの必要性や実装の勘所などをお伝えしました。
今回ご紹介した実装はあくまで方法の 1 つであり、必ずこれが正解というものはありませんが、少しでも実装イメージの助けになれば幸いです。
少し前の記事にはなりますが、実際に HubSpot の API クライアントを作成した際の苦労話が気になる方はHubSpot の Go client を作った話を覗いてみてください。

弊社 Belong ではこういった問題解決やプロジェクト・コードのアーキテクチャについて一緒に考えてくれるエンジニアを募集しています。
少しでも興味を持っていただけた方は https://entrancebook.belonginc.dev/ をご覧ください。