🪄

GraphQL 界の Babel こと Envelop を使ってスキーマの破壊的変更をごまかす

2024/07/10に公開

この記事は LayerX のエンジニアブログがたくさん出る #ベッテク月間 の8記事目になります。こちらのカレンダーに、これまでの記事と今後出る予定がまとまっています。

https://www.notion.so/layerx/253bee10186e4010b2ab37eff7252e09?v=00bf49a9c456450498e4d67dd5a76ef7

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) {
        # ...
      }
    }
    
  • 型の名前を変更したい

    • 引数や 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

EnvelopThe Guild が開発・公開している JavaScript 向けの GraphQL プラグインシステムです。JavaScript で GraphQL server を実装する際には GraphQL Yoga, Apollo Server, NestJS などがよく使われますが、それらと Envelop および基盤となる graphql-js の関係は以下の図のようになります。

GraphQLサーバの実装技術を Schema Definition, Resolver, Execution Engine, Middleware, Adapter, Transport の6層に分類した図。

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 スキーマに負債を溜め込んだり最適なスキーマ探求を諦めてしまっている場合などでは、今回紹介した方法により破壊的変更を安全かつ効率的に吸収し、スムーズなマイグレーションを実現することができるかもしれません。

脚注
  1. モダンな開発環境のBtoB SaaSアーキテクチャ特集 技術選定のポイントと今後の展望 - Findy Tools ↩︎

LayerX

Discussion