現在携わっているプロジェクトにGraphQL
を使ってスキーマ駆動開発を導入したんですけど、かなり開発効率もよくなおかつ品質向上に良いのでやったことのまとめとして記事にしてみます。
これからスキーマ駆動開発を導入を検討している方の後押しになれば幸いです。
この開発をした時のプロジェクトを元にボイラープレートも作ってみました。実際のコードはこちらをみた方がわかりやすいです。
https://github.com/Takumi0901/graphql-koa-apollo-react-boilerplate
スキーマ駆動以前の開発だと何が辛いのか
どうしてもフロントエンド開発の着手が遅くなりがちという点があると思います。
基本的には設計 > API実装 > フロント実装という流れなんですよね。さらに開発を進めていく中でうまく行かない部分があればAPIの実装・修正が必要になる場合もあります。そうするとさらにフロントの実装は遅れてしまいます。
あとはAPIの実装がこんな感じになりそうだから、そうなるていでフロントも実装したりも。んで、あとで出戻りがあったりして開発効率としては良くないですね。
スキーマ駆動開発だと何が嬉しいのか
- API開発とフロント開発を同時に進めることができる
- スキーマがドキュメントとして存在するので出戻りがない(少ない)
- スキーマを元に型定義するので品質もアップ
あとは直接スキーマ駆動開発の利点ではないのですが、後述の graphql-codegen を使うとコマンド一つでスキーマから型定義ファイルを生成することができ、さらに開発効率がアップします。
開発手順のおさらい
まずスキーマ駆動の開発手順としてはざっくり以下のよう流れになっています。
- スキーマの定義
- mock作成
- フロント、サーバ共に実装
- テスト or リリース
以下、開発手順と照らし合わせながら記事を進めます。
プロジェクトの構成図
基本方針として以下のことが決まっていました。
- GraphQLを使う
- サーバサイド(BFF)はnodeで
- フロントはReact
- すでにマイクロサービスはある
スキーマ
まずはスキーマの定義を行います。
例えばこんな感じで書きます。今回はわかりやすくUser一覧とUser単体の取得系とログインするスキーマを定義してみます。
scalar Date
scalar DateTime
scalar Error
scalar EmailAddress
scalar URL
type Query {
user(id: ID!): User
users: [User]
}
type Mutation {
login(email: String!, password: String!): AuthResponse!
}
type AuthResponse {
success: Boolean
error: Error
token: String
}
type User {
id: ID!
name: String
email: EmailAddress
registerDate: DateTime
profileImageUrl: URL
}
上記スキーマでOKならば次にqueryとmutationの作成をします。
query user($id: ID!) {
user(id: $id) {
id
name
profileImageUrl
registerDate
}
}
query users {
users {
id
name
registerDate
profileImageUrl
}
}
mutation loginStaff($email: String!, $password: String!) {
login(email: $email, password: $password) {
token
}
}
で次にTypeScriptであれば型の定義したり、resolverの型定義だったりしなければいけません。これが意外と面倒なんですよね。 graphql-codegen
を使うことでこの辺りの面倒な作業を完全に省けます。
というのもスキーマさえあればコマンド一つでTSの型定義やらuseQuery(後述)、useMutation(後述)などを生成をしてくれます。これについてもスキーマ駆動開発との相性がよかったなと思っています。スキーマを定義したらフロントのViewでqueryとmutationが使える状態です。
詳しいやり方は公式をみてもらうとして
codegenの設定ファイルは以下のようにしました。出力先として server/
と client/
を指定しています。
overwrite: true
schema: './graphql/schema.graphql' // スキーマの場所
documents: './graphql/**/*.graphql' // queryとかmutationの定義の場所
generates:
../server/src/gen/types.ts: // 出力先 こっちはserver側
plugins:
- 'typescript'
- 'typescript-resolvers'
../client/src/gen/actions.tsx: // 出力先 こっちはclient側
plugins:
- 'typescript'
- 'typescript-operations'
- 'typescript-react-apollo'
config:
withComponent: false
withHooks: true
withHOC: false
server/src/gen/types.ts
かなり省略してるけど概ねこんな感じで生成されます。
export type AuthResponse = {
success?: Maybe<Scalars['Boolean']>
error?: Maybe<Scalars['Error']>
token?: Maybe<Scalars['String']>
}
export type Mutation = {
login: AuthResponse
}
export type MutationLoginArgs = {
email: Scalars['String']
password: Scalars['String']
}
export type Query = {
user?: Maybe<User>
users?: Maybe<Array<Maybe<User>>>
}
export type QueryUserArgs = {
id: Scalars['ID']
}
export type User = {
id: Scalars['ID']
name?: Maybe<Scalars['String']>
email?: Maybe<Scalars['EmailAddress']>
registerDate?: Maybe<Scalars['DateTime']>
profileImageUrl?: Maybe<Scalars['URL']>
}
export type MutationResolvers<Context = any, ParentType = Mutation> = {
login?: Resolver<AuthResponse, ParentType, Context, MutationLoginArgs>
}
export type QueryResolvers<Context = any, ParentType = Query> = {
user?: Resolver<Maybe<User>, ParentType, Context, QueryUserArgs>
users?: Resolver<Maybe<Array<Maybe<User>>>, ParentType, Context>
}
export type UserResolvers<Context = any, ParentType = User> = {
id?: Resolver<Scalars['ID'], ParentType, Context>
name?: Resolver<Maybe<Scalars['String']>, ParentType, Context>
email?: Resolver<Maybe<Scalars['EmailAddress']>, ParentType, Context>
registerDate?: Resolver<Maybe<Scalars['DateTime']>, ParentType, Context>
profileImageUrl?: Resolver<Maybe<Scalars['URL']>, ParentType, Context>
}
export type Resolvers<Context = any> = {
AuthResponse?: AuthResponseResolvers<Context>
Mutation?: MutationResolvers<Context>
Query?: QueryResolvers<Context>
User?: UserResolvers<Context>
}
export type IResolvers<Context = any> = Resolvers<Context>
client/src/gen/actions.ts
今回は、hooksのみで実装しようと思っていたので react-apollo-hooks
使います。codegen.yml の withHooks
をtrueにすることで生成されます。Apolloのcomponentタイプなども選べるのでフロントの実装に合わせて生成が可能です。
type Maybe<T> = T | null
/** All built-in and custom scalars, mapped to their actual values */
export type Scalars = {
ID: string
String: string
Boolean: boolean
Int: number
Float: number
EmailAddress: any
DateTime: any
URL: any
Error: any
Date: any
}
export type AuthResponse = {
success?: Maybe<Scalars['Boolean']>
error?: Maybe<Scalars['Error']>
token?: Maybe<Scalars['String']>
}
export type Mutation = {
login: AuthResponse
}
export type MutationLoginArgs = {
email: Scalars['String']
password: Scalars['String']
}
export type Query = {
user?: Maybe<User>
users?: Maybe<Array<Maybe<User>>>
}
export type QueryUserArgs = {
id: Scalars['ID']
}
export type User = {
id: Scalars['ID']
name?: Maybe<Scalars['String']>
email?: Maybe<Scalars['EmailAddress']>
registerDate?: Maybe<Scalars['DateTime']>
profileImageUrl?: Maybe<Scalars['URL']>
}
export type LoginMutationVariables = {
email: Scalars['String']
password: Scalars['String']
}
export type LoginMutation = { __typename?: 'Mutation' } & {
login: { __typename?: 'AuthResponse' } & Pick<AuthResponse, 'token'>
}
export type UserQueryVariables = {
id: Scalars['ID']
}
export type UserQuery = { __typename?: 'Query' } & {
user: Maybe<{ __typename?: 'User' } & Pick<User, 'id' | 'name' | 'profileImageUrl' | 'registerDate'>>
}
export type UsersQueryVariables = {}
export type UsersQuery = { __typename?: 'Query' } & {
users: Maybe<Array<Maybe<{ __typename?: 'User' } & Pick<User, 'id' | 'name' | 'registerDate' | 'profileImageUrl'>>>>
}
import gql from 'graphql-tag'
import * as ReactApolloHooks from 'react-apollo-hooks'
export const LoginDocument = gql`
mutation login($email: String!, $password: String!) {
login(email: $email, password: $password) {
token
}
}
`
export function useLoginMutation(
baseOptions?: ReactApolloHooks.MutationHookOptions<LoginMutation, LoginMutationVariables>
) {
return ReactApolloHooks.useMutation<LoginMutation, LoginMutationVariables>(LoginDocument, baseOptions)
}
export const UserDocument = gql`
query user($id: ID!) {
user(id: $id) {
id
name
profileImageUrl
registerDate
}
}
`
export function useUserQuery(baseOptions?: ReactApolloHooks.QueryHookOptions<UserQueryVariables>) {
return ReactApolloHooks.useQuery<UserQuery, UserQueryVariables>(UserDocument, baseOptions)
}
export const UsersDocument = gql`
query users {
users {
id
name
registerDate
profileImageUrl
}
}
`
export function useUsersQuery(baseOptions?: ReactApolloHooks.QueryHookOptions<UsersQueryVariables>) {
return ReactApolloHooks.useQuery<UsersQuery, UsersQueryVariables>(UsersDocument, baseOptions)
}
mockサーバー
Apollo Serverで簡単に作れます。てかこれだけです。
さらに、レスポンスをカスタマイズも簡単にできます。詳しいやり方はMocking - Apollo Docsを見てみてくださいね。
const { ApolloServer, gql } = require('apollo-server');
const typeDefs = gql`
type Query {
hello: String
}
`;
const server = new ApolloServer({
typeDefs,
mocks: true,
});
server.listen().then(({ url }) => {
console.log(`🚀 Server ready at ${url}`)
});
マイクロサービス
User, Admin ・・・といった具合にマイクロサービス化しています。
BFF (バックエンド For フロントエンド)
簡単に技術スタックをご紹介すると・・・
Node + koa + Apollo + TypeScript って感じです。GraphQLをBFFで使うこともできたのもマイクロサービスがすでにあったのが大きいですね。今回のプロジェクトにGraphQLはかなりマッチしていました。
Apollo-Serverを使ってGraphQLサーバを立ち上げるのですが、http://localhost:4000/graphql
など任意のURLでGraphiQL IDEが立ち上がるのでブラウザ上で確認をしながら実装することができます。
const Query: QueryResolvers = {
talent(obj, args, context, info) {
// マイクロサービスからかき集めてデータを返す
},
users(obj, args, context, info) {
// マイクロサービスからかき集めてデータを返す
}
}
const Mutation: MutationResolvers = {
async login(_obj, arg: { email: string; password: string }) {
const { password, email } = arg
// 何かしらの処理
}
}
const resolvers: Resolvers = {
Query,
Mutation,
}
export default resolvers
フロントエンド
React + Apollo + TypeScript で実装をしています。
実際の開発の流れとしてはまずは前述のmockサーバで実装、resolverができたらdevサーバで開発・確認をして進めます。
さらに、今回はwithHooksを使って取得・更新をするようにしました。graphql-codegen
で useUsersQuery
や useLoginMutation
を生成してくれるのでフロントでは使うだけです。このwithHooksが結構よかったですね。Componentと機能ごとに作ることができその中に閉じ込めることができます。個人的にはAtomic Designと合わせるさらに良いかなと。
const Users: React.FunctionComponent<{}> = () => {
const { data, loading } = useUsersQuery()
if (loading) return <div>Loading</div>
if (Object.keys(data).length < 1) return null
return (
<React.Fragment>
{data.talents.map((e, key) => {
return (
<div>
// 何かしら表示
</div>
)
})}
</React.Fragment>
)
}
const LoginContent: React.FunctionComponent<{}> = () => {
const onSubmitSignIn = useLoginMutation({
update: (_, { data }) => {
// mutationでsuccess後の処理
}
})
return (
// form
)
}
余談だけどReduxはいらない
GraphQLを使うことでReduxはいよいよ必要ないかなと。そもそもサーバ側とReduxで同じデータを管理していたようなものですし。さらに、GraphQLというかこの場合はApolloなのかな、エラーハンドリングも UNAUTHENTICATED
や INTERNAL_SERVER_ERROR
のコードを返してくれるので、Toastなんかと組み合わせてそこまでコストかからずに実装できます。
テストについて
基本的にスキーマから生成しているものなので、型やデータの整合性は取れています。さらにGraphQLエラーとしてValidationもしてくれるので、従来の開発スタイルよりもかなり品質は向上しますね。
ただ、ログインのMuationのようにemailやpasswordをvariablesとして渡す必要がある場合にはスキーマの変更がフロントまで行き届かないです。
例えばスキーマで email
> emailAddress
というように変更があったとして、型定義やらwithHooksなどは生成し直してくれるわけなんですが、View側で
useLoginMutation({variables: {email: '', password: ''}})
としている場所は勝手に変更されることはないです。ま、当然といえば当然何ですが。あまり頻発するようなことはないでしょうけどこのあたりの変更をテストで間違ってリリースしてしまうのを防ぐ必要はありそうです。
スナップショットなどをとってViewの差分を見ても良いんですけど、これはリリースした後にゆっくり導入していければ。
色々試したんですけど、 easygraphql-tester
一番簡単でわかりやすかったので。あとはCircle Ciなど使ってテストしていければいいですよね。
describe('A user', function() {
let tester
beforeAll(() => {
tester = new EasyGraphQLTester(schemaCode)
})
test('UsersDocument', () => {
tester.test(true, UsersDocument)
})
test('UserDocument', () => {
tester.test(true, UserDocument, { id: 1 })
})
test('LoginDocument', () => {
tester.test(true, LoginStaffDocument, { email: 'example@gmail.com', password: 'hgoehoge' })
})
})
とはいえ問題点もある
プロジェクトリリース後にスケールした時や変更があった時ににスキーマ管理(バージョン含め)が今後のリリース後の課題感としてチームで話が上がっています。その辺りも今後記事にできればと思っています。
とはいえGraphQLを初めてプロジェクトに導入した結果、スキーマ駆動開発のおかげで作業効率もよく、品質が向上したのは良いことだと思っています。
BFFとの相性が良いのは実感できたが、そうじゃない場合はどうなんだろってのはあるので機会があれば試したいみたいです。
今後GraphQLの事例がもっと増えてくれると良いなと。