微服務架構—自動化測試全鏈路設計

  • 背景
  • 被忽視的軟件工程環節 - DEVTESTOPS
  • 微服務架構下測試複雜度和效率問題
  • 開發階段 unitTest mock 外部依賴
  • 連調階段 mock 外部依賴
  • 自動化測試階段 mock 需求
  • autoTest Mock Gateway 浮出水面
  • 輕量級版本實現
    • 總體邏輯架構
    • mock parameter 歸入服務框架標準 request contract
    • 使用 AOP + RestEasy HttpClientRequest SPI 初步實現 Mock
  • 總結

背景

SOA 架構到如今大行其道的微服務架構,系統越拆越小,總體架構的複雜度也是直線上升,咱們一直老生常談的微服務架構下的技術難點及解決方案也日漸成熟(包括典型的數據一致性,系統調用帶來的一致性問題,仍是跨節點跨機房複製帶來的一致性問題都有了不少解決方案),可是有一個環節咱們明顯忽略了。java

在如今的微服務架構趨勢下,微服務在運維層面和自動化部署方面基本上是比較完善了。從我我的經驗來看,上層的開發、測試對微服務架構帶來的巨大變化還在反應和學習中。後端

開發層面討論微服務的更可能是框架、治理、性能等,可是從完整的軟件工程來看咱們嚴重缺失分析、設計知識,這也是咱們如今的工程師廣泛缺少的技術。api

咱們常常會發現一旦你想重構點東西是多麼的艱難,就是由於在初期構造這棟建築的時候嚴重缺失了通盤的分析、設計,最終致使這個建築慢慢僵化最後人見人怕,由於他逐漸變成一個怪物。(好比,開發不多寫 unitTest ,咱們老是忽視單元測試背後產生的軟件工程的價值。)架構

被忽視的軟件工程環節 — DEVTESTOPS

咱們有沒有發現一個現象,在整個軟件過程裏,測試這個環節容易被忽視。任何一種軟件工程模型都有 QA 環節,可是這個環節彷佛很薄很弱,目前咱們絕大多數工程師、架構師都嚴重低估了這個環節的力量和價值,還停留在無技術含量,手動功能測試低級效率印象裏。框架

這主要是測試這個角色整個技術體系、工程化能力偏弱,一部分是客觀大環境問題,還有一部分自身問題,沒有讓本身走出去,多去學習整個工程化的技術,多去了解開發的技術,生產上的物理架構,這會有助於測試放大本身的聲音。運維

致使測試環節在國內整個設計創新薄弱的緣由還有一個主要緣由就是,開發工程師廣泛沒有完整的工程基礎。在國外IT發達國家,日本、美國等,一個合格的開發工程師、測試工程師都是邊界模糊的,本身開發產品本身測試,這須要切換思惟模式,須要同時具有這兩種能力,可是這纔是整個軟件工程的完整流程。ide

咱們有沒有想過一個問題,爲何如今你們都在談論 DevOps,而不是 DevTestOps,爲何恰恰跳過測試這個環節,難道開發的系統須要具有良好的可運維性就不須要可測試性嗎,開發須要具有運維能力,運維須要具有開發能力,爲何測試環節忽略了。微服務

咱們對 QA 環節的輕視,對測試角色的不重視其實帶來的反作用是很是大的。工具

微服務架構下測試複雜度和效率問題

微服務的拆分粒度要比 SOA 細了不少,從容器化鏡像自動部署來衡量,是拆小了以後很方便,可是拆小了以後會給整個開發、測試環節增長很大的複雜度和效率問題。性能

SOA 時期,契約驅動 這個原則在微服務裏也同樣適用,跨部門需求定義好契約你就能夠先開發上線了。可是這個裏面最大的問題就是當前系統的部分連調問題和自動化迴歸問題,若是是新系統上線還須要作性能壓測,這外部的依賴如何解決。

