远程热部署的落地与思考-动态编译篇(远程部署是什么意思)

远程热部署(代号名称Mark42、Jarvis)是参考美团Sonic并结合转转的业务场景研发的一款热部署组件,由Java Agent与IDEA插件组成。

整个热部署全流程涉及知识范围广泛,三言两语无法描述清楚,全流程会拆分成专题的形式进行分享。本文主要选讲在落地过程中遇到的一些Sonic未提及的问题与自己的思考感悟。

通读前建议阅读美团原文:远程热部署在美团的落地实践,原文讲述到相关技术介绍、原理、实现方案等不再赘述。

1、背景

1.1 、真实工作场景

某次前后端联调时的对话(部分内容存在虚构):

H师傅:果子果子,你这接口返回的结果好像不太对啊,是不是写反了啊~

:啊,不能吧,稍等我看看哈~

H师傅:你看一下~

:woc,大于等于写反了,我改一下~

H师傅:好小子,抓紧抓紧~

:改完了,就一个符号,已经在编译部署了~

五分钟后……

:部署完了,你再看一下~

H师傅:好了,没问题了~

H师傅:果子,这里返回的文案要不要把最后一句删掉,不太通顺~

:有道理,PM同意了,我删一下~

又过了五分钟……

:编译部署完了,你在看一下~

H师傅:可以的 可以的~


原本一两分钟可以完成的工作,由于代码的改动、编译部署等待导致前后端同学各自浪费了十多分钟,极大的影响了协作效率。

如果能拥有一种“魔法”,使得后端的代码像前端一样“热更新”,那该是一件多么幸福的事情!

1.2、项目背景

作为一名业务侧的一线开发同学,一直把高优支持业务放在首位。由于业务系统相对复杂,且受限于公司架构历史原因,使得开发者在开发过程中往往都是“一次性编写”代码,等业务逻辑实现的差不多,“看”上去没问题,就部署到Docker容器中进行自测查漏补缺,当遇到极为复杂的场景,就需要进行远程Debug协助,发现问题后修改代码,再次部署,反反复复。

正因如此我们每天少不了Beetle(公司内部编译管理及发布管理轻量级效率平台)多次编译与部署的循环反复的操作,一行小小的代码改动就需要走完一整个流程才能使得代码生效,严重影响了开发自测、联调、提测的效率。

远程热部署的落地与思考-动态编译篇(远程部署是什么意思)

现有流程

面对如此“长”的流程,能否对其进行简化,尽可能的减少编译部署次数,使得修改后的代码快速生效,减少用户等待时间。

远程热部署的落地与思考-动态编译篇(远程部署是什么意思)

期望流程

2、预期目标

日常开发场景中,最大限度的帮助开发者减少代码提交、编译、部署的次数,节省因等待而造成的碎片化时间,使得开发者只需把主要精力放在编码实现,间接提升开发效率。

3、选讲问题分析

“热部署”简单讲就是Java程序运行时更新Java类文件,即JVM字节码重载,通过新的字节码二进制流和旧的Class对象生成ClassDefinition定义,同时重载或初始化Spring容器以及第三方框架,达到“不停机”状态更新

思考一个问题:新的字节码二进制流也就是字节码文件(.class 文件)从何而来呢?

无非存在两种解法:

1、本地编译Java源代码,将生成的.class文件推到远端服务器;

2、直接将Java源代码推到远端服务器,由远端服务器进行编译生成.class文件;

我们来逐一解析两种方案成本与利弊:

方案1:成本低,易实现,用户在本地先执行编译操作,通过IDEA开发工具完成,但由于IDEA工具和Maven等构建工具之间的兼容性问题,经常出现本地编译不通过的情况,当然也可以通过Maven的Install命令编译整个服务文件,但是这种方案操作时间长,不人性化。其次还存在潜在的安全性问题:本地开发Jdk环境与服务器Jdk环境不一致等。

远程热部署的落地与思考-动态编译篇(远程部署是什么意思)

本地编译失败

方案2:难度系数高,实现复杂,但却是更优解。首先由用户将修改后的Java源代码推到远端服务器,由远端服务器进行动态编译生成.class文件,整个过程对用户透明。

问题:

①极多数服务都是Springboot – Fat Jar(将一个Jar及其依赖的三方Jar全部打到一个包中,这个包即为FatJar)这种结构方式。想要动态编译则需要从ClassLoader中恢复classpath,但Springboot – Fat Jar是一个整体的jar包,恢复出来的路径不合法(Url转换成File不存在),这就导致动态编译时找不到代码中引用的各种类。

