对嵌入式静态资源(文件)的GO命令支持

2020-07-24 08:29:56

这是一个设计草案,而不是一个正式的GO提案,因为它描述了一个潜在的巨大变化,它解决了与许多第三方软件包相同的需求,并可能影响它们的实现(希望通过简化它们!)。分发此设计草案的目的是收集反馈以形成预期的最终提案。

我们正在利用这一变化来试验新的方法来扩大关于重大变化的讨论。对于这一变化,我们将使用Go Reddit线程来管理问答,因为Reddit的线程支持可以很容易地将问题与答案匹配,并将不同的讨论线分开。

有很多工具可以将静态资产(文件)嵌入到GO二进制文件中。所有这些都依赖于手动生成步骤,然后将生成的文件签入源代码存储库。此草案设计通过向GO命令本身添加对嵌入式静态资产的支持,省去了这两个步骤。

有很多工具可以将静态资产(文件)嵌入到GO二进制文件中。最早和最受欢迎的是github.com/jteeuwen/go-bindata及其分支之一,但还有更多,包括(但不限于!):

GO命令是GO开发人员构建GO程序的方式。向go命令添加对嵌入基本功能的直接支持将消除对其中一些工具的需要,并至少简化其他工具的实现。

明确的目标是消除为资产生成新的GO源文件并将这些源文件提交给版本控制的需要。

另一个明确的目标是避免语言更改。对我们来说,嵌入静态资产似乎是一个工具问题,而不是语言问题。避免语言更改也意味着我们不需要更新许多处理GO代码的工具,其中包括goimport、gopls和stataticcheck。

重要的是要注意,作为设计和策略问题,GO命令在构建期间从不运行用户指定的代码。这提高了构建的重现性、可伸缩性和安全性。这也是Go Generate是一个单独的手动步骤而不是自动步骤的原因。对嵌入式静态资源的任何新GO命令支持都受该设计和策略选择的限制。

另一个目标是解决方案递归地同样适用于主包及其依赖项。例如,要求开发人员在Go Build命令行中列出所有嵌入是行不通的,因为这需要知道正在构建的程序的所有依赖项所需的嵌入。

另一个目标是避免为访问文件设计新的API。用于访问嵌入式文件的API应该尽可能接近*os.File,这是用于访问本机操作系统文件的现有标准库API。

此设计在文件系统草案设计的基础上,将对嵌入静态资产的直接支持添加到go命令本身。

一个新的嵌入包,它定义了类型embed.Files,这是一组嵌入文件的公共API。Embed.Files从文件系统接口草案设计中实现了fs.FS,使其可以直接与net/http和html/template等包一起使用。

下面详细描述的一个新的包Embed提供了Embed.Files类型。在该类型的变量声明之上的一个或多个//go:embed指令以GLOB模式的形式指定要嵌入哪些文件。例如:

Go命令将识别指令,并安排使用来自文件系统的匹配文件填充声明的embed.Files变量(在本例中为content)。

为简洁起见,//go:embed指令接受多个空格分隔的glob模式,但它也可以重复,以避免在有很多模式时出现非常长的行。GLOB模式的语法为path.Match;它们必须是无根的,并且它们是相对于包含源文件的包目录进行解释的。路径分隔符是正斜杠,即使在Windows系统上也是如此。要允许命名名称中包含空格的文件,可以将模式编写为去掉双引号或反引号的字符串文字。

如果模式命名了一个目录,则以该目录为根的子树中的所有文件都会(递归地)嵌入,因此上面的示例等同于:

可以导出或取消导出embed.Files变量,具体取决于包是否希望使文件集对其他包可用。同样,embed.Files变量可以是全局变量,也可以是局部变量,这取决于上下文中哪一个更方便。

在评估模式时,空目录的匹配将被忽略(因为空目录从不打包到模块中)。

模式与任何文件或非空目录不匹配是错误的。

重复一个模式或让多个模式与特定文件匹配并不是错误;这样的文件只会嵌入一次。

模式中包含。Path元素(要匹配当前目录中的所有内容,请使用*)。

