GolangでAPI Clientを実装する

特定のAPIを利用するコマンドラインツールやサービスを書く場合はClientパッケージ(SDKと呼ばれることも多いが本記事ではClientと呼ぶ)を使うことが多いと思う.広く使われているサービスのAPIであれば大抵はオフィシャルにClientパッケージが提供されている.例えば以下のようなものが挙げられる.

特別使いにくい場合を除けば再実装は避けオフィシャルに提供されているものを使ってしまえばよいと思う(まともなものなら互換性などをちゃんと考慮してくれるはずなので).一方で小さなサービスや社内のサービスの場合はClientは提供されておらず自分で実装する必要がある.

自分はこれまでいくつかのAPI client パッケージを実装してきた.本記事ではその実装の自分なりの実装パターン(各人にやりかたはあると思う)といくつかのテクニックを紹介する.

Clientとは何か?

API ClientとはAPIのHTTPリクエストを(言語の)メソッドの形に抽象化したものである.例えば https://api.example.com/users というエンドポイントからユーザ一覧を取得できるとする.API Clientは具体的なHTTPリクエスト(メソッドやヘッダの設定,認証など)を抽象化し ListUsers()のようなメソッドに落とし込んでその機能を提供する.

なぜ Client を書くべきか?

そもそも共通化できることが多いため.それぞれのリクエストは独立していても例えばユーザ名やパスワード,Tokenなどは基本は同じものを使うし,ヘッダの設定なども共通して行える.またテストも書きやすくなる.

いつClientを書くべきか?

複数のエンドポイントに対してリクエストを投げる必要がある場合はClientを書いてしまえばいいと思う.例えば,単一のエンドポイントに決まったリクエストを投げるだけであればClientをわざわざ書く必要はない.自分の場合は3つ以上エンドポイントがあればClientをさっと書いていると思う.

基本的な実装パターン

以下では https://api.example.com (存在しない)のAPI Client パッケージを実装するとする.このAPIでは/usersというパスでユーザの作成と取得,削除が可能であるとする.また各リクエストにはBasic認証が必要であるとする.

パッケージの名前をつける

https://golang.org/doc/effective_go.html#package-names

上のEffective Goにも書かれているようにパッケージ名は shortかつconciseかつevocativeのものを選択する.API Clientであればそのサービス名がそのままパッケージ名になると思う.例えば PagerDutyであれば pagerdutyがパッケージ名になる.

名前については以下でもいくつか述べる.

Client(struct)を定義する

まずはClient structを実装する.Clientのフィールドにはリクエスト毎に共通に利用する値を持たせるようにする.HTTP APIの場合は例えば以下のようなものが考えられる:

  • url.URL - リクエスト毎にパスは異なるがベースのドメインは基本的には共通になる.例えば今回の場合は https://api.example.com は共通である
  • http.Client - 各HTTP リクエストにはnet/httpパッケージのClientを用いる.これは同じものを使い回す
  • 認証情報 - 認証に利用する情報も基本的には同じになる.例えば今回の場合はBasic認証に必要なユーザ名とパスワードは共通である.他にもTokenなどが考えられる
  • log.Logger - デバッグの出力も共通である.自分はグローバルなlogを使うよりも明示的に指定するのを好む

今回の場合は以下のように実装できる.

type Client struct {
    URL        *url.URL
    HTTPClient *http.Client

    Username, Password string

    Logger *log.Logger
}

importのように関連するフィールドでグールピングして記述しておくと読みやすい.

また名前はClientで十分である.例えばPagerDutyのAPI Clientを書いているときにPagerDutyClientという名前にしない.上述したように既にそれはパッケージ名で説明されるはずである.pagerduty.PagerDutyClientでは冗長になる.簡潔な名前を心がける.

コンストラクタを定義する

次にClientのコンストラクタを定義する.例えば今回の場合は以下のようになる.

func NewClient(urlStr, username, password string, logger *log.Logger) (*Client, error) 

