2024年在线市场平台的11大最佳支付解决方案
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(¶ms)
// 业务逻辑
// 返回数据
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 上呢?
概要方案
- 我们需要从 proto 文件中得知 http method,http path 的信息,这样我们才知道要注册到哪个路由上
- 我们需要构建
func handler(ctx *gin.Context)
函数用于注册路由- 函数内需要处理参数,用于调用 service 层的代码
- 调用 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 设计这部分就先到这里了,下一篇文章我们一起看看配置管理
参考文献
- Go 进阶训练营-极客时间
- Go 工程化(二) 项目目录结构
- https://github.com/go-kratos/kratos
- https://github.com/golang/protobuf/blob/master/protoc-gen-go/main.go
文章转自微信公众号@mohuishou