远程热部署的落地与思考-动态编译篇(远程部署是什么意思)

Fat-Jar

②LomBok依赖丢失问题:Lombok主要是在编译.class文件期间,生成Get/Set/Hash/Equals/ToString等方法,使实体对象更简洁,所以像Lombok这样的依赖只作用于编译阶段,编译完成就没用了,对于有“代码洁癖”的同学会选择从依赖Jar包里排除掉。这样子可能会导致我们修改、新增实体类时动态编译失败,找不到依赖。

Maven如下配置:

<dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.6</version> <scope>provided</scope></dependency> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <excludes> <exclude> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </exclude> </excludes> </configuration> </plugin> </plugins>

③动态编译时ClassLoader的处理。美团热部署作者龙哥说:所有的远程和本地执行不一致的问题,百分之99在ClassLoader的问题上找。动态编译需兼容目前公司现有服务类型以及后续可能存在类型。

公司内部Java服务类型分为三种:SpringBoot服务、SCF服务、ZZJava服务,不同服务类型打包方式不同。

SpringBoot:Spring Boot服务,LaunchedURLClassLoader加载依赖资源

SCF服务:历史SCF(内部RPC)框架内嵌Spring服务模式,服务启动前需解压服务所有依赖,得到绝对路径后作为-classpath参数,通过AppClassLoader加载依赖资源(查看完整启动命令足有2-3W字符,可怕)

ZZJava服务:基于SpringBoot自定义的一种项目结构、打包及启动、停止标准,依旧为Spring Boot服务,通过LaunchedURLClassLoader加载依赖资源

4、方案选择

方案1:每次打包Docker镜像时添加Dockerfile命令,解压服务Jar包到指定位置,获得BOOT-INF绝对路径,并在JVM启动命令中添加绝对路径参数,服务运行时可取得BOOT-INF绝对路径,并将其作为options -classpath参数调用getTask方法编译代码。

CompilationTask getTask(Writer out, JavaFileManager fileManager, DiagnosticListener<? super JavaFileObject> diagnosticListener, Iterable<String> options, Iterable<String> classes, Iterable<? extends JavaFileObject> compilationUnits);

实现方案虽简单,但目前架构不支持自定义Dockerfile命令,无法做到通用解压服务,且依然无法解决像Lombok、Mapstruct等问题,某些情景下编译还会报错,可用性低。

方案2:解决Fatjar模式下的动态编译

思考一下SpringBoot服务为什么可以读取Fatjar的资源

一句话描述可以总结为SpringBoot自定义了URL Handler处理逻辑,将嵌套的jar转换为URL,通过URLClassLoader的addURL方法添加获取资源,完整细节可以翻阅SpringBoot源码查看。

