在web应用开发或者游戏服务器开发的过程中,我们时时刻刻都在使用热部署。热部署的目的很简单,就是为了节省应用开发和发布的时间。比如,我们在使用Tomcat或者Jboss等应用服务器开发应用时,我们经常会开启热部署功能。热部署,简单点来说,就是我们将打包好的应用直接替换掉原有的应用,不用关闭或者重启服务器,一切就是这么简单。那么,热部署到底是如何实现的呢?在本文中,我将写一个实例,这个实例就是一个容器应用,允许用户发布自己的应用,同时支持热部署。
在Java中,要实现热部署,首先,你得明白,Java中类的加载方式。每一个应用程序的类都会被ClassLoader加载,所以,要实现一个支持热部署的应用,我们可以对每一个用户自定义的应用程序使用一个单独的ClassLoader进行加载。然后,当某个用户自定义的应用程序发生变化的时候,我们首先销毁原来的应用,然后使用一个新的ClassLoader来加载改变之后的应用。而所有其他的应用程序不会受到一点干扰。先看一下,该应用的设计图:
有了总体实现思路之后,我们可以想到如下几个需要完成的目标:
1、定义一个用户自定义应用程序的接口,这是因为,我们需要在容器应用中去加载用户自定义的应用程序。
2、我们还需要一个配置文件,让用户去配置他们的应用程序。
3、应用启动的时候,加载所有已有的用户自定义应用程序。
4、为了支持热部署,我们需要一个监听器,来监听应用发布目录中每个文件的变动。这样,当某个应用重新部署之后,我们就可以得到通知,进而进行热部署处理。
实现部分:
首先,我们定义一个接口,每一个用户自定义的程序中都必须包含唯一一个实现了该接口的类。代码如下:
- public interface IApplication {
-
- public void init();
-
- public void execute();
-
- public void destory();
-
- }
在这个例子中,每一个用户自定义的应用程序,都必须首先打包成一个jar文件,然后发布到一个指定的目录,按照指定的格式,然后首次发布的时候,还需要将应用的配置添加到配置文件中。所以,首先,我们需要定义一个可以加载指定目录jar文件的类:
- public ClassLoader createClassLoader(ClassLoader parentClassLoader, String... folders) {
-
- List jarsToLoad = new ArrayList();
- for (String folder : folders) {
- List jarPaths = scanJarFiles(folder);
-
- for (String jar : jarPaths) {
-
- try {
- File file = new File(jar);
- jarsToLoad.add(file.toURI().toURL());
-
- } catch (MalformedURLException e) {
- e.printStackTrace();
- }
- }
- }
-
- URL[] urls = new URL[jarsToLoad.size()];
- jarsToLoad.toArray(urls);
-
- return new URLClassLoader(urls, parentClassLoader);
- }
这个方法很简单,就是从多个目录中扫描jar文件,然后返回一个新的URLClassLoader实例。至于scanJarFiles方法,你可以随后下载本文的源码。然后,我们需要定义一个配置文件,用户需要将他们自定义的应用程序信息配置在这里,这样,该容器应用随后就根据这个配置文件来加载所有的应用程序:
- <apps>
- <app>
- <name> TestApplication1name >
- <file> com.ijavaboy.app.TestApplication1file >
- app>
- <app>
- <name> TestApplication2name >
- <file> com.ijavaboy.app.TestApplication2file >
- app>
- apps>
这个配置是XML格式的,每一个app标签就表示一个应用程序,每一个应用程序,需要配置名称和那个实现了IApplication接口的类的完整路径和名称。
有了这个配置文件,我们需要对其进行解析,在这个例子中,我使用的是xstream,很简单,你可以下载源码,然后看看就知道了。这里略过。这里需要提一下:每个应用的名称(name),是至关重要的,因为该例子中,我们的发布目录是整个项目发布目录下的applications目录,这是所有用户自定义应用程序发布的目录。而用户发布一个应用程序,需要首先在该目录下新建一个和这里配置的name一样名称的文件夹,然后将打包好的应用发布到该文件夹中。(你必须这样做,否则在这个例子中,你会发布失败)。
好了,现在加载jar的方法和配置都有了,下面将是整个例子的核心部分,对,就是应用程序管理类,这个类就是要完成对每一个用户自定义应用程序的管理和维护。首先要做的,就是如何加载一个应用程序:
- public void createApplication(String basePath, AppConfig config){
- String folderName = basePath + GlobalSetting. JAR_FOLDER + config.getName();
- ClassLoader loader = this.jarLoader .createClassLoader(ApplicationManager. class.getClassLoader(), folderName);
-
- try {
- Class> appClass = loader. loadClass(config.getFile());
-
- IApplication app = (IApplication)appClass.newInstance();
-
- app.init();
-
- this.apps .put(config.getName(), app);
-
- } catch (ClassNotFoundException e) {
- e.printStackTrace();
- } catch (InstantiationException e) {
- e.printStackTrace();
- } catch (IllegalAccessException e) {
- e.printStackTrace();
- }
可以看到,这个方法接收两个参数,一个是基本路径,一个是应用程序配置。基本路径其实就是项目发布目录的地址,而AppConfig其实就是配置文件中app标签的一个实体映射,这个方法从指定的配置目录中加载指定的类,然后调用该应用的init方法,完成用户自定义应用程序的初始化。最后将,该加载的应用放入内存中。
现在,所有的准备工作,都已经完成了。接下来,在整个应用程序启动的时候,我们需要加载所有的用户自定义应用程序,所以,我们在ApplicationManager中添加一个方法:
- public void loadAllApplications(String basePath){
-
- for(AppConfig config : this.configManager.getConfigs()){
- this.createApplication(basePath, config);
- }
- }
这个方法,就是将用户配置的所有应用程序加载到该容器应用中来。好了,现在我们是不是需要写两个独立的应用程序试试效果了,要写这个应用程序,首先我们新建一个java应用程序,然后引用这个例子项目,或者将该例子项目打包成一个jar文件,然后引用到这个独立的应用中来,因为这个独立的应用程序中,必须要包含一个实现了IApplication接口的类。我们来看看这个例子包含的一个独立应用的样子:
- public class TestApplication1 implements IApplication{
-
- @Override
- public void init() {
- System. out.println("TestApplication1-->init" );
- }
-
- @Override
- public void execute() {
- System. out.println("TestApplication1-->do something" );
- }
-
- @Override
- public void destory() {
- System. out.println("TestApplication1-->destoryed" );
- }
-
- }
是不是很简单?对,就是这么简单。你可以照这个样子,再写一个独立应用。接下来,你还需要在applications.xml中进行配置,很简单,就是在apps标签中增加如下代码:
- <app>
- <name> TestApplication1name >
- <file> com.ijavaboy.app.TestApplication1file >
- app>
接下来,进入到本文的核心部分了,接下来我们的任务,就全部集中在热部署上了,其实,也许现在你还觉得热部署很神秘,但是,我相信一分钟之后,你就不会这么想了。要实现热部署,我们之前说过,需要一个监听器,来监听发布目录applications,这样当某个应用程序的jar文件改变时,我们可以进行热部署处理。其实,要实现目录文件改变的监听,有很多种方法,这个例子中我使用的是apache的一个开源虚拟文件系统——common-vfs。如果你对其感兴趣,你可以访问 http://commons.apache.org/proper/commons-vfs/
。这里,我们继承其FileListener接口,实现
fileChanged
即可:
- public void fileChanged (FileChangeEvent event) throws Exception {
-
- String ext = event.getFile().getName().getExtension();
- if(!"jar" .equalsIgnoreCase(ext)){
- return;
- }
-
- String name = event.getFile().getName().getParent().getBaseName();
-
- ApplicationManager. getInstance().reloadApplication(name);
-
当某个文件改变的时候,该方法会被回调。所以,我们在这个方法中调用了ApplicationManager的reloadApplication方法,重现加载该应用程序。
- public void reloadApplication (String name){
- IApplication oldApp = this.apps .remove(name);
-
- if(oldApp == null){
- return;
- }
-
- oldApp.destory();
-
- AppConfig config = this.configManager .getConfig(name);
- if(config == null){
- return;
- }
-
- createApplication(getBasePath(), config);
重现加载应用程序时,我们首先从内存中删除该应用程序,然后调用原来应用程序的destory方法,最后按照配置重新创建该应用程序实例。
到这里,你还觉得热部署很玄妙很高深吗?一切就是如此简单。好了,言归正传,为了让我们自定义的监听接口可以有效工作起来,我们还需要指定它要监听的目录:
- public void initMonitorForChange(String basePath){
- try {
- this.fileManager = VFS.getManager();
-
- File file = new File(basePath + GlobalSetting.JAR_FOLDER);
- FileObject monitoredDir = this.fileManager .resolveFile(file.getAbsolutePath());
- FileListener fileMonitorListener = new JarFileChangeListener();
- this.fileMonitor = new DefaultFileMonitor(fileMonitorListener);
- this.fileMonitor .setRecursive(true);
- this.fileMonitor .addFile(monitoredDir);
- this.fileMonitor .start();
- System. out.println("Now to listen " + monitoredDir.getName().getPath());
-
- } catch (FileSystemException e) {
- e.printStackTrace();
- }
- }
这里,就是初始化监听器的地方,我们使用VFS的DefaultFileMonitor完成监听。而监听的目录,就是应用发布目录applications。接下来,为了让整个应用程序可以持续的运行而不会结束,我们修改下启动方法:
- public static void main(String[] args){
-
- Thread t = new Thread(new Runnable() {
-
- @Override
- public void run() {
- ApplicationManager manager = ApplicationManager.getInstance();
- manager.init();
- }
- });
-
- t.start();
-
- while(true ){
- try {
- Thread. sleep(300);
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- }
- }
好了,到这里,一切都要结束了。现在,你已经很明白热部署是怎么一回事了,对吗?不明白?OK,还有最后一招,去看看源码吧!
源码我已经放到了GitHub上面了,地址: https://github.com/chenjie19891104/ijavaboy/tree/master/AppLoader,欢迎下载使用,你拥有一切的权利对其进行修改。
二、一个新的热更新的框架
(1)重新加载修改过的脚本文件
/**
* 重新加载所有被修改过的脚本
*/
public static void reloadAllModifiedScript() {
if (!reloadMutexLock.compareAndSet(false, true)) {
return;
}
try {
//待编译的java源文件集合
List compilationUnits = new ArrayList<>();
//遍历脚本目录,加载所有被修改过的java源文件
forEachJavaFile((file, attrs) -> {
try {
String absFilePath = file.toAbsolutePath().toString();
ScriptFile sf = SCRIPT_FILES.get(absFilePath);
if (sf != null) {
if (sf.getLastModifiedTime() == attrs.lastModifiedTime().toMillis()) {
//没有修改过的就直接返回
return;
}
}
//加载java文件源码
String sourceCode = new String(Files.readAllBytes(file));
String sourceName = file.getFileName().toString();
//生成java源码对象
JavaSourceObject sourceObj = new JavaSourceObject(sourceName, sourceCode);
compilationUnits.add(sourceObj);
if (sf != null) {
//更新最近修改时间
sf.setLastModifiedTime(attrs.lastModifiedTime().toMillis());
} else {
//脚本文件信息对象
sf = new ScriptFile();
//文件名
sf.setName(file.toAbsolutePath().toString());
//最近修改时间
sf.setLastModifiedTime(attrs.lastModifiedTime().toMillis());
SCRIPT_FILES.put(sf.getName(), sf);
}
} catch (Exception e) {
e.printStackTrace();
}
});
if (compilationUnits.isEmpty()) {
//没有需要编译的脚本
return;
}
//编译脚本
List list = ScriptCompiler.compile(compilationUnits);
List scriptNames = new ArrayList();
//扫描并增强编译后的脚本
list.forEach(s -> {
if (!s.isScan()) {
scanAndEnhanceScriptBytes(s);
}
if (s.isScriptHandler()) {
scriptNames.add(s.getName().replace('.', '/'));
}
});
//查找所有存在依赖关系的脚本
list.forEach(s -> {
searchAllDependScripts(s, scriptNames);
});
//注册脚本
regScripts(scriptNames);
} catch (Exception e) {
e.printStackTrace();
} finally {
reloadMutexLock.set(false);
}
}
(2)重新加载所有的脚本文件
/**
* 重新加载所有脚本
*/
public static void reloadAllScript() {
if (!reloadMutexLock.compareAndSet(false, true)) {
return;
}
try {
//待编译的java源文件集合
List compilationUnits = new ArrayList<>();
//遍历脚本目录,加载所有java源文件
forEachJavaFile((file, attrs) -> {
try {
//加载java文件源码
String sourceCode = new String(Files.readAllBytes(file));
String sourceName = file.getFileName().toString();
//生成java源码对象
JavaSourceObject sourceObj = new JavaSourceObject(sourceName, sourceCode);
compilationUnits.add(sourceObj);
//脚本文件信息对象
ScriptFile sf = new ScriptFile();
//文件名
sf.setName(file.toAbsolutePath().toString());
//最近修改时间
sf.setLastModifiedTime(attrs.lastModifiedTime().toMillis());
SCRIPT_FILES.put(sf.getName(), sf);
} catch (Exception e) {
e.printStackTrace();
}
});
if (compilationUnits.isEmpty()) {
//没有需要编译的脚本
return;
}
//编译脚本
List list = ScriptCompiler.compile(compilationUnits);
List scriptNames = new ArrayList();
//扫描并增强编译后的脚本
list.forEach(s -> {
if (!s.isScan()) {
scanAndEnhanceScriptBytes(s);
}
if (s.isScriptHandler()) {
scriptNames.add(s.getName().replace('.', '/'));
}
});
//注册脚本
regScripts(scriptNames);
} catch (Exception e) {
e.printStackTrace();
} finally {
reloadMutexLock.set(false);
}
}
/**
* 注册脚本
*/
public static void regScripts(List scriptNames) {
try {
//根据脚本名,动态生成MasterHandler.class
byte[] bytes = MasterHandlerBuilder.builder(scriptNames);
ScriptBytes sb = new ScriptBytes(MasterHandlerBuilder.CLASS_NAME, bytes);
ScriptCompiler.addScriptBytes(sb);
//加载MasterHandler.class并执行main()方法
HotswapClassLoader loader = new HotswapClassLoader(SCRIPT_ROOT_PATH.toString(), ClassLoader.getSystemClassLoader());
Class> clazz = loader.loadClass(MasterHandlerBuilder.CLASS_NAME);
//执行MasterHandler的main()方法
MethodType mt = MethodType.methodType(void.class, String[].class);
MethodHandle mh = MethodHandles.publicLookup().findStatic(clazz, "main", mt);
if (mh != null) {
mh.invokeExact(new String[0]);
}
loader.close();
} catch (Throwable e) {
e.printStackTrace();
}
}
重点注明:1、java类的加载方式是每一个类都会被classloader加载,也就是说很多的类共一个classloader来加载。上面的热更新方式就是每次扫描所有的类文件,变成java的字节码框架bytes,然后表示string,由string生成java源码object,然后对对象进行编译,编译完后通过classloader注册。
2、jrebel实现热更的方式就是给每个类配一个new classloader,这样的话,当有类修改时,就找到对应的classloader,删掉原来的应用,new 一个classloader,重新加载修改的那个类。因为每个类都有一个classloader,所以当前类的重新加载不会影响到其他类。
3、classloader的特性:旧的classloader默认只加载原来的类文件,当类文件修改时,需要new一个新的classloader来加载。
重点注明:其实一个已经加载的类是无法被更新的,如果你试图用同一个ClassLoader再次加载同一个类,就会得到异常(java.lang.LinkageError: duplicate classdefinition),我们只能够重新创建一个新的ClassLoader实例来再次加载新类。至于原来已经加载的类,开发人员不必去管它,因为它可能还有实例正在被使用,只要相关的实例都被内存回收了,那么JVM就会在适当的时候把不会再使用的类卸载。
4、Classloader 类加载器,用来加载 Java 类到 Java 虚拟机中。与普通程序不同的是。Java程序(class文件)并不是本地的可执行程序。当运行Java程序时,首先运行JVM(Java虚拟机),然后再把Java class加载到JVM里头运行,负责加载Java class的这部分就叫做Class Loader。
类的加载过程:.java----bytes(字节码文件)----string-----javaSourceObject(源码对象)----compile编译成.class文件-----classloader将.class文件加载到JVM虚拟机中进行运行。
java是先运行JVM虚拟机,然后再把.class放到虚拟机中进行运行。
5、类加载机制
类的生命周期
加载——
验证——准备——解析
——初始化——使用——卸载
加载,验证,准备,初始化和卸载这5个阶段的顺序是确定的,类的加载过程按照这种顺序按部就班地开始,而解析阶段则不一定;有时候它可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定.
类加载的五个阶段
加载——验证——准备——解析——初始化
加载阶段就是找到类的静态存储结构并加载到虚拟机,然后转换成方法区的运行时数据结构,生成class对象的过程.加载阶段用户可以自定义类加载器参与进来.
验证阶段主要是确保字节码是合法的,确保不会对虚拟机安全造成危害,可以通过-Xverify:none禁用一些验证.
准备阶段是确定内存布局,初始化类变量,注意是赋初始值,不会执行程序自己定义的赋值操作,譬如 private static int count=12; 这里的 count 会初始化为0,而不是12.
解析阶段是将符号引用变成直接应用。
初始化阶段才是调用程序自定义代码,譬如
private static int count=12 这里的count 会设置为12,初始化阶段会生成()方法,这个方法由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生,同一个类加载器下,一个类型只会初始化一次。
Java虚拟机没有强制约束何时开始初始化阶段,但规定了有且只有5种情况必须立刻进行初始化,当然在初始化之前 加载,验证,准备 已经开始.
初始化的时机
1.遇到new,getstatic,pustaic,invokstatic 这4条字节码指令时,如果类没有初始化,则需要触发初始化.这里要注意 final修饰的类字段,会在编译期把结果放入常量池,所以即便调用也不会触发初始化.
2.使用java.lang.reflect包的方法对类进行反射调用时,如果类没有初始化,则需要先初化.
3.当初始化一个类时,如果发现其父类还没有进行过初始化,则需要先触发其父类初始化.
4.虚拟机启动时,用户需要制定一个要执行的主类,虚拟机会先初始化这个主类.
5.使用jdk1.7动态语言支持时,如果java.lang.invoke.MethodHandle实例最后解析结果是REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄对应的类没有初始化,则需要先初始化.