コンストラクタ内では必須の情報が与えられているか,その値は期待するものかをチェックし,そうでなければエラーを返す(以下ではpkg/errrosを使っている).

if len(username) == 0 {
    return nil, errors.New("missing  username")
}

if len(password) == 0 {
    return nil, errors.New("missing user password")
}
parsedURL, err := url.ParseRequestURI(urlStr)
if err != nil {
    return nil, errors.Wrapf(err, "failed to parse url: %s", urlStr)
}

必須でないものはデフォルト値を準備しておきそれを使う.例えば今回の場合はLoggerは必須ではない.

var discardLogger = log.New(ioutil.Discard, "", log.LstdFlags)
if logger == nil {
    logger = discardLogger
}

http.Clientもコンストラクタ内で生成しClientにセットしておく.デフォルトを使っても良いしProxyや各Timeoutを変更したい場合は独自で準備しても良い(http.DefaultClientの値はバージョンによって変わるので注意する.独自の設定を使っているとバージョン毎の新しい設定に追従できないことがある).

共通メソッドを定義する1

API Clientでは多くの共通メソッドを定義できる.代表的なのはhttp.Requestを作成するメソッドである.http.NewRequestを使いhttp.Requestを生成しBasic認証の設定やヘッダの設定といった共通の処理を行う.

例えば今回の場合は以下のように書ける.

var userAgent = fmt.Sprintf("XXXGoClient/%s (%s)", version, runtime.Version())

func (c *Client) newRequest(ctx context.Context, method, spath string, body io.Reader) (*http.Request, error) {
    u := *c.URL
    u.Path = path.Join(c.URL.Path, spath)

    req, err := http.NewRequest(method, u.String(), body)
    if err != nil {
        return nil, err
    }

    req = req.WithContext(ctx)

    req.SetBasicAuth(c.Username, c.Password)
    req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
    req.Header.Set("User-Agent", userAgent)

    return req, nil
}

引数はHTTP メソッドとパス名,そしてリクエストのBody(io.Reader)である.他にも引数が増える場合はRequestOptsのようなstructを別途準備して渡すようにするとインターフェースの変更がなくなり,呼び出し側の変更コストをなくすことができる(ref. Tips for Creating a Good Package).

さらにGo1.7以降であるならcontext.Contextをセットするようにすればモダンになる(ref. context パッケージの紹介

またUser-Agentをセットしておくとサーバ側に優しい.ClientのバージョンやGoのバージョンをつけておくとより便利である.

共通メソッドを定義する2

多くのAPIはレスポンスとしてJSONやXMLなどを返す.これらをGolangのstructへDecodeする処理はAPI Clientでは共通の処理になる.例えばJSONの場合は以下のような関数を準備しておくと良い.

func decodeBody(resp *http.Response, out interface{}) error {
    defer resp.Body.Close()
    decoder := json.NewDecoder(resp.Body)
    return decoder.Decode(out)
}

ちゃんとDecoderを使う.ioutil.ReadAllなどを使うとメモリ効率もパフォーマンスも良くない(ref. Crossing Streams: a love letter to Go io.ReaderGo Walkthrough: io package).

各メソッドを定義する

最後にこれらを使って各メソッドを定義する.今回の場合は以下のようなメソッドが考えられる.外部からリクエストをキャンセルできるようにcontext.Contextを渡す.

func (c *Client) GetUser(ctx context.Context, userID string) (*User, error)
func (c *Client) CreateUser(ctx context.Context, name string) error
func (c *Client) DeleteUser(ctx context.Context, userID string) error

例えばGetUserは以下のように実装できる.

func (c *Client) GetUser(ctx context.Context, userID string) (*User, error) {
    spath := fmt.Sprintf("/users/%s", userID)
    req, err := c.newRequest(ctx, "GET", spath, nil)
    if err != nil {
        return nil, err
    }

    res, err := c.HTTPClient.Do(req)
    if err != nil {
        return nil, err
    }
    
    // Check status code here…

    var user User
    if err := decodeBody(res, &user); err != nil {
        return nil, err
    }

    return &user, nil
}

リクエストメソッドは上記で定義した共通メソッドでhttp.Requestを作成しClientHTTPClientを利用して実際のリクエストを実行する.そしてレスポンスのDecodeを行う.異なるのはパス名やリクエストBodyである.ステータスコードのチェックもここで行う.

いくつかのテクニック

以下では自分が実践しているいくつかのテクニックを紹介する.

insecureオプション

Clientのコンストラクタに渡す値としてinsecurebool)はよく使う.例えば社内の古いサービスやステージング環境だと自己署名証明書を使っている場合がありInsecureSkipVerifyを有効にする必要がある.insecureはこの設定に使う.例えば以下のように切り替えを行う.

