NuGet 包管理的前世今生:从 packages.config 到 PackageReference 再到 Central Package Management

写在前面

.NET 生态有大量 NuGet 包,几乎每个项目都靠它们搭起来。但“一个项目到底依赖了哪些包”这件最基础的事,.NET 十几年间经历了从 packages.configPackageReference,再到在 PackageReference 之上增加 Central Package Management 的演进。很多人对前两者的认知停留在“一个老一个新”,却没注意到:它们不只是文件位置不同,还代表两种依赖状态管理方式。

依赖清单该“记录已安装结果”还是“声明版本约束”? 把每个传递依赖的已安装版本都写进清单,结果直观,却要维护一份扁平列表;只声明直接依赖、让工具还原传递图,清单更简洁,但最终版本需要通过资产文件或锁文件审计。

理解了这层张力,就能看清 NuGet 包管理的演进:packages.config 保存安装后的扁平结果,PackageReference 声明顶层依赖并在 restore 时维护传递图,Central Package Management(CPM)再把 PackageReference 的版本声明集中到仓库级文件。


一、直接依赖 vs 传递依赖:分歧的起点

在讲格式之前,先把一个概念钉死:你的项目依赖的包,分两类。

  • 直接依赖(direct dependency):你在代码里 using 的、你主动装的那个包。你清楚地知道它的存在。
  • 传递依赖(transitive dependency):你的直接依赖自己又依赖的包。它们“搭便车”进来,你通常看不见、也不关心——直到它出了问题。

一棵最小的依赖树长这样:

1
2
3
4
5
6
你的项目 MyApp
├── Newtonsoft.Json          ← 直接依赖(你写的)
├── Polly                    ← 直接依赖(你写的)
│   └── Polly.Core           ← 传递依赖(Polly 拉进来的)
└── Serilog
    └── Serilog.Extensions   ← 传递依赖

packages.configPackageReference 的根本分歧,就从这棵树开始:这两类依赖,谁来记录?什么时候求解? packages.config 把安装 / 更新时得到的直接与传递依赖版本都平铺记录下来;PackageReference 只保留顶层声明,让 restore 维护完整依赖闭包。

二、前世 packages.config:保存扁平的安装结果

packages.config 是 NuGet 1.0 时代的原始格式;NuGet 1.0 发布于 2011 年 1 月 13 日。它是一个 per-project(每个项目一个)的 XML 文件,内容是该项目已经安装的依赖清单:

1
2
3
4
5
6
<?xml version="1.0" encoding="utf-8"?>
<packages>
  <package id="Newtonsoft.Json" version="13.0.1" targetFramework="net48" />
  <package id="Castle.Core"     version="5.1.1"  targetFramework="net48" />
  <package id="Moq"             version="4.20.70" targetFramework="net48" />
</packages>

关键特征:当你安装一个包时,NuGet 会把这个包 连同它的传递依赖 一起装进项目,并全部写进 packages.config。于是这个清单是“扁平”的——直接依赖和传递依赖混在一起,平铺成一长串。

这些包被物理解压到一个 解决方案级(solution-level)的 packages/ 文件夹里,所有项目共用:

1
2
3
4
5
6
7
8
9
MySolution/
├── MyProject.csproj
├── packages.config          ← 该项目的依赖清单(含传递)
├── packages/                ← 解决方案级,所有项目共用
│   ├── Newtonsoft.Json.13.0.1/
│   │   └── lib/net45/Newtonsoft.Json.dll
│   ├── Castle.Core.5.1.1/
│   └── Moq.4.20.70/
└── MySolution.sln

而项目是怎么“引用”这些 DLL 的?是直接在 .csproj 里手写 HintPath(提示路径),指向 packages/ 里的具体文件:

1
2
3
<Reference Include="Newtonsoft.Json">
  <HintPath>..\packages\Newtonsoft.Json.13.0.1\lib\net45\Newtonsoft.Json.dll</HintPath>
</Reference>

安装与还原:求解发生在安装时,结果写进扁平清单

packages.config 的还原(restore)极其直观,甚至有点“笨”:

1
2
3
4
5
6
7
packages.config                 全局缓存                        解决方案 packages/
┌───────────────┐              ┌────────────────────┐          ┌────────────────────┐
│ A 1.0         │   读清单      │ ~/.nuget/packages  │  逐包    │ A.1.0/             │
│ D 1.0 (传递)  │ ───────────▶ │   /a/1.0/          │ ───────▶ │ D.1.0/ ← A 的传递  │
└───────────────┘   (逐条)      └────────────────────┘   拷贝   └────────────────────┘
        │                                                                │
        └──────── .csproj 里已有的 <Reference><HintPath> 指向这里 ──────┘

