公開API設計と互換性
このスキルを使用するタイミング
以下の場合にこのスキルを使用します:
- NuGetパッケージやライブラリ向けの公開APIを設計する場合
- 既存の公開APIに変更を加える場合
- 分散システムにおけるワイヤフォーマットの変更を計画する場合
- バージョニング戦略を実装する場合
- 破壊的変更を含むプルリクエストをレビューする場合
3種類の互換性
| 種類 | 定義 | スコープ |
|---|---|---|
| API/ソース | 新しいバージョンに対してコードがコンパイルできること | 公開メソッドシグネチャ、型 |
| バイナリ | コンパイル済みコードが新しいバージョンで動作すること | アセンブリレイアウト、メソッドトークン |
| ワイヤ | シリアル化されたデータが他のバージョンでも読み取り可能であること | ネットワークプロトコル、永続化フォーマット |
これらのいずれかを破ると、ユーザーのアップグレードに摩擦が生じます。
拡張のみの設計
安定したAPIの基盤:決して削除や変更は行わず、拡張のみを行う。
3つの柱
- 以前の機能は不変 - 一度リリースされた動作とシグネチャはロックされる
- 新しい機能は新しいコンストラクトを通じて - オーバーロード、新しい型、オプトイン機能を追加する
- 削除は非推奨期間の後でのみ - リリース単位ではなく、年単位
利点
- 古いコードは新しいバージョンでも引き続き動作する
- 新しいパスと古いパスが共存できる
- アップグレードはデフォルトで非破壊的
- ユーザーは自分のペースでアップグレードできる
リソース:
API変更ガイドライン
安全な変更(任意のリリース)
// デフォルトパラメータを持つ新しいオーバーロードを追加
public void Process(Order order, CancellationToken ct = default);
// 既存のメソッドに新しいオプションパラメータを追加
public void Send(Message msg, Priority priority = Priority.Normal);
// 新しい型、インターフェース、列挙型を追加
public interface IOrderValidator { }
public enum OrderStatus { Pending, Complete, Cancelled }
// 既存の型に新しいメンバーを追加
public class Order
{
public DateTimeOffset? ShippedAt { get; init; } // NEW
}
安全でない変更(絶対に行わないか、メジャーバージョンでのみ)
// 公開メンバーの削除またはリネーム
public void ProcessOrder(Order order); // 以前: Process()
// パラメータ型または順序の変更
public void Process(int orderId); // 以前: Process(Order order)
// 戻り値の型の変更
public Order? GetOrder(string id); // 以前: public Order GetOrder()
// アクセス修飾子の変更
internal class OrderProcessor { } // 以前: public
// デフォルト値なしの必須パラメータ追加
public void Process(Order order, ILogger logger); // 呼び出し元が壊れる!
非推奨化パターン
// ステップ1: バージョン付きでObsoleteとしてマーク(任意のリリース)
[Obsolete("v1.5.0から廃止されました。代わりにProcessAsyncを使用してください。")]
public void Process(Order order) { }
// ステップ2: 新しい推奨APIを追加(同じリリース)
public Task ProcessAsync(Order order, CancellationToken ct = default);
// ステップ3: 次のメジャーバージョンで削除(v2.0以降)
// ユーザーが移行する時間を確保した後にのみ
API承認テスト
自動化されたAPI表面テストで偶発的な破壊的変更を防ぎます。
ApiApprover + Verify の使用
dotnet add package PublicApiGenerator
dotnet add package Verify.Xunit
[Fact]
public Task ApprovePublicApi()
{
var api = typeof(MyLibrary.PublicClass).Assembly.GeneratePublicApi();
return Verify(api);
}
ApprovePublicApi.verified.txt が作成されます:
namespace MyLibrary
{
public class OrderProcessor
{
public OrderProcessor() { }
public void Process(Order order) { }
public Task ProcessAsync(Order order, CancellationToken ct = default) { }
}
}
APIの変更はすべてテストに失敗します - レビュー担当者は変更を明示的に承認する必要があります。
PRレビュープロセス
- PRには
*.verified.txtファイルへの変更が含まれる - レビュー担当者はdiffで正確なAPI表面の変更を確認できる
- 破壊的変更は即座に可視化される
- 承認には意識的な決定が必要
ワイヤ互換性
分散システムの場合、シリアル化されたデータはバージョン間で読み取り可能である必要があります。
要件
| 方向 | 要件 |
|---|---|
| 後方互換 | 古い書き込み → 新しい読み取り(現在のバージョンが古いデータを読める) |
| 前方互換 | 新しい書き込み → 古い読み取り(古いバージョンが新しいデータを読める) |
ゼロダウンタイムのローリングアップグレードには両方が必要です。
ワイヤフォーマットを安全に進化させる
フェーズ1: 読み取り側のサポートを追加(オプトイン)
// 新しいメッセージタイプ - 読み取り側が先にデプロイされる
public sealed record HeartbeatV2(
Address From,
long SequenceNr,
long CreationTimeMs); // 新しいフィールド
// デシリアライザは新旧両方を処理する
public object Deserialize(byte[] data, string manifest) => manifest switch
{
"Heartbeat" => DeserializeHeartbeatV1(data), // 古いフォーマット
"HeartbeatV2" => DeserializeHeartbeatV2(data), // 新しいフォーマット
_ => throw new NotSupportedException()
};
フェーズ2: 書き込み側を有効化(オプトアウト、次のマイナーバージョン)
// 新しいフォーマットを有効にする設定(初期はデフォルトでオフ)
akka.cluster.use-heartbeat-v2 = on
フェーズ3: デフォルトにする(将来のバージョン)
インストールベースが読み取り側コードを取り込んだ後。
スキーマベースのシリアル化
リフレクションベースよりもスキーマベースのフォーマットを優先:
| フォーマット | 種類 | ワイヤ互換性 |
|---|---|---|
| Protocol Buffers | スキーマベース | 優れている - 明示的なフィールド番号 |
| MessagePack | スキーマベース | 良い - コントラクトあり |
| System.Text.Json | スキーマベース(ソース生成あり) | 良い - 明示的なプロパティ |
| Newtonsoft.Json | リフレクションベース | 悪い - ペイロードに型名 |
| BinaryFormatter | リフレクションベース | 非常に悪い - 絶対に使用しない |
詳細は dotnet/serialization スキルを参照。
カプセル化パターン
内部API
非公開APIを明示的にマーク:
// ドキュメント用の属性
[InternalApi]
public class ActorSystemImpl { }
// 名前空間の慣例
namespace MyLibrary.Internal
{
public class InternalHelper { } // 拡張性のために公開、ユーザー向けではない
}
明確に文書化:
.Internal名前空間の型、または[InternalApi]でマークされた型は、予告なく任意のリリース間で変更される可能性があります。
クラスのシール
// 行う: 継承を意図していないクラスはシールする
public sealed class OrderProcessor { }
// 行わない: うっかりシールを忘れる
public class OrderProcessor { } // ユーザーが継承する可能性があり、変更を妨げる
インターフェース分離
// 行う: 小さく焦点を絞ったインターフェース
public interface IOrderReader
{
Order? GetById(OrderId id);
}
public interface IOrderWriter
{
Task SaveAsync(Order order);
}
// 行わない: モノリシックなインターフェース(メソッドを追加すると破壊的になる)
public interface IOrderRepository
{
Order? GetById(OrderId id);
Task SaveAsync(Order order);
// 新しいメソッドを追加すると、すべての実装が壊れる!
}
バージョニング戦略
セマンティックバージョニング(実践的)
| バージョン | 許可される変更 |
|---|---|
| パッチ (1.0.x) | バグ修正、セキュリティパッチ |
| マイナー (1.x.0) | 新機能、非推奨化、廃止属性の削除 |
| メジャー (x.0.0) | 破壊的変更、古いAPIの削除 |
主要原則
- 驚きの破壊はなし - メジャーバージョンでも、発表と計画を行うべき
- いつでも拡張可能 - 新しいAPIは任意のリリースで出荷できる
- 削除前に非推奨化 - 少なくとも1つのマイナーバージョンで
[Obsolete]を使用 - タイムラインを伝達 - ユーザーはアップグレードを計画する必要がある
チェスタートンのフェンス
何かを削除または変更する前に、なぜそれが存在するのかを理解せよ。
すべての公開APIは誰かに使用されていると想定する。変更したい場合:
- GitHubで提案を周知する
- 移行パスを文書化する
- 非推奨期間を設ける
- 計画されたリリースで出荷する
プルリクエストチェックリスト
公開APIに触れるPRをレビューする際:
- 公開メンバーが削除されていない(代わりに
[Obsolete]を使用) - シグネチャが変更されていない(代わりにオーバーロードを追加)
- 新しい必須パラメータがない(デフォルト値を使用)
- API承認テストが更新されている(
.verified.txtの変更がレビューされている) - ワイヤフォーマットの変更がオプトインである(読み取り側が先)
- 破壊的変更が文書化されている(リリースノート、移行ガイド)
アンチパターン
修正と偽装した破壊的変更
// ユーザーを壊す「バグ修正」
public async Task<Order> GetOrderAsync(OrderId id) // 以前は同期!
{
// 非同期に「修正」されたが、すべての呼び出し元を壊す
}
// 正しい: 新しいメソッドを追加し、古いメソッドを非推奨にする
[Obsolete("代わりにGetOrderAsyncを使用してください")]
public Order GetOrder(OrderId id) => GetOrderAsync(id).Result;
public async Task<Order> GetOrderAsync(OrderId id) { }
暗黙的な動作変更
// デフォルト値の変更は、古い動作に依存していたユーザーを壊す
public void Configure(bool enableCaching = true) // 以前: false!
// 正しい: 新しい名前の新しいパラメータ
public void Configure(
bool enableCaching = false, // 元のデフォルトを維持
bool enableNewCaching = true) // 新しい動作はオプトイン
ポリモーフィックなシリアル化
// 避ける: ワイヤフォーマットに型名を含める
{ "$type": "MyApp.Order, MyApp", "Id": 123 }
// Orderクラスのリネーム = ワイヤの破壊!
// 推奨: 明示的な識別子
{ "type": "order", "id": 123 }