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

陈恒捷 for PPmoney · July 12, 2019 · Last by qiugang250 replied at October 24, 2019 · 8406 hits
本帖已被设为精华帖!

为了避免文章过长,此文章单独记录 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 (刚好项目也需要用到)
  • 批量回放,还得自行实现。这套录制回放要在项目中用上还有很多东西要做。
如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
共收到 16 条回复 时间 点赞
026 将本帖设为了精华贴 12 Jul 11:11

给恒捷点赞。

刘晓光 回复

哇哇,受宠若惊,谢谢点赞。

陈恒捷 回复

正在琢磨大规模搞起。

Author only
梦梦GO 回复

Failed to execute goal on project repeater-console-service: Could not resolve dependencies for project com.alibaba.jvm.sandbox:repeater-console-service:jar:1.0.0-SNAPSHOT: Could not find artifact com.alibaba.jvm.sandbox:repeater-plugin-core:jar:1.0.0-SNAPSHOT -> [Help 1]

缺少依赖,估计是因为你之前没 build 过 repeater 的其它 module 。
可以先 cd 到 repeater 项目根目录,执行 mvn install ,再按文中步骤到 repeater-console 执行这个命令试试?

陈恒捷 回复

谢谢楼主,退了一级目录执行 mvn install -DskipTests, 后再进到 repeater-console去执行就成功启动了。特别感谢,期望紧跟楼主步伐 哈哈

1.在Linux上使用git 下载repeater的源码
git clone git://github.com/alibaba/jvm-sandbox-repeater.git
2.在源码上修改properties文件
vim repeater-console/repeater-console-start/src/main/resources/application.properties

3.在linux的repeater的源码上修改 ConfigFacadeApi.java文件
vim repeater-console/repeater-console-start/src/main/java/com/alibaba/repeater/console/start/controller/ConfigFacadeApi.java

4.修改 linux上的peteater的源码的,RepeaterBridge.java文件
vim repeater-plugin-core/src/main/java/com/alibaba/jvm/sandbox/repeater/plugin/core/bridge/RepeaterBridge.java

5.修改在linux上使用 下载安装的sandbox的配置文件
curl -s http://sandbox-ecological.oss-cn-hangzhou.aliyuncs.com/install-repeater.sh | sh
vim ~/.sandbox-module/cfg/repeater.properties

6.进入在Linux上源码的 bin文件夹,执行./install-local.sh
cd /root/shifeng/git/jvm-sandbox-repeater/bin
./install-local.sh

7.进入 repetaer的跟目录,进行清空,编译,打包
mvn clean

mvn compile -DskipTests

mvn install -DskipTests

8.进入 repeater-console 执行清空,编译,打包
mvn clean

mvn compile -DskipTests

mvn install -DskipTests

9.修改sandbox的配置文件开启 console
vim ~/.sandbox-module/cfg/repeater.properties

10.启动刚刚打好jar包的repeater-console
java -jar repeater-console-start/target/*.jar

11.启动要录制的项目
java -jar gs-rest-service-0.1.0.jar

12.将启动项目的进程,绑定到指定的12580端口

查看 repeater-console 项目日志

查看 sandbox日志

  1. 进行录制的请求访问 curl -s 'http://localhost:8080/greeting' 录制项目正确打印,返回了结果 但是 repeater-console 和sandbox并没有进行录制

您好,看了您的文章收获了很多,非常感谢!还有个问题想请教,mock回放是不是只和repeat-config的配置相关,与回放的方式无关,即采用repeat-console的方式也能够进行mock的回放?

是的。

这篇文章说的就是 repeater-console 这种方式如何进行 mock 和回放。

13Floor has been deleted
14Floor has been deleted

问下,case筛选是咋做的?录制下来后,肯定会有重复的请求

Wang 回复

这个自己二次开发 console 。
而且说实话,重复的请求如果不是非常多,不会额外造成太多的执行耗时,其实重复又何妨?

curl窗口报错:

repeater日志(只有第一次发送那一条):

求教是什么问题啊?

qiugang250 回复

把你的完整步骤(包括配置项怎么改、用的什么操作系统等)发下?信息太少,无法定位问题原因。

陈恒捷 回复

非常感谢如此迅速的回复,问题配置和步骤如下(这次问题和坑中的问题表现一致,之前截图问题未再次出现):
1、配置:
1)用的vmware虚拟机

2)操作系统版本:

3)脱机模式关闭

4)修改端口:

5)修改配置:

6)修改坑中描述类型:

完成以上配置后,执行步骤
2、操作步骤:
1)cd /opt/git/gs-rest-service/complete && mvn install && java -jar target/.jar

2)cd /opt/git/jvm-sandbox-repeater/repeater-console && mvn install -DskipTests && java -jar repeater-console-start/target/
.jar

3)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

4)录制和回放
curl -s 'http://127.0.0.1:8001/facade/api/repeat/unknown/010000002015157182403926310001ed'报成功,服务端也收到请求并响应;
curl -s 'http://127.0.0.1:8001/facade/api/repeat/callback/010000002015157182403926310001ed'报错
curl -s 'http://127.0.0.1:8001/facade/api/repeat/unknownrepeater日志中也中间打印了一段,看起来貌似是成功的,但是怎么又出现了一条TraceId不同的请求?

qiugang250 回复

从你前面配置步骤,没看出太大问题。但你的截图里总是有些像乱码一样的奇怪字符,不知道是不是你的系统默认字符集不大对。

另外,有个很奇怪的点,你倒数第二个截图里面,repeat 请求返回的 data 值,没见到具体值是啥,展示不完整。

然后最后的 callback 里用的 id 是必须用 repeat 返回的 data 里面带有的 id 的,因为它查询的是回放结果,直接用和请求 repeat 一样的 id 是不正确的。我刚刚也微调了下正文里的内容,强调一下这个点。

陈恒捷 回复

非常感谢,确实是没注意,以为都用发送的那个traceId,现在已经通了。

我们是想把线上流量引到线下环境使用,并比较两次返回区别,所以图示中diff字段非常关键,但是开源代码并有实现(可以自己实现,一些字段可能得加白名单不比对,此外还涉及一些脱敏字段部分);另外,我们如果要应用,必须要实现以上接口的自动发送和存储,非常期待大神有时间的时候出一下以上两块的文章,大赞!!!

需要 Sign In 后方可回复, 如果你还没有账号请点击这里 Sign Up