乐趣区

关于golang:深入浅出-Golang-资源嵌入方案前篇

十分多的语言都具备资源嵌入计划,在 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 无关的次要的代码实现次要在上面四个文件中:

  • src/embed/embed.go
  • src/go/build/read.go
  • src/cmd/compile/internal/noder/noder.go
  • src/cmd/compile/internal/gc/main.go

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 服务的动态资源服务器,其中动态资源则来自“嵌入资源”。

第一步:筹备测试资源

提到资源嵌入性能,咱们天然须要寻找适合的资源。因为不波及具体文件类型的解决,所以这里咱们只须要关注文件尺寸即可。我找了两个网络上公开的文件作为嵌入的对象。

  • 一个约 100KB(94KB)的前端 JavaScript 文件:Vue.js

    • https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.min.js
  • 一个约 20MB(17.8MB)的高清图片

    • https://stocksnap.io/photo/technology-motherboard-PUWNNLCU1C

如果你想入手亲自试一试,能够应用下面的链接,取得同款测试资源。将文件下载之后,咱们将资源搁置程序雷同目录中的 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/,将可能看到相似上面的界面。

这部分相干代码能够在 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


咱们有一个小小的折腾群,外面汇集了几百位喜爱折腾的小伙伴。

在不发广告的状况下,咱们在外面会一起聊聊软硬件、HomeLab、编程上的一些问题,也会在群里不定期的分享一些技术沙龙的材料。

喜爱折腾的小伙伴欢送扫码增加好友。(增加好友,请备注实名,注明起源和目标,否则不会通过审核)

对于折腾群入群的那些事


如果你感觉内容还算实用,欢送点赞分享给你的敌人,在此谢过。


本文应用「署名 4.0 国内 (CC BY 4.0)」许可协定,欢送转载、或从新批改应用,但须要注明起源。署名 4.0 国内 (CC BY 4.0)

本文作者: 苏洋

创立工夫: 2022 年 01 月 15 日
统计字数: 7122 字
浏览工夫: 15 分钟浏览
本文链接: https://soulteary.com/2022/01…

退出移动版