LayerX エンジニアブログ

LayerX の エンジニアブログです。

新プロダクトでスモールチームを作りつつ、goaからgo-swaggerへの乗り換えていった話

こんにちは。LayerXから三井物産デジタル・アセットマネジメント(以下MDM)に出向中のサルバ(@MasashiSalvador)です。三体 3とSFマガジンの異常論文特集と心待ちにして生きています。

エンジニア集団が「効率的なアセマネ会社」を作ると嬉しみが深い件 において@peroyuki_ が書いているように、MDMでは「OperationとTechを掛け合わせて、どうアセマネ業務をリデザインするか」という問いに挑戦しています。実際に顧客と向き合い、Operationを回さない限り深い課題や広い全体感を得ることは難しいため、OperationとTechの掛け合わせは一筋縄ではいきません。そのような困難さに対処するため、MDMでは極力小さくリリースすることを心がけています。

時には、いち早い立ち上げを目指すため、コードベース自体を切り離し、2,3名ほどの新しい小さなチームを切り出し、開発をクイックに行うこともあります。事業や業務のブラッシュアップの検証を早く行うためです。また、小さな開発の副次的な効果として、ライブラリやアーキテクチャ、SaaS連携などの技術検証の余地が生まれます。

MDMでは、これまでにいくつものSaaSの検証とプロダクトの開発を行ってきました。プロ投資家向けプロダクトである「あさどれ不動産」 もその1つです。あさどれ不動産に関しても、上述のように小さな開発チームを切り出し、短期間でのリリース(企画からβ版公開まで約2ヶ月)を行いました。

本記事は、あさどれ不動産リリース時に、バックエンドのAPIのフレームワークをgoa(v3)からgo-swaggerへの切り替えを行った経緯についてお話します。

goaとは? go-swaggerとは?差は?

goa(v3)とは、下記のような独自DSLを書くことで、httpリクエストの処理(バリデーションなど)やレスポンスを返すコードを自動生成できるフレームワークです。実装者はビジネスロジックの実装に集中すれば良いことになります。v3はGRPCのエンドポイントの自動生成にも対応しており、それなりに便利です。goaのDSLでAPIの定義を書くと、定義に対応した swagger.yaml が生成されます。swagger.yamlを元にAPI定義をswaggerUIなどで共有することもできます。

// https://goa.design/ より引用
var _ = Service("calc", func() {
    Description("The calc service performs operations on numbers.")

    Method("add", func() {
        Payload(func() {
            Field(1, "a", Int, "Left operand")
            Field(2, "b", Int, "Right operand")
            Required("a", "b")
        })

        Result(Int)

        HTTP(func() {
            GET("/add/{a}/{b}")
        })

        GRPC(func() {
        })
    })

    Files("/openapi.json", "./gen/http/openapi.json")
})

go-swagger を利用する場合は独自DSLではなく、openAPI(swagger)でAPIを定義し、swagger.yamlからAPIのリクエスト、レスポンスを返すコードを自動生成します。

実際、現在のMDMのプロダクトでは、swaggerのAPI定義ファイルを下記のように分割し

- definitions
   - index.yaml
   - Foo.yaml
   - Bar.yaml
- paths
   - index.yaml
   - hoges/list.yaml
          /detail.yaml
- root.yaml

下記のようなコマンドでswaggerを結合& validateし、サーバサイドのコードを自動生成しています。

yarn swagger-merger -i docs/swagger_files/root.yaml -o swagger.yaml
swagger validate swagger.yaml
swagger generate server -a mdm -A mdm --exclude-main --strict-additional-properties -t gen -m respmodel -P model.User -f ./swagger.yaml

生成されるrouting周りのコード

// api config.
    api.JSONConsumer = runtime.JSONConsumer()
    api.JSONProducer = runtime.JSONProducer()
    authMiddleware := authMidldeWare{buRepo: buRepo}
    api.AuthorizationAuth = authMiddleware.exec

    // swaggerで定義したエンドポイントのパスとハンドラーを紐付ける
  // ハンドラーのHandle関数の型定義が自動生成される
    api.GetHcHandler = mdmbackoffice.GetHcHandlerFunc(func(params mdmbackoffice.GetHcParams) middleware.Responder {
        return mdmbackoffice.NewGetHcOK().WithPayload(nil)
    })
  
  // swaggerで定義したエンドポイントのパスとハンドラーを紐付ける
  // ハンドラーのHandle関数の型定義が自動生成される
    smplHandler := sampleHandler.Sample{}
    api.GetSampleHandler = mdmbackoffice.GetSampleHandlerFunc(func(params mdmbackoffice.GetSampleParams, bu *model.User) middleware.Responder {
    // 時と場合により、userの権限チェックのコードを挟み込む
        return smplHandler.Handle(params, bu)
    })

レスポンスの型も自動生成されます。

# /project_root/gen/respmodel/
- foo.go # Fooのresponeの型
- bar.go

