mcpField
D
dotnet-api-surface-validation

dotnet-api-surface-validation

github.com/wshaddix测试与质量8670 次安装35 周前更新
代码仓库

关于 dotnet-api-surface-validation

为 .NET 库维护者提供公共 API 表面验证工作流,涵盖源码跟踪、反射快照测试和 CI 兼容性检查。帮助团队审查并显式批准 API 变更。

主要功能

  1. 01用 PublicApiAnalyzers 跟踪已发布与未发布 API
  2. 02用 Verify 对程序集 API 做快照测试
  3. 03用 ApiCompat 在 CI 检查 API 差异
  4. 04支持多目标框架的独立 API 清单

适用场景

  1. 01开发期间发现未登记的公共成员
  2. 02发布时审查并归档 API 变更
  3. 03在拉取请求中阻止未经批准的兼容性变化

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/wshaddix/dotnet-skills --skill dotnet-api-surface-validation

安装后在 Claude Code 中输入/dotnet-api-surface-validation确认已加载。

详细介绍

dotnet-api-surface-validation

用于验证和跟踪 .NET 库公共 API 表面的工具和工作流。涵盖三种互补方式:使用 PublicApiAnalyzers 通过文本文件跟踪已发布和未发布的 API,并提供 Roslyn 诊断;使用 Verify 快照模式 基于反射进行 API 表面快照测试;以及使用 ApiCompat CI 强制检查,根据 API 表面变更阻止或放行拉取请求。

版本假设: 以 .NET 8.0+ 为基准。PublicApiAnalyzers 3.3+(随 Microsoft.CodeAnalysis.Analyzers 一同提供,或使用独立的 Microsoft.CodeAnalysis.PublicApiAnalyzers)。ApiCompat 工具包含在 .NET 8+ SDK 中。

范围之外: 二进制兼容性与源代码兼容性规则、类型转发器、SemVer 影响——请参阅 [skill:dotnet-library-api-compat]。NuGet 打包、EnablePackageValidation 基础知识及抑制文件机制——请参阅 [skill:dotnet-nuget-authoring] 和 [skill:dotnet-multi-targeting]。Verify 库基础知识(设置、清理、转换器)——请参阅 [skill:dotnet-snapshot-testing]。常规 Roslyn 分析器配置(EditorConfig、严重性级别)——请参阅 [skill:dotnet-roslyn-analyzers]。HTTP API 版本控制——请参阅 [skill:dotnet-api-versioning]。

交叉引用:[skill:dotnet-library-api-compat] 介绍二进制兼容性与源代码兼容性规则,[skill:dotnet-nuget-authoring] 介绍 EnablePackageValidation 和 NuGet SemVer,[skill:dotnet-multi-targeting] 介绍多 TFM 的 ApiCompat 工具机制,[skill:dotnet-snapshot-testing] 介绍 Verify 基础知识,[skill:dotnet-roslyn-analyzers] 介绍常规分析器配置,[skill:dotnet-api-versioning] 介绍 HTTP API 版本控制。


PublicApiAnalyzers

PublicApiAnalyzers 会跟踪提交到源代码管理的文本文件中的每个公共 API 成员。分析器要求新 API 先经过明确的“未发布”阶段,然后才能标记为“已发布”,从而防止意外公开 API,以及未记录的 API 表面变更。

设置

安装分析器包:

<ItemGroup>
  <PackageReference Include="Microsoft.CodeAnalysis.PublicApiAnalyzers" Version="3.3.*" PrivateAssets="all" />
</ItemGroup>

在项目根目录(与 .csproj 相邻)创建两个跟踪文件:

MyLib/
  MyLib.csproj
  PublicAPI.Shipped.txt    # APIs shipped in released versions
  PublicAPI.Unshipped.txt  # APIs added since last release

两个文件都必须存在,即使内容为空也一样。每个文件都必须包含标头注释:

#nullable enable

#nullable enable 标头会告知分析器跟踪 API 签名中的可空注释。若没有此标头,分析器会忽略可空上下文差异。

诊断规则
规则严重性含义
RS0016警告公共 API 成员未在 API 跟踪文件中声明
RS0017警告公共 API 成员已移除,但仍保留在跟踪文件中
RS0024警告公共 API 成员的可空注释不正确
RS0025警告已标记为已发布的公共 API 符号签名已发生变化
RS0026警告新增公共 API,但未在 PublicAPI.Unshipped.txt 中添加条目
RS0036警告API 文件缺少 #nullable enable 标头
RS0037警告已声明公共 API,但源码中不存在

