Chapter 05

GraphQLを使ってみよう

oubakiou
oubakiou
2021.11.11に更新

GraphQL ServerをNext.jsのAPI Routesで動かしてみよう

この章ではGraphQL ServerでAPIを提供してみましょう。

コラム:なぜGraphQLを使うのか

GraphQLのQLはQuery Languageを意味しています。データを参照する際にはGraphQLサーバーが提供するスキーマに対してGraphQLクライアントがクエリーで問い合わせを行い、そのクエリーに基づいた結果をサーバーが返すというRDBとSQLの関係にも似たモデルで動作します。

では伝統的なREST APIや他の選択肢と比べてどういったメリットやデメリットがあるのでしょうか。


メリット

APIの型をクライアントサイドで生成できる

GraphQLクライアントやライブラリの中にはクエリーやスキーマを元にコードや型を生成出来るものがあり、これによって前章で扱ったような外界からやってくる型の問題を解決(緩和)する事ができます。

ただし伝統的なREST APIでもOpenAPI(Swagger) generatorのような仕組みと組み合わせる事で同様の利点を得る事は出来ます。

通信効率が良い

特にモバイル回線を利用するクライアントでは重要になります。

一般的なREST APIの場合、1つのAPIから取得できるリソースは1つのみです。例えば1つのページを作り上げるのに3つのリソースへのアクセスが必要な場合、3回のAPIコールが発生する事になります。APIコールの並列化など実装方法によってある程度の緩和は可能ですが非効率である事には代わり有りません。こういった問題をアンダーフェッチと呼びますが、GraphQLでは1つのクエリーで複数のリソースを取得する文法が規定されているため、クエリー内で宣言的に記述するだけで解決することが出来ます。

また同じリソースへのアクセスであっても、あるページでは全てのフィールドのデータが必要になり、別のあるページでは2つのフィールドのデータだけが必要になる、という状況はよくあるものです。一般的なREST APIの場合、こういった状況では後者のページでも全てのフィールドのデータを取得してしまいがちです。こういった問題をオーバーフェッチと呼びますが、GraphQLではクエリーの中でリソースのどのフィールドを取得するかを指定する文法が規定されているため、クエリー内で宣言的に記述するだけで解決することが出来ます。

ただし伝統的なREST APIでもクライアントとの間にBFF(Backend For Frontend)と呼ばれる層を設ける事で同様の利点を得る事は出来ます。(BFFとしてGraphQLサーバーを利用するケースもあります)またオーバーフェッチに関しては例えばGoogleのAPIデザインガイドラインでは$fieldsパラメータ(FieldMask)という手法も紹介されています。

エコシステムが実用水準まで発達している

本書で利用しているApolloはJavaScriptで実装されたGraphQLサーバーやクライアントを提供していますが、このクライアントはNext.jsのようなWebアプリケーションからだけでなく、React Nativeで作られたネイティブアプリケーション等からも利用する事が出来ます。Apolloプロジェクトはコード生成機能やキャッシュ機構なども含まれたオールインワンを志向したライブラリ群ですが、Apollo以外にも様々な実装があるため自身の要件にあったものを選ぶと良いでしょう。

またJavaScriptに限らず様々な言語の実装があるため、サーバーはRubyでAPIモードのRailsと組合せて書き、クライアントはSwiftKotlinで書くような多言語の混在も可能です。


デメリット

エコシステムが発展途上のため今後まだ大きく変化する可能性がある

例えばGraphQLのスキーマ定義手法ではSDLファーストとコードファーストのように異なるアプローチが並び立っている状況にあります。どちらにも利点はある話ですが将来的にどちらが主流となっていくかは不明です。

またGraphQLクライアントで言えば、Apollo Clientを使うかRelayを使うか、といった部分も迷いどころです。今後何かがデファクトになりそれ以外は廃れていくのか、それともコンセプトの違いから使い分けられ続けていくのか今後の発展次第の部分があります。

クエリーの自由度の高さ故に負荷予測や対策がし辛い

Securing Your GraphQL API from Malicious Queries

GraphQLと同様に自由度の高いSQLではコントロール下にあるマシンからしかクエリーを受け付けない運用が一般的ですが、インターネットに露出したGraphQLサーバーに対してはブラウザのようなコントロール下にないクライアントからのクエリーも届くため負荷予測やその対策が難しい側面があります。

ただしクエリーコストを分析してリミッターをかけるGithub APIのようなアプローチ、あるいはクエリーの自由度が必要ない用途であればpersistgraphqlのような登録されたクエリーのみを受け付けるアプローチでの対策が可能です。


どちらを選ぶべきなのか

GraphQLのメリットは基本的にはREST APIでもやり方によっては実現できるものです。自身やプロダクトの状況に応じてどちらがより適しているのか考慮をした上で選定すると良いでしょう。

先ずは下記を実行してApollo Serverをインストールします。

npm install apollo-server-micro

