一、为什么需要关心NuGet源优先级
很多人刚开始用NuGet的时候,都是直接打开Visual Studio或者命令行,从官方源拉包,感觉一切都很简单。但等你到了公司,尤其是稍微有点规模的公司,情况立马就不一样了。公司内部会有些自己封装的工具库、公共类库,这些包不太可能放到官方NuGet去,因为里面可能有业务逻辑、内部代码,甚至还有安全策略的限制。于是你们公司就会搭一个内部的NuGet服务器,比如用Azure Artifacts、MyGet、BaGet,或者自己拿文件共享搭一个简单的源。
这时候问题就来了:你同时配置了官方源和内部源,那当你执行dotnet restore的时候,系统到底先从哪儿找包?如果两边都存在相同名字的包,该用哪个?如果内部源暂时挂了,会不会导致恢复失败?如果你不小心把内部包的名字和官方某个包重了,会不会被覆盖成错误版本?这些问题如果不搞清楚,日常开发就会时不时踩坑。所以搞清楚NuGet源的优先级,不光是给运维看的,更是我们每个写代码的人必须掌握的基本功。
二、NuGet源的基本配置方式
2.1 全局配置和项目配置
NuGet的配置是放在NuGet.Config文件里的。这个文件可以出现在多个层级,它们之间有继承关系,越靠近项目的位置优先级越高。从上到下大概这么个顺序:
- 机器级配置:一般在Windows上是
C:\Program Files (x86)\NuGet\Config,或者放在用户目录下。 - 用户级配置:在
%AppData%\NuGet\NuGet.Config(Windows)或者~/.nuget/NuGet/NuGet.Config(Linux/macOS)下。 - 项目级配置:放在项目根目录下,或者解决方案所在的目录下。
项目级配置离代码最近,所以它能覆盖上面两层的同名配置。这就给了我们很大的灵活性,比如不同项目可以用不同的包源顺序,大家不用挤在一个全局文件里改来改去。
下面先看一个最简单的用户级配置文件长什么样。技术栈是.NET / NuGet,我们用XML格式来写。
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<!-- 这里可以放一些全局的NuGet设置,比如源、apikey等 -->
<packageSources>
<!-- 公司内网源,取个名字叫CompanyFeed,方便后面引用 -->
<add key="CompanyFeed" value="http://192.168.1.100/v3/index.json" />
</packageSources>
</configuration>
这个文件放到用户目录下以后,你所有项目在还原包的时候都会先用到这个源。但是这时候你没加官方源,那官方包全拉不下来了。所以一般我们还会把官方源也加上。
2.2 源添加与优先级设置
手动编辑配置文件是一种方式,但更推荐用命令行来管理。比如咱们可以用dotnet nuget来添加源、移除源、更新源的优先级。这里的优先级是通过--protocol-version、--configfile等参数控制的?不对,优先级在NuGet里其实是由packageSources列表中的顺序决定的。排在列表前面的源,会被优先查找。
先看命令行添加源的方法。下面示例也是.NET / NuGet技术栈,使用bash命令行。
# 添加一个公司内部源,放到用户配置里
dotnet nuget add source http://192.168.1.100/v3/index.json \
--name CorporateFeed \
--username yourname \
--password yourpassword \
--store-password-in-clear-text \
--configfile ~/.nuget/NuGet/NuGet.Config
# 再添加官方NuGet源
dotnet nuget add source https://api.nuget.org/v3/index.json \
--name nuget.org \
--configfile ~/.nuget/NuGet/NuGet.Config
执行完之后,你打开配置文件,会看到类似下面这样的结构。注意clear标签是用来清空之前继承来的源列表的,它很关键。我们后面会讲。
<configuration>
<packageSources>
<clear />
<add key="CorporateFeed" value="http://192.168.1.100/v3/index.json" />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</packageSources>
</configuration>
看到没?谁在上面,谁就被先访问。也就是说,现在这个例子里,公司内部源会被优先访问。如果某个包在内部源找到了,就直接用它,不再去官方源找了。如果内部源找不到,才会去官方源找。这就是我们想要的“内部优先,官方兜底”的效果。
2.3 理解包源的查找顺序
NuGet在还原依赖的时候,会按照配置文件里的源顺序,从上到下一个一个去查。某一个包在某一个源上找到了,而且版本也满足要求,那就会用这个源上的包,后面的源就不再看了。这个顺序是基于每个包单独判断的,不是一次性的拉取。什么意思呢?比如你的项目同时依赖A和B,A只在内部源上,B只在官方源上。那么还原的时候,A会从内部源拉取,B会从官方源拉取,互不影响。但是如果A在两个源上都有,那就默认从排在前面的源拉取。
这个机制听起来很合理,但也会带来一个问题:如果内部源上的某个包的版本比官方源上的同名包版本低,而你项目里又没有限制最低版本,那NuGet可能就给你装了一个“旧包”。这时候你可能还莫名其妙,为什么同一个包在别人电脑上引用的是新版本,而在你这里还原出来的是旧版本?多半就是源优先级搞怪。
三、优先级配置的实战示例
咱们直接来一个完整的项目示例。假设你有一个解决方案,里面有个MyApp项目,需要引用公司内部的Company.Common.Logging包,同时也需要引用官方源上的Newtonsoft.Json。为了让整个团队行为一致,我们最好把配置放到解决方案根目录下的NuGet.Config里。
技术栈:.NET / NuGet。先看项目目录结构的一个示意(这不是代码,是为了方便说明,我们用bash风格的注释)。
# 这是一个解决方案目录,根目录下放NuGet.Config
/MySolution
/src/MyApp/MyApp.csproj
/NuGet.Config
下面是NuGet.Config的完整内容。
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<!--
清空继承来的所有源,
避免用户机器上还有乱七八糟的自定义源干扰
-->
<packageSources>
<clear />
<!-- 公司源放在第一位,表示优先级最高 -->
<add key="CorpFeed" value="http://corp-nuget.local/v3/index.json" />
<!-- 官方源放在第二位,作为兜底 -->
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</packageSources>
<!--
可以给特定源配置访问凭据,
username和password只是示例,生产环境别写成明文
-->
<packageSourceCredentials>
<CorpFeed>
<add key="Username" value="devuser" />
<add key="ClearTextPassword" value="devpassword" />
</CorpFeed>
</packageSourceCredentials>
</configuration>
接下来,我们看一眼MyApp.csproj文件里的依赖是怎么声明的。这里为了演示,我故意让Company.Common.Logging和Newtonsoft.Json都从不同的源还原。
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<!-- 使用NuGet的锁定文件,保证每个人都还原出相同结果 -->
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
<ItemGroup>
<!--
这个包在公司源上,版本号为1.0.0
官方源上也有同名包,但版本号最高只有0.9.0
因为公司源优先级高,所以会取到1.0.0
-->
<PackageReference Include="Company.Common.Logging" Version="1.0.0" />
<!-- 这个包官方源上才有,所以只能从官方源拉取 -->
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
</Project>
现在我们用命令行来还原。技术栈是.NET / NuGet,语言是bash。
# 在解决方案根目录执行还原
dotnet restore MySolution.sln --configfile NuGet.Config
加上--configfile参数,是确保我们用了项目目录下这个配置文件。如果不加,NuGet会自动向上查找,也能找到。加了更保险。
还原成功后,我们可以看看生成的packages.lock.json文件,它会记录每个包是从哪个源来的。下面截取了一部分。
{
"version": 1,
"dependencies": {
".NETCoreApp,Version=v8.0": {
"Company.Common.Logging": {
"type": "Direct",
"resolved": "1.0.0",
"contentHash": "abc123...",
"source": "CorpFeed"
},
"Newtonsoft.Json": {
"type": "Direct",
"resolved": "13.0.3",
"contentHash": "def456...",
"source": "nuget.org"
}
}
}
}
看这里,source字段明确记录了每个包来自哪个源。有了这个文件,以后你再还原,NuGet就会优先参照这个锁定结果,不会因为源顺序变化而随意切换版本。这在团队协作里特别有用,能避免大部分人踩“包版本诡异错乱”的坑。
四、依赖冲突的产生原因和表现
源优先级搞好了,包也能正常还原了,但依赖冲突这个问题就随之而来了。啥是依赖冲突?说白了,就是一个包里用到另一个包,然后版本要求对不上。举个例子,你的项目直接引用了Foobar.Http这个包,版本是2.0。然后你又引用了Corp.Utils这个内部包,而这个Corp.Utils内部依赖Foobar.Http的1.0版本。这时候,你项目里有两条路都要用到Foobar.Http,一个要2.0,一个要1.0,但最终一个项目只能有一个版本生效,于是冲突就出现了。
这种冲突在编译的时候通常会表现为两种形式:
一是编译报错,提示找不到某个方法,或者某个命名空间在引用的程序集里不存在。比如1.0版本有HttpClientFactory.Create()方法,2.0版本改成了HttpClientFactory.CreateClient(),那你调用老方法当然找不到。
二是运行时报一堆类似“无法加载程序集”的错。最常见的就是System.IO.FileLoadException,说无法加载某某程序集,因为它依赖的某个版本找不到。
还有一种更恶心的情况是,编译和运行都没问题,但某些功能行为莫名其妙。这是因为实际加载的版本不是你以为的那个,有些老版本的bug还在,或者新版本的新特性不可用。
我们来看一个更具体的例子。假设公司内部包Company.Common.Caching 依赖 StackExchange.Redis 的 2.5.0,而你想直接引用最新版的StackExchange.Redis 2.8.0。你项目里同时写了这两个引用。技术栈是C#/.NET,下面代码展示项目文件:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<!--
注意:这里显式引用了最新版
但是下面的内部包传递依赖了旧版
-->
<PackageReference Include="StackExchange.Redis" Version="2.8.0" />
<PackageReference Include="Company.Common.Caching" Version="3.1.0" />
</ItemGroup>
</Project>
还原的时候,NuGet会看上层的直接引用,也就是2.8.0。通常它会选择这个直接引用版本来覆盖传递依赖的2.5.0。这算是一个好消息。但问题来了:Company.Common.Caching这个包是你们公司老同事写的,里面某些代码用了2.5.0才有的API,而那个API在2.8.0里被标记成过时,甚至被删了。于是编译一跑,报错。这就是冲突的一种典型反馈。
再看另一个场景:两个平级包A和B,A依赖C的1.0,B依赖C的2.0,而你项目没有直接引用C。这时候NuGet默认会选择版本较高的那个2.0,但这可能导致A在运行时调用C的某个方法时找不到。这种冲突最烦人,因为你完全没有直接参与,锅从天上来。
五、解决依赖冲突的常用方法
既然冲突躲不掉,那我们就得想办法解决。下面我给你介绍几种在实际项目中用得比较多的姿势。技术栈依然是.NET / NuGet。
5.1 直接依赖版本统一
最简单的办法就是,谁冲突,我就直接在项目里强制指定一个版本。这个版本要能同时满足各方的API要求,也就是找最大公约数。
还是上面那个StackExchange.Redis的例子。既然公司内部包和我的需求都需要Redis客户端,那我可以直接声明一个比较新的版本,保证内部包的老API还在。比如先查一下Company.Common.Caching的依赖范围,发现它对StackExchange.Redis要求不低于2.5.0,那我干脆显式引用2.5.0。这样大家就都满意了。
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<!-- 统一到这个版本,满足所有相关方的需求 -->
<PackageReference Include="StackExchange.Redis" Version="2.5.0" />
<PackageReference Include="Company.Common.Caching" Version="3.1.0" />
</ItemGroup>
</Project>
注意,这个办法是“压最低线”,不是找最高版。你得查清楚每个包到底依赖什么版本的API,别直接上来就指定一个看起来最新的版本。否则容易把老API干掉,反而搞出更大问题。
5.2 使用绑定重定向
如果你的项目是.NET Framework,不是.NET Core/5+,那可能会遇到程序集绑定重定向的问题。这种情况在ASP.NET WebForms或者老式WCF服务里特别常见。解决办法是在web.config或app.config里加上bindingRedirect,把冲突的版本统一重定向到一个确定的版本。
下面是一个示例。技术栈是.NET Framework / C#,配置文件是XML格式。
<configuration>
<runtime>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<!--
name是程序集的名字
oldVersion表示原始版本范围
newVersion表示实际要用的版本
注意这里说的是程序集版本,不是NuGet包版本
-->
<dependentAssembly>
<assemblyIdentity name="StackExchange.Redis" publicKeyToken="..." culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-2.5.0" newVersion="2.5.0" />
</dependentAssembly>
</assemblyBinding>
</runtime>
</configuration>
这个配置的意思是,但凡你在运行过程中发现请求的是0.0.0.0到2.5.0之间的任何版本,统统给你换成2.5.0这个具体版本。这样就不会再报“找不到程序集”的错误了。但是注意,如果新版本和老版本之间真的改了某个接口,光重定向是救不回来的,运行时会直接抛异常。所以这个办法严格来说是“堵漏”,不是“治本”。
5.3 使用包版本范围约束
还有一种办法是,在PackageReference里给版本加上范围约束,告诉NuGet我允许选择哪个范围内的版本。这在处理传递依赖的时候很实用。比如你可以这样写:
<ItemGroup>
<!-- 指定最低2.0,最高小于3.0,也就是[2.0,3.0) -->
<PackageReference Include="Newtonsoft.Json" Version="[12.0.1, 13.0.0)" />
</ItemGroup>
但这种写法控制的是你项目里直接依赖的版本,对于传递依赖的约束其实有限。NuGet默认在还原传递依赖时,如果同一个包在依赖树里出现了不同版本,它通常会选一个最高版本。你可以通过nuget.config里的dependencyVersion属性来调整策略。
技术栈是.NET / NuGet,配置文件XML示例:
<configuration>
<!--
可选值有Lowest、HighestPatch、HighestMinor、Highest
Lowest:选最低版本,减少跟老代码的兼容风险
Highest:选最高版本,拿最新修复和特性
这里选Lowest,更适合解决老依赖API兼容问题
-->
<config>
<add key="dependencyVersion" value="Lowest" />
</config>
</configuration>
如果设置成Lowest,那NuGet在有版本冲突时就不一股脑选最高,而是选最低的那个。这有时候能救命,有时候也坑人,得看情况。如果低版本有安全漏洞,那就不合适了。所以一般我还是建议用显式版本指定来“一锤定音”。
5.4 使用dotnet list package诊断
遇到不明不白的冲突,与其靠猜,不如用工具看看当前依赖树。dotnet list package这个命令可以列出项目里所有包以及它们的传递依赖。加上--include-transitive参数就能显示更深层的依赖。
技术栈是.NET / NuGet,命令行示例:
# 查看MyApp项目的所有直接依赖和传递依赖
dotnet list MyApp/MyApp.csproj package --include-transitive
# 查看是否存在版本冲突的依赖
dotnet list MyApp/MyApp.csproj package --vulnerable
输出结果会像下面这样(这是示意文本,不是代码,但为了排版我还是放在代码块里)。
Project 'MyApp' has the following package references
[net8.0]:
Top-level Package Requested Resolved
Company.Common.Caching 3.1.0 3.1.0
StackExchange.Redis 2.8.0 2.8.0
Transitive Package Resolved
StackExchange.Redis 2.8.0
这里的Requested是你声明的版本,Resolved是最终还原出来的版本。如果Resolved比你预期的低或者高,说明可能被其他依赖影响了。
5.5 使用中央包管理方式
在大型解决方案里,几十个项目各自引用版本,特别容易乱。微软后来搞了个“中央包管理”(Central Package Management,简称CPM)特性。它允许你在解决方案根目录放一个Directory.Packages.props文件,里面把所有包版本统一管起来。项目文件里只写包名,不写版本号,版本号以中央文件为准。
这样从根上就避免了多个项目版本不一致的问题,依赖冲突也会大大减少。来看完整示例。
技术栈是.NET / NuGet,先写中央包管理文件Directory.Packages.props:
<Project>
<PropertyGroup>
<!-- 开启中央包管理 -->
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<!--
在这里统一声明所有包的版本
以后要升级版本,只需要改这一个文件
-->
<PackageVersion Include="StackExchange.Redis" Version="2.8.0" />
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
<PackageVersion Include="Company.Common.Caching" Version="3.1.0" />
</ItemGroup>
</Project>
然后在项目文件MyApp.csproj里这样引用:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<!-- 没有Version属性,版本由中央文件统一管理 -->
<PackageReference Include="StackExchange.Redis" />
<PackageReference Include="Newtonsoft.Json" />
<PackageReference Include="Company.Common.Caching" />
</ItemGroup>
</Project>
这样一来,如果某个传递依赖和你的直接依赖有版本冲突,你可以直接在Directory.Packages.props里改版本,整个解决方案的所有项目都会跟着变。这个思路特别适合中大型团队。
六、应用场景和注意事项
6.1 应用场景
先说说啥时候需要做源优先级配置。首当其冲就是公司有私有NuGet仓库,需要跟官方源共存。其次,你可能需要隔离外网和内网,比如某些开发机不允许直连外网,只能在局域网里访问公司镜像源。这时候你甚至可以把官方源直接指向公司的NuGet代理镜像,这样既能用公共包,又能符合网络安全规范。
还有,如果你的项目要进行可重复构建,那你必须把源优先级固定下来,并且最好配合packages.lock.json。否则今天排序变了,明天某个包更新了,都会导致还原出来的依赖不同,这会给发布带来很大的不确定性。
6.2 注意事项
源优先级这块有几个坑我是真踩过,拿出来给你提个醒。
第一,不要在配置文件里写明文密码,尤其是提交到代码库的配置文件。我们前面示例里写的ClearTextPassword只是为了演示方便。实际生产建议用dotnet nuget命令加上--store-password-in-clear-text?不对,那还是明文。更好的办法是使用NuGet信任的凭据提供程序,或者在CI里用环境变量注入。比如下面这个命令行示例。技术栈是.NET / NuGet,bash:
# 环境变量方式提供凭据
export NUGET_CREDENTIAL_PROVIDER_SESSIONTOKENCACHE_ENABLED=true
# 使用NuGet自带的凭据提供程序
dotnet restore MySolution.sln \
--configfile NuGet.Config \
--interactive
第二,注意<clear />的作用。如果不加这个标签,配置文件会继承更高层级的配置。如果用户机器上自己配了一个源,那个源的优先级可能会插入到你的源之间,导致实际顺序跟你预想的不一样。加了clear就清空了,所有源都以当前文件为准,干净利落。
第三,源数量别搞太多。每增加一个源,还原的时候都要多访问一个服务。而且如果某个源挂了,NuGet默认会尝试下一个,但这个过程可能带来网络超时,耽误时间。所以一般建议最多两到三个源,内部一个,官方一个,可能再加一个公司测试源。
第四,别轻易修改已发布包的版本。NuGet包是不可变的,如果你在内部源上删掉某个包,然后重新上传一个同版本但内容不同的包,那简直是在给团队埋雷。你的同事可能还会缓存到本地global-packages文件夹,怎么也拉不到新内容。遇到包有改动,一定要升级版本号。
第五,全局包文件夹也会造成一种“伪冲突”。NuGet默认会在用户目录下缓存所有下载过的包,比如~/.nuget/packages。如果你之前装过旧版本,后来源里的新版本也是同一个版本号,但内容已经变了,NuGet可能直接用缓存,导致你迟迟看不到新变化。这时可以清一下缓存。命令行示例,技术栈.NET / NuGet,bash:
# 强制删除全局包缓存
dotnet nuget locals global-packages --clear
# 或者只清某一个包的缓存
# 手动去对应目录找到包删掉,比如:
# rm -rf ~/.nuget/packages/stackexchange.redis/2.5.0
七、技术优缺点
7.1 优点
通过合理配置NuGet源优先级和依赖冲突解决策略,我们能得到这么几个好处。
第一,内网私有包和公共包共存得非常自然,开发者不需要切来切去。只要配置好一次,后面所有还原操作都自动完成,效率很高。
第二,能有效保证安全性。只要你把源顺序锁定,并且用clear清掉不必要的源,就能避免开发者意外从一些可疑的第三方源拉取到被篡改的包。
第三,依赖冲突解决手段丰富,基本能覆盖从简单到复杂的各种场景。从最直接的“显式指定版本”到“中央包管理”,你能根据自己项目的实际情况选合适的方案。
7.2 缺点
这些技术也不是银弹,有一些代价。
首先,配置复杂度提升了。尤其是大公司,可能还会有多套源、多套凭据、多套权限管理,光是一个NuGet.Config文件就可能变得很复杂。新手进来要被配置文件当头一棒。
其次,NuGet的源优先级是基于“找到即停”的逻辑,也就是某个包在第一个源上找到了,就不会再去看后面源上是否有更好或更新的版本。这导致一个潜在风险:如果内部源上没有及时更新某个公共包的版本,开发者就会一直用到旧版本,还很难察觉。
再有,依赖冲突的排查还是比较费心。虽然有些工具能帮你列出依赖树,但最终拿主意的人还是你。你不仅要了解当前项目的引用,还要知道每个间接依赖自己引用了什么,层层递进,非常考验耐心。
最后,中央包管理虽然统一了版本,但它也让项目文件看起来“缺胳膊少腿”,有的同事会感觉不习惯,甚至不知道某个包的版本是哪来的。团队里需要有一定的约定和文档支撑。
八、总结
NuGet源优先级这件事,说到底就是让计算机知道去哪找包,按什么顺序找,找到以后听谁的。它看起来不过是一个配置项顺序的问题,但实际牵涉到还原稳定性、团队一致性、依赖安全等多个方面。上面这些内容没有特别抽象的东西,只要你在一家依赖稍微复杂点的公司写过几天代码,应该都能对上号。
解决依赖冲突的核心思想也很简单:要么你直接强制指定一个版本,让所有人都闭嘴;要么你用工具看清依赖关系,再对症下药;要么你从组织层面用中央包管理统一规则,避免大家各自为战。没有哪个方法是万能的,每个都需要结合实际场景去尝试。但只要你理解了底层逻辑,就不会被各种怪异的还原错误吓住。
最后给你一个小建议:把所有源优先级的配置文件纳入代码库,用锁定文件固化还原结果,再配合CI环境做一次干净的还原验证。这套组合拳一旦打起来,以后你基本不会再因为包源顺序或者依赖冲突被折腾到半夜了。
评论
围绕“企业内部NuGet源与官方源的优先级配置及依赖冲突解决方法”参与讨论