通用流量录制回放工具 jvm-sandbox-repeater 尝鲜 (二)——repeater-console 使用

本文作者陈恒捷是TesterHome社区主编,第十届MTSC大会上海站-开源专场出品人。先后在PP助手、PPmoney、荔枝等公司从事测试效能提升相关工作,在测试技术及效率提升方面有丰富的经验积累。

为了避免文章过长,此文单独记录 repeater-console 部分的使用。对于 jvm-sandbox-repeater 普通用法的尝鲜记录,请参照 通用流量录制回放工具 jvm-sandbox-repeater 实践记录 (一)

repeater-console 简介

官方的说明:

jvm-sandbox-repeater 仅仅提供了录制回放的能力,如果需要完成业务回归实时监控压测等平台,后面须要有一个数据中心负责采集数据的加工、存储、搜索,repeater-console 提供了简单的demo示例;一个模块管理平台负责管理JVM-Sandbox各模块生命周期;一个配置管理平台负责维护和推送jvm-sandbox-repeater采集所须要的各种配置变更

在阿里集团淘系技术质量内部,已有一套完整的体系在持续运行,从17年开始支持了淘系技术质量部的CI、建站、系统重构等多方面质量保障任务,后续如有需要也会考虑把更多的东西开源回馈社区

注意:目前项目代码默认启动standalone模式,不需要依赖任何服务端和存储,能够简单快速的实现单机的录制回放,控制单机模式的开关在~/.sandbox-module/cfg/repeater.properties 文件中的 repeat.standalone.mode=true //开启或关闭单机工作模式,关闭单机模式后,配置拉取/消息投递等都依赖repeater.properties中配置的具体url;如不想通过http拉取和消息投递的也可以自己实现BroadcasterConfigManager。稍后我们会公布一份录制回放所需的完整架构图以及jvm-sandbox-repeater在整个体系中的位置供大家工程使用做参考。

个人理解,要想在业务中使用,我们还得搞下 数据中心模块管理配置管理

【数据中心】:你存了那么多流量,总得有个存储和管理的地方吧,数据中心就是干这个活。要不光靠官方提供的那个透传 repeatId 的回放方法,只能回放单个流量,实际项目不够用。
【模块管理】:这个还不是太了解,个人理解是各个 plugin 的管理?
【配置管理】:就是之前试用时说过的只有一个 ~/.sandbox-module/cfg/repeater-config.json 配置文件,是不可能满足多个项目同时使用的需要的。所以需要有个配置管理,提供这方面配置的存储和修改能力。

源码熟悉

由于目前官方对于这个 console 只有一份非常简单的文档:

repeater-console工程集成录制/回放的配置管理;数据存储/数据对比等具备多种能力,因各系统架构差异较大,目前仅开源简单的demo工程,后续会提供统一的工程,也希望有能力和时间的同学来提PR

curl -s http://127.0.0.1:8001/regress/getAsync/repeater -H
'Repeat-TraceId:030010083212156034386424510101ed'
curl -s http://127.0.0.1:8001/facade/api/repeat/repeater/030010083212156034386424510101ed -H "RepeatId:030010083212156034386424510201ed" 
curl -s http://127.0.0.1:8001/facade/api/repeat/callback/030010083212156034386424510201ed

所以只能通过解读源码来反推用法咯。

个人的源码阅读三步骤:明确阅读目的、了解整体架构、细读目标功能

step 0 明确阅读目的

目的很简单,使用 repeater-console ,在目前的 demo 项目上完成批量流量录制回放的功能

step 1 了解整体架构

为了便于描述,还是用 tree 吧。

特别说明:以下均为个人分析,并不保证正确哈。

