作者简介:大家好,我是smart哥,前中兴通讯、美团架构师,现某互联网公司CTO
联系qq:184480602,加我进群,大家一起学习,一起进步,一起对抗互联网寒冬
写一个接口,大致就几个步骤:
业务代码总是变化的,没太多可说的,统一结果封装我们已经介绍过,今天我们来聊聊参数校验的琐事。
老实说,参数校验很烦!不校验不行,仔细校验吧,代码又显得非常冗余,很丑:
@PostMapping("insertUser")
public Result insertUser(@RequestBody User user) {
if (user == null) {
return Result.error(ExceptionCodeEnum.EMPTY_PARAM);
}
if (user.getId() == null || user.getId() <= 0) {
return Result.error("id为空或小于0");
}
if (StringUtils.isEmpty(user.getName()) || user.getName().length() > 4) {
return Result.error("姓名不符合规范");
}
if (user.getAge() < 18) {
return Result.error("年龄不小于18");
}
if (StringUtils.isEmpty(user.getPhone()) || user.getPhone().length() != 11) {
return Result.error("手机号码不正确");
}
return Result.success(userService.save(user));
}
但无论以什么方式进行参数校验,归根到底就是两种:
对应到实际编码的话,推荐:
其实对于上面两种方式,Spring都提供了解决方案。很多人只知道Spring Validation,却不知道简单好用的Assert。
public class SpringAssertTest {
/**
* Spring提供的Assert工具类,可以指定IllegalArgumentException的message
*
* @param args
*/
public static void main(String[] args) {
String name = "";
// Assert.hasText(name, "名字不能为空");
Integer age = null;
// Assert.notNull(age, "年龄不能为空");
Integer height = 180;
Assert.isTrue(height > 185, "身高不能低于185");
}
}
只要在全局异常处理IllegalArgumentException即可。但个人觉得还是自己封装自由度高一些,所以我们按照这个思路,写一个ValidatorUtils。
封装ValidatorUtils也有两种思路:
比如,方法只返回true/false:
public final class ValidatorUtils {
private ValidatorUtils() {}
/**
* 校验id是否合法
*
* @param id
*/
public static boolean isNotId(Long id) {
if (id == null) {
return true;
}
if (id < 0) {
return true;
}
return false;
}
}
调用者根据返回值自行处理(抛异常或者用Result封装):
@PostMapping("insertUser")
public Result insertUser(@RequestBody User user) {
if (user == null) {
return Result.error(ExceptionCodeEnum.EMPTY_PARAM);
}
// 对校验结果进行判断并返回,也可以抛异常让@RestControllerAdvice兜底
if (ValidatorUtils.isNotId(user.getId())) {
return Result.error("id为空或小于0");
}
return Result.success(userService.save(user));
}
这种方式,本质上和不封装差不多...
这种形式一般会结合@RestControllerAdvice进行全局异常处理:
public final class ValidatorUtils {
private ValidatorUtils() {}
// 错误信息模板
private static final String IS_EMPTY = "%s不能为空";
private static final String LESS_THAN_ZERO = "%s不能小于0";
/**
* 校验参数是否为null
*
* @param param
* @param fieldName
*/
public static void checkNull(Object param, String fieldName) {
if (param == null) {
// ValidatorException是自定义异常
throw new ValidatorException(String.format(IS_EMPTY, fieldName));
}
}
/**
* 校验id是否合法
*
* @param id
* @param fieldName
*/
public static void checkId(Long id, String fieldName) {
if (id == null) {
throw new ValidatorException(String.format(IS_EMPTY, fieldName));
}
if (id < 0) {
throw new ValidatorException(String.format(LESS_THAN_ZERO, fieldName));
}
}
}
@PostMapping("updateUser")
public Result updateUser(@RequestBody User user) {
// 一连串的校验
ValidatorUtils.checkNull(user, "user");
ValidatorUtils.checkId(user.getId(), "用户id");
return Result.success(true);
}
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/* 省略业务异常、运行时异常处理*/
/**
* ValidatorUtils校验异常
* @see ValidatorUtils
*
* @param e
* @return
*/
@ExceptionHandler(ValidatorException.class)
public Result handleValidatorException(ValidatorException e) {
// 打印精确的参数错误日志,方便后端排查
log.warn("参数校验异常: {}", e.getMessage(), e);
// 一般来说,给客户端展示“参数错误”等泛化的错误信息即可,联调时可以返回精确的信息:e.getMessage()
return Result.error(ExceptionCodeEnum.ERROR_PARAM);
}
}
具体选择哪种,看个人喜好啦。这里给出第二种封装形式(也可以改成第一种):
public final class ValidatorUtils {
private ValidatorUtils() {}
private static final String IS_EMPTY = "%s不能为空";
private static final String LESS_THAN_ZERO = "%s不能小于0";
private static final String LENGTH_OUT_OF_RANGE = "%s长度要在%d~%d之间";
private static final String LENGTH_LESS_THAN = "%s长度不能小于%d";
private static final String LENGTH_GREATER_THAN = "%s长度不能大于%d";
private static final String ILLEGAL_PARAM = "%s不符合规则";
// 手机号码正则,可以根据需要自行调整
public static final String MOBILE = "1\\d{10}";
/**
* 是否为true
*
* @param expression
* @param message
*/
public static void isTrue(boolean expression, String message) {
if (!expression) {
throw new ValidatorException(message);
}
}
/**
* 校验参数是否为null
*
* @param param
* @param fieldName
*/
public static void checkNull(Object param, String fieldName) {
if (param == null) {
throw new ValidatorException(String.format(IS_EMPTY, fieldName));
}
}
/**
* 校验参数是否为null或empty
*
* @param param
* @param fieldName
*/
public static void checkNullOrEmpty(Object param, String fieldName) {
if (param == null) {
throw new ValidatorException(String.format(IS_EMPTY, fieldName));
}
if (param instanceof CharSequence) {
if (param instanceof String && "null".equals(((String) param).toLowerCase())) {
throw new ValidatorException(String.format(IS_EMPTY, fieldName));
}
if (isBlank((CharSequence) param)) {
throw new ValidatorException(String.format(IS_EMPTY, fieldName));
}
}
if (isCollectionsSupportType(param) && sizeIsEmpty(param)) {
throw new ValidatorException(String.format(IS_EMPTY, fieldName));
}
}
/**
* 校验id是否合法
*
* @param id
* @param fieldName
*/
public static void checkId(Long id, String fieldName) {
if (id == null) {
throw new ValidatorException(String.format(IS_EMPTY, fieldName));
}
if (id < 0) {
throw new ValidatorException(String.format(LESS_THAN_ZERO, fieldName));
}
}
/**
* 校验id是否合法
*
* @param id
* @param fieldName
*/
public static void checkId(Integer id, String fieldName) {
if (id == null) {
throw new ValidatorException(String.format(IS_EMPTY, fieldName));
}
if (id < 0) {
throw new ValidatorException(String.format(LESS_THAN_ZERO, fieldName));
}
}
/**
* 校验参数字符串
*
* @param param 字符串参数
* @param min 最小长度
* @param max 最大长度
*/
public static void checkLength(String param, int min, int max, String fieldName) {
if (param == null || "".equals(param)) {
throw new ValidatorException(String.format(IS_EMPTY, fieldName));
}
int length = param.length();
if (length < min || length > max) {
throw new ValidatorException(String.format(LENGTH_OUT_OF_RANGE, fieldName, min, max));
}
}
/**
* 校验参数字符串
*
* @param param 字符串参数
* @param min 最小长度
*/
public static void checkMinLength(String param, int min, String fieldName) {
if (param == null || "".equals(param)) {
throw new ValidatorException(String.format(IS_EMPTY, fieldName));
}
if (param.length() < min) {
throw new ValidatorException(String.format(LENGTH_LESS_THAN, fieldName, min));
}
}
/**
* 校验参数字符串
*
* @param param 字符串参数
* @param max 最大长度
*/
public static void checkMaxLength(String param, int max, String fieldName) {
if (param == null || "".equals(param)) {
throw new ValidatorException(String.format(IS_EMPTY, fieldName));
}
if (param.length() > max) {
throw new ValidatorException(String.format(LENGTH_GREATER_THAN, fieldName, max));
}
}
/**
* 校验手机号是否合法
*
* @param phone 手机号
*/
public static void checkPhone(String phone, String fieldName) {
if (phone == null || "".equals(phone)) {
throw new ValidatorException(String.format(IS_EMPTY, fieldName));
}
boolean matches = Pattern.matches(MOBILE, phone);
if (!matches) {
throw new ValidatorException(String.format(ILLEGAL_PARAM, fieldName));
}
}
// --------- private method ----------
private static boolean isBlank(CharSequence cs) {
int strLen;
if (cs != null && (strLen = cs.length()) != 0) {
for (int i = 0; i < strLen; ++i) {
if (!Character.isWhitespace(cs.charAt(i))) {
return false;
}
}
}
return true;
}
private static boolean sizeIsEmpty(final Object object) {
if (object == null) {
return true;
} else if (object instanceof Collection>) {
return ((Collection>) object).isEmpty();
} else if (object instanceof Map, ?>) {
return ((Map, ?>) object).isEmpty();
} else if (object instanceof Object[]) {
return ((Object[]) object).length == 0;
} else {
try {
return Array.getLength(object) == 0;
} catch (final IllegalArgumentException ex) {
throw new IllegalArgumentException("Unsupported object type: " + object.getClass().getName());
}
}
}
private static boolean isCollectionsSupportType(Object value) {
boolean isCollectionOrMap = value instanceof Collection || value instanceof Map;
return isCollectionOrMap || value.getClass().isArray();
}
}
@Getter
@NoArgsConstructor
public class ValidatorException extends RuntimeException {
/**
* 自定义业务错误码
*/
private Integer code;
/**
* 系统源异常
*/
private Exception originException;
/**
* 完整的构造函数:参数错误码+参数错误信息+源异常信息
*
* @param code 参数错误码
* @param message 参数错误信息
* @param originException 系统源异常
*/
public ValidatorException(Integer code, String message, Exception originException) {
super(message);
this.code = code;
this.originException = originException;
}
/**
* 构造函数:错误枚举+源异常信息
*
* @param codeEnum
*/
public ValidatorException(ExceptionCodeEnum codeEnum, Exception originException) {
this(codeEnum.getCode(), codeEnum.getDesc(), originException);
}
/**
* 构造函数:参数错误信息+源异常信息
*
* @param message 参数错误信息
* @param originException 系统源错误
*/
public ValidatorException(String message, Exception originException) {
this(ExceptionCodeEnum.ERROR_PARAM.getCode(), message, originException);
}
/**
* 构造函数:错误枚举
*
* @param codeEnum 错误枚举
*/
public ValidatorException(ExceptionCodeEnum codeEnum) {
this(codeEnum.getCode(), codeEnum.getDesc(), null);
}
/**
* 构造函数:参数错误信息
*
* @param message 参数错误信息
*/
public ValidatorException(String message) {
this(ExceptionCodeEnum.ERROR_PARAM.getCode(), message, null);
}
}
Spring也封装了一套基于注解的参数校验逻辑,常用的有:
大家可能之前听说过@Valid,它和@Validated有什么关系呢?@Valid是JSR303规定的,@Validated是Spring扩展的,@Validated相对来说功能更加强大,推荐优先使用@Validated。
SpringBoot2.3.x之前可以直接使用@Validated及@Valid,SpringBoot2.3.x以后需要额外引入依赖:
org.hibernate
hibernate-validator
6.0.1.Final
实际开发中,如果某个GET接口只有一两个参数,可以使用“散装”的参数列表(注意类上加@Validated):
@Slf4j
@Validated
@RestController
public class UserController {
@GetMapping("getUser")
public Result getUser(@NotNull(message = "部门id不能为空") Long departmentId,
@NotNull(message = "年龄不能为空")
@Max(value = 35, message = "年龄不超过35")
@Min(value = 18, message = "年龄不小于18") Integer age) {
return Result.success(null);
}
}
如果@RestControllerAdvice没有捕获对应的异常,会返回SpringBoot默认的异常JSON:
服务端则抛出ConstraintViolationException:
这样的提示不够友好,我们可以按之前的思路,为ConstraintViolationException进行全局异常处理:
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/* 省略业务异常、运行时异常等其他异常处理*/
/**
* ConstraintViolationException异常
*
* @param e
* @return
* @see ValidatorUtils
*/
@ExceptionHandler(ConstraintViolationException.class)
public Result handleConstraintViolationException(ConstraintViolationException e) {
log.warn("参数错误: {}", e.getMessage(), e);
// 一般只需返回泛化的错误信息,比如“参数错误”
return Result.error(ExceptionCodeEnum.ERROR_PARAM, e.getMessage());
}
}
格式觉得丑的话,可以自己调整。
@Data
public class User {
@NotNull(message = "id不能为空")
private Long id;
@NotNull(message = "年龄不能为空")
@Max(value = 35, message = "年龄不超过35")
@Min(value = 18, message = "年龄不小于18")
private Integer age;
}
@Slf4j
@RestController
public class UserController {
/**
* 如果都是用DTO包装参数,那么Controller可以不加@Validated(但建议还是都加上吧)
* 参数列表里用@Validated或@Valid都可以
*
* @param user
* @return
*/
@GetMapping("getUser")
public Result getUser(@Validated User user) {
System.out.println("进来了");
return Result.success(null);
}
}
你会发现,虽然参数校验确实生效了:
但是全局异常似乎没有捕获到这个异常,最终又交给了SpringBoot处理:
{
"timestamp": "2021-02-08T02:57:27.025+00:00",
"status": 400,
"error": "Bad Request",
"message": "",
"path": "/getUser"
}
这是怎么回事呢?
实际上,从GET“散装参数”变成“DTO参数”后,校验异常从ConstraintViolationException变成了BindException(见上面的截图),所以需要另外定义:
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/* 省略业务异常、运行时异常等其他异常处理*/
/**
* BindException异常
*
* @param e
* @return
*/
@ExceptionHandler(BindException.class)
public Result
重新请求:
{
"code": 10000,
"message": "id不能为空 && 年龄不小于18",
"data": null
}
@PostMapping("updateUser")
public Result updateUser(@Validated @RequestBody User user) {
System.out.println("进来了");
return Result.success(null);
}
和GET DTO参数校验形式上一样,但POST校验的异常又是另一种,所以全局异常处理又要加一种:
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/* 省略业务异常、运行时异常等其他异常处理*/
/**
* MethodArgumentNotValidException异常
*
* @param e
* @return
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 业务异常
*
* @param
* @return
*/
@ExceptionHandler(BizException.class)
public Result handleBizException(BizException bizException) {
log.warn("业务异常:{}", bizException.getMessage(), bizException);
return Result.error(bizException.getError());
}
/**
* 运行时异常
*
* @param e
* @return
*/
@ExceptionHandler(RuntimeException.class)
public Result handleRunTimeException(RuntimeException e) {
log.warn("运行时异常: {}", e.getMessage(), e);
return Result.error(ExceptionCodeEnum.ERROR);
}
/**
* ValidatorUtils校验异常
*
* @param e
* @return
* @see ValidatorUtils
*/
@ExceptionHandler(ValidatorException.class)
public Result handleValidatorException(ValidatorException e) {
// 打印精确的参数错误日志,方便后端排查
log.warn("参数校验异常: {}", e.getMessage(), e);
// 一般来说,给客户端展示泛化的错误信息即可,联调时可以返回精确的信息
return Result.error(e.getMessage());
}
/**
* ConstraintViolationException异常(散装GET参数校验)
*
* @param e
* @return
* @see ValidatorUtils
*/
@ExceptionHandler(ConstraintViolationException.class)
public Result handleConstraintViolationException(ConstraintViolationException e) {
log.warn("参数错误: {}", e.getMessage(), e);
return Result.error(ExceptionCodeEnum.ERROR_PARAM, e.getMessage());
}
/**
* BindException异常(GET DTO校验)
*
* @param e
* @return
*/
@ExceptionHandler(BindException.class)
public Result
Spring Validation还有一些校验场景,这里补充一下:
@Validated不支持嵌套校验,只能用@Valid:
@Data
public class User {
@NotNull(message = "id不能为空")
private Long id;
@NotNull(message = "年龄不能为空")
@Max(value = 35, message = "年龄不超过35")
@Min(value = 18, message = "年龄不小于18")
private Integer age;
@NotNull(message = "所属部门不能为空")
@Valid
private Department department;
@Data
static class Department {
@NotNull(message = "部门编码不能为空")
private Integer sn;
@NotBlank(message = "部门名称不能为空")
private String name;
}
}
@Data
public class User {
@NotNull(message = "id不能为空", groups = {Update.class})
private Long id;
@NotNull(message = "年龄不能为空", groups = {Add.class, Update.class})
@Max(value = 35, message = "年龄不超过35", groups = {Add.class, Update.class})
@Min(value = 18, message = "年龄不小于18", groups = {Add.class, Update.class})
private Integer age;
public interface Add {
}
public interface Update {
}
}
@Slf4j
@RestController
public class UserController {
@PostMapping("insertUser")
public Result insertUser(@Validated(User.Add.class) @RequestBody User user) {
System.out.println("进来了");
return Result.success(null);
}
@PostMapping("updateUser")
public Result updateUser(@Validated(User.Update.class) @RequestBody User user) {
System.out.println("进来了");
return Result.success(null);
}
}
有两点需要注意:
@Data
public class User {
// 只在Update分组下生效
@NotNull(message = "id不能为空", groups = {Update.class})
private Long id;
// 此时如果没执行Group,那么无论什么分组,都会校验
@NotNull(message = "年龄不能为空")
@Max(value = 35, message = "年龄不超过35")
@Min(value = 18, message = "年龄不小于18")
private Integer age;
public interface Add extends Default {
}
public interface Update extends Default {
}
}
继承Default后,除非显示指定,否则只要加了@NotNull等注解,就会起效。但显示指定Group后,就按指定的分组进行校验。比如,上面的id只会在update时校验生效。
个人不建议继承Default,一方面是理解起来比较乱,另一方是加了Default后就无法进行部分字段更新了。比如:
@PostMapping("updateUser")
public Result updateUser(@Validated(User.Update.class) @RequestBody User user) {
System.out.println("进来了");
return Result.success(null);
}
@Data
public class User {
@NotNull(message = "id不能为空", groups = {Update.class})
private Long id;
@NotNull(message = "年龄不能为空")
private Integer age;
@NotBlank(message = "住址不能为空")
private String address;
public interface Add extends Default {
}
public interface Update extends Default {
}
}
此时如果想更新name,就不能只传id和name了,address也要传(默认也会校验)。当然,你也可以认为一般情况下update前都会有getById(),所以更新时数据也是全量的。
Spring Validation不支持以下方式校验:
@Data
public class User {
@NotNull(message = "id不能为空")
private Long id;
@NotNull(message = "年龄不能为空")
private Integer age;
}
@PostMapping("updateBatchUser")
public Result updateBatchUser(@Validated @RequestBody List list) {
System.out.println(list);
return Result.success(null);
}
即使age不填,还是进来了,说明对于List而言,@Validated根本没作用:
解决办法是,借鉴嵌套校验的模式,在List外面再包一层:
@PostMapping("updateBatchUser")
public Result updateBatchUser(@Validated @RequestBody ValidationList userList) {
System.out.println(userList);
return Result.success(null);
}
public class ValidationList implements List {
@NotEmpty(message = "参数不能为空")
@Valid
private List list = new LinkedList<>();
@Override
public int size() {
return list.size();
}
@Override
public boolean isEmpty() {
return list.isEmpty();
}
@Override
public boolean contains(Object o) {
return list.contains(o);
}
@Override
public Iterator iterator() {
return list.iterator();
}
@Override
public Object[] toArray() {
return list.toArray();
}
@Override
public T[] toArray(T[] a) {
return list.toArray(a);
}
@Override
public boolean add(E e) {
return list.add(e);
}
@Override
public boolean remove(Object o) {
return list.remove(o);
}
@Override
public boolean containsAll(Collection> c) {
return list.containsAll(c);
}
@Override
public boolean addAll(Collection extends E> c) {
return list.addAll(c);
}
@Override
public boolean addAll(int index, Collection extends E> c) {
return list.addAll(index, c);
}
@Override
public boolean removeAll(Collection> c) {
return list.removeAll(c);
}
@Override
public boolean retainAll(Collection> c) {
return list.retainAll(c);
}
@Override
public void clear() {
list.clear();
}
@Override
public E get(int index) {
return list.get(index);
}
@Override
public E set(int index, E element) {
return list.set(index, element);
}
@Override
public void add(int index, E element) {
list.add(index, element);
}
@Override
public E remove(int index) {
return list.remove(index);
}
@Override
public int indexOf(Object o) {
return list.indexOf(o);
}
@Override
public int lastIndexOf(Object o) {
return list.lastIndexOf(o);
}
@Override
public ListIterator listIterator() {
return list.listIterator();
}
@Override
public ListIterator listIterator(int index) {
return list.listIterator(index);
}
@Override
public List subList(int fromIndex, int toIndex) {
return list.subList(fromIndex, toIndex);
}
public List getList() {
return list;
}
public void setList(List list) {
this.list = list;
}
}
实际开发时,建议专门建一个package存放Spring Validation相关的接口和类:
一起来封装一个SpringValidatorUtils:
public final class SpringValidatorUtils {
private SpringValidatorUtils() {}
/**
* 校验器
*/
private static final Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
/**
* 校验参数
*
* @param param 待校验的参数
* @param groups 分组校验,比如Update.class(可以不传)
* @param
*/
public static void validate(T param, Class>... groups) {
Set> validateResult = validator.validate(param, groups);
if (!CollectionUtils.isEmpty(validateResult)) {
StringBuilder validateMessage = new StringBuilder();
for (ConstraintViolation constraintViolation : validateResult) {
validateMessage.append(constraintViolation.getMessage()).append(" && ");
}
// 去除末尾的 &&
validateMessage.delete(validateMessage.length() - 4, validateMessage.length());
// 抛给全局异常处理
throw new ValidatorException(validateMessage.toString());
}
}
}
代码很简单,做的事情本质是和@Validated是一模一样的。@Validated通过注解方式让Spring使用Validator帮我们校验,而SpringValidatorUtils则是我们从Spring那借来Validator自己校验:
@PostMapping("insertUser")
public Result insertUser(@RequestBody User user) {
SpringValidatorUtils.validate(user);
System.out.println("进来了");
return Result.success(null);
}
此时不需要加@Validated。
买一送一,看看我之前一个同事封装的工具类(更加自由,调用者决定抛异常还是返回错误信息):
public final class ValidationUtils {
private static final Validator DEFAULT_VALIDATOR = Validation.buildDefaultValidatorFactory().getValidator();
private ValidationUtils() {
}
/**
* 验证基于注解的对象
*
* @param target
*/
public static String validateReq(T target, boolean throwException) {
if (null == target) {
return errorProcess("校验对象不能为空", throwException);
} else {
Set> constraintViolations = DEFAULT_VALIDATOR.validate(target);
ConstraintViolation constraintViolation = Iterables.getFirst(constraintViolations, null);
if (constraintViolation != null) {
// 用户可以指定抛异常还是返回错误信息
return errorProcess(constraintViolation.getPropertyPath() + ":" + constraintViolation.getMessage(),
throwException);
}
}
return "";
}
private static String errorProcess(String errorMsg, boolean throwException) {
if (throwException) {
throw new InvalidParameterException(errorMsg);
}
return errorMsg;
}
}
OK,至此对Spring Validation的介绍结束。
为什么@Validated这么方便,还要封装这个工具类呢?首先,很多人搞不清楚@Validated的使用或者觉得注解很碍眼,不喜欢。其次,也是最重要的,如果你想在Service层做校验,使用SpringValidatorUtils会方便些(Service有接口和实现类,麻烦些)。当然,Service也能用注解方式校验。
参数校验就介绍到这,有更好的方式欢迎大家评论交流。我个人曾经特别喜欢Spring Validation,后来觉得其实使用工具类也蛮好,想校验啥就写啥,很细腻,不用考虑乱七八糟的分组,而Spring Validation有时需要花费很多心思在分组上,就有点本末倒置了。
最后抛出两个问题:
作者简介:大家好,我是smart哥,前中兴通讯、美团架构师,现某互联网公司CTO
进群,大家一起学习,一起进步,一起对抗互联网寒冬