なまえは まだ ない

思いついたことをアウトプットします

フラーで採用しているWebアプリケーションフレームワーク Goa の紹介

はじめに

この記事は、フラー株式会社 Advent Calendar 2021の13日目の記事です。 12日目は@seto_inugamiで「技術選定を疎かにしたツケを払ったお話し 」でした。

さて、フラーではデジタルパートナー事業の一環としてこれまで何本かのスマートフォンアプリケーションをリリース、運用しています。当然ながらアプリが利用するデータはサーバーで管理されており、ほとんどのアプリでそのバックエンド(Web APIやインフラ)もフラーが開発、運用しています。

今回はフラーがGoのWebアプリケーションフレームワークとして採用しているGoaの紹介と、フラー流の使い方について紹介します。

Goaとは

GoaはAPIデザインをDSLで記述すると、Web APIホスティングするためのベースとなる部分(ルーティングやリクエストボディの構築など)や、APIクライアントを開発するためのベースとなる部分を自動生成してくれるフレームワークです。

goa.design

Goaの特徴

Goaは独自定義のDSLをベースにAPIサーバー、APIクライアント、Swagger、果てはテストコードまで生成することができます。とても機能が多く、GoのWebフレームワークとしてメジャーなGinEchoと比べたらとても重いフレームワークだと思います。

なぜGoaなのか?

Goaの特徴のひとつであるSwaggerを生成できるという点に魅力を感じているからです。

フラーではサーバーとクライアントの間のWeb API仕様はSwaggerによって共有しています。 開発をスムーズに進めるためには、Web APIの仕様と実装の同期を保てるか(=Swaggerを最新の情報に保ち続けられるか)がとても重要なポイントとなります。

GoaはひとつのDSLからアプリケーションコードとSwaggerファイルが同時に生成されるので、仕様と実装が一致した状態を簡単に保つことができます。また、Swaggerも自動生成なので、ニンゲンがあのYAMLJSONを記述するより遥かに正確・確実に更新することができます。

よくGoのWebアプリケーション界隈では「ドキュメントと実装の整合性をどう保つか」みたいな問題が発生して、go-swaggerをはじめとするSwaggerと連携できるライブラリと、GinやEchoなどのWeb APIフレームワークをどう組み合わせるか、みたいな苦労をしている印象があります。 GoaはWeb API開発とドキュメンテーションをセットで扱えるため、そういったライブラリ/フレームワークの相性問題を考える必要がありません。

Goaの開発事情

現在Goaはv3とv1の2ラインがあり(なぜかv2は無かったことにされている)、メインの開発はv3で行われています。フラーでは昔からv1を使っていたのですが、v3リリース後も(2021年12月現在)移行に踏み切れていません。完全に下火になってしまった本家にコントリビュートし続けることに疲れた我々は、ついに弊社テックリード独自カスタマイズを加えたfork版を使うことにしました。

github.com

完全に本家と袂を分かったことで、完全にv3にマイグレーションするきっかけを見失ってしまいました。。どうしましょうかね。要検討です。

実際にGoaで開発してみる

文章でつらつら書いててもアレなので、実際にGoaでWeb APIを開発するときのサンプルコードを用意してみました。

github.com

GET /v1/users/{user_id} という単一のエンドポイントを持つシンプルなコードです。アプリケーションを起動して次のようにリクエストをすると、予め登録された情報が返ってきます。

$ curl http://127.0.0.1:8080/v1/users/1

{
  "created_at": "2021-09-25T11:22:33Z",
  "family_name": "田中",
  "given_name": "太郎",
  "user_id": 1
}

自動生成されたSwaggerファイルをSwagger UIで表示すると、このエンドポイントの仕様を確認することができます。

f:id:furusax0621:20211212230730p:plain
Goaから生成されたSwagger

ディレクトリ構成

Goaに関係する部分だけ抜き出すと大体こんな感じになってます。

webapi/
├── app     # APIを実装するための自動生成コード
├── design  # DSL
├── gen     # DSLから各種コードを生成するコマンド
├── swagger # Swaggerファイル
├── user.go
├── user_test.go
└── webapi.go

注目すべきは gen ディレクトリです。

gen にはDSLからコードを生成するためのプログラムが入っています。次のようなコードです。

package main

import (
    _ "github.com/furusax0621/goa-sample/webapi/design"

    "github.com/shogo82148/goa-v1/design"
    "github.com/shogo82148/goa-v1/goagen/codegen"
    genapp "github.com/shogo82148/goa-v1/goagen/gen_app"
    genswagger "github.com/shogo82148/goa-v1/goagen/gen_swagger"
)

func main() {
    codegen.ParseDSL()
    codegen.Run(
        genswagger.NewGenerator(
            genswagger.API(design.Design),
        ),
        genapp.NewGenerator(
            genapp.API(design.Design),
            genapp.OutDir("app"),
            genapp.Target("app"),
        ),
    )
}

このコードを go generate で実行されるよう設定しておき、DSLを更新したら go generate ./... と実行することで自動生成コードを更新する、といった手法で開発しています。

GoaはDSLから各種コードを生成するときに通常 goagen というコマンドをインストールして使います。 goagen を使わないようにしているのは、弊社が複数のアプリを開発・運用しているという事情が関係しています。

