☰
Spring Boot + MyBatis + JavaFX三件套集成实践:从架构到避坑全解析
2026/10/15 17:07:08 网站建设 项目流程

简介:这是一套将SpringBoot、MyBatis与JavaFX整合的档案管理扫描功能项目源码,适合正在学习Java桌面端与后端服务整合、持久层映射的开发者作为综合实践参考。压缩包共含130个文件,核心为65个xml配置与MyBatis映射文件、24个java业务及控制器源码、28个class编译产物,另有FXML界面布局、CSS美化样式、Properties工程配置及少量依赖jar,整体体积仅146KB,下载与查阅都很方便。目前已有4207人学习下载,内容精炼,具有一定的参考价值。项目结构完整,清晰展示了SpringBoot自动配置与内嵌服务器机制、MyBatis通过SQL映射解耦数据库操作、JavaFX构建响应式桌面GUI,并借助Oracle数据库完成档案数据的存储、查询与事务管理。读者可以从中看到一个桌面应用从界面交互到数据库落地的完整链路,尤其适合模仿其分层设计、配置组织方式以及前后端调用逻辑。

1. 这个三件套组合到底在解决什么问题

我接到过不少这样的需求:客户手里有一套老旧的 Swing 桌面管理系统,界面丑、报表逻辑和数据库访问全揉在一个类里,改一个查询要重新打包发版。后来换过一条路:用浏览器做前端,后端挂 Spring Boot,但客户是内部办公场景,数据敏感、网络受限,IT 部门明确要求数据不能出内网,浏览器方案又涉及部署和维护浏览器服务。最后落地的方案就是 Spring Boot + MyBatis + JavaFX:JavaFX 负责桌面端界面和交互,Spring Boot 在同一个进程里掌管业务逻辑和对象装配,MyBatis 负责 SQL 持久化。三件套的核心价值在于:既保留桌面客户端的离线操作能力和部署简单性,又能享受 Spring 生态的依赖注入、事务管理和 MyBatis 的 SQL 可控性,适合中小型内部管理系统、运维工具、图像处理 Demo 这类重数据录入和查询的项目。

这套组合的适用人群很明确:已经会 Spring Boot 和 MyBatis、但没碰过 JavaFX 的后端开发,或者是 Swing 老手想迁移到现代化 UI 框架。它的学习曲线不算陡,真正磨人的是把 JavaFX 的 UI 生命周期和 Spring 的容器生命周期拧到一起,以及最后打包时 classpath 和资源的处理。这篇笔记会把进程内组合的架构选型、最小可运行代码、MyBatis 接线方式、五个高频坑和最后验证方法一次讲完。

2. 认清两种架构形态:进程内还是进程外

2.1 为什么我不推荐进程外方案

最常见的一种做法是 JavaFX 只当纯客户端,Spring Boot 单独起一个端口当后端服务,两边通过 HTTP 通信。这个方案看似解耦,实际上在桌面场景里很尴尬:客户机器上要维护两个进程,JavaFX 是桌面程序,Spring Boot 是个控制台窗口,用户一不留神就把后端关掉了;内网环境下端口被安全策略拦掉也是常事;数据交互全走 JSON 序列化,查询列表大一点,界面转圈转得明显。某公司的一个内部库存工具走了这条路,上线后一半的工单都跟“服务没启动”有关。

我一般倾向用进程内方案:JavaFX 的 main 方法里直接拉起 Spring 容器,整个项目打成带依赖的 fat jar 或者 jlink 镜像,用户双击运行的就是一个完整应用。Spring Boot 内置的 Web 容器可以关掉,业务逻辑作为普通的 Bean 被 JavaFX Controller 注入使用。这样部署件只有一个,事务、连接池、MyBatis 全部在同一个 JVM 里工作,桌面操作的数据延迟基本为零。

2.2 进程内架构的分层与职责边界

进程内方案虽然实现简单,但分层反而要更克制。我建议保持三层:JavaFX 层只做界面渲染和事件绑定,不写 SQL、不直接 new Service;Spring Service 层处理业务逻辑、事务边界;MyBatis Mapper 层只做持久化。Controller 里禁止出现new SqlSessionFactory()之类的字眼,也禁止把 Mapper 静态化。

常见做法是让 JavaFX 的 Controller 类由 Spring 容器创建,而不是由 FXML 加载器创建。这样才能把 Service 注入进 Controller。FXML 里的fx:controller属性依然要写类全路径,但加载时要用 Spring 容器来实例化这个 Controller。具体做法在下一节讲。

2.3 最小可运行骨架:让 Spring 管理 JavaFX 生命周期

