こんにちわ。 OPENLOGI AdventCalendar 10日目です。
社内ツールをGraphQLを使って構築して2年ほど運用してみました。
色々とPros-Consあるなか、やはり一番便利なのはフロントとサーバーサイドの通信に置いて型ができることの利便性。
それにもかかわらず、一番不便なのが型ファイルの生成と管理。まだまだ型ファイルの変換などのツール群にベストなものがなく、自前でゴリゴリ変換したりする必要があるのが、人に勧めづらい原因の一つでした。
そんな中、React Conf 2019で紹介されていたものを使って見るとめちゃめちゃ便利になってたので、改めてこのツールの導入と、開発環境のワークフローを整理してみたので紹介します。
Automagic TypeScript Codegen for GraphQL | Salvatore Aiello
構成
https://github.com/haradakunihiko/graphql-sandbox に置いているので詳しくは参照。
特徴としては、
- サーバー/クライアントをモノレポで管理
- サーバーは
Koa
×Apollo Server
×typescript
-
nodemon
で監視、ts-node
で実行
-
- クライアントは
React
×Apollo Client
×typescript
-
create-react-app
で生成
-
- GraphQLスキーマからのtypescript型生成に graphql-code-generator を利用
です。
スクリプトを、以下のように定義し、プロジェクトルートから、
yarn run dev
を行うことで、型生成・アプリケーションサーバーの起動・クライアントdev-serverの起動を行うようにしています。
{
"scripts": {
"codegen-watch": "graphql-codegen --config codegen.yml --watch",
"dev:server": "yarn workspace server dev",
"dev:client": "yarn workspace client start",
"dev": "concurrently \"yarn run codegen-watch\" \"yarn run dev:server\" \"yarn run dev:client\""
}
}
{
"scripts": {
"dev": "nodemon"
}
}
{
"scripts": {
"start": "react-scripts start"
}
}
サーバーサイドでは、nodemonを利用してts-nodeを実行させます。
{
"watch": ["."],
"ext": "ts",
"ignore": "node_modules",
"exec": "ts-node ./index.ts"
}
型ファイルの自動生成
まず、ディレクトリ構成はこんな感じ。(上述のリポジトリから少し簡素化して記述しています)
.
├── client
│ ├── components
│ └── graphql
│ ├── generated
│ │ └── graphql.tsx
│ ├── addTodo.graphql
│ └── getTodos.graphql
├── server
│ ├── application
│ └── interface
│ └── graphql
│ ├── generated
│ │ ├── graphql.ts
│ │ └── schema.graphql
│ ├── mutations
│ │ └── Todo.ts
│ ├── resolvers
│ │ ├── Query.ts
│ │ └── Todo.ts
│ ├── schema.ts
│ └── typedefs
│ ├── Mutation.graphql
│ ├── Query.graphql
│ └── Todo.graphql
└── codegen.yml
- graphQLの型定義を複数にわけてかけること
- Resolvers/Mutationsの処理を同様に分割していること
- client/serverにそれぞれ型の定義ファイルを生成すること(
generated
)
あたりがポイントです。これらに対して、以下の設定で型生成を行います。
overwrite: true
# graphQLのスキーマ定義。複数ファイルに分散して保持しているものをGlob Expressionで指定。
schema: "server/interface/graphql/typedefs/**/*.graphql"
documents: "client/graphql/**/*.graphql" # クライアントから実行するクエリ
generates:
# 複数の型定義ファイルをまとめたやつを生成。これをGraphQL Serverに食わせる
server/interface/graphql/generated/schema.graphql:
plugins:
- schema-ast
# サーバーサイドの型定義
server/interface/graphql/generated/graphql.ts:
plugins:
- "typescript"
- "typescript-resolvers"
# クライアントサイドの型定義
client/graphql/generated/graphql.tsx:
plugins:
- "typescript"
- "typescript-operations"
- "typescript-react-apollo" # apollo用。なくてもよい
config:
withHooks: true
withComponent: false
withHOC: false
-
schema
に定義するのは型定義ファイル。.graphql
で型を書いていますが、.json
でもよいしschemaを返すURLも記載できる。また、関数を定義して実行させることもできるので柔軟にできる。 -
documents
に定義するのは主にクエリ。 -
generates
pluginsを指定することで、それぞれ必要な型定義のみを分けて出力できます。react-apolloを利用しているのでtypescript-react-apollo
のプラグインを追加しています。型として必要なものではないですが、apolloを使う場合はラップした関数を生成してくれるのでとても便利。typescriptだけでなく、flowやJava、Kotlinなども対応済み。
生成したファイルやリゾルバーをまとめてApolloServerに渡してサーバーを起動。
import fs from 'fs';
import path from 'path';
// use generated file
export const typeDefs = fs
.readFileSync(path.join(__dirname, 'generated/schema.graphql'))
.toString();
// Load mutations
const mutations = fs.readdirSync(path.join(__dirname, 'mutations')).map(file => {
return require('./mutations/' + file);
}).reduce((acc, functions) => ({ ...acc, ...functions}), {});
// // Load resolvers
export const resolvers = fs.readdirSync(path.join(__dirname, 'resolvers')).map(file => {
return require('./resolvers/' + file);
}).reduce((acc, functions) => ({ ...acc, ...functions}), mutations);
import Koa from 'koa';
import { ApolloServer } from 'apollo-server-koa';
import { typeDefs, resolvers } from './interface/graphql/schema';
const server = new ApolloServer({ typeDefs, resolvers });
const app = new Koa();
server.applyMiddleware({ app });
app.listen({ port: 4000 }, () =>
console.log(`🚀 Server ready at http://localhost:4000${server.graphqlPath}`),
);
生成物とその利用
サーバーサイド
こんな形の型定義が生成されます。
export type QueryResolvers<Context = any, ParentType = Query> = {
viewer?: Resolver<User, ParentType, Context>,
todos?: Resolver<Maybe<ArrayOrIterable<Todo>>, ParentType, Context>,
};
これを利用することで、存在しないリソルバーを定義したり、返り値が異なる場合はエラーになります。
import { todoStore } from '../../db';
import { QueryResolvers } from '../generated/graphql';
export const Query: QueryResolvers = {
todos: () => todoStore.getAllTodo()
}
クライアントサイド
クエリや、型が生成され、 typescript-react-apollo
を利用すると、hooksでクエリを投げるための関数が生成されます。(HOCやComponentベースの関数を出力することもできます)
export const GetTodoDocument = gql`
query getTodo {
todos {
id
content
}
}
`;
export type Todo = {
id: Scalars['Int'],
content: Scalars['String'],
};
export type Query = {
todos?: Maybe<Array<Todo>>,
};
export type GetTodoQueryVariables = {};
export type GetTodoQuery = ({ __typename?: 'Query' } & { todos: Maybe<Array<({ __typename?: 'Todo' } & Pick<Todo, 'id' | 'content'>)>> });
export function useGetTodoQuery(baseOptions?: ApolloReactHooks.QueryHookOptions<GetTodoQuery, GetTodoQueryVariables>) {
return ApolloReactHooks.useQuery<GetTodoQuery, GetTodoQueryVariables>(GetTodoDocument, baseOptions);
}
hooksの関数を利用すればデータの取得が行え、もちろんクエリの引数や返り値が型で守られます。
import React, { useState } from 'react';
import { useGetTodoQuery, useAddTodoMutation } from '../graphql/generated/graphql';
const TodoList: React.FC = () => {
const todoQuery = useGetTodoQuery();
const [addTodoMutation] = useAddTodoMutation();
const [todoValue, setTodoValue] = useState('');
if (todoQuery.loading) {
return <div>loading</div>
}
return (
<div>
<ul>
{ todoQuery.data.todos.map(({ id, content }) => <li key={id}>{content}</li>) }
</ul>
<input value={todoValue} onChange={(e) => setTodoValue(e.target.value)} />
<button onClick={async () => {
await addTodoMutation({
variables: {
content: todoValue
}
});
todoQuery.refetch();
}}>追加</button>
</div>
)
}
export default TodoList;
クエリの定義を書くことで、実行するHooks関数の生成や型定義によって守られるのは非常に楽ですね・・
実際にアプリケーションを書いているともう少し複雑になるケースも多そうですが、コーディング量が減るのは純粋に楽です。
最後に
これでようやく型生成については一つの解が出たような気がします。とはいえGraphQLには気になるところがまだまだ、、
特に、大規模での開発になるとネームスペースのような概念が必要だと感じるのですが、今の所なんとか頑張るしかやりようがないのが辛い。
また、スキーマファーストではなく、コードファーストなアプローチのNexusなども試しつつ、ベストプラクティスを探して行きたいと思います。