Help us understand the problem. What is going on with this article?

GraphQLでスキーマ駆動開発導入したら開発効率がアップするぞ!!

More than 1 year has passed since last update.

現在携わっているプロジェクトにGraphQL を使ってスキーマ駆動開発を導入したんですけど、かなり開発効率もよくなおかつ品質向上に良いのでやったことのまとめとして記事にしてみます。

これからスキーマ駆動開発を導入を検討している方の後押しになれば幸いです。

この開発をした時のプロジェクトを元にボイラープレートも作ってみました。実際のコードはこちらをみた方がわかりやすいです。
https://github.com/Takumi0901/graphql-koa-apollo-react-boilerplate

スキーマ駆動以前の開発だと何が辛いのか

どうしてもフロントエンド開発の着手が遅くなりがちという点があると思います。
基本的には設計 > API実装 > フロント実装という流れなんですよね。さらに開発を進めていく中でうまく行かない部分があればAPIの実装・修正が必要になる場合もあります。そうするとさらにフロントの実装は遅れてしまいます。

あとはAPIの実装がこんな感じになりそうだから、そうなるていでフロントも実装したりも。んで、あとで出戻りがあったりして開発効率としては良くないですね。

スキーマ駆動開発だと何が嬉しいのか

  • API開発とフロント開発を同時に進めることができる
  • スキーマがドキュメントとして存在するので出戻りがない(少ない)
  • スキーマを元に型定義するので品質もアップ

あとは直接スキーマ駆動開発の利点ではないのですが、後述の graphql-codegen を使うとコマンド一つでスキーマから型定義ファイルを生成することができ、さらに開発効率がアップします。

開発手順のおさらい

まずスキーマ駆動の開発手順としてはざっくり以下のよう流れになっています。

  1. スキーマの定義
  2. mock作成
  3. フロント、サーバ共に実装
  4. テスト or リリース

以下、開発手順と照らし合わせながら記事を進めます。

プロジェクトの構成図

基本方針として以下のことが決まっていました。

  • GraphQLを使う
  • サーバサイド(BFF)はnodeで
  • フロントはReact
  • すでにマイクロサービスはある

image.png

スキーマ

まずはスキーマの定義を行います。
例えばこんな感じで書きます。今回はわかりやすくUser一覧とUser単体の取得系とログインするスキーマを定義してみます。

schema.graphql
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の作成をします。

user.graphql
query user($id: ID!) {
  user(id: $id) {
    id
    name
    profileImageUrl
    registerDate
  }
}

query users {
  users {
    id
    name
    registerDate
    profileImageUrl
  }
}

login.graphql
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/ を指定しています。

codegen.yml
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

かなり省略してるけど概ねこんな感じで生成されます。

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タイプなども選べるのでフロントの実装に合わせて生成が可能です。

actions.ts
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-codegenuseUsersQueryuseLoginMutation を生成してくれるのでフロントでは使うだけです。この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なのかな、エラーハンドリングも UNAUTHENTICATEDINTERNAL_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の事例がもっと増えてくれると良いなと。

102Design
埼玉でフロントエンドエンジニアのフリーランス JavaScript, TypeScript, Reactを中心にコード書いてます。 2児の父。フットサル、麻雀が趣味
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした