likes
comments
collection
share

一种简单的热部署方式结合动态编译实现java文件热替换

作者站长头像
站长
· 阅读数 15

前言

日常开发中常常遇到这样的场景:

1、测试环境查证问题,想加日志来辅助查证,之前人们都是使用塞包的方式。使用这种方式还得重启,一些应用启动耗时也比较久。

2、测试环境联调,一些数据测试环境不一定造的出来,需要后台写死,这种如果采用提交代码的方式,复杂程度大,也容易被带上线,风险高。

如何解决:

一、初级阶段:

采用代码热更新的方式,这种如果只是涉及一些添加日志,或者在方法内部做一些微小的改动,那么可以采用jvm提供的Instrumentation接口,通过该接口可以实现初级的热部署。

例如:

public static void agentmain(String agentArgs, Instrumentation inst) {

   // 从 agentArgs 获取外部参数
   System.out.println("开始热更新代码");
   String path = agentArgs;
   System.out.println("路径为:" + path);
   PrintStream out;
   try {

       RandomAccessFile f = new RandomAccessFile(path, "r");
       final byte[] bytes = new byte[(int) f.length()];
       f.readFully(bytes);
       final String clazzName = readClassName(bytes);

       // 加载
       for (Class clazz : inst.getAllLoadedClasses()) {

           //System.out.println("========hotswap=========" + clazz.getName());

           if (clazz.getName().equals(clazzName)) {

               ClassDefinition definition = new ClassDefinition(clazz, bytes);
               inst.redefineClasses(definition);
           }
       }

   } catch (UnmodifiableClassException | IOException | ClassNotFoundException e) {
       System.out.println("热更新数据失败");
   }
}
二、进阶阶段

一般情况下使用初级阶段就可以解决大部分问题,但是有一些比较老旧的代码可能开发人员本地编译环境都没有,没有办法生成class文件,那么这种时候就需要引入动态编译。

动态编译的原理是采用JavaCompiler的方式来实现内存编译。核心思想就是利用java的编译命令去编译。

例如

javac -classpath "a.jar;b.jar;c.jar" Test.java   -- windows下的写法
javac -classpath "a.jar:b.jar:c.jar" Test.java   -- Linux下的写法,区别在于分隔符一个是;y一个是:

使用代码实现核心逻辑:

/**
* 编译java文件
* @param javaContent 要编译的内容
* @param jarPath 依赖jar包路径
* @param saveClassPath 编译后的.class文件保存路径
*/
public String compileJava(String javaContent, String jarPath, String saveClassPath, String className) {

  try {
      // 创建java编译器实例
      JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
      // 创建对象用于获取编译输出信息
      DiagnosticCollector<JavaFileObject> diagnosticCollector = new DiagnosticCollector<>();
      // 获得JavaFileManager文件管理器对象,用于管理需要编译的文件
      StandardJavaFileManager fileManager = compiler.getStandardFileManager(diagnosticCollector, null, null);
      // 生成一个JavaFileObject对象,表示需要编译的源文件
      GenericJavaFileObject fileObject = new GenericJavaFileObject(className, javaContent);
      // 获取该工程下所有的jar文件。
      String importPagePathLogin = getJarFiles(jarPath);

      // 编译选项,在编译java文件时,编译程序会自动的去寻找java文件引用的其他的java源文件或者class。
      // -sourcepath选项就是定义java源文件的查找目录, -classpath选项就是定义class文件的查找目录,-d就是编译文件的输出目录。
      Map<String, String> cacheMap = Config.getConfig().getsysConfig();
      String isSave = cacheMap.get("isSave");
      Iterable<String> options = null;
      if ("true".equals(isSave)) {
          options = Arrays.asList("-d", saveClassPath, "-classpath", importPagePathLogin);
      } else {
          options =  Arrays.asList( "-classpath", importPagePathLogin);
      }
      //Iterable<String> options = Arrays.asList("-d", saveClassPath, "-classpath", importPagePathLogin);
      //Iterable<String> options = Arrays.asList( "-classpath", importPagePathLogin);
      // 将java文件转化为list
      Iterable<? extends JavaFileObject> fileObjects = Collections.singletonList(fileObject);

      // 获取编译任务(1、第一个参数为文件输出,这里我们可以不指定,我们采用javac命令的-d参数来指定class文件的生成目录
      // 2、第二个参数为文件管理器实例
      // 3、DiagnosticCollector<JavaFileObject> diagnostics是在编译出错时,存放编译错误信息
      // 4、第四个参数为编译命令选项,就是javac命令的可选项,这里我们主要使用了-d和-sourcepath这两个选项
      // 5、第五个参数为类名称
      // 6、第六个参数为上面提到的编译单元,就是我们需要编译的java源文件)
      JavaCompiler.CompilationTask task = compiler.getTask(null, fileManager, diagnosticCollector, options, null, fileObjects);
      // 编译
      Boolean result = task.call();

      if (result) {
          System.out.println("编译成功");
          return "success";
      } else {
          System.out.println("编译失败");
          // 编译失败,打印错误信息
          StringBuilder errorInfo = new StringBuilder();
          for (Diagnostic<?> diagnostic : diagnosticCollector.getDiagnostics()) {
              errorInfo.append("编译错误。 Code:").append(diagnostic.getCode())
                      .append("\r\n").append("Kind:").append(diagnostic.getKind())
                      .append("\r\n").append("StartPosition:").append(diagnostic.getStartPosition())
                      .append("\r\n").append("EndPosition:").append(diagnostic.getEndPosition())
                      .append("\r\n").append("Position:").append(diagnostic.getPosition())
                      .append("\r\n").append("Source:").append(diagnostic.getSource())
                      .append("\r\n").append("Message:").append(diagnostic.getMessage(null))
                      .append("\r\n").append("ColumnNumber:").append(diagnostic.getColumnNumber())
                      .append("\r\n").append("LineNumber:").append(diagnostic.getLineNumber());
          }
          System.out.println("错误信息如下:" + errorInfo);
          return errorInfo.toString();
      }

  } catch(Exception e) {

      e.printStackTrace();
  }
  return null;
}

这样就可以实现动态根据java文件生成class文件,那么再结合初级阶段的热更新方式基本上就可以解决日常开发中常见的问题了。

注意:

动态编译依赖的jar包需要在编译的时候加载进来,我的实现方式是,动态编译不在本地编译而是去测试环境编译,因为测试环境的依赖包肯定是完整的。所以我加载依赖包的路径指定的是容器发布目录下的lib文件夹中的所有文件。