Versionless APIという世界
とりあえずAPI開発に関わる人たちはWunderGraphのManifestoを読んで欲しい。
とても良いことを言っていて、僕はこれを見て多用なクライアント、多用なマイクロサービスが生まれ、サービスを開発するために複数のAPIを実行しなければいけない時代における次世代のAPI管理のプラクティスだと感銘を受けました。もし、同じような印象を受けたならよりHowに寄った話のUse Casesを読むこともおすすめします。
こちらはちょっと「んん・・・?」みたいに思うことはありますが、全体としてはとても良いことを言われてます。中でもVersionless APIについては素直にこれが欲しかったと叫びたくなるものです。
バージョンレス API は、API をバージョン管理しないという意味ではありません。 これは、クライアントを壊すことなく API を進化させ続けることができるということです。
しかし、最も重要なことは 、この機能により、 バージョン管理について考えなければならないという精神的負担がまったくなくなったことです。
過去 2 か月以内に API Gateway と通信したクライアントなど、サポートするクライアントを構成し、を 実行してから関数を実装します。wunderctl generate migration
移行テストスイートがグリーンになると、 クライアントを中断することなく、更新された API をデプロイできます。
API開発で後方互換性をなくなる変更を入れるときにネックになるのはクライアントの存在です。
この記事を読んでいる皆さまも同じように問題があることがわかっているから変更したい。が、クライアントに変更が波及し、変更するためのコストが大きく変更する決断が行えないという経験はありませんか?僕はよくあります。
また、モバイルクライアントでは古いアプリケーションが長期間に渡って動作するため、モバイルサポートをしているならどうしても変更に長期間かかります。
昨今の開発事情ではアジリティを最大化するために変更容易性を高めていくことが重要になってきています。
それなのに、サービスは分割され、多くのクライアントと合意してAPIの変更を行わなければいけないというアジリティを低下させる要因となってしまいます。
それがVersionless APIの世界ならなくなるなんて素晴らしい!!!!と、言いたいところですが、そんな上手い話は転がってる訳がないのですよ。
2022/12現時点でWunderGraphのCloud版はEarly Accessなので検証できず、GitHubにあるVersionless APIを実現する方法は実装されてないように見えます。(僕の調査が甘いだけでもしあったらごめんなさい。見つけたら検証したいのでやり方教えてください)
という訳でないなら仕方ないので自分で作るしかありません。いきなり作って失敗するのも嫌なので、まずは何が必要で、どういったトレードオフが発生するのかを考えていきます。
全体の構成
ユーザーのリクエストを軸に考えると下記のようなフローになります
- ユーザーがリクエストを行う
-
Router
がクエリに対応するSchema
を判別しルーティングを行う- 古いスキーマの
Schema A
に対応するリクエストならSchema A
のResolver
を呼び出す - 新しいスキーマの
Schema B
に対応するリクエストならSchema B
のResolver
を呼び出す
- 古いスキーマの
- 古いスキーマの
Schema A
の場合、Migration
を呼び出しSchema B
のリクエストに変換する -
Schema B
で解決を行い、Migration
に結果を返し、Schema A
のレスポンスに変換する
ざっくりとした形にはなりますが、こういった形であれば後方互換性を維持したVersionless APIを作ることができそうです。
それぞれの要素について詳細を見ていきます。
Router
Router
の役割はリクエストからスキーマを判別し、どこにルーティングするのか決定することになります。
では、このスキーマの判別というのはどのように行えば良いのでしょうか。
- 事前にリクエストとスキーマの紐付けを行う
- WunderGraphはおそらくこの方式。事前にSchemaとOperationを定義するのでできる
- リクエストにスキーマを特定する情報を付与する
- 例えばリクエストに対応するスキーマのハッシュをヘッダーに埋め込むなど
- リクエストを最新のスキーマから順にパースして成功するか判定する
それぞれトレードオフがあり、1に近づけば実行時に判定を高速に行えますが事前に行うためのフローを構築する必要があります。逆に3に近づけば事前準備が不要になる代わりに実行時にレイテンシが悪化するケースが起こりえます。
そういう意味では2がバランスよく、クライアント開発でスキーマバージョンは取得できるのでビルド時やデプロイ時にそのスキーマバージョンを埋め込む機能があれば実現できそうです。
Migration
Migration
は2種類あり、リクエストマイグレーションとレスポンスマイグレーションが考えられます。
function requestMigration(request: Request<SchamaA>): Request<SchemaB> {
// ...
}
function responseMigration(response: Response<SchemaB>): Response<SchemaA> {
// ...
}
ざっくりとしたコードにはなりますが、このような2つの関数の形でしょうか。
こう考えると何でもはマイグレーションはできないように見えてきますね。後方互換がなくマイグレーションできないケースについて考えてみましょう。
request
スキーマ操作 | マイグレーション可 | 備考 |
---|---|---|
追加 | × | Aになく、Bに必須要素が追加された場合は情報がないためマイグレーションできない |
更新 | △ |
fieldA: TimeStamp からfieldA: DateTime など元情報を使える変更はマイグレーション可能だが、fieldA: DateTime がfieldA: Email など関係ない変更はマイグレーションできない |
削除 | ○ | 常にマイグレーション可能 |
response
スキーマ操作 | マイグレーション可 | 備考 |
---|---|---|
追加 | ○ | 常にマイグレーション可能 |
更新 | △ |
fieldA: TimeStamp からfieldA: DateTime など元情報を使える変更はマイグレーション可能だが、fieldA: DateTime がfieldA: Email など関係ない変更はマイグレーションできない |
削除 | △ | Aになく、Bに必須要素が追加された場合は情報がないためマイグレーションできない。ただし分割などで別のスキーマに問い合わせで解決できる場合はマイグレーション可能 |
つまりマイグレーション元に情報がないケースは行えない形になります。
更新のケースはどちらもまずないとして、リクエストの追加は必須の入力値を増やすケース、レスポンスの削除は不要になった、フィールドに問題があり削除が必要などケースとしてはないとは言えません。(どちらも頻繁にあるケースではないですが)
大規模なスキーマの刷新はこの複合でできないスキーマ操作がなければ実現は可能そうです。
古いResolverを残す方法について
単純なマイグレーションではマイグレーションできないケースが存在するため、どんな状態でも後方互換性を維持する方法についても考えてみます。
単純に思いつくのは古いResolver
実装を残す方法でしょうか。これなら確実に古いスキーマに対するリクエストを処理することができます。
pros
- 完全な互換性を実現できる
- マイグレーションの仕組みが不要になる
cons -
Resolver
の実装が残り続けるため保守するコード量が増加する- もし、バックエンドのDBなどを変更するさいに古い
Resolver
の実装の変更が必要など
- もし、バックエンドのDBなどを変更するさいに古い
ここは完全にトレードオフでMigration
する方法はこの裏返しになります。つまり焦点は古い実装をどれだけ保守するかですね。
感覚的にはクライアントの変更が遅いケースでは古いResolver
が大量に残るため変更容易性が低下しやすそうです。逆に変更が早いならアグレッシブに古いResolver
を削除できるので気にならないかもしれません。
もし僕が選ぶならクライアントの変更の速さはAPIの提供側のコントロール可能なものではないので、変更できないケースがあるconsを飲み込んでMigration
をする方法を選びそうです。もし、このAPIがBFFのようなものでコントロール可能なら古いResolver
を残すかも。
その他の必要になりそうな要素
まず、トレースは必ず必要になります。
特に古いSchema
とMigration`(もしくは
Resolver`)を消す判断を行うためになければいけません。
次にトレースと繋いだスキーマの後方互換チェックを行うCIはあった方が安全そうです。もし過去Nヶ月以内にあったクライアントからのリクエストが動作しなくなるスキーマの変更は行えないようしないといけないです。
ここで一つ注意なのは後方互換のない変更を禁止する訳ではなく、クライアントが動作しなくなる変更を禁止しなければならないところですね。クライアント影響がないなら好きなだけ変更して良いです。
そういう意味ではトレースと繋ぐ必要はなくConsumer-Driven Contracts testingがあれば十分かもしれません。とか思ったけど、最新のクライアントだけをサポートすれば良い訳ではないのでやはりトレースと繋ぐ方が良さそうですね。
総括
こう考えていくとVersionless APIはやれそうな雰囲気はありますよね。
WunderGraphはGraphQLベースで僕も用語としてGraphQLに引きずられていますが、RESTful APIでも適用できそうな雰囲気があります。
おそらく焦点としてはどのようにしてMigration
を実装するのか、それの安全性をどう担保するのかといったあたりでしょうか。本当はWunderGraphでそれを検証して試したかったのに実装を見つけきれなかったせいで・・・。
道としてありそうなので時間を見つけて実装に落としてみたいですね。
おまけ
WunderGraphで遊んでみた感じを軽く書くと、思想や実現したい体験はよくわかるけどまだ早いって感じです。
ドキュメントを見てSupport!と言いつつ中を見たら対応中だったり、計画で止まっていたことから察しはしてました。
でも
。゚(゚இωஇ゚)゚。 < 罠でもいいんだっ!!
と触らずにはいれなかった魅力がWunderGraphには詰まっていますね。もし、今時点で利用をするならWunderGraphにコミットして一緒にAPIを育てていくぐらいの気持ちで進めると幸せになれるかもしれません。
Discussion
余談だけど、これってAPIだけではなくKinesis/Kafkaなどで行われるStreamingなんかでも大事な考え方になりそう。
Streamingの場合はProducerがイベントを流して、Consumerがそれを受け取って処理するだけでなのでVersionlessにする仕組みを入れる方法はない。なのでそこの関係性から変える必要があり、例えばGraphQL SubscriptionのようなConsumerが何を必要としているのか、ConsumerからProducerに伝えれる状態にできるとVersionless Streamingのような世界観が描けるかもなぁと妄想してます。
これらの event streaming platform では、schema evolution という呼ばれ方で、schema registory をつかって解決するアプローチが多そうですね。
あと、データ自体のシリアライゼーションフォーマットが、その機能を持っていることもあり、それを利用するのも多そうです。
Migration の項で考察されている、Migration 用の層を挟むアプローチは、schema registory を利用するアプローチに近いものがありそうですね。
情報ありがとうございます!
1つ僕が不勉強でよくわかっていないのですが、Schema Registry/Schema EvolutionはVersionless APIのように後方互換性のない変更を吸収するのではなく、ガードとして後方互換性ない変更を入れないための取り組みという認識であっていますか?
それともSchema Registryでそういった後方互換性のない変更を吸収できる仕組みがあったりするのでしょうか?
いえ、基本的には互換性のない変更は対応できないはずです。
たとえば、ConfluentPlatform の schema registory では、互換性レベルに応じて、Schema field の変更などのできることが変わるようですが、「先に変更が必要なロール」ができる形のようです。(Consumer から変更が必要というような。)
API においても、Schema A を構築するための情報が Schema B にない場合は、同じような形になると思いますが、例えば「互換性を崩さないためにそのような場合にはデフォルト値で埋める」みたいなことが、schema registory などでできるかは不明です。
ありがとうございます。理解が追いつきました。
Versionless API的なアプローチの場合とSchema Evolution的なアプローチの場合でクライアントに影響を与えないケースに差が出るのか洗ってみたいですね。Migrationレイヤーが入る分Versionless API的アプローチの方が懐が広そうですが、その広さは本当に必要なのかみたいなのはありそうだなと思い始めてきました。