🔙

後方互換性を考慮した開発をしよう

2024/09/20に公開

はじめに

ソフトウェア開発において、ユーザー体験を向上させるため新機能をリリースしたり、ユーザーの要望に応えて機能を改修したりするにあたり、バージョン更新は避けては通れない道です。
バージョン更新で特に気をつけなければならないのは、後方互換性の破壊、すなわち、古いバージョンでは使用できていたはずの機能が新しいバージョンでは使用できなくなることです。後方互換性の破壊により、例えば

  • プログラミング言語の特定の関数がバージョン更新によって挙動が変化し、プログラムが壊れた
  • 普段使用している SaaS の特定の機能を使用すると、データの不整合を引き起こすようになった
  • SNS が公開していたある API が廃止され、その機能を前提として組まれていたサードパーティアプリが全滅した

といった形でユーザーに深刻な被害を及ぼし、ユーザーからの信頼を棄損してしまう可能性があります。

本記事では、ソフトウェアの開発者として、後方互換性とは何かを解説し、後方互換性を意識しながらユーザー影響のない方法でソフトウェアやシステムの更新を行うにはどうすればよいかを具体例を交えながら説明します。

後方互換性とは

後方互換性とは、「旧バージョンのコンポーネントを前提として動作しているものが、コンポーネントを新バージョンに置き換えても挙動変更なく動作すること」を指します。上の図の例で言えば、コンポーネントA v1.2.0 を利用して動作しているコンポーネントBが、コンポーネントA を v1.3.0 に差し替えたとしても問題なく動作する場合、「コンポーネントA v1.3.0 は v1.2.0 に対して後方互換性を保っている」といいます。
コンポーネントB としてありうるもの全てに対して条件が成り立つことで、はじめて後方互換性があると言える点に注意しましょう。

後方互換性を考慮する必要のある状況

コンポーネントの種類としてデータ(スキーマ)・プログラムの関数・API・システムなど様々なものが存在しますが、バージョン更新時に後方互換性を考慮する必要があるのは共通して以下の状況です。

  • 更新対象のコンポーネントに依存している他のコンポーネントが存在する
  • 更新対象コンポーネントと依存コンポーネント 2つの更新タイミングが異なる

ミソなのは更新のタイミングが異なるという点です。コンポーネント同士の変更タイミングを全く同一にできれば、依存コンポーネントを新しい更新対象コンポーネント向けに一緒に書き換えることで機能を保てるので、(コンポーネント同士で変更箇所の合意が必要ではありますが)後方互換性を考慮せず開発することができます。
例えばソフトウェア内部のクラスの挙動を変更する場合、同時にそのクラスを参照している(=依存している)クラスにも改修を入れれば後方互換性を気にする必要はありません。

また、スマホ向けアプリのメンテナンス -> 強制アップデートも良い例です。新バージョンのサーバーへの更新中は旧バージョンのアプリからのアクセスを遮断し、サーバーの更新が完了したら旧バージョンのアプリを使用不可にして新バージョンのアプリに乗り換えさせることで、実質的にサーバーとアプリの更新タイミングをシンクロさせることができます。その結果サーバーの機能開発は、後方互換性を考慮することなく新バージョンのアプリ向け改修に注力することができます。

(※実際は、サーバーとアプリの更新タイミングを完全にシンクロさせられなかったり、DBなど他のコンポーネントが絡んできたりと一筋縄ではいかないことが多いです)

逆に、一般公開している API は、ユーザーが API を利用して開発したアプリに対して強制的に何らかのアップデートをさせるのは不可能です。そのため、後方互換性を保つ開発が求められます。

後方互換性とサポート範囲

後方互換性は「どれくらい過去まで後方互換性を保つのか?」、すなわち後方互換性のサポート範囲を明確にするという視点が重要です。

