带你写个自己的protoc生成工具

完整代码已上传至GitHub,在文章最下方获取喔~

  • 你是否用过protobuf或gRPC?
  • 你们公司项目的API有没有用到proto文件?

本文将带你一步一步写个类似protoc-gen-go-grpc的proto文件生成工具,从proto文件生成兼容Go标准库的HTTP框架代码。掌握它之后,你就能随心所欲的从proto文件生成ginechonet/http代码,甚至生成你自己的框架代码。

别担心,生成的内容不局限于Go语言,别的语言也没问题,甚至不是编程语言都可以!

你可以从proto文件生成任何它能描述的东西。😉

我们正在做什么?

在学习gRPC时,你执行了一段命令,就将proto文件变成了gRPC代码:

sh 复制代码
protoc -I api \
  --go_out=internal/genproto/$service \
  --go_opt=paths=source_relative \
  --go-grpc_out=internal/genproto/$service \
  --go-grpc_opt=paths=source_relative \
  $service.proto

这多亏了protoc-gen-goprotoc-gen-go-grpc这两个可执行文件。

我们在执行protoc -xxx_out=. -xxx_opt=.命令时,protoc会从你操作系统的$PATH目录下寻找protoc-gen-xxx这个可执行文件进行执行,并把后面的参数传给它。

你可以执行ls $GOPATH/bin | grep protoc命令来查看自己电脑上安装了哪些protoc生成工具:

本文会带你实现一个名叫protoc-gen-go-example的工具,在使用时,我们只需要执行以下命令,即可调用我们自己写的生成工具来生成代码:

sh 复制代码
protoc --go-example_out=. # 此处省略其他参数

好啦,看到这儿你也应该明白我们在做什么事情啦,我们直接进入正题吧!

站在巨人的肩膀上

如果你学过编译原理,你一定很清楚我们要做些什么(没学过的同学先别跑😢):

1.解析.proto文件,构建proto文件的AST(抽象语法树)

2.遍历AST,将其转换为想要生成的内容。

天哪,这要是从零实现,需要多大的工程量啊!更别提一些没学过编译原理的同学们了。我要是从零开始教,那能写一本书了...

幸运的是,我们有一些可以利用的东西!不需要我们自己去实现proto文件的Parser啦!

protocolbuffers/protobuf-go这个库(也就是protoc-gen-go)已经帮我们实现了工作量最大的parser部分。

这下我们可以继续一起愉快的玩耍了!

创建项目

我创建的项目叫protoc-gen-go-example,这就是我们最终生成的二进制文件名称。

我们在执行go install命令时,默认会以main.go的上一级目录名作为可执行文件的名称。

假设我们的main.go文件放在根目录下,我们执行go install github.com/bootun/protoc-gen-test-name后,就会在你的$GOPATH/bin目录下安装一个名为protoc-gen-test-name可执行文件。

你可能会觉得:"那这样我的项目名岂不是很丑😢"。如果你不想让项目名作为最终的文件名称,你可以参考protocolbuffers/protobuf-go的做法。 protobuf-go把main.go放在了项目的cmd/protoc-gen-go目录下,这样在执行go install时,生成的文件就不会与项目名一样了,但代价就是go install的路径也会变长:

sh 复制代码
go install google.golang.org/protobuf/cmd/protoc-gen-go

想了解更多可以去看看go的官方文档

执行以下命令来初始化项目:

sh 复制代码
mkdir protoc-gen-go-example
cd protoc-gen-go-example
touch main.go
go mod init github.com/bootun/protoc-gen-go-example

现在你的项目看起来像下面这样:

sh 复制代码
protoc-gen-go-example
├── main.go
└── go.mod

现在让我们来编辑main.go文件:

go 复制代码
package main

import (
    "github.com/bootun/protoc-gen-go-example/parser"
    "google.golang.org/protobuf/compiler/protogen"
)

