从0开始开发一个表单引擎(下)

简介: 上一篇文档里,我们介绍了如何开发一个表单引擎:通过生成数据库建表语句,Java代码,并部署到spring上下文,实现表单实例CRUD API的创建。同时我们也演示了如何在IDE中启动应用,成功创建表单信息并使用表单CRUD API来创建表单实例。本节我们主要讨论解决表单引擎发布部署的实际问题。

上一篇文档里,我们介绍了如何开发一个表单引擎:通过生成数据库建表语句,Java代码,并部署到spring上下文,实现表单实例CRUD API的创建。

同时我们也演示了如何在IDE中启动应用,成功创建表单信息并使用表单CRUD API来创建表单实例。

本节我们主要讨论解决表单引擎发布部署的实际问题。

正确设置编译期classpath

当我们需要部署表单引擎的时候,直接通过java -jar命令运行spring bootjar文件,服务能正常启动,但是当我们创建表单元数据的时候,会遇到编译Java代码失败的问题。

失败的错误是:Java Compiler“找不到符号,例如Controller中引用的ApiModelswagger依赖),RestController注解,Entity类引用的JsonFormatfastjson依赖),Tablemybatis依赖)等。

这是为什么呢?

  • 小知识

在编译Java代码的时候,java编译器从系统变量"java.class.path"中寻找源代码中使用的类,如果找不到,就会出现找不到符号xxx”这样的错误。

回忆上一篇文档,我们知道,Java Compiler(或者javac)在编译动态生成的EntityServiceMapperController,需要找到这些第三方依赖的库,他们需要存在于编译器所识别的classpath中。在IDE我们运行启动类的main函数,IDE都帮我们把依赖都加入到当前classpath中了。

但是使用java -jar运行far jar的时候,classpath中就只有jdk的一些基础类和spring-bootfat 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

image.png

classpath甚至包含了本地maven仓库的地址。

现在我们通过jar包启动后,再次访问该API

image.png

仅有该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加载相关的基础类。

image.png

  • 小知识

