惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

量子位
小众软件
小众软件
S
SegmentFault 最新的问题
人人都是产品经理
人人都是产品经理
博客园 - 【当耐特】
博客园 - 三生石上(FineUI控件)
C
Check Point Blog
S
Schneier on Security
Microsoft Azure Blog
Microsoft Azure Blog
N
Netflix TechBlog - Medium
Engineering at Meta
Engineering at Meta
GbyAI
GbyAI
罗磊的独立博客
有赞技术团队
有赞技术团队
V
V2EX
Y
Y Combinator Blog
博客园 - 叶小钗
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
F
Fortinet All Blogs
W
WeLiveSecurity
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
Stack Overflow Blog
Stack Overflow Blog
The Cloudflare Blog
S
Security @ Cisco Blogs
TaoSecurity Blog
TaoSecurity Blog
MyScale Blog
MyScale Blog
Hugging Face - Blog
Hugging Face - Blog
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
www.infosecurity-magazine.com
www.infosecurity-magazine.com
PCI Perspectives
PCI Perspectives
H
Heimdal Security Blog
Schneier on Security
Schneier on Security
Security Latest
Security Latest
AWS News Blog
AWS News Blog
月光博客
月光博客
Security Archives - TechRepublic
Security Archives - TechRepublic
Recent Announcements
Recent Announcements
Google DeepMind News
Google DeepMind News
博客园 - Franky
Cisco Talos Blog
Cisco Talos Blog
T
Threat Research - Cisco Blogs
M
MIT News - Artificial intelligence
T
Troy Hunt's Blog
N
News and Events Feed by Topic
Cloudbric
Cloudbric
Scott Helme
Scott Helme
云风的 BLOG
云风的 BLOG
Attack and Defense Labs
Attack and Defense Labs

Mohuishou

如何实现支持多集群的 Kubernetes Operator? 第三方应用如何调用我们 kubebuilder 生成的自定义资源? Kubernetes 简明教程 k8s job 为何迟迟不能结束? Go 工程化(十一) 如何优雅的写出 repo 层代码 Go 工程化(十) 如何在整洁架构中使用事务? 给博客添加章节目录 使用 Notion Database 管理静态博客文章 一个普通 Go 开发的三年 4. localhost 就一定是 localhost 么? Go可用性(七) 总结: 一张图串联可用性知识点 Go可用性(六) 熔断 10. 总结 9. kubebuilder 进阶: 源码分析 8. kubebuilder 进阶: webhook 7. kubebuilder 进阶: 测试 6. kubebuilder 实战: status & event 5. kubebuilder 实战: CRUD 4. kustomize 简明教程 3. KubeBuilder 简明教程 2. Kind: 如何快速搭建本地 K8s 开发环境? 1. Operator概述: 如何对 Kubernetes 进行扩展 Go可用性(五) 自适应限流 Go可用性(四) 漏桶算法 Go可用性(三) 令牌桶的实现 rate/limt Go可用性(二) 令牌桶原理及使用 Go可用性(一) 隔离设计 Go并发编程(十二) Singleflight Go工程化(九) 项目重构实践 Go工程化(八) 单元测试 Go工程化(七) Go Module Go工程化(六) 配置管理 Go工程化(四) API 设计上: 项目结构 & 设计 Go工程化(三) 依赖注入框架 wire Go工程化(二) 项目目录结构 Go工程化(一) 架构整洁之道阅读笔记 Go并发编程(十一) 总结 Go并发编程(十) 深入理解 Channel Go并发编程(九) 深入理解 Context Go并发编程(八) 深入理解 sync.Once Go并发编程(七) 深入理解 errgroup Go并发编程(六) 深入理解 WaitGroup Go并发编程(五) 深入理解 sync/atomic Go并发编程(四) 深入理解 Mutex Go并发编程(三) data race Go并发编程(二) Go 内存模型 Go并发编程(一) goroutine Go错误处理最佳实践 微服务(二) 服务发现&多租户 微服务(一) 微服务概览 5. 栈下: 深入理解 defer 4. 栈上: 如何实现一个计算器 Go Struct 初始化风格的抉择 3. 数组下: 使用 GDB 调试 Golang 代码 2. 数组上: 深入理解 slice 1. 链表: 深入理解container/list&LRU缓存的实现 Go设计模式24-总结(更新完毕) Go设计模式23-中介模式 Go设计模式22-解释器模式 Go设计模式21-命令模式 Go设计模式20-备忘录模式 Go设计模式19-访问者模式 Go设计模式18-迭代器模式 Go设计模式17-状态模式 Go设计模式16-职责链模式(Gin的中间件实现) Go设计模式15-策略模式 Go模板模式14-模板模式 Go设计模式13-观察者模式(实现简单的EventBus) Go设计模式12-享元模式 Go设计模式11-组合模式 Go设计模式10-门面模式 Go设计模式09-适配器模式 Go设计模式08-装饰器模式 Go设计模式07-桥接模式 Go设计模式06-代理模式(generate实现类似动态代理) Go设计模式05-创建型模式总结 Go设计模式04-原型模式 Go设计模式03-建造者模式 Go设计模式02-工厂模式&DI容器 笔记-让你最快速地改善代码质量的20条编程规范 Go设计模式01-单例模式 一点拙见-如何写好一个技术预研报告? Go Web小技巧(四)在单个仓库中支持多个 go mod 模块 Go Web 小技巧(三)Gin 参数绑定 Go Web 小技巧(二)GORM 使用自定义类型 Go Web 小技巧(一)简化Gin接口代码 善用工具之postman高级用法概述 go generate and ast hexo-next-algolia-search全文搜索 docker镜像瘦身&优化 GORM避坑指南之含关联关系的更新 Github Actions介绍&自动构建Github Pages博客 在blog中内嵌在线PPT 记一次net http内存泄漏 使用TravisCI自动部署Blog 使用Goland调试Go程序 一个十分边缘的gorm的bug Httprouter介绍及源码阅读 Gin源码阅读 从0.1开始
Go工程化(五) API 设计下: 基于 protobuf 自动生成 gin 代码
Mohuishou · 2021-03-03 · via Mohuishou

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

本系列为 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

关注我获取更新

猜你喜欢