apollo-server-micromicroというHTTPサーバー向けに調整されたApollo Serverです。今回はNext.jsに含まれているhttpサーバーを利用するためmicroを利用するわけではありませんが、単独でGraphQLサーバーを設置したい時などにも役立つでしょう。

インストールが終わったらGraphQL用のエンドポイントとして下記を作成しましょう。

src/pages/api/graphql.ts
import { ApolloServer, Config, gql } from 'apollo-server-micro'

const typeDefs: Config['typeDefs'] = gql`
  type Query {
    statuses: [Status]!
  }
  
  type Status {
    id: String!
    body: String!
    author: String!
    createdAt: String!
  }
`

const resolvers: Config['resolvers'] = {
  Query: {
    statuses() {
      return listStatuses()
    },
  },
}

type Status = { id: string; body: string; author: string; createdAt: Date }
const statuses: Status[] = [
  {
    id: '2',
    body: 'inviting coworkers',
    author: 'jack',
    createdAt: new Date(2021, 4, 2).toISOString(),
  },
  {
    id: '1',
    body: 'just setting up my app',
    author: 'jack',
    createdAt: new Date(2021, 4, 1).toISOString(),
  },
]

const listStatuses = (): Status[] => statuses

export const config = {
  api: {
    bodyParser: false,
  },
}

const isDevelopment = process.env.NODE_ENV === 'development'

const apolloServer = new ApolloServer({
  typeDefs,
  resolvers,
  introspection: isDevelopment,
  playground: isDevelopment,
})
const handler = apolloServer.createHandler({ path: '/api/graphql' })

export default handler

作成できたら

npm run dev

で起動し

http://localhost:3000/api/graphql
へアクセスします。

これはGraphQL Playgroundというブラウザベースのクエリーエディタです。試しに下記のクエリーを入力して実行してみましょう。

{
  statuses {
    id
    body
  }
}

idとbodyの一覧が取得できたでしょうか。

それではGraphQLサーバーのコードを見てみましょう。

const typeDefs: Config['typeDefs'] = gql`
  type Query {
    statuses: [Status]!
  }
  
  type Status {
    id: ID!
    body: String!
    author: String!
    createdAt: String!
  }
`

typeDefsはこのGraphQLサーバーが扱うスキーマの定義です。またgql関数へタグ付きテンプレートリテラルで渡されているものはGraphQL SDL(スキーマ定義言語)で記述されたスキーマ定義です。今回はQuery型としてStatus型の配列を返すstatusesというクエリーを一つ定義しています。Query型はGraphQLのシステム上特別な意味を持った予約型でGraphQLクライアントからはここで定義したクエリーを使う事になります。

SDLでの型定義でフィールドに対して!というマークがついているのは、そのフィールドが非nullである事を表現しています。またID型やString型は標準で用意されているスカラー型です。[Status]のように[]で囲まれているものは配列型を意味しています。

const resolvers: Config['resolvers'] = {
  Query: {
    statuses() {
      return listStatuses()
    },
  },
}

type Status = { id: string; body: string; author: string; createdAt: Date }
const statuses: Status[] = [
  {
    id: '2',
    body: 'inviting coworkers',
    author: 'jack',
    createdAt: new Date(2021, 4, 2).toISOString(),
  },
  {
    id: '1',
    body: 'just setting up my app',
    author: 'jack',
    createdAt: new Date(2021, 4, 1).toISOString(),
  },
]

const listStatuses = (): Status[] => statuses

resolversは先ほどtypeDefsで定義した各クエリーについて、それを実際に処理するコードを書きます。今回はハードコーディングされたデータを使用していますが一般的なアプリケーションでは、MySQLやFirestoreのようなDB、あるいは別のREST APIのような外部データソースから取得されたデータを返す事になります。

// @see https://nextjs-ja-translation-docs.vercel.app/docs/api-routes/api-middlewares
export const config = {
  api: {
    bodyParser: false,
  },
}

const isDevelopment = process.env.NODE_ENV === 'development'

const apolloServer = new ApolloServer({
  typeDefs,
  resolvers,
  introspection: isDevelopment,
  playground: isDevelopment,
})
const handler = apolloServer.createHandler({ path: '/api/graphql' })

最後はtypeDefsやresolversを元にして実際にGraphQLサーバーを生成しているコードです。playgroundとintrospectionは明示的に開発環境でのみ有効にしています。introspectionはGraphQLクライアントなどがコードや型を生成するにあたって必要なスキーマ情報などをGraphQLサーバーから得るための機能です。

引数を受け取れるクエリーを書いてみよう

ステータスIDを渡して1件のステータスを返すクエリーを追加してみましょう。

