在前说明:好久没有更新博客了,这一年在公司做了好多事情,包括代码分析和热部署替换等黑科技,一直没有时间来进行落地写出一些一文章来,甚是可惜,趁着中午睡觉的时间补一篇介绍性的文章吧。
首先热部署的场景是这样的,公司的项目非常多,真个BU事业部的项目加起来大约上几百个项目了,有一些项目本地无法正常启动,所以一些同学在修改完代码,或者是在普通的常规任务开发过程中都是盲改,然后去公司的代码平台进行发布,恶心的事情就在这里,有的一些项目从构建到发布运行大约30分钟,所以每次修改代码到代码见效需要30分钟的周期,这个极大的降低了公司的开发效率,一旦惰性成习惯,改变起来将十分的困难,所以我们极需要一个在本地修改完代码之后,可以秒级在服务端生效的神器,这样,我们的热部署插件就诞生了。
热部署在业界本身就是一个难啃的骨头,属于逆向编程的范畴,JVM有类加载,那么热部署就要去做卸载后重新加载,Spring有上下文注册,spring Bean执行初始化生命周期,热部署就要去做类的销毁,重新初始化,里面设计到的细节点非常之多,业界的几款热部署的处理方式也不尽相同,由于需要巨大的底层细节需要处理,所以目前上想找到一个完全覆盖所有功能的热部署插件是几乎不可能的,一般大家听到的热部署插件主要是国外的一些项目比如商业版本的jrebel,开源版的springloaded,以及比较粗暴的spring dev tools。当前这些项目都是现成的复杂开源项目或者是闭包的商业项目,想去自行修改匹配自己公司的项目,难度是非常之大。闲话少说,进入正文
1、整体设计方案
2、走进agent
instrument 规范:https://docs.oracle.com/javase/8/docs/api/java/lang/instrument/package-summary.html?is-external=true
Class VirtualMachine:https://docs.oracle.com/javase/8/docs/jdk/api/attach/spec/com/sun/tools/attach/VirtualMachine.html#loadAgent-java.lang.String-
Interface ClassFileTransformer:https://docs.oracle.com/javase/8/docs/api/java/lang/instrument/ClassFileTransformer.html
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 方法 。
在命令行输入 java可以看到相应的参数,其中有 和 java agent相关的:
-agentlib:[=<选项>] 加载本机代理库 , 例如 -agentlib:hprof 另请参阅 -agentlib:jdwp=help 和 -agentlib:hprof=help -agentpath: [=<选项>] 按完整路径名加载本机代理库 -javaagent: [=<选项>] 加载 Java 编程语言代理, 请参阅 java.lang.instrument
该包提供了一些工具帮助开发人员在 Java 程序运行时,动态修改系统中的 Class 类型。其中,使用该软件包的一个关键组件就是 Javaagent。从名字上看,似乎是个 Java 代理之类的,而实际上,他的功能更像是一个Class 类型的转换器,他可以在运行时接受重新外部请求,对Class类型进行修改。
agent加载时序图
从本质上讲,Java Agent 是一个遵循一组严格约定的常规 Java 类。 上面说到 javaagent命令要求指定的类中必须要有premain()方法,并且对premain方法的签名也有要求,签名必须满足以下两种格式:
public static void premain(String agentArgs, Instrumentation inst) public static void premain(String agentArgs)
JVM 会优先加载 带 Instrumentation 签名的方法,加载成功忽略第二种,如果第一种没有,则加载第二种方法。这个逻辑在sun.instrument.InstrumentationImpl
2.2、Instrumentation类常用API
public interface Instrumentation { //增加一个Class 文件的转换器,转换器用于改变 Class 二进制流的数据,参数 canRetransform 设置是否允许重新转换。 void addTransformer(ClassFileTransformer transformer, boolean canRetransform); //在类加载之前,重新定义 Class 文件,ClassDefinition 表示对一个类新的定义, 如果在类加载之后,需要使用 retransformClasses 方法重新定义。addTransformer方法配置之后,后续的类加载都会被Transformer拦截。 对于已经加载过的类,可以执行retransformClasses来重新触发这个Transformer的拦截。类加载的字节码被修改后,除非再次被retransform,否则不会恢复。 void addTransformer(ClassFileTransformer transformer); //删除一个类转换器 boolean removeTransformer(ClassFileTransformer transformer); //是否允许对class retransform boolean isRetransformClassesSupported(); //在类加载之后,重新定义 Class。这个很重要,该方法是1.6 之后加入的,事实上,该方法是 update 了一个类。 void retransformClasses(Class>... classes) throws UnmodifiableClassException; //是否允许对class重新定义 boolean isRedefineClassesSupported(); //此方法用于替换类的定义,而不引用现有的类文件字节,就像从源代码重新编译以进行修复和继续调试时所做的那样。 //在要转换现有类文件字节的地方(例如在字节码插装中),应该使用retransformClasses。 //该方法可以修改方法体、常量池和属性值,但不能新增、删除、重命名属性或方法,也不能修改方法的签名 void redefineClasses(ClassDefinition... definitions) throws ClassNotFoundException, UnmodifiableClassException; //获取已经被JVM加载的class,有className可能重复(可能存在多个classloader) @SuppressWarnings("rawtypes") Class[] getAllLoadedClasses(); }
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方法。
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的动态性
参照:https://docs.oracle.com/javase/8/docs/technotes/guides/jpda/enhancements1.4.html
1.5开始通过JVMTI实现的java.lang.instrument (Java Platform SE 8 ) 的premain方式,实现了agent方式的动态性(JVM启动时指定agent)
参照:https://docs.oracle.com/javase/8/docs/api/java/lang/instrument/package-summary.html
1.6又增加了agentmain方式,实现了运行时动态性(通过The Attach API 绑定到具体VM)。
参照:https://blogs.oracle.com/corejavatechtips/the-attach-api
其基本实现是通过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,可以增加 删除类属性、方法,甚至改变一个类的父类、dcevm 是一个开源项目,遵从GPL 2.0、更多关于dcevm的介绍:
https://www.cnblogs.com/redcreen/archive/2011/06/03/2071169.html
https://www.slideshare.net/wangscu/hotspot-hotswap-who-and-who-are-best-freinds
https://www.cnblogs.com/redcreen/archive/2011/06/14/2080718.html
https://dl.acm.org/doi/10.1145/2076021.2048129
http://ssw.jku.at/Research/Papers/Wuerthinger11PhD/
http://ssw.jku.at/Research/Papers/Wuerthinger10a/
https://dl.acm.org/doi/10.1145/1868294.1868312
https://dl.acm.org/doi/10.1145/1890683.1890688
3、热部署技术解析
3.1、文件监听
首先会在本地和远程预定义两个目录,/var/tmp/xxx/extraClasspath和/var/tmp/xxxx/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包的热替换(方案设计中)。
核心代码赏析:
@Override public void run() { //agent启动时,后台开启一个非守护线程来监听文件的变更 runner = new Thread() { @Override public void run() { try { for (;;) { //processEvents 监听文件变更,异步调用Listener if (stopped || !processEvents()) { break; } } } catch (InterruptedException x) { } } }; runner.setDaemon(true); runner.setName("HotSwap Watcher"); runner.start(); dispatcher.start(); }
在agent启动时,会启动一个线程里面来轮询文件监听事件,以保证发现文件变更后文件监听的逻辑顺利执行,核心逻辑在方法processEvents中。
private boolean processEvents() throws InterruptedException { // wait for key to be signaled WatchKey key = watcher.poll(10, TimeUnit.MILLISECONDS); //没有文件变更时,直接返回 if (key == null) { return true; } //防止文件没有写完、下面获取到正在写入的文件。停止一会,文件无论如何也写完了。 //因为我们文件变更采用的是Watch文件监听。这里涉及到一个问题 //当java文件过大,有可能文件还没有写完全,已经被监听到了,很容易引发EOF,这里这里适当休眠一小会 //服务端测试,修改1万5000行代码写入监听无压力。 Thread.sleep ( 200 ); Path dir = keys.get(key); if (dir == null) { return true; } for (WatchEvent> event : key.pollEvents()) { WatchEvent.Kind> kind = event.kind(); if (kind == OVERFLOW) { continue; } // Context for directory entry event is the file name of entry WatchEventev = cast(event); Path name = ev.context(); //获取到当前变更的文件。 Path child = dir.resolve(name); //核心逻辑,交给时间监视器来处理 dispatcher.add(ev, child); if (kind == ENTRY_CREATE) { try { if (Files.isDirectory(child, NOFOLLOW_LINKS)) { //当文件首次监控到,需要先初始化一下监控的目录。手动调用Listener recursiveFiles(child.toFile ().getAbsolutePath (),dispatcher,ev); registerAll(child); } } catch (IOException x) { } } } boolean valid = key.reset(); if (!valid) { keys.remove(key); // all directories are inaccessible if (keys.isEmpty()) { return false; } if (classLoaderListeners.isEmpty()) { for (WatchKey k : keys.keySet()) { k.cancel(); } return false; } } return true; }
WatchKey key = watcher.poll(10, TimeUnit.MILLISECONDS);来不断收集监听事件,当发现有文件变更/新增时,通过WatchEvent来获取到文件目录和dispatcher处理器来异步处理文件变更逻辑【dispatcher.add(ev, child);】
下面再看一下事件处理器做了些什么事情
private final ArrayBlockingQueueeventQueue = new ArrayBlockingQueue<>(500); //将监听到的文件传输给队列,异步消费。 public void add(WatchEvent event, Path path) { eventQueue.offer(new Event(event, path)); } while (true) { eventQueue.drainTo(working); // work on new events. for (Event e : working) { //调用各个注册的Listener callListeners(e.event, e.path); if (Thread.interrupted()) { return; } Thread.yield(); } // crear the working queue. working.clear(); try { Thread.sleep(50); } catch (InterruptedException e1) { // TODO Auto-generated catch block return; } }
这里代码逻辑很清晰,主要是处理监听事件调用所有插件注册的Listeners,下一章说一下几个主要Listener
3.2、匹配监听逻辑
当发现文件变更时,去匹配满足条件的Listener
private void callListeners(final WatchEvent> event, final Path path) { //重复注册的listener删除掉 synchronized (EventDispatcher.class){ for (Path p:listeners.keySet ()){ if(listeners.get ( p )==null || listeners.get ( p ).size ()==0){ listeners.remove ( p ); } } } boolean matchedOne = false; for (Map.Entry> list : listeners.entrySet()) { if (path.startsWith(list.getKey())) { matchedOne = true; for (WatchEventListener listener : new ArrayList<>(list.getValue())) { WatchFileEvent agentEvent = new HotswapWatchFileEvent(event, path); try { //调用核心热加载逻辑 listener.onEvent(agentEvent); } catch (Throwable e) { // LOGGER.error("Error in watch event '{}' listener // '{}'", e, agentEvent, listener); } } } } if (!matchedOne) { LOGGER.error("无匹配 '{}', path '{}'", event, path); } }
发现当前变更的文件满足listener的匹配条件时,执行核心方法listener.onEvent(agentEvent);
我们debug看一下这些Listener都是什么。
Listener功能一览:
名称 |
功能 |
---|---|
HotswapPlugin Listener |
当class变更时触发,将最新的变更后的字节码重新reload到JVM中,触发变更流程,新增方法,字段,修改方法等内容在此处触发。spring bean重载,spring mybatis bean重载也在此处触发。 |
WatchResource Listener |
当发现class/xml等文件变更时,将变更文件URL保存到内存,当classloader.findResource等资源文件获取时,首先从变更的缓存文件中进行获取,插件中远程反编译通过此功能来实现,以保证用户侧看到的资源文件是最新变更的。 |
Spring xml Listener |
当发现spring的xml配置文件新增变更时,触发变更事件,通过XmlBeanDefinitionReader的loadBeanDefinitions方法重载xml,重新刷新spring上下文。 |
Spring bean add Listener |
当发现java bean新增时,首先判断是否是spring bean,然后将class 字节码转换成BeanDefinition对象,注册到spring上下文 |
Mybatis xml Listener |
发现xml变更时,发现是mybatis xml,触发reload mybatis事件。 |
加载流程一览
3.3、jvm class reload
JVM的字节码reload是通过HotswapPlugin Listener来实现的。
@OnClassFileEvent(classNameRegexp = ".*", events = {FileEvent.MODIFY, FileEvent.CREATE},seq = 1,name = "HotswapperPlugin for java reload") public void watchReload(CtClass ctClass, ClassLoader appClassLoader, URL url) throws IOException, CannotCompileException { // fix main start //因为只处理新增,考虑是否已经被classloader加载过。 if (!ClassLoaderHelper.isClassLoaded(appClassLoader, ctClass.getName()) && PluginManager.springbootClassLoader != null) { //PluginManager.springbootClassLoader为用户代码在agent代理时,反向持有 //这里做兼容的目的是因为spring boot项目自定义了一个classloader,而不是APPclassloader ClassLoader springbootClassLoader = PluginManager.springbootClassLoader; if(appClassLoader!=springbootClassLoader){ appClassLoader = springbootClassLoader; if(!ClassLoaderHelper.isClassLoaded(appClassLoader, ctClass.getName())){ return; } } else { return; } } // search for a class to reload Class clazz; try { //读取到旧的class字节码 clazz = appClassLoader.loadClass(ctClass.getName()); } catch (ClassNotFoundException e) { return; } synchronized (reloadMap) { reloadMap.put(clazz, ctClass.toBytecode()); } //触发JVM热加载逻辑 scheduler.scheduleCommand(hotswapCommand, 100, Scheduler.DuplicateSheduleBehaviour.SKIP); }
当发现文件变更时,会通过Listener调用watchReload方法,ctClass为新增class的ctClass对象,appClassLoader为加载当前class的classloader,url为当前新增/修改 class的URL地址路径
appClassLoader.loadClass(ctClass.getName());获取到原class对象。ctClass.toBytecode()获取到新修改之后的class二进制字节码。
public void hotswap(Map, byte[]> reloadMap) { if (instrumentation == null) { throw new IllegalStateException("Plugin manager is not correctly initialized - no instrumentation available."); } synchronized (reloadMap) { ClassDefinition[] definitions = new ClassDefinition[reloadMap.size()]; String[] classNames = new String[reloadMap.size()]; int i = 0; for (Map.Entry , byte[]> entry : reloadMap.entrySet()) { classNames[i] = entry.getKey().getName(); definitions[i++] = new ClassDefinition(entry.getKey(), entry.getValue()); } try { LOGGER.reload("重新加载class 字节码到JVM开始!!! classes {} (autoHotswap)", Arrays.toString(classNames)); synchronized (hotswapLock) { LOGGER.info ( "触发jvm redefineClasses,回调触发注册的Transformer,classes : " + Arrays.toString(classNames) ); //批量热加载 instrumentation.redefineClasses(definitions); } LOGGER.reload("重新加载class 字节码到JVM结束!!! classes {} (autoHotswap)", Arrays.toString(classNames)); } catch (Exception e) { throw new IllegalStateException("Unable to redefine classes", e); } reloadMap.clear(); } }
字节码批量重载逻辑,通过新的字节码二进制流和旧的class对象生成ClassDefinition定义,instrumentation.redefineClasses(definitions),来触发JVM重载,重载过后将触发初始化时spring插件注册的transfrom,下一章我们简单讲解一下spring是怎么重载的
3.4、spring bean重载
spring bean reload核心代码如下
public BeanDefinition resolveBeanDefinition(byte[] bytes) throws IOException { Resource resource = new ByteArrayResource(bytes); resetCachingMetadataReaderFactoryCache(); //获取到新class字节流的MetadataReader MetadataReader metadataReader = getMetadataReaderFactory().getMetadataReader(resource); //判断当前字节流对象是否是spring bean if (isCandidateComponent(metadataReader)) { ScannedGenericBeanDefinition sbd = new ScannedGenericBeanDefinition(metadataReader); sbd.setResource(resource); sbd.setSource(resource); if (isCandidateComponent(sbd)) { return sbd; } else { return null; } } else { return null; } }
熟悉spring源码的同学这里应该很清楚,参数byte[]数组封装成ByteArrayResource,通过ClassPathBeanDefinitionScanner的metadataReaderFactory的getMetadataReader获取到MetadataReader,通过ASM字节码读取class byte流来生成类MetadataReader对象,通过isCandidateComponent方法,里面通过注解分析来check MetadataReader是否是spring bean,如果不是spring bean返回spring的加载流程
synchronized (ClassPathBeanDefinitionScannerAgent.class) { // TODO sychronize on DefaultListableFactory.beanDefinitionMap? ScopeMetadata scopeMetadata = this.scopeMetadataResolver.resolveScopeMetadata(candidate); candidate.setScope(scopeMetadata.getScopeName()); String beanName = this.beanNameGenerator.generateBeanName(candidate, registry); if (candidate instanceof AbstractBeanDefinition) { postProcessBeanDefinition((AbstractBeanDefinition) candidate, beanName); } if (candidate instanceof AnnotatedBeanDefinition) { processCommonDefinitionAnnotations((AnnotatedBeanDefinition) candidate); } try { try{ nestHotSwapDestroy = new Object (); //刷新spring上下文 removeIfExists(beanName); } finally { nestHotSwapDestroy = null; } if (checkCandidate(beanName, candidate)) { BeanDefinitionHolder definitionHolder = new BeanDefinitionHolder(candidate, beanName); definitionHolder = applyScopedProxyMode(scopeMetadata, definitionHolder, registry); LOGGER.reload("重新注册spring bean核心逻辑开始, Spring bean '{}', scanner:{}, classLoader:{}", beanName, scanner.getClass().getName(), this.getClass().getClassLoader()); // 扩展点:对 BeanDefinition 做后置处理 //处理mybatis注解bean BeanDefinitionPostProcessManager.applyPostProcessAfterScanned(definitionHolder, scanner); //重新注册spring bean registerBeanDefinition(definitionHolder, registry); //维护一份spring bean依赖,防止出错时,无法回滚代码 mergeDependSpringBean(beanName); DefaultListableBeanFactory bf = maybeRegistryToBeanFactory(); if (bf != null) //重新reload controller bean //注册URL依赖 ResetRequestMappingCaches.reset(bf, beanName,isSpringBeanAdd); ProxyReplacer.clearAllProxies(); freezeConfiguration(); //重启spring bean reLoadSpringBean(); } } finally { //doto }
这代码是spring加载的核心逻辑,通过BeanDefinition bean定义,来获取beanName,removeIfExists方法首先来销毁当前bean,和dependon当前bean的spring bean对象,通过spring启动时对DefaultListableBeanFactory进行字节码增强来记录下来当前bean和所有依赖当前bean的SetName。核心agent代码如下
@OnClassLoadEvent(classNameRegexp = "org.springframework.beans.factory.support.DefaultListableBeanFactory") public static void transform(CtClass clazz, ClassPool classPool) throws NotFoundException, CannotCompileException { CtMethod method = clazz.getDeclaredMethod(PRE_INSTANTIATE_SINGLETONS); LOGGER.info ( "已经对spring boot classLoader进行增强" ); String agentString = "try {" + "java.lang.ClassLoader classLoader = org.springframework.beans.factory.support.DefaultListableBeanFactory.class.getClassLoader ();" + "java.lang.Class clazz = classLoader.loadClass ( \"org.hotswap.agent.config.PluginManager\" );" + "java.lang.reflect.Field field = clazz.getDeclaredField ( \"springbootClassLoader\" );" + "field.setAccessible ( true );" + "field.set ( null, classLoader);" + "} catch (Exception e) {" + "e.printStackTrace ();" + "}"; agentString+= "try {\n" + "\t\t\tjava.lang.ClassLoader classLoader = org.springframework.beans.factory.support.DefaultListableBeanFactory.class.getClassLoader ();\n" + "\t\t\tjava.lang.Class clazz = classLoader.loadClass ( \"org.hotswap.agent.config.PluginManager\" );\n" + "\t\t\tjava.lang.reflect.Method method = clazz.getDeclaredMethod ( \"enhanceUserClassLoader\",new java.lang.Class[0]);\n" + "\t\t\tmethod.setAccessible ( true );\n" + "\t\t\tmethod.invoke ( null, new Object[0]);\n" + "\t\t} catch (java.lang.Exception e){\n" + "\t\t\te.printStackTrace ( );\n" + "\t\t}"; agentString += "try {\n" + "\t\t\tjava.lang.ClassLoader classLoader = org.springframework.beans.factory.support.DefaultListableBeanFactory.class.getClassLoader ();\n" + "\t\t\tjava.lang.Class clazz = classLoader.loadClass ( \"org.hotswap.agent.plugin.spring.scanner.ClassPathBeanDefinitionScannerAgent\" );\n" + "\t\t\tjava.lang.reflect.Method method = clazz.getDeclaredMethod ( \"clearSpringBean\",new java.lang.Class[0]);\n" + "\t\t\tmethod.setAccessible ( true );\n" + "\t\t\tmethod.invoke ( null, new Object[0]);\n" + "\t\t} catch (java.lang.Exception e){\n" + "\t\t\te.printStackTrace ( );\n" + "\t\t}"; method.insertAfter ( agentString ); }
对agent字节码增强的小伙伴一定很熟悉上面的操作,在spring启动过程中就会对当前的字节码增强。
BeanDefinitionPostProcessManager.applyPostProcessAfterScanned(definitionHolder, scanner) 如果是spring mybatis bean,去realod mybatis的bean。
registerBeanDefinition(definitionHolder, registry) 重新注册reload的spring bean。
ResetRequestMappingCaches.reset(bf, beanName,isSpringBeanAdd),这段代码非常重要,属性spring mvc的小伙伴一定知道,对于Controller Bean来说,一般是通过@Controller注解扫描来注册spring bean的。并且会将将Controller的spring bean @RequestMapping的 URL和当前方法Method做为绑定,以便当用户通过HTTP访问项目时,需要对HTTP URL进行解析,然后获取到缓存中的spring bean来进行反射调用Controller的,所以对于业务方的MVC项目,我们需要对Controller的RequestMappingHandlerMapping进行解绑和重新绑定,这样才能支持Controller项目的URL变更/新增和Controller Bean新增等,spring mvc源码核心注册部分源码如下:
protected void registerHandlerMethod(Object handler, Method method, T mapping) { this.mappingRegistry.register(mapping, handler, method); }
protected void registerHandlerMethod(Object handler, Method method, T mapping) {
this.mappingRegistry.register(mapping, handler, method);
}
public void register(T mapping, Object handler, Method method) { this.readWriteLock.writeLock().lock(); try { HandlerMethod handlerMethod = createHandlerMethod(handler, method); assertUniqueMethodMapping(handlerMethod, mapping); if (logger.isInfoEnabled()) { logger.info("Mapped \"" + mapping + "\" onto " + handlerMethod); } this.mappingLookup.put(mapping, handlerMethod); ListdirectUrls = getDirectUrls(mapping); for (String url : directUrls) { this.urlLookup.add(url, mapping); } String name = null; if (getNamingStrategy() != null) { name = getNamingStrategy().getName(handlerMethod, mapping); addMappingName(name, handlerMethod); } CorsConfiguration corsConfig = initCorsConfiguration(handler, method, mapping); if (corsConfig != null) { this.corsLookup.put(handlerMethod, corsConfig); } this.registry.put(mapping, new MappingRegistration (mapping, handlerMethod, directUrls, name)); } finally { this.readWriteLock.writeLock().unlock(); } }
上面代码很简单,主要是spring mvc注册一些URL和Method和spring controller Bean的元数据信息。我们热替换需要做的是当发现controller bean变更时,除了修改spring bean,还有解绑、重新注册绑定mvc信息到缓存
重新绑定核心代码逻辑
Class> c = getHandlerMethodMappingClassOrNull(); if (c == null) return; Mapmappings = BeanFactoryUtils.beansOfTypeIncludingAncestors(beanFactory, c, true, false); if (mappings.isEmpty()) { LOGGER.trace("Spring: no HandlerMappings found"); } try { for (Entry e : mappings.entrySet()) { Object am = e.getValue(); LOGGER.info("Spring: clearing HandlerMapping for {}", am.getClass()); try { Field f = c.getDeclaredField("handlerMethods"); f.setAccessible(true); ((Map,?>)f.get(am)).clear(); f = c.getDeclaredField("urlMap"); f.setAccessible(true); ((Map,?>)f.get(am)).clear(); try { f = c.getDeclaredField("nameMap"); f.setAccessible(true); ((Map,?>)f.get(am)).clear(); } catch(NoSuchFieldException nsfe) { LOGGER.trace("Probably using Spring 4.0 or below", nsfe); } if (am instanceof InitializingBean) { ((InitializingBean) am).afterPropertiesSet(); } } catch(NoSuchFieldException nsfe) { LOGGER.trace("Probably using Spring 4.2+", nsfe); Method m = c.getDeclaredMethod("getHandlerMethods", new Class[0]); Class>[] parameterTypes = new Class[1]; parameterTypes[0] = Object.class; Method u = c.getDeclaredMethod("unregisterMapping", parameterTypes); Map,?> unmodifiableHandlerMethods = (Map,?>) m.invoke(am); Object[] keys = unmodifiableHandlerMethods.keySet().toArray(); CopyOnWriteArraySet controllerBean = new CopyOnWriteArraySet ( ); for (Object key : keys) { LOGGER.trace("Unregistering handler method {}", key); String needReload = isNeedReload ( unmodifiableHandlerMethods.get ( key ) ); if(needReload!=null){ controllerBean.add ( needReload ); u.invoke(am, key); } } unmodifiableHandlerMethods = null; if (isSpringBeanAdd && am.getClass().getSimpleName().equals("RequestMappingHandlerMapping") && isHandler(beanFactory,beanName, am, c)) { controllerBean.add(beanName); } //重新注册controller bean for(String bean:controllerBean){ Method detectHandlerMethods = c.getDeclaredMethod ( "detectHandlerMethods",new Class[]{Object.class} ); detectHandlerMethods.setAccessible ( true ); detectHandlerMethods.invoke ( am, bean ); } } } } catch (Exception e) { LOGGER.error("Failed to clear HandlerMappings", e); }
现在到了最后一步,我们需要手动去初始化刚刚销毁的bean
manualDestroyThriftBean (); if(concurrentSkipListSet.size ()==0) return; try { Setbeans = new HashSet<> ( ); for (String beanName:concurrentSkipListSet){ if(instances!=null && instances.size ()>0){ for(ClassPathBeanDefinitionScannerAgent classPathBeanDefinitionScannerAgent: instances.values ()){ DefaultListableBeanFactory defaultListableBeanFactory = maybeRegistryToBeanFactory ( classPathBeanDefinitionScannerAgent.registry ); if(defaultListableBeanFactory != null && defaultListableBeanFactory.containsBeanDefinition ( beanName )){ try { defaultListableBeanFactory.getBean ( beanName ); beans.add ( beanName ); } catch (Exception e){ LOGGER.info ( "重新加载spring bean 失败 bean:" + beanName,e ); } } } } } LOGGER.info(" "); LOGGER.info("\n ___ _ _ ___ ___ ___ ___ ___ \n" + " / __| | | |/ __/ __/ _ / __/ __|\n" + " \\__ | |_| | (_| (_| __\\__ \\__ \\\n" + " |___/\\__,_|\\___\\___\\___|___|___/\n" + " "); LOGGER.info(" "); LOGGER.info ( "spring load beans size:" + beans.size () ); } finally { concurrentSkipListSet.clear (); }
3.5、spring xml重载
当用户修改/新增spring xml时,需要对xml中所有bean进行重载,核心代码
public void reloadBeanFromXml(URL url) { LOGGER.info("Reloading XML file: " + url); this.reader.loadBeanDefinitions(new FileSystemResource(url.getPath())); ResetBeanPostProcessorCaches.reset(maybeRegistryToBeanFactory()); ProxyReplacer.clearAllProxies(); reloadFlag = false; }
核心代码逻辑this.reader.loadBeanDefinitions(new FileSystemResource(url.getPath()));通过BeanDefinitionReader 重新reload xml
重新reload之后,将spring 销毁后重启。
3.6、mybatis xml 重载
核心代码逻辑
public static void reloadConfiguration(URL url) { try { XmlResource mybatisResource = MybatisResourceManager.findXmlResource(url); if (mybatisResource == null) { LOGGER.info("变更 XML 不是 Mybatis XML, xmlPath:{}", url.getPath()); LOGGER.info("xmlResource classLoader:{}", XmlResource.class.getClassLoader()); return; } LOGGER.info("xmlResource objectClassLoader:{}, classLoader:{}", mybatisResource.getClass().getClassLoader(), XmlResource.class.getClassLoader()); LOGGER.info("Mybatis XML 形式热加载" + url.getPath()); mybatisResource.reload(url); } catch (Exception e) { LOGGER.error(e.getMessage(), e); } }
通过变更的xml url地址来获取spring项目启动时,保存的URL和XmlResource映射,目前mybatis xml不支持新增,只支持修改,重新reload xml来刷新配置项中的sql和其他配置信息
/** * 重新加载 xml * * @param url 要 reload 的xml */ public void reloadXML(URL url) throws Exception { XMLMapperBuilder xmlMapperBuilder = new XMLMapperBuilder(url.openConnection().getInputStream(), configuration, loadedResource, configuration.getSqlFragments()); xmlMapperBuilder.parse(); }
4、远程反编译
在代码中通过插件右键-远程反编译即可查看当前classpath下面最新编译的最新class文件,这是如何办到的的呢,核心代码如下:
agentString+= "try {\n" + "\t\t\tjava.lang.ClassLoader classLoader = org.springframework.beans.factory.support.DefaultListableBeanFactory.class.getClassLoader ();\n" + "\t\t\tjava.lang.Class clazz = classLoader.loadClass ( \"org.hotswap.agent.config.PluginManager\" );\n" + "\t\t\tjava.lang.reflect.Method method = clazz.getDeclaredMethod ( \"enhanceUserClassLoader\",new java.lang.Class[0]);\n" + "\t\t\tmethod.setAccessible ( true );\n" + "\t\t\tmethod.invoke ( null, new Object[0]);\n" + "\t\t} catch (java.lang.Exception e){\n" + "\t\t\te.printStackTrace ( );\n" + "\t\t}";
上面代码是在用户侧启动DefaultListableBeanFactory时,初始化所有bean之后完成的,在方法preInstantiateSingletons之后会对当前用户侧classloader进行反向持有+ 路径增强。
public static void enhanceUserClassLoader(){ if(springbootClassLoader != null){ LOGGER.info ( "对用户classloader进行增强,springbootClassLoader:" + springbootClassLoader ); URLClassLoaderHelper.prependClassPath ( springbootClassLoader ); LOGGER.info ( "对用户classloader进行增强成功,springbootClassLoader:" + springbootClassLoader ); } }
通过使用代码启动时反射增强classloader,下面来看看核心方法prependClassPath
public static void prependClassPath(ClassLoader classLoader){ LOGGER.info ( "用户classloader增强,classLoader:" + classLoader ); if(!(classLoader instanceof URLClassLoader)){ return; } URL[] extraClasspath = PropertiesUtil.getExtraClasspath (); prependClassPath( (URLClassLoader) classLoader,extraClasspath); }
其中URL[] extraClasspath = PropertiesUtil.getExtraClasspath ();这里获取的是用户自定义的classpath,每次新增修改class之后都会放进去最新的资源文件。
public static void prependClassPath(URLClassLoader classLoader, URL[] extraClassPath) { synchronized (classLoader) { try { Field ucpField = URLClassLoader.class.getDeclaredField("ucp"); ucpField.setAccessible(true); URL[] origClassPath = getOrigClassPath(classLoader, ucpField); URL[] modifiedClassPath = new URL[origClassPath.length + extraClassPath.length]; System.arraycopy(extraClassPath, 0, modifiedClassPath, 0, extraClassPath.length); System.arraycopy(origClassPath, 0, modifiedClassPath, extraClassPath.length, origClassPath.length); Object urlClassPath = createClassPathInstance(modifiedClassPath); ExtraURLClassPathMethodHandler methodHandler = new ExtraURLClassPathMethodHandler(modifiedClassPath); ((Proxy)urlClassPath).setHandler(methodHandler); ucpField.set(classLoader, urlClassPath); LOGGER.debug("Added extraClassPath URLs {} to classLoader {}", Arrays.toString(extraClassPath), classLoader); } catch (Exception e) { LOGGER.error("Unable to add extraClassPath URLs {} to classLoader {}", e, Arrays.toString(extraClassPath), classLoader); } } }
只需关注
URL[] origClassPath = getOrigClassPath(classLoader, ucpField);
URL[] modifiedClassPath = new URL[origClassPath.length + extraClassPath.length];
System.arraycopy(extraClassPath, 0, modifiedClassPath, 0, extraClassPath.length);
System.arraycopy(origClassPath, 0, modifiedClassPath, extraClassPath.length, origClassPath.length);这几行代码
首先获取到用户侧classloader中URLClassPath的URLS,然后在通过反射的方式将用户配置的extclasspath的路径设置到URLS数组中的首位,这样每次调用URLClassLoader的findResource方法都会获取到最新的资源文件了。
5、我们支持的功能
功能点 |
是否支持 |
---|---|
修改方法体内容 |
✅ |
新增方法体 |
✅ |
新增非静态字段 |
✅ |
新增静态字段 |
✅ |
Thrift bean变更 |
✅ |
spring bean中新增@autowired注解 |
✅ |
spring bean中新增zebra dao bean |
✅ |
spring bean中新增mafka client |
✅ |
spring bean中新增Thrfit client |
✅ |
在spring 扫描包base package下,新增带@Service的bean,并且注入 |
✅ |
新增xml |
✅ |
增加修改静态块 |
✅ |
新增修改匿名内部类 |
✅ |
新增修改继承类 |
✅ |
新增修改接口方法 |
✅ |
新增泛型方法 |
✅ |
修改 annotation sql |
✅ |
修改 xml sql |
✅ |
增加修改静态块 |
✅ |
匿名内部类新增,修改 |
✅ |
内部类新增,修改 |
✅ |
新增,删除extend父类,implement 接口 |
✅ |
父类或接口新增方法,删除方法 |
✅ |
泛型方法,泛型类 |
✅ |
多文件热部署 |
✅ |
spring boot项目 |
✅ |
war包项目 |
✅ |
其他功能迭代挖掘ing |
☺ |
6、源码交流
由于篇幅原因和文采捉急,没有办法完整的写出热部署过程中遇到的各种各样稀奇古怪和无法解释的问题,和其中的坎坷经历。更多的功能需求迭代建议和agent源码技术交流可以加入QQ群来详细交流,QQ群号:825199617