tree -L 10 | grep -v iml | grep -v target
.
├── Readme.md
├── pom.xml
├── repeater-console-common      // 存放公共方法的模块
│   ├── pom.xml
│   └── src
│       └── main
│           └── java
│               └── com
│                   └── alibaba
│                       └── repeater
│                           └── console
│                               └── common
│                                   ├── PackageInfo.java // 一个空的类,应该是预留用的
│                                   └── domain           // 目前只有一个名为 Regress 的 java bean ,代表单条回放记录
├── repeater-console-dal         // 和数据库打交道的存储模块,model 层
│   ├── pom.xml
│   └── src
│       └── main
│           ├── java
│           │   └── com
│           │       └── alibaba
│           │           └── repeater
│           │               └── console
│           │                   └── dal
│           │                       ├── mapper  // mybatis 的 mapper 映射类,存放数据库操作犯法
│           │                       └── model   // mybatis 的 model 类,和数据库表结构对应
│           └── resources
│               └── database.sql                // 数据库初始化语句
├── repeater-console-service   // 主要逻辑实现的模块,service 层
│   ├── pom.xml
│   └── src
│       └── main
│           └── java
│               └── com
│                   └── alibaba
│                       └── repeater
│                           └── console
│                               └── service
│                                   ├── RecordService.java  // 存储服务,提供存储录制、存储回放、获取记录、执行回放、查看回放结果接口的定义
│                                   ├── RegressService.java // 回归服务,提供获取单个回放、多个回放、找到你的小伙伴、slogan喊口号4个接口的定义(最后两个接口不知道是什么鬼。。。)
│                                   ├── impl
│                                   │   ├── AbstractRecordService.java // 存储服务一个抽象实现,提供了 repeat 方法和 jvm-sandbox-repeater 进行交互,触发回放
│                                   │   ├── RecordServiceLocalImpl.java // 存储服务的本地存储实现。使用一个 ConcurrentHashMap 把所有数据存到内存中。
│                                   │   ├── RecordServiceMysqlImpl.java // 存储服务 mysql 存储的实现。使用前面存储模块和 mysql 数据库交互,进行存储。
│                                   │   ├── RecordServiceProxyImpl.java // 存储服务的代理类,根据配置文件值来决定用哪个实现类进行存储服务的实现
│                                   │   └── RegressServiceImpl.java // 回归服务的实现类。包含了官方提供的 slogan 服务的实现。
│                                   └── util
│                                       └── ConvertUtil.java // 给原始录制记录加上一些元数据(如 appName,environment 等),并转换成一个完整的录制记录的工具类。转换方法目前各个存储服务用的都是 hessian 序列化。
├── repeater-console-start   // 最外部的层,controller 层。直接暴露接口和提供 main 入口。我们最前面 slogan 示例看到的 repeater-bootstrap.jar 包,实际就是用这里源码打出来的包。
│   ├── pom.xml
│   └── src
│       ├── main
│       │   ├── java
│       │   │   └── com
│       │   │       └── alibaba
│       │   │           └── repeater
│       │   │               └── console
│       │   │                   └── start
│       │   │                       ├── Application.java   // 标准的 spring boot 启动类
│       │   │                       ├── ConfigurationBean.java // java 回放用的感知 spring context 的 hook 
│       │   │                       └── controller
│       │   │                           ├── ConfigFacadeApi.java  // 配置管理服务 api 设计的示例。仅提供了获取配置的方法,而且直接 hard code 了一份配置。
│       │   │                           ├── RecordFacadeApi.java  // 存储服务 api 设计的示例,提供了存储录制、存储回放、获取记录、执行回放、查看回放结果五个 api 接口
│       │   │                           └── RegressController.java // 回归服务,相当于一个示例的被测服务。官方的 slogan 例子用的就是这里的接口。
│       │   └── resources
│       │       └── application.properties // 配置文件。需要留意的是,里面有个 `repeat.repeat.url` 配置项,需要和 sandbox 的监听 port 保持一致。
│       └── test
│           └── java
│               └── com
│                   └── alibaba
│                       └── repeater
│                           └── console
│                               └── start
│                                   └── RegressTest.java  // 一个自动化集成测试用例,如果在 idea 里面跑的话,需要先手动启动 console 服务才能运行,且测试了下,全部用例都是 fail 的。先忽略。

简单小结:

1、console 划分为了4个子模块,除了一个是公共模块外,剩余三个分别是数据层、service逻辑层和最外部的 controller 层,基本是一个标准 spring boot 程序。
2、里面主要提供了3个服务:存储服务,配置管理服务,回归服务(本质上就是个示例,估计是给自动化测试用的)
3、需要重点关注的是存储服务,里面包含了存储录制、存储回放、获取记录、执行回放、查看回放结果五个 api 接口。

step 3 细读目标功能

从上一步已经明确了,目标功能是存储服务。因此进一步细看对应的代码。主要关注存储服务的实现。为了简便理解,主要针对 local 这个本地存储的实现进行解读。

里面涉及几个 model 定义,为了方便理解,先说明下:

  • RecordWrapper: repeater 提供的一个完整的录制记录。包括appName、环境名、主机名、traceId、入口描述、入口调用记录、子调用记录。
  • RepeatModel: repeater 提供的一个回放结果记录。包括 repeatId、是否完成、实际返回值、原始返回值、diff记录、耗时、traceId。
  • Record: console 提供的单个录制记录的描述,包括创建时间、录制时间、appName、主机名、traceId、原始录制记录。用途估计是后续用来过滤筛选记录。

