GraphQLとコードジェレータでスキーマファーストな開発
フロントエンドとバックエンドを結合テスト! ...動かない;;
みたいな手戻りは最小限に抑え、効率的な開発を行っていきましょう。
この記事では、GraphQL のスキーマ定義からコード生成してフロントエンドとバックエンドを実装するスキーマファーストな開発について紹介しようと思います。
また、この記事で紹介するコードは以下のリポジトリに置いてます。
技術スタック
TypeScript
バックエンド、フロントエンド共に TypeScript で実装しています。
GraphQL.js
GraphQL の JavaScript ライブラリです。
React
フロントエンドはcreate-react-app
で作りました。
Apollo GraphQL Client
フロントエンドの GraphQL クライアントとしてApolloを使います。
以下で紹介するGraphQL Code Generatorでスキーマから React hooks をコード生成して利用します。
Fastify
サーバーは、Fastifyを採用しました。また、Fastify で GraphQL サーバーを作るのにmercuriusという Adapter を使います。
GraphQL Code Generator
スキーマ定義からフロントエンド、バックエンドのコードを自動生成するツールです。
Prisma
DB クライアントです。prisma はスキーマ定義をもとに Client を生成したり、マイグレーションを実行してテーブルを作ったり、CLI コマンドが充実しています。
GraphQL のスキーマ定義
サンプルのリポジトリでは、backend/ディレクトリ下にあります。
scalar DateTime
type Post {
id: Int!
title: String!
content: String
published: Boolean!
author: User
viewCount: Int!
createdAt: DateTime!
}
type User {
id: Int!
email: String!
name: String
}
type Query {
allUsers: [User!]!
draftsByUser(input: UserUniqueInput): [Post!]
feed(q: String, sort: FeedSort, skip: Int, take: Int): [Post!]!
}
type Mutation {
createDraft(authorEmail: String!, data: PostCreateInput!): Post
deletePost(id: Int!): Post
incrementPostViewCount(id: Int!): Post
signup(data: UserCreateInput!): User
togglePublishPost(id: Int!): Post
}
enum SortOrder {
asc
desc
}
input UserUniqueInput {
email: String
id: Int
}
input FeedSort {
updatedAt: SortOrder!
}
input PostCreateInput {
title: String!
content: String
}
input UserCreateInput {
email: String!
name: String
}
このスキーマ定義をもとにクライアントサイドとサーバーサイドをそれぞれ実装していきます。
バックエンドの実装
graphql-codegen
で Resolver の型を生成
以下の公式ドキュメントを参考に GraphQL Code Generator を使ってみます。
GraphQL Code Generator で自動生成するのにプラグインを指定します。今回使ったプラグインは、 @graphql-codegen/typescript
@graphql-codegen/typescript-resolvers
です。
設定ファイル
overwrite: true
schema: "./schema.graphql"
generates:
src/lib/generated/graphql.ts:
plugins:
- typescript
- "typescript-resolvers"
以下のコマンドを実行して Resolver の型を生成します
yarn graphql-codegen --config codegen.yml
Resolver を実装
Resolver の型が生成されたら、それをもとに実装していきます。
export const resolvers: Resolvers<Context> = {
Query: {
allUsers: (_parent, _args, context, _info) => {
return context.prisma.user.findMany()
},
...
},
Mutation: {
...
}
}
PrismaClient を Context を通して Resolver で受け取ります。
Context の実装は以下。
const prisma = new PrismaClient({
log: ['query', 'info', 'warn', 'error'],
})
export interface Context {
prisma: PrismaClient
}
export const context: Context = {
prisma
}
export const buildContext = async (
_req: FastifyRequest,
_reply: FastifyReply
): Promise<Context> => {
return { prisma }
}
サーバーは Fastify を使いました。Fastify で GraphQL サーバーを実装するのにmercuriusという Adapter を使います。
上で実装したcontext
とresolvers
も一緒に渡します。
import { resolvers } from './resolvers'
import { buildContext } from './context'
// Load schema from the file
const schema = loadSchemaSync(join(__dirname, '../schema.graphql'), {
loaders: [new GraphQLFileLoader()]
})
// Add resolvers to the schema
const schemaWithResolvers = addResolversToSchema({
schema, resolvers
})
const fastify = Fastify()
fastify.register(mercurius, {
schema: schemaWithResolvers,
context: buildContext,
graphiql: true
})
fastify.listen(4000, '0.0.0.0', (err, addr) => {
if (err) {
console.error(err)
process.exit(1)
}
console.info(`Server listening on ${addr}`)
})
あっという間に GraphQL サーバーが実装出来ちゃいましたね
server.ts を実行すると、GraphQL サーバーが立ち上がります。
GraphiQL も立ち上がる設定にしてあるので、ブラウザで以下の URL で開きます
http://localhost:4000/graphiql
フロントエンドの実装
フロントエンドの実装を始める前に、モックサーバーを立ち上げてみます。
ApolloServer を使うことで GraphQL のスキーマからお手軽にモックサーバーを立ち上げられます。
スキーマ定義からモックサーバーを立ち上げ
サンプルのリポジトリでは frontend/mock/ディレクトリ下にあります。
// Load schema from the file
const schema = loadSchemaSync(join(__dirname, '../../server/schema.graphql'), {
loaders: [new GraphQLFileLoader()]
})
const server = new ApolloServer({
schema,
mocks: true
})
server.listen().then(({ url }) =>
console.log(`🚀 Server ready at ${url}`)
)
server.ts を実行したら、ブラウザで以下の URL から ApolloStudio を開けます。
http://localhost:4000
graphql-codegen
で Apollo クライアント生成
バックエンドと同じように GraphQL Code Generator でコード生成します。
以下の公式ドキュメントに従いながら進めました。
使ったプラグインは @graphql-codegen/typescript
@graphql-codegen/typescript-operations
@graphql-codegen/typescript-react-apollo
です。
@graphql-codegen/typescript-react-apollo
は ApolloClient の React hooks を生成するプラグインです。
今回は使用しませんでしたが、hooks 以外にも Component や HOC なども生成出来たり、その他の設定も豊富に用意されてあり、便利そうです。詳しくは公式のドキュメントを参照。
設定ファイル
overwrite: true
schema: "../backend/schema.graphql"
documents: "graphql/**/*.graphql"
generates:
src/graphql/types.ts:
plugins:
- "typescript"
- "typescript-operations"
- "typescript-react-apollo"
documents
documents は、リクエストするときに発行するクエリを定義します。
サンプルのリポジトリでは、frontend/graphql/下にあります。
query feed($q: String!, $take: Int, $skip: Int, $sort: SortOrder!) {
feed(q: $q, sort: { updatedAt: $sort }, take: $take, skip: $skip) {
id
title
content
viewCount
createdAt
author {
id
name
}
}
}
コード生成すると、ApolloClient でリクエストするフックが生成されます
yarn graphql-codegen --config codegen.yml
GraphQL サーバーへのリクエストを実装
上のコマンドでuseFeedQuery
が生成されました。
これを使って、データを取得・表示するコンポーネントを作ります。
export const Feed: React.FC<FeedProps> = ({ q, take, skip, sort }) => {
const { loading, error, data } = useFeedQuery({
variables: { q, take, skip, sort }
})
if (loading) {
return (<div>loading...</div>)
}
if (error) {
return (<div>{error.message}</div>)
}
const posts = data?.feed ?? []
return (
<div className='Feed'>
{posts.map((post) => (
<div className='Feed-Row'>
<a className='Feed-Row-Title' href='/'>{post.title}</a>
{' '}
<span className='Post-Views'>{post.viewCount}views</span>
<div>
<span className='Post-Author'>author: {post.author?.name}</span>
{' '}
<DateTime className='Post-DateTime' datetime={post.createdAt} />
</div>
</div>
))}
</div>
)
}
動作チェック
こちらのサンプルでは、docker-compose で立ち上がるようになっています。
docker-compose up
ブラウザでhttp://localhost:3000
を開いてみます。
サーバーからデータを取得して表示出来ていますね!
いかがだったでしょう?コードジェネレータを活用することで型安全に開発が進められ、尚且つ大幅に実装量を減らすことが出来ましたね!
課題
以上で概ね型安全に実装出来たのですが、残念ながら完全には型安全になっていません。。。
問題は、schema.graphql で定義した custom scalar のDateTime
です。
バックエンドでは graphql-scalars というライブラリを使って resolvers で DateTimeResolver を渡してやることで、serialize してレスポンスを返してくれています。
export const resolvers: Resolvers<Context> = {
...,
DateTime: DateTimeResolver,
}
一方、クライアントサイドで GraphQL Code Generator で生成された型を見てみると、DateTime が any 型になっていました。
export type Scalars = {
ID: string;
String: string;
Boolean: boolean;
Int: number;
Float: number;
DateTime: any; //←これ
};
custom scalar の DateTime を deserialize して Date 型に変換する処理もコード生成して欲しいのですが、どうすればいいのか分からず。。。
現状方法ないんでしょうかね、、、?
Discussion