所有文章 > API设计 > Go工程化(五) API 设计下: 基于 protobuf 自动生成 gin 代码

Go工程化(五) API 设计下: 基于 protobuf 自动生成 gin 代码

方案设计

开始开发之前我们先看一下 gin 的路由是怎么注册的,以及 grpc 生成的接口格式是什么样的

gin example

package main

import "github.com/gin-gonic/gin"

func handler(ctx *gin.Context) {
// get params
params := struct {
Msg string `json:"msg"`
}{}
ctx.BindQuery(&params)

// 业务逻辑

// 返回数据
ctx.JSON(200, gin.H{
"message": params.Msg,
})
}

func main() {
r := gin.Default()
r.GET("/ping", handler)
r.Run() // 监听并在 0.0.0.0:8080 上启动服务
}

这是一个简单的示例,可以发现 gin 注册路由需要一个 func (ctx *gin.Context) 签名的函数,这个函数一般做三件事,获取参数,调用业务逻辑,调用 gin 的方法返回 http response

grpc server interface

先看一下 proto 文件中的 rpc 定义,一般就是包含一个参数和一个返回值的函数

// The greeting service definition.
service Greeter {
// Sends a greeting
rpc SayHello (HelloRequest) returns (HelloReply) {}
}

然后看 grpc 生成的接口,其实和 proto 文件一一对应,只是多了一个 context 和 error

type GreeterServer interface {
// Sends a greeting
SayHello(context.Context, *HelloRequest) (*HelloReply, error)
mustEmbedUnimplementedGreeterServer()
}

所以问题来了,我们让 service 层实现类似 GreeterServer 接口就行了,那我们代码生成器要怎么写才能够应用到 http 上呢?

概要方案

  1. 我们需要从 proto 文件中得知 http method,http path 的信息,这样我们才知道要注册到哪个路由上
    1. "GET", "FIND", "QUERY", "LIST", "SEARCH" –> GET
    2. "POST", "CREATE" –> POST
    3. "PUT", "UPDATE" –> PUT
    4. "DELETE" –> DELETE
    5. 这个可以通过 google/api/annotations.proto 为 rpc 方法添加 Option 实现
    6. 或者是通过函数签名来约定,我们约定方法名使用驼峰方式命名,首个单词是 http method 或者是 http method 的映射,如果都不是默认采用 post
  2. 我们需要构建 func handler(ctx *gin.Context) 函数用于注册路由
    1. 函数内需要处理参数,用于调用 service 层的代码
    2. 调用 service 层的代码结束之后,将返回值调用 gin 相关方法返回

所以我们最后生成的代码大概应该是这样的:

type GreeterService struct {
server GreeterHTTPServer
router gin.IRouter
}

// 生成的 gin.HandlerFunc
// 由于 HandlerFunc 签名的限制,不能从参数传递 service 接口进来
// 所以我们使用一个 Struct 托管 service 数据
func (s *GreeterService) SayHello(ctx *gin.Context) {
var in HelloRequest

if err := ctx.ShouldBindJSON(∈); err != nil {
// 返回参数错误
return
}

// 调用业务逻辑
out, err := s.server.(GreeterHTTPServer).SayHello(ctx, ∈)
if err != nil {
// 返回错误结果
return
}

// 返回成功结果
ctx.JSON(200, out)
return
}

// 路由注册,首先需要 gin.IRouter 接口用于注册
// 其次需要获取到 SayHello 方法对应的 http method 和 path
func (s *GreeterService) RegisterService() {
s.router.Handle("GET", "/hello", s.SayHello)
}

代码实现

理论可行,开始干活,代码从 kratos v2 相关代码修改而来,文章篇幅有限,这里只贴关键代码,完整可执行代码请访问 mohuishou/protoc-gen-go-gin

「如果对实现不感兴趣可以直接跳到最后一个部分查看使用示例,看最终的效果」

大致流程: 获取所有的 proto 文件 –> 获取 proto 文件中的所有 service 信息 –> 获取 service 中的所有 method 信息

