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 自动执行,库的质量就不再依赖某个人记得做对每一步。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。