【ApolloFederation】Schemaマージ時のコンフリクトエラーを回避できるか試してみた
はじめに
こんにちは、スペースマーケットでバックエンドエンジニアをしています。
今回は弊社で利用しているApolloFederationについての記事です。
弊社では複数のマイクロサービスを取りまとめて単一のGraphQLスキーマを提供するGatewayとしてApolloFederationを利用しています。(弊社のApolloFederationを利用したSchemaマージに関してはこちらで詳しく解説されています)
加えて、弊社では技術刷新の取り組みとしてRubyOnRailsからNestJSへのGraphQL実装移行も進めております。
今回はこのGraphQL移行実装のリリースをする際に障壁となる課題が存在したため、これをどうにかアプリケーションレベル(ApolloFederation)で回避することができないか実装・検証してみました。
現状課題
まずは移行実装のリリースにおける課題について説明します。
例えば、ServiceA(Rails)にhoge
Queryを実装していたとします。
これを移行先となるServiceB(NestJS)にも同様のhoge
Queryを実装しました。
各サービスで生成されるSchema以下の通りです。
ServiceAの既存実装のSchema
type Query {
hoge: String!
...
}
ServiceBの移行実装後のSchema
type Query {
hoge: String!
...
}
ここで、ServiceBの移行実装PRレビューを貰った勢いで、ServiceBをリリースするとどうなるでしょうか?答えはApolloFederationでSchemaのコンフリクトエラーが発生して起動に失敗します。
Schemaコンフリクトのイメージ
エラー内容は以下です。Query.hoge
の定義は1度にしてと言われてます。
Error: A valid schema couldn't be composed. The following composition errors were found:
Field "Query.hoge" can only be defined once.".
Gatewayは全てのAPIリクエストの受け口となっているため、このエラーが発生すると全てのAPIリクエストが失敗してしまいます。
逆に、このコンフリクトを避けるために、先にServiceAのhoge
Queryを削除した移行実装をリリースする場合はどうでしょうか?こちらも答えは単純ですが、クライアントサイドからhoge
Queryを参照することができなくなってしまいます。
このように一瞬でも特定のSchemaが一時的に重複する状態、もしくは一時的に存在しない状態になるとサービスのダウンタイム発生に繋がります。これが移行実装のリリースを難しくさせている根本的な原因です。
弊社ではこのエラーを回避するために、tmp環境(一時的に用意した既存のミラー環境)を作成し、一旦そちらに全てのAPIリクエストを流してから、アクセスが来なくなった既存の環境で各サービスの移行実装をリリースし、正常にGatewayが機能するようになったことを確認してからAPIリクエストを戻すリリース手法をとっていました。所謂カナリアリリース的な手法(?)です。
しかし、実装のリプレイスにしてはtmp環境の作成等作業コストが高いと感じており、どうにかしてコードの変更だけ(アプリケーションレベル)でこの移行実装をリリースできないかと考えるに至りました。
実装
それでは早速ですが、今回の趣旨に入ります。
私は上記の課題の根本的な原因となるスキーマコンフリクトをApolloFederationだけで回避できないか考えてみました。
色々試してみた結果、以下の実装でスキーマのコンフリクトを回避することができました。
const gateway = new ApolloGateway({
serviceList: [
{ name: 'ServiceA', url: `${process.env.SERVICE_A}/graphql` },
{ name: 'ServiceB', url: `${process.env.SERVICE_B}/graphql` },
],
buildService({ url, name }) {
return new RemoteGraphQLDataSource<GatewayContext>({
url,
willSendRequest({ request, context }) {
// 省略
},
didReceiveResponse({ response }) {
if (name === 'ServiceB') {
// ServiceBからのレスポンスのみ
if (response.data?._service) {
// スキーマの取得時のみ
// 該当のQueryを削除
response.data._service.sdl = response.data!._service.sdl.replace(
/hoge: String!/g,
'',
);
}
}
return response;
},
});
},
});
buildService
のdidReceiveResponse
内で、各サービスからのSDL(Schema Definition Language)が渡ってくるので、ServiceBから提供されるSchema中のhoge
Queryのみを文字列置換によって削除します。少々強引さが否めないですが、これでServiceAとServiceBから同じSchemaが提供されたとしてもコンフリクトエラーを回避することができるようになります。
ちなみに下記の条件分岐を設定しているのは、Schema取得時だけでなく、APIリクエストを受けた際にもこちらのdidReceiveResponse
が実行されるのですが、その場合はresponse.data
内に_service
が含まれず、エラーになってしまうためです。
if (response.data?._service) {}
_service
はApolloGatewayが各サービスから提供されるSchemaを取得するときにのみ含まれるようになっているようですね。
検証
では実際にこの実装でhoge
Queryの呼び出しに問題がないか検証してみます。
前提条件としては以下の通りです。
- ServiceAは既存の実装のまま
- hogeクエリで文字列
hoge from ServiceA
を返す
- hogeクエリで文字列
- ServiceBは移行実装を行なった状態
- hogeクエリで文字列
hoge from ServiceB
を返す
- hogeクエリで文字列
- GatewayはServiceBからの
hoge
Queryを削除するように実装(上述)
こちらの条件下で、Gatewayが正常に起動したことを確認してからhoge
Queryを呼び出してみます。
以下のレスポンスが返ってきました。
{
"data": {
"hoge": "hoge from ServiceA"
}
}
ServiceAで実装されたhoge
Queryが呼び出されていることが確認できました。
続いて先ほどの前提条件の3つ目を以下のように変更してServiceAからのレスポンスを消し込んでみます。
- GatewayはServiceAからの
hoge
Queryを削除するように実装(変更は以下)
- if (name === 'ServiceB') {
+ if (name === 'ServiceA') {
再度hoge
Queryを呼び出してみると、以下のレスポンスが返ってきました。
{
"data": {
"hoge": "hoge from ServiceB"
}
}
今度はServiceAからではなく、ServiceBで実装されたhoge
Queryが呼び出されていることが確認できました。
まとめ
いかがでしたでしょうか?
無理矢理感は否めないのですが、tmp環境の作成といった作業を行うことなくGatewayのコードに変更を加えるだけ(アプレケーションレベル)で移行実装のリリースができるようになるのではないかと思います✨
よりスマートにSchemaマージ時の挙動を変えることができないかApolloFederationのドキュメント等も調べてはみたのですが、現状はこのように取得した生のSchemaを力技で改変するしかないようです。
もし、より良い方法があればコメントで教えていただけると嬉しいです。
最後に
弊社では、GraphQL や NestJS を利用した技術刷新に積極的に取り組んでおります。これらの技術を用いた開発に興味がある方は、ぜひ弊社の採用ページをご覧ください。
スペースを簡単に貸し借りできるサービス「スペースマーケット」のエンジニアによる公式ブログです。 弊社採用技術スタックはこちら -> whatweuse.dev/company/spacemarket
Discussion