微服务架构—自动化测试全链路设计

  • 背景
  • 被忽视的软件工程环节 - 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架构师)

相关文章
相关标签/搜索