tlsConfig := tls.Config{
    InsecureSkipVerify: insecure,
}

transport := *http.DefaultTransport.(*http.Transport)
transport.TLSClientConfig = &tlsConfig

c.HTTPClient = &http.Client{
    Transport: &transport,
}

Symmetric API testing

Symmetric API Testing

API Clientを書くときもテストは大切である.もっとも簡単で確実なのは実際にAPIにリクエストを投げてレスポンスが期待するものであるかを確認する方法である.しかしAPIによってはリクエストに制限があるし,RTTを考えるとテストの時間も長くなる,またオフラインでテストすることができない.これを解決するのがGopher Academyで紹介されていたSymmetric API Testingである.

詳しくは上記のリンクを読むのが良いが,簡単にいうとAPIのレスポンスをローカルのディスクに保存して次回からそれを使ってテストする方法である.例えば上で紹介したdecodeBodyを以下のように変更する.

func decodeBody(resp *http.Response, out interface{}, f *os.File) error {
    defer resp.Body.Close()

    if f != nil {
        resp.Body = ioutil.NopCloser(io.TeeReader(resp.Body, f))
        defer f.Close()
    }

    decoder := json.NewDecoder(resp.Body)
    return decoder.Decode(out)
}

io.TeeReaderを使いos.Fileが渡された場合にDecodeと同時にレスポンスをファイルに書き込む.

実際にテストを行うときはこのファイルをhttptestで返すようにする.例えば/users/1のレスポンスをtestadata/users-1.jsonに保存したとする.

muxAPI := http.NewServeMux()
testAPIServer := httptest.NewServer(muxAPI)
defer testAPIServer.Close()

muxAPI.HandleFunc("/users/1", func(w http.ResponseWriter, r *http.Request) {
    http.ServeFile(w, r, "testdata/users-1.json")
})
...

これで実際のAPIリクエストを避けてテストを行うことができる.

ネットワーク関連のデバッグ

Clientを定義するときにlog.Loggerを渡すようにしたがこれはデバッグに用いる.API Clientでもデバッグは重要である.リクエストBodyなどはもちろんだが,以下のようにネットワークに関わる情報をデバッグとして出力しておくと問題が起こったときに解決しやすい.

Goのhttp.Clientはデフォルトで環境変数(http_proxyhttps_proxy)を参照しProxyを設定する.複雑なネットワーク環境から使われた場合結局Proxyが問題の原因になってることが多い.そのため以下のようにProxy情報も基本はデバッグで出力されるようにしておくと良い.上でいうとnewRequestにこれは書ける.

proxy := "no"
if proxyURL, _ := http.ProxyFromEnvironment(req); proxyURL != nil {
    proxy = proxyURL.String()
}
c.Logger.Printf("[DEBUG] request proxy: %s", proxy)

HTTPリクエストのどこで時間がかかっているかわかると問題の切り分けがしやすい.tcnksm/go-httpstatを使うとDNSLookupやTLSHandshakeのレイテンシを簡単に測定することができる.詳しくはTracing HTTP request latency in golang に書いた.

まとめ

API Clientは最初に書くGolang パッケージとしても良いと思う.