这里必须区分 install / updaterestore

  • 安装或更新包时,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,而且只写直接依赖。 传递依赖在还原时被自动求解。

1
2
3
4
5
6
7
8
9
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Newtonsoft.Json" Version="13.0.1" />   <!-- ← 只声明直接依赖 -->
    <PackageReference Include="Polly"           Version="8.4.1" />
  </ItemGroup>
</Project>

注意:Polly.Core(Polly 的传递依赖)不再出现在任何清单里——它在还原时被自动算出来。

包也不再散落在解决方案的 packages/ 文件夹,而是统一住在 全局包缓存(global packages folder)里,所有解决方案、所有项目共享:

1
2
3
4
5
6
~/.nuget/packages/                   ← 全局共享(Windows: %USERPROFILE%\.nuget\packages)
├── newtonsoft.json/13.0.1/
│   └── lib/netstandard2.0/Newtonsoft.Json.dll
├── polly/8.4.1/
└── polly.core/8.4.1/                ← Polly 的传递依赖,求解后才出现
                                     # 项目里只剩 obj/project.assets.json,再无 packages/ 文件夹

一个要权衡的好处:在同一用户、同一 global-packages 配置下,同一 id + version 的包通常只展开一份,多个项目直接复用;代价是依赖的真实形态不在项目目录里,得通过工具或资产文件查看。不同用户、容器、CI Agent 或自定义 NUGET_PACKAGES 仍可能各有一份缓存。

还原机制深挖:从“逐包拷贝”到“图求解”

这是 PackageReference 的真正核心,也是它和 packages.config 最本质的区别。还原不再读一个扁平清单,而是 求解一张依赖图,产物是 project.assets.json

