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,大大提高我們的開發效率。