proto 文件

syntax = "proto3";

option go_package = "github.com/mohuishou/protoc-gen-go-gin/example/testproto;testproto";

package testproto;

import "google/api/annotations.proto";

// blog service is a blog demo
service BlogService {
// 方法名 action+resource
rpc GetArticles(GetArticlesReq) returns (GetArticlesResp) {
// 添加 option 用于指定 http 的路由和方法
option (google.api.http) = {
get: "/v1/articles"

    // 可以通过添加 additional_bindings 一个 rpc method 对应多个 http 路由
additional_bindings {
get: "/v1/author/{author_id}/articles"
}
};
}
}

需要获取的信息

「service info」

type service struct {
Name     string // Greeter
FullName string // helloworld.Greeter
FilePath string // api/helloworld/helloworld.proto

Methods   []*method
MethodSet map[string]*method
}

为什么需要 Methods 和 MethodSet,因为可能存在多个 HTTP 请求对应一个 RPC Method,这也是下面的 method 结构中包含了一个 num 字段的原因

「method info」

type method struct {
Name   string // SayHello
Num     int   // 一个 rpc 方法可以对应多个 http 请求
Request string // SayHelloReq
Reply   string // SayHelloResp
// http_rule
Path         string // 路由
Method       string // HTTP Method
Body         string
ResponseBody string
}

获取所有的 proto 文件

// main.go
func main() {
// ...

options := protogen.Options{
ParamFunc: flags.Set,
}

options.Run(func(gen *protogen.Plugin) error {
// ...
for _, f := range gen.Files {
  if !f.Generate {
  continue
  }
  generateFile(gen, f)
}
return nil
})
}

生成单个 proto 文件中的内容

这一部分的逻辑主要是生成文件的包名,以及将需要导入的第三方库,例如 gin 之类的导入到其中 然后循环调用 genService 方法生成相关代码

// 后面都是 gin.go 的内容

// generateFile generates a _gin.pb.go file.
func generateFile(gen *protogen.Plugin, file *protogen.File) *protogen.GeneratedFile {
// 如果不存在 service 就直接跳过了,我们主要生成 service 的接口
  if len(file.Services) == 0 {
return nil
}

filename := file.GeneratedFilenamePrefix + "_gin.pb.go"
g := gen.NewGeneratedFile(filename, file.GoImportPath)
g.P("// Code generated by github.com/mohuishou/protoc-gen-go-gin. DO NOT EDIT.")
g.P()
g.P("package ", file.GoPackageName)
g.P()
g.P("// This is a compile-time assertion to ensure that this generated file")
g.P("// is compatible with the mohuishou/protoc-gen-go-gin package it is being compiled against.")
g.P("// ", contextPkg.Ident(""), metadataPkg.Ident(""))
g.P("//", ginPkg.Ident(""), errPkg.Ident(""))
g.P()

for _, service := range file.Services {
genService(gen, file, g, service)
}
return g
}

获取 service 相关信息

这一部分主要是利用 protogen.Service 的信息构建 service 结构,然后循环调用 genMethod 方法生成

func genService(gen *protogen.Plugin, file *protogen.File, g *protogen.GeneratedFile, s *protogen.Service) {
if s.Desc.Options().(*descriptorpb.ServiceOptions).GetDeprecated() {
g.P("//")
g.P(deprecationComment)
}
// HTTP Server.
sd := &service{
Name:     s.GoName,
FullName: string(s.Desc.FullName()),
FilePath: file.Desc.Path(),
}

for _, method := range s.Methods {
sd.Methods = append(sd.Methods, genMethod(method)...)
}
g.P(sd.execute())
}

获取 rpc 方法的相关信息

我们通过 proto.GetExtension 获取之前在 rpc method 设置的 option 信息,如果存在那么就从 option 获取路由和 method 的信息,如果没有就根据方法名生成默认的路由

