背景

关于 AREX

AREX 是基于真实请求与数据的自动化回归测试平台,利用 Java Agent 和字节码增强技术,在生产环境中记录真实请求链路的入口和依赖的请求和响应数据,然后在测试环境中进行模拟请求回放,并逐一验证整个调用链路的逻辑正确性。AREX Agent 现在已经支持了大部分开源组件的 Mock,本文将介绍 Agent 如何实现 Apollo 配置中心的 Mock。

关于 Apollo

Apollo(阿波罗)是一款可靠的分布式配置管理中心,诞生于携程框架研发部,能够集中化管理应用不同环境、不同集群的配置,配置修改后能够实时推送到应用端。

以下是官方对 Apollo 基础模型的描述:

  1. 用户在配置中心对配置进行修改并发布;
  2. 配置中心通知 Apollo 客户端有配置更新;
  3. Apollo 客户端从配置中心拉取新的配置、更新本地配置并通知到应用。

实现原理

下图简要描述了 Apollo 客户端的实现原理:

  1. 客户端和服务端保持了一个长连接,从而能获得配置更新的推送。(通过 Http Long Polling 实现)
  2. 客户端还会定时从 Apollo 配置中心服务端拉取应用的新配置。
  3. 客户端从 Apollo 配置中心服务端获取到应用的新配置后,会保存在内存中

图片来源:https://www.apolloconfig.com/#/zh/design/apollo-design

开发过程

从上图可知 AREX 只需要支持 Apollo 客户端的录制和回放,即 Java 应用项目内部引用 apollo-client 的组件:

<dependency>
    <groupId>com.ctrip.framework.apollo</groupId>
    <artifactId>apollo-client</artifactId>
    <version>{apollo-client.version}</version>
</dependency>

通常,项目中使用 Apollo 的方式主要有以下三种:

  1. Spring Autowired 注解 configBean (内部还是使用 EnableApolloConfig 注解)
  2. 基于 Apollo 自带的注解 ApolloConfig,如代码中的 config 对象
  3. API 方式,如代码中的 config1 对象:
@Autowired
ConfigBean configBean; // 内部基于 EnableApolloConfig 注解  

@ApolloConfig("TEST1.lucas")
private Config config; // 第二种方式

private Config config1; // 第三种方式,在代码中调用 getAppConfig 实例化

public void test() {
config1 = ConfigService.getAppConfig();

    System.out.println("timeout="+config.getProperty("timeout", "0"));
    System.out.println("switch="+config.getBooleanProperty("switch", false));
    System.out.println("json="+config.getProperty("json", ""));
    System.out.println("white.list="+config1.getProperty("flight.change.white.list", ""));
    System.out.println("configBean="+configBean);

 // 监听 Apollo 配置变更
    ConfigChangeListener changeListener = changeEvent -> {
        System.out.println("Changes for namespace:" + changeEvent.getNamespace());
    };
    config.addChangeListener(changeListener);
}

@Component
@Configuration
@EnableApolloConfig("TEST1.sofia")
public class ConfigBean {
    @Value("${age:0}")
    int age;

    @Value("${name:}")
    String name;

    @ApolloJsonValue("${resume:[]}")
    private List<JsonBean> jsonBean;

}

如果 AREX 需要实现 Apollo 的录制和回放就要兼容这 3 种使用方式,通过查看 Apollo 源码发现前两种基于注解 EnableApolloConfigApolloConfig 和后一种调用 API 的方式底层都是通过 ConfigService.getAppConfig() 创建的实例,也就是说底层 API 是共用的,这样我们就可以修饰这些 Apollo 底层的方法插入 AREX 的字节码,达到录制和回放的目的。

录制实现

Apollo 里所有的配置项是根据 Namespace 区分的,我们要获取所有的配置实例,即拿到所有 Namespace 对应的 config instance 才能录制。进一步查看 apollo-client 源码发现,config instance 都维护在 DefaultConfigManager 类的 Mapm_configs 里。

