从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主表对多子表的支持。

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

目录
相关文章
|
10月前
|
数据采集 数据可视化 数据挖掘
利用Python自动化处理Excel数据:从基础到进阶####
本文旨在为读者提供一个全面的指南,通过Python编程语言实现Excel数据的自动化处理。无论你是初学者还是有经验的开发者,本文都将帮助你掌握Pandas和openpyxl这两个强大的库,从而提升数据处理的效率和准确性。我们将从环境设置开始,逐步深入到数据读取、清洗、分析和可视化等各个环节,最终实现一个实际的自动化项目案例。 ####
1686 10
|
小程序 前端开发 JavaScript
微信小程序结合PWA技术,提供离线访问、后台运行、桌面图标及原生体验,增强应用性能与用户交互。
微信小程序结合PWA技术,提供离线访问、后台运行、桌面图标及原生体验,增强应用性能与用户交互。开发者运用Service Worker等实现资源缓存与实时推送,利用Web App Manifest添加快捷方式至桌面,通过CSS3和JavaScript打造流畅动画与手势操作,需注意兼容性与性能优化,为用户创造更佳体验。
522 0
|
机器学习/深度学习 Kubernetes 算法框架/工具
ONNX 与容器化:实现端到端的 ML 管道自动化
【8月更文第27天】在现代机器学习 (ML) 工作流程中,模型的训练、转换、部署和管理通常涉及多个步骤和技术栈。Open Neural Network Exchange (ONNX) 提供了一种统一的方式来表示和交换机器学习模型,而容器化技术(如 Docker 和 Kubernetes)则为部署和管理这些模型提供了灵活且可扩展的方式。本文将探讨如何结合 ONNX 和容器化技术来构建端到端的 ML 管道自动化系统。
306 1
|
12月前
|
算法 安全 网络安全
使用 Python 实现 RSA 加密
使用 Python 实现 RSA 加密
337 2
|
12月前
|
物联网 C#
【C#】简单的蓝牙通讯功能实现
【C#】简单的蓝牙通讯功能实现
571 0
|
监控 安全 数据挖掘
Python自动化交易
【8月更文挑战第7天】随着科技发展,自动化交易成为高效智能的投资方式。Python因其实用性和灵活性,在此领域大放异彩。本文介绍使用Python进行自动化交易的流程,包括获取市场数据、制定交易策略、执行交易、风险管理、监控与优化、实时监控及通知、心态管理、安全与隐私保护以及持续学习与优化等方面,并提供了具体的代码示例。通过这些步骤,读者可以构建自己的自动化交易系统,实现稳健的投资回报。
|
12月前
ChatGPT高效提问—prompt基础
ChatGPT高效提问—prompt基础
248 0
|
计算机视觉
【已解决】cv2.imread读取中文名称图片报错或者无法保存中文名图片:使用cv2.imdecode与cv2.imencode解决
【已解决】cv2.imread读取中文名称图片报错或者无法保存中文名图片:使用cv2.imdecode与cv2.imencode解决
|
缓存 网络协议 Ubuntu
DHCP的开源实现及其在不同Linux发行版上的安装过程
DHCP的开源实现及其在不同Linux发行版上的安装过程
506 0
|
数据采集 设计模式 自然语言处理
设计模式最佳套路2 —— 愉快地使用管道模式
管道模式(Pipeline Pattern) 是责任链模式(Chain of Responsibility Pattern)的常用变体之一。在管道模式中,管道扮演着流水线的角色,将数据传递到一个加工处理序列中,数据在每个步骤中被加工处理后,传递到下一个步骤进行加工处理,直到全部步骤处理完毕。 PS:纯的责任链模式在链上只会有一个处理器用于处理数据,而管道模式上多个处理器都会处理数据。
12949 0
设计模式最佳套路2 —— 愉快地使用管道模式