SpringBoot参数校验及统一异常处理最佳实践

SpringBoot全套教程 专栏收录该内容
17 篇文章 556 订阅 ¥9.90 ¥99.00

【订阅专栏合集,作者所有付费文章都能看(持续更新)】

推荐【Kafka教程https://bigbird.blog.csdn.net/article/details/108770504
推荐【rabbitmq教程https://bigbird.blog.csdn.net/article/details/81436980
推荐【Flink教程https://blog.csdn.net/hellozpc/article/details/109413465
推荐【JVM面试与调优教程https://bigbird.blog.csdn.net/article/details/113888604
推荐【SpringBoot全套教程https://blog.csdn.net/hellozpc/article/details/107095951
推荐【SpringCloud教程https://blog.csdn.net/hellozpc/article/details/83692496
推荐【Mybatis教程https://blog.csdn.net/hellozpc/article/details/80878563
推荐【SnowFlake教程https://blog.csdn.net/hellozpc/article/details/108248227
推荐【并发限流教程https://blog.csdn.net/hellozpc/article/details/107582771
推荐【Redis教程https://bigbird.blog.csdn.net/article/details/81267030
推荐【Netty教程https://blog.csdn.net/hellozpc/category_10945233.html

SpringBoot参数校验及统一异常处理最佳实践

在后端接口开发中,我们经常要对接口的请求参数进行“参数合法性性”检查。比如我们要进行入参的判空、格式检查等来避免程序出现异常。在请求参数很少的情况下,可以使用 if(){…} else{…} 方式逐个对参数进行判断,这种方式功能上完全没有问题,能够达到目的。但是在入参很多的场景下,这种方式会导致代码中充斥着大量的 if else 判断,很不优雅。那么有没有更好的方式来做这件事呢?

回答上述问题前我们不妨再来看看另一个问题。

在服务端开发中,还有个常见的问题。就是在程序中,我们不可避免地需要处理各种异常。因此代码中常常会看到大量的 try {…} catch {…} finally {…}语句。这同样造成了代码冗余,降低了代码可读性。那么有没有更优雅的方式来进行异常处理呢?

好了,细细品味上述问题,我们娓娓道来本文的主要内容。

Bean Validation

简介

Bean Validation,顾名思义,就是对Bean的数据校验。怎么在Bean上定义约束规则,以及如何校验,这就需要Java API规范(Java Specification)的支持。在Java领域,JSR(Java Specification Requests)就是一个制定业界标准的途径。JSR,即Java规范提案,是指向JCP(Java Community Process)提出新增一个标准化技术规范的正式请求。任何人、任何组织都可以提交JSR,以向Java平台增添新的API和服务。与Bean Validation相关的JSR有JSR 380、JSR 349、JSR 303。分别对应Bean Validation 2.0、Bean Validation 1.1和Bean Validation 1.0。Bean Validation只是制定了标准规范,并没有提供实现。目前市面上能找到的该规范的实现有2种,分别是Hibernate ValidatorApache BVal。目前官方推荐的使用最新版规范,即2.0版本(后续还会有新的版本),相对于1.x版本,2.0版本新增了许多实用的注解,比如@NotEmpty、@NotBlank、@Email等,这几个注解之前是Hibernate额外提供的,2.0标准发布后Hibernate退位让贤,将上述注解标注为@Deprecated(过期)。Bean Validation 2.0是随着JavaEE8发布的,因此最低的JDK要求为jdk8。并且随着Oracle把 JavaEE 移交给开源组织 Eclipse基金会,Java EE 更名为Jakarta EEJava Bean Validation 2.0 也正式更名为Jakarta Bean Validation 2.0。因此对于一些jar包,我们在maven仓库中经常能看见两种坐标,比如:

<dependency>
    <groupId>javax.validation</groupId>
    <artifactId>validation-api</artifactId>
    <version>2.0.1.Final</version>
</dependency>

<dependency>
    <groupId>jakarta.validation</groupId>
    <artifactId>jakarta.validation-api</artifactId>
    <version>2.0.1</version>
</dependency>

这两种jar包除了maven坐标(GAV)上有变化,其它地方没任何区别。从Jakarta Bean Validation 2.0开始,官方推荐的参考实现只有Hibernate validator了。因此通常我们还需要引入Hibernate validator的依赖,Hibernate Validator自6.x版本开始对JSR 380规范提供完整支持:

<dependency>
    <groupId>org.hibernate.validator</groupId>
    <artifactId>hibernate-validator</artifactId>
    <version>6.0.1.Final</version>
</dependency>

Spring Boot 2.x及之后就彻底摒弃了老版本的validation-api,进而集成了新版的jakarta.validation-api。因此在SpringBoot中只需要引入下列依赖即可:

<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

上述依赖隐式引入了hibernate-validator,而后者又引入了jakarta.validation-api。

常见的校验注解

Java Bean Validation 1.0(JSR303)

