はじめに
この記事は、フラー株式会社 Advent Calendar 2021の13日目の記事です。 12日目は@seto_inugamiで「技術選定を疎かにしたツケを払ったお話し 」でした。
さて、フラーではデジタルパートナー事業の一環としてこれまで何本かのスマートフォンアプリケーションをリリース、運用しています。当然ながらアプリが利用するデータはサーバーで管理されており、ほとんどのアプリでそのバックエンド(Web APIやインフラ)もフラーが開発、運用しています。
今回はフラーがGoのWebアプリケーションフレームワークとして採用しているGoaの紹介と、フラー流の使い方について紹介します。
Goaとは
GoaはAPIデザインをDSLで記述すると、Web APIをホスティングするためのベースとなる部分(ルーティングやリクエストボディの構築など)や、APIクライアントを開発するためのベースとなる部分を自動生成してくれるフレームワークです。
Goaの特徴
Goaは独自定義のDSLをベースにAPIサーバー、APIクライアント、Swagger、果てはテストコードまで生成することができます。とても機能が多く、GoのWebフレームワークとしてメジャーなGinやEchoと比べたらとても重いフレームワークだと思います。
なぜGoaなのか?
Goaの特徴のひとつであるSwaggerを生成できるという点に魅力を感じているからです。
フラーではサーバーとクライアントの間のWeb API仕様はSwaggerによって共有しています。 開発をスムーズに進めるためには、Web APIの仕様と実装の同期を保てるか(=Swaggerを最新の情報に保ち続けられるか)がとても重要なポイントとなります。
GoaはひとつのDSLからアプリケーションコードとSwaggerファイルが同時に生成されるので、仕様と実装が一致した状態を簡単に保つことができます。また、Swaggerも自動生成なので、ニンゲンがあのYAMLやJSONを記述するより遥かに正確・確実に更新することができます。
よくGoのWebアプリケーション界隈では「ドキュメントと実装の整合性をどう保つか」みたいな問題が発生して、go-swaggerをはじめとするSwaggerと連携できるライブラリと、GinやEchoなどのWeb APIフレームワークをどう組み合わせるか、みたいな苦労をしている印象があります。 GoaはWeb API開発とドキュメンテーションをセットで扱えるため、そういったライブラリ/フレームワークの相性問題を考える必要がありません。
Goaの開発事情
現在Goaはv3とv1の2ラインがあり(なぜかv2は無かったことにされている)、メインの開発はv3で行われています。フラーでは昔からv1を使っていたのですが、v3リリース後も(2021年12月現在)移行に踏み切れていません。完全に下火になってしまった本家にコントリビュートし続けることに疲れた我々は、ついに弊社テックリードが独自カスタマイズを加えたfork版を使うことにしました。
完全に本家と袂を分かったことで、完全にv3にマイグレーションするきっかけを見失ってしまいました。。どうしましょうかね。要検討です。
実際にGoaで開発してみる
文章でつらつら書いててもアレなので、実際にGoaでWeb APIを開発するときのサンプルコードを用意してみました。
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で表示すると、このエンドポイントの仕様を確認することができます。
ディレクトリ構成
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ファイルも自動的に更新されます。便利ですね。
自動生成したコードを利用して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はこれを引数として受けつつ、ここから情報を受け取りながらビジネスロジックを書いていきます。
実際に実装した例がこちらになります。
Goaのここがつらい
初期導入コストが高い
これはこのぐらい大きなフレームワークならあるあるだと思うのですが、そもそもGoaの学習コストがとても高いです。アプリケーションの記述方法が決まってるだけでなく、独自定義のDSLも覚える必要があります。
フラーのサーバーサイドエンジニアとして入社すると、まずGoaとお友達にならなくてはなりません。最近入社した方は、オンボーディング期間中に一回Goaを使えるようにするためのチュートリアルをやってもらうようにしています。
また、前準備として記述しなくてはいけないコード量が多いので、最低限のAPIサーバーを立ち上げるだけでもそれなりの時間を要します。「ちょっと試しにAPI作ってみよ」という軽いノリで導入できるものではありません。
コードの記述量も多くなりがち
今回紹介したシンプルなコードですらそれなりのコーディングを要求されますし、何より自動生成されるコード量がとても多いです。 ほとんどはレビュー対象から外れるとは言え、うっかり変更が10,000行超えるPull Requestなんかが簡単に発生してしまうので、 レビュワーの精神力をごっそり持っていきがちです。
swagger.json が確定コンフリクトする
Goaで生成されるSwaggerファイルにはYAMLとJSONフォーマットがあるのですが、この内JSONはインデントはおろか改行すらされず、一行にすべての内容がガッと記述されてしまいます。
これの何が困るって、複数のAPIを同時並行で開発していると、確定でコンフリクトを起こしてしまうんですね。複数人で開発を進めていると毎度一回mergeするなりrebaseするなり対応しなくてはいけないので、非常に面倒です。YAMLファイルさえ生きていればJSONファイルは不要なので、フラーでは大抵 swagegr.json をGit管理から外すことで対応しています。
おわりに
というわけで、フラーで採用しているGoaフレームワークに簡単に触れてみました。
Goaにはまだまだたくさんの機能があり、慣れれば色々なユースケースに対応したAPIを開発することができます。 Goでバリバリ活躍したい方、Goaフレームワークに対する理解をもっと深めたい方、ぜひ私達と一緒に働きましょう!!フラーは通年でサーバーサイドエンジニアを募集しています!!!
明日は@SaturnR7さんで「何かを」です。お楽しみに!