也許咱們會說,不是應該依賴方先ready,而後咱們緊接着進行測試、發佈嗎。若是是業務、架構合理的狀況下,這種場景最大的問題就是咱們的項目容易被依賴方牽制,這會帶來不少問題,好比,研發人員須要切換出來作其餘事情,branch 一直掛着,不知道哪天忽然來找你說能夠對接了,也許這已通過去一個月或者更久,這種方式一旦養成習慣性研發流程就很容易產生線上 BUG

還有一種狀況也是合理的狀況就是平臺提供方須要調用業務方的接口,這裏面有通常調用的 callback 接口、交易鏈路上的 marketing 接口、配送 routing 接口等。

這裏給你們分享咱們目前正在進行中的 marketing-cloud (營銷雲) 規則引擎 項目。

marketing-cloud 提供了一些營銷類業務,有 團購優惠券促銷 等,可是咱們的業務方須要有本身個性化的營銷活動玩法,咱們須要在 marketing-cloud 規則引擎 中抽象出業務方營銷活動的返回信息,同時打通個性化營銷活動與公共交易、結算環節,造成一個完整的業務流。

這是一個 marketing-cloud 邏輯架構圖,跟咱們主題相關的就是 營銷規則引擎 ,他就是咱們這裏所說的合理的業務場景。

在整個正向下單過程當中,營銷規則引擎要肩負起既要提供 marketing-cloud 內的共用營銷活動,還須要橋接外部營銷中心的各種營銷玩法,外部的營銷中心會有多個,目前咱們主要有兩個。

因爲這篇文章不是介紹營銷平臺怎麼設計,因此這裏不打算擴展話題。主要是起到拋磚引玉的目的,平臺型的業務會存在各類各樣的對外系統依賴的業務場景。文章接下來的部分將展開 marketing-cloud 規則引擎 在打通測試鏈路上的實踐。

開發階段 unitTest mock 外部依賴

在開發階段,咱們會常常性的編寫單元測試來測試咱們的邏輯,在編寫 unitTest 的時候都須要 mock 周邊的依賴,mock 出來的對象分爲兩種類型,一種是不具備 Assert 邏輯的 stub 樁 對象,還有一種就是須要支持 Assertmocker 模擬對象。

可是咱們也不須要明顯區分他們,二者的區別不是太明顯,在編碼規範內可能須要區分。

咱們關心的是如何解決對象之間的依賴問題,各類 mock 框架其實提供了不少很是好用的工具,咱們能夠很輕鬆的 mock 周邊的依賴。

given(marketingService.mixMarketingActivity(anyObject())).willReturn(stubResponse);
RuleCalculateResponse response = this.ruleCalculatorBiz.ruleCalculate(request);

這裏咱們 mockmarketingService.mixMarketingActivity() 方法。

Java 世界裏提供了不少好用的 mock 框架,比較流行好用的框架之一 mockito 能夠輕鬆 mock Service 層的依賴,固然除了 mockito 以外還有不少優秀的 mock 框架。

這些框架大同小異,編寫 unitTest 最大的問題就是如何重構邏輯使之更加便於測試,也就是代碼是否具有很好的可測試性,是否已經消除了絕大多數 private 方法,private 方法是否有某些指責是咱們沒有捕捉到業務概念。

連調階段 mock 外部依賴

在咱們完成了全部的開發,完善的單元測試保證了咱們內部的邏輯是沒有問題的(固然這裏不討論 unitTestcase 的設計是否完善狀況)。

如今咱們須要對接周邊系統開發進行連調了,這個周邊系統仍是屬於本平臺之類的其餘支撐系統。好比咱們的 marketing-cloud 規則引擎系統下單系統 之間的關係。在開發的時候咱們編寫 unitTest 是順利的完成了開發解決的驗證工做,可是如今面對連調問題。

系統須要正式的跑起來,可是咱們缺少對外部營銷中心的依賴,咱們怎麼辦。其實咱們也須要在連調階段 mock 外部依賴,只不過這個 mock 的技術和方法不是經過 unitTest 框架來支持,而是須要咱們本身來設計咱們的整個服務的開發架構。

首先要能識別本次 request 是須要 mock 的,那就須要某種 mock parameter 參數來提供識別能力。

