スタディサプリ Product Team Blog

株式会社リクルートが開発するスタディサプリのプロダクトチームのブログです

GraphQL Type Mergingを利用したサブスキーマをまたがった宣言的スキーマ定義

こんにちは。スタディサプリのWeb開発をやっている @highwide です。

今日は、スタディサプリ中学講座で利用されている、GraphQLにおける「Type Merging」という技術について紹介します。

自分が撮った写真の中でType Mergingっぽいものを探しました

Schema Stitchingとは

Type Mergingの前にまず、Schema Stitchingについて紹介します。

Schema Stitchingとは、複数のGraphQLスキーマをつなぎ合わせるためにgraphql-toolsから提供されている技術です。

the-guild.dev

これによって、スタディサプリ中学講座では

  1. 学習履歴や"ミッション"(後述)など、ユーザー起点の情報を関心事とするマイクロサービス
  2. 学習教材のマスターデータ管理を関心事とするマイクロサービス

...という、2つのインターナルなサービスが提供するGraphQLスキーマを、API gatewayが「つなぎ合わせる」ことで一つのGraphQLスキーマとしてクライアントに見せることができています。

(つまり、この技術によって、マイクロサービス間のインターナルな通信もGraphQLの仕組みのうえで行われることになります)

サービスの構成を簡単に示すと以下のようなものです。

Type Mergingとは

Type Mergingはgraphql-toolsが提供する、複数のSchemaに存在するTypeをマージするための仕組みです。

the-guild.dev

たとえば、スタディサプリ中学講座には"ミッション"という、ユーザーの方に「今週行うべき学習教材」を提示する仕組みがあります。

これは

  1. ユーザー情報と紐付けた今週の"ミッション"
  2. "ミッション"の中身として行うべき学習教材

を併せて扱う必要がありますが、実は先に挙げたマイクロサービスの設計上、データを管理するサービスは別々のものとなっています。

サンプルとして以下のようなGraphQLスキーマを想定します。

※ 実際にサービスで利用しているGraphQLスキーマとは異なります。

ユーザー管理サービス サブスキーマ

=> Mission はユーザーに紐づくデータなのでユーザー管理サービスで定義される

type Query {
  missionByUserId(userId: String!): Mission
}

type Mission {
  userId: String!
  title: String!
  TeachingMaterialId: String!
}

学習教材管理サービス サブスキーマ

=> TeachingMaterial (教材)は学習コンテンツのマスターデータなので学習教材管理サービスで定義される

type Query {
  teachingMaterialById(id: String!): TeachingMaterial!
}

type TeachingMaterial {
  title: String!
  subject: String!
  grade: String!
}

MissionTeachingMaterial (教材)への参照を持ちます。

ではNativeアプリやWebアプリといったクライアントが、 Mission に紐づく TeachingMaterial を一度に取得したいと思ったとき、どのようにサーバーのロジックを書けば実際にresolveすることができるでしょうか。

ここで、Type Mergingの出番です。

Type Mergingを利用しない場合

「Type Mergingの出番です」といいつつも、先にType Mergingを利用しない場合のコードサンプルを用意してみます。

まず、api-gatewayにて、Missionの型定義を以下のように extend します。

const typeDefs = gql`
  extend type Mission {
    teachingMaterial: TeachingMaterial!
  }
`

そのうえで、resolverでは以下のような delegateToSchema を利用した手続き的なコードを書くことができます。

import { delegateToSchema } from '@graphql-tools/delegate';

const resolvers = {
  Mission: {
    teachingMaterial: {
      resolve(payload, _, context, info) {
        // payloadに入っている、MissionのteachingMaterialIdを取得
        const teachingMaterialId = payload.teachingMaterialId

        // 取得したteachingMaterialIdをもとに、TeachingMaterialを教材管理サービスに取りに行く
        return delegateToSchema({
          schema: teachingMaterialSchema,
          operation: 'query',
          operationName: 'query_TeachingMaterialByMission',
          fieldName: 'teachingMaterialById',
          args: {
            id: teachingMaterialId,
          },
          context,
          info,
        })
      },
    },
  },
}

