コンセプト、アイデア、戦略
コンセプト、アイデア、戦略異なるGraphQLサーバーで動作するアプリケーションの設計

異なるGraphQLサーバーで動作するアプリケーションの設計

「インターフェースに対してコーディングし、実装に対してコーディングしない」とは、機能を直接呼び出すのではなく、必要な入力と期待される出力を列挙したコントラクトを通じて呼び出す実践です。実装の詳細は隠蔽されます。この戦略により、アプリケーションを特定の実装・プロバイダー・スタックから切り離し、アプリケーションコードを変更せずに切り替えられるようになります。

この戦略はGraphQLにも適用できます。GraphQLはアプリケーションとサーバーの中間役として機能し、必要なすべての変更をGraphQLクエリのみに集中させ、ビジネスロジックには手を加えずに済みます。

GraphQLクエリはクライアントとサーバーの間のインターフェースとして機能します。クエリを実行すると、GraphQLサーバーはそれを処理してクライアントに必要なデータを返します。データはどこから来るのか?どのように取得されたのか?クライアントはそれを知らず、また気にもしません。

GraphQLクエリはクライアントとサーバーの間のインターフェースとして機能します

クエリへのレスポンスはクエリと同じ形状になります。この GraphQLクエリに対して:

{
  post(by: { id: 1 }) {
    id
    title
  }
}

...レスポンスは次のようになります:

{
  "data": {
    "post": {
      "id": 1,
      "title": "Hello world!"
    }
  }
}

同じクエリでもパラメーターが異なれば返されるデータは変わりますが、形状は一定です。つまり、クエリが変わらない限り、アプリケーションはデータの読み取りと処理のロジックを変更する必要がなく、どのGraphQLサーバーがクエリを実行しているかも問題になりません。

このようにして、あるGraphQLサーバーから別のサーバーへシームレスに切り替えることができます。

クエリはGraphQLスキーマに依存する

ただし、最後の段落は少し楽観的すぎます。GraphQLクエリはGraphQLサーバーによって変更が必要になることがあるためです。より正確には、クエリはGraphQLスキーマに基づいており、異なるサーバーが異なるスキーマを公開している場合、クエリも異なるものになります。

たとえば、Cursor Connections仕様を使用するGraphQLサーバーでは、次のようなクエリを実行します:

{
  categories(first: 10000) {
    edges {
      node {
        categoryId
        description
        id
        name
        slug
      }
    }
  }
}

一方、WordPress風のページネーションを使用する別のサーバー(Gato GraphQLなど)では、同じクエリを次のように実行します:

{
  postCategories(pagination: { limit: 10000 }) {
    id
    description
    globalID
    name
    slug
  }
}

2つのクエリの違いを確認できます:

機能サーバー #1サーバー #2
投稿カテゴリーフィールドcategoriespostCategories
結果数を制限するフィールド引数firstpagination.limit
オブジェクトのフィールド id が表すものグローバルな一意IDその型における一意ID
クエリの形状edges.node により深いよりフラット

最初のサーバーのクエリを2番目のサーバーの同等クエリに置き換えるだけでは機能しません。ロジックが引き続き元のクエリの形状とフィールドに従ってレスポンスデータにアクセスしようとするためです。

1つの解決策は、クライアント側のデータ取得ロジックも置き換えることです。たとえば、次のロジックを:

const categories = data?.data.categories.edges.map(({ node = {} }) => node);

...次のように置き換えます:

const categories = data?.data.postCategories;

しかし、それはまさに避けたいことです。変更を最小限に抑え、インターフェース(GraphQLクエリ)のみを変更し、ビジネスロジックはそのままにしておきたいのです。

幸いにも、次の手順に従いGraphQLクエリのみを変更することで、この差異を吸収することができます:

  1. GraphQLクエリをアプリケーションから切り離した状態に保つ
  2. エイリアスを使ってフィールド名を適合させる
  3. self フィールドを使ってレスポンスの形状を適合させる

この3つのステップを通じて、アプリケーションを別のGraphQLサーバーに向けるよう適合させる方法を見ていきましょう。

GraphQLクエリをアプリケーションから切り離した状態に保つ

GraphQLクエリをアプリケーションロジックから切り離すには、次のことが必要です:

  • 各GraphQLクエリ(またはそのまとまり)を個別ファイルに保存し、すべてを特定のフォルダーに配置する
  • クエリをエクスポートしてアプリケーションにインポートする

