GraphQL 界の Babel こと Envelop を使ってスキーマの破壊的変更をごまかす
この記事は LayerX のエンジニアブログがたくさん出る #ベッテク月間 の8記事目になります。こちらのカレンダーに、これまでの記事と今後出る予定がまとまっています。
LayerX のバクラク事業部には GraphQL Gateway というバクラク全プロダクトから参照される GraphQL スキーマが存在します[1]。今回の記事は、その GraphQL Gateway のスキーマをより良い状態にしていくためにぶつかった課題を強引に突破したときの話です。
モチベーション
GraphQL スキーマの破壊的変更によって GraphQL Document がスキーマに適合しなくなる場合、そのリクエストはエラーになります。例えば以下のようなケースが考えられます:
-
使わなくなったフィールドを削除したい
- 削除されたフィールド(存在しないフィールド)を含む Document を処理することはできない
-
Enum value の名前を変更したい
- 引数で変更される前の Enum value が渡されるとエラーになる
-
引数の型を変更したい
- e.g. 日付型に
String
を使っていたが、それをDate
カスタムスカラーに置き換えたい
type Query { # before # posts(fromDate: String!, toDate: String!): [Post]! # after: Date scalar を導入し、fromDate と toDate に適用 posts(fromDate: String!, toDate: String!): [Post]! } # すでにデプロイされているアプリケーションは `fromDate`, `toDate` を `String` のままでクエリを投げる query ListPosts(fromDate: String!, toDate: String!) { # 変更後のスキーマでは `fromDate`, `toDate` が `Date` を要求するので、`String` を渡すことができない posts(fromDate: $fromDate, toDate: $toDate) { # ... } }
- e.g. 日付型に
-
型の名前を変更したい
- 引数や Fragment など、型名が Document 中に露出していることがあるため
# `on Post` の部分で型名が使われるので、ここで `Post` の名前を変えることはできない fragment PostListPagePostItem on Post { # ... }
GraphQL スキーマの破壊的変更があると、すでに稼働中のアプリケーションで影響を受ける Document を投げている場合にエラーが発生することになります。なのでエラーを避けるために、基本的には破壊的変更が起きないようにスキーマを改修していくことになります。
例えば、前述のカスタムスカラー導入の場合、posts(fromDate: String, toDate: String, fromDate2: Date, toDate2: Date)
のように別名フィールドを追加することで回避する方法が考えられます。しかし、影響範囲が広い場合、そのすべてでこのような対応を取るのは大変です。また、〇〇2
のような名前のフィールドが多数生まれるのは認知負荷を高める要因になってしまいます。
あるいは別の query field を作ってそちらに移行するというのも考えられますが、いずれにせよ手間と命名の問題はついて回るでしょう。
このようなケースで Envelop を使うことで、破壊的変更をうまく乗り切れる可能性がある、というのがこの記事で紹介したい内容になります。
Envelop
Envelop は The Guild が開発・公開している JavaScript 向けの GraphQL プラグインシステムです。JavaScript で GraphQL server を実装する際には GraphQL Yoga, Apollo Server, NestJS などがよく使われますが、それらと Envelop および基盤となる graphql-js の関係は以下の図のようになります。
GraphQLサーバの構成要素を整理する で紹介したものを一部更新したもの
(これは筆者が独自に分類・命名したものです。より一般的な定義が存在していたら教えてください。)
GraphQL Yoga や Apollo Server は大きく2つの役割を持ちます。
- Transport 層(一般的には HTTP server)からのリクエストを GraphQL の処理につなぎこむための Adapter 層
- GraphQL の処理前後に介入することで機能拡張をする Middleware 層
そして、GraphQL Yoga の Middleware 層の内部実装として今回重要な役割を果たす Envelop が使われています。
Envelop は graphql-js の parse
, validate
, execute
等の機能をプラグインで拡張することができます。それによって graphql-js ではまだサポートされていない機能に対応するなど、GraphQL 自体の高度な拡張が可能になります。このことを指して、公式のリリースブログでは "Babel for GraphQL" であると表現されていました。
Introducing Envelop - The GraphQL Plugin System (The Guild) より
スキーマの破壊的変更を Envelop plugin で吸収する
ここからが記事の本題です。
前述したとおり、 Envelop を使うことで GraphQL の parse
を拡張することができます。この機能を使い、破壊的変更の影響を受けているリクエストが来た場合に、新しいスキーマに適合するように GraphQL Document を改変することで、リクエストがエラーとなるのを回避することができます。
たとえば、前述の Date カスタムスカラー導入の例では以下のように変換をすることで、新しい GraphQL スキーマに適合するようになります。
-query ListPosts(fromDate: String!, toDate: String!) {
+query ListPosts(fromDate: Date!, toDate: Date!) {
実装上はリクエストで渡された GraphQL Document をパースしたあとの AST に対して、上記の変換を適用することになります。GraphQL Yoga + Envelop による実装例は以下のようになります。
// 3rd party アプリケーションが存在しないなど、飛んでくる GraphQL Document の内容がすべて既知の場合は
// 影響を受ける GraphQLDocument の operationName と variable の一覧を持っておくことで
// 実装の大幅な簡略化が可能
const targetFieldsByOpName: Readonly<Record<string, ReadonlySet<string>>> = {
ListPosts: new Set(["fromDate", "toDate"]),
};
function useDateCompat(): Plugin {
return {
onParse() {
// Envelop が GraphQL Document のパース後に呼ばれる関数
// `result` はパース後の AST である `DocumentNode` オブジェクトである
return ({ result, replaceParseResult }) => {
let targetFields: ReadonlySet<string> | undefined;
// `"graphql".visit` は AST を深さ優先探索する関数
const newResult = visit(result, {
// GraphQL Operation の定義(`query ListPosts(fromDate: String!)` みたいなやつ)
OperationDefinition(node) {
// 変換対象の operationName であれば、変換が必要なフィールド名の集合を取り出す
targetFields = targetFieldsByOpName[node.name?.value ?? ""];
},
// 変数定義(`fromDate: String!`)みたいなやつ
VariableDefinition(node) {
if (targetFields == null) return node;
if (!targetFields.has(node.variable.name.value)) return node;
return {
...node,
// 変換が必要な変数定義であれば、変数の型をむりやり変換
type: convertTypeToDate(node.type),
};
},
});
if (targetFields) replaceParseResult(newResult);
};
},
};
}
/**
* 変数定義の型を Date を使ったものに変換したものを返す。
*/
function convertTypeToDate(typeNode: TypeNode): TypeNode {
switch (typeNode.kind) {
case Kind.NAMED_TYPE:
return { ...typeNode, name: { ...typeNode.name, value: "Date" } };
case Kind.LIST_TYPE:
return { ...typeNode, type: convertTypeToDate(typeNode.type) };
case Kind.NON_NULL_TYPE:
return {
...typeNode,
type: convertTypeToDate(typeNode.type) as Exclude<
TypeNode,
NonNullTypeNode
>,
};
default: {
typeNode satisfies never;
return typeNode;
}
}
}
このサンプルコードは「飛んでくる GraphQL Document の内容がすべて既知である」というのを前提に簡略化した実装になっています。この前提がおけない場合はもっと複雑な実装が必要になるでしょう。
また、GraphQL の AST については AST Explorer を活用することで、深い知識がなくてもなんとなく理解することができます。
同じように parse を拡張することで「消されたフィールドがリクエストに含まれる場合にそれを削除する」「改名前の Enum value を改名後の名前に変換する」なども対応できるでしょう。
また、parse 以外の処理を拡張することで変数やレスポンスを書き換えて、さらなる破壊的変更をも吸収するとができるかもしれません。
注意点
この破壊的変更のマイグレーションコードが長期間残ると、逆に認知負荷を高めたり、予期せぬバグの原因にもなり得ます。そのため、以下の点に注意することが重要です:
- あくまで最終手段として考え、基本は素直な段階移行などで対応するのがいいでしょう
- マイグレーションコードは長生きしないようにしましょう
- 消す期限を決めたり、期限を Lint で強制するなどで確実に対応するのがおすすめです
- 生存期間が管理不能なクライアント(例:ネイティブアプリ, 3rd party アプリ)がある場合は、より慎重に検討・対応する必要があるでしょう
一方で、破壊的変更を許容できないために GraphQL スキーマに負債を溜め込んだり最適なスキーマ探求を諦めてしまっている場合などでは、今回紹介した方法により破壊的変更を安全かつ効率的に吸収し、スムーズなマイグレーションを実現することができるかもしれません。
Discussion