> ## Content Index
> Fetch the complete content index at: https://huizhou92.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# RPC实践 EP2: Protobuf与 它的插件系统。
- URL: https://huizhou92.com/rpcshi-jian-ep2-protobufyu-ta-de-cha-jian-xi-tong/
- Published: 2024-08-13T08:10:53.000Z
- Updated: 2026-09-06T11:40:25.000Z
- Author: huizhou92
- Tags: tech, go, RPC, #lang-zh-cn

在上一篇文章中，我用`net/rpc` 包实现了一个简单的 RPC接口，并且尝试了`net/rpc`自带的`Gob`编码以及`JSON`编码，学习了Golang RPC 的一些基本知识。本篇文章，我会将`net/rpc` 跟 protobuf结合起来，然后在了解一下如何使用Protobuf + 插件 生成 `gRPC` 的代码。 最后会尝试创建一个自己的`protobuf`插件来帮助我们生成代码，开始吧。

> This article was first published in the Medium MPP plan. If you are a Medium user, please follow me on [Medium](https://medium.huizhou92.com/?ref=huizhou92.com). Thank you very much.

我们在工作过程中一定使用过`gRPC` \+ `protobuf` ,但是它们两个并不是绑定关系，`gRPC`可以使用JSON编码，[protobuf](https://en.wikipedia.org/wiki/Protocol%5FBuffers?ref=huizhou92.com)也可以在其他语言中实现。

> [Protocol Buffers](https://en.wikipedia.org/wiki/Protocol%5FBuffers?ref=huizhou92.com) 是谷歌推出的编码标准，它在传输效率和编解码性能上都要优于 JSON。但其代价则是需要依赖中间描述语言（IDL）来定义数据和服务的结构（ `*.proto` 文件），并且需要一整套的工具链（protoc 及其插件）来生成对应的序列化和反序列化代码。
> 
> 除了谷歌官方提供的工具和插件（比如生成 go 代码的 protoc-gen-go）外，我们还可以开发或定制自己的插件，根据业务需要按照 proto 文件的定义生成代码或者文档。由 IDL 生成代码或者文档是元编程的一种形式，可以极大的解放程序员的生产力。

## 一个使用protobuf 的例子

首先我们写一个`proto` 文件` hello-service.proto`，定义一个message `“String“`

```proto
syntax = "proto3";
package api;
option  go_package="api";

message String {
  string value = 1;
}

```

然后使用protoc 工具生成message String 的 Go代码

```shell
protoc --go_out=./api proto/hello.proto 

```

生成的代码 里面有一个 `String` struct

```go
type String struct {
	Value                string   `protobuf:"bytes,1,opt,name=value,proto3" json:"value,omitempty"`
	XXX_NoUnkeyedLiteral struct{} `json:"-"`
	XXX_unrecognized     []byte   `json:"-"`
	XXX_sizecache        int32    `json:"-"`
}

```

我们可以直接使用它，修改一下函数的参数。

```go
type HelloServiceInterface = interface {  
    Hello(request api.String, reply *api.String) error  
}  

```

其实使用起来跟以前没什么区别。甚至还不如直接使用`string`方便。  
那么我们为什么要使用Protobuf?  
正如前面所说的，**用Protobuf定义与语言无关的RPC服务接口以及message，然后使用`protoc`工具生成不同语言的代码，才是它真正的价值所在**。

## protoc 的插件系统

`protobuf`是一个与编程语言无关的协议。它通过`protoc`跟各种不同的插件来将protobuf文件编译成不同的编程语言，我们以`Golang`+`gRPC` 为例子。

```shell
protoc --go_out=plugins=grpc. hello-service.proto

```

这里有一个 `--go_out` 参数。因为我们调用的插件是`protoc-gen-go`，所以参数名字叫 go\_out；如果名字叫 XXX，那参数名字就叫 XXX\_out。

protoc 在运行的时候首先会解析 proto 文件的所有内容，生成一组 Protocol Buffers 编码的描述数据，首先会判断protoc 内部是否包含`go`插件, 然后会尝试在`$PATH` 里面寻找`protoc-gen-go`，找不到会报错，然后运行`protoc-gen-go` 命令，并且通过 stdin 将描述数据发送给插件命令。插件生成好文件内容后再向 stdout 输入 Protocol Buffers 编码的数据来告诉 protoc 生成具体的文件。

`plugins=grpc` 是 为了调用`protoc-gen-go` 自带的一个插件，如果不使用它，那么只会生成 Go 语言 的message信息，使用这个插件才会生成grpc 相关的代码。

## 自定义一个 protoc 插件

如果在protobuf 中添加`Hello` 接口的定时，我们是不是可以自定义一个 `protoc` 插件，直接生成代码？

```proto
syntax = "proto3";  
package api;  
option  go_package="./api";  
service HelloService {  
  rpc Hello (String) returns (String) {}  
}  
message String {  
  string value = 1;
}

```

### 目标

这篇文章，我的目标是创建一个插件，然后用来生成RPC 的服务端与客户端代码，生成的代码大致是这样的。

```go
// HelloService_rpc.pb.go
type HelloServiceInterface interface {  
    Hello(String, *String) error  
}  
  
func RegisterHelloService(  
    srv *rpc.Server, x HelloServiceInterface,  
) error {  
    if err := srv.RegisterName("HelloService", x); err != nil {  
       return err  
    }  
    return nil  
}  
  
type HelloServiceClient struct {  
    *rpc.Client  
}  
  
var _ HelloServiceInterface = (*HelloServiceClient)(nil)  
  
func DialHelloService(network, address string) (  
    *HelloServiceClient, error,  
) {  
    c, err := rpc.Dial(network, address)  
    if err != nil {  
       return nil, err  
    }  
    return &HelloServiceClient{Client: c}, nil  
}  
  
func (p *HelloServiceClient) Hello(  
    in String, out *String,  
) error {  
    return p.Client.Call("HelloService.Hello", in, out)  
}

```

这样我们的业务代码就能改成下面这个样子。

```go
// service
func main() {  
    listener, err := net.Listen("tcp", ":1234")  
    if err != nil {  
       log.Fatal("ListenTCP error:", err)  
    }  
    _ = api.RegisterHelloService(rpc.DefaultServer, new(HelloService))  
    for {  
       conn, err := listener.Accept()  
       if err != nil {  
          log.Fatal("Accept error:", err)  
       }  
       go rpc.ServeConn(conn)  
    }  
}  
  
type HelloService struct{}  
  
func (p *HelloService) Hello(request api.String, reply *api.String) error {  
    log.Println("HelloService.proto Hello")  
    *reply = api.String{Value: "Hello:" + request.Value}  
    return nil  
}
// client.go
func main() {  
    client, err := api.DialHelloService("tcp", "localhost:1234")  
    if err != nil {  
       log.Fatal("net.Dial:", err)  
    }  
    reply := &api.String{}  
    err = client.Hello(api.String{Value: "Hello"}, reply)  
    if err != nil {  
       log.Fatal(err)  
    }  
    log.Println(reply)  
}

```

基于生成的代码，我们的工作量已经小了很多，并且出错的几率已经很小了。一个不错的开始。  
根据上面的api代码，我们可以抽出来一个模板文件：

```go
const tmplService = `  
package {{.PackageName}}  
import (  
    "net/rpc")  
{{$root := .}}  
type {{.ServiceName}}Interface interface {  
    {{- range $_, $m := .MethodList}}    {{$m.MethodName}}({{$m.InputTypeName}}, *{{$m.OutputTypeName}}) error    {{- end}}}  
func Register{{.ServiceName}}(  
    srv *rpc.Server, x {{.ServiceName}}Interface,) error {  
    if err := srv.RegisterName("{{.ServiceName}}", x); err != nil {        return err    }    return nil}  
type {{.ServiceName}}Client struct {  
    *rpc.Client}  
var _ {{.ServiceName}}Interface = (*{{.ServiceName}}Client)(nil)  
func Dial{{.ServiceName}}(network, address string) (  
    *{{.ServiceName}}Client, error,) {  
    c, err := rpc.Dial(network, address)    if err != nil {        return nil, err    }    return &{{.ServiceName}}Client{Client: c}, nil}  
{{range $_, $m := .MethodList}}  
func (p *{{$root.ServiceName}}Client) {{$m.MethodName}}(  
    in {{$m.InputTypeName}}, out *{{$m.OutputTypeName}},) error {  
    return p.Client.Call("{{$root.ServiceName}}.{{$m.MethodName}}", in, out)}  
{{end}}  
`

```

整个模板很清晰，里面有一些占位符，比如 MethodName，ServiceName 等，我们后面会介绍。

### 如何开发 一个插件？

谷歌发布了 Go 语言 API[1](https://go.dev/blog/protobuf-apiv2?ref=huizhou92.com)，其中引入了一个新包 `google.golang.org/protobuf/compiler/protogen` ，极大的降低了plugins 开发难度：

1. 首先我们创建一个go 语言工程，比如`protoc-gen-go-spprpc`
2. 然后我们需要定义一个`protogen.Options`，然后调用它的`Run`方法，并传入一个 `func(*protogen.Plugin) error`回调。主流程代码到此就结束了。
3. 我们还可以设置`protogen.Options`的`ParamFunc`参数，这样 protogen 会自动为我们解析命令行传入的参数。诸如从标准输入读取并解码 protobuf 信息，将输入信息编码成 protobuf 写入 stdout 等操作全部由 protogen 包办了。我们要做的就是与 `protogen.Plugin` 交互实现代码生成逻辑。

每个服务最重要的是服务的名字，然后每个服务有一组方法。而对于服务定义的方法，最重要的是方法的名字，还有输入参数和输出参数类型的名字。我们首先定义一个`ServiceData`，用于描述服务的元信息：

```go
// ServiceData 结构体定义  
type ServiceData struct {  
    PackageName string  
    ServiceName string  
    MethodList  []Method  
}
// Method 结构体定义  
type Method struct {  
    MethodName     string  
    InputTypeName  string  
    OutputTypeName string  
}

```

然后就是主逻辑，以及代码生成逻辑，最后调用`tmpl`生成代码。

```go
func main() {  
    protogen.Options{}.Run(func(gen *protogen.Plugin) error {  
       for _, file := range gen.Files {  
          if !file.Generate {  
             continue  
          }  
          generateFile(gen, file)  
       }  
       return nil  
    })  
}  
  
// generateFile 函数定义  
func generateFile(gen *protogen.Plugin, file *protogen.File) {  
    filename := file.GeneratedFilenamePrefix + "_rpc.pb.go"  
    g := gen.NewGeneratedFile(filename, file.GoImportPath)  
    tmpl, err := template.New("service").Parse(tmplService)  
    if err != nil {  
       log.Fatalf("Error parsing template: %v", err)  
    }  
    packageName := string(file.GoPackageName)  
    // 遍历每个服务生成代码  
    for _, service := range file.Services {  
       serviceData := ServiceData{  
          ServiceName: service.GoName,  
          PackageName: packageName,  
       }  
       for _, method := range service.Methods {  
          inputType := method.Input.GoIdent.GoName  
          outputType := method.Output.GoIdent.GoName  
  
          serviceData.MethodList = append(serviceData.MethodList, Method{  
             MethodName:     method.GoName,  
             InputTypeName:  inputType,  
             OutputTypeName: outputType,  
          })  
       }  
       // 执行模板渲染  
       err = tmpl.Execute(g, serviceData)  
       if err != nil {  
          log.Fatalf("Error executing template: %v", err)  
       }  
    }  
}

```

### 调试插件

最后我们将编译后的 二进制执行文件`protoc-gen-go-spprpc`，放在$PATH 里面， 然后运行`protoc` 就能生成我们想要的代码了。

```shell
protoc --go_out=.. --go-spprpc_out=.. HelloService.proto

```

因为`protoc-gen-go-spprpc` 必须依赖 `protoc` 才能运行，所以调试起来比较麻烦。我们可以使用  
`fmt.Fprintf(os.Stderr, "Fprintln: %v\n", err)` 打印错误日志的方式调试。

## 总结

以上就是本文的全部内容了。我们首先使用protobuf 实现了一个rpc call，然后创建了一个protobuf 插件来帮助我们生成代码。为我们打开了一扇学习protobuf + RPC 的大门，也是我们通往彻底理解gRPC的路。希望大家都能掌握这个技术。

## 参考文档

1. [https://taoshu.in/go/create-protoc-plugin.html](https://taoshu.in/go/create-protoc-plugin.html?ref=huizhou92.com)
2. [https://chai2010.cn/advanced-go-programming-book/ch4-rpc/ch4-02-pb-intro.html](https://chai2010.cn/advanced-go-programming-book/ch4-rpc/ch4-02-pb-intro.html?ref=huizhou92.com)