SpringBoot 定义优雅全局统一 Restful API 响应和统一异常处理,太优雅了!

开发 架构
统一接口响应能够减少团队内部不必要的沟通;减轻接口消费者校验数据的负担;降低其他同事接手代码的难度;提高接口的健壮性和可扩展性。

大家好,我是码哥,《Redis 高手心法》作者。

假如你作为项目组长,为 Spring Boot 项目设计一个规范的统一的RESTfulAPI 响应框架。

前端或者移动端开发人员通过调用后端提供的RESTful接口完成数据的交换。

常见的统一响应数据结构如下所示:

public class Result<T> implements Serializable {

    private Integer code;

    private String message;

    private T data;
}

统一接口响应能够减少团队内部不必要的沟通;减轻接口消费者校验数据的负担;降低其他同事接手代码的难度;提高接口的健壮性和可扩展性。

除此之外,还需要实现一个统一的异常处理框架。通过这个全局异常处理,可以避免将异常信息和系统敏感信息直接抛出给客户端。

针对特定异常捕获后可以重新对异常输出信息做编排,提高交互友好度,同时可以记录异常信息。

实现思路

我们需要定义一个 Result类,在类中定义需要返回的字段信息,比如状态码、结果描述、结果数据集等。

接口的状态码很多,我们可以用一个枚举类进行封装。于是就有了下面的代码。顺便说一句,推荐大家使用 lombok,减少繁琐的 set、get、构造方法。

状态码枚举

@Getter
@AllArgsConstructor
public enum ResultEnum {

    /**
     * return success result.
     */
    SUCCESS(200, "接口调用成功"),
    /**
     * return business common failed.
     */
    COMMON_FAILED(400, "接口调用失败"),

    NOT_FOUND(404, "接口不存在"),
    FORBIDDEN(403, "资源拒绝访问"),
    UNAUTHORIZED(401, "未认证(签名错误)"),

    INTERNAL_SERVER_ERROR(500, "服务器内部错误"),


    NULL_POINT(200002, "空指针异常"),
    PARAM_ERROR(200001, "参数错误");

    private Integer code;

    private String message;

}

统一响应封装

封装一个固定返回格式的结构对象:Result。

@Setter
@Getter
public class Result<T> implements Serializable {

    private Integer code;

    private String message;

    private T data;

    public static <T> Result<T> success() {
        return new Result<>(ResultEnum.SUCCESS.getCode(), ResultEnum.SUCCESS.getMessage());
    }

    public static <T> Result<T> success(T data) {
        return new Result<>(ResultEnum.SUCCESS.getCode(), ResultEnum.SUCCESS.getMessage(),
                data);
    }

    public static <T> Result<T> success(String message, T data) {
        return new Result<>(ResultEnum.SUCCESS.getCode(), message, data);
    }

    public static Result<?> failed() {
        return new Result<>(ResultEnum.COMMON_FAILED.getCode(),
                ResultEnum.COMMON_FAILED.getMessage(), null);
    }

    public static Result<?> failed(String message) {
        return new Result<>(ResultEnum.COMMON_FAILED.getCode(), message, null);
    }

    public static Result<?> failed(IResult errorResult) {
        return new Result<>(errorResult.getCode(), errorResult.getMessage(), null);
    }

    public Result() {
    }

    public Result(Integer code, String message) {
        this.code = code;
        this.message = message;
    }

    public Result(Integer code, String message, T data) {
        this.code = code;
        this.message = message;
        this.data = data;
    }

    public static <T> Result<T> instance(Integer code, String message, T data) {
        Result<T> result = new Result<>();
        result.setCode(code);
        result.setMessage(message);
        result.setData(data);
        return result;
    }

}

有了统一响应体,于是你就可以在 Controller 返回结果时这样写:

@RestController
public class UserController {

    @Autowired
    private UserService userService;

    @RequestMapping(value = "/queryUser")
    public Result<User> query(@RequestParam("userId") Long userId){
        try {
            // 业务代码...
            User user = userService.queryId(userId);
            return ResultMsg.success(user);
        } catch (Exception e){
            return ResultMsg.fail(e.getMessage());
        }
    }
}

唐二婷:Controller 类中每一个方法的返回值类型都只能是这个响应对象类,太不优雅了。

这个问题问得好。

为了能够实现统一的响应对象,又能优雅的定义 Controller 类的方法,使其每个方法的返回值是其应有的类型。

