这段时间在调整老系统相关的一些业务代码;发现一些模块,在无形中就被弄的有点乱了,由于每个开发人员技术水平不同、编码习惯差异;从而导致在请求、响应、异常这一块儿,出现了一些比较别扭的代码;但是归根究底,主要问题还是出在规范上面;不管是大到项目还是小到功能模块,对于请求、响应、异常这一块儿,应该是一块儿公共的模板化的代码,一旦定义清楚之后,是不需要做任何改动,而且业务开发过程中,也几乎是不需要动到他丝毫;所以,一个好的规范下,是不应该在这部分代码上出现混乱或者别扭的情况的;忍不住又得来整理一下这一块儿的东西;
作为一个后台的工程师,接受请求、处理业务、解决异常、响应数据,几乎覆盖了日常开发的全部;但是这个中间,除了业务代码是不可避免且无可替代之外;其他的三项操作,不管是啥功能,也都是大同小异的,那我们要如何来把这一块儿的东西抽离出来,让我们只需要去管业务,不用去管那些杂七杂八的的破事儿,从而腾出更多的时间学(mo)习(yu)呢?当然就是得去定义一个好的规则,运用优秀的轮子;让这部分重复的、可复用的工作给模板化、标准化;
这样,开发一遍,后面就不需要再去弄这些通用的东西了。
思考一下,关于请求、响应、异常,我们到底要注意些啥问题呢?
请求
1. 如何优雅的
接受
数据?
2. 如何优雅的校验
数据?
响应
1.
响应数据格式
如何统一?
2. 错误码如何规范
?
3.如何将业务功能和响应给剥离开来?
异常
1.
异常
如何捕获?
2. 业务异常、校验异常如何合理的转换为友好的标准响应?
3. 如何规避未捕获到的异常并优雅返回标准响应?
这一些列的问题,就衍生出,我们该如何去规范的问题?任何利用已有的优秀框架去解决这些问题?
接下来,就通过一个完整的示例,基于这三个大点下面的小问题,去把这个规范给讲清楚;
讲每个大的问题点之前,我会给大家一个或几个疑问;然后可以带着这些疑问,边思考边看。
下面的介绍,我们就以一个简单的用户信息(UserInfo)的CURD展开
hibernate-validator优雅的处理请求
疑问
主要的目的是为了减少一些非必要的DTO对象
@RestController
@RequestMapping("user")
public class UserController{
@PostMapping("add")
public String add(@RequestBody UserAddRequestDto addInfo){
// ......
return "ok";
}
@PutMapping("update")
public void update(@RequestBody UserUpdateRequestDto updateInfo)
{
// ......
return "ok";
}
}
这样?嗯!这样确实可以接受到请求参数,但是我们回归到上面的疑问;
参数如何校验?难道这样?
if(null==addInfo.getUserName()){
throw new Exceprion();
}
if(null==addInfo.getPassWord()){
throw new Exceprion();
}
// 。。。。
固然可以,这样真的好吗?很明显不好。。。。劳力伤神的事儿,咱可不干。
addInfo和updateInfo大部分属性都是一样的,添加的字段,大部分都是可以进行修改的,但是也有部分是不可以修改的;比如密码,一般都是单独写接口进行修改;
既然大部分都一样;有必要定义这么多个请求的DTO对象吗?有必要!!没办法啊!大部分一样,他也有不一样的地方!
那有没有能优雅的去解决参数校验问题,又可以将请求对象合多为一呢?
hibernate-validator就是一个可以完美的解决这些问题的优秀框架;
接下来,我们就详细的来看一下,如何使用这个工具。
优点
解耦
,数据的校验与业务逻辑进行分离,降低耦合度
到controller的对象就已经是校验过的对象了,接受到之后就只需要安心处理业务就好,不用再进行数据校验相关逻辑
规范的校验方式
,减少参数校验所带来的繁琐体力活
以注解的方式配置校验规则;大大减少校验的工作量,而且复用性强
简洁代码
,提高代码的可读性
以注解方式即可完成属性校验,去掉了各种冗长的校验代码;且所有的校验规则都定义在对象内部;使得代码结构更加清晰,可读性非常强。
注解说明
下面包含了validator的所有内置的注解
注解 | 作用 |
---|---|
@AssertFalse | 被注释的元素必须为 false |
@AssertTrue | 被注释的元素必须为 true |
@DecimalMin(value) | 被注释的元素必须是一个数字,其值必须大于等于指定的最小值 |
@DecimalMax(value) | 被注释的元素必须是一个数字,其值必须小于等于指定的最大值 |
@Digits (integer, fraction) | 被注释的元素必须是一个数字,其值必须在可接受的范围内 |
被注释的元素必须是电子邮箱地址 | |
@Future | 被注释的元素必须是一个将来的日期 |
@Length(min=,max=) | 被注释的字符串的大小必须在指定的范围内 |
@Min(value) | 被注释的元素必须是一个数字,其值必须大于等于指定的最小值 |
@Max(value) | 被注释的元素必须是一个数字,其值必须小于等于指定的最大值 |
@Negative | 该值必须小于0 |
@NegativeOrZero | 该值必须小于等于0 |
@Null | 被注释的元素必须为 null |
@NotNull | 被注释的元素必须不为 null |
@NotBlank(message =) | 验证字符串非null,且长度必须大于0 |
@NotEmpty | 被注释的字符串的必须非空 |
@Past | 被注释的元素必须是一个过去的日期 |
@Pattern(regex=,flag=) | 被注释的元素必须符合指定的正则表达式 |
@Positive | 该值必须大于0 |
@PositiveOrZero | 该值必须大于等于0 |
@Range(min=,max=,message=) | 被注释的元素必须在合适的范围内 |
@Size(max=, min=) | 数组大小必须在[min,max]这个区间 |
@URL(protocol=,host,port) | 检查是否是一个有效的URL,如果提供了protocol,host等,则该URL还需满足提供的条件 |
@Valid | 该注解主要用于字段为一个包含其他对象的集合或map或数组的字段,或该字段直接为一个其他对象的引用,这样在检查当前对象的同时也会检查该字段所引用的对象 |
第一步;引入依赖
<dependency>
<groupId>org.hibernate.validatorgroupId>
<artifactId>hibernate-validatorartifactId>
dependency>
第二步;属性添加对应的注解
按照上面表格的说明,根据自己定义属性的特点,添加相应的注解。
如下示例,用户名,密码,年龄不能为空;那我们就用@NotBlank @NotNull
去修饰,如果违背规则,就会按message的文本提示
年龄不能小于0岁、大于120岁;那么就用@min @max
进行约束
message描述了违背校验规则之后的描述。
@Data
public class UserRequestDto {
/**
* 用户名
*/
@NotBlank(message = "姓名不能为空")
public String userName;
/**
* 密码
*/
@NotBlank(message = "密码不能为空")
public String passWord;
/**
* 年龄
*/
@NotNull(message = "年龄不能为空")
@Min(value = 0,message = "年龄不能小于0岁")
@Max(value = 120,message = "年龄不能大于120岁")
private Integer age;
/**
* 手机号码;使用正则进行匹配
*/
@NotBlank(message = "手机号码不能为空")
@Pattern(regexp = "^((13[0-9])|(14[5,7])|(15[0-3,5-9])|(17[0,3,5-8])|(18[0-9])|166|198|199|(147))\\d{8}$", message = "号码格式不正确!")
private String phoneNum;
// 。。。。
}
第三步,Controller的参数加上@Validated
@PostMapping("add")
public String add(@Validated @RequestBody UserRequestDto userRequestDto) {
// 。。。。
}
第四步,测试
第五步,异常处理
上面的操作可以看出,当请求参数如果不符合条件的话,就已经抛出异常并响应客户端了;
但是异常并没有针对性的处理,也没有进行友好的提示;前端收到错误之后,没办法根据错误信息准确的判断出是什么问题;因此对于的异常还需要进行特殊处理;
全局异常:
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
/**
* 处理自定义异常
*
*/
@ExceptionHandler(value = BusinessException.class)
public AjaxResult bizExceptionHandler(BusinessException e) {
log.error(e.getMessage(), e);
return AjaxResult.defineError(e);
}
@ExceptionHandler(value = MethodArgumentNotValidException.class)
public AjaxResult exceptionHandler( MethodArgumentNotValidException e) {
log.error(e.getMessage(), e);
return AjaxResult.otherError(e.getFieldError().getDefaultMessage(),500);
}
/**
*处理其他异常
*
*/
@ExceptionHandler(value = Exception.class)
public AjaxResult exceptionHandler( Exception e) {
log.error(e.getMessage(), e);
return AjaxResult.otherError(ErrorEnum.INTERNAL_SERVER_ERROR);
}
}
异常枚举类
public enum ErrorEnum {
// 数据操作错误定义
SUCCESS(200, "成功"),
NO_PERMISSION(403,"你没得权限"),
NO_AUTH(401,"未登录"),
NOT_FOUND(404, "未找到该资源!"),
INTERNAL_SERVER_ERROR(500, "服务器异常请联系管理员"),
;
/** 错误码 */
private Integer errorCode;
/** 错误信息 */
private String errorMsg;
ErrorEnum(Integer errorCode, String errorMsg) {
this.errorCode = errorCode;
this.errorMsg = errorMsg;
}
public Integer getErrorCode() {
return errorCode;
}
public String getErrorMsg() {
return errorMsg;
}
}
自定义异常
public class BusinessException extends RuntimeException{
private static final long serialVersionUID = 1L;
/**
* 错误状态码
*/
protected Integer errorCode;
/**
* 错误提示
*/
protected String errorMsg;
public BusinessException(){
}
public BusinessException(Integer errorCode, String errorMsg) {
this.errorCode = errorCode;
this.errorMsg = errorMsg;
}
public Integer getErrorCode() {
return errorCode;
}
public void setErrorCode(Integer errorCode) {
this.errorCode = errorCode;
}
public String getErrorMsg() {
return errorMsg;
}
public void setErrorMsg(String errorMsg) {
this.errorMsg = errorMsg;
}
}
通用返回类
public class AjaxResult {
//是否成功
private Boolean success;
//状态码
private Integer code;
//提示信息
private String msg;
//数据
private Object data;
public AjaxResult() {
}
//自定义返回结果的构造方法
public AjaxResult(Boolean success,Integer code, String msg,Object data) {
this.success = success;
this.code = code;
this.msg = msg;
this.data = data;
}
//自定义异常返回的结果
public static AjaxResult defineError(BusinessException de){
AjaxResult result = new AjaxResult();
result.setSuccess(false);
result.setCode(de.getErrorCode());
result.setMsg(de.getErrorMsg());
result.setData(null);
return result;
}
//其他异常处理方法返回的结果
public static AjaxResult otherError(ErrorEnum errorEnum){
AjaxResult result = new AjaxResult();
result.setMsg(errorEnum.getErrorMsg());
result.setCode(errorEnum.getErrorCode());
result.setSuccess(false);
result.setData(null);
return result;
}
public static AjaxResult otherError(String msg, Integer code){
AjaxResult result = new AjaxResult();
result.setMsg(msg);
result.setCode(code);
result.setSuccess(false);
result.setData(null);
return result;
}
public Boolean getSuccess() {
return success;
}
public void setSuccess(Boolean success) {
this.success = success;
}
public Integer getCode() {
return code;
}
public void setCode(Integer code) {
this.code = code;
}
public String getMsg() {
return msg;
}
public void setMsg(String msg) {
this.msg = msg;
}
public Object getData() {
return data;
}
public void setData(Object data) {
this.data = data;
}
}
上面我们已经将请求的参数以一种比较优雅的方式给验证了;但是并没有将请求对象合并,依然还是使用的addInfo和updateInfo对参数进行接受的;下面就一起来看一下,如何将这边同质化的对象进行优雅的合并。
group分组校验说明
上面的业务场景中添加和修改用户信息,添加的时候,密码字段是必传的;修改的时候,密码是不需要传的;那我们能否把添加和修改所有用到的属性定义到一个对象中,然后根据不同的请求,去校验参数,比如,调用添加接口,密码是必传的;调用修改接口,就不需要传密码;为了能做到接口区分校验,就可以用到group这个关键参数;
group的理解
可以简单的理解就是把各个属性进行分组;校验的时候,会根据当前Controller指定的组进行校验,这些组里面包含了那些属性,就只校验那些属性,其他不在范围内的,就直接给忽略调掉。
group定义
group的定义是以接口为基本单元;也就是一个接口代表一个组;
使用示例
定义基础的、修改、添加的接口(group)
// 基础的校验接口,标识着所有操作都需要校验的字段
public interface UserRequestDtoSimpleValidate {};
// 修改的校验;继承自UserRequestDtoSimpleValidate
// 也就是说指定为这个组的时候在满足当前校验规则的同时还得校验simple接口的属性
public interface UserRequestDtoUpdateValidate extends UserRequestDtoSimpleValidate {}
// 原理同上
public interface UserRequestDtoAddValidate extends UserRequestDtoUpdateValidate {}
属性校验添加上分组配置
@Data
public class UserRequestDtoGroups {
/**
* 用户名
*/
@NotBlank(message = "姓名不能为空",groups = UserRequestDtoSimpleValidate.class)
public String userName;
/**
* 密码
*/
@NotBlank(message = "密码不能为空",groups = UserRequestDtoAddValidate.class)
public String passWord;
/**
* 年龄
*/
@NotNull(message = "年龄不能为空",groups = UserRequestDtoSimpleValidate.class)
@Min(value = 0,message = "年龄不能小于0岁",groups = UserRequestDtoSimpleValidate.class)
@Max(value = 120,message = "年龄不能大于120岁",groups = UserRequestDtoSimpleValidate.class)
private Integer age;
/**
* 手机号码;使用正则进行匹配
*/
@NotBlank(message = "手机号码不能为空",groups = UserRequestDtoAddValidate.class)
@Pattern(regexp = "^((13[0-9])|(14[5,7])|(15[0-3,5-9])|(17[0,3,5-8])|(18[0-9])|166|198|199|(147))\\d{8}$", message = "号码格式不正确!",groups = UserRequestDtoAddValidate.class)
private String phoneNum;
}
Controller指定分组进行校验
如下@Validated中,指定分组接口类;可以一个,也可以多个,这样就会按照指定的分组进行参数校验
@PostMapping("add")
public String add(@Validated(UserRequestDtoAddValidate.class) @RequestBody UserRequestDtoGroups dtoGroups) {
// 后续业务
return "ok";
}
@PutMapping("update")
public void update(@Validated(UserRequestDtoUpdateValidate.class) @RequestBody UserRequestDtoGroups dtoGroups) {
// 后续业务
}
测试:
结果没有报错,因为熟悉走的是新增的校验group,而controller写的是修改的group,所以不会报错。
;修改属性的校验分组修改为修改的校验。
因为分组一致则进行了校验。
上面的所有校验,全部使用的是内置的注解,实际的使用过程中,不可避免的有一些特殊的业务场景,参数规则太过于个性化,内置的注解无法满足我们的需求时,要怎么办?比如说,文本必须全部是大写或者小写(该需求其实也可以通过正则表达式的方式进行);为了剧情需要,那我们可以基于这个需求,来自定义一个校验器;
定义大小写的枚举
用于注解使用的时候,来指定是校验规则是大写的还是小写的
public enum CaseMode {
//大写
UPPER,
//小写
LOWER;
}
定义校验大小写的注解
@Documented
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
//指定校验器
@Constraint(validatedBy = CaseCheckValidator.class)
public @interface CaseCheck {
String message() default "";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
CaseMode value() default CaseMode.UPPER;
}
这里面,我们将CaseMode枚举作为了注解中的value参数,可以根据需要动态设置大小写的参数,这里默认就是大写的;
@Constraint(validatedBy = CaseCheckValidator.class)
指明的使用CaseCheckValidator这个校验器进行数据校验;具体的校验规则,判断逻辑,就是写在这个校验器里面。
自定义校验器
public class CaseCheckValidator implements ConstraintValidator<CaseCheck, String> {
//大小写的枚举
private CaseMode caseMode;
@Override
public void initialize(CaseCheck caseCheck) {
this.caseMode = caseCheck.value();
}
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
//如果文本是空,则不进行校验,因为有其他的注解是可以校验空或者空字符串的
if (null == value) {
return true;
}
//文本只能是字母的正则
String pattern = "^[a-zA-Z]*$";
//校验传进来的是否是只包含了字母的文本
boolean isMatch = Pattern.matches(pattern, value);
//如果存在其他字符则返回校验失败
if (!isMatch) {
return false;
}
//如果没有指定方式,则直接返回false
if (null == caseMode) {
return false;
}
//判断是否符合大小写条件
if (caseMode == CaseMode.UPPER) {
return value.equals(value.toUpperCase());
} else {
return value.equals(value.toLowerCase());
}
}
}
泛型说明
该校验器继承自ConstraintValidator
这个接口;并传递了两个泛型参数;第一个是指明你自定义的注解;第二个是该注解作用的属性类型;
校验初始化
如果属性添加了该校验器对应的注解,就会初始化(initialize)该校验器时,将你加在属性上面的注解传递进来;
验证
初始化完会调用isValid方法·,并传递属性值;拿到属性值之后,就可以根据初始化传入的注解指定的规则,对属性值进行校验。验证通过返回true,并进行下一个属性的校验;验证失败返回false,并抛出异常;
测试
/**
* 用户名
*/
@NotBlank(message = "姓名不能为空",groups = UserRequestDtoSimpleValidate.class)
@CaseCheck(value = CaseMode.UPPER,message = "用户名必须大写字母",groups = UserRequestDtoSimpleValidate.class)
public String userName;
// 。。。。