【JAVA】自记-手搓热更新agent过程

88 阅读9分钟

背景

前面因为生成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>

image.png

两个参数,一个在命令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时做了什么就很关键了。

    偷张图:

image.png 如图中⑤,和对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方法栈(一份),我们重载就是把后者重新设置了,自然就不需要管老实例的问题。

image.png 引用深入理解 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登陆后,操作。

后面的以后再写吧~