1. 程式人生 > >微服務架構—自動化測試全鏈路設計

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

  • 背景
  • 被忽視的軟體工程環節 - 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架構師)