本文将以技术调研模式编写,非技术同学可跳过。
文章目录
- 背景
- 实现二:开源 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 mainimport ("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 stringif 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 stringswitch 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 mainimport ("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 utilimport ("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() stringSpeak(string) stringExecute(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 implementationImpl 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 stringerr := 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 stringerr := 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 Reserr := 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+0800963 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/