02 SpringBoot初体验:统一响应和异常处理

  1. 01 SpringBoot初体验:初始化web项目
  2. 02 SpringBoot初体验:统一响应和异常处理
  3. 03 SpringBoot初体验:Swagger接口文档
  4. 04 SpringBoot初体验:玩转应用监控

背景

​ 我们通常接触的项目大多已经是前后端分离的项目,前后端的数据交互往往封装成统一的格式,形如以下:

{
  "code" : 200,
  "message" : "操作成功",
  "data" : null
}

​ 正常请求时通过data提供返回数据对象,请求失败时通过code和message提供错误提示。

目标

​ 体验如何使用springboot框架为Java后端web项目提供统一的响应对象。同时体验异常替代错误返回的用法。

准备工作

基础知识

  1. springmvc的处理流程:请求映射、响应映射、异常处理视图
  2. @ControllerAdvice@RestController@ExceptionHandlerRequestBodyAdviceResponseBodyAdvice
  3. ResponseEntityExceptionHandlerBasicErrorController

统一响应

  1. 定义统一的响应体
  2. 使用统一响应
    1. 直接返回对象
    2. 使用ResponseBodyAdvice拦截封装
    3. Aop拦截封装(类似b,拦截@ResponseBody+@RequestMapping

定义统一响应

Response<T>

推荐参考《Java开发手册(黄山版)》中前后端规约和异常日志部分。

​ 按实际项目需要,通常定义codemessagedata如下:

@Data
public class Response<T> {
    /**
     * 响应码
     */
    private String code;
    /**
     * 响应信息
     */
    private String message;
    /**
     * 响应数据
     */
    private T data;

    private Response(String code, String message, T data) {
        this.code = code;
        this.message = message;
        this.data = data;
    }

    private static <T> Response<T> of(BizError bizError, String message, T data) {
        return new Response<>(bizError.getCode(), message, data);
    }

    public static <T> Response<T> fail(BizError bizError, String message) {
        return of(bizError, message, null);
    }

    public static <T> Response<T> fail(BizError bizError) {
        return fail(bizError, bizError.getMessage());
    }

    public static <T> Response<T> success(T data) {
        return of(BizErrorEnum.SUCCESS, BizErrorEnum.SUCCESS.getMessage(), data);
    }
}

​ 为了方便使用,我们定义了通用的successfail静态方法。下面来写一个web版的hello world吧~

使用统一响应

​ 不建议使用JsonObject来作为对象/入参/出参用,本质是一个Map,阅读成本贼高。我们有些老项目为了JsonJson是一种不恰当的使用姿势,返回的数据格式应该通过MediaType识别并自动转换,比较棒的是Spring提供了诸多的HttpMessageConverter帮我们做了这些事情。所以业务开发上面向对象就好~

​ 使用我们封装的Response对象有2种方式:

  1. controller层直接返回该对象包装的业务数据,如Response.success("hello,world")
  2. controller层返回业务数据,通过ResponseBodyAdvice或者Aop封装成Response对象返回

Response.success

package com.gitee.theskyone.bird.web;

@RestController
public class HelloWorldController {

    @GetMapping("/hello/{world}")
    public Response<String> hello(@PathVariable String world) {
        String helloWorld = "hello " + world + " ~";
        // 第1种方式: 使用Response.success(helloWorld)返回
        return Response.success(helloWorld);
    }

    @GetMapping(value = "/hello/kitty")
    public String helloKitty() {
        // 第2种方式: 直接返回业务对象。但是对象是个String!
        return "hello kitty~";
    }

    @GetMapping(value = "/hello")
    public Hello hello() {
        // 第2种方式: 直接返回业务对象
        return new Hello("nice kitty");
    }
}

ResponseBodyAdvice

不建议大家真的使用~

  1. 理解成本高:小伙伴强迫症
  2. 易踩坑:比如返回String的时候,更适合纯后端分离的项目
  3. 接口文档工具不友好:像swaggercontroller层解析,不会关心到controllerAdvice

​ 对于第2种直接返回业务对象的方式,我们使用ResponseBodyAdvice来封装返回的业务对象为Response。注意,ResponseBodyAdvice需要结合@ControllerAdvice@RestControllerAdvice使用。

package com.gitee.theskyone.bird.common.web.advice;

/**
 * @see RequestMappingHandlerAdapter#initControllerAdviceCache() ResponseBodyAdvice 结合 ControllerAdvice使用
 */
@RestControllerAdvice
public class ResponseBody2ResponseAdvice implements ResponseBodyAdvice<Object> {

    @Override
    public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
        // 只处理AbstractJackson2HttpMessageConverter类型,注意是使用默认的jackson
        return AbstractJackson2HttpMessageConverter.class.isAssignableFrom(converterType)
                && (!returnType.getMethod().getReturnType().isAssignableFrom(Response.class)
                // 忽略ResponseEntity
                && !returnType.getMethod().getReturnType().isAssignableFrom(ResponseEntity.class));
    }

    @Override
    @Nullable
    public Object beforeBodyWrite(@Nullable Object body, MethodParameter returnType, MediaType contentType, Class<? extends HttpMessageConverter<?>> converterType, ServerHttpRequest request, ServerHttpResponse response) {
        return Response.success(body);
    }
}

