AREX 是基于真实请求与数据的自动化回归测试平台,利用 Java Agent 和字节码增强技术,在生产环境中记录真实请求链路的入口和依赖的请求和响应数据,然后在测试环境中进行模拟请求回放,并逐一验证整个调用链路的逻辑正确性。AREX Agent 现在已经支持了大部分开源组件的 Mock,本文将介绍 Agent 如何实现 Apollo 配置中心的 Mock。
Apollo(阿波罗)是一款可靠的分布式配置管理中心,诞生于携程框架研发部,能够集中化管理应用不同环境、不同集群的配置,配置修改后能够实时推送到应用端。
以下是官方对 Apollo 基础模型的描述:
下图简要描述了 Apollo 客户端的实现原理:
从上图可知 AREX 只需要支持 Apollo 客户端的录制和回放,即 Java 应用项目内部引用 apollo-client
的组件:
<dependency>
<groupId>com.ctrip.framework.apollo</groupId>
<artifactId>apollo-client</artifactId>
<version>{apollo-client.version}</version>
</dependency>
通常,项目中使用 Apollo 的方式主要有以下三种:
configBean
(内部还是使用 EnableApolloConfig
注解)ApolloConfig
,如代码中的 config
对象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 源码发现前两种基于注解 EnableApolloConfig
,ApolloConfig
和后一种调用 API 的方式底层都是通过 ConfigService.getAppConfig()
创建的实例,也就是说底层 API 是共用的,这样我们就可以修饰这些 Apollo 底层的方法插入 AREX 的字节码,达到录制和回放的目的。
Apollo 里所有的配置项是根据 Namespace 区分的,我们要获取所有的配置实例,即拿到所有 Namespace 对应的 config instance 才能录制。进一步查看 apollo-client
源码发现,config instance 都维护在 DefaultConfigManager
类的 Mapm_configs
里。
但需要考虑以下几个问题:
m_configs
属性是 private 的,且没有相关 API 可以获取到;m_configs
;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 的配置覆盖掉真实的配置。
但同样有以下几个问题需要考虑:
changeListener
方法;基于以上几点,如果还使用录制的实现方式来做回放是不全面的,无法满足这些特殊场景。
我们通过查看源码后确定的实现方案是通过修饰 com.ctrip.framework.apollo.internals.RemoteConfigRepository#loadApolloConfig
方法,在请求服务器配置前直接返回我们 Mock 的配置数据,这样就能利用 Apollo 现有的机制触发完整的配置更新流程,也就达到了我们回放的目的。当然触发回放是调用 com.ctrip.framework.apollo.internals.RemoteConfigRepository#sync
→ loadApolloConfig
。
解决方案如下:
流程图如下所示:
由于 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) 的版本号一样,同一批次的只录制或回放一次。
配置版本号分别存在数据库的 RollingServletMocker
、RollingDubboProviderMocker
、RollingConfigFileMocker
,可以先去入口表 RollingServletMocker/RollingDubboProviderMocker -> targetRequest -> attributes -> configBatchNo (录制的配置版本号),然后使用 configBatchNo 值关联到 RollingConfigFileMocker 表的 recordId,即可查出这个版本号录制的所有配置。
以上就是如何在 Agent 中实现 Apollo 配置中心录制和回放的具体实现细节,总体来说 Apollo Config 的源码逻辑还是比较清晰的,可以借鉴下它和服务端交互的实现方式,如长轮询 + 服务端推送的机制,另外 Apollo 对 Spring 的支持也做得很好,比如基于 BeanPostProcessor
机制,实现自定义注解初始化 Config 实例,以及通过继承 EnumerablePropertySource
类利用 Spring 现有的注解 @Value("${timeout:0}")
来实现读取自定义的配置,对业务方来说使用就会很方便,降低接入成本。
回放期间如果有其他请求到这台机器,也会返回回放的 Apollo 配置,Apollo 的配置都是存在内存中共享的,所以回放期间不要请求这个机器的应用,待回放结束,配置会自动还原。
Apollo 有 local 模式 (com.ctrip.framework.apollo.spi.DefaultConfigFactory#createLocalConfigRepository
),这个的使用场景很少,所以 AREX Agent 暂时不考虑支持这种模式,如果大家有使用 local 模式且需要录制回放的,可以在 Github 提交 Issue。
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