主要是借助RestControllerAdvice注解和ResponseBodyAdvice接口来实现对接口响应给客户端之前封装成 Result。

全局统一 Restful API 统一返回

Spring Boot 框架其实已经帮助开发者封装了很多实用的工具,比如 ResponseBodyAdvice 接口,我们可以利用来实现数据格式的统一返回。

忽略响应包装

有些场景下我们不希望 Controller 方法的返回值被包装为统一响应对象,可以先定义一个忽略响应封装的注解,配合后续代码实现。

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface IgnoreRestFulAPI {
}

ResponseBodyAdvice 接口

这是 Spring 框架提供的一个接口,我们可以利用它实现对接口数据格式统一封装。

ResponseBodyAdvice可以对 controller 层中的拥有@ResponseBody 注解属性的方法进行响应拦截,用户可以利用这一特性来封装数据的返回格式,也可以进行加密、签名等操作。

实现该接口的类还需要添加 @RestControllerAdvice注解,这是一个组合注解,由@ControllerAdvice、@ResponseBody组成,而@ControllerAdvice继承了@Component,因此@RestControllerAdvice本质上是个Component。

本质上就是使用 Spring AOP 定义的一个切面,作用于 Controller 方法执行完成后的增强操作。

ResponseBodyAdvice接口有两个方法需要重写。

  • supports方法:实际开发中不一定所有的方法封装统一接口响应,这里可以根据MethodParameter进行过滤,此方法返回 true 则会走过滤,即会调用beforeBodyWrite方法,否则不会调用。
  • beforeBodyWrite:编写具体响应客户端之前的的数据逻辑。
@RestControllerAdvice
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
public class GlobalResponseAdvice implements ResponseBodyAdvice<Object> {


    private static final ObjectMapper mapper = new ObjectMapper();

    @Override
    public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
        // 方法没有IgnoreRestFulAPI注解,且返回类型不是 Result类型时调用 beforeBodyWrite 实现响应数据封装
        return !returnType.hasMethodAnnotation(IgnoreRestFulAPI.class)
                && !returnType.getParameterType().isAssignableFrom(Result.class);
    }

    @Override
    public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType
            , Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {

        // 如果返回值是void类型,直接返回200状态信息
        if (returnType.getParameterType().isAssignableFrom(Void.TYPE)) {
            return Result.success();
        }

        // 返回类型不是 Result,是 String 类型
        if (!(body instanceof Result)) {
            // warning: RestController方法上返回值类型为String时,默认响应的Content-Type是text/plain,
            // 需要手动指定为application/json 才能对结果进行包装成 json
            if (body instanceof String) {
                return toJson(Result.success(body));
            }
            return Result.success(body);
        }

        // 返回类型是 Result,直接返回
        return body;
    }

    private Object toJson(Object body) {
        try {
            return mapper.writeValueAsString(body);
        } catch (JsonProcessingException e) {
            throw new RuntimeException("无法转发json格式", e);
        }
    }
}

@RestControllerAdvice 未生效?

ResponseBodyAdvice 接口实现类 GlobalResponseAdvice 没有被 Spring 管理。

因为启动类上的 @SpringbootApplication 默认扫描本包和子包。

比如 GlobalResponseAdvice 在 zero.magebyte.shop.common 包下,而启动类在 zero.magebyte.shop.order.server 包,那么 GlobalResponseAdvice 就不会生效。

为了防止全局接口统一响应处理器 GlobalResponseAdvice类未被扫描到,建议在启动类上加上包扫描。

测试

定义一个 Controller 类来进行简单的开发和测试。

@RestController
@RequestMapping("/demo")
public class DemoController {
    @GetMapping("/method1")
    public Result<Integer> method1() {
        return Result.success(100);
    }

    @GetMapping("/method2")
    public void method2() {

    }

    @GetMapping(value = "/method3")
    @IgnoreRestFulAPI
    public String method3() {
        return "不会被封装,直接返回 String";
    }

    /**
     * RestController中返回值类型是String的方法默认响应类型是text/plain,需要手动指定为application/json方可对其进行包装
     */
    @GetMapping(value = "/method4", produces = MediaType.APPLICATION_JSON_VALUE)
    public String method4() {
        return "会被封装 Result 结构 JSON";
    }

    /**
     * 会被封装,但是响应类型是text/plain
     * @return
     */
    @GetMapping(value = "/method5")
    public String method5() {
        return "会被封装为 Result 的 text/html";
    }
}