咱們來看下 marketing-cloud 營銷規則引擎 在這塊的一個初步嘗試。

public interface CCMarketingCentralFacade {
    CallResponse callMarketingCentral(CallRequest request);
}
public interface ClassMarketingCentralFacade {
    CallResponse callMarketingCentral(CallRequest request);
}

營銷規則引擎使用 RestEasy client api 做爲 rest 調用框架。這兩個 Facade 是營銷平臺對 CCTalk滬江網校 滬江兩大子公司營銷中心發起調用的 Facade

(爲了儘可能還原咱們的工程實踐乾貨同時須要消除一些敏感信息的狀況下,整篇文章全部的代碼實例,我都刪除了一些不影響閱讀且和本文無關的代碼,同時作了一些僞編碼和省略,使代碼更精簡更便於閱讀。)

在正常邏輯下,咱們會根據營銷路由 key 來決定調用哪一個公司的營銷中心接口,可是因爲咱們在開發這個項目的時候暫時業務方尚未存在的地址讓咱們對接,因此咱們本身作了 mock facade,來解決連調問題。

public class CCMarketingCentralFacadeMocker implements CCMarketingCentralFacade {

    @Override
    public CallResponse callMarketingCentral(CallRequest request) {

        CallResponse response = ...
        MarketingResultDto marketingResultDto = ...
        marketingResultDto.setTotalDiscount(new BigDecimal("90.19"));
        marketingResultDto.setUseTotalDiscount(true);

        response.getData().setMarketingResult(marketingResultDto);

        return response;
    }
}
public class ClassMarketingCentralFacadeMocker implements ClassMarketingCentralFacade {

    @Override
    public CallResponse callMarketingCentral(CallRequest request) {
        CallResponse response = ...

        MarketingResultDto marketingResultDto = ...
        marketingResultDto.setUseCoupon(true);
        marketingResultDto.setTotalDiscount(null);
        marketingResultDto.setUseTotalDiscount(false);

        List<MarketingProductDiscountDto> discountDtos = ...

        request.getMarketingProductTagsParameter().getMarketingTags().forEach(item -> {

            MarketingProductDiscountDto discountDto = ...
            discountDto.setProductId(item.getProductID());
            ...
            discountDtos.add(discountDto);
        });
...
        return response;
    }
}

咱們定義了兩個 mock 類,都是一些測試數據,就是爲了解決在連調階段的問題,也就是在 DEV 環境上的依賴問題。

有了 mock facade 以後就須要 request 定義 mock parameter 參數了。

public abstract class BaseRequest implements Serializable {
    public MockParameter mockParameter;
}
public class MockParameter {

    /**
     * mock cc 營銷調用接口
     */
    public Boolean mockCCMarketingInterface;

    /**
     * mock class 營銷調用接口
     */
    public Boolean mockClassMarketingInterface;

    /**
     * 是否自動化測試 mock
     */
    public Boolean useAutoTestMock;

    /**
     * 測試mock參數
     */
    public String testMockParam;

}

咱們暫且忽略通用型之類的設計,這裏只是咱們在趕項目的狀況下作的一個迭代嘗試,等咱們把這整個流程都跑通了再來考慮重構提取框架。

有了輸入參數,咱們就能夠根據參數判斷來動態注入 mock facade

自動化測試階段 mock 需求

咱們繼續向前推動,過了連調階段緊接着就進入測試環節,如今基本上大多數互聯網公司都是自動化的測試,不多在有手動的,尤爲是後端系統。

那麼在 autoTest 階段面臨的一個問題就是,咱們須要一個公共的 autoTest 地址,這個測試地址是不變的,咱們在自動化測試下 mockfacade bean 的地址就是這個地址,這個地址輸出的值須要可以對應到每次自動化腳本執行的上下文中。

咱們有不少微服務系統來組成一個平臺,每一個服務都有依賴的第三方接口,原來在自動化測試這些服務的時候都須要去了解業務方系統的接口、DB、前臺入口等,由於在編寫自動化腳本的時候須要同步建立測試數據,最後才能 Assert