但需要考虑以下几个问题:

  1. m_configs 属性是 private 的,且没有相关 API 可以获取到;
  2. 这个实例创建后,在业务运行期间很少会调用该类,所以通过常规的 AREX Mock 方式可能无法获取到这个 m_configs
  3. 拿到 m_configs 实例后还需要获取 Config 类中的 m_configProperties,这里面才是真正的配置数据。

UML 依赖如下图:

所以权衡下来使用反射的方式获取所有的配置并录制(只有首次启动和配置发生变更了才通过反射录制,频率很低)。

另外一个需要考虑的点就是录制的时机,比如上面代码展示的项目中使用 Apollo 的方式中的第 3 种,在业务接口 test() 中才创建 config1 实例:

Config config1 = ConfigService.getAppConfig()

这种可以理解为增量配置实例(对比前两种注解的方式在项目启动时已创建好的全量配置实例),所以我们在录制的时候需要考虑这两种方式都要录制到,目前的做法是在请求完主入口 servlet/dubbo 接口,返回结果前,即 postHandle 后置点进行录制,这样不管是哪种方式创建的 config 实例我们都能获取到并录制下来。(具体实现参考:ApolloServletV3RequestHandler#postHandle

如果录制期间 Apollo 配置发生了变更,我们可以通过在 Apollo 源码:com.ctrip.framework.apollo.internals.DefaultConfig#updateAndCalcConfigChanges 方法加入修饰代码,监听变更事件,重新打开我们的录制开关,这样就可以在下次录制时录制到新的配置。(具体实现参考:ApolloDefaultConfigInstrumentation\$UpdateAdvice

AREX 在录制时会生成一个版本号,用来区分不同时间段内录制的这一批用例 (Case) 属于哪个版本号,即起到一个批次的概念,如下面时间轴所示:

回放实现

回放的实现可以参考录制的实现,通过反射给 m_configProperties 赋值,使用 Mock 的配置覆盖掉真实的配置。

但同样有以下几个问题需要考虑:

  1. 如何触发应用设置的配置变更监听方法,如上面 Apollo 使用方式里的 changeListener 方法;
  2. 回放期间,Apollo 长轮询获取配置变更后可能覆盖掉我们回放的配置,需要避免这种情况;
  3. 回放多个版本的配置如何保证配置数据的正确性;
  4. 回放结束后如何还原回原来的配置。

基于以上几点,如果还使用录制的实现方式来做回放是不全面的,无法满足这些特殊场景。

我们通过查看源码后确定的实现方案是通过修饰 com.ctrip.framework.apollo.internals.RemoteConfigRepository#loadApolloConfig 方法,在请求服务器配置前直接返回我们 Mock 的配置数据,这样就能利用 Apollo 现有的机制触发完整的配置更新流程,也就达到了我们回放的目的。当然触发回放是调用 com.ctrip.framework.apollo.internals.RemoteConfigRepository#syncloadApolloConfig

解决方案如下:

  1. 如果在回放过程中则不会调用真实的 Apollo-Server 服务,直接返回 Mock 的配置;
  2. 如果回放结束后不再 Mock 该方法(不回放超过 1 分钟则认为回放配置结束),执行正常的逻辑,即使用真实的配置。

流程图如下所示:

由于 Apollo 的长轮询一直在运行中,如果回放结束,此时 Apollo 发现服务器的配置和我们 AREX 回放的配置不一致则会触发更新本地配置的操作,达到还原的目的。(具体实现参考:ApolloConfigHelper#replayAllConfigs

另外上面列的第 2 点问题:如何保证回放多个版本的配置数据正确性?

参考录制实现中的时间轴图,AREX 在首次(项目启动时)和后续配置发生变更时才录制配置,此时生成的版本号(UUID)也会作为回放时版本号使用,即如果录制了多个版本号,那回放时也是按照不同的版本号依次做的回放,也就是说通过 AREX 生成的版本号来区分不同版本的配置。实现方式是在每次回放配置时,在构造返回的 Apollo 配置实体 com.ctrip.framework.apollo.core.dto.ApolloConfig 类后,设置 releaseKey 属性为我们 AREX 的版本号,这样就可以保证回放多个版本的配置数据正确性。( releaseKey 是 Apollo Client 和 Server 端交互的关键字段,Server 端根据此字段判断配置是否和 Client 一致,不一致则会返回一个新的 releaseKey 值,一致则返回 304 状态)具体实现参考:ApolloConfigHelper#getReplayConfig

版本差异

另外还需要注意修饰的 apollo-client 不同版本之间源码的差异,有些方法或类在不同的版本会有一些差异,我们在决定修饰底层方法前看下不同版本之间的差异,否则可能因为用户项目使用的 apollo-client 版本和 arex-agent 修饰的版本不一样而失败。

比如我们修饰的下面这个 Apollo 方法 com.ctrip.framework.apollo.internals.DefaultConfig#updateAndCalcConfigChanges 分别在 1.0.0 和 1.2.0 两个版本的入参差异:

v1.0.0

v1.2.0

我们在修饰这个方法时要兼容这种差异,才能让我们插入的字节码在不同版本下都能正常工作,另外对于我们要修饰组件的版本选择建议选低版本的,按照一般的开闭原则,尽量能做到兼容。

联调

这里的联调指的是跟 AREX Schedule Service 的回放联调,用户在页面点击回放按钮时,Schedule 会先按照录制时生成的所有配置版本号进行一次分组,即这个时间段内的所有用例分完组后再根据版本号,每次回放前先进行一次版本号的切换,即告知 arex-agent 需要回放 Apollo 的配置了。

这次版本切换的请求不作为真实的回放结果,仅仅是作为一次版本预热,等版本号对应的配置切换成功后再进行常规的回放流程,后面如果要切换其他的版本号时一样的流程,如下图所示:

同一批用例 (case) 的版本号一样,同一批次的只录制或回放一次。

配置版本号分别存在数据库的 RollingServletMockerRollingDubboProviderMockerRollingConfigFileMocker,可以先去入口表 RollingServletMocker/RollingDubboProviderMocker -> targetRequest -> attributes -> configBatchNo (录制的配置版本号),然后使用 configBatchNo 值关联到 RollingConfigFileMocker 表的 recordId,即可查出这个版本号录制的所有配置。

总结思考

以上就是如何在 Agent 中实现 Apollo 配置中心录制和回放的具体实现细节,总体来说 Apollo Config 的源码逻辑还是比较清晰的,可以借鉴下它和服务端交互的实现方式,如长轮询 + 服务端推送的机制,另外 Apollo 对 Spring 的支持也做得很好,比如基于 BeanPostProcessor 机制,实现自定义注解初始化 Config 实例,以及通过继承 EnumerablePropertySource 类利用 Spring 现有的注解 @Value("${timeout:0}") 来实现读取自定义的配置,对业务方来说使用就会很方便,降低接入成本。

注意事项

  1. 回放期间如果有其他请求到这台机器,也会返回回放的 Apollo 配置,Apollo 的配置都是存在内存中共享的,所以回放期间不要请求这个机器的应用,待回放结束,配置会自动还原。

  2. Apollo 有 local 模式 (com.ctrip.framework.apollo.spi.DefaultConfigFactory#createLocalConfigRepository),这个的使用场景很少,所以 AREX Agent 暂时不考虑支持这种模式,如果大家有使用 local 模式且需要录制回放的,可以在 Github 提交 Issue。

  3. AREX 生成的配置版本号 (UUID.randomUUID()) 是无序的,Schedule 如果按照版本号分组做预热,可能不支持排序,不过目前暂时没有这种业务场景。


AREX 文档:http://arextest.com/zh-Hans/docs/intro/

AREX 官网:http://arextest.com/

AREX GitHub:https://github.com/arextest

AREX 官方 QQ 交流群:656108079


↙↙↙阅读原文可查看相关链接,并与作者交流