javac编译代码的时候,提供的classpath格可以是:

  1. 一个包含class的文件目录,例如target/classes
  2. 一个包含jar的文件目录,可以通过(jdk6以后)/lib/*来表示
  3. 一个jar文件

当提供的是一个jar文件的时候,从jar文件根目录开始,classpath认为是package name,下面的类是可以被识别的。

多个路径通过文件分隔符隔离,linux上是“:”windows上是“;”。例如:mylib/xxx.jar:target/classes

 

far-jar中的其他目录:

BOOT-INF/classes包含我们业务代码的classes,例如我们的controllermapper都放在这里。

BOOT-INF/lib下放置了应用依赖的其他第三方库,例如mybatis-plusspring-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都显式地提供出来才行。

参考:https://stackoverflow.com/questions/1563909/how-to-set-classpath-when-i-use-javax-tools-javacompiler-compile-the-source

image.png

正确设置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就会加载编译好的ControllerMapperServiceEntity

实际上是不会的。观察到的现象就是重启应用(使用jar包方式)之后,之前能访问的表单的CRUD REST API都会返回404,实际上我们生成的表单API都丢失了。

  • 小知识

在运行期一个类是不是能被找到,直接原因是当前线程的类加载器(ClassLoader)是不是能够找到这个类。

原因是spring-boot中加载业务代码类的ClassLoader比较特殊,并不会pick up我们设置的系统classpath,所以不知道去加载动态生成的class文件。

这里我们要首先了解一下spring-boot类型的web应用启动的原理。

还是从fat jar说起。

解压jar包以后,打开META-INF/MANIFEST.MF文件,我们可以看到下面的内容:

image.png

注意到Main-Class,实际启动spring-bootMain-Class是这个属性标明的类,即JarLauncher

JarLauncher的主要工作是定位启动jar本身的路径,并获取其中BOOT-INF/libjar的路径,将其加载成一个个“Archive”,完成以后,把收集到的Archive集合作为参数,动态地创建一个名为LaunchedURLClassLoader的类,该类被设置成当前线程的类加载器。如下图:

image.png

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包的早期被加载,而不是作为业务代码被加载。必须完成两件事:

  1. 我们的CustomCpJarLauncher必须被打包在fat      jar的根目录,和org.springframework同级。
  2. 替换MANIFST.MFMain-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以后的效果是:

  1. 生成的MANIFEST.MF如下:

image.png

  1. 定制的类被拷贝到jar包的根目录:

image.png

完成以后JarLauncher就被替换成我们定制的Launcher了,从而在应用启动以后自动加载之前编译好的.class文件。

总结

  • spring-boot应用中的类加载器比较特殊,需要对其进行重写,用于在应用启动早期加载类路径。
  • spring-boot应用通过MANIFEST.MF文件中的Main-Class属性来指定启动的主类。

结语

至此我们就实现了一个简单的表单引擎。当然如果要实际在项目中使用,我们还需要做一些production-ready的工作。例如:

  1. 前端的对接工作。
  2. 持久化表单元数据的信息。
  3. 支持表单元数据的版本控制。
  4. 自动生成代码路径的管理。

除此之外,功能上还需要:

  1. 对接审批流,用于对表单流程状态的管理。
  2. 1主表对多子表的支持。

希望有兴趣的同学一起探讨表单引擎实现过程中值得优化和扩展的地方。

目录
相关文章
|
7月前
|
前端开发 JavaScript
“构建高效的前端表单验证与增删改功能实现“
“构建高效的前端表单验证与增删改功能实现“
28 0
|
11月前
|
前端开发 API 容器
关于我对表单设计的一点思考—自动化生成表单
关于我对表单设计的一点思考—自动化生成表单
132 0
|
JavaScript 前端开发 编译器
第三十九章 构建数据库应用程序 - 将数据绑定到表单
第三十九章 构建数据库应用程序 - 将数据绑定到表单
|
前端开发
3.26前端作业-表格表单的综合应用
3.26前端作业-表格表单的综合应用
63 0
3.26前端作业-表格表单的综合应用
|
SQL IDE 前端开发
从0开始开发一个表单引擎(上)
介绍如何通过一张数据库表中字段的相关信息,生成一系列CRUD的api。
928 1
从0开始开发一个表单引擎(上)
|
IDE Java fastjson
从0开始开发一个表单引擎(下)
上一篇文档里,我们介绍了如何开发一个表单引擎:通过生成数据库建表语句,Java代码,并部署到spring上下文,实现表单实例CRUD API的创建。 同时我们也演示了如何在IDE中启动应用,成功创建表单信息并使用表单CRUD API来创建表单实例。 本节我们主要讨论解决表单引擎发布部署的实际问题。
154 0
从0开始开发一个表单引擎(下)
|
前端开发
模板驱动表单学习
模板驱动表单学习
97 0
模板驱动表单学习
|
JSON 移动开发 数据格式
强大的移动端表单开发方案 @alitajs/dform
强大的移动端表单开发方案 @alitajs/dform
314 0
强大的移动端表单开发方案 @alitajs/dform
html+css实战31-表单-应用场景
html+css实战31-表单-应用场景
71 0
html+css实战31-表单-应用场景
|
JSON 运维 JavaScript
《前端那些事》从0到1开发动态表单
前沿:中后台应用中表单需求颇多,左手一个表单,右手又是一个表单,无穷无尽,如果用模版一个个来写,不单写起来费时费力,而且看起来也是天花乱坠,于是这个时候你会去设想,那有没有什么方式可以去替换琐碎的手写表单模版的方式呢?让表单是“配出来”的,而不是撸出来的,让你轻松解决 form 表单,也不再为表单而烦恼。答案就是:动态表单
254 0
《前端那些事》从0到1开发动态表单