.NET5.0 单文件发布打包操作深度剖析

时间:2023-01-05 19:48:50

.NET5.0 单文件发布打包操作深度剖析

前言

随着 .NET5.0 Preview 8 的发布,许多新功能正在被社区成员一一探索;这其中就包含了“单文件发布”这个炫酷的功能,实际上,这也是社区一直以来的呼声,从 WinForm 的 msi 开始,我们就希望有这样一个功能,虽然在 docker 时代,单文件发布的功能显得“不那么重要”,但正是从这一点可以看出,.NET 的团队成员一直在致力于实用功能的完善。

在 Java 的世界里,单文件发布一直伴随着他们的成长,War 文件可以直接上传到 Tomcat 上运行,话说我们还是有那么一丢丢的羡慕的,不过凡事有利就有弊,单文件发布对于细分模块的热更新来说,还有有一点点的不方便。

不过瑕不掩瑜,在微服务概念越来越火热的今天,相信单文件发布的功能带给大家更多的是兴奋。

什么是单文件发布

首先,我们要清楚的了解,什么是单文件发布。

官方的目标定义:

.Net 5.0单个文件解决方案应为:

  • 广泛兼容:可以将包含IL程序集,随时运行的程序集,复合程序集,本机二进制文件,配置文件等的应用程序打包为一个可执行文件。
  • 可以直接从打包软件直接运行应用程序的托管组件,而无需提取到磁盘。
  • 可与调试器和工具一起使用。

从上面的目标可以看出,和以往版本最大的不同在于:将所有依赖打包到一个可执行文件中,可直接运行,不影响调试操作。

注意上面的这句话“将所有依赖打包到一个可执行文件中”,而在以往,我们使用 dotnet publish 将应用程序进行发布之后,我们会看到,在 publish 下有许多项目依赖的 dll 文件,在 .NET5.0 到来之后,这些依赖文件可收纳到一个文件中,瞬间让人感受到了清凉。

发布操作指令相关

命令

平台 命令 说明
Linux dotnet publish -r linux-x64 /p:PublishSingleFile=true -
Windows dotnet publish -r win-x64 --self-contained=false /p:PublishSingleFile=true -
Mac OS - -

可选参数

属性 描述
IncludeNativeLibrariesInSingleFile 在发布时,将依赖的本机二进制文件打包到单文件应用程序中。
IncludeSymbolsInSingleFile 将 .pdb 文件打包到单个文件中。提供该选项是为了和 .NET 3 单文件模式兼容。建议替代的方法是生成带有嵌入式的 PDB (embedded)的程序集
IncludeAllContentInSingleFile 将所有发布的文件(符号文件除外)打包到单文件中。该选项提供是为了向后兼容 .NETCore 3.x 版本

配置文件设置参数

除了可以使用命令行参数的形式,还可以通过配置文件的形式设置发布参数,编辑项目文件,添加配置节点到文件中并保存即可。

<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<RuntimeIdentifier>linux-x64</RuntimeIdentifier>
<PublishSingleFile>true</PublishSingleFile>
<IncludeContentInSingleFile>true</IncludeContentInSingleFile>
</PropertyGroup>

关于 RID 说明见:https://docs.microsoft.com/en-us/dotnet/core/rid-catalog

这是截止本文发布前的 RID 版本,不排除 .NET5.0 有新的发布

其它参数

除了上面的三个可选参数,我在查询文档的过程中还发现,官方还提到了其它参数的使用,目前不确定是否有效

<PropertyGroup>
<SelfContained>true</SelfContained>
<!--启用使用assemby修剪-仅支持自包含应用程序-->
<PublishTrimmed> true </PublishTrimmed>
<!--启用AOT编译 目前暂不支持预编译-->
<!--<PublishReadyToRun>true</PublishReadyToRun>-->
</PropertyGroup>
<ItemGroup>
<Content Update="*-exclute.dll">
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
</Content>
</ItemGroup>

还可以通过设置 ExcludeFromSingleFile 元素,该设置将指定某些文件不嵌入单个文件之中。

编写待打包的应用程序

为了更直观的看出正常发布和单文件发布的区别,我们特别准备了一个 Web 应用程序,并对两个程序集进行依赖引用。

.NET5.0 单文件发布打包操作深度剖析

准备好项目,编译成功,尝试发布,打开 PowerShel 控制台,分别输入以下命令

dotnet publish -r linux-x64 /p:PublishSingleFile=true
dotnet publish -r win-x64 --self-contained=false /p:PublishSingleFile=true

.NET5.0 单文件发布打包操作深度剖析

linux-x64 和 win-x64 两个目录下,分别有 publish 目录,由于平台的不同,所引用的依赖也不一样,这是我们早就了解过的,我们看看打包前后的区别

.NET5.0 单文件发布打包操作深度剖析