先看启动入口。JavaFX 的Application类有独立的生命周期,它在 main 线程里通过launch()启动,而 Spring Boot 的SpringApplication.run()会阻塞当前线程。两者不能在同一个线程里顺序调用,否则launch()之后 Spring 根本没机会启动。核心技巧是用SpringApplicationBuilder把 Spring 容器在init()阶段就拉起,再在start()里加载 FXML。

// App.java - 应用入口 public class App extends Application { private ConfigurableApplicationContext springContext; @Override public void init() { // JavaFX 的 init() 运行在 JavaFX-Launcher 线程,适合做容器初始化 springContext = new SpringApplicationBuilder(SpringBootApp.class) .web(WebApplicationType.NONE) // 关闭 Web 容器,纯桌面应用 .run(getParameters().getRaw().toArray(new String[0])); } @Override public void start(Stage primaryStage) throws Exception { // 从 Spring 容器拿 JavaFX 的 Application 的子类实例,而不是直接 new SpringFxmlLoader loader = springContext.getBean(SpringFxmlLoader.class); Parent root = loader.load("/fxml/MainView.fxml"); primaryStage.setTitle("某跨平台系统"); primaryStage.setScene(new Scene(root, 1200, 800)); primaryStage.show(); } @Override public void stop() { // 让 Spring 容器跟着窗口关闭一起收尾,否则 JVM 不退出 springContext.close(); } public static void main(String[] args) { launch(args); } }

init()是 JavaFX 生命周期里最早且适合做重量级初始化动作的地方,比放在main()里更好控制启动顺序。web(WebApplicationType.NONE)是这里的关键参数:桌面程序不需要内嵌 Tomcat,关掉它能让启动时间缩短不少,也少了端口占用问题。最后的main()里只调launch(args),不要在这里写任何业务代码。

注意这个配置里 Spring Boot 的启动类SpringBootApp只留一个@SpringBootApplication注解,不需要main()方法,因为它不是手工启动的,而是由SpringApplicationBuilder在init()里触发。

2.4 SpringFxmlLoader 的写法:把 Controller 交给容器

// SpringFxmlLoader.java - 自定义 FXML 加载器 @Component public class SpringFxmlLoader { private final ConfigurableApplicationContext springContext; public SpringFxmlLoader(ConfigurableApplicationContext springContext) { this.springContext = springContext; } public Parent load(String fxmlPath) throws IOException { FXMLLoader loader = new FXMLLoader(getClass().getResource(fxmlPath)); loader.setControllerFactory(springContext::getBean); return loader.load(); } }

FXMLLoader默认用无参构造器反射创建 Controller,但 Spring 管理的 Bean 有很多依赖需要注入,直接反射会拿到一个所有字段都是 null 的壳子。setControllerFactory的作用就是告诉 FXML 加载器“别用反射了,找 Spring 容器要 Bean”。springContext::getBean这个函数式写法会把 FXML 里声明的 Controller 类名当作 Bean 名去容器里查找。

还有一个细节:如果 FXML 里的fx:controller声明了类路径,而 FXML 的属性文件也准备用 Spring 管理,可以用setControllerFactory((Class<?> type) -> springContext.getBean(type)),这样所有 Controller 都能被 Spring 容器实例化和注入,Service、Mapper 在 Controller 里用@Autowired或构造器注入都正常生效。

3. 数据层接入与事务边界:MyBatis 在这套架构里的正确姿势

3.1 Mapper 扫描与数据源配置

MyBatis 在 Spring Boot 里一般用@MapperScan指定 Mapper 接口的包路径,配合mapper-locations指定 XML 文件位置。桌面应用和 Web 应用不同的点在于:数据源高度依赖本地环境,配置不能写死。

# application.yml - 本地数据库连接配置 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/desktop_app?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: ${DB_USER:root} password: ${DB_PASSWORD:root} mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.domain configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

map-underscore-to-camel-case必开,否则数据库字段user_name映射到实体属性userName会全部为 null,而且不报错,只在运行时出现“查出来的数据全是空”的诡异现象。log-impl在开发阶段用 StdOutImpl,方便在控制台直接看 SQL 和参数;生产环境建议改成org.apache.ibatis.logging.slf4j.Slf4jImpl。

加密和安全性上,DB_USER和DB_PASSWORD用环境变量占位符是常见做法,桌面应用不建议把密码明文写进 yml。如果有特殊合规要求,可以考虑对密码字段做 jasypt 处理的方案,不过那是额外的事了。

3.2 一个完整的 Mapper 接口与 XML 实例