このようにすれば、

  1. Mission を取得する
  2. 取得したミッションから teachingMaterialId を取り出す
  3. 「2」で取り出した teachingMaterialId をもとに TeachingMaterial を取得する

...ということ処理を実現できます。

しかし、このアプローチにはいくつかの課題があります。

1. api-gatewayにロジックが寄りがち

今回の例であれば比較的単純なロジックではありましたが、「まずスキーマAからαというリソースを取得する」「次にαが持つデータを利用して、スキーマBにβを問い合わせるためのkeyとにする」...といったことをやり始めると、api-gatewayにおける処理が手続き的になりやすいです。

この手の設計論には様々な意見があることを承知の上で、私たちのチームではapi-gatewayが複雑なロジックを持たないに越したことはなく、より宣言的に書ける手法があるならそれを採用したいという議論がなされていました。

この議論にあたっての同僚の @ywada526 の言葉を引用しますが、

delegateToSchemagateway schema に "実装" する API デザインになっているのが問題の肝だと理解してます。 delegateToSchema の考え方自体はわかりやすいけど、このデザインになっているがゆえ、開発者が気をつけないと gateway resolever に実装することでなんとかしようという発想が生まれやすいことが delegateToSchema の課題なのかなあと

というのは、まさにそのとおりだなと思ったのでした。

2. サブスキーマへのdelegateを行うため、api-gatewayextend したスキーマ定義は利用できない

今回の例においても、api-gatewayextend を利用して Missionスキーマ定義を拡張していました。

このように、サブスキーマ(私たちの例で言えば、各マイクロサービスが持つそれぞれのGraphQLスキーマ)の定義を拡張することはときおりあります。

一方で、 delegateToSchema を利用して「あるfieldのresolveはサブスキーマに移譲する」というアプローチを利用した場合、サブスキーマの定義をextendしている箇所を意図通りresolveすることができない場合があります。

たとえば、今回のケースでは、

  1. サブスキーマAから Misson を取得
  2. api-gatewayによる Mission 拡張定義を参照
  3. 拡張定義により、サブスキーマBに TeachingMatearial を取得すべくdelegateされる

...という処理が行われました。

しかし、 TeachingMaterialapi-gatewayによって拡張された定義が行われていた場合どうなるでしょうか。

上の「3」で行われるのはあくまでも「サブスキーマB」への移譲であるため、api-gatewayによる拡張定義は参照することができません。

Type Mergingを利用する場合

続いては、このエントリで取り上げたいType Mergingを利用した例です。

Type Merging を実現する手段として以下の2種類が @graphql-tools から提供されています。

the-guild.dev

  • 個々のサービスにおける GraphQL スキーマに専用のディレクティブを付与する方法
  • Gateway 層にマージ定義を記述する方法

スタディサプリ中学講座では、 api-gateway に delegateToSchema を利用した Stitching コードが中央集権的に存在していることもあり、まずは同じく中央集権的といえる後者の方法を採用しています。以降の説明も、このgatewayにマージ定義を記述する方法を用いていきます。

まず、本来は学習教材のデータを保持していないユーザー管理サービスに、 TeachingMaterial (学習教材)のGraphQLスキーマ定義を置きます。

# ユーザー管理サービス サブスキーマ
type Mission {
  userId: String!
  title: String!
  teachingMaterial: TeachingMaterial!
}

type TeachingMaterial {
  id: String!
}

次に、ユーザー管理サービスにおいて、 TeachingMaterial のresolverを書きます。

後に学習教材管理サービスに問い合わせるうえでのkeyとなる id のみを返す TeachingMaterial をユーザー管理サブスキーマが返すことによって、api-gatewayにてType Mergingをすることができるようになるのです。

(なお、そのためにはユーザー管理サービスにおいても学習教材のidを知ってなくてはいけないという制約はありますが、マイクロサービス間でのやりとりをするkeyをそれぞれのサービスが保持しているという設計はそれほど不自然なものではないのかなと思っています)

