Go 自动生成代码工具二 (在proto文件中定义http的接口,并自动生成gin的接口工具)

发布时间 2023-11-27 22:12:43作者: 杨阳的技术博客

一、需求分析

在和前端对接过程中,需要后端维护一份接口文档,对于这份文档的维护在实际工作中会有一系列的问题,例如参数个数、参数类型、返回类型等。

主要还是后期需要一直维护,如果改了接口,忘记维护文档就会导致前端调用异常。

但是当使用 protobuf定义好了接口,微服务相互间调用,一般不会出现这类问题,因为调用双方都要遵守 GRPC自动生成的 _grpc.pd.go;

所以,如果前端人员如果能看懂proto文件内容(难度低),而后端也必须遵守一份类似 _grpc.pd.go的文件中定义的路由接口,就能解决问题。

go-zero也是类似的思想,不过它为api层单独定义一套语法,用于生成文档、测试、go代码等。

这里借鉴的是Kratos,最终要实现的效果:

  1. 在proto文件中定义 gin接口信息,得让proto文件在解析时候,不会报错。
  2. 通过 protoc 命令生成 一份类似 _grpc.pd.go,就叫 _gin.pb.go
  3. 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.待开发的功能

  1. 对参数进行定制验证规则,例如 PassWord string form:"password" json:"password" binding:"required,min=3,max=20"``

  2. 对接swagger自动生成接口文档。

工具源码地址:
https://github.com/shinyYangYang/autoGenerateGinByProto