如果模式与当前模块之外的文件匹配,或者无法打包到模块中,如.git/*或符号链接(或者如上所述,空目录),这是错误的。

除非声明了embed.Files,否则//go:embed指令出现是错误的。(更具体地说,每个//go:embed指令后面必须跟一个Embed.Files类型变量的var声明,在//go:embed和声明之间只有空行和其他//-仅注释行。)。

在不导入";embed";源文件中使用//go:embed是错误的(违反此规则的唯一方式涉及类型别名欺骗)。

在GO 1.N之前声明GO版本的模块中使用//GO:Embed是错误的,其中N是添加此支持的GO版本。

声明没有//go:embed指令的embed.Files不是错误的。该变量不包含任何嵌入式文件。

//文件提供对生成时嵌入到包中的一组文件的访问。type文件结构{…。}。

Files类型提供打开嵌入文件的Open方法,作为fs.File:

通过提供此方法,Files类型实现了fs.FS,并且可以与实用程序函数(如fs.ReadFile、fs.ReadDir、fs.Glob和fs.Walk)一起使用。

为了方便对嵌入式文件进行最常见的操作,Files类型还提供了ReadFile方法:

因为Files实现了fs.FS,所以还可以将一组嵌入文件传递给template.ParseFS以解析嵌入模板,并传递给http.HandlerFS以通过HTTP提供一组嵌入文件。

Go命令将更改为process//go:embed指令,并将适当的信息传递给编译器和链接器以执行嵌入。

GO命令还会将六个新字段添加到GO列表公开的包结构中:

EmbedPatterns字段列出在//go上找到的所有模式:包的非测试源文件中的嵌入行;TestEmbedPatterns和XTestEmbedPatterns列出包的测试源文件(分别是内部测试和外部测试)中的模式。

EmbedFiles字段列出了与EmbedPatterns匹配的相对于包目录的所有文件;它没有指定哪些文件与哪种模式匹配,尽管可以使用path.Match重新构建。同样,TestEmbedFiles和XTestEmbedFiles列出与TestEmbedPatterns和XTestEmbedPatterns匹配的文件。这些文件列表仅包含文件;如果模式与目录匹配,则文件列表包括在该目录子树中找到的所有文件。

在Go/Build包中,Package struct只添加EmbedPatterns、TestEmbedPatterns和XTestEmbedPatterns,而不添加EmbedFiles、TestEmbedFiles或XTestEmbedFiles,因为Go/Build包不承担根据文件系统匹配模式的工作。

在golang.org/x/tools/go/Packages包中,包结构添加了一个新字段:EmbedFiles列出嵌入的文件。(如果将嵌入文件添加到OtherFiles,则无法判断该列表中具有有效源扩展名的文件(例如x.c)是否正在生成或嵌入,或者两者都是。)。

如上所述,Go生态系统有许多用于嵌入静态资产的工具,太多了,无法与每一个进行直接比较。取而代之的是,这一节列出了赞成设计的每个部分的肯定的理论基础。每一小节还涉及Golang.org/Issue/35950上有益的初步讨论中提出的观点。(本文档末尾的附录与几个现有工具进行了直接比较,并研究了如何简化这些工具。)。

该解决方案必须像应用于主包一样应用于依赖项包。

该设计的核心是使用新的//go:embed指令注释的新embed.Files类型:

这与Golang.org/Issue/35950初步讨论开始时提到的两种做法不同。在某些方面,它是各自最好的部分的结合。

它将被生成的函数Logo()[]字节函数或一些类似的访问器替换。

这种方法的一个重大缺点是它改变了对程序进行类型检查的方式:除非您知道该指令变成了什么,否则不能对Logo调用进行类型检查。也没有明显的地方可以编写新Logo函数的文档。实际上,这个新指令最终是一个完整的语言更改:所有处理GO代码的工具都必须更新才能理解它。

提到的第二种方法是使用带有标准GO函数定义的新的可导入嵌入式包,但是这些函数实际上是在编译时执行的,如下所示:

这种方法修复了类型检查问题-它不是完全的语言更改-但它仍然具有很大的实现复杂性。GO命令需要解析整个GO源文件,以了解需要使哪些文件可用于嵌入。今天,它只解析到IMPORT块,而不是完整的GO表达式。用户也不清楚对这些特殊调用的参数施加了什么约束:它们看起来像普通的GO调用,但它们只能接受字符串文字,而不是由GO代码计算的字符串,甚至可能没有命名常量(否则GO命令将需要完整的GO表达式计算器)。

许多初步讨论都集中在这两种方法之间的选择上。这种设计将两者结合起来,避免了各自的缺点。

//go:embed Comment指令遵循GO构建系统和编译器指令的既定约定。Go命令很容易找到该指令,并且很明显该指令不能引用由函数调用计算的字符串,也不能引用命名常量。

Embed.Files类型是纯GO代码,在纯GO包嵌入中定义。所有输入-check go代码或对其运行其他分析的工具都可以理解代码,而无需对//go:embed指令进行任何特殊处理。

(从GO 1.15开始,//GO:Embed行不被视为文档注释的一部分。)。

显式变量声明还提供了一种清晰的方法来控制是否导出embed.Files。仅数据包可能只会导出嵌入的文件,例如:

Package web//Styles保存我们所有网站之间共享的CSS文件。//go:embed style/*.cssvar Styles Embedded.Files。

在初步讨论中,一些人建议使用go.mod中的新指令指定嵌入文件。

然而,GO模块的设计是,go.mod仅用于描述有关模块版本要求的信息,而不是特定包的其他细节。它不是通用元数据的集合。例如,在go.mod中使用编译器标志或构建标记是不合适的。出于同样的原因,关于一个包的嵌入文件的信息在go.mod中也是不合适的:每个包的单独含义应该由其GO源来定义。Go.mod仅用于决定使用其他包的哪些版本来解析导入。

将嵌入信息放在包中具有使用go.mod所没有的好处,包括显式声明文件集、控制可导出性等。

显然,需要某种方式来提供要包含的文件模式,例如*.jpg。此设计采用GLOB模式作为命名要包含的文件的唯一方式。GLOB模式对于来自命令shell的开发人员来说是常见的,并且它们已经在Go中、在path.Match、filepath.Match和filepath.Glob的API中定义良好。几乎所有的文件名都是只与自身匹配的有效glob模式;使用globs可以避免使用单独的//go:embedfile和//go:embedglob指令。(比方说,如果我们使用regexp包提供的GO正则表达式,情况就不会是这样。)。

在某些系统中,全局模式**与*类似,但可以匹配多个路径元素。例如,image/**.jpg与以image/为根目录的目录树中的所有.jpg文件相匹配。这个语法在Go的path.Match或filepath.Glob中不可用,而且使用可用的语法似乎比定义新的语法更好。匹配目录的规则应包括该目录树中的所有文件,以满足对**模式的大部分需求。例如,//go:embed image而不是//go:embed image/**.jpg。这不完全一样,但希望足够好。

如果在将来的某个时候明确需要**全局模式,支持它们的正确方式应该是将它们添加到path.Match和filepath.Glob中;然后//go:embed指令将免费获得它们。

为了构建嵌入到依赖项中的文件,原始文件本身必须包含在模块zip文件中。这意味着任何嵌入的文件都必须位于模块自己的文件树中。它不能位于模块根目录上方的父目录中(如../../../etc/passwd),不能位于包含不同模块的子目录中,也不能位于将从模块中删除的目录(如.git)中。另一个含义是,不可能嵌入仅在文件名大小写方面不同的两个不同文件,因为这些文件不可能在不区分大小写的系统(如Windows或MacOS)上提取。因此,您不能嵌入具有不同外壳的两个文件,如下所示:

因为embed.Files实现了fs.FS,所以它不能提供对名称以..开头的文件的访问,因此父目录中的文件也是完全不允许的,即使父目录命名为..。确实碰巧在同一个舱里。

初步讨论提出了大量可能在嵌入之前应用于文件的转换,包括:数据压缩、JavaScript缩小、打字脚本编译、图像大小调整、子画面映射生成、UTF-8标准化和CR/LF标准化。

GO命令预测或包含所有可能需要的转换是不可行的。GO命令也不是一般的构建系统;尤其要记住设计约束,即它在构建期间从不运行用户程序。这些类型的转换最好留给外部构建系统,比如make或Bazel,它可以写出go命令应该嵌入的确切字节。

这个建议的一个更有限的版本是对嵌入的数据进行gzip压缩,然后将压缩后的表单作为gzip响应内容直接用于HTTP服务器。这样做将强制使用(或至少支持)gzip和压缩内容,随着我们更多地了解它是如何工作的,这将使将来更难调整实现。总体而言,这似乎过于适合特定的用例。

最简单的方法是使用Go的嵌入功能来存储纯文本文件,让构建系统或第三方包在构建之前进行预处理,或者在运行时进行后处理。也就是说,该设计侧重于提供将原始字节嵌入到二进制文件中以供运行时使用的核心功能,而将其他工具和包留在坚实的基础上构建。

初步讨论中的一个流行问题是嵌入的数据应该以压缩还是未压缩的形式存储在二进制中。这种设计小心翼翼地避免假设这个问题的答案。相反,是否压缩可以留给实现细节。

压缩带来了较小二进制文件的明显好处。然而,它也带来了一些不太明显的成本。大多数压缩格式(特别是gzip和zip)不支持对未压缩数据的随机访问,但是http.File需要随机访问(ReadAt、Seek)来实现范围请求。其他用途可能也需要随机访问。因此,许多流行的嵌入工具都是从运行时解压缩嵌入的数据开始的。这会增加启动CPU成本和内存成本。相反,将未压缩的嵌入数据存储在二进制文件中支持随机访问,无需启动CPU开销。它还降低了内存成本:文件内容永远不会存储在垃圾收集堆中,操作系统在访问可执行文件中的必要数据时会高效地将这些数据分页,而不需要一次加载所有这些数据。

大多数系统的磁盘比RAM多。在这些系统上,以在运行时使用更多内存(和更多CPU)为代价来缩小二进制文件是没有意义的。

另一方面,像TinyGo和U-root这样的项目针对的是RAM比磁盘或闪存更多的系统。对于这些项目,压缩资产并在运行时使用增量式解压缩可以显著节省成本。

同样,此设计允许将压缩保留为实现细节。细节不是由每个包的作者决定的,而是可以在构建最终的二进制文件时决定。未来的工作可能是将add-embed=compress作为Go构建选项添加到有限的环境中使用。

除了对//go:embed本身的支持之外,用户可见的唯一GO命令更改是GO列表输出中显示的新字段。

对于处理围棋包的工具来说,能够理解构建需要哪些文件是很重要的。Go list命令是现在使用的底层机制,即使是golang.org/x/tools/go/Packages也是如此。将嵌入的文件作为GO列表使用的Package Struct中的新字段公开,使它们既可直接使用,也可供更高级别的API使用。

在初步讨论中,一些人建议可以在go build命令行中指定嵌入文件的列表。这可能适用于嵌入在主包中的文件,也许可以使用适当的Makefile。但是对于依赖项,它会严重失败:如果依赖项想要添加新的嵌入式文件,所有使用该依赖项构建的程序都需要调整它们的构建命令行。

在初步讨论中,一些人指出开发人员可能会被//go:embed指令在构建期间处理而//go:Generate指令不在构建期间处理这一矛盾所困扰。

还有其他特殊的注释指令://go:noinline、//go:noscape、//+build、//line。所有这些都是在构建期间处理的。例外是//GO:GENERATE,因为设计约束是GO命令在构建期间不运行用户代码。//go:embed不是特例,它也不会使//go:生成更多的特例。

新的嵌入式软件包提供对嵌入式文件的访问。之前对标准库的添加首先是在golang.org/x中进行的,以使它们可用于Go的早期版本。但是,使用golang.org/x/embed代替embed是没有意义的:旧版本的go可以导入golang.org/x/embed,但如果没有较新的go命令支持,仍然无法嵌入文件。对于使用e的程序来说,它更清楚。

.