func genMethod(m *protogen.Method) []*method {
var methods []*method

// 存在 http rule 配置
rule, ok := proto.GetExtension(m.Desc.Options(), annotations.E_Http).(*annotations.HttpRule)
if rule != nil && ok {
for _, bind := range rule.AdditionalBindings {
  methods = append(methods, buildHTTPRule(m, bind))
}
methods = append(methods, buildHTTPRule(m, rule))
return methods
}

// 不存在走默认流程
methods = append(methods, defaultMethod(m))
return methods
}

从 option 中生成路由

func buildHTTPRule(m *protogen.Method, rule *annotations.HttpRule) *method {
// ....

switch pattern := rule.Pattern.(type) {
case *annotations.HttpRule_Get:
path = pattern.Get
method = "GET"
// ... 其他映射
}
md := buildMethodDesc(m, method, path)
return md
}

生成默认的路由

func defaultMethod(m *protogen.Method) *method {
// 分割方法名
names := strings.Split(toSnakeCase(m.GoName), "_")
// ...

// 如果 http method 映射成功,那么路由就是 names[1:]
// 如果没有映射成功路由就是 names
switch strings.ToUpper(names[0]) {
case http.MethodGet, "FIND", "QUERY", "LIST", "SEARCH":
httpMethod = http.MethodGet
// ... 其他方法映射
default:
httpMethod = "POST"
paths = names
}

// ...

md := buildMethodDesc(m, httpMethod, path)
return md
}

最后我们在使用 go template 生成最开始方案中文件即可,代码有点多了这里就不贴了,可以在 https://github.com/mohuishou/protoc-gen-go-gin 中找到

使用案例

案例完整源代码可以在 https://github.com/mohuishou/protoc-gen-go-gin 中找到

syntax = "proto3";

option go_package = "github.com/mohuishou/protoc-gen-go-gin/example/api/product/app/v1";

package product.app.v1;

import "google/api/annotations.proto";

// blog service is a blog demo
service BlogService {
rpc GetArticles(GetArticlesReq) returns (GetArticlesResp) {
option (google.api.http) = {
get: "/v1/articles"
additional_bindings {
get: "/v1/author/{author_id}/articles"
}
};
}

rpc CreateArticle(Article) returns (Article) {
option (google.api.http) = {
post: "/v1/author/{author_id}/articles"
};
}
}

message GetArticlesReq {
// @inject_tag: form:"title"
string title = 1;

// @inject_tag: form:"page"
int32 page = 2;

// @inject_tag: form:"page_size"
int32 page_size = 3;

// @inject_tag: form:"author_id" uri:"author_id"
int32 author_id = 4;
}

message GetArticlesResp {
int64 total = 1;
repeated Article articles = 2;
}

message Article {
string title = 1;
string content = 2;
// @inject_tag: form:"author_id" uri:"author_id"
int32 author_id = 3;
}

注意 @inject_tag: 的注释是使用了 protoc-go-inject-tag 插件用来附加额外的 Struct tags,protoc-gen-go 目前暂时不支持添加 tag

定义好 proto 文件之后我们只需要执行命令

 protoc -I ./example/api \
# 这个是用来生成 swagger json 文件的,我们很多系统支持导入 swagger 的定义,生成这个方便导入
--openapiv2_out ./example/api --openapiv2_opt logtostderr=true --openapiv2_opt json_names_for_fields=false \
# 生成对应的 go 文件
--go_out ./example/api --go_opt=paths=source_relative \
# 生成本文插件中的 gin 文件
--go-gin_out ./example/api --go-gin_opt=paths=source_relative \
example/api/product/app/v1/v1.proto
protoc-go-inject-tag -input=./example/api/product/app/v1/v1.pb.go

总结

api 设计这部分就先到这里了,下一篇文章我们一起看看配置管理

参考文献

  1. Go 进阶训练营-极客时间
  2. Go 工程化(二) 项目目录结构
  3. https://github.com/go-kratos/kratos
  4. https://github.com/golang/protobuf/blob/master/protoc-gen-go/main.go

文章转自微信公众号@mohuishou

#你可能也喜欢这些API文章!