// UserMapper.java - Mapper 接口 @Mapper public interface UserMapper { User selectById(@Param("id") Long id); List<User> selectPage(@Param("offset") int offset, @Param("limit") int limit); int insert(User user); int update(User user); }
<!-- UserMapper.xml - SQL 语句与结果映射 --> <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.example.mapper.UserMapper"> <resultMap id="UserMap" type="com.example.domain.User"> <id property="id" column="user_id"/> <result property="userName" column="user_name"/> <result property="email" column="email"/> </resultMap> <select id="selectById" resultMap="UserMap"> SELECT user_id, user_name, email FROM t_user WHERE user_id = #{id} </select> <select id="selectPage" resultMap="UserMap"> SELECT user_id, user_name, email FROM t_user ORDER BY user_id LIMIT #{offset}, #{limit} </select> <insert id="insert" useGeneratedKeys="true" keyProperty="id"> INSERT INTO t_user(user_name, email) VALUES(#{userName}, #{email}) </insert> <update id="update"> UPDATE t_user SET user_name = #{userName}, email = #{email} WHERE user_id = #{id} </update> </mapper>

@MapperScan放在 Spring Boot 启动类上,指定接口包路径。XML 的 namespace 必须和接口全限定名一致,这是 MyBatis 绑定接口和 SQL 的唯一依据,写错一个字符启动时就报绑定异常。分页用的LIMIT #{offset}, #{limit}在 MySQL 下直接生效,如果换成 PostgreSQL 或 SQLite,语法需要调整。桌面系统如果数据量不大,不建议引入 PageHelper 这类分页插件,手写 LIMIT 更直白。

3.3 桌面场景下 Service 层事务的边界设置

事务在桌面应用里的边界和 Web 应用不一样。Web 层通常一个请求一个事务,桌面应用里用户可能在一个界面里连续编辑多条记录,最后点保存。这时候事务要放在 Service 方法上,而不是 Controller 方法上。我见过有同事把@Transactional写在 JavaFX 的 Controller 方法里,结果代理对象不是由 Spring 管理的,事务完全没生效,update 一条成功一条失败,数据处于半提交状态。

// UserService.java - 事务设置在 Service 层 @Service public class UserService { private final UserMapper userMapper; public UserService(UserMapper userMapper) { this.userMapper = userMapper; } @Transactional(rollbackFor = Exception.class) public void batchImport(List<User> users) { for (User user : users) { if (userMapper.selectById(user.getId()) != null) { userMapper.update(user); } else { userMapper.insert(user); } } } }

rollbackFor = Exception.class在桌面场景极其重要。Spring 默认只对 RuntimeException 回滚,检查性异常不会触发回滚,而业务代码里捕获IOException后想整体回滚是常事,不加这个参数就会静默提交。每一批导入几千条,数据量并不大,但事务一致性很重要——这是内部管理系统最喜欢埋雷的地方。

3.4 连接池和 SQLite 的特殊处理

如果系统是单机部署,也可以选 SQLite 代替 MySQL。SQLite 在 MyBatis 里配置方式和常规数据库略有不同:驱动用org.sqlite.JDBC,URL 是jdbc:sqlite:data/desktop.db。连接池要注意:HikariCP 对 SQLite 的兼容性一般,可以利用maximumPoolSize=1,因为 SQLite 同一时间只允许一个写连接,多连接并发写会直接报 database is locked。

另一个大坑是 SQLite 对事务和并发读写的锁机制,桌面应用会边查边写,造成 UI 线程卡顿。通用的方案是让耗时查询走子线程,JavaFX 的 UI 更新再通过Platform.runLater回主线程,这个在避坑章节再展开讲。

4. 集成高频踩坑记录:现象、原因、解决办法

4.1 启动后界面一闪而过,进程直接消失

现象:双击启动,窗口闪了一下就没了,控制台也没报错(或者报错被吞了)。原因:最常见的是FXMLLoader.load()在加载 FXML 时抛了异常,而异常发生在start()方法里,JavaFX 默认把异常打印到控制台;如果用户是双击 jar 包启动,没有控制台,异常信息直接丢失,看起来就像程序崩了。解决:开发阶段一定用 IDEA 启动并看控制台;打包后建议在start()最外层包一层try-catch把异常写进日志文件。还有一类可能是init()里 Spring 容器启动失败,ConfigurableApplicationContext没初始化完,getBean拿到 null,同样表现为闪退。这两个问题排查方向不同,先看日志再定位。

4.2 Controller 里的 Service 是 null

