1. 記事一覧 >
  2. ブログ記事
category logo

Next.js, graphql-codegen, React Query, Apollo Server v4 で簡易BFF作成(2/2)

(更新) (公開)

はじめに

Next.js v13、graphql-codegen 2.16.1、React v18、TypeScript、TanStack Query v4(React Query)、Apollo Server v4 で簡易 BFF を作成しました。
前回は、サーバーサイド、バックエンド部分(Next.js の pages/api/graphql)を作成してきましたが、今回は、クライアントサイド、フロントエンド部分(pages/_app.tsxpages/index.tsx)を作成していきます。


クライアント動作内容 動画


1.codegen 利用からサーバーサイドの実装、動作確認(前回の記事
2.クライアントサイドの実装、動作確認(今回の記事)
になります。

【検証環境】

Ubuntu 20.04.2 LTS

 node v14.20.0

 npm 6.14.17/code>

 @apollo/server 4.3.0

 graphql 16.6.0

 next 13.1.1

 react 18.2.0

 typescript 4.9.4

 @graphql-codegen/cli 2.16.2

 graphql-request 5.1.0

 @tanstack/react-query 4.20.4

Windows 10 Pro x64

 Visual Studio Code 1.74.2

GraphQL、Next.js、graphql-codegen、TanStack Query(React Query)、graphql-request、Apollo Server、BFF それぞれの役割

各要素の意味については、前回の記事で説明していますので、省略します。

また、Next.js、React について基本的な事の説明はありません。いきなりソースコードを書き始めます。

graphql-codegen で自動生成した React Hooks を使います。TanStack Query、graphql-request 単独の使い方についての説明はこの記事にはありません。

キャッシュの生存時間等、オプションをいろいろ使えますが、オプションについてはほとんど触れていません。


今回の要件

名前を返す API(http://localhost:4000/names)と住所を返す API(http://localhost:5000/addresses)があるとします。

名前を返すAPIと住所を返すAPI

・フロントエンドから一つのエンドポイントに対してリクエスト
・一度のリクエストで、名前を返す API と住所を返す API の結果を一度に受け取る
・名前を返す API だけデータの追加、更新ができる
・名前を返す API だけから全データ取り出すことができる
・名前を返す API に id(何番目か)を指定して、指定の名前を1つだけ取り出すことができる
・住所を返す API だけから全データ取り出すことができる
・住所を返す API に id(何番目か)を指定して、指定の住所を1つだけ取り出すことができる


両 API を用いて実現できることについて、実用的な意味は無いです。今回の場合、BFF によって、Rest API 群(場合によっては、gRPC などを使ったマイクロサービス群など)を束ねるというのが趣旨です。


これに対して、GraphQL クエリーを実行するクライアントを作ります。
クライアントは、以下のことができるものとします。
・全ての名前と住所を返す GraphQL クエリーを実行 → レスポンスの JSON を画面に表示
・最初の名前だけを返す GraphQL クエリーを実行 → レスポンスの JSON を画面に表示
・名前を追加する GraphQL クエリーを実行(追加する名前は、ランダムとする。)→ 成功したら、全ての名前と住所を返す GraphQL クエリーを実行して、レスポンスの JSON を画面に表示
・最初の名前を更新する GraphQL クエリーを実行(更新する名前は、ランダムとする。)→ 成功したら、全ての名前と住所を返す GraphQL クエリーを実行して、レスポンスの JSON を画面に表示


前回は、Apollo Server の機能を使ってテストしましたが、自分で実装したクライアントで GraphSQL クエリを実行するということです。


React Hooks 自動生成

前回の記事と重複しますが、この前提が無いと意味不明になりますので、おさらいします。
graphql-codegen にて、GraphQL クエリーに合わせた React Hooks を生成します。

大半の説明は省略します。

詳細は、前回の記事を参照してください。


graphql-codegen init

graphql-codegen 初期化作業を行います。

$ npx graphql-codegen init

ここで、以下のように回答します。
What type of application are you building? Application built with React
Where is your schema?: (path or url) pages/api/graphql/schema.ts
Where are your operations and fragments?: graphql/**/*.graphql
Where to write the output: generated/resolvers.d.ts
Do you want to generate an introspection file? Yes
How to name the config file? codegen.yml
What script in package.json should run the codegen? codegen

codegen.ts or codegen.yml が生成されて package.json に "codegen": "graphql-codegen --config codegen.yml" が書き込まれます。


codegen.yml 設定

codegen.yml が自動生成されていますので、これを編集します。


$ vi codegen.yml
codegen.yml
# 既に生成物がある場合、上書き
overwrite: true
# スキーマファイルの場所
schema: pages/api/graphql/schema.ts
generates:
  # resolvers(サーバー側)の TypeScript 型定義生成設定
  ./generated/resolvers.d.ts:
    plugins:
      - typescript
      - typescript-resolvers
    config:
      useIndexSignature: true
      contextType: ../../pages/api/graphql/resolvers#Context
  ./generated/graphql.ts:
    documents: "graphql/**/*.graphql"
    plugins:
      - typescript
      # @graphql-codegen/typescript-resolvers
      # documents から TypeScript 型定義を生成するプラグイン
      - typescript-operations
      # documents から React Hooksコード生成するプラグイン
      - typescript-react-query
    config:
      # React Hooks の fetcher に graphql-request を使用
      # 指定しないと fetch が使われる。
      fetcher: graphql-request
      # Hooks を生成するか否か。true,falseにしても生成された。(?)何も変わらないので、コメントアウトしておく。
      # isReactHook: true
      # 生成物に useGetNameQuery.getKey = (variables: GetNameQueryVariables) => ['getName', variables];
      # のように QueryKey を取り出すことができるメソッドが追加される。(['getName', {id: '0'}]のような値がキーとして返る。)
      # react-query は QueryKey を使用してキャッシュを管理する。
      # setQueryData や getQueryData などのメソッドには、このキーが必要。
      # 今回直接react-queryを使っていないため、結局使わない。
      exposeQueryKeys: true
  # introspectionファイルを作成
  ./graphql.schema.json:
    plugins:
      - "introspection"

自動で package.json に追加された @graphql-codegen/client-preset は不要なので、削除します。(末尾のカンマに注意。)

$ vi package.json
package.json
  "devDependencies": {
    "@graphql-codegen/cli": "2.16.2",
    "@graphql-codegen/introspection": "2.2.3",
    "@graphql-codegen/client-preset": "1.2.4"
  }

package.json
  "devDependencies": {
    "@graphql-codegen/cli": "2.16.2",
    "@graphql-codegen/introspection": "2.2.3"
  }

schema 作成

schema として指定した schema.ts を作成します。
注意:サーバーサイドに関係します。今回のクライアントサイドでは関係ありません。

$ mkdir pages/api/graphql
$ vi pages/api/graphql/schema.ts
pages/api/graphql/schema.ts
import { gql } from "graphql-tag";

export const typeDefs = gql`
  type Name {
    name: String!
  }

  input NameInput {
    name: String!
  }

  type Address {
    address: String!
  }

  type Query {
    names: [Name!]
    name(id: ID!): Name
    addresses: [Address!]
    address(id: ID!): Address
  }

  type Mutation {
    addName(input: NameInput!): Name!
    updateName(id: ID!, input: NameInput!): Name!
  }
`;

documents 作成

クライアント側の documents を作成します。


Query と Mutation があるため、2ファイルに分けます。(1ファイルに全部書いても、もっと細分化しても構いません。)

$ mkdir graphql
$ vi graphql/queries.graphql
$ vi graphql/mutations.graphql
graphql/queries.graphql
query getNames {
  names {
    name
  }
}
query getName($id: ID!) {
  name(id: $id) {
    name
  }
}
query getAddresses {
  addresses {
    address
  }
}
query getAddress($id: ID!) {
  address(id: $id) {
    address
  }
}
query getNamesAndAddresses {
  names {
    name
  }
  addresses {
    address
  }
}
query getNameAndAddress($id: ID!) {
  name(id: $id) {
    name
  }
  address(id: $id) {
    address
  }
}
graphql/mutations.graphql
mutation addName($input: NameInput!) {
  addName(input: $input) {
    name
  }
}
mutation updateName($id: ID!, $input: NameInput!) {
  updateName(id: $id, input: $input) {
    name
  }
}

codegen 実行

generated/resolvers.d.ts
generated/graphql.ts
graphql.schema.json
を生成します。


Unable to find template plugin matching 'typescript-react-query'
のようにエラーになりますので、plugins に指定したパッケージを --save-dev でインストールしてから、実行します。

$ npm install --save-dev @graphql-codegen/typescript-operations
$ npm install --save-dev @graphql-codegen/typescript-react-query
$ npm install --save-dev @graphql-codegen/typescript-resolvers
$ mkdir generated
$ npm install
$ npm run codegen

今回は、generated/graphql.ts を使用します。ここに React Hooks が自動生成されています。

graphql.ts実装の一部


npm install

前回の記事で実行した npm install をおさらいすると、以下です。

$ npm install --save-dev @graphql-codegen/cli
$ npm install graphql @apollo/server @apollo/datasource-rest graphql-tag
$ npm install --save-dev @graphql-codegen/typescript-operations
$ npm install --save-dev @graphql-codegen/typescript-react-query
$ npm install --save-dev @graphql-codegen/typescript-resolvers

今回、クライアント側に必要な npm を追加します。

$ npm install graphql-request @tanstack/react-query @tanstack/react-query-devtools
$ npm install react-toastify
$ npm install unique-names-generator

@tanstack/react-query / TanStack Query(React Query)
サーバー側のデータの取得と操作を容易にするために使用されるライブラリです。 React Query を使用すると、React アプリケーションでのサーバー状態の取得、キャッシュ、同期、および更新を簡単に行うことができます。
TanStack Query は、TS/JS、React、Solid、Vue で使えます。(Svelte も対応予定にあるようですが、2022 年 12 月現在まだ開発中のようです。)


@tanstack/react-query-devtools
React Query 専用の開発ツールです。React Query のすべての内部動作を視覚化するのに役立ち、ピンチに陥った場合にデバッグの時間を節約できる可能性があります。

作成するクライアントがあまりに殺風景のため、デフォルトでオンとします。使い方の説明はこの記事にはありません。


graphql-request
シンプルかつ軽量な GraphQL クライアントです。プロミス(非同期処理の最終的な完了もしくは失敗を表すオブジェクト)に対応しています。TypeScript をサポートしています。Node のサーバーサイド/ブラウザ側で使用できます。今回は、ブラウザ側で使用しています。


react-toastify
以下のようにトースト(スッと表れてスッと消える通知メッセージ)を実現するために導入します。
特に React Query とか、GraphQL とかには関係しません。

トーストの動作例動画


unique-names-generator
ランダムに適当な名前を返すツールです。追加したり更新したりする名前が毎回同じとかだとつまらないので、これを使ってスターウォーズのキャラクター名がランダムに生成されるようにしました。


_app.tsx 実装

$ vi pages/_app.tsx

説明は、ソースコード内のコメントを参照してください。

最初、以下のようになっていると思いますが、完全に書き換えます。

pages/_app.tsx
import '../styles/globals.css'
import type { AppProps } from 'next/app'

export default function App({ Component, pageProps }: AppProps) {
  return <Component {...pageProps} />
}
pages/_app.tsx
import "../styles/globals.css";
import type { AppProps } from "next/app";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ToastContainer } from "react-toastify";
// ToastContainerコンポーネント配置と同時にReactToastify.cssを読み込むreact-toastifyの作法
import "react-toastify/dist/ReactToastify.css";

const queryClient = new QueryClient();

export default function App({ Component, pageProps }: AppProps) {
  return (
    // 全体を通して、React Queryを有効にするため、(今回は、index.tsxだけだが)
    // QueryClientProviderで要素を包みQueryClientをインスタンス化したqueryclientを設定。(必須)
    <QueryClientProvider client={queryClient}>
      {/* react-toastify の ToastContainerコンポーネントを配置 */}
      {/* 注意:index.tsxに配置しても機能しない。親要素に配置が必要。 */}
      <ToastContainer />
      <Component {...pageProps} />
      {/* react-query-devtoolsを初期表示から有効する。 */}
      {/* falseの場合、最初ボタンだけ表示されて、クリックしたら開く。 */}
      <ReactQueryDevtools initialIsOpen={true} />
    </QueryClientProvider>
  );
}

index.tsx 実装

$ vi pages/index.tsx

最初、以下のようになっていると思いますが、完全に書き換えます。

自動生成された generated/graphql.ts を使います。useQuery をそのまま使う場合と異なります。

pages/index.tsx
import Head from 'next/head'
import Image from 'next/image'
import { Inter } from '@next/font/google'
import styles from '../styles/Home.module.css'

const inter = Inter({ subsets: ['latin'] })

export default function Home() {
  return (
    <>
      <Head>
        <title>Create Next App</title>
・・・
pages/_app.tsx
import Head from "next/head";
import { GraphQLClient } from "graphql-request";
// graphql-codegen を実行した生成物 graphql.ts から各フック関数を読み込み。
import {
  useGetNamesAndAddressesQuery,
  useGetNameQuery,
  GetNamesAndAddressesQuery,
  useAddNameMutation,
  useUpdateNameMutation,
} from "../generated/graphql";
import { useState } from "react";
// 成功、エラーを表示するために、
// トースト(スッと表れてスッと消える通知メッセージ)を利用
import { toast } from "react-toastify";
// スターウォーズのキャラクタ名をランダムに返す
import { uniqueNamesGenerator, Config, starWars } from "unique-names-generator";

// (alias) new GraphQLClient(url: string, options?: PatchedRequestInit | undefined): GraphQLClient
// 今回は、オプション無しでシンプルにURLのみ指定。環境変数は使わないため、URL固定。
const graphQLClient = new GraphQLClient(
  process.env.NEXT_PUBLIC_GRAPHQL_URL || "http://localhost:3000/api/graphql"
);

// unique-names-generator のスターウォーズのキャラクタ名を使う設定
const config: Config = {
  dictionaries: [starWars],
};

export default function Home() {
  // GetNamesAndAddressesQuery型(名前&住所が入る)
  // data は、画面表示に使う。
  const [data, setData] = useState<GetNamesAndAddressesQuery | undefined>(
    undefined
  );

  // 全ての名前と住所を返す GraphQL クエリーを実行→レスポンスのJSONを画面に表示
  const res1 = useGetNamesAndAddressesQuery(
    graphQLClient,
    {}, // GraphQLクエリの引数無し
    {
      // ユーザーがブラウザのコンポーネントにフォーカスを当てた時に自動でフェッチが動くのを抑止
      refetchOnWindowFocus: false,
      // ボタンをクリックしたタイミングで起動するため、ここでは実行されない。
      enabled: false,
      // 成功したときのコールバック
      onSuccess(data) {
        // 取得したデータ(JSON)を画面に反映
        setData(data);
      },
      onError(error: any) {
        // エラーの時、トーストに表示(注意:エラーは複数返る可能性がある)
        error.response.errors.forEach((err: any) => {
          toast(err.message, {
            type: "error", // error タイプのデザイン(赤色)
            position: "top-center", // 真ん中の上から出現
          });
        });
      },
    }
  );
  // 最初の名前だけを返す GraphQL クエリーを実行→レスポンスのJSONを画面に表示
  const res2 = useGetNameQuery(
    graphQLClient,
    { id: "0" }, // GraphQLクエリの引数に最初の名前と指定(ハードコード)
    {
      refetchOnWindowFocus: false,
      enabled: false,
      onSuccess(data) {
        setData(data);
      },
      onError(error: any) {
        error.response.errors.forEach((err: any) => {
          toast(err.message, {
            type: "error",
            position: "top-center",
          });
        });
      },
    }
  );
  // 名前を追加する GraphQL クエリーを実行(追加する名前は、ランダムとする。)
  // →成功したら、全ての名前と住所を返す GraphQL クエリーを実行して、レスポンスのJSONを画面に表示
  // Mutation系(取得ではなく、更新系)は、ここでは実行されない。
  const res3 = useAddNameMutation(graphQLClient, {
    onSuccess() {
      // "Name created successfully" とトースト表示
      toast("Name created successfully", {
        autoClose: 1000, // 1秒で消えるように設定
        type: "success", // success タイプのデザイン(緑色)
        position: "top-center", // 真ん中の上から出現
      });
      res1.refetch(); // 全ての名前と住所を返す GraphQL クエリーを実行
    },
    onError(error: any) {
      error.response.errors.forEach((err: any) => {
        toast(err.message, {
          type: "error",
          position: "top-center",
        });
      });
    },
  });
  // 最初の名前を更新する GraphQL クエリーを実行(更新する名前は、ランダムとする。)
  // →成功したら、全ての名前と住所を返す GraphQL クエリーを実行して、レスポンスのJSONを画面に表示
  const res4 = useUpdateNameMutation(graphQLClient, {
    onSuccess() {
      toast("Name updated successfully", {
        autoClose: 1000,
        type: "success",
        position: "top-center",
      });
      res1.refetch(); // 全ての名前と住所を返す GraphQL クエリーを実行
    },
    onError(error: any) {
      error.response.errors.forEach((err: any) => {
        toast(err.message, {
          type: "error",
          position: "top-center",
        });
      });
    },
  });

  // GetNamesAndAddressesQueryボタンクリック
  const onGetNamesAndAddressesQuery = async () => {
    res1.refetch(); // 全ての名前と住所を返す GraphQL クエリーを実行
  };

  // GetNameQueryボタンクリック
  const onGetNameQuery = async () => {
    res2.refetch(); // 最初の名前だけを返す GraphQL クエリーを実行
    // 注意:引数 { id: "0" } は、res2 作成時に指定している。
  };

  // AddNameMutationボタンクリック
  const onAddNameMutation = async () => {
    // スターウォーズのキャラクタ名をランダムに生成
    const characterName: string = uniqueNamesGenerator(config);
    res3.mutate({ input: { name: characterName } }); // 名前を追加する GraphQL クエリーを実行
    // 注意:引数 { input: { name: characterName } } は、ここで指定。
  };

  // UpdateNameMutationボタンクリック
  const onUpdateNameMutation = async () => {
    const characterName: string = uniqueNamesGenerator(config); // 最初の名前を更新する GraphQL クエリーを実行
    res4.mutate({ id: "0", input: { name: characterName } });
    // 注意:引数 { id: "0", input: { name: characterName } } は、ここで指定。
  };

  return (
    <>
      <Head>
        <title>Next App</title>
        <meta name="description" content="Generated by create next app" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <button onClick={onGetNamesAndAddressesQuery}>
        GetNamesAndAddressesQuery
      </button>{" "}
      <button onClick={onGetNameQuery}>GetNameQuery</button>{" "}
      <button onClick={onAddNameMutation}>AddNameMutation</button>{" "}
      <button onClick={onUpdateNameMutation}>UpdateNameMutation</button>
      {/* GraphQLクエリ実行の結果JSONをスペース2個の文字列に整形して<pre></pre>タグで表示 */}
      <pre>{JSON.stringify(data, null, 2)}</pre>
    </>
  );
}

完成しました!


動作確認

$ npm run dev

http://localhost:3000 にアクセスします。

アクセス直後


GetNamesAndAddressesQuery ボタンをクリックして、全ての名前と住所を得ます。

GetNamesAndAddressesQuery ボタンをクリック

ヨシ!


GetNameQuery ボタンをクリックして、最初の名前だけを得ます。

GetNameQuery ボタンをクリック

ヨシ!


AddNameMutation ボタンをクリックして、名前を追加します。

AddNameMutation ボタンをクリック

ヨシ!


UpdateNameMutation ボタンをクリックして、最初の名前を更新します。

UpdateNameMutation ボタンをクリック

ヨシッ!!


ちなみにですが、エラーになると、以下の挙動になります。
(データソースの REST API を落として、接続エラーの例)
注意:実際は、リトライするため、エラー表示までに時間がかかりました。

データソースの REST API を落として、接続エラーの例 動画


loading...