這個跨部門的溝通和協做效率嚴重低下,並且人員變更、系統變更都會直接影響上線週期,這裏絕對值得創新來解決這個效率嚴重阻塞問題。

@Value("${marketing.cloud.business.access.url.mock}")
private String mockUrl;
/**
     * 自動化測試 mocker bean
     */
    @Bean("CCMarketingCentralFacadeTestMock")
    public CCMarketingCentralFacade CCMarketingCentralFacadeTestMock() {
        RestClientProxyFactoryBean<CCMarketingCentralFacade> restClientProxyFactoryBean ...
        restClientProxyFactoryBean.setBaseUri(this.mockUrl);
        ...
    }

    /**
     * 自動化測試 mocker bean
     */
    @Bean("ClassMarketingCentralFacadeTestMock")
    public ClassMarketingCentralFacade ClassMarketingCentralFacadeTestMock()  {
        RestClientProxyFactoryBean<ClassMarketingCentralFacade> restClientProxyFactoryBean ...
        restClientProxyFactoryBean.setBaseUri(this.mockUrl);
        ...
    }

這裏的 mockUrl 就是咱們抽象出來的統一的 autoTest 地址,在前面的 mock parameter 中有一個 useAutoTestMock Boolean 類型的參數,若是當前請求此參數爲 true,咱們將動態注入自動化測試 mock bean ,後續的全部調用都會走到 mockUrl 指定的地方。

autoTest Mock Gateway 浮出水面

到目前爲止,咱們遇到了自動化測試統一的 mock 地址要收口全部微服務在這方面的需求。如今最大的問題就是,全部的微服務對外依賴的 response 都不相同,自動化腳本在執行的時候預先建立好的 response 要能適配到當前測試的上下文中。

好比,營銷規則引擎,咱們的自動化腳本在建立一個訂單的時候須要預先構造好當前商品(好比,productID:101010),在獲取外部營銷中心提供的活動信息和抵扣信息的 response ,最後才能去 Assert 訂單的金額和活動信息記錄是否正確,這就是一次 autoTest context

有兩種方式來識別當前 autoTest context ,一種是在 case 執行的時候肯定商品ID,最後經過商品ID來獲取 mockresponse 。還有一種就是支持傳遞 autoTest mock 參數給到 mockUrl 指定的服務,可使用這個參數來識別當前測試上下文。

一個測試 case 可能會穿過不少微服務,這些全部的依賴服務可能都須要預設 mock response,這基本上是一勞永逸的。

因此,咱們抽象出了 autoTest Mock Gateway(自動化測試mock網關服務) ,在整個自動化測試環節還有不少須要支持的工做,服務之間的鑑權,鑑權 keymock,加解密,加解密 keymock,自動化測試 case 交替並行執行等。

做爲工程師的咱們都但願用系統化、工程化的方式來解決總體問題,而不是個別點狀問題。有了這個 mock gateway 咱們能夠作不少事情,也能夠普惠全部須要的其餘部門。

在一次 autoTest context 裏構造好 mock response,而後經過 mock parameter 來動態識別具體的來源服務進行路由、鑑權、加解密等操做。

MockGateway 是一個支點,我相信這個支點能夠撬動不少測試空間和創新能力。

輕量級版本實現

接下來咱們將展現在 marketing-cloud 營銷規則引擎 中的初步嘗試。

總體邏輯架構

自動化腳本在每跑一個 case 的時候會建立當前 case 對應的 autoTestContext,這裏面都是一些 meta data,用來表示這個 case 中全部涉及到的微服務系統哪些是須要走 mock gateway 的。

mockGateway 中全部的配置都是有一個 autoTestContext 所對應,若是沒有 autoTestContext 說明是全部 case 共用。

將 mock parameter 歸入服務框架標準 request contract

要想打通整個微服務架構中的全部通道,就須要在標準 request contract 定義 mockParameter ,這是這一切的前提。

