Goで書いたMicroservicesな構成からWeb APIを叩くときに考えたこと

突然ですが、みなさんはGoで何を書いていますか?
Webアプリ?APIサーバー?デーモンアプリケーション?コマンドラインツール?ライブラリ?
WebアプリやAPIサーバーの場合はGAE SE1などのPaaS環境かそれ以外かでも変わってくるでしょう。

ぼくはコマンドラインツール、APIサーバーとそれらで使うライブラリを最近は書いています。
直近だと、Microservices構成をGAE SE Goで構築しています。

さて、Microservices構成を取ると他のWeb APIを叩く機会が増えます。
コンポーネントによってはほぼ全てのエンドポイントで他のコンポーネントに依存することになることも珍しくはないでしょう。
そうなると、Web APIを呼び出すの実装の割合とその重要性が高まります。
つまり、数多のWeb APIを効率的に実装して叩ける、そして見通しの良いコードを書き、それが十分にテストできることが求められます。

Web APIの呼び出し方

Microservices構成からは複数のコンポーネントのWeb API呼び出すケースがあります。
最初に思いつくのはerrgroupを使った実装でしょう。

たとえば、ある構造を持つResourceXstruct相当のJSONを返却するX APIと、ある構造を持つResourceYstruct相当のJSONを返却するY APIを叩くコンポーネントがあったとしましょう。
複数のAPIを叩くとき、そのレイテンシは問題になりがちです。並列でリクエストすることを前提に考えます。

愚直に書くとこんな感じでしょうか。

g, ctx := errgroup.WithContext(ctx)

urls := []string{
    "http://x-service.example.com/xresources/1",
    "http://y-service.example.com/yresources/1",
}

results := make([]*http.Response, len(urls))
for i, u := range urls {
    i, u := i, u // https://golang.org/doc/faq#closures_and_goroutines
    g.Go(func() error {
        req, err := http.NewRequest(http.MethodGet, u, nil)
        if err != nil {
            return nil, err
        }
        req = req.WithContext(ctx)

        res, err := http.Do(req)
        if err != nil {
            return err
        }

        defer res.Body.Close()

        if http.StatusOK > res.StatusCode || res.StatusCode <= http.StatusBadRequest {
            return fmt.Errorf("[%s %s] Unexpected status code %d", res.Request.Method, res.Request.URL.RequestURI(), res.StatusCode)
        }

        results[i] = res
        return nil
    })
}
if err := g.Wait(); err != nil {
    return err
}

var x ResourceX
defer results[0].Body.Close()
err = json.NewDecoder(results[0].Body).Decode(&x)
if err != nil {
    return err
}

var y ResourceY
defer results[1].Body.Close()
err = json.NewDecoder(results[1].Body).Decode(&y)
if err != nil {
    return err
}

// xとyを扱う処理

…愚直ですね!

もちろん、単なるAPI Gatewayなどであればそもそもjson.Unmarshalなどする必要もなく、json.RawMessageにくるんであげればよい場合もありますし、ロジックがそんなになければ interface{}json.Unmarshalしてdproxyで扱えば良いでしょう。

なぜなら、Web APIからのレスポンスというのはそもそもただのバイト列であり、実装がそれに型情報を付けることになるために、どれだけ精密に型マッピングをしたとしても静的な型チェックには限界があるため、レスポンスをあまり加工しない場合は型マッピングの恩恵は少ないためです。そして、その割にはレスポンスの種類は多く、マッピングするべき構造は無限に増えていきます。

しかし、そんな単純でない場合もあり、上記のコードのようにレスポンスのデータをstructに型マッピングして扱いたいことがあります。APIから返ってきたデータを加工したり、データの内容を処理して更に別のAPIを叩くなど、一定以上の複雑さが要求される場合はある程度でも静的な型制約の恩恵を受けられるコードにしたい場合も多いでしょう。

今回は並列で行うWeb APIのレスポンスを型安全に扱うことについて考えました。(長い前置き)

型安全にWeb APIのレスポンスを扱う

先程のコードを改善できないか考えてみましょう。
最大の問題はAPIのリクエストとレスポンスハンドリングが離れすぎているところでしょう。
リクエストとレスポンスハンドリングをまとめて定義して、それを呼び出すという形に変えると改善することができそうです。

このような感じで愚直にAPIを叩く関数を定義して:

func GetX(ctx context.Context, id string) (*ResourceX, error) {
    u := fmt.Sprintf("http://x-service.example.com/xresources/%s", id)

    req, err := http.NewRequest(http.MethodGet, u, nil)
    if err != nil {
        return nil, err
    }
    req = req.WithContext(ctx)

    res, err := http.Do(req)
    if err != nil {
        return nil, err
    }

    defer res.Body.Close()

    if http.StatusOK > res.StatusCode || res.StatusCode <= http.StatusBadRequest {
        return nil, fmt.Errorf("[%s %s] Unexpected status code %d", res.Request.Method, res.Request.URL.RequestURI(), res.StatusCode)
    }

    var x ResourceX
    err = json.NewDecoder(res.Body).Decode(&x)
    if err != nil {
        return nil, err
    }

    return &x, nil
}