func main() {
    protogen.Options{}.Run(func(gen *protogen.Plugin) error {
        // 这个循环遍历所有要生成的proto文件
        for _, f := range gen.Files {
            if !f.Generate {
                // 如果该文件不需要生成,则跳过
                continue
            }
            // 如果需要生成,就把文件的相关信息传递给生成器
            if err := parser.GenerateFile(gen, f); err != nil {
                return err
            }
        }
        return nil
    })
}

还记得我之间说过的吗?我们在main.go中使用了protobuf-go中的组件,这样我们就不需要从零开始解析proto文件中的内容了。

protogen.Options{}.Run()的参数是一个回调函数,回调函数的gen参数里包括了所有已经解析好的信息。gen.Files表示所有proto文件的集合,我们需要遍历这些proto文件,来为它们生成代码。

我们把gen和file向下传递,以便下面的组件能够获得足够多的信息。现在parser.GenerateFile还在报错,我们来实现GenerateFile这个函数:

GenerateFile函数

GenerateFile函数还是比较清晰明了的:

go 复制代码
func GenerateFile(gen *protogen.Plugin, file *protogen.File) error {
    // 如果这个proto文件里没写service
    // 我们就不需要为它生成代码
    if len(file.Services) == 0 {
        return nil
    }

    // 要生成的文件名称
    filename := file.GeneratedFilenamePrefix + ".example.pb.go"
    g := gen.NewGeneratedFile(filename, file.GoImportPath)

    return NewFile(g, file).Generate()
}

代码里有注释的部分我就不额外解释了,很容易理解。我们需要额外关注下NewGeneratedFile这个函数。

NewGeneratedFile使用给定的文件名和ImportPath创建一个新的生成文件实体,我们将它命名为g。那这ImportPath又是个啥东西呢?

看过我上一篇文章的小伙伴们应该比较清楚,ImportPath就是我们在proto文件中写的option go_package里的内容。protobuf-go帮我们做了许多处理,使得我们不需要过度关注像--xxx_opt=paths=source_relative等这种与代码生成逻辑无关的内容,感兴趣的话可以去看我的上篇文章------彻底搞清protobuf-go的文件生成位置

有了g之后,我们只需要调用g.P("xxx")方法,即可在文件中写入对应的字符串。

看到这里你可能就明白了,我们只需要创建一套模板,将file参数中的信息套到这个模板上,然后传给g.P(模板字符串)就行了。没错,就是这么简单!

在NewGeneratedFile函数的最后,我们调用NewFile创建了一个文件结构,并执行了该结构体上的Generate方法,整个代码生成工作就完成了。让我们看看NewFile里做了什么工作吧。

NewFile函数

go 复制代码
func NewFile(gen *protogen.GeneratedFile, protoFile *protogen.File) *File {
    f := &File{
        // 保存example.pb.go的文件实体
        // 以便后面操作
        gen: gen,
    }

    f.PackageName = string(protoFile.GoPackageName)

    for _, s := range protoFile.Services {
        f.ParseService(s)
    }

    return f
}

我在NewFile中创建了一个File结构体,这是我们自定义的一个结构体,用来表示一个proto文件的内容。

这个结构体不是必要的,甚至可能是多余的,它只是把protogen.File参数里的内容给转成了我们的内部表示,所有的信息protogen.File里都有,如果你想的话,你可以直接使用protogen.File+text/template来生成文件。这里我出于教学目的,希望你能更容易明白这个过程,同时也为了日后做些更骚的操作,就留下这个结构了。

proto文件的内部表示

刚刚我提到了File结构体,说它只是把protogen.File里的一部分信息复制出来,转为我们自己的内部结构体了,事实上除了File之外,还有几个同样表示proto信息的结构体,他们都是File结构的下属结构:

go 复制代码
// 一个File表示一个proto文件的信息
type File struct {
    // File内部同时保存了example.pb.go的文件句柄
    // 方便我们直接调用gen.P向pb文件写入内容
    gen *protogen.GeneratedFile

    // 内嵌了一个FileDescription结构
    // 更多信息可以继续往下看
    FileDescription
}