服務與服務之間調用走標準微服務 request contract,服務與外部系統的依賴能夠選擇走 HTTP Header,也能夠選擇走標準 request ,就要看咱們的整個服務框架是否已經覆蓋全部的產線及一些遺留系統的問題。

public abstract class BaseRequest implements Serializable {
    public MockParameter mockParameter;
}

BaseRequest 是全部 request 的基類,這樣才能保證全部的請求可以正常的傳遞。

使用 AOP + RestEasy HttpClientRequest SPI 初步實現 Mock

整個系統的開發架構分層依賴是:facade->biz->service,基本的全部核心邏輯都是在 service 中,請求的 request dto 最多不能越界到 service 層,按照規範講 request dto 頂多滯留在 biz 層,可是在互聯網的世界中一些都是能夠快速迭代的,並非多麼硬性規定,及時重構是償還技術債務的主要方法。

前面咱們已經講過,咱們採用的 RPC 框架是 RestEasy + RestEasy client ,咱們先來看下入口的地方。

@Component
@Path("v1/calculator/")
public class RuleCalculatorFacadeImpl extends BaseFacade implements RuleCalculatorFacade {
    @MockFacade(Setting = MockFacade.SETTING_REQUEST_MOCK_PARAMETER)
    public RuleCalculateResponse ruleCalculate(RuleCalculateRequest request)  {
    ...
    }
}

再看下 service 對象。

@Component
public class MarketingServiceImpl extends MarketingBaseService implements MarketingService {
    @MockFacade(Setting = MockFacade.SETTING_FACADE_MOCK_BEAN)
    public MarketingResult onlyExtendMarketingActivity(Marketing..Parameter tagsParameter) {
    ...
    }

咱們重點看下 @MockFacade annotation 聲明。

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface MockFacade {

    String SETTING_REQUEST_MOCK_PARAMETER = "setting_request_mock_parameter";
    String SETTING_FACADE_MOCK_BEAN = "setting_facade_mock_bean";

    String Setting();
}

經過這個 annotation 咱們的主要目的就是將 mockParameter 放到 ThreadLocal 中去和請求處理完時的清理工做。還有一個功能就是 service 層的 mock bean 處理。

@Aspect
@Component
@Slf4j
public class MockMarketingFacadeInterceptor {

    @Before("@annotation(mockFacade)")
    public void beforeMethod(JoinPoint joinPoint, MockFacade mockFacade) {

        String settingName = mockFacade.Setting();

        if (MockFacade.SETTING_REQUEST_MOCK_PARAMETER.equals(settingName)) {

            Object[] args = joinPoint.getArgs();
            if (args == null) return;

            List<Object> argList = Arrays.asList(args);
            argList.forEach(item -> {

                if (item instanceof BaseRequest) {
                    BaseRequest request = (BaseRequest) item;

                    if (request.getMockParameter() != null) {
                        MarketingBaseService.mockParameterThreadLocal.set(request.getMockParameter());
                        log.info("----setting mock parameter:{}", JSON.toJSONString(request.getMockParameter()));
                    }
                }
            });
        } else if (MockFacade.SETTING_FACADE_MOCK_BEAN.equals(settingName)) {

            MarketingBaseService marketingBaseService = (MarketingBaseService) joinPoint.getThis();
            marketingBaseService.mockBean();
            log.info("----setting mock bean.");
        }
    }

    @After("@annotation(mockFacade)")
    public void afterMethod(JoinPoint joinpoint, MockFacade mockFacade) {

        if (MockFacade.SETTING_FACADE_MOCK_BEAN.equals(mockFacade.Setting())) {

            MarketingBaseService marketingBaseService = (MarketingBaseService) joinpoint.getThis();
            marketingBaseService.mockRemove();

            log.info("----remove mock bean.");
        }

        if (MockFacade.SETTING_REQUEST_MOCK_PARAMETER.equals(mockFacade.Setting())) {

            MarketingBaseService.mockParameterThreadLocal.remove();

            log.info("----remove ThreadLocal. ThreadLocal get {}", MarketingBaseService.mockParameterThreadLocal.get());
        }
    }
}

這些邏輯徹底基於一個約定,就是 MarketingBaseService,不具備通用型,只是在逐步的重構和提取中,最終會是一個 plugin 框架。

public abstract class MarketingBaseService extends BaseService {

