非常多的语言都具备资源嵌入方案,在 Golang 中,资源嵌入相关的开源方案更是百家争鸣。网络上关于 Golang 资源嵌入的使用方案很多,但是鲜有人剖析原理,以及将原生实现和开源实现进行性能比较,适用场景分析。

所以本文就来聊聊这个话题,权作抛砖引玉。

写在前面

不论是哪一种语言,总会因为一些原因,我们需要将静态资源嵌入语言编译结果中。Golang 自然也不例外,不过在官方 2019 年 12 月有人提出“资源嵌入功能”草案前,Golang 生态中能够提供这个需求功能的项目已经有不少了,直到 2020 年 Golang 1.16 发布,资源嵌入功能,正式的被官方支持了。

在越来越多的文章、甚至之前实现了资源嵌入功能的开源项目纷纷推荐使用官方 go embed 指令来进行功能实现的今天。我们或许应该更客观的了解“语言原生功能”和三方实现的异同,以及作为追求性能的 Go 语言生态中技术解决方案性能上的客观差距。

接下来的文章里,我会陆续介绍在 GitHub 成名已久或者被广泛使用的一些同类项目,比如 packr(3.3k stars)、statik (3.4k stars)、go.rice (2.3k stars)、go-bindata (1.5k stars)、vsfgen (1k stars)、esc(0.6k stars)、fileb0x (0.6k stars)…

本篇文章里,我们先以官方原生功能 go embed 指令为切入点,作为标准参考系,聊聊原理、聊聊基础使用、聊聊性能。

先来聊聊原理。

Go Embed 原理

阅读目前最新的 Golang 1.17 的源码,忽略掉一些和命令行参数处理相关的部分,我们不难发现和 Embed 有关的主要的代码实现主要在下面四个文件中:

embed/embed.go

embed.go 主要提供了 embed 功能在运行时的相关声明和函数定义( FS 的接口实现),以及提供了 go doc 文档中的说明部分。

FS 接口实现对于想要通过文件系统的方式访问和操作文件来说非常关键,比如你想使用标准的 FS 函数针对文件进行 “CRUD” 操作。

// lookup returns the named file, or nil if it is not present.
func (f FS) lookup(name string) *file {
	...
}

// readDir returns the list of files corresponding to the directory dir.
func (f FS) readDir(dir string) []file {
	...
}


func (f FS) Open(name string) (fs.File, error) {
	...}

// ReadDir reads and returns the entire named directory.
func (f FS) ReadDir(name string) ([]fs.DirEntry, error) {
	...
}

// ReadFile reads and returns the content of the named file.
func (f FS) ReadFile(name string) ([]byte, error) {
	...
}

通过阅读代码,我们不难看到在 go embed 中文件被设定为只读,但是如果你愿意的话,你完全可以实现一套可读可写的文件系统,这点我们后面的文章会提到。

func (f *file) Mode() fs.FileMode {
	if f.IsDir() {
		return fs.ModeDir | 0555
	}
	return 0444
}

除了能够通过 FS 相关的函数直接操作文件之外,我们还能够将“ embed fs ”挂载到 Go 的 HTTP Server 中或任何你喜欢的 Go Web 框架的对应的文件处理函数中,实现类似 Nginx 的静态资源服务器。

go/build/read.go

如果说前者提供了我们编写代码时 go:embed 的可用,相对比较“虚”,那么 build/read.go 则提供了程序编译阶段前比较实在的分析和验证处理。

这个程序主要解析在程序中书写的 go:embed 指令内容,并处理内容的有效性,以及针对需要嵌入的内容(变量、文件)进行具体的逻辑处理。比较关键的函数有两个:

func readGoInfo(f io.Reader, info *fileInfo) error {
  ...
}

func parseGoEmbed(args string, pos token.Position) ([]fileEmbed, error) {
  ...
}

函数 readGoInfo 负责读取我们的代码文件 *.go,找到代码中包含 go:embed 的内容,然后将包含这个内容的对应文件的行数传递给 parseGoEmbed 函数,将指令中的文件路径相关的函数解析为具体的文件或文件列表。

