在前后端分离的架构下,后端通常是一个 RESTFul 的接口,而因为 HTTP 的响应码数量有限,无法灵活的反映出接口执行的各种结果,在这种情况下,就需要通过自定义的结构来表达接口最终的状态和返回的信息。而我正好最近在一个项目中实现了一个基于 ControllerAdvice
的统一请求响应的功能,在这里记录一下实现的方式。
创建 common 模块
因为这是一个公共的功能,所以需要创建一个新的 Maven 模块,并被所有项目引用为依赖。具体操作这里不再赘述。以下的所有代码,如无特殊说明,都将存在于这个 common 模块中。
定义全局的错误码
首先我们需要定义一个全局的错误码,使得项目中的所有模块都可以使用统一的一套返回码来表达自己接口的状态。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64
|
@Getter public enum ReturnCode {
OK("0000", "成功"),
FAIL("9999", "失败"),
INVALID_REQUEST_PARAM("0001", "请求参数中包含无效参数或请求体为空"),
DUPLICATED_RECORD("0002", "新数据的主键与已有数据重复"),
NON_EXISTENT_RECORD("0003", "未找到对应记录,请检查主键或操作流水号"),
SIGNATURE_VERIFICATION_FAIL("0004", "签名校验失败"),
;
private String code; private String message;
ReturnCode(final String code, final String message) { this.code = code; this.message = message; }
public static String getMessageByCode(String code) { for (ReturnCode item : values()) { if (item.code.equals(code)) { return item.message; } }
return null; } }
|
定义统一响应结构
在这个项目中,我选择在这个结构中定义三个字段:错误码 errCode
,错误信息 errMessage
,和返回的数据 data
。
同时,用于构造响应体的类应该同时兼顾数据合法性和灵活性,所以我决定不允许通过构造方法或者 setter 来填充信息,而是使用定义好了的静态方法来完成构造。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106
|
@Data public class CommonResponseParams {
private String errCode;
private String errMessage;
private Object data;
private CommonResponseParams(final String errCode, final String errMessage, final Object data) { this.errCode = errCode; this.errMessage = errMessage; this.data = data; }
public static CommonResponseParams ofSuccessful() { return ofSuccessful(null); }
public static <T> CommonResponseParams ofSuccessful(final T content) { return new CommonResponseParams( ReturnCode.OK.getCode(), ReturnCode.OK.getMessage(), JSONArray.toJSON(content)); }
public static CommonResponseParams ofFailure() { return new CommonResponseParams( ReturnCode.FAIL.getCode(), ReturnCode.FAIL.getMessage(), null); }
public static CommonResponseParams ofFailure(String errMessage) { return new CommonResponseParams( ReturnCode.FAIL.getCode(), errMessage, null); }
public static CommonResponseParams ofFailure(ReturnCode returnCode) { return new CommonResponseParams( returnCode.getCode(), returnCode.getMessage(), null); }
public static CommonResponseParams ofFailure(ReturnCode returnCode, String errMessage) { return new CommonResponseParams( returnCode.getCode(), errMessage, null); } }
|
定义统一的业务异常基类
为了减少不必要的 try-catch
模版代码,业务异常必须不能为受检异常;而为了与其它的运行时异常区分开来,业务异常类就不能直接继承 RuntimeException
,而是需要继承于一个自定义的基类。同时,这个业务异常基类不能被直接使用,所以必须是一个抽象类。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
|
@Getter public abstract class BaseBizException extends RuntimeException { protected ReturnCode returnCode = null;
public BaseBizException(String message) { super(message); }
public BaseBizException(ReturnCode returnCode) { super(returnCode.getMessage()); this.returnCode = returnCode; }
@Override public synchronized Throwable fillInStackTrace() { return this; } }
|
定义统一的异常处理方法
在上面的准备工作全部完成后,就可以开始着手配置统一的异常处理方法。之所以选择不使用 AOP
实现,是因为在这个情况下,业务接口必须返回 Object
类型,而这样一来,会降低代码层面的可读性。使用 ControllerAdvice
注解实现则没有这个限制,业务接口可以自由选择自己合适的数据类型。
需要注意的是,因为我们所有的 controller 类都会带有 RestController
注解,所以在 ControllerAdvice
注解中,我们使用 annotations
参数指定了这个配置类仅针对带有 RestController
的类启用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90
|
@Slf4j @ResponseBody @ControllerAdvice(annotations = RestController.class) public class UnifiedExceptionHandler {
@ExceptionHandler(CannotCreateTransactionException.class) public CommonResponseParams handleCannotCreateTransactionException(CannotCreateTransactionException e) { log.error(e.getMessage(), e); return CommonResponseParams.ofFailure("数据库连接失败"); }
@ExceptionHandler(RuntimeException.class) public CommonResponseParams handleUnknownRuntimeExceptions(RuntimeException e) { log.error(e.getMessage(), e); return CommonResponseParams.ofFailure(e.getMessage()); }
@ExceptionHandler(MethodArgumentNotValidException.class) public CommonResponseParams handleRequestParamValidationExceptions(MethodArgumentNotValidException e) { String errMessage = Optional.ofNullable(e.getBindingResult().getFieldError()) .map(FieldError::getDefaultMessage) .orElse(ReturnCode.INVALID_REQUEST_PARAM.getMessage());
log.error(e.getMessage()); return CommonResponseParams.ofFailure(ReturnCode.INVALID_REQUEST_PARAM, errMessage); }
@ExceptionHandler(HttpMessageNotReadableException.class) public CommonResponseParams handleHttpMessageNotReadableException() { return CommonResponseParams.ofFailure(ReturnCode.INVALID_REQUEST_PARAM); }
@ExceptionHandler(DuplicateKeyException.class) public CommonResponseParams handleDuplicateKeyException() { return CommonResponseParams.ofFailure(ReturnCode.DUPLICATED_RECORD); }
@ExceptionHandler(BaseBizException.class) public CommonResponseParams handleBizExceptions(BaseBizException e) { if (e.getReturnCode() != null) { ReturnCode returnCode = e.getReturnCode(); log.error(returnCode.getMessage()); return CommonResponseParams.ofFailure(returnCode); } else if (StringUtils.isNotBlank(e.getMessage())) { log.error(e.getMessage()); return CommonResponseParams.ofFailure(e.getMessage()); } else { log.error(e.getMessage()); return CommonResponseParams.ofFailure(); } } }
|
这里详细说一下各个方法的作用。
第一个方法用于处理 CannotCreateTransactionException
异常类,这个异常会在应用无法成功连接数据库时被抛出。处理方式就是返回一个错误信息为 “数据库连接失败” 的失败结果。
第二个方法用于处理 RuntimeException
异常,这个方法的意义在于,我们无法预见所有可能出现的异常,所以使用这个方法作为一个兜底的处理方法。
第三个方法用于处理 MethodArgumentNotValidException
异常。因为这个项目中我们选择使用 javax.validation.constraints
包中的注解实现输入参数合法性的校验,而当校验失败时会抛出 MethodArgumentNotValidException
异常,并且在异常中会包含具体的校验失败的原因。同时为了保证方法的健壮性,在代码中也保证了如果无法获取到校验失败信息,就会选择 INVALID_REQUEST_PARAM
这个错误码作为兜底的错误信息。
第四个方法用于处理 HttpMessageNotReadableException
异常。如果一个接口方法的参数中存在被 @RequestBody
标记的参数,但是在请求该接口时 body 为空时,就会抛出这个异常。在出现了这个异常后,就会返回带有 INVALID_REQUEST_PARAM
错误信息的失败结果。
第五个方法用于处理 DuplicateKeyException
异常。因为这个项目中一部分数据的主键是由请求发起方生成的,同时数据库中也会将这一列定为主键来实现插入接口的幂等性。一旦出现网络状况不佳的情况时,发起方会尝试再次调用接口。而在重发请求时,可能数据已经在上一个请求中就已经成功插入了,只是因为网络不佳导致发起方没能接收到返回,在第二次请求中重复插入相同主键的数据,就会抛出这个异常。为了最终接口返回信息的可读性,我们选择在这里返回一个用户友好的信息。
最后一个方法就是这里的主角了,它用于处理所有继承了 BaseBizException
的业务异常。这个方法中,我们对应着 CommonResponseParams
中不同的静态方法,实现了对应的错误处理逻辑。
定义统一的成功响应处理方法
上面洋洋洒洒写了一堆针对异常的处理逻辑,但是接口成功执行的处理逻辑也不能落下。这里我们使用 RestControllerAdvice
表示这是一个接口增强类,同时实现了 ResponseBodyAdvice
接口,用于实现实际的处理逻辑。
在这个配置类上,我们也指定了该配置类仅针对被 RestController
标记的类生效。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
|
@EnableWebMvc @Configuration @RestControllerAdvice(annotations = RestController.class) public class UnifiedReturnConfig implements ResponseBodyAdvice<Object> {
@Override public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) { return true; }
@Override public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) { if (body instanceof CommonResponseParams) { return body; }
return CommonResponseParams.ofSuccessful(body); } }
|
上面代码的重点是在 beforeBodyWrite
方法中。这个方法会在 HttpMessageConverter#write()
方法执行前,也就是返回被发出去之前被调用。借助这个功能,我们就可以实现在业务接口返回之后,将返回信息重新包装。
实现逻辑很简单,如果返回信息是一个 CommonResponseParams
对象,那么就认为这个返回信息已经被包装好了,所以不再进行二次包装,直接返回;否则就通过 CommonResponseParams#ofSuccessful()
方法,将返回信息包装为一个成功响应的格式,再返回到客户端。
最后的一点配置
在上文中,统一返回格式的配置已经完成了。但是有的人可能会发现,虽然在自己的项目中引用了这个模块,但是实际上却没有生效,这是因为上面的配置类都存在于另一个 jar 包中,导致在应用启动时这些请求并没有被自动发现。解决方法也很简单,在项目的启动类 (即 xxxApplication
) 中加上 @ComponentScan
注解,并在注解参数中加上 UnifiedReturnConfig
和 UnifiedExceptionHandler
所在的包名即可。