如何在 Go 中构建可插拔库

什么是 go buildmode=plugin?

go buildmode=plugin 选项允许开发者将 Go 代码编译成共享对象文件。另一个 Go 程序可以在运行时加载该文件。当我们想在应用程序中添加新功能而又不想重建它时,这个选项非常有用。可以将新功能作为插件加载。

Go 中的插件是编译成共享对象(.so)文件的软件包。可以使用 Go 中的 plugin package 加载该文件,打开插件,查找符号(如函数或变量)并使用它们。

实践范例

这里举了了一个简单的后端演示项目的示例,它提供了一个用于计算第 n 个 斐波那契数列的 API。出于演示目的,这里特意使用了慢速斐波那契实现。考虑到计算速度较慢,我需要添加了一个缓存层来存储结果,因此如果再次请求相同的 nth 斐波那契数字,无需重新计算,只需返回缓存结果即可。

API 是 GET /fib/{n} ,其中 n 是要计算的斐波纳契数。下面我们来看看 API 是如何实现的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// 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 接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
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)时的日志:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
./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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
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 标志即可。

1
2
3
4
5
# 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 中的标准插件库,它提供了两个函数,不言自明:

下面是加载插件的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 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。

使用内存缓存插件

1
./bin/demo -port=8080 -log-level=debug -cache-plugin-path=./memcache.so -cache-plugin-factory-name=Factory

两次调用 http://localhost:8080/fib/45 后的日志:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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 缓存插件

1
./bin/demo -port=8080 -log-level=debug -cache-plugin-path=./rediscache.so -cache-plugin-factory-name=Factory

两次调用 http://localhost:8080/fib/45 后的日志:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 关于插件的官方文档


-------------The End-------------

本文标题:如何在 Go 中构建可插拔库

文章作者:cloud sjhan

发布时间:2024年08月24日 - 20:08

最后更新:2024年08月24日 - 20:08

原始链接:https://cloudsjhan.github.io/2024/08/24/如何在-Go-中构建可插拔库/

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。

cloud sjhan wechat
subscribe to my blog by scanning my public wechat account
坚持原创技术分享,您的支持将鼓励我继续创作!
0%
;