写在前面
.NET 生态有大量 NuGet 包,几乎每个项目都靠它们搭起来。但“一个项目到底依赖了哪些包”这件最基础的事,.NET 十几年间经历了从 packages.config 到 PackageReference,再到在 PackageReference 之上增加 Central Package Management 的演进。很多人对前两者的认知停留在“一个老一个新”,却没注意到:它们不只是文件位置不同,还代表两种依赖状态管理方式。
依赖清单该“记录已安装结果”还是“声明版本约束”? 把每个传递依赖的已安装版本都写进清单,结果直观,却要维护一份扁平列表;只声明直接依赖、让工具还原传递图,清单更简洁,但最终版本需要通过资产文件或锁文件审计。
理解了这层张力,就能看清 NuGet 包管理的演进:packages.config 保存安装后的扁平结果,PackageReference 声明顶层依赖并在 restore 时维护传递图,Central Package Management(CPM)再把 PackageReference 的版本声明集中到仓库级文件。
一、直接依赖 vs 传递依赖:分歧的起点
在讲格式之前,先把一个概念钉死:你的项目依赖的包,分两类。
- 直接依赖(direct dependency):你在代码里
using的、你主动装的那个包。你清楚地知道它的存在。 - 传递依赖(transitive dependency):你的直接依赖自己又依赖的包。它们“搭便车”进来,你通常看不见、也不关心——直到它出了问题。
一棵最小的依赖树长这样:
| |
packages.config 和 PackageReference 的根本分歧,就从这棵树开始:这两类依赖,谁来记录?什么时候求解? packages.config 把安装 / 更新时得到的直接与传递依赖版本都平铺记录下来;PackageReference 只保留顶层声明,让 restore 维护完整依赖闭包。
二、前世 packages.config:保存扁平的安装结果
packages.config 是 NuGet 1.0 时代的原始格式;NuGet 1.0 发布于 2011 年 1 月 13 日。它是一个 per-project(每个项目一个)的 XML 文件,内容是该项目已经安装的依赖清单:
| |
关键特征:当你安装一个包时,NuGet 会把这个包 连同它的传递依赖 一起装进项目,并全部写进 packages.config。于是这个清单是“扁平”的——直接依赖和传递依赖混在一起,平铺成一长串。
这些包被物理解压到一个 解决方案级(solution-level)的 packages/ 文件夹里,所有项目共用:
| |
而项目是怎么“引用”这些 DLL 的?是直接在 .csproj 里手写 HintPath(提示路径),指向 packages/ 里的具体文件:
| |
安装与还原:求解发生在安装时,结果写进扁平清单
packages.config 的还原(restore)极其直观,甚至有点“笨”:
| |
这里必须区分 install / update 和 restore:
- 安装或更新包时,NuGet 会读取包的
.nuspec,解析依赖和版本约束,把选中的直接依赖与传递依赖全部平铺写入packages.config,并同步.csproj引用; - 后续 restore 主要按照这份已经解析好的扁平清单恢复确切版本,不像 PackageReference 那样根据顶层声明重新计算完整传递图;
- 当前 NuGet 可能先从 global-packages 文件夹取得包,再复制到由
repositoryPath指定的目录;传统默认位置通常是解决方案的packages/。
一句话:packages.config 不是“从来不求解”,而是安装 / 更新时求解,restore 时按持久化结果恢复。PackageReference 则把完整依赖闭包的计算放进每次需要重新评估的 restore。
在 2011 年的 .NET Framework 世界里,这个设计很直观:那时没有今天的 SDK 风格项目和跨平台 .NET,把安装结果摊平并写进项目,是符合当时 Visual Studio 工程模型的选择。
痛点全景:精确的代价
但“把完整安装结果平铺进项目清单”这条路,会越走越痛:
- 版本冲突(diamond dependency):项目同时依赖对
CommonLib有不同约束的包。NuGet 会在安装 / 更新时尝试解析出一个版本并把结果平铺进清单;如果约束无法兼容,仍需升级顶层包、调整版本范围或显式选择版本。扁平结果也掩盖了“这个包是谁带进来的”。 - 合并冲突:
packages.config和.csproj可能同时变化,多人并行安装 / 更新包时更容易发生冲突。 packages/的两难:提交进版本库?仓库瞬间膨胀几百 MB;不提交?换台机器、CI 上还得先 restore。两难。- 多项目重复:每个项目都拖着自己的
packages.config和一整套HintPath,同一个包在不同项目里版本漂移是家常便饭。 - 传递依赖升级不直观:通常应通过 NuGet UI、
Update-Package等工具更新,而不是手改 XML;但扁平清单很难看出依赖来源,升级时仍要同时关注packages.config、项目引用和绑定重定向。
这些痛点的共同根源,是把安装后的完整结果持久化成一份需要维护的扁平清单。它记录了确切版本,但真正复现还取决于包源、目标框架、NuGet 配置,以及安装脚本 / 内容转换等副作用;“记录确切版本”不自动等于供应链意义上的完全可复现。
三、今生 PackageReference:只声明直接依赖
PackageReference 随 NuGet 4.0 / Visual Studio 2017 / .NET Core SDK 项目登场。它的核心改变只有一句话:引用直接写进 .csproj,而且只写直接依赖。 传递依赖在还原时被自动求解。
| |
注意:Polly.Core(Polly 的传递依赖)不再出现在任何清单里——它在还原时被自动算出来。
包也不再散落在解决方案的 packages/ 文件夹,而是统一住在 全局包缓存(global packages folder)里,所有解决方案、所有项目共享:
| |
一个要权衡的好处:在同一用户、同一 global-packages 配置下,同一 id + version 的包通常只展开一份,多个项目直接复用;代价是依赖的真实形态不在项目目录里,得通过工具或资产文件查看。不同用户、容器、CI Agent 或自定义
NUGET_PACKAGES仍可能各有一份缓存。
还原机制深挖:从“逐包拷贝”到“图求解”
这是 PackageReference 的真正核心,也是它和 packages.config 最本质的区别。还原不再读一个扁平清单,而是 求解一张依赖图,产物是 project.assets.json:
| |
project.assets.json 是整个还原的灵魂——它把求解结果按“用途”拆开,告诉 MSBuild 哪些 DLL 该喂给编译器、哪些该拷进输出目录、哪些该当分析器跑。看一个精简后的片段:
| |
设计点:
libraries是“涉及到的全部包”的元数据目录,targets是“在某个目标框架下,这些包按用途如何归类”。这种 包元数据 / 引用用途 的分离,正是 PackageReference 能优雅支持多目标框架(multi-target)的根基——同一份声明,按框架求解出不同的引用集。
版本解析规则:不是一句“最低版本”就能概括
PackageReference 的传递还原主要有四条规则,不能只记“最低版本”:
- 最低适用版本(lowest applicable version):在某个依赖约束下,优先选择可用的最低版本;
- 浮动版本(floating versions):使用
*明确请求匹配范围内的较新版本; - 直接依赖优先(direct-dependency-wins):当前子图里的直接引用可以覆盖传递依赖选择,发生降级时会产生 NU1605;
- 同层 / 旁系依赖(cousin dependencies):来自不同子图的约束合并后,选择满足它们的最低版本。
回到那个钻石依赖的例子:
| |
在这个没有直接引用覆盖的简单例子里,两个旁系约束合并为 >= 2.0,因此选择可用的最低版本 2.0。但如果应用自己直接引用 CommonLib 1.0,直接依赖优先规则可能选择 1.0 并报告 NU1605;如果上下界根本没有交集,则还原失败。最终规则作用于依赖子图,不是机械地把全图所有版本号取最大或最小。
几个常见的冲突信号(看还原日志时的关键词):
- NU1605(版本降级,downgrade):你直接锁了
CommonLib 1.0,但某个传递依赖要>= 2.0。NuGet 会警告你“检测到降级”,并要求你把直接引用抬到2.0。 - NU1603(找不到依赖的预期下界):例如包声明
Y (>= 4.0.0),源里没有 4.0.0,NuGet 改用 5.0.0 等更高的近似匹配并发出警告。若包 id 根本不存在,通常应查看 NU1101; - NU1107(版本约束冲突):两个依赖要求互不兼容的版本,无法正常求解,需要由顶层项目显式选择可接受版本或升级相关包;
- NU1201 / NU1202 等兼容性错误:项目或包与当前目标框架不兼容,应检查 TFM 和包提供的资产,而不是把它和版本冲突混为一谈。
一句话:packages.config 的
version="13.0.1"记录当前已安装版本;PackageReference 的Version="13.0.1"是 restore 输入,表示最低版本 13.0.1,并受直接依赖优先等规则影响。前者保存结果,后者参与求解。
得与失
PackageReference 赢得了简洁、多目标的优雅、磁盘复用,但也付出代价:你不再一眼看见项目最终用了哪些传递依赖、什么版本。 答案藏进 project.assets.json,得靠工具还原出来:
| |
固化求解结果的钥匙:设置
RestorePackagesWithLockFile=true会生成packages.lock.json。对应用程序,应把锁文件提交到版本库,并在 CI 使用dotnet restore --locked-mode(或RestoreLockedMode=true);这样输入与锁文件不一致时直接失败,而不是悄悄更新。普通 restore 在依赖输入变化时可以重新求解并改写锁文件。类库自己的锁文件也无法强制最终消费项目沿用同一套传递版本,因此要按项目角色决定是否提交。
四、两张图看懂差别
把前面散落的事实收拢成一张表:
| 维度 | packages.config | PackageReference |
|---|---|---|
| 清单位置 | 独立 packages.config XML 文件 | 嵌入 .csproj 的 <PackageReference> |
| 是否列出传递依赖 | 是(全列,扁平) | 否(只直接依赖) |
| 包的物理存储 | 解决方案级 packages/ 文件夹 | 全局缓存 ~/.nuget/packages |
| 还原产物 | packages/ 文件夹(逐包拷贝) | obj/project.assets.json(求解图) |
| 版本语义 | version 记录已安装版本;allowedVersions 可限制更新 | Version 是还原输入,支持最低版本、范围和浮动版本 |
| 多目标框架 | 不是一等公民,传统项目通常单目标 | 一等公民(可按 TFM 条件引用并分别求解) |
| 是否进版本库 | packages/ 两难 | 无需提交(全局缓存) |
| 老 .NET Framework | 默认 | 需从 packages.config 迁移 |
| C++ 项目 | 支持 | 不支持(仍只能 packages.config) |
再看还原流程的并排对比,差别一目了然:
| |
五、迁移实战:从 packages.config 到 PackageReference
如果你的项目还停在 packages.config(典型的老 .NET Framework 工程),迁移到 PackageReference 有官方路径,但坑不少。
VS 内置迁移工具
在 Visual Studio 里:右键 packages.config → “将 packages.config 迁移到 PackageReference”(Migrate packages.config to PackageReference)。工具会:
- 读
packages.config,结合包元数据,尝试区分出哪些是直接依赖、哪些是传递依赖; - 把直接依赖写成
.csproj里的<PackageReference>; - 删掉旧的
packages.config和.csproj里手写的<HintPath>引用; - 把传递依赖交给求解器接管。
真实坑:迁移不是一键无脑的
迁移工具能处理大多数情况,但这几类包行为会变,需要你手动盯:
- content / contentFiles 行为不同:packages.config 时代,包可以把文件塞进项目的
content/(直接拷进你的源码目录)。PackageReference 不再这样做,改用 nuspec 里的contentFiles声明(文件是只读的、按规则注入)。老包如果依赖往content/拷文件,迁移后会“消失”。 developmentDependency的去向:packages.config 用developmentDependency="true"影响打包时的依赖传播。PackageReference 中常用PrivateAssets="all"表达“只供当前项目使用、不向下游传播”,目标相近但不是所有资产语义的一一等价。迁移器还会把含build、buildCrossTargeting、contentFiles、analyzers或developmentDependency=true的包保留为顶层引用,因为这些资产不一定能靠传递依赖正确流动:
| |
- build
*.props/*.targets的注入:包里自带的 MSBuild props/targets 在 PackageReference 下是自动导入的,逻辑大致延续,但导入顺序、作用域偶有差异,复杂包需回归测试。 - 老 ASP.NET(非 Core)Web 项目:完整 .NET Framework ASP.NET 对 PackageReference 只有有限支持,官方右键迁移工具目前仍不支持 ASP.NET 项目。
web.config的 XDT 转换、content/注入和install.ps1等机制在 PackageReference 下也不会照旧执行,不能直接套用普通类库的迁移步骤。 - 残留的旧引用(最常见的误操作):迁移后忘了清理
.csproj里残留的手写<HintPath>,等于新旧两套引用打架:
| |
顺带澄清一个历史误会
dotnet migrate不是 packages.config → PackageReference 的工具。 它是 .NET Core 早期用来把project.json(DNX/RC 时代的产物)转成 SDK 风格.csproj的命令,早已废弃。packages.config → PackageReference 的迁移走的是上面那个 VS 内置工具,命令行没有对应物。别拿dotnet migrate去迁 packages.config——它根本不认这个文件。
六、第三幕 Central Package Management:PackageReference 之上的集中管理层
PackageReference 解决了单个项目内的版本地狱,却顺手制造了新问题:大解决方案里,同一个包在不同项目里版本漂移。 A 项目里 Newtonsoft.Json 13.0.1,B 项目里 12.0.3,C 项目里 13.0.1——你以为是同一个库,其实是三个版本,行为不一致、升级要逐个改。
Central Package Management(CPM,集中式包管理) 就是来治这个的。它从 NuGet 6.2 开始正式提供,让仓库在一个地方声明包版本。CPM 不是与 packages.config、PackageReference 并列的第三种引用格式:项目仍使用 <PackageReference>,只是把版本元数据提升到 Directory.Packages.props 集中维护。
做法是在仓库/解决方案根放一个 Directory.Packages.props,打开开关:
| |
而在各项目的 .csproj 里,<PackageReference> 不再写 Version,版本由上面集中接管:
| |
边界提示:CPM 开启后,项目里的
<PackageReference Version="...">会触发 NU1008,常规升级应修改Directory.Packages.props。但 NuGet 支持显式的项目级例外:<PackageReference Include="PackageA" VersionOverride="3.0.0" />会覆盖中央版本。该能力默认允许,也可设置CentralPackageVersionOverrideEnabled=false在仓库中禁用,真正做到不允许局部覆盖。
层级与传递依赖边界
CPM 还有两个经常被忽略的边界:
- 一个项目默认只自动导入从项目目录向上找到的最近一个
Directory.Packages.props。大型仓库若使用多层文件,需要在子级文件中显式导入父级,不能假设它们会自动合并; - 默认集中的是项目显式引用的包版本。若要在没有顶层
PackageReference的情况下钉住传递包,可启用CentralPackageTransitivePinningEnabled=true。但打包类库时,NuGet 可能把被钉住的传递包提升为 nuspec 中的显式依赖,必须评估对消费者的影响。
CPM 补回的是“控制”,但这次是 集中式 的控制:一处声明、默认统一、批量升级(.NET 10+ 可用 dotnet package list --outdated;旧 SDK 使用 dotnet list package --outdated)。如果允许 VersionOverride,个别项目仍可有例外。把三代放在一起,演进轨迹清晰可见:
| |
注意它不是回到 packages.config,更不是替换 PackageReference:传递依赖仍由求解器自动计算,被集中的主要是顶层包的版本声明;启用传递钉选时才会进一步影响传递包。
七、版本号、版本范围与浮动版本
要把“约束”说清楚,绕不开版本号本身。NuGet 现在遵循 语义化版本(Semantic Versioning,SemVer)2.0:
| |
在 PackageReference 里,Version 不必是死值,它支持 版本范围(version range) 语法——这正是“约束”得以成立的语法基础:
| 写法 | 含义 |
|---|---|
1.0 或 [1.0,) | >= 1.0,最低版本(最常用,NuGet 默认) |
(1.0,) | > 1.0 |
[1.0] | 恰好 1.0(精确版本约束) |
[1.0, 2.0] | >= 1.0 且 <= 2.0(闭区间) |
[1.0, 2.0) | >= 1.0 且 < 2.0(最常用:锁同一个主版本) |
1.0.* | 浮动:1.0 主次固定,补丁取最新(* 只能出现在最右段) |
packages.config 也有范围,但用途不同。 其中的
version="13.0.1"记录当前安装结果;可选的allowedVersions="[13.0,14.0)"用来限制后续更新范围。PackageReference 则直接把Version作为 restore 输入,可以表达最低版本、区间或浮动版本,并在求解依赖图时使用。
八、避坑清单
把工程中真实踩过的坑列一下,给迁移和日常提个醒:
packages/该不该提交:packages.config 时代的老问题。提交则仓库膨胀,不提交则换机器要 restore。PackageReference 模式下packages/根本不存在了,这个纠结随之消失——别再纠结,能迁就迁。- 先按错误码分类,不要混成“引用丢失”:NU1107 是版本约束冲突;NU1106 表示依赖约束无法求解(如循环、空图或版本无交集);NU1201 / NU1202 等通常与目标框架兼容性有关。这些一般会让 restore 失败,而不是“还原成功但引用丢失”。修复后再用 .NET 10+ 的
dotnet package list --include-transitive(旧 SDK 用dotnet list package)检查最终图。 - 传递依赖冲突定位(NU1605):看到“Detected package downgrade”,说明你直接锁的版本低于某个传递依赖所需。解法不是改传递依赖,而是把你自己的直接引用版本抬上去。
- packages.config 与 PackageReference 混用:一个解决方案里部分项目 packages.config、部分 PackageReference 是允许的(边界场景),但两者项目间互相以包引用时会扯出奇怪问题。新项目一律用 PackageReference,别再开 packages.config 的口子。
- CPM 下漏删项目里的 Version:开启集中管理后,某个项目残留
Version="..."会构建报错。全仓库搜一次Version=在<PackageReference>行上的残留即可。 - 全局缓存损坏:先列出各目录并按需清理。
all --clear会同时清除 global-packages、HTTP cache、临时目录和插件缓存,之后大量包需要重新下载;Visual Studio 或构建进程正在占用文件时还可能失败:
| |
九、决策清单:我的项目该用哪种
| 场景 | 推荐格式 | 说明 |
|---|---|---|
| 新项目(.NET Core / .NET 5+) | PackageReference | SDK 风格 .NET 项目的默认模型 |
| 老 .NET Framework(非 SDK 工程) | 迁到 PackageReference | 用 VS 迁移工具;留意 contentFiles / Web 项目限制 |
| C++ / C++/CLI 项目 | packages.config | PackageReference 不支持 C++,别强迁 |
| 多项目大仓 / monorepo | PackageReference + CPM | Directory.Packages.props 治版本漂移 |
| 应用需要可重复还原(CI 一致性) | PackageReference + 提交 lock 文件 + locked mode | 生成并提交 packages.lock.json,CI 用 dotnet restore --locked-mode |
十、设计思想:NuGet 包管理教会了我们什么
两种引用模型与 CPM 管理层的演进,留下几条可以迁移到别处的思考:
- “可见的安装结果”和“自动维护的依赖图”是依赖管理的长期张力——packages.config 持久化扁平结果,代价是维护成本和来源不透明;PackageReference 声明顶层依赖并自动求解,代价是最终图需要借助工具查看;CPM 再把版本声明集中起来。没有银弹,只有权衡。
- 把顶层声明作为求解输入,是工具从“恢复已安装列表”走向“维护依赖图”的关键跃迁。packages.config 的
version记录已安装结果,PackageReference 的Version则参与 restore 求解;但 NuGet 仍受最低适用版本、直接依赖优先等多条规则约束,不能简化成单一的>=运算。 - 传递依赖必须自动求解,但求解结果必须可审计。把传递图交给求解器是对的,但不能黑箱——
project.assets.json和 lock 文件就是“信任但要核实”的凭证。自动化的地方,都要留一扇可审计的窗。 - 集中化能降低多项目的版本漂移。CPM 把版本声明收拢到一处,同时保留 PackageReference 的传递还原;
VersionOverride、多层Directory.Packages.props和传递钉选则提供了受控的例外机制。
结语
packages.config 把安装后的直接依赖和传递依赖平铺记录,restore 按结果恢复;PackageReference 只要求项目声明顶层依赖,把完整依赖图交给还原器维护;Central Package Management 则作为 PackageReference 之上的一层,把版本声明集中起来,降低多项目版本漂移。
所以下一次当你敲下 dotnet restore,看着那行 Restore completed 时,你会知道它背后发生了什么:一张依赖图被求解、一个 project.assets.json 被写下、一组引用被接入构建。需要进一步控制时,可以用版本范围表达约束、用 CPM 集中版本,再用提交到仓库的锁文件和 locked mode 约束应用的 CI 还原。
参考资料: