参数校验实战

177 阅读11分钟

在进一步开发我们小卷生鲜项目的业务模块前,我们会进一步完善项目的基础模块。这一节我们一起来实践下通用的参数校验。

我们这里说的参数校验是web层的参数校验,而不是service层(业务逻辑层)的校验,后者这种业务校验,比如校验注册用户是否存在,我们将在service层来完成,而我们这里的参数校验是controller层的事情,只不过,我们将这种重复的劳动使用校验框架帮我们以注解的形式来更好的开发和维护。废话不多说,开整!

引入依赖

首先,我们引入校验模块起始依赖和定义基本校验注解的api依赖。

dependencies {
    ...

    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'jakarta.validation:jakarta.validation-api'

}

维护校验错误常量

之前我们对后台校验失败的信息是service接口中定义的常量,显然这不是一个好注意,因为现在我们要兼顾controller层的校验,为此,我们把它们维护到一个存放常量的接口中:

package com.xiaojuan.boot.consts;

public interface ValidationConst {

    String MSG_USERNAME_REQUIRED = "用户名不能为空";
    String MSG_USERNAME_EXISTS = "用户名已存在";
    String MSG_USERNAME_ERROR = "用户名错误";
    String MSG_PASSWORD_REQUIRED = "密码不能为空";
    String MSG_PASSWORD_ERROR = "密码错误";
    String MSG_PERSONAL_SIGNATURE_REQUIRED = "个性签名不能为空";
    String MSG_AGE_LIMIT = "年龄不小于18岁";
    String MSG_EMAIL_REQUIRED = "邮箱不能为空";
    String MSG_EMAIL_FORMAT_BAD = "邮箱格式不对";
    String MSG_ADMIN_ROLE_REQUIRED = "非管理员角色,不能操作!";

}

使用校验注解

使用校验模块给我们提供的校验注解,我们会对其应用在controller请求方法的参数列表上,或者应用在DTO类的字段上,下面针对这两种情况,给出示例。

方法参数校验

package com.xiaojuan.boot.web.api;

import ...

import static com.xiaojuan.boot.consts.ValidationConst.*;

@RequestMapping("user")
@Validated
public interface UserAPI {

    ...

    // 给管理员一个单独的登录入口
    @PostMapping("admin/login")
    UserInfoDTO adminLogin(
            @NotBlank(message = MSG_USERNAME_REQUIRED)
            @RequestParam(defaultValue = "")
            String username,
            @NotBlank(message = MSG_PASSWORD_REQUIRED)
            @RequestParam(defaultValue = "")
            String password
    );

    @PostMapping("signature")
    void signature(
            @NotBlank(message = MSG_PERSONAL_SIGNATURE_REQUIRED)
            @RequestParam(defaultValue = "")
            String signature
    );

    ...
}

代码说明

注意这里我们为web层的API接口应用校验的一些设置,而不是其实现——controller组件。这种将各种声明方式与具体实现分离的形式,让关注点分离,具有非常高的可读性和可集中维护性。

这里我们通过在方法参数上加校验注解,完善了之前缺失的校验,需要注意的是,@NotBlank注解在和@RequestParam注解一起使用时,校验注解是后生效的,也就是说我们要确保@RequestParam在启用必输规则时,如果前端没有提交该参数,则会抛错。

我们发现该异常MissingServletRequestParameterException其实也是NestedServletException的子类,但是按照我们先前全局响应异常处理的逻辑,判断它是NestedServletException类型,然后试着调用其getCause()返回的是null,为此我们要完善判断:

// 响应异常的情况
if (exObj != null) {
    if (exObj instanceof NestedServletException) {
        Exception self = exObj;
        exObj = (Exception) exObj.getCause();
        if (exObj == null) exObj = self;
    }
    // 处理全局异常的调用
    ...
}

再回到之前的话题,我们指定了一个默认值——空字符串,来保证后生效的@NotBlank可以正常工作。

为了让方法参数校验可以被校验框架处理,需要在controller类上加@Validated注解。

DTO字段校验

通过form表单或者json内容提交的数据在后端web层会映射到一个DTO对象中,对于这些字段的校验,可以采用下面的形式:

package com.xiaojuan.boot.dto;

import ...

import static com.xiaojuan.boot.consts.ValidationConst.*;

@NoArgsConstructor
@Data
public class UserRegisterDTO {
    @NotBlank(message = MSG_USERNAME_REQUIRED)
    private String username;
    @NotBlank(message = MSG_PASSWORD_REQUIRED)
    private String password;

    @Min(value = 18, message = MSG_AGE_LIMIT)
    private Integer age;

    @NotBlank(message = MSG_EMAIL_REQUIRED)
    @Email(message = MSG_EMAIL_FORMAT_BAD)
    private String email;

    public UserRegisterDTO(String username, String password) { ... }
}

这里我们对原先的注册功能所包含的信息额外做了扩展,从我们加的校验注解可以看出:用户名、密码和邮箱必输、且邮箱格式有检查;年龄可以为空,但输入了要检查不能小于18岁。

要让这些校验注解生效,不要忘了在方法参数DTO类型前面加@Valid注解:

@PostMapping("register")
void register(@Valid UserRegisterDTO userRegisterDTO);

拦截校验异常

当我们应用了校验注解并做好了启用校验的设置后,校验框架会帮助我们完成校验,并将错误信息以相关异常的方式抛出来。而我们要做的就是在之前的全局异常处理的地方,对涉及的异常进行判断:

package com.xiaojuan.boot.common.web.support;

import ...

@Slf4j
@Component
public class GlobalExceptionHandler {

    /** 维护一个错误码与http状态码的映射 */
    private Map<Integer, HttpStatus> httpStatusMap;

    @PostConstruct
    private void init() {
        httpStatusMap = new HashMap<>();
        httpStatusMap.put(NO_LOGIN.getValue(), HttpStatus.UNAUTHORIZED);
        httpStatusMap.put(HAS_NO_ROLE.getValue(), HttpStatus.FORBIDDEN);
    }

    public ResponseEntity<Response<?>> resolveException(Exception ex) {
        if (ex instanceof BusinessException) {
            return handleException((BusinessException) ex);
        } else if (ex instanceof MethodArgumentNotValidException) {
            return handleException((MethodArgumentNotValidException) ex);
        } else if (ex instanceof ConstraintViolationException) {
            return handleException((ConstraintViolationException) ex);
        } else if (ex instanceof BindException) {
            return handleException((BindException) ex);
        } else {
            return handleException(ex);
        }
    }

    private ResponseEntity<Response<?>> handleException(BusinessException ex) {
        // 默认服务器端错误,返回500状态码
        HttpStatus status = HttpStatus.INTERNAL_SERVER_ERROR;
        if (ex.getErrCode() != null && httpStatusMap.containsKey(ex.getErrCode())) {
            status = httpStatusMap.get(ex.getErrCode());
        }
        return ResponseEntity.status(status).body(Response.fail(ex.getMessage(), ex.getErrCode(), ex.getData()));
    }

    private ResponseEntity<Response<?>> handleException(Exception ex) {
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Response.fail("小卷生鲜电商系统异常:" + ex.getMessage()));
    }

    public ResponseEntity<Response<?>> handleException(MethodArgumentNotValidException ex) {
        List<String> errors = ex.getBindingResult().getAllErrors().stream().map(DefaultMessageSourceResolvable::getDefaultMessage).collect(Collectors.toList());
        String msg = StringUtils.join(errors, "、");
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Response.fail(msg, BusinessError.PARAM_INVALID.getValue()));
    }

    public ResponseEntity<Response<?>> handleException(ConstraintViolationException ex) {
        List<String> errors = ex.getConstraintViolations().stream().map(ConstraintViolation::getMessage).collect(Collectors.toList());
        String msg = StringUtils.join(errors, "、");
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Response.fail(msg, BusinessError.PARAM_INVALID.getValue()));
    }

    public ResponseEntity<Response<?>> handleException(BindException ex) {
        List<String> errors = ex.getBindingResult().getAllErrors().stream().map(DefaultMessageSourceResolvable::getDefaultMessage).collect(Collectors.toList());
        String msg = StringUtils.join(errors, "、");
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Response.fail(msg, BusinessError.PARAM_INVALID.getValue()));
    }

}

以上是我们对全局异常处理的进一步完善。校验框架会针对不同的校验方式抛出不同类型的异常,我们只需要把可能的异常类型都处理一下即可。

web单元测试

要验证以上开发的功能,只需要再加一个测试用例即可:

package com.xiaojuan.boot.web.controller;

import ...

import static org.assertj.core.api.Assertions.*;
import static com.xiaojuan.boot.consts.ValidationConst.*;

