花了三天整理,Spring Cloud微服務如何設計異常處理機制?還看不懂算我輸
前言
首先說一下為什麼發這篇文章,是這樣的、之前和粉絲聊天的時候有聊到在採用Spring Cloud進行微服務架構設計時,微服務之間呼叫時異常處理機制應該如何設計的問題。我們知道在進行微服務架構設計時,一個微服務一般來說不可避免地會同時面向內部和外部提供相應的功能服務介面。面向外部提供的服務介面,會通過服務閘道器(如使用Zuul提供的apiGateway)面向公網提供服務,如給App客戶端提供的使用者登陸、註冊等服務介面。
而面向內部的服務介面,則是在進行微服務拆分後由於各個微服務系統的邊界劃定問題所導致的功能邏輯分散,而需要微服務之間彼此提供內部呼叫介面,從而實現一個完整的功能邏輯,它是之前單體應用中原生代碼介面呼叫的服務化升級拆分。例如,需要在團購系統中,從下單到完成一次支付,需要交易系統在呼叫訂單系統完成下單後再呼叫支付系統,從而完成一次團購下單流程,這個時候由於交易系統、訂單系統及支付系統是三個不同的微服務,所以為了完成這次使用者訂單,需要App呼叫交易系統提供的外部下單介面後,由交易系統以內部服務呼叫的方式再呼叫訂單系統和支付系統,以完成整個交易流程。如下圖所示:
這裡需要說明的是,在基於SpringCloud的微服務架構中,所有服務都是通過如consul或eureka這樣的服務中介軟體來實現的服務註冊與發現後來進行服務呼叫的,只是面向外部的服務介面會通過閘道器服務進行暴露,面向內部的服務介面則在服務閘道器進行遮蔽,避免直接暴露給公網。而內部微服務間的呼叫還是可以直接通過consul或eureka進行服務發現呼叫,這二者並不衝突,只是外部客戶端是通過呼叫服務閘道器,服務閘道器通過consul再具體路由到對應的微服務介面,而內部微服務則是直接通過consul或者eureka發現服務後直接進行呼叫。
異常處理的差異
面向外部的服務介面,我們一般會將介面的報文形式以JSON的方式進行響應,除了正常的資料報文外,我們一般會在報文格式中冗餘一個響應碼和響應資訊的欄位,如正常的介面成功返回:
{ "code": "0", "msg": "success", "data": { "userId": "zhangsan", "balance": 5000 } }
而如果出現異常或者錯誤,則會相應地返回錯誤碼和錯誤資訊,如:
{ "code": "-1", "msg": "請求引數錯誤", "data": null }
在編寫面向外部的服務介面時,服務端所有的異常處理我們都要進行相應地捕獲,並在controller層對映成相應地錯誤碼和錯誤資訊,因為面向外部的是直接暴露給使用者的,是需要進行比較友好的展示和提示的,即便系統出現了異常也要堅決向用戶進行友好輸出,千萬不能輸出程式碼級別的異常資訊,否則使用者會一頭霧水。對於客戶端而言,只需要按照約定的報文格式進行報文解析及邏輯處理即可,一般我們在開發中呼叫的第三方開放服務介面也都會進行類似的設計,錯誤碼及錯誤資訊分類得也是非常清晰!
而微服務間彼此的呼叫在異常處理方面,我們則是希望更直截了當一些,就像呼叫本地介面一樣方便,在基於Spring Cloud的微服務體系中,微服務提供方會提供相應的客戶端SDK程式碼,而客戶端SDK程式碼則是通過FeignClient的方式進行服務呼叫,如:而微服務間彼此的呼叫在異常處理方面,我們則是希望更直截了當一些,就像呼叫本地介面一樣方便,在基於Spring Cloud的微服務體系中,微服務提供方會提供相應的客戶端SDK程式碼,而客戶端SDK程式碼則是通過FeignClient的方式進行服務呼叫,如:
@FeignClient(value = "order", configuration = OrderClientConfiguration.class, fallback = OrderClientFallback.class) public interface OrderClient { //訂單(內) @RequestMapping(value = "/order/createOrder", method = RequestMethod.POST) OrderCostDetailVo orderCost(@RequestParam(value = "orderId") String orderId, @RequestParam(value = "userId") long userId, @RequestParam(value = "orderType") String orderType, @RequestParam(value = "orderCost") int orderCost, @RequestParam(value = "currency") String currency, @RequestParam(value = "tradeTime") String tradeTime) }
而服務的呼叫方在拿到這樣的SDK後就可以忽略具體的呼叫細節,實現像本地介面一樣呼叫其他微服務的內部介面了,當然這個是FeignClient框架提供的功能,它內部會整合像Ribbon和Hystrix這樣的框架來實現客戶端服務呼叫的負載均衡和服務熔斷功能(註解上會指定熔斷觸發後的處理程式碼類),由於本文的主題是討論異常處理,這裡暫時就不作展開了。
現在的問題是,雖然FeignClient向服務呼叫方提供了類似於原生代碼呼叫的服務對接體驗,但服務呼叫方卻是不希望呼叫時發生錯誤的,即便發生錯誤,如何進行錯誤處理也是服務呼叫方希望知道的事情。另一方面,我們在設計內部介面時,又不希望將報文形式搞得類似於外部介面那樣複雜,因為大多數場景下,我們是希望服務的呼叫方可以直截了的獲取到資料,從而直接利用FeignClient客戶端的封裝,將其轉化為本地物件使用。
@Data @Builder public class OrderCostDetailVo implements Serializable { private String orderId; private String userId; private int status; //1:欠費狀態;2:扣費成功 private int orderCost; private String currency; private int payCost; private int oweCost; public OrderCostDetailVo(String orderId, String userId, int status, int orderCost, String currency, int payCost, int oweCost) { this.orderId = orderId; this.userId = userId; this.status = status; this.orderCost = orderCost; this.currency = currency; this.payCost = payCost; this.oweCost = oweCost; } }
如我們在把返回資料就是設計成了一個正常的VO/BO物件的這種形式,而不是向外部介面那麼樣額外設計錯誤碼或者錯誤資訊之類的欄位,當然,也並不是說那樣的設計方式不可以,只是感覺會讓內部正常的邏輯呼叫,變得比較囉嗦和冗餘,畢竟對於內部微服務呼叫來說,要麼對,要麼錯,錯了就Fallback邏輯就好了。
不過,話雖說如此,可畢竟服務是不可避免的會有異常情況的。如果內部服務在呼叫時發生了錯誤,呼叫方還是應該知道具體的錯誤資訊的,只是這種錯誤資訊的提示需要以異常的方式被集成了FeignClient的服務呼叫方捕獲,並且不影響正常邏輯下的返回物件設計,也就是說我不想額外在每個物件中都增加兩個冗餘的錯誤資訊欄位,因為這樣看起來不是那麼優雅!
既然如此,那麼應該如何設計呢?
最佳實踐設計
首先,無論是內部還是外部的微服務,在服務端我們都應該設計一個全域性異常處理類,用來統一封裝系統在丟擲異常時面向呼叫方的返回資訊。而實現這樣一個機制,我們可以利用Spring提供的註解@ControllerAdvice來實現異常的全域性攔截和統一處理功能。如:
@Slf4j @RestController @ControllerAdvice public class GlobalExceptionHandler { @Resource MessageSource messageSource; @ExceptionHandler({org.springframework.web.bind.MissingServletRequestParameterException.class}) @ResponseBody public APIResponse processRequestParameterException(HttpServletRequest request, HttpServletResponse response, MissingServletRequestParameterException e) { response.setStatus(HttpStatus.FORBIDDEN.value()); response.setContentType("application/json;charset=UTF-8"); APIResponse result = new APIResponse(); result.setCode(ApiResultStatus.BAD_REQUEST.getApiResultStatus()); result.setMessage( messageSource.getMessage(ApiResultStatus.BAD_REQUEST.getMessageResourceName(), null, LocaleContextHolder.getLocale()) + e.getParameterName()); return result; } @ExceptionHandler(Exception.class) @ResponseBody public APIResponse processDefaultException(HttpServletResponse response, Exception e) { //log.error("Server exception", e); response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value()); response.setContentType("application/json;charset=UTF-8"); APIResponse result = new APIResponse(); result.setCode(ApiResultStatus.INTERNAL_SERVER_ERROR.getApiResultStatus()); result.setMessage(messageSource.getMessage(ApiResultStatus.INTERNAL_SERVER_ERROR.getMessageResourceName(), null, LocaleContextHolder.getLocale())); return result; } @ExceptionHandler(ApiException.class) @ResponseBody public APIResponse processApiException(HttpServletResponse response, ApiException e) { APIResponse result = new APIResponse(); response.setStatus(e.getApiResultStatus().getHttpStatus()); response.setContentType("application/json;charset=UTF-8"); result.setCode(e.getApiResultStatus().getApiResultStatus()); String message = messageSource.getMessage(e.getApiResultStatus().getMessageResourceName(), null, LocaleContextHolder.getLocale()); result.setMessage(message); //log.error("Knowned exception", e.getMessage(), e); return result; } /** * 內部微服務異常統一處理方法 */ @ExceptionHandler(InternalApiException.class) @ResponseBody public APIResponse processMicroServiceException(HttpServletResponse response, InternalApiException e) { response.setStatus(HttpStatus.OK.value()); response.setContentType("application/json;charset=UTF-8"); APIResponse result = new APIResponse(); result.setCode(e.getCode()); result.setMessage(e.getMessage()); return result; } }
如上述程式碼,我們在全域性異常中針對內部統一異常及外部統一異常分別作了全域性處理,這樣只要服務介面丟擲了這樣的異常就會被全域性處理類進行攔截並統一處理錯誤的返回資訊。
理論上我們可以在這個全域性異常處理類中,捕獲處理服務介面業務層丟擲的所有異常並統一響應,只是那樣會讓全域性異常處理類變得非常臃腫,所以從最佳實踐上考慮,我們一般會為內部和外部介面分別設計一個統一面向呼叫方的異常物件,如外部統一介面異常我們叫ApiException,而內部統一介面異常叫InternalApiException。這樣,我們就需要在面向外部的服務介面controller層中,將所有的業務異常轉換為ApiException;而在面向內部服務的controller層中將所有的業務異常轉化為InternalApiException。如:
@RequestMapping(value = "/creatOrder", method = RequestMethod.POST) public OrderCostDetailVo orderCost( @RequestParam(value = "orderId") String orderId, @RequestParam(value = "userId") long userId, @RequestParam(value = "orderType") String orderType, @RequestParam(value = "orderCost") int orderCost, @RequestParam(value = "currency") String currency, @RequestParam(value = "tradeTime") String tradeTime)throws InternalApiException { OrderCostVo costVo = OrderCostVo.builder().orderId(orderId).userId(userId).busiId(busiId).orderType(orderType) .duration(duration).bikeType(bikeType).bikeNo(bikeNo).cityId(cityId).orderCost(orderCost) .currency(currency).strategyId(strategyId).tradeTime(tradeTime).countryName(countryName) .build(); OrderCostDetailVo orderCostDetailVo; try { orderCostDetailVo = orderCostServiceImpl.orderCost(costVo); return orderCostDetailVo; } catch (VerifyDataException e) { log.error(e.toString()); throw new InternalApiException(e.getCode(), e.getMessage()); } catch (RepeatDeductException e) { log.error(e.toString()); throw new InternalApiException(e.getCode(), e.getMessage()); } }
如上面的內部服務介面的controller層中將所有的業務異常型別都統一轉換成了內部服務統一異常物件InternalApiException了。這樣全域性異常處理類,就可以針對這個異常進行統一響應處理了。
對於外部服務呼叫方的處理就不多說了。而對於內部服務呼叫方而言,為了能夠更加優雅和方便地實現異常處理,我們也需要在基於FeignClient的SDK程式碼中丟擲統一內部服務異常物件,如:
@FeignClient(value = "order", configuration = OrderClientConfiguration.class, fallback = OrderClientFallback.class) public interface OrderClient { //訂單(內) @RequestMapping(value = "/order/createOrder", method = RequestMethod.POST) OrderCostDetailVo orderCost(@RequestParam(value = "orderId") String orderId, @RequestParam(value = "userId") long userId, @RequestParam(value = "orderType") String orderType, @RequestParam(value = "orderCost") int orderCost, @RequestParam(value = "currency") String currency, @RequestParam(value = "tradeTime") String tradeTime)throws InternalApiException};
這樣在呼叫方進行呼叫時,就會強制要求呼叫方捕獲這個異常,在正常情況下呼叫方不需要理會這個異常,像本地呼叫一樣處理返回物件資料就可以了。在異常情況下,則會捕獲到這個異常的資訊,而這個異常資訊則一般在服務端全域性處理類中會被設計成一個帶有錯誤碼和錯誤資訊的json資料,為了避免客戶端額外編寫這樣的解析程式碼,FeignClient為我們提供了異常解碼機制。如:
@Slf4j @Configuration public class FeignClientErrorDecoder implements feign.codec.ErrorDecoder { private static final Gson gson = new Gson(); @Override public Exception decode(String methodKey, Response response) { if (response.status() != HttpStatus.OK.value()) { if (response.status() == HttpStatus.SERVICE_UNAVAILABLE.value()) { String errorContent; try { errorContent = Util.toString(response.body().asReader()); InternalApiException internalApiException = gson.fromJson(errorContent, InternalApiException.class); return internalApiException; } catch (IOException e) { log.error("handle error exception"); return new InternalApiException(500, "unknown error"); } } } return new InternalApiException(500, "unknown error"); } }
我們只需要在服務呼叫方增加這樣一個FeignClient解碼器,就可以在解碼器中完成錯誤訊息的轉換。這樣,我們在通過FeignClient呼叫微服務時就可以直接捕獲到異常物件,從而實現向本地一樣處理遠端服務返回的異常物件了。
最後
以上就是在利用Spring Cloud進行微服務拆分後關於異常處理機制的一點分享了,因為最近發現公司專案在使用Spring Cloud的微服務拆分過程中,這方面的處理比較混亂,所以寫一篇文章和大家一起探討下,如有更好的方式,也歡迎大家給我留言一起討論!