下面的解读主要涉及上述3个类,更详细的领域模型划分,建议参考 domain

  • 添加录制的记录
    @Override
    public RepeaterResult<String> saveRecord(String body) {
        try {
            // 把输入值反序列化成 RecordWrapper 对象
            RecordWrapper wrapper = SerializerWrapper.hessianDeserialize(body, RecordWrapper.class);
            // 如果反序列化失败,直接返回错误
            if (wrapper == null || StringUtils.isEmpty(wrapper.getAppName())) {
                return RepeaterResult.builder().success(false).message("invalid request").build();
            }
            // 把 wrapper + 原始传入的 body ,组合成 record 。主要是添加了一个创建日期、大部分 wrapper 和 record 一一对应地存储,以及把整个 body 放到 wrapperRecord 对象中作为存档
            Record record = ConvertUtil.convertWrapper(wrapper, body);
            // 存到record的缓存里,key 是 appName + traceId 组合而成,value 就是 record 对象
            recordCache.put(buildUniqueKey(wrapper.getAppName(), wrapper.getTraceId()), record);
            // 保存成功,就可以返回了
            return RepeaterResult.builder().success(true).message("operate success").data("-/-").build();
        } catch (Throwable throwable) {
            return RepeaterResult.builder().success(false).message(throwable.getMessage()).build();
        }
    }
  • 添加回放的结果
    @Override
    public RepeaterResult<String> saveRepeat(String body) {
        try {
            // 相同的套路,先反序列化出 RepeatModel 对象
            RepeatModel rm = SerializerWrapper.hessianDeserialize(body, RepeatModel.class);
            
            // 从缓存中根据 repeatId 获取到录制的记录。特别留意,虽然 value 类型一样,但 record 和 repeat 是两个分别独立的缓存,所以这里的调整是不会影响上面 record 的调整的。
            Record record = repeatCache.remove(rm.getRepeatId());
            // 如果找不到记录,那就认为无效(repeatCached的记录添加,在执行回放的接口里会进行。所以如果找不到记录,说明这次回放的执行不是通过这个服务进行的,所以也没必要记录它的回放结果)
            if (record == null) {
                return RepeaterResult.builder().success(false).message("invalid repeatId:" + rm.getRepeatId()).build();
            }
            
            // 校验确认这个回放是通过这个服务执行后,取出原始的回放记录,并转成 RecordWrapper 对象,便于获取更多信息
            RecordWrapper wrapper = SerializerWrapper.hessianDeserialize(record.getWrapperRecord(), RecordWrapper.class);
            // 添加原始 response 信息
            rm.setOriginResponse(SerializerWrapper.hessianDeserialize(wrapper.getEntranceInvocation().getResponseSerialized()));
            // 把 repeatModel 记录到缓存
            repeatModelCache.put(rm.getRepeatId(), rm);
        } catch (Throwable throwable) {
            return RepeaterResult.builder().success(false).message(throwable.getMessage()).build();
        }
        return RepeaterResult.builder().success(true).message("operate success").data("-/-").build();
    }
  • 根据应用名和 traceId ,获取序列化后的录制数据
    @Override
    public RepeaterResult<String> get(String appName, String traceId) {
        // 从缓存中找数据,找不到就返回失败
        Record record = recordCache.get(buildUniqueKey(appName, traceId));
        if (record == null) {
            return RepeaterResult.builder().success(false).message("data not exits").build();
        }
        
        // 返回成功,数据为 wrapperRecord ,即序列化后的数据
        return RepeaterResult.builder().success(true).message("operate success").data(record.getWrapperRecord()).build();
    }
  • 根据 appName、traceId、repeatId 执行回放记录
    @Override
    public RepeaterResult<String> repeat(String appName, String traceId, String repeatId) {
        // 从录制记录里获取录制信息,如果找不到,返回失败
        final Record record = recordCache.get(buildUniqueKey(appName, traceId));
        if (record == null) {
            return RepeaterResult.builder().success(false).message("data does not exist").build();
        }
        
        // 执行回放
        RepeaterResult<String> pr = repeat(record, repeatId);
        
        // 如果成功,以执行结果的 data 字段(成功时是 repeatId)为 key ,录制记录为 value ,记录到 repeatCache 中
        if (pr.isSuccess()) {
            repeatCache.put(pr.getData(), record);
        }
        return pr;
    }
  • 根据 repeatId 获取回放执行结果
    @Override
    public RepeaterResult<RepeatModel> callback(String repeatId) {
        // 因为保存回放记录时会移除 repeatCache 里的记录。如果发现里面没被移除,说明回放未结束,返回还在进行中
        if (repeatCache.containsKey(repeatId)) {
            return RepeaterResult.builder().success(true).message("operate is going on").build();
        }
        
        // 从 repeatModelCache 获取到完整的回放结果记录
        RepeatModel rm = repeatModelCache.get(repeatId);
        // 如果取不到,返回错误
        if (rm == null) {
            return RepeaterResult.builder().success(false).message("invalid repeatId:" + repeatId).build();
        }
        // 返回完整的回放结果记录
        return RepeaterResult.builder().success(true).message("operate success").data(rm).build();
    }