注解支持类型含义null值是否校验
@NotNullany元素不能为null
@Nullany元素必须为null
@AssertTruebool元素必须是true
@AssertFalsebool元素必须是false
@MinNumber的子类型(浮点数除外)以及String元素必须是一个数字,且值必须>=最小值
@Max同上元素必须是一个数字,且值必须<=最大值
@DecimalMaxNumber的子类型(浮点数除外)以及String元素必须是一个数字,且值必须<=最大值
@DecimalMin同上元素必须是一个数字,且值必须>=最小值
@SizeString、集合数组元素大小需在指定范围中
@Digits同上元素构成是否合法(整数部分和小数部分)
@Past同上元素必须为一个过去(不包含相等)的日期(比较精确到毫秒)
@Future时间类型(包括JSR310)元素必须为一个将来(不包含相等)的日期(比较精确到毫秒)
@Pattern字符串元素需符合指定的正则表达式

第一版提供了13个注解,除了@NotNull和@Null之外的注解对null是免疫的,即如果被校验的目标是null,是不会触发该属性对应的校验逻辑的。

Jakarta Bean Validation 2.0(JSR380)

注解支持类型含义null值是否校验
@NotEmpty容器类型集合的Size必须大于0
@NotBlank字符串字符串必须包含至少一个非空白的字符
@Positive数字类型元素必须为正数(不包括0)
@PositiveOrZero同上同上(包括0)
@Negative同上元素必须为负数(不包括0)
@NegativeOrZero同上同上(包括0)
@PastOrPresent时间类型在@Past基础上包括相等
@FutureOrPresent时间类型在@Futrue基础上包括相等
@Email字符串元素必须为电子邮箱地址

相较于validation 1.x版本,2.0版本在其基础上新增了9个实用注解。除了JSR标准提供的这22个注解外,Hibernate Validator还提供了一些非常实用的注解,本文就不做介绍了,有兴趣的可以去hibernate-validator源码中瞅瞅。

SpringBoot集成Validation

在编写案例之前,我们首先统一引入依赖,保证springboot2.x以上版本。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

RequestBody参数校验

通常POSTPUT请求会使用requestBody传递参数,后端使用对象实例进行接收。只要该对象实现了getter/setter方法,springmvc会为我们自动绑定对象。只要在对象加上@Validated或者@Valid注解就能实现自动参数校验。校验失败时会抛出MethodArgumentNotValidException异常,Spring默认会将其转为http 400(Bad Request)响应。

  • 在实例对象中声明约束注解

    public class User {
        @NotEmpty(message = "userId不能为空")
        @Length(min = 10, max = 10, message = "用户id长度为10")
        private String userId;
    
        @NotEmpty(message = "username不能为空")
        private String username;
    
        @NotEmpty(message = "email不能为空")
        @Email(message = "邮箱格式有误")
        private String email;
    
        @Range(min = 1, max = 150, message = "年龄范围1-150")
        private int age;
    
    	//省略getter/setter方法和toString方法
    }
    
  • 在方法参数上声明校验注解

    @Controller
    @RequestMapping("test1")
    public class MyController1 {
    
        @RequestMapping("/addUser")
        @ResponseBody
        public String addUser(@RequestBody @Validated User user) {
            System.out.println(user);
            return "OK";
        }
    }
    

    启动web应用,使用postman等工具测试接口 http://127.0.0.1:8080/test1/addUser。

    缺少必填参数时,可以看到控制台报异常:org.springframework.web.bind.MethodArgumentNotValidException,即校验生效。

RequestParam/PathVariable参数校验

GET请求一般会使用requestParam/PathVariable传参,因此可以在方法入参上直接声明约束注解。当然,如果参数比较多还是推荐使用对象接收。需要注意的是,这种方式下要使参数校验生效,必须在Controller类上标注@Validated注解,示例如下:

@Controller
@RequestMapping("test1")
@Validated
public class MyController1 {

    @RequestMapping("/addUser")
    @ResponseBody
    public String addUser(@RequestBody @Validated User user) {
        System.out.println(user);
        return "OK";
    }

    @RequestMapping("/getUser")
    @ResponseBody
    public User getUser(@NotEmpty(message = "userId不能为空") String userId) {
        User user = new User();
        user.setUserId(userId);
        user.setUsername("小明");
        user.setAge(33);
        return user;
    }
}

getUser接口缺少必填参数userId时,可以看到控制台报异常:javax.validation.ConstraintViolationException,即校验生效。

分组校验

