花了三天整理,Spring Cloud微服務如何設計異常處理機制?還看不懂算我輸

前言

首先說一下為什么發(fā)這篇文章,是這樣的、之前和粉絲聊天的時候有聊到在采用Spring Cloud進行微服務架構設計時,微服務之間調用時異常處理機制應該如何設計的問題。我們知道在進行微服務架構設計時,一個微服務一般來說不可避免地會同時面向內部和外部提供相應的功能服務接口。面向外部提供的服務接口,會通過服務網關(如使用Zuul提供的apiGateway)面向公網提供服務,如給App客戶端提供的用戶登陸、注冊等服務接口。

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

這里需要說明的是,在基于SpringCloud的微服務架構中,所有服務都是通過如consul或eureka這樣的服務中間件來實現(xiàn)的服務注冊與發(fā)現(xiàn)后來進行服務調用的,只是面向外部的服務接口會通過網關服務進行暴露,面向內部的服務接口則在服務網關進行屏蔽,避免直接暴露給公網。而內部微服務間的調用還是可以直接通過consul或eureka進行服務發(fā)現(xiàn)調用,這二者并不沖突,只是外部客戶端是通過調用服務網關,服務網關通過consul再具體路由到對應的微服務接口,而內部微服務則是直接通過consul或者eureka發(fā)現(xiàn)服務后直接進行調用。

異常處理的差異

面向外部的服務接口,我們一般會將接口的報文形式以JSON的方式進行響應,除了正常的數(shù)據(jù)報文外,我們一般會在報文格式中冗余一個響應碼和響應信息的字段,如正常的接口成功返回:

{
    "code": "0",
    "msg": "success",
    "data": {
        "userId": "zhangsan",
        "balance": 5000
    }
}

而如果出現(xiàn)異?;蛘咤e誤,則會相應地返回錯誤碼和錯誤信息,如:

{
    "code": "-1",
    "msg": "請求參數(shù)錯誤",
    "data": null
}

在編寫面向外部的服務接口時,服務端所有的異常處理我們都要進行相應地捕獲,并在controller層映射成相應地錯誤碼和錯誤信息,因為面向外部的是直接暴露給用戶的,是需要進行比較友好的展示和提示的,即便系統(tǒng)出現(xiàn)了異常也要堅決向用戶進行友好輸出,千萬不能輸出代碼級別的異常信息,否則用戶會一頭霧水。對于客戶端而言,只需要按照約定的報文格式進行報文解析及邏輯處理即可,一般我們在開發(fā)中調用的第三方開放服務接口也都會進行類似的設計,錯誤碼及錯誤信息分類得也是非常清晰!

而微服務間彼此的調用在異常處理方面,我們則是希望更直截了當一些,就像調用本地接口一樣方便,在基于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后就可以忽略具體的調用細節(jié),實現(xiàn)像本地接口一樣調用其他微服務的內部接口了,當然這個是FeignClient框架提供的功能,它內部會集成像Ribbon和Hystrix這樣的框架來實現(xiàn)客戶端服務調用的負載均衡和服務熔斷功能(注解上會指定熔斷觸發(fā)后的處理代碼類),由于本文的主題是討論異常處理,這里暫時就不作展開了。

現(xiàn)在的問題是,雖然FeignClient向服務調用方提供了類似于本地代碼調用的服務對接體驗,但服務調用方卻是不希望調用時發(fā)生錯誤的,即便發(fā)生錯誤,如何進行錯誤處理也是服務調用方希望知道的事情。另一方面,我們在設計內部接口時,又不希望將報文形式搞得類似于外部接口那樣復雜,因為大多數(shù)場景下,我們是希望服務的調用方可以直截了的獲取到數(shù)據(jù),從而直接利用FeignClient客戶端的封裝,將其轉化為本地對象使用。

@Data
@Builder
public class OrderCostDetailVo implements Serializable {

    private String orderId;
    private String userId;
    private int status;   //1:欠費狀態(tài);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;
    }
}