小结:

  • 从接口上看,调用顺序必须是 saveRecord -> repeat -> saveRepeat -> callback 。如果不对会导致后续接口调用失败。
  • 通过一个 repeatCached 的中间缓存,巧妙解决了回放还在进行中,查找回放结果时需要返回进行中这个场景。
  • 正常情况下 console 存储服务主要关注的是 Record 对象,缓存主要用的也是它。完整录制记录,由 RecordWrapper 负责。完整的回放结果记录,由 RepeatModel 负责。

实际使用

step 0 调整模式重新启动

上面分析了整个 console 服务的使用,主要提供的是存储服务、配置获取服务。很遗憾,里面并没有提供批量回放的接口,后续需要另行开发。

但上面终究只是从源码的推测,不实际跑下怎么知道是不是真的是这样呢?

根据官方的 用户使用手册 只需要把 ~/.sandbox-module/cfg/repeater.properties 里面的 repeat.standalone.mode 的值,从 true 改为 false 即可改为用 console 进行存储和配置获取。

同时,console 的一些配置项也要对应调整下,否则端口号和 repeater 的对不上,repeater-config 不正确,也会出问题。

具体步骤:

1、杀掉原来的进程,关闭应用
2、修改 ~/.sandbox-module/cfg/repeater.properties 的值,repeat.standalone.mode 改为 false
3、修改源码目录中的 jvm-sandbox-repeater/repeater-console/repeater-console-start/src/main/resources/application.properties ,把 repeat.repeat.url 中的 8820 端口号,改为 12580
4、修改 jvm-sandbox-repeater/repeater-console/repeater-console-start/src/main/java/com/alibaba/repeater/console/start/controller/ConfigFacadeApi.java ,内容基本参照以前的配置,但需要微调下,把 javaEntranceBehaviors 的加上(否则 request 就会没录制下来),去掉 javaSubInvokeBehaviors (response 不要用 Mock ,用真实的)。修改后内容如下:

    @RequestMapping("/config/{appName}/{env}")
    public RepeaterResult<RepeaterConfig> getConfig(@PathVariable("appName") String appName,
                                                    @PathVariable("env") String env) {
        // 改为了可以适用于 gs-rest-service 的配置
        RepeaterConfig config = new RepeaterConfig();
        List<Behavior> behaviors = Lists.newArrayList();
        config.setPluginIdentities(Lists.newArrayList("http", "java-entrance", "java-subInvoke", "mybatis", "ibatis"));
        // 回放器
        config.setRepeatIdentities(Lists.newArrayList("java", "http"));
        // 白名单列表
        config.setHttpEntrancePatterns(Lists.newArrayList("^/greeting.*$"));
        // java入口方法
        behaviors.add(new Behavior("hello.GreetingController", "greeting"));
        config.setJavaEntranceBehaviors(behaviors);
        List<Behavior> subBehaviors = Lists.newArrayList();
        // java调用插件
        config.setJavaSubInvokeBehaviors(subBehaviors);
        config.setUseTtl(true);
        return RepeaterResult.builder().success(true).message("operate success").data(config).build();
    }

5、启动 repeater-console

