什么是 go buildmode=plugin?
go buildmode=plugin 选项允许开发者将 Go 代码编译成共享对象文件。另一个 Go 程序可以在运行时加载该文件。当我们想在应用程序中添加新功能而又不想重建它时,这个选项非常有用。可以将新功能作为插件加载。
Go 中的插件是编译成共享对象(.so)文件的软件包。可以使用 Go 中的 plugin package 加载该文件,打开插件,查找符号(如函数或变量)并使用它们。
实践范例
这里举了了一个简单的后端演示项目的示例,它提供了一个用于计算第 n 个 斐波那契数列的 API。出于演示目的,这里特意使用了慢速斐波那契实现。考虑到计算速度较慢,我需要添加了一个缓存层来存储结果,因此如果再次请求相同的 nth 斐波那契数字,无需重新计算,只需返回缓存结果即可。
API 是 GET /fib/{n} ,其中 n 是要计算的斐波纳契数。下面我们来看看 API 是如何实现的:
1 | // Fibonacci calculates the nth Fibonacci number. |
代码的解释如下:
- NewHandler 函数创建一个新的 http.Handler 程序。它依赖于日志记录器、缓存和过期时间。cache.Cache 是一个接口,我们很快就会定义它。
返回的 http.Handler 会解析路径参数中的 n 值。如果出现错误,它会发送错误响应。否则,它会检查缓存中是否已经存在第 n 个斐波那契数字。如果没有,处理程序会计算出该数字并将其存储在缓存中,以备将来请求之用。
goroutine 在一个单独的进程中处理斐波那契计算和缓存,而 select 语句则等待计算完成或客户端取消请求。这样可以确保在客户端取消请求时,我们不会浪费资源等待计算完成。
现在,我们希望在运行时,即应用程序启动时,可以选择缓存的实现方式。一种直接的方法是在同一代码库中创建多个实现,并使用配置来选择所需的实现。但这样做的缺点是,未选择的实现仍将是编译后二进制文件的一部分,从而增加了二进制文件的大小。虽然构建标签可能是一种解决方案,但我们将留待下一篇文章讨论。现在,我们希望在运行时而不是在构建时选择实现。这就是 buildmode=plugin 的真正优势所在。
确保应用程序无需插件即可运行
由于我们已将 cache.Cache 定义为一个接口,因此我们可以在任何地方创建该接口的实现,甚至可以在不同的存储库中创建。但首先,让我们来看看 Cache 接口:
1 | package cache |
由于 NewHandler 需要依赖于 cache.Cache 实现,因此最好有一个默认实现,以确保代码不会中断。因此,让我们创建一个什么都不做的 no-op(无操作)实现。
这个NopCache实现了cache.Cache接口,但实际上并不做任何事情。它只是为了确保处理程序正常工作。
如果我们不使用任何自定义的cache.Cache实现来运行代码,API将正常工作,但结果不会被缓存–这意味着每次调用都会重新计算斐波那契数字。以下是使用NopCache(n=45)时的日志:
1 | ./bin/demo -port=8080 -log-level=debug |
不出所料,由于没有缓存,两次调用都需要 3 秒左右。
插件实现
由于我们要实现可插拔的库是 cache.Cache,因此我们需要实现该接口。您可以在任何地方实现该接口,甚至是在单独的存储库中。在本例中,我创建了两个实现:一个使用内存缓存,另一个使用 Redis,两者都在独立的存储库中。
In-Memory Cache Plugin
1 | package main |
Redis Cache Plugin
1 | package main |
这两个插件都实现了 cache.Cache 接口。这里有几件重要的事情需要注意:
- 这两个插件都是在 main 包中实现的。这是必须的,因为当我们将代码作为插件构建时,Go 至少需要一个 main 包。尽管如此,这并不意味着你必须在一个文件中编写所有代码。你可以像一个典型的 Go 项目那样,用多个文件和包来组织代码。为了简单起见,我在这里将其保留在一个文件中。
- 这两个插件都有 var Factory cache.Factory=New。虽然不是强制性的,但这是一个很好的做法。我们创建了一种类型,希望每个插件都能将其作为实现构造函数的签名。两个插件都确保其 New 函数(实际构造函数)的类型为 cache.Factory。这在我们稍后查找构造函数时非常关键。
构建插件非常简单,只需添加 -buildmode=plugin 标志即可。
1 | # build the in memory cache plugin |
运行这些命令将生成 memcache.so 和 rediscache.so,它们是共享对象二进制文件,可在运行时由 bin/demo 二进制文件加载。
加载插件
插件加载器非常简单。我们可以使用 Go 中的标准插件库,它提供了两个函数,不言自明:
下面是加载插件的代码:
1 | // loadCachePlugin loads a cache implementation from a shared object (.so) file at the specified path. |
仔细看看这一行: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 | time=2024-08-22T18:31:08.372+07:00 level=INFO msg="application started" |
使用 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 | time=2024-08-22T18:33:49.920+07:00 level=INFO msg="application started" |
总结
Go 中的 buildmode=plugin
功能是增强应用程序的强大工具,例如在 Envoy Proxy
中添加自定义缓存解决方案。它允许你构建和使用插件,使你能够在运行时加载和执行自定义代码,而无需更改主程序。这不仅有助于减少二进制文件的大小,还能加快构建过程。由于插件可以独立组成和更新,因此只有当主应用程序发生变化时才需要重建,避免了重建未更改的插件。
当然,这个方案也会存在缺点:插件加载会带来运行时开销,而且与静态链接代码相比,插件系统有一定的局限性。例如,可能存在跨平台兼容性和调试复杂性的问题。您应根据自己的具体需求仔细评估这些方面。有关使用插件的更多信息和详细警告,请参阅 Go 关于插件的官方文档。