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

注:本文已发布超过一年,请注意您所使用工具的相关版本是否适用

本系列为 Go 进阶训练营 笔记,访问 博客: Go进阶训练营, 即可查看当前更新进度,部分文章篇幅较长,使用 PC 大屏浏览体验更佳。

3 月进度: 02/15 3 月开始会尝试爆更模式,争取做到两天更新一篇文章,如果感兴趣可以拉到文章最下方获取关注方式。

在官方的提供的 protobuf 代码生成器当中仅支持生成 grpc 的代码,但是我们团队现状是大部分的项目都是使用 http1 类似 restful 的方式进行通信的,目前公司内部 gRPC 相关基础设施建设的还不完全,例如跨区域路由,服务注册等等原因导致我们没有办法向 gRPC 进行迁移,但是我们又想要有前面提到的各种好处,考虑到后面还是会进行 RPC 的迁移,又不想在代码里面写两套相同逻辑 service 层代码那该怎么办?

毛老师在课程上介绍了 kratos v2 的方法,自己写一个 protoc 插件,在代码生成的时候生成相关代码,只要我们生成的 server 接口和 gRPC 保持一致,或者接入其他的 RPC 服务,只需要接口保持一致那么我们的 service 层代码无需修改任何代码,就可以支持多种协议,做到了“框架只是细节”。

虽然框架只是细节,但是我们之前一直使用的 gin 作为 web 框架,kratos 使用的是 mux 作为路由框架,所以我们如果直接使用 kratos 的插件会导致很多中间件都需要做重构,这样影响比较大,并且也算是为了积累一些经验,因为除了生成 server 的代码,我们还需要同时配合公司内部的网关生成 client 端的代码,解决之前每个项目都需要自己手写 sdk 的问题。

接下来我们就一起来从借鉴开始实现一个生成 gin 的 http server 代码。

注: 本文代码可以在 [mohuishou/protoc-gen-go-gin](https://github.com/mohuishou/protoc-gen-go-gin) 中找到,如果你有类似的需求可以直接使用

方案设计

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

gin example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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 定义,一般就是包含一个参数和一个返回值的函数

1
2
3
4
5
// The greeting service definition.
service Greeter {
// Sends a greeting
rpc SayHello (HelloRequest) returns (HelloReply) {}
}

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

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

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

概要方案

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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

1
2
3
4
5
6
7
8
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

1
2
3
4
5
6
7
8
9
10
11
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 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 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 方法生成相关代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 后面都是 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 方法生成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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 的信息,如果没有就根据方法名生成默认的路由

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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 中生成路由

1
2
3
4
5
6
7
8
9
10
11
12
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
}

生成默认的路由

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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](https://github.com/mohuishou/protoc-gen-go-gin) 中找到
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
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 文件之后我们只需要执行命令

1
2
3
4
5
6
7
8
9
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

关注我获取更新

wechat
知乎
github

猜你喜欢