默认情况下,工程所引用的包在编译时会复制到输出目录。
这样会导致可执行文件和所有的 DLL 文件都处在同一个文件夹下,这样的目录没有层次结构,当文件过多时就难以定位程序或配置文件。
而我希望将所有第三方包放在子目录下,根目录只保留项目所产生的文件。
.NET Framework工程的修改方法
通过用 进程监视器 对可执行文件的观察,得知程序会依次从以下位置加载 DLL 文件:
- 全局程序集缓存目录(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.dll和WinRT.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包独立存放 (二)