通常、1台の開発環境にインストールできる goagen コマンドはひとつだけです。 goagen をビルドしたGoaのバージョンとDSLが依存しているGoaのバーションは一致している必要があるので、複数のプロジェクトが同時稼働しているとバージョン管理がとても困難になってしまいます。

プロジェクト間やエンジニア間での混乱を防ぐため、Goの標準コマンドである generate で管理できるような構成にしているというわけです。

APIを追加してみる

では、新しいエンドポイントとしてユーザーを登録するための POST /v1/users というエンドポイントを追加してみましょう。

まずはDSLを記述します。実装したいアクション、ルーティングパス、パラメータ、レスポンスの種類などを記述していきます。

Action("post", func() {
    Routing(POST("")) // /v1/users というパスに POST メソッドのエンドポイントを作る
    Payload(func() { // given_name, family_name というパラメータを必要とするペイロードを受け付ける
        Member("given_name", String, func() {
            Description("名前")
            Example("太郎")
        })
        Member("family_name", String, func() {
            Description("姓")
            Example("田中")
        })

        Required("given_name", "family_name")
    })
    Response(Created, UserMedia) // Created(201)レスポンスを返し、ボディにユーザー情報が入る
})

DSLを記述したら go generate ./... を実行して、自動生成コードやSwaggerを更新します。

$ go generate ./...

swagger
swagger/swagger.json
swagger/swagger.yaml
app
app/contexts.go
app/controllers.go
app/hrefs.go
app/media_types.go
app/user_types.go
app/test
app/test/user_testing.go

この時点でSwaggerファイルも自動的に更新されます。便利ですね。

f:id:furusax0621:20211212235304p:plain
更新後のSwagger

自動生成したコードを利用してControllerにメソッドを追加します。 app パッケージには、実装すべきControllerのインターフェースが宣言されています。

// https://github.com/furusax0621/goa-sample/blob/fac893d35c4db6384c4a7f088d2bbbe3cf6d64eb/webapi/app/controllers.go#L27-L32
// UserController is the controller interface for the User actions.
type UserController interface {
    goa.Muxer
    Get(*GetUserContext) error
    Post(*PostUserContext) error
}

リクエストのパラメータやペイロードで宣言した変数や、宣言したレスポンスを返すためのメソッドは app パッケージの (Action名)(Resource名)Context に内蔵されています。

// https://github.com/furusax0621/goa-sample/blob/fac893d35c4db6384c4a7f088d2bbbe3cf6d64eb/webapi/app/contexts.go#L60-L66
// PostUserContext provides the user post action context.
type PostUserContext struct {
    context.Context
    *goa.ResponseData
    *goa.RequestData
    Payload *PostUserPayload
}

Controllerはこれを引数として受けつつ、ここから情報を受け取りながらビジネスロジックを書いていきます。

実際に実装した例がこちらになります。

github.com

Goaのここがつらい

初期導入コストが高い

これはこのぐらい大きなフレームワークならあるあるだと思うのですが、そもそもGoaの学習コストがとても高いです。アプリケーションの記述方法が決まってるだけでなく、独自定義のDSLも覚える必要があります。

フラーのサーバーサイドエンジニアとして入社すると、まずGoaとお友達にならなくてはなりません。最近入社した方は、オンボーディング期間中に一回Goaを使えるようにするためのチュートリアルをやってもらうようにしています。

また、前準備として記述しなくてはいけないコード量が多いので、最低限のAPIサーバーを立ち上げるだけでもそれなりの時間を要します。「ちょっと試しにAPI作ってみよ」という軽いノリで導入できるものではありません。

コードの記述量も多くなりがち

今回紹介したシンプルなコードですらそれなりのコーディングを要求されますし、何より自動生成されるコード量がとても多いです。 ほとんどはレビュー対象から外れるとは言え、うっかり変更が10,000行超えるPull Requestなんかが簡単に発生してしまうので、 レビュワーの精神力をごっそり持っていきがちです。

swagger.json が確定コンフリクトする

Goaで生成されるSwaggerファイルにはYAMLJSONフォーマットがあるのですが、この内JSONはインデントはおろか改行すらされず、一行にすべての内容がガッと記述されてしまいます。

これの何が困るって、複数のAPIを同時並行で開発していると、確定でコンフリクトを起こしてしまうんですね。複数人で開発を進めていると毎度一回mergeするなりrebaseするなり対応しなくてはいけないので、非常に面倒です。YAMLファイルさえ生きていればJSONファイルは不要なので、フラーでは大抵 swagegr.json をGit管理から外すことで対応しています。

github.com

おわりに

というわけで、フラーで採用しているGoaフレームワークに簡単に触れてみました。

Goaにはまだまだたくさんの機能があり、慣れれば色々なユースケースに対応したAPIを開発することができます。 Goでバリバリ活躍したい方、Goaフレームワークに対する理解をもっと深めたい方、ぜひ私達と一緒に働きましょう!!フラーは通年でサーバーサイドエンジニアを募集しています!!!

明日は@SaturnR7さんで「何かを」です。お楽しみに!