开头直接切入:NuGet打包这事儿,命令看着不多,但实际用起来坑不少。很多.NET开发者第一次发包,都会在“nuget打包常用命令”这类关键词里翻到一堆零散的指令,贴过来一跑,要么版本号对不上,要么包打进私服后同事引用不了。这篇文章我就把打包这条链路从头到尾梳理一遍,从命令背后的原理,到工程文件该怎么配,再到发布后怎么验证,一次性讲透。
先交代一下适用人群:如果你在维护一个会被多个项目引用的类库,或者要把公司内部的通用组件发布到私有源,又或者准备把开源项目发到NuGet.org,这篇文章值得你从头看完。如果你是第一次接触打包,我保证你用最普通的命令行就能完成整个流程,不依赖任何IDE图形界面。
1. 打包的本质:一个.nupkg文件里到底装了什么
很多人在打包时会有一个误解:以为打包就是把项目“发布”一下。实际上,NuGet打包做的是另外一件事——把编译产物(dll、pdb、xml等)连同包的元数据(名称、版本、依赖、作者、图标)压缩成一个后缀为.nupkg的文件。这个文件本质上就是一个zip压缩包,只是NuGet客户端能识别它的内部结构。
我第一次带新人时,最喜欢让他们做一个实验:随便找一个.nupkg文件,把后缀改成.zip然后解压,里面大概长这样:
MyLib.nuspec lib/netstandard2.0/MyLib.dll lib/netstandard2.0/MyLib.pdb lib/netstandard2.0/MyLib.xmlMyLib.nuspec是一个XML清单文件,记录了这个包的“身份证信息”——包名、版本号、作者、依赖项列表、文件清单。lib目录下则按目标框架(Target Framework)分门别类地放着编译产物。
1.1 NuGet对包内目录的约定
NuGet对.nupkg内部有几个约定俗成的目录,搞清楚这些,后面排查很多问题会容易得多。
| 目录路径 | 作用 | 说明 |
|---|---|---|
lib/{tfm}/ | 编译时和运行时程序集 | 最常见的目录,tfm指目标框架,如netstandard2.0、net6.0 |
ref/{tfm}/ | 仅编译期引用的程序集 | 可以在不加载实现的情况下提供编译支持,用得较少 |
build/{tfm}/ | MSBuild的props/targets文件 | 需要在编译期注入属性和目标时使用 |
runtimes/{rid}/ | 平台相关的原生运行库 | 比如win-x64下的native dll |
contentFiles/ | 随包注入项目的内容文件 | 较老的用法,新项目不太推荐 |
tools/ | 安装包时执行的脚本或工具 | 部分场景会用,需要注意安全审查 |
NuGet会把项目引用的包中lib目录下最匹配目标框架的dll取出来,加入编译和运行时搜索路径。所以如果你发现引用了包但编译时找不到类型,大概率就是包里的lib目录框架与你项目不匹配。
1.2 dotnet pack和nuget pack,两条打包路线怎么选
这是命令行操作前必须先定的路线。简单说:
dotnet pack:现代SDK风格项目(即带<Project Sdk="Microsoft.NET.Sdk">的csproj)的首选,跨平台,无需额外安装,构建和打包一体,是当前的主流。nuget pack:NuGet.exe命令行工具提供的命令,适用于老式非SDK风格项目,或者当你需要直接操作.nuspec文件做精细控制时。NuGet.exe需要单独下载,在Windows下使用体验最好。
IDE里那个“打包”按钮,底层触发的其实就是MSBuild的Pack目标,这条链路和dotnet pack是同一条,所以你在命令行里做的事情和IDE里做的没有本质区别,只是命令行能加的参数更多、更容易自动化。
2. 打包之前,工程文件里必须做对的事
命令本身不难,难的是工程文件没配好。一个csproj里如果缺少必要的Package*属性,打出来的包在NuGet页面上会很难看,甚至缺依赖、缺图标、缺文档,直接影响使用者体验。
2.1 SDK风格项目里的核心打包属性
以下这些属性是打包时最常用的,建议在csproj的<PropertyGroup>里配好:
<PropertyGroup> <PackageId>MyCompany.CoolLib</PackageId> <Version>1.2.0</Version> <Authors>张三</Authors> <Description>一个用于演示NuGet打包流程的示例类库</Description> <PackageTags>utils;demo;nuget</PackageTags> <PackageProjectUrl>https://github.com/yourname/coolib</PackageProjectUrl> <RepositoryUrl>https://github.com/yourname/coolib.git</RepositoryUrl> <PackageLicenseExpression>MIT</PackageLicenseExpression> <PackageIcon>icon.png</PackageIcon> <PackageReadmeFile>README.md</PackageReadmeFile> </PropertyGroup>这些属性会在打包时自动写入.nuspec文件。PackageId决定了包显示在NuGet站点上的名字,不设置的话会取AssemblyName。Version就是包的版本号,它和程序集的AssemblyVersion是两回事,这一点特别容易踩坑,我后面专门讲。
2.2 版本号怎么在不翻车的情况下统一
版本号是最容易埋雷的地方。很多项目在csproj里只设置了<Version>1.2.0</Version>,但程序集的AssemblyVersion可能还停留在1.0.0.0。这种做法短期内没毛病,可一旦某个依赖了该程序集的强名称引用,运行时就可能报FileLoadException或者“无法加载文件或程序集”的错误,因为程序集的实际版本和引用版本对不上。
我的建议是在仓库根目录放一个Directory.Build.props,把版本号统一管理起来:
<Project> <PropertyGroup> <Version>1.2.0</Version> <AssemblyVersion>1.2.0.0</AssemblyVersion> <FileVersion>1.2.0.0</FileVersion> </PropertyGroup> </Project>这样仓库内所有项目都会自动继承这个版本号,不需要在每个csproj里重复写。SDK风格项目默认会根据<Version>生成AssemblyInfo,但如果你遇到版本对不上的问题,最直接的办法是手动指定<AssemblyVersion>,再在输出dll上右键查看文件属性确认。
注意:
<Version>、<AssemblyVersion>、<FileVersion>三者语义不同。<Version>决定NuGet包版本号;<AssemblyVersion>是CLR加载程序集时校验的版本;<FileVersion>是Windows文件属性里显示的版本。三者不要求一致,但还是建议保持同步,能少很多莫名其妙的问题。
2.3 什么时候才需要手写nuspec
现代SDK风格项目完全可以不写.nuspec,dotnet pack会自动根据csproj内容生成。但有两种情况,手写.nuspec更合适:
- 老式非SDK风格项目:比如传统的
packages.config项目,csproj里没有SDK风格的那些打包属性,此时用nuget pack xxx.csproj不如直接维护一个.nuspec文件清晰。 - 需要精细控制包内文件:比如想把多个项目的dll合并到一个包里,或者需要在包根目录放一些特殊文件,手动写
.nuspec的控制力就体现出来了。
一个简化版的.nuspec长这样:
<?xml version="1.0" encoding="utf-8"?> <package> <metadata> <id>MyCompany.CoolLib</id> <version>1.2.0</version> <authors>张三</authors> <description>一个示例包</description> <dependencies> <group targetFramework=".NETStandard2.0"> <dependency id="Newtonsoft.Json" version="13.0.1" /> </group> </dependencies> </metadata> <files> <file src="bin/Release/netstandard2.0/MyCompany.CoolLib.dll" target="lib/netstandard2.0" /> </files> </package>如果项目不复杂,我仍然推荐用SDK风格项目加dotnet pack,省心得多。
3. 常用打包命令逐个拆解
先给一份我实际工作中用得最多的命令速查表,然后逐个解释关键参数。表格前面有dotnet pack,后面有nuget pack,两条路线分开看。
3.1 dotnet pack 常用参数清单
| 命令示例 | 作用 |
|---|---|
dotnet pack MyLib.csproj -c Release -o ./artifacts | 以Release配置打包,输出到artifacts目录 |
dotnet pack -p:PackageVersion=2.0.0 | 临时指定包版本号,不修改csproj |
dotnet pack --include-symbols --include-source | 生成包含源码和符号的包 |
dotnet pack --no-build | 跳过编译,直接基于上次build产物打包 |
dotnet pack -v m | 输出最简日志,适合在CI里看 |
解释几个容易忽略的:
-o / --output:指定输出目录。不指定的话,默认输出到项目根目录下的bin/Release/里,会在目录里混入一堆其他文件,所以建议每次都指定。-p / --property:可以临时覆盖MSBuild属性。典型场景是CI打包时想临时改版本号,不动csproj文件。--no-build:在已经编译过的情况下,可以跳过编译直接打包,能省几秒到几十秒。但如果代码改了却忘了重新编译,打出来的就是旧产物,我自己就吃过这个亏。--include-symbols:生成符号包。如果不配合--symbol-package-format使用,默认可能是.symbols.nupkg老格式,推荐用-p:SymbolPackageFormat=snupkg生成.snupkg。
3.2 nuget pack 常用参数清单
| 命令示例 | 作用 |
|---|---|
nuget pack MyLib.csproj -Properties Configuration=Release -OutputDirectory ./artifacts | 指定配置和输出目录 |
nuget pack MyLib.csproj -Version 2.0.0 | 打包时直接指定版本号 |
nuget pack MyLib.nuspec | 直接根据nuspec文件打包 |
nuget pack MyLib.csproj -IncludeReferencedProjects | 把引用的项目也一起打进来 |
nuget pack MyLib.csproj -Symbols -SymbolPackageFormat snupkg | 生成snupkg符号包 |
nuget pack和dotnet pack最直观的差异是:nuget pack更贴近“操作.nuspec文件”的思维,它允许你直接在命令行指定版本号,并且对老项目的兼容性更好。但如果你已经用SDK风格项目了,我还是建议优先用dotnet pack,不需要额外下载NuGet.exe。
3.3 从csproj打包和从nuspec打包,到底有什么区别
从csproj打包时,NuGet/MSBuild会“读取项目文件 → 生成nuspec → 执行打包”,这一过程会动态收集依赖、文件、属性。从nuspec打包时,命令行只认你写好的.nuspec文件,不会帮你动态补全依赖和文件列表。
这意味着:如果你从nuspec打包但nuspec里的文件路径不对,或者依赖版本写错,那打出来的包就是错的,而且没有任何警告。所以我的经验是:能用csproj打包就不手写nuspec;必须用nuspec时,务必先在本地解压成品包检查一遍。
3.4 一条常用命令组合:本地一键打包并验证
我一般在本地会反复敲这几条命令,形成一个简单的“打包闭环”:
dotnet clean MyLib.csproj -c Release dotnet pack MyLib.csproj -c Release -o ./artifacts -p:PackageVersion=1.2.0 unzip -l ./artifacts/MyLib.1.2.0.nupkg先清洁,再打包,最后看一眼nupkg里的文件列表。unzip -l在Windows也可以换成tar -tf(Win10以上自带),或者直接用NuGet Package Explorer打开查看。这一步能避免很多“以为自己打了包但内容不对”的情况。
4. 我在实际打包中踩过的五个坑
命令背得再熟,不踩几个坑很难真正理解NuGet打包。下面这些是我和团队在实际使用中真实遇到的问题,每一个都带症状、排查思路和解决办法。
4.1 坑一:AssemblyVersion和NuGet包版本对不上,运行时加载异常
症状:包打好了,也能引用了,项目编译也通过,但程序运行到某个方法时突然抛出System.IO.FileLoadException,提示“无法加载文件或程序集……所检索到的程序集版本与所引用的程序集版本不匹配”。
排查链路:我当时第一反应是包损坏或引用错误,但干净环境重装包后问题依旧。后来打开bin目录下的dll,右键看属性,再切到“详细信息”选项卡,发现文件版本是1.0.0.0,而我们引用的包版本是1.2.0,CLR发现引用的程序集版本和实际加载的不一致,直接拒绝加载。
解决办法:在Directory.Build.props里把三个版本号全部固定住,然后用dotnet pack -p:PackageVersion=1.2.0统一打包。这样NuGet包版本、程序集版本、文件版本保持一致,CLR加载时就不会再挑刺。
4.2 坑二:包内混入垃圾文件,导致体积异常大或加载冲突
症状:有次同事发我一个包,只有几个类,但nupkg体积有几十MB。解压一看,里面除了dll,还有大量*.pdb、*.xml之外的临时文件,甚至包含测试工程生成的文件。
排查链路:这种情况多半是csproj里写了过于“贪婪”的<Content>或<None>包含规则,比如:
<ItemGroup> <Content Include="**/*" /> </ItemGroup>一旦这样写,打包时所有匹配的文件都会被当成内容包进去。SDK风格项目默认打包的是编译输出,不会把obj、bin下的中间产物打进去,但如果你手动Include了乱七八糟的文件,就会把垃圾也带进包里。
解决办法:检查csproj里的ItemGroup,把不需要的内容排除掉,或者给对应的文件加上Pack="false":
<ItemGroup> <Content Include="test/**" Pack="false" /> <None Include="*.user" Pack="false" /> </ItemGroup>打包后在本地用unzip -l看文件列表,确认只有需要的内容。
4.3 坑三:依赖项没有包含在包里,用户一装就报缺少程序集
症状:包发布后,同事在另一个项目里dotnet add package MyLib,结果编译报错,说什么类型找不到,或者运行时提示缺少Newtonsoft.Json。
排查链路:NuGet包的依赖不是“把第三方dll嵌入你的包”,而是通过nuspec里的dependencies节点告诉NuGet“我依赖谁”。SDK风格项目打包时,会把.csproj里的<PackageReference>自动转换到nuspec的依赖组里。如果你解决依赖的方式是“直接引用dll文件”而不是“PackageReference”,那这个依赖就不会出现在包信息里,用户自然装不上。
解决办法:所有运行时依赖的第三方包,全部改为<PackageReference>形式引用。如果只是编译期使用,不需要传给使用方,可以加上PrivateAssets="all":
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" /> <PackageReference Include="Internal.Tool" Version="1.0.0" PrivateAssets="all" />打包后可以打开nupkg里的nuspec文件,检查dependencies节点是否符合预期。
4.4 坑四:图标和README没有正确进包,NuGet页面一片空白
症状:包推到NuGet.org之后,页面上的图标不显示,README也不显示,看起来非常不专业。
排查链路:PackageIcon和PackageReadmeFile虽然设置了属性,但如果图标文件和README文件本身没有被包含在包里,NuGet.org就找不到文件来展示。SDK风格项目里,这些文件默认不一定作为打包内容包含。
解决办法:在csproj里显式把它们标记为打包内容:
<ItemGroup> <None Include="icon.png" Pack="true" PackagePath="\" /> <None Include="README.md" Pack="true" PackagePath="\" /> </ItemGroup>同时记得设置:
<PropertyGroup> <PackageIcon>icon.png</PackageIcon> <PackageReadmeFile>README.md</PackageReadmeFile> </PropertyGroup>这样打包后,icon.png和README.md会出现在包根目录,NuGet.org才能正确读取。
4.5 坑五:push到私有源时,被NuGet.Config里的其他源干扰
症状:公司内部搭了私有NuGet源,但执行dotnet nuget push时提示401 Unauthorized,或者莫名其妙把包传到了nuget.org。
排查链路:dotnet nuget push如果不带-s参数,会读取NuGet.Config里配置的默认源。而很多机器上的NuGet.Config里配置了多个源,甚至默认源是nuget.org,API Key不匹配就导致认证失败。
解决办法:推送时始终显式指定源地址和API Key,不要依赖默认配置:
dotnet nuget push ./artifacts/MyLib.1.2.0.nupkg \ -k <你的APIKey> \ -s https://api.nuget.org/v3/index.json推送私有源时同理,把-s换成私有源地址。
另外一个小习惯:推送到私有源之前,先确认同一个版本号没有被推过。NuGet源是不允许重复版本号的,一旦推上去再想覆盖,就得先删除或unlist旧版本,这对使用者来说非常烦。
5. 发布和验证:包推上去之后,必须做的一次“买家视角”检查
打包只是第一步,发布和验证才是确定这个包“能用”的最终关口。很多人打完包直接推到线上,结果使用者装了一堆问题,然后回来说“你的包是坏的”。与其这样,不如自己在发布前多花两分钟做一次完整验证。
5.1 推到NuGet.org的命令与前置条件
推到NuGet.org需要先在网站注册账号,然后在个人账户里创建API Key。推送命令:
dotnet nuget push ./artifacts/MyLib.1.2.0.nupkg \ -k <你的APIKey> \ -s https://api.nuget.org/v3/index.json注意这里的源是https://api.nuget.org/v3/index.json,这是NuGet服务的V3 API地址,dotnet nuget push默认会走这个地址,但显式写出来更保险。
5.2 推到私有源或本地目录
公司内部通常会用本地目录、局域网共享、或Artifactory等工具搭建私有源。最轻量的方式之一是本地目录源:
dotnet nuget push ./artifacts/MyLib.1.2.0.nupkg -s D:\nuget-feed然后在Visual Studio的包管理器中添加这个本地目录为包源,就能像使用nuget.org一样安装这个包。这个方法特别适合跨团队快速共享内部组件,不用搭复杂的服务端。
5.3 发布后的验证清单
新建一个干净的项目来测试安装,这是最直接有效的验证方式:
dotnet new console -n TestPackage cd TestPackage dotnet add package MyLib --version 1.2.0 -s D:\nuget-feed dotnet run这一步能测出依赖是否完整、目标框架是否兼容、API是否能正常调用。
检查包缓存:NuGet在设计上会在本地缓存下载过的包。如果你改了包又用同一个版本号重新推,本地可能还留着旧版缓存,导致你验证时装的还是旧包。这是非常容易被忽视的问题,遇到“明明改了却不生效”的怪事,先清一下缓存:
dotnet nuget locals all --clear用NuGet Package Explorer查看:如果是在Windows上,推荐装一个NuGet Package Explorer,它能图形化显示包内的文件、依赖、元数据,检查起来非常直观。命令行党也可以用unzip -l或tar -tf查看包结构。
6. 进阶:把打包做进日常开发流
当你不满足于“手动在命令行敲命令打包”,就可以考虑把打包过程自动化,并为使用者提供更好的调试体验。
6.1 SourceLink和符号包:让使用者在调试时能进入你的包源码
如果你的包是开源的,强烈建议启用SourceLink。这样使用者在调试时按F11,就能直接跳进你的源码,而不是对着反编译代码干瞪眼。
启用方式是在csproj里添加:
<PropertyGroup> <PublishRepositoryUrl>true</PublishRepositoryUrl> <EmbedUntrackedSources>true</EmbedUntrackedSources> <DebugType>portable</DebugType> </PropertyGroup> <ItemGroup> <PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0" PrivateAssets="all" /> </ItemGroup>然后打包并推送符号包:
dotnet pack MyLib.csproj -c Release -o ./artifacts -p:SymbolPackageFormat=snupkg dotnet nuget push ./artifacts/MyLib.1.2.0.snupkg \ -k <你的APIKey> \ -s https://api.nuget.org/v3/index.json注意符号包的版本号要和主包一致,否则NuGet无法关联。
6.2 多目标框架打包:一个包兼容多个.NET版本
当你维护的类库需要同时被.NET Framework 4.7.2、.NET 6、.NET 8项目引用时,可以用多目标框架打包:
<PropertyGroup> <TargetFrameworks>netstandard2.0;net6.0;net8.0</TargetFrameworks> </PropertyGroup>打包后,lib目录下会生成:
lib/netstandard2.0/MyLib.dll lib/net6.0/MyLib.dll lib/net8.0/MyLib.dllNuGet在安装时会根据使用者项目的目标框架选择最合适的dll,谁的版本与目标框架最接近就用谁的。这里的基本原则是:
| 使用者目标框架 | NuGet选择 |
|---|---|
| net8.0 | 优先匹配net8.0,其次net6.0,再其次netstandard2.0 |
| net7.0 | 优先匹配net6.0(不匹配net8.0因为版本更高),其次netstandard2.0 |
| .NET Framework 4.8 | 优先匹配netstandard2.0 |
多目标框架不是越多越好,每多一个目标框架,你就要多维护一份API兼容性测试。比较稳健的起步组合是netstandard2.0加当前主力版本(比如net8.0)。
6.3 CI里自动打包:把命令写进流水线
手动打包适合本地验证,正式发布建议交给CI/CD。这里以GitHub Actions为例,核心就两步:
- name: Pack run: dotnet pack ./src/MyLib/MyLib.csproj -c Release -o artifacts -p:Version=${GITHUB_REF_NAME} - name: Push run: dotnet nuget push "artifacts/*.nupkg" -k ${{ secrets.NUGET_API_KEY }} -s https://api.nuget.org/v3/index.json这里有几个细节值得注意:
- 版本号来自Git标签:
GITHUB_REF_NAME就是当前tag名,比如v1.2.0,但要注意NuGet版本号不允许v前缀,最好在推送前做一次字符串处理。 - Secrets管理:API Key一定不要硬编码在yml里,用仓库的Secrets配置。
- 幂等性:同一tag重复跑流水线时,第二次推送会失败,因为包版本已存在。可以在Push步骤前加一个判断,或者接受失败并视为“已发布”。
个人经验:就算没有完整CI,也至少把dotnet pack和dotnet nuget push脚本化保存下来,不要每次手工敲一堆长命令。
最后再分享一个小技巧。我本地每次打包前习惯先跑一次dotnet nuget locals all --clear,把自己的全局包缓存清干净,确保后续验证安装时是从当前推送的源里拉的最新包,而不是被本地缓存误导。很多“改了没生效”的诡异问题,最后都发现是缓存惹的祸。如果你也在为NuGet包的“旧版本残留”头疼,不妨先试试这个动作,再回头排查命令和配置。