azihsoyn's blog

技術のこととか釣りの事とか(書けたらいいなぁ)

goaでマイクロサービス間のI/Fを共有する

最近会社のAPIサーバーがgoaを使って実装されることが多くなってきています。設計とドキュメンテーションが同時にできる上にボイラープレートの省略(バリデーションとかレスポンス定義とか)ができるので使わない手はないですね。

それはいいのですが、今まで単体のAPIサーバーでgoaを使っていた時には気づかなかった問題に遭遇しました。

下のような構成を考えてみます。

client -> gateway API -> user API

gatewayAPIが内部のuser APIからデータを取得してクライアントにレスポンスを返すとします。マイクロサービスアーキテクチャでよくある構成だと思います。user APIのレスポンスだけをclientに返すのであればproxyすればいいのですが、gatewayで構造を変える場合はいい感じにレスポンスの構造体に詰める必要があります。

// こんなイメージ
type User struct {
    ID int
    Name string
}

type Response struct {
    Page int
    Users []*User // この部分はマイクロサービス通信
}

愚直にuser APIのレスポンス構造体をgateway側にコピペ定義してあげてもいいのですが、ネストした構造だったり更新が多かったりすると辛いところです。また、どうせBのレスポンスを返すんだからAny (interface{})でいいじゃんという気持ちもあるのですが、なんでも突っ込めるフィールドになってしまうのが悩ましいところです。

そこでgoaを活かした解決策がないか考えてみました。

1. user APIのdesignをimportする

goaのdesign DSLはgoで定義します。つまりgo getできるということです。 user APIのdesignをgo getしてgatewayのdesignで同じものを使うようにしてgenerateすれば同じ構造のstructを作ることができます。gRPCのprotoファイルのような使い方ですね。 goは1.8から同じstructの構造であれば簡単にキャストできるようになったので変換もすぐできます。 Go 1.8 Release Notes - The Go Programming Language

// user api
package media_types

import (
    . "github.com/goadesign/goa/design"
    . "github.com/goadesign/goa/design/apidsl"
)

var User = MediaType("application/vnd.user+json", func() {
    Description("user")
    Attributes(func() {
        Attribute("id", Integer, "ID of user", func() {
            Example(1)
        })
        Attribute("name", String, "Name of user", func() {
            Example("test")
        })
        Attribute("created_at", DateTime, "Date of creation")
        Required("id", "name", "created_at")
    })

    View("default", func() {
        Attribute("id")
        Attribute("name")
        Attribute("created_at")
    })
})
// gateway
package design

import (
    . "github.com/azihsoyn/goa_microservice_sample/user_service/design/media_types"
    . "github.com/goadesign/goa/design"
    . "github.com/goadesign/goa/design/apidsl"
)

var Complex = MediaType("application/vnd.complex+json", func() {
    Attributes(func() {
        Attribute("users", CollectionOf(User)) // user_serviceのUserを使う
        Required("users")
    })

    View("default", func() {
        Attribute("users")
    })
})

2. user APIでgenerateしたモデルを使う

1でもある程度問題は解決できるのですが、同じ構造のstructの配列はそのままcastできません。

package main

type A struct {
    ID int
}

type B struct {
    ID int
}

type ArrA []*A

type ArrB []*B

func main() {
    a := []*A{&A{1}, &A{2}, &A{3}, &A{4}, &A{5}}
    var b ArrB
    b = ArrB(a)
}

// ./cast.go:18:10: cannot convert a (type []*A) to type ArrB

愚直に変換してあげてもいいのですが、どうせならuser APIの構造体をそのまま使いたいところです。 以前

azihsoyn.hatenablog.com

というエントリで書いた方法と同じなのですが、Metadata DSLを使います。

var Complex = MediaType("application/vnd.complex+json", func() {
    Attributes(func() {
        Attribute("users", CollectionOf(User), "list of user", func() {
            Metadata("struct:field:type", "app.UserCollection", `github.com/azihsoyn/goa_microservice_sample/user_service/app`)
        })
        Required("users")
    })
})

と書くと以下のように

import (
    "github.com/azihsoyn/goa_microservice_sample/user_service/app"
    "github.com/goadesign/goa"
    "time"
)

type Complex struct {
    // list of user
    Users app.UserCollection `form:"users" json:"users" xml:"users"`
}

と、user APIの型そのままでgenerateすることができます。 これでgateway → user API間のstructの構造とレスポンスの構造を再利用できるようになりました。

ただこの方法も幾つか制限があり、

  • design, appがinternalパッケージになってると使えない
  • 1つのAPIから接続するマイクロサービスの数が2つ以上になるとappパッケージがバッティングする(Metadata DSLはnamed importできない模様)
    • ので、generateする際のディレクトリ名を変更する(デフォルトapp)
  • designをimportするときにapi_definitionが重複するとgenerateできないのでimportされる側はパッケージを分ける必要がある
  • designをimportするときにMediaTypeのidentifierが重複するとgenerateできないので識別子はユニークにする

と気軽には使えないかもしれません。

せっかくgoa実装のAPIが増えてきたので連携もいい感じにできたら嬉しいんですけどね。。

こんな感じにやってるなどの知見があればアドバイスいただけるとうれしいです。

素直にI/F用のパッケージ置き場を作ったほうがいいんだろうか🤔

// こんなイメージ?
gateway -(import)-> I/F packages <-(import)- user api

サンプルはこちらに置いておきます。

github.com