前言
其实我是一个工作不久的新人,虽然经常写着大家常说的增删改查功能,但还是快乐充实的不行~
说到业务开发就离不开入参校验(或者以前说的表单验证),看着同事一遍遍写啊写的 if else
我就感觉这不是一个很好的编程实践,虽然看着逻辑很直观简单,但是重复的代码也太多了吧···
在寻找解决方案的过程中了解到两个框架,Hibernate Validator 和 OVal 。我先学会了使用Hibernate Validator ,后来在没仔细看OVal的情况下感觉差不多(不好意思,有空再去细细看看它的文档),因为我们的项目中已经有Hibernate Validator的依赖,所以就继续使用这个了(好吧,其实是因为我没仔细看OVal的文档)。
在使用过程中,我这个菜鸟自然是碰到了不少坑,不过还好一一解决了,没有影响按时发版上线,那么就趁记忆还新鲜的时候纪录一下。
使用注解校验
能用的注解很多其他博客也列出来了,我这里简单的引用一下其他文章的内容,并加上一些最新可用的注解吧。(貌似是网上最全的,我参考官方文档补充的)
注解 | 可以使用的类型 | 作用 |
---|---|---|
Bean Validation API 中列出的 | *************** | *************** |
@AssertFalse | Boolean, boolean | 验证注解的元素值是false |
@AssertTrue | Boolean, boolean | 验证注解的元素值是true |
@DecimalMax(value=值) | BigDecimal,BigInteger, byte, short, int, long,等任何Number或CharSequence(存储的是数字)或 javax.money.MonetaryAmount 子类型 | 验证注解的元素值小于等于@ DecimalMax指定的value值 |
@DecimalMin(value=值) | 和@DecimalMax要求一样 | 验证注解的元素值大于等于@ DecimalMin指定的value值 |
@Digits(integer=整数位数, fraction=小数位数) | 和@DecimalMax要求一样 | 验证注解的元素值的整数位数和小数位数上限 |
@Email(regexp=正则表达式, flag=标志的模式) | CharSequence子类型(如String) | 验证注解的元素值是Email,也可以通过regexp和flag指定自定义的email格式 |
@Future | java.util.Date, java.util.Calendar; Joda Time类库的日期类型; Java8 增加的java.time中的类,等 | 验证注解的元素值(日期类型)比当前时间晚 |
@FutureOrPresent | 与@Future要求一样 | 验证注解的元素值(日期类型)等于当前时间或比当前时间晚 |
@Max(value=值) | 和@DecimalMax要求一样 | 验证注解的元素值小于等于@Max指定的value值 |
@Min(value=值) | 和@DecimalMax要求一样 | 验证注解的元素值大于等于@Min指定的value值 |
@NotBlank | CharSequence子类型 | 验证注解的元素值不为空(不为null、去除首位空格后长度为0),不同于@NotEmpty,@NotBlank只应用于字符串且在比较时会去除字符串的首位空格 |
@NotEmpty | CharSequence子类型、Collection、Map、数组 | 验证注解的元素值不为null且不为空(字符串长度不为0、集合大小不为0) |
@NotNull | 任意类型 | 验证注解的元素值不是null |
@Negative | 和@DecimalMax要求一样 | 验证注解的元素是不是负数,0不能通过验证 |
@NegativeOrZero | 和@DecimalMax要求一样 | 验证注解的元素是不是非负数 |
@Null | 任意类型 | 验证注解的元素值是null |
@Past | 与@Future要求一样 | 验证注解的元素值(日期类型)比当前时间早 |
@PastOrPresent | 与@Future要求一样 | 验证注解的元素值(日期类型)比当前时间早或等于当前时间 |
@Pattern(regexp=正则表达式,flag=标志的模式) | String,任何CharSequence的子类型 | 验证注解的元素值与指定的正则表达式匹配 |
@Positive | 和@DecimalMax要求一样 | 验证注解的元素是不是正数,0不能通过验证 |
@PositiveOrZero | 和@DecimalMax要求一样 | 验证注解的元素是不是非正数 |
@Size(min=下限, max=上限) | 字符串、Collection、Map、数组等 | 验证注解的元素值的在min和max(包含)指定区间之内,如字符长度、集合大小 |
Hibernate Validator 额外提供的,很多类型我也没用过,具体如何设置里面的参数还需要学习,或者可以参考源码 | *************** | *************** |
@CreditCardNumber(ignoreNonDigitCharacters=是否忽略其中的非数字字符,默认false) | CharSequence | 校验字符是否通过 Luhn 算法,这个校验的目的是校验用户错误的,不能真的检测信用卡号的有效性 |
@Currency(value=) | 任何 javax.money.MonetaryAmount 的子类 | 校验货币的单位是否在 javax.money.MonetaryAmount 中 |
@DurationMax(days=天数, hours=小时数, minutes=分钟数, seconds=秒数, millis=毫秒数, nanos=纳秒数, inclusive=是否允许相等) | java.time.Duration | 判断被注解的元素是否不大于注解的时间长度 |
@DurationMin(days=天数, hours=小时数, minutes=分钟数, seconds=秒数, millis=毫秒数, nanos=纳秒数, inclusive=是否允许相等) | java.time.Duration | 判断被注解的元素是否不小于注解的时间长度 |
@EAN | CharSequence | 校验字符序列是否是有效的国际商品编号 |
@ISBN | CharSequence | 校验字符序列是否是有效的国际标准书号 |
@Length(min=下限, max=上限) | CharSequence子类型 | 验证注解的元素值长度在min和max区间内 |
@CodePointLength(min=最小值, max=最大值, normalizationStrategy=如果设置就校验归一化值) | CharSequence | 校验Unicode 中的 code point 的长度是否在注解的最大最小值之间 |
@LuhnCheck(startIndex=被注解序列子数字开始位置 , endIndex=被注解序列子数字结束位置, checkDigitIndex=检查点位置, ignoreNonDigitCharacters=是否忽略非数字字符) | CharSequence | 校验数字是否满足 Luhn 校验算法 |
@Mod10Check(multiplier=对于奇数的乘数(默认3), weight=对于偶数的权重(默认1), startIndex=被注解序列子数字开始位置, endIndex=被注解序列子数字结束位置, checkDigitIndex=检查点位置, ignoreNonDigitCharacters=是否忽略非数字字符) | CharSequence | 校验数字是否能通过 mod 10 算法 |
@Mod11Check(threshold=mod11乘数增长的阈值, startIndex=被注解序列子数字开始位置, endIndex=被注解序列子数字结束位置, checkDigitIndex=检查点位置, ignoreNonDigitCharacters=是否忽略非数字字符, treatCheck10As=指定当mod 11校验和等于10时要使用的校验位, treatCheck11As=指定当mod 11校验和等于11时要使用的校验位) | CharSequence | 校验数字能否通过 mod 11 算法 |
@Range(min=最小值, max=最大值) | BigDecimal, BigInteger, CharSequence, byte, short, int, long等原子类型和包装类型 | 验证注解的元素值在最小值和最大值之间 |
@SafeHtml(whitelistType=可预先定义白名单的类型 , additionalTags=允许添加的无属性标签, additionalTagsWithAttributes=允许添加特定的带属性标签, baseURI=允许指定特定URI来解析) | CharSequence | 校验字段是否含有潜在的威胁片段 |
@ScriptAssert(lang=任何能在 JSR223 中找到的脚本语言或者表达式, script=, alias=, reportOn=如果使用在类级别上,可以指定用在特定的属性上) | 任何类型 | 校验给定的脚本能否成功根据注解的属性执行 |
@UniqueElements | 集合Collection | 校验集合含有独一无二的元素,是否相等通过 equals() 方法判断 |
@URL(protocol=协议, host=主机名, port=端口, regexp=正则表达式, flags=) | CharSequence | 根据RFC2396来校验是否是一个有效的 URL,regexp和flags允许我们指定一个特殊的正则表达式 |
@Valid | 任何非原子类型 | 指定递归验证关联的对象;如用户对象中有个地址对象属性,如果想在验证用户对象时一起验证地址对象的话,在地址对象上加@Valid注解即可级联验证 |
使用方法
Bean上注解的添加
在我们写业务代码时,主要就是用来校验前端传来的参数,一般我会根据前端需要回传的参数建立特定的DTO,然后在各个变量(属性)上添加它们所需的校验注解,一个例子如下(字段设置不一定合理,主要为了多展示几种情况):
public class User {
/**
* 暂存校验组
*/
public interface Save {}
/**
* 提交校验组
*/
public interface Submit {}
/**
* 用户id
*/
@NotBlank(message = "id不能为空", groups = {Submit.class})
@Length(max = 32, message = "id长度最大为32", group = {Save.class, Submit.class})
private Integer id;
/**
* 用户名
*/
@NotBlank(message = "用户名不能为空", groups = {Submit.class})
@Length(max = 50, message = "用户名长度最大为50", group = {Save.class, Submit.class})
private String username;
/**
* 年龄
*/
@NotNull(message = "年龄不能为空", groups = {Submit.class})
@Min(value = 18, message = "未成年不能注册", group = {Save.class, Submit.class})
private Integer age;
/**
* 手机号
*/
@NotBlank(message = "手机号不能为空", groups = {Submit.class})
@Pattern(regexp = "1[0-9]{10}", message = "手机号格式错误", group = {Save.class, Submit.class})
private String phone;
/**
* 出生日期
*/
@DateTimeFormat(pattern = "yyyy-MM-dd")
@NotNull(message = "出身日期不能为空", groups = {Submit.class})
@Past(message = "出生日期不能后于当前日期"), group = {Save.class, Submit.class}
@JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+08")
private Date birthday;
/**
* 注册费
*/
@NotNull(message = "注册费不能为空不能为空", groups = {Submit.class})
@Digits(integer = 10, fraction = 2, message = "注册费金额格式有误", group = {Save.class, Submit.class})
private BigDecimal money;
// getters and setters ...
提一下上面的几个注意点:
- 我首先定义了两个接口,用于区分两种校验组。在暂存校验组中,不会校验必填字段,只关注填了内容的字段内容是否合理,如果字段为null是能通过格式校验的,这个可以看看这些注解的javadoc就知道了;
- 在提交校验组中,就需要校验必填项是否都填了,同时也要校验格式;
- 对于时间类的字段,需要加
@DateTimeFormat
用来反序列化时告诉fastjson
(我们用了阿里巴巴这个工具)如何转换日期时间格式; - 如果返回参数也用这个类进行序列化的话,需要在时间类上加注解
@JsonFormat
告诉工具如何序列化时间的格式;
在controller入参中实现校验
在现在前后端分离的架构中,一般前端以json格式传入的参数,会通过spring自动绑定到controller中的入参,那么我的目的就是在入参绑定时去校验参数,从而将这一部分的校验逻辑剥离出业务逻辑。
这里以上面的bean为例,模拟一个注册接口,虽然注册一般不会有暂存的情况,但是为了演示校验组的用法,这里还是写了【暂存】和【提交】两种状态。
@RestController
@RequestMapping(value = "/user")
public class UserController {
@Autowired
private UserRepository userRepository;
@PostMapping("/save")
public User saveUser(@Validated({User.Save.class}) User user) {
return userRepository.save(user);
}
@PostMapping("/submit")
public User submitUser(@Validated({User.Submit.class}) User user) {
return userRepository.save(user);
}
}
我们在入参前面加上注解 @Validated
并跟上校验组,表示这个入参需要在绑定参数时校验,并使用指定校验组校验。这里我没有像有些文章介绍那样,在被校验的入参后面再增加一个 BindingResult
用于接收校验错误,这样的话,处理校验错误的逻辑还是要放在我们的方法中,下一节将介绍如何处理。
当然这种校验方法不仅局限于controller,也能用在其他方法上,不过处理抛出的校验错误就不能借鉴下一节的方法了,可以自己写一个注解,定义切面用AOP来处理。
通过AOP统一处理controller入参校验错误
以下是直接使用spring提供的注解来处理controller抛出的异常,其实也相当于使用了AOP。
另外,关于 @ExceptionHandler
注解中的两个异常:第一个异常 BindException
是在controller中对入参使用 @Validated
(由spring提供) 注解产生的异常;第二个异常 MethodArgumentNotValidException
是在controller中对入参使用 @Valid
(由javax提供)注解产生的异常,这里推荐使用前一个注解 @Validated
,因为这样可以使用校验组的写法,而第二个注解无法填写更多参数。
@ControllerAdvice
public class ExceptionHandlerAdvice {
private static final Logger logger = LoggerFactory.getLogger(ExceptionHandlerAdvice.class);
@ExceptionHandler(value = {BindException.class, MethodArgumentNotValidException.class})
public void handleValidException(HttpServletResponse response, Exception e) {
// ····自己想做的一些处理
// 处理校验错误
BindingResult bindingResult;
String resultMsg;
if (e instanceof BindException) {
bindingResult = ((BindException) e).getBindingResult();
} else {
bindingResult = ((MethodArgumentNotValidException) e).getBindingResult();
}
if (bindingResult.hasFieldErrors()) {
// 如果是类型错误的话,我们没法在bean中设置错误消息,所以单独处理
if ("typeMismatch".equals(bindingResult.getFieldError().getcode())) {
resultMsg = bindingResult.getFieldError().getField() + "类型错误";
} else {
esultMsg = bindingResult.getFieldError().getDefaultMessage();
}
}
// ····拿到错误信息,就可以自己想做的其他处理,如输出报错内容,记录日志等
}
}
其他问题
json序列化的一些注意事项
- 前面提到过的
@JsonFormat
,可以用来对付时间参数的序列化,当数据库中存的时间不含时区时,直接取出放到Date
类中并序列化,会造成时区的时间差,这时候在这个注解中添加如下属性就可以解决问题timezone = "GMT+08"
。 - 如果POJO中的时间想设置为Java8中提供的 LocalDateTime等类,低版本的
fastjson
无法支持指定格式传入,只能以yyyy-MM-ddThh-mm-ss
的格式传入;如果你给这个属性添加注解@DateTimeFormat(pattern = "yyyy-MM-dd")
企图将这样形式的时间转换成LocalDate,需要测试一下,并看看你的fastjson
支不支持。
最后
其实我还有一些校验的需求没有得到满足(比如前一个属性选“是”就校验后面几个,“否”就校验另外几个),当然自己也没有把这个校验框架的功能全部发挥出来,为了更好的了解这个框架,目前正在翻译它的最新文档,欢迎大家捧场,最新版在GitHub中HibernateValidator6.0.13_zh,本博客中也会跟着更新。