コンセプト、アイデア、戦略
コンセプト、アイデア、戦略フィールドバージョニングによるスキーマの進化

フィールドバージョニングによるスキーマの進化

アプリケーションのニーズが進化するにつれて、データを提供するGraphQL APIもスキーマに変更を加えながら進化していく必要があります。新しいタイプやフィールドを追加するような非破壊的な変更であれば、副作用を恐れることなく直接適用できます。しかし、変更が破壊的なものである場合は、アプリケーションにバグや予期しない動作を導入しないようにする必要があります。

破壊的な変更とは、タイプ、フィールド、またはディレクティブを削除したり、既存のフィールド(またはディレクティブ)のシグネチャを変更したりするものです。例えば以下のようなものが該当します:

  • フィールドのリネーム
  • 既存のフィールド引数の型の変更、または必須化
  • フィールドへの新しい必須引数の追加
  • フィールドのレスポンス型へのnon-nullableの追加

破壊的な変更に対処するために、主に2つの戦略があります:RESTとGraphQLがそれぞれ実装しているバージョニングと進化です。

REST APIは、エンドポイントURL(https://api.mycompany.com/v1https://api-v1.mycompany.com など)または何らかのヘッダー(Accept-version: v1 など)を通じて使用するAPIのバージョンを示します。バージョニングによって、破壊的な変更はAPIの新しいバージョンに追加され、クライアントは新しいバージョンのAPIを明示的に指定する必要があるため、変更を認識できます。

GraphQLはバージョニングの使用を否定しているわけではありませんが、進化の使用を推奨しています。GraphQL best practicesのページに記載されているとおりです:

While there's nothing that prevents a GraphQL service from being versioned just like any other REST API, GraphQL takes a strong opinion on avoiding versioning by providing the tools for the continuous evolution of a GraphQL schema.

進化はバージョニングとは異なる動作をします。バージョニングのように数ヶ月に一度行われるものではなく、必要であれば毎日でも行われる継続的なプロセスであり、迅速なイテレーションに適しています。このアプローチは、GraphQLサービスの開発をガイドするベストプラクティスの集合体であるPrincipled GraphQLによって、その5原則として示されています:

5. Use an Agile Approach to Schema Development: The schema should be built incrementally based on actual requirements and evolve smoothly over time

スキーマの進化

進化を通じて、破壊的な変更を伴うフィールドは以下のプロセスを経る必要があります:

  1. 異なる名前を使用してフィールドを再実装する。
  2. フィールドを非推奨(deprecated)にし、クライアントに代わりに新しいフィールドを使用するよう求める。
  3. フィールドが誰にも使用されなくなったら、スキーマから削除する。

例を見てみましょう。Account というタイプがあり、GraphQLのSDL(Schema Definition Language)を使って、名前と苗字を持つ人物としてアカウントをモデリングするスキーマを考えます:

type Account {
  id: Int
  name: String!
  surname: String!
}

このスキーマでは、namesurname の両方のフィールドが必須です(型 String の後に追加された ! 記号がそれを示しています)。これは、すべての人が名前と苗字の両方を持つことを前提としているためです。

やがて、組織もアカウントを開設できるようにしたとします。しかし、組織には苗字がないため、surname フィールドのシグネチャを変更して必須でなくする必要があります:

type Account {
  id: Int
  name: String!
  surname: String # これが変更されました
}

これは破壊的な変更です。なぜなら、アプリケーションは surname フィールドが null を返すことを想定しておらず、以下のJavaScriptコードを実行するときのように、この条件をチェックしていない可能性があるためです:

// account.surname が null のときにこれは失敗します
const upperCaseSurname = account.surname.toUpperCase();

破壊的な変更から生じる潜在的なバグは、スキーマを進化させることで回避できます:

  • surname フィールドのシグネチャは変更せず、代わりに非推奨としてマークし、置き換えるフィールドの名前を示す有用なメッセージを追加します
  • スキーマに新しいフィールド名 personSurname(または accountSurname)を導入します

Account タイプは次のようになります:

type Account {
  id: Int
  name: String!
  surname: String! @deprecated(reason: "Use `personSurname`")
  personSurname: String
}

最後に、クライアントからのクエリのログを収集することで、新しいフィールドへの切り替えが行われたかどうかを分析できます。surname フィールドが誰にも使用されなくなったことに気づいたら、スキーマから削除できます:

type Account {
  id: Int
  name: String!
  personSurname: String
}

進化に伴う問題点

上記の例は非常にシンプルですが、スキーマを進化させる際の潜在的な問題をすでにいくつか示しています:

問題説明
フィールド名が洗練されなくなるフィールドに最初に名前をつけるとき、surname のような最適な名前を見つけられる可能性があります。しかし、置き換える必要が生じたときは、最適名がすでに使われているため、それより劣る別の名前を作らなければなりません。上の例でのすべての候補には問題があります:

- personName はアカウントが人物のためのものであることを明示するため、後に苗字を持つ非人物(例えばエイリアン?)のアカウントを開設しなければならなくなった場合、一貫した名前を保つためにスキーマを再度進化させる必要があります
- accountName の「account」の部分は、タイプがすでに Account であるため完全に冗長です
- では他にどんな名前を使うのでしょうか?surname1surnameNew?あるいはさらに悪い、surnameV2

結果として、更新されたスキーマは理解しにくく、より冗長になります。
スキーマに非推奨フィールドが蓄積される可能性があるフィールドの非推奨化は一時的な状況として最も適切です。最終的には、蓄積される前にそれらのフィールドをスキーマから削除してクリーンアップしたいところです。

しかし、クエリを見直さず、非推奨フィールドから引き続き情報を取得するクライアントが存在する可能性があります。この場合、スキーマは徐々に同一の機能に対して複数の異なるフィールドが積み重なった「フィールドの墓場」のようなものになっていきます。

これらの問題を解決する方法を見ていきましょう。

フィールドのバージョニング

フィールドに version という引数を作成し、使用するフィールドのバージョンをそこで指定することができます。

このシナリオでは、非推奨フィールドの実装を引き続き保持しなければならないため、その点での改善はありません。しかし、その契約が隠蔽されます。新しいフィールドはその元の名前を保持できるようになります(surname から personSurname にリネームする必要がなくなります)。これにより、スキーマが過度に冗長になることを防ぎます。

このバージョニングの概念はRESTのものとは異なることに注意してください:

  • RESTは、バージョンがエンドポイントの一部であるため、クエリされたAPI全体が同じバージョンを持つオール・オア・ナッシングの状況を確立します
  • このアプローチでは、各フィールドが独立してバージョニングされます

したがって、異なるフィールドに対して異なるバージョンにアクセスできます:

query GetPosts {
  posts(version: "1.0.0") {
    id
    title(version: "2.1.1")
    url
    author {
      id
      name(version: "1.5.3")
    }
  }
}

さらに、semantic versioningに基づいて、パッケージの依存関係を宣言するためのComposerが使用するルールに従って、バージョン制約を使用してバージョンを選択できます。そこで、フィールド引数 versionversionConstraint にリネームし、クエリを更新します:

query GetPosts {
  posts(versionConstraint: "^1.0") {
    id
    title(versionConstraint: ">=2.1")
    url
    author {
      id
      name(versionConstraint: "~1.5.3")
    }
  }
}

この戦略を非推奨フィールド surname に適用すると、非推奨の実装をバージョン "1.0.0" として、新しい実装をバージョン "2.0.0" としてタグ付けし、同じクエリ内で両方にアクセスできます:

query GetSurname {
  account(id: 1) {
    oldVersion: surname(versionConstraint: "^1.0")
    newVersion: surname(versionConstraint: "^2.0")
  }
}

この機能はGato GraphQLで利用可能です:

バージョン制約を通じたフィールドのクエリ

ディレクティブのバージョニング

ディレクティブも引数を受け取るため、まったく同じ方法論を適用してディレクティブをバージョニングすることもできます!

例えば、このクエリを実行すると:

query {
  post(by: { id: 1 }) {
    oldVersion: title @strTitleCase(versionConstraint: "^0.1")
    newVersion: title @strTitleCase(versionConstraint: "^0.2")
  }
}

ディレクティブの各バージョンに対して異なるレスポンスを生成できます:

バージョニングされたディレクティブのクエリ