远程热部署(代号名称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如下配置:
org.projectlombok
lombok
1.18.6
provided
org.springframework.boot
spring-boot-maven-plugin
org.projectlombok
lombok
③动态编译时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>() {
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 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 processJar(URL packageFolderURL) {
List 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 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 buildGetByteCodes() {
errors.clear();
warnings.clear();
JavaFileManager fileManager = new DynamicJavaFileManager(standardFileManager, dynamicClassLoader);
DiagnosticCollector 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