如何在 Go 中構建可插拔的庫
什麼是 go buildmode=plugin?
go buildmode=plugin 選項允許開發者將 Go 代碼編譯成共享對象文件。另一個 Go 程序可以在運行時加載該文件。當我們想在應用程序中添加新功能而又不想重建它時,這個選項非常有用。可以將新功能作爲插件加載。
Go 中的插件是編譯成共享對象(.so)文件的軟件包。可以使用 Go 中的 plugin package[1] 加載該文件,打開插件,查找符號(如函數或變量)並使用它們。
實踐範例
這裏舉了一個簡單的後端演示項目的示例,它提供了一個用於計算第 n 個 斐波那契數列的 API。出於演示目的,這裏特意使用了慢速斐波那契實現。考慮到計算速度較慢,我需要添加了一個緩存層來存儲結果,因此如果再次請求相同的 nth 斐波那契數字,無需重新計算,只需返回緩存結果即可。
API 是 GET /fib/{n} ,其中 n 是要計算的斐波納契數。下面我們來看看 API 是如何實現的:
// Fibonacci calculates the nth Fibonacci number.
// This algorithm is not optimized and is used for demonstration purposes.
func Fibonacci(n int64) int64 {
if n <= 1 {
return n
}
return Fibonacci(n-1) + Fibonacci(n-2)
}
// NewHandler returns an HTTP handler that calculates the nth Fibonacci number.
func NewHandler(l *slog.Logger, c cache.Cache, exp time.Duration) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
started := time.Now()
defer func() {
l.Info("request completed", "duration", time.Since(started).String())
}()
param := r.PathValue("n")
n, err := strconv.ParseInt(param, 10, 64)
if err != nil {
l.Error("cannot parse path value", "param", param, "error", err)
sendJSON(l, w, map[string]any{"error": "invalid value"}, http.StatusBadRequest)
return
}
ctx := r.Context()
result := make(chan int64)
go func() {
cached, err := c.Get(ctx, param)
if err != nil {
l.Debug("cache miss; calculating the fib(n)", "n", n, "cache_error", err)
v := Fibonacci(n)
l.Debug("fib(n) calculated", "n", n, "result", v)
if err := c.Set(ctx, param, strconv.FormatInt(v, 10), exp); err != nil {
l.Error("cannot set cache", "error", err)
}
result <- v
return
}
l.Debug("cache hit; returning the cached value", "n", n, "value", cached)
v, _ := strconv.ParseInt(cached, 10, 64)
result <- v
}()
select {
case v := <-result:
sendJSON(l, w, map[string]any{"result": v}, http.StatusOK)
case <-ctx.Done():
l.Info("request cancelled")
}
}
}
代碼的解釋如下:
-
NewHandler 函數創建一個新的 http.Handler 程序。它依賴於日誌記錄器、緩存和過期時間。cache.Cache 是一個接口,我們很快就會定義它。
-
返回的 http.Handler 會解析路徑參數中的 n 值。如果出現錯誤,它會發送錯誤響應。否則,它會檢查緩存中是否已經存在第 n 個斐波那契數字。如果沒有,處理程序會計算出該數字並將其存儲在緩存中,以備將來請求之用。
-
goroutine 在一個單獨的進程中處理斐波那契計算和緩存,而 select 語句則等待計算完成或客戶端取消請求。這樣可以確保在客戶端取消請求時,我們不會浪費資源等待計算完成。
現在,我們希望在運行時,即應用程序啓動時,可以選擇緩存的實現方式。一種直接的方法是在同一代碼庫中創建多個實現,並使用配置來選擇所需的實現。但這樣做的缺點是,未選擇的實現仍將是編譯後二進制文件的一部分,從而增加了二進制文件的大小。雖然構建標籤可能是一種解決方案,但我們將留待下一篇文章討論。現在,我們希望在運行時而不是在構建時選擇實現。這就是 buildmode=plugin 的真正優勢所在。
確保應用程序無需插件即可運行
由於我們已將 cache.Cache 定義爲一個接口,因此我們可以在任何地方創建該接口的實現,甚至可以在不同的存儲庫中創建。但首先,讓我們來看看 Cache 接口:
package cache
import (
"context"
"log/slog"
"time"
)
// consterror is a custom error type used to represent specific errors in the cache implementation.
// It is derived from the int type to allow it to be used as a constant, ensuring immutability across packages.
type consterror int
// Possible errors returned by the cache implementation.
const (
ErrNotFound consterror = iota
ErrExpired
)
// _text maps consterror values to their corresponding error messages.
var _text = map[consterror]string{
ErrNotFound: "cache: key not found",
ErrExpired: "cache: key expired",
}
// Error implements the error interface.
func (e consterror) Error() string {
txt, ok := _text[e]
if !ok {
return "cache: unknown error"
}
return txt
}
// Cache defines the interface for a cache implementation.
type Cache interface {
// Set stores a key-value pair in the cache with a specified expiration time.
Set(ctx context.Context, key, val string, exp time.Duration) error
// Get retrieves a value from the cache by its key.
// Returns ErrNotFound if the key is not found.
// Returns ErrExpired if the key has expired.
Get(ctx context.Context, key string) (string, error)
}
// Factory defines the function signature for creating a cache implementation.
type Factory func(log *slog.Logger) (Cache, error)
// nopCache is a no-operation cache implementation.
type nopCache int
// NopCache a singleton cache instance, which does nothing.
const NopCache nopCache = 0
// Ensure that NopCache implements the Cache interface.
var _ Cache = NopCache
// Set is a no-op and always returns nil.
func (nopCache) Set(context.Context, string, string, time.Duration) error { return nil }
// Get always returns ErrNotFound, indicating that the key does not exist in the cache.
func (nopCache) Get(context.Context, string) (string, error) { return "", ErrNotFound }
由於 NewHandler 需要依賴於 cache.Cache 實現,因此最好有一個默認實現,以確保代碼不會中斷。因此,讓我們創建一個什麼都不做的 no-op(無操作)實現。
這個 NopCache 實現了 cache.Cache 接口,但實際上並不做任何事情。它只是爲了確保處理程序正常工作。如果我們不使用任何自定義的 cache.Cache 實現來運行代碼,API 將正常工作,但結果不會被緩存 -- 這意味着每次調用都會重新計算斐波那契數字。以下是使用 NopCache(n=45)時的日誌:
./bin/demo -port=8080 -log-level=debug
time=2024-08-22T17:39:06.853+07:00 level=INFO msg="application started"
time=2024-08-22T17:39:06.854+07:00 level=DEBUG msg="using configuration" config="{Port:8080 LogLevel:DEBUG CacheExpiration:15s CachePluginPath: CachePluginFactoryName:Factory}"
time=2024-08-22T17:39:06.854+07:00 level=INFO msg="no cache plugin configured; using nop cache"
time=2024-08-22T17:39:06.854+07:00 level=INFO msg=listening addr=:8080
time=2024-08-22T17:39:19.465+07:00 level=DEBUG msg="cache miss; calculating the fib(n)" n=45 cache_error="cache: key not found"
time=2024-08-22T17:39:23.246+07:00 level=DEBUG msg="fib(n) calculated" n=45 result=1134903170
time=2024-08-22T17:39:23.246+07:00 level=INFO msg="request completed" duration=3.781674792s
time=2024-08-22T17:39:26.409+07:00 level=DEBUG msg="cache miss; calculating the fib(n)" n=45 cache_error="cache: key not found"
time=2024-08-22T17:39:30.222+07:00 level=DEBUG msg="fib(n) calculated" n=45 result=1134903170
time=2024-08-22T17:39:30.222+07:00 level=INFO msg="request completed" duration=3.813693s
不出所料,由於沒有緩存,兩次調用都需要 3 秒左右。
插件實現
由於我們要實現可插拔的庫是 cache.Cache,因此我們需要實現該接口。我們可以在任何地方實現該接口,甚至是在單獨的存儲庫中。在本例中,我創建了兩個實現:一個使用內存緩存,另一個使用 Redis,兩者都在獨立的存儲庫中。
In-Memory Cache Plugin
package main
import (
"context"
"log/slog"
"sync"
"time"
"github.com/josestg/yt-go-plugin/cache"
)
// Value represents a cache entry.
type Value struct {
Data string
ExpAt time.Time
}
// Memcache is a simple in-memory cache.
type Memcache struct {
mu sync.RWMutex
log *slog.Logger
store map[string]Value
}
// Factory is the symbol the plugin loader will try to load. It must implement the cache.Factory signature.
var Factory cache.Factory = New
// New creates a new Memcache instance.
func New(log *slog.Logger) (cache.Cache, error) {
log.Info("[plugin/memcache] loaded")
c := &Memcache{
mu: sync.RWMutex{},
log: log,
store: make(map[string]Value),
}
return c, nil
}
func (m *Memcache) Set(ctx context.Context, key, val string, exp time.Duration) error {
m.log.InfoContext(ctx, "[plugin/memcache] set", "key", key, "val", val, "exp", exp)
m.mu.Lock()
m.log.DebugContext(ctx, "[plugin/memcache] lock acquired")
defer func() {
m.mu.Unlock()
m.log.DebugContext(ctx, "[plugin/memcache] lock released")
}()
m.store[key] = Value{
Data: val,
ExpAt: time.Now().Add(exp),
}
return nil
}
func (m *Memcache) Get(ctx context.Context, key string) (string, error) {
m.log.InfoContext(ctx, "[plugin/memcache] get", "key", key)
m.mu.RLock()
v, ok := m.store[key]
m.mu.RUnlock()
if !ok {
return "", cache.ErrNotFound
}
if time.Now().After(v.ExpAt) {
m.log.InfoContext(ctx, "[plugin/memcache] key expired", "key", key, "val", v)
m.mu.Lock()
delete(m.store, key)
m.mu.Unlock()
return "", cache.ErrExpired
}
m.log.InfoContext(ctx, "[plugin/memcache] key found", "key", key, "val", v)
return v.Data, nil
}
Redis Cache Plugin
package main
import (
"cmp"
"context"
"errors"
"fmt"
"log/slog"
"os"
"strconv"
"time"
"github.com/josestg/yt-go-plugin/cache"
"github.com/redis/go-redis/v9"
)
// RedisCache is a cache implementation that uses Redis.
type RedisCache struct {
log *slog.Logger
client *redis.Client
}
// Factory is the symbol the plugin loader will try to load. It must implement the cache.Factory signature.
var Factory cache.Factory = New
// New creates a new RedisCache instance.
func New(log *slog.Logger) (cache.Cache, error) {
log.Info("[plugin/rediscache] loaded")
db, err := strconv.Atoi(cmp.Or(os.Getenv("REDIS_DB"), "0"))
if err != nil {
return nil, fmt.Errorf("parse redis db: %w", err)
}
c := &RedisCache{
log: log,
client: redis.NewClient(&redis.Options{
Addr: cmp.Or(os.Getenv("REDIS_ADDR"), "localhost:6379"),
Password: cmp.Or(os.Getenv("REDIS_PASSWORD"), ""),
DB: db,
}),
}
return c, nil
}
func (r *RedisCache) Set(ctx context.Context, key, val string, exp time.Duration) error {
r.log.InfoContext(ctx, "[plugin/rediscache] set", "key", key, "val", val, "exp", exp)
return r.client.Set(ctx, key, val, exp).Err()
}
func (r *RedisCache) Get(ctx context.Context, key string) (string, error) {
r.log.InfoContext(ctx, "[plugin/rediscache] get", "key", key)
res, err := r.client.Get(ctx, key).Result()
if errors.Is(err, redis.Nil) {
r.log.InfoContext(ctx, "[plugin/rediscache] key not found", "key", key)
return "", cache.ErrNotFound
}
r.log.InfoContext(ctx, "[plugin/rediscache] key found", "key", key, "val", res)
return res, err
}
這兩個插件都實現了 cache.Cache 接口。這裏有幾件重要的事情需要注意:
-
這兩個插件都是在 main 包中實現的。這是必須的,因爲當我們將代碼作爲插件構建時,Go 至少需要一個 main 包。儘管如此,這並不意味着你必須在一個文件中編寫所有代碼。你可以像一個典型的 Go 項目那樣,用多個文件和包來組織代碼。爲了簡單起見,我在這裏將其保留在一個文件中。
-
這兩個插件都有 var Factory cache.Factory=New。雖然不是強制性的,但這是一個很好的做法。我們創建了一種類型,希望每個插件都能將其作爲實現構造函數的簽名。兩個插件都確保其 New 函數(實際構造函數)的類型爲 cache.Factory。這在我們稍後查找構造函數時非常關鍵。
構建插件非常簡單,只需添加 -buildmode=plugin 標誌即可。
# build the in memory cache plugin
go build -buildmode=plugin -o memcache.so memcache.go
# build the redis cache plugin
go build -buildmode=plugin -o rediscache.so rediscache.go
運行這些命令將生成 memcache.so 和 rediscache.so,它們是共享對象二進制文件,可在運行時由 bin/demo 二進制文件加載。
加載插件
插件加載器非常簡單。我們可以使用 Go 中的標準插件庫,它提供了兩個函數,不言自明:
-
Open[2]
-
Lookip[3]
下面是加載插件的代碼:
// loadCachePlugin loads a cache implementation from a shared object (.so) file at the specified path.
// It calls the constructor function by name, passing the necessary dependencies, and returns the initialized cache.
// If path is empty, it returns the NopCache implementation.
func loadCachePlugin(log *slog.Logger, path, name string) (cache.Cache, error) {
if path == "" {
log.Info("no cache plugin configured; using nop cache")
return cache.NopCache, nil
}
plug, err := plugin.Open(path)
if err != nil {
return nil, fmt.Errorf("open plugin %q: %w", path, err)
}
sym, err := plug.Lookup(name)
if err != nil {
return nil, fmt.Errorf("lookup symbol New: %w", err)
}
factoryPtr, ok := sym.(*cache.Factory)
if !ok {
return nil, fmt.Errorf("unexpected type %T; want %T", sym, factoryPtr)
}
factory := *factoryPtr
return factory(log)
}
仔細看看這一行:factoryPtr, ok := sym.(*cache.Factory)。我們要查找的符號是 plug.Lookup("Factory"),正如我們所看到的,每個實現都有 var Factory cache.Factory = New,而不是 var Factory *cache.Factory = New。
使用內存緩存插件
./bin/demo -port=8080 -log-level=debug -cache-plugin-path=./memcache.so -cache-plugin-factory-name=Factory
兩次調用 http://localhost:8080/fib/45 後的日誌:
time=2024-08-22T18:31:08.372+07:00 level=INFO msg="application started"
time=2024-08-22T18:31:08.372+07:00 level=DEBUG msg="using configuration" config="{Port:8080 LogLevel:DEBUG CacheExpiration:15s CachePluginPath:./memcache.so CachePluginFactoryName:Factory}"
time=2024-08-22T18:31:08.376+07:00 level=INFO msg="[plugin/memcache] loaded"
time=2024-08-22T18:31:08.376+07:00 level=INFO msg=listening addr=:8080
time=2024-08-22T18:31:16.850+07:00 level=INFO msg="[plugin/memcache] get" key=45
time=2024-08-22T18:31:16.850+07:00 level=DEBUG msg="cache miss; calculating the fib(n)" n=45 cache_error="cache: key not found"
time=2024-08-22T18:31:20.752+07:00 level=DEBUG msg="fib(n) calculated" n=45 result=1134903170
time=2024-08-22T18:31:20.752+07:00 level=INFO msg="[plugin/memcache] set" key=45 val=1134903170 exp=15s
time=2024-08-22T18:31:20.752+07:00 level=DEBUG msg="[plugin/memcache] lock acquired"
time=2024-08-22T18:31:20.752+07:00 level=DEBUG msg="[plugin/memcache] lock released"
time=2024-08-22T18:31:20.753+07:00 level=INFO msg="request completed" duration=3.903607875s
time=2024-08-22T18:31:24.781+07:00 level=INFO msg="[plugin/memcache] get" key=45
time=2024-08-22T18:31:24.783+07:00 level=INFO msg="[plugin/memcache] key found" key=45 val="{Data:1134903170 ExpAt:2024-08-22 18:31:35.752647 +0700 WIB m=+27.380493292}"
time=2024-08-22T18:31:24.783+07:00 level=DEBUG msg="cache hit; returning the cached value" n=45 value=1134903170
time=2024-08-22T18:31:24.783+07:00 level=INFO msg="request completed" duration=1.825042ms
使用 Redis 緩存插件
./bin/demo -port=8080 -log-level=debug -cache-plugin-path=./rediscache.so -cache-plugin-factory-name=Factory
兩次調用 http://localhost:8080/fib/45 後的日誌:
time=2024-08-22T18:33:49.920+07:00 level=INFO msg="application started"
time=2024-08-22T18:33:49.920+07:00 level=DEBUG msg="using configuration" config="{Port:8080 LogLevel:DEBUG CacheExpiration:15s CachePluginPath:./rediscache.so CachePluginFactoryName:Factory}"
time=2024-08-22T18:33:49.937+07:00 level=INFO msg="[plugin/rediscache] loaded"
time=2024-08-22T18:33:49.937+07:00 level=INFO msg=listening addr=:8080
time=2024-08-22T18:34:01.143+07:00 level=INFO msg="[plugin/rediscache] get" key=45
time=2024-08-22T18:34:01.150+07:00 level=INFO msg="[plugin/rediscache] key not found" key=45
time=2024-08-22T18:34:01.150+07:00 level=DEBUG msg="cache miss; calculating the fib(n)" n=45 cache_error="cache: key not found"
time=2024-08-22T18:34:04.931+07:00 level=DEBUG msg="fib(n) calculated" n=45 result=1134903170
time=2024-08-22T18:34:04.931+07:00 level=INFO msg="[plugin/rediscache] set" key=45 val=1134903170 exp=15s
time=2024-08-22T18:34:04.934+07:00 level=INFO msg="request completed" duration=3.791582708s
time=2024-08-22T18:34:07.932+07:00 level=INFO msg="[plugin/rediscache] get" key=45
time=2024-08-22T18:34:07.936+07:00 level=INFO msg="[plugin/rediscache] key found" key=45 val=1134903170
time=2024-08-22T18:34:07.936+07:00 level=DEBUG msg="cache hit; returning the cached value" n=45 value=1134903170
time=2024-08-22T18:34:07.936+07:00 level=INFO msg="request completed" duration=4.403083ms
總結
Go 中的 buildmode=plugin
功能是增強應用程序的強大工具,例如在 Envoy Proxy
中添加自定義緩存解決方案。它允許你構建和使用插件,使你能夠在運行時加載和執行自定義代碼,而無需更改主程序。這不僅有助於減少二進制文件的大小,還能加快構建過程。由於插件可以獨立組成和更新,因此只有當主應用程序發生變化時才需要重建,避免了重建未更改的插件。
當然,這個方案也會存在缺點:插件加載會帶來運行時開銷,而且與靜態鏈接代碼相比,插件系統有一定的侷限性。例如,可能存在跨平臺兼容性和調試複雜性的問題。應根據自己的具體需求仔細評估這些方面。有關使用插件的更多信息和詳細警告,請參閱 Go 關於插件的官方文檔 [4]。
參考資料
[1]
plugin package: https://pkg.go.dev/plugin
[2]
Open: https://pkg.go.dev/plugin#Open
[3]
Lookup: https://pkg.go.dev/plugin#Plugin.Lookup
[4]
go official documents on plugin: https://pkg.go.dev/plugin#hdr-Warnings
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/yIKeZ2tdaO6mdaGtgnA0dA