const typeDefs: Config['typeDefs'] = gql`
  type Query {
    statuses: [Status]!
+    status(id: ID!): Status
  }

typeDefsのQuery型にstatusというクエリーを追加します。

const resolvers: Config['resolvers'] = {
  Query: {
    statuses() {
      return listStatuses()
    },
+    status(_parent, args) {
+      return getStatus(args?.id) ?? null
+    },

resolversにもstatusと対応したリゾルバーを追加します。リゾルバーはparent, args, context, infoという4つの引数を受け取りますが今回は第2引数のargsのみを使用しています。

const listStatuses = (): Status[] => statuses

+const getStatus = (id: string): Status | undefined =>
+  statuses.find((d) => d.id === id)

最後にstatusリゾルバーへ紐付ける処理の実体を追加します。

それではPlaygroundから下記を実行してみましょう。

{
  status(id: "1") {
    id
    body
  }
}

入れ子になったオブジェクトを取得してみよう

次はオブジェクトが別のオブジェクトを持った入れ子構造を表現してみましょう。

  type Status {
    id: ID!
    body: String!
-    author: String!
+    author: Author!
    createdAt: String!
  }

+  type Author {
+    id: ID!
+    name: String!
+  }

SDLに新しくAuthor型を追加し、それをStatus型に持たせます。続けてハードコーディングされたデータを新しい構造に合わせて修正しましょう。

type Status = { id: string; body: string; author: Author; createdAt: Date }
type Author = { id: string; name: string }
const statuses: Status[] = [
  {
    id: '2',
    body: 'inviting coworkers',
    author: {
      id: '1',
      name: 'jack',
    },
    createdAt: new Date(2021, 4, 2).toISOString(),
  },
  {
    id: '1',
    body: 'just setting up my app',
    author: {
      id: '1',
      name: 'jack',
    },
    createdAt: new Date(2021, 4, 1).toISOString(),
  },
]

今回の例ではresolverを変更する必要はありませんが、一般的な正規化されたRDBが背後にある場合にはstatusテーブルのデータとauthorテーブルのデータとを何らかの方法で組合せたり整形したりする必要があります。ORMが助けになる場合もあるでしょう。

{
  statuses {
    id
    body
    author {
      id
      name
    }
  }
}

クエリーを実行して結果を確認してみましょう。

入れ子になったオブジェクトをリゾルバーチェーンで取得してみよう

別解としてリゾルバーチェーンという仕組みを利用して取得する方法も見てみましょう。これは複数のリゾルバを組合せる事で入れ子構造に対応する仕組みです。

今回はRDBをイメージした下記のようなデータ構造を使います。

type Status = { id: string; body: string; authorId: string; createdAt: Date }
const statuses: Status[] = [
  {
    id: '2',
    authorId: '1',
    body: 'inviting coworkers',
    createdAt: new Date(2021, 4, 2).toISOString(),
  },
  {
    id: '1',
    authorId: '1',
    body: 'just setting up my app',
    createdAt: new Date(2021, 4, 1).toISOString(),
  },
]

type Author = { id: string; name: string }
const authors: Author[] = [
  {
    id: '1',
    name: 'jack',
  },
]

resolversのQuery型と同レベルにStatus型のリゾルバーを書きましょう。

const resolvers: Config['resolvers'] = {
  Query: {
    status(_parent, args) {
      return getStatus(args?.id) ?? null
    },
    statuses() {
      return listStatuses()
    }
  },
+  Status: {
+    author: (parent) => {
+      return getAuthor(parent.authorId)
+    },
+  },
}

これはStatus型のオブジェクトのauthorフィールドを取得する際のリゾルバーとして機能します。また第一引数であるparentからは親要素(この場合はStatus)の実データを参照する事が出来ます。

const listStatuses = (): Status[] => statuses

const getStatus = (id: string): Status | undefined =>
  statuses.find((d) => d.id === id)

+const getAuthor = (id: string): Author | undefined =>
+  authors.find((a) => a.id === id)

リゾルバーチェーンを使うとリゾルバーの実装がシンプルになる反面、N+1問題に気をつける必要があります。仮にgetAuthorが1回SQLを発行する実装の場合、100個のStatusを取得する際には100回のgetAuthorが呼ばれ100回SQLを発行する事になります。

場合によってはこれを解消するためにJOIN句やIN句を使ってSQLを書き換えるなどの対策が必要になるでしょう。またdataloaderと呼ばれる仕組みが組み合わされる事もあります。

複数種類のオブジェクトを取得するクエリーを書いてみよう

最後に、複数種類のオブジェクトを取得する例を見てみましょう。今まではStatus型を中心に扱ってきましたが、それとは独立したBanner型を新しく追加します。

  type Query {
    status(id: ID!): Status
    statuses: [Status]!
+    banners(groupId: ID): [Banner]!
  }

  type Status {
    id: ID!
    body: String!
    author: Author!
    createdAt: String!
  }

  type Author {
    id: ID!
    name: String!
  }

+  type Banner {
+    id: ID!
+    groupId: String!
+    href: String
+  }

サーバー側の実装には特に新しい知識は必要ありません。bannersクエリーのリゾルバーを実装しましょう。

const resolvers: Config['resolvers'] = {
  Query: {
    status(_parent, args) {
      return getStatus(args?.id) ?? null
    },
    statuses() {
      return listStatuses()
    },
+    banners(_parent, args) {
+      return listBanners(args.groupId)
+    },
  },
  Status: {
    author: (parent) => {
      return getAuthor(parent.authorId)
    },
  },
}

type Status = { id: string; body: string; authorId: string; createdAt: Date }
const statuses: Status[] = [
  {
    id: '2',
    authorId: '1',
    body: 'inviting coworkers',
    createdAt: new Date(2021, 4, 2).toISOString(),
  },
  {
    id: '1',
    authorId: '1',
    body: 'just setting up my app',
    createdAt: new Date(2021, 4, 1).toISOString(),
  },
]

type Author = { id: string; name: string }
const authors: Author[] = [
  {
    id: '1',
    name: 'jack',
  },
]

+type Banner = {
+  id: string
+  groupId: string
+  href: string | null
+}
+const banners: Banner[] = [
+  {
+    id: '2',
+    groupId: '1',
+    href: null,
+  },
+  {
+    id: '1',
+    groupId: '1',
+    href: null,
+  },
+]

const listStatuses = (): Status[] => statuses

const getStatus = (id: string): Status | undefined =>
  statuses.find((d) => d.id === id)
  
const getAuthor = (id: string): Author | undefined =>
  authors.find((a) => a.id === id)
  
+const listBanners = (groupId: string): Banner[] =>
+  banners.filter((b) => b.groupId === groupId)

それでは下記のクエリーを実行してみましょう。Playgroundの左ペインにクエリーを

query getStatusPageProps ($statusId: ID!, $bannerGroupId: ID!){
  status(id: $statusId) {
    id
    body
    author {
      id
      name
    }    
  },
   banners(groupId: $bannerGroupId) {
    id
    href
  }
}

左ペイン下のQUERY VARIABLESタブにクエリーの変数をそれぞれ入力して実行します。

QUERY VARIABLES
{
  "statusId": 1
  "bannerGroupId": 1
}

QUERY VARIABLESタブの隣にはHTTP HEADERSタブがありますが、誤ってそちらへ入力しないよう気をつけましょう

期待した通りの結果は返ってきたでしょうか。

typeDefsとresolversを別ファイルに切り出してみよう

graphql.tsが長くなってきたのでtypeDefsとresolversを別ファイルへ分割をしましょう。またtypeDefsはTypeScriptファイルとしてではなく、純粋なSDLファイルとして.graphqlの拡張子で保持するようにします。

まずはwebpackの設定を追加するためnext.config.jsに下記を追加します。これによって拡張子がgraphqlのファイルがimport時にgraphql-tag/loaderを使って読み込まれるようになります。

src/next.config.js
module.exports = {
  distDir: '../.next',
+  // @see https://www.apollographql.com/docs/react/integrations/webpack/
+  webpack: (config, options) => {
+    config.module.rules.push({
+      test: /\.(graphql)$/,
+      exclude: /node_modules/,
+      loader: 'graphql-tag/loader',
+    })
+
+    return config
+  },
}

新しくgraphqlディレクトリを作り下記を保存します。

src/graphql/graphql.d.ts
declare module '*.graphql' {
  import { DocumentNode } from 'graphql'
  const Schema: DocumentNode

  export = Schema
}

ここでは読み込まれた.graphqlファイルの中身がDocumentNode型として扱われるよう宣言しています。また今後importしやすいようtsconfigにエイリアスも追加しておきましょう。

src/tsconfig.json
    "baseUrl": ".",
    "paths": {
      "@/*": ["components/*"],
+      "@gql/*": ["graphql/*"]
    }

準備が出来たのでtypeDefsとresolversを別ファイルとして保存してみましょう。

src/graphql/typeDefs.graphql
type Query {
  status(id: ID!): Status
  statuses: [Status]!
  banners(groupId: ID!): [Banner]!
}

type Status {
  id: ID!
  body: String!
  author: Author!
  createdAt: String!
}

type Author {
  id: ID!
  name: String!
}

type Banner {
  id: ID!
  groupId: ID!
  href: String
}
src/graphql/resolvers.ts
import { Config } from 'apollo-server-micro'

export const resolvers: Config['resolvers'] = {
  Query: {
    status(_parent, args) {
      return getStatus(args?.id) ?? null
    },
    statuses() {
      return listStatuses()
    },
    banners(_parent, args) {
      return listBanners(args.groupId)
    },
  },
  Status: {
    author: (parent) => {
      return getAuthor(parent.authorId)
    },
  },
}

type Status = { id: string; body: string; authorId: string; createdAt: Date }
const statuses: Status[] = [
  {
    id: '2',
    authorId: '1',
    body: 'inviting coworkers',
    createdAt: new Date(2021, 4, 2),
  },
  {
    id: '1',
    authorId: '1',
    body: 'just setting up my app',
    createdAt: new Date(2021, 4, 1),
  },
]

type Author = { id: string; name: string }
const authors: Author[] = [
  {
    id: '1',
    name: 'jack',
  },
]

type Banner = {
  id: string
  groupId: string
  href: string | null
}
const banners: Banner[] = [
  {
    id: '2',
    groupId: '1',
    href: null,
  },
  {
    id: '1',
    groupId: '1',
    href: null,
  },
]

const listStatuses = (): Status[] => statuses

const getStatus = (id: string): Status | undefined =>
  statuses.find((d) => d.id === id)

const getAuthor = (id: string): Author | undefined =>
  authors.find((a) => a.id === id)

const listBanners = (groupId: string): Banner[] =>
  banners.filter((b) => b.groupId === groupId)

今後resolverの実装が増えていくことを考えると、さらに分割しておいたほうが見通しが良くなりそうですが次章へ託しましょう。最後にgraphql.tsからimportします。

src/pages/api/graphql.ts
import { ApolloServer } from 'apollo-server-micro'
import typeDefs from '@gql/typeDefs.graphql'
import { resolvers } from '@gql/resolvers'

// @see https://nextjs-ja-translation-docs.vercel.app/docs/api-routes/api-middlewares
export const config = {
  api: {
    bodyParser: false,
  },
}

const isDevelopment = process.env.NODE_ENV === 'development'

const apolloServer = new ApolloServer({
  typeDefs,
  resolvers,
  introspection: isDevelopment,
  playground: isDevelopment,
})
const handler = apolloServer.createHandler({ path: '/api/graphql' })

export default handler

リゾルバーを安全に実装してみよう

既に気付いていたかもしれませんが今のままでは、resolversを実装する際にSDLで定義されている型は一切考慮されません。例えばSDLでString型になっているフィールドに対して、booleanを渡すリゾルバーを書いたとしても型検査では何も言われません。それではさすがに困るのでSDLからTypeScriptの型定義を生成するようにしてみましょう。

npm install --save-dev @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-resolvers

インストールが終わったら設定ファイルとして下記を作成します。

codegen.json
{
  "generates": {
    "./src/graphql/generated/resolvers-types.ts": {
      "schema": "./src/graphql/typeDefs.graphql",
      "plugins": ["typescript", "typescript-resolvers"]
    },
  }
}

またnpmスクリプトとして下記を追加しておきましょう

package.json
  "scripts": {
    "dev": "NODE_OPTIONS='--inspect' next src/",
-    "build": "next build src/",
+    "build": "npm run gen && next build src/",
    "start": "next start src/",
    "serve": "npm run build && firebase emulators:start --only functions,hosting",
    "shell": "npm run build && firebase functions:shell",
    "deploy": "firebase deploy --only functions,hosting",
    "logs": "firebase functions:log"
+    "gen": "npx graphql-codegen"
  },

それではコード生成を実行しましょう。

npm run gen

src/graphql/generated/resolvers-types.tsというファイルが作成されたらresolvers.tsでimportしてみましょう。

src/graphql/resolvers.ts
-import { Config } from 'apollo-server-micro'
+import { Resolvers } from './resolvers-types'

-export const resolvers: Config['resolvers'] = {
+export const resolvers: Resolvers = {

banners以外のリゾルバーで型エラーが出ているのは、スキーマ側のStatus型が内部でもっているAuthor型について、TS側のリゾルバーチェーン適用前の型と不一致を起こしているためです。

スキーマ側の型とTS側の型とをマッピングする設定を追加して解消させましょう。

codegen.json
{
  "generates": {
    "./src/graphql/generated/resolvers-types.ts": {
      "schema": "./src/graphql/typeDefs.graphql",
+      "config": {
+        "mapperTypeSuffix": "Ts",
+        "mappers": {
+          "Status": "../resolvers#Status",
+          "Author": "../resolvers#Author"
+        }
+      },
      "plugins": ["typescript", "typescript-resolvers"]
    },
  }
}

mappersには生成されるファイル(この場合はresolvers-types.ts)から見ての相対パスでマッピング先ファイルを指定する必要があります。

src/graphql/resolvers.ts
-type Status = {
+export type Status = {

-type Author = { id: string; name: string }
+export type Author = { id: string; name: string }

またマッピング先になるTS側の型は生成ファイル(resolvers-types.ts)からimportされるため、exportしておく必要があります。

設定が終わったら型定義を再生成しましょう。

npm run gen

うまく設定されていればStatus.authorリゾルバーの型エラーだけが残るはずです。

type Status {
  id: ID!
  body: String!
  author: Author!
  createdAt: String!
}

この型エラーは、スキーマによって指定されているStatus.authorがAuthor!型(非nullのAuthor型)であるのに対して、リゾルバーがAuthor | undefined型を返しているために発生している型エラーです。どう修正するべきかはどういう仕様にしたいか次第ですが、今回はAuthor | null型へ統一する事で型を一致させましょう。

type Status {
  id: ID!
  body: String!
-  author: Author!
+  author: Author
  createdAt: String!
}
  Status: {
    author: (parent) => {
-      return getAuthor(parent.authorId)
+      return getAuthor(parent.authorId) ?? null
    },
  },

ところで今回のStatus.authorリゾルバーの型エラーについて、皆さんは型検査が有効化される前に気付けていたでしょうか?

筆者はもちろん気付いていませんでした

GraphQL ClientをNext.jsのサーバーサイド(getServerSideProps)で使ってみよう

GraphQLサーバーの作業が一通り終わったので、今度はGraphQLクライアントを見てみましょう。今までPlaygroundから実行していたクエリーを今度はファイルとして保存します。

src/graphql/query/getStatusPageProps.graphql
query getStatusPageProps($statusId: ID!, $bannerGroupId: ID!) {
  status(id: $statusId) {
    id
    body
    author {
      id
      name
    }
    createdAt
  }
  banners(groupId: $bannerGroupId) {
    id
    href
  }
}
コラム:Queryをどう配置するか?Fragment Colocationという考え方

本書では最上位のコンポーネントであるページコンポーネントと対応させたクエリーだけを配置しました。これはトップダウンの考え方に基づいていると言えますが、逆にボトムアップなアプローチもあります。

fragment AuthorNamePart on Author {
  id
  name
}

GraphQL Queryにはデータ構造の一部分を切り出して定義するFragmentと呼ばれる機能があります。Fragment Colocationというアプローチでは個々のコンポーネントが必要とするデータ構造(props)をこのFragmentで定義してコンポーネントとセットで配置し、実際に発行するQueryはそのページで利用するコンポーネントに応じたFragmentを集めて書きます。

Fragment Colocationではコンポーネントとクエリーとの物理的な位置が近いため個々のコンポーネントの変更に伴うクエリーの変更漏れが起こりにくく、余分なデータの取得(オーバーフェッチ)も起こりにくい等のメリットがあります。なおApollo Clientと並んで人気があるGraphQLクライアントのFacebook RelayではFragment Colocationが基本方針となっています。

この.graphqlファイルを元にTypeScriptのコードと型を生成します。

npm install @apollo/client
npm install --save-dev @graphql-codegen/typescript-react-apollo @graphql-codegen/typescript-operations

必要なパッケージをインストールしたらcodegen.jsonに設定を追加しましょう。

codegen.json
{
  "generates": {
    "./src/graphql/generated/resolvers-types.ts": {
      "schema": "./src/graphql/typeDefs.graphql",
      "config": {
        "mapperTypeSuffix": "Ts",      
        "mappers": {
          "Status": "./resolvers#Status",
          "Author": "./resolvers#Author"
        }
      },
      "plugins": ["typescript", "typescript-resolvers"]
    },
+    "./src/graphql/generated/query-types.ts": {
+      "schema": "http://localhost:3000/api/graphql",
+      "documents": "./src/graphql/query/*.graphql",
+      "plugins": [
+        "typescript",
+        "typescript-operations",
+        "typescript-react-apollo"
+      ]
+    }
  }
}

またVSCodeでgraphql/vscode-graphqlによる補完が効くよう下記の設定ファイルを作っておきましょう。

.graphqlrc.json
{
  "schema": "http://localhost:3000/api/graphql"
}

再びコード生成を実行しますが、この時にはGraphQLサーバー(を乗せているNext.js)を起動しておくようにしてください。生成にあたってintrospection機能でGraphQLサーバーからスキーマ情報を取得する必要があるためです。

npm run gen

src/graphql/generated/query-types.tsというファイルが生成されたでしょうか。それではStatusPageについて下記のように変更しましょう。

src/pages/statuses/[id].tsx
import React, { FC } from 'react'
import { GetServerSideProps } from 'next'
import Head from 'next/head'
import { BirdHouseLayout } from '@/organisms/layouts/BirdHouseLayout'
import { StatusCard } from '@/moleclues/StatusCard'
import {
  GetStatusPagePropsDocument,
  GetStatusPagePropsQuery,
} from '@gql/query-types'
import { apolloClient } from '@gql/apollo-client'
import { setSwrHeader, toSec } from 'lib/utils'

type StatusPageProps = Omit<GetStatusPagePropsQuery, 'status'> & {
  status: NonNullable<GetStatusPagePropsQuery['status']>
}

export const getServerSideProps: GetServerSideProps<StatusPageProps> = async (
  context
) => {
  if (typeof context.query.id !== 'string') {
    return { notFound: true }
  }

  setSwrHeader(context, sec.fromMinutes(10), sec.fromDays(30))

  try {
    const props = await fetch(context.query.id)
    return { props }
  } catch {
    return { notFound: true }
  }
}

const fetch = async (statusId: string) => {
  const result = await apolloClient.query<GetStatusPagePropsQuery>({
    query: GetStatusPagePropsDocument,
    variables: { statusId: statusId, bannerGroupId: '1' },
  })
  const status = result.data.status
  if (!status) {
    throw new Error('status not found')
  }

  return { ...result.data, status }
}

const StatusPage: FC<StatusPageProps> = (props) => {
  return (
    <BirdHouseLayout>
      <>
        <Head>
          <title>{props.status.body}</title>
          <meta property="og:title" content={props.status.body} key="ogtitle" />
        </Head>
        <StatusCard
          {...props.status}
          author={props.status.author?.name ?? 'John Doe'}
          linkEnabled={false}
        />
      </>
    </BirdHouseLayout>
  )
}

export default StatusPage

また環境変数を元にしてAPIへ接続していた箇所は下記のように別ファイルへ分割して、apolloClientを使うように変更しましょう。

src/graphql/apollo-client.ts
import { ApolloClient, InMemoryCache } from '@apollo/client'

const apiRoot = process.env.NEXT_PUBLIC_API_ROOT

export const apolloClient = new ApolloClient({
  uri: `${apiRoot}/api/graphql`,
  cache: new InMemoryCache(),
})

SWRヘッダの設定についてもページコンポーネントの見通しが悪くなるので、これを機に分割してします。

src/lib/utils.ts
import { GetServerSidePropsContext } from 'next'
import { ParsedUrlQuery } from 'querystring'

export const setSwrHeader = (
  context: GetServerSidePropsContext<ParsedUrlQuery>,
  sMaxAgeSec: number,
  swrSec: number
): void => {
  context.res.setHeader(
    'Cache-Control',
    `public, s-maxage=${sMaxAgeSec}, stale-while-revalidate=${swrSec}`
  )
}

export const sec = {
  fromMinutes: (m: number): number => 60 * m,
  fromHours: (h: number): number => 3600 * h,
  fromDays: (d: number): number => 86400 * d,
}

それではページコンポーネントのコードを見てみましょう。

type StatusPageProps = Omit<GetStatusPagePropsQuery, 'status'> & {
  status: NonNullable<GetStatusPagePropsQuery['status']>
}

GetStatusPagePropsQueryという型はgetStatusPageProps.graphqlを元にquery-types.tsへ自動生成された型です。両者を見比べてみましょう。

src/graphql/query/getStatusPageProps.graphql
query getStatusPageProps($statusId: ID!, $bannerGroupId: ID!) {
  status(id: $statusId) {
    id
    body
    author {
      id
      name
    }
    createdAt
  }
  banners(groupId: $bannerGroupId) {
    id
    href
  }
}
src/graphql/generated/query-types.ts
export type GetStatusPagePropsQuery = (
  { __typename?: 'Query' }
  & { status?: Maybe<(
    { __typename?: 'Status' }
    & Pick<Status, 'id' | 'body' | 'createdAt'>
    & { author?: Maybe<(
      { __typename?: 'Author' }
      & Pick<Author, 'id' | 'name'>
    )> }
  )>, banners: Array<Maybe<(
    { __typename?: 'Banner' }
    & Pick<Banner, 'id' | 'href'>
  )>> }
);

一見ごちゃごちゃとしていて見難い型ですが基本的にはクエリーと同じ構造の型になっています。

Maybe<T>はT型がnullになる可能性がある事を示すものですが、デフォルト設定ではtype Maybe<T> = T | nullとして生成されています。今回のクエリーを例にするとstatusがMaybeになっていますが、これはGraphQLサーバー側のスキーマ定義でstatusをnull許可しているため、それを反映した型になっています。

src/graphql/typeDefs.graphql
type Query {
  status(id: ID!): Status
  statuses: [Status]!
  banners(groupId: ID!): [Banner]!
}

またPick<Type, Keys>という型はTypeScriptが標準で用意しているユーティリティ型の一つです。Pick<Type, Keys>にはType型からKeysで指定した名前のプロパティのみを抜き出した新しい型を得る効果があります。

StatusPagePropsの型定義に戻りましょう。

type StatusPageProps = Omit<GetStatusPagePropsQuery, 'status'> & {
  status: NonNullable<GetStatusPagePropsQuery['status']>
}

ここで登場しているOmit<Type, Keys>NonNullable<Type>もまたユーティリティ型です。Omit<Type, Keys>にはType型からKeysで指定した名前のプロパティを取り除いた新しい型を得る効果が、NonNullable<Type>にはType型からnull可能性を削除する効果がそれぞれあります。

&は交差型(Intersection Type)を使うための記法です。交差型を使うと2つの型を合成したような型を作り出す事ができます。

type Dog = { breedName: string }
type Pet = { name: string }
type PetDog = Dog & Pet
//type PetDog = { breedName: string; name: string }と同義

これらを組合せてStatusPagePropsで何をしているかをまとめると「GetStatusPagePropsQuery型を素材にしてstatusプロパティが非nullな新しい型を作っている」という事になります。何度も書くには少し冗長な型なので独自のユーティリティ型として作ってしまいましょう。

src/lib/utils.ts
+export type SetNonNullable<T, K extends keyof T> = Omit<T, K> &
+  Required<
+    {
+      [P in K]: NonNullable<T[P]>
+    }
+  >
src/pages/statuses/[id].tsx
-import { setSwrHeader, sec } from 'lib/utils'
+import { setSwrHeader, sec, SetNonNullable } from 'lib/utils'

-type StatusPageProps = Omit<GetStatusPagePropsQuery, 'status'> & {
-  status: NonNullable<GetStatusPagePropsQuery['status']>
-}
+type StatusPageProps = SetNonNullable<GetStatusPagePropsQuery, 'status'>

型の話はこれぐらいにして実装を見てみましょう。

const fetch = async (statusId: string) => {
  const result = await apolloClient.query<GetStatusPagePropsQuery>({
    query: GetStatusPagePropsDocument,
    variables: { statusId: statusId, bannerGroupId: '1' },
  })
  const status = result.data.status
  if (!status) {
    throw new Error('status not found')
  }

  return { ...result.data, status }
}

GetStatusPagePropsDocumentもまたgetStatusPageProps.graphqlから生成されたコードで、ApolloClientへ渡すためのクエリー本体です。また最後のreturnで新しくオブジェクトを生成しているのはstatusプロパティがnullチェック済で非nullである事を明確にするためです。

変更が終わったら下記を表示して、これまで通りのページが表示されている事を確認してみましょう。

http://localhost:3000/statuses/1

GraphQL ClientをNext.jsのクライアントサイド(Hooks)で使ってみよう

次はクライアントサイドからHooksを通じてGraphQL Client使ってみましょう。

import React from 'react'
import type { AppProps } from 'next/app'
import { CssBaseline, ThemeProvider } from '@material-ui/core'
import createCache from '@emotion/cache'
import { CacheProvider } from '@emotion/react'
import { defaultTheme } from 'default-theme'
+import { ApolloProvider } from '@apollo/client'
+import { apolloClient } from '@gql/apollo-client'

// @see https://next.material-ui.com/guides/server-rendering/
const cache = createCache({ key: 'css', prepend: true })
cache.compat = true

const MyApp = ({ Component, pageProps }: AppProps): JSX.Element => {
  return (
+  <ApolloProvider client={apolloClient}>
+    <CacheProvider value={cache}>
      <ThemeProvider theme={defaultTheme}>
        <CssBaseline />
        <Component {...pageProps} />
      </ThemeProvider>
    </CacheProvider>
+  </ApolloProvider>    
  )
}
export default MyApp

以上で準備は終了です。

それでは以下のクエリーを追加してコード生成をしましょう。

src/graphql/query/getHomePageProps.graphql
query getHomePageProps($bannerGroupId: ID!) {
  statuses {
    id
    body
    author {
      id
      name
    }
    createdAt
  }
  banners(groupId: $bannerGroupId) {
    id
    href
  }
}
npm run gen

続けてHomePageを下記のように変更しましょう。

src/pages/index.tsx
import React, { FC } from 'react'
import { StatusCard } from '@/moleclues/StatusCard'
import { BirdHouseLayout } from '@/organisms/layouts/BirdHouseLayout'
import { Backdrop, Box, CircularProgress, Typography } from '@material-ui/core'
import Head from 'next/head'
import { useGetHomePagePropsQuery } from '@gql/query-types'

const HomePage: FC = () => {
  const { data, loading, error } = useGetHomePagePropsQuery({
    variables: { bannerGroupId: '1' },
  })

  if (loading) {
    return (
      <Backdrop open={true}>
        <CircularProgress />
      </Backdrop>
    )
  }

  return (
    <BirdHouseLayout currentRouteName="home">
      <>
        <Head>
          <title>最新ステータス</title>
          <meta property="og:title" content="最新ステータス" key="ogtitle" />
        </Head>
        {error || !data ? (
          <Typography>エラーが発生しました</Typography>
        ) : (
          data.statuses.map((s) => (
            <Box key={s.id} pb={2}>
              <StatusCard {...s} author={s.author?.name ?? 'John Doe'} />
            </Box>
          ))
        )}
      </>
    </BirdHouseLayout>
  )
}

export default HomePage

useGetHomePagePropsQuerygetStatusPageProps.graphqlから生成されたHooksです。useGetHomePagePropsQueryは通信の進捗に応じて左辺の{ data, loading, error }を変化させるので、その変化に応じてどういったパターンのコンポーネントを返すのかを記述します。

例えばこのコンポーネントが今現在エラー状態にあるのか否かといった状態はHooks部分の仕事です。それ以外の部分ではあくまで、エラー状態の時にどういうViewを返すのか/非エラー状態の時にどういうViewを返すのか、という各状態に対するパターンを記述します。

変更を保存したら表示を確認してみましょう。

http://localhost:3000/

F12キーを押してデベロッパーツールを開き「Network」の「XHR」で、ブラウザとGraphQLサーバーとの間の通信も確認してみましょう。