以上执行的两条命令语句,会为我们生成 Linux 和 Windows 两个平台的程序包,从上图中可以看出,在打包之前,项目的各种引用依赖都被复制到了发布目录下,这也是我们之前的程序发布方式,在经过打包后,所有依赖文件都被装入了一个可执行文件中,在 Linux 平台下表现为:PreviewWebApplication ,Windows 平台下则为:PreviewWebApplication.exe。从打包效果来看,迁移将变得更加方便了。

运行打包程序

打包后的程序和未打包的发布程序在运行方式上没有太多的差异性,在 Windows 平台上,只需要双击 PreviewWebApplication.exe 就可以运行该打包程序了,本示例创建的是一个 WebApi 的程序,直接访问程序侦听的地址后得到接口返回的结果,如果您创建的是带有 Razor 视图或者携带其它资源文件的,可能无法访问指定的 url。

.NET5.0 单文件发布打包操作深度剖析

在程序成功运行起来后,我们发现,打包程序并没有解压缩文件到磁盘,而是直接从包中加载文件到内存中运行;这是巨大的进步,也是和 War 文件根本的区别。

需要注意的是,该 .exe 文件并不能单独复制到别的地方运行,你必须把 .exe 当前目录完整的复制才能运行,这涉及到主机探测的问题,下面我们将会一一提到。

跨平台的打包文件

通过上面的示例我们了解到,打包程序总是为不同的平台生成独立的包程序,这是为什么呢?这里就涉及到一个概念,也就是 Tool Interface Standard (TIS)

Executable and Linking Format(ELF)

Common Object File Format(COFF)于1983年引入,最初使用在 AT&T 的 UNIX 系统上。由于 COFF 的各种局限性,比如:节的最大数量受到限制,节名称,所包含的源文件的长度受到限制,并且符号调试信息无法支持实际的语言。最后,在 System V Release 4 (SVR4) 发布后,AT&T 使用 ELF 替代了 COFF。

工具接口标准委员会

援引委员会规范文件的说明:可执行文件和链接格式最初由 UNIX 系统开发和发布实验室(USL)作为应用程序二进制接口(API)的一部分。工具接口标准委员会 (TIS) 选择将不断发展的 ELF 标准作为便携式对象文件。该标准适用于各种操作系统的 32 位英特尔架构环境的格式。ELF 标准旨在通过向开发人员提供具有一组跨多个操作环境的二进制接口定义。这将减少不同接口实现的数量,从而减少需要重新编写和编译的代码。

ELF 文件结构又分为三种类型,分别是:

名称 说明 描述
可重定位文件 Relocatable File 包含适合与其他对象文件链接的代码和数据,以创建可执行文件或共享对象文件。
可执行文件 Executable File 包含适合执行的程序
共享目标文件 Shared Object File 包含适合在两种上下文中链接的代码和数据。首先,链接编辑器可以处理它与其他可重新删除和共享的对象文件,以创建另一个对象文件。其次,动态链接器将其与可执行文件和其他共享对象相结合,以创建进程映像。

Portable Executable (PE)

在 Windows 阵营,微软在此 COFF 标准的基础上,又进行了创新和发展出了 PE 文件标准

PE Format

该规范描述了Windows操作系统家族下的可执行文件(图像)和目标文件的结构。这些文件分别称为可移植可执行(PE)和公用对象文件格式(COFF)文件。

从上面的两种规范中可以看出,LinuX 和 Windows 都有各自的文件格式规范,而这种规范在一定程度上是不兼容的,不论是从文件结构还是解析方式;所以 .NET5.0 中的打包程序必须为不同的平台实现独立的打包器。打包器的实现在 runtime 中的 Microsoft.NET.HostModel 库中。

认识了 ELF 和 PE 文件结构之后,我们就可以对打包器代码进行阅读理解。

Microsoft.NET.HostModel

你可以从 github 上下载 .NET 5.0 的源代码,

转到目录:

runtime/src/installer/managed/Microsoft.NET.HostModel

源码不太多,可直接进行阅读,主要理解层次关系即可。

打包器主要包含了三大部分的内容,分别是 AppHost、Boundler、ComHost

模块 说明
AppHost 用于单文件主机启动时的文件探测,还复制将程序资源从 App.dll 复制到 AppHost备用,目前已通过 HostFxr 和 HostPolicy 进行静态链接,其探测逻辑已转移到 HostPolicy(由C++编写)
Boundler 打包器的具体实现,主要是将应用程序及其依赖项嵌入 AppHost 中,随后发布单个可执行文件到指定目录
ComHost 创建一个包含嵌入式 CLSIDMap 文件的 ComHost,以将 CLSID 映射到 .NET 类。

在文件 Boundle/Manifest.cs 的头部,我们看到了“单文件程序”的文件结构定义

 BundleManifest is a description of the contents of a bundle file.