セマンティックバージョニング というバージョニング方法では、バージョン情報を「vX.Y.Z」というように表現し、X(メジャーバージョン)の数字が変化しない限りは後方互換性を保つというルールが設定されています。このルールを開発しているソフトウェアに適用することで、ユーザーはソフトウェアの更新が発表される際に変更なしにアップデートができるか否かを容易に判断できるようになります。
また、外部コンポーネントのバージョンに着目して、「外部コンポーネントのこのバージョンまでは動作を保証しますよ」とサポートマトリクスを提供するのも一つの手でしょう。

とはいえ、サポート範囲が広くなれば広くなるほど開発の複雑性は増し、機能改修が困難になります。そこで、先ほど説明したスマホ向けアプリの強制アップデートの例のように、後方互換性をあえて切るという選択肢もアリです。

サポート範囲(≒ユーザーの利便性)と開発コストはトレードオフです。どこまでサポート範囲を広げるかは、開発するソフトウェアそのものや開発体制、ユーザーの性質との相談になるかと思います。

後方互換性を保つ開発

ここからは、後方互換性を保ちながら開発するにはどうすれば良いかを説明していきます。

依存関係を明確化する

まず第一にやるべきは、改修箇所が絡むコンポーネントやバージョン同士の依存関係を明確化することです。
変更タイミングが全く同一のものは1つのコンポーネントとして解釈した上で、コンポーネント同士の依存関係をバージョンごとに洗い出しましょう。
今後の説明で共通する原則として、「別コンポーネントに依存されているコンポーネントは外形挙動の変更や削除ができない」点が挙げられます。逆に言えば、どのコンポーネントにも依存されていないものは変更・削除が可能です。そのため、依存関係の洗い出しは非常に重要です。

追加は後方互換性を壊さない

追加改修は後方互換性を壊しません。追加した時点では誰からも参照されていないからです。
たとえば、API サーバーのエンドポイントを追加しただけでは、その時点ではまだ誰もそのエンドポイントを呼び出さないので後方互換性は維持されます。
また、HTTP リクエストのレスポンス追加は、レスポンスを読む側で厳密にパラメータのスキーマの型チェックが行われないのであれば、問題なく追加が可能です(一般的には追加したばかりの未知のレスポンスパラメータは無視されるため、問題ありません)。

機能追加を行う場合は、誰からも依存されていないコンポーネントから追加していきます。例えば、
DBスキーマの追加 -> APIサーバーのエンドポイント作成・追加スキーマの参照 -> クライアントから新規エンドポイントを呼びだすように変更
といった具合です。

追加改修の注意点として、見た目は追加のみであっても実際は変更改修になっていたというケースがあります
簡単な例では関数のパラメータ追加です。これはパラメータを追加 = 関数のインターフェースが変更 されるため、追加のつもりでも変更改修を行なっていたことになります。
自身が行うとしている追加改修は暗黙的に何か他のものを変更していないか、注意深く確認しましょう。

削除は、依存関係があるうちは後方互換性を壊す

基本的に、削除改修は後方互換性を破壊するので削除はできません。依存されているコンポーネントの参照先がなくなるためです。
しかし、依存されているコンポーネントが全くないことが保証されれば、削除改修は後方互換性を壊さなくなります。例えば、サポート範囲外の外部コンポーネントからは参照されていたがサポート範囲内の外部コンポーネントからはどこからも参照されていない(= deprecated な)機能が該当します。

使用しなくなったものを依存関係に注意しながら消去していくことで、コードがすっきりしたり逼迫していたストレージ容量が解放されたりと、メリットが発生することもあります。必須の改修ではないですが、ある程度開発に余裕があるのであれば削除によって整理をするのも良いかと思います。

変更は、追加と削除の併せ技と解釈する

変更改修も後方互換性を破壊しますが、変更の仕方を工夫することで後方互換性を維持することができます。それは、変更内容を追加改修によって実装する方法です。
例えばある API エンドポイント /sample を変更改修する場合、/sample の処理を直接変更するのでなく、新仕様を実装した /sample2 を新規作成します。その後、/sample を参照していた外部コンポーネントのバージョン更新時に /sample2 を利用するよう変更することで、

  • /sample は古い外部コンポーネントが使い続ける
  • /sample2 は新しい外部コンポーネントが使用する

