はじめに
TIG DXユニット 1の真野です。echo → 生net/http → gorilla/mux → go-swagger, gqlgenの経歴でGoのHTTP APIを実装してきました。本記事では最近業務でヘビーユーズしているgo-swaggerについての開発Tipsをまとめました。
背景
フューチャーではGoを採用する案件が増えて来ており、その際にgo-swagger というツールを利用することが多いです。 2 理由はWeb APIのスキーマを駆動に開発することに慣れているという開発文化(DBレイヤのERDやデータフローを駆動に開発することは今も多い)や、リリース後の保守や将来のマイグレーションを考慮しなるべく特定のDSLに依存したくないというポリシーを強く持つこと、開発前にある程度固く機能数を洗い出して工数見積もりや開発スケジュールに活かしたいといった大人な事情など、色々相性が良いからだと思います。
Swaggerとは
Swaggerは、OpenAPI仕様(以下OAS)と言われる、REST APIを定義するための標準仕様にもとづいて構築された一連のオープンソースツールです。REST APIの設計、構築、文書化、および使用に役立つ機能を提供します。
YAML(JSON)でWeb APIの定義を記載することで、ドキュメンテーション・Client/Serverのコード生成・モックサービスの生成など多くのメリットを享受できます。またエコシステムも多数作られ、あるDSLに則ることでコードからSwaggerファイルを生成するなど、リバース系の生成手段も出てきています。
Swaggerを記述する流れ
Swagger(OpenAPI)のYAML定義は生で書くと大変なので、武田さんの本当に使ってよかったOpenAPI (Swagger) ツール 記事で紹介されたツールを利用してYAMLファイルを作成し、それをインプットにサーバサイドのコードを自動生成しています。中にはそれらの文明を捨て生身のYAML職人になった猛者もいます。続いて後述するgo-swaggerでサーバサイドやクライアントサイドのコードを生成・実装・テストし、その中で足りない点を設計にフィードバック(つまりYAMLを修正)し、さらにコードを再生成するといったサイクルを取ることが一般的だと思います。
実際に生成したSwaggerに対する規約は、亀井さんのスキーマファースト開発のためのOpenAPI(Swagger)設計規約の記事を見ると、どのようなところに注意すべきか分かって良いと思います。
go-swaggerはWebアプリケーションフレームワーク
go-swaggerとは、Swaggerファイルを入力にGoのコードを生成できるツールです。生成されるコードは、go-openapi で管理されているモジュールが利用されています。go-swaggerそのものの技術選定については、多賀さんのWAFとして go-swagger を選択してみた 記事にも記載があります。
go-swaggerがWAF(Webアプリケーションフレームワーク)というのは直感では理解しにくいですが、go-swaggerで生成したサーバサイドのコードは、実質的にechoやginのように多くの機能を持ちます。 例えば、URLのルーティング、入力Validation、クエリパラメータ、フォーム、リクエストヘッダ、リクエストボディなどの 入力modelへのバインディング、HTTPレスポンスコード別のmodelの作成や、Middlewareの設定専用の関数など多くをサポートしていますし、固有のビジネスロジックを書くルールもgo-swaggerの生成したコードによって決められています。
フロントエンド側の生成は?
TypeScriptのフロントエンド側の生成はopenapi-generatorを当社では採用することが多いです。あくまでサーバサイドの生成にgo-swaggerを用いています。go-swaggerもクライアントコードは生成でき、こちらはあるWeb APIロジック中で、別のWeb APIを呼び出す時に利用したりもします(下図のイメージ)
The Gopher character is based on the Go mascot designed by Renée French.
Tips
そんなgo-swaggerを用いてWeb APIサーバを開発し、いくつかのシステムをリリースしてきました。そこで得たTipsを紹介していきます。比較的サーバサイドの話が多いですが、一部クライアントサイドの話しもあります(前述したあるWeb APIサーバから、別のOpenAPIで定義されているWeb APIを呼び出すことも合ったので)
1. インストールバージョンをチームで固定しよう
インストール手順は様々ですが、コードを自動生成する関係上、バージョンは必ず揃えた方が良いです。もしチーム内に複数バージョンが混在してしまうと、自動生成するたびに不要なコード差分が発生して履歴が汚れてしまいます。
もし、コードからビルドするのであれば、下記のように@v0.23.0
のように固定することがオススメです。
go get -u github.com/go-swagger/go-swagger/cmd/swagger@v0.23.0 |
特に理由がなければ最新のバージョンを利用するのが良いと思います。2020/05/19時点ではリリースノートを見る限り数ヶ月ごとにリリースされているように活発に開発が続いているので、適時バージョンも上げていきたいですね。
2. swagger genrate server コマンドの推奨オプション
オプションは公式ドキュメントに記載されています。次のオプションは設定したほうが良いかなと思います。
--strict-additional-properties
リクエストボディなどで指定外のフィールドを指定した場合にエラーになる-a
,--api-package
パッケージがoperationsではなく任意のパッケージ名になる。短くしたい時にオススメ-A
,--name
Swagger定義のinfo.title
に大文字が入るとアンスコ繋がりにされちゃうの回避できる--exclude-main
main.goのファイルを生成するのを除外してくれる-t
,--target
出力先のパッケージを指定。3にもあるが、gen
にすることが経験上多い
まとめると、例えばルート管理(RouteManagement)のAPIであれば、以下のようなコマンドにすることが多いです。
swagger generate server -a routemanagement -A routemanagement \ |
立ち上がり初期は、-a
や-A
の値を変えながらしっくり来るのを探すことがオススメです。
3. パッケージ構造
先ほど、出力先ディレクトリを gen
に指定しましたが、公式ドキュメントにもgenで生成する例が書いてありました。最初は generated
にしようか迷いましたが、短いですし gen
に合わせることをおすすめします。
genの意味が何か? というのは新規参画者が全員抱く疑問だと思うので、READMEの上の方にディレクトリ構成を書くようにしています。
. |
例がモノリポで作っているイメージなので、適時書き換えて参考にしていただければです。
4. 起動時オプションの --host
には注意
go-swaggerで生成したサーバ起動時オプションがいくつか存在します。その中で必須なのは、--host
と --port
だと思います。--host
を指定した場合はデフォルトlocalhost
、つまり 127.0.0.1
になります。そうすると、ローカル開発では良いですが、他のサーバからアクセスできなくなります。ネットワークインタフェースを個別に指定したいケースは別ですが、基本的には --host 0.0.0.0
を指定すると良いでしょう。
また、ポート番号は未指定だと毎回ランダムな数値を選択します。固定したほうが何かと都合が良いと思うのでアプリごとに利用するポートを決定しましょう。
./exmample-server --host 0.0.0.0 --port 3000 |
--host
は$HOST
、--port
は$PORT
という環境変数でも利用できるので、コンテナ化するときなどはこちらを利用するのも良いと思います。特にGCPのCloudRunは$PORTで待ち受けるのが必須なので、この場合はGCP側にポート設定は任せましょう。
5. OpenAPIのバージョンを見間違えないように注意
go-swaggerが対応しているのは OAS2
であるので注意です。最新は OAS3
ですが、その記法は利用できないことがあります。特にググった時に出てくる公式ドキュメントが OAS2
であることをよく確認しましょう
大事なポイントなのでちゃんとテストします。次の画像↓はOAS2かOAS3のどちらでしょうか?
..はい、OAS2
と書かれているのでOKです。このドキュメントはgo-swaggerで利用できます。
では、次の画像↓はどちらでしょうか?
..はい、OAS3
と書かれていますね。というわけで、このドキュメントはgo-swaggerでは利用できない可能性が高いので、参考にするのはほどほどにしましょう。
個人的には OAS2
の仕様については下記が最もまとまっていて簡潔なのでオススメです。ググるのではなくまずこの仕様書を見ましょう。
https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md
6. go-swaggerで対応しているOpenAPIの規約とは
5で説明したとおり、OpenAPIの3系と2系(Swagger)でGoogle検索しにくいのが実情だと思います。さらにその中でgo-swaggerがその記法に対応しているかどうか迷う場面が出てくると思います。
対応状況については下記に記載があります。
https://github.com/go-swagger/go-swagger/blob/master/docs/use/spec/params.md
主要どころは網羅できているとお気づきになると思います。実際、経験上はほとんどが問題なく利用できてきました。もし、上手く動かない場合は、設定ミスや仕様の勘違い、あるいはコードの再生成をし忘れているといった可能性が高いです。
7. Swaggerのモデルの必須属性を外すとGoのコードがポインタじゃなくなり便利だが落とし穴がある
Goの辛いところかも知れませんが、nullかどうかを判定するためにGoではしばしばstringやint64のフィールドが、必須設定されるとポインタ型になります。これを swag.String()/swag.StringValue()
や swag.Int64()/swag.Int64Value()
で変換するのが厄介なので、特にレスポンスに関してはチェックもしないし必須属性を外そうかという判断になりがちだと思います。
この時に厄介なのが、必須属性ではない フィールドには、JSONの omitempty
タグが付与されることです。これによって、int64やboolの型がついているフィールドが、0
値やfalse
の場合にレスポンスのJSONフィールドから除外されてしまいます。意味は分かるけど意図はそういうことじゃないんだよなーって思う人も多いのでは無いでしょうか? これを回避するためには、 go-swaggerの拡張記法である、 x-omitempty: false
を設定する必要があります。
..なんというか、色々歪みが大きい気がするので、必ずレスポンスに含まれる項目であれば素直に必須だという宣言に、Swagger上はしておく方が良いかも知れません。このあたりはチーム全体の判断になると思います。
8. 数値始まりのフィールド名にNrというプレフィックスが付与される
数値始まりのフィールド 0x9101
といったフィールドを、go-swaggerで生成すると、 Nr0x9101
と言った具合にNr
といったプレフィックスが付与されます。ドキュメントには見当たりませんでしたが、コードではこの辺 に実装されていました。おそらく数値を表すNumberのドイツ語読み? でしょうか。これはGoのフィールド名が数字始まりを許可しないため、仕方ない挙動だとは思いますが、Nr
は辞めたいと思われる方も多いのではないでしょうか?
これを回避すると前には、x-go-name
という拡張記法を用います。コレを用いると、 x-go-name: d0x9101
といった形でカスタムな名称にできます。まぁAPIの定義と、内部で用いるフィールド名が異なると脳内変換が大変なので、この場合は状況が許すのであればAPI定義側も d0x9101
などと変更したほうが良い判断に思われます。
x-go-name
ですが、おそらくは、company_cd
やuser_id
といったsnake_caseでAPIを定義した場合、go-swaggerのデフォルトの挙動は companyCd
、userId
といった具合に、Goの慣習と合わないことへの対応に使うことが本来は多いと思います。このあたりに用いるのであれば本来の意図したオプションだと思います。
9. go-swaggerの拡張記法
7,8と関連しますが、x-omitempty
やx-go-name
以外にも、go-swagger独自の拡張パラメータが存在します。
どういったパラメータが利用できるかは、コードを見ると分かりやすいです。
https://github.com/go-swagger/go-swagger/blob/master/generator/types.go#L45
この中でも、比較的よく使いそうなのは x-go-type
やx-order
でしょうか? x-go-type
は自分でtype aliasした型を指定することが出来ます。 x-order
は、go-swaggerはデフォルトの挙動では、Swaggerに記載した順番にStructのフィールドを生成してくれません。それが視認性など場合によっては困ると言った場合に、順序を指定することも出来ます。あまり乱用すると、扱いにくいSwaggerファイルになりかねないので、トレードオフを考えながら指定していくと良いかなと思います。
10. DateTimeを活用しよう
type=string
を指定した時に、format
には、date
, date-time
などが指定できます。
event_time: |
こうすると、go-swaggerでは github.com/go-openapi/strfmt
の strfmt.DateTiime
型でStructが生成されます。
type ExampleParams struct { |
date-time
を指定すると、full-date - RFC3339
の形式での入力をパースすることが出来ます。コードではこの辺です。中身を見ると、複数のフォーマットに対応してくれおり、どれかに一致すればOKという仕様です。このあたりの受け入れる日付フォーマットを一々取り決めるのは厄介ですが、標準ライブラリレベルで規定してくれるているため、楽ができます。
const ( |
また、レスポンスのモデル側のフィールドにdate-time
を指定したときは、デフォルトでは上記 RFC3339Millis
のフォーマットが利用されます。もし、これを変更したい場合は、strfmtパッケージのMarshalFormatフィールドを書き換えればOKです(グローバルに書き換わります)。
strfmt.MarshalFormat = time.RFC3339 |
strfmt.DateTime
ですが、初見だと色々と扱いにくいと思います。なぜならswag.DateTime
とかswag.DateTimeValue
とかが無いからです。理由はもともとOpenAPI側のライブラリだからです。
変換の仕方をざっとまとめます。
import ( |
time.Timeへの変換さえ慣れれば、自前で日付パースを行うコードを減らせ見通しが良くなると思います。ぜひ、日付周りのデータを受け付ける場合は活用下さい。
11. アクセスログ
Go系のWAF全般に言えることかも知れませんが、go-swaggerも標準ではアクセスログなどが一切出力されず、自前でMiddlewareを仕込む必要があります。
設定する場所は、 restapi/configure_{project name}.go
にある、2つの関数のどちらかに設定します。
func setupMiddlewares(handler http.Handler) http.Handler { |
setupMiddlewares
はプログラム上で指定したルートに対するMiddlewareで、setupGlobalMiddleware
は/swagger.json
のエントリーポイントにも着火するミドルウェアです。
アクセスログの実装方法は色々ですが、私は以下のようなAccessLogの関数を実装することが多いです。
package mymiddleware |
これを、先ほどのsetupGlobalMiddleware
関数に設定します。
func setupGlobalMiddleware(handler http.Handler) http.Handler { |
これで、go-swaggerへのリクエストに対してロギングを行うことができました。開発や利用状況の調査などに役立て下さい。
12. panicしたときの防御
これも11に関連した話ですが、go-swaggerのロジックでpanicが発生するとレスポンスを何も返さないため不便です(どこかのレイヤーでGateway Timeoutなどが発生します)。この場合は、panicをキャプチャするmiddlewareを設定し、500エラーを返すなどをしたほうが良いでしょう。
公式ドキュメントにも実装例が記載されています。dre1080/recoverを利用しても良いと思いますし、私はもう少し自由度を高めたかったのでこちらの実装を参考にして、カスタムミドルウェアをつくることもあります。
package mymiddleware |
これを11のアクセスログと合わせて設定します。
func setupGlobalMiddleware(handler http.Handler) http.Handler { |
これで、panicが発生しても仕様通りに何かしらレスポンスできました。
13. Middleware
現実には、10, 11以外にも多くのMiddlewareを実装する必要が出てくると思います。多いのは、CORS、gzipでしょう。BodyLimitやRateLimitなどは、LANを飛び出してWeb APIを実装すると必要性が出てくると思います。どういったMiddlewareが必要になってくるかは、echoのMiddlewareページを見て、どういった観点がありそうか確認してみるのも良いかも知れません。
CORSに関しては、公式のFAQ があります。
import "github.com/rs/cors" |
大体が、cors.Default()
の設定で大丈夫だと思いますが、Access-Control-Allow-Headers
のリクエストヘッダに対してはデフォルトで許可していないので、要件によっては追加でオプションを追加します。
myCORS := cors.New(cors.Options{ |
gzipはこちらのライブラリを利用すると良いかと思います。こちらは最後の設定例でまとめて説明します。
BodyLimitはこちらのStackOverflowの記事を参考に実装しました。やりたいことは、指定されたサイズ以上のリクエストボディを許可せず、サーバ側に負荷をかけないようにしたいことです。
const MaxBodyByteSize = 2 * 1024 * 1024 // 2MB |
RateLimitはさきほどの公式ドキュメント にも記載があります。WAFなどを導入していれば不要かもしれないですが、負荷試験時にベンチマークツールの誤作動でDynamoDBなど回数課金なサービスで費用がかさんだ悪夢から、防御的に設定することにしています。
func setupMiddlewares(handler http.Handler) http.Handler { |
これらを合わせると以下のようになります。
func setupGlobalMiddleware(handler http.Handler) http.Handler { |
デコレートの階層が深すぎてよくわからなくなってきましたが、浅い方から順番に動くので、アクセスログはCORSの前に出したいとかがあれば順序を動かしてみてください。
14. エラーハンドリング
go-swaggerの入力Validationでエラーが発生したときは、デフォルトでは 422 Unprocessable Entity
が発生します。422のままで良いよという方はこのままでも良いですが、400 Bad Request
で統一したい場合もあるでしょう。理由は、悪さをしようとするユーザーのリクエストがあるという性悪説にたって、不正パラメーターの詳細なエラー情報は悪いクライアントに不要な情報を与えるものとなりかねないので、雑に400を返すといった考えもあると思うからです。
単純にエラー時のステータスコードを変えたいだけなら、ここに書いてあるように簡単に実施できます。
import "github.com/go-openapi/errors" |
これで入力されたパラメータがSwaggerで定義したスキーマと異なる場合は、400 Bad Request
を返すことができました。
一方で、エラー時のレスポンスボディは {"code":400, "mesasge": "xxx"}
といった形式になります。実装はこのあたりになります。もし、レスポンスボディのレイアウトを変更したいときは、自分でカスタムのerrorHandlerを設定することもできます。
func configureAPI(api *myapp.MyApplicationAPI) http.Handler { |
myerrors.MyServeErrorの実装ですが、デフォルトである github.com/go-openapi/errors
の errors.ServeError
の実装を参考にしながら、一部を改修するといった形になります。このIssueで話題になっています。例えば、code
というフィールドを削除したいよって場合は、errors.ServeError
のerrorAsJSON
関数を書き換えて対応します。
func errorAsJSON(err Error) []byte { |
上記で、色々go-swaggerのフレームワーク側が対応してしてくれているエラーハンドリングも自由自在になりました。あまりカスタマイズすると、本家バージョンアップの追随が大変なので、なるべくgo-swagger標準の形式に則ってWeb API設計することがおすすめですが、いざという時の逃げ道として認識してもらえると幸いです。
15. Defaultステータスコードの勧め
OAS2
のSwagger定義に、Defaultレスポンスという設定が出来ます。
下記のように、200以外は全て同じErrorモデルを利用するというのであれば定義の簡略として便利だと思います。
responses: |
これが特に効果を発揮するのは、クライアントコードを生成した時です。理由は、サーバサイドが行儀よくWeb API定義通りのレスポンスコードを返してくれればよいのですが、実装によって予期せぬレスポンスコードを帰す場合(例えば先ほどの422の話)には、クライアントコードはそれを上手く扱えません。他にも自動生成部分ではなく開発者が個別実装する部分で、間違った自動生成コードを利用した場合にも発生します。
例えば、もしdefalutが存在しない場合は、下記のようにクライアント側でエラーをログ出力しても、{resp:0xc0005325a0}
のようなポインタ情報しか出力されないです。
params := user.NewGetUserParamsWithContext(ctx).WithUserID(userID) |
これは、ステータスコード別にバインドするStructを自動生成する関係上、想定外のステータスコードの場合に動かしようが無いからだともいます。このあたりのIsseuにも似たような議論がありました。これを避けるためには、横断的にエラー時のModelを共通化しておき、全てのエンドポイントごとにDefaultステータスコードを設定しておくことがオススメです。
16. NewXxxの関数を利用する
15でもちょっと実装が出ましたが、go-swaggerで生成したクライアントコードを利用して、サーバにリクエストする場合について注意があります。リクエストパラメータの生成には、 NewXxx
を利用してStructを作らないと、timeout=0になって、context deadline exceeded
エラーとなり上手く動作しません。このあたりのIssueでも話題にしています。
NewXxxの関数を用いるときは、WithContext
付きの方を利用すると便利です。さらにチェーンスタイルでパラメータも設定できます。必須属性については swag.String
などで *string 型への変換が必要です。
params := user.NewPostUserParamsWithContext(ctx). |
17. クライアントコードでホスト名やBASE_PATHを書き換えたい
Swaggerに記載するホスト名と開発中のホスト名は異なるため、書き換えが必要です。また、URLの基底となるパスですが、 /v1
などを設定することが一般的だと思います。一方で、ロードバランサやAPIゲートウェイの仕様のため、本番環境では別の基底パスを追加したいときがあると思います。そうすると、ローカルで利用したいURLと差異がでるため、差異を吸収する設定が必要です。
gen/{project name}_client.go
にあるクライアントの HTTPClientWithConfig
を書き換えます。
import ( |
もし、Swaggerの設定そのままのホスト名やBASE_PATHを利用するのであれば、Defaultクライアントを利用もできます。
if _, err := client.Default.user.GetUserContract(user.NewGetUserParamsWithContext(ctx).WithUserID(userID)); err != nil { |
この辺りの作り込みは上手く環境変数など外部プロパティで切り替えられるようにしておきたいですね。
18. 単体テストの話
go-swaggerのサーバサイドの単体テストは、Goの関数呼び出しと同様に実現できます。レスポンスに関しては httptest.NewRecorder()
を利用するとヘッダ・ボディなど全て取得できます。
import ( |
レスポンスボディのチェックは、jsondiffというパッケージを利用していますが、他にも色々な方法があると思いますので、要件に合わせて書き換えて下さい。他のGoのテストの考え方と特に変わらないのは嬉しいですね。
19. Lambdaで動かしたい
go-swaggerのサーバですが、実はAWS Lambdaでも動かせます。httpadapter
というパッケージを利用することで、API Gatewayのevents.APIGatewayProxyRequest
といったイベントを、go-swaggerのリクエストである *http.Request
に変換してくれます。コードは下記のようなイメージです。
package main |
起動速度がちょっと気になる..という方もいらっしゃるかと思いますが、とあるシステムの本番環境で、ほぼほぼ上記のコードを動かしていますが、気持ち10-20msくらいかかっているかも? といったレベルです。init関数で初期化した部分を、毎回のリクエストのたびに使いまわしているからだと思います。そこまでレイテンシを求められないシステムであれば、go-swaggerもドンドンLambdaに載せちゃって良いのでは? と私は考えています。
他のServlerless相当でgo-swaggerで動かしたい場合も、このコードを参考にサーバレス関数のイベントを、*http.Request
に変換すれば動かすことができそうです。夢が広がりますね!
まとめ
最初は3,4つのTipsをまとめて終わりにしようかと思いましたが、書いていると非常に長くなってしまいました。go-swaggerは良いプロダクトだと思うのですが、定義情報からコードを自動生成する関係上、どこまで何ができるのかイメージがつきにくかったり、そもそもOpenAPI(Swagger)の知識も必要のため敷居が高かったりと、最初はハマる箇所が多いからかも知れません(さらにはサーバサイドとクライアントサイドの2種類のコードも生成できるためネタが増える..)
上手く使えば、Web API定義と実装が完全に一致する(定義からコードを生成しているため)で強力なツールだと思いますしオススメです。すでに使っている方にも今回のTipsを活用していただければ幸いです。