func GetY(ctx context.Context, id string) (*ResourceY, error) {
    u := fmt.Sprintf("http://y-service.example.com/yresources/%s", id)

    req, err := http.NewRequest(http.MethodGet, u, nil)
    if err != nil {
        return nil, err
    }
    req = req.WithContext(ctx)

    res, err := http.Do(req)
    if err != nil {
        return nil, err
    }

    defer res.Body.Close()

    if http.StatusOK > res.StatusCode || res.StatusCode <= http.StatusBadRequest {
        return nil, fmt.Errorf("[%s %s] Unexpected status code %d", res.Request.Method, res.Request.URL.RequestURI(), res.StatusCode)
    }

    var y ResourceY
    err = json.NewDecoder(res.Body).Decode(&y)
    if err != nil {
        return nil, err
    }

    return &y, nil
}

実際の実装はこのように書いてみましょうか。

var x *ResourceX
var y *ResourceY

g, ctx := errgroup.WithContext(ctx)
g.Go(func() (err error) {
    x, err = GetX(ctx, "1")
    return
})
g.Go(func() (err error) {
    y, err = GetY(ctx, "1")
    return
})
if err := g.Wait(); err != nil {
    return err
}

// xとyを扱う処理

すっきりした気がしますね。
goroutine毎にアクセスする変数領域を分離することで並列でデータが格納されても困らないようにして並列処理して待ち合わせをすれば良いというスンポーです。
こういうアプローチの方向性で良さそうです。

でも、ちょっとボイラープレートコードとなる手続きが多すぎるとは思いませんでしたでしょうか。
本質的に個別に実装が必要な処理は、リクエストをつくる処理とレスポンスをハンドルする処理であるはずです。
逆に言えば、そこさえインターフェースが定義できればある程度一般化できるものではないでしょうか。

そう考えて、そのHTTPリクエストの流れを一般化して定義して利用できる簡単なフレームワークを作ってみました。
近い概念を知らないので、いったん httpflow という名前を付けています。

httpflowで書いてみる

httpflowを使って書くとこのように記述できます。

APIリクエストの定義:

type ResourceXGetSession struct {
    ID string
    httpflow.JSONResponseHandler
}

func (r *ResourceXGetSession) BuildRequest() (*http.Request, error) {
    u := fmt.Sprintf("http://x-service.example.com/xresources/%s", r.ID)
    return http.NewRequest(http.MethodGet, u, nil)
}

func (r *ResourceXGetSession) ParseBody() (*ResourceX, error) {
    var x ResourceX
    err := r.DecodeJSON(&x)
    if err != nil {
        return nil, err
    }

    return &x, err
}

type ResourceYGetSession struct {
    ID string
    httpflow.JSONResponseHandler
}

func (r *ResourceYGetSession) BuildRequest() (*http.Request, error) {
    u := fmt.Sprintf("http://y-service.example.com/yresources/%s", r.ID)
    return http.NewRequest(http.MethodGet, u, nil)
}

func (r *ResourceYGetSession) ParseBody() (*ResourceY, error) {
    var y ResourceY
    err := r.DecodeJSON(&y)
    if err != nil {
        return nil, err
    }

    return &y, err
}

呼び出す側はこう:

sessionX := &ResourceXGetSession{ID: "1"}
sessionY := &ResourceYGetSession{ID: "1"}

g, ctx := errgroup.WithContext(ctx)
for _, session := range []httpflow.Session{sessionX, sessionY} {
    s := session // https://golang.org/doc/faq#closures_and_goroutines
    g.Go(func() error {
        return httpflow.DefaultAgent.RunSessionCtx(ctx, s)
    })
}
if err := g.Wait(); err != nil {
    return err
}

x, err := sessionX.ParseBody()
if err != nil {
    return nil, err
}

y, err := sessionY.ParseBody()
if err != nil {
    return nil, err
}

// xとyを扱う処理

そこそこスッキリ、適度にDRYに書けているのではないでしょうか。

どうなっているのか

httpflow.SessionBuildRequest() (*http.Request, error)HandleResponse(*http.Response) error を要求するinterfaceになっています。
一般的な処理であればhttpflowの実装を使うことができるのでレスポンスの直接的なハンドリング処理はhttpflow.JSONResponseHandlerに移譲して、実際にデコードするヘルパメソッドをParseBodyで呼び出しているという形になります。(ちなみに、ParseBodyではContent-Typeも見てJSONとみなせないレスポンスではエラーとなるようになっています。)

なにが嬉しいのか

こういうフレームワークの上で実装することにより、実装パターンが限られるために実装の見通しがよくなることが期待できないかと考えています。
また、リクエストを作る処理とレスポンスをハンドルする処理を分けることでテストがしやすくなることが期待できます。
あと、全然関係ないですが Do(*http.Request) (*http.Response, error) を要求するインターフェースを定義しているのでHTTP Clientのモックがしやすいという利点とかがあります。

反面、リクエストを行ってからレスポンスを処理するという手続きとしての見通しの良さは損なわれます。
とはいえ、1つのstructにまとめることでこれは大した問題にはならなくなると考えています。

結局良いの?悪いの?

実装どうでしょうか?というのはこれから試していくところで、実験的なものになります。
正直、めちゃくちゃ良いよと言い切れるものでもないですが、こういうアプローチで上手くやれる可能性は感じている。というくらいの所感です。
そのため、ドキュメントはまだ全然書けていません。それでもよければぜひ触って見てもらえると嬉しいです。

まとめ

Goで書いたMicroservicesな構成からWeb APIを叩くときに考えたことを書きました。
並列で異なるWebAPIにリクエストして、そのレスポンスを型安全に扱うことを上手くやることへの課題感とその解決策の案を示しました。
また、 httpflow というライブラリでその解決策を一般化してみるという試みを紹介しました。
またまだ未完成ですが、よかったらぜひ試してみてください。


  1. Google App Engine Standard Environment 

Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account log in.