# 进入源码根目录,再执行以下命令(跳过测试的原因是,这个不是单测,必须用指定脚本运行才行)
$ cd repeater-console 
$ mvn install -DskipTests && java -jar repeater-console-start/target/*.jar

...
2019-07-16 17:26:53.028  INFO 30001 --- [           main] c.a.repeater.console.start.Application   : Started Application in 7.092 seconds (JVM running for 7.774)

6、启动应用、启动 sandbox 并 attach 到进程中。务必记住,后续每次重启,sandbox 的 attach 必须放在 console 之后,因为它只有启动时会去获取一次配置。

# 启动被录制的那个应用。如果不知道这个文件夹哪里来,请查看系列文章第一篇的内容,搜索下一行用到的命令。
cd complete 
mvn install && java -jar target/*.jar

# sandbox attach
sh ~/sandbox/bin/sandbox.sh -p `ps -ef | grep "target/gs-rest-service-0.1.0.jar" | grep -v grep | awk '{print $2}'` -P 12580

如果启动正常,且确实改为了从 console 中读取配置,~/logs/sandbox/repeater/repeater.log 会出现如下日志:

2019-07-16 17:32:06 INFO  module on loaded,id=repeater,version=1.0.0,mode=ATTACH
2019-07-16 17:32:06 INFO  onActive
2019-07-16 17:32:07 INFO  pull repeater config success,config=com.alibaba.jvm.sandbox.repeater.plugin.domain.RepeaterConfig@c1faf51
2019-07-16 17:32:07 INFO  enable plugin http success
2019-07-16 17:32:08 INFO  add watcher success,type=http,watcherId=1000
2019-07-16 17:32:08 INFO  enable plugin ibatis success
2019-07-16 17:32:08 INFO  add watcher success,type=ibatis,watcherId=1003
2019-07-16 17:32:08 INFO  enable plugin java-entrance success
2019-07-16 17:32:09 INFO  add watcher success,type=java,watcherId=1005
2019-07-16 17:32:09 INFO  enable plugin java-subInvoke success
2019-07-16 17:32:09 INFO  watch plugin occurred error
com.alibaba.jvm.sandbox.repeater.plugin.exception.PluginLifeCycleException: enhance models is empty, plugin type is java-subInvoke
    at com.alibaba.jvm.sandbox.repeater.plugin.core.impl.AbstractInvokePluginAdapter.watchIfNecessary(AbstractInvokePluginAdapter.java:117)
    at com.alibaba.jvm.sandbox.repeater.plugin.core.impl.AbstractInvokePluginAdapter.watch(AbstractInvokePluginAdapter.java:62)
    at com.alibaba.jvm.sandbox.repeater.module.RepeaterModule.initialize(RepeaterModule.java:186)
    at com.alibaba.jvm.sandbox.repeater.module.RepeaterModule.access$500(RepeaterModule.java:60)
    at com.alibaba.jvm.sandbox.repeater.module.RepeaterModule$1.run(RepeaterModule.java:132)
    at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142)
    at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617)
    at java.lang.Thread.run(Thread.java:745)
2019-07-16 17:32:09 INFO  enable plugin mybatis success
2019-07-16 17:32:09 INFO  add watcher success,type=mybatis,watcherId=1007
2019-07-16 17:32:09 INFO  register event bus success in repeat-register

repeater-console 服务的命令行也会输出:

2019-07-16 17:29:01.016  INFO 30001 --- [nio-8001-exec-1] o.s.web.servlet.DispatcherServlet        : FrameworkServlet 'dispatcherServlet': initialization started
2019-07-16 17:29:01.054  INFO 30001 --- [nio-8001-exec-1] o.s.web.servlet.DispatcherServlet        : FrameworkServlet 'dispatcherServlet': initialization completed in 38 ms

step 1 录制回放

启动完毕后,就可以开始测试录制和回放了。在此之前,请阅读下一章节的坑1,按指引修改源码并更新本地安装,否则很有可能会被这个坑卡住:

# 手动发出2条请求
$ curl -s 'http://localhost:8080/greeting'
{"id":1,"content":"Hello, World!"}%
$ curl -s 'http://localhost:8080/greeting?name=User'
{"id":2,"content":"Hello, User!"}%

# 查看对应的 traceId 
$ tail -2 ~/logs/sandbox/repeater/repeater.log
2019-07-16 17:34:04 INFO  broadcast success,traceId=192168015059156326964361610001ed,resp=HttpUtil.Resp(code=200, body={"success":true,"data":"-/-","message":"operate success"}, message=null)
2019-07-16 17:34:34 INFO  broadcast success,traceId=192168015059156326967477410002ed,resp=HttpUtil.Resp(code=200, body={"success":true,"data":"-/-","message":"operate success"}, message=null)

# 调用 console 接口,触发回放
$ curl -s 'http://127.0.0.1:8001/facade/api/repeat/unknown/192168015059156326967477410002ed'
{"success":true,"data":"192168015059156328015008810009ed","message":"operate success"}%
# 同时查看被测应用日志,确实有收到请求

# 调用 console 接口,查看回放结果。==请特别留意,此处用的 id 是上一步服务端返回 body 里面 data 字段的值,而非原来录制请求时的 traceId 。原因是查询回放结果时,一个录制可能对应多个回放结果,所以需要用回放结果的 id 进行查询==
$ curl -s 'http://127.0.0.1:8001/facade/api/repeat/callback/192168015059156328015008810009ed'
{"success":true,"data":{"repeatId":"192168015059156328015008810009ed","finish":true,"response":"{\"id\":3,\"content\":\"Hello, User!\"}","originResponse":"{\"id\":2,\"content\":\"Hello, User!\"}","diff":null,"cost":13,"traceId":"192168015059156328015025810003ed","mockInvocations":null},"message":"operate success"}%

至此,基于 console 进行录制回放,也完成了。虽然没达到批量回放的目的,但总算把整体流程跑通了。

踩坑

坑1:请求 facade/api/repeat/ 接口进行回放时,接口返回 success ,但被测应用日志显示没有收到任何新的请求。
解决:把 com.alibaba.jvm.sandbox.repeater.plugin.core.bridge.RepeaterBridge 里面的 HashMap key ,从 InvokeType 改为 String ,put 和 get 方法也对应调整,即可解决。

参考 diff:

diff --git a/repeater-plugin-core/src/main/java/com/alibaba/jvm/sandbox/repeater/plugin/core/bridge/RepeaterBridge.java b/repeater-plugin-core/src/main/java/com/alibaba/jvm/sandbox/repeater/plugin/core/bridge/RepeaterBridge.java
index 26c0218..6f2102d 100644
--- a/repeater-plugin-core/src/main/java/com/alibaba/jvm/sandbox/repeater/plugin/core/bridge/RepeaterBridge.java
+++ b/repeater-plugin-core/src/main/java/com/alibaba/jvm/sandbox/repeater/plugin/core/bridge/RepeaterBridge.java
@@ -17,7 +17,7 @@ public class RepeaterBridge {
 
     private RepeaterBridge() {}
 
-    private volatile Map<InvokeType, Repeater> cached = new HashMap<InvokeType, Repeater>();
+    private volatile Map<String, Repeater> cached = new HashMap<String, Repeater>();
 
     public static RepeaterBridge instance() {
         return RepeaterBridge.LazyInstanceHolder.INSTANCE;
@@ -28,7 +28,7 @@ public class RepeaterBridge {
         // reset repeat'er container
         cached.clear();
         for (Repeater repeater : rs) {
-            cached.put(repeater.getType(), repeater);
+            cached.put(repeater.getType().name(), repeater);
         }
     }
 
@@ -43,6 +43,6 @@ public class RepeaterBridge {
      * @return 回放器
      */
     public Repeater select(InvokeType type) {
-        return cached.get(type);
+        return cached.get(type.name());
     }
 }

