SpringBoot 定義優(yōu)雅全局統(tǒng)一 Restful API 響應(yīng)和統(tǒng)一異常處理,太優(yōu)雅了!
大家好,我是碼哥,《Redis 高手心法》作者。
假如你作為項目組長,為 Spring Boot 項目設(shè)計一個規(guī)范的統(tǒng)一的RESTfulAPI 響應(yīng)框架。
前端或者移動端開發(fā)人員通過調(diào)用后端提供的RESTful接口完成數(shù)據(jù)的交換。
常見的統(tǒng)一響應(yīng)數(shù)據(jù)結(jié)構(gòu)如下所示:
public class Result<T> implements Serializable {
private Integer code;
private String message;
private T data;
}
統(tǒng)一接口響應(yīng)能夠減少團隊內(nèi)部不必要的溝通;減輕接口消費者校驗數(shù)據(jù)的負擔(dān);降低其他同事接手代碼的難度;提高接口的健壯性和可擴展性。
除此之外,還需要實現(xiàn)一個統(tǒng)一的異常處理框架。通過這個全局異常處理,可以避免將異常信息和系統(tǒng)敏感信息直接拋出給客戶端。
針對特定異常捕獲后可以重新對異常輸出信息做編排,提高交互友好度,同時可以記錄異常信息。
實現(xiàn)思路
我們需要定義一個 Result類,在類中定義需要返回的字段信息,比如狀態(tài)碼、結(jié)果描述、結(jié)果數(shù)據(jù)集等。
接口的狀態(tài)碼很多,我們可以用一個枚舉類進行封裝。于是就有了下面的代碼。順便說一句,推薦大家使用 lombok,減少繁瑣的 set、get、構(gòu)造方法。
狀態(tài)碼枚舉
@Getter
@AllArgsConstructor
public enum ResultEnum {
/**
* return success result.
*/
SUCCESS(200, "接口調(diào)用成功"),
/**
* return business common failed.
*/
COMMON_FAILED(400, "接口調(diào)用失敗"),
NOT_FOUND(404, "接口不存在"),
FORBIDDEN(403, "資源拒絕訪問"),
UNAUTHORIZED(401, "未認證(簽名錯誤)"),
INTERNAL_SERVER_ERROR(500, "服務(wù)器內(nèi)部錯誤"),
NULL_POINT(200002, "空指針異常"),
PARAM_ERROR(200001, "參數(shù)錯誤");
private Integer code;
private String message;
}
統(tǒng)一響應(yīng)封裝
封裝一個固定返回格式的結(jié)構(gòu)對象: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;
}
}
有了統(tǒng)一響應(yīng)體,于是你就可以在 Controller 返回結(jié)果時這樣寫:
@RestController
public class UserController {
@Autowired
private UserService userService;
@RequestMapping(value = "/queryUser")
public Result<User> query(@RequestParam("userId") Long userId){
try {
// 業(yè)務(wù)代碼...
User user = userService.queryId(userId);
return ResultMsg.success(user);
} catch (Exception e){
return ResultMsg.fail(e.getMessage());
}
}
}
唐二婷:Controller 類中每一個方法的返回值類型都只能是這個響應(yīng)對象類,太不優(yōu)雅了。
這個問題問得好。
為了能夠?qū)崿F(xiàn)統(tǒng)一的響應(yīng)對象,又能優(yōu)雅的定義 Controller 類的方法,使其每個方法的返回值是其應(yīng)有的類型。
主要是借助RestControllerAdvice注解和ResponseBodyAdvice接口來實現(xiàn)對接口響應(yīng)給客戶端之前封裝成 Result。
全局統(tǒng)一 Restful API 統(tǒng)一返回
Spring Boot 框架其實已經(jīng)幫助開發(fā)者封裝了很多實用的工具,比如 ResponseBodyAdvice 接口,我們可以利用來實現(xiàn)數(shù)據(jù)格式的統(tǒng)一返回。
忽略響應(yīng)包裝
有些場景下我們不希望 Controller 方法的返回值被包裝為統(tǒng)一響應(yīng)對象,可以先定義一個忽略響應(yīng)封裝的注解,配合后續(xù)代碼實現(xiàn)。
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface IgnoreRestFulAPI {
}
ResponseBodyAdvice 接口
這是 Spring 框架提供的一個接口,我們可以利用它實現(xiàn)對接口數(shù)據(jù)格式統(tǒng)一封裝。
ResponseBodyAdvice可以對 controller 層中的擁有@ResponseBody 注解屬性的方法進行響應(yīng)攔截,用戶可以利用這一特性來封裝數(shù)據(jù)的返回格式,也可以進行加密、簽名等操作。
實現(xiàn)該接口的類還需要添加 @RestControllerAdvice注解,這是一個組合注解,由@ControllerAdvice、@ResponseBody組成,而@ControllerAdvice繼承了@Component,因此@RestControllerAdvice本質(zhì)上是個Component。
本質(zhì)上就是使用 Spring AOP 定義的一個切面,作用于 Controller 方法執(zhí)行完成后的增強操作。
ResponseBodyAdvice接口有兩個方法需要重寫。
- supports方法:實際開發(fā)中不一定所有的方法封裝統(tǒng)一接口響應(yīng),這里可以根據(jù)MethodParameter進行過濾,此方法返回 true 則會走過濾,即會調(diào)用beforeBodyWrite方法,否則不會調(diào)用。
- beforeBodyWrite:編寫具體響應(yīng)客戶端之前的的數(shù)據(jù)邏輯。
@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類型時調(diào)用 beforeBodyWrite 實現(xiàn)響應(yīng)數(shù)據(jù)封裝
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狀態(tài)信息
if (returnType.getParameterType().isAssignableFrom(Void.TYPE)) {
return Result.success();
}
// 返回類型不是 Result,是 String 類型
if (!(body instanceof Result)) {
// warning: RestController方法上返回值類型為String時,默認響應(yīng)的Content-Type是text/plain,
// 需要手動指定為application/json 才能對結(jié)果進行包裝成 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("無法轉(zhuǎn)發(fā)json格式", e);
}
}
}
@RestControllerAdvice 未生效?
ResponseBodyAdvice 接口實現(xiàn)類 GlobalResponseAdvice 沒有被 Spring 管理。
因為啟動類上的 @SpringbootApplication 默認掃描本包和子包。
比如 GlobalResponseAdvice 在 zero.magebyte.shop.common 包下,而啟動類在 zero.magebyte.shop.order.server 包,那么 GlobalResponseAdvice 就不會生效。
為了防止全局接口統(tǒng)一響應(yīng)處理器 GlobalResponseAdvice類未被掃描到,建議在啟動類上加上包掃描。
測試
定義一個 Controller 類來進行簡單的開發(fā)和測試。
@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的方法默認響應(yīng)類型是text/plain,需要手動指定為application/json方可對其進行包裝
*/
@GetMapping(value = "/method4", produces = MediaType.APPLICATION_JSON_VALUE)
public String method4() {
return "會被封裝 Result 結(jié)構(gòu) JSON";
}
/**
* 會被封裝,但是響應(yīng)類型是text/plain
* @return
*/
@GetMapping(value = "/method5")
public String method5() {
return "會被封裝為 Result 的 text/html";
}
}
Result 返回類型
method1 方法返回類型是 Result,所以不會再次封裝,而是直接返回 Result 結(jié)構(gòu),并以 Content-Type: application/json格式響應(yīng)給客戶端。
{
"code": 200,
"message": "接口調(diào)用成功",
"data": 100
}
void 類型
method2 方法返回類型是 void,會封裝成 Result 結(jié)構(gòu),并以 Content-Type: application/json格式響應(yīng)給客戶端。只不過 data 數(shù)據(jù)是 null。
{
"code": 200,
"message": "接口調(diào)用成功",
"data": null
}
@IgnoreRestFulAPI 注解
method3 被 @IgnoreRestFulAPI 注解,不會被封裝 Result 結(jié)構(gòu),直接返回。
String 類型
默認 String 類型的數(shù)據(jù)響應(yīng)給客戶端的格式為 text/html,為了統(tǒng)一響應(yīng)格式,需要手動設(shè)置響應(yīng)類型為 json,如下所示。
@GetMapping(value = "/method4", produces = MediaType.APPLICATION_JSON_VALUE)
響應(yīng)給客戶端的格式就是一個 Result JSON 對象,Content-Type: application/json。
{
"code": 200,
"message": "接口調(diào)用成功",
"data": "會被封裝 Result 結(jié)構(gòu) JSON"
}
否則將會以 Content-Type: text/html;charset=UTF-8響應(yīng)呵客戶端。
另外需要注意的是,如果你使用了 swagger,以上代碼會導(dǎo)致 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:
原因:因為統(tǒng)一響應(yīng)攔截器對 swagger 的接口做了攔截并對結(jié)果做了包裝,導(dǎo)致返回結(jié)構(gòu)發(fā)生后變化,swagger 無法解析。
解決方案:修改統(tǒng)一響應(yīng)處理器攔截的范圍,配置散列包路徑。你可以指定 @RestControllerAdvice(basePackages = {"xxx.xxx"})項目的 controller 目錄即可。
統(tǒng)一異常處理
唐二婷:雖然有了統(tǒng)一結(jié)構(gòu)響應(yīng),接口可以直接返回實際數(shù)據(jù),但是每個接口都要根據(jù)業(yè)務(wù)的要求進行不同程度的 try..catch 處理異常,如果有幾百個接口,不僅編程工作量大,可讀性也差。
@RestController
public class UserController {
@Autowired
private UserService userService;
@RequestMapping(value = "/queryUser")
public User query(@RequestParam("userId") Long userId){
try {
// 業(yè)務(wù)代碼...
User user = userService.queryId(userId);
return user;
} catch (Exception e){
return Result.fail(e.getMessage());
}
}
}
兵來將擋,水來土掩。這樣寫代碼并不是不好看,而是十分垃圾?。?!
如下是我們自定義的業(yè)務(wù)異常。
@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 中,我們不用這樣寫,可以繼續(xù)利用 @RestControllerAdvice
注解和@ExceptionHandler
注解實現(xiàn)全局異常處理器,攔截 Controller 層拋出的異常。
實現(xiàn)方式
新增 GlobalExceptionHandler 類,編寫統(tǒng)一異常處理,類上面添加 @RestControllerAdvice 注解就開啟了全局異常處理。
我們可以在類面創(chuàng)建多個方法,并在方法上添加 @ExceptionHandler 注解,對不同的異常進行定制化處理,并統(tǒng)一返回 Result 結(jié)構(gòu)響應(yīng)給客戶端。
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
/**
* 處理自定義的業(yè)務(wù)異常
*
* @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("發(fā)生業(yè)務(wù)異常!", 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("發(fā)生運行時異常!", 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("發(fā)生空指針異常!", 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("數(shù)據(jù)庫異常!", 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;
}
自定義拋出業(yè)務(wù)異常。
@GetMapping(value = "/method7")
public Order method7() {
Order order = new Order();
order.setId(1);
order.setMoney(999);
if (order.getCreateTime() == null) {
throw new BusinessException("創(chuàng)建時間不能為空");
}
return order;
}
總結(jié)
RestControllerAdvice注解和ResponseBodyAdvice接口來實現(xiàn)對接口響應(yīng)給客戶端之前封裝成 Result。
統(tǒng)一接口響應(yīng)客戶端,減少團隊內(nèi)部不必要的溝通;減輕接口消費者校驗數(shù)據(jù)的負擔(dān);降低其他同事接手代碼的難度;提高接口的健壯性和可擴展性。
通過 @RestControllerAdvice 注解和@ExceptionHandler` 注解實現(xiàn)統(tǒng)一異常處理,能夠減少代碼的重復(fù)度和復(fù)雜度,有利于代碼的維護,并且能夠快速定位到 BUG,大大提高我們的開發(fā)效率。