
简介这是一份将SpringBoot、MyBatis与JavaFX整合的档案管理扫描项目资源适合希望掌握桌面客户端与服务端联动开发的Java学习者。项目以SpringBoot处理依赖配置、内嵌Web服务器等基础环境通过MyBatis完成与Oracle数据库的SQL映射和事务管理再由JavaFX构建交互式图形界面覆盖从数据存储到界面展示的完整链路。资源包共130个文件其中包含65个XML映射与配置、24个Java源码、28个class文件、4个FXML布局以及properties、CSS等配套文件压缩包仅146KB结构紧凑便于对照研读。已有4207人学习下载。开发者可从中重点理解三框架的分工协作、基于Oracle的档案数据查询与权限安全设计以及JavaFX事件驱动的界面开发思路适合快速搭建同类信息管理系统时参考。1. 这套三件套到底是什么SpringBoot 当后台JavaFX 当前台很多人一听 springbootmybatisjavafx 项目第一反应是「SpringBoot 不都是配网页的吗怎么跟桌面端扯上关系了」。实际上在企业内部业务系统里JavaFX 做桌面客户端、SpringBoot 做后台服务、MyBatis 管数据库持久化是一条很成熟的落地路线。它解决的核心问题很直接你有一批操作员需要天天录数据、查报表、走审批流程浏览器页面太重、Excel 流程太散这时候一个原生桌面客户端加一个稳定的后台接口服务比 Web 前端更顺手也比 Swing 的老代码好维护得多。这套组合适合谁适合已经有 SpringBoot 后端经验、但没写过 JavaFX 的团队也适合准备把老的 Swing/网页工具重构成桌面客户端的场景。它的价值在于后端那一套你熟悉的 Controller-Service-Mapper 结构完整保留前端 JavaFX 只当一个「能画界面的 HTTP 客户端」来用。这篇笔记就把战线怎么划、工程怎么搭、接口怎么调、坑在哪里一次讲清楚。2. 先把工程骨架立起来两个进程、一套代码的目录边界2.1 为什么推荐「独立后台服务 JavaFX 客户端」而不是进程内启动刚开始接触这个组合的人最容易走的一条弯路是把 SpringBoot 和 JavaFX 塞进同一个进程main 方法里先启动 Spring 容器再启动 JavaFX 窗口Service 直接被界面调用。这种方案 demo 跑起来很快但真实项目里会很快翻车。原因有三条一是界面线程和后台线程混在一起稍微有点耗时操作界面就卡死二是 Spring 容器初始化和 JavaFX 的 Application.launch 都会抢占主线程启动顺序稍不注意窗口就出不来三是以后你想给同一个后台接第二个客户端比如移动端或另一个桌面端进程内方案完全拆不开。所以我在实际项目里通常会采用双进程结构SpringBoot 打包成独立服务监听本机或局域网端口JavaFX 客户端只负责界面和调用 HTTP 接口。这个结构下JavaFX 工程和后端工程可以放在同一个 Maven 多模块项目里也可以完全分成两个目录看团队习惯。我们当初用的是单仓库多模块因为共享 DTO 对象方便但模块之间依赖方向一定只能是 client 依赖 api不能反过来。2.2 Maven 模块划分与依赖声明假设仓库名是 demo-desktop下面拆成 api、server、client 三个模块。api 放接口 DTO 和常量server 是 SpringBoot 应用本体client 是 JavaFX 界面工程。父 pom 负责统一版本管理子模块各自声明自己的依赖。modules moduledemo-api/module moduledemo-server/module moduledemo-client/module /modulesserver 模块的 pom 里核心依赖就是 SpringBoot 的 web 和 JDBC 相关 starter加上 MyBatis 的 SpringBoot 整合包dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependencyclient 模块的依赖则集中在 JavaFX 上。如果你用的是较新的 JDK 版本JavaFX 已经从 JDK 里拆出来了需要单独引入 openjfx 的控件相关依赖并且要注意引入的模块化坐标和你的 JDK 版本匹配。这个不细说具体版本号因为各团队用的 JDK 差异很大只要记住界面相关的代码全部放在 client 模块client 不依赖 server 的任何类只依赖 api 的 DTO。这样结构带来的直接收益是后端接口可以脱离界面单独测试客户端也可以在没有数据库的情况下用 Mock 数据先画界面。两者并行开发效率比单进程方案高很多。2.3 JavaFX 启动与 SpringBoot 启动的先后问题双进程结构下启动顺序就简单了先启动 server再启动 client。但开发期为了省事也可以在一个 main 里同时拉起两个进程。这里有一个关键约束JavaFX 的 Application.launch 方法必须在主线程调用而且一个 JVM 生命周期内只能调用一次SpringApplication.run 则不要放在主线程否则会阻塞 JavaFX 启动。我在项目里常用的做法是main 里先启动一个守护线程跑 SpringBoot然后主线程调 JavaFX 的 Application.launch。注意 SpringBoot 要完全启动成功再让界面发请求所以客户端加一个重试机制启动后先请求健康检查接口失败就隔 500 毫秒重试。下面是示意代码public class MainLauncher { public static void main(String[] args) { // 后台线程启动 SpringBoot 服务 Thread springThread new Thread(() - { SpringApplication.run(ServerApplication.class, args); }); springThread.setDaemon(true); springThread.start(); // 主线程启动 JavaFX Application.launch(FxApplication.class, args); } }这里有两个细节要说明。第一SpringApplication.run 放在 daemon 线程里主线程结束时会跟着结束所以 JavaFX 窗口关闭前不会退出这个顺序是安全的。第二如果 SpringBoot 启动过程中抛异常会直接打到控制台但 JavaFX 窗口已经起来了界面会显示但连不上后台。这时健康检查机制就会兜底弹提示让用户确认服务是否启动成功。参数上SpringBoot 的 server.port 建议和默认的 8080 区分开比如用 8086避免开发机上别的服务冲突。客户端把请求地址统一放在一个配置类里不要散落在各个界面类中改端口的时候只改一处。3. MyBatis 在 SpringBoot 里的正确姿势接口、XML 与主键回填3.1 数据源和 MyBatis 配置先写对后面少踩一半坑后端模块的配置集中在 application.yml。MyBatis 整合 SpringBoot 后大部分行为都靠这几个配置项控制写错一个就是「启动报错」或「字段全为 null」。我把一份常用的配置贴在下面这是模拟项目里跑过的组合可以直接抄。spring: datasource: url: jdbc:mysql://127.0.0.1:3306/demo_biz?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 10 minimum-idle: 2 connection-timeout: 30000 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.demo.server.domain configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl配置项里我特别讲三个。第一个是 mapper-locations它指定 XML 映射文件的位置如果不写MyBatis 只会扫描注解形式的方法XML 里的 SQL 全部失效第二个是 map-underscore-to-camel-case数据库的 create_time 能不能自动映射成 createTime全靠它强烈建议打开第三个是 log-impl设为 Slf4j 后 SQL 会打到日志里排查问题时一眼就能看到实际执行的语句和参数比盲猜强太多。HikariCP 连接池参数也值得提一句。maximum-pool-size 并不是越大越好MySQL 默认连接数有限桌面客户端并发请求量一般不高10 个连接完全够用。真遇到连接池满优先检查是不是有连接没释放而不是盲目调大。3.2 Mapper 接口与 XML 文件的分工原则MyBatis 的一个老问题是到底用注解写 SQL 还是用 XML我的建议很简单单表简单查询用注解多表关联或复杂动态 SQL 一律用 XML。比如下面这个分页查订单列表的方法注解就不太好写动态条件一多注解里的字符串拼接能把人逼疯。Mapper public interface OrderMapper { ListOrderDO findPage(Param(keyword) String keyword, Param(offset) int offset, Param(limit) int limit); }XML 里对应这样写select idfindPage resultTypecom.demo.server.domain.OrderDO SELECT id, order_no, customer_name, create_time, status FROM t_order where if testkeyword ! null and keyword ! AND (order_no LIKE CONCAT(%, #{keyword}, %) OR customer_name LIKE CONCAT(%, #{keyword}, %)) /if /where ORDER BY create_time DESC LIMIT #{offset}, #{limit} /select注意看两个细节。第一接口方法的参数都加了 Param 注解XML 里用 #{keyword} 取值时才不会报「Parameter keyword not found」的错误第二LIMIT #{offset}, #{limit} 里的偏移量和条数必须用 int 类型不能用字符串拼接否则有 SQL 注入风险。分页这种高频操作与其引入 PageHelper 这类插件不如自己手写 LIMIT 封装依赖少、排查简单、也不受插件版本兼容性困扰。mapper 接口的注册方式通常在启动类上加 MapperScan(com.demo.server.mapper)或者在每个接口上标 Mapper。我建议用 MapperScan包多了以后好统一管理也避免少标一个导致启动时注入失败。3.3 主键回填与批量插入的两个必调参数桌面业务系统离不开新增单据这种操作界面上填完表单点保存后端 insert 一条记录前端马上要用到新记录的主键 ID 去做后续操作比如继续添加明细。MyBatis 里这个场景靠 useGeneratedKeys 解决。insert idinsertOrder parameterTypecom.demo.server.domain.OrderDO useGeneratedKeystrue keyPropertyid INSERT INTO t_order(order_no, customer_name, status) VALUES(#{orderNo}, #{customerName}, #{status}) /insertuseGeneratedKeys 告诉 MyBatis 使用 JDBC 返回的自增主键keyProperty 指定把生成的主键写回实体类的哪个属性。注意 keyProperty 写的是实体属性名 id不是数据库列名写错的话插入完对象里的值还是 null前端拿不到新 ID。批量插入的写法也有讲究尤其是 JavaFX 客户端可能一次导入几百条 Excel 数据时一条一条 insert 会导致接口响应慢、连接占用久。常见做法是用 foreach 拼一条批量 SQLinsert idbatchInsert INSERT INTO t_order(order_no, customer_name, status) VALUES foreach collectionlist itemitem separator, (#{item.orderNo}, #{item.customerName}, #{item.status}) /foreach /insert这里要控制批量大小。我一般单批控制在 200 到 500 条之间太长的话 MySQL 可能报 max_allowed_packet 超限太短又体现不出批量优势。参数 collection 固定写 list对应接口方法里不加 Param 的 List 参数如果加了 Param(list)XML 里写 #{list} 也行但前提是接口方法参数必须和这里对齐。4. JavaFX 客户端怎么跟后端打交道线程、HTTP 与界面刷新4.1 界面线程不允许阻塞这是 JavaFX 的第一条铁律JavaFX 的 UI 更新必须在 JavaFX Application Thread 上执行而所有耗时操作——HTTP 请求、解析 JSON、读写文件——都不能放在这个线程上。很多新手把请求直接写在按钮的 setOnAction 里程序跑起来一按钮就白屏这就是触犯了这一条。正确做法是把耗时操作交给后台线程拿到结果后再切回 UI 线程刷新界面。JDK 自带 java.net.http.HttpClient 是现在比较省事的方案不用额外引依赖配合 CompletableFuture 很适合这个场景。public class ApiClient { private static final HttpClient HTTP_CLIENT HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .build(); public static CompletableFutureString getJson(String url) { HttpRequest request HttpRequest.newBuilder() .uri(URI.create(url)) .timeout(Duration.ofSeconds(10)) .header(Accept, application/json) .GET() .build(); return HTTP_CLIENT.sendAsync(request, HttpResponse.BodyHandlers.ofString()) .thenApply(response - { if (response.statusCode() ! 200) { throw new RuntimeException(HTTP response.statusCode()); } return response.body(); }); } }这个工具类的关键点在于 sendAsync 返回 CompletableFuture不会阻塞调用线程。connectTimeout 控制建立连接的超时如果连不上会快速失败而不是让用户等几十秒timeout 控制整个请求的超时。statusCode 判断放在 thenApply 里非 200 直接抛异常方便上层统一处理错误。4.2 界面刷新CompletableFuture 与 Platform.runLater 的配合拿到 JSON 后更新界面必须用 Platform.runLater 切回 UI 线程。下面这个例子演示了从按钮点击到表格刷新的完整链路。searchBtn.setOnAction(e - { // 这里在 JavaFX Application Thread 上先禁用按钮防重复点击 searchBtn.setDisable(true); loadingLabel.setVisible(true); ApiClient.getJson(http://127.0.0.1:8086/api/order/list) .thenAccept(json - Platform.runLater(() - { ListOrderDO orders JsonUtil.parseList(json, OrderDO.class); tableView.getItems().setAll(orders); loadingLabel.setVisible(false); searchBtn.setDisable(false); })) .exceptionally(ex - { Platform.runLater(() - { loadingLabel.setVisible(false); searchBtn.setDisable(false); showErrorDialog(查询失败 ex.getMessage()); }); return null; }); });这里有三层逻辑值得说明。第一层按钮点击后立刻 setDisable(true)防止用户重复提交这是桌面客户端常见的体验问题第二层thenAccept 里的代码通过 Platform.runLater 包装后才允许操作 TableView 的数据不然会抛出 IllegalStateException第三层exceptionally 负责兜底异常网络不通、后端 500、JSON 解析失败都会走这里弹个对话框而不是让用户面对无响应的界面。写复杂界面时我习惯再把 JSON 解析这一层独立出去。表格列、弹窗、表单控件都不应该直接处理 JSON 字符串统一转成 api 模块里的 DTO 对象再往下传。这样后端的字段名变动时只改 DTO 的映射注解不用翻遍所有界面类。4.3 Controller 层设计给桌面端单独设接口不做网页版的事后端接口是为 JavaFX 客户端服务的设计上也应该贴合桌面端的交互习惯。桌面端大多是「一次拉一批数据、本地翻页或搜索」的模式而不是网页那种频繁的分页跳转。所以我建议 Controller 层返回统一结构体把状态码、消息、数据包在一起。RestController RequestMapping(/api/order) public class OrderController { private final OrderMapper orderMapper; public OrderController(OrderMapper orderMapper) { this.orderMapper orderMapper; } GetMapping(/list) public ResultListOrderDO list(RequestParam(defaultValue ) String keyword, RequestParam(defaultValue 0) int offset, RequestParam(defaultValue 100) int limit) { ListOrderDO orders orderMapper.findPage(keyword, offset, limit); return Result.success(orders); } }Result 结构体的好处是前端只用判断 code 字段是否为 200不用每次解析不同的错误格式。limit 默认给 100 也是一层保护防止界面误请求巨量数据把后端内存打爆。如果业务上确实要拉大量数据就用分批查询加进度条而不是把接口超时时间无限调大。5. 联调期最常踩的 5 个坑现象、原因与处置5.1 后端起了JavaFX 窗口一片黑控制台没任何异常这是典型的启动顺序和线程问题。SpringBoot 在 daemon 线程启动时如果抛异常异常栈打到控制台可能被吞掉而 JavaFX 主线程已经尝试启动窗口但等不到资源初始化完成黑屏是表象。别急着改代码先看后端有没有真正起来。用浏览器或 curl 访问一次健康检查接口确认能通再回头看 JavaFX。我遇到过的问题是 SpringBoot 启动类被放在了 client 模块的包路径外面Spring 扫描不到结果整个服务没起来窗口自然黑着。解决方式SpringBoot 启动类的包路径必须覆盖所有要扫描的包或者显式指定 scanBasePackages。5.2 查询结果全是 null但数据库里明明有数据大概率是驼峰映射没开。数据库字段 create_time实体属性 createTime默认情况下 MyBatis 不会自动把下划线转驼峰。打开 application.yml 里的 map-underscore-to-camel-case: true 立即生效不用改任何代码。如果开了还不行检查实体类有没有用 Lombok 的 Data或者字段名是不是拼错了。SQL 日志一定要开起来看 SELECT 语句执行的结果集字段对比实体属性名比用 debug 打断点快很多。5.3 点击搜索按钮界面卡死两三秒后才弹结果这就是在 UI 线程里直接做了 HTTP 请求的典型症状。JavaFX Application Thread 被阻塞画面停在点击瞬间的状态等请求返回后才一次刷新。解决方式就是把请求挪到 CompletableFuture 里去参考第 4 章的写法。还有一个隐蔽情况即使你用了后台线程如果 thenAccept 里没有包 Platform.runLater界面上表格数据更新那一瞬间仍然会抛异常界面表现为闪退或卡住。5.4 表里能查到数据但分页查出来永远只有前几十条手写 LIMIT 时最容易犯的错是 offset 和 limit 顺序写反。MySQL 的语法是 LIMIT offset, count也就是 LIMIT #{offset}, #{limit}。有些团队的代码里习惯写 LIMIT #{limit}, #{offset}接口返回的数据和界面页码对不上。这个坑不好发现因为请求参数都是正常的SQL 也能执行。排查办法是打开 SQL 日志看实际执行的 LIMIT 值算一下当前页码对应的 offset马上能定位。另外JavaFX 端的表格翻页要自己维护当前页码页码从 0 开始还是从 1 开始要和后端约定一致我遇到过前端从 1 开始传、后端从 0 开始算第一页数据直接跳过。5.5 生产环境连不上数据库但开发环境一切正常开发环境跑在你自己电脑上数据库密码账号都写在 application.yml 里。打包发布后配置文件在 jar 包内改密码要重新打包而且运维不一定有打包环境。解决办法是用 SpringBoot 的外部配置覆盖机制jar 包旁边放一个 config/application.ymlSpringBoot 会自动优先读取外部配置。# 启动脚本 java -jar demo-server.jar --spring.config.additional-locationfile:./config/启动参数 spring.config.additional-location 可以追加外部配置目录放在这里的 application.yml 会覆盖包内的同名配置项。数据库密码、端口、连接池大小这类环境相关的配置全部挪到外部代码包里只保留默认值。这样换机器部署时只需要改 config 目录下的文件不用重新打包血泪经验早该这么做。6. 打包与交付的进阶技巧健康检查、日志链路与一键启动6.1 先做健康检查接口再做界面联调期和后端部署阶段客户端都要能快速判断「服务到底通没通」。在 Controller 里加一个什么都不查的 health 接口是关键一步。JavaFX 启动时先请求它通了就进入主界面不通就弹一个重试对话框而不是让用户看到一棵空树。GetMapping(/api/health) public ResultVoid health() { return Result.success(null); }配合一个简单的启动判断逻辑客户端初始化时发送最多 10 次健康检查请求每次间隔 500 毫秒。如果 5 秒内服务还没起来弹窗提示并给出查看日志的路径。这个小机制省掉了大量「我界面为什么是空的」的沟通成本。6.2 日志别分开定一个统一查看入口后端日志用 SpringBoot 自带的 logback-spring.xml 控制客户端 JavaFX 的日志单独配一套 slf4j任务调度搞完两边日志切来切去非常崩溃。我的习惯是客户端和后端各自滚动日志到同一个目录的不同子目录运维看的时候按时间戳对齐查问题定位快很多。后端 SQL 日志要长期打开尤其在生产环境的前两周SQL 日志能解决大量疑难问题等系统稳定后再按需关闭。6.3 打包脚本与依赖分离SpringBoot 打出来的 fat jar 在 JavaFX 场景下不是最优解因为 JavaFX 的模块化加载和 fat jar 的嵌套 jar 机制在部分 JDK 版本上会出现类加载不到的情况。我更推荐用 Maven 的 dependency 插件把依赖拷贝到 target/lib然后脚本里用 classpath 方式启动。#!/bin/bash BASE_DIR$(cd $(dirname $0) pwd) JAR$BASE_DIR/lib/demo-server.jar LIB_DIR$BASE_DIR/lib # 服务端启动 java -cp $LIB_DIR/*:$JAR com.demo.server.ServerApplication \ --spring.config.additional-locationfile:$BASE_DIR/config/ $BASE_DIR/logs/server.log 21 # 客户端启动 java -cp $LIB_DIR/*:$BASE_DIR/client/demo-client.jar \ com.demo.client.FxApplication $BASE_DIR/logs/client.log 21 这个脚本把服务端和客户端一起拉起日志输出到指定目录config 目录放外部配置。参数上通过 spring.config.additional-location 指定外部配置位置生产环境改数据库密码不用重新打包。这套结构我在几个内部工具项目里验证过稳定性和可维护性都明显比单 fat jar 方案好。最后说个习惯。这个组合用久了以后我最大的体会是SpringBoot 和 MyBatis 是「省心」的真正的复杂度全在 JavaFX 的线程模型和桌面端交互细节上。任何一次界面卡死先问自己一句「这个操作是不是在 UI 线程里干了活」八成能直接命中问题。把后台当纯粹的接口服务拆开、把界面当纯粹的 HTTP 客户端来写这个项目的复杂度就真的降下来了。希望帮到你。本文还有配套的精品资源点击获取