@Slf4j
public class UserControllerTest extends WebTestBase {

    @SneakyThrows
    @Test
    @SuppressWarnings("unchecked")
    public void testBasicValidation() {

        runner.runScript(Resources.getResourceAsReader("db/user.sql"));

        ResponseEntity<Response<UserInfoDTO>> resp;
        Map<String, List<String>> params = new HashMap<>();
        params.put("username", Collections.singletonList("zhangsan"));
        resp = postForm("/user/login", new TypeReference<UserInfoDTO>() {}, params);
        assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
        assertThat(resp.getBody().getErrCode()).isEqualTo(BusinessError.PARAM_INVALID.getValue());
        List<String> errors = CollectionUtils.arrayToList(StringUtils.split(resp.getBody().getMsg(), "、"));
        assertThat(errors.contains(MSG_PASSWORD_REQUIRED)).isTrue();

        // 注册
        params = new HashMap<>();
        params.put("password", Collections.singletonList("123"));
        params.put("age", Collections.singletonList("16"));
        ResponseEntity<Response<Void>> resp2 = postForm("/user/register", new TypeReference<Void>() {}, params);
        assertThat(resp2.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
        assertThat(resp2.getBody().getErrCode()).isEqualTo(BusinessError.PARAM_INVALID.getValue());
        errors = CollectionUtils.arrayToList(StringUtils.split(resp2.getBody().getMsg(), "、"));
        assertThat(errors.contains(MSG_USERNAME_REQUIRED)).isTrue();
        assertThat(errors.contains(MSG_AGE_LIMIT)).isTrue();
        assertThat(errors.contains(MSG_EMAIL_REQUIRED)).isTrue();

        params = new HashMap<>();
        params.put("username", Collections.singletonList("zhangsan"));
        params.put("password", Collections.singletonList("123"));
        params.put("age", Collections.singletonList("23"));
        params.put("email", Collections.singletonList("xxx"));
        resp2 = postForm("/user/register", new TypeReference<Void>() {}, params);
        assertThat(resp2.getBody().getErrCode()).isEqualTo(BusinessError.PARAM_INVALID.getValue());
        errors = CollectionUtils.arrayToList(StringUtils.split(resp2.getBody().getMsg(), "、"));
        assertThat(errors.size()).isEqualTo(1);
        assertThat(errors.contains(MSG_EMAIL_FORMAT_BAD)).isTrue();

        params = new HashMap<>();
        params.put("username", Collections.singletonList("zhangsan2"));
        params.put("password", Collections.singletonList("123"));
        params.put("email", Collections.singletonList("xxx@162.com"));
        resp2 = postForm("/user/register", new TypeReference<Void>() {}, params);
        assertThat(resp2.getStatusCode()).isEqualTo(HttpStatus.OK);
    }

    ...

}

断言非常的简单,只要判断返回的错误类型码为参数校验失败的类型,并对错误信息转成列表看某个校验错误是否包含在其中即可。前面我们对所有校验错误信息都维护到常量中,这里引用就非常方便了。

自定义校验

内置的校验注解在有些情况使用麻烦,比如,自带的@Pattern会处理传过来的参数为空的情况,而实际需求可能有些字段可以为空,只检查格式,要通过正则来兼顾的话,就增加了正则表达式的复杂性。因此我们可以自定义一个正则校验类型@MyPattern

自定义正则校验

为此我们来自定义一个正则表达式的校验注解:

package com.xiaojuan.boot.common.validation.annotation;

import ...

@Constraint(validatedBy = MyPatternValidator.class)
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Documented
public @interface MyPattern {
    String regexp();
    String message() default "";
    Class<?>[] groups() default { };
    Class<? extends Payload>[] payload() default { };
}

我们可以仿照内置的校验注解,来写一份自定义正则校验注解类,让它可用于DTO字段或方法参数的校验,声明时要指定regexp属性来定义应用校验的字符串形式的正则表达式。这里用@Constraint注解绑定了该注解对应的校验器类型。

然后,我们再开发一个响应的校验器实现类:

package com.xiaojuan.boot.common.validation.validator;

import ...

public class MyPatternValidator implements ConstraintValidator<MyPattern, String> {

    private String regexp;

    @Override
    public void initialize(MyPattern constraintAnnotation) {
        regexp = constraintAnnotation.regexp();
    }

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (!StringUtils.hasLength(value)) return true;
        return Pattern.compile(regexp).matcher(value).matches();
    }
}

