mcpField
D
dotnet-library-api-compat

dotnet-library-api-compat

github.com/wshaddix開発8672 回インストール35 週間前に更新
リポジトリ

dotnet-library-api-compat について

.NET ライブラリ開発者向けのスキルです。バイナリおよびソースの API 互換性、型フォワーダー、変更内容に応じた SemVer の更新方法を解説します。

主な機能

  1. 01バイナリ互換性の規則
  2. 02ソース互換性の注意点
  3. 03型フォワーダーの指針
  4. 04SemVer への影響整理

活用シーン

  1. 01公開 API 変更のレビュー
  2. 02アセンブリ間の型移動
  3. 03ライブラリのリリース版計画

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/wshaddix/dotnet-skills --skill dotnet-library-api-compat

インストール後、Claude Code で/dotnet-library-api-compatを入力して読み込まれたか確認します。

詳細

dotnet-library-api-compat

.NET ライブラリの作成者向けのバイナリ互換性とソース互換性のルールです。どの API 変更がバイナリレベル(アセンブリの読み込み、JIT による解決)でコンシューマーに影響するのか、どの変更がソースレベル(コンパイル)で影響するのか、コンシューマーに影響を与えずにアセンブリを再編成するために型フォワーダーを使う方法、そしてバージョンの決定が SemVer のメジャー/マイナー/パッチの増分にどう対応するかを扱います。

バージョンの前提条件: .NET 8.0 以降を基準とします。互換性ルールはすべての .NET バージョンに適用されますが、例は最新の SDK スタイルのプロジェクトを対象としています。

対象外: HTTP API のバージョン管理については、[skill:dotnet-api-versioning] を参照してください。NuGet パッケージのメタデータ、署名、公開のワークフローについては、[skill:dotnet-nuget-authoring] を参照してください。複数の TFM に対応するパッケージの仕組み(ポリフィル戦略、条件付きコンパイル)については、[skill:dotnet-multi-targeting] を参照してください。PublicApiAnalyzers と API サーフェスの検証ツールについては、[skill:dotnet-api-surface-validation] を参照してください。Roslyn アナライザーの構成については、[skill:dotnet-roslyn-analyzers] を参照してください。

相互参照:[skill:dotnet-api-versioning](HTTP API のバージョン管理)、[skill:dotnet-nuget-authoring](NuGet パッケージングと SemVer のルール)、[skill:dotnet-multi-targeting](複数の TFM に対応するパッケージングと ApiCompat ツール)。


バイナリ互換性

バイナリ互換性とは、既存のコンパイル済みアセンブリが再コンパイルなしで実行時に引き続き動作することです。バイナリ互換性を破る変更は、実行時に TypeLoadException、MissingMethodException、MissingFieldException、または TypeInitializationException を引き起こします。

互換性を維持する変更(バイナリ互換)
変更安全な理由
新しいパブリック型を追加する既存のコードはその型を参照しません
非シールドクラスに新しいパブリックメソッドを追加する既存の呼び出し箇所は元のオーバーロードに解決されます
パラメーター数が異なる新しいオーバーロードを追加する既存のバイナリは元のメソッドトークンにバインドされます
既存のメソッドにオプションのパラメーターを追加する古いシグネチャに対してコンパイルされた呼び出し元では、既定値が IL に埋め込まれています。オプションのパラメーターが指定されているかどうかにかかわらず、ランタイムは同じメソッドトークンを解決します
アクセス修飾子の可視性を広げる(protected から public)可視性が高くなっても、既存の参照は有効なままです
既定の実装を持つ、抽象ではないインターフェイスメンバーを追加する既存の実装者は既定の実装を継承するため、TypeLoadException は発生しません
クラスから sealed を削除する制約がなくなるだけで、既存のコードがそのクラスをサブクラス化していることはありません
新しい enum メンバーを追加する既存のバイナリで列挙型を切り替える場合、新しいメンバーは単に default に進みます
互換性を破る変更(バイナリ非互換)
変更実行時のエラー例
パブリック型を削除するTypeLoadExceptionpublic class Widget を削除する
パブリックメソッドを削除するMissingMethodExceptionWidget.Calculate() を削除する
メソッドの戻り値の型を変更するMissingMethodExceptionint Calculate() を long Calculate() に変更する
メソッドのパラメーター型を変更するMissingMethodExceptionvoid Process(int id) を void Process(long id) に変更する
フィールドの型を変更するMissingFieldExceptionpublic int Count を public long Count に変更する
構造体のフィールドの順序を変更するメモリレイアウトの変更相互運用と Unsafe.As<> を使うコンシューマーに影響します
パブリッククラスに抽象メンバーを追加するTypeLoadException既存のサブクラスに実装がありません
既定の実装なしでインターフェイスメンバーを追加するTypeLoadException既存の実装者にそのメンバーがありません
virtual メソッドを non-virtual に変更するオーバーライド側で MissingMethodException仮想ディスパッチを想定してコンパイルされたオーバーライド側でエラーが発生します
シールドされていなかったクラスをシールドするTypeLoadException既存のサブクラスを読み込めません
パブリック型の名前空間を変更するTypeLoadException型フォワーダーを追加した場合を除きます(下記参照)
メソッドから virtual を削除するMissingMethodExceptioncallvirt を使ってコンパイルされたコンシューマーが仮想スロットを見つけられません
既定のインターフェイスメンバー