修改后,请务必执行源码目录的 bin/install-local.sh 把源码更新安装到本地,并重新调整配置项。

问题定位过程:

1、查看 ~/logs/sandbox/repeater/repeater.log ,看到如下错误:

2019-07-16 17:44:23 INFO  subscribe success params={_data=QzA5Y29tLmFsaWJhYmEuanZtLnNhbmRib3gucmVwZWF0ZXIucGx1Z2luLmRvbWFpbi5SZXBlYXRNZXRhmAdhcHBOYW1lB3RyYWNlSWQEbW9jawhyZXBlYXRJZA9tYXRjaFBlcmNlbnRhZ2UKZGF0YXNvdXJjZQxzdHJhdGVneVR5cGUJZXh0ZW5zaW9uYAd1bmtub3duMCAxOTIxNjgwMTUwNTkxNTYzMjcwMjI3NDQ4MTAwMDFlZFQwIDE5MjE2ODAxNTA1OTE1NjMyNzAyNjI3OTkxMDAwMWVkXWROQzBFY29tLmFsaWJhYmEuanZtLnNhbmRib3gucmVwZWF0ZXIucGx1Z2luLnNwaS5Nb2NrU3RyYXRlZ3kkU3RyYXRlZ3lUeXBlkQRuYW1lYQ9QQVJBTUVURVJfTUFUQ0hIWg==}
2019-07-16 17:44:23 ERROR [Error-0000]-uncaught exception occurred when register repeat event, req={_data=QzA5Y29tLmFsaWJhYmEuanZtLnNhbmRib3gucmVwZWF0ZXIucGx1Z2luLmRvbWFpbi5SZXBlYXRNZXRhmAdhcHBOYW1lB3RyYWNlSWQEbW9jawhyZXBlYXRJZA9tYXRjaFBlcmNlbnRhZ2UKZGF0YXNvdXJjZQxzdHJhdGVneVR5cGUJZXh0ZW5zaW9uYAd1bmtub3duMCAxOTIxNjgwMTUwNTkxNTYzMjcwMjI3NDQ4MTAwMDFlZFQwIDE5MjE2ODAxNTA1OTE1NjMyNzAyNjI3OTkxMDAwMWVkXWROQzBFY29tLmFsaWJhYmEuanZtLnNhbmRib3gucmVwZWF0ZXIucGx1Z2luLnNwaS5Nb2NrU3RyYXRlZ3kkU3RyYXRlZ3lUeXBlkQRuYW1lYQ9QQVJBTUVURVJfTUFUQ0hIWg==}
com.alibaba.jvm.sandbox.repeater.plugin.exception.RepeatException: no valid repeat found for invoke type:com.alibaba.jvm.sandbox.repeater.plugin.domain.InvokeType$1@7cefc025
    at com.alibaba.jvm.sandbox.repeater.plugin.core.impl.api.DefaultFlowDispatcher.dispatch(DefaultFlowDispatcher.java:38)
    at com.alibaba.jvm.sandbox.repeater.plugin.core.impl.spi.RepeatSubscribeSupporter.onSubscribe(RepeatSubscribeSupporter.java:59)
    at com.alibaba.jvm.sandbox.repeater.plugin.core.impl.spi.RepeatSubscribeSupporter.onSubscribe(RepeatSubscribeSupporter.java:26)
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.lang.reflect.Method.invoke(Method.java:498)
    at com.google.common.eventbus.Subscriber.invokeSubscriberMethod(Subscriber.java:95)
    at com.google.common.eventbus.Subscriber$1.run(Subscriber.java:80)
    at com.alibaba.ttl.TtlRunnable.run(TtlRunnable.java:51)
    at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142)
    at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617)
    at java.lang.Thread.run(Thread.java:745)