// FileDescription 描述了一个解析过后的proto文件的信息
// 为我们后边的代码生成做准备
type FileDescription struct {
    // PackageName 代表我们生成后的example.pb.go文件的包名
    // 也就是go文件中的 package xxx
    PackageName string

    // Services 代表我们生成后的example.pb.go文件中的所有服务
    // 我们在proto文件中写的每个server都会转化为一个 Service 实体
    Services []*Service
}

type Service struct {
    // Service 的名称
    Name string

    // Service 里具有哪些方法
    Methods []*Method
}

type Method struct {
    // 方法名称
    Name string
    // 请求类型
    RequestType string
    // 响应类型
    ResponseType string
}

这些结构结合起来描述了一个简单的proto文件信息:

因为是教学的缘故,所以各种类型的信息都很简单,几乎都用字符串存储,只保留了最核心的内容。接下来,我们需要把信息从protogen.File里复制到我们自己的结构体里。

复制proto信息到内部表示中

还记得上面的NewFile函数吗?里面有这样一段代码:

go 复制代码
for _, s := range protoFile.Services {
    f.ParseService(s)
}

这段代码遍历protoFile中所有的Service,并调用f.ParseService()方法来处理proto中的每个service:

go 复制代码
func (f *File) ParseService(protoSvc *protogen.Service) {
    s := &Service{
        Name:    protoSvc.GoName,
        Methods: make([]*Method, 0, len(protoSvc.Methods)),
    }

    for _, m := range protoSvc.Methods {
        // 遍历并处理Service中的所有Method
        s.Methods = append(s.Methods, f.ParseMethod(m))
    }

    f.FileDescription.Services = append(f.FileDescription.Services, s)
}

func (f *File) ParseMethod(m *protogen.Method) *Method {
    return &Method{
        Name:         m.GoName,
        RequestType:  m.Input.GoIdent.GoName,
        ResponseType: m.Output.GoIdent.GoName,
    }
}

ParseService又会调用ParseMethod方法来遍历处理service中的每个method,我将它们两个的代码一起贴上来了,里面的逻辑很简单,就是从protogen的对应结构里找到我们需要的属性复制过来,解析工作就完成了。

现在,我们的File结构体被"填满了",里面保存了一个proto文件(比较粗略)的信息。接下来让我们来创建一套模板,这将是代码生成的最后一步。

模板代码

在给你代码之前,我想先明确一下,我在例子中生成的是"基于Go标准库net/http的框架代码"。当然,你可以生成gin或其他框架的代码,这全看你自己。但在写模板之前,我们要先想想,我们要生成什么样的代码?使用者又希望你能帮他做哪些事?

要知道,proto文件不是为gRPC而生的,除了gRPC, Transport层的框架多到数不清,gin/echo/chi等都算Transport层的框架。

因此,站在业务工程师的角度上,我希望能将关注点放在业务代码上,业务代码中不能包含任何传输层的细节,这样我就可以随时以很低的成本更换传输层的框架。

叠个甲: 这里的Transport层和传输层指的不是网络协议中的传输层,别喷。

所以站在使用者的角度上,我们可能会写出以下代码:

go 复制代码
func main() {
    // 初始化Transport
    mux := http.NewServeMux()
    // 初始化业务依赖
    svc := UserService{
        store: make([]User, 0),
    }
    // 将业务Service注册到Transport框架中
    user_pb.RegisterUserServiceHTTPServeMux(mux, &svc)
    // 启动Transport框架
    if err := http.ListenAndServe(":8080", mux); err != nil {
        panic(err)
    }
}

// 业务Handler
type UserService struct {
    store []User
}

func (u *UserService) GetUser(ctx context.Context, req *user_pb.GetUserRequest) (resp *user_pb.GetUserResponse, err error) {
    // 这里写GetUser的业务代码
}

func (u *UserService) CreateUser(ctx context.Context, req *user_pb.CreateUserRequest) (resp *user_pb.CreateUserResponse, err error) {
    // 这里写CreateUser的业务代码
}

上面这段代码中,业务代码的GetUserCreateUser中没有任何Transport层的内容,业务代码不知道上层使用的是HTTP还是gRPC,又或者是gin等其他框架。

