深入淺出 Golang 資源嵌入方案:前篇

soulteary 發表於 2022-01-17
Go

非常多的語言都具備資源嵌入方案,在 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


我們有一個小小的折騰群,裡面聚集了幾百位喜歡折騰的小夥伴。

在不發廣告的情況下,我們在裡面會一起聊聊軟硬體、HomeLab、程式設計上的一些問題,也會在群裡不定期的分享一些技術沙龍的資料。

喜歡折騰的小夥伴歡迎掃碼新增好友。(新增好友,請備註實名,註明來源和目的,否則不會通過稽核)

關於折騰群入群的那些事


如果你覺得內容還算實用,歡迎點贊分享給你的朋友,在此謝過。


本文使用「署名 4.0 國際 (CC BY 4.0)」許可協議,歡迎轉載、或重新修改使用,但需要註明來源。 署名 4.0 國際 (CC BY 4.0)

本文作者: 蘇洋

建立時間: 2022年01月15日
統計字數: 7122字
閱讀時間: 15分鐘閱讀
本文連結: https://soulteary.com/2022/01...