というように旧仕様と新仕様を共存させることができます。

あとは、/sample を参照していたすべての外部コンポーネントがサポート範囲外となった時点で /sample を削除すれば、後方互換性を壊さずに /sample2 への移行を完了させられます。

この方法の欠点としては、従来使用していた機能の名称を変更する必要があることです。うまく名称変更をしないと、新仕様の機能が実態がよくわからない機能となり、後々の開発で困るかもしれません。

ケーススタディ

ここからは、これまで説明してきた考え方をベースに、私が参画しているプロジェクトで実際にあった例を改造して、具体的なケースとして取り上げます。このケースにおいて、後方互換性を保つ開発をどのように行うか説明します。

システム構成

私が参画しているプロジェクトではスマートフォン向けアプリを開発しており、アプリ(クライアント) - APIサーバー - DBからなるクライアント/サーバーが構成されています。
クライアントにユーザーデータは保存しておらず、ユーザーデータを参照/変更する操作が発生した場合は都度 API サーバーへの HTTP リクエストを送信します。API サーバーはリクエストを受けて、適切に DB から情報を取得し加工した上でクライアントにレスポンスを返します。
これらの依存関係を示した図は以下のとおりです。

定常時はクライアント・API サーバー・DB スキーマのバージョンは全て一致しており、過去バージョンとの後方互換性は切っています。クライアントが v1.3.0 であれば API サーバーも DB スキーマも全て v1.3.0 で、それ以外からのバージョンのアクセスは発生しません。
ただし、バージョン更新時は別で、DB スキーマ(下図中①) -> APIサーバー(下図中②) -> クライアント(下図中③) の順でバージョンを更新する都合上、新バージョンDBスキーマを持つ DB⇔旧バージョンAPIサーバー・新バージョンAPIサーバー⇔旧バージョンクライアント の通信が発生し得ます。

また、私が参画しているプロジェクトで最も特徴的な点は、「よほどの場合を除きメンテインを許していない」点にあります。すなわち、バージョン更新の際にメンテインと強制アップデートを駆使して更新タイミングをシンクロさせる手は使用できません。
その結果、依存されている側のDBスキーマやAPIサーバーは1個前のバージョンとの後方互換性を維持しなければなりません。

システムで必要になった改修

さて、このようなシステムの中である API エンドポイントを改修する必要が出ました。仮に /sample/resourceA とします。

クライアントには sceneA と sceneB が存在しているのですが、両シーンは外形挙動が非常に似ていること、sceneA と sceneB が使用できる期間が重ならないことから、以下のような実装が行われており、挙動に問題は発生していませんでした。

  • どちらのシーンでも GET /sample/resourceA でデータを参照し、PUT /sample/resourceA でデータを加工
  • /sample/resourceA が参照する DB テーブルも同一(tableA)で、sceneA と sceneB で共通のデータを参照・加工

しかし、sceneB の仕様が変化し、sceneA とはデータを区別して操作しなければいけなくなりました。すなわち、sceneB で参照・加工する新たなテーブル tableB を用意し、sceneA と sceneB で異なるテーブルを操作する改修が必要になりました。

この改修を、v4.8.7 の次バージョン v4.9.0 に入れることが決定しました。どのように後方互換性を維持しながら改修をすれば良いでしょうか?

うまくいかない方針

単純に考えれば、以下のような方針で実行すれば、追加改修だけをしているため「追加は後方互換性を壊さない」の原則に則り問題ないように思えます。

  • v4.9.0 のDBスキーマに tableB を追加する
  • v4.9.0 の API サーバーに /sample/resourceB を追加し、tableB のデータを参照・加工する
  • v4.9.0 のクライアントで、sceneB の操作時に /sample/resourceB を介して操作するよう変更する