这里我们从注解对象获取regexp属性,经过isValid(value, context)方法来实现自定义的校验逻辑,如果要校验的字段为空,我们就不做了,说明允许为空的。要注意的是,对于每个要实现自定义校验的字段,这里都会创建一个校验器实例来处理,因此这里的成员变量regexp,只存储一个正则注解要保存的值。

这里我们对其使用举一个例子,比如在UserRegisterDTO中有一个手机号属性mobileNo,我们要求该字段非必输,但输入的话要确保格式正确,我们则可以用自定义正则注解来这样声明:

@MyPattern(regexp = PatternConst.MOBILE, message = MSG_MOBILE_FORMAT_BAD)
private String mobileNo;

这里我们对正则表达式也采用了常量的形式来方便维护。

自定义NotAllBlank校验

我们再开发一个实用的校验注解@NotAllBlank,该注解用在DTO字段上,对一组字段校验确保不全为空。比如说,用户在注册时账号字段username必输外,对手机号mobileNo和邮箱email字段,至少要填一个,不能都留空,此时我们的自定义注解@NotAllBlank就可以派上用场了。

来看下注解定义:

package com.xiaojuan.boot.common.validation.annotation;

import ...

@Documented
@Constraint(validatedBy = NotAllBlankValidator.class)
@Target({ TYPE })
@Retention(RUNTIME)
public @interface NotAllBlank {

	String[] value();
	String message() default "";
	Class<?>[] groups() default { };
	Class<? extends Payload>[] payload() default { };
}

注意该注解我们定义为只能用在类上,接收的默认属性为一个字符串数组。再看下校验器实现:

package com.xiaojuan.boot.common.validation.validator;

import ...

public class NotAllBlankValidator implements ConstraintValidator<NotAllBlank, Object> {

    private String[] fields;

    @Override
    public void initialize(NotAllBlank constraintAnnotation) {
        fields = constraintAnnotation.value();
    }

    @Override
    public boolean isValid(Object value, ConstraintValidatorContext context) {
        for (int i = 0; i < fields.length; i++) {
            BeanWrapper bean = new BeanWrapperImpl(value);
            if (!ObjectUtils.isEmpty(bean.getPropertyValue(fields[i]))) return true;
        }
        return false;
    }
}

这里我们获取注解修饰的对象,这里的value值为对象的引用,我们再用Spring框架提供的BeanWrapper类型来获取指定的属性的值,只要声明的字段数组中有一个字段值不为空,则返回true,即校验通过。

看具体的应用:

package com.xiaojuan.boot.dto;

import ...

@NotAllBlank(value = {"mobileNo", "email"}, message = MSG_NOT_ALL_EMPTY_MOBILE_EMAIL)
...
public class UserRegisterDTO {

    ...

    @Email(message = MSG_EMAIL_FORMAT_BAD)
    private String email;

    @MyPattern(regexp = PatternConst.MOBILE, message = MSG_MOBILE_FORMAT_BAD)
    private String mobileNo;

    ...
}

以上我们实现了用户注册时,手机号和邮箱至少要填一个。

应用自定义校验后,看我们对web单元测试的调整:

