本文将以技术调研模式编写,非技术同学可跳过。
文章目录
- 背景
- 实现二:开源 go-plugin
- Demo 实现
- Benchwork 基准性能
- 小结
- 附录
背景
基于组件(插件)模式设计构建的入口服务,在使用 Go 原生包 plugin 实现的时候,会存在功能缺陷问题,不足以支撑预期能力。
注:详细见上文 《千万级入口服务[Gateway]框架设计(一)》
千万级入口服务[Gateway]框架设计(一)
本文将继续介绍另一种关于 go-plugin 的开源实现。
实现二:开源 go-plugin
针对前文提到的几个问题,go-plugin 提出以 rpc 协议通信的方式进行组件搭建。
将每个组件进行服务封装,主程序、组件之间以通信方式进行交互,这样可以完全规避原生的不足。
- 组件是 Go 接口的实现:这让组件的编写、使用非常自然。对于组件的作者来说,他只需要实现一个Go 接口即可;对于组件的用户来说,他只需要调用一个Go 接口即可。
- 跨语言支持:组件可以基于任何主流语言编写,同样可以被任何主流语言消费
- 支持复杂的参数、返回值:go-plugin 可以处理接口、io.Reader/Writer 等复杂类型
- 双向通信:为了支持复杂参数,宿主进程能够将接口实现发送给组件,组件也能够回调到宿主进程
- 内置日志系统:任何使用 log 标准库的的组件,都会将日志信息传回宿主机进程。宿主进程会在这些日志前面加上组件二进制文件的路径,并且打印日志
- 协议版本化:支持一个简单的协议版本化,增加版本号后可以基于老版本协议的组件无效化。
- 标准输出/错误同步:组件以子进程的方式运行,这些子进程可以自由的使用标准输出/错误,并且打印的内容会被自动同步到宿主进程,宿主进程可以为同步的日志指定一个 io.Writer
- TTY Preservation:组件子进程可以链接到宿主进程的 stdin 文件描述符,以便要求 TTY 的软件能正常工作
- 宿主进程升级:宿主进程升级的时候,组件子进程可以继续允许,并在升级后自动关联到新的宿主进程
- 加密通信:gRPC 信道可以加密
- 完整性校验:支持对组件的二进制文件进行 Checksum
- 稳定性保障:组件崩溃了,不会导致宿主进程崩溃
- 容易安装:只需要将组件放到某个宿主进程能够访问的目录
Demo 实现
实现分为三部分:主程序、组件程序、公共库。公共库可与主程序所属库共同,组件程序进行包引用即可。
- 主程序
package main
import (
"fmt"
"log"
"os"
"os/exec"
"time"
"github.com/hashicorp/go-hclog"
"github.com/hashicorp/go-plugin"
util "XXXXX"
)
var pluginMap = map[string]plugin.Plugin{
// 插件名称到插件对象的映射关系
"s": &util.GreeterPlugin{},
"bar": &util.GreeterPlugin{},
}
func main() {
// 创建hclog.Logger类型的日志对象
logger := hclog.New(&hclog.LoggerOptions{
Name: "plugin",
Output: os.Stdout,
Level: hclog.Debug,
})
req := util.Req{}
for i := 0; i < 10; i++ {
fmt.Println("for i:", i) // 验证热加载功能
req.Str = RandStr(i)
var mod string
if mod = Dispatch(req.Str); len(mod) < 1 {
fmt.Println("don't deal str")
os.Exit(1)
}
// 两种方式选其一
// 以exec.Command方式启动插件进程,并创建宿主机进程和插件进程的连接
// 或者使用Reattach连接到现有进程
client := plugin.NewClient(&plugin.ClientConfig{
HandshakeConfig: util.HandshakeConfig,
Plugins: pluginMap,
// 创建新进程,或使用Reattach连接到现有进程中
Cmd: exec.Command(mod),
Logger: logger,
})
// 关闭client,释放相关资源,终止插件子程序的运行
defer client.Kill()
// 返回协议客户端,如rpc客户端或grpc客户端,用于后续通信
rpcClient, err := client.Client()
if err != nil {
log.Fatal(err)
}
// 根据指定插件名称分配新实例
raw, err := rpcClient.Dispense(req.Str)
if err != nil {
log.Fatal(err)
}
// 像调用普通函数一样调用接口函数就ok,很方便是不是?
greeter := raw.(util.Greeter)
fmt.Println(greeter.Greet())
fmt.Println(greeter.Speak(req.Str))
fmt.Println(greeter.Execute(req).Msg)
time.Sleep(1 * time.Second)
}
}
func Dispatch(str string) string {
var mod string
switch str {
case "A":
mod = "./X/A"
case "B":
mod = "./X/B"
default:
}
return mod
}
func RandStr(i int) string {
var str = "B"
if i%2 == 0 {
str = "A"
}
return str
}
- 组件程序
package main
import (
"os"
"github.com/hashicorp/go-hclog"
"github.com/hashicorp/go-plugin"
util "XXX"
)
// Here is a real implementation of Greeter
type A struct {
logger hclog.Logger
}
func (g *A) Greet() string {
g.logger.Debug("message from A.Greet")
return "A Hello!"
}
func (g *A) Speak(str string) string {
return "Now A-" + str + " is speaking!"
}
func (g *A) Execute(req util.Req) util.Res {
return util.Res{Msg: "Now A-" + req.Str + " is executing!"}
}
func main() {
logger := hclog.New(&hclog.LoggerOptions{
Level: hclog.Trace,
Output: os.Stderr,
JSONFormat: true,
})
greeter := &A{
logger: logger,
}
// pluginMap is the map of plugins we can dispense.
var pluginMap = map[string]plugin.Plugin{
"A": &util.GreeterPlugin{Impl: greeter},
}
plugin.Serve(&plugin.ServeConfig{
HandshakeConfig: util.HandshakeConfig,
Plugins: pluginMap,
})
}
运行:go build -o ./X/A main-plugin-A.go
- 公共库
package util
import (
"net/rpc"
"github.com/hashicorp/go-plugin"
)
type Req struct {
Str string
}
type Res struct {
Msg string
}
// Greeter is the interface that we're exposing as a plugin.
type Greeter interface {
Greet() string
Speak(string) string
Execute(Req) Res
}
// Here is the RPC server that GreeterRPC talks to, conforming to
// the requirements of net/rpc
type GreeterRPCServer struct {
// This is the real implementation
Impl Greeter
}
func (s *GreeterRPCServer) Greet(args any, resp *string) error {
*resp = s.Impl.Greet()
return nil
}
func (s *GreeterRPCServer) Speak(str string, resp *string) error {
*resp = s.Impl.Speak(str)
return nil
}
func (s *GreeterRPCServer) Execute(req Req, resp *Res) error {
*resp = s.Impl.Execute(req)
return nil
}
// Here is an implementation that talks over RPC
type GreeterRPC struct{ client *rpc.Client }
func (g *GreeterRPC) Greet() string {
var resp string
err := g.client.Call("Plugin.Greet", new(any), &resp)
if err != nil {
// You usually want your interfaces to return errors. If they don't,
// there isn't much other choice here.
panic(err)
}
return resp
}
func (g *GreeterRPC) Speak(str string) string {
var resp string
err := g.client.Call("Plugin.Speak", str, &resp)
if err != nil {
// You usually want your interfaces to return errors. If they don't,
// there isn't much other choice here.
panic(err)
}
return resp
}
func (g *GreeterRPC) Execute(req Req) Res {
var resp Res
err := g.client.Call("Plugin.Execute", req, &resp)
if err != nil {
// You usually want your interfaces to return errors. If they don't,
// there isn't much other choice here.
panic(err)
}
return resp
}
type GreeterPlugin struct {
// 内嵌业务接口
// 插件进程会设置其为实现业务接口的对象
// 宿主进程则置空
Impl Greeter
}
// 此方法由插件进程延迟的调用
func (p *GreeterPlugin) Server(*plugin.MuxBroker) (any, error) {
return &GreeterRPCServer{Impl: p.Impl}, nil
}
// 此方法由宿主进程调用
func (GreeterPlugin) Client(b *plugin.MuxBroker, c *rpc.Client) (any, error) {
return &GreeterRPC{client: c}, nil
}
var HandshakeConfig = plugin.HandshakeConfig{
ProtocolVersion: 1,
MagicCookieKey: "BASIC_PLUGIN",
MagicCookieValue: "hello",
}
Benchwork 基准性能
go test -bench BenchmarkMainDeal -benchtime=5s -benchmem
......timestamp=2023-06-08T15:07:35.095+0800
963 6329922 ns/op 156723 B/op 844 allocs/op
PASS
ok XXX 6.736s
小结
虽然开源组件打破了原生包的囧境,但其实质是本地的 RPC 调用,存在本地网络开销。对比前文我们原生包的 Benchwork 指标,性能相对不足。尤其是在调用过程中的序列化、反序列化,在频繁交互的场景下,是木桶璧的短板所在。
当然,针对 Go 来讲,可以使用 GRPC 协议,充分降低短板对整体的影响占比。在一些性能适中的场景下,是完全满足需求的。
附录
- https://eli.thegreenplace.net/2023/rpc-based-plugins-in-go/