1
2
3
4
5
6
7
8
9
.csproj(只声明直接依赖)         收集约束 → 求解传递图         project.assets.json
┌────────────────────────┐       ┌───────────────────┐       ┌──────────────────────┐
│ <PackageReference      │       │ 求解器:            │ 产出  │ targets / libraries  │
│   Include="Polly" />   │ ────▶ │  选“满足全部约束    │ ────▶ │ compile / runtime /  │
│                        │       │   的最低适用版本”    │       │ analyzers / build    │
└────────────────────────┘       └───────────────────┘       └──────────────────────┘
                                       obj/*.nuget.g.props / *.targets 由 MSBuild 自动导入
                                       (编译引用、分析器、构建任务全部由它接管)

project.assets.json 是整个还原的灵魂——它把求解结果按“用途”拆开,告诉 MSBuild 哪些 DLL 该喂给编译器、哪些该拷进输出目录、哪些该当分析器跑。看一个精简后的片段:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
{
  "targets": {
    ".NETCoreApp,Version=v6.0": {
      "Newtonsoft.Json/13.0.1": {
        "type": "package",
        "compile": { "lib/netstandard2.0/Newtonsoft.Json.dll": {} }, // ← 编译期可见
        "runtime": { "lib/netstandard2.0/Newtonsoft.Json.dll": {} }  // ← 运行期可见
      }
    }
  },
  "libraries": {
    "Newtonsoft.Json/13.0.1": {
      "type": "package",
      "path": "newtonsoft.json/13.0.1",
      "files": [ "lib/netstandard2.0/Newtonsoft.Json.dll" ]
    }
  }
}

设计点libraries 是“涉及到的全部包”的元数据目录,targets 是“在某个目标框架下,这些包按用途如何归类”。这种 包元数据 / 引用用途 的分离,正是 PackageReference 能优雅支持多目标框架(multi-target)的根基——同一份声明,按框架求解出不同的引用集。

版本解析规则:不是一句“最低版本”就能概括

PackageReference 的传递还原主要有四条规则,不能只记“最低版本”:

  1. 最低适用版本(lowest applicable version):在某个依赖约束下,优先选择可用的最低版本;
  2. 浮动版本(floating versions):使用 * 明确请求匹配范围内的较新版本;
  3. 直接依赖优先(direct-dependency-wins):当前子图里的直接引用可以覆盖传递依赖选择,发生降级时会产生 NU1605;
  4. 同层 / 旁系依赖(cousin dependencies):来自不同子图的约束合并后,选择满足它们的最低版本。

回到那个钻石依赖的例子:

1
2
3
你的项目 MyApp
├── PackageA ──► CommonLib (>= 1.0)
└── PackageB ──► CommonLib (>= 2.0)

在这个没有直接引用覆盖的简单例子里,两个旁系约束合并为 >= 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,得靠工具还原出来:

1
2
3
4
5
# .NET 10+
dotnet package list --include-transitive

# .NET 9 及更早 SDK 使用旧的 verb-first 写法
dotnet list package --include-transitive

固化求解结果的钥匙:设置 RestorePackagesWithLockFile=true 会生成 packages.lock.json。对应用程序,应把锁文件提交到版本库,并在 CI 使用 dotnet restore --locked-mode(或 RestoreLockedMode=true);这样输入与锁文件不一致时直接失败,而不是悄悄更新。普通 restore 在依赖输入变化时可以重新求解并改写锁文件。类库自己的锁文件也无法强制最终消费项目沿用同一套传递版本,因此要按项目角色决定是否提交。

四、两张图看懂差别

把前面散落的事实收拢成一张表:

维度packages.configPackageReference
清单位置独立 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)

再看还原流程的并排对比,差别一目了然:

1
2
3
4
5
6
7
8
9
【packages.config】                          【PackageReference】
 读已安装结果的扁平清单                       读顶层 PackageReference
      │                                          │
      ▼                                          ▼
 恢复清单中的确切包版本                       按四类规则求解传递图
      │                                          │
      ▼                                          ▼
 .csproj 里写死的 HintPath 指过去              生成 project.assets.json
(安装 / 更新阶段已完成依赖解析)                (生成完整资产图,MSBuild 消费)

五、迁移实战:从 packages.config 到 PackageReference

如果你的项目还停在 packages.config(典型的老 .NET Framework 工程),迁移到 PackageReference 有官方路径,但坑不少。

VS 内置迁移工具

在 Visual Studio 里:右键 packages.config“将 packages.config 迁移到 PackageReference”(Migrate packages.config to PackageReference)。工具会:

  1. packages.config,结合包元数据,尝试区分出哪些是直接依赖、哪些是传递依赖
  2. 把直接依赖写成 .csproj 里的 <PackageReference>
  3. 删掉旧的 packages.config.csproj 里手写的 <HintPath> 引用;
  4. 把传递依赖交给求解器接管。

真实坑:迁移不是一键无脑的

迁移工具能处理大多数情况,但这几类包行为会变,需要你手动盯:

  • content / contentFiles 行为不同:packages.config 时代,包可以把文件塞进项目的 content/(直接拷进你的源码目录)。PackageReference 不再这样做,改用 nuspec 里的 contentFiles 声明(文件是只读的、按规则注入)。老包如果依赖往 content/ 拷文件,迁移后会“消失”。
  • developmentDependency 的去向:packages.config 用 developmentDependency="true" 影响打包时的依赖传播。PackageReference 中常用 PrivateAssets="all" 表达“只供当前项目使用、不向下游传播”,目标相近但不是所有资产语义的一一等价。迁移器还会把含 buildbuildCrossTargetingcontentFilesanalyzersdevelopmentDependency=true 的包保留为顶层引用,因为这些资产不一定能靠传递依赖正确流动:
1
2
3
4
5
6
7
8
9
<!-- ❌ packages.config 时代的标记(迁移后失效) -->
<package id="Microsoft.CodeAnalysis.Analyzers" version="3.3.4"
         developmentDependency="true" targetFramework="net48" />

<!-- ✅ 常见 PackageReference 写法:用 PrivateAssets 把它对下游隐藏 -->
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4">
  <PrivateAssets>all</PrivateAssets>
  <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
  • 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>,等于新旧两套引用打架:
1
2
3
4
5
6
7
8
9
<!-- ❌ 迁移后还残留旧 packages.config 的影子:HintPath 指向已不存在的 packages/ -->
<Reference Include="Newtonsoft.Json">
  <HintPath>..\packages\Newtonsoft.Json.13.0.1\lib\net48\Newtonsoft.Json.dll</HintPath>
</Reference>

<!-- ✅ 迁移后应由 NuGet 接管引用,.csproj 里只留 PackageReference,删掉所有手写 HintPath -->
<ItemGroup>
  <PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
</ItemGroup>

顺带澄清一个历史误会

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,打开开关:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<!-- Directory.Packages.props -->
<Project>
  <PropertyGroup>
    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>   <!-- ← 总开关 -->
  </PropertyGroup>
  <ItemGroup>
    <PackageVersion Include="Newtonsoft.Json" Version="13.0.1" />           <!-- ← 版本集中声明 -->
    <PackageVersion Include="Polly"           Version="8.4.1" />
  </ItemGroup>
</Project>

而在各项目的 .csproj 里,<PackageReference> 不再写 Version,版本由上面集中接管:

1
2
3
4
5
<!-- 某项目 .csproj:只声明“我要用谁”,不写版本 -->
<ItemGroup>
  <PackageReference Include="Newtonsoft.Json" />   <!-- ← 无 Version,继承 PackageVersion -->
  <PackageReference Include="Polly" />
</ItemGroup>

边界提示: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,个别项目仍可有例外。把三代放在一起,演进轨迹清晰可见:

1
2
3
4
5
packages.config        →   PackageReference      +   Central Package Management
 精确、但分散              自动求解、但分散           自动求解、且集中
 (完整安装结果平铺)          (声明顶层依赖)            (版本集中、允许受控例外)
   [控制]                      [自动化]                  [控制 + 自动化]
        ◀──────── 钟摆 ─────────▶◀───────── 再次回摆 ─────────▶

注意它不是回到 packages.config,更不是替换 PackageReference:传递依赖仍由求解器自动计算,被集中的主要是顶层包的版本声明;启用传递钉选时才会进一步影响传递包。

七、版本号、版本范围与浮动版本

要把“约束”说清楚,绕不开版本号本身。NuGet 现在遵循 语义化版本(Semantic Versioning,SemVer)2.0

1
2
3
4
5
6
7
8
MAJOR.MINOR.PATCH[-prerelease][+build]
   1     .  0  .  3  -beta.1      +meta.5
   │        │     │      │            │
   │        │     │      │            └─ 构建元数据(不影响版本顺序)
   │        │     │      └─ 预发布标识(排序低于正式版)
   │        │     └─ 补丁:向后兼容的缺陷修复
   │        └─ 次版本:向后兼容的新功能
   └─ 主版本:不兼容的破坏性变更(Major 升级要警惕)

在 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 或构建进程正在占用文件时还可能失败:
1
2
3
4
5
6
dotnet nuget locals all --list
dotnet nuget locals global-packages --clear
dotnet nuget locals http-cache --clear

# 确认确实需要全部重建时再用
dotnet nuget locals all --clear      # 或 nuget locals all -clear

九、决策清单:我的项目该用哪种

场景推荐格式说明
新项目(.NET Core / .NET 5+)PackageReferenceSDK 风格 .NET 项目的默认模型
老 .NET Framework(非 SDK 工程)迁到 PackageReference用 VS 迁移工具;留意 contentFiles / Web 项目限制
C++ / C++/CLI 项目packages.configPackageReference 不支持 C++,别强迁
多项目大仓 / monorepoPackageReference + CPMDirectory.Packages.props 治版本漂移
应用需要可重复还原(CI 一致性)PackageReference + 提交 lock 文件 + locked mode生成并提交 packages.lock.json,CI 用 dotnet restore --locked-mode

十、设计思想:NuGet 包管理教会了我们什么

两种引用模型与 CPM 管理层的演进,留下几条可以迁移到别处的思考:

  1. “可见的安装结果”和“自动维护的依赖图”是依赖管理的长期张力——packages.config 持久化扁平结果,代价是维护成本和来源不透明;PackageReference 声明顶层依赖并自动求解,代价是最终图需要借助工具查看;CPM 再把版本声明集中起来。没有银弹,只有权衡。
  2. 把顶层声明作为求解输入,是工具从“恢复已安装列表”走向“维护依赖图”的关键跃迁。packages.config 的 version 记录已安装结果,PackageReference 的 Version 则参与 restore 求解;但 NuGet 仍受最低适用版本、直接依赖优先等多条规则约束,不能简化成单一的 >= 运算。
  3. 传递依赖必须自动求解,但求解结果必须可审计。把传递图交给求解器是对的,但不能黑箱——project.assets.json 和 lock 文件就是“信任但要核实”的凭证。自动化的地方,都要留一扇可审计的窗。
  4. 集中化能降低多项目的版本漂移。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 还原。


参考资料:

Licensed under CC BY-NC-SA 4.0