Apollo Federation再入門 ~シュッとGraphQLのスキーマ合成を試す~
こんにちは!スペースマーケットのバックエンドエンジニアの北島と申します。
今夏は平年並み以上の暑さと予測されているようですが、かと思えば急に肌寒い日があったりと非常にややこしい気温ですね。日常会話の中でも「今年も暑いですね」,「今年は涼しいですね」などの定型的な季節の挨拶がシュッと使えず、本当にややこしいと感じています。Tシャツ一枚で玄関を出てみたものの慌てて上着を取りに帰る羽目になったり、格好についてもすごくややこしいですよね。ややこしいことこの上ないですね。そういえばややこしいといえばあれがありますね。そうですね、Apollo Federationですね。
0. 記事のゴール
* Apollo Federationにできることを把握する
* Apollo Federation特有の記述方法を知る
* 実際にスキーマの合成を試す
今回はGraphQL,Apollo Server自体の解説はほとんど扱いませんのでご注意されたし。
1. Apollo Federationにできること
Apollo Federationにできることを説明する上で、まずは弊社システムの構成と状況をご説明いたします。
弊社のバックエンドシステムはマイクロサービス化を積極的に推進しています。メインサービスのspacemarket.comだけで見ても数十ものリポジトリから構成されており、基本的には検索,予約といったドメインごとにリポジトリを分割する方針をとっています。
通信は基本的にGraphQLを用いており、クライアントが求めたデータのみを抽出できる、というGraphQLの代表的な利点をweb,アプリ共にばっちり享受しています。
また、複数のマイクロサービスを跨いだ単一のエンドポイント(インターフェース)を提供する目的で、GraphQLゲートウェイと呼ばれるゲートウェイを設置しています。こうしたアーキテクチャを分散GraphQLアーキテクチャと呼称することがあるようです。弊社はその手段としてApollo Federationを採用しています。ここでようやくApollo Federationが出てきます。
Apollo Federation以外にも似たような構成を実現できるSchema Stitchingという手段がありましたが、検討の結果Apollo Federationを採用することにいたしました。(詳細は弊社西尾による下記記事をご参照ください)
ここまでの構造を簡単に図示すると以下のようになります。
Apollo Federationの公式ドキュメントにある図とほぼ同じ構造になっているのがお分かりかと思います。GraphQL Gatewayが果たしている役割は、単一エンドポイントとしての役割,スキーマを合成することでサービス同士をなるべく疎結合に保つ役割であると言えます。
単一エンドポイントであることは明らかなのでよいとして、「サービス同士をなるべく疎結合に保つ」という役割が分かりづらいですね。その点の理解をより深めるため、一旦GraphQL Gatewayがない場合を考えます。Gatewayがない状況で下記のようなクエリを実現したいとして、どういった手段が考えられるでしょうか。
query {
reservation { // 予約サービスの情報
id
date
user { // ユーザサービスの情報
id
name
email
}
}
}
予約というドメインがあって、ユーザというドメインがある。それぞれが独立したサービスだという前提です。予約情報とそれを持つユーザデータを一括で取得したい、というイメージです。
少々乱暴な図ですが、すぐに思いつくのは下記のような手段です。
こんな形で実現はできそうですね。
実現はできるのですが、この構造で頑張ってしまうと後々辛くなりそうなことも容易に想像がつきますね。この場合、予約サービスがユーザの型や取得クエリを知っている必要がありそうです。予約サービスなのにユーザの型をよく知っている必要がある、というのは関心ごとがうまく分離されておらず良い設計ではないですよね。サービス同士の結合度が高まってしまっていて、マイクロサービス化がうまく活かされておらずもったいない構造と言えます。
この問題を解決してくれるのがApollo Federationです。Apollo Federationを利用することで、なるべく関心の分離を維持しながら要求を実現できるというわけです。
Apollo Federationの特徴やメリットは他にもあります。個人的に特に大きいメリットだと感じているのが、Gatewayの更新がほとんど不要という点です。本記事ではこの後ハンズオン的にコードを記述していきます。やっていくうちにお気づきになるかと思うのですが、gateway部分のコードはほとんど触らずに済みます。手を動かしながら実際にそのメリットを体感していただければ幸いです。
というわけで、改めてApollo Federationを利用したスキーマ合成の利点をまとめます。私が特に感じる利点は大きく以下の3つだと感じています(他にもいろいろあるとは思います)。
1. 単一エンドポイント
2. マイクロサービスの疎結合維持
3. 保守運用の容易さ
いかがでしょうか。どんな時に使いたくなる、何ができる技術なのか、という全体像がご説明できていれば幸いです。
2. 実装準備
Apollo Federationについての概念レベルの理解は完了しました。次に、簡単なサンプルを作りながら実際の設定方法や挙動を確認しましょう。
弊社における各マイクロサービスは、RoRであったりNest.jsであったりと色々な言語で実装されています。今回は大きなFWを利用するほどの機能は用意しないので、最小構成で実現できそうなNode.jsで書いてみます。Node.jsのバージョンは14.17.5を利用します。
完成したものを下記githubに置いておきました。本記事の内容からさらに発展させたいろいろなパターンのスキーマ合成を実装しておきましたので、参考にしてみてください。
まず初めに、構成の全体像を確認します。今回は下記のようなサービス群を作ろうと思います。ちゃんとした図ではないので雰囲気で察していただけると助かります。
Authorサービスが作家、Bookサービスが書籍、Reviewサービスがレビューをそれぞれ担当しているというイメージです。作家は0以上の書籍(著書)を持っていて、書籍は0以上のレビューを持っているという関係です。書籍の著者に関しては今回は単著のみを考えるとします。
これらのサービスを実際に作っていきます。それに加え、横断的に網羅するGraphQL GatewayをApollo Federationを使って実装していきます。
今回の記事で実現したい部分は、以下の部分です。
1. Apollo Federationを利用して単一のエンドポイントの実現
2. Apollo Federationを利用してスキーマ合成を実際に記述してみる(authorsからBookを引けるようにする)
本記事の範囲ではAuthorサービスとBookサービス、Gatewayの実装のみを扱います。最終的にReviewも含めて実装したサンプルコードは上のgithubに置いておきます。
3. Authorサービス実装
Authorサービスの実装からはじめましょう。
まずはそのまえに、各サービスのディレクトリを切って置きましょう。ディレクトリは同一でもよいのですが、今回は別サービス感を出すために下記のようなディレクトリ構造としてみます。
workspace
├ api_authors/
│ └ index.js
├ api_books/
│ └ index.js
├ api_reviews/
│ └ index.js
└ graphql_gateway/
└ index.js
各ディレクトリにて、@apollo/gateway,apollo-server,graphqlをインストールします。
cd 各サービスディレクトリ
npm init --yes
npm install @apollo/federation apollo-server graphql
graphql_gatewayにのみ@apollo/gatewayが必要なのでそれも併せて入れておきましょう。
npm install @apollo/gateway @apollo/federation apollo-server graphql
次に、Authorサービスを書いていきます。
# api_authors/index.js
const { ApolloServer, gql } = require('apollo-server');
const { buildSubgraphSchema } = require("@apollo/federation")
const authors = [
{ id: "1", name: '宮沢賢治', birthday: '1896-08-27' },
{ id: "2", name: '坂口安吾', birthday: '1906-10-20' },
{ id: "3", name: '芥川龍之介', birthday: '1892-03-01' },
]
const typeDefs = gql`
type Query {
authors: [Author]
}
type Author {
id: ID!
name: String!
birthday: String
}
`;
const resolvers = {
Query: {
authors(parent, args, context, info) {
return authors
},
},
}
const server = new ApolloServer({
schema: buildSubgraphSchema([{ typeDefs, resolvers }]) // 注意点1
});
server.listen(4001).then(({ url }) => {
console.log(`🚀 Server ready at ${url}`);
});
ごく簡素なApollo Serverが書けました。
DBとの接続は主題でないので、今回はメモリ上のauthors配列を読むような構造にしてあります。作家データは適当ですが、私は特に坂口安吾が好きです。
この時点のコードにおけるApollo Federation特有要素は、コメント注意点1部分のbuildSubgraphSchemaの部分のみかと思います。こちらはtype定義とresolver定義を受け取り、フェデレーションに対応するスキーマを返してくれる関数です。詳しくは公式にあるのでご確認ください。サブグラフとは、という部分を理解しておくと公式ドキュメントがグッと読みやすくなります。
では、Authorサービスを立ち上げてみます。index.jsを実行すればサーバが立ち上がります。
node index.js
🚀 Server ready at http://localhost:4001/
正しく動いているかを確認するため、http://localhost:4001/へクエリを実行してみましょう。
GraphiQLやInsomnia等の手元clientで実行しても問題ないです。特にこだわりがない場合、Apollo Studioのsandboxを使うとブラウザ上からlocalhostを確認できて便利です。
リクエスト先がhttp://localhost:4001/に向いていることを確認し、下記クエリを実行します。
query {
authors {
id
name
birthday
}
}
成功すれば下記のようなレスポンスが得られるはずです。
{
"data": {
"authors": [
{
"id": "1",
"name": "宮沢賢治",
"birthday": "1896-08-27"
},
{
"id": "2",
"name": "坂口安吾",
"birthday": "1906-10-20"
},
{
"id": "3",
"name": "芥川龍之介",
"birthday": "1892-03-01"
}
]
}
}
うまく行きましたでしょうか。ここまでで、Authorサービスの下準備は完了です。
4. Bookサービス実装
同様に、Bookサービスも実装します。
# api_books/index.js
const { ApolloServer, gql } = require('apollo-server');
const { buildSubgraphSchema } = require("@apollo/federation")
const books = [
{ id: "1", author_id: "1", title: "春と修羅" },
{ id: "2", author_id: "2", title: "白痴" },
{ id: "3", author_id: "3", title: "藪の中" },
{ id: "4", author_id: "1", title: "銀河鉄道の夜"}
];
const typeDefs = gql`
type Query {
books: [Book]
}
type Book {
id: ID!
author_id: ID!
title: String!
}
`;
const resolvers = {
Query: {
books() {
return books;
},
},
}
const server = new ApolloServer({
schema: buildSubgraphSchema([{ typeDefs, resolvers }])
});
server.listen(4002).then(({ url }) => {
console.log(`🚀 Server ready at ${url}`);
});
内容はほぼAuthorサービスと同様です。serverのlistenポートを4002にしました。立ち上げて確認してみましょう。
node index.js
🚀 Server ready at http://localhost:4002/
クエリ例と期待する結果は以下のようになります。
query {
books {
id
author_id
title
}
}
{
"data": {
"books": [
{
"id": "1",
"author_id": "1",
"title": "春と修羅"
},
{
"id": "2",
"author_id": "2",
"title": "白痴"
},
{
"id": "3",
"author_id": "3",
"title": "藪の中"
},
{
"id": "4",
"author_id": "1",
"title": "銀河鉄道の夜"
}
]
}
}
4. GraphQL Gatewayの実装
次に、GraphQL Gatewayを実装します。
いよいよ、という感じではありますが、コード自体は実に簡素です。
# graphql_gateway/index.js
const { ApolloServer } = require('apollo-server');
const { ApolloGateway } = require('@apollo/gateway');
const gateway = new ApolloGateway({
serviceList: [
{ name: 'author', url: 'http://localhost:4001' },
{ name: 'book', url: 'http://localhost:4002' }
],
});
const server = new ApolloServer({
gateway,
subscriptions: false,
});
server.listen().then(({ url }) => {
console.log(`🚀 Gateway ready at ${url}`);
}).catch(err => {console.error(err)});
やっていることは、合成したいサービスのリストを記述する程度です。subscriptions: falseとしているのは、現在Apollo Federationではsubscriptionをサポートしていないという記述を公式で見かけたためです。今回は特にサブスクリプション機能を使うつもりはないので気にしなくてOKです。
Authorサービス、Bookサービス共に立ち上がっている状態でGatewayを起動してみましょう。
node index.js
🚀 Gateway ready at http://localhost:4000/
まず、これで単一エンドポイントの実現は完了しています。一旦clientで確認してみましょう。authorsとbooks双方のクエリが取得できるエンドポイントが作成できたことが確認できると思います。
クエリ例を貼っておきます。実行することで各サービスのデータが正しく取得できていると思います。
query {
authors {
name
}
books {
title
}
}
5. authors から Bookを引けるようにする
次に、いよいよスキーマ合成を実践してみましょう。
まず、Bookサービスから修正します。
# api_books/index.js
(略)
const typeDefs = gql`
type Query {
books: [Book]
}
type Book {
id: ID!
author_id: ID!
title: String!
}
extend type Author @key(fields: "id") { // 追加点1
id: ID! @external // 追加点2
books: [Book]
}
`;
const resolvers = {
Query: {
books() {
return books;
},
},
Author: { // 追加点3
books(author) {
return books.filter(book => book.author_id === author.id)
}
}
}
(略)
追加点1はAuthorサービスで定義されているAuthorタイプを拡張する宣言をしています。@key部分はキー(エンティティを一意に識別できるフィールド)とするfieldを指定しています。後述するAuthorsサービス側の指定と合わせることで、Bookサービスのresolver内でAuthorのidを受け取ることが可能となります。@keyやエンティティの説明はAuthor側の実装時に回します。
追加点2は@externalの部分です。これは別のサービスで定義されているフィールドの宣言となります。@externalとなっている部分はBookサービスでは解決しません。依存関係の明示と捉えるとわかりやすいかと思います。
books: [Book]と指定している部分はAuthorタイプにbooksというフィールドを足し、Book配列を返すように定義しています。
追加点3 はAuthorタイプの解決を定義しています。まだ動きませんが、books(author)のauthorにはどんな値が渡ってくるのかここで例示しておきます。
{ __typename: 'Author', id: '1' }
追加点1で記述した@keyをidとしているため、idが受け取れる予定というわけです。
次に、Authorサービスを修整します。
# api_authors/index.js
(略)
const typeDefs = gql`
type Query {
authors: [Author]
}
type Author @key(fields: "id") { // 変更点1
id: ID!
name: String!
birthday: String
}
`;
(略)
こちらの変更点は@key(fields: "id")の部分だけです。
先ほども出てきた@keyというディレクティブですが、これはオブジェクトタイプをエンティティとして指定する記述になります。エンティティとは、という部分は公式のこのあたりに記載されています。@keyとはRDBでいう主キーです。fieldsに指定したものが主キーとなりますが、複合主キーを指定できたりいろいろな指定ができますので公式を覗いてみてください。
Bookサービス側で記述した@keyと同様の内容を記述しています。Author側、Book側双方に@keyを明示することにより、Bookサービス側resolverでAuthor.idを受け取ることができるようになります。公式のこのあたりに当たります。
今回の実装で必要な知識は、先ほど@keyの説明部分でご紹介したエンティティという概念に集中しているので、深く理解を進めるため公式のこのページを一読されることをお勧めします。
ともかく、これで準備が整いました。それぞれのサーバを再起動してゲートウェイにリクエストを投げてみましょう。
# 各サービスで実行。graphql_gatewayは最後に起動すること
node index.js
クエリ例は以下です。
query {
authors {
id
name
birthday
books {
id
author_id
title
}
}
}
期待するレスポンス例は以下です。
{
"data": {
"authors": [
{
"id": "1",
"name": "宮沢賢治",
"birthday": "1896-08-27",
"books": [
{
"id": "1",
"author_id": "1",
"title": "春と修羅"
},
{
"id": "4",
"author_id": "1",
"title": "銀河鉄道の夜"
}
]
},
{
"id": "2",
"name": "坂口安吾",
"birthday": "1906-10-20",
"books": [
{
"id": "2",
"author_id": "2",
"title": "白痴"
}
]
},
{
"id": "3",
"name": "芥川龍之介",
"birthday": "1892-03-01",
"books": [
{
"id": "3",
"author_id": "3",
"title": "藪の中"
}
]
}
]
}
}
問題なく取得できたでしょうか。エラーやロジック不備が起きなければ、しっかりとAuthor.idとBookのauthor_idのリレーションが正確なデータが取れているかとおもいます。
スキーマ合成成功おめでとうございます!
ここまでで、当初の目的としていた簡単な記述だけでの単一エンドポイント&スキーマ合成の実践を実現できました。ロジックを記述するというよりは定義を記述するだけで実現してくれますので、ほとんど手を動かすことなくサンプルが完成したと思います。
一旦記事としてはまとめに入りますが、上に挙げたgithubのリポジトリでは今回実装していないReview、booksからAuthorを引く実装,reviewsからBook...といくつかの例を実装しておきました。ここからここを参照するのはどうするんだろう、resolveReferenceを使う場合の書き方は?等気になったことがあれば、もしよろしければご覧ください。
6. まとめ
いかがでしたでしょうか。Apollo Federationはなんだかとっつきにくいイメージがありますが、実際に動かしてみるとそれほど難しい印象はうけなかったのではないでしょうか。
実は、元々は現在技術検証中(半分趣味)のgRPCについての記事を書こうと考えておりました。検証のため弊社の環境再現を目的としたプロトタイプを作成していく中で「Apollo Federationの方が記事にしがいがあるかも」と思い直しこの記事を書くことにいたしました。Apollo Federationについての日本語記事はまだまだ少ない印象があります。それが原因で導入を見送っていたり敬遠されている方もいるかと思いますが、簡単に試せるサンプルがより充実すればより採用しやすい技術となっていくのではないかと今後に期待しています。
ちなみになぜgRPCを検証しているの?というお話をすると、実はgRPCを導入することは会社の方針としてまだ決定しておりません。個人的には取り入れる必要と可能性を感じているのでほぼ趣味ですね。取り入れたいな、と思っている理由としては、クライアント向けはGraphQL, BEの内部通信はgrpcと棲み分けをすることでよりマイクロサービスを崩さず高速なバックエンドのシステム群を開発できるのではないかという思いからです。今想像しているのは下記のような構成です。
サービス同士の通信で利用するのもそうですが、まずはbatchやその他のバックエンドサービスのエントロピー増大を抑える目的(治安維持)で使えそうだ、と考えています。現状の構成では、クライアントで扱いたいドメインやデータと、batchやその他BEで扱いたいドメインやデータをどう分ければいいか明確な方針が分かりづらいと考えています。このまま放置していると、フロントやアプリで利用する予定のないスキーマをGraphQLスキーマに混入させてしまったり、各サービスに独自のRESTエンドポイントを新設してしまったり、関心ごとの分離を諦めてバッチから直接各DBへアクセスしてしまうなど、マイクロサービスの前提を崩しかねない独自実装を進めてしまう可能性があります。そういった防ぐために統一した方針、規格を作るようなイメージです。
7. おわりに
というわけで、エントロピー増大を防ぎたい、コードの治安維持が好きなエンジニアの方はいらっしゃいませんか?
治安維持?やってらんない!新規開発ゴリゴリやりたい!という方ももちろん探しております。
まずは話を聞きたい!というだけでも大変嬉しいので、是非是非ご応募お待ちしております!
この記事が気に入ったらサポートをしてみませんか?