GraphQLのクライアントを破壊しない安全なスキーマ変更を素早く行うためのアプローチ
GraphQLではスキーマがあるのでサーバーとクライアントで齟齬を出さずに安全に通信を行うことができます。
schema
type Queury {
hello: String!
}
query
query HelloWorld {
hello
}
このスキーマを変更するのにも定番のやり方があり、@deprecated
を使うことで安全なスキーマ変更を行うことができます。
schema
type Queury {
hello: String! @deprecated(reason: "Use `hi`.")
hi: String!
}
上記の例ではhello
を利用したリクエストがなくなったタイミングでスキーマからhello
を削除することができるようになります。
本当に削除していいかについてはApollo StudioのSchema checksなどのSchema Registryがそういった機能を持っているため、そういったツールやサービスを利用するとより安全にスキーマの変更を行うことができます。
しかし、この方法では一つ課題があり、リクエストがなくなるまで古いスキーマを維持する必要があります。
Webフロントエンドなどでは常に最新のJavaScriptを配布できるため、この古いスキーマを維持する期間は短い時間で済むため問題になりにくいです。
対してiOSやAndroidといったモバイルアプリケーションでは配布にかかる時間や、古いアプリケーションからのリクエストなどで長期にわたって古いスキーマの維持を求められます。(特に強制アップデートがなければ年単位で待つことがあります)
古いスキーマを維持しなければならないということはリゾルバを維持する必要があるということであり、リゾルバの先のデータベースやAPIも維持しなければならないということです。
このように連鎖的に古いものを維持するが求められるため、サーバーサイドのコードが肥大化しやすく、技術的負債が長期に残ってしまい、素早く負債を返却して小さな状態を保ちにくいということになってしまいます。
そこでより素早くアグレッシブにスキーマを変更するためにGraphQL Operation Driven Approchというものを考えてみました。
GraphQL Operation Driven Approch
※ 最低限の実装でまだ細かいところは作りきっていません
GODAはシンプルな考えで以前に書いたVersionless APIの考えを元に作成しています。
簡単にまとめると
- クライアントからのオペレーションを事前にサーバーに登録する
- サーバーでクライアントからのリクエストを解析し、登録されたオペレーションであればリクエストとレスポンスをマイグレーションする
というだけの仕組みになります。
let operation = registry.resolve(operation_id.as_str()).await;
if let Ok(operation) = operation {
trace!(
"action: request and response are hooked now that the operation ID has been found: {}",
operation_id
);
let request = operation.hook_request(request).await;
let response = schema.execute(request).await;
operation.hook_response(response).await
} else {
trace!("action: operation ID not found: {}", operation_id);
schema.execute(request).await
}
上記のコードはサンプルのためベタ書きですが、実際にはHTTP ServerライブラリのMiddlewareやGraphQL ServerライブラリのExtensionなどで実施する形になります。
古いスキーマのリゾルバの維持をすることで依存が連鎖してしまうなら、より前段の部分で吸収するレイヤーを作ればいいという考え方ですね。
実装はRustで行っていますが他言語のGraphQLライブラリでも取り入れやすい考え方になると思います。
このコアとなる部分は簡単なものになりますが、実際に安全に行うにはいくつか考えないといけないことがあります。
オペレーションを登録するタイミング
全体の運用フローは下記のようになります(コード駆動の場合)
- サーバーで新しいスキーマに対応したコードを変更
- 過去に登録されたオペレーションを元にテストを実行
- サーバーでテストが落ちるようならリクエスト/レスポンスのマイグレーションを実装
- サーバーで全てのテストが通ったらリリース&スキーマレジストリに登録
- スキーマレジストリからスキーマを取得してクライアントを開発
- クライアントができたら新しいオペレーションをサーバー側のリポジトリに登録
- クライアントでmainブランチにマージしたタイミングでサーバーのリポジトリに自動PRするなど
- クライアントをリリース
オペレーションを登録するオペレーションレジストリのようサービスはないため、クライアント -> サーバーで何時、どうやってオペレーションを伝えるかが肝になります。(こういうのもあるけど情報が少なすぎて使えるかは知らない)
安全なマイグレーションの実装
async fn hook_request(&self, mut request: async_graphql::Request) -> async_graphql::Request {
request.query = r#"
query HelloWorld {
hi
}
"#
.to_string();
request
}
async fn hook_response(
&self,
mut response: async_graphql::Response,
) -> async_graphql::Response {
let json = response.data.into_json().unwrap();
response.data = async_graphql::Value::from_json(json!({ "hello": json
.as_object()
.unwrap()
.get("hi")
.unwrap() }))
.expect("unreachable");
response
}
2022/12時点での実装ではシンプルにGraphQLリクエスト、レスポンスをマイグレーションしています。
これでも動作はしますがリクエスト、レスポンスという緩い型で行なっているため、間違ったマイグレーションが行われる可能性があります。
そこで、Queryに合わせた型を用意することでA to Bというような実装を強制する方がより安全になります。(未実装)
安全なマイグレーションのテスト実装
async fn test_current_schema() -> anyhow::Result<()> {
let schema = Schema::build(
Query::default(),
EmptyMutation::default(),
EmptySubscription::default(),
)
.finish();
let operation = OperationA::default();
let request = async_graphql::Request::new(operation.query());
let request = operation.hook_request(request).await;
let response = schema.execute(request).await;
let response = operation.hook_response(response).await;
let json = serde_json::to_string(&response)?;
let expected = json!({
"data": {
"hello": "world"
}
});
let actual = serde_json::from_str::<serde_json::Value>(&json)?;
assert_json_eq!(expected, actual);
Ok(())
}
古いオペレーションの互換性を維持するために常に最新のスキーマに対してテストを実行するべきです。
このケースではクエリの変換だけのため、このテストで十分ですが、もしvariables
を使う場合はProperty Based Testを使うか、パターンを組んでテストを書くと良いです。
先の安全なマイグレーションの実装と合わせることで、十分な強固さを出すことができます。(もし、他にこういうテストもあると良いというものがあれば教えてください)
コードやテストの自動生成
クライアントのオペレーションからコードやテストを起こすため、このあたりは自動生成できるとより安全で楽に行うことができます。(未実装)
知性溢れる人間のやることじゃない。
GORAでも互換性を保てないケース
GORAでスキーマの後方互換性を向上することができますが、それでも互換性を保てないケースがあります。
- 新しいスキーマでInputに必須フィールドを追加する
- 新しいスキーマでFieldを削除する
つまり、代替となる情報のないような変更はGORAでも救うことはできません。(変更も同様に代替となる情報がなくなるような変更はできません)
こういったケースではこれまで通り@deprecated
を使ってスキーマの互換性を保ってください。
GORAで得られる副産物
GORAはGraphQLのスキーマの後方互換性に着目した考えですが、スキーマ関係以外にもメリットがあります。
GraphQLはその性質上、サービスを停止させるような攻撃に弱いことが言われ続けています。
この対策として
- 深さ制限
- 複雑さ制限
- コスト制限
- レートリミット
などを組み合わせてサービスを停止させる攻撃を防ぐのが一般的なアプローチです。
しかし、この方式では完全な対策にはなっておらず、閾値によっては攻撃が通ってしまったり、適切な閾値を設定しても状況の変化に合わせて閾値を調整するなど一定のコストが必要になってしまいます。
そもそも、GraphQLを使う際に本番環境で自由なクエリをしたいケースがあるのでしょうか?
GitHubのようにAPIとして公開する場合は必要ですが、一般的なWebサービスでは開発環境で自由度が欲しいが本番環境では自由度が要らないというのが大部分です。
そこで、GORAでは事前にオペレーションを登録することでクエリを制限し、サービス停止攻撃を完全に防ぐことができます。
let operation = registry.resolve(operation_id.as_str()).await;
if let Ok(operation) = operation {
let request = operation.hook_request(request).await;
let response = schema.execute(request).await;
operation.hook_response(response).await
} else {
// Error Response
}
ユースケース的には本番、ステージ環境ではエラーにし、テスト、開発環境では素通りさせるみたいな形にすると良さそうです。
ただし、この対応をすれば各種制限は不要になるという訳ではありません。
本番環境に実装として制限を入れる必要はありませんが、クライアントが重すぎるクエリを発行しないようにCIなどで計算したり、開発環境では制限を有効にして、危険なクエリを発行できないようにするとより安全にGraphQLサーバーの運用をすることができます。
おわりに
この考え方はWunderGraph社の出している考えを元にして生まれました。
Manifesto、Use Casesは本当に良いことが書いているので皆さんぜひ読んでください。
Discussion