TypeScript で GraphQL Client を便利に開発するためのツールを作っている話
昨年から少しずつGraphQLを使ったフロントエンドの開発に携わることが増えてきました。
GraphQL / TypeScriptという文脈では、3年近く前に https://github.com/Quramy/ts-graphql-plugin というツールを公開しているのですが、当時これを作ったモチベーションは「TypeScript Pluginを勉強したかったから」だけで、自分自身が使う機会が無かったために、作ってからは放置していました。
このツールに限らずですが、開発支援系のツールは自分で使うモチベーションがあるときに作るのが一番ということもあり、重い腰を上げてこいつに大幅にアップデートを加えました。年末年始で時間もいっぱいありましたしね。
エディタ支援機能
ts-graphql-plugin という名前にもある通り、こいつはTypeScriptのLanguage Service Pluginです。 Language Service Pluginというのは、TypeScriptの言語支援サーバー(tsserver)上で動作するため、エディタの種別を問わず動作するのが特徴です。
さて、例えばReact + ApolloでGraphQLクライアントアプリを開発する場合、下記のようにコードを書いていきます。
import React from 'react';
import gql from 'graphql-tag';
import useQuery from '@apollo/react-hooks';const query = gql`
query MyQuery($first: Int!) {
viewer {
repositories(first: $first) {
nodes {
id
description
}
}
}
}
`;const App = () => {
const { data } = useQuery(query, { variables: { first: 10 } });
if (!data?.viewer?.repositories) return null;
return (
<ul>
{data.viewer.repositories.map(repo => (
<li key={repo.id}>repo?.description</li>
))}
</ul>
);
};export default App;
GraphQLにはGraphiQLというREPLがあり、GraphQLのsyntax errorの表示や、フィールド・フラグメント名の補完機能などがあるのですが、ts-graphql-pluginはそれらの機能をTemplate String中に持ち込みます。 言葉で説明するよりもデモ用のGIFで見てもらった方がわかりやすいと思います。
導入は簡単です。まずは以下のコマンドでNPMからインストール。
$ npm i ts-graphql-plugin -D
あとはプロジェクトのtsconfig.jsonにpluginの設定を追加するだけです。
{
"compilerOptions": {
"plugins": [
{
"name": "ts-graphql-plugin",
"schema": "schema.graphql", /* GraphQL schemaファイルのパス */
"tag": "gql"
}
]
}
}
schema
オプションにはGraphQL サーバーのURLを指定したり、Introspection Queryをjsonファイルとして保存しておき、そのファイルパスを指定することもできます。
設定が終わったら、あとはVSCでもvimでもemacsでも好きなエディタを立ち上げてください。僕はVSCとvimで最終的な動作確認をしていますが、tsserverに対応しているエディタであれば種別問わず動作するはずです。
CLIとしての機能
ts-graphql-pluginをリリースした当初から上記のLanguage Service Pluginとしての機能は一通り作ってあったのですが、今回、CLIとしての機能も提供するようにしました。
Language Service Pluginというのは、飽くまでエディタ上でのサポートでしかないため、tsc
コマンドの結果には何も影響を与えません。 すなわち、誤ったGraphQLのクエリがcommitされたときにCI/CDで弾こうとすると、何かしらのCLIが必要となるため、Language Service Pluginで提供しているのと同様のvalidationをCLIとしても実行できるようにしてみました。
$ npx ts-graphql-plugin validate
クエリにエラーが存在すると、以下のように表示されます。
ソースコードの行数表示が背景反転していたり、エラー箇所を示すキャラクタに ~
使ったりしているのは、tsc
コマンドのエラー表示結果を参考に作っているからです。TypeScriptのソースコードを解析するCLIなので、この辺りは統一感出すために少し頑張りました。
これはどうでもいいことですが、今回は時間に結構な余裕があったこともあり、CLIを作る上でyargsやchalkなどのNPMパッケージには一切頼っていません。コマンドラインパーサーや上記のようなテキスト装飾も含めて自作しています。お陰で npm i
するときの依存パッケージはめちゃくちゃ少ないです。
もともとts-graphql-pluginレポジトリのissueに上がっていたCLIの要望はバリデーションのみだったのですが、折角CLIを作るのであれば他の機能も提供したくなってしまいました。
$ npx ts-graphql-plugin report
report
サブコマンドはTypeScriptファイルに記述されたGraphQLのオペレーション(Query, Mutation, Subscription)を抜き出してMarkdownのレポートを生成します。
そのオペレーションがどのtsファイルの何行目に定義されているかもリンクするようになっています。
$ npx ts-graphql-plugin typegen
コマンド名から類推が付くと思いますが、この手のツールの定番、型の自動生成です。最早ここまでくると完全に趣味の領域に近いですね。apollo-toolやgraphql-codegenなど、既に色々なツールがありますし。 僕は今まではapollo-toolが提供するコマンドを使うことが多かったのですが、依存パッケージも多いし、バージョン上げるとちょいちょい動作しなくなることがあったのが不満だったので作ってしまいました。
下記のようなコードが存在する場合に、
const repositoryFragment = gql`
fragment RepositoryFragment on Repository {
description
}
`;const query = gql`
${repositoryFragment}
query GitHubQuery($first: Int!) {
viewer {
repositories(first: $first) {
nodes {
id
...RepositoryFragment
}
}
}
}
`;
そのコードと同じ階層に __generated__/git-hub-query.ts
が以下の中身とともに生成されます。
/* eslint-disable */
/* This is an autogenerated file. Do not edit this file directly! */
export type RepositoryFragment = {
description: string | null;
};
export type GitHubQuery = {
viewer: {
repositories: {
nodes: (({
id: string;
} & RepositoryFragment) | null)[] | null;
};
};
};
export type GitHubQueryVariables = {
first: number;
};
クエリの抜き出し
「ソースコード中から抜き出されたGraphQLのクエリを解析して何かを行う」という意味では、validate
も report
も typegen
もすべて同じカテゴリに属するわけですが、プロジェクト側で同じようにクエリを解析するツールを簡単につくれるように、「クエリを抜き出す部分」単品も提供しています。
$ npx ts-graphql-plugin extract
extract
サブコマンドを叩くと、manifest.jsonという名前のファイルが生成されます。この中身には以下の情報が配列で格納されています。
- GraphQLとしてのドキュメント
- どの.tsファイルのどの位置から抜き出されたのか
上の方でも例でサラッと書いたのですが、GraphQLの場合、フラグメントを別の変数から持ってきてクエリのTemplate Stringに内挿して利用する、というコードが普通に出てきます。
実は今回のアップデートを行うまで、このツールの話をあまりしないようにしていたのですが、内挿付きのTemplate String(ASTでいうところのTemplateExpression)に対応できていなかったからです。 このエントリではツール内部の作り方の話には触れませんが、内挿をある程度解決しつつ、結合された結果の文字列を取り出す、というのは結構面倒です。この意味では extract
コマンドこそがts-graphql-pluginの本質と言っても過言ではないくらい。
ts-graphql-pluginの今後
折角作ったので、自分で使いつつイケてないところがあれば直していこうと思っています。 もちろん、使ってもらってフィードバックくれたりするのも大歓迎です。
機能追加もいくつか考えています。
案の1つは、抜き出したクエリを事前にGraphQL ASTに変換するCustom Transformerです。 Apollo Clientの場合、gqlタグがランタイムにASTへparseするのですが、これを事前にやってしまう、という文脈です。いわゆるAoTコンパイルになるんですが、速度的なメリットはあまりないし、バンドルサイズはむしろ膨らむので、単純にはモチベーションが上がらないんですが、「GraphQLクエリの静的な最適化」のような話があると、ビルド時にそれを組み込む口が必要になってきそうなんですよね。 あとはRelay Compilerではよく出てきますが、GraphQL AST変換のみで解決できるようなClient Custom Directiveなんかも同じ文脈ですね。
もう1つは、型生成機能のユーザー拡張です。 現状、Custom Scalarはすべてanyにマッピングされてしまう、などのイケてないポイントがあるのは分かっているんですが、折角TypeScriptに特化したツールなので、TypeScript AST APIと親和性の高い拡張方式を提供したいと思っているんですが、あまり案がまとまっておらず、もう少し自分の中で整理してから着手したいのが本音です。
最終目標
僕の最終的な目標は、over-fetchingのチェックを静的に行うことです。
over-fetchingというのは「クライアント側でクエリに記載したフィールドが、取得したものの実際には使われていない」という状況を指します。すごく単純なコードで説明すると下記のような状態です。
const App = () => {
const { data } = useQuery(gql`
query MyQuery {
hello
bye # <- not referenced
}
`);
return (
<div>{data.hello}</div>
);
}
「やたら性能の悪いページを調査していたら、サーバーサイドでN + 1が発生していたが、そもそもクライアントで利用していないフィールドだった」という事態にリアルに遭遇したことがあり、それくらいover-fetchingというのは質の悪い状況です(逆に「利用しているフィールドをクエリに書き忘れている」状態はunder-fetchingと呼ばれますが、こちらは型を自動生成しておけば起き得ない問題です)。
そもそも僕がTemplate Stringを使って「JSXのそばにGraphQL クエリ(またはフラグメント)」を書いているのも、「クエリとその結果を利用するコード」が一箇所にあれば、コードを目で見れば over-fetching かどうかがレビュー時にわかりやすいからです。
「レビュー時にわかりやすい」ということは、ある程度コンテキストに制約を置いてしまえば、機械的にチェックできるはずです。
今回ts-grphql-pluginの開発を通して TypeScriptとGraphQL双方のASTを触る技術はほぼ完全に身に付きました。over-fetchingのチェックをこのツールでやるのか、eslintのような別の何かの上でやるかはともかく、何かしらの解を叩きつけて、「俺のプロジェクトでは絶対にover fetchingは存在しない」と言ってやる日を迎えたいです。