🚗

GoでGraphQLの静的解析ツールを作る

2022/09/29に公開

はじめに

Appify Technologiesでは以下のGraphQLの静的解析ツールをGoで実装しました。

忘れないうちに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.Queriespass.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です。サンプルとしてご活用だくさい。
https://github.com/gqlgo/optionalschema/blob/main/optional_schema.go

クエリーやスキーマの指定方法 - 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 以外にも FragmentSpreadInlineFragment が存在します。

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などの情報を参照することが可能となります。

https://github.com/gqlgo/deprecatedquery/blob/6e0c556231b37599ed3f210d44e43db8d0e90e59/deprecated_query.go#L94

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はコメントの位置情報を保持していないため、出力する際にコメントが消えることには注意が必要です。

https://github.com/gqlgo/querystring/blob/070be01ace129f1fb30a96a8d3d48b85483c1628/query_string.go#L132-L144

脚注
  1. GraphQLの静的解析基盤を作った - tenntenn.dev ↩︎

Discussion