しかし、これではうまくいきません。クライアントの v4.9.0 は sceneA における操作 /sample/resourceA が sceneA からのみ呼ばれるのを期待しているの対し、v4.8.7 のクライアントでは sceneB で /sample/resourceA を呼び出しており、v4.9.0 視点で本来 sceneB によって操作されてほしくない sceneA のデータ tableA が操作されてしまうからです。
その結果、クライアント v4.9.0 へバージョン更新が行われた段階で、データの不整合を引き起こす危険性があります。

後方互換性を考慮すべきDBスキーマとAPIサーバーに追加操作しかしていないのに意図しない挙動が発生してしまったのは、「仕様変更によりDBスキーマの意味が変化した = 意味的にDBスキーマの後方互換性が破壊されたから」です。
v4.8.7 までは tableA が sceneA・sceneB 両方で使用するテーブルだったのに対し、v4.9.0 では sceneA でのみ使用するテーブルとなったため、v4.9.0 でDBスキーマの変更が発生し、後方互換性が破壊されたと解釈することができます。

うまくいく方針

例えば以下のような方針であれば、後方互換性を維持しながらの変更が可能です。

  • v4.9.0 の DB スキーマに tableA_tableB を追加する
  • v4.9.0 の API サーバーに、以下の処理を追加する
    • tableA_ を参照・加工するエンドポイント /sample/resourceA_ を追加する
    • tableB を参照・加工するエンドポイント /sample/resourceB を追加する
  • v4.9.0 のクライアントで、以下のように処理を変更する
    • sceneA では /sample/resourceA_ を呼び出す
    • sceneB では /sample/resourceB を呼び出す
  • (必須ではない)クライアントが v4.9.0 へのアップデートされた後のタイミングで、以下を順に実行する
    • API サーバーの /sample/resourceA を削除する
    • /sample/resourceA を削除したバージョンより後で、DBスキーマの tableA を削除する

「変更は追加と削除の併せ技と解釈する」の原則に則り、tableA の意味は変えず(sceneA と sceneB の両方から参照・変更される)、sceneA からのみ変更される tableA_ や操作エンドポイント /sample/resourceA_ を作成しようという方針です。
これであれば、DBスキーマやAPIサーバーを v4.9.0 に更新しても、v4.8.7 のクライアントは sceneA・sceneB のどちらでも tableA を参照し続け、v4.9.0 以降のクライアントは sceneA では tableA_、sceneB では tableB を参照するという形で後方互換性を保ちながら sceneA と sceneB が操作するデータを分けるすることができます。

v4.9.0 への更新が完了したあとであれば/sample/resourceA は誰からも依存されなくなるため、/sample/resourceAtableA を順次削除する操作が可能になります。

抜け道

余談ですが、v4.9.0 へのアップデート中にクライアントが sceneB を使用しないことがわかっている場合(eg. sceneB が月毎の特定の期間しか使用されず、バージョン更新タイミングがその期間と被らない)は、「うまくいかない方針」でもうまくいきます。問題になっていた sceneB から /sample/resourceA へのアクセスが存在しないためです。
本質的には「依存コンポーネントが存在しない状態であれば後方互換性を保つことができる」ため、開発するソフトウェアの性質を見極め、後方互換性を保ちつつもよりシンプルな方法を採用すると良いでしょう。

まとめ

本記事では、後方互換性を保つ開発とは何かを説明し、具体的なケースを交えて紹介しました。
後方互換性を保つにあたり大事な点は、以下の4点です。

  • コンポーネント同士の依存関係と変更タイミングを明確化する
  • 追加系の改修は基本的に後方互換性を壊さないが、本当にその改修が追加だけで済んでいるのかを検査する
  • 削除系の改修は、依存されているコンポーネントがなくならない限り後方互換性を壊すので、バージョン更新時の依存コンポーネントの状況を確認する
  • 変更系の改修は、追加と削除に分離して考える

後方互換性を適切に保ち、ユーザーに信頼される良いソフトウェア開発をしていきましょう!

Discussion