默认情况下,工程所引用的包在编译时会复制到输出目录。
这样会导致可执行文件和所有的 DLL 文件都处在同一个文件夹下,这样的目录没有层次结构,当文件过多时就难以定位程序或配置文件。
而我希望将所有第三方包放在子目录下,根目录只保留项目所产生的文件。
.NET Framework工程的修改方法
通过用 进程监视器 对可执行文件的观察,得知程序会依次从以下位置加载 DLL 文件:
- 全局程序集缓存目录(Global Assembly Cache),共有6个从 .NET Framework 4开始,全局程序集缓存的默认位置为
1
2
3
4
5
6C:\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\%windir%\Microsoft.NET\assembly
。在早期版本的.NET Framework中,默认位置为%windir%\assembly
。
不过我们并不需要关心这个东西,想要进一步了解可以查看官方文档 全局程序集缓存。 - 程序工作目录,一般就是 exe 文件所在目录。
{Package}\{Package}.dll
。比如HtmlAgilityPack\HtmlAgilityPack.dll
。[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 元素。- AppDomain.AssemblyResolve 事件。如果以上路径都未找到 DLL,那么会触发此事件
1
2
3
4
5AppDomain.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 | <ItemDefinitionGroup> |
这样,所有引用的包都会复制到 libs 文件夹下,根目录清爽了。但是如果解决方案中有 DLL 工程的话,其也会复制到这个目录下去。
下面是改进后的版本,仅将第三方包复制到子目录:
1 | <Target Name="AddDestinationSubDirectory" AfterTargets="ResolveAssemblyReferences"> |
语法简化,效果一样:
1 | <Target Name="AddDestinationSubDirectory" AfterTargets="ResolveAssemblyReferences"> |
简单说下这个任务,我们需要用 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 | { |
当然这个配置模板的文件名也可以修改,在工程文件中用UserRuntimeConfig
指定一个新的名称。
1 | <PropertyGroup> |
最终的[appname].runtimeconfig.json
文件内容大概就是这个样子
1 | { |
与传统的 NetFx 项目一样,程序首先会尝试在当前目录下加载 DLL,找不到文件时才会从additionalProbingPaths
指定的路径中搜索。
不过在新的 .NET 中,并不是将 DLL 复制到子目录就完事了,因为additionalProbingPaths
指定的只是 nuget 包的 packages 位置,子目录结构要保持和包的结构一致。
我们都知道,一个包可能含有多个不同版本的 DLL 文件,那么程序到底用的哪个 DLL 呢?可以查看[appname].deps.json
文件。一个例子:
1 | { |
探测路径就是这样的:
1 | {additionalProbingPaths}\{libraries.package.path}\{targets.package.runtime} |
就上面的例子而言,最终加载路径是:
1 | libs/htmlagilitypack/1.11.51/lib/netstandard2.0/HtmlAgilityPack.dll |
改变包的输出位置
加载路径清楚了,那么如何让项目在编译时将 DLL 复制到子目录呢?有位网友给出了 答案
1 | <Target Name="CreateLibs" AfterTargets="AfterBuild"> |
不过这个任务触发时机较晚,DLL 已经复制完成了,为了提高效率,我将它改进如下:
1 | <Target Name="AddDestinationSubDirectory" AfterTargets="ResolveAssemblyReferences"> |
WinUI工程的修改方法
在 WinUI 工程下,用以上的方法修改后程序依然运行不起来。监控后发现Microsoft.Windows.SDK.NET.dll
和WinRT.Runtime.dll
这两个文件不受additionalProbingPaths
设置的影响,它们只从可执行文件的工作目录下加载。
解决方法也很简单,加个 Condition 验证,过滤掉这两个文件就行了
1 | <Target Name="AddDestinationSubDirectory" AfterTargets="ResolveAssemblyReferences"> |
参考
Additional probing paths for .NET Core 3 migration
additionalProbingPaths not respected after dotnet publish?
netcore项目生成 runtimeconfig.json 文件 & additionalProbingPaths 详解
.Net core 程序Nuget包独立存放 (一)
.Net core 程序Nuget包独立存放 (二)