永山です。
本記事では筆者の開発した、Go製のWebフレームワーク Goa (v3) 向けのlinterツール goalint を紹介します。
Goa とは
Goaは、GoのDSLでHTTPやgRPCのAPIの仕様を記述するというアプローチが特徴的なGo製のWebフレームワークです。 昨今ではWeb APIの仕様の記述に OpenAPI を使用することも少なくないかと思いますが、GoaはそれをGo上で行うようなものであるといえます。
// Goaで記述されたAPI仕様の例 (以下より, 一部改変) // https://github.com/goadesign/examples/blob/master/basic/design/design.go package design import ( . "goa.design/goa/v3/dsl" ) var _ = Service("calc", func() { // パラメータ a, b の積を返す API Method("multiply", func() { Payload(func() { Attribute("a", Int, "Left operand") Attribute("b", Int, "Right operand") Required("a", "b") }) Result(Int) HTTP(func() { GET("/multiply/{a}/{b}") Response(StatusOK) }) }) })
Goa DSLによって記述されたAPI仕様からはサーバー・クライアントのGoコードを生成でき、またOpenAPI (HTTP APIの場合) や protobuf (gRPCの場合) も出力できます。
モチベーション
さて、APIを作成するにあたって重要なことの1つがインタフェースの統一性です:
- HTTP APIのパスのケーシング (snake_case / kebab-case / lowerCamelCase / ...)
- JSONのキー名のケーシング (snake_case / lowerCamelCase / PascalCase / ...)
Web APIは様々な箇所から呼ばれることもあるため、これらの統一性がおざなりになっていた場合あとから変更するのに不必要に大きいコストを払わなければならないようなことも決して珍しくはないでしょう (そして割に合わず放置されてしまいます)。
これらの表現が混在しているものはコードレビューの段階ですべて弾いてしまえるのが理想的ですが、そのような指摘の網羅性を実装者やレビュアーの集中力に頼るのも現実的ではありません。 可能であれば機械的に検知できることが望ましいです。
既存のlinter
まず次のような既存のlinterを用いてGoaのAPI仕様をチェックできないかを考えました。
goavl
goavlはGoa v1のlinter/validatorです。
GoaのDSLをGoのASTから解析するパワフルなアプローチで実装されています。
しかし、対応しているのはGoa v1のみで、現在のメジャーバージョンであるv3に対応する予定はなさそうでした *1。
IBM OpenAPI Validator
IBM OpenAPI ValidatorはNode.js製のOpenAPIのlinter/validatorです。
前述の通りGoaはOpenAPIフォーマットのファイルを出力できるため、それに対して適用することで間接的にGoa DSLをチェックできないかと考えました。
IBM OpenAPI Validatorは実装されているルール数が豊富で、チーム内でもOpenAPIを導入している他のプロジェクトへの採用経験がありました。 一方で、指摘は当然OpenAPIファイルに対して行われるため、警告が発生した場合はOpenAPIの出力から逆算してGoaの定義を修正しなければなりません。 またGoaの出力する形式の都合上、対応が不可能なルールも少なからず存在しています。
goalint
上記のような点を鑑みて、今回は自前でlinterを実装することにしました。
現在はIBM OpenAPI Validatorなどを参考に18のルールが実装してあります。
使用方法
goalintは以下の2通りの使用方法をサポートしています。
- Goaのコード生成時にチェックを行うプラグインとして実行する
- 独立したCLIとして実行する
どちらとして使用する場合も以下のようなファイルを作成することでgoalintを導入できます。
// design/goalint.go package design import ( "github.com/NagayamaRyoga/goalint" // プラグインとして使用する場合は以下をimportする // CLIとして使用する場合は不要 _ "github.com/NagayamaRyoga/goalint/plugin" ) var _ = goalint.Configure(func(c *goalint.Config) { // ルールの設定を変更できる // APIのパス名は snake_case c.HTTPPathCasingConvention.WordCase = goalint.SnakeCase })
# goalintをコマンドラインから実行する $ go run github.com/NagayamaRyoga/goalint/cmd/goalint <package名>/design
goavlとのアプローチの違い
先に説明した通り、goavlは (静的な) GoのASTからDSLを解析しています。 対して、goalintはGoaのDSLの評価結果として組み立てられる (動的な) 内部表現を解析して警告を出力します。
このような異なるアプローチを取ったのは、Goaの表現に (悪い意味で) 柔軟さがあるためです。 例えば以下の2つのコードはどちらも同じ意味を持ちます:
Payload(func() { Attribute("a", Int, "Left operand") // ↑と同じ Attribute("a", Int, func() { Description("Left operand") }) })
また、DSLには任意のGoのコードを書けるため、ファイルの分割や、関数化・変数化などの共通化を行った場合、ASTからAPI定義の出力結果を静的に推測するのはさらに難しくなります。
そのためgoalintはGoaがDSLからコードを生成する時のように、一通りDSLの評価をしたあと解析する方法を選択しています。 これによって実装をかなり単純にできました (設計開始から2日程度でおおよそ動くものができました)。
一方、Goaの内部表現からはそれの書かれたファイル名や行数などの復元ができません。 それによって、警告の原因となっている具体的な位置をエラーメッセージに表示出来ないという課題が残っています (エラー位置を特定するのに実用上十分な情報は出力できていますが……)。
エラーメッセージの例:
Found 5 errors and 0 warnings [MethodDescriptionExists]: error in service "calc" method "multiply": Method should have non-empty description [NoUnnamedMethodPayloadType]: error in service "calc" method "multiply": Method payload should be an user defined type [NoUnnamedMethodResultType]: error in service "calc" method "multiply": Method result should be an user defined type [TypeAttributeExampleExists]: error in attribute "a" in Object: Attribute of type Int should have examples [TypeAttributeExampleExists]: error in attribute "b" in Object: Attribute of type Int should have examples goalint failed
結果
実際にチーム内のGoaを採用しているサービス2つにgoalintを導入しました。 結果、コードレビュー時に注意すべき事柄が減り、より本質的な実装のレビューに集中できるようになりました。
また、初見ではわかりにくいGoaのプラクティス (型の命名やExampleの明示など) のガイド役としても一定の働きをしてくれています。
We are hiring!!
エムスリーではlinterが好きなエンジニアを募集しています。 Go/Goa以外にも様々な技術スタックのプロダクトがありますので、ご興味ある方は是非こちらからお願いします。