在编译或者发布程序的时候,入口程序通常会依赖的其它dll,并跟随这些依赖项一起发布。如果某个dll丢失,可能会导致主程序无法运行,还有些时候为了调用方便,也需要把程序及其依赖的dll打包发布为单独的可执行文件。
好消息是.NET Core 3.0及以上的版本,在Visual Studio中发布的时候,可以选择单文件版本发布。但在此之前版本的程序,则需要手动处理了。
本文首先介绍了.NET中程序集加载的原理,并手动实现一个类似将依赖的dll以及主程序打包为一个单独的可执行文件的例子,然后接下来使用第三方的库Costura来实现针对.NET Core 3.0之前版本的自动打包。
场景
为什么需要单独打包为一个exe呢?这其实是有一些使用场景的。
在前面的文章中介绍了使用命名管道进行IPC通讯,在这个例子中,我们在项目中把UI渲染比较耗时的部分和模块,从主程序里面独立出来了做成了一个程序,而这个单独的程序依赖的一些dll和主程序依赖的dll是一样的。并且有部分的配置文件需要与主模块的配置文件共用。在主程序里面使用Process运行这个独立出来的模块,并将渲染UI需要的数据通过命名管道的方式传输给程序。这时候就需要把这个独立的模块编译为一个单一的exe,这样就不会导致一些依赖的dll被覆盖,并且不需要考虑复杂的程序所在的路径问题。
将程序打包为单一的exe还有一个好处是比较清爽,只有一个exe,没有其它的乱七八糟的dll,传输起来可能更快。
但也不是没有缺点,缺点就是可能会导致该程序的内存占用可能会过大。因为在正常的情况下主程序对外部dll的是动态加载的,当某个代码逻辑第一次运行时,会进行JIT编译,然后分析其依赖的dll,然后动态加载进来,继而运行。如果打包为单一的exe,那么这个唯一的exe在运行时就需要把整个文件都加载到内存中,不管这些dll是否会被用到。
基本原理
许多应用程序都有一个要依赖众多dll文件的exe文件构成。当部署应用程序时,所有的文件都必须部署。我们在Visual Studio中可以通过如下方法生成单个exe。
首先标识出exe文件需要依赖的、不是.NET Frameowk的所有的dll文件,然后将这些dll添加到Visual Studio项目中来,并将这些dll的“生成操作”,修改为“嵌入的资源”,这会导致C#编译器将dll文件嵌入到exe文件中,在编译的时候会生成单一的exe。
在运行时,CLR会找不到这些依赖的dll程序集,所以为了解决这个问题。第二步就是当应用程序初始化时,向AppDomain的ResolveAssembly注册回调方法,在代码中手动加载这些依赖的dll程序集。
void Main()
{
AppDomain domain = AppDomain.CurrentDomain;
domain.AssemblyResolve += AssemblyResolveEvent;
}
Assembly AssemblyResolveEvent(object sender, ResolveEventArgs args)
{
string dllName = new AssemblyName(args.Name).Name + ".dll";
var assem = Assembly.GetExecutingAssembly();
string resourceName = assem.GetManifestResourceNames().FirstOrDefault(rn => rn.EndsWith(dllName));
if (resourceName == null) return null;
using (var stream = assem.GetManifestResourceStream(resourceName))
{
byte[] assemblyData = new byte[stream.Length];
stream.Read(assemblyData, 0, assemblyData.Length);
return Assembly.Load(assemblyData);
}
}
当线程首次调用一个方法时,如果发现该方法引用了依赖dll文件中的类型,就会引发一个AssemblyResolve事件,上面的回调代码就会找到所需的嵌入的dll资源,并调用Assembly的Load方法获取一个Byte[]实参的重载版本来加载所需的资源。可以将dll嵌入到程序集,但这回增加应用程序在运行时的内存消耗。
.NET Frameowrk中演示
为了演示,创建两个项目,一个项目为TestSingle的Winform窗体程序,一个项目为TestSingleDLL的类库。
在TestSingleDLL中定义了一个类,里面有一个方法Add。这个方法在窗体程序中会被调用。
namespace TestSingleDLL
{
public class MathLib
{
public static int Add(int x, int y)
{
return x + y;
}
}
}
将TestSingleDLL,编译为一个TestSingleDLL.dll之后,在TestSingle中将这个dll添加为资源。
▲step1:添加依赖的dll文件为资源
▲ step2:将资源的生成操作修改为“嵌入的资源”
▲ step3:添加“引用”时,选择Resources目录下的dll文件
▲ step4:编译完成之后,可以看到只有单个的exe文件,依赖的dll都嵌入到了exe中。
添加好引用之后,在TestSingle的窗体中,创建一些控件,然后调用TestSingleDLL中的add方法:
public partial class Form1 : Form
{
public Form1()
{
InitializeComponent();
}
private void button1_Click(object sender, EventArgs e)
{
lblResult.Text = MathLib.Add(int.Parse(textBox1.Text), int.Parse(textBox2.Text)).ToString();
}
}
现在运行程序,当点击“add”按钮,就会调用MathLib的方法,程序发现MathLib在TestSingleDLL程序集中,但目前找不到,所以会报错。
▲找不到程序集,报错。
现在回到TestSingle窗体程序里,我们要注册相关事件,来让程序去改项目的资源文件里面去寻找和加载相关的dll。
namespace TestSingle
{
static class Program
{
/// <summary>
/// 应用程序的主入口点。
/// </summary>
[STAThread]
static void Main()
{
AppDomain.CurrentDomain.AssemblyResolve += CurrentDomain_AssemblyResolve;
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new Form1());
}
private static System.Reflection.Assembly CurrentDomain_AssemblyResolve(object sender, ResolveEventArgs args)
{
string dllName = new AssemblyName(args.Name).Name + ".dll";
var assem = Assembly.GetExecutingAssembly();
string resourceName = assem.GetManifestResourceNames().FirstOrDefault(rn => rn.EndsWith(dllName));
if (resourceName == null) return null;
using (var stream = assem.GetManifestResourceStream(resourceName))
{
byte[] assemblyData = new byte[stream.Length];
stream.Read(assemblyData, 0, assemblyData.Length);
return Assembly.Load(assemblyData);
}
}
}
}
在Program的Main函数里,注册当前AppDomain的AssemblyResolve事件,在事件中,找到当前程序集的所有资源名称,然后在其中寻找需要加载的dll,然后加到当前的程序集中来。
再次编译程序,运行,可以发现能够正常调用。
▲完美
.NET Core中演示
上面演示了在.NET Framework中如何通过将依赖的dll作为嵌入式资源添加到exe中从而达到编译为单一exe的效果。在.NET Core中则不需要这么麻烦了,在发布中就能直接生成单一应用,我这里没有.NET Core 3.0的环境,这里以.NET Core 7.0为例,在Visual Studio 2022中创建了两个项目,一个为TestSingleNetCore的Winform应用,一个为TestSingleNetCoreDLL的类库,在Winform中直接引用TestSingleNetCoreDLL项目或者生成的dll,
然后在发布中直接进行设置即可。
▲直接发布为文件
▲ 默认发布选项为可移植,这里面的exe不包含依赖的dll以及运行时
在这里要发布为单文件格式,在“部署模式”里需要选择“独立”,并且在“文件发布选项”里勾选“生成单个文件”
▲ “独立”的“单文件”的部署模式
可以看到在发布文件夹里仍然有其它很多依赖文件,但生成的exe其实对这些文件不依赖,把这个exe拷贝到其它目录下,点击也可以运行。
▲ 独立单文件发布后的目录,TestSingleNetCore.exe不依赖任何其它文件,其内部包含了依赖的dll以及运行时。
使用Costura生成单文件
上面已经介绍了分别针对.NET Framework和.NET Core的单文件部署的方法。.NET Core中单文件部署比较简单,Visual Studio中自带的发布选项就能实现单文件部署。而.NET Framework中则需要手动添加依赖的dll到资源文件并将其“生成操作”设置为“嵌入的资源”,并且还需要注册AppDomain的AssemblyResolve事件,编写代码手动到资源文件里面加载对应的dll,比较繁琐。
如果程序对第三方的dll依赖较多,这些dll手动加载也需要很多工作量。幸好有第三方名为Costura的包可以帮助将程序自动打包为单文件。
为了演示,这里创建一个名为TestSingleUsingCostura的.NET Framework 4.5的Winform程序,并且直接项目引用之前编写的TestSingleDLL。
然后安装Costura包。
▲因为项目是.NET Framework 4.5,所以支持的最新版本为Costura.Fody 1.6.2版本
添加完成之后,项目中会自动添加名为"FodyWeavers.xml"的文件,这个文件里面可以进行各自配置,比如压缩、剪切等等,具体可以访问网站说明。
▲添加完Costura之后,直接编译,就能生成单文件程序。
总结
本文简单介绍了在.NET中将程序打包为单文件的原理,并在.NET Framework和.NET Core中分别做了演示。在.NET Core中单文件部署比较简单,Visual Studio中自带的发布选项就能实现单文件部署。而.NET Framework中则需要手动添加依赖的dll到资源文件并将其“生成操作”设置为“嵌入的资源”,并且还需要注册AppDomain的AssemblyResolve事件,编写代码手动到资源文件里面加载对应的dll,比较繁琐。但借助第三方的工具比如Costura这个类库,就能十分方便的不用做任何修改的情况下,就能将程序发布为单文件应用。
参考
- https://stackoverflow.com/questions/126611/can-a-net-windows-application-be-compressed-into-a-single-exe
- https://github.com/Fody/Costura
- https://learn.microsoft.com/en-us/dotnet/core/whats-new/dotnet-core-3-0#single-file-executables
- https://github.com/Fody/Fody