📚

GraphQLエンドポイントが複数ある時のフロントエンド

2024/05/18に公開

最近GraphQLについて触る機会があったのでその過程で調査したことについてのメモです。
GraphQLエンドポイントが複数あり、それぞれに個別のSchemaが定義されている場合、以下の点についてまとめています

  • そもそもサーバー側でどういうことができそうか
  • クラインアント側でどういう対応を行うことができるのか

GraphQLマイクロサービス

GraphQLマイクロサービスとは、GraphQLサーバーがマイクロサービスとして稼働している状態を指します。
それぞれのマイクロサービスが独自のスキーマを持ち、個別にサーバーが起動しています。
これらのサーバーに対して、フロントエンドが直接アクセスする場合、各エンドポイントを知っておく必要があり、GraphQLの利便性が損なわれることがあります。
それを解決するためにGraphQL Federationアーキテクチャを利用する場合があります

GraphQL Federation

GraphQL Federationは、いわゆるBFF(Backend for Frontend)をマイクロサービスの前段に配置し、フロントエンドがそのBFFのみを見ればよいという構成です。
これにより、複数のエンドポイントを一つのエンドポイントとして扱うことができます。各マイクロサービスのスキーマはBFFで統合され、クライアント側は統一されたスキーマを利用できます。
BFFでは、各マイクロサービスのSchemaをマージして持っておく必要があります
https://moneyforward-dev.jp/entry/2021/12/20/graphql-federation-2/

BFFもSchemaもマージしたくない時

BFFも配置したくなく、Schemaもマージしたくない時にフロントエンド側でどのような対応ができそうか調査していきました

フロントエンドのGraphQLクライアントとして、Relay, Apollo Client, urql, graphql-requestの4つがよく挙げられますが一旦RelayとApollo Clientについて着目しました

Relayでの対応

Relayでは、relay-compilerを用いてschemaと定義したgraphqlタグから型を生成します
例えば、relay-configをschemaごとに定義してネットワークレイヤーを特定条件時にエンドポイントを切り替える ということを試してみます

relay.schema1.config.json
{
  "artifactDirectory": "./src/__generated__/schema1",
  "src": "./src",
  "schema": "./graphql/schema1.graphql",
  "excludes": ["**/node_modules/**", "**/__generated__/**"],
  "eagerEsModules": true,
  "language": "typescript"
}
relay.schema2.config.json
{
  "artifactDirectory": "./src/__generated__/schema2",
  "src": "./src",
  "schema": "./graphql/schema2.graphql",
  "excludes": ["**/node_modules/**", "**/__generated__/**"],
  "eagerEsModules": true,
  "language": "typescript"
}
package.json
{
  "relay:schema1": "relay-compiler ./relay.schema1.config.json",
  "relay:schema2": "relay-compiler ./relay.schema2.config.json"
}

エンドポイントについては、Network作成時のfetchをカスタマイズすることで対応を検討
https://github.com/relay-tools/relay-hooks/issues/59#issuecomment-883551486

createRelayEnvironment.ts
import { Store, RecordSource, Environment, Network, Observable } from "relay-runtime";
import type { FetchFunction, IEnvironment, RequestParameters } from "relay-runtime";

const operationKinds = {
  MUTATION: 'mutation',
  QUERY: 'query',
};

const isQuery = (operation: RequestParameters): boolean => operation.operationKind === operationKinds.QUERY;

const fetchFn: FetchFunction = (params, variables) => {
  const host = isQuery(params) ? "http://hogehoge/query" : "http://hogehoge2/query";
  const response = fetch(host, {
    method: "POST",
    headers: [["Content-Type", "application/json"]],
    body: JSON.stringify({
      query: params.text,
      variables,
    }),
  });
  return Observable.from(response.then((data) => data.json()));
};
export const createRelayEnvironment = (): IEnvironment => {
  const network = Network.create(fetchFn);
  const store = new Store(new RecordSource());
  return new Environment({ store, network });
}

これらを設定した上でビルドするとエラーが発生しました
主な原因としてはデフォルトではbabel-plugin-relayがrelay.config.jsrelay.config.jsonを読み込みにいくためと考えられました
対策としては、Babel側の設定をoverrideすることで解決できそうですが一旦ここで断念しました
https://github.com/facebook/relay/issues/4407

Apollo Clientでの対応

Apollo Clientの場合、型生成はApolloではなくgraphql-codegenを利用しました
ここでもschemaごとにそれぞれの設定ファイルを定義します

codegen.schema1.ts
import { CodegenConfig } from '@graphql-codegen/cli';

const config: CodegenConfig = {
  schema: './graphql/schema1.graphql',
  documents: ['src/**/*.schema1.ts'],
  generates: {
    './src/__generated__/schema1/': {
      preset: 'client',
      plugins: [],
      presetConfig: {
        gqlTagName: 'gql',
      },
      config: {
        strictScalars: true,
        scalars: {
          DateTimeISO: 'string',
        },
      },
    },
  },
  ignoreNoDocuments: true,
};

export default config;
codegen.schema2.ts
import { CodegenConfig } from '@graphql-codegen/cli';

const config: CodegenConfig = {
  schema: './graphql/schema2.graphql',
  documents: ['src/**/*.schema2.ts'],
  generates: {
    './src/__generated__/schema2/': {
      preset: 'client',
      plugins: [],
      presetConfig: {
        gqlTagName: 'gql',
      },
      config: {
        strictScalars: true,
        scalars: {
          DateTimeISO: 'string',
        },
      },
    },
  },
  ignoreNoDocuments: true,
};

export default config;

これらをそれぞれ実行します

package.json
{
  "codegen:schema1": "graphql-codegen --config codegen.schema1.ts",
  "codegen:schema2": "graphql-codegen --config codegen.schema2.ts"
}

Apollo Clientでは、複数のエンドポイントを扱うためのtipsを公式が上げてくれているのでそちらを参考にしました
https://www.apollographql.com/docs/react/api/link/introduction/#directional-composition

client.ts
import { HttpLink, ApolloClient, ApolloLink } from '@apollo/client';

const hogehogeLink = new HttpLink({
  uri: 'https://hogehoge/query',
});

const hogehoge2Link = new HttpLink({
  uri: 'https://hogehoge2/query'
});

const client = new ApolloClient({
  link: ApolloLink.split(
    (operation) => operation.getContext()['clientName'] === 'hogehoge2',
    hogehoge2Link,
    hogehogeLink
   )
});

実際に使うときは、呼び出し時に指定するだけでエンドポイントを切り替えることが可能です

useQuery(query, { variables, context: { clientName: 'hogehoge2' }});

めでたく、schemaを分割しつつ、それぞれのエンドポイントに対しれリクエストを行うことができました!

おわり

クライアント側のみでschemaとエンドポイントをそれぞれ個別に扱うには、Apollo Clientが最も楽そうだと感じました
特にドキュメントが充実しているのがよかったです

複数のGraphQLエンドポイントをクライアント側で保持するユースケースがどれくらいあるかわからないですが、実装は可能ということがわかりました
しかし、エンドポイントが増えるごとに管理するエンドポイントないしschemaも増えるため、やはりBFFなりSchema stitchingなどを行うのが無難そうだなと思います

GitHubで編集を提案

Discussion