C# 8 で追加された既定のインターフェイスメンバー(DIM)を使うと、既存の実装側を壊さずにインターフェイスへメンバーを追加できます。ただし、バイナリレベルに限ります。

public interface IWidget
{
    string Name { get; }

    // Binary-safe: existing implementors inherit this default
    string DisplayName => Name.ToUpperInvariant();
}

ただし、利用側が明示的にインターフェイスへキャストし、ランタイムが既定の実装を見つけられない場合(古いランタイム)は失敗します。.NET 8.0 以降のベースラインに含まれるすべてのランタイムは、DIM をサポートしています。


ソース互換性

ソース互換性とは、既存の利用側コードが変更なしでコンパイルを続けられることです。ソース互換性を破る変更は、コンパイラーエラーを引き起こすか、動作を暗黙に変更します(こちらのほうが問題です)。

よくあるソース互換性を破る変更
変更コンパイラーへの影響例
あいまいさを引き起こすオーバーロードを追加CS0121(呼び出しがあいまい)Process(int id) が存在する状態で Process(long id) を追加すると、int リテラルを渡す呼び出し側には候補が 2 つになります
インスタンスメソッドと競合する拡張メソッドを追加新しい拡張メソッドが隠れる、または競合する利用側がインポートする名前空間に Where() 拡張メソッドを追加する
オプションパラメーターの既定値を変更動作が暗黙に変わるvoid Log(string level = "info") を "debug" に変更すると、再コンパイルした呼び出し側には新しい既定値が適用されます
インターフェイスにメンバーを追加(DIM の場合も含む)利用側がすべてのメンバーを明示的に実装している場合は CS0535インターフェイスを明示的に実装している利用側は、新しいメンバーを追加する必要があります
パラメーターの既定値を削除(必須に変更)CS7036(必須の引数がありません)既定値に依存している呼び出し側は、明示的に値を渡す必要があります
必須の名前空間インポートを追加利用側がインポートしない場合は CS0246利用側の名前空間に新しいパブリック型を追加すると、名前が衝突します
パラメーター名を変更名前付き引数を使う呼び出し側が壊れるパラメーター名を identifier に変更すると、Process(id: 5) は失敗します
class を struct に変更(またはその逆)new() 制約、is null チェック、ボックス化の動作が変わる根本的な意味の変更
既存の型名と衝突する新しい名前空間を追加CS0104(参照があいまい)MyLib.Tasks 名前空間を追加すると、System.Threading.Tasks と競合します
オーバーロード解決の落とし穴

オーバーロードの追加は、ライブラリでソース互換性を破る変更が起きる最も一般的な原因です。C# コンパイラーはコンパイル時に「最適な」オーバーロードを選択するため、新しいオーバーロードによって選ばれるメソッドが変わることがあります。

// V1 -- only overload
public void Send(object message) { }

// V2 -- new overload; ALL callers passing string now bind here
public void Send(string message) { }