测试

测试一下!

package com.gitee.theskyone.bird.web;

@SpringBootTest
@AutoConfigureMockMvc
class HelloWorldControllerTest {

    @Autowired
    MockMvc mockMvc;

    @Test
    void helloKitty() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.get("/hello/kitty"))
                .andDo(MockMvcResultHandlers.print())
            	// assert 响应字符串包含 Response.code字段
                .andExpect(MockMvcResultMatchers.content()
                        .string(StringContains.containsString("code")));
        // 翻车了? string类型默认返回text/plain,使用StringHttpMessageConverter序列化,不会走我们定义的ResponseBodyAdvice
    }

    @Test
    void helloWorld() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.get("/hello/world"))
                .andDo(MockMvcResultHandlers.print())
            	// assert 响应字符串包含 Response.code字段
                .andExpect(MockMvcResultMatchers.content()
                        .string(StringContains.containsString("code")));
    }

    @Test
    void hello() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.get("/hello"))
                .andDo(MockMvcResultHandlers.print())
            	// assert 响应字符串包含 Response.code字段
                .andExpect(MockMvcResultMatchers.content()
                        .string(StringContains.containsString("code")));
    }
}

// 项目地址 https://gitee.com/theskyone/new-bird.git
// 这里helloKitty()会测试失败,因为使用String返回不会被拦截封装成Response

到这里,我们一个正常的成功请求都能以我们定义的Response对象返回。那么如果发生了异常呢?

异常处理

​ 异常处理主要做2件事:异常拦截和处理。

  1. 什么时候(在哪儿)拦截异常?
    1. 异常发生时
    2. 异常传递时(内部,外部)
  2. 如何拦截异常?
  3. 异常处理:catch or not
    1. 记录日志:请求响应上下文
    2. 全局处理

全局异常处理

@ExceptionHandler

​ 我们使用@RestControllerAdvice结合@ExceptionHandler,并继承ResponseEntityExceptionHandler支持web类的错误处理。

package com.gitee.theskyone.bird.common.web.advice;

@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler({BizException.class})
    public ResponseEntity<Response<?>> handleBizException(BizException bizException) {
        return ResponseEntity
                .badRequest() // 自定义httpStatus
                .body(Response.fail(bizException.getBizError(), bizException.getMessage()));
    }

    @ExceptionHandler({Exception.class})
    public ResponseEntity<Response<?>> handleBizException(Exception exception) {
        return handleBizException(new BizException(exception, BizErrorEnum.A0000));
    }

    @Override
    protected ResponseEntity<Object> handleExceptionInternal(Exception ex, Object body, HttpHeaders headers, HttpStatus status, WebRequest request) {
        // web类错误封装成Response返回
        return super.handleExceptionInternal(ex, Response.fail(BizErrorEnum.A0000, ex.getMessage()), headers, status, request);
    }
}

