hashicorp go-plugin构建golang插件系统
共 25181字,需浏览 51分钟
·
2021-04-28 16:43
一、go-plugin 简介
1.1 go-plugin 是什么?
我们知道 Go 语言缺乏动态加载代码的机制,Go 程序通常是独立的二进制文件,因此难以实现类似于 C++ 的插件系统。即使go的最新标准引入了 go plugin 机制,但是由于限制性条件比较多导致在生产环境中不是很好用,比如插件的编写环境和插件的使用环境要保持一致,如 gopath、go sdk 版本等。
HashiCorp 公司开源的 go-plugin 库解决了上述问题,允许应用程序通过本地网络(本机)的 gRPC 调用插件,规避了 Go 无法动态加载代码的缺点。go-plugin 是一个通过RPC 实现的 Go 插件系统,并在 Packer、Terraform, Nomad、Vault 等由 HashiCorp 主导的项目中均有应用。
顺便说一句,Vault 开源代码,我这几天看了下,代码写的很不错,感兴趣的小伙伴可以看看vault是怎么使用 go-plugin,很值得借鉴,后续会针对 vault 的源代码的插件部分进行剖析。
1.2 特性
go-plugin 的特性包括:
插件是 Go 接口的实现: 这让插件的编写、使用非常自然。对于插件编写者来说,他只需要实现一个 Go 接口即可;对于插件的用户来说,就像在同一个进程中使用和调用函数即可。go-plugin 会处理好本地调用转换为 gRPC 调用的所有细节 跨语言支持:插件可以被任何主流语言编写(和使用),该库支持通过 gRPC 提供服务插件,而基于 gRPC 的插件是允许被任何语言编写的。 支持复杂的参数、返回值: go-plugin 可以处理接口、io.Reader/Writer 等复杂类型,我们为您提供了一个库(MuxBroker),用于在客户端/服务器之间创建新连接,以服务于附加接口或传输原始数据。 双向通信: 为了支持复杂参数,宿主进程能够将接口实现发送给插件,插件也能够回调到宿主进程(这点还需要看官网的双向通信的例子好好理解下) 内置日志系统: 任何使用 log 标准库的的插件,都会自动将日志信息传回宿主机进程。宿主进程会镜像日志输出,并在这些日志前面加上插件二进制文件的路径。这会使插件的调试变简单。如果宿主机使用 hclog,日志数据将被结构化。如果插件同样使用 hclog,插件的日志会发往宿主机并被结构化。 协议版本化: 支持一个简单的协议版本化,可增加版本号使之前插件无效。当接口签名变化、协议版本改变等情况时,协议版本话是很有用的。当协议版本不兼容时,会发送错误消息给终端用户。 标准输出/错误同步: 插件以子进程的方式运行,这些插件可以自由的使用标准输出/错误,并且输出会被镜像回到宿主进程。 TTY Preservation: 插件子进程可以链接到宿主进程的 stdin 标准输入文件描述符,允许以TTY方式运行的软件。 插件运行状态中,宿主进程升级: 插件可以"reattached",所以可以在插件运行状态中升级宿主机进程。NewClient 函数使用 ReattachConfig 选项来确定是否 Reattach 以及如何 Reattach。 加密通信: gRPC信道可以加密
1.3 架构优势
插件不影响宿主机进程:插件崩溃了,不会导致宿主进程崩溃 插件容易编写:仅仅写个 go 应用程序并执行 go build。或者使用其他语言来编写 gRPC 服务 ,加上少量的模板来支持 go-plugin。 易于安装:只需要将插件放到宿主进程能够访问的目录即可,剩下的事情由宿主进程来处理。 完整性校验:支持对插件的二进制文件进行 Checksum 插件是相对安全的:插件只能访问传递给它的接口和参数,而不是进程的整个内存空间。另外,go-plugin 可以基于 TLS 和插件进行通信。
1.4 适用场景
go-plugin 目前仅设计为在本地[可靠]网络上工作,不支持 go-plugin 在真实网络,并可能会导致未知的行为。
即不能将 go-plugin 用于在两台服务器之间的远程过程调用,这点和传统的RPC有很大区别,望谨记。
二、核心数据结构
2.1 Plugin接口
Plugin 是一个接口,是插件进程和宿主进程进行通信的桥梁。
不管是插件编写者还是插件使用者,都需要实现 plugin.Plugin 接口,只是各自的实现不同。
type Plugin interface {
// Server should return the RPC server compatible struct to serve
// the methods that the Client calls over net/rpc.
Server(*MuxBroker) (interface{}, error)
// Client returns an interface implementation for the plugin you're
// serving that communicates to the server end of the plugin.
Client(*MuxBroker, *rpc.Client) (interface{}, error)
}
Server 接口:Server 接口应返回与 RPC server 兼容的结构以提供方法,客户端可以通过net/rpc来调用此方法。
Client 接口:Client 接口返回你提供服务的插件的接口实现,该接口实现将与该插件的服务器端进行通信。
2.2 GRPCPlugin 接口
type GRPCPlugin interface {
// 由于gRPC插件以单例方式服务,因此该方法仅调用一次
GRPCServer(*GRPCBroker, *grpc.Server) error
// 插件进程退出时,context会被go-plugin关闭
GRPCClient(context.Context, *GRPCBroker, *grpc.ClientConn) (interface{}, error)
}
GRPCPlugin的接口实现,在grpc的例子中我们再详细解释。
2.3 plugin.client接口
这个接口负责管理一个插件进程的完整生命周期,包括创建插件进程、连接到插件进程、分配接口实现、处理杀死进程。
对于每个插件,宿主机进程需要创建一个plugin.Client实例。
type Client struct {
// 插件客户端配置
config *ClientConfig
// 插件进程是否已经退出
exited bool
l sync.Mutex
// 插件进程的RPC监听地址
address net.Addr
// 插件进程对象
process *os.Process
// 协议客户端,宿主进程需要调用其Dispense方法来获得业务接口的Stub
client ClientProtocol
// 通信协议
protocol Protocol
logger hclog.Logger
doneCtx context.Context
ctxCancel context.CancelFunc
negotiatedVersion int
// 用于管理 插件管理协程的生命周期
clientWaitGroup sync.WaitGroup
stderrWaitGroup sync.WaitGroup
// 测试用,标记进程是否被强杀
processKilled bool
}
2.4 ClientConfig 和 ServeConfig 对比
ClientConfig 包含了初始化一个插件客户端所需的配置信息,一旦初始化,则不可更改。
ServeConfig 包含了初始化一个插件服务器端所需的配置信息,一旦初始化,则不可更改。
枚举这两个结构体,对其字段进行对比和类比分析
type ClientConfig struct {
// 握手信息,用于宿主、插件的匹配。如果不匹配,插件会拒绝连接
HandshakeConfig
// 可以消费的插件列表
Plugins PluginSet
// 版本化的插件列表,用于支持在客户端、服务器之间协商兼容版本
VersionedPlugins map[int]PluginSet
// 启动插件进程使用的命令行,不能和Reattach联用
Cmd *exec.Cmd
// 连接到既有插件进程的必要信息,不能和Cmd联用
Reattach *ReattachConfig
// 用于在启动插件时校验二进制文件的完整性
SecureConfig *SecureConfig
// 基于TLS进行RPC通信时需要的信息
TLSConfig *tls.Config
// 标识客户端是否应该被plugin包自动管理
// 如果为true,则调用CleanupClients自动清理
// 否则用户需要负责杀掉插件客户端,默认false
Managed bool
// 和子进程通信使用的端口范围,
MinPort, MaxPort uint
// 启动插件的超时
StartTimeout time.Duration
......
}
type ServeConfig struct {
// 和客户端匹配的握手配置,其信息必须和客户端匹配,否则会拒绝连接
HandshakeConfig
// 调用此函数得到tls.Config
TLSProvider func() (*tls.Config, error)
// 可以提供服务的插件集
Plugins PluginSet
// 版本化的插件列表,用于支持在客户端、服务器之间协商兼容版本
VersionedPlugins map[int]PluginSet
// 如果通过gRPC提供服务,则此字段不能为空
// 调用此函数创建一个gRPC服务器对象
// 公司场景采用grpc通信,所以涉及到grpc的要重点看
GRPCServer func([]grpc.ServerOption) *grpc.Server
Logger hclog.Logger
}
ClientConfig 和 ServeConfig 中都需要填写 HandshakeConfig,这里要声明两点
对于 rpc 通信的 client 和 server 来说,其 ClientConfig 和 ServeConfig 中的配置要保持完全一致,否则会导致连接失败,这块在调试的时候,耗费了我一些时间和精力。
2.5 PluginSet结构体
插件进程在启动时设置Plugins,即ServeConfig中设置Plugins时,会指明其实现者;
宿主机进程在启动时也设置Plugins,即ClientConfig中设置Plugins时,不需要指明其实现者。
//插件进程的插件集
var pluginMap = map[string]plugin.Plugin{
"greeter": &example.GreeterPlugin{Impl: greeter},
}
//宿主机进程的插件集
var pluginMap = map[string]plugin.Plugin{
"greeter": &example.GreeterPlugin{},
}
如上图所示,其ServeConfig中插件业务接口实现者是greeter
三、架构设计图
四、从example入手学习
通过go-plugin库自带的两个例子来展示库的使用方法
4.1 basic例子剖析
4.1.1 业务接口定义
// Greeter is the interface that we're exposing as a plugin.
type Greeter interface {
Greet() string
}
暴露插件需要实现的接口,接口的实现是在插件进程中。
4.1.2 宿主机进程剖析
宿主机进程的代码如下:
func main() {
// 创建hclog.Logger类型的日志对象
logger := hclog.New(&hclog.LoggerOptions{
Name: "plugin",
Output: os.Stdout,
Level: hclog.Debug,
})
// 两种方式选其一
// 以exec.Command方式启动插件进程,并创建宿主机进程和插件进程的连接
// 或者使用Reattach连接到现有进程
client := plugin.NewClient(&plugin.ClientConfig{
HandshakeConfig: handshakeConfig,
Plugins: pluginMap,
//创建新进程,或使用Reattach连接到现有进程中
Cmd: exec.Command("./plugin/greeter"),
Logger: logger,
})
// 关闭client,释放相关资源,终止插件子程序的运行
defer client.Kill()
// 返回协议客户端,如rpc客户端或grpc客户端,用于后续通信
rpcClient, err := client.Client()
if err != nil {
log.Fatal(err)
}
// 根据指定插件名称分配新实例
raw, err := rpcClient.Dispense("greeter")
if err != nil {
log.Fatal(err)
}
// 像调用普通函数一样调用接口函数就ok,很方便是不是?
greeter := raw.(example.Greeter)
fmt.Println(greeter.Greet())
}
var pluginMap = map[string]plugin.Plugin{
//插件名称到插件对象的映射关系
"greeter": &example.GreeterPlugin{},
}
其流程一共拆解为5步:
第一步、plugin.NewClient创建宿主机进程和插件进程之间的连接
plugin.NewClient创建plugin.Client,可以简单理解为宿主机进程和插件进程之间连接。其参数pluginMap表示可被消费的插件列表。
第二步、调用client.Client(),返回当前连接的协议客户端(即rpcClient)
协议支持net/rpc或gRPC,所以协议客户端可能是gRPC客户端,也可能是标准net/rpc客户端。
第三步、调用rpcClient.Dispense,根据指定插件名称分配一个新实例
由于此函数很关键,下面通过走读源代码来梳理下流程:
func (c *RPCClient) Dispense(name string) (interface{}, error) {
//1、查找插件类型是否支持
p, ok := c.plugins[name]
if !ok {
return nil, fmt.Errorf("unknown plugin type: %s", name)
}
var id uint32
if err := c.control.Call(
"Dispenser.Dispense", name, &id); err != nil {
return nil, err
}
conn, err := c.broker.Dial(id)
if err != nil {
return nil, err
}
//2、非常重要,Dispense函数会回调Plugin的Client接口实现
return p.Client(c.broker, rpc.NewClient(conn))
}
在Dispense方法中会调用自己实现的插件的Client方法。
前面2.1章节提过,每个新插件都会实现plugin.Plugin接口(grpc插件实现的是GRPCPlugin接口),即Server和Client接口
下面是basic例子中的GreeterPlugin插件实现
type GreeterPlugin struct {
// 内嵌业务接口
// 插件进程会设置其为实现业务接口的对象
// 宿主进程则置空
Impl Greeter
}
// 此方法由插件进程延迟的调用
func (p *GreeterPlugin) Server(*plugin.MuxBroker) (interface{}, error) {
return &GreeterRPCServer{Impl: p.Impl}, nil
}
// 此方法由宿主进程调用
func (GreeterPlugin) Client(b *plugin.MuxBroker, c *rpc.Client) (interface{}, error) {
return &GreeterRPC{client: c}, nil
}
Server方法必须返回一个这种插件类型的RPC server服务器,我们构造了GreeterRPCServer。
Client方法必须返回一个接口的实现,并且能够通过RPC client客户端通信,我们返回了GreeterRPC
这里出个思考题
1、Server方法为什么需要返回GreeterRPCServer的指针?
2、Client方法为什么需要返回GreeterRPC的指针?
感兴趣的小伙伴可在评论区留言哈,看看你是否理解到本质呢?
综上所述,Dispense的返回值是指向GreeterRPC的指针。
type GreeterRPC struct{ client *rpc.Client }
func (g *GreeterRPC) Greet() string {
var resp string
err := g.client.Call("Plugin.Greet", new(interface{}), &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
}
GreeterRPC结构体实现了业务接口的Greet(),在方法实现的函数体body中,实际是用rpc client客户端调用Call()来进行远程过程调用,并将响应返回,如出错则会导致panic。
问题3:g.client.Call(“Plugin.Greet”, new(interface{}), &resp)中的第一个参数"Plugin.Greet"可以更换吗?
第四步、转换成业务接口类型,并调用对应api
从第三步我们知道Dispense的返回值raw是指向GreeterRPC的指针。而GreeterRPC结构体实现了业务接口example.Greeter。所以两者之间可以进行类型转换。
greeter := raw.(example.Greeter) fmt.Println(greeter.Greet())
上述两句代码,将raw转换为业务接口类型example.Greeter,然后调用之前暴露的业务接口Greet()函数。
第五步、关闭client,释放资源
调用client.Kill()函数,来释放之前申请的系统资源,防止内存泄露。
4.1.3 插件进程剖析
// Here is a real implementation of Greeter
// 重点:业务接口的真正实现
type GreeterHello struct {
logger hclog.Logger
}
//之前暴露的插件业务接口,此处必须实现,供宿主机进程RPC调用
func (g *GreeterHello) Greet() string {
g.logger.Debug("message from GreeterHello.Greet")
return "Hello!"
}
//握手配置,插件进程和宿主机进程,都需要保持一致
var handshakeConfig = plugin.HandshakeConfig{
ProtocolVersion: 1,
MagicCookieKey: "BASIC_PLUGIN",
MagicCookieValue: "hello",
}
func main() {
logger := hclog.New(&hclog.LoggerOptions{
Level: hclog.Trace,
Output: os.Stderr,
JSONFormat: true,
})
greeter := &GreeterHello{
logger: logger,
}
// pluginMap is the map of plugins we can dispense.
// 插件进程必须指定Impl,此处赋值为greeter对象
var pluginMap = map[string]plugin.Plugin{
"greeter": &example.GreeterPlugin{Impl: greeter},
}
logger.Debug("message from plugin", "foo", "bar")
//调用plugin.Serve()启动侦听,并提供服务
plugin.Serve(&plugin.ServeConfig{
HandshakeConfig: handshakeConfig,
Plugins: pluginMap,
})
}
第一步、定义GreeterHello结构体,并实现插件暴露的业务接口Greet()
第二步、整理插件的映射关系,并在plugin.Serve函数调用时,以参数形式赋值给Plugins
如名称为greeter的插件,对应&example.GreeterPlugin{Impl: greeter}
var pluginMap = map[string]plugin.Plugin{
//插件名称到插件对象的映射关系
"greeter": &example.GreeterPlugin{Impl: greeter},
}
第三步、在main函数中调用plugin.Serve(),启动监听来提供插件服务。
服务器调用plugin.Serve方法后,主线程会阻塞。直到客户端调用 Dispense方法请求插件实例时,服务器端才会实例化插件(业务接口的实现):
func (d *dispenseServer) Dispense(
name string, response *uint32) error {
// 从PluginSet中查找
p, ok := d.plugins[name]
if !ok {
return fmt.Errorf("unknown plugin type: %s", name)
}
// 调用(下面的那个函数)插件接口的方法
impl, err := p.Server(d.broker)
if err != nil {
return errors.New(err.Error())
}
// MuxBroker基于唯一性的ID进行TCP连接的多路复用
id := d.broker.NextId()
*response = id
// 在另外一个协程中处理该请求
go func() {
conn, err := d.broker.Accept(id)
if err != nil {
log.Printf("[ERR] go-plugin: plugin dispense error: %s: %s", name, err)
return
}
serve(conn, "Plugin", impl)
}()
return nil
}
func (p *GreeterPlugin) Server(*plugin.MuxBroker) (interface{}, error) {
return &GreeterRPCServer{Impl: p.Impl}, nil
}
4.2 grpc 例子剖析
应当尽量采用grpc而非net/rpc,原因如下:
1)gRPC支持多种语言来实现插件,而net/rpc是Go专有的,不利于程序的可扩展性
2)在gRPC模式下,go-plugin插件请求通过http2发送,传输性能更好
3)对于gRPC模式来说,插件进程只会有单个插件“实例”。对于net/rpc你可能需要创建多个“实例”。
使用gRPC模式下的go-plugin,其步骤如下:
xx(未完成)
xx
xx
4.2.1 proto 定义
syntax = "proto3";
package proto;
//请求
message GetRequest {
string key = 1;
}
//应答
message GetResponse {
bytes value = 1;
}
message PutRequest {
string key = 1;
bytes value = 2;
}
message Empty {}
//定义service的接口Get和Put
service KV {
rpc Get(GetRequest) returns (GetResponse);
rpc Put(PutRequest) returns (Empty);
}
执行命令:protoc -I proto/ proto/kv.proto --go_out=plugins=grpc:proto/ 生成Go代码。
4.2.2 业务接口
在examples/grpc/shared/interface.go文件中,是其定义的业务接口
// 业务接口
type KV interface {
Put(key string, value []byte) error
Get(key string) ([]byte, error)
}
4.2.3 插件接口
gRPC模式下,你需要实现接口plugin.GRPCPlugin,并嵌入plugin.Plugin接口:
问题4:为什么要嵌入plugin.Plugin插件接口呢?以前只嵌入接口的实现就行(这点暂时还没解决)
type KVGRPCPlugin struct {
// 需要嵌入插件接口
plugin.Plugin
// 具体实现,仅当业务接口实现基于Go时该字段有用
Impl KV
}
plugin.GRPCPlugin接口的规格如下,你需要实现两个方法:
type GRPCPlugin interface {
// 此方法被插件进程调用
// 你需要向其提供的grpc.ServergRPC参数,注册服务的实现(服务器端存根)
// 由于gRPC下服务器端是单例模式,因此该方法仅调用一次
GRPCServer(*GRPCBroker, *grpc.Server) error
// 此方法被宿主进程调用
// 你需要返回一个业务接口的实现(客户端存根),此实现直接将请求转给gRPC客户端即可
// 传入的context对象会在插件进程销毁时取消
GRPCClient(context.Context, *GRPCBroker, *grpc.ClientConn) (interface{}, error)
}
其实现如下:
func (p *KVGRPCPlugin) GRPCServer(broker *plugin.GRPCBroker, s *grpc.Server) error {
//向grpc.ServergRPC类型参数s,注册服务的实现
proto.RegisterKVServer(s, &GRPCServer{Impl: p.Impl})
return nil
}
func (p *KVGRPCPlugin) GRPCClient(ctx context.Context, broker *plugin.GRPCBroker, c *grpc.ClientConn) (interface{}, error) {
//创建gRPC客户端的方法是自动生成的
return &GRPCClient{client: proto.NewKVClient(c)}, nil
}
备注信息:
KVPlugin是对plugin.Plugin接口的实现
KVGRPCPlugin是对GRPCPlugin接口的实现
GRPCClient是对KV接口的实现
GRPCServer是对KVServer接口的实现
1、GRPCServer接口实现
// 实现自动生成的KVServer接口,具体逻辑委托给业务接口KV的实现
type GRPCServer struct {
// This is the real implementation
Impl KV
}
func (m *GRPCServer) Put(
ctx context.Context,
req *proto.PutRequest) (*proto.Empty, error) {
return &proto.Empty{}, m.Impl.Put(req.Key, req.Value)
}
func (m *GRPCServer) Get(
ctx context.Context,
req *proto.GetRequest) (*proto.GetResponse, error) {
v, err := m.Impl.Get(req.Key)
return &proto.GetResponse{Value: v}, err
}
2、GRPCClient接口实现
在GRPCClient方法的实现中,你需要返回一个业务接口的实现(客户端stub),此实现只是将请求转发给gRPC服务处理:
//业务接口KV
type KV interface {
Put(key string, value []byte) error
Get(key string) ([]byte, error)
}
//业务接口KV的实现,通过gRPC客户端转发请求给插件进程
type GRPCClient struct{ client proto.KVClient }
func (m *GRPCClient) Put(key string, value []byte) error {
_, err := m.client.Put(context.Background(), &proto.PutRequest{
Key: key,
Value: value,
})
return err
}
func (m *GRPCClient) Get(key string) ([]byte, error) {
resp, err := m.client.Get(context.Background(), &proto.GetRequest{
Key: key,
})
if err != nil {
return nil, err
}
return resp.Value, nil
}
4.2.4 宿主机进程
宿主机进程使用gRPC方式时,只需要设置AllowedProtocols,指明同时支持plugin.ProtocolNetRPC和plugin.ProtocolGRPC两种协议。
func main() {
// We don't want to see the plugin logs.
log.SetOutput(ioutil.Discard)
// We're a host. Start by launching the plugin process.
client := plugin.NewClient(&plugin.ClientConfig{
HandshakeConfig: shared.Handshake,
Plugins: shared.PluginMap,
Cmd: exec.Command("sh", "-c", os.Getenv("KV_PLUGIN")),
AllowedProtocols: []plugin.Protocol{
plugin.ProtocolNetRPC, plugin.ProtocolGRPC},
})
defer client.Kill()
// Connect via RPC
rpcClient, err := client.Client()
if err != nil {
fmt.Println("Error:", err.Error())
os.Exit(1)
}
// Request the plugin
raw, err := rpcClient.Dispense("kv_grpc")
if err != nil {
fmt.Println("Error:", err.Error())
os.Exit(1)
}
// We should have a KV store now! This feels like a normal interface
// implementation but is in fact over an RPC connection.
kv := raw.(shared.KV)
os.Args = os.Args[1:]
switch os.Args[0] {
case "get":
result, err := kv.Get(os.Args[1])
if err != nil {
fmt.Println("Error:", err.Error())
os.Exit(1)
}
fmt.Println(string(result))
case "put":
err := kv.Put(os.Args[1], []byte(os.Args[2]))
if err != nil {
fmt.Println("Error:", err.Error())
os.Exit(1)
}
default:
fmt.Printf("Please only use 'get' or 'put', given: %q", os.Args[0])
os.Exit(1)
}
os.Exit(0)
}
4.2.5 插件进程
只需要指定GRPCServer,提供创建gRPC服务器的函数,其他的和以前没什么区别。
// Here is a real implementation of KV that writes to a local file with
// the key name and the contents are the value of the key.
type KV struct{}
func (KV) Put(key string, value []byte) error {
value = []byte(fmt.Sprintf("%s\n\nWritten from plugin-go-grpc", string(value)))
return ioutil.WriteFile("kv_"+key, value, 0644)
}
func (KV) Get(key string) ([]byte, error) {
return ioutil.ReadFile("kv_" + key)
}
func main() {
plugin.Serve(&plugin.ServeConfig{
HandshakeConfig: shared.Handshake,
Plugins: map[string]plugin.Plugin{
"kv": &shared.KVGRPCPlugin{Impl: &KV{}},
},
// A non-nil value here enables gRPC serving for this plugin...
GRPCServer: plugin.DefaultGRPCServer,
})
}
五、总结
5.1 宿主机进程和插件进程在rpc通信中扮演的角色?
扮演客户端的角色,插件进程扮演服务器的角色,因为插件进程在主函数的末尾会调用Serve(opts *ServeConfig)函数。
5.2 插件编写者和插件使用者如何来使用go-plugin库?
一般来说,步骤如下:
1、选择插件希望暴露的接口。
2、对于每个接口,实现该接口确保其通过net/rpc连接或gRPC连接可以通信,你必须同时实现客户端和服务器。
3、创建Plugin接口的实现,知道如何为给定的插件类型创建RPC client/server。
4、插件编写者,在main函数中调用plugin.Serve(),启动监听来提供插件服务。
5、插件使用者,使用plugin.Client启动子进程,通过rpc请求一个接口实现。
上述步骤有不妥当的地方,请及时反馈给我。
参考链接:https://blog.gmem.cc/go-plugin-over-grpc#comment-26043
⚠️ 各位Gopher们,注意啦!
别忘了还有 Gopher China2021 大会
还没报名的童鞋们赶快抓住最后的机会!!!