现象:FXML 界面加载出来了,一点按钮就抛NullPointerException,报错的字段全是 Service。原因:FXML 文件里的fx:controller被 FXMLLoader 自己反射创建了,Spring 容器根本没有参与,所以@Autowired的 Service 全是 null。解决:检查SpringFxmlLoader是否传给FXMLLoader了setControllerFactory;另外确认SpringFxmlLoader的load()方法用的是同一个 FXMLLoader 实例,不要某处直接new FXMLLoader()加载同一个 FXML,那会绕过 Spring。

4.3 fxml 里的参数、场所属性全是文字,运行却中文乱码

现象:FXML 文件里写了中文,运行时显示乱码。原因:FXMLLoader按 UTF-8 读取 FXML 文件,但如果文件本身是 GBK 编码(Windows 上常见),或者构建工具没有把编码统一,就会乱码。解决:所有.fxml、.properties文件在 IDEA 里统一设置为 UTF-8 编码;Maven 项目的pom.xml里加project.build.sourceEncoding为 UTF-8;如果实在遇到历史文件,可以显式指定loader.setCharset(StandardCharsets.UTF_8),但治本还是统一编码。

4.4 打包后要么没有界面,要么连不上数据库

现象:本地 IDEA 跑得好好的,打 fat jar 后运行,要么界面加载不出 fxml,要么提示找不到数据库驱动。原因:资源路径问题。IDEA 运行时的 classpath 是target/classes,能直接找到fxml目录;而 fat jar 里资源路径在 jar 内部,如果 FXML 路径写的是"fxml/MainView.fxml",少了/前缀,会找不到资源。数据库驱动问题通常是pom.xml里依赖标了runtime或providedscope,打包时没带上。解决:FXML 路径统一以/开头;镜像打包配置里让spring-boot-maven-plugin的includes包含所有 runtime 依赖;数据库驱动放宽 scope 为默认 compile。打了包建议先用java -jar app.jar测试,再看窗口是否正常。

4.5 窗口关闭后 JVM 进程不退出,任务管理器里一堆残留

现象:点窗口右上角的 X,界面关了,但 JVM 进程一直在内存里,还占着端口或数据库连接。原因:JavaFX 关闭窗口时默认只调用Platform.exit(),但 Spring 容器的非守护线程(比如 HikariCP 的 HouseKeeper 线程)还在运行,JVM 不会自动退出。解决:在Application类的stop()方法里调用springContext.close(),还会顺带关闭数据源连接池;如果某些自定义线程还挂着,建议在实现DisposableBean或@PreDestroy时做好收尾。

5. 进阶技巧:这玩意儿怎样做得顺手又稳定

JavaFX 的界面线程模型和 Swing 一样,是单线程的。所有 UI 操作必须在 Application 线程上执行,但数据库查询会在 MyBatis 里做磁盘 IO,直接放 UI 线程会卡界面。常见的做法是给 Service 方法包一层任务,让它跑在子线程里,查询完成后再切回 UI 线程更新控件。

// 查询按钮的事件处理方法 @FXML private void onSearchClick() { // 参数从界面取,DatePicker 的值要转成字符串再传 String keyword = keywordInput.getText(); Task<List<User>> queryTask = new Task<>() { @Override protected List<User> call() { // 这段代码在子线程执行,Service 里查数据库不会卡界面 return userService.search(keyword); } }; queryTask.setOnSucceeded(event -> { List<User> result = queryTask.getValue(); // 这里回到 UI 线程,可以安全操作 TableView userTable.getItems().setAll(result); }); Thread thread = new Thread(queryTask); thread.setDaemon(true); thread.start(); }

这里有个边界问题:new Thread()每次查询都新建线程,高频操作时开销大。更精细的做法是交给通用的线程池,JavaFX 也提供了ExecutorService的集成方式。setDaemon(true)也很要紧,否则关闭窗口后子线程还会阻止 JVM 退出,spring 容器已经关了但线程还在跑,问题甚至比stop()没调用更隐蔽。

验证这套项目是否健康,我习惯做三件事:第一,启动后看控制台 SQL 日志,确认 Mapper 位置和参数绑定正确;第二,用jstack <pid>看线程状态,确认查询子线程没有泄漏、UI 线程没有被数据库操作占住;第三,连续 50 次保存操作,每次启动后都执行,配置事务回滚测试,确认rollbackFor正常。有回滚的场景可以在 Service 里故意抛个异常,看看数据是否真的回滚,这是测试事务边界最实用的手段。

回想我第一次把三件套集成起来的那次,在ControllerFactory和stop()上各耗掉一天,最后发现全是生命周期和线程边界的问题。这个组合本身不难,难在理解两条生命周期线的交汇点,以及资源统一由 Spring 管理的纪律性。如果用这套方案做事,建议先把最小的骨架跑通,再谈界面美化和业务扩展。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询