GoでGraphQLの静的解析ツールを作る
はじめに
Appify Technologiesでは以下のGraphQLの静的解析ツールをGoで実装しました。
- Yamashou/gqlgenc - GraphQLクライアントコードの生成
- gqlgo/lackid - idの指定を忘れているQueryを検出
- gqlgo/nodecheck - Nodeを実装していないtypeを検出
- gqlgo/deprecatedquery - deprecatedなqueryを検出
- gqlgo/optionalschema - optionalなfieldをSchemaから検出
- gqlgo/querystring - ソースコードの中からGraphQL Queryを抽出
忘れないうちにGraphQLの静的解析ツールの実装方法をまとめておこうと思います。
前提知識
Goの .go
ファイルの静的解析ツールの作成にはanalysis を利用しますが、GraphQLファイルの静的解析ツールの作成にはgqlgo/gqlanalysis を利用します。
gqlgo/gqlanalysisは弊社の副業で@tenntennさんに作って頂いたライブラリ[1]で、Goのanalysisと同等な使用感で静的解析ツールの実装とテストを行うことができます。
gqlgo/gqlanalysisは、内部でvektah/gqlparserを利用してGraphQLファイルをパースします。gqlparserは99designs/gqlgenが利用しているライブラリです。
GraphQLの静的解析ツールの開発
開発の基盤となるソースコードの準備
gqlskeleton
は、GraphQLの静的解析開発の基盤となるソースコードを生成してくれるツールです。
インストールして基盤となるソースコードを生成します。
$ go install github.com/gqlgo/gqlanalysis/cmd/gqlskeleton@latest
以下の 自分のorg
を変更して実行する。
$ gqlskeleton -kind query github.com/自分のorg/samplelint
samplelintディレクトリの下に、以下のようなファイルが作成されます。
$ tree samplelint
samplelint
├── cmd
│ └── samplelint
│ └── main.go # インストールするコマンド
├── go.mod
├── samplelint.go # 静的解析を実装するファイル
├── samplelint_test.go # 静的解析のテストを書くファイル
└── testdata # 静的解析のテストデータとなるGraphQLファイル
└── a
├── query
│ └── query.graphql
└── schema
├── model.graphql
├── mutation.graphql
├── query.graphql
├── schema.graphql
└── subscription.graphql
コマンドのインストールと実行
ソースコードを見る前に、実際に動かしてみましょう。
このディレクトリでコマンドをインストールことが可能です。
$ cd samplelint/cmd/samplelint
$ go get
$ go install
$ which samplelint
/Users/sonatard/go/bin/samplelint
実行してみます。
$ cd ../..
$ samplelint -query testdata/a/query/*.graphql -schema testdata/a/schema/*.graphql
# results of analyzer samplelint
testdata/a/query/query.graphql:2 NG
testdata/a/query/query.graphql:7 NG
testdata/a/query/query.graphql:12 NG
最初に生成されたファイルでは、 name
というフィールドをFragmentに見つけたらNGと警告するツールなので、それが該当する行が解析結果として出力されていることがわかります。
$ cat testdata/a/query/query.graphql
fragment AFragment1 on A {
name # want "NG"
}
fragment AFragment2 on A {
id
name # want "NG"
...BFragment
}
fragment BFragment on A {
name # want "NG"
}
name
というフィールドの横にコメントで # want "NG"
と書かれていますが、これはテストで利用するものです。次の章で説明します。
テストの実行
テストを実行すると成功します。
$ go test
PASS
ok github.com/gqlgo/samplelint 0.121s
以下のファイルには # want "NG"
というコメントがnameフィールドの横に記載されています。これはテストの期待値を表しています。
fragment AFragment1 on A {
name # want "NG"
}
fragment AFragment2 on A {
id
name # want "NG"
...BFragment
}
fragment BFragment on A {
name # want "NG"
}
そこで1度試しにテストを落としてみましょう。
AFragment1のnameフィールドからコメントを削除してテストを再度実行します。
fragment AFragment1 on A {
name
}
そうすると以下のように期待値に記載されていない出力があったためテストがFAILとなります。
$ go test
--- FAIL: TestAnalyzer (0.00s)
samplelint_test.go:13: unxpected diagnostic in /Users/sonatard/samplelint/testdata/a/query/query.graphql:2: NG
FAIL
exit status 1
FAIL github.com/gqlgo/samplelint 0.305s
今度は期待値を Hello
に変更してテストを実行してみます。
fragment AFragment1 on A {
name # want "Hello"
}
今回は出力されたNGが期待値のHelloと一致しないためテストがFAILになります。
$ go test
--- FAIL: TestAnalyzer (0.00s)
samplelint_test.go:13: diagnostic "NG" does not match Hello in /Users/sonatard/samplelint/testdata/a/query/query.graphql:2
FAIL
exit status 1
FAIL github.com/gqlgo/samplelint 0.116s
以上でテストの説明は終了です。続いて実装方法を確認していきます。
静的解析ツールの実装
依存関係のあるpackageを取得します。
$ cd samplelint
$ go get
cmdファイル
goのanalysis packageでお馴染みのmulticheckerの形式で静的解析ツールを登録することができます。今回は1つの静的解析ツールだけが登録されていますが、独自に複数のgqlanalysisで書かれた静的解析を登録することも可能です。
今回は特に修正する必要はありません。
$ cat cmd/samplelint/main.go
package main
import (
"github.com/gqlgo/gqlanalysis/multichecker"
"github.com/gqlgo/samplelint"
)
func main() { multichecker.Main(samplelint.Analyzer) }
実装ファイルの確認
静的解析ツールを実装するためには gqlanalysis.Analyzer
に登録する run
関数を実装するだけです。
SchemaファイルやQueryファイルの情報はrun関数が実行された時点で引数のpass変数の pass.Queries
、 pass.Schema
に格納されています。
あとは上記の情報から該当するものを見つけて pass.Reportf
メソッドを利用して警告を出力するだけです。
実装ファイルについては、ファイル内のコメントでどのように実現しているかを説明します。
$ cat samplelint.go
package samplelint
import (
"github.com/vektah/gqlparser/v2/ast"
"github.com/gqlgo/gqlanalysis"
)
const doc = "samplelint is ..."
// Analyzer is ...
var Analyzer = &gqlanalysis.Analyzer{
Name: "samplelint", #
Doc: doc,
Run: run,
}
func run(pass *gqlanalysis.Pass) (interface{}, error) {
for _, q := range pass.Queries { // すべてのQuery情報の一覧でループ
for _, f := range q.Fragments { // Queryの中のFragment情報でループ
for _, sel := range f.SelectionSet { // Fragmentが持つSelectionのsliceでループ
switch sel := sel.(type) {
case *ast.Field: # SelectionがFieldであり
if sel.Name == "name" { // Fieldの名前がnameである場合には
pass.Reportf(sel.Position, "NG") // 警告する
}
}
}
}
}
return nil, nil
}
この後の応用は gqlanalysis.Pass
以下に存在する型の意味を調べて、該当の値を探すだけで実現することができます。型の意味はあまりGoDocに記載されておらず、理解することが難しいかもしれませんが、GraphQLを勉強すれば理解できると思います。
実装に役立つ情報は別途 おわりに
の後に記載しているので、是非参考にしてみてください。
SchemaのLinterを作る
上記のサンプルはQuery側の静的解析ツールのお話でしたが、Schemaに対しての静的解析ツールを作ることができます。参照する型が pass.Queries
ではなく pass.Schema
になりますが、あとは型に含まれる情報を確認しながら pass.Reportf
で警告するというのは同じです。
以下はSchemaの中からOptionalなフィールドを警告するLinterです。サンプルとしてご活用だくさい。
クエリーやスキーマの指定方法 - Introspection Query
解析対象のGraphQLのSchemaファイルを指定するためには、直接GraphQLファイルで指定する方法とは別にGraphQLのエンドポイントからスキーマ情報をIntrospection Queryで取得する方法もあります。
gqlanalysisもgqlgencに含まれているintrospection packageを利用してスキーマ情報をGraphQLエンドポイントから取得することが可能です。
samplelint -query testdata/a/query/*.graphql -schema https://example.com/graphql
クエリーやスキーマの指定方法 - glob
globにも対応しており **
を指定することで、ディレクトリを再帰的に探索することができます。
samplelint -query testdata/a/query/**/*.graphql -schema testdata/a/schema/**/*.graphql
おわりに
以上のようにgqlskeletonでファイルを生成して、あとはforループで必要な値を探してpass.Reportfで出力するだけでLinterを実装することができました。
もしGraphQLのコードレビューでよく指摘している点がある場合には、静的解析ツールを実装してみてください。
実装参考情報
以下は、静的解析ツールの実装で役に立つ情報をまとめています。
gqlparserを利用したその他静的解析ツール
自分で初めて実装する際には参考になります。
Selectionの種類
Selectionは、Field
以外にも FragmentSpread
、 InlineFragment
が存在します。
GraphQL Queryでは以下のような構文になります。
fragment AFragment2 on A {
id
... on A { # InlineFragment
name # want "NG"
}
...BFragment # FragmentSpread
}
必要な場合には、適切にそれぞれハンドリングしてください。
switch sel := sel.(type) {
case *ast.Field:
if sel.Name == "name" {
pass.Reportf(sel.Position, "NG")
}
case *ast.FragmentSpread:
// TODO
case *ast.InlineFragment:
// TODO
}
FragmentSpreadやInlineFragmentについて詳しく理解したい場合にはGraphQL仕様を確認してください。
Queryの元となるSchema情報を取得する - Definition
QueryからShemaの情報の取得が必要になることがあります。
例えば deprecatedquery
はdeprecatedなQueryを見つけるLinterなのですが、まずqueryを探索して、そのQueryのFieldがSchemaでdeprecated directiveが指定されていれば警告を出力します。そのような場合はQueryの情報だけでは判定することができません。QueryからSchemaの情報を参照するにはQueryのFieldが持つDefinitionを参照します。Definitionにはスキーマ側の情報が入っているので、Directiveなどの情報を参照することが可能となります。
Introspection Queryはスキーマのフィールドに指定されたdirective情報を取得できない
Introspection Queryはスキーマのフィールドに指定されたdirective情報を取得できません。
そのためもしQueryで参照しているあるFieldのSchemaFieldでdirectiveが設定されていることをチェックしたいという場合には、Introspection QueryではなくSchemaファイルをダウンロードしてくる必要があります。
get-graphql-schema のようなGraphQLエンドポイントからスキーマをダウンロードするツールもIntrospection Queryを利用しているため、スキーマのフィールドに指定されたdirective情報を取得できません。
もしどうしても必要な場合は、Introspection Queryを諦めて、社内のGraphQLファイルならGitHubリポジトリなどからダウンロードする必要があります。外部のスキーマの場合はどうしようもありません。
また例外的にdeprecated directiveの情報だけは特別に取得できるようになっています。そこでintrospectionでは、本来Definitionにdeprecated directiveの情報は入らないところを、Introspection Queryから取得できる情報を元にDirectiveDefinitionを生成しています。
パースした情報をフォーマットしてGraphQLファイルとして出力する
gqlparser - formatterが存在しているため、これを使うことでパースした情報からフォーマットしたGraphQLファイルを出力することができます。
ただしgqlparserはコメントの位置情報を保持していないため、出力する際にコメントが消えることには注意が必要です。
Discussion