Result 返回类型

method1 方法返回类型是 Result,所以不会再次封装,而是直接返回 Result 结构,并以 Content-Type: application/json格式响应给客户端。

{
  "code": 200,
  "message": "接口调用成功",
  "data": 100
}

void 类型

method2 方法返回类型是 void,会封装成 Result 结构,并以 Content-Type: application/json格式响应给客户端。只不过 data 数据是 null。

{
  "code": 200,
  "message": "接口调用成功",
  "data": null
}

@IgnoreRestFulAPI 注解

method3 被 @IgnoreRestFulAPI 注解,不会被封装 Result 结构,直接返回。

String 类型

默认 String 类型的数据响应给客户端的格式为 text/html,为了统一响应格式,需要手动设置响应类型为 json,如下所示。

@GetMapping(value = "/method4", produces = MediaType.APPLICATION_JSON_VALUE)

响应给客户端的格式就是一个 Result JSON 对象,Content-Type: application/json。

{
  "code": 200,
  "message": "接口调用成功",
  "data": "会被封装 Result 结构 JSON"
}

否则将会以 Content-Type: text/html;charset=UTF-8响应呵客户端。

另外需要注意的是,如果你使用了 swagger,以上代码会导致 swagger 无法访问。

报错如下:

Unable to infer base url. This is common when using dynamic servlet registration or when the API is behind an API Gateway. The base url is the root of where all the swagger resources are served. For e.g. if the api is available at http://example.org/api/v2/api-docs then the base url is http://example.org/api/. Please enter the location manually:

原因:因为统一响应拦截器对 swagger 的接口做了拦截并对结果做了包装,导致返回结构发生后变化,swagger 无法解析。

解决方案:修改统一响应处理器拦截的范围,配置散列包路径。你可以指定 @RestControllerAdvice(basePackages = {"xxx.xxx"})项目的 controller 目录即可。

统一异常处理

唐二婷:虽然有了统一结构响应,接口可以直接返回实际数据,但是每个接口都要根据业务的要求进行不同程度的 try..catch 处理异常,如果有几百个接口,不仅编程工作量大,可读性也差。

@RestController
public class UserController {

    @Autowired
    private UserService userService;

    @RequestMapping(value = "/queryUser")
    public User query(@RequestParam("userId") Long userId){
        try {
            // 业务代码...
            User user = userService.queryId(userId);
            return user;
        } catch (Exception e){
            return Result.fail(e.getMessage());
        }
    }
}

兵来将挡,水来土掩。这样写代码并不是不好看,而是十分垃圾!!!

如下是我们自定义的业务异常。

@Setter
@Getter
public class BusinessException extends RuntimeException {

    private Integer code;

    private String message;

    public BusinessException(Throwable cause) {
        super(cause);
    }

    public BusinessException(String message) {
        super(message);
        this.message = message;
    }

    public BusinessException(Integer code, String message, Throwable cause) {
        super(cause);
        this.code = code;
        this.message = message;
    }

}

在 Spring Boot 中,我们不用这样写,可以继续利用 @RestControllerAdvice 注解和@ExceptionHandler注解实现全局异常处理器,拦截 Controller 层抛出的异常。

实现方式

新增 GlobalExceptionHandler 类,编写统一异常处理,类上面添加 @RestControllerAdvice 注解就开启了全局异常处理。