如我們在把返回數(shù)據(jù)就是設計成了一個正常的VO/BO對象的這種形式,而不是向外部接口那么樣額外設計錯誤碼或者錯誤信息之類的字段,當然,也并不是說那樣的設計方式不可以,只是感覺會讓內部正常的邏輯調用,變得比較啰嗦和冗余,畢竟對于內部微服務調用來說,要么對,要么錯,錯了就Fallback邏輯就好了。

不過,話雖說如此,可畢竟服務是不可避免的會有異常情況的。如果內部服務在調用時發(fā)生了錯誤,調用方還是應該知道具體的錯誤信息的,只是這種錯誤信息的提示需要以異常的方式被集成了FeignClient的服務調用方捕獲,并且不影響正常邏輯下的返回對象設計,也就是說我不想額外在每個對象中都增加兩個冗余的錯誤信息字段,因為這樣看起來不是那么優(yōu)雅!

既然如此,那么應該如何設計呢?

最佳實踐設計

首先,無論是內部還是外部的微服務,在服務端我們都應該設計一個全局異常處理類,用來統(tǒng)一封裝系統(tǒng)在拋出異常時面向調用方的返回信息。而實現(xiàn)這樣一個機制,我們可以利用Spring提供的注解@ControllerAdvice來實現(xiàn)異常的全局攔截和統(tǒng)一處理功能。如:

@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;
    }

    /**
     * 內部微服務異常統(tǒng)一處理方法
     */
    @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;
    }
}

如上述代碼,我們在全局異常中針對內部統(tǒng)一異常及外部統(tǒng)一異常分別作了全局處理,這樣只要服務接口拋出了這樣的異常就會被全局處理類進行攔截并統(tǒng)一處理錯誤的返回信息。

理論上我們可以在這個全局異常處理類中,捕獲處理服務接口業(yè)務層拋出的所有異常并統(tǒng)一響應,只是那樣會讓全局異常處理類變得非常臃腫,所以從最佳實踐上考慮,我們一般會為內部和外部接口分別設計一個統(tǒng)一面向調用方的異常對象,如外部統(tǒng)一接口異常我們叫ApiException,而內部統(tǒng)一接口異常叫InternalApiException。這樣,我們就需要在面向外部的服務接口controller層中,將所有的業(yè)務異常轉換為ApiException;而在面向內部服務的controller層中將所有的業(yè)務異常轉化為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層中將所有的業(yè)務異常類型都統(tǒng)一轉換成了內部服務統(tǒng)一異常對象InternalApiException了。這樣全局異常處理類,就可以針對這個異常進行統(tǒng)一響應處理了。

對于外部服務調用方的處理就不多說了。而對于內部服務調用方而言,為了能夠更加優(yōu)雅和方便地實現(xiàn)異常處理,我們也需要在基于FeignClient的SDK代碼中拋出統(tǒng)一內部服務異常對象,如:

@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};

這樣在調用方進行調用時,就會強制要求調用方捕獲這個異常,在正常情況下調用方不需要理會這個異常,像本地調用一樣處理返回對象數(shù)據(jù)就可以了。在異常情況下,則會捕獲到這個異常的信息,而這個異常信息則一般在服務端全局處理類中會被設計成一個帶有錯誤碼和錯誤信息的json數(shù)據(jù),為了避免客戶端額外編寫這樣的解析代碼,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調用微服務時就可以直接捕獲到異常對象,從而實現(xiàn)向本地一樣處理遠程服務返回的異常對象了。

最后

以上就是在利用Spring Cloud進行微服務拆分后關于異常處理機制的一點分享了,因為最近發(fā)現(xiàn)公司項目在使用Spring Cloud的微服務拆分過程中,這方面的處理比較混亂,所以寫一篇文章和大家一起探討下,如有更好的方式,也歡迎大家給我留言一起討論!

?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

友情鏈接更多精彩內容