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

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

本文将带你一步一步写个相似protoc-gen-go-grpc的proto文件生成东西,从proto文件生成兼容Go规范库的HTTP结构代码。把握它之后,你就能为所欲为的从proto文件生成ginechonet/http代码,乃至生成你自己的结构代码。

别忧虑,生成的内容不局限于Go言语,别的言语也没问题,乃至不是编程言语都能够!

你能够从proto文件生成任何它能描绘的东西。

咱们正在做什么?

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

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生成东西

本文会带你完成一个名叫protoc-gen-go-example的东西,在运用时,咱们只需求履行以下指令,即可调用咱们自己写的生成东西来生成代码:

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的路径也会变长:

go install google.golang.org/protobuf/cmd/protoc-gen-go

想了解更多能够去看看go的官方文档

履行以下指令来初始化项目:

mkdir protoc-gen-go-example
cd protoc-gen-go-example
touch main.go
go mod init github.com/bootun/protoc-gen-go-example

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

protoc-gen-go-example
├── main.go
└── go.mod

现在让咱们来编辑main.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函数还是比较明晰明晰的:

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函数

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结构的下属结构:

// 一个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文件信息:

带你写个自己的protoc生成东西

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

仿制proto信息到内部表明中

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

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

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

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层和传输层指的不是网络协议中的传输层,别喷。

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

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)的味道,从运用者的角度上开端,来定义代码应该“长什么样”。

咱们很简略就能写出下面的适配代码, 这将是咱们模板的雏形:

// 这个接口便是事务和结构的“契约”
// 完成这个接口的结构都能够注册进咱们的结构中
// 这个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等各个部分,就得到了咱们终究的模板代码:

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()进行打印就能够啦:

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…

你也能够运用以下指令来直接装置

go install github.com/bootun/protoc-gen-go-example@latest

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

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了,赶快发完睡了…

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