goaとgo-swaggerはどちらも便利ですが

  • API定義の書きやすさ、柔軟性
  • middlewareの差し込みの柔軟性
  • Cookieの扱いやすさ
  • コード生成の柔軟性
    • goa(v3)の吐き出すswaggerがopenapi 3.0なので、コード生成系が未成熟(バックエンドもフロントエンドも)

などの点を加味すると、われわれのユースケースではgo-swaggerの方が適していました。

MDMのプロダクトでは、バックエンドとフロントエンド間のセッションのやり取りにCookieを用いていますし、特定の秘密保持契約(Confidential Agreement = CA)を結んだ顧客にのみ閲覧させたいリソースのアクセス管理に、Cloudfrontの署名付きCoookieを利用しているため、Cookieの取り扱いを自然に行えないgoaを使い続けるのが苦しくなりました。

Announcing Goa v3.2.0Add ability to design HTTP cookies #1717 に記載されているようにCookieのDSLは存在しますが、柔軟な読み書きはできません。responseのencoderを独自実装し、特定の条件下でのみ、デフォルトのコードがresponseを返す前にresponseを上書きするようなコードを書くワークアラウンドを実装しましたが、encoder内でcontextを受け取れないなど、handlerレイヤとの情報連携がうまくいきません。

チームメンバーがDSLを調査する時間的コストも膨らんできたため、go-swaggerへの以降を決めました。

go-swaggerへの移行によるコンポーネント間及びSaasとの連携

go-swaggerへ移行することで、openapi 2.0のコード生成系が使えるようになりました。これにより、フロンエンドもコード生成の恩恵が受けられるようになりました。

// package.json
"scripts": {
    "gen-webapp-interface": "rm -rf types/response && openapi-generator-cli generate -g typescript-axios -i swagger.yaml -o ./types/response"
}

生成されたAPIクライアントをフロントエンドで利用

methods: {
    async getHogeList (page: number) {
      const api = new DefaultApi()
      const userId = this.currentUserId
          // 自動生成されたapiのクライアントコードを呼び出す。
      const result = await api.getHoges(userId, page).catch((e) => {
        console.error(e)
      })
      if (!result) {
        alert('hoge')
        return
      }
}

自動生成されたAPIクライアントの関数名が長かったり、モデル名Hogeに対して struct Hoge1などとナンバリングされてしまう点に読んでいるとたまに違和感を感じますが、人間が書いているコードではないので、慣れればそういうものだと思えます。

MDMでは各コンポーネントのAPIをswaggerで定義し、APIクライアントの自動生成やレスポンス型の自動生成機構を利用することで、連携にかかる手数を減らしています。また、BoxやDocusignなど公式のGoのSDKが存在しないSaaSとの連携においても、彼らが公開しているswaggerから生成したapiクライアントを利用しています(独自のクライアントを開発せずに済む、OAuthトークンのリフレッシュ機構だけ自前でアレンジが必要)

f:id:masashisalvador:20210423173737p:plain
自動生成クライアントコードに依るコンポーネント間連携

現状の課題・今後の展望

swagger(open api2.0)を用いたコード生成は便利なのですが、swagger側の型とGo側の型の対応付がいまいちなのと、swaggerで生成したモデルとxoで生成したモデル(DBの各テーブルのレコードに対応)、xoで生成したモデルを埋め込み組み合わせたモデル(emmbed model) の間の変換にそれなりに神経を使ってしまっています。

// api/emodel/hoge.go
type Hoge struct {
   Hoge *model.Hoge
   HogeImages *model.HogeImage `gorm:"foreignkey:HogeID;references:id"`
} 

// api/gen/hoge.xo.go # 自動生成
//         hogeimage.xo.go

import (
    "database/sql"
    "encoding/csv"
    "time"

    "github.com/LayerXcom/mdm/api/lib/csvconv"
)

const (
    TableHoge = "hoges"
    // ColumnHoge* represents a column name.
    CHogeID = "id"
        ....

...

api/gen/respmodel/hoge.go

// swagger:model Hoge
type Hoge struct {

    // access description
    // Required: true
    Description *string `json:"description"`

    // address
    // Required: true
    Address *string `json:"address"`
}
...

また、openapi 3.0とコード生成機構の未成熟さ(Goのクライアント生成は、型定義が欠落していてビルドエラーが出るレベル)を鑑みると、今後のメンテナビリティに若干の不安を感じます。そのため、MDMでも、GraphQLによるLayerX インボイス ワークフロー機能のモデル設計 にあるようにGraphQL及びコード生成機構の利用へのシフトを睨んでいます。

おわりに

スモールなチームで素早く開発するのが好きな方も、素早く動きつつも手堅く守りを固めるのが好きな方も、MDMを含むLayerXでは絶賛仲間を募集中です。 100xのその先へ、 TechnologyとOperationの <harmony /> を実現したい方、行きましょう!向こう側へ!

まずはお話だけでも!お待ちしております! herp.careers

herp.careers