在实际开发中,同一个服务(系统)中的同一个对象实例可能会被多个不同的方法使用,而不同的接口方法对入参的校验规则可能不一样。直接在实体类上加约束注解将对其它引用了同一个实体类的方法产生影响。spring validation通过注解@Validated提供了分组校验的功能,专门应对这类问题。当然,我们在接口开发中也可以避免重用入参对象,针对不同的接口定义不同的入参(Req)、出参(Res)实体对象,这样就不需要分组校验了。

  • 约束注解上声明分组信息groups

    比如在添加User时不需要传入userId,而在修改、查询用户信息时,用户id必填。在修改、保存用户时都要进行邮箱格式校验。

    public class User2 {
    
        @NotEmpty(message = "userId不能为空", groups = {Query.class, Update.class})
        @Length(min = 10, max = 10, message = "用户id长度为10", groups = Update.class)
        private String userId;
    
        @NotEmpty(message = "username不能为空", groups = {Save.class})
        private String username;
    
        @NotEmpty(message = "email不能为空", groups = {Save.class})
        @Email(message = "邮箱格式有误", groups = {Update.class, Save.class})
        private String email;
    
        @Range(min = 1, max = 150, message = "年龄范围1-150", groups = {Save.class, Update.class})
        private int age;
    }
    

    groups中的分组本质上就是个class类。因此我们可以根据自己的业务需求定义一些分组标记类。比如可以根据对数据的增删改查来进行约束规则分组。

    public interface Query {}
    public interface Save {}
    public interface Update {}
    public interface Delete {}
    
  • 在@Validated注解上指定分组

@Controller
@RequestMapping("test2")
@Validated
public class MyController2 {

    @RequestMapping("/addUser")
    @ResponseBody
    public String addUser(@RequestBody @Validated({Save.class}) User2 user) {
        System.out.println(user);
        return "OK";
    }

    @RequestMapping("/getUser")
    @ResponseBody
    public User2 getUser(@NotEmpty(message = "userId不能为空") String userId) {
        User2 user = new User2();
        user.setUserId(userId);
        user.setUsername("小明");
        user.setAge(33);
        return user;
    }

    @RequestMapping("/updateUser")
    @ResponseBody
    public User2 updateUser(@RequestBody @Validated({Update.class}) User2 user) {
        User2 updatedUser = new User2();
        updatedUser.setUserId(user.getUserId());
        updatedUser.setUsername(user.getUsername());
        updatedUser.setAge(user.getAge());
        System.out.println("updatedUser:" + updatedUser);
        return updatedUser;
    }
}

测试发现,约束规则只在指定的分组校验中生效了。

嵌套类校验

上述案例中,实体类User中的字段都是基本数据类型String类型。实际开发中,业务极其复杂,可能某个字段本身也是一个对象,这种情况可以使用嵌套校验。实体类中需要嵌套校验的字段必须标记@Valid注解。

/**
 * 嵌套实体类校验
 */
public class User3 {
    @NotEmpty(message = "userId不能为空", groups = {Update.class, Query.class})
    @Length(min = 10, max = 10, message = "用户id长度为10", groups = Update.class, Query.class)
    private String userId;
    @NotEmpty(message = "username不能为空", groups = {Save.class})
    private String username;
    @NotEmpty(message = "email不能为空", groups = {Save.class})
    @Email(message = "邮箱格式有误", groups = {Update.class, Save.class})
    private String email;
    @Range(min = 1, max = 150, message = "年龄范围1-150", groups = {Save.class, Update.class})
    private int age;
    @NotEmpty(message = "mobile不能为空", groups = {Save.class})
    private String mobile;
    @Valid
    private Address address;    //省略setter/getter方法
 }

嵌套类:

public class Address {

    @NotEmpty(message = "zipCode不能为空", groups = {Save.class})
    private String zipCode;

    @NotEmpty(message = "street不能为空")
    private String street;

    @NotEmpty(message = "houseNumber不能为空", groups = {Save.class})
    private String houseNumber;
    //省略setter/getter方法
}

嵌套校验支持和分组校验一起使用。如果嵌套的是集合类型,则会对集合中的每个元素都进行校验,例如List<Address>字段会对这个list里面的每一个Address对象都进行校验。

集合元素校验

比如前端请求传入的是json数组数据给后台,那么后端可以使用List集合来接收,并对集合中的每个元素都进行校验。这种方式使用的是 @Valid 注解标注参数, @Validated注解对普通的Java集合不会生效。使用@Valid注解处理集合类的校验,缺点是不能使用 @Validated的分组功能,因此需要为不同的校验需求提供不同的实体类。

/**
 * 集合校验
 */
public class User4 {

    private String userId;

    @NotEmpty(message = "username不能为空")
    private String username;

    @NotEmpty(message = "email不能为空")
    @Email(message = "邮箱格式有误")
    private String email;

    @Range(min = 1, max = 150, message = "年龄范围1-150")
    private int age;

    @PhoneNo(message = "mobile不能为空")
    private String mobile;

    @Valid
    private Address address;

   //省略setter/getter方法
  }

和requestParam/PathVariable参数校验一样,集合参数校验也需要在Controller类上加@Validated,否则校验不生效。

@Controller
@RequestMapping("test3")
@Validated
public class MyController3 {
    @RequestMapping("/addUserList")
    @ResponseBody
    public String addUserList(@RequestBody @Valid List<User4> users) {
        System.out.println(users);
        return "OK";
    }
}