在代码中查了下,错误日志对应的关键代码如下:

    @Override
    public void dispatch(RepeatMeta meta, RecordModel recordModel) throws RepeatException {
        if (recordModel == null || recordModel.getEntranceInvocation() == null || recordModel.getEntranceInvocation().getType() == null) {
            throw new RepeatException("invalid request, record or root invocation is null");
        }
        
        // 从 RepeaterBridge 中寻找对应的 type 记录
        Repeater repeater = RepeaterBridge.instance().select(recordModel.getEntranceInvocation().getType());
        if (repeater == null) {
            throw new RepeatException("no valid repeat found for invoke type:" + recordModel.getEntranceInvocation().getType().name());
        }
        RepeatContext context = new RepeatContext(meta, recordModel, TraceGenerator.generate());
        // 放置到回放缓存中
        RepeatCache.putRepeatContext(context);
        repeater.repeat(context);
    }

看起来是从 RepeaterBridge 中找不到对应的 type 记录。再细看下它具体是怎么找的:

public class RepeaterBridge {

    private RepeaterBridge() {}

    private volatile Map<InvokeType, Repeater> cached = new HashMap<InvokeType, Repeater>();

    public static RepeaterBridge instance() {
        return RepeaterBridge.LazyInstanceHolder.INSTANCE;
    }

    public void build(List<Repeater> rs) {
        if (rs == null || rs.isEmpty()) { return; }
        // reset repeat'er container
        cached.clear();
        for (Repeater repeater : rs) {
            cached.put(repeater.getType(), repeater);
        }
    }

    private final static class LazyInstanceHolder {
        private final static RepeaterBridge INSTANCE = new RepeaterBridge();
    }

    /**
     * 选择合适的回放器
     *
     * @param type 调用类型
     * @return 回放器
     */
    public Repeater select(InvokeType type) {
        return cached.get(type);
    }
}

恩,内部有一个 cached ,以 InvokeType 为 key ,repeater 为 value 缓存数据。那为何会对不上呢?各个有可能的地方都加些日志看看:

public class RepeaterBridge {

    protected final static Logger log = LoggerFactory.getLogger(RepeaterBridge.class);

    private RepeaterBridge() {}

    private volatile Map<InvokeType, Repeater> cached = new HashMap<InvokeType, Repeater>();

    public static RepeaterBridge instance() {
        return RepeaterBridge.LazyInstanceHolder.INSTANCE;
    }

    public void build(List<Repeater> rs) {
        log.info("进入 RepeaterBridge 的 build 方法,参数为: {}", JSONObject.toJSONString(rs));
        if (rs == null || rs.isEmpty()) {
            log.info("rs 为 null 或空,直接返回");
            return;
        }
        // reset repeat'er container
        log.info("清空缓存");
        cached.clear();

        for (Repeater repeater : rs) {
            log.info("往 cached 中添加记录: {}", JSONObject.toJSONString(rs));
            log.info("记录的 type 为:{}", repeater.getType().name());
            cached.put(repeater.getType(), repeater);
        }
        log.info("完成 build 所有处理");
    }

    private final static class LazyInstanceHolder {
        private final static RepeaterBridge INSTANCE = new RepeaterBridge();
    }

