Go 自动生成代码工具二 (在proto文件中定义http的接口,并自动生成gin的接口工具)
作者:@杨阳
本文为作者原创,转载请注明出处:https://www.cnblogs.com/studyios/p/17859749.html
一、需求分析
在和前端对接过程中,需要后端维护一份接口文档,对于这份文档的维护在实际工作中会有一系列的问题,例如参数个数、参数类型、返回类型等。
主要还是后期需要一直维护,如果改了接口,忘记维护文档就会导致前端调用异常。
但是当使用 protobuf
定义好了接口,微服务相互间调用,一般不会出现这类问题,因为调用双方都要遵守 GRPC
自动生成的 _grpc.pd.go
;
所以,如果前端人员如果能看懂proto文件内容(难度低),而后端也必须遵守一份类似 _grpc.pd.go
的文件中定义的路由接口,就能解决问题。
go-zero
也是类似的思想,不过它为api
层单独定义一套语法,用于生成文档、测试、go代码等。
这里借鉴的是Kratos
,最终要实现的效果:
- 在proto文件中定义 gin接口信息,需要让proto文件在解析时候,不会报错。
- 通过
protoc
命令生成 一份类似 _grpc.pd.go定义好了rpc接口的文件, 工具中叫_gin.pb.go
,定义好了http路由和参数的文件。- gin 遵守这个文件中定义的接口,实现业务。
说明下:这个和直接使用grpc的gateway,将rpc服务转为http服务还是不一样,我们的api层使用的是gin开发。工具的目的是为了帮助前后端限定对接接口的定义
二、 proto文件和模板确定
2.1 在proto中定义 api接口,要求能正常解析
先看结果:
syntax = "proto3";
package template;
import "google/api/annotations.proto";
option go_package="./;v1";
// The greeting service definition.
service Greeter {
// Sends a greeting
rpc SayHello (HelloRequest) returns (HelloReply) {
option (google.api.http) = {
post: "/v1/sayhello"
body: "*"
};
}
// Sends another greeting
rpc SayHelloAgain (HelloRequest) returns (HelloReply) {
option (google.api.http) = {
post: "/v1/sayhelloagain"
body: "*"
};
}
}
// The request message containing the user's name.
message HelloRequest {
string name = 1;
}
// The response message containing the greetings
message HelloReply {
string message = 1;
}
这里在 service中定义了http的方法和路由,入参和返回直接用 grpc定义的。
这里之所以不报错,是因为引入了第三方库 import "google/api/annotations.proto"
google/api/annotations.proto 是 Google API 开发者使用的一种 Protocol Buffers 文件,它包含了一些预定义的注释,用于在 gRPC 服务中定义 API。
这些注释可以帮助开发者为服务生成客户端库、Swagger 文档和其他相关工具。
后期也能自动直接生成 Swagger
文档,接着导入 yapi
之类的会很方便。
2.2 生成文件的模板
根据上篇分析的 go-zero的生成代码原理,也需要一份模板文件,模板文件是根据我们最终实现的文件来的:
package v1
import (
gin "github.com/gin-gonic/gin"
http "net/http"
)
type GreeterHttpServer struct {
server GreeterServer
router gin.IRouter
}
// 入口方法,要求 注册的 srv 必须实现 GreeterServer 接口。这个接口在grpc的go中定义
// 因为是同一个包名,这里就不用import了。
func RegisterGreeterServerHTTPServer(srv GreeterServer, r gin.IRouter) {
s := GreeterHttpServer{
server: srv,
router: r,
}
s.RegisterService()
}
// 实现方法,但是只是做了json判断
func (s *GreeterHttpServer) SayHello_0(c *gin.Context) {
var in HelloRequest
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 真正的业务调用
out, err := s.server.SayHello(c, &in)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, out)
}
func (s *GreeterHttpServer) SayHelloAgain_0(c *gin.Context) {
var in HelloRequest
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
out, err := s.server.SayHelloAgain(c, &in)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, out)
}
// 注册路由
func (s *GreeterHttpServer) RegisterService() {
s.router.Handle("POST", "/v1/sayhello", s.SayHello_0)
s.router.Handle("POST", "/v1/sayhelloagain", s.SayHelloAgain_0)
}
依据这个实现,来生成模板,其实就是把一些需要变动的方法名,报名,通过proto文件获取后,传过来,最终模板的定义:
type {{$.Name}}HttpServer struct{
server {{ $.ServiceName }}
router gin.IRouter
}
func Register{{ $.ServiceName }}HTTPServer(srv {{ $.ServiceName }}, r gin.IRouter) {
s := {{.Name}}HttpServer{
server: srv,
router: r,
}
s.RegisterService()
}
{{range .Methods}}
func (s *{{$.Name}}HttpServer) {{ .HandlerName }} (c *gin.Context) {
var in {{.Request}}
{{if eq .Method "GET" "DELETE" }}
if err := c.ShouldBindQuery(&in); err != nil {
s.resp.ParamsError(ctx, err)
return
}
{{else if eq .Method "POST" "PUT" }}
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
{{else}}
if err := c.ShouldBind(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
{{end}}
{{if .HasPathParams }}
{{range $item := .PathParams}}
in.{{$.GoCamelCase $item }} = c.Params.ByName("{{$item}}")
{{end}}
{{end}}
out, err := s.server.{{.Name}}(c, &in)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, out)
}
{{end}}
func (s *{{$.Name}}HttpServer) RegisterService() {
{{range .Methods}}
s.router.Handle("{{.Method}}", "{{.Path}}", s.{{ .HandlerName }})
{{end}}
}
模板中对需要变动的,都提取了出来,利用的就是go的template知识。
三、核心实现
1. 解析proto文件
上篇讲到 go-zero是利用 github.com/emicklei/proto
库,进行的proto文件解析,我们因为还要同时生成grpc和rpc的文件,所以直接在protoc命令的基础上进行扩展更好。
kratos具体的地址:https://github.com/go-kratos/kratos/blob/main/cmd/protoc-gen-go-http/http.go
参考kratos的实现,发现它引入了protobuf的库:
import (
// protobuf 反射
"google.golang.org/protobuf/reflect/protoreflect"
// 上面讲过了 解析http方法定义
"google.golang.org/genproto/googleapis/api/annotations"
// 核心 用于解析 proto定义的 message 和 service等
"google.golang.org/protobuf/compiler/protogen"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/descriptorpb"
)
这里我们参照 Kratos实现:
package generator
import (
"google.golang.org/genproto/googleapis/api/annotations"
"google.golang.org/protobuf/compiler/protogen"
"google.golang.org/protobuf/proto"
)
// 需要导入的头文件
const (
ginPkg = protogen.GoImportPath("github.com/gin-gonic/gin")
httpPkg = protogen.GoImportPath("net/http")
)
var methodSets = make(map[string]int)
func GenerateFile(gen *protogen.Plugin, file *protogen.File) *protogen.GeneratedFile {
if len(file.Services) == 0 {
return nil
}
//设置生成的文件名,文件名会被protoc使用,生成的文件会被放在响应的目录下
filename := file.GeneratedFilenamePrefix + "_gin.pb.go"
g := gen.NewGeneratedFile(filename, file.GoImportPath)
//该注释会被go的ide识别到, 表示该文件是自动生成的,尽量不要修改,
//一旦尝试修改。ide就会提醒,亲测goland是为提醒。
g.P("// Code generated by protoc-gen-gin. DO NOT EDIT.")
g.P()
g.P("package ", file.GoPackageName)
//该函数是注册全局的packge 的内容,但是此时不会写入
g.QualifiedGoIdent(ginPkg.Ident(""))
g.QualifiedGoIdent(httpPkg.Ident(""))
// 关心的只有 sevice
for _, service := range file.Services {
genService(file, g, service)
}
// 最后只要把要内容生成好,protogen 会帮你生成,不用自己写文件
return g
}
func genService(file *protogen.File, g *protogen.GeneratedFile, s *protogen.Service) {
// HTTP Server
sd := &service{
Name: s.GoName,
FullName: string(s.Desc.FullName()),
}
for _, method := range s.Methods {
sd.Methods = append(sd.Methods, genMethod(method)...)
}
text := sd.execute() // 调用模板文件,生成替换过的内容
g.P(text)
}
func genMethod(m *protogen.Method) []*method {
var methods []*method
// 存在 http rule 配置
// options
rule, ok := proto.GetExtension(m.Desc.Options(), annotations.E_Http).(*annotations.HttpRule)
if rule != nil && ok {
methods = append(methods, buildHTTPRule(m, rule))
return methods
}
methods = append(methods, defaultMethod(m))
return methods
}
rule, ok := proto.GetExtension(m.Desc.Options(), annotations.E_Http).(*annotations.HttpRule)
rule就是 配置的http相关信息,和声明时候对应上 option (google.api.http) = {}
。
func buildHTTPRule(m *protogen.Method, rule *annotations.HttpRule) *method {
var path, method string
switch pattern := rule.Pattern.(type) {
case *annotations.HttpRule_Get:
path = pattern.Get
method = "GET"
case *annotations.HttpRule_Put:
path = pattern.Put
method = "PUT"
case *annotations.HttpRule_Post:
path = pattern.Post
method = "POST"
case *annotations.HttpRule_Delete:
path = pattern.Delete
method = "DELETE"
case *annotations.HttpRule_Patch:
path = pattern.Patch
method = "PATCH"
case *annotations.HttpRule_Custom:
path = pattern.Custom.Path
method = pattern.Custom.Kind
}
md := buildMethodDesc(m, method, path)
return md
}
path是路由信息,method是方法。到这里都获取到了。
2.将模板文件中的变量进行替换
func (m *method) HandlerName() string {
return fmt.Sprintf("%s_%d", m.Name, m.Num)
}
// HasPathParams 是否包含路由参数
func (m *method) HasPathParams() bool {
paths := strings.Split(m.Path, "/")
for _, p := range paths {
if len(p) > 0 && (p[0] == '{' && p[len(p)-1] == '}' || p[0] == ':') {
return true
}
}
return false
}
// 转换参数路由 {xx} --> :xx
func (m *method) initPathParams() {
paths := strings.Split(m.Path, "/")
for i, p := range paths {
if p != "" && (p[0] == '{' && p[len(p)-1] == '}' || p[0] == ':') {
paths[i] = ":" + p[1:len(p)-1]
m.PathParams = append(m.PathParams, paths[i][1:])
}
}
m.Path = strings.Join(paths, "/")
}
func (s *service) execute() string {
if s.MethodSet == nil {
s.MethodSet = make(map[string]*method, len(s.Methods))
for _, m := range s.Methods {
m := m // TODO ?
s.MethodSet[m.Name] = m
}
}
buf := new(bytes.Buffer)
tmpl, err := template.New("http").Parse(strings.TrimSpace(tpl))
if err != nil {
panic(err)
}
if err := tmpl.Execute(buf, s); err != nil {
panic(err)
}
return buf.String()
}
4.生成可执行文件
入口方法:
func main() {
flag.Parse()
var flags flag.FlagSet
protogen.Options{
ParamFunc: flags.Set,
}.Run(func(gen *protogen.Plugin) error {
gen.SupportedFeatures = uint64(pluginpb.CodeGeneratorResponse_FEATURE_PROTO3_OPTIONAL)
for _, f := range gen.Files {
if !f.Generate {
continue
}
// 这里能看到,protogen 会把解析好的 proto文件对象,传递过来。
generator.GenerateFile(gen, f)
}
return nil
})
}
如何把 自定义的插件 放入 protoc命令中。这里还需要理解下protoc的命令的执行方式。
当输入protoc --help后,会发现命令中并没有熟悉的 grpc 相关参数:
protoc --help
Usage: protoc [OPTION] PROTO_FILES
Parse PROTO_FILES and generate output based on the options given:
-IPATH, --proto_path=PATH Specify the directory in which to search for
imports. May be specified multiple times;
directories will be searched in order. If not
given, the current working directory is used.
If not found in any of the these directories,
the --descriptor_set_in descriptors will be
checked for required proto file.
--version Show version info and exit.
-h, --help Show this text and exit.
--encode=MESSAGE_TYPE Read a text-format message of the given type
from standard input and write it in binary
to standard output. The message type must
be defined in PROTO_FILES or their imports.
--deterministic_output When using --encode, ensure map fields are
deterministically ordered. Note that this order
is not canonical, and changes across builds or
releases of protoc.
未列举完。。。
当输入 protoc --go_out=. --go-grpc_out=. api.proto 后,为什么protoc没报错,而且还能生成 对应的 rpc和grpc的go的代码。
这个涉及到protoc的插件原理,在go/bin中我们有安装过两个插件,在安装protobuf时候,也会一并安装:
protoc-gen-go
protoc-gen-go-grpc
这是因为 protoc 在发现未指定的参数时候,会去bin下找对应的插件,比如--go_out 会取第一个单词,go去找protoc-gen-go,对应 --go-grpc_out , 会去找go-grpc 对应的插件 protoc-gen-go-grpc
所以,只要把插件编译好后,放入bin中,并且把 参数和插件名对应上就可以。
这里把插件名叫 protoc-gen-gin,对应的命令中的参数为--gin_out。
protoc --proto_path=. --proto_path=../../third_party --go_out=. --go-grpc_out=. --gin_out=. api.proto
--proto_path=../../third_party
这里是指定 annotations.proto 文件的位置,不然,proto文件不能识别里面定义的 http相关代码。
最终生成的文件:
5.待开发的功能
-
对参数进行定制验证规则,例如
PassWord string
form:"password" json:"password" binding:"required,min=3,max=20"`` -
对接swagger自动生成接口文档。
工具源码地址:
https://github.com/shinyYangYang/autoGenerateGinByProto
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享4款.NET开源、免费、实用的商城系统
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
· 上周热点回顾(2.24-3.2)