Spring Cloud微服務如何設計異常處理機制?

前言

今天和大家聊一下在採用Spring Cloud進行微服務架構設計時,微服務之間調用時異常處理機制應該如何設計的問題。我們知道在進行微服務架構設計時,一個微服務一般來說不可避免地會同時面向內部和外部提供相應的功能服務接口。面向外部提供的服務接口,會通過服務網關(如使用Zuul提供的apiGateway)面向公網提供服務,如給App客戶端提供的用戶登陸、註冊等服務接口。

而面向內部的服務接口,則是在進行微服務拆分後由於各個微服務系統的邊界劃定問題所導致的功能邏輯分散,而需要微服務之間彼此提供內部調用接口,從而實現一個完整的功能邏輯,它是之前單體應用中本地代碼接口調用的服務化升級拆分。例如,需要在團購系統中,從下單到完成一次支付,需要交易系統在調用訂單系統完成下單後再調用支付系統,從而完成一次團購下單流程,這個時候由於交易系統、訂單系統及支付系統是三個不同的微服務,所以爲了完成這次用戶訂單,需要App調用交易系統提供的外部下單接口後,由交易系統以內部服務調用的方式再調用訂單系統和支付系統,以完成整個交易流程。如下圖所示:

Spring Cloud微服務如何設計異常處理機制?

這裏需要說明的是,在基於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 proce***equestParameterException(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的微服務拆分過程中,這方面的處理比較混亂,所以寫一篇文章和大家一起探討下,如有更好的方式,也歡迎大家給我留言一起討論!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章