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

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

標籤:microServices autoTest mock unitTest testTrace

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

背景

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

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

開發層面討論微服務的更多是框架、治理、性能等,但是從完整的軟件工程來看我們嚴重缺失分析、設計知識,這也是我們現在的工程師普遍缺乏的技術。

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

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

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

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

導致測試環節在國內整個設計創新薄弱的原因還有一個主要原因就是,開發工程師普遍沒有完整的工程基礎。在國外IT發達國家,日本、美國等,一個合格的開發工程師、測試工程師都是邊界模糊的,自己開發產品自己測試,這需要切換思維模式,需要同時具備這兩種能力,但是這纔是整個軟件工程的完整流程。

我們有沒有想過一個問題,爲什麼現在大家都在談論 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架構師)
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章