NuGet 打包与发布实践

系统讲解 .NET 类库的 NuGet 打包与发布,覆盖 csproj 打包元数据、多目标框架与条件编译、符号包与源链接调试、私有源搭建与版本策略,以及包体积与依赖治理的工程方法。

1. 从类库到可发布包

一句话总结: 打包不是加一条 dotnet pack 命令,而是把元数据、目标框架、依赖与符号完整定义清楚,让使用者能正确引用、调试与升级。

一个可发布的包需要同时回答四个问题:这是什么包(元数据)、能在哪些运行时用(目标框架)、会带来哪些依赖(依赖声明)、出问题怎么调试(符号与源链接)。

# 生成包(输出到 bin/Release)
dotnet pack -c Release -o ./artifacts

# 检查包内容,避免误打包或漏文件
unzip -l ./artifacts/MyLib.1.0.0.nupkg

包的目录结构是理解打包的起点:

路径内容
lib/<tfm>/*.dll各目标框架的程序集
ref/<tfm>/*.dll仅编译期引用程序集
runtimes/<rid>/native原生库
build/<tfm>/*.props自动导入的 MSBuild 片段
analyzers/dotnet/cs分析器与源生成器
README.md包详情页展示的说明

1.1 最小可发布配置

一句话总结: 必填元数据只有五项——PackageId、Version、Authors、Description 与 PackageLicenseExpression,其余都有默认值但强烈建议显式声明。

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net9.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

  <PropertyGroup>
    <PackageId>Company.Orders.Client</PackageId>
    <Version>1.0.0</Version>
    <Authors>Leeting Yan</Authors>
    <Description>订单服务的 .NET 客户端库,封装认证、重试与分页。</Description>
    <PackageLicenseExpression>MIT</PackageLicenseExpression>
    <PackageProjectUrl>https://github.com/example/orders-client</PackageProjectUrl>
    <RepositoryUrl>https://github.com/example/orders-client</RepositoryUrl>
    <RepositoryType>git</RepositoryType>
    <PackageTags>orders;client;http</PackageTags>
    <GenerateDocumentationFile>true</GenerateDocumentationFile>
  </PropertyGroup>
</Project>

GenerateDocumentationFile 会让编译器从 XML 注释生成文档并自动打进包,使用者就能在 IDE 中看到参数说明与摘要。开启后所有公开成员都必须写注释,否则会收到 CS1591 警告,可通过 NoWarn 局部抑制。

2. 打包元数据的细节

一句话总结: 元数据决定包在 NuGet.org 上如何被发现、如何被信任、如何被升级,PackageId 与 Version 一旦发布就不可更改。

2.1 版本号与 SemVer

一句话总结: 遵循语义化版本,主版本表示不兼容变更、次版本表示向后兼容的新增、修订号表示修复,预发布后缀用 -alpha、-beta、-rc。

<PropertyGroup>
  <!-- 稳定版本 -->
  <Version>1.4.2</Version>

  <!-- 预发布版本 -->
  <Version>1.5.0-beta.1</Version>

  <!-- 由 CI 注入:结合 MinVer 或 Nerdbank.GitVersioning 从 Git 标签推导 -->
  <Version>$(GitVersion_SemVer)</Version>
</PropertyGroup>

NuGet 对版本号有一条硬规则:同一个 PackageId 加同一个 Version 只能推送一次,即使删除也无法重新推送同一版本号。因此发布前必须确认版本正确,回滚只能靠发布更高的版本号。

# 从 Git 标签推导版本,避免手工维护
dotnet add package MinVer --version 5.*
# 之后打 tag v1.4.2,构建产物版本即为 1.4.2
git tag v1.4.2 && git push origin v1.4.2

2.2 依赖与包引用

一句话总结: PackageReference 的依赖会写进包的依赖清单,PrivateAssets 能阻止依赖传递给使用者,包引用版本的浮动范围要谨慎使用。

<ItemGroup>
  <!-- 传递依赖:使用者也会获得 -->
  <PackageReference Include="System.Text.Json" Version="9.0.0" />

  <!-- 仅本包使用:不传递给使用者 -->
  <PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0"
                    PrivateAssets="all" />
</ItemGroup>
PrivateAssets 取值含义
all不传递给使用者,最常用
compile不传递编译期引用
runtime不传递运行期依赖
contentfiles不传递内容文件
analyzers不传递分析器

把分析器、源生成器、SourceLink、打包工具全部标记为 PrivateAssets="all",否则使用者的项目会被迫引入一堆无关依赖,甚至产生版本冲突。

3. 多目标框架

一句话总结: 用 TargetFrameworks 同时产出多个框架的程序集,按框架条件编译差异代码,并为旧框架补齐缺失的 API。

<PropertyGroup>
  <TargetFrameworks>netstandard2.0;net8.0;net9.0</TargetFrameworks>
</PropertyGroup>

netstandard2.0 提供最广的兼容面(.NET Framework 4.6.1 及以上都能用),net8.0 与 net9.0 则能用上 Span、静态抽象接口、SearchValues 等新特性。运行时按最接近的框架选择程序集,因此多目标是兼容性与性能的折中手段。

3.1 条件编译与 API 补齐

一句话总结: 按框架条件编译时,用自定义符号把差异隔离在一处,旧框架缺失的 API 用条件定义补齐,而不是复制整份实现。

<PropertyGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
  <DefineConstants>$(DefineConstants);NETSTANDARD</DefineConstants>
</PropertyGroup>

<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
  <PackageReference Include="System.Memory" Version="4.6.0" />
  <PackageReference Include="PolySharp" Version="1.14.1" PrivateAssets="all" />
</ItemGroup>

PolySharp 是个实用的源生成器,它在旧框架上补齐 required、init、record、可空引用类型注解等语法糖,让同一份代码能在 netstandard2.0 上编译。

public static ReadOnlySpan<char> 取前缀(ReadOnlySpan<char> input)
{
#if NET8_0_OR_GREATER
    // 新框架可用 SearchValues 做向量化查找
    return input[..input.IndexOfAny(SearchValues.Create(";,|"))];
#else
    int idx = input.IndexOf(';');
    if (idx < 0) idx = input.IndexOf(',');
    return idx < 0 ? input : input[..idx];
#endif
}

多目标的测试也必须覆盖:用条件测试项目或在 CI 中按框架分别执行测试,否则旧框架路径长期无人验证,问题只在用户环境暴露。

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFrameworks>net8.0;net9.0</TargetFrameworks>
    <IsPackable>false</IsPackable>
  </PropertyGroup>
</Project>

4. 符号包与源链接

一句话总结: 符号包让使用者能单步调试到你的源码,源链接把 PDB 指向 Git 提交,两者结合才能实现完整的可调试体验。

<PropertyGroup>
  <IncludeSymbols>true</IncludeSymbols>
  <SymbolPackageFormat>snupkg</SymbolPackageFormat>
  <PublishRepositoryUrl>true</PublishRepositoryUrl>
  <EmbedUntrackedSources>true</EmbedUntrackedSources>
  <DebugType>portable</DebugType>
</PropertyGroup>

<ItemGroup>
  <PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0"
                    PrivateAssets="all" />
</ItemGroup>

snupkg 格式把符号独立成包,与主包一起推送。使用者只需在 Visual Studio 中勾选启用源链接支持,就能在异常堆栈里直接跳到对应的 GitHub 源码行。

EmbedUntrackedSources 用于把未被 Git 跟踪的生成文件(如源生成器产物)嵌入 PDB,否则这些文件在调试时会显示为不可用。

4.1 调试体验的验证

一句话总结: 发布前应在独立项目中引用自己的包并尝试单步调试,确认 PDB 与源链接真的生效,而不是发布后才发现符号缺失。

验证步骤:

# 打包并推送符号包
dotnet pack -c Release -o ./artifacts
dotnet nuget push ./artifacts/*.nupkg --source https://api.nuget.org/v3/index.json --api-key $KEY
dotnet nuget push ./artifacts/*.snupkg --source https://api.nuget.org/v3/index.json --api-key $KEY

# 用本地源验证包内容
dotnet nuget add source ./artifacts -n local
dotnet add package Company.Orders.Client --source ./artifacts

5. 私有源与发布流程

一句话总结: 内部包应放在私有源而非公共 NuGet.org,源地址与凭据通过 nuget.config 与 CI 密钥管理,绝不明文写入仓库。

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <packageSources>
    <clear />
    <add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
    <add key="company" value="https://pkgs.example.com/v3/index.json" />
  </packageSources>

  <packageSourceCredentials>
    <company>
      <!-- 值从环境变量读取,不落盘明文 -->
      <add key="Username" value="%NUGET_USER%" />
      <add key="ClearTextPassword" value="%NUGET_TOKEN%" />
    </company>
  </packageSourceCredentials>
</configuration>

<clear /> 会清空继承的源配置,保证构建可复现。私有源可用 Azure Artifacts、GitHub Packages、Artifactory 或自建 BaGet,选择依据是团队已有的制品管理体系。

5.1 CI 中的自动发布

一句话总结: 打包与发布应在打标签时自动触发,版本由 Git 标签推导,凭据从密钥仓库注入,发布前先跑完整测试。

name: publish
on:
  push:
    tags: ['v*']

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0   # MinVer 需要完整历史
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: 9.0.x
      - run: dotnet test -c Release
      - run: dotnet pack -c Release -o ./artifacts
      - run: >
          dotnet nuget push ./artifacts/*.nupkg
          --source https://api.nuget.org/v3/index.json
          --api-key ${{ secrets.NUGET_API_KEY }}
          --skip-duplicate

--skip-duplicate 让重复推送同一版本不会失败,适合重跑流水线的场景。fetch-depth: 0 是 MinVer 这类从 Git 历史推导版本的工具的必要条件。

6. 包体积与依赖治理

一句话总结: 包越小、依赖越少、传递面越窄,使用者的升级成本越低;用 IsTrimmable 与依赖裁剪把成本降到最低。

常见瘦身手段:

  • 拆分大包为按功能划分的小包,使用者只引入需要的部分。
  • 避免不必要的传递依赖,能内联的实现就内联。
  • 只发布必要目标框架,若使用者都在 .NET 8 以上,就不必再产 netstandard2.0。
  • 标记 IsTrimmable 让使用者可以安全裁剪。
<PropertyGroup>
  <IsTrimmable>true</IsTrimmable>
  <EnableTrimAnalyzer>true</EnableTrimAnalyzer>
  <IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>

IsAotCompatible 会开启 AOT 兼容性分析器,编译期就报出反射、动态代码生成等不兼容用法。对于要在 Native AOT 场景使用的库,这个开关是必备的质量门槛。

6.1 兼容性检查

一句话总结: 用 Microsoft.CodeAnalysis.PublicApiAnalyzers 把公开 API 快照纳入版本控制,任何破坏性变更都会在 PR 阶段被拦截。

<ItemGroup>
  <PackageReference Include="Microsoft.CodeAnalysis.PublicApiAnalyzers" Version="3.3.4"
                    PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
  <AdditionalFiles Include="PublicAPI.Shipped.txt" />
  <AdditionalFiles Include="PublicAPI.Unshipped.txt" />
</ItemGroup>

新增或删除公开成员时,分析器会要求同步更新这两个文件,评审时破坏性变更一目了然。这是维护长期被广泛引用的库时最有效的护栏之一。

7. 工程实践与陷阱

一句话总结: 打包问题多集中在版本号冲突、误打包测试文件与依赖版本过旧三类,靠 CI 检查与本地源验证可以提前拦截。

第一,避免误打包。测试项目与示例项目必须显式标记不可打包:

<PropertyGroup>
  <IsPackable>false</IsPackable>
</PropertyGroup>

第二,统一依赖版本。多项目仓库中同一个依赖的不同版本会引发程序集绑定冲突,用 Directory.Packages.props 集中管理:

<Project>
  <PropertyGroup>
    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
  </PropertyGroup>
  <ItemGroup>
    <PackageVersion Include="System.Text.Json" Version="9.0.0" />
    <PackageVersion Include="Microsoft.Extensions.Http" Version="9.0.0" />
  </ItemGroup>
</Project>

此后各项目只写 <PackageReference Include="System.Text.Json" />,不写版本号,全仓库统一升级。

第三,包内容要检查。把包解开逐项确认,是发现多余文件与缺失文件最快的办法:

# 列出包内文件
unzip -l ./artifacts/Company.Orders.Client.1.0.0.nupkg

# 确认依赖声明
unzip -p ./artifacts/Company.Orders.Client.1.0.0.nupkg \
  Company.Orders.Client.nuspec

第四,README 与许可证必须存在。NuGet.org 会把 README 渲染到包详情页,缺少许可证的包在企业环境中通常被直接拒绝使用。

<PropertyGroup>
  <PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup>
<ItemGroup>
  <None Include="README.md" Pack="true" PackagePath="\" />
</ItemGroup>

第五,谨慎使用浮动版本。Version="9.*" 会让每次还原拉到不同版本,构建不可复现;只在确有需要的场景使用,且应配合锁定文件。

<PropertyGroup>
  <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>

8. 总结

环节要点
元数据PackageId、Version、Authors、Description 与许可证是必填核心
版本策略遵循 SemVer,同一版本号不可重复推送,用 Git 标签自动推导
依赖声明分析器与打包工具一律 PrivateAssets=all,避免污染使用者
多目标netstandard2.0 保兼容,新框架走条件编译,测试须覆盖全部框架
符号与源链接snupkg 加 SourceLink 才能单步调试到源码,发布前实测验证
私有源凭据走环境变量与 CI 密钥,packageSources 用 clear 保证可复现
治理IsTrimmable 与 IsAotCompatible 提升质量,公开 API 快照防破坏性变更

打包与发布是把代码变成产品的最后一段路,也是最容易被忽视的一段。元数据决定了包能否被信任,版本策略决定了升级是否顺畅,符号与源链接决定了排障体验,而多目标与依赖治理决定了使用者的迁移成本。把这些环节都纳入 CI 自动执行,库的质量就不再依赖某个人记得做对每一步。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「csharp」更多文章

  1. .NET 机器学习实战
  2. 内存剖析与 dump 分析
  3. 分布式事务与 Saga 编排