自定义注解式校验

除了使用校验框架提供的现成的约束注解外,我们也可以自定义校验注解来满足复杂的业务需求。比如我们需要校验手机号是否是大陆手机号。

  • 自定义约束注解
/**
 * 手机号校验注解.可以参照框架自带的注解
 */
@Target({FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER})
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = {PhoneNoValidator.class})
public @interface PhoneNo {
    // 支持传入自定义的正则表达式
    String regexp() default ".*";

    // 默认错误消息
    String message() default "手机号格式错误";

    // 支持分组
    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}

  • 自定义注解对应Validator

public class PhoneNoValidator implements ConstraintValidator<PhoneNo, String> {

    //中国大陆目前开放的手机号号段
    private Pattern pattern = Pattern.compile("^((13[0-9])|(14[0,1,4-9])|(15[0-3,5-9])|(16[2,5,6,7])|(17[0-8])|(18[0-9])|(19[0-3,5-9]))\\d{8}$");

    @Override
    public void initialize(PhoneNo constraintAnnotation) {
        if (!".*".equals(constraintAnnotation.regexp())) {
            try {
                pattern = Pattern.compile(constraintAnnotation.regexp());
            } catch (PatternSyntaxException e) {
                throw new RuntimeException(e);
            }
        }
    }

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null) {
            return true;
        }
        return pattern.matcher(value).matches();
    }
}
  • 使用

    在User类中加入如下注解约束:

@PhoneNo(message = "mobile格式错误", groups = {Save.class, Update.class})
private String mobile;

编程式校验

上述案例中,我们都是通过注解方式实现的自动参数校验,是由SpringMVC封装好了的验证机制。某些场景下我们希望能够手动触发参数校验,此时我们可以直接使用javax.validation.Validator,直接创建该对象,调用其API。在Springboot中可以通过Java配置方式注入该对象。

