使用protobuf validate自定义报错内容
使用validate
限制输入
在一些http
框架中,都可以直接引入validate
,对入参进行限制和校验,例如gin
框架
gin
要将请求正文绑定到类型中,请使用模型绑定。我们目前支持 JSON、XML、YAML 和标准表格值(foo=bar&boo=baz)的绑定。
Gin 使用 go-playground/validator/v10 进行验证。点击此处查看有关标签用法的完整文档。
请注意,您需要在所有要绑定的字段上设置相应的绑定标签。例如,从 JSON 绑定时,设置 json: "fieldname"
。
此外,Gin 还提供了两套绑定方法:
- Type - Must bind
- 方法 -
Bind
,BindJSON
,BindXML
,BindQuery
,BindYAML
- 表现 - 这些方法在
gin.context
下使用MustBindWith
。如果出现绑定错误,请求将通过c.AbortWithError(400, err).SetType(ErrorTypeBind)
中止。这会将响应状态代码设置为400
,并将Content-Type
标头设置为text/plain;charset=utf-8
。请注意,如果在此之后尝试设置响应代码,将会出现警告[GIN-debug] [WARNING] Headers were already written.
想用422
覆盖状态代码400
。如果您希望对行为有更大的控制权,请考虑使用ShouldBind
同等方法。
- 方法 -
- Type - Should bind
- 方法 -
ShouldBind
,ShouldBindJSON
,ShouldBindXML
,ShouldBindQuery
,ShouldBindYAML
- 表现 - 这些方法在
gin.context
下使用ShouldBindWith
。如果出现绑定错误,则会返回错误信息,开发人员有责任妥善处理请求和错误。
- 方法 -
使用绑定方法时,Gin
会根据 Content-Type
标头来推断绑定内容。如果确定要绑定的内容,可以使用 MustBindWith
或 ShouldBindWith
。
您还可以指定特定字段为必填字段。如果某个字段使用 binding: "required"
修饰,但在绑定时值为空,则会返回错误信息。
1 | // Binding from JSON |
请求示例
1 | curl -v -X POST \ |
跳过验证
使用上述 curl
命令运行上述示例时,返回错误。因为示例中的密码使用了 binding: "required"
。如果对密码使用 binding:"-"
,再次运行上述示例时就不会返回错误。
但是现在普遍使用微服务架构,通过protocol buffer
,使用RPC
或者gRPC
框架实现服务之间交互,这种情况下,参数传递也需要实现参数校验。
protoc-gen-validate
(PGV)
PGV
是一个用于生成多语言消息验证器的 protoc
插件。虽然protocol buffers
能有效保证结构化数据的类型,但它们无法执行值的语义规则。该插件为 protoc-generated
代码添加了验证此类约束的支持。
开发人员导入 PGV
扩展,并在他们的 proto
文件中用约束规则注释消息和字段:
1 | syntax = "proto3"; |
使用 PGV 和目标语言的默认插件执行 protoc 会在生成的类型上创建验证方法:
1 | p := new(Person) |
使用方法
前提:
- go 工具链(≥ v1.7)
- 在
$PATH
中的protoc
编译器 - 在
$PATH
中的protoc-gen-validate
- 目标语言的官方特定语言插件
- 目前仅支持
proto3
语法,计划支持proto2
语法。
安装
从GitHub发布下载
从 GitHub Releases,然后将插件添加到 $PATH
。
从源代码构建
1 | fetches this repo into $GOPATH |
💡 Yes, our go module path is
github.com/envoyproxy/protoc-gen-validate
notbufbuild
this is intentional.Changing the module path is effectively creating a new, independent module. We would prefer not to break our users. The Go team are working on better
cmd/go
support for modules that change paths, but progress is slow. Until then, we will continue to use theenvoyproxy
module path.
1 | git clone github.com/bufbuild/protoc-gen-validate |
参数
lang
:指定要生成的目标语言。目前,仅支持以下选项go
- c++ 的
cc
(已部分实现) java
- 注意:
Python
通过运行时代码生成工作。没有编译时生成。详见 Python 部分。
示例
Go
Go 生成应与官方插件的输出路径相同。对于原语文件 example.proto
,相应的验证代码会生成到 ../generated/example.pb.validate.go
:
1 | protoc \ |
生成的所有信息都包括以下方法:
Validate()
错误会返回验证过程中遇到的第一个错误。ValidateAll()
错误,返回验证过程中遇到的所有错误。
PGV
不需要现有生成代码的额外运行时依赖性。
注意:默认情况下,example.pb.validate.go
嵌套在与您的 go_package
选项名称相匹配的目录结构中。您可以使用 protoc
参数 paths=source_relative:.../generated
来更改,如 --validate_out="lang=go,paths=source_relative:.../generated"
。然后,--validate_out
将在预期的位置输出文件。更多信息,请参阅 Google
的 protobuf
文档或软件包和输入路径或参数。
描述对 module=example.com/foo
标记的支持。
在较新的 Buf CLI
版本(>v1.9.0
)中,可以使用新的插件密钥来代替直接使用 protoc
命令:
1 | # buf.gen.yaml |
1 | # proto/buf.yaml |
更多语言:
- Java
- Python
更多约束:Constraint Rules
拦截器
对于这类相同的报错,可以直接使用中间件,不需要在代码层面对每一次调用进行错误判断处理
中间件包:https://github.com/grpc-ecosystem/go-grpc-middleware
1 | grpcSrv := grpc.NewServer( |
validate中间件包:https://github.com/grpc-ecosystem/go-grpc-middleware/tree/main/interceptors/validator
示例服务端代码,中间件引入
1 | grpcServer := grpc.NewServer( |
一元拦截器
1 | func UnaryServerInterceptor(opts ...Option) grpc.UnaryServerInterceptor { |
也可以自己写专属自己的拦截器。
Next Generation
📣更新:新一代
protoc-gen-validate
(现称为 protovalidate)已发布测试版,适用于Golang
、Python
、Java
和C++
!我们还在努力开发TypeScript
实现。要了解更多信息,请查看我们的博文。我们非常重视您在完善我们产品方面的意见和建议,因此请随时在protovalidate.**
上分享您的反馈意见。
protovalidate
https://github.com/bufbuild/protovalidate
protovalidate
是一系列库,旨在根据用户定义的验证规则在运行时验证 Protobuf
消息。它由谷歌通用表达式语言(CEL
)提供支持,为定义和评估自定义验证规则提供了灵活高效的基础。protovalidate
的主要目标是帮助开发人员确保整个网络的数据一致性和完整性,而不需要生成代码。
protovalidate 是 protoc-gen-validate 的精神继承者。
用法
导入 protovalidate
要在 Protobuf 消息中定义约束条件,请在 .proto
文件中导入 buf/validate/validate.proto
:
1 | syntax = "proto3"; |
通过 buf
构建
在模块的 buf.yaml
中添加对 buf.build/bufbuild/protovalidate
的依赖:
1 | version: v1 |
修改完 buf.yaml
后,别忘了运行 buf mod update
,以确保您的依赖项是最新的。
通过protoc
构建
在调用 protoc
时,添加指向 proto/protovalidate
目录内容的导入路径(-I 标志):
1 | protoc \ |
可以看到,与PGV
不同的是,PGV
通过protoc
会生成单独的go
代码文件,而protovalidate
不会。
实施验证约束
可以使用 buf.validate
Protobuf 软件包强制执行验证约束。规则直接在 .proto
文件中指定。
例如:
String
字段验证:对于基本的User
信息,我们可以强制执行一些限制条件,如用户姓名的最小长度。1
2
3
4
5
6
7
8syntax = "proto3";
import "buf/validate/validate.proto";
message User {
// User's name, must be at least 1 character long.
string name = 1 [(buf.validate.field).string.min_len = 1];
}Map
字段验证:对于带有Product
数量映射的产品信息,我们可以确保所有数量都是正数。1
2
3
4
5
6
7
8
9syntax = "proto3";
import "google/protobuf/timestamp.proto";
import "buf/validate/validate.proto";
message User {
// User's creation date must be in the past.
google.protobuf.Timestamp created_at = 1 [(buf.validate.field).timestamp.lt_now = true];
}常用类型 (WKT) 验证:对于
User
信息,我们可以添加一个约束条件,以确保created_at
时间戳在过去。1
2
3
4
5
6
7
8
9syntax = "proto3";
import "google/protobuf/timestamp.proto";
import "buf/validate/validate.proto";
message User {
// User's creation date must be in the past.
google.protobuf.Timestamp created_at = 1 [(buf.validate.field).timestamp.lt_now = true];
}
对于更高级或自定义的限制,protovalidate
允许使用 CEL
表达式,将信息整合到各个字段中。
字段级表达式:我们可以强制要求以字符串形式发送的产品
price
包含"$"
或"£"
等货币符号。我们要确保价格为正数,货币符号有效。1
2
3
4
5
6
7
8
9
10
11syntax = "proto3";
import "buf/validate/validate.proto";
message Product {
string price = 1 [(buf.validate.field).cel = {
id: "product.price",
message: "Price must be positive and include a valid currency symbol ($ or £)",
expression: "(this.startsWith('$') || this.startsWith('£')) && double(this.substring(1)) > 0"
}];
}报文级表达式:对于
Transaction
消息,我们可以使用消息级CEL
表达式来确保delivery_date
总是在purchase_date
之后。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15syntax = "proto3";
import "google/protobuf/timestamp.proto";
import "buf/validate/validate.proto";
message Transaction {
google.protobuf.Timestamp purchase_date = 1;
google.protobuf.Timestamp delivery_date = 2;
option (buf.validate.message).cel = {
id: "transaction.delivery_date",
message: "Delivery date must be after purchase date",
expression: "this.delivery_date > this.purchase_date"
};
}在表达式中生成错误信息:我们可以直接在
CEL
表达式中生成自定义错误信息。在本例中,如果age
小于 18 岁,CEL
表达式将评估为错误信息字符串。1
2
3
4
5
6
7
8
9
10syntax = "proto3";
import "buf/validate/validate.proto";
message User {
int32 age = 1 [(buf.validate.field).cel = {
id: "user.age",
expression: "this.age < 18 ? 'User must be at least 18 years old': ''"
}];
}
验证信息
一旦信息注释了限制条件,就可以使用支持的语言库之一进行验证;无需额外生成代码。使用PGV
相同的中间件代码即可。
文档
protovalidate
为验证 Protobuf
消息提供了一个强大的框架,它可以对各种数据类型执行标准和自定义约束,并在出现验证违规时提供详细的错误信息。要详细了解其所有组件、支持的约束条件以及如何有效使用它们,请参阅的综合文档。主要组件包括
- 标准约束:
protovalidate
支持所有字段类型的各种标准约束,以及Protobuf Wellnown-Type
的特殊功能。你可以将这些约束应用到你的Protobuf
消息中,以确保它们满足某些常见条件。 - 自定义约束:利用
Google
的通用表达式语言 (CEL
),protovalidate
允许您创建复杂的自定义约束,以处理字段和消息级别的标准约束未涵盖的独特验证场景。 - 错误处理:当发生违规时,
protovalidate
会提供详细的错误信息,帮助您快速确定问题来源并进行修复。
protoc-gen-validate
protovalidate
是 protoc-gen-validate
的精神继承者,它提供了与原始插件相同的所有功能,无需自定义代码生成,并具有在 CEL 中描述复杂约束的新能力。
protovalidate
的约束条件与 protoc-gen-validate
中的约束条件非常接近,以确保开发人员能轻松过渡。要从 protoc-gen-validate
迁移到 protovalidate
,请使用提供的迁移工具逐步升级您的.proto
文件。
迁移向导
从 protoc-gen-validate
迁移到 protovalidate
应该是安全、渐进和相对无痛的,但仍然需要进行一些操作才能实现。为了减轻这一负担,我们提供了本文档和迁移工具,以简化迁移过程。
迁移工具,可以将将所有热PGV
约束改为使用protovalidate
约束,避免一条一条更改
命令汇总:
1 | // 先保证已经格式化,避免出现一些奇怪的代码 |
更多更详细的用法,可参考官方文档:https://github.com/bufbuild/protovalidate/tree/main/tools/protovalidate-migrate
自定义报错
使用protovalidate
最大的原因是protovalidate
支持自定义报错,可以替代掉PGV的默认报错,具体用法:
例如一个注册服务,需要验证用户输入的用户名和昵称,并且报错由自己自定义,返回给用户更明确的报错
1 | message SignupReq { |
通过自定义的中间件统一返回中文报错
1 | package server |
优势
总结下来,protovalidate
相比PGV
而言,有很多优势:
- 不需要生成额外代码
- CEL通用表示语言有很强大的扩展性,而且带有很多函数,实现逻辑判断
CEL 通用表示语言
通用表达式语言(CEL
)是一种非图灵完备语言,旨在实现简单、快速、安全和可移植性。CEL 类似 C 语言的语法与 C++、Go、Java 和 TypeScript 中的等效表达式几乎完全相同。
这里为了更好的使用protovalidate
,列举一些常用的CEL
表达式
字符串长度
1 | //`len` dictates that the field value must have the specified |
特定字符
1 | //`in` specifies that the field value must be equal to one of the specified |
通过说明可以看到,继承自PGV
的约束,在protovalidate
中,其实是通过CEL
实现,不得不再次感叹Google
在扩展性和兼容性上展现出的强大实力。
更多CEL
示例,可以查看protoc
文件
https://github.com/bufbuild/protovalidate/blob/main/proto/protovalidate/buf/validate/validate.proto
一些实践
由于正则表达式使用 RE2
语法,并且是以 CEL
语言写在一个字符串中,因此,中间会会很多的转义。
例如
账号 由数字/字母/特殊字符(~!@#$%^&*()_+`-={}|[];’:”,./<>?·)组成,最大支持100个字符,不支持空格
1
2
3
4
5
6
7string user_name = 1 [
(buf.validate.field).cel = {
id: "user_name",
message: "账号格式不符合规范",
expression: "this.matches(\"^[\\\\w~!@#\\\\$%\\\\^&\\\\*\\\\(\\\\)_+`\\\\-={}|\\\\[\\\\];':\\\",./<>?·]{1,100}$\")",
}
]; // 账号 由数字/字母/特殊字符(~!@#$%^&*()_+`-={}|[];':",./<>?·)组成,最大支持100个字符,不支持空格用户名、昵称由汉字/数字/字母/特殊字符(~!@#$%^&*()_+`-={}|[];’:”,./<>?·)组成,最大支持100个字符,两边不允许有空格,中间支持空格
1
2
3
4
5
6
7string nick_name = 3 [
(buf.validate.field).cel = {
id: "nick_name",
message: "用户名/昵称格式不符合规范",
expression: "this.matches(\"^[^\\\\s][\\u4e00-\\u9fa5\\\\w~!@#\\\\$%\\\\^&\\\\*\\\\(\\\\)_+`\\\\-={}|\\\\[\\\\];':\\\",./<>?·\\\\s]{1,100}[^\\\\s]$\")",
}
]; // 用户名、昵称由汉字/数字/字母/特殊字符(~!@#$%^&*()_+`-={}|[];':",./<>?·)组成,最大支持100个字符,两边不允许有空格,中间支持空格
上面这两个也是踩了很多坑,取巧的方法是直接询问 ChatGPT
1 | 以下是一个CEL语法的声明 |
1 | 以下是完善后的声明: |
1 | 出现报错 |
1 | 非常抱歉,我之前的回答有一些错误。在正则表达式中,需要对一些特殊字符进行转义。以下是修正后的声明: |
。。。
其实 ChatGPT
的回答也不是完全正确,还需要进一步调试。但是使用 ChatGPT
确实是一个很好的解决思路。
推荐阅读
Model binding and validation
Go gRPC Middleware
protovalidate
protoc-gen-validate
protovalidate-go