Quarkus 集成 Validation 参数验证

官方文档: validation

Quarkus 集成 Jakarta Validation(原 Bean Validation) 能优雅地完成请求参数和方法入参/返回值的合法性校验, 无需手写大量逻辑.

除了挂载 Rest 相关组件需要引入以下依赖:

1
2
3
4
5
6
7
8
<!-- 其他略 -->
<dependencies>
<!-- 核心 Validation 依赖 -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-hibernate-validator</artifactId>
</dependency>
</dependencies>

官方简单的例子, 需要先定义验证结构体, 这里推荐采用高版本的 record 特性, 能节省大量编写样板代码:

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
import jakarta.validation.constraints.*;

import java.util.Collections;
import java.util.Map;
import java.util.Objects;

/**
* 测试请求参数
*
* @param appid
* @param username 正则:用户名只能以字母开头,可包含字母、数字、下划线,且不能为纯数字/纯下划线
* @param mail 注意: 默认允许空, 也就是要么传递空字符串, 要么传递 xxx@xxx 邮件格式, 如果要强制不允许空就要设置 @NotBlank
* @param extras 传递 {"aaa":"bbb"} 对象组
*/
public record ParamRequest(

@NotNull(message = "appid is required")
@Min(value = 1, message = "failed by appid")
Long appid,

@NotBlank(message = "username is empty")
@Pattern(regexp = "^[a-zA-Z](?!.*?_+$)(?!.*?\\\\d+$)[a-zA-Z0-9_]{3,19}$")
String username,

@Email
String mail,

Map<String, String> extras

) {

/**
* 默认构造方法, 对于 record 结构必须采用全参数构造方法, 内部用 is-null 判断
*/
public ParamRequest(Long appid, String username, String mail, Map<String, String> extras) {
this.appid = appid;
this.username = username;
this.mail = mail;
this.extras = Objects.isNull(extras) ? Collections.emptyMap() : extras;
}
}

这里用到 quarkus-rest 设计验证, 这里面有两种参数验证方式:

  • @Valid: Jakarta 原生注解, 支持基本的验证功能

  • @Validated: 扩展的验证特性, 包括 SpringBoot 也支持的扩展, 用于定制高级的扩展功能

最终测试例子如下, 注意这里可能会报错, 可以先把这些异常错误放着后面说明:

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
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;

/**
* 首页验证
*/
@Path("/")
@Consumes({MediaType.APPLICATION_JSON})
@Produces({MediaType.APPLICATION_JSON})
public class IndexController {

/**
* 请求参数
*
* @param request 请求结构
* @return 响应内容
*/
@POST
public String v1(@NotNull @Valid ParamRequest request) {
// 注意: @Valid 结构是允许 null, 也就是不传递参数
// 所以对于这里的 @Valid 结构对象也需要做 @NotNull 验证
return request.toString();
}
}

这里测试模拟 POST 请求数据:

1
2
3
4
5
# 我这边采用监听端口为 9099
curl -X POST -d "{}" -H "Content-Type: application/json" http://localhost:9099
# 请求之后可以看到内部抛出异常:
# 2025-12-12 02:52:35,111 INFO [io.clo.ser.api.con.ConstraintViolationExceptionMapper] (executor-thread-1) Params Exception: v1.request.username: username is empty, v1.request.appid: appid is required
# 注意这个异常 ConstraintViolationExceptionMapper, 后续需要处理的就是拦截这个异常

注意: 这里可能会异常没办法解析出结构体, 这是因为 record 是高级特性, 需要额外引入组件依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!-- 其他略 -->
<dependencies>

<!-- Quarkus RestJSON 依赖, 支持高级的 record 特性 -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-jackson</artifactId>
</dependency>

<!-- 字段验证 -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-hibernate-validator</artifactId>
</dependency>
</dependencies>

quarkus-hibernate-validator 涉及验证注解如下:

