在编译或者发布程序的时候,入口程序通常会依赖的其它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这个类库,就能十分方便的不用做任何修改的情况下,就能将程序发布为单文件应用。

 

参考