mcpField
D
dotnet-library-api-compat

dotnet-library-api-compat

github.com/wshaddix开发8672 次安装35 周前更新
代码仓库

关于 dotnet-library-api-compat

面向 .NET 库作者的技能,讲解二进制与源代码 API 兼容性、类型转发器,以及 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 的 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除非添加类型转发器(见下文)
从方法中移除 virtualMissingMethodException使用方编译时生成了 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对子类而言破坏二进制兼容性
向接口添加成员但不使用 DIMMajor对实现者而言破坏二进制兼容性
为之前未密封的类添加 sealedMajor对子类而言破坏二进制兼容性
更改结构体字段布局Major对互操作使用者而言破坏二进制兼容性
更改命名空间但不使用类型转发器Major破坏二进制兼容性
将成员标记为 [Obsolete](警告或错误)Minor二进制兼容;提示弃用
添加新的公共类型Minor增量式更改,不产生破坏性影响
添加重载(可能破坏源代码兼容性)Minor二进制兼容;接受次要版本中的源代码影响
添加可选参数Minor二进制兼容;重新编译后会采用新的默认值
向接口添加 DIMMinor二进制兼容;增量式更改
使用类型转发器更改命名空间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 注意事项

  1. 不要认为添加重载总是安全的 —— 这在二进制层面兼容,但由于重载解析发生变化,可能会破坏源代码兼容性。务必检查现有参数类型与新参数类型之间是否存在隐式转换路径。
  2. 不要在未提升 Major 版本号的情况下移除公共成员 —— 即使是标记了 [Obsolete] 的成员,也必须保留到下一个 Major 版本,以维持二进制兼容性。
  3. 在程序集之间移动类型时,不要忘记类型转发器 —— 如果没有 [TypeForwardedTo],使用者将在运行时收到 TypeLoadException。务必在原始程序集中添加转发器。
  4. 不要在补丁版本中更改 optional 参数的默认值 —— 这会悄然改变重新编译的使用者的行为,而旧二进制文件仍保留旧默认值,从而导致行为因版本而异。
  5. 不要混淆二进制兼容性与源代码兼容性 —— 某项更改可能在二进制层面安全,但会破坏源代码兼容性(新增重载);也可能兼容源代码,但会破坏二进制兼容性(将返回类型从 int 更改为 long)。两者都要测试。
  6. 对于可序列化类型,不要省略 [TypeForwardedFrom] —— 会对程序集限定类型名称进行编码的序列化器(DataContractSerializer、旧版 BinaryFormatter)将无法反序列化旧版本写入的数据。
  7. 不要将 ApiCompatSuppressionFile 放在 PropertyGroup 中 —— 它是 ItemGroup 项(<ApiCompatSuppressionFile Include="..." />),而不是属性。使用 PropertyGroup 语法不会产生任何效果,也不会给出提示。
  8. 不要在未提升 Major 版本号的情况下从库包中移除 TFM —— 使用已移除 TFM 的使用者将失去兼容性,且没有回退方案。

先决条件

  • .NET 8.0+ SDK
  • 用于自动兼容性检查的 EnablePackageValidation MSBuild 属性
  • 了解 SemVer 2.0 约定(请参阅 [skill:dotnet-nuget-authoring])
  • 熟悉程序集加载与绑定(强名称概念)

参考资料

评论

支持 Markdown