RS0016 是最常见的诊断。添加新的 public 或 protected 成员时,RS0016 会持续触发,直到你将该成员的签名添加到 PublicAPI.Unshipped.txt 中。在 IDE 中使用代码修复(灯泡图标)自动添加条目。

移除或重命名 public 成员,但跟踪文件中仍保留旧签名时,会触发 RS0017。从相应文件中删除过时的行。

文件格式

跟踪文件中的每一行都使用其文档注释 ID 格式表示一个公共 API 符号:

#nullable enable
MyLib.Widget
MyLib.Widget.Widget() -> void
MyLib.Widget.Name.get -> string!
MyLib.Widget.Name.set -> void
MyLib.Widget.Calculate(int count) -> decimal
MyLib.Widget.CalculateAsync(int count, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<decimal>!
MyLib.IWidgetFactory
MyLib.IWidgetFactory.Create(string! name) -> MyLib.Widget!
MyLib.WidgetOptions
MyLib.WidgetOptions.WidgetOptions() -> void
MyLib.WidgetOptions.MaxRetries.get -> int
MyLib.WidgetOptions.MaxRetries.set -> void

关键格式规则:

  • ! 后缀表示启用了可空上下文时的非可空引用类型
  • ? 后缀表示可空引用类型或可空值类型
  • 构造函数使用类型名称(例如,Widget.Widget() -> void)
  • 属性会展开为 .get 和 .set 条目
  • 签名中包含默认参数值
已发布/未发布生命周期

跨发布周期的工作流:

开发期间(版本发布之间):

  1. 在源代码中添加新的公共 API 成员
  2. 触发 RS0016——该成员尚未跟踪
  3. 使用代码修复或手动将其添加到 PublicAPI.Unshipped.txt
  4. RS0016 警告消失

发布时:

  1. 将 PublicAPI.Unshipped.txt 中的所有条目移至 PublicAPI.Shipped.txt
  2. 将 PublicAPI.Unshipped.txt 清空,仅保留 #nullable enable 标头
  3. 将这两个文件作为发布 PR 的一部分提交
  4. 为此次发布打标签

移除先前已发布的 API 时(主版本):

  1. 从源代码中移除该成员
  2. 从 PublicAPI.Shipped.txt 中移除该条目
  3. RS0017 警告消失(如果曾触发)
  4. 在发布说明中记录此次移除

移除尚未发布的 API 时(发布前):

  1. 从源代码中移除该成员
  2. 从 PublicAPI.Unshipped.txt 中移除该条目
  3. 不影响 SemVer——该 API 从未发布
多 TFM 项目

对于多目标项目,如果不同目标的 API 表面存在差异,PublicApiAnalyzers 支持按 TFM 跟踪文件:

MyLib/
  MyLib.csproj
  PublicAPI.Shipped.txt           # Shared across all TFMs
  PublicAPI.Unshipped.txt         # Shared across all TFMs
  PublicAPI.Shipped.net8.0.txt    # net8.0-specific APIs
  PublicAPI.Unshipped.net8.0.txt  # net8.0-specific APIs
  PublicAPI.Shipped.net10.0.txt   # net10.0-specific APIs
  PublicAPI.Unshipped.net10.0.txt # net10.0-specific APIs

共享文件包含所有 TFM 通用的 API。TFM 专属文件包含仅在相应目标上存在的 API。分析器会在构建时将它们合并。

要启用按 TFM 区分的文件,请在 .csproj 中添加:

<PropertyGroup>
  <RoslynPublicApiPerTfm>true</RoslynPublicApiPerTfm>
</PropertyGroup>

有关多 TFM 打包机制,请参阅 [skill:dotnet-multi-targeting]。

集成到 CI

PublicApiAnalyzers 会作为标准构建的一部分运行。要在 CI 中强制执行,请确保将 RS 系列规则的警告视为错误:


<PropertyGroup>
  <WarningsAsErrors>$(WarningsAsErrors);RS0016;RS0017;RS0036;RS0037</WarningsAsErrors>
</PropertyGroup>

这样,任何未声明的公共 API 更改都会阻止 CI 构建通过。开发人员必须先明确更新跟踪文件,才能通过构建。


验证 API 表面快照模式

使用 Verify 库对程序集的整个公共 API 表面进行快照测试。此方法通过反射枚举所有公共类型和成员,生成可读的快照并将其提交到源代码管理中;每次测试运行时都会进行比对。公共 API 表面的任何更改都会导致测试失败,直到快照得到明确批准。

此模式与 PublicApiAnalyzers 相辅相成——分析器在项目内的构建时捕获更改,而 Verify 快照则从已编译程序集使用者的角度捕获更改。

有关 Verify 基础知识(设置、清理、转换器、差异工具集成、CI 配置),请参阅 [skill:dotnet-snapshot-testing]。

提取公共 API 表面

创建一个辅助方法,通过反射检查程序集,生成稳定且经过排序的所有公共类型及其成员的表示:

using System.Reflection;
using System.Text;

public static class PublicApiExtractor
{
    public static string GetPublicApi(Assembly assembly)
    {
        var sb = new StringBuilder();

        var publicTypes = assembly
            .GetTypes()
            .Where(t => t.IsPublic || t.IsNestedPublic)
            .OrderBy(t => t.FullName, StringComparer.Ordinal);

        foreach (var type in publicTypes)
        {
            AppendType(sb, type);
        }

        return sb.ToString();
    }

    private static void AppendType(StringBuilder sb, Type type)
    {
        var kind = type switch
        {
            { IsEnum: true } => "enum",
            { IsValueType: true } => "struct",
            { IsInterface: true } => "interface",
            { IsAbstract: true, IsSealed: true } => "static class",
            { IsAbstract: true } => "abstract class",
            { IsSealed: true } => "sealed class",
            _ => "class"
        };

        sb.AppendLine($"{kind} {type.FullName}");

        var members = type
            .GetMembers(BindingFlags.Public | BindingFlags.Instance
                | BindingFlags.Static | BindingFlags.DeclaredOnly)
            .OrderBy(m => m.MemberType)
            .ThenBy(m => m.Name, StringComparer.Ordinal)
            .ThenBy(m => m.ToString(), StringComparer.Ordinal);

        foreach (var member in members)
        {
            sb.AppendLine($"  {FormatMember(member)}");
        }

        sb.AppendLine();
    }

    private static string FormatMember(MemberInfo member) =>
        member switch
        {
            ConstructorInfo c => $".ctor({FormatParameters(c.GetParameters())})",
            MethodInfo m when !m.IsSpecialName =>
                $"{m.ReturnType.Name} {m.Name}({FormatParameters(m.GetParameters())})",
            PropertyInfo p => $"{p.PropertyType.Name} {p.Name} {{ {GetAccessors(p)} }}",
            FieldInfo f => $"{f.FieldType.Name} {f.Name}",
            EventInfo e => $"event {e.EventHandlerType?.Name} {e.Name}",
            _ => member.ToString() ?? string.Empty
        };

    private static string FormatParameters(ParameterInfo[] parameters) =>
        string.Join(", ", parameters.Select(p => $"{p.ParameterType.Name} {p.Name}"));

    private static string GetAccessors(PropertyInfo prop)
    {
        var parts = new List<string>();
        if (prop.GetMethod?.IsPublic == true) parts.Add("get;");
        if (prop.SetMethod?.IsPublic == true) parts.Add("set;");
        return string.Join(" ", parts);
    }
}
编写快照测试
[UsesVerify]
public class PublicApiSurfaceTests
{
    [Fact]
    public Task PublicApi_ShouldMatchApprovedSurface()
    {
        var assembly = typeof(Widget).Assembly;
        var publicApi = PublicApiExtractor.GetPublicApi(assembly);

        return Verify(publicApi);
    }
}

首次运行时,会创建一个包含完整公共 API 列表的 .verified.txt 文件。后续运行会将当前 API 表面与已批准的快照进行比对。公共成员的任何新增、移除或修改都会导致测试失败,并显示清晰的差异。

审查 API 表面更改

当快照测试失败时:

  1. Verify 会生成一个 .received.txt 文件,展示新的 API 表面
  2. 比较 .received.txt 和 .verified.txt,审查更改
  3. 如果更改是有意的,请使用 verify accept 接受新快照
  4. 如果更改是意外的,请还原代码更改

这样就创建了一个代码审查检查点:每项公共 API 更改都必须由审查拉取请求中快照差异的人员明确批准。

与 PublicApiAnalyzers 结合使用

这两种方法各有用途:

关注点PublicApiAnalyzersVerify 快照
检测时机构建时(IDE 内)测试时(编译后)
粒度每个成员的签名整个程序集的 API 表面
可空注解通过 #nullable enable 跟踪需要显式反射
审批工作流编辑文本文件(已发布/未发布)接受快照差异
多 TFM每个 TFM 对应独立文件每个 TFM 对应独立测试目标
CI 门禁将警告视为错误测试失败

若要最大限度地覆盖 API 变更,请同时使用两者:PublicApiAnalyzers 可在开发过程中捕获变更,而 Verify 快照则可在测试套件中提供端到端的程序集级验证。


ApiCompat CI 强制检查

ApiCompat 会比较两个程序集(或将基准 NuGet 包与当前构建进行比较),并报告 API 差异。将其集成到 CI 后,它会根据 API 表面变更拦截拉取请求——任何破坏性变更都会导致构建错误,作者必须明确确认该变更。

关于 EnablePackageValidation 基础知识和抑制文件机制,请参阅 [skill:dotnet-nuget-authoring] 和 [skill:dotnet-multi-targeting]。

在 CI 中进行包验证

最简单的强制检查方式是在执行 dotnet pack 时使用 EnablePackageValidation:

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

在 CI 流水线中,dotnet pack 会自动运行包验证:

# GitHub Actions -- gate PRs on API compatibility
name: API Compatibility Check
on:
  pull_request:
    paths:
      - 'src/**'
      - '*.props'
      - '*.targets'

jobs:
  api-compat:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '8.0.x'

      - name: Restore
        run: dotnet restore

      - name: Build
        run: dotnet build --configuration Release --no-restore

      - name: Pack with API validation
        run: dotnet pack --configuration Release --no-build
        # EnablePackageValidation runs during pack and fails
        # the build if breaking changes are detected
使用独立的 ApiCompat 工具比较程序集

如果需要在不打包的情况下比较程序集(例如,将功能分支的构建与主分支的构建进行比较),请使用独立的 ApiCompat 工具:

# GitHub Actions -- compare assemblies directly
name: API Diff Check
on:
  pull_request:

jobs:
  api-diff:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '8.0.x'

      - name: Install ApiCompat tool
        run: dotnet tool install --global Microsoft.DotNet.ApiCompat.Tool

      - name: Build current branch
        run: dotnet build src/MyLib/MyLib.csproj -c Release -o artifacts/current

      - name: Build baseline (main branch)
        run: |
          git stash
          git checkout origin/main -- src/MyLib/
          dotnet build src/MyLib/MyLib.csproj -c Release -o artifacts/baseline
          git checkout - -- src/MyLib/
          git stash pop || true

      - name: Compare APIs
        run: |
          apicompat --left-assembly artifacts/baseline/MyLib.dll \
                    --right-assembly artifacts/current/MyLib.dll
为 API 变更标记 PR

结合使用 ApiCompat 和 PR 标记,让评审者能够清楚地看到 API 变更:

      - name: Check for API changes
        id: api-check
        continue-on-error: true
        run: |
          apicompat --left-assembly artifacts/baseline/MyLib.dll \
                    --right-assembly artifacts/current/MyLib.dll 2>&1 | tee api-diff.txt
          echo "has_changes=$([[ -s api-diff.txt ]] && echo true || echo false)" >> "$GITHUB_OUTPUT"

      - name: Label PR with API changes
        if: steps.api-check.outputs.has_changes == 'true'
        run: gh pr edit "${{ github.event.pull_request.number }}" --add-label "api-change"
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
处理有意进行的破坏性变更

如果有意进行破坏性变更(新主版本),请生成抑制文件:

dotnet pack /p:GenerateCompatibilitySuppressionFile=true

这会在项目目录中创建 CompatibilitySuppressions.xml。如果文件存放在其他位置,请显式引用:

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

注意:ApiCompatSuppressionFile 是 ItemGroup 项,而不是 PropertyGroup 属性。使用 PropertyGroup 语法不会生效,也不会报错。

抑制文件会记录已接受的特定破坏性变更:

<?xml version="1.0" encoding="utf-8"?>
<Suppressions xmlns:xsd="http://www.w3.org/2001/XMLSchema"
              xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <Suppression>
    <DiagnosticId>CP0002</DiagnosticId>
    <Target>M:MyLib.Widget.Calculate</Target>
    <Left>lib/net8.0/MyLib.dll</Left>
    <Right>lib/net8.0/MyLib.dll</Right>
  </Suppression>
</Suppressions>

将抑制文件提交到源代码管理。评审者可以检查文件,确认破坏性变更已有记录且经过有意批准。

在 CI 中强制检查 PublicApiAnalyzers 文件

将 PublicApiAnalyzers 的警告视为错误,并结合 CI 步骤验证跟踪文件不是过时文件:

      - name: Build with API tracking enforcement
        run: dotnet build -c Release /p:TreatWarningsAsErrors=true /warnaserror:RS0016,RS0017,RS0036,RS0037

      - name: Verify PublicAPI files are committed
        run: |
          if git diff --name-only | grep -q 'PublicAPI'; then
            echo "::error::PublicAPI tracking files have uncommitted changes"
            git diff -- '**/PublicAPI.*.txt'
            exit 1
          fi
多库单体仓库强制检查

对于包含多个库的仓库,请在解决方案级别应用 API 验证:


<Project>
  <PropertyGroup Condition="'$(IsPackable)' == 'true'">
    <EnablePackageValidation>true</EnablePackageValidation>
    <WarningsAsErrors>$(WarningsAsErrors);RS0016;RS0017;RS0036;RS0037</WarningsAsErrors>
  </PropertyGroup>

  <ItemGroup Condition="'$(IsPackable)' == 'true'">
    <PackageReference Include="Microsoft.CodeAnalysis.PublicApiAnalyzers"
                      Version="3.3.*" PrivateAssets="all" />
  </ItemGroup>
</Project>

这样可确保仓库中的每个可打包项目都启用了 PublicApiAnalyzers 和包验证,而无需重复配置。


Agent 注意事项

  1. 切勿忘记创建 PublicAPI.Shipped.txt 和 PublicAPI.Unshipped.txt —— PublicApiAnalyzers 要求这两个文件都存在,即使文件为空。缺少文件会导致每个公共成员都出现 RS0037 警告。
  2. 切勿从 PublicAPI 跟踪文件中省略 #nullable enable 头部 —— 如果缺少该头部(RS0036),分析器会忽略可空注释的差异,导致启用了可空性的库遗漏真实的 API 表面变更。
  3. 切勿将 ApiCompatSuppressionFile 放在 PropertyGroup 中 —— 它是一个 ItemGroup 项(<ApiCompatSuppressionFile Include="..." />)。PropertyGroup 语法会被静默忽略,抑制功能也不会生效。
  4. 开发过程中切勿将条目从 PublicAPI.Unshipped.txt 移到 PublicAPI.Shipped.txt —— 只在发布时移动条目。过早标记为已发布,会导致无法干净地撤销尚未发布的 API 添加。
  5. 切勿将 Verify API 表面快照作为唯一的验证机制 —— 它在测试时运行,晚于编译。使用 PublicApiAnalyzers 获取即时的构建时反馈,并使用 ApiCompat 进行基线比较;将 Verify 快照作为额外的安全保障。
  6. 切勿在 CI ApiCompat 工作流中硬编码特定于 TFM 的路径 —— 使用 MSBuild 输出路径变量,或将 TFM 参数化,以避免添加或更改 TFM 时导致工作流失效。
  7. 切勿使用 <NoWarn> 全局抑制 RS0016 —— 这会静默禁用所有公共 API 跟踪。应将缺失的 API 条目添加到跟踪文件中。如果某个 API 有意设为内部,但必须为 public(例如,用作 InternalsVisibleTo 的替代方案),请使用 [EditorBrowsable(EditorBrowsableState.Never)],并将其添加到跟踪文件中。
  8. 切勿使用 GenerateCompatibilitySuppressionFile=true 生成抑制文件后就不检查它 —— 该文件可能会抑制超出预期的变更。提交前务必检查生成的 XML。

前置条件

  • .NET 8.0+ SDK
  • Microsoft.CodeAnalysis.PublicApiAnalyzers NuGet 包(用于 RS0016/RS0017 诊断)
  • EnablePackageValidation MSBuild 属性(用于在 dotnet pack 期间进行基线 API 比较)
  • Microsoft.DotNet.ApiCompat.Tool(可选,用于在 dotnet pack 之外独立比较程序集)
  • Verify 测试库和测试框架集成包(用于 API 表面快照测试)——设置说明请参阅 [skill:dotnet-snapshot-testing]
  • 了解二进制兼容性与源代码兼容性规则——请参阅 [skill:dotnet-library-api-compat]

参考资料

评论

支持 Markdown