上一篇文档里,我们介绍了如何开发一个表单引擎:通过生成数据库建表语句,Java代码,并部署到spring上下文,实现表单实例CRUD API的创建。
同时我们也演示了如何在IDE中启动应用,成功创建表单信息并使用表单CRUD API来创建表单实例。
本节我们主要讨论解决表单引擎发布部署的实际问题。
正确设置编译期classpath
当我们需要部署表单引擎的时候,直接通过java -jar命令运行spring boot的jar文件,服务能正常启动,但是当我们创建表单元数据的时候,会遇到编译Java代码失败的问题。
失败的错误是:Java Compiler“找不到符号”,例如Controller中引用的ApiModel(swagger依赖),RestController注解,Entity类引用的JsonFormat(fastjson依赖),Table(mybatis依赖)等。
这是为什么呢?
- 小知识
在编译Java代码的时候,java编译器从系统变量"java.class.path"中寻找源代码中使用的类,如果找不到,就会出现“找不到符号xxx”这样的错误。
回忆上一篇文档,我们知道,Java Compiler(或者javac)在编译动态生成的Entity,Service,Mapper和Controller,需要找到这些第三方依赖的库,他们需要存在于编译器所识别的classpath中。在IDE我们运行启动类的main函数,IDE都帮我们把依赖都加入到当前classpath中了。
但是使用java -jar运行far jar的时候,classpath中就只有jdk的一些基础类和spring-boot的fat jar。
可以验证一下,在标准springboot web应用中,写一个简单的controller,打印classpath。
@GetMapping("/classpath")
public ResultResponse showClasspath() {
System.out.println("classpath: " + System.getProperty("java.class.path"));
ResultResponse resp = new ResultResponse();
return resp;
}
当在IDE中访问启动后,GET /classpath,
classpath甚至包含了本地maven仓库的地址。
现在我们通过jar包启动后,再次访问该API。
仅有该jar包本身的路径。
所以,我们在spring-boot上下文编译java代码的时候,如果不对classpath进行改写的话,编译是成功不了的。
如果我们能找到这些三方依赖的路径,在启动应用之前,把他们加入到classpath中,不就行了吗?
我们去哪里找这些三方库的依赖呢?
我们将fat jar解压(使用命令:jar xvf app.jar),能看到目录结构是这样的:
+ BOOT-INF
+ classes
+ lib
+ META-INF
- MANIFEST.MF
+ org
+ springframework
编译期间能找到的类文件,只有上面的org/springframework目录中的类,这里的类仅有如下和spring boot加载相关的基础类。
- 小知识
javac编译代码的时候,提供的classpath格可以是:
- 一个包含class的文件目录,例如target/classes
- 一个包含jar的文件目录,可以通过(jdk6以后)/lib/*来表示
- 一个jar文件
当提供的是一个jar文件的时候,从jar文件根目录开始,classpath认为是package name,下面的类是可以被识别的。
多个路径通过文件分隔符隔离,linux上是“:”,windows上是“;”。例如:mylib/xxx.jar:target/classes
far-jar中的其他目录:
BOOT-INF/classes包含我们业务代码的classes,例如我们的controller,mapper都放在这里。
BOOT-INF/lib下放置了应用依赖的其他第三方库,例如mybatis-plus,spring-boot starters等。
我们是希望classpath能加载BOOT-INF/classes下面的业务类和BOOT-INF/lib下面的jar。
可以通过如下步骤实现这个效果。
首先在部署之前,将jar文件解压,放置到指定路径。如下面的shell:
#!/usr/bin/env bash
# make sure you perform mvn clean install first
FILE=form-engine-service-boot/target/app.jar
FORM_ENGINE_ROOT=.
if [ $# -eq 2 ]; then
FILE=$1
FORM_ENGINE_ROOT=$2
echo "form engine root: $FORM_ENGINE_ROOT"
fi
echo "app.jar is at $FILE"
if [ ! -f $FILE ]; then
echo "没找到$FILE"
echo "请先执行mvn install,产生app.jar文件,或者提供一个app.jar的文件路径,例如./prepare.sh xxx/xxx/app.jar"
exit 1
fi
FILE=$(realpath $FILE)
echo "创建dist文件,用于解压app.jar"
mkdir -p $FORM_ENGINE_ROOT/dist
cd $FORM_ENGINE_ROOT/dist
echo "解压app.jar文件"
jar xf $FILE
cd -
其次,我们将解压的路径添加到classpath中。下面的方法将dir代表的路径加入到系统变量classpath中。
privatestaticbooleanaddSingleClassPath(@NotNull String dir) {
File file = new File(dir);
if (file.exists()) {
String path;
try {
path = file.getCanonicalPath();
} catch (IOException ignored) {
path = file.getAbsolutePath();
}
if (!Arrays.asList(System.getProperty(JAVA_CLASS_PATH).split(File.pathSeparator)).contains(path)) {
System.setProperty(JAVA_CLASS_PATH
, System.getProperty(JAVA_CLASS_PATH) + File.pathSeparator + path);
classpath += File.pathSeparator + path;
}
} else {
return false;
}
return true;
}
值得注意的是,虽然这里提到可以使用/lib/*的方式,把/lib文件夹中的jar都加入到classpath,但是实际上,当你使用JavaCompiler类的时候,通过options提供的“-classpath”,必须要把里面的jar都显式地提供出来才行。
正确设置classpath以后,再次通过jar包方式启动,我们就可以正常编译Java文件了。
总结
- spring boot应用通过jar包的方式启动的情况下,classpath中仅包含jar包本身。
- 要使用JavaCompiler正常编译动态生成源代码,需要修改classpath。
- 正确的classpath设置,包括以下路径:
- 解压fat jar以后,应用的业务classes文件夹,即BOOT-INF/classes
- 解压fat jar以后,BOOT-INF/lib下面的各个jar包的路径。
- 启动以后classpath类似下面的形式:
/form-engine/data/dist/BOOT-INFO/classes:/form-engine/data/dist/BOOT-INFO/lib/mybatis-plus.1.0.1.jar:/form-engine/data/dist/BOOT-INFO/lib/some-other-lib.jar:/form-engine/data/target/classes:...
正确设置运行期classpath
我们的应用重新启动以后,之前编译的Java代码能被重新加载吗?
有同学可能会认为我们在上一节设置好了classpath(把生成.class的路径放在classpath中),spring就会加载编译好的Controller,Mapper,Service和Entity。
实际上是不会的。观察到的现象就是重启应用(使用jar包方式)之后,之前能访问的表单的CRUD REST API都会返回404,实际上我们生成的表单API都丢失了。
- 小知识
在运行期一个类是不是能被找到,直接原因是当前线程的类加载器(ClassLoader)是不是能够找到这个类。
原因是spring-boot中加载业务代码类的ClassLoader比较特殊,并不会pick up我们设置的系统classpath,所以不知道去加载动态生成的class文件。
这里我们要首先了解一下spring-boot类型的web应用启动的原理。
还是从fat jar说起。
解压jar包以后,打开META-INF/MANIFEST.MF文件,我们可以看到下面的内容:
注意到Main-Class,实际启动spring-boot的Main-Class是这个属性标明的类,即JarLauncher。
JarLauncher的主要工作是定位启动jar本身的路径,并获取其中BOOT-INF/lib中jar的路径,将其加载成一个个“Archive”,完成以后,把收集到的Archive集合作为参数,动态地创建一个名为LaunchedURLClassLoader的类,该类被设置成当前线程的类加载器。如下图:
LaunchedURLClassLoader能从这些Archive中提取编译好的class文件,加载到内存中。
所以要使spring能加载我们编译的.class文件,那我们就需要在创建LaunchedURLClassLoader的时候把这些class文件的文件夹路径传给LaunchedURLClassLoader。例如:
public class CustomCpJarLauncher extends JarLauncher {
@Override
protected ClassLoader createClassLoader(URL[] urls) throws Exception {
// this.absAutogenClasspath 是我们定义好的之前动态编译的class文件的路径
URL[] nUrls = appendClasspathAsURL(urls, this.absAutogenClasspath);
return new LaunchedURLClassLoader(nUrls, getClass().getClassLoader());
}
我们要让这个类在启动jar包的早期被加载,而不是作为业务代码被加载。必须完成两件事:
- 我们的CustomCpJarLauncher必须被打包在fat jar的根目录,和org.springframework同级。
- 替换MANIFST.MF中Main-Class属性为CustomCpJarLauncher。
这两件事都没有找到比较常规的做法,org.springframework.boot maven插件不能复写MANIFEST.MF文件属性,也不支持自定义拷贝文件到jar包。
最后work的方案是使用groovy maven插件,动态修改。如下:
<plugin>
<groupId>org.codehaus.gmaven</groupId>
<artifactId>groovy-maven-plugin</artifactId>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>execute</goal>
</goals>
<configuration>
<source>
import java.io.*
import java.nio.file.*
import java.util.jar.Manifest
import java.net.URI
def uri = new File(project.build.directory, project.build.finalName + '.jar').toURI()
def fs = FileSystems.newFileSystem(URI.create("jar:${uri.toString()}"), ['create':'false'])
try {
def path = fs.getPath("/META-INF/MANIFEST.MF")
def data = Files.readAllBytes(path)
def mf = new Manifest(new ByteArrayInputStream(data))
mf.mainAttributes.putValue("Main-Class", "com.aliyun.gts.bpaas.form.engine.CustomCpJarLauncher")
def out = new ByteArrayOutputStream()
mf.write(out);
data = out.toByteArray()
Files.delete(path)
Files.write(path, data)
// copy custom launcher class to root of the resulted jar
path = fs.getPath("/BOOT-INF/classes/com/aliyun/gts/bpaas/form/engine/CustomCpJarLauncher.class")
def dest = fs.getPath("/com/aliyun/gts/bpaas/form/engine/CustomCpJarLauncher.class")
Files.createDirectories(dest.getParent())
Files.copy(path, dest)
} finally {
fs.close()
}
</source>
</configuration>
</execution>
</executions>
</plugin>
这样mvn package以后的效果是:
- 生成的MANIFEST.MF如下:
- 定制的类被拷贝到jar包的根目录:
完成以后JarLauncher就被替换成我们定制的Launcher了,从而在应用启动以后自动加载之前编译好的.class文件。
总结
- spring-boot应用中的类加载器比较特殊,需要对其进行重写,用于在应用启动早期加载类路径。
- spring-boot应用通过MANIFEST.MF文件中的Main-Class属性来指定启动的主类。
结语
至此我们就实现了一个简单的表单引擎。当然如果要实际在项目中使用,我们还需要做一些production-ready的工作。例如:
- 前端的对接工作。
- 持久化表单元数据的信息。
- 支持表单元数据的版本控制。
- 自动生成代码路径的管理。
除此之外,功能上还需要:
- 对接审批流,用于对表单流程状态的管理。
- 1主表对多子表的支持。
希望有兴趣的同学一起探讨表单引擎实现过程中值得优化和扩展的地方。