# DAMC-动态AgentMock组件 **Repository Path**: loopstack/damc ## Basic Information - **Project Name**: DAMC-动态AgentMock组件 - **Description**: DAMC-动态AgentMock组件:对所有函数进行Agent Mock - **Primary Language**: Java - **License**: Apache-2.0 - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2025-05-12 - **Last Updated**: 2025-10-10 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 什么是字节码插桩可视化平台 + 在业务开发的过程中,我们需要对接外部的接口、本地数据进行测试等,但是因为外部的接口不稳定、尚未开发完成,以及环境数据问题,会导致流程阻塞,无法完成自己内部的逻辑测试和联调。同时,在测试同学进行测试的时候,也会用到同样的功能来进行测试。 # 目标功能 + 在方法指定行插桩,修改指定的参数,参数类型包含但是不限于:基本数据类型、String、枚举、对象、数组、集合、以及其他可能的类型 + 在指定方法的返回值进行插桩,参数类型包含但是不限于:基本数据类型、String、枚举、对象、数组、集合、以及其他可能的类型 + 在指定方法的指定行添加指定异常,对流程进行中断:可以自定义异常或错误码等进行插桩 + 上述的功能,支持根据函数内的参数、全局变量、静态变量、以及某个测试同学工号的配置等条件,进行插桩的功能 + 需要支持各种类型的插桩处理 + 因为项目部署的很慢,发布一次需要很久,并且为了安全起见,需要进行外挂式插桩,而非侵入性插桩,因此在技术选型的时候需要进行处理 # 技术方案可行性调研 + 代码从编译到运行的过程,见下图 ![](static/img/img0.jpg) + 技术方案调研如下 ![](static/img/img1.png) ## JSR 269 + 业界最佳实践:Lombok、Mapstruct 基于在编译期间生成文件,一般是基于注解 + 如果使用 JSR 269,因为其需要侵入代码,需要额外引入包文件,并且在处理的时候,可以根据配置文件获取到目标全限定类目和函数。 + 如果使用此种方式,预期的系统交互如下 ![](static/img/jsr269.png) + 此方式的优点:后台配置开发人员可以介入,可以编写**任意类型**的代码实现,**完全基于Java代码编程**,**灵活性超级高** + 此方式的缺点:**新增类需要重新构建部署**,测试学习成本比较高 ## Java Agent(ASM) + 业界最佳实践:全链路跟踪系统 + 如果使用此种方式,开发难度很大,需要定制各种流程,并且需要针对性开发、并且针对不同数据类型,处理的逻辑完全不一样 + 此方式的优点:可以针对指定行、指定类型、异常、进行插桩和修改、可以在运行时候进行修改、可以对任何类进行修改 + 此方式的缺点:需要外挂Agent包;代码实现难度非常大;配置难度极大;需要熟悉Java字节码原理;因为配置基于行,代码修改之后,也需要修改对应的行的配置,复用性极低;需要针对各种场景完全定制插桩流程(穷举法);很难定制指定代码插入流程(暂时没想到好的方法) + 目前已经实现的是对指定位置基本数据类型的修改,更多的逻辑需要一点点定制开发 ## Spring AOP + 依赖Spring家族,不支持静态类、final类,只能够适用于Spring场景 + 基于此种场景,也可以使用基于JSR 269的配置加载方式 + 此方式的优点:实现简单,基于代理或委托 + 此方式的缺点:适用范围较少 # 最终技术选项结果 + 最终使用Java Agent的方式实现。现在有如下三个类 + 被Mock类 ```java package cn.loopstack.damc.springweb.controller; import java.util.HashSet; import java.util.UUID; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; /** * @author icanci * @since 1.0 Created in 2023/09/27 14:07 */ @RestController @RequestMapping("/order") public class OrderController { @GetMapping("/create") public String createOrder(String orderNo, Integer channel, OrderController controller, HashSet tags) { return createOrder0("IC20250101OXAAS0001", 100, null); } @GetMapping("/search") public String searchOrder() { String traceId = UUID.randomUUID() + "-info"; System.out.println(traceId); return traceId; } public final String createOrder0(String orderNo, Integer channel, HashSet tags) { try { String traceId = UUID.randomUUID() + "-info"; System.out.println("traceId:" + traceId); return traceId; } catch (Exception e) { return e.getMessage(); } } private static String createOrder1(String orderNo, Integer channel, HashSet tags) { try { String traceId = UUID.randomUUID() + "-info"; System.out.println("traceId:" + traceId); return traceId; } catch (Exception e) { return e.getMessage(); } } public final String createOrder2(String orderNo, Integer channel, HashSet tags) { try { String traceId = UUID.randomUUID() + "-info"; System.out.println("traceId:" + traceId); return traceId; } catch (Exception e) { return e.getMessage(); } } } ``` + 被Agent之后的类 ```java package cn.loopstack.damc.springweb.controller; import cn.loopstack.damc.core.service.MockRunnerService; import java.util.HashSet; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping({"/order"}) public class OrderController { public OrderController() { } @GetMapping({"/create"}) public String createOrder(String var1, Integer var2, OrderController var3, HashSet var4) { return (String)MockRunnerService.invoke(this, new Object[]{var1, var2, var3, var4}, "cn.loopstack.damc.springweb.controller.OrderController", "createOrder", "(Ljava/lang/String;Ljava/lang/Integer;Lcn/loopstack/damc/springweb/controller/OrderController;Ljava/util/HashSet;)Ljava/lang/String;"); } @GetMapping({"/search"}) public String searchOrder() { return (String)MockRunnerService.invoke(this, new Object[0], "cn.loopstack.damc.springweb.controller.OrderController", "searchOrder", "()Ljava/lang/String;"); } public final String createOrder0(String var1, Integer var2, HashSet var3) { return (String)MockRunnerService.invoke(this, new Object[]{var1, var2, var3}, "cn.loopstack.damc.springweb.controller.OrderController", "createOrder0", "(Ljava/lang/String;Ljava/lang/Integer;Ljava/util/HashSet;)Ljava/lang/String;"); } private static String createOrder1(String var0, Integer var1, HashSet var2) { return (String)MockRunnerService.invoke((Object)null, new Object[]{var0, var1, var2}, "cn.loopstack.damc.springweb.controller.OrderController", "createOrder1", "(Ljava/lang/String;Ljava/lang/Integer;Ljava/util/HashSet;)Ljava/lang/String;"); } public final String createOrder2(String var1, Integer var2, HashSet var3) { return (String)MockRunnerService.invoke(this, new Object[]{var1, var2, var3}, "cn.loopstack.damc.springweb.controller.OrderController", "createOrder2", "(Ljava/lang/String;Ljava/lang/Integer;Ljava/util/HashSet;)Ljava/lang/String;"); } } ``` + Mock方法执行类:MockRunnerService ```java /** * Mock 执行函数 * * @author icanci * @since 1.0 Created in 2025/05/13 14:02 */ public class MockRunnerService { /** * Spring 上下文 */ private static ApplicationContext context; public static void setContext(ApplicationContext context) { MockRunnerService.context = context; } /** * @param _this 被mock的方法的对象,当mock的方法为静态函数的时候,此值为null * @param args 被mock的方法的请求参数 * @param clazzName 被mock的方法所在类的类名 * @param methodName 被mock的方法所在类的方法名字 * @param descriptor 被mock的方法的descriptor * @return 被mock的方法的返回参数 */ public static Object invoke(Object _this, Object[] args, String clazzName, String methodName, String descriptor) { try { // 记录日志 log(clazzName, methodName, descriptor); // 此处是本地函数调用(只能是本地调用) String script = GroovyScriptRepository.getScript(clazzName, methodName, descriptor); Class compile = GroovyClassLoaderHolder.compile(script); Object instance = compile.newInstance(); if (instance instanceof MockRunnerHandler) { MockRunnerHandler handler = (MockRunnerHandler) instance; // 全限定类名重复的处理。此处无需处理, // 经过 GroovyClassLoaderHolder.compile 的class对象是不重复的,即使代码是完全一样的 // 处理 Spring 使用@Resource 注入 injectionSpringBeanProcessor(handler); return handler.invoke(_this, args, clazzName, methodName, descriptor); } throw new IllegalArgumentException("class must implements MockRunnerHandler"); } catch (Exception e) { throw new RuntimeException(e); } } /** * 记录日志 * * @param clazzName 被mock的方法所在类的类名 * @param methodName 被mock的方法所在类的方法名字 * @param descriptor 被mock的方法的descriptor */ private static void log(String clazzName, String methodName, String descriptor) { String format = "[MockRunnerService][log] the class:[%s],the method:[%s],the descriptor:[%s] mocked."; System.out.printf((format) + "%n", clazzName, methodName, descriptor); } /** * 后置注入Bean * * @param handler handler */ public static void injectionSpringBeanProcessor(MockRunnerHandler handler) { context.getAutowireCapableBeanFactory().autowireBean(handler); } } ``` # 架构设计 + 基于远程配置通知+运行/编译时处理,具体的交互如下 ![](static/img/jgu1.png) + 具体的注册中心如下 ![](static/img/jgu2.png) # 功能可行性 + 通过替换方法的方式,可以对整个方法进行全方面各种使用,能够满足所有的场景——只需要你会Java代码即可 # 边界、安全与风险 + 在目标方法被Mock的时候,在调用的时候,会使用到配置的Mock数据,如果被带到生产环境,会有致命问题 + 解决方式:不允许在线上使用,并且也不提供线上环境 # 页面设计和实现 + 应用配置 ![](static/img/app-config.png) ![](static/img/app-config-form.png) + 脚本配置 ![](static/img/script1.png) + 新增脚本 ![](static/img/script2.png) + 注册表 ![](static/img/registertable.png) # 数据库设计 + 使用Mongodb进行数据的存储。因为很多是基于Java代码的实现,因此使用Mongodb文档型数据库查询 + 在数据设计的过程中,有些数据是固化的,每6张表数据都有的,如下表,因此这些字段不再单独列出,具体的可在存储的数据结构中看到 | **字段名称** | **类型** | **备注** | | --- | --- | --- | | id | object(String) | mongodb 自带id | | uuid | String | 雪花算法随机UUID | | desc | String | 功能描述 | | createTime | Date | 创建时间 | | updateTime | Date | 更新时间 | | deleted | boolean | 状态 true无效,false有效 | | env | String | 环境 | + 项目划分(文档名称:**damc-app**) | **字段名称** | **类型** | **备注** | | --- | --- | --- | | appName | String | 项目名称 | | appCode | String | 项目唯一Code | + 项目注册表(文档名称:**damc-register-table**) 此数据在服务部署的时候就上报ip地址和端口 | **字段名称** | **类型** | **备注** | | --- | --- | --- | | appCode | String | 项目唯一Code | | clientAddress | String | 被Mock项目服务ip地址 | | clientPort | int | 被Mock项目服务端口 | | registerTime | Date | 服务注册时间 | | lastUpdateTime | Date | 上次注册更新时间 | + 项目配置的全限定类名、函数、和执行脚本(文档名称:**damc-class-script**) | **字段名称** | **类型** | **备注** | | --- | --- | --- | | appCode | String | 项目唯一Code | | clazzName | String | 全限定类名 | | methodName | String | 方法名 | | descriptor | String | 方法描述符 | | script | String | 方法的执行脚本 | | scriptType | String | 方法的执行脚本类型 | + 对操作的日志进行详细记录(文档名称:**damc-log**) | **字段名称** | **类型** | **备注** | | --- | --- | --- | | module | String | 操作模块 | | targetId | String | 对象编号,取uuid | | operatorType | | 操作类型 | | content | text | 操作数据 | # 基于MongoDB的分布式锁实现 + 为什么需要分布式锁,因为Admin可能集群部署,在上述的Admin线程池中,会定时进行探活,但是实际上探活只需要单独机器触发即可,不需要所有Admin机器都去触发,这样会导致浪费资源,所以需要在探活的地方加分布式锁 + 基于MongoDB实现的分布式锁,依赖于`FindAndModifyOptions`实现,具体参见如下代码,处理过期是因为,别的机器可能执行慢了,这样就需要重新获取锁,再次尝试探活 ```java public interface LockDAO { /** * 文档对应的名字 */ String COLLECTION_NAME = "rec-lock"; /** * 文档对应的Class */ Class COLLECTION_CLASS = LockDO.class; String lock(String key, long expireTime); boolean release(String key, String token); boolean refresh(String key, String token, long expiration); interface LockColumn { /** id */ String _id = "_id"; /** key */ String key = "key"; /** expireAt */ String expireAt = "expireAt"; /** token */ String token = "token"; /** env */ String env = "env"; } } ``` ```java @Service("lockDAO") public class MongoLockDAO implements LockDAO { private static final Logger logger = LoggerFactory.getLogger(MongoLockDAO.class); @Resource protected MongoTemplate mongoTemplate; @Override public String lock(String key, long expireTime) { Query query = Query.query(Criteria.where(LockColumn.key).is(key)); String token = IDHolder.generateNoBySnowFlake("LOCK"); Update update = new Update().setOnInsert(LockColumn.key, key).setOnInsert(LockColumn.env, EnvUtils.getEnv()) .setOnInsert(LockColumn.expireAt, System.currentTimeMillis() + expireTime).setOnInsert(LockColumn.token, token); FindAndModifyOptions options = new FindAndModifyOptions().upsert(true).returnNew(true); LockDO lock = mongoTemplate.findAndModify(query, update, options, COLLECTION_CLASS, COLLECTION_NAME); if (lock == null) { return StringUtils.EMPTY; } boolean locked = StringUtils.equals(token, lock.getToken()); // 如果已过期 if (!locked && lock.getExpireAt() < System.currentTimeMillis()) { DeleteResult deleted = this.mongoTemplate.remove( Query.query(Criteria.where(LockColumn.key).is(key).and(LockColumn.token).is(lock.getToken()).and(LockColumn.expireAt).is(lock.getExpireAt())), COLLECTION_CLASS, COLLECTION_NAME); if (deleted.getDeletedCount() >= 1) { // 成功释放锁, 再次尝试获取锁 return lock(key, expireTime); } } return locked ? token : StringUtils.EMPTY; } @Override public boolean release(String key, String token) { Query query = Query.query(Criteria.where(LockColumn.key).is(key).and(LockColumn.token).is(token).and(LockColumn.env).is(EnvUtils.getEnv())); DeleteResult deleted = mongoTemplate.remove(query, COLLECTION_CLASS, COLLECTION_NAME); boolean released = deleted.getDeletedCount() == 1; if (released) { logger.info("Remove query successfully affected 1 record for key {} with token {}", key, token); } else if (deleted.getDeletedCount() > 0) { logger.error("Unexpected result from release for key {} with token {}, released {}", key, token, deleted); } else { logger.warn("Remove query did not affect any records for key {} with token {}", key, token); } return released; } @Override public boolean refresh(String key, String token, long expiration) { Query query = Query.query(Criteria.where(LockColumn.key).is(key).and(LockColumn.token).is(token).and(LockColumn.env).is(EnvUtils.getEnv())); Update update = Update.update(LockColumn.expireAt, System.currentTimeMillis() + expiration); UpdateResult updated = mongoTemplate.updateFirst(query, update, COLLECTION_CLASS, COLLECTION_NAME); final boolean refreshed = updated.getModifiedCount() == 1; if (refreshed) { logger.info("Refresh query successfully affected 1 record for key {} " + "with token {}", key, token); } else if (updated.getModifiedCount() > 0) { logger.error("Unexpected result from refresh for key {} with token {}, " + "released {}", key, token, updated); } else { logger.warn("Refresh query did not affect any records for key {} with token {}. " + "This is possible when refresh interval fires for the final time " + "after the lock has been released", key, token); } return refreshed; } } ``` # 发布功能 + 发布的时候必须是刷新被刷新应用的目标类的所有被Mock方法,不支持指定刷新! # 其他功能 + 当项目下线的时候,也应该发布配置进行处理,此处暂未处理,比较简单,暂不实现 + 当项目已经下线的时候,重新启动或者修改配置,都不会生效,都会被拦截 # 接入方式 + 使用Java Agent的方式接入,如下 + 需要将一些必要的信息传入到参数中去 ```text -javaagent:/Users/icanci/ideaProjects/loopstack/damc/damc-core/target/damc-core-1.0-SNAPSHOT-jar-with-dependencies.jar=127.0.0.1;127.0.0.2|9191|9292|QA|DAMC测试项目|DAMC-TEST-CORE^/Users/icanci/ideaProjects/loopstack/damc/damc-core/target/damc-core-1.0-SNAPSHOT-jar-with-dependencies.jar ``` + 项目基于Spring,需要配置包扫描,需要扫描以下包路径 ```text cn.icanci.loopstack.damc.core ``` + 项目中需要被加载的模块引入maven坐标 ```xml cn.icanci.loopstack.damc damc-core 1.0-SNAPSHOT org.springframework spring-context io.netty netty-all org.codehaus.groovy groovy-all ``` # IDEA SpringBoot项目配置 ![](static/img/SpringBootCfg1.png) ![](static/img/SpringBootCfg2.png) # IDEA Tomcat项目配置 ![](static/img/TomcatCfg1.png) ![](static/img/TomcatCfg2.png) # Tomcat类加载问题 + 在SpringBoot环境中,使用当前的执行流程是没有问题的。 + 但是在Tomcat环境中。会被加载2次,第一次是`AppClassLoader`,第二次是`ParallelWebappClassLoader`如下 ![](static/img/AppClassLoader.png) ![](static/img/TomcatClassLoader.png) + 而`ParallelWebappClassLoader`没有继承`AppClassLoader`,因此导致类隔离,从而导致初始化的数据丢失 ![](static/img/ParallelWebappClassLoader.png) + 因此,再重新加上Java Attach的方式,在ApplicationContextAware回调之后尝试重新在加载一次,因此解决了问题。 + 而这个时候,需要将Java Agent的参数存储到系统变量中,等待后续使用 ```java public class DynamicAgent { /** * JDK Agent premain * * @param args args,例如: 127.0.0.1;127.0.0.2|9191|9292|QA|DAMC测试项目|DAMC-TEST-CORE^/Users/icanci/ideaProjects/loopstack/damc/damc-core/target/damc-core-1.0-SNAPSHOT-jar-with-dependencies.jar * @param inst inst */ public static void premain(String args, Instrumentation inst) { if (StringUtils.isBlank(args)) { return; } String[] argsArray = args.split("\\^"); if (argsArray.length != 2) { return; } // 加载到系统参数里面 System.getProperties().put(DamcCoreConstant.DAMC_ARGS_KEY, args); System.getProperties().put(DamcCoreConstant.DAMC_ARGS_INIT_KEY, argsArray[0]); System.getProperties().put(DamcCoreConstant.DAMC_ARGS_AGENT_PATH_KEY, argsArray[1]); // doAgentTransformer doAgentTransformer(inst, argsArray[0]); } } ``` + 但是问题来了,当存储局部变量的时候,由于不同的类加载器,并且二者之间没有继承关系,加载的类不一样,因此也是处理不了的 # 跨类加载器赋值对象 + 因此需要一种方法,能够解决“跨类加载器赋值对象”,因为 **Instrumentation** 是由JVM系统类加载器加载的,会对所有类可见,因此从基本面上是可以完成这个跨类加载器处理的 + 具体的处理流程如下 TODO # ClassNotFound问题 + 在生成字节码的时候,需要先读取原本的字节码,从jar中文件读取,但是在运行的时候抛出了此异常 + 因此改为使用当前Class的类加载器对象的字节流文件 # 包冲突问题 + 依赖的包有hutool、groovy,在执行的时候需要进行排包 # ASM执行问题 VerifyError + 在修改字节码之后,重新加载的时候,可能有字节码局部变量等乱序,导致的失败。使用ASM自动重拍解决问题 # ASM类冲突问题 + 很多项目中都会使用ASM,并且版本各种不一样,因此,DAMC将ASM部分源码移动到Damc-Core中,并且修改其全限定类名,就不会存在冲突问题 # 持有Bean和持有函数 + 将外部SpringBean植入当前服务(原始Bean) + 将被Mock的函数植入当前服务(原始Mock函数),可以通过反射的方式调用 # 预编译功能 + 编译编写的类 TODO + 预编译并执行?可以设置执行参数等 # Groovy脚本缓存 + TODO # 万物Mock + 流量复制和回放 + AB实验分流 + 紧急故障处理