This class handles creation and consumption of bundle-manifests. Here is the description of the Bundle Layout:
_______________________________________________
AppHost ------------Embedded Files ---------------------
The embedded files including the app, its
configuration files, dependencies, and
possibly the runtime. ------------ Bundle Header -------------
MajorVersion
MinorVersion
NumEmbeddedFiles
ExtractionID
DepsJson Location [Version 2+]
Offset
Size
RuntimeConfigJson Location [Version 2+]
Offset
Size
Flags [Version 2+]
- - - - - - Manifest Entries - - - - - - - - - - -
Series of FileEntries (for each embedded file)
[File Type, Name, Offset, Size information] _________________________________________________

从上面的文件结构中,我们可以非常清晰的看到,单文件程序的结构一共分为三大部分,分别是:

定义 说明 描述
嵌入的文件 Embedded file 主要是配置文件和描述文件,比如 .deps.json,runtimeconfig.json 等文件
打包文件头信息 Bundle Header 描述了整个文件的结构信息,类型,存储位置,段、表等信息
实体清单 Manifest Entries 实际打包的文件列表,每个文件分段写入,可执行文件使用 16byte - prev file end position 进行分隔,普通文件直接按 prev file end position 进行写入

文件头信息的查看

我们可以通过一些工具去查看已经打包好的文件,在 Linux 下,可以使用 readelf/objdump 等程序来获取 PreviewWebApplication 文件的信息。在 Windows 下,可以使用 PE Tools 等工具

Linux 下 readelf 读取文件头信息

.NET5.0 单文件发布打包操作深度剖析

从图中我们可以看到 Type:DYN (Shared object file) 这是一个标准的共享对象文件,关于 ELF 头部信息的内容不再展开,有兴趣的同学可以自行学习相关内容。

Windows下 PE Tools 读取文件头信息

.NET5.0 单文件发布打包操作深度剖析

已经打包好的程序内部包含了 319(Linux)、Windows(359) 个文件,Windows 版本在未打包前是 84.3MB,打包后是 69.8MB,最重要的是在运行时无需解压缩,直接从 Boundle 中运行文件。

文件中的第三部分,也就是 “实体清单(Manifest Entries)的写入代码在 Boundle\Boundler.cs\AddToBundle

long AddToBundle(Stream bundle, Stream file, FileType type)
{
if (type == FileType.Assembly)
{
long misalignment = (bundle.Position % AssemblyAlignment);
if (misalignment != 0)
{
long padding = AssemblyAlignment - misalignment;
bundle.Position += padding;
}
}
file.Position = 0;
long startOffset = bundle.Position;
file.CopyTo(bundle);
return startOffset;
}

在成员方法 GenerateBundle(IReadOnlyList fileSpecs) 内部迭代调用了 AddToBundle 方法,完成了实体清单文件的写入。

// 代码片段

public string GenerateBundle(IReadOnlyList<FileSpec> fileSpecs)
{
...
foreach (var fileSpec in fileSpecs)
{
string relativePath = fileSpec.BundleRelativePath;
...
using (FileStream file = File.OpenRead(fileSpec.SourcePath))
{
FileType targetType = Target.TargetSpecificFileType(type);
long startOffset = AddToBundle(bundle, file, targetType);
FileEntry entry = BundleManifest.AddEntry(targetType, relativePath, startOffset, file.Length);
Tracer.Log($"Embed: {entry}");
}
} // Write the bundle manifest
headerOffset = BundleManifest.Write(writer);
...
}

因为解压器的实现已经转移到了 HostFxr 和 HostPolicy 中,以静态链接库的方式链接到打包器中,且该部分代码由 C++ 进行编写,鉴于 C++ 水平有限,在这里不作介绍。

结束语

编写这篇文章耗费了我大量的时间,期间大量阅读海量的参考资料、文献、标准文档、制作文章配图等等,写干货文章真的需要投入巨大的精力和时间,希望你们喜欢。

文章进行到这里,我知道肯定还有很多同学没看过瘾,但是我们可以通过回顾打包器的开发进度表来体验一下 .NET 团队的开发热情。

.NET5.0 单文件发布打包操作深度剖析

主要参考资料

.NET团队计划经理 Richard Lander 的博客:https://devblogs.microsoft.com/dotnet/announcing-net-5-0-preview-8/

Boundler 进度表:https://github.com/dotnet/runtime/issues/36590

single-file:https://github.com/dotnet/designs/tree/master/accepted/2020/single-file

ELF文档:https://refspecs.linuxbase.org/elf/elf.pdf

ELF*:https://en.wikipedia.org/wiki/Executable_and_Linkable_Format

Readelf:https://sourceware.org/binutils/docs/binutils/readelf.html

PE文档:https://docs.microsoft.com/en-us/windows/win32/debug/pe-format

PE Tools:https://github.com/petoolse/petools