企业级java增量热部署解决方案

前言
在前说明:好久没有更新博客了,这一年在公司做了好多事情,包括代码分析和热部署替换等黑科技,一直没有时间来进行落地写出一些一文章来,甚是可惜,趁着中午睡觉的时间补一篇介绍性的文章吧。
首先热部署的场景是这样的,公司的项目非常多,真个BU事业部的项目加起来大约上几百个项目了,有一些项目本地无法正常启动,所以一些同学在修改完代码,或者是在普通的常规任务开发过程中都是盲改,然后去公司的代码平台进行发布,恶心的事情就在这里,有的一些项目从构建到发布运行大约30分钟,所以每次修改代码到代码见效需要30分钟的周期,这个极大的降低了公司的开发效率,一旦惰性成习惯,改变起来将十分的困难,所以我们极需要一个在本地修改完代码之后,可以秒级在服务端生效的神器,这样,我们的热部署插件就诞生了。
热部署在业界本身就是一个难啃的骨头,属于逆向编程的范畴,JVM有类加载,那么热部署就要远程桌面去做卸载后重新加载,Spring有上下文注册,spring Bean执行初始化生命周期,热部署就要去做类的销毁,重新初始化,里面设计到的细节点非常之多,业界的几款热部署的处理方式也不尽相同,由于需要巨大的底层细节需要处理,所以目前上想找到一个完全覆盖所有功能的热部署插件是几乎不可能的,一般大家听到的热部署插件主要是国外的一些项目比如商业版本的jrebel,开源版的springloaded,以及比较粗暴的spring dev tools。当前这些项目都是现成的复杂开源项目或者是闭包的商业项目,想去自行修改匹配自己公司的项目,难度是非常之大。闲话少说,进入正文
前言一:什么是热部署
所谓热部署,就是在应用正在运行的时候升级软件,却不需要重新启动应用。对于Java应用程序来说,热部署就是在运行时更新Java类文件,同时触发spring的一些列重新加载过程。在这个过程中不需要重新启动,并且修改的代码实时生效
前言二:为什么我们需要热部署
程序员每天本地重启服务5-12次,单次大概3-8分钟,每天向Cargo部署3-5次,单次时长20-45分钟,部署频繁频次高、耗时长。插件提供的本地和远程热部署功能可让将代码变更秒级生效,RD日常工作主要分为开发自测和联调两个场景,下面分别介绍热部署在每个场景中发挥的作用:
前言三:热部署难在哪,为什么业界没有好用的开源工具
热部署不等同于热重启,像tomcat或者spring boot tool dev这种热重启相当于直接加载项目,性能较差,增量文件热部署难度很大,需要兼容各种中间件和用户写法,技术门槛高,需要对JPDA(Java Platform Debugger Architecture)、java agent、字节码增强、classloader、spring框架、Mybatis框架等集成解决方案等各种技术原理深入了解才能全面支持各种框架,另外需要IDEA插件开发能力,形成整体的产品解决方案。现在有了热部署,代码就是任人打扮的小姑娘!
前言四:为什么我们不用spring boot devtools
有一些朋友问我,为什么不直接使用spring boot devtools,有两方面原因吧,第一它仅仅只使用在spring boot项目中,对于普通的java项目以及spring xml项目是不支持的,最主要的第二点它的热加载方案实际上和tomcat热加载是一样的,只不过它的热加载通过嵌套classloader的方式来完成,这个classloader每次只加载class file变更的class二进制文件,这样就会来带一个问题,在非常庞大的项目面前(启动大约10min+)这种情况,它就显得很苍白。这归根结底的原因是在于他的reload范围实在是太大了,对于一些小项目还可以,但是一些比较庞大的项目实际使用效果还是非常感人的。
1、整体设计方案
2.1、JVM启动前静态Instrument
Javaagent是java命令的一个参数。参数 javaagent 可以用于指定一个 jar 包,并且对该 java 包有2个要求:
这个 jar 包的 MANIFEST.MF 文件必须指定 Premain-Class 项。
Premain-Class 指定的那个类必须实现 premain() 方法。
premain 方法,从字面上理解,就是运行在 main 函数之前的的类。当Java 虚拟机启动时,在执行 main 函数之前,JVM 会先运行-javaagent所指定 jar 包内 Premain-Class 这个类的 premain 方法 。
2.3、instrument原理:
instrument的底层实现依赖于JVMTI(JVM Tool Interface),它是JVM暴露出来的一些供用户扩展的接口集合,JVMTI是基于事件驱动的,JVM每执行到一定的逻辑就会调用一些事件的回调接口(如果有的话),这些接口可以供开发者去扩展自己的逻辑。JVMTIAgent是一个利用JVMTI暴露出来的接口提供了代理启动时加载(agent on load)、代理通过attach形式加载(agent on attach)和代理卸载(agent on unload)功能的动态库。而instrument agent可以理解为一类JVMTIAgent动态库,别名是JPLISAgent(Java Programming Language Instrumentation Services Agent),也就是专门为java语言编写的插桩服务提供支持的代理。
2.3.1、启动时加载instrument agent过程:
创建并初始化 JPLISAgent;
监听 VMInit 事件,在 JVM 初始化完成之后做下面的事情:
创建 InstrumentationImpl 对象 ;
监听 ClassFileLoadHook 事件 ;
调用 InstrumentationImpl 的loadClassAndCallPremain方法,在这个方法里会去调用 javaagent 中 MANIFEST.MF 里指定的Premain-Class 类的 premain 方法 ;
解析 javaagent 中 MANIFEST.MF 文件的参数,并根据这些参数来设置 JPLISAgent 里的一些内容。
2.3.2、运行时加载instrument agent过程:
通过 JVM 的attach机制来请求目标 JVM 加载对应的agent,过程大致如下:
创建并初始化JPLISAgent;
解析 javaagent 里 MANIFEST.MF 里的参数;
创建 InstrumentationImpl 对象;
监听 ClassFileLoadHook 事件;
调用 InstrumentationImpl 的loadClassAndCallAgentmain方法,在这个方法里会去调用javaagent里 MANIFEST.MF 里指定的Agent-Class类的agentmain方法。
【企业级java增量热部署解决方案】2.3.3、Instrumentation的局限性
大多数情况下,我们使用Instrumentation都是使用其字节码插桩的功能,或者笼统说就是类重定义(Class Redefine)的功能,但是有以下的局限性:
premain和agentmain两种方式修改字节码的时机都是类文件加载之后,也就是说必须要带有Class类型的参数,不能通过字节码文件和自定义的类名重新定义一个本来不存在的类。
类的字节码修改称为类转换(Class Transform),类转换其实最终都回归到类重定义Instrumentation#redefineClasses()方法,此方法有以下限制:
新类和老类的父类必须相同;
新类和老类实现的接口数也要相同,并且是相同的接口;
新类和老类访问符必须一致。 新类和老类字段数和字段名要一致;
新类和老类新增或删除的方法必须是private static/final修饰的;
可以修改方法体。
除了上面的方式,如果想要重新定义一个类,可以考虑基于类加载器隔离的方式:创建一个新的自定义类加载器去通过新的字节码去定义一个全新的类,不过也存在只能通过反射调用该全新类的局限性。
2.4、那些年JVM和Hotswap之间的相爱相杀
围绕着method body的hotSwap JVM一直在进行改进
1.4开始JPDA引入了hotSwap机制(JPDA Enhancements),实现了debug时的method body的动态性1.5开始通过JVMTI实现的java.lang.instrument (Java Platform SE 8 ) 的premain方式,实现了agent方式的动态性(JVM启动时指定agent)1.6又增加了agentmain方式,实现了运行时动态性(通过The Attach API 绑定到具体VM)。其基本实现是通过JVMTI的retransformClass/redefineClass进行method body级的字节码更新,ASM、CGLib之类基本都是围绕这些在做动态性。
但是针对Class的hotSwap一直没有动作(比如Class添加method,添加field,修改继承关系等等),为什么?因为复杂度高并且没有太高的回报。
2.5、如何解决Instrumentation的局限性
由于JVM限制,JDK7和JDK8都不允许都改类结构,比如新增字段,新增方法和修改类的父类等,这对于spring项目来说是致命的,假设小龚同学想修改一个spring bean,新增了一个@Autowired字段,这种场景在实际应用时很多,所以我们对这种场景的支持必不可少。
那么我们是如何做到的呢,下面有请大名鼎鼎的dcevm,dcevm(DynamicCode Evolution Virtual Machine)是java hostspot的补丁(严格上来说是修改),允许(并非无限制)在运行环境下修改加载的类文件.当前虚拟机只允许修改方法体(method bodies),decvm,可以增加 删除类属性、方法
3、热部署技术解析
3.1、文件监听
热部署启动时首先会在本地和远程预定义两个目录,/var/tmp/xxx/extraClasspath和/var/tmp/xxx/classes,extraClasspath为我们自定义的拓展classpath url,classes为我们监听的目录,当有文件变更时,通过idea插件来部署到远程/本地,触发agent的监听目录,来继续下面的热加载逻辑,为什么我们不直接替换用户的classPath下面的资源文件呢,因为业务方考虑到war包的api项目,和spring boot项目,都是以jar包来启动的,这样我们是无法直接修改用户的class文件的,即使是用户项目我们可以修改,直接操作用户的class,也会带来一系列的安全问题,所以我们采用了拓展classPath url来实现文件的修改和新增,并且有这么一个场景,多个业务侧的项目引入了相同的jar包,在jar里面配置了mybatis的xml和注解,这种情况我们没有办法直接来修改jar包中源文件,通过拓展路径的方式可以不需要关注jar包来修改jar包中某一文件和xml,是不是很炫酷,同理这种方法可以进行整个jar包的热替换(方案设计中)。下面简单介绍一下核心监听器
3.2、jvm class reload
JVM的字节码批量重载逻辑,通过新的字节码二进制流和旧的class对象生成ClassDefinition定义,instrumentation.redefineClasses(definitions),来触发JVM重载,重载过后将触发初始化时spring插件注册的transfrom,下一章我们简单讲解一下spring是怎么重载的。
新增class我们如何保证可以加载到classloader上下文中?由于项目在远程执行,所以运行环境复杂,有可能是jar包方式启动(spring boot),也有可能是普通项目,也有可能是war web项目,针对这种情况我们做了一层classloader url拓展User classLoader是框架自定义的classLoader统称,例如Jetty项目是WebAppclassLoader,其中Urlclasspath为当前项目的lib文件件下,例如spring boot项目也是从当前项目中BOOT-INF/lib/,等等,不同框架的自定义位置稍有不同。所以针对这种情况 我们必须拿到用户的自定义classloader,如果常规方式启动的,比如普通spring xml项目借助plus发布,这种没有自定义classloader,是默认AppClassLoader,所以我们在用户项目启动过程中借助agent字节码增强的方式来获取到真正的用户classloader。
我们做的事情:找到用户使用的子classloader之后通过反射的方式来获取classloader中的元素Classpath,其中classPath中的URL就是当前项目加载class时需要的所有运行时class环境,并且包括三方的jar包依赖等。
我们获取到URL数组,把我们自定义的拓展classpath目录加入到URL数组的首位,这样当有新增class时,我们只需要将class文件放到拓展classpath对应的包目录下面即可,当有其他bean依赖新增的class时,会从当前目录下面查找类文件。
为什么不直接对Appclassloader进行加强?而是对框架的自定义classloader进行加强考虑这样一个场景,框架自定义类加载器中有ClassA,然后这个时候用户新增了一个Class B需要热加载,B class里面有A的引用关系,如果我们增强AppClassLoader时,初始化B实例时ClassLoader.loadclass首先从UserClassLoader开始找classB,依靠双亲委派原则,B是被Appclassloader加载的,因为B依赖了类A,所以当前AppClassLoader加载B一定是找不到的,这个时候汇报ClassNotFoundException。也就是说我们对类加载器拓展一定要拓展最上层的类加载器,这样才会达到我们想要的效果。
3.3、spring bean重载
spring bean reload过程中,bean的销毁和重启流程,其中细节点涉及的比较多。首先当修改java class D时,通过spring classpathScan扫描校验当前修改的bean是否是spring bean(注解校验)然后触发销毁流程(BeanDefinitionRegistry.removeBeanDefinition)此方法会将当前spring 上下文中的 bean D 和依赖 spring bean D的 Bean C 一并销毁,但是作用范围仅仅在当前spring 上下文,若C被子上下文中的Bean B 依赖,是无法更新子上下文中的依赖关系的,此时,当有流量打进来,Bean B中关联的Bean C还是热部署之前的对象,所以热部署失败,所以我们在spring初始化过程中,需要维护一个父子上下文的对应关系,当子上下文变时若变更范围涉及到Bean B时,需要重新更新子上下文中的依赖关系,所以当有多上下文关联时需要维护多上下文环境,并且当前上下文环境入口需要reload。入口指:spring mvc controller,Mthrift和pigeon,对不同的流量入口,我们采用不同的reload策略。RPC框架入口主要操作为解绑注册中心,重新注册,重新加载启动流程等,对Spring mvc controller主要是解绑和注册url Mappping来实现流量入口类的变化切换
3.4、spring xml重载
当用户修改/新增spring xml时,需要对xml中所有bean进行重载
重新reload之后,将spring 销毁后重启。
注意:xml修改方式改动较大,可能涉及到全局的Aop的配置以及前置和后置处理器相关的内容,影响范围为全局,所以目前只放开普通的xml bean标签的新增/修改,其他能力酌情逐步放开。

    推荐阅读