那我们就按照这个格式,来抽象出一个接口,作为和业务之间的契约。

如果你用过gRPC,你会发现: gRPC也是这套"契约",这意味着未来我们要从net/http迁移到gRPC时,业务代码不需要进行任何的改造,天然适配!

那为了能让上面那段业务代码能够正常运行,我们先来手写一遍框架代码,来"适配"上面的业务代码。

这有点类似TDD(Test-Driven Development)的味道,从使用者的角度上开始,来定义代码应该"长什么样"。

我们很容易就能写出下面的适配代码, 这将是我们模板的雏形:

go 复制代码
// 这个接口就是业务和框架的"契约"
// 实现这个接口的结构都能够注册进我们的框架中
// 这个Service对应着proto文件的service
type ServiceNameService interface {
    // 这里就是service的方法列表,对应着proto文件中service的方法列表
    ServiceMethodName(ctx context.Context, req *MethodRequestName) (resp *MethodResponseName, err error)
}

// 业务代码通过下面这段代码将服务注册到我们的框架中
func RegisterServerNameServiceHTTPServeMux(mux *http.ServeMux, svc ServiceNameService) {
    // 这里用到了依赖注入的思想
    // 此时业务代码是依赖,通过接口的形式注入进来
    s := ServiceName{
        svc: svc,
    }
    // 将对应的方法绑定到相应的路由上
    mux.HandleFunc("/UserCode", s.Name)
}

// 框架service具体实现,里面通过接口保存了业务结构体
type ServiceName struct {
    svc ServiceNameService
}

// 每个service下都会有数个method
// 每个method也都对应着proto文件里service的method
// 这里用到了适配器(Adaptor)的设计思想
// 将业务代码(通过接口)与 net/http 转换,把它们"打通"
func (s *ServiceName) Name(rw http.ResponseWriter, r *http.Request) {
    // 我们在这个函数中要做的就是把HTTP请求中的内容解析出来
    // 尝试将其转换成业务需要的参数
    _ = r.ParseForm()
    var req MethodRequestName // 这个结构是protobuf生成的,和框架无关
    switch r.Method {
    // 出于教学目的,这里只支持了POST请求
    case http.MethodPost:
        if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
            rw.WriteHeader(http.StatusBadRequest)
            rw.Write([]byte(err.Error()))
            return
        }
    default:
        rw.WriteHeader(http.StatusMethodNotAllowed)
        rw.Write([]byte(`method not allowed`))
        return
    }
    // 到这里就顺利的把HTTP请求转为了业务所需要的Request类型了
    // 接下来我们把控制权交给业务代码吧
    resp, err := s.svc.ServiceMethodName(r.Context(), &req)
    if err != nil {
        rw.WriteHeader(http.StatusInternalServerError)
        rw.Write([]byte(err.Error()))
        return
    }
    // 将业务代码返回的Response类型再转为HTTP请求返回给客户端
    if err := json.NewEncoder(rw).Encode(resp); err != nil {
        rw.WriteHeader(http.StatusInternalServerError)
        rw.Write([]byte(err.Error()))
        return
    }
}

你可以看到,上面的适配代码其实挺简陋的,它只支持POST请求,甚至HTTP路由都是proto文件里method的名字。但这对于我们学习它的核心原理已经够用了。

即使是个玩具级别的demo,它依旧用到了许多设计模式。

PS: 如果这篇文章反响还不错的话,可能会考虑后续继续加点东西。这篇文章我从晚上8点开始写,写到这里已经凌晨1:15了😢

知道了我们的模板大概长什么样子后,剩下的就简单了,替换上面代码中的Name等各个部分,就得到了我们最终的模板代码:

go 复制代码
package template