@Configurationpublic class ValidatorConfig {    @Bean    public Validator validator() {        ValidatorFactory validatorFactory = Validation.byProvider(HibernateValidator.class)                .configure()                //设置快速失败模式                .failFast(false)                .buildValidatorFactory();        return validatorFactory.getValidator();    }}

如果快速失败设置为true,则不会等到所有校验完成后再返回所有的失败信息,只要有一个字段校验失败就立即返回。

在下面的addUser2方法中,我们不使用@Validated注解,而是手动调用Validatorvalidate方法进行参数验证,并获得验证结果。

@Controller@RequestMapping("test3")@Validatedpublic class MyController3 {    @Autowired    Validator validator;    @RequestMapping("/addUser")    @ResponseBody    public String addUser(@RequestBody @Validated({Save.class}) User3 user) {        System.out.println(user);        return "OK";    }    @RequestMapping("/addUser2")    @ResponseBody    public String addUser2(@RequestBody User2 user) {        System.out.println(user);        Set<ConstraintViolation<User2>> violations = validator.validate(user, Save.class);        if (!violations.isEmpty()) {            for (ConstraintViolation constraint : violations) {                //可以根据校验结果做下一步操作,比如输出失败的信息                System.out.println(constraint.getMessage());            }            return "Fail";        }        return "OK";    }}

Service接口参数校验

有的时候对外提供的Service接口也需要进行参数校验。尤其是现在微服务盛行,服务之间都是接口调用。如果都是以SpringMVC方式提供的接口,那么我们可以统一在Controller层进行参数校验;如果是RPC式的接口调用,那么我们可以在Service层进行参数校验。像Dubbo这种RPC框架都支持服务接口参数校验。

  • 在Service层使用注解约束校验

    Service接口

public interface OrderService {
    @Validated
    QueryOrderRes queryOrder(@Valid QueryOrderReq queryOrderReq);

    @Validated
    CreateOrderRes createOrder(@Valid CreateOrderReq createOrderReq);
}

接口实现

@Service
@Validated
public class OrderServiceImpl implements OrderService {
    @Override
    public QueryOrderRes queryOrder(@Valid QueryOrderReq queryOrderReq) {
        QueryOrderRes res = new QueryOrderRes();
        res.setOrderNo(queryOrderReq.getOrderNo());
        List<Product2> products = new ArrayList<>();
        products.add(new Product2("P0001", "苹果"));
        products.add(new Product2("P0002", "裤子"));
        res.setProducts(products);
        return res;
    }

    @Override
    public CreateOrderRes createOrder(@Valid CreateOrderReq createOrderReq) {
        CreateOrderRes res = new CreateOrderRes();
        res.setStatus("success");
        return res;
    }
}

实体类

public class QueryOrderReq {

    @NotBlank(message = "orderNo不能为空")
    private String orderNo;

    @NotBlank(message = "userId不能为空")
    private String userId;

    public String getOrderNo() {
        return orderNo;
    }

    public void setOrderNo(String orderNo) {
        this.orderNo = orderNo;
    }

    public String getUserId() {
        return userId;
    }

    public void setUserId(String userId) {
        this.userId = userId;
    }
}

Controller层(注意这一层未加任何约束校验注解)

@Controller
@RequestMapping("test6")
public class MyController6 {

    @Autowired
    OrderService orderService;

    @Autowired
    OrderService2 orderService2;


    @RequestMapping("/addOrder")
    @ResponseBody
    public CreateOrderRes addOrder(@RequestBody CreateOrderReq createOrderReq) {
        return orderService2.createOrder(createOrderReq);
    }

    @RequestMapping("/getOrder")
    @ResponseBody
    public QueryOrderRes getOrder(@RequestBody QueryOrderReq queryOrderReq) {
        return orderService2.queryOrder(queryOrderReq);
    }
}

上面演示了Service接口参数校验的场景。上面的例子虽然是通过SpringMVC方式访问的Service接口,但是可以看到,我们在Controller层没有加任何约束注解。实际上Service层的参数校验一般在RPC调用中更常见。尤其是非HTTP方式(非SpringMVC),比如基于长连接的RPC调用。实际上我们也可以采用手动编程方式校验,即手动校验请求对象。

  • 在Service层手动校验参数

    Service接口

public interface OrderService2 {
    QueryOrderRes queryOrder(@Valid QueryOrderReq queryOrderReq);
    CreateOrderRes createOrder(@Valid CreateOrderReq createOrderReq);
}

手动校验只需要在请求参数中加上@Valid 标记即可,无需接口层加约束注解。在实现类中调用Validator的validate方法手动校验入参对象。


@Service
public class OrderServiceImpl2 implements OrderService2 {
    @Autowired
    Validator validator;

    @Override
    public QueryOrderRes queryOrder(@Valid QueryOrderReq queryOrderReq) {
        Set<ConstraintViolation<QueryOrderReq>> violations = validator.validate(queryOrderReq);
        if (!violations.isEmpty()) {
            StringBuilder msg = new StringBuilder();
            for (ConstraintViolation constraint : violations) {
                //可以根据校验结果做下一步操作,比如输出失败的信息
                System.out.println(constraint.getMessage());
                msg.append(constraint.getMessage()).append(",");
            }
            String errMsg = msg.substring(0, msg.length() - 1);
            throw new BizException(ErrorCodeEnum.PARMA_ERROR.getErrorCode(),errMsg);
        }

        QueryOrderRes res = new QueryOrderRes();
        res.setOrderNo(queryOrderReq.getOrderNo());
        List<Product2> products = new ArrayList<>();
        products.add(new Product2("P0001", "苹果"));
        products.add(new Product2("P0002", "裤子"));
        res.setProducts(products);
        return res;
    }

    @Override
    public CreateOrderRes createOrder(@Valid CreateOrderReq createOrderReq) {
        CreateOrderRes res = new CreateOrderRes();
        res.setStatus("success");
        return res;
    }
}

这种对Validation的使用方式尤为灵活,更具有通用性。只要在需要校验的对象上加上@Valid注解,在需要参数校验的地方调用Validator的validate方法即可。有兴趣的读者也可以评论区探讨下如何优化对Service层的参数校验(尤其是在非SpringMVC框架下),比如是否可以通过AOP实现统一校验?

规范化数据响应和异常处理

之前的例子中,我们没有对参数校验的异常进行处理,都是直接由SpringMVC框架封装的错误信息返回给客户端。参数校验失败会自动引发异常,如果我们在Controller的每个方法中手动try{…}catch(){…},然后包装返回信息,那么代码无疑将显得很臃肿。其实我们可以使用@ControllerAdvice来配置SpringBoot全局异常处理。如果你用的是@RestController,那么对于的注解是@RestControllerAdvice

统一异常处理与响应

我们先来看看如何统一处理参数校验异常。参数异常主要涉及到两种,MethodArgumentNotValidException和ConstraintViolationException。

@ControllerAdvice注解可以指定生效的Controller类,传入Controller的class类型或者扫描路径都行。

@ControllerAdvice(assignableTypes = MyController4.class)
public class CommonExceptionHandler {
    @ExceptionHandler({MethodArgumentNotValidException.class})
    @ResponseStatus(HttpStatus.OK)
    @ResponseBody
    public CommonResult handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) {
        BindingResult bindingResult = ex.getBindingResult();
        StringBuilder sb = new StringBuilder("参数校验失败:");
        for (FieldError fieldError : bindingResult.getFieldErrors()) {
            sb.append(fieldError.getField()).append(":").append(fieldError.getDefaultMessage()).append(",");
        }
        String msg = sb.toString();
        msg = msg.substring(0, msg.length() - 1);
        return new CommonResult(-100001, msg, null);
    }

    @ExceptionHandler({ConstraintViolationException.class})
    @ResponseStatus(HttpStatus.OK)
    @ResponseBody
    public CommonResult handleConstraintViolationException(ConstraintViolationException ex) {
        return new CommonResult(-100002, "参数校验失败:" + ex.getMessage().split(":")[1], null);
    }
}

这里我们简单地定义了一个公共返回类型CommonResult

public class CommonResult implements Serializable {
    private static final long serialVersionUID = -504027247149928390L;

    private int code;
    private String msg;
    private Object data;

    public CommonResult() {
    }

    public CommonResult(int code, String msg, Object data) {
        this.code = code;
        this.msg = msg;
        this.data = data;
    }
    //省略getter/setter}
}

    

再看看Controller层如何改造

@Controller
@RequestMapping("test4")
@Validated
public class MyController4 {

    @RequestMapping("/addUser")
    @ResponseBody
    public CommonResult addUser(@RequestBody @Validated({Save.class}) User3 user) {
        System.out.println(user);
        return new CommonResult(0, "OK", user);
    }

    @RequestMapping("/addUserList")
    @ResponseBody
    public CommonResult addUserList(@RequestBody @Valid List<User4> users) {
        System.out.println(users);
        return new CommonResult(0, "OK", users);
    }

    @RequestMapping("/getUser")
    @ResponseBody
    public CommonResult getUser(@Validated @NotEmpty(message = "userId不能为空")
                                @Length(min = 10, max = 10, message = "userId长度为10") String userId) {
        User3 user = new User3();
        user.setUserId(userId);
        user.setUsername("小明");
        user.setAge(33);
        return new CommonResult(0, "OK", user);
    }

    @RequestMapping("/updateUser")
    @ResponseBody
    public CommonResult updateUser(@RequestBody @Validated({Update.class}) User3 user) {
        User3 updatedUser = new User3();
        updatedUser.setUserId(user.getUserId());
        updatedUser.setUsername(user.getUsername());
        updatedUser.setAge(user.getAge());
        System.out.println("updatedUser:" + updatedUser);
        return new CommonResult(0, "OK", updatedUser);
    }
}

可以看到Controller层统一返回了公共响应实体,这就和统一异常处理的返回一致了。

测试发现,参数校验异常后,客户端收到了统一的响应格式的数据。

思考

上面的示例已经实现了参数校验的统一异常处理,再进一步思考,我们能不能实现系统全局异常的统一处理?即包括运行时异常、自定义业务异常。

另外上面的实现方式中,需要在Controller的每个方法中封装一次统一响应,如果有几十上百个接口,这种重复的包装过程能否全局统一处理?

带着这些问题我们总结出一套较为通用的全局统一数据响应异常处理实践方案。

最佳实践

对于第一个问题,我们可以自定义业务异常,自定义返回码枚举,以及在全部异常处理里增加统一处理逻辑。

对于第二个问题,我们可以继承ResponseBodyAdvice接口重写其方法,再使用@RestController注解使其成为全局处理类对controller进行增强操作。

自定义错误码枚举

/**
 * 基础的错误信息接口类,自定义的错误枚举类需实现该接口
 */
public interface BaseErrorInterface {
    String getErrorCode();

    String getErrorMsg();
}

public enum ErrorCodeEnum implements BaseErrorInterface {

    SUCCESS("0", "成功"),
    FAIL("-1", "失败"),
    PARMA_ERROR("-100001", "参数校验错误"),
    SERVER_ERROR("-100002", "服务内部异常"),
    USER_NOT_FOUND_ERROR("-100003", "该用户不存在"),
    ORDER_NOT_FOUND_ERROR("-100004", "该订单不存在");

    private String errorCode;
    private String errorMsg;

    ErrorCodeEnum() {
    }

    ErrorCodeEnum(String errorCode, String errorMsg) {
        this.errorCode = errorCode;
        this.errorMsg = errorMsg;
    }

    @Override
    public String getErrorCode() {
        return errorCode;
    }

    @Override
    public String getErrorMsg() {
        return errorMsg;
    }
}

自定义业务异常

/**
 * 统一业务异常
 */
public class BizException extends RuntimeException {

    protected String errorCode;
    protected String errorMsg;

    public BizException() {
        super();
    }

    public BizException(String errorMsg) {
        super(errorMsg);
        this.errorMsg = errorMsg;
    }

    public BizException(String errorCode, String errorMsg) {
        super(errorCode + ":" + errorMsg);
        this.errorCode = errorCode;
        this.errorMsg = errorMsg;
    }

    public BizException(String errorCode, String errorMsg, Throwable cause) {
        super(errorCode, cause);
        this.errorCode = errorCode;
        this.errorMsg = errorMsg;
    }

    public BizException(BaseErrorInterface errorInterface) {
        super(errorInterface.getErrorCode()+":"+errorInterface.getErrorMsg());
        this.errorCode = errorInterface.getErrorCode();
        this.errorMsg = errorInterface.getErrorMsg();
    }

    public BizException(BaseErrorInterface errorInterface, Throwable cause) {
        super(errorInterface.getErrorCode(), cause);
        this.errorCode = errorInterface.getErrorCode();
        this.errorMsg = errorInterface.getErrorMsg();
    }

    public String getErrorCode() {
        return errorCode;
    }

    public void setErrorCode(String errorCode) {
        this.errorCode = errorCode;
    }

    public String getErrorMsg() {
        return errorMsg;
    }

    public void setErrorMsg(String errorMsg) {
        this.errorMsg = errorMsg;
    }
}

全局统一响应数据

/**
 * 全局统一响应体
 */
public class Response {

    //返回码,非0的返回码表示异常
    private String retCode;
    //返回信息
    private String retMsg;
    //响应数据
    private Object data;

    public Response() {
    }

    public Response(String retCode) {
        this(retCode, "");
    }

    public Response(String retCode, String retMsg) {
        this(retCode, retMsg, null);
    }

    public Response(String retCode, String retMsg, Object data) {
        this.retCode = retCode;
        this.retMsg = retMsg;
        this.data = data;
    }

    public Response(BaseErrorInterface errorInterface) {
        this.retCode = errorInterface.getErrorCode();
        this.retMsg = errorInterface.getErrorMsg();
    }

    public Response(BaseErrorInterface errorInterface, Object data) {
        this.retCode = errorInterface.getErrorCode();
        this.retMsg = errorInterface.getErrorMsg();
        this.data = data;
    }

    public static Response ok() {
        return ok(null);
    }

    public static Response ok(Object data) {
        Response response = new Response();
        response.setRetCode(ErrorCodeEnum.SUCCESS.getErrorCode());
        response.setRetMsg(ErrorCodeEnum.SUCCESS.getErrorMsg());
        response.setData(data);
        return response;
    }

    public static Response fail() {
        Response response = new Response();
        response.setRetCode(ErrorCodeEnum.FAIL.getErrorCode());
        response.setRetMsg(ErrorCodeEnum.FAIL.getErrorMsg());
        return response;
    }

    public static Response fail(String retCode) {
        Response response = new Response();
        response.setRetCode(retCode);
        return response;
    }

    public static Response fail(String retCode, String retMsg) {
        Response response = new Response();
        response.setRetCode(retCode);
        response.setRetMsg(retMsg);
        return response;
    }

    public static Response fail(BaseErrorInterface errorInterface) {
        Response response = new Response();
        response.setRetCode(errorInterface.getErrorCode());
        response.setRetMsg(errorInterface.getErrorMsg());
        response.setData(null);
        return response;
    }

    public String getRetCode() {
        return retCode;
    }

    public void setRetCode(String retCode) {
        this.retCode = retCode;
    }

    public String getRetMsg() {
        return retMsg;
    }

    public void setRetMsg(String retMsg) {
        this.retMsg = retMsg;
    }

    public Object getData() {
        return data;
    }

    public void setData(Object data) {
        this.data = data;
    }
}

全局统一异常处理

/**
 * 全局统一异常处理
 */
//@ControllerAdvice(basePackages = "com.example.validation.controller")
@ControllerAdvice(assignableTypes = MyController6.class)
public class GlobalExceptionHandler {

    /**
     * 实体类参数校验
     *
     * @param e
     * @return
     */
    @ExceptionHandler({MethodArgumentNotValidException.class})
    @ResponseBody
    public Response handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
        System.out.println(e);
        BindingResult bindingResult = e.getBindingResult();
        StringBuilder sb = new StringBuilder();
        for (FieldError fieldError : bindingResult.getFieldErrors()) {
            sb.append(fieldError.getField()).append(":").append(fieldError.getDefaultMessage()).append(",");
        }
        String msg = sb.toString();
        msg = msg.substring(0, msg.length() - 1);
        return new Response(ErrorCodeEnum.PARMA_ERROR, msg);
    }

    /**
     * 直接参数校验
     *
     * @param e
     * @return
     */
    @ExceptionHandler({ConstraintViolationException.class})
    @ResponseBody
    public Response handleConstraintViolationException(ConstraintViolationException e) {
        System.out.println(e);
        StringBuilder sb = new StringBuilder();
        Set<ConstraintViolation<?>> constraintViolations = e.getConstraintViolations();
        for (ConstraintViolation<?> constraintViolation : constraintViolations) {
            PathImpl pathImpl = (PathImpl) constraintViolation.getPropertyPath();
            String paramName = pathImpl.getLeafNode().getName();
            String message = constraintViolation.getMessage();
            sb.append(paramName).append(":").append(message).append(",");
        }
        String msg = sb.toString();
        msg = msg.substring(0, msg.length() - 1);
        return new Response(ErrorCodeEnum.PARMA_ERROR, msg);
    }

    /**
     * 业务异常
     *
     * @param e
     * @return
     */
    @ExceptionHandler({BizException.class})
    @ResponseBody
    public Response handleBizException(BizException e) {
        System.out.println(e);
        return Response.fail(e.getErrorCode(), e.getErrorMsg());
    }

    /**
     * 其它异常
     *
     * @param e
     * @return
     */
    @ExceptionHandler({Exception.class})
    @ResponseBody
    public Response handleException(Exception e) {
        System.out.println(e);
        return Response.fail(ErrorCodeEnum.SERVER_ERROR);
    }
}

全局统一响应处理

/**
 * 全局统一响应
 */
//@ControllerAdvice(basePackages = "com.example.validation.controller")
@ControllerAdvice(assignableTypes = MyController6.class)
public class GlobalResponseAdvice implements ResponseBodyAdvice<Object> {

    @Override
    public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
        //如果接口返回类型本身就是统一Response,或者接口方法加了@IgnoreRestBody注解,就不进行额外的转换操作
        boolean isResponse = returnType.getGenericParameterType().equals(Response.class);
        boolean isIgnoreRestBody = returnType.hasMethodAnnotation(IgnoreRestBody.class);
        return !(isResponse || isIgnoreRestBody);
    }

    @Override
    public Object beforeBodyWrite(Object data, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
        // String类型不能直接被转换成JSON串,需要手动包装
        if (returnType.getGenericParameterType().equals(String.class)) {
            ObjectMapper objectMapper = new ObjectMapper();
            try {
                return objectMapper.writeValueAsString(Response.ok(data));
            } catch (JsonProcessingException e) {
                throw new BizException("类型转换错误");
            }
        }
        //将业务数据包装在统一Response中
        return Response.ok(data);
    }
}


有时有一些方法不需要进行统一响应包装,此时我们可以使用自定义全局统一响应白名单注解来过滤。

/**
 * 加此注解的方法不统一包装返回结果,原样返回
 */
@Documented
@Inherited
@Target({ElementType.ANNOTATION_TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface IgnoreRestBody {
}

Controller层测试

/**
 * 测试各种情况下的统一响应
 */
@Controller
@RequestMapping("test5")
@Validated
public class MyController5 {

    @RequestMapping("/addOrder")
    @ResponseBody
    public void addOrder(@RequestBody @Validated({Save.class}) Order order) {
        System.out.println("order created:" + order);
    }

    @RequestMapping("/addOrderList")
    @ResponseBody
    public String addOrderList(@RequestBody @Valid List<Order2> orders) {
        System.out.println("orders created:" + orders);
        return "批量添加订单成功";
    }

    @RequestMapping("/getOrder")
    @ResponseBody
    public Order getOrder(@Validated @NotEmpty(message = "orderNo不能为空")
                          @Length(min = 10, max = 10, message = "orderNo长度为10") String orderNo) {
        Order order = new Order();
        order.setOrderNo(orderNo);
        order.setOrderTime(new Date());
        order.setUserId("U100001");
        List<Product> products = new ArrayList<>();
        products.add(new Product("I1000001", "商品01"));
        products.add(new Product("I1000002", "商品02"));
        order.setProducts(products);
        return order;
    }

    @RequestMapping(value = "/updateOrder", produces = "application/json;charset=UTF-8")
    @ResponseBody
    public String updateOrder(@RequestBody @Validated({Update.class}) Order order) {
        if ("O100000011".equals(order.getOrderNo())) {
            throw new BizException(ErrorCodeEnum.ORDER_NOT_FOUND_ERROR);
        }
        return "订单修改成功";
    }

    @RequestMapping("/payNotify")
    @ResponseBody
    @IgnoreRestBody
    public PayNotify payNotify(@RequestBody @Validated({Query.class}) Order order) {
        PayNotify payNotify = new PayNotify("PAY" + order.getOrderNo(), order.getOrderNo(), new BigDecimal("99.19"), 0);
        return payNotify;
    }
}

可以看到,我们不需要在Controller中统一包装返回信息了,而是由GlobalResponseAdvice统一拦截处理。Controller接口方法无论返回什么类型的结果都可以进行统一数据响应。这样客户端接收到响应数据格式就完全统一了。那些不需要包装为统一响应格式的接口,比如支付回调通知,我们可以加上自定义注解标记@IgnoreRestBody,这样就原样返回给客户端了。

小结

通过上述的案例讲解,我们逐步构建起了整个后端接口的基本体系。

  • 通过Bean Validator+自动抛出异常来完成接口入参的参数校验
  • 通过自定义异常、返回码枚举统一了异常操作、规范了响应体中的响应码和响应信息
  • 通过全局统一数据响应简化了Controller层的响应数据统一包装,统一、规范了给前端的响应数据格式

项目的构建、接口的编写没有一个放之四海而皆准的绝对标准。本文也只是自我总结,抛砖引玉,欢迎留言、点赞、指教!

  • 5
    点赞
  • 4
    评论
  • 13
    收藏
  • 一键三连
    一键三连
  • 扫一扫,分享海报

相关推荐
©️2020 CSDN 皮肤主题: 博客之星2020 设计师:CY__ 返回首页
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值