如果文件资源路径是具体的文件,那么将文件保存到待处理的文件列表中,如果是目录或者类似 go:embed image/* template/* 这样的语法,随后其他调用函数会将这个内容以 glob 的方式扫描出来,并将文件保存到待处理的文件列表中。

这些内容最终会被保存在和每个程序文件相关 fileInfo 结构体中,然后等待 go/build/build.go 和其他相关的编译程序的使用。

// fileInfo records information learned about a file included in a build.
type fileInfo struct {
	name     string // full name including dir
	header   []byte
	fset     *token.FileSet
	parsed   *ast.File
	parseErr error
	imports  []fileImport
	embeds   []fileEmbed
	embedErr error
}

type fileImport struct {
	path string
	pos  token.Pos
	doc  *ast.CommentGroup
}

type fileEmbed struct {
	pattern string
	pos     token.Position
}

compile/internal/noder/noder.go

相比较前两个程序, noder.go 干的活最重,负责进行最终的解析和内容关联并将结果以 IR 的形式保存,等待最终编译程序的处理。另外,它还负责处理 cgo 相关程序的解析(也算是某种形式的嵌入嘛)。

这里它也和前面的 read.go 一样,会做一些校验和判断的工作,比如判断用户嵌入的资源是否真的被使用到了,或者用户使用了 embed 对象和其下面的函数,但是却忘记声明 go:embed 指令的,如果发现这些预期之外的事件,就及时停止程序运行,避免进入编译阶段,浪费时间。

相对核心的函数有:

func parseGoEmbed(args string) ([]string, error) {
 ...
}

func varEmbed(makeXPos func(syntax.Pos) src.XPos, name *ir.Name, decl *syntax.VarDecl, pragma *pragmas, haveEmbed bool) {
 ...
}

func checkEmbed(decl *syntax.VarDecl, haveEmbed, withinFunc bool) error {
 ...
}

在上面的函数中,我们在文件中声明的 go:embed 指令和实际程序目录中的静态资源会以 IR 的方式产生关联,可以简单理解为此刻我们根据 go:embed 指令上下文中的变量已经被赋值了。

cmd/compile/internal/gc/main.go

在经过上面几个程序的处理后,文件最终会来到编译器这里,由 func Main(archInit func(*ssagen.ArchInfo)) {} 调用下面的内部函数,将静态资源直接写入磁盘(附加到文件里):

	// Write object data to disk.
	base.Timer.Start("be", "dumpobj")
	dumpdata()
	base.Ctxt.NumberSyms()
	dumpobj()
	if base.Flag.AsmHdr != "" {
		dumpasmhdr()
	}

在文件写入的过程中,我们可以看到针对嵌入的静态资源而言,写入过程非常简单(实现部分在 src/cmd/compile/internal/gc/obj.go):

func dumpembeds() {
	for _, v := range typecheck.Target.Embeds {
		staticdata.WriteEmbed(v)
	}
}

至此,关于 Golang 资源嵌入的原理和流程我们就清楚了,官方资源嵌入功能实现具备什么能力,又欠缺哪些能力(相比较其他开源实现)我们也就清楚了。随后,我将在后续文章中逐一展开。

基础使用

我们需要先来聊聊 embed 的基础使用。这一方面是为了照顾还未使用过 embed 功能的同学,另外一方面是为了建立一个标准的参考系,来为后续性能对比做出客观评价。

为了测试的方便和直观,本篇文章和后续文章中,我们都以优先实现一个可进行性能测试的,并且能够提供 Web 服务的静态资源服务器,其中静态资源则来自“嵌入资源”。

第一步:准备测试资源

提到资源嵌入功能,我们自然需要寻找合适的资源。因为不涉及具体文件类型的处理,所以这里我们只需要关注文件尺寸即可。我找了两个网络上公开的文件作为嵌入的对象。

如果你想动手亲自试一试,可以使用上面的链接,获得同款测试资源。将文件下载之后,我们将资源放置程序相同目录中的 assets 文件夹即可。

第二步:编写基础程序

首先初始化一个空的项目:

mkdir basic && cd basic
go mod init solution-embed

为了公允,我们先使用 Go 官方仓库中的测试代码作为基础模版。

// Copyright 2021 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package embed_test

import (
	"embed"
	"log"
	"net/http"
)

//go:embed internal/embedtest/testdata/*.txt
var content embed.FS

func Example() {
	mutex := http.NewServeMux()
	mutex.Handle("/", http.FileServer(http.FS(content)))
	err := http.ListenAndServe(":8080", mutex)
	if err != nil {
		log.Fatal(err)
	}
}

简单调整之后,我们可以得到一个将当前目录下 assets 目录进行资源嵌入的程序。

package main

import (
	"embed"
	"log"
	"net/http"
)

//go:embed assets
var assets embed.FS

func main() {
	mutex := http.NewServeMux()
	mutex.Handle("/", http.FileServer(http.FS(assets)))
	err := http.ListenAndServe(":8080", mutex)
	if err != nil {
		log.Fatal(err)
	}
}

接着我们启动程序,或者编译程序,就能够在 localhost:8080 中访问我们静态资源目录中的文件了,例如:http://localhost:8080/assets/example.txt

这部分代码,你可以在 https://github.com/soulteary/awesome-golang-embed/tree/main/go-embed-official/basic 中获取。

测试准备

在聊性能之前,我们首先需要改造一下程序,让程序能够被测试,以及能够给出明确的性能指标。

第一步:完善可测试性

上面的代码因为足够简单,所以写在了相同的 main 函数中。为了能够被测试,我们需要做一些简单的调整,比如将注册路由部分和启动服务部分拆分。

package main

import (
	"embed"
	"log"
	"net/http"
)

//go:embed assets
var assets embed.FS

func registerRoute() *http.ServeMux {
	mutex := http.NewServeMux()
	mutex.Handle("/", http.FileServer(http.FS(assets)))
	return mutex
}

func main() {
	mutex := registerRoute()
	err := http.ListenAndServe(":8080", mutex)
	if err != nil {
		log.Fatal(err)
	}
}

为了简化测试代码编写,这里我们使用一个开源断言库 testify,先进行安装 :

go get -u  github.com/stretchr/testify/assert

接着编写测试代码:

package main

import (
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/stretchr/testify/assert"
)

func TestStaticRoute(t *testing.T) {
	router := registerRoute()

	w := httptest.NewRecorder()
	req, _ := http.NewRequest("GET", "/assets/example.txt", nil)
	router.ServeHTTP(w, req)

	assert.Equal(t, http.StatusOK, w.Code)
	assert.Equal(t, "@soulteary: Hello World", w.Body.String())
}

代码编写完毕之后,我们执行 go test,不出意外,将能够看到类似下面的结果:

# go test

PASS
ok  	solution-embed	0.219s

除了验证功能正常之外,这里还可以添加一些额外的操作,来进行一个比较粗的性能测试,比如测试 10万次通过 HTTP 方式获取资源所需要的时间:

func TestRepeatRequest(t *testing.T) {
	router := registerRoute()

	passed := true
	for i := 0; i < 100000; i++ {
		w := httptest.NewRecorder()
		req, _ := http.NewRequest("GET", "/assets/example.txt", nil)
		router.ServeHTTP(w, req)

		if w.Code != 200 {
			passed = false
		}
	}

	assert.Equal(t, true, passed)
}

这部分代码,你可以从 https://github.com/soulteary/awesome-golang-embed/tree/main/go-embed-official/testable 中获得。

第二步:添加性能探针

以往针对黑盒程序,我们只能用监控和事前事后的对比来获取具体的性能数据,当我们具备对程序的定制能力的时候,就可以直接用 profiler 程序来进行程序运行过程中的性能指标采集了。

借助 pprof 的能力,我们可以快速的在上面代码的 Web 服务中添加几个和性能相关的接口。多数文章会告诉你引用 pprof 这个模块就可以了,其实不然。因为阅读代码(https://cs.opensource.google/go/go/+/refs/tags/go1.17.6:src/net/http/pprof/pprof.go),我们可知,pprof 的“性能监控接口自动注册”的能力,仅针对默认的 http 服务有效,而不会针对多路复用(mux)的 http 服务生效:

func init() {
	http.HandleFunc("/debug/pprof/", Index)
	http.HandleFunc("/debug/pprof/cmdline", Cmdline)
	http.HandleFunc("/debug/pprof/profile", Profile)
	http.HandleFunc("/debug/pprof/symbol", Symbol)
	http.HandleFunc("/debug/pprof/trace", Trace)
}

所以为了让 pprof 生效,我们需要手动注册这几个性能指标接口,将上文中的代码进行调整,可以得到类似下面的程序。

package main

import (
	"embed"
	"log"
	"net/http"
	"net/http/pprof"
	"runtime"
)

//go:embed assets
var assets embed.FS

func registerRoute() *http.ServeMux {

	mutex := http.NewServeMux()
	mutex.Handle("/", http.FileServer(http.FS(assets)))
	return mutex
}

func enableProf(mutex *http.ServeMux) {
	runtime.GOMAXPROCS(2)
	runtime.SetMutexProfileFraction(1)
	runtime.SetBlockProfileRate(1)

	mutex.HandleFunc("/debug/pprof/", pprof.Index)
	mutex.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
	mutex.HandleFunc("/debug/pprof/profile", pprof.Profile)
	mutex.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
	mutex.HandleFunc("/debug/pprof/trace", pprof.Trace)
}

func main() {
	mutex := registerRoute()
	enableProf(mutex)

	err := http.ListenAndServe(":8080", mutex)
	if err != nil {
		log.Fatal(err)
	}
}

再次运行或者编译程序后,访问 http://localhost:8080/debug/pprof/,将能够看到类似下面的界面。

Go PPROF Web 界面

这部分相关代码可以在 https://github.com/soulteary/awesome-golang-embed/tree/main/go-embed-official/profiler 中看到。

性能测试(建立基准)

这里我选择使用两种方式进行性能测试:第一种时候基于测试用例的采样数据,第二种则是基于构建后的程序的接口压力测的吞吐能力。

相关代码我已经上传至 https://github.com/soulteary/awesome-golang-embed/tree/main/go-embed-official/benchmark,可自行获取进行实验。

基于测试用例的性能取样

我们针对默认的测试程序进行简单调整,让其能够针对前文中,我们准备的两个资源进行大量重复请求(1000次小文件读取,100次大文件读取)。

func TestSmallFileRepeatRequest(t *testing.T) {
	router := registerRoute()

	passed := true
	for i := 0; i < 1000; i++ {
		w := httptest.NewRecorder()
		req, _ := http.NewRequest("GET", "/assets/vue.min.js", nil)
		router.ServeHTTP(w, req)

		if w.Code != 200 {
			passed = false
		}
	}

	assert.Equal(t, true, passed)
}

func TestLargeFileRepeatRequest(t *testing.T) {
	router := registerRoute()

	passed := true
	for i := 0; i < 100; i++ {
		w := httptest.NewRecorder()
		req, _ := http.NewRequest("GET", "/assets/chip.jpg", nil)
		router.ServeHTTP(w, req)

		if w.Code != 200 {
			passed = false
		}
	}

	assert.Equal(t, true, passed)
}

接着,编写一个脚本,帮助我们分别获取不同体积文件时的资源消耗状况。

#!/bin/bash

go test -run=TestSmallFileRepeatRequest -benchmem -memprofile mem-small.out -cpuprofile cpu-small.out -v
go test -run=TestLargeFileRepeatRequest -benchmem -memprofile mem-large.out -cpuprofile cpu-large.out -v

执行过后,能够看到类似下面的输出:

=== RUN   TestSmallFileRepeatRequest
--- PASS: TestSmallFileRepeatRequest (0.04s)
PASS
ok  	solution-embed	0.813s
=== RUN   TestLargeFileRepeatRequest
--- PASS: TestLargeFileRepeatRequest (1.14s)
PASS
ok  	solution-embed	1.331s
=== RUN   TestStaticRoute
--- PASS: TestStaticRoute (0.00s)
=== RUN   TestSmallFileRepeatRequest
--- PASS: TestSmallFileRepeatRequest (0.04s)
=== RUN   TestLargeFileRepeatRequest
--- PASS: TestLargeFileRepeatRequest (1.12s)
PASS
ok  	solution-embed	1.509s

嵌入大文件的性能状况

使用 go tool pprof -http=:8090 cpu-large.out 可以针对程序执行过程的调用以及资源消耗进行可视化展示。执行完命令后,在浏览器中打开 http://localhost:8090/ui/ ,可以看到类似下面的调用图:

嵌入大文件资源使用状况

上面的调用图中,我们可以看到在最耗时的 runtime.memmove (30.22%) 函数上一跳的发起者,就是 embed(*openFile) Read (5.04%)。从嵌入资源中获取我们要的接近 20m 的资源,只花费了总时间 5% 出头。其余的计算量则都集中在数据交换、go 数据长度自动扩展以及数据回收上。

读取嵌入资源以及相对耗时的调用状况

同样的,使用 go tool pprof -http=:8090 mem-large.out,我们来查看内存的使用状况:

读取嵌入资源内存消耗状况

可以看到在一百次调用之后,内存中总计使用过 6300 多MB 的空间,相当于我们原始资源的 360 倍的消耗,平均到每次请求,我们大概需要付出原文件 3.6 倍的资源

嵌入小文件的资源使用

看完大文件,我们再来看看小文件的资源使用状况。因为执行 go tool pprof -http=:8090 cpu-small.out 之后,调用图中并没有出现 embed 相关的函数(消耗资源可以忽略不计),所以我们就跳过 CPU 调用,直接看内存使用状况。

读取嵌入资源(小文件)内存消耗状况

在最终输出给用户之前,io copyBuffer 这里的资源使用量大概会是我们资源的 1.7 倍,应该是得益于 gc 回收功能,最终向用户输出数据的时候,资源用量会降低到 1.4 倍,相比较大体积的资源,实惠了不少

使用 Wrk 进行吞吐测试

我们先执行 go build main.go,获取构建后的程序,然后执行 ./main 启动服务,接着先来测试小文件的吞吐能力:

# wrk -t16 -c 100 -d 30s http://localhost:8080/assets/vue.min.js

Running 30s test @ http://localhost:8080/assets/vue.min.js
  16 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency     4.29ms    2.64ms  49.65ms   71.59%
    Req/Sec     1.44k   164.08     1.83k    75.85%
  688578 requests in 30.02s, 60.47GB read
Requests/sec:  22938.19
Transfer/sec:      2.01GB

在不进行任何代码优化的前提下,Go 使用嵌入的小体积的资源提供服务,大概能处理每秒 2万左右的请求量。然后再来看看针对大文件的吞吐:

# wrk -t16 -c 100 -d 30s http://localhost:8080/assets/chip.jpg 

Running 30s test @ http://localhost:8080/assets/chip.jpg
  16 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   332.75ms  136.54ms   1.32s    80.92%
    Req/Sec    18.75      9.42    60.00     56.33%
  8690 requests in 30.10s, 144.51GB read
Requests/sec:    288.71
Transfer/sec:      4.80GB

因为文件体积变大,虽然看起来请求量降低了,但是每秒的数据吞吐则提升了一倍有余。总的数据下载量相比较小问题提升了三倍有余,从 60GB 变成了 144GB。

最后

写到这里,本篇文章要聊的事情就都讲完了,接下来的内容中,我将讲解各种开源实现和本文中的官方实现的异同,以及揭示性能的差别。

–EOF