たとえば、すべてのGraphQLクエリを src/data 配下の個別ファイルに配置してエクスポートできます:

// file `src/data/categories.js`
export const QUERY_ALL_CATEGORIES = gql`
  {
    categories(first: 10000) {
      edges {
        node {
          databaseId
          description
          id
          name
          slug
        }
      }
    }
  }
`;

アプリケーションはGraphQLクエリをインポートして使用できます:

import { QUERY_ALL_CATEGORIES } from 'data/categories';
 
export async function getAllCategories() {
  const apolloClient = getApolloClient();
 
  const data = await apolloClient.query({
    query: QUERY_ALL_CATEGORIES,
  });
 
  const categories = data?.data.categories.edges.map(({ node = {} }) => node);
 
  return {
    categories,
  };
}

この設定のおかげで、すべての変更は src/data 配下のファイルに対してのみ行えばよくなります。

エイリアスを使ってフィールド名を適合させる

フィールドエイリアスを使って、2番目のGraphQLサーバーのレスポンス内のフィールド名を、最初のサーバーのフィールド名に変更できます。

こうすることで、フィールド postCategoriesidglobalID を、アプリケーションが期待する名前である categoriescategoryIdid として取得できます:

{
  categories: postCategories(pagination: { limit: 10000 }) {
    categoryId: id
    description
    id: globalID
    name
    slug
  }
}

フィールド categories には引数 first があり、対応するフィールド postCategories は引数 pagination.limit を使用することに注意してください。ただし、フィールド引数はレスポンス内のフィールド名に反映されないため、気にする必要はありません。

self フィールドを使ってレスポンスの形状を適合させる

最後の課題は少し難しくなります。Cursor Connections仕様から来る edgesnode の追加レベルを付け加えて、レスポンスの形状を変更する必要があります。

これを実現するために、GraphQLスキーマのすべての型に self フィールドを追加します。このフィールドは適用されたオブジェクト自体をそのまま返します:

type QueryRoot {
  self: QueryRoot!
}
 
type Post {
  self: Post!
}
 
type User {
  self: User!
}

self フィールドを使うと、クエリ対象のオブジェクトから離れることなく、クエリに追加レベルを付け加えることができます。このクエリを実行すると:

{
  __typename
  self {
    __typename
  }
  
  post(by: { id: 1 }) {
    self {
      id
      __typename
    }
  }
  
  user(by: { id: 1 }) {
    self {
      id
      __typename
    }
  }
}

...次のレスポンスが得られます:

{
  "data": {
    "__typename": "QueryRoot",
    "self": {
      "__typename": "QueryRoot"
    },
    "post": {
      "self": {
        "id": 1,
        "__typename": "Post"
      }
    },
    "user": {
      "self": {
        "id": 1,
        "__typename": "User"
      }
    }
  }
}

これで、self を使って nodesedge のレベルを人工的に付け加えることができます:

{
  categories: self {
    edges: postCategories(pagination: { limit: 10000 }) {
      node: self {
        categoryId: id
        description
        id: globalID
        name
        slug
      }
    }
  }
}

GraphQLスキーマにおける edgesself のオブジェクト型は明らかに異なります。しかし、アプリケーションはGraphQLサーバーでモデル化された実際のオブジェクトと直接やり取りしないため、それは問題になりません。代わりにJSONオブジェクトとしてデータを受け取るため、PostConnection オブジェクトから来るフィールドであっても Post オブジェクトから来るフィールドであっても、そのデータは同じです。

categories フィールドは self を通じて解決され、edgespostCategories を通じて解決されており、逆の順序ではないことに注意してください。これは、返される要素の基数がCursor Connections仕様を使用するフィールドで定義されたものと一致するようにするためです:

type RootQuery {
  categories: RootQueryToCategoryConnection
}
 
type RootQueryToCategoryConnection {
  edges: [RootQueryToCategoryConnectionEdge]
}
 
type RootQueryToCategoryConnectionEdge {
  node: Category
}

適合後のGraphQLクエリが逆の順序(つまり categories: postCategoriesedges: self をクエリする形)であった場合、データへのアクセスは失敗します。data.categories が配列になるため、次を実行する際に data.categories.edges がエラーを投げるためです:

const categories = data?.data.categories.edges.map(({ node = {} }) => node);

すべてのクエリを適合させる

src/data 内のすべてのGraphQLクエリに同じ戦略を適用した後、アプリケーションはあるGraphQLサーバーから別のサーバーへ簡単に切り替えられるようになります。