    protected ClassMarketingCentralFacade classMarketingCentralFacade;

    protected CCMarketingCentralFacade ccMarketingCentralFacade;

    public static ThreadLocal<MockParameter> mockParameterThreadLocal = new ThreadLocal<>();

    public void mockBean() {

        MockParameter mockParameter = mockParameterThreadLocal.get();

        if (mockParameter != null && mockParameter.mockClassMarketingInterface) {
            if (mockParameter.useAutoTestingMock) {
                this.setClassMarketingCentralFacade(SpringContextHolder.getBean("ClassMarketingCentralFacadeTestMock", ClassMarketingCentralFacade.class));
            } else {
                this.setClassMarketingCentralFacade(SpringContextHolder.getBean("ClassMarketingCentralFacadeMocker", ClassMarketingCentralFacadeMocker.class));
            }
        } else {
            this.setClassMarketingCentralFacade(SpringContextHolder.getBean("ClassMarketingCentralFacade", ClassMarketingCentralFacade.class));
        }

        if (mockParameter != null && mockParameter.mockCCMarketingInterface) {
            if (mockParameter.useAutoTestingMock) {
                this.setCcMarketingCentralFacade(SpringContextHolder.getBean("CCMarketingCentralFacadeTestMock", CCMarketingCentralFacade.class));
            } else {
                this.setCcMarketingCentralFacade(SpringContextHolder.getBean("CCMarketingCentralFacadeMocker", CCMarketingCentralFacadeMocker.class));
            }
        } else {
            this.setCcMarketingCentralFacade(SpringContextHolder.getBean("CCMarketingCentralFacade", CCMarketingCentralFacade.class));
        }
    }

    public void mockRemove() {
        mockParameterThreadLocal.remove();
    }
}

咱們能夠順利的將 request 中的 mockParameter 放到 ThreadLocal 中,能夠動態的經過 AOP 的方式來注入相應的 mockerBean

如今咱們還要處理的就是對 mockGateway 的調用將 __mockParameter_ 中的 autoContext 中的標示字符串放到 HTTP Header 中去。

@Component
public class MockHttpHeadSetting implements ClientRequestFilter {

    @Override
    public void filter(ClientRequestContext requestContext) throws IOException {

        MultivaluedMap<String, Object> header = requestContext.getHeaders();

        MockParameter mockParameter = MarketingBaseService.mockParameterThreadLocal.get();

        if (mockParameter != null && StringUtils.isNotBlank(mockParameter.getTestingMockParam())) {
            header.add("Mock-parameter", mockParameter.getTestingMockParam());
        }
    }
}

接着在 SPI(javax.ws.rs.ext.Providers ) 文件中配置便可

com.hujiang.marketingcloud.ruleengine.service.MockHttpHeadSetting

總結

在整個微服務架構的實踐中,工程界一直缺乏探討的就是在微服務架構的測試這塊,離咱們比較近的是自動化測試,由於自動化測試基本上是全部系統都須要的。

可是有一塊咱們一直沒有重視的就是 全鏈路壓力測試 這塊,在生產上進行全鏈路的真實的壓力測試須要解決不少問題,比較重要的就是 DB 這塊,壓測的時候產生的全部交易數據不可以參與結算、財務流程,這就須要藉助 影子表 來解決,全部的數據都不會寫入最終的真實的交易數據中去。固然還有其餘地方都須要解決,一旦打開全鏈路壓測開關,應該須要處理全部產生數據的地方,這是一個龐大的工程,可是也會很是有意思。

本篇文章只是咱們在這塊的一個初步嘗試,咱們會繼續擴展下去,在下次產線全鏈路壓測的時候咱們就能夠藉助如今的實踐架構擴展起來。

做者:王清培 (滬江集團資深JAVA架構師)

相關文章
相關標籤/搜索