关于 dotnet-library-api-compat
面向 .NET 库作者的技能,讲解二进制与源代码 API 兼容性、类型转发器,以及 API 变更对应的 SemVer 版本调整。
主要功能
- 01二进制兼容规则
- 02源代码兼容性陷阱
- 03类型转发器指南
- 04SemVer 影响映射
适用场景
- 01审查公共 API 变更
- 02在程序集间迁移类型
- 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 的 major/minor/patch 版本递增。
**版本假设:**以 .NET 8.0+ 为基准。兼容性规则适用于所有 .NET 版本,但示例针对现代 SDK 风格的项目。
**不在讨论范围内:**HTTP API 版本控制——参见 [skill:dotnet-api-versioning]。NuGet 包元数据、签名和发布工作流——参见 [skill:dotnet-nuget-authoring]。多 TFM 打包机制(polyfill 策略、条件编译)——参见 [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 成员 | 现有二进制文件中针对该枚举的 switch 会直接落入 default |
破坏性变更(二进制不兼容)
| 变更 | 运行时故障 | 示例 |
|---|---|---|
| 移除公共类型 | TypeLoadException | 删除 public class Widget |
| 移除公共方法 | MissingMethodException | 移除 Widget.Calculate() |
| 更改方法返回类型 | MissingMethodException | 将 int Calculate() 改为 long Calculate() |
| 更改方法参数类型 | MissingMethodException | 将 void Process(int id) 改为 void Process(long id) |
| 更改字段类型 | MissingFieldException | 将 public int Count 改为 public long Count |
| 重新排列结构体字段 | 内存布局发生变化 | 会破坏互操作以及使用 Unsafe.As<> 的使用方 |
| 向公共类添加抽象成员 | TypeLoadException | 现有子类缺少该成员的实现 |
| 添加没有默认实现的接口成员 | TypeLoadException | 现有实现者缺少该成员 |
将 virtual 方法改为 non-virtual | 覆盖该方法时出现 MissingMethodException | 覆盖方法的编译结果预期使用虚调用分派 |
| 将原本非密封的类设为密封 | TypeLoadException | 现有子类无法加载 |
| 更改公共类型的命名空间 | TypeLoadException | 除非添加类型转发器(见下文) |
从方法中移除 virtual | MissingMethodException | 使用方编译时生成了 callvirt,但找不到虚方法槽 |
默认接口成员
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 字面量时会出现两个候选项 |
| 添加与实例方法冲突的扩展方法 | 新扩展方法隐藏或冲突 | 在使用方导入的命名空间中添加 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; }
}
对于通过 BinaryFormatter、DataContractSerializer 或任何会编码程序集限定类型名称的序列化程序反序列化的类型,[TypeForwardedFrom] 至关重要。若没有该特性,反序列化由旧版本写入的数据时将失败,并引发 TypeLoadException。
类型转发链
类型转发器可以形成链:程序集 A 转发到程序集 B,程序集 B 再转发到程序集 C。运行时会沿链查找。不过,转发链应尽量简短(最好只经过一跳),以尽可能减少程序集加载开销。
多 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 打包机制,请参阅 [skill:dotnet-multi-targeting];有关转发垫片的 NuGet 打包,请参阅 [skill:dotnet-nuget-authoring]。
SemVer 影响概要
将 API 更改映射到语义化版本增量。有关完整的 SemVer 规则和 NuGet 版本策略,请参阅 [skill:dotnet-nuget-authoring]。
| 更改类别 | SemVer | 原因 |
|---|---|---|
| 移除公共类型或成员 | Major | 破坏二进制兼容性 |
| 更改方法签名(返回类型、参数) | Major | 破坏二进制兼容性 |
| 向公共类添加抽象成员 | Major | 对子类而言破坏二进制兼容性 |
| 向接口添加成员但不使用 DIM | Major | 对实现者而言破坏二进制兼容性 |
为之前未密封的类添加 sealed | Major | 对子类而言破坏二进制兼容性 |
| 更改结构体字段布局 | Major | 对互操作使用者而言破坏二进制兼容性 |
| 更改命名空间但不使用类型转发器 | Major | 破坏二进制兼容性 |
将成员标记为 [Obsolete](警告或错误) | Minor | 二进制兼容;提示弃用 |
| 添加新的公共类型 | Minor | 增量式更改,不产生破坏性影响 |
| 添加重载(可能破坏源代码兼容性) | Minor | 二进制兼容;接受次要版本中的源代码影响 |
| 添加可选参数 | Minor | 二进制兼容;重新编译后会采用新的默认值 |
| 向接口添加 DIM | Minor | 二进制兼容;增量式更改 |
| 使用类型转发器更改命名空间 | Minor | 通过转发保持二进制兼容 |
| 扩大访问修饰符的范围 | Minor | 二进制兼容;增量式更改 |
| 不更改 API 的 Bug 修复 | 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 和计划移除的版本,以便人员和 Agent 主动完成迁移。
多目标框架二进制兼容性
添加或移除目标框架会影响使用者的二进制兼容性:
- 添加新的 TFM(例如,在现有的
net8.0包中添加net9.0):版本号提升 Minor。现有的net8.0使用者不受影响;新的net9.0使用者可使用优化后的代码路径。 - 移除 TFM(例如,移除
netstandard2.0):版本号提升 Major。目标为已移除 TFM 的使用者将无法解析兼容的程序集。 - 更改最低支持的 TFM(例如,从
net6.0更改为net8.0):版本号提升 Major。使用已弃用 TFM 的使用者将失去兼容性。
请参阅 [skill:dotnet-multi-targeting],了解管理 TFM 添加与移除的实用指南。
兼容性验证
在 .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]。
Agent 注意事项
- 不要认为添加重载总是安全的 —— 这在二进制层面兼容,但由于重载解析发生变化,可能会破坏源代码兼容性。务必检查现有参数类型与新参数类型之间是否存在隐式转换路径。
- 不要在未提升 Major 版本号的情况下移除公共成员 —— 即使是标记了
[Obsolete]的成员,也必须保留到下一个 Major 版本,以维持二进制兼容性。 - 在程序集之间移动类型时,不要忘记类型转发器 —— 如果没有
[TypeForwardedTo],使用者将在运行时收到TypeLoadException。务必在原始程序集中添加转发器。 - 不要在补丁版本中更改
optional参数的默认值 —— 这会悄然改变重新编译的使用者的行为,而旧二进制文件仍保留旧默认值,从而导致行为因版本而异。 - 不要混淆二进制兼容性与源代码兼容性 —— 某项更改可能在二进制层面安全,但会破坏源代码兼容性(新增重载);也可能兼容源代码,但会破坏二进制兼容性(将返回类型从
int更改为long)。两者都要测试。 - 对于可序列化类型,不要省略
[TypeForwardedFrom]—— 会对程序集限定类型名称进行编码的序列化器(DataContractSerializer、旧版 BinaryFormatter)将无法反序列化旧版本写入的数据。 - 不要将
ApiCompatSuppressionFile放在 PropertyGroup 中 —— 它是 ItemGroup 项(<ApiCompatSuppressionFile Include="..." />),而不是属性。使用 PropertyGroup 语法不会产生任何效果,也不会给出提示。 - 不要在未提升 Major 版本号的情况下从库包中移除 TFM —— 使用已移除 TFM 的使用者将失去兼容性,且没有回退方案。
先决条件
- .NET 8.0+ SDK
- 用于自动兼容性检查的
EnablePackageValidationMSBuild 属性 - 了解 SemVer 2.0 约定(请参阅 [skill:dotnet-nuget-authoring])
- 熟悉程序集加载与绑定(强名称概念)