const HTTP = `// Code generated by github.com/bootun/protoc-gen-go-example. DO NOT EDIT.
package {{.PackageName}}

import (
    "context"
    "encoding/json"
    "net/http"
)

{{range $service := .Services}}
type {{$service.Name}}Service interface {
{{range $method := .Methods}}
    {{$method.Name}}(ctx context.Context, req *{{$method.RequestType}}) (resp *{{$method.ResponseType}}, err error){{end}}
}

type {{$service.Name}} struct {
    svc {{$service.Name}}Service
}

func Register{{$service.Name}}HTTPServeMux(mux *http.ServeMux, svc {{$service.Name}}Service) {
    s := {{$service.Name}}{
        svc: svc,
    }
    {{range $method := .Methods}}
    mux.HandleFunc("/{{$method.Name}}", s.{{$method.Name}}){{end}}
}

{{range $method := .Methods}}
func (s *{{$service.Name}}) {{$method.Name}}(rw http.ResponseWriter, r *http.Request) {
    _ = r.ParseForm()
    var req {{$method.RequestType}}
    switch r.Method {
    case http.MethodPost:
        if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
            rw.WriteHeader(http.StatusBadRequest)
            return
        }
    default:
        rw.WriteHeader(http.StatusMethodNotAllowed)
        return
    }
    resp, err := s.svc.{{$method.Name}}(r.Context(), &req)
    if err != nil {
        rw.WriteHeader(http.StatusInternalServerError)
        return
    }
    if err := json.NewEncoder(rw).Encode(resp); err != nil {
        rw.WriteHeader(http.StatusInternalServerError)
        return
    }
}
{{end}}

{{end}}
`

如果你看不懂上面的语法,你需要去看下go的text/template,或者你有其他的办法能拼凑渲染出这段字符串也可以。

我们只需要将我们内部结构中的数据"填充"到模板里,交给前文提到的g.P()进行打印就可以啦:

go 复制代码
func (f *File) Generate() error {
    tmpl, err := template.New("example-template").Parse(example_tmpl.HTTP)
    if err != nil {
        return fmt.Errorf("failed to parse example template: %w", err)
    }
    buf := &bytes.Buffer{}
    if err := tmpl.Execute(buf, f.FileDescription); err != nil {
        return fmt.Errorf("failed to execute example template: %w", err)
    }
    f.gen.P(buf.String())
    return nil
}

至此,我们就完成了所有的代码编写。

我将完整代码上传到了GitHub上: github.com/bootun/prot...

你也可以使用以下命令来直接安装

sh 复制代码
go install github.com/bootun/protoc-gen-go-example@latest

然后使用--go-example_out来生成代码:

sh 复制代码
protoc -I ./api \
   --go_out=./user \
   --go_opt=paths=source_relative \
   --go-example_out=./user \
   --go-example_opt=paths=source_relative \
    api/user.proto

都看到最后了,点个关注呗~

写到这都凌晨1:42了😱,赶快发完睡了...

本文首发于微信公众号梦真日记,欢迎关注

相关推荐
回家路上绕了弯14 小时前
分布式锁原理深度解析:从理论到实践
分布式·后端
磊磊磊磊磊14 小时前
用AI做了个排版工具,分享一下如何高效省钱地用AI!
前端·后端·react.js
hgz071014 小时前
Spring Boot Starter机制
java·spring boot·后端
daxiang1209220514 小时前
Spring boot服务启动报错 java.lang.StackOverflowError 原因分析
java·spring boot·后端
我家领养了个白胖胖15 小时前
极简集成大模型!Spring AI Alibaba ChatClient 快速上手指南
java·后端·ai编程
一代明君Kevin学长15 小时前
快速自定义一个带进度监控的文件资源类
java·前端·后端·python·文件上传·文件服务·文件流
aiopencode15 小时前
上架 iOS 应用到底在做什么?从准备工作到上架的流程
后端
哈哈老师啊15 小时前
Springboot简单二手车网站qs5ed(程序+源码+数据库+调试部署+开发环境)带论文文档1万字以上,文末可获取,系统界面在最后面。
数据库·spring boot·后端
JIngJaneIL16 小时前
基于Java+ vue图书管理系统(源码+数据库+文档)
java·开发语言·前端·数据库·vue.js·spring boot·后端
IT_陈寒16 小时前
Vue 3.4 实战:5个被低估的Composition API技巧让我的开发效率提升40%
前端·人工智能·后端