注解 适用数据类型 核心校验规则 典型应用场景
@NotNull 所有类型(对象/基本类型包装类) 校验对象不能为 null(对空字符串 ""、空集合、0 等不生效) 1. 实体主键(如 id)、关联对象(如 user
2. 非空的数值型参数(如 age price
3. 非空的布尔值(如 isVip
@NotBlank 仅 String 类型 校验字符串不能为 null + 去除首尾空格后长度 > 0(即非空且非空白) 1. 用户名、密码、手机号、邮箱(需实际字符)
2. 订单备注、用户昵称(不允许纯空格)
3. 接口查询的关键词参数
@NotEmpty String/Collection/Map/数组 1. 字符串:非 null + 长度 > 0(不去除空格)
2. 集合/数组:非 null + 元素个数 > 0
1. 字符串(允许首尾空格,如地址 address
2. 集合(如 roles 角色列表、ids 批量ID数组)
3. Map(如请求参数 params
@Null 所有类型 校验对象必须为 null(极少用,多用于特殊业务规则) 1. 新增场景下强制 id 为 null(防止手动传ID)
2. 特定状态下某字段必须为空
@Min 数值型(byte/short/int/long/浮点型/大数)、String(可转数字) 校验数值大于等于指定最小值 1. 年龄(@Min(18))、商品库存(@Min(0)
2. 订单金额(@Min(0.01))、用户等级(@Min(1)
@Max @Min 校验数值小于等于指定最大值 1. 年龄(@Max(120))、商品限购数量(@Max(10)
2. 接口分页大小(@Max(100))、金额上限(@Max(999999.99)
@DecimalMin @Min(更精准的小数控制) 校验数值≥指定最小值(支持指定 inclusive=false 表示「大于」) 1. 支付金额(@DecimalMin(value = "0.01", inclusive = true)
2. 利率(@DecimalMin("0.001")
@DecimalMax @DecimalMin 校验数值≤指定最大值(支持 inclusive=false 表示「小于」) 1. 折扣率(@DecimalMax("1.0", inclusive = true)
2. 税率(@DecimalMax("0.25", inclusive = true)
@Positive 数值型(同 @Min 校验数值严格大于 0(不包含 0) 1. 商品单价(@Positive)、提现金额(@Positive
2. 积分兑换数量(@Positive
@PositiveOrZero 数值型(同 @Min 校验数值大于等于 0(包含 0) 1. 商品销量(@PositiveOrZero)、退款金额(@PositiveOrZero
2. 库存余量(@PositiveOrZero
@Negative 数值型(同 @Min 校验数值严格小于 0(不包含 0) 1. 账户欠费金额(@Negative)、扣减积分(@Negative
@NegativeOrZero 数值型(同 @Min 校验数值小于等于 0(包含 0) 1. 退款金额(反向记账,@NegativeOrZero)、积分扣减上限(@NegativeOrZero
@Digits 数值型/字符串(可转数字) 校验数字的整数位+小数位总长度不超过指定值
格式:@Digits(integer=整数位, fraction=小数位)
1. 手机号(@Digits(integer=11, fraction=0)
2. 金额(@Digits(integer=8, fraction=2),如最大 99999999.99)
@Size String/Collection/Map/数组 1. 字符串:长度在 min-max 之间
2. 集合/数组:元素个数在 min-max 之间
1. 用户名长度(@Size(min=4, max=20)
2. 角色列表(@Size(min=1, max=5)
3. 密码长度(@Size(min=8, max=32)
@Pattern 仅 String 类型 字符串匹配指定的正则表达式 1. 手机号(@Pattern(regexp="^1[3-9]\\d{9}$")
2. 邮箱(@Pattern(regexp="^\\w+@\\w+\\.\\w+$")
3. 用户名格式(字母+数字+下划线)
@Email 仅 String 类型 校验字符串为合法邮箱格式(支持 regexp 自定义正则) 1. 用户邮箱(@Email(message="邮箱格式错误")
2. 联系邮箱(@Email(regexp="^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$")
@Future Date/Calendar/Instant/LocalDate 等日期类型 校验日期晚于当前系统时间 1. 预约时间(@Future)、活动结束时间(@Future
2. 过期时间(@Future
@FutureOrPresent @Future 校验日期晚于或等于当前系统时间 1. 优惠券生效时间(@FutureOrPresent)、任务执行时间(@FutureOrPresent
@Past @Future 校验日期早于当前系统时间 1. 出生日期(@Past)、订单创建时间(@Past
2. 登录时间(@Past
@PastOrPresent @Future 校验日期早于或等于当前系统时间 1. 商品上架时间(@PastOrPresent)、退款申请时间(@PastOrPresent
@AssertTrue 布尔型(Boolean/boolean) 校验布尔值必须为 true 1. 用户同意协议(@AssertTrue(message="必须同意用户协议")
2. 数据有效性标记(@AssertTrue
@AssertFalse 布尔型(Boolean/boolean) 校验布尔值必须为 false 1. 禁用标记(@AssertFalse(message="该功能已禁用,不可提交")
2. 删除标记(@AssertFalse
@Valid 复杂对象(嵌套实体/集合) 触发嵌套对象的递归校验(如 UserDTO 中的 AddressDTO 字段) 1. 订单DTO中的收货地址(@Valid private AddressDTO address
2. 批量用户列表(@Valid List<UserDTO> users

另外还有其他在 quarkusapplication.properties 配置文件修改的参数:

1
2
3
4
## Quarkus Hibernate
# 快速异常抛出, 默认结构所有字段验证之后才会抛出错误, 实际上只需要验证一个错误就能抛出了
# 默认为false, 推荐为 true 防止对全部字段验证再返回
quarkus.hibernate-validator.fail-fast=true

其他配置基本不太需要关注, 如果需要就可以去官方查询下就可以了.

异常拦截

目前已经能够实现拦截参数, 但是没办法拦截异常并返回自定义的参数对象, 这里就依赖 ExceptionMapper + @Provider
拦截全局异常:

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
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.ExceptionMapper;
import jakarta.ws.rs.ext.Provider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.Optional;

/**
* 参数异常拦截
* '@Provider' 代表全局挂载这个功能类
*/
@Provider
public class ConstraintViolationExceptionMapper implements ExceptionMapper<ConstraintViolationException> {

/**
* 日志对象
*/
final Logger log = LoggerFactory.getLogger(this.getClass());

/**
* 获取异常拦截
*
* @param e 异常
* @return 响应对象
*/
@Override
public Response toResponse(ConstraintViolationException e) {
log.debug("Params Exception: {}", e.getMessage());
Optional<ConstraintViolation<?>> msg = e.getConstraintViolations().stream().findFirst();

// 最后响应自定义的结构对象, 这个可以自己去定义
return RestResponse.build(
Response.Status.BAD_REQUEST.getStatusCode(),
Response.Status.BAD_REQUEST.getStatusCode(),
msg.isEmpty() ? "unknown" : msg.get().getMessage()
);
}
}

只要触发 ConstraintViolationException 异常就会被拦截, 不过如果你传递以下内容会抛出怪异错误:

lines
1
2
3
4
5
6
7
8
{
"appid": 1001,
"username": "meteorcat",
"mail": "xxx",
// extras 需要传递 {} 对象组, 而传递的是 ""
// 抛出异常内容: {"objectName":"Class","attributeName":"extras","line":5,"column":14,"value":""}
"extras": ""
}

这里其实是内部 jackson 序列化处理异常问题, 但是没有被 Quarkus 内部异常拦截, 官方的 issues 也有反馈:

可能这部分是源于 Hibernate Validator 内部自身的异常, 开启 DEBUG 模式发现是
com.fasterxml.jackson.databind.exc.InvalidFormatException 抛出的异常, 所以要拦截异常

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
import com.fasterxml.jackson.databind.exc.InvalidFormatException;
import io.cloud.services.api.utils.RestResponse;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.ExceptionMapper;
import jakarta.ws.rs.ext.Provider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* validation json 异常拦截
*/
@Provider
public class InvalidFormatExceptionMapper implements ExceptionMapper<InvalidFormatException> {

/**
* 日志对象
*/
final Logger log = LoggerFactory.getLogger(this.getClass());

/**
* 获取异常拦截
*
* @param e 异常
* @return 响应对象
*/
@Override
public Response toResponse(InvalidFormatException e) {
log.debug("Json Exception: {}", e.getMessage());
// 最后响应自定义的结构对象
return RestResponse.build(
Response.Status.BAD_REQUEST.getStatusCode(),
Response.Status.BAD_REQUEST.getStatusCode(),
"Invalid Format JSON" // 格式异常不要传递给客户端, 容易被嗅探到系统信息
);
}
}

这样就能处理对应的 Map|ListJSON 字段内容, 方便客户端做更加丰富的 JSON 提交.

自定义拦截器

可以先参考 @Email 格式定义手段, 这里需要自定义注解和实现 ConstraintValidator 来构建自己的拦截处理器.

可以编写个自定义的 货币代码(CurrencyCode) 验证器来验证用户传入参数是否 USD/CNY/HKD/JPY 合法值, 需要定义以下功能类:

  • @CurrencyCode: 提供给验证结构的注解对象

  • CurrencyCodeValidator: 具体的检查验证器, 拦截参数之后回调该对象

@CurrencyCode 注解对象声明如下:

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
import jakarta.validation.Constraint;
import jakarta.validation.Payload;

import java.lang.annotation.*;


/**
* 货币码验证枚举
* '@Constraint' 就是指定该注解对应关联的验证器功能类
*/
@Constraint(validatedBy = CurrencyCodeValidator.class)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface CurrencyCode {

/**
* 异常错误消息
* {xxx.yyy.zz} 是模板变量方法
* 使用模板变量, 会去检索对应本地的 ValidationMessages_{LOCAL}.properties 配置
* 查询内部是否有 'currency.code.invalid = 错误消息' 行从而加载错误信息
*/
String message() default "{currency.code.invalid}";


/**
* 是否忽略文本大小写比较
*/
boolean ignoreCase() default false;


/**
* 提供默认需要的属性
*/
Class<?>[] groups() default {};

/**
* 提供默认需要的属性
*/
Class<? extends Payload>[] payload() default {};

}

CurrencyCodeValidator 拦截器验证类功能如下:

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
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

import java.util.Objects;
import java.util.Set;

/**
* 货币代码验证器
* 'ConstraintValidator' 就是声明绑定的注解类
*/
public final class CurrencyCodeValidator implements ConstraintValidator<CurrencyCode, String> {


/**
* 支持的货币码集合, 默认这些货币代码日常足够使用
*/
private static final Set<String> SUPPORTED_CURRENCIES = Set.of(
"AED", "AFN", "ALL", "AMD", "ANG", "AOA", "ARS", "AUD", "AWG", "AZN",
"BAM", "BBD", "BDT", "BGN", "BHD", "BIF", "BMD", "BND", "BOB", "BRL", "BSD", "BTN", "BWP", "BYN", "BZD",
"CAD", "CDF", "CHF", "CLP", "CNY", "COP", "CRC", "CUC", "CUP", "CVE", "CZK",
"DJF", "DKK", "DOP", "DZD",
"EGP", "ERN", "ETB", "EUR",
"FJD", "FKP",
"GBP", "GEL", "GGP", "GHS", "GIP", "GMD", "GNF", "GTQ", "GYD",
"HKD", "HNL", "HRK", "HTG", "HUF",
"IDR", "ILS", "IMP", "INR", "IQD", "IRR", "ISK",
"JEP", "JMD", "JOD", "JPY",
"KES", "KGS", "KHR", "KMF", "KPW", "KRW", "KWD", "KYD", "KZT",
"LAK", "LBP", "LKR", "LRD", "LSL", "LYD",
"MAD", "MDL", "MGA", "MKD", "MMK", "MNT", "MOP", "MRU", "MUR", "MVR", "MWK", "MXN", "MYR", "MZN",
"NAD", "NGN", "NIO", "NOK", "NPR", "NZD",
"OMR",
"PAB", "PEN", "PGK", "PHP", "PKR", "PLN", "PYG",
"QAR",
"RON", "RSD", "RUB", "RWF",
"SAR", "SBD", "SCR", "SDG", "SEK", "SGD", "SHP", "SLL", "SOS", "SRD", "SSP", "STN", "SYP", "SZL",
"THB", "TJS", "TMT", "TND", "TOP", "TRY", "TTD", "TWD", "TZS",
"UAH", "UGX", "USD", "UYU", "UZS",
"VES", "VND", "VUV",
"WST",
"XAF", "XCD", "XDR", "XOF", "XPF",
"YER",
"ZAR", "ZMW", "ZWL"
);

/**
* 是否区分大小写
*/
private boolean ignoreCase = false;

/**
* 初始化回调加载内部注解参数
*/
@Override
public void initialize(CurrencyCode constraintAnnotation) {
ConstraintValidator.super.initialize(constraintAnnotation);

// 读取注解声明的条件
this.ignoreCase = constraintAnnotation.ignoreCase();
}

/**
* 具体的验证回调方法
*
* @param s 传递的内容
* @param constraintValidatorContext 验证器传递的上下文
* @return 是否通过验证
*/
@Override
public boolean isValid(String s, ConstraintValidatorContext constraintValidatorContext) {
// 注意: 应该学习 @Email 的验证方式, 也就是默认空字符直接通过, 而外部需要自己通过 @NotBlank 去验证
// 这样的好处就可以支持某些参数可有可无的时候默认不触发验证, 而必须传入就丢给 @NotBlank 处理达到配合目的
if (Objects.isNull(s) || s.isBlank()) {
return true;
}

// 判断是否要做大小写忽略
if (ignoreCase) {
return SUPPORTED_CURRENCIES
.stream()
.anyMatch(code -> code.equalsIgnoreCase(s));
} else {
return SUPPORTED_CURRENCIES.contains(s);
}
}
}

还需要定制写本地 i18n 异常模板, 这里采用比较常用的中英文错误码表即可.

需要将以下文件创建于 src/main/resources 之下, 应用启动的时候会自动加载系统之中:

  • ValidationMessages_en.properties: 英文错误表

  • ValidationMessages_zh.properties: 中文错误表

/ValidationMessages_en.properties 内容如下:

1
currency.code.invalid=Unsupported currency code, please use ISO 4217 standard

ValidationMessages_zh.properties 内容如下:

1
2
# 自定义货币码校验提示
currency.code.invalid=货币代码不受支持, 请使用 ISO 4217 标准货币代码

这样系统异常的时候会默认会去加载这部分码表获取错误消息, 最后就是引用该注解:

1
2
3
4
5
6
7
8
9
10
/**
* @param currency 结合 '@NotBlank - 不能为空' 和 '@CurrencyCode - 必须货币代码' 结合验证
*/
public record ParamRequest(
// 其他略
@NotBlank
@CurrencyCode(ignoreCase = false)
String currency) {

}

最后就可以做具体的功能验证, 直接测试请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 传递不成立的参数
curl --location 'http://localhost:9099' \
--header 'Content-Type: application/json' \
--data '{
"appid": 1001,
"username": "meteorcat",
"mail":"[email protected]",
"extras":{},
"currency":"XXX"
}'
# 响应内容: {"code":400,"message":"货币代码不受支持, 请使用 ISO 4217 标准货币代码","data":{}}

# 传递合法的参数
curl --location 'http://localhost:9099' \
--header 'Content-Type: application/json' \
--data-raw '{
"appid": 1001,
"username": "meteorcat",
"mail":"[email protected]",
"extras":{},
"currency":"CNY"
}'
# 响应内容: ParamRequest[appid=1001, username=meteorcat, [email protected], extras={}, currency=CNY]

测试完成通过即可, 后续可以去编写衍生出多种专属的参数拦截器.