远程热部署的落地与思考-动态编译篇

远程热部署(代号名称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如下配置:

xml 复制代码
<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方法编译代码。

dart 复制代码
    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源码查看。

kotlin 复制代码
    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方法返回字节流。

kotlin 复制代码
    @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方法,通过双亲委托加载资源

java 复制代码
    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方法

java 复制代码
        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资源。

csharp 复制代码
    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

ini 复制代码
    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;
    }

动态编译获取字节码

java 复制代码
    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的字节码重载错误。

csharp 复制代码
    @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技术部供应链后端研发工程师

> 转转研发中心及业界小伙伴们的技术学习交流平台,定期分享一线的实战经验及业界前沿的技术话题。

> 关注公众号「转转技术」(综合性)、「大转转FE」(专注于FE)、「转转QA」(专注于QA),更多干货实践,欢迎交流分享~

相关推荐
程序员阿鹏2 分钟前
SpringBoot自动装配原理
java·开发语言·spring boot·后端·spring·tomcat·maven
Andy工程师2 分钟前
一个接口可以有多个实现类
java
程序员爱钓鱼3 分钟前
Node.js 编程实战:CSV&JSON &Excel 数据处理
前端·后端·node.js
老华带你飞9 分钟前
工会管理|基于springboot 工会管理系统(源码+数据库+文档)
java·数据库·vue.js·spring boot·后端·spring
自在极意功。9 分钟前
MyBatis配置文件详解:environments、transactionManager与dataSource全面解析
java·数据库·tomcat·mybatis
爱吃土豆的马铃薯ㅤㅤㅤㅤㅤㅤㅤㅤㅤ14 分钟前
配置springdoc swagger开关
java
Echo flower17 分钟前
Spring Boot WebFlux 实现流式数据传输与断点续传
java·spring boot·后端
没有bug.的程序员23 分钟前
微服务中的数据一致性困局
java·jvm·微服务·架构·wpf·电商
鸽鸽程序猿27 分钟前
【Redis】Java客户端使用Redis
java·redis·github
悦悦子a啊27 分钟前
使用 Java 集合类中的 LinkedList 模拟栈以此判断字符串是否是回文
java·开发语言