​ 这里定义的比较简单实用,拦截BizException是我们定义的通用业务异常,拦截Exception作为兜底保证所有的异常都被拦截处理,重写handleExceptionInternal则是用Response封装了默认的web类错误响应信息。

BizException

package com.gitee.theskyone.bird.common.error;

public class BizException extends RuntimeException {

    private BizError bizError;

    public BizException(String message, BizError bizError) {
        super(message);
        this.bizError = bizError;
    }

    public BizException(Throwable cause, BizError bizError) {
        super(cause);
        this.bizError = bizError;
    }

    public BizError getBizError() {
        return bizError;
    }
}

测试

测试一下!

package com.gitee.theskyone.bird.web;

@SpringBootTest
@AutoConfigureMockMvc
class HelloWorldControllerTest {

    @Autowired
    MockMvc mockMvc;

    @Test
    void postHello() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.post("/hello"))
                .andDo(MockMvcResultHandlers.print())
                .andExpect(MockMvcResultMatchers.content()
                        .string(StringContains.containsString("code")));
    }
}

// HttpRequestMethodNotSupportedException
// {"code":"A0000","message":"Request method 'POST' not supported","data":null}

​ 基本到这里关于全局异常处理的部分也就差不多了,我们将异常也封装成默认的Response对象进行返回。那么一个有趣的实用技巧就跃然纸上了:使用异常替代错误处理。

异常替代错误处理

​ 比较以下2种coding姿势,你会喜欢用哪一种呢?

package com.gitee.theskyone.bird.web;

/**
 * @author theskyzero
 * @date 2022-04-04
 */
@RestController
public class HelloWorldController {
    
    @GetMapping("/fail/hello1/{world}")
    public Response<Hello> failHello1(@PathVariable String world) {
        if (!"world".equals(world)) {
            return Response.fail(BizErrorEnum.A0000, "你说啥我看不懂");
        }
        return Response.success(new Hello(world));
    }

    @GetMapping("/fail/hello2/{world}")
    public Response<Hello> failHello2(@PathVariable String world) {
        Assert.isTrue("world".equals(world), "你说啥我也看不懂");
        return Response.success(new Hello(world));
    }
}

​ 后者可以直接通过使用Assert或者throw Exception的方式来拒绝处理,比Response.fail更加灵活且清晰直观。比如在底层一些逻辑的校验失败能用Response封装出去吗?不符合依赖倒置原则同时读起来也很费力~而全局结合异常拦截处理我们可以大胆的使用异常拒绝不合理的请求!至于产生异常会不会引起的一点点性能损耗,忽略就好啦!

ErrorController

​ 最后还有一个小知识点,比如我们访问的路径不对,默认返回的是一个"空白页"。这个错误默认被转发到"/error"视图,而被BasicErrorController处理到。当然我们也可以自定义,虽然不是很必要。

package com.gitee.theskyone.bird.common.web.advice;

@RestController
@RequestMapping("${server.error.path:${error.path:/error}}")
public class CustomErrorController extends AbstractErrorController {

    public CustomErrorController(ErrorAttributes errorAttributes) {
        super(errorAttributes);
    }

    @RequestMapping
    public ResponseEntity<Response<?>> error(HttpServletRequest request) {
        HttpStatus status = getStatus(request);
        if (status == HttpStatus.NO_CONTENT) {
            return new ResponseEntity<>(Response.fail(BizErrorEnum.A0000, status.getReasonPhrase()), status);
        }
        Map<String, Object> body = getErrorAttributes(request, ErrorAttributeOptions.defaults());
        return new ResponseEntity<>(Response.fail(BizErrorEnum.A0000, String.valueOf(body.get("error"))), status);
    }
}
  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

theskyzero

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值