我们可以在类面创建多个方法,并在方法上添加 @ExceptionHandler 注解,对不同的异常进行定制化处理,并统一返回 Result 结构响应给客户端。

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    /**
     * 处理自定义的业务异常
     *
     * @param req
     * @param e
     * @return
     */
    @ExceptionHandler(value = BusinessException.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public <T> Result<T> baseExceptionHandler(HttpServletRequest req, BusinessException e) {
        log.error("发生业务异常!", e);
        int code = Objects.isNull(e.getCode()) ? ResultEnum.INTERNAL_SERVER_ERROR.getCode() : e.getCode();
        String message = StringUtils.isBlank(e.getMessage()) ? ResultEnum.INTERNAL_SERVER_ERROR.getMessage() : e.getMessage();
        return new Result<>(code, message);
    }

    @ExceptionHandler(value = RuntimeException.class)
    @ResponseBody
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public <T> Result<T> runtimeExceptionHandler(HttpServletRequest req, RuntimeException e) {
        log.error("发生运行时异常!", e);
        return Result.failed(ResultEnum.INTERNAL_SERVER_ERROR);
    }

    /**
     * 处理空指针的异常
     *
     * @param req
     * @param e
     * @return
     */
    @ExceptionHandler(value = NullPointerException.class)
    @ResponseBody
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public <T> Result<T> exceptionHandler(HttpServletRequest req, NullPointerException e) {
        log.error("发生空指针异常!", e);
        return Result.failed(ResultEnum.INTERNAL_SERVER_ERROR);
    }


    /**
     * 处理其他异常
     *
     * @param req
     * @param e
     * @return
     */
    @ExceptionHandler(value = Exception.class)
    @ResponseBody
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public <T> Result<T> exceptionHandler(HttpServletRequest req, Exception e) {
        log.error("未知异常!", e);
        return Result.failed(ResultEnum.INTERNAL_SERVER_ERROR);
    }

    @ExceptionHandler(value = BindException.class)
    @ResponseBody
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Result<String> handlerBindException(HttpServletRequest request, BindException e) {
        StringBuilder sb = new StringBuilder();
        List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors();
        for (FieldError fe : fieldErrors) {
            sb.append(fe.getField()).append(":").append(fe.getDefaultMessage()).append(";");
        }
        String errorStr = sb.length() == 0 ? "" : sb.substring(0, sb.length() - 1);
        return new Result(HttpStatus.BAD_REQUEST.value(), errorStr);
    }

    @ExceptionHandler(value = MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Result<String> handlerMethodArgumentNotValidException(HttpServletRequest request, MethodArgumentNotValidException e) {
        StringBuilder sb = new StringBuilder();
        List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors();
        for (FieldError fe : fieldErrors) {
            sb.append(fe.getField()).append(":").append(fe.getDefaultMessage()).append(";");
        }
        String errorStr = sb.isEmpty() ? "" : sb.substring(0, sb.length() - 1);
        return new Result<String>(HttpStatus.BAD_REQUEST.value(), errorStr);
    }

    @ExceptionHandler(value = SQLException.class)
    @ResponseBody
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public Result<String> handlerSQLException(SQLException e) {
        log.error("数据库异常!", e);
        return Result.failed(ResultEnum.INTERNAL_SERVER_ERROR);
    }

}

测试代码

故意制造一个除 0 异常。

@GetMapping(value = "/method6")
public Order method6() {
    int a = 1/0;
    Order order = new Order();
    order.setId(1);
    order.setMoney(999);
    return order;
}

自定义抛出业务异常。

@GetMapping(value = "/method7")
public Order method7() {
    Order order = new Order();
    order.setId(1);
    order.setMoney(999);
    if (order.getCreateTime() == null) {
        throw new BusinessException("创建时间不能为空");
    }
    return order;
}

总结

RestControllerAdvice注解和ResponseBodyAdvice接口来实现对接口响应给客户端之前封装成 Result。

统一接口响应客户端,减少团队内部不必要的沟通;减轻接口消费者校验数据的负担;降低其他同事接手代码的难度;提高接口的健壮性和可扩展性。

通过 @RestControllerAdvice 注解和@ExceptionHandler` 注解实现统一异常处理,能够减少代码的重复度和复杂度,有利于代码的维护,并且能够快速定位到 BUG,大大提高我们的开发效率。

责任编辑:姜华 来源: 码哥跳动
相关推荐

2019-01-24 16:11:19

前端全局异常数据校验

2022-08-03 07:07:10

Spring数据封装框架

2019-08-22 14:02:00

Spring BootRestful APIJava

2023-11-28 14:32:04

2021-04-20 10:50:38

Spring Boot代码Java

2024-08-09 08:25:32

Spring流程注解

2017-05-18 14:14:25

过滤器Spring ClouZuul

2022-05-07 10:09:01

开发Java日志

2023-12-05 14:10:00

接口可读性

2024-12-11 09:16:38

2020-03-16 17:20:02

异常处理Spring Boot

2023-11-30 07:00:56

SpringBoot处理器

2023-10-10 13:23:18

空指针异常Java

2017-05-19 15:13:05

过滤器Spring ClouZuul

2017-07-31 15:47:50

Zuul统一处理

2024-04-02 09:52:12

自定义返回类API开发

2019-03-14 15:59:44

前端开发编程

2022-05-03 10:43:43

SpringJava

2022-12-30 08:49:41

SpringBoot@Validated

2021-07-16 08:58:35

SpringBoot
点赞
收藏

51CTO技术栈公众号