背景
前面因为生成pinpoint慢就去了解了原理,一下子就把什么arthas、skywalking这种对于新手上路时,不可知不可学的东西贯通了。
中性的说,这类开源项目就是许多人维护了个基于jvm提前埋好的口子,(业务)足够复杂完善基于各自企业级经验完善的项目。是一个业务堆量的过程,我们学习时是真没必要磕别人源码,很可笑。谁要是面试问这个我就怼他你是P99吗这么在意这个源码,你真差那点性能还是说没炫技设计项目就完蛋了。
正主
入门
学pinpoint原理时已经知道了,是基于JVM的agent机制埋的口子,写好MANI-INF配置,指定完口子类,然后实现就完事了,可以调用的JVM提供的能力也是在jdk里都实现了的
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.1.0</version>
<configuration>
<archive>
<!--自动添加META-INF/MANIFEST.MF -->
<manifest>
<addClasspath>true</addClasspath>
</manifest>
<manifestEntries>
<Premain-Class>self.ol.hotfix.HotFixPreMainAgent</Premain-Class>
<Agent-Class>self.ol.hotfix.HotFixPreMainAgent</Agent-Class>
<Can-Redefine-Classes>true</Can-Redefine-Classes>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
</manifestEntries>
</archive>
</configuration>
</plugin>
两个参数,一个在命令JVM触发agentmain时传入,另一个则是JVM提供的我们可以使用的能力的实现。
而且实现这个方法的类甚至不用实现什么interface!你看看这个,JVM/JDK核心路子都这么野,咱也不知道是不是这也是反射哈哈哈。而且你看看,高度抽象的标准接口,那还得是String入参,然后自己解析就完事了,万物皆可转String。
Java大神尚且如此你写个业务还瞎封装什么,又不是不能用,封一堆结构化入参,那确实看起来很规范,但1你设计可能达不到反而让别人乱,2是你维护着维护着就忘了原始设计了,除非你自愿积极详细维护文档而不是交差了事。但多少钱啊,你这么积极。
这里其实有个大坑【坑1】后面会讲,经典字符集问题,而且我还没改掉,但并不阻塞。
触达目标JVM,命令进行agent
代码一大堆,没什么复杂逻辑,就是找你要注入的进程pid,然后使用JDK提供好的底层能力,直接attach触达,然后把我们写的含有上面说到的agent方法逻辑的jar传进去就好啦
public static void main(String[] args) throws IOException, AgentLoadException, AgentInitializationException, AttachNotSupportedException {
Assert.notEmpty(args, "启动入参需要:目标jar名");
String agentJar = ("D:\AsiaInfo\Christina\target\Christina-1.0-DEMO.jar");
String attachJar = args[0];
System.out.println("running: try JVM attach " + attachJar);
List<VirtualMachineDescriptor> jvmList = VirtualMachine.list();
System.out.println("running: exist JVM size " + jvmList.size());
Runtime runtime = Runtime.getRuntime();
Pattern jarPattern = Pattern.compile("(-jar\s+.*" + attachJar.replaceAll("\.", "\\.") + ")");
// Process exec0= runtime.exec(" WMIC PATH Win32_Process WHERE "processid = '" + 35764 + "'"");
// String execResult0 = IoUtil.read(exec0.getInputStream(),"UTF-8");
// jarPattern.matcher(execResult0).find();
String targetPid = null;
for (VirtualMachineDescriptor vmDescriptor : jvmList) {
// System.out.println("pid[" + vmDescriptor.id() + "] " + vmDescriptor.displayName() + ", " + vmDescriptor.provider());
Process exec = runtime.exec(" WMIC PATH Win32_Process WHERE "processid = '" + vmDescriptor.id() + "'"");
String execResult = IoUtil.read(exec.getInputStream()).toString();
if (jarPattern.matcher(execResult).find()) {
System.out.println("pid[" + vmDescriptor.id() + "] is " + attachJar);
System.out.println("pid[" + vmDescriptor.id() + "] detail:\n" + execResult);
targetPid = vmDescriptor.id();
break;
}
// System.out.println("pid[" + vmDescriptor.id() + "] " + execResult);
}
if (targetPid == null) {
System.err.println("未找到" + attachJar + "进程!");
return;
}
VirtualMachine virtualMachine = VirtualMachine.attach(targetPid);
virtualMachine.loadAgent(agentJar);
virtualMachine.detach();
}
DEMO-转生产试用的封装过程
demo写好啦,测试通过啦,那我们就要考虑给这个demo搞上去用起来啦。说来这个自己搓的热更新插件就是因为现在的项目比较傻逼,观察基层开发们确实需要一个热更新的能力。
当然导致这个诉求的真实原因有2个:
1、需求傻逼客户傻逼没信用和担当搞得很赶,质量就不行了,上线发布也是因为客户要用的平台很慢很冗长,CI/CD过程长,调试不方便;
2、X信的制度性颠倒完蛋,有能力的跑,领导的能力配钱吗,研发的钱配得上能力吗?结果就是上面舵掌不好,下面千人千心要不你开了我们,然后看看这点钱找得到什么样的。结果就是研发全是混子现在。
回归正题:DEMO转生产,就要考虑的多了:
1、稳定性
需要去测试验证多次agent、重复agent、卸载agent,对于内存啦、方法耗时啦的影响。
- 为什么是测试验证?JVM底层是C的吧,看不懂,中文互联网也大概搜了下没资料,验证是可行性最高的了。
- 验证结果:多次agent没问题。
2、分布式特性-优先级1最高
如题,分布式多服务多节点,热更新从入口打到期望的应用,并顺利分发到所有实例的过程
老生常谈的,最终一致性,土就是好,定时任务。
3、健壮性
如题,这个云平台可不太稳定,说不好什么时候就因为中间件或者存储什么问题就容器重启了。而且开发质量确实差,压测都不敢狠测的。
那么就要保证多次重启你上次热更新的进度自动恢复吧~不然你说你热修了,第二天夜里,啪,死了,重启,早上客户就该投诉了。
当然热修其实是临时方案了,晚上测试完成后,那就正式提交分支走发布了~,所以这个其实可有可无~
4、如何通用集成-优先级2
说实话,直接就想到了xxx-starter,然后翻了下各类Spring注解都在spring-context下,那就差不多了。
搓个starter,做个AutoConfiguration.java来将热更新的spring实现(比如store、schedule)引入现有工程的spring管理。
5、热修支持的范围-优先级2
你让人用你的东西热修,你总得给份操作手册吧,什么样的代码能修,什么样的不能修,再给人宣贯下原理,让他们自己能思考下能不能修。
-
这个其实就是JVM解释型执行的原理、实例化过程、类加载八股了。
(1)热更新后,所有实例都会方法变动生效,但实例中的中变量不会被热更新
写Demo验证了,已实例化的也会被retransform为新的类逻辑。
那原理上为什么呢?class被jvm加载定义后,在new时做了什么就很关键了。
偷张图:
如图中⑤,和对new的合理的猜测一样,从jvm已加载的class原型创建新对象,然后init。既然如此reTransform后还能将老对象的逻辑就……
有必要验证下reTransform后老对象的构造方法啦,field初始化:
验证结果为:reTransform后(1)不存在重新初始化构造方法;(2)field值不会更新,且类里不能有基本数据类型,不然会报java.lang.UnsupportedOperationException: class redefinition failed: attempted to change the schema (add/remove fields)
那可能就是更底层的jvm面向过程重新设置了class及实例化的对象?或者方法执行原理上导致的?
偷图:那确实是这样的,本地方法栈(每次运行必要数据)和JVM的class方法栈(一份),我们重载就是把后者重新设置了,自然就不需要管老实例的问题。
引用深入理解 JVM 的栈帧结构 | 二哥的Java进阶之路
ok
(2)不能修改如public、final、returnType等方法的修饰
根本上是接口的签名/JVM对方法的识别依赖于方法前面
返回类型不同,或者public改private,,在JVM看来已经是两个方法了,也就是在新增一个方法并且移除老方法,而新增或移除则是改动了类的签名,JVM不认可
在虚拟机中,内部类型签名在字节码层面用来识别函数或者类。
本质上和重载方法/反射调用可能遇到的问题和要求都是共通的
小发散一下:方法签名的策略 与 数据库需不需要唯一id这个讨论点,存在着联系。
方法签名实质上是配置类&固定的东西,不需要管理持续跟踪生命周期,是固定的,所以可以通过Return&Param&Name联合定位一个方法;
而数据库一般来说我们做业务都是要持续管理跟踪的,联合主键/签名是不合理的,常见于那些C++开发的代码/表设计。
而且这和重载还有些不同,重载是根据返回啦、参数啦,在方法执行时的判断路由;签名则是单纯的不允许变动类结构?
(3)基本数据类型的(无论static)field的存在会导致reTransform失败,无论有没有赋值
可能与类初始化时的赋值有关,别的引用数据类型是在cinit赋值,而基本数据类型没有null一说,不到初始化阶段就提前赋值,而这种赋值不被允许(?)就抛出“尝试增删字段”异常?
-----不是不被允许,是reTransform就不能更新field值,方法区可以说只有方法内容可以变,其他的如class定义、常量、类定义都不能变,但基本数据类型必须赋值,那就被JVM当成了新字段咯
- 异常记录
不能增减字段
java.lang.UnsupportedOperationException: class redefinition failed: attempted to change the schema (add/remove fields)
类中不能存在基本数据类型(Long、int等)
java.lang.UnsupportedOperationException: class redefinition failed: attempted to change the schema (add/remove fields)
6、agent-参数-业务参数封装
要把我们业务要热更新的一些业务定义/定位信息顺利打到一个agent抽象执行方法里,根据传入信息去做agent做class的reload。这里就简单使用数据库做中介啦。
- 有个坑点!!
-
- 传入的类路径、class文件bytes原本准备一律转byte[]再转String传进入,但实际发现的字符集异常,中文异常,也就是执行attach的调度端和被agent的执行端,即使编码一致,但是通过jdk提供的方法attach时,执行端收到的就是会乱码。
-
- 举例就是入参是在调度端JVM内可以反复编码解码的UTF-8,但attach过去就乱码了
-
- 所以最后就是改为了文件走一遭数据库中介,再转存到执行端本地存储。我们入参只传类路径和class文件路径,且路径中不能有中文或奇奇怪怪的,尽量全字母。
7、安全性
主要通过网络安全保证。内网可信人员账号4a登陆后,操作。
后面的以后再写吧~