Go 文件系统与文件嵌入
Go 团队最近发布了几份草案设计,提议对语言、标准库和工具链进行修改:我们在 6 月份报道过其中关于泛型的那份。上周,Go 团队又公布了两份与文件相关的草案设计:一份是全新的只读文件系统接口,定义了文件系统的最小接口;另一份则提出了一种将文件嵌入到 Go 二进制文件的标准方式(以前者为基础)。将文件嵌入 Go 二进制文件的目的是通过把程序所需的所有资源都打包到单一可执行文件中来简化部署;而文件系统接口的设计主要是作为前者的基础构件起草的。这两份草案引发了大量讨论,总体评价积极,但也存在一些值得重视的担忧。
Go 团队的技术负责人 Russ Cox 与 Go 语言创始人之一 Rob Pike 是文件系统接口设计的作者。Cox 还与长期贡献者 Brad Fitzpatrick 共同撰写了文件嵌入的设计。此外,Cox 还为每份设计制作了 YouTube 视频讲解,供偏好视频形式的读者观看(文件系统接口视频和文件嵌入视频)。两份设计都很快指出,它们(目前)还不是正式提案:
这是一份草案设计,而非正式的 Go 提案,因为它描述的是一项潜在的大型改动,涉及的需求已有众多第三方包在解决,也可能会影响这些包的实现(希望是以简化的方式!)。发布这份草案设计的目的是收集反馈,以完善未来拟提交的正式提案。
许多较小的语言和库改动在 GitHub issue 跟踪器上讨论,但对于这类较大的议题,Go 团队正尝试利用 r/golang 上的 Reddit 帖子来扩大讨论规模——GitHub issue 没有任何形式的楼中楼,多个对话难以并行跟踪。每份草案都有对应的 Reddit 讨论帖——文件系统接口帖和文件嵌入帖——每个帖子下都有不少评论。此外,Hacker News 上也有一个讨论文件嵌入设计的长帖。
文件系统接口
文件系统接口设计的核心,是在新的标准库包 io/fs 中定义的一个单方法接口 FS:
type FS interface {
Open(name string) (File, error)
}这意味着每个文件系统实现至少都必须具备按名称打开文件的能力,并返回一个 File 和一个错误。File 接口定义如下:
type File interface {
Stat() (os.FileInfo, error)
Read(buf []byte) (int, error)
Close() error
}换言之,文件具有以下特性:能够提供类似 stat() 返回的文件信息、能够被读取、能够被关闭。这些是合规文件系统需要提供的最基本能力,但实现“也可以提供其他方法来优化操作或增加新功能
”。标准库中的文件类型(os.File)已经实现了这三个方法,因此它本身就是一个符合要求的 fs.File 实现。
如果一个 File 实际上是目录,Stat() 返回的文件信息会标明这一点;此时 Open() 返回的 File 还必须在 File 接口之上实现 Readdir() 方法。Readdir() 会返回一组 os.FileInfo 对象,代表该目录下的文件。
文件系统实现可以通过设计中所称的“扩展接口”来暴露额外功能,即“嵌入基础接口并添加一个或多个额外方法,以此来规定基础接口的实例可能提供的可选功能”。例如,一次性读取整个文件是很常见的操作,而对于内存文件系统实现来说,通过 Open()、多次调用 Read() 再 Close() 来完成这一操作可能效率不高。在这种情况下,开发者可以按ReadFileFS 扩展接口中的定义来实现 ReadFile() 方法:
type ReadFileFS interface {
FS // embed the filesystem interface (Open method)
ReadFile(name string) ([]byte, error)
}除了扩展接口本身,该设计还在 io/fs 包中新增了一个 ReadFile() 辅助函数,它会检查文件系统是否实现了 ReadFileFS 扩展,如果有就直接调用,否则回退到执行打开/读取/关闭的流程。草案中还定义了其他多种扩展接口,包括 StatFS、ReadDirFS 和 GlobFS。该设计并未提供重命名或写入文件的方式,但这些功能同样可以通过扩展来实现。
除了 io/fs 中新增的类型和辅助函数,该设计还建议对多个标准库包进行调整,以利用新的 FS 接口。例如,为 html/template 包增加 ParseFS() 方法,使其能够从内存文件系统解析模板,或让 archive/zip 包实现 FS 接口,让开发者可以把 zip 文件当作文件系统,在任何接受 FS 的地方使用。
Reddit 讨论中的反馈大多是积极的,这类接口似乎正是开发者所需要的。不过,也有数人提出了批评,其中之一就是扩展接口的弊端。“Acln0”总结了相关担忧:
我只有一个观察,想谈谈扩展接口和扩展模式。这让我想起了 http.ResponseWriter 以及 http 包所使用的那些可选接口。由于这些可选接口的存在,包装 http.ResponseWriter 变得很困难。要“通用地”进行包装,会涉及可选接口的组合爆炸,而且很容易以如下方式出错:“我们通过包装 http.ResponseWriter 增加了状态日志,结果 HTTP/2 推送却失效了,因为我们的包装器把 Push 方法对下游处理器隐藏掉了”。
知名 Go 博主兼演讲者 Peter Bourgon 认为,这种对扩展接口的运用意味着“(极其有用的)装饰器模式将变得难以使用。这实在令人遗憾。对我而言,这几乎让该提案无法成立;装饰器模式太有用了,不该以这种方式被破坏。
”装饰器模式通过包装接口来增加功能,常用于 Web 服务器中的日志或认证中间件;在文件系统的语境下,它可能会被用来添加缓存或转换层。如果中间件作者没有考虑到各种可选接口,最终的包装器就不会支持这些接口。Go 编写的云存储工具 Rclone 的作者 Nick Craig-Wood 虽然喜欢这份提案,但也表达了类似的担忧:“扩展(或者按我常说的,可选)接口是很大的维护负担——包装它们非常困难
”。
该设计指出,“支持这类中间件是本草案设计的一个关键目标
”,因此设计者直面这一问题似乎是明智之举。Cox 尚未提出解决方案,但承认了这个问题:“确实——扩展与包装之间确实存在张力。我还没见过完美的解决方法。
”
另一个担忧来自“TheSwedeheart”,涉及 contexts (Go 中显式传递超时、取消信号和请求作用域值沿调用链向下传播的标准方式):“要把[他的虚拟文件系统]迁移到这套方案上,我缺少的是将 context 传播到每个操作以实现取消的支持。
”Cox 回复称,库作者“大概可以把 context 传给构造函数,让它返回一个内部嵌入了该 context 的 FS,然后让这个特定的 FS 上的所有调用都应用该 context。
”正如“lobster_johnson”指出的,这违背了 context 包的指导原则——应显式地将 context 作为第一个函数参数传递,而不是将其存储在结构体内部。不过,Cox 用 http.Request 的类似做法进行了反驳:“那些更多是指导原则而非硬性规定。[...] 有时这样做确实是有道理的。
”
当然,也少不了围绕命名的常规 bikeshedding 讨论;“olegkovalov”说:“我有点担心 io/fs 这个名字,fs 是个很好的变量名,当 io/fs 出现时会给用户带来很多麻烦
”。经过一番来回讨论,Cox 强调需要一个简短的名字,以便把关注点放在应用开发者而非文件系统实现者身上:
你关注的是文件系统实现者而非使用者。像 os.FileInfo、os.ModeDir、os.PathError、os.ErrNotExist 这类代码,今后都将规范地指向 fs.FileInfo、fs.ModeDir、fs.PathError、fs.ErrNotExist。这看起来比比如 filesystem.ErrNotExist 要好得多。而且,引用这些名称的代码将远多于实现文件系统的代码。
在二进制文件中嵌入文件
另一份草案设计提出了一种在 Go 二进制文件中嵌入文件(或称“静态资源”)并在运行时读取其内容的方法。这简化了发布与部署,因为开发者只需分发一个无外部依赖的大型二进制文件即可(其中可包含 SQL 片段、HTML 模板、Web 应用的 CSS 与 JavaScript 资源等)。正如文档所指出的,已有十多种第三方工具可以实现这一功能,但“为 go 命令直接添加嵌入基础功能的支持,将消除对其中一些工具的需求,至少也能简化其他工具的实现
”。将嵌入功能纳入标准的 go 工具还意味着,无需在构建前把文件转换成 Go 源码中的数据,也无需将这些生成的文件提交到版本控制中。
设计的作者明确指出,这是一项工具链的改动,而非 Go 语言本身的改动:
另一个明确的目标是避免语言层面的改动。对我们而言,嵌入静态资源似乎是一个工具问题,而非语言问题。避免语言改动也意味着我们无需更新众多处理 Go 代码的工具,包括 goimports、gopls 和 staticcheck。
go 工具已经会在 Go 源文件中寻找各种特殊注释,包括用于仅在特定架构下包含某些文件的 // +build 标签,以及告诉 go generate 为代码生成目的应执行哪些命令的 //go:generate 注释。这份文件嵌入设计提出了一种新的 //go:embed 注释指令,它直接位于变量声明之上,指示 go build 将这些文件包含到与该变量关联的最终二进制文件中。下面是一个具体示例:
// The "content" variable holds our static web server content.
//go:embed image/* template/*
//go:embed html/index.html
var content embed.Files这会让 go build 将 image 和 template 目录下的所有文件,以及 html/index.html 文件都包含进来,并通过 content 变量(类型为 embed.Files)提供访问。embed 包是一个拟新增的标准库包,其中包含了访问嵌入文件的 API。此外,embed.Files 类型实现了上文文件系统设计中的 fs.FS 接口,从而使嵌入的文件可以直接与 net/http 和 html/template 等其他标准库包,以及任何支持新文件系统接口的第三方包配合使用。
该设计在一个重要方面限制了提案的范围。在将文件数据包含进二进制文件之前,有许多种对其进行转换的方式:数据压缩、TypeScript 编译、图像缩放等等。这份设计采取了简单的做法,只包含原始文件数据:
go 命令不可能预见或涵盖所有可能需要的转换。go 命令也不是一个通用的构建系统;特别要记住的设计约束是,它在构建期间从不运行用户程序。这类转换最好留给外部构建系统,如 Make 或 Bazel,由它们生成 go 命令应当嵌入的确切字节。
同样,Reddit 帖子中的反馈大多是积极的,例如“bojanz”的这条评论:“这看起来是个很好的开端。感谢你们着手解决这个问题。
”也有一些小的建议,比如“zikaeroh”提议增加更强大的路径匹配 API,支持用于递归匹配的双星号,如同 Python 中的 glob('**/*.png', recursive=True)。文件嵌入包的维护者 Kevin Burke 则建议同时存储每个文件内容的加密哈希,以免开发者在运行时再对文件进行哈希:“这对于例如静态文件服务器上的缓存 busting 很有用
”。
反复出现的一种批评来自不喜欢用特殊 //go:embed 语法让源码注释负担过重的开发者。“Saturn_vk”直言不讳地表示:“我真的不喜欢注释被滥用于实际工作
”,Hacker News 评论者“breakingcups”则强烈主张使用项目文件而非注释中的指令:
又是更多的魔法注释。
提议的功能很棒,但 Go 团队不愿使用单独的、定义清晰的项目文件,或者至少在代码文件中使用单独的语法,导致他们把每个新增功能都塞进注释里——而注释本是供人做笔记的空间。
Cox 用以下评论总结了他对此的看法,并将该语法与 C 语言中的 #pragma 进行了类比:
平心而论,我们已经有了 //go:generate 以及其他几个不太知名的指令。还有一份单独的草案设计要用 //go:build 替代 // +build。到那时我们将完全保持一致:这类指令都以 //go: 开头。要点在于,它要看起来足够像注释,让不需要关心的工具可以忽略它,但又要足够不像注释,以向人们表明有特殊的事情正在发生。
C 语言为此使用 #pragma foo。Go 只是把 #pragma 拼写为 //go:。
后续展望
两份草案设计都获得了相当多的社区支持,尤其是更面向用户的文件嵌入提案。许多开发者已经在使用第三方文件嵌入库来简化部署,这些努力将使相关工具标准化。这些设计很可能会得到进一步完善并转化为正式提案。随着 Go 1.15 定于 8 月 1 日发布,这些提案有可能赶上 Go 1.16(计划在六个月后发布),但如果还需要一轮反馈——例如关于扩展接口的问题——则更有可能在一年后的 Go 1.17 中加入。
随机一篇博客
评论
登录后参与讨论