修改 nuget 包输出位置
2024-10-14 15:28:47

默认情况下,工程所引用的包在编译时会复制到输出目录。
这样会导致可执行文件和所有的 DLL 文件都处在同一个文件夹下,这样的目录没有层次结构,当文件过多时就难以定位程序或配置文件。
而我希望将所有第三方包放在子目录下,根目录只保留项目所产生的文件。

.NET Framework工程的修改方法

通过用 进程监视器 对可执行文件的观察,得知程序会依次从以下位置加载 DLL 文件:

  1. 全局程序集缓存目录(Global Assembly Cache),共有6个
    1
    2
    3
    4
    5
    6
    C:\Windows\Microsoft.Net\assembly\GAC_64\
    C:\Windows\Microsoft.Net\assembly\GAC_MSIL\
    C:\Windows\Microsoft.Net\assembly\GAC\
    C:\Windows\assembly\GAC_64\
    C:\Windows\assembly\GAC_MSIL\
    C:\Windows\assembly\GAC\
    从 .NET Framework 4开始,全局程序集缓存的默认位置为%windir%\Microsoft.NET\assembly。在早期版本的.NET Framework中,默认位置为%windir%\assembly
    不过我们并不需要关心这个东西,想要进一步了解可以查看官方文档 全局程序集缓存
  2. 程序工作目录,一般就是 exe 文件所在目录。
  3. {Package}\{Package}.dll。比如HtmlAgilityPack\HtmlAgilityPack.dll
  4. [appname].exe.config中所配置的探测路径。比如:
    1
    2
    3
    4
    5
    6
    7
    <configuration>  
    <runtime>
    <assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
    <probing privatePath="libs"/>
    </assemblyBinding>
    </runtime>
    </configuration>
    比如HtmlAgilityPack.dll,那么加载路径就是libs\HtmlAgilityPack.dll
    这个选项支持配置多个探测路径,详细用法查看官方文档:probing 元素
  5. AppDomain.AssemblyResolve 事件。如果以上路径都未找到 DLL,那么会触发此事件
    1
    2
    3
    4
    5
    AppDomain.CurrentDomain.AssemblyResolve += (sender, args) =>
    {
    var assemblyPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "libs", new AssemblyName(args.Name).Name + ".dll");
    return File.Exists(assemblyPath) ? Assembly.LoadFrom(assemblyPath) : null;
    };

    显然,只有第4、5两种方法是我们需要的,一个通过配置文件,一个写死在代码中。用哪种方案根据自身喜好决定即可。

改变包的输出位置

在这里找到了一个答案:How to specify output folder for the referenced nuget packages?

1
2
3
4
5
<ItemDefinitionGroup>
<ReferenceCopyLocalPaths>
<DestinationSubDirectory>libs\</DestinationSubDirectory>
</ReferenceCopyLocalPaths>
</ItemDefinitionGroup>

这样,所有引用的包都会复制到 libs 文件夹下,根目录清爽了。但是如果解决方案中有 DLL 工程的话,其也会复制到这个目录下去。
下面是改进后的版本,仅将第三方包复制到子目录:

1
2
3
4
5
6
7
<Target Name="AddDestinationSubDirectory" AfterTargets="ResolveAssemblyReferences">
<ItemGroup>
<ReferenceCopyLocalPaths Condition="'%(ReferenceCopyLocalPaths.ReferenceSourceTarget)' != 'ProjectReference'">
<DestinationSubDirectory>libs\</DestinationSubDirectory>
</ReferenceCopyLocalPaths>
</ItemGroup>
</Target>

语法简化,效果一样:

1
2
3
4
5
<Target Name="AddDestinationSubDirectory" AfterTargets="ResolveAssemblyReferences">
<ItemGroup>
<ReferenceCopyLocalPaths Condition="'%(ReferenceCopyLocalPaths.ReferenceSourceTarget)' != 'ProjectReference'" Update="%(ReferenceCopyLocalPaths)" DestinationSubDirectory="libs\"/>
</ItemGroup>
</Target>

简单说下这个任务,我们需要用 MSBuild Structured Log Viewer 这个软件来分析 MSBuild 的构建过程。
其中 ResolveAssemblyReferences 任务正是用来确定引用程序集的,文件列表就存在ReferenceCopyLocalPaths中。
所以我们的任务需要在其后执行,验证并向ReferenceCopyLocalPaths追加DestinationSubDirectory元数据即可。

.NET工程的修改方法

对于新的 .NET 工程,虽然也可以通过DestinationSubDirectory属性指定 nuget 包的输出位置,但是却无法再加载这些包了,因为加载路径变了。

修改DLL探测位置

在编译 .NET 项目时,将在输出目录中生成一个[appname].runtimeconfig.json文件,这个文件中有一个additionalProbingPaths属性,它允许你指定一个或多个自定义探测路径,供运行时在查找程序集时使用。

但是[appname].runtimeconfig.json文件在工程编译时会被覆盖掉,所以我们要将配置写在一个名为runtimeconfig.template.json文件中,让它与工程文件在同一个目录下。

1
2
3
{
"additionalProbingPaths": ["libs"]
}

当然这个配置模板的文件名也可以修改,在工程文件中用UserRuntimeConfig指定一个新的名称。

1
2
3
<PropertyGroup>
<UserRuntimeConfig>newname.template.json</UserRuntimeConfig>
</PropertyGroup>

最终的[appname].runtimeconfig.json文件内容大概就是这个样子

