こんにちは。スタディサプリのWeb開発をやっている @highwide です。
今日は、スタディサプリ中学講座で利用されている、GraphQLにおける「Type Merging」という技術について紹介します。
Schema Stitchingとは
Type Mergingの前にまず、Schema Stitchingについて紹介します。
Schema Stitchingとは、複数のGraphQLスキーマをつなぎ合わせるためにgraphql-toolsから提供されている技術です。
これによって、スタディサプリ中学講座では
- 学習履歴や"ミッション"(後述)など、ユーザー起点の情報を関心事とするマイクロサービス
- 学習教材のマスターデータ管理を関心事とするマイクロサービス
...という、2つのインターナルなサービスが提供するGraphQLスキーマを、API gatewayが「つなぎ合わせる」ことで一つのGraphQLスキーマとしてクライアントに見せることができています。
(つまり、この技術によって、マイクロサービス間のインターナルな通信もGraphQLの仕組みのうえで行われることになります)
サービスの構成を簡単に示すと以下のようなものです。
Type Mergingとは
Type Mergingはgraphql-toolsが提供する、複数のSchemaに存在するTypeをマージするための仕組みです。
たとえば、スタディサプリ中学講座には"ミッション"という、ユーザーの方に「今週行うべき学習教材」を提示する仕組みがあります。
これは
- ユーザー情報と紐付けた今週の"ミッション"
- "ミッション"の中身として行うべき学習教材
を併せて扱う必要がありますが、実は先に挙げたマイクロサービスの設計上、データを管理するサービスは別々のものとなっています。
サンプルとして以下のような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! }
Mission
は TeachingMaterial
(教材)への参照を持ちます。
では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, }) }, }, }, }
このようにすれば、
Mission
を取得する- 取得したミッションから
teachingMaterialId
を取り出す - 「2」で取り出した
teachingMaterialId
をもとにTeachingMaterial
を取得する
...ということ処理を実現できます。
しかし、このアプローチにはいくつかの課題があります。
1. api-gatewayにロジックが寄りがち
今回の例であれば比較的単純なロジックではありましたが、「まずスキーマAからαというリソースを取得する」「次にαが持つデータを利用して、スキーマBにβを問い合わせるためのkeyとにする」...といったことをやり始めると、api-gatewayにおける処理が手続き的になりやすいです。
この手の設計論には様々な意見があることを承知の上で、私たちのチームではapi-gatewayが複雑なロジックを持たないに越したことはなく、より宣言的に書ける手法があるならそれを採用したいという議論がなされていました。
この議論にあたっての同僚の @ywada526 の言葉を引用しますが、
delegateToSchema
が gateway schema に "実装" する API デザインになっているのが問題の肝だと理解してます。delegateToSchema
の考え方自体はわかりやすいけど、このデザインになっているがゆえ、開発者が気をつけないと gateway resolever に実装することでなんとかしようという発想が生まれやすいことがdelegateToSchema
の課題なのかなあと
というのは、まさにそのとおりだなと思ったのでした。
2. サブスキーマへのdelegateを行うため、api-gatewayで extend
したスキーマ定義は利用できない
今回の例においても、api-gatewayで extend
を利用して Mission
のスキーマ定義を拡張していました。
このように、サブスキーマ(私たちの例で言えば、各マイクロサービスが持つそれぞれのGraphQLスキーマ)の定義を拡張することはときおりあります。
一方で、 delegateToSchema
を利用して「あるfieldのresolveはサブスキーマに移譲する」というアプローチを利用した場合、サブスキーマの定義をextendしている箇所を意図通りresolveすることができない場合があります。
たとえば、今回のケースでは、
- サブスキーマAから
Misson
を取得 - api-gatewayによる
Mission
拡張定義を参照 - 拡張定義により、サブスキーマBに
TeachingMatearial
を取得すべくdelegateされる
...という処理が行われました。
しかし、 TeachingMaterial
もapi-gatewayによって拡張された定義が行われていた場合どうなるでしょうか。
上の「3」で行われるのはあくまでも「サブスキーマB」への移譲であるため、api-gatewayによる拡張定義は参照することができません。
Type Mergingを利用する場合
続いては、このエントリで取り上げたいType Mergingを利用した例です。
Type Merging を実現する手段として以下の2種類が @graphql-tools から提供されています。
スタディサプリ中学講座では、 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"の図は「一見難しいけれど、何度も読んでいるとわかるようになる」と同僚の間で評判です。
ここでも同僚の言葉を引用しようと思いますが、 Quramy はこの図によって
個々のserviceが「自分の知っている field はxxxだよ」という type を持ち寄ってきて、それが合体(= merge) されるを理解した瞬間に全てが腑に落ちた記憶があります
と語ってくれて、自分もすごく頷ける意見でした。
Type Mergingを利用した場合のデメリットについても簡単に言及しようと思いますが、
- 上にも書いたように、Type Mergingの処理フローやメンタルモデルを理解するのがやや難しい
- 宣言的な定義による仕組みの上で何かが起こると、デバッグにあたってはその裏で何が起こっているかの理解が難しくなりがち
...といったところでしょうか。
また、今回紹介したGraphQL Tools以外にも、GraphQL MeshやApollo Federationなどの手法もあるようです。
最後に
スタディサプリではGraphQLなどを利用したWeb開発を行うポジションでの採用を行っています。
また、実を言うと自分は5月からこの記事で紹介した技術を利用するチームから、プロダクトプラットフォーム開発を行うポジションに異動してしまったのですが、そちらでも採用を行っています。
ポジションによらず、「ちょっと話聞いてみたい」などのお声掛けもお待ちしています!