const resolvers = {
  Mission: {
    teachingMaterial: {
      resolve(payload) {
        // payloadに入っている、MissionのteachingMaterialIdを取得
        return { id: payload.teachingMaterialId }
      }
    }
  }
}

そのうえで、api-gatewayにおいて、 stitchSchemas する際にの subschemas において、merge設定を入れます。

stitchSchemas({
  subschemas: [
    {
      schema: userServiceSchema,
    },
    {
      schema: teachingMaterialSchema,
      batch: true, // Type Mergingする際のリクエストをbach化する
      merge: {
        TeachingMaterial: {
           // 学習教材管理サービスに `TeachingMaterial` を取得する際のQuery field
          fieldName: 'teachingMaterialById',

          // 学習教材管理サービスに問い合わせる前に
          // 取得しておかないといけない `TeachingMaterial` のfield
          selectionSet: '{ id }',

          // (ユーザー管理サービスから)既に取得できたObjectから
          // どうやって `teachingMaterialById` のargsを組み立てるか
          args: (originalObject) => ({
            id: originalObject.id,
          }),
        },
      }
    },
  ],
})

以上のように、stitchSchema でサブスキーマをstitchする際に、特定の型をmergeするような指定を入れることが可能です。

また、今回の例では必要ありませんでしたが、1度のリクエストで複数のType Mergingを行うような場合は更に考慮が必要です。

その場合は、以下のように SomeResource がひとつ返されるQueryではなく、 [SomeResource] が返却されるようなQueryを利用することが大事です。

(Aサービスに1回の問い合わせした結果によって、BサービスのリソースをN個取得するような場合、BサービスにN回Queryを投げるようなことを避けたいため)

merge: {
  TeachingMaterial: {
    fieldName: 'someResourcesByIds', // 複数個のResourseが返却されるQueryを利用する
    selectionSet: '{ id }',

    // SomeResourceを受け取ってidを返す関数によってkeyを決定する
    key: ({ id }: { id: string }) => id,

    // keyを複数受け取る関数によって
    // { ids: [id, id, id...] } という `someResourcesByIds` のArgumentの作り方を決定する
    argsFromKeys: (ids) => ({ ids }),
  },
}

このようにして、 stitchSchema をする際に、「特定の型は指定の方法でmergeする」というのを、(ある程度)宣言的に行うことができました。

これによって、クライアントは以下のようなGraphQLクエリを投げることができます。

query fetchMissionWithTeachingMaterial($userId: String!) {
  missions(userId: $userId) {
    mission {
      title
      TeachingMaterial {
        title
        subject
      }
    }
  }
}

...さて、ここまで書いてきて元も子もないことを言うのですが、結局のところ公式のドキュメントが一番理解に役立ちます。

"Merging Flow"の図は「一見難しいけれど、何度も読んでいるとわかるようになる」と同僚の間で評判です。

the-guild.dev

ここでも同僚の言葉を引用しようと思いますが、 Quramy はこの図によって

個々のserviceが「自分の知っている field はxxxだよ」という type を持ち寄ってきて、それが合体(= merge) されるを理解した瞬間に全てが腑に落ちた記憶があります

と語ってくれて、自分もすごく頷ける意見でした。


Type Mergingを利用した場合のデメリットについても簡単に言及しようと思いますが、

  • 上にも書いたように、Type Mergingの処理フローやメンタルモデルを理解するのがやや難しい
  • 宣言的な定義による仕組みの上で何かが起こると、デバッグにあたってはその裏で何が起こっているかの理解が難しくなりがち

...といったところでしょうか。

また、今回紹介したGraphQL Tools以外にも、GraphQL MeshやApollo Federationなどの手法もあるようです。

the-guild.dev

最後に

スタディサプリではGraphQLなどを利用したWeb開発を行うポジションでの採用を行っています。

www.saiyo-dr.jp

また、実を言うと自分は5月からこの記事で紹介した技術を利用するチームから、プロダクトプラットフォーム開発を行うポジションに異動してしまったのですが、そちらでも採用を行っています。

www.saiyo-dr.jp

ポジションによらず、「ちょっと話聞いてみたい」などのお声掛けもお待ちしています!