1
2
3
4
5
6
7
8
9
10
11
12
{
"runtimeOptions": {
"tfm": "net7.0",
"framework": {
"name": "Microsoft.NETCore.App",
"version": "7.0.0"
},
"additionalProbingPaths": [
"libs"
]
}
}

与传统的 NetFx 项目一样,程序首先会尝试在当前目录下加载 DLL,找不到文件时才会从additionalProbingPaths指定的路径中搜索。
不过在新的 .NET 中,并不是将 DLL 复制到子目录就完事了,因为additionalProbingPaths指定的只是 nuget 包的 packages 位置,子目录结构要保持和包的结构一致。

我们都知道,一个包可能含有多个不同版本的 DLL 文件,那么程序到底用的哪个 DLL 呢?可以查看[appname].deps.json文件。一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
{
"runtimeTarget": {
"name": ".NETCoreApp,Version=v7.0",
"signature": ""
},
"compilationOptions": {},
"targets": {
".NETCoreApp,Version=v7.0": {
"ConsoleApp1/1.0.0": {
"dependencies": {
"HtmlAgilityPack": "1.11.51"
},
"runtime": {
"ConsoleApp1.dll": {}
}
},
"HtmlAgilityPack/1.11.51": {
"runtime": {
"lib/netstandard2.0/HtmlAgilityPack.dll": {
"assemblyVersion": "1.11.51.0",
"fileVersion": "1.11.51.0"
}
}
}
}
},
"libraries": {
"ConsoleApp1/1.0.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"HtmlAgilityPack/1.11.51": {
"type": "package",
"serviceable": true,
"sha512": "sha512-yRn0Vd6Smu69xGyRWIZVl/Ley2OMMCMwC9tZRR/GdXfxGYj1sGMT4KskWd8OP9dGlqaXPkyB0NzBHKejtZdbow==",
"path": "htmlagilitypack/1.11.51",
"hashPath": "htmlagilitypack.1.11.51.nupkg.sha512"
}
}
}

探测路径就是这样的:

1
{additionalProbingPaths}\{libraries.package.path}\{targets.package.runtime}

就上面的例子而言,最终加载路径是:

1
libs/htmlagilitypack/1.11.51/lib/netstandard2.0/HtmlAgilityPack.dll

改变包的输出位置

加载路径清楚了,那么如何让项目在编译时将 DLL 复制到子目录呢?有位网友给出了 答案

1
2
3
4
5
6
7
8
9
10
<Target Name="CreateLibs" AfterTargets="AfterBuild">
<ItemGroup>
<NugetFiles Include="@(ReferenceCopyLocalPaths->HasMetadata('PathInPackage'))">
<OutPath>$(OutDir)libs\%(ReferenceCopyLocalPaths.NuGetPackageId)\%(ReferenceCopyLocalPaths.NuGetPackageVersion)\%(ReferenceCopyLocalPaths.PathInPackage)</OutPath>
<DeletePath>$(OutDir)%(ReferenceCopyLocalPaths.DestinationSubPath)</DeletePath>
</NugetFiles>
</ItemGroup>
<Copy SourceFiles="@(NugetFiles)" DestinationFiles="@(NugetFiles->'%(OutPath)')" />
<Delete Files="@(NugetFiles->'%(DeletePath)')"/>
</Target>

不过这个任务触发时机较晚,DLL 已经复制完成了,为了提高效率,我将它改进如下:

1
2
3
4
5
<Target Name="AddDestinationSubDirectory" AfterTargets="ResolveAssemblyReferences">
<ItemGroup>
<ReferenceCopyLocalPaths Condition="'%(ReferenceCopyLocalPaths.ReferenceSourceTarget)' != 'ProjectReference'" Update="%(ReferenceCopyLocalPaths)" DestinationSubDirectory="$([System.IO.Path]::GetDirectoryName(libs\%(ReferenceCopyLocalPaths.NuGetPackageId)\%(ReferenceCopyLocalPaths.NuGetPackageVersion)\%(ReferenceCopyLocalPaths.PathInPackage)))\"/>
</ItemGroup>
</Target>

WinUI工程的修改方法

在 WinUI 工程下,用以上的方法修改后程序依然运行不起来。监控后发现Microsoft.Windows.SDK.NET.dllWinRT.Runtime.dll这两个文件不受additionalProbingPaths设置的影响,它们只从可执行文件的工作目录下加载。
解决方法也很简单,加个 Condition 验证,过滤掉这两个文件就行了

1
2
3
4
5
<Target Name="AddDestinationSubDirectory" AfterTargets="ResolveAssemblyReferences">
<ItemGroup>
<ReferenceCopyLocalPaths Condition="'%(ReferenceCopyLocalPaths.ReferenceSourceTarget)' != 'ProjectReference' And '%(FileName)%(Extension)' != 'Microsoft.Windows.SDK.NET.dll' And '%(FileName)%(Extension)' != 'WinRT.Runtime.dll'" Update="%(ReferenceCopyLocalPaths)" DestinationSubDirectory="$([System.IO.Path]::GetDirectoryName(libs\%(ReferenceCopyLocalPaths.NuGetPackageId)\%(ReferenceCopyLocalPaths.NuGetPackageVersion)\%(ReferenceCopyLocalPaths.PathInPackage)))\"/>
</ItemGroup>
</Target>

参考

Additional probing paths for .NET Core 3 migration
additionalProbingPaths not respected after dotnet publish?
netcore项目生成 runtimeconfig.json 文件 & additionalProbingPaths 详解
.Net core 程序Nuget包独立存放 (一)
.Net core 程序Nuget包独立存放 (二)