これはソース互換性を破る変更(呼び出し側が暗黙に別のメソッドへ再バインドされます)ですが、バイナリ互換性があります(古いコンパイル済みコードは引き続き object オーバーロードのトークンを呼び出します)。

対策: パブリック API にオーバーロードを追加するときは、既存のパラメーター型から暗黙の変換経路が生じない型を優先してください。バイナリ互換性のために維持する必要があるものの、IntelliSense に表示する必要がない互換性用シムには、[EditorBrowsable(EditorBrowsableState.Never)] を使用してください。

拡張メソッドの競合

拡張メソッドは、インポートされた名前空間に基づいてコンパイル時に解決されます。新しい拡張メソッドを追加すると、既存のインスタンスメソッドを隠したり、他のライブラリの拡張メソッドと競合したりすることがあります。

// Library V1 ships in namespace MyLib.Extensions
public static class StringExtensions
{
    public static string Truncate(this string s, int maxLength) =>
        s.Length <= maxLength ? s : s[..maxLength];
}

// Library V2 adds to SAME namespace -- safe
// Library V2 adds to DIFFERENT namespace -- may conflict
// if consumer imports both namespaces

対策: バージョン間で拡張メソッドを同じ名前空間に配置してください。名前空間の追加については、リリースノートに記載してください。


型フォワーダー

型フォワーダーを使うと、既存のコンパイル済み参照を壊さずに、パブリック型をあるアセンブリから別のアセンブリへ移動できます。元のアセンブリには転送エントリが含まれ、ランタイムの型リゾルバーを新しい場所へ誘導します。

型フォワーダーを使用する場面
  • 大きなアセンブリを、より小さく役割に特化したアセンブリに分割する
  • アセンブリを統合して、パッケージ化を簡素化する
  • アセンブリの境界を越えて名前空間を再編成する
  • 複数のパッケージで使用する型を共有アセンブリに移動する
型フォワーダーの追加

元のアセンブリ(型の移動元)で、型を新しいアセンブリに移動した後、転送属性を追加します。

// In the ORIGINAL assembly's AssemblyInfo.cs or a dedicated TypeForwarders.cs
// This tells the runtime: "Widget now lives in MyLib.Core"
using System.Runtime.CompilerServices;

[assembly: TypeForwardedTo(typeof(MyLib.Core.Widget))]
[assembly: TypeForwardedTo(typeof(MyLib.Core.IWidgetFactory))]
[assembly: TypeForwardedTo(typeof(MyLib.Core.WidgetOptions))]

typeof() が正しく解決されるように、元のアセンブリは移動先のアセンブリを参照する必要があります。

型フォワーダーの受け入れ側

移行先アセンブリ(型の移行先となるアセンブリ)には、実際の型定義が含まれます。移行先側では特別な属性は必要ありません。[TypeForwardedFrom] 属性は省略可能なメタデータで、型が元々どこにあったかを記録します。これはシリアル化の互換性を保つうえで役立ちます。

// In the DESTINATION assembly -- optional but recommended for
// types that participate in serialization
using System.Runtime.CompilerServices;

namespace MyLib.Core;

[TypeForwardedFrom("MyLib, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null")]
public class Widget
{
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }
}

[TypeForwardedFrom] は、BinaryFormatter、DataContractSerializer、またはアセンブリ修飾型名をエンコードするシリアライザーによって逆シリアル化される型に不可欠です。この属性がないと、以前のバージョンで書き込まれたデータの逆シリアル化に失敗し、TypeLoadException が発生します。

型フォワーダーのチェーン

型フォワーダーはチェーンできます。アセンブリ A がアセンブリ B に転送し、アセンブリ B がアセンブリ C に転送する形です。ランタイムはこのチェーンをたどります。ただし、アセンブリの読み込みオーバーヘッドを最小限にするため、チェーンは短く(理想的には 1 ホップに)保ってください。

マルチ TFM 型フォワーダーのパターン

マルチ TFM ライブラリでアセンブリを再構成する場合、転送元アセンブリは、コンシューマーが使用する可能性のあるすべての TFM を対象にする必要があります。一般的なパターンを以下に示します。


<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFrameworks>net8.0;netstandard2.0</TargetFrameworks>
  </PropertyGroup>
  <ItemGroup>
    <ProjectReference Include="../MyLib.Core/MyLib.Core.csproj" />
  </ItemGroup>
