protoc-gen-validate
礼物说本文诣在让读者了解protoc-gen-validate的前世今生,以及如何开发一个protoc-gen-xxx的插件
在刚入职我司的时候,做过一个技术项目:为部门开源框架实现一个轻量级的proto校验规则,Pull request在此。当时的方式是直接合入了开源框架中,现在回想起来还有些许遗憾:
- 未做成插件形式(protoc-gen-xxx),将插件开源
- 未完善测试用例
- 没有一份完整的使用文档/描述文档
正好借此机会温故知新,回忆下当时的心路历程,补全遗憾。
背景
当时了解过一些开源插件,比如:
- proto-gen-validate 使用方式是 需要
import validate/validate.proto
然后通过option去添加规则.
"proto3";
syntax =
package examplepb;
import "validate/validate.proto";
message Person {
uint64 id = 1 [(validate.rules).uint64.gt = 999];
}
- gogo 使用方式是 需要
github.com/gogo/protobuf/gogoproto/gogo.proto
然后通过option去添加规则.
"proto3";
syntax =
package examplepb;
import "github.com/gogo/protobuf/gogoproto/gogo.proto";
message Person {
int64 uid = 2 [(gogoproto.moretags) = "validate:\"gt=0,required\""];
}
他们都有一些相同的劣势:
- 需要引入外部包,有一定的侵入性(对方可能并不是使用gogo,而因为要对接,他们不得不引入这个包)
- 规则复杂冗余,有一定的学习成本,且放在option中单行会很长
基于此我们期望自己实现一个protoc-genvalidate插件,目标为:
- 制定一套简单清晰的参数校验规则
- 自动生成逻辑,业务无需操作
- 禁止在运行时使用反射
- 使用注解实现
过程
前置知识
我们需要先了解proto插件是如何运作的。
当你执行proptc --go_out=. test.proto
时,它会将proto文件的结构发给protoc-gen-go
可执行文件。本质上其实是会根据--xxx_out
去寻找protoc-gen-xxx
。同时我们可以配合使用google.golang.org/protobuf/compiler/protogen
google自带的proto解析包去获取我们整个proto的结构树。
基于此,我们可以先生成一个简单的demo:protoc-gen-validate
插件打印整个proto结构树。
插件代码如下:
package main
import (
"fmt"
"google.golang.org/protobuf/compiler/protogen"
)
func main() {
.Options{}.Run(func(plugin *protogen.Plugin) error {
protogen.Println(plugin)
fmtreturn nil
})
}
生成可执行文件:
go install .
生成测试proto文件:
"proto3";
syntax =
package main;
service Test{
rpc Foo(FooReq) returns (FooResp);
}
message FooReq {
// @gt:0
int64 uid = 1;
}
message FooResp {
}
执行命令protoc test.proto --validate_out=.
:
➜ proto protoc test.proto --validate_out=.
--validate_out: protoc-gen-validate: Plugin output is unparseable: &{file_to_generate:\"test.proto\" proto_file:{name:\"test.proto\" package:\"main\" message_type:{name:\"FooReq\" field:{name:\"uid\" number:1 label:LABEL_OPTIONAL
ype:TYPE_INT64 json_name:\"uid\"}} message_type:{name:\"FooResp\"} service:{name:\"Test\" method:{name:\"Foo\" input_type:\".main.FooReq\" output_type:\".main.FooResp\"}} options:{go_package:\"./\"} source_code_info:{location:{sp
n:0 span:0 span:16 span:1} location:{path:12 span:0 span:0 span:18} location:{path:2 span:2 span:0 span:13} location:{path:8 span:4 span:0 span:25} location:{path:8 path:11 span:4 span:0 span:25} location:{path:6 pa
h:0 span:6 span:0 span:8 span:1} location:{path:6 path:0 path:1 span:6 span:8 span:12} location:{path:6 path:0 path:2 path:0 span:7 span:4 span:38} location:{path:6 path:0 path:2 path:0 path:1 span:7 span:8 span:
1} location:{path:6 path:0 path:2 path:0 path:2 span:7 span:12 span:18} location:{path:6 path:0 path:2 path:0 path:3 span:7 span:29 span:36} location:{path:4 path:0 span:10 span:0 span:13 span:1} location:{path:4
ath:0 path:1 span:10 span:8 span:14} location:{path:4 path:0 path:2 path:0 span:12 span:4 span:18 leading_comments:\" @gt:0\\n\"} location:{path:4 path:0 path:2 path:0 path:5 span:12 span:4 span:9} location:{path:4
ath:0 path:2 path:0 path:1 span:12 span:10 span:13} location:{path:4 path:0 path:2 path:0 path:3 span:12 span:16 span:17} location:{path:4 path:1 span:15 span:0 span:16 span:1} location:{path:4 path:1 path:1 span:
5 span:8 span:15}} syntax:\"proto3\"} compiler_version:{major:3 minor:17 patch:3 suffix:\"\"} [0x1400016e900] map[test.proto:0x1400016e900] 0 0x1400000cf48 map[] map[main.FooReq:0x140000010e0 main.FooResp:0x14000001200] false 0 [
{<nil> <nil>} <nil>}\n
我们初步demo成功。并可以发现注释在File.Services.Comments.Leading
字段上。
实现细节
大致里程碑分为下述几点:
- 确认需要支持的规则
- 确认协议方式 避免正常注释被解析
- 如何为每个message生成方法
- 应用到实际项目中
初步拟定需要的功能:
- 数字类型
- 比较方法(eq,gt,lt,gte,lte)
- 是否在数组中(in,not_in)
- 较数学的开闭区间(range)
- 字符串
- 比较方法(eq)
- 字串问题(contains,not_contains,prefix,suffix)
- 是否在数组中(in,not_in)
- 字符串长度(len,min_len,max_len)
- 正则(pattern)
- 常见类型(url,phone,email,ip)
- 数组
- 需要支持循环执行所有的单项规则,
- 数组长度(min_items,max_items)
- 判重(unique)
- 支持嵌套/循环的规则
确认协议:
使用 @
+key
+:
+value
的方式规避正常注射
生成方法: 通过协议我们可以获取到规则的数组kv形式,再通过text/template
包生成模版内容,最后输出到文件中,拿eq
举例:
package rule
const eqTpl = `
if {{ .Key }} != {{ .Value }} {
return {{ .Field.Parent.GoIdent.GoName }}ValidationError {
field: "{{ .Field.GoName }}",
reason: "value must equal {{ escape .Value }}",
}
}
`
更多的内容可以直接查看project
此步完成后,我们已经可以为每个message生成专有的validate方法:
func (m *NumericsReq) validate() error {
if m == nil {
return nil
}
if m.GetA() != 1.23 {
return NumericsReqValidationError{
: "A",
field: "value must equal 1.23",
reason}
}
if m.GetB() >= 20 {
return NumericsReqValidationError{
: "B",
field: "value must less than 20",
reason}
}
if m.GetB() <= 10 {
return NumericsReqValidationError{
: "B",
field: "value must greater than 10",
reason}
}
if m.GetC() > 20 {
return NumericsReqValidationError{
: "C",
field: "value must less than or equal to 20",
reason}
}
if m.GetC() < 10 {
return NumericsReqValidationError{
: "C",
field: "value must greater than or equal to 10",
reason}
}
var NumericsReq_D_In = map[uint32]struct{}{
1: {},
2: {},
3: {},
}
if _, ok := NumericsReq_D_In[m.GetD()]; !ok {
return NumericsReqValidationError{
: "D",
field: "value must be in list [1,2,3]",
reason}
}
var NumericsReq_E_NotIn = map[float32]struct{}{
1: {},
2: {},
3: {},
}
if _, ok := NumericsReq_E_NotIn[m.GetE()]; ok {
return NumericsReqValidationError{
: "E",
field: "value must be not in list [1,2,3]",
reason}
}
if m.GetF() <= 1 || m.GetF() >= 5 {
return NumericsReqValidationError{
: "F",
field: "value must in range (1,5)",
reason}
}
if m.GetG() < 1 || m.GetG() > 5 {
return NumericsReqValidationError{
: "G",
field: "value must in range [1,5]",
reason}
}
return nil
}
我们项目使用的是sniper框架,使用protoc-gen-twirp
生成路由代码,我们增加函数:
func (t *twirp) addValidate(method *protogen.Method, service *protogen.Service) {
if t.ValidateEnable {
.P(` if validerr := reqContent.validate(); validerr != nil {`)
t.P(` s.writeError(ctx, resp, twirp.InvalidArgumentError("argument", validerr.Error()))`)
t.P(` return`)
t.P(` }`)
t}
}
为每个req增加validate校验。基于此我们完成了整个protoc-gen-validate的功能实现
完善测试用例以及使用文档
补充了一波测试用例以及使用文档.让项目看上去明显更沉稳了哈哈。
结果
protoc-gen-validate也顺利推到了github上,欢迎小伙伴们使用以及交流,一起共勉。