问题描述
不知道你们有没有过这样的经历,项目已经经历了几个版本,但是始终没有做过统一返回,突然某一版要做统一返回格式,包括统一异常拦截处理也一起统一,比如统一成如下常用格式
{
"code": 0;
"message": "成功";
"data": {}
}
如果要改代码这心态得崩啊,接口少的话倒是问题也不大,但是像我们接口比较多的情况下,改代码的话可能会想死,而且还有原先抛的自定义异常,我们处理后返回的结果如下:
{
"code": aaa.bbb.ccc;
"message": "某某操作不合法";
"detail": {} // 详细描述,不一定存在
}
这与我们想要统一的结构也不太一样,而且HTTP
状态码也并非200
,我们现在希望都返回200
,code
来标识结果是否为正确返回,这直接原来抛异常的处理也不能用了,而这个异常的拦截处理又是在我们通用的web stater
中去处理的,如果直接改造,这无疑将带来巨大的工作量。
可能这么说无法体会到这之中的工作量,那就详细说一下
Controller
中现有的写法:
@PostMapping("/test13")
public TestClass test13(@RequestBody @Validated TestClass test) {
return test;
}
@Data
public static class TestClass {
@NotNull
private String name;
@NotBlank
private String address;
}
这到前端之间就一个TestClass
返回了
{
"name": felixu;
"address": 杭州
}
但是我们希望的结构是:
{
"code": 0;
"message": "成功";
"data": {
"name": felixu;
"address": 杭州
}
}
这就需要对然后结果做包装,不然全部接口都得改成
@PostMapping("/test13")
public RespDTO<TestClass> test13(@RequestBody @Validated TestClass test) {
return RespDTO.onSuc(test);
}
另一个就是原有的自定义异常:
@GetMapping("/test1")
public String test1() {
throw new BusinessException("param.error");
}
它会被我们web stater
中的统一异常处理器所处理:
@Slf4j
@RestControllerAdvice
public class ExceptionResolver {
/**
* i18n 消息源
*/
private final MessageSource messageSource;
public ExceptionResolver(MessageSource messageSource, Environment environment) {
this.messageSource = messageSource;
}
@ExceptionHandler(value = BusinessException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse<?> bizExceptionHandler(BusinessException ex,
HttpServletRequest request) {
String code = ex.getMessage();
//优先从代码传递里获取异常描述,再从定义业务出错码的文件里获取
String message = MoreObjects.firstNonNull(ex.getI18nDesc(), messageSource.getMessage(code, ex.getArgs(), code, LocaleContextHolder.getLocale()));
return ErrorResponse.of(code, message);
}
然后在国际化文件中配有:
param.error=您提交的数据不符合要求
这样到前端就是如下结果:
{
"code": "param.error";
"message": "您提交的数据不符合要求"
}
而这跟我们提到的结构明显不一致,首先统一异常处理得改,第二点要兼容代码中原有的如throw new BusinessException("param.error");
的写法,因为经过几次迭代了,代码中存在大量的这种写法。
所以我就在想有么有没有什么“投机取巧”的方式。
问题分析
基于以上描述,我们发现要做以下事情:
- 对所有的
Controller
中接口返回结果做包装,将其处理为我们的统一返回结构; - 需要重新定义统一异常拦截处理,并取代
web stater
中的异常处理; - 兼容代码中原有的抛异常处理,并保留国际化处理。
至于为啥要改,前面版本为啥要这么做,国际化为啥在后端,我只想说emmmm
,毕竟我只是个搬砖的,好了,既然要干,那就想想办法吧。
解决问题
首先是解决code
的维护,我们定义一个ErrorCode
来存储code
,而且原有代码中抛的英文串肯定要在这个类中也映射上,所以可以看到比如之前的param.error
会对应到一个PARAM_ERROR(100, "param.error")
枚举项,并且定义parse
方法,将原先的字符串映射到枚举项:
@Getter
@AllArgsConstructor
public enum ErrorCode {
/*-------------------------------- 成功 ------------------------------*/
OK(0, "成功"),
/*------------------- 失败(大多为没有被处理到的特殊异常) ------------------*/
FAIL(-1, "internal.server.error"),
MISSING_CODE(-2, "missing.code"),
/*-------------------------------- 通用异常 ------------------------------*/
PARAM_ERROR(100, "param.error"),
;
/**
* 返回的 code,非 0 表示错误
*/
private final int code;
/**
* 发生错误时的描述
*/
private final String message;
/**
* 由于经过很长时间的迭代,才出现要统一返回,统一异常 code
* 故而兼容原 BusinessException 和 SystemException 解析到 ErrorCode
* 避免大规模改代码
* 后续可能会逐步移除
*/
public static ErrorCode parse(String message) {
for (ErrorCode value : ErrorCode.values()) {
if (value.message.equals(message))
return value;
}
return MISSING_CODE;
}
}
其次既然要做统一返回,首先我们需要定义一个统一返回的结构体:
@Data
@AllArgsConstructor
public class RespDTO<T> {
/**
* 返回的 code 码
*/
private int code;
/**
* 发生错误时的错误信息
*/
private String message;
/**
* 成功返回时的真正数据集
*/
private T data;
/**
* 成功返回且无任何数据返回
*/
public static <T> RespDTO<T> onSuc() {
return onSuc(null);
}
/**
* 成功返回且需要传入真正被返回的数据
*/
public static <T> RespDTO<T> onSuc(T data) {
return build(ErrorCode.OK.getCode(), ErrorCode.OK.getMessage(), data);
}
/**
* 错误返回时,放入错误类型的枚举
*/
public static <T> RespDTO<T> onFail(ErrorCode error) {
return onFail(error.getCode(), error.getMessage());
}
/**
* 错误返回时,放入错误的 code 码以及错误信息
*/
public static <T> RespDTO<T> onFail(int code, String message) {
return build(code, message, null);
}
private static <T> RespDTO<T> build(int code, String message, T data) {
return new RespDTO<>(code, message, data);
}
}
完成以上定义,接下来便是改造工作了,对于Controller
的包装,这让我想起来ResponseBodyAdvice
接口,他的Java doc
上有如下注释:
/** * Allows customizing the response after the execution of an {@code @ResponseBody} * or a {@code ResponseEntity} controller method but before the body is written * with an {@code HttpMessageConverter}. * * <p>Implementations may be registered directly with * {@code RequestMappingHandlerAdapter} and {@code ExceptionHandlerExceptionResolver} * or more likely annotated with {@code @ControllerAdvice} in which case they * will be auto-detected by both. * * @author Rossen Stoyanchev * @since 4.1 */
他说允许我们在执行完@ResponseBody
或者ResponseEntity
的控制器方法之后,但是在HttpMessageConverter
执行之前做自定义的响应。下面一段则是告诉我们该怎么做以及怎么被处理的了,不想涉及太多原理,便暂且忽略。所以我们做如下实现:
@RestControllerAdvice
public class BaseResponseAdvice implements ResponseBodyAdvice<Object> {
@Value("${api-full-prefix:}")
private String prefix;
// 该方法用于确定是否被处理,这里直接所有请求均处理
@Override
public boolean supports(MethodParameter returnType,
Class<? extends HttpMessageConverter<?>> converterType) {
return true;
}
// 该方法用于处理包装返回
@Override
public Object beforeBodyWrite(Object body,
MethodParameter returnType,
MediaType contentType,
Class<? extends HttpMessageConverter<?>> converterType,
ServerHttpRequest request,
ServerHttpResponse response) {
ServletServerHttpResponse servletServerHttpResponse = (ServletServerHttpResponse) response;
HttpServletResponse realResponse = servletServerHttpResponse.getServletResponse();
// 均返回 Json 格式
// 主要是为了处理 body 为 String 类型时,后面的 JsonMapper.toNonNullJson(resp) 后
// 将会以 content-type 为 text/plain 返回 Json 字符串
realResponse.setContentType(MediaType.APPLICATION_JSON_VALUE);
// 没有返回,直接返回 RespDTO.onSuc() 这样能将 void 返回一起处理
if (body == null)
return RespDTO.onSuc();
// 已经是 RespDTO(避免被包两层) 或者不是项目路径开头直接返回(避免其他框架如 Swagger 等的请求被包装),不要杠我用 contains 毕竟某些有苦不能言
if (body instanceof RespDTO || !request.getURI().getPath().contains(prefix))
return body;
// 其他类型直接包装
RespDTO<Object> resp = RespDTO.onSuc(body);
// 如果是 String 需要单独处理,不然 RespDTO 继续被 StringHttpMessageConverter 处理会报错
return (body instanceof String) ? JsonMapper.toNonNullJson(resp) : resp;
}
}
这便是我的实现了,其中有较详细的注释,便不再多解释,到此原有的返回便已经可以处理了,需要注意的就是String
类型的返回需要单独处理,不然会报类型转换异常。
接下来便是处理原有的异常了,可是这个在web stater
中已经定义过了,而这个类是无法被如exclude
方法排除的,这里就需要定义一个统一异常拦截处理了,想必这个都不陌生吧,然后我们需要调整多个ControllerAdvice的执行顺序,当然,为了方便,不再直接在代码里面做类似throw new BusinessException("param.error");
而改成throw new MyException(ErrorCode.PARAM_ERROR)
这种形式,也对上层包中的BusinessException
做了扩展,类似:
@Getter
public class MyException extends BusinessException {
private final ErrorCode error;
private final Object[] args;
private final String i18nDesc;
public MyException(ErrorCode error) {
super(error.getMessage());
this.error = error;
this.args = null;
this.i18nDesc = null;
}
public MyException(ErrorCode error, Object... args) {
super(error.getMessage(), args);
this.error = error;
this.args = args;
this.i18nDesc = null;
}
public MyException(ErrorCode error, String i18nDesc, Object... args) {
super(error.getMessage(), i18nDesc, args);
this.error = error;
this.args = args;
this.i18nDesc = i18nDesc;
}
}
我们原有的异常:
@Getter
public class BusinessException extends RuntimeException {
private final Object[] args;
private final String i18nDesc;
/**
* @param code 异常错误code,非message
*/
public BusinessException(String code) {
this(code, (String) null);
}
/**
* 需要带错误码及出错提示内容出来(动态内容,无法在拦截地方properties文件定义)
*
* @param code 异常code
* @param i18nDesc 国际化翻译
*/
public BusinessException(String code, String i18nDesc) {
this(code, i18nDesc, (Object[]) null);
}
/**
* @param code 错误码
* @param i18nDesc 国际化翻译
* @param args i18n 业务参数
*/
public BusinessException(String code, String i18nDesc, Object... args) {
super(code);
this.args = args;
this.i18nDesc = i18nDesc;
}
public BusinessException(String code, Object[] args) {
this(code, null, args);
}
}
接下来就统一异常拦截处理了:
@Slf4j
@RestControllerAdvice
// 调整该异常处理器的执行顺序,让其优先于 web stater 包中的执行,包中的便不再执行
@Order(1)
public class MyExceptionHandler {
/**
* i18n 消息源
*/
private final MessageSource messageSource;
public MyExceptionHandler(MessageSource messageSource, Environment environment) {
this.messageSource = messageSource;
}
@ExceptionHandler(MyException.class)
public ResponseEntity<RespDTO<?>> MyExceptionHandler(MyException ex,
HttpServletRequest request) {
log.debug("业务异常:{} {}", request.getMethod(), request.getRequestURI(), ex);
ErrorCode error = ex.getError();
String message = MoreObjects.firstNonNull(null, messageSource.getMessage(error.getMessage(), ex.getArgs(), error.getMessage(), LocaleContextHolder.getLocale()));
return new ResponseEntity<>(RespDTO.onFail(error.getCode(), message), HttpStatus.OK);
}
@ExceptionHandler(BusinessException.class)
public ResponseEntity<RespDTO<?>> bizExceptionHandler(BusinessException ex,
HttpServletRequest request) {
log.debug("业务异常:{} {}", request.getMethod(), request.getRequestURI(), ex);
String code = ex.getMessage();
ErrorCode error = ErrorCode.parse(code);
Object[] args = ex.getArgs();
if (error == ErrorCode.MISSING_CODE)
args = new String[]{code};
String message = MoreObjects.firstNonNull(ex.getI18nDesc(), messageSource.getMessage(error.getMessage(), args, code, LocaleContextHolder.getLocale()));
return new ResponseEntity<>(RespDTO.onFail(error.getCode(), message), HttpStatus.OK);
}
.
.
.
// 其他异常的处理省略
}
总结一下这里的操作:
- 通过
@order(1)
调整多个@RestControllerAdvice
的顺序,让当前定义的执行,其他的不再执行 - 对于新拓展的异常,直接从枚举中拿到
message
,之后去国际化文件中获取真正的message
信息,封装为统一结构体后返回 - 原有的异常,我们先将其解析为
ErrorCode
,再取ErrorCode
中的message
,完事之后再去取国际化后的message
信息,封装为我们的统一返回体
结语
- 每个领导对于架构上的认知都不一样,这种变化是不可控的,但是作为搬砖的我,该偷懒的时候还是要偷懒的,一个短的迭代周期要去改一大堆代码这种事情我是不想去干的。
- 感觉迟早要写
@RestControllerAdvice
的原理????
网友评论