    /**
     * 选择合适的回放器
     *
     * @param type 调用类型
     * @return 回放器
     */
    public Repeater select(InvokeType type) {
        log.info("传入 select 的 type name: {}", type.name());
        log.info("RepeaterBridge 中的所有 cached: {}", JSONObject.toJSONString(cached));
        for (InvokeType invokeType : cached.keySet()) {
            log.info("cached 中具有的 type name: {}", invokeType.name());
            log.info("cached 中的 type {} 和传入的 type {} 作 equals 的结果: {}", invokeType, type, invokeType.equals(type));
        }

        return cached.get(type);
    }
}

调整后,通过源码目录的 bin/install-local.sh 把源码更新安装到本地,并重新调整配置项、重启被测应用、sandbox后,查看日志,发现一个非常诡异的结果:

2019-07-16 19:53:36 INFO  传入 select 的 type: http
2019-07-16 19:53:36 INFO  RepeaterBridge 中的所有 cached: {{}:{"type":{"$ref":"$.null"}},{}:{"type":{"$ref":"$.null"}},{}:{"type":{"$ref":"$.null"}}}
2019-07-16 19:53:36 INFO  cached 中具有的 type: dubbo
2019-07-16 19:53:36 INFO  cached 中的 type com.alibaba.jvm.sandbox.repeater.plugin.domain.InvokeType$6@7c418ee0 和传入的 type com.alibaba.jvm.sandbox.repeater.plugin.domain.InvokeType$1@f23bf1 作 equals 的结果: false
2019-07-16 19:53:36 INFO  cached 中具有的 type: http
2019-07-16 19:53:36 INFO  cached 中的 type com.alibaba.jvm.sandbox.repeater.plugin.domain.InvokeType$1@1469dba0 和传入的 type com.alibaba.jvm.sandbox.repeater.plugin.domain.InvokeType$1@f23bf1 作 equals 的结果: false
2019-07-16 19:53:36 INFO  cached 中具有的 type: java
2019-07-16 19:53:36 INFO  cached 中的 type com.alibaba.jvm.sandbox.repeater.plugin.domain.InvokeType$2@3d4fcd8 和传入的 type com.alibaba.jvm.sandbox.repeater.plugin.domain.InvokeType$1@f23bf1 作 equals 的结果: false
2019-07-16 19:53:36 ERROR [Error-0000]-uncaught exception occurred when register repeat event, req={_data=QzA5Y29tLmFsaWJhYmEuanZtLnNhbmRib3gucmVwZWF0ZXIucGx1Z2luLmRvbWFpbi5SZXBlYXRNZXRhmAdhcHBOYW1lB3RyYWNlSWQEbW9jawhyZXBlYXRJZA9tYXRjaFBlcmNlbnRhZ2UKZGF0YXNvdXJjZQxzdHJhdGVneVR5cGUJZXh0ZW5zaW9uYAd1bmtub3duMCAxOTIxNjgwMTUwNTkxNTYzMjc3MTA4MjUxMTAwMDFlZFQwIDE5MjE2ODAxNTA1OTE1NjMyNzgwMTYwNDExMDAwNWVkXWROQzBFY29tLmFsaWJhYmEuanZtLnNhbmRib3gucmVwZWF0ZXIucGx1Z2luLnNwaS5Nb2NrU3RyYXRlZ3kkU3RyYXRlZ3lUeXBlkQRuYW1lYQ9QQVJBTUVURVJfTUFUQ0hIWg==}

cached 中具有的 type: http 这个情况下时,传入的值和缓存的值内存地址不一样,且 equals 的结果也是 false ,所以导致了虽然 name 一样,但缓存查找 key 的时候匹配不上。

本地测试了下,不管怎么初始化,都不会出现内存地址不一样的情况。因此具体原因还在细究中,估计是一些 java 的暗坑。

总结

  • repeater 底层本身就已经提供了接口对接配套的存储服务、配置获取服务,可以直接复用。
  • repeater 负责录制和存储原始录制数据,配合 console 控制录制和回放,可以实现强大的功能满足各类需要,且各个模块职责清晰。这个设计值得学习。
  • repeater 内部实现包含很多和录制回放息息相关的信息,后续还需要继续了解 repeater-plugin ,下一步的目标是写出一个 rabbitmq 的 plugin (刚好项目也需要用到)
  • 批量回放,还得自行实现。这套录制回放要在项目中用上还有很多东西要做。

本文首发于TesterHome社区,点此链接可查看原文并与作者直接交流

今日份的知识已摄入~
想了解更多前沿测试开发技术:欢迎关注「第十届MTSC大会·上海」>>>
1个主会场+11大专场,大咖云集精英齐聚

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,053评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,527评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,779评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,685评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,699评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,609评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,989评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,654评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,890评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,634评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,716评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,394评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,976评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,950评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,191评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,849评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,458评论 2 342

推荐阅读更多精彩内容