天天看點

微服務架構—自動化測試全鍊路設計背景被忽視的軟體工程環節 — DEVTESTOPS 微服務架構下測試複雜度和效率問題開發階段 unitTest mock 外部依賴連調階段 mock 外部依賴自動化測試階段 mock 需求 autoTest Mock Gateway 浮出水面輕量級版本實作總結

  • 背景
  • 被忽視的軟體工程環節 - 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 規則引擎 中抽象出業務方營銷活動的傳回資訊,同時打通個性化營銷活動與公共交易、結算環節,形成一個完整的業務流。

微服務架構—自動化測試全鍊路設計背景被忽視的軟體工程環節 — DEVTESTOPS 微服務架構下測試複雜度和效率問題開發階段 unitTest mock 外部依賴連調階段 mock 外部依賴自動化測試階段 mock 需求 autoTest Mock Gateway 浮出水面輕量級版本實作總結

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

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

由于這篇文章不是介紹營銷平台怎麼設計,是以這裡不打算擴充話題。主要是起到抛磚引玉的目的,平台型的業務會存在各種各樣的對外系統依賴的業務場景。文章接下來的部分将展開 marketing-cloud 規則引擎 在打通測試鍊路上的實踐。

在開發階段,我們會經常性的編寫單元測試來測試我們的邏輯,在編寫 unitTest 的時候都需要 mock 周邊的依賴,__mock__ 出來的對象分為兩種類型,一種是不具有 Assert 邏輯的 stub 樁 對象,還有一種就是需要支援 Assert 的 mocker 模拟對象。

但是我們也不需要明顯區分他們,兩者的差別不是太明顯,在編碼規範内可能需要區分。

我們關心的是如何解決對象之間的依賴問題,各種 mock 架構其實提供了很多非常好用的工具,我們可以很輕松的 mock 周邊的依賴。

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

這裡我們 mock 了 marketingService.mixMarketingActivity() 方法。

Java 世界裡提供了很多好用的 mock 架構,比較流行好用的架構之一 mockito 可以輕松 mock Service 層的依賴,當然除了 mockito 之外還有很多優秀的 mock 架構。

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

在我們完成了所有的開發,完善的單元測試保證了我們内部的邏輯是沒有問題的(當然這裡不讨論 unitTest 的 case 的設計是否完善情況)。

現在我們需要對接周邊系統開發進行連調了,這個周邊系統還是屬于本平台之類的其他支撐系統。比如我們的 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__。

我們繼續向前推進,過了連調階段緊接着就進入測試環節,現在基本上大多數網際網路公司都是自動化的測試,很少在有手動的,尤其是後端系統。

那麼在 autoTest 階段面臨的一個問題就是,我們需要一個公共的 autoTest 位址,這個測試位址是不變的,我們在自動化測試下 mock 的 facade 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 指定的地方。

到目前為止,我們遇到了自動化測試統一的 mock 位址要收口所有微服務在這方面的需求。現在最大的問題就是,所有的微服務對外依賴的 response 都不相同,自動化腳本在執行的時候預先建立好的 response 要能适配到目前測試的上下文中。

比如,營銷規則引擎,我們的自動化腳本在建立一個訂單的時候需要預先構造好目前商品(比如,__productID:101010__),在擷取外部營銷中心提供的活動資訊和抵扣資訊的 response ,最後才能去 Assert 訂單的金額和活動資訊記錄是否正确,這就是一次 autoTest context 。

微服務架構—自動化測試全鍊路設計背景被忽視的軟體工程環節 — DEVTESTOPS 微服務架構下測試複雜度和效率問題開發階段 unitTest mock 外部依賴連調階段 mock 外部依賴自動化測試階段 mock 需求 autoTest Mock Gateway 浮出水面輕量級版本實作總結

有兩種方式來識别目前 autoTest context ,一種是在 case 執行的時候确定商品ID,最後通過商品ID來擷取 mock 的 response 。還有一種就是支援傳遞 autoTest mock 參數給到 mockUrl 指定的服務,可以使用這個參數來識别目前測試上下文。

一個測試 case 可能會穿過很多微服務,這些所有的依賴服務可能都需要預設 __mock response__,這基本上是一勞永逸的。

是以,我們抽象出了 autoTest Mock Gateway(自動化測試mock網關服務) ,在整個自動化測試環節還有很多需要支援的工作,服務之間的鑒權,鑒權 key 的 mock__,加解密,加解密 __key 的 mock__,自動化測試 __case 交替并行執行等。

作為工程師的我們都希望用系統化、工程化的方式來解決整體問題,而不是個别點狀問題。有了這個 mock gateway 我們可以做很多事情,也可以普惠所有需要的其他部門。

微服務架構—自動化測試全鍊路設計背景被忽視的軟體工程環節 — DEVTESTOPS 微服務架構下測試複雜度和效率問題開發階段 unitTest mock 外部依賴連調階段 mock 外部依賴自動化測試階段 mock 需求 autoTest Mock Gateway 浮出水面輕量級版本實作總結

在一次 autoTest context 裡構造好 mock response__,然後通過 __mock parameter 來動态識别具體的來源服務進行路由、鑒權、加解密等操作。

MockGateway 是一個支點,我相信這個支點可以撬動很多測試空間和創新能力。

接下來我們将展示在 marketing-cloud 營銷規則引擎 中的初步嘗試。

微服務架構—自動化測試全鍊路設計背景被忽視的軟體工程環節 — DEVTESTOPS 微服務架構下測試複雜度和效率問題開發階段 unitTest mock 外部依賴連調階段 mock 外部依賴自動化測試階段 mock 需求 autoTest Mock Gateway 浮出水面輕量級版本實作總結

自動化腳本在每跑一個 case 的時候會建立目前 case 對應的 autoTestContext__,這裡面都是一些 __meta data__,用來表示這個 __case 中所有涉及到的微服務系統哪些是需要走 mock gateway 的。

在 mockGateway 中所有的配置都是有一個 autoTestContext 所對應,如果沒有 autoTestContext 說明是所有 case 共用。

要想打通整個微服務架構中的所有通道,就需要在标準 request contract 定義 mockParameter ,這是這一切的前提。

服務與服務之間調用走标準微服務 request contract__,服務與外部系統的依賴可以選擇走 __HTTP Header__,也可以選擇走标準 __request ,就要看我們的整個服務架構是否已經覆寫所有的産線及一些遺留系統的問題。

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

BaseRequest 是所有 request 的基類,這樣才能保證所有的請求能夠正常的傳遞。

整個系統的開發架構分層依賴是:__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架構師)