# 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:验证属性值必须为 truefalse

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 自定义的 LocalDateTimeSerializerLocalDateTimeDeserializer 实现序列化和反序列化,之后进行如下配置:

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;