# scaffold 项目之参数校验、时间传参
# 简介
项目使用 Hibernate Validator
框架,对 RESTful API 接口进行参数的校验,以保证最终数据入库的正确性。例如说,用户注册时,会校验手机格式的正确性,密码非弱密码。
如果参数校验不通过,会抛出 ConstraintViolationException
异常,被全局的 异常处理
捕获,返回 “请求参数不正确” 的响应。示例如下:
{ | |
"code": 400, | |
"data": null, | |
"msg": "请求参数不正确:密码不能为空" | |
} |
# 参数校验注解
Hibernate Validator 是一个强大的参数校验框架,它基于 Java Bean Validation 规范(JSR 380)提供了一系列的注解,用于校验 Java 对象的属性、方法参数和返回值等,Validator 内置了 20+ 个参数校验注解。
# 常用注解
@NotNull:确保属性值不为 null
。
java | |
@NotNull(message = "不能为空") | |
private String name; |
@NotEmpty:确保字符串、集合或数组不为空。
java | |
@NotEmpty(message = "不能为空") | |
private List<String> emails; |
@NotBlank:确保字符串不为 null
、空字符串或只包含空白字符。
java | |
@NotBlank(message = "用户名不能为空") | |
private String username; |
@Size:验证字符串、集合或数组的长度是否在指定范围内。
java | |
@Size(min = 2, max = 20) | |
private String username; |
@Min 和 @Max:验证数字属性的最小值和最大值。
java | |
@Min(18) | |
private int age; | |
@Max(100) | |
private int score; |
@Email:验证字符串是否为有效的电子邮件地址。
java | |
@Email | |
private String email; |
@Pattern:验证字符串是否匹配指定的正则表达式。
java | |
@Pattern(regexp = "[A-Za-z0-9]+") | |
private String password; |
@Range:验证数字属性是否在指定范围内(包括最小值和最大值)。
java | |
@Range(min = 1, max = 10) | |
private int quantity; |
@Valid:递归验证嵌套对象。
java | |
@Valid | |
private Address address; |
@AssertTrue 和 @AssertFalse:验证属性值必须为 true
或 false
。
java | |
@AssertTrue | |
private boolean active; | |
@AssertFalse | |
private boolean deleted; |
@Future 和 @Past:验证日期或时间属性是否为将来的时间或过去的时间。
java | |
@Future | |
private LocalDateTime expirationDate; | |
@Past | |
private LocalDate birthDate; |
这些注解可以单独使用,也可以组合使用,以满足不同的校验需求。通过使用这些注解,可以确保数据的合法性和业务逻辑的正确性。Hibernate Validator 还支持自定义注解和分组校验,提供了灵活的校验机制。
# 参数校验使用
只需要三步,即可开启参数校验的功能。
首先是引入参数校验的 spring-boot-starter-validation
依赖。一般不需要做,项目默认已经引入。
注意:springboot2.3.* 之后的 @Validated 不生效,需要手动引用
第一步,在需要参数校验的类上,添加 @Validated
注解,例如说 Controller、Service 类。代码如下:
// Controller 示例 | |
@Validated | |
public class AuthController {} | |
// Service 示例,一般放在实现类上 | |
@Service | |
@Validated | |
public class AdminAuthServiceImpl implements AdminAuthService {} |
第二步(情况一)如果方法的参数是 Bean 类型,则在方法参数上添加 @Valid
注解,并在 Bean 类上添加参数校验的注解。代码如下:
// Controller 示例 | |
@Validated | |
public class AuthController { | |
@PostMapping("/login") | |
public CommonResult<AuthLoginRespVO> login(@RequestBody @Valid AuthLoginReqVO reqVO) {} | |
} | |
// Service 示例,一般放在接口上 | |
public interface AdminAuthService { | |
String login(@Valid AuthLoginReqVO reqVO, String userIp, String userAgent); | |
} | |
// Bean 类的示例。一般建议添加参数注解到属性上。原因:采用 Lombok 后,很少使用 getter 方法 | |
public class AuthLoginReqVO { | |
@NotEmpty(message = "登录账号不能为空") | |
@Length(min = 4, max = 16, message = "账号长度为 4-16 位") | |
@Pattern(regexp = "^[A-Za-z0-9]+$", message = "账号格式为数字以及字母") | |
private String username; | |
@NotEmpty(message = "密码不能为空") | |
@Length(min = 4, max = 16, message = "密码长度为 4-16 位") | |
private String password; | |
} |
第二步(情况二)如果方法的参数是普通类型,则在方法参数上直接添加参数校验的注解。代码如下:
// Controller 示例 | |
@Validated | |
public class DictDataController { | |
@GetMapping(value = "/get") | |
public CommonResult<DictDataRespVO> getDictData(@RequestParam("id") @NotNull(message = "编号不能为空") Long id) {} | |
} | |
// Service 示例,一般放在接口上 | |
public interface DictDataService { | |
DictDataDO getDictData(@NotNull(message = "编号不能为空") Long id); | |
} |
第三步 启动项目测试校验情况
疑问:Controller 做了参数校验后,Service 是否需要做参数校验?
是需要的。Service 可能会被别的 Service 进行调用,也会存在参数不正确的情况,所以必须进行参数校验
# 自定义注解
如果 validation
框架自带的校验不满足业务需求,那么就需要自定义校验注解了。
如何定义自定义注解,比如需要定义一个需要校验手机号的注解,步骤如下:
第一步 定义一个用于校验手机号的注解 @Mobile
,代码如下:
package com.tz.scaffold.framework.common.validation; | |
import javax.validation.Constraint; | |
import javax.validation.Payload; | |
import java.lang.annotation.*; | |
/** | |
* <p> Project: scaffold - Mobile </p> | |
* | |
* 校验手机格式的注解 | |
* @author Tz | |
* @date 2024/01/09 23:45 | |
* @version 1.0.0 | |
* @since 1.0.0 | |
*/ | |
@Target({ | |
ElementType.METHOD, | |
ElementType.FIELD, | |
ElementType.ANNOTATION_TYPE, | |
ElementType.CONSTRUCTOR, | |
ElementType.PARAMETER, | |
ElementType.TYPE_USE | |
}) | |
@Retention(RetentionPolicy.RUNTIME) | |
@Documented | |
@Constraint( | |
validatedBy = MobileValidator.class | |
) | |
public @interface Mobile { | |
String message() default "手机号格式不正确"; | |
Class<?>[] groups() default {}; | |
Class<? extends Payload>[] payload() default {}; | |
} |
@Constraint
:这个注解将Mobile
标记为一个约束注解,它定义了校验逻辑。validatedBy
属性指定校验器类MobileValidator
来负责实际的校验逻辑。
第二步 实现具体的校验逻辑
package com.tz.scaffold.framework.common.validation; | |
import cn.hutool.core.util.StrUtil; | |
import com.tz.scaffold.framework.common.util.validation.ValidationUtils; | |
import javax.validation.ConstraintValidator; | |
import javax.validation.ConstraintValidatorContext; | |
/** | |
* <p> Project: scaffold - MobileValidator </p> | |
* | |
* 手机格式验证器 | |
* @author Tz | |
* @date 2024/01/09 23:45 | |
* @version 1.0.0 | |
* @since 1.0.0 | |
*/ | |
public class MobileValidator implements ConstraintValidator<Mobile, String> { | |
@Override | |
public void initialize(Mobile annotation) { | |
} | |
@Override | |
public boolean isValid(String value, ConstraintValidatorContext context) { | |
// 如果手机号为空,默认不校验,即校验通过 | |
if (StrUtil.isEmpty(value)) { | |
return true; | |
} | |
// 校验手机 | |
return ValidationUtils.isMobile(value); | |
} | |
} |
第三步 在需要校验手机号的地方加上该注解
package com.tz.scaffold.module.system.api.sms.dto.code; | |
/** | |
* <p> Project: scaffold - SmsCodeSendReqDTO </p> | |
* | |
* 短信验证码的发送 Request DTO | |
* @author Tz | |
* @date 2024/01/09 23:45 | |
* @version 1.0.0 | |
* @since 1.0.0 | |
*/ | |
@Data | |
public class SmsCodeSendReqDTO { | |
/** | |
* 手机号 | |
*/ | |
@Mobile | |
@NotEmpty(message = "手机号不能为空") | |
private String mobile; | |
} |
# 时间传参
# Query 时间传参
Query 时间传参,指的是 GET
请求、或者 POST
的 form-data 请求。
后端接收时间参数时,需要添加 SpringMVC 的 @DateTimeFormat
注解,并设置时间格式。例如说:
@NotNull(message = "开始时间不能为空") | |
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) | |
private LocalDateTime startTime; | |
@NotNull(message = "结束时间不能为空") | |
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) | |
private LocalDateTime endTime; |
前端传递时间参数时,需要时间格式为 yyyy-MM-dd HH:mm:ss
,和上面的 FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND
对应。
# Request Body 传参
Request Body 传参指的是 Put
, Post
等请求,通过 JSON
格式传递数据。
后端接收时间参数时,需要添加 SpringMVC 的 @RequestBody
注解,使用 LocalDateTime
属性进行接收。例如说:
//@RequestBody | |
@PostMapping("/create") | |
@Operation(summary = "新增字典数据") | |
@PreAuthorize("@ss.hasPermission('system:dict:create')") | |
public CommonResult<Long> createDictData(@Valid @RequestBody DictDataCreateReqVO reqVO) { | |
Long dictDataId = dictDataService.createDictData(reqVO); | |
return success(dictDataId); | |
} | |
//LocalDateTime | |
package com.tz.scaffold.module.system.controller.admin.dict.vo.data; | |
import io.swagger.v3.oas.annotations.media.Schema; | |
import lombok.Data; | |
import javax.validation.constraints.NotBlank; | |
import javax.validation.constraints.NotNull; | |
import javax.validation.constraints.Size; | |
/** | |
* <p> Project: scaffold - DictDataBaseVO </p> | |
* | |
* @author Tz | |
* @date 2024/01/09 23:45 | |
* @version 1.0.0 | |
* @since 1.0.0 | |
*/ | |
@Data | |
public class DictDataCreateReqVO { | |
private LocalDateTime startTime; | |
} |
前端传递时间参数时,需要时间格式为 Long 时间戳。
# Response Body 时间响应
JSON 返回的时间,使用 LocalDateTime 定义属性,会被序列化为 Long 时间戳进行相应。
例如说 TenantRespVO 的 createTime
属性,如下:
@Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) | |
private LocalDateTime createTime; | |
// 响应回前端会变成时间戳 |
# 如何自定义 JSON 时间格式?
# 为什么使用 Long 时间戳呢?
每个项目希望展示的时间格式可能不同,有希望 yyyy-MM-dd HH:mm:ss
,也有希望 yyyy/MM/dd HH:mm:ss
,又或者是其它。
而 Long 时间戳是比较标准的,没有任何 “产品需求” 的味道,所以使用它。 至于业务希望展示成什么样子,可以通过前端封装统一的 format 方法去实现,更加规范。
它是通过 LocalDateTime
自定义的 LocalDateTimeSerializer
和 LocalDateTimeDeserializer
实现序列化和反序列化,之后进行如下配置:
package com.tz.scaffold.framework.jackson.config; | |
import cn.hutool.core.collection.CollUtil; | |
import com.tz.scaffold.framework.common.util.json.JsonUtils; | |
import com.tz.scaffold.framework.jackson.core.databind.LocalDateTimeDeserializer; | |
import com.tz.scaffold.framework.jackson.core.databind.LocalDateTimeSerializer; | |
import com.tz.scaffold.framework.jackson.core.databind.NumberSerializer; | |
import com.fasterxml.jackson.databind.ObjectMapper; | |
import com.fasterxml.jackson.databind.module.SimpleModule; | |
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer; | |
import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer; | |
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer; | |
import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer; | |
import lombok.extern.slf4j.Slf4j; | |
import org.springframework.boot.autoconfigure.AutoConfiguration; | |
import org.springframework.context.annotation.Bean; | |
import java.time.LocalDate; | |
import java.time.LocalDateTime; | |
import java.time.LocalTime; | |
import java.util.List; | |
/** | |
* <p> Project: scaffold - ScaffoldJacksonAutoConfiguration </p> | |
* | |
* jackson 序列号的配置类 | |
* @author Tz | |
* @date 2024/01/09 23:45 | |
* @version 1.0.0 | |
* @since 1.0.0 | |
*/ | |
@AutoConfiguration | |
@Slf4j | |
public class ScaffoldJacksonAutoConfiguration { | |
@Bean | |
@SuppressWarnings("InstantiationOfUtilityClass") | |
public JsonUtils jsonUtils(List<ObjectMapper> objectMappers) { | |
// 1.1 创建 SimpleModule 对象 | |
SimpleModule simpleModule = new SimpleModule(); | |
simpleModule | |
// 新增 Long 类型序列化规则,数值超过 2^53-1,在 JS 会出现精度丢失问题,因此 Long 自动序列化为字符串类型 | |
.addSerializer(Long.class, NumberSerializer.INSTANCE) | |
.addSerializer(Long.TYPE, NumberSerializer.INSTANCE) | |
.addSerializer(LocalDate.class, LocalDateSerializer.INSTANCE) | |
.addDeserializer(LocalDate.class, LocalDateDeserializer.INSTANCE) | |
.addSerializer(LocalTime.class, LocalTimeSerializer.INSTANCE) | |
.addDeserializer(LocalTime.class, LocalTimeDeserializer.INSTANCE) | |
// 新增 LocalDateTime 序列化、反序列化规则 | |
.addSerializer(LocalDateTime.class, LocalDateTimeSerializer.INSTANCE) | |
.addDeserializer(LocalDateTime.class, LocalDateTimeDeserializer.INSTANCE); | |
// 1.2 注册到 objectMapper | |
objectMappers.forEach(objectMapper -> objectMapper.registerModule(simpleModule)); | |
// 2. 设置 objectMapper 到 JsonUtils { | |
JsonUtils.init(CollUtil.getFirst(objectMappers)); | |
log.info("[init][初始化 JsonUtils 成功]"); | |
return new JsonUtils(); | |
} | |
} |
# Jackson 配置项 | |
jackson: | |
serialization: | |
write-dates-as-timestamps: true # 设置 Date 的格式,使用时间戳 | |
write-date-timestamps-as-nanoseconds: false # 设置不使用 nanoseconds 的格式。例如说 1611460870.401,而是直接 1611460870401 | |
write-durations-as-timestamps: true # 设置 Duration 的格式,使用时间戳 | |
fail-on-empty-beans: false # 允许序列化无属性的 Bean |
# 全局配置时间格式
如果你想 JSON 全局配置成 yyyy-MM-dd HH:mm:ss
或其它时间格式,通过使用 Jackson 内置的 LocalDateTimeSerializer 和 LocalDateTimeDeserializer 即可,配置:
# Jackson 配置项 | |
jackson: | |
serialization: | |
#注释下面两个 | |
write-dates-as-timestamps: true # 设置 Date 的格式,使用时间戳 | |
write-date-timestamps-as-nanoseconds: false # 设置不使用 nanoseconds 的格式。例如说 1611460870.401,而是直接 1611460870401 | |
write-durations-as-timestamps: true # 设置 Duration 的格式,使用时间戳 | |
fail-on-empty-beans: false # 允许序列化无属性的 Bean |
# 局部配置时间格式
如果只是部分 VO 的字段想自定义 yyyy-MM-dd HH:mm:ss
或其它时间格式,可通过 Jackson 内置的 @JsonFormat
注解,如下所示:
@JsonSerialize(using = LocalDateTimeSerializer.class) // 序列化(响应) | |
@JsonDeserialize(using = LocalDateDeserializer.class) // 反序列化(请求) | |
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") | |
private LocalDateTime createTime; |