public URL getUrl() throws MalformedURLException { if (this.url == null) { String file = this.rootFile.getFile().toURI() this.pathFromRoot "!/"; file = file.replace("file:////", "file://"); // Fix UNC paths // 这里返回的时候 new了一个Handler来处理URL this.url = new URL("jar", "", -1, file, new Handler(this)); } return this.url; }

Handler继承了URLStreamHandler,重写了openConnection方法来处理获取JarURLConnection,最终通过JarURLConnection的getInputStream方法返回字节流。

@Override protected URLConnection openConnection(URL url) throws IOException { if (this.jarFile != null && isUrlInJarFile(url, this.jarFile)) { return JarURLConnection.get(url, this.jarFile); } try { return JarURLConnection.get(url, getRootJarFileFromUrl(url)); } catch (Exception ex) { return openFallbackConnection(url, ex); } }

我们回到URLClassLoader,URLClassLoader重写了findClass方法,通过双亲委托加载资源

protected Class<?> findClass(final String name) throws ClassNotFoundException { final Class<?> result; try { result = AccessController.doPrivileged( new PrivilegedExceptionAction<Class<?>>() { public Class<?> run() throws ClassNotFoundException { String path = name.replace('.', '/').concat(".class"); // 这里调用URLClassPath的getResource方法 Resource res = ucp.getResource(path, false); if (res != null) { try { return defineClass(name, res); } catch (IOException e) { throw new ClassNotFoundException(name, e); } catch (ClassFormatError e2) { if (res.getDataError() != null) { e2.addSuppressed(res.getDataError()); } throw e2; } } else { return null; } } }, acc); } catch (java.security.PrivilegedActionException pae) { throw (ClassNotFoundException) pae.getException(); } if (result == null) { throw new ClassNotFoundException(name); } return result; }

最终调用到URLClassPath的getResource方法

Resource getResource(final String name, boolean check) { final URL url; try { url = new URL(base, ParseUtil.encodePath(name, false)); } catch (MalformedURLException e) { throw new IllegalArgumentException("name"); } final URLConnection uc; try { if (check) { URLClassPath.check(url); } // 这里就会调用到URLStreamHandler的openConnection方法 uc = url.openConnection(); InputStream in = uc.getInputStream(); if (uc instanceof JarURLConnection) { /* Need to remember the jar file so it can be closed * in a hurry. */ JarURLConnection juc = (JarURLConnection)uc; boolean firstLoad = jarfile == null; jarfile = JarLoader.checkJar(juc.getJarFile()); if (firstLoad && JarLoadEvent.isEnabled()) { Tooling.notifyEvent(JarLoadEvent.jarLoadEvent(url, jarfile)); } } } catch (Exception e) { return null; } return new Resource() { public String getName() { return name; } public URL getURL() { return url; } public URL getCodeSourceURL() { return base; } public InputStream getInputStream() throws IOException { //JarURLConnection的getInputStream方法 return uc.getInputStream(); } public int getContentLength() throws IOException { return uc.getContentLength(); } }; }

既然SpringBoot已经帮我们处理好Fatjar的资源读取,我们将直接复用其能力获取加载的资源。

5、探索实践

Agent启动时,通过字节码增强Spring框架。在Spring框架初始化时获取其ClassLoader并反射存储到Agent全局静态字段(SpringBoot服务为LaunchedURLClassLoader,SCF服务为AppClassLoader)。当触发动态编译时(Agent运行期),针对于SpringBoot服务,我们将复用SpringBoot解析Fatjar的这个能力,通过LaunchedURLClassLoader获取完整的URL资源,通过URL解析来得到JavaFileObject,从而完成动态编译。

针对于缺失的Lombok、Mapstruct等依赖以及自定义添加的jar包,我们可以手动添加URL资源。

public DynamicCompiler(ClassLoader userClassLoader) { if (javaCompiler == null) { throw new IllegalStateException("Can not load JavaCompiler from javax.tools.ToolProvider#getSystemJavaCompiler(), please confirm the application running in JDK not JRE."); } standardFileManager = javaCompiler.getStandardFileManager(null, null, null); options.add("-Xlint:unchecked"); options.add("-g"); List<URL> urlList = new ArrayList<>(); //添加自定义jar资源 urlList.addAll(getCustomJarUrl()); //获取userClassLoader加载的资源(SpringBoot服务 LaunchedURLClassLoader) urlList.addAll(getClassLoaderUrl(userClassLoader)); // 向上查找父类 ClassLoader appClassLoader = getAppClassLoader(userClassLoader); //DynamicClassLoader同样继承URLClassLoader dynamicClassLoader = new DynamicClassLoader(urlList.toArray(new URL[0]), appClassLoader); }

解析URL获取JavaFileObject

private List<JavaFileObject> processJar(URL packageFolderURL) { List<JavaFileObject> result = new ArrayList<>(); try { String jarUri = packageFolderURL.toExternalForm().substring(0, packageFolderURL.toExternalForm().lastIndexOf("!/")); JarURLConnection jarConn = (JarURLConnection) packageFolderURL.openConnection(); String rootEntryName = jarConn.getEntryName(); if (StringUtils.isBlank(rootEntryName)){ return new ArrayList<>(); } int rootEnd = rootEntryName.length() 1; Enumeration<JarEntry> entryEnum = jarConn.getJarFile().entries(); while (entryEnum.hasMoreElements()) { JarEntry jarEntry = entryEnum.nextElement(); String name = jarEntry.getName(); if (name.startsWith(rootEntryName) && name.indexOf('/', rootEnd) == -1 && name.endsWith(CLASS_FILE_EXTENSION)) { URI uri = URI.create(jarUri "!/" name); String binaryName = name.replaceAll("/", "."); binaryName = binaryName.replaceAll(CLASS_FILE_EXTENSION "$", ""); result.add(new CustomJavaFileObject(binaryName, uri)); } } } catch (Exception e) { throw new RuntimeException("Wasn't able to open " packageFolderURL " as a jar file", e); } return result; }

动态编译获取字节码

public Map<String, byte[]> buildGetByteCodes() { errors.clear(); warnings.clear(); JavaFileManager fileManager = new DynamicJavaFileManager(standardFileManager, dynamicClassLoader); DiagnosticCollector<JavaFileObject> collector = new DiagnosticCollector<>(); JavaCompiler.CompilationTask task = javaCompiler.getTask(null, fileManager, collector, options, null, compilationUnits); try { if (!compilationUnits.isEmpty()) { boolean result = task.call(); if (!result || collector.getDiagnostics().size() > 0) { for (Diagnostic<? extends JavaFileObject> diagnostic : collector.getDiagnostics()) { switch (diagnostic.getKind()) { case NOTE: case MANDATORY_WARNING: case WARNING: warnings.add(diagnostic); break; case OTHER: case ERROR: default: errors.add(diagnostic); break; } } if (!errors.isEmpty()) { return new HashMap<>(); } } } return dynamicClassLoader.getByteCodes(); } catch (ClassFormatError e) { throw new DynamicCompilerException(e, errors); } finally { compilationUnits.clear(); } }

Mapstruct编译过程较为特殊,首先会根据接口生成接口的实现类,进而生成字节码,getJavaFileForOutput方法需要根据kind类型判断一下,不能忽略SOURCE类型,不然会导致Mapstruct接口的字节码文件里存储的是实现类的Java代码,进而导致JVM的字节码重载错误。

@Override public JavaFileObject getJavaFileForOutput(Location location, String className, JavaFileObject.Kind kind, FileObject sibling) { if (JavaFileObject.Kind.SOURCE.equals(kind)) { // 源码 for (StringSource stringSource : this.sourceCodes) { if (stringSource.getClassName().equals(className)) { return stringSource; } } StringSource stringSource = new StringSource(className); sourceCodes.add(stringSource); //这里可以存一下动态生成的源代码,编译完成后输出到文件夹 // classLoader.registerCompiledSource(stringSource); return stringSource; } else { // 字节码 for (MemoryByteCode byteCode : this.byteCodes) { if (byteCode.getClassName().equals(className)) { return byteCode; } } MemoryByteCode innerClass = new MemoryByteCode(className); byteCodes.add(innerClass); classLoader.registerCompiledSource(innerClass); return innerClass; } }

注:动态编译时一定要添加-g参数生成完整调试信息,不然热部署代码Debug会发现方法栈内变量没有名字、Jacoco布尔数组透出、slot对不上等问题。(坑了我半年多一直没发现原因)

做完以上动作,你就可以任意的动态编译Java源代码,得到字节码文件了。

到这就完成了远程热部署准备工作了。

6、总结与展望

经常被问到做热部署的夙愿是什么:

远程热部署的初心不是代替掉Beetle发布部署流程,而是尽可能减少用户编译部署次数,节省用户碎片化的时间,希望可以做到一次部署,“任意”修改

初版交互图:

远程热部署的落地与思考-动态编译篇(远程部署是什么意思)

部分功能UI交互展示图:

远程热部署的落地与思考-动态编译篇(远程部署是什么意思)


目前Mark42已经支持以下功能

框架/功能状态远程热部署✅远程动态编译✅热部署代码远程Debug✅远程Agent日志✅远程服务日志✅批量热部署✅IDEA插件集成✅修改方法体内容✅新增方法体✅新增泛型方法✅新增非静态字段✅新增修改静态字段✅新增修改继承类✅新增修改接口方法✅新增修改匿名内部类✅新增修改静态块✅FastJson✅Jackson✅Jdk代理✅Spring✅Spring MVC✅Avenger✅Fsmx状态机✅ZZMQ✅MyBatis✅Mapstruct✅XXL-JOB✅SCF✅……


热部署还有很长的路要走,跟美团Sonic相比,这仅仅是刚开始

ToDoList:

框架/功能状态Configuration配置bean支持已支持、测试中xml文件配置bean支持已支持、测试中SCF Agent级别调用待开发远程单测支持待开发远程反编译待开发究极体 Spring loader替换dcevm待开发

关于作者

谭金果 22届校招生,现任转转B2C技术部供应链后端研发工程师

来源:微信公众号:转转技术

出处:https://mp.weixin.qq.com/s/5yZaKr9pf2mncHWrYRAmhQ

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

(0)
上一篇 2024年7月8日 下午5:42
下一篇 2024年7月8日 下午5:53

相关推荐