こんにちは。ファンと共に時代を進める、Web3スタートアップのGaudiyでエンジニアをしているkodai(@r34b26)です。
Gaudiyでは、以前からフロントエンド(Next.js)とGateway(Node.js)の通信においてGraphQLを使用しています。
その際に、GraphQLスキーマからコードを自動生成するツールとしてGraphQL-Codegenを活用してきましたが、開発者体験やユーザー体験においていくつかの課題を抱えていたため、今回、gql.tadaに移行しました。
この記事では、課題背景から実際の移行プロセスを紹介してみるので、gql.tadaが気になっている人やGraphQLの運用に課題感のある人の参考になれば嬉しいです。
1. GaudiyとGraphQL
最初に、Gaudiyの技術スタックを簡単に紹介します。
Gaudiyのバックエンドでは、各コミュニティで必要な機能をマイクロサービスとして構成しており、その機能群を、GatewayであるGraphQLサーバーを通すことでフロントエンドの複雑性を回避しています。
導入時の課題背景やGaudiyのプロダクト戦略におけるGraphQLの役割は、以下の記事が詳しいのでここでは割愛します。
2. GraphQL-Codegenにまつわる課題
これまではGraphQL-Codegenを用いて、GraphQLのスキーマファイルからでコード生成し、Query, Fragmentのスキーマ定義を記述してきました。
GraphQL-Codegenは、Apolloの公式ドキュメントにも記述されていたり、プラグインのエコシステムも充実していることから、GraphQLを使う上ではもはや最低限整備しなければいけないツールとして一般的になっているのではないでしょうか。
ただ、このツールには、いくつかの課題があります。
まず開発者体験としては、
- schema記述時に補完やバリデーションが効かず、
npx graphql-codegen
を都度実行して検証しなければならない - schema記述後に、それを用いて、queryやmutationを記述する前に
npx graphql-codegen
を実行しなければいけない
などが課題です。もちろん watch mode を利用すれば軽減しますが、体感できるほどのラグがあることと、schema量が大量になってくると生成はされてもVSCode(などのエディタ)が読み込まれるまでに時間がかかることがありました。
またユーザー体験としても、
- 生成されたdocument nodeがclient bundleに含まれてしまい、エンドユーザーまで配信されてしまうことで、サイトパフォーマンスに悪影響を与えてしまう
- アプリケーションの成長(schema定義数増加)と比例して線形的に増えるしかない構造にある
といった課題があります。
/** * Map of all GraphQL operations in the project. * * This map has several performance disadvantages: * 1. It is not tree-shakeable, so it will include all operations in the project. * 2. It is not minifiable, so the string of a GraphQL query will be multiple times inside the bundle. * 3. It does not support dead code elimination, so it will add unused operations. * * Therefore it is highly recommended to use the babel or swc plugin for production. */
これらは、生成結果のドキュメントファイルにも明確に記述されています。babelとswcのプラグインが用意されていますが、特にswcプラグインはNext.jsとのversion管理の問題などがあり、ブロッカーが依然として多い状態にあります。
実際、Gaudiyのwebアプリケーションでは、60kb近くにも膨れていました。
これらの課題を解決するために、今回導入したのが、gql.tadaです。
3. gql.tadaの導入
In short, `gql.tada`, - parses your GraphQL documents in the TypeScript type system - uses your introspected schema and scalar configuration to derive a schema - maps your GraphQL queries and fragments with the schema to result and variables types - creates fragment masks and enforces unwrapping fragments gradually
`gql.tada`を簡潔に述べると、 - GraphQLドキュメントをTypeScriptの型systemでパースする - イントロスペクトスキーマとスカラーからスキーマを導出する - スキーマと共に GrahphQLクエリとフラグメントを戻り値と変数の型にマッピングする - フラグメントマスクを生成し、段階的に解消する
ということで、今までdocument nodeを生成してschemaを解釈していた部分まですべて、型systemの中で解決することができます。
つまりは即座にコード反映し、推論とエラー表現によって操作性が高く、clientバンドルにも影響しない環境を実現してくれます。
仕組みとしては TypeScript LSP プラグインとして、GraphQLSPが動いており、これと組み合わせることで TypeScript コンパイラが型チェックを実行できるようになっています。
これが何をしているかというと schema
で指定している GraphQL Schema の変更を監視しており、これがスキーマのイントロスペクションとして tadaOutputLocation
で指定されたファイルに保存しています。
その後 gql.tada に渡されアンビエント宣言をすることで typescript コンパイラが型チェックを実行できるようになっています。
またスポンサーには、GraphQL-Codegenの作者である The Guild も名を連ねており、urqlの作者がコアメンテナーであることも、将来性の観点から導入を後押しする一材料となりました。
デモを見れば、その快適性は一目瞭然かと思うので、ぜひこちらの動画を見つつ、exampleを動かしてみてください。
4. 移行プロセスと注意点
ここからは、実際の移行プロセスについてご紹介します。
現在、client presetからのcodemodは検討されてはいるものの、まだ提供されていない状態にあります。
ただ、基本的には GraphQL-Codegen の成果物と対応するメソッドは用意されているので、それを書き換えるだけで問題なく動作することと、メリットの大きさからこれを待たずに移行することを決めました。
手順は以下の通りです。ドキュメント通りにセットアップした後、
1:graphql
関数と型のimport先を変更する
// before import { graphql } from '@/generated/graphql'; import type { ResultOf, VariablesOf } from '@graphql-typed-document-node/core'; // after import { graphql } from '@/tada'; import type { ResultOf, VariablesOf } from 'gql.tada';
2:Fragment, Query, Mutation 定義時のimport元と命名を変更し、使用する子Fragmentをarrayに追加する
// before import { getFragmentData, graphql, type FragmentType } from '@/generated/graphql'; const ParentFragment = graphql(` fragment ParentFragment { ...ChildFragment } `) type Props = { data: FragmentType<typeof ParentFragment>; } const Component = ({ data }) => { const fragment = getFragmentData(ParentFragment, data); ... } // after import { graphql } from '@/tada'; import { readFragment, type FragmentOf } from 'gql.tada'; import { ChildFragment } from './ChildComponent'; const ParentFragment = graphql(` fragment ParentFragment { ...ChildFragment } `, [ChildFragment]) type Props = { data: FragmentOf<typeof ParentFragment>; } const Component = ({ data }) => { const fragment = readFragment(ParentFragment, data); ... }
3:テストデータ作成時のimport元と命名、引数の順番を変更する
// before import { makeFragmentData } from '@/generated/graphql'; const data = makeFragmentData({ __typename: 'Test', id: '1', ... }, TestFragment ) // after import { maskFragments } from 'gql.tada'; const data = maskFragments([TestFragment], { // __typename: 'Test', id: '1', ... } )
これでできます。我々の場合は jscodeshift を用いて、自分たちのケースに沿って8割程の移行をしてくれるscriptを用意した上で、機能区分ごとに移行しました。
また移行に際しての注意点として、いくつかTipsをご紹介します。
- scalar or enum の取り出し
type Status = ReturnType<typeof graphql.scalar<'Status'>>;
maskFragments
のpropertyは完全一致させる
export const ParentFragment = graphql( /* GraphQL */ ` fragment ParentFragment on Test { id title items { id ...ChildFragment } } `, [ChildFragment] );
このFragmentをmockする場合、以下のように同じFragment構造で定義します。
maskFragments([ParentFragment], { id: 'id1', title: 'タイトル', items: [{ id: 'id1', ...maskFragments([ChildFragment], { id: 'id1', title: 'タイトル', // ... }), }], })
一致していない時に出る型エラーがわかりにくく、かつ codemod作りきるのが難しかったため、この修正に一番時間がかかりました。
- Fragmentを複数同じ階層に渡す際には、arrayに追加する
fragment CFragment on CSection { id ...AFragment ...BFragment }
上述のような Fragment定義のときには、
// NO maskFragments([CFragment], { id, ...maskFragments([AFragment], { id, // 省略 }), ...maskFragments([BFragment], { id, // 省略 }), }), // OK maskFragments([CFragment], { id, ...maskFragments([AFragment, BFragment], { id, // 省略 }), }),
このようにする必要があります。
些細ではありますが、ドキュメントに例として明示されていないので、嵌る人もいたポイントです。
5. まとめ
GraphQL-Codegenからgql.tadaに移行した結果、
- schema記述時の補完と即時の型反映という開発体験の向上
- 100KB近いバンドルサイズ削減から得られるユーザー体験の向上
という、まさに一石二鳥の効果を得ることができました。
コードベースが多ければ、turboを設定するなどでコンパイル速度とメモリの問題も抑えられますし、大きなダウンサイドは今のところ感じていません。
我々のユースケースでは、一点だけ型の不足を感じましたが、逆に言うとそれだけでした。
TypeScript自体のperformanceが、このツールが向かう方向性の明確なリスクであることは間違いないですが、Codegen以外の選択肢の一つとして検討してもよいのでは? と個人的には思います。
もしGaudiyのGraphQL周りやwebフロントエンドについて興味ある人いたら、ラフにお話しさせてください!
一緒に開発する仲間も募集してます