完整代码已上传至GitHub,在文章最下方获取喔~
- 你是否用过protobuf或gRPC?
- 你们公司项意图API有没有用到proto文件?
本文将带你一步一步写个相似protoc-gen-go-grpc
的proto文件生成东西,从proto文件生成兼容Go规范库的HTTP结构代码。把握它之后,你就能为所欲为的从proto文件生成gin
、echo
、net/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-go
和protoc-gen-go-grpc
这两个可履行文件。
咱们在履行protoc -xxx_out=. -xxx_opt=.
指令时,protoc会从你操作系统的$PATH目录下寻找protoc-gen-xxx
这个可履行文件进行履行,并把后面的参数传给它。
你能够履行ls $GOPATH/bin | grep 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文件信息:
因为是教学的缘故,所以各种类型的信息都很简略,简直都用字符串存储,只保留了最核心的内容。接下来,咱们需求把信息从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的事务代码
}
上面这段代码中,事务代码的GetUser
和CreateUser
中没有任何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了,赶快发完睡了…
本文首发于微信公众号梦真日记,欢迎重视