@SneakyThrows
@Test
@SuppressWarnings("unchecked")
public void testValidation() {

    runner.runScript(Resources.getResourceAsReader("db/user.sql"));

    ResponseEntity<Response<UserInfoDTO>> resp;
    Map<String, List<String>> params = new HashMap<>();
    params.put("username", Collections.singletonList("zhangsan"));
    resp = postForm("/user/login", new TypeReference<UserInfoDTO>() {}, params);
    assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
    assertThat(resp.getBody().getErrCode()).isEqualTo(BusinessError.PARAM_INVALID.getValue());
    List<String> errors = CollectionUtils.arrayToList(StringUtils.split(resp.getBody().getMsg(), "、"));
    assertThat(errors.contains(MSG_PASSWORD_REQUIRED)).isTrue();

    // 注册
    params = new HashMap<>();
    params.put("password", Collections.singletonList("123"));
    params.put("age", Collections.singletonList("16"));
    ResponseEntity<Response<Void>> resp2 = postForm("/user/register", new TypeReference<Void>() {}, params);
    assertThat(resp2.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
    assertThat(resp2.getBody().getErrCode()).isEqualTo(BusinessError.PARAM_INVALID.getValue());
    errors = CollectionUtils.arrayToList(StringUtils.split(resp2.getBody().getMsg(), "、"));
    assertThat(errors.contains(MSG_USERNAME_REQUIRED)).isTrue();
    assertThat(errors.contains(MSG_AGE_LIMIT)).isTrue();
    assertThat(errors.contains(MSG_NOT_ALL_EMPTY_MOBILE_EMAIL)).isTrue();

    params = new HashMap<>();
    params.put("username", Collections.singletonList("zhangsan"));
    params.put("password", Collections.singletonList("123"));
    params.put("age", Collections.singletonList("23"));
    params.put("mobileNo", Collections.singletonList("131"));
    resp2 = postForm("/user/register", new TypeReference<Void>() {}, params);
    assertThat(resp2.getBody().getErrCode()).isEqualTo(BusinessError.PARAM_INVALID.getValue());
    errors = CollectionUtils.arrayToList(StringUtils.split(resp2.getBody().getMsg(), "、"));
    assertThat(errors.size()).isEqualTo(1);
    assertThat(errors.contains(MSG_MOBILE_FORMAT_BAD)).isTrue();

    params = new HashMap<>();
    params.put("username", Collections.singletonList("zhangsan2"));
    params.put("password", Collections.singletonList("123"));
    params.put("age", Collections.singletonList("23"));
    params.put("mobileNo", Collections.singletonList("13122331234"));
    resp2 = postForm("/user/register", new TypeReference<Void>() {}, params);
    assertThat(resp2.getStatusCode()).isEqualTo(HttpStatus.OK);
}

测试通过:

image.png

从输出日志我们我们看到:

image.png

校验分组

某些时候由于我们的DTO设计的意图是想让多个API接口的入参实现共用,注意,这种设计小卷不推荐,我们最好为每个API接口定义自己的入参类型,比如用户注册和用户登录可以设计两个DTO类型。但也难免遇到一些遗留系统这样的共用的做法。比如我们现在用一个UserDTO来作为用户登录和用户注册这两个API接口的共同入参类型:

package com.xiaojuan.boot.dto;

import ...

import static com.xiaojuan.boot.consts.ValidationConst.*;

@NotAllBlank(value = {"mobileNo", "email"}, message = MSG_NOT_ALL_EMPTY_MOBILE_EMAIL, groups = UserDTO.Register.class)
@NoArgsConstructor
@Data
public class UserDTO {
    @NotBlank(message = MSG_USERNAME_REQUIRED, groups = {Login.class, Register.class})
    private String username;
    @NotBlank(message = MSG_PASSWORD_REQUIRED, groups = {Login.class, Register.class})
    private String password;

    @Min(value = 18, message = MSG_AGE_LIMIT, groups = Register.class)
    private Integer age;

    @Email(message = MSG_EMAIL_FORMAT_BAD, groups = Register.class)
    private String email;

    @MyPattern(regexp = PatternConst.MOBILE, message = MSG_MOBILE_FORMAT_BAD, groups = Register.class)
    private String mobileNo;

    public UserDTO(String username, String password) {
        this.username = username;
        this.password = password;
    }

    public interface Login{}
    public interface Register{}
}

代码说明

为了实现这两个用户接口的字段校验不会相互干扰,这里我们对校验注解声明时采用了分组,来相互隔离。同样在自定义校验注解上我们也定义了groups属性。

再来看我们的API接口的调整:

package com.xiaojuan.boot.web.api;

import ...

@RequestMapping("user")
@Validated
public interface UserAPI {

    ...

    @PostMapping("register")
    void register(@Validated(UserDTO.Register.class) UserDTO userRegisterDTO);

    @PostMapping("login")
    UserInfoDTO login(@Validated(UserDTO.Login.class) UserDTO userLoginDTO);

    // 给管理员一个单独的登录入口
    @PostMapping("admin/login")
    UserInfoDTO adminLogin(@Validated(UserDTO.Login.class) UserDTO userLoginDTO);

    ...
}

在接收表单提交的DTO参数类型上我们用@Validated注解指定了要校验的组。这样我们就实现了在一个DTO类中按分组来校验不同API接口的字段逻辑。

最后再次运行先前的web单元测试,检查调整后的代码是否正确。

当然我们不太推荐这种分组校验的形式,可读性太差了,且比较难维护。以上就是本节的全部内容,大家加油!