</Project>

マルチ TFM のパッケージングの仕組みについては[スキル:dotnet-multi-targeting]を、転送用シムの NuGet パッケージングについては[スキル:dotnet-nuget-authoring]を参照してください。


SemVer への影響の概要

API の変更を、Semantic Versioning のバージョン増分に対応付けます。SemVer の完全なルールと NuGet のバージョン管理戦略については、[スキル:dotnet-nuget-authoring]を参照してください。

変更カテゴリSemVer理由
パブリック型またはメンバーの削除Majorバイナリー互換性を損なう
メソッドシグネチャの変更(戻り値の型、パラメーター)Majorバイナリー互換性を損なう
パブリッククラスへの抽象メンバーの追加Majorサブクラスとのバイナリー互換性を損なう
DIM を使わずにインターフェイスメンバーを追加Major実装側とのバイナリー互換性を損なう
以前は sealed でなかったクラスに sealed を追加Majorサブクラスとのバイナリー互換性を損なう
構造体のフィールドレイアウトの変更Major相互運用性を利用するコンシューマーとのバイナリー互換性を損なう
型フォワーダーを使わずに名前空間を変更Majorバイナリー互換性を損なう
メンバーに [Obsolete] を付与(警告またはエラー)Minorバイナリー互換性を保ち、非推奨であることを示す
新しいパブリック型の追加Minor追加のみで、互換性を損なわない
オーバーロードの追加(ソース互換性を損なう可能性あり)Minorバイナリー互換性を保つ。ソースへの影響は Minor で許容される
オプションパラメーターの追加Minorバイナリー互換性を保つ。再コンパイル時に新しい既定値が反映される
インターフェイスへの DIM の追加Minorバイナリー互換性を保ち、追加のみ
型フォワーダーを使って名前空間を変更Minorフォワーディングによりバイナリー互換性を保つ
アクセス修飾子の公開範囲を拡大Minorバイナリー互換性を保ち、追加のみ
API の変更を伴わないバグ修正Patchパブリック API への影響なし
ドキュメントまたはメタデータのみの変更Patchパブリック API への影響なし
API を変更しないパフォーマンス改善Patchパブリック API への影響なし
[Obsolete] による非推奨化のライフサイクル

メジャーバージョンをまたいでパブリック API メンバーを削除する際の標準的なワークフローです。

リリースアクション影響
v2.1(Minor)[Obsolete("Use Widget.CalculateAsync() instead.")] を追加コンパイラー警告 CS0618 が発生。既存のコードはコンパイルされ、実行される
v2.3(Minor)[Obsolete("Use Widget.CalculateAsync() instead.", error: true)] に変更コンパイラーエラー CS0619 が発生。既存のバイナリーは引き続き実行される(バイナリー互換)
v3.0(Major)メンバーを完全に削除バイナリー互換性を損なう。コンシューマーは移行が必要
// v2.1 -- warn consumers
[Obsolete("Use CalculateAsync() instead. This method will be removed in v3.0.")]
public int Calculate() => CalculateAsync().GetAwaiter().GetResult();

// v2.3 -- block new compilation against this member
[Obsolete("Use CalculateAsync() instead. This method will be removed in v3.0.", error: true)]
public int Calculate() => CalculateAsync().GetAwaiter().GetResult();

// v3.0 -- remove the member (Major version bump)

非推奨メッセージには、必ず置き換え先の API と削除予定のバージョンを記載し、人間とエージェントの両方が事前に移行できるようにしてください。

複数の TFM におけるバイナリ互換性

ターゲットフレームワークを追加または削除すると、利用者とのバイナリ互換性に影響します。

  • 新しい TFM の追加(例:既存の net8.0 パッケージに net9.0 を追加):バージョンをマイナーアップします。net8.0 を使用する既存の利用者には影響しません。新たに net9.0 を使用する利用者は、最適化されたコードパスを利用できます。
  • TFM の削除(例:netstandard2.0 の削除):バージョンをメジャーアップします。削除した TFM を対象とする利用者は、互換性のあるアセンブリを解決できなくなります。
  • サポート対象の最低 TFM の変更(例:net6.0 から net8.0 への変更):バージョンをメジャーアップします。削除した TFM を使用する利用者は、互換性を失います。

TFM の追加と削除の管理方法については、実践的なガイダンスを示す [skill:dotnet-multi-targeting] を参照してください。


互換性の検証

.csproj で EnablePackageValidation を使用すると、現在のビルドを以前にリリースしたパッケージと自動的に比較し、バイナリ互換性またはソース互換性を損なう変更を検出できます。

<PropertyGroup>
  <EnablePackageValidation>true</EnablePackageValidation>
  
  <PackageValidationBaselineVersion>1.2.0</PackageValidationBaselineVersion>
</PropertyGroup>

ビルド出力には、互換性を損なう変更が表示されます。

error CP0002: Member 'MyLib.Widget.Calculate()' was removed
error CP0006: Cannot change return type of 'MyLib.Widget.GetName()'

既知の意図的な変更による警告を抑制するには、抑制ファイルを生成します。

dotnet pack /p:GenerateCompatibilitySuppressionFile=true

これにより CompatibilitySuppressions.xml ファイルが生成され、チェックインできます。指定がない場合、SDK はプロジェクトディレクトリから CompatibilitySuppressions.xml を自動的に読み込みます。抑制ファイルを明示的に指定するには、次のようにします。

<ItemGroup>
  <ApiCompatSuppressionFile Include="CompatibilitySuppressions.xml" />
</ItemGroup>

注:ApiCompatSuppressionFile は ItemGroup 項目であり、PropertyGroup プロパティではありません。複数の抑制ファイルを含めることができます。

PublicApiAnalyzers を使った、より詳細な API サーフェスの追跡と CI による適用ワークフローについては、[skill:dotnet-api-surface-validation] を参照してください。


エージェントが注意すべき点

  1. オーバーロードの追加が常に安全だと考えないでください。バイナリ互換性は保たれますが、オーバーロード解決の変更によってソース互換性が損なわれる可能性があります。既存のパラメーター型と新しいパラメーター型の間に暗黙的な変換経路がないか、必ず確認してください。
  2. メジャーバージョンを上げずにパブリックメンバーを削除しないでください。バイナリ互換性を維持するには、[Obsolete] が付いたメンバーであっても、次のメジャーバージョンまでは保持する必要があります。
  3. アセンブリ間で型を移動する際に、型フォワーダーを忘れないでください。[TypeForwardedTo] がない場合、利用者は実行時に TypeLoadException を受け取ります。元のアセンブリに必ずフォワーダーを追加してください。
  4. パッチリリースで optional パラメーターの既定値を変更しないでください。再コンパイルした利用者の動作が暗黙的に変わる一方、古いバイナリは古い既定値を保持するため、バージョンによって動作が異なるようになります。
  5. バイナリ互換性とソース互換性を混同しないでください。バイナリ互換性は保たれていてもソース互換性が損なわれる変更(新しいオーバーロードなど)や、ソース互換性は保たれていてもバイナリ互換性が損なわれる変更(戻り値の型を int から long に変更するなど)があります。両方をテストしてください。
  6. シリアライズ可能な型で [TypeForwardedFrom] を省略しないでください。アセンブリ修飾された型名をエンコードするシリアライザー(DataContractSerializer、従来の BinaryFormatter)は、以前のバージョンで書き込まれたデータを逆シリアル化できなくなります。
  7. ApiCompatSuppressionFile を PropertyGroup に記述しないでください。これは PropertyGroup プロパティではなく、ItemGroup 項目です(<ApiCompatSuppressionFile Include="..." />)。PropertyGroup の構文を使用しても、何も行われません。
  8. メジャーバージョンを上げずにライブラリパッケージから TFM を削除しないでください。削除した TFM を使用する利用者は、フォールバックなしで互換性を失います。

前提条件

  • .NET 8.0+ SDK
  • 自動互換性チェックのための EnablePackageValidation MSBuild プロパティ
  • SemVer 2.0 の規約に関する理解([skill:dotnet-nuget-authoring] を参照)
  • アセンブリの読み込みとバインディングに関する知識(厳密な名前の概念)

参考資料

コメント