简介:这个项目是一个将SpringBoot、MyBatis与JavaFX整合在一起的档案扫描管理应用,面向需要搭建桌面端+服务端完整业务系统的Java开发者,也适合正在学习主流框架整合的初学者。资源包内共包含130个文件,以xml配置文件、java源码和class编译文件为主,另有fxml界面布局、properties属性配置、css样式等,压缩包仅146KB,便于快速下载后直接查阅。目前已有4208人学习浏览,适合作为中小型综合项目的参考实现。通过项目源码可以直观学习SpringBoot的自动配置、起步依赖与内嵌Web服务器,理解MyBatis如何通过映射文件解耦SQL并配合Oracle完成事务管理,同时掌握JavaFX事件监听、响应式GUI与业务界面的实际写法。整个项目将后端服务、持久层和桌面客户端串成完整链路,对想在真实场景中熟悉多技术栈协作的开发者很有借鉴价值。1. Spring Boot + MyBatis + JavaFX 同机联调:一套工程模板先跑通,值不值得下?springboot、mybatis、javafx 三个词凑在同一个压缩包里,第一眼很像课设模板,但把它拆开来看,它其实是一个「桌面端 + 服务端」合体工程:JavaFX 负责窗口和交互,Spring Boot 对外暴露 REST 接口,MyBatis 接住所有 SQL 读写。它解决的痛点很实际:桌面应用不想在客户端里直接拼 JDBC,又想复用 Java 后端的连接池、事务和 SQL 管理能力。适合三类人——要给内部团队快速搭管理系统的、拿 Java 做课题但不想从零写网络层的、以及想弄清楚 C/S 架构里职责边界怎么划的从业者。我的建议是:先照抄跑通,再删业务代码,这比从空工程起步省出一个下午。2. 工程全貌与调用链:三个子模块如何在同一个 Maven 工程里各司其职这份资源的典型结构不是一个扁平的 Spring Boot 单工程,而是把代码切成 server、client、common 三个部分。第一次打开的人容易懵——不就是一个桌面管理系统吗,为什么还要拆模块?拆开的代价是第一次构建多几步,收益是后续排查问题时,前后端边界一目了然。JavaFX 侧改界面不会动到 SQL,服务端改接口也不会让 FXML 文件跟着遭殃。2.1 为什么让 JavaFX 走 HTTP,而不是直接在客户端连 MySQLJavaFX 里直接连 MySQL 是最省事的路径:引入驱动、写个 JDBC 工具类、在界面线程里查数据。但真做一个多窗口业务系统时,问题会一个个冒出来。数据库账号密码得写死在客户端配置里,换一台机器就要重新打包;SQL 和界面代码绑在一起,以后想换 Web 端等于把数据层全部重写;更麻烦的是事务控制只能散落在每个按钮事件里,两个操作要在一个事务里完成时就只能干瞪眼。拆成 Spring Boot + MyBatis 之后,客户端只认识 HTTP 接口,连数据库的驱动都不需要。连接池、事务、慢 SQL 排查全收归服务端,客户端崩溃了也不会把数据库拖垮。这个取舍在只有两三个界面的小工具里确实显得重,但资源既然带了 Boot 端,说明它是按可扩展方向设计的。我的看法是:如果目标就是单机 Demo,直连更快;如果后面要加权限、要和别的系统交换数据,走 HTTP 是更不容易返工的路。2.2 目录结构:server、client、common 三模块的分工拿到压缩包后先看顶层目录,通常会按下面的结构组织。我把常见划分整理成一张表,后面讲配置时都会引用这里的路径。模块核心目录职责serverapi-server/src/main/java/.../controller、service、mapper接收 HTTP 请求,处理业务,执行 SQLserverapi-server/src/main/resources/mapperMyBatis XML 映射文件clientdesktop-client/src/main/java/.../view、httpJavaFX 界面、FXML、页面控制器commoncommon/src/main/java/.../dto、result统一返回体、DTO、跨进程使用的模型模块拆分在 Maven 里靠三个 artifact 实现,server 依赖 common,client 也依赖 common,但 server 和 client 之间不互相依赖。这样客户端拿到的只是 DTO 和 Result 类,不会把整个服务端代码带过去。pom 里的关键依赖如下:<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> </dependency> <dependency> <groupId>org.openjfx</groupId> <artifactId>javafx-controls</artifactId> </dependency>这段配置里的依赖数量比一个纯 Boot 工程多,但都属于常见组合。spring-boot-starter-web 负责内置 Tomcat 和 Spring MVC;mybatis-spring-boot-starter 把 MyBatis 的 SqlSessionFactory 交给 Boot 管理,省掉手动建工厂类;javafx-controls 比较容易被忽略——JDK 11 之后 JavaFX 不再是 JDK 自带模块,必须显式声明。有一点要提醒:如果你的 JDK 版本较新,javafx 依赖需要加上 classifier 或对应平台参数,否则运行时会报 "JavaFX runtime components are missing"。多数现成模板已经配好,直接跑即可;如果是从旧工程升级过来的,优先检查这一条。2.3 一次查询操作跨了多少层:客户端 → Boot → Mapper理解整套工程最快的方式是追一条调用链。以界面上点“查询用户列表”为例,完整路径是:JavaFX 按钮事件发出 HTTP GET 请求 → Boot 端 Controller 接收 → Service 做参数校验 → Mapper 接口调用 XML 里的 SQL → 查完逐层返回 → JavaFX 拿到 JSON 后刷新 TableView。先看 JavaFX 侧发请求的代码:HttpClient http = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(3)) .build(); HttpRequest req = HttpRequest.newBuilder() .uri(URI.create("http://127.0.0.1:8080/api/user/list")) .timeout(Duration.ofSeconds(5)) .GET() .build(); HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString()); if (resp.statusCode() == 200) { // 在这里解析 JSON,不要直接在界面线程里做耗时操作 }这里用的是 JDK 11 之后自带的 HttpClient,省掉引入第三方客户端的依赖。connectTimeout 管的是建立 TCP 连接的超时,单位是秒;timeout 管的是整个请求读完响应的超时。内网环境下前者 3 秒、后者 5 秒够用,如果是公网部署,建议放大到 5 秒和 10 秒。需要特别注意的是,http.send 是阻塞调用,不能直接放在 JavaFX 的 UI 线程里,否则界面会卡死。常见做法是包一层 Task 或者 CompletableFuture,在回调用 Platform.runLater 刷新界面。接着看 Boot 端入口,Controller 只做路由,不写业务:@RestController @RequestMapping("/api/user") public class UserController { private final UserService userService; public UserController(UserService userService) { this.userService = userService; } @GetMapping("/list") public Result<List<UserDto>> list( @RequestParam(required = false) String keyword) { return Result.ok(userService.list(keyword)); } }这里用了构造器注入而不是字段上的 @Autowired,是 Spring 官方推荐的写法,IDE 里看依赖关系也直观。构造器注入的好处是 Bean 初始化时就能发现循环依赖,而不是等到调用时才报错。@RequestParam(required = false) 保证不带 keyword 参数时不会直接 400,很多从 DAO 时代转过来的开发者喜欢用 Map 接参数,但这里用明确 DTO 返回更稳。最后是 MyBatis 的 Mapper 接口和 XML:public interface UserMapper { List<UserDto> selectList(@Param("keyword") String keyword); }<select id="selectList" resultType="com.xxx.dto.UserDto"> SELECT id, name, created_time FROM user <where> <if test="keyword != null and keyword != ''"> AND name LIKE CONCAT('%', #{keyword}, '%') </if> </where> ORDER BY id DESC </select>这段 XML 里有两个值得关注的细节。一个是 标签:当查询条件只有部分生效时,它会自动处理掉多余的 AND/OR,避免手写 WHERE 1=1 这种丑陋的兜底。另一个是 LIKE 拼接用了 CONCAT 而不是在 Java 端拼好再传进来,目的是让 #{keyword} 保持预编译参数,防止把 '%' 拼进 SQL 造成注入风险。开发者习惯上把删除、更新这类操作也写成相同模式,看到 和 基本就能确认这不是 generate 一键生成的死 SQL。整条链路跑通后,你会看到 Boot 控制台输出 SQL 日志,客户端 TableView 刷出数据。第一次跑如果卡在中间某一层,按这个顺序查:客户端有没有报连接异常、服务端日志有没有收到请求、Mapper 日志有没有输出 SQL。三层各看各的日志,问题基本十分钟内定位。3. MyBatis 配置落位:数据源、驼峰映射与 XML 路径三件套MyBatis 在这类工程里处于“中间件偏底层”的位置,配置对了没人夸,错了全是坑。我见过不少人在 Boot 工程里花一个下午调数据源,最后发现只是 mapper-locations 写错了一个星号。这一章把三件套讲透:数据源怎么配、XML 怎么绑、动态 SQL 怎么写不踩坑。3.1 application.yml:连接池、驼峰映射、SQL 日志一次配齐服务端的配置集中在 application.yml,这一段是最小可用配置:spring: datasource: url: jdbc:mysql://127.0.0.1:3306/xxx_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 hikari: connection-timeout: 5000 maximum-pool-size: 10 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.xxx.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl逐个说参数。url 里必须带 useUnicode=true 和 characterEncoding=utf8,否则插入中文很容易出现乱码;serverTimezone=Asia/Shanghai 是 MySQL 8.x 最常见的坑,不写会直接报时区错误,或者日期时间差八小时。driver-class-name 在单数据源时可以不写,Boot 会根据 url 自动推断,但如果后期改成多数据源,就需要在每个数据源里显式指定。hikari 是 Boot 默认的连接池,connection-timeout 设 5000 毫秒,意思是拿不到连接时快速失败,而不是傻等三十秒。maximum-pool-size 对内部管理系统来说 10 足够,参数值越大反而会在数据库重启时造成大量连接同时失效。mybatis.mapper-locations 用 classpath:mapper/*.xml 匹配 resources 下的 mapper 目录。这里有一个容易忽视的细节:如果用的是多模块工程,XML 必须放在 server 模块的 resources 里,放在 common 模块里是扫不到的。map-underscore-to-camel-case 设置为 true 后,数据库里的 created_time 才能自动映射到 JavaBean 的 createTime,不用写一大堆 resultMap。log-impl 配成 StdOutImpl 是最直接的调错手段,SQL 会原样打到控制台,参数值和影响行数都会显示出来。3.2 namespace、id、resultType:XML 与接口的三条绑定规则Mapper 接口和 XML 之间的绑定关系,总结下来就三条规则:第一,XML 的 namespace 必须写接口的全限定名,比如 com.xxx.mapper.UserMapper,写错一个包名,运行时就报 Invalid bound statement。第二,XML 里 select 的 id 必须和接口方法名一模一样,大小写都要一致。第三,方法返回值和 XML 的 resultType 要对得上,返回 List 时 resultType 写泛型里的类型,不是写 List。下面是一段标准的绑定写法:public interface UserMapper { UserDto selectById(@Param("id") Long id); }<select id="selectById" resultType="com.xxx.dto.UserDto"> SELECT id, name, created_time FROM user WHERE id = #{id} </select>这里 resultType 直接写了 DTO 的全限定名,没有用 resultMap,依赖的就是 3.1 里开启的驼峰映射。如果数据库字段和实体属性差异太大,比如字段叫 user_name_cn,属性叫 name,那就只能写 resultMap 手动指定 column 和 property。我的习惯是:能靠驼峰映射解决的绝不用 resultMap,一旦用了,新增字段时容易漏改。出现绑定问题时的排查顺序也固定:先看 target/classes 下有没有把 XML 编译进去,再看 namespace 全限定名对不对,最后看方法名和 id 是否一致。很多所谓的 MyBatis 玄学问题,实际上就是这三个地方之一打错了字。3.3 动态 SQL 与参数绑定:多条件查询和分页的两个高频写法多条件查询是这类管理系统最常用的功能。前端传用户名、状态、时间段三个条件,后端不能为每个组合写一条 SQL,所以要用动态标签:List<UserDto> selectByCondition(@Param("name") String name, @Param("status") Integer status);<select id="selectByCondition" resultType="com.xxx.dto.UserDto"> SELECT * FROM user <where> <if test="name != null and name != ''"> AND name LIKE CONCAT('%', #{name}, '%') </if> <if test="status != null"> AND status = #{status} </if> </where> </select>这里有两个必须说清的参数规约。第一,多参数方法上必须写 @Param,否则 MyBatis 会把参数命名为 param1、param2,XML 里写 #{name} 会直接按不到值。第二,Integer 类型的 status 判断要用 status != null,不能像字符串那样再判空串。很多新手把 name 的判断照搬到 status 上,造成 status = 0 时条件失效——因为 0 会被当成空值处理。分页又是另一个高频点。这份资源如果没引入分页插件,我建议不要为此再加依赖,手动 LIMIT 更可控:List<UserDto> selectPage(@Param("offset") int offset, @Param("limit") int limit);<select id="selectPage" resultType="com.xxx.dto.UserDto"> SELECT * FROM user ORDER BY id DESC LIMIT #{offset}, #{limit} </select>offset 的计算公式是 (page - 1) * size,比如第 2 页、每页 10 条,offset 就是 10。在 Service 层算好这个值再传给 Mapper,SQL 保持最简。分页插件看起来方便,但多一个依赖就多一层黑匣子,翻车时排查链路变长。手动分页的代价只是多写一条 COUNT 查询,对中小系统完全够用。要注意的是 LIMIT #{offset}, #{limit} 是 MySQL 的写法,如果换到 PostgreSQL 就要改成 LIMIT #{limit} OFFSET #{offset},换数据库时这里一定要同步改。4. JavaFX 与 Spring Boot 的跨进程边界:端口约定、超时设置与 JSON 序列化JavaFX 客户端和 Spring Boot 服务端是两个独立的 JVM 进程,边界上的问题是最容易让人产生“昨天还好好的,今天就不行”这种错觉的地方。这一章不讲怎么美化界面,专门讲两个进程之间通信最容易翻车的三件事:端口怎么固定、返回体怎么约定、时间字段怎么序列化。4.1 端口固定与启动顺序:把“端口被占用”从玄学变成日志服务端默认端口是 8080,开发阶段就把它固定死,不要依赖随机端口,否则客户端配置文件里的地址天天要改。在 application.yml 里加一行:server: port: 8080启动顺序上的习惯是先启动 Boot,看到控制台输出 Tomcat started on port 8080 后再启动 JavaFX。顺序反过来的话,客户端点按钮时会直接报 Connection refused,且服务端不会有任何日志——因为请求根本没到服务端。为了不让每次启动都靠肉眼盯日志,可以在 JavaFX 启动早期做一个端口探测:public static boolean isPortOpen(String host, int port, int timeoutMs) { try (Socket socket = new Socket()) { socket.connect(new InetSocketAddress(host, port), timeoutMs); return true; } catch (IOException e) { return false; } }这段代码用系统自带的 Socket 连一下目标端口,能连上说明服务端已经在监听。timeoutMs 设 1000 毫秒足够,连不上就弹个 Alert 提示“服务端未启动”,而不是让用户面对一片空白窗口。如果探测发现端口被占且不是自己的服务,在 Windows 下用 netstat -ano | findstr 8080 找到占用进程的 PID,再在任务管理器里核对;类 Unix 系统用 lsof -i:8080 更直接。4.2 统一返回体:客户端先判 code 再取 data很多直连数据库时代留下的代码习惯是接口直接返回实体对象,客户端拿 JSON 直接解析字段。这在单体小工具里能用,但只要涉及登录失败、参数错误、系统异常这些场景,裸实体就撑不住了——因为错误信息没法塞进实体里。这份资源里应该有统一返回体的设计,没有的话尽快补上。核心结构长这样:public class Result<T> { private int code; private String message; private T data; public static <T> Result<T> ok(T data) { Result<T> r = new Result<>(); r.code = 0; r.message = "ok"; r.data = data; return r; } public static <T> Result<T> error(int code, String message) { Result<T> r = new Result<>(); r.code = code; r.message = message; return r; } }Controller 里统一返回 Result,客户端的解析逻辑也随之统一:先看 code,再决定要不要取 data。我在实际项目里用的约束如下:code含义客户端行为0成功解析 data 并刷新界面4000参数错误弹出 message,不解析 data5000业务或系统异常记录日志,弹出错误提示这样设计的好处是客户端不用 catch 每个接口的异常,HTTP 状态码基本保持 200,业务问题通过 code 传达。注意不要把业务异常直接抛成 HTTP 500,否则客户端拿不到 message 里的具体原因,排查问题全靠猜。4.3 LocalDateTime 与 BigDecimal:两个最常翻车的序列化点JavaFX 拿到的 JSON 如果日期字段变成一串数字,或者变成数组,基本可以断定是 LocalDateTime 序列化没有配好。Spring Boot 默认用 Jackson 序列化,而 LocalDateTime 在默认配置下会被序列化成时间戳格式,客户端解析时一脸懵。服务端要做两件事。一是在配置文件里关掉时间戳输出:spring: jackson: serialization: write-dates-as-timestamps: false date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8二是对实体里的时间字段显式声明格式:@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime createTime;date-format 对 java.util.Date 生效,LocalDateTime 必须靠 @JsonFormat 或全局配置配合。客户端收到的是字符串 "2024-05-01 10:30:00",可以直接往 TableView 的单元格里塞。BigDecimal 的坑不太一样。数据库里的 DECIMAL(10,2) 查询出来是 123.00,Jackson 默认会把它序列化成数字 123.00,但如果字段值是 0 或者整数时,又可能不带小数位。客户端如果用 float 接收金额,很容易出现 0.1 + 0.2 ≠ 0.3 这类问题。我的一般做法是:在 DTO 里把金额字段定义为 String,客户端拿到字符串后再用 BigDecimal.valueOf 转换,绕开 JSON 层的精度损耗。界面里显示金额时用字符串,计算时才转 BigDecimal,两边都不委屈。5. 常见翻车现场排查:端口占用、Mapper 找不到、中文乱码的五条记录这套工程我第一次跑的时候,半小时里连碰三个错,而且三个错长得完全不像同一个原因。后来复盘发现,绝大多数问题都能归到几个固定类型。这一章写五条踩坑记录,每一条都是我当时或帮别人排查时真实处理过的,按 现象 → 原因 → 解决 三段式写,遇到同类问题可以直接对号入座。5.1 排查顺序:先定位进程,再查日志,最后怀疑数据接了太多被“MyBatis 有问题”这种描述带偏的求助后,我总结出一个固定排查顺序:先确认 8080 端口有没有服务在监听,这一步排除客户端打错地址和服务端没启动;再去看 Boot 控制台最后二十行日志,有没有 Exception、有没有打印出 SQL;最后才拿 SQL 去数据库客户端里手动执行,确认是 SQL 本身的问题还是数据的问题。顺序不能反。很多人一报错就打开 XML 改 SQL,改了半天发现数据库里根本没有这张表,或者服务端根本没启动。端口探测、日志查看、手动执行 SQL,这三步熟练之后,定位问题基本不会超过五分钟。HTTP 状态码 404 先看 Controller 的 @RequestMapping 路径,500 先看控制台异常栈,Connection refused 先看端口,这是我认为这套工程里最值得背下来的排错口诀。5.2 五条高频踩坑记录坑一:客户端点按钮,弹窗报 Connection refused。现象:JavaFX 启动正常,窗口能打开,但点击查询或登录按钮后,控制台输出 java.net.ConnectException: Connection refused: connect。原因:Spring Boot 服务端没有启动,或者启动后端口被其他进程占用,请求根本打不到接口。解决:先回控制台看有没有 Tomcat started on port 8080 的日志;没有就先启动服务端。有但客户端仍报错,用 netstat 或 lsof 查端口占用,确认端口没变。最后检查客户端代码里的 baseUrl 是不是写成了 https 或者带上了莫名路径。这个坑治愈了我一个坏习惯——以前每次报连接错误都先改客户端代码,现在先看服务端。坑二:一调用列表接口,Boot 控制台抛 BindingException: Invalid bound statement (not found)。现象:Spring Boot 能正常启动,但访问 /api/user/list 时抛异常,提示找不到某个方法。原因:MyBatis 的 XML 和 Mapper 接口没有正确绑定。最常见的是 mapper-locations 配置的路径没匹配到 XML,或者 XML 根本没编译到 target/classes 目录。解决:先打开 target/classes 目录,看有没有对应的 mapper XML。如果文件不在,说明 resources 配置有问题,或者 XML 放错了模块。如果文件在,再核对 XML 的 namespace 和接口全限定名是否完全一致。这个坑通常发生在从别人电脑拷贝资源包时漏掉了某些历史文件。坑三:接口返回 200,但列表里每个字段都是 null。现象:客户端能拿到 JSON,但所有字段值都是 null,数据库里明明有数据。原因:数据库的 created_time 字段和实体里的 createTime 属性没有对上。要么是 map-underscore-to-camel-case 没开,要么是 XML 里手动写了 resultMap 但 column 拼写错误。解决:检查 application.yml 里是否配置了 map-underscore-to-camel-case: true。如果实体字段和数据库字段差异实在太大,在 XML 里写 resultMap 显式映射,但注意 resultMap 和 resultType 不能同时生效。我一般优先用驼峰映射,只有极个别字段才补 resultMap。坑四:界面提示删除成功,刷新后数据还在。现象:客户端显示操作成功,Service 也返回了成功,但数据库里记录一条没少。原因:事务没有真正生效,或者删除 SQL 的 WHERE 条件传参类型不匹配,导致条件失效但影响行数为 0。Spring 的 @Transactional 只对 public 方法生效,如果写在 private 方法上,或者同类内部通过 this 调用,代理不会拦截,事务等于没开。解决:先看 Mapper 的删除方法返回值,确认影响行数是不是 0。影响行数是 0,说明 SQL 的 WHERE 条件没有匹配到记录,检查主键类型是否一致。影响行数正常但数据还在,把 @Transactional 移到 public 方法上,并确保是通过 Controller 注入的 Service 调用的,而不是同类内部自调用。坑五:界面显示中文直接乱码。现象:客户端窗口标题和表格里的中文变成 “???”,或者一个字裂成两个乱码符号。原因:Windows 环境下 JVM 默认文件编码是 GBK,而工程代码是 UTF-8;或者 MySQL 连接 url 没有带 characterEncoding=utf8,数据库客户端和表字符集也不一致。解决:三处统一编码。第一,pom 里设置 maven.compiler.encoding 为 UTF-8;第二,JavaFX 加载 FXML 时确保读取编码为 UTF-8;第三,mysql 连接 url 带上 characterEncoding=utf8,数据库表和字段用 utf8mb4。这三个地方只要漏一个,中文都有可能在某个环节变乱码。5.3 客户端兜底:一错就弹窗,别让界面白屏JavaFX 的 UI 线程只要抛一次未捕获异常,整个窗口就可能卡住或者直接退出。网络请求这种天然不稳定的操作,必须做好兜底。常见做法是把请求和解析包进 try-catch:try { HttpResponse<String> resp = sendRequest(); Result<?> result = parseResult(resp.body()); if (result.code != 0) { showError(result.message); } } catch (IOException e) { showError("服务端未启动或网络异常"); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }这里有两个细节。InterruptedException 不能静默吞掉,捕获后要恢复中断标志,也就是 Thread.currentThread().interrupt(),否则异步任务的中断信号丢失。IOException 分支要区分连接失败和读取超时,连接失败提示“服务端未启动”,读取超时提示“请求超时,请稍后重试”,这两种情况处理方式完全不同。弹窗提示只需要一行 showError,但这一行能阻止九成“点了一下没反应”的困惑。6. 进阶用法:把服务端与客户端打成一套免配置启动包工程在 IDE 里跑通只是第一步。真正要交给同事、放进内网服务器使用的时候,不能让对方先装 JDK、配环境变量、启动两个进程。我的做法是把 Boot 端打成可执行 jar,JavaFX 端也打出可执行包,然后用一个启动脚本按顺序拉起两个进程。Boot 端用 spring-boot-maven-plugin 打成 fat jar,客户端用 javafx-maven-plugin 配合 jlink 打出免安装包。两个产物的默认名称一般长这样:server.jar 和 client。启动脚本的核心逻辑是:先探测服务端健康状态,没启动就后台拉起,等服务端就绪后再启动客户端。#!/bin/bash BASE_DIR=$(cd "$(dirname "$0")" && pwd) LOG_DIR="$BASE_DIR/logs" mkdir -p "$LOG_DIR" if ! curl -s --connect-timeout 2 "http://127.0.0.1:8080/actuator/health" >/dev/null; then nohup java -jar "$BASE_DIR/server.jar" >> "$LOG_DIR/server.log" 2>&1 & echo "服务端启动中,请稍候..." for i in $(seq 1 30); do sleep 1 if curl -s --connect-timeout 2 "http://127.0.0.1:8080/actuator/health" >/dev/null; then break fi done fi java -jar "$BASE_DIR/client.jar" "$@"脚本里的关键点是健康检查。curl 的 connect-timeout 设 2 秒,防止服务端没起来时 curl 自己挂半天;循环最多等 30 秒,给慢机器留足余量,但机器正常的场景下一般三到五秒就能就绪。需要用 actuator 的 /actuator/health 接口,所以服务端 pom 里要加上 spring-boot-starter-actuator 依赖,并在配置文件里暴露 health 端点。如果不引入 actuator,也可以用前面 4.1 的 Socket 探测代替,脚本里换成一段 small Java 程序调用即可。脚本写好后,整个目录就是一个可交付物:server.jar、client.jar、启动脚本、logs 目录。验证方法也简单:双击启动脚本后,浏览器访问 http://127.0.0.1:8080/actuator/health 看到 JSON 返回是 UP,客户端窗口能正常查询数据,这套就绪状态就成立了。从那以后,我每次给别人传这类 Spring Boot + MyBatis + JavaFX 的模板工程,都强制把打包脚本放进根目录,并在交付前完整走一遍“新机器启动流程”——正因为曾经在某台干净的机器上翻过车。希望你拿到这份资源后,也先把启动脚本跑通,再改业务代码。希望帮到你。本文还有配套的精品资源,点击获取
简介:这个项目是一个将SpringBoot、MyBatis与JavaFX整合在一起的档案扫描管理应用,面向需要搭建桌面端+服务端完整业务系统的Java开发者,也适合正在学习主流框架整合的初学者。资源包内共包含130个文件,以xml配置文件、java源码和class编译文件为主,另有fxml界面布局、properties属性配置、css样式等,压缩包仅146KB,便于快速下载后直接查阅。目前已有4208人学习浏览,适合作为中小型综合项目的参考实现。通过项目源码可以直观学习SpringBoot的自动配置、起步依赖与内嵌Web服务器,理解MyBatis如何通过映射文件解耦SQL并配合Oracle完成事务管理,同时掌握JavaFX事件监听、响应式GUI与业务界面的实际写法。整个项目将后端服务、持久层和桌面客户端串成完整链路,对想在真实场景中熟悉多技术栈协作的开发者很有借鉴价值。1. Spring Boot + MyBatis + JavaFX 同机联调:一套工程模板先跑通,值不值得下?springboot、mybatis、javafx 三个词凑在同一个压缩包里,第一眼很像课设模板,但把它拆开来看,它其实是一个「桌面端 + 服务端」合体工程:JavaFX 负责窗口和交互,Spring Boot 对外暴露 REST 接口,MyBatis 接住所有 SQL 读写。它解决的痛点很实际:桌面应用不想在客户端里直接拼 JDBC,又想复用 Java 后端的连接池、事务和 SQL 管理能力。适合三类人——要给内部团队快速搭管理系统的、拿 Java 做课题但不想从零写网络层的、以及想弄清楚 C/S 架构里职责边界怎么划的从业者。我的建议是:先照抄跑通,再删业务代码,这比从空工程起步省出一个下午。2. 工程全貌与调用链:三个子模块如何在同一个 Maven 工程里各司其职这份资源的典型结构不是一个扁平的 Spring Boot 单工程,而是把代码切成 server、client、common 三个部分。第一次打开的人容易懵——不就是一个桌面管理系统吗,为什么还要拆模块?拆开的代价是第一次构建多几步,收益是后续排查问题时,前后端边界一目了然。JavaFX 侧改界面不会动到 SQL,服务端改接口也不会让 FXML 文件跟着遭殃。2.1 为什么让 JavaFX 走 HTTP,而不是直接在客户端连 MySQLJavaFX 里直接连 MySQL 是最省事的路径:引入驱动、写个 JDBC 工具类、在界面线程里查数据。但真做一个多窗口业务系统时,问题会一个个冒出来。数据库账号密码得写死在客户端配置里,换一台机器就要重新打包;SQL 和界面代码绑在一起,以后想换 Web 端等于把数据层全部重写;更麻烦的是事务控制只能散落在每个按钮事件里,两个操作要在一个事务里完成时就只能干瞪眼。拆成 Spring Boot + MyBatis 之后,客户端只认识 HTTP 接口,连数据库的驱动都不需要。连接池、事务、慢 SQL 排查全收归服务端,客户端崩溃了也不会把数据库拖垮。这个取舍在只有两三个界面的小工具里确实显得重,但资源既然带了 Boot 端,说明它是按可扩展方向设计的。我的看法是:如果目标就是单机 Demo,直连更快;如果后面要加权限、要和别的系统交换数据,走 HTTP 是更不容易返工的路。2.2 目录结构:server、client、common 三模块的分工拿到压缩包后先看顶层目录,通常会按下面的结构组织。我把常见划分整理成一张表,后面讲配置时都会引用这里的路径。模块核心目录职责serverapi-server/src/main/java/.../controller、service、mapper接收 HTTP 请求,处理业务,执行 SQLserverapi-server/src/main/resources/mapperMyBatis XML 映射文件clientdesktop-client/src/main/java/.../view、httpJavaFX 界面、FXML、页面控制器commoncommon/src/main/java/.../dto、result统一返回体、DTO、跨进程使用的模型模块拆分在 Maven 里靠三个 artifact 实现,server 依赖 common,client 也依赖 common,但 server 和 client 之间不互相依赖。这样客户端拿到的只是 DTO 和 Result 类,不会把整个服务端代码带过去。pom 里的关键依赖如下:<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> </dependency> <dependency> <groupId>org.openjfx</groupId> <artifactId>javafx-controls</artifactId> </dependency>这段配置里的依赖数量比一个纯 Boot 工程多,但都属于常见组合。spring-boot-starter-web 负责内置 Tomcat 和 Spring MVC;mybatis-spring-boot-starter 把 MyBatis 的 SqlSessionFactory 交给 Boot 管理,省掉手动建工厂类;javafx-controls 比较容易被忽略——JDK 11 之后 JavaFX 不再是 JDK 自带模块,必须显式声明。有一点要提醒:如果你的 JDK 版本较新,javafx 依赖需要加上 classifier 或对应平台参数,否则运行时会报 "JavaFX runtime components are missing"。多数现成模板已经配好,直接跑即可;如果是从旧工程升级过来的,优先检查这一条。2.3 一次查询操作跨了多少层:客户端 → Boot → Mapper理解整套工程最快的方式是追一条调用链。以界面上点“查询用户列表”为例,完整路径是:JavaFX 按钮事件发出 HTTP GET 请求 → Boot 端 Controller 接收 → Service 做参数校验 → Mapper 接口调用 XML 里的 SQL → 查完逐层返回 → JavaFX 拿到 JSON 后刷新 TableView。先看 JavaFX 侧发请求的代码:HttpClient http = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(3)) .build(); HttpRequest req = HttpRequest.newBuilder() .uri(URI.create("http://127.0.0.1:8080/api/user/list")) .timeout(Duration.ofSeconds(5)) .GET() .build(); HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString()); if (resp.statusCode() == 200) { // 在这里解析 JSON,不要直接在界面线程里做耗时操作 }这里用的是 JDK 11 之后自带的 HttpClient,省掉引入第三方客户端的依赖。connectTimeout 管的是建立 TCP 连接的超时,单位是秒;timeout 管的是整个请求读完响应的超时。内网环境下前者 3 秒、后者 5 秒够用,如果是公网部署,建议放大到 5 秒和 10 秒。需要特别注意的是,http.send 是阻塞调用,不能直接放在 JavaFX 的 UI 线程里,否则界面会卡死。常见做法是包一层 Task 或者 CompletableFuture,在回调用 Platform.runLater 刷新界面。接着看 Boot 端入口,Controller 只做路由,不写业务:@RestController @RequestMapping("/api/user") public class UserController { private final UserService userService; public UserController(UserService userService) { this.userService = userService; } @GetMapping("/list") public Result<List<UserDto>> list( @RequestParam(required = false) String keyword) { return Result.ok(userService.list(keyword)); } }这里用了构造器注入而不是字段上的 @Autowired,是 Spring 官方推荐的写法,IDE 里看依赖关系也直观。构造器注入的好处是 Bean 初始化时就能发现循环依赖,而不是等到调用时才报错。@RequestParam(required = false) 保证不带 keyword 参数时不会直接 400,很多从 DAO 时代转过来的开发者喜欢用 Map 接参数,但这里用明确 DTO 返回更稳。最后是 MyBatis 的 Mapper 接口和 XML:public interface UserMapper { List<UserDto> selectList(@Param("keyword") String keyword); }<select id="selectList" resultType="com.xxx.dto.UserDto"> SELECT id, name, created_time FROM user <where> <if test="keyword != null and keyword != ''"> AND name LIKE CONCAT('%', #{keyword}, '%') </if> </where> ORDER BY id DESC </select>这段 XML 里有两个值得关注的细节。一个是 标签:当查询条件只有部分生效时,它会自动处理掉多余的 AND/OR,避免手写 WHERE 1=1 这种丑陋的兜底。另一个是 LIKE 拼接用了 CONCAT 而不是在 Java 端拼好再传进来,目的是让 #{keyword} 保持预编译参数,防止把 '%' 拼进 SQL 造成注入风险。开发者习惯上把删除、更新这类操作也写成相同模式,看到 和 基本就能确认这不是 generate 一键生成的死 SQL。整条链路跑通后,你会看到 Boot 控制台输出 SQL 日志,客户端 TableView 刷出数据。第一次跑如果卡在中间某一层,按这个顺序查:客户端有没有报连接异常、服务端日志有没有收到请求、Mapper 日志有没有输出 SQL。三层各看各的日志,问题基本十分钟内定位。3. MyBatis 配置落位:数据源、驼峰映射与 XML 路径三件套MyBatis 在这类工程里处于“中间件偏底层”的位置,配置对了没人夸,错了全是坑。我见过不少人在 Boot 工程里花一个下午调数据源,最后发现只是 mapper-locations 写错了一个星号。这一章把三件套讲透:数据源怎么配、XML 怎么绑、动态 SQL 怎么写不踩坑。3.1 application.yml:连接池、驼峰映射、SQL 日志一次配齐服务端的配置集中在 application.yml,这一段是最小可用配置:spring: datasource: url: jdbc:mysql://127.0.0.1:3306/xxx_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 hikari: connection-timeout: 5000 maximum-pool-size: 10 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.xxx.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl逐个说参数。url 里必须带 useUnicode=true 和 characterEncoding=utf8,否则插入中文很容易出现乱码;serverTimezone=Asia/Shanghai 是 MySQL 8.x 最常见的坑,不写会直接报时区错误,或者日期时间差八小时。driver-class-name 在单数据源时可以不写,Boot 会根据 url 自动推断,但如果后期改成多数据源,就需要在每个数据源里显式指定。hikari 是 Boot 默认的连接池,connection-timeout 设 5000 毫秒,意思是拿不到连接时快速失败,而不是傻等三十秒。maximum-pool-size 对内部管理系统来说 10 足够,参数值越大反而会在数据库重启时造成大量连接同时失效。mybatis.mapper-locations 用 classpath:mapper/*.xml 匹配 resources 下的 mapper 目录。这里有一个容易忽视的细节:如果用的是多模块工程,XML 必须放在 server 模块的 resources 里,放在 common 模块里是扫不到的。map-underscore-to-camel-case 设置为 true 后,数据库里的 created_time 才能自动映射到 JavaBean 的 createTime,不用写一大堆 resultMap。log-impl 配成 StdOutImpl 是最直接的调错手段,SQL 会原样打到控制台,参数值和影响行数都会显示出来。3.2 namespace、id、resultType:XML 与接口的三条绑定规则Mapper 接口和 XML 之间的绑定关系,总结下来就三条规则:第一,XML 的 namespace 必须写接口的全限定名,比如 com.xxx.mapper.UserMapper,写错一个包名,运行时就报 Invalid bound statement。第二,XML 里 select 的 id 必须和接口方法名一模一样,大小写都要一致。第三,方法返回值和 XML 的 resultType 要对得上,返回 List 时 resultType 写泛型里的类型,不是写 List。下面是一段标准的绑定写法:public interface UserMapper { UserDto selectById(@Param("id") Long id); }<select id="selectById" resultType="com.xxx.dto.UserDto"> SELECT id, name, created_time FROM user WHERE id = #{id} </select>这里 resultType 直接写了 DTO 的全限定名,没有用 resultMap,依赖的就是 3.1 里开启的驼峰映射。如果数据库字段和实体属性差异太大,比如字段叫 user_name_cn,属性叫 name,那就只能写 resultMap 手动指定 column 和 property。我的习惯是:能靠驼峰映射解决的绝不用 resultMap,一旦用了,新增字段时容易漏改。出现绑定问题时的排查顺序也固定:先看 target/classes 下有没有把 XML 编译进去,再看 namespace 全限定名对不对,最后看方法名和 id 是否一致。很多所谓的 MyBatis 玄学问题,实际上就是这三个地方之一打错了字。3.3 动态 SQL 与参数绑定:多条件查询和分页的两个高频写法多条件查询是这类管理系统最常用的功能。前端传用户名、状态、时间段三个条件,后端不能为每个组合写一条 SQL,所以要用动态标签:List<UserDto> selectByCondition(@Param("name") String name, @Param("status") Integer status);<select id="selectByCondition" resultType="com.xxx.dto.UserDto"> SELECT * FROM user <where> <if test="name != null and name != ''"> AND name LIKE CONCAT('%', #{name}, '%') </if> <if test="status != null"> AND status = #{status} </if> </where> </select>这里有两个必须说清的参数规约。第一,多参数方法上必须写 @Param,否则 MyBatis 会把参数命名为 param1、param2,XML 里写 #{name} 会直接按不到值。第二,Integer 类型的 status 判断要用 status != null,不能像字符串那样再判空串。很多新手把 name 的判断照搬到 status 上,造成 status = 0 时条件失效——因为 0 会被当成空值处理。分页又是另一个高频点。这份资源如果没引入分页插件,我建议不要为此再加依赖,手动 LIMIT 更可控:List<UserDto> selectPage(@Param("offset") int offset, @Param("limit") int limit);<select id="selectPage" resultType="com.xxx.dto.UserDto"> SELECT * FROM user ORDER BY id DESC LIMIT #{offset}, #{limit} </select>offset 的计算公式是 (page - 1) * size,比如第 2 页、每页 10 条,offset 就是 10。在 Service 层算好这个值再传给 Mapper,SQL 保持最简。分页插件看起来方便,但多一个依赖就多一层黑匣子,翻车时排查链路变长。手动分页的代价只是多写一条 COUNT 查询,对中小系统完全够用。要注意的是 LIMIT #{offset}, #{limit} 是 MySQL 的写法,如果换到 PostgreSQL 就要改成 LIMIT #{limit} OFFSET #{offset},换数据库时这里一定要同步改。4. JavaFX 与 Spring Boot 的跨进程边界:端口约定、超时设置与 JSON 序列化JavaFX 客户端和 Spring Boot 服务端是两个独立的 JVM 进程,边界上的问题是最容易让人产生“昨天还好好的,今天就不行”这种错觉的地方。这一章不讲怎么美化界面,专门讲两个进程之间通信最容易翻车的三件事:端口怎么固定、返回体怎么约定、时间字段怎么序列化。4.1 端口固定与启动顺序:把“端口被占用”从玄学变成日志服务端默认端口是 8080,开发阶段就把它固定死,不要依赖随机端口,否则客户端配置文件里的地址天天要改。在 application.yml 里加一行:server: port: 8080启动顺序上的习惯是先启动 Boot,看到控制台输出 Tomcat started on port 8080 后再启动 JavaFX。顺序反过来的话,客户端点按钮时会直接报 Connection refused,且服务端不会有任何日志——因为请求根本没到服务端。为了不让每次启动都靠肉眼盯日志,可以在 JavaFX 启动早期做一个端口探测:public static boolean isPortOpen(String host, int port, int timeoutMs) { try (Socket socket = new Socket()) { socket.connect(new InetSocketAddress(host, port), timeoutMs); return true; } catch (IOException e) { return false; } }这段代码用系统自带的 Socket 连一下目标端口,能连上说明服务端已经在监听。timeoutMs 设 1000 毫秒足够,连不上就弹个 Alert 提示“服务端未启动”,而不是让用户面对一片空白窗口。如果探测发现端口被占且不是自己的服务,在 Windows 下用 netstat -ano | findstr 8080 找到占用进程的 PID,再在任务管理器里核对;类 Unix 系统用 lsof -i:8080 更直接。4.2 统一返回体:客户端先判 code 再取 data很多直连数据库时代留下的代码习惯是接口直接返回实体对象,客户端拿 JSON 直接解析字段。这在单体小工具里能用,但只要涉及登录失败、参数错误、系统异常这些场景,裸实体就撑不住了——因为错误信息没法塞进实体里。这份资源里应该有统一返回体的设计,没有的话尽快补上。核心结构长这样:public class Result<T> { private int code; private String message; private T data; public static <T> Result<T> ok(T data) { Result<T> r = new Result<>(); r.code = 0; r.message = "ok"; r.data = data; return r; } public static <T> Result<T> error(int code, String message) { Result<T> r = new Result<>(); r.code = code; r.message = message; return r; } }Controller 里统一返回 Result,客户端的解析逻辑也随之统一:先看 code,再决定要不要取 data。我在实际项目里用的约束如下:code含义客户端行为0成功解析 data 并刷新界面4000参数错误弹出 message,不解析 data5000业务或系统异常记录日志,弹出错误提示这样设计的好处是客户端不用 catch 每个接口的异常,HTTP 状态码基本保持 200,业务问题通过 code 传达。注意不要把业务异常直接抛成 HTTP 500,否则客户端拿不到 message 里的具体原因,排查问题全靠猜。4.3 LocalDateTime 与 BigDecimal:两个最常翻车的序列化点JavaFX 拿到的 JSON 如果日期字段变成一串数字,或者变成数组,基本可以断定是 LocalDateTime 序列化没有配好。Spring Boot 默认用 Jackson 序列化,而 LocalDateTime 在默认配置下会被序列化成时间戳格式,客户端解析时一脸懵。服务端要做两件事。一是在配置文件里关掉时间戳输出:spring: jackson: serialization: write-dates-as-timestamps: false date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8二是对实体里的时间字段显式声明格式:@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime createTime;date-format 对 java.util.Date 生效,LocalDateTime 必须靠 @JsonFormat 或全局配置配合。客户端收到的是字符串 "2024-05-01 10:30:00",可以直接往 TableView 的单元格里塞。BigDecimal 的坑不太一样。数据库里的 DECIMAL(10,2) 查询出来是 123.00,Jackson 默认会把它序列化成数字 123.00,但如果字段值是 0 或者整数时,又可能不带小数位。客户端如果用 float 接收金额,很容易出现 0.1 + 0.2 ≠ 0.3 这类问题。我的一般做法是:在 DTO 里把金额字段定义为 String,客户端拿到字符串后再用 BigDecimal.valueOf 转换,绕开 JSON 层的精度损耗。界面里显示金额时用字符串,计算时才转 BigDecimal,两边都不委屈。5. 常见翻车现场排查:端口占用、Mapper 找不到、中文乱码的五条记录这套工程我第一次跑的时候,半小时里连碰三个错,而且三个错长得完全不像同一个原因。后来复盘发现,绝大多数问题都能归到几个固定类型。这一章写五条踩坑记录,每一条都是我当时或帮别人排查时真实处理过的,按 现象 → 原因 → 解决 三段式写,遇到同类问题可以直接对号入座。5.1 排查顺序:先定位进程,再查日志,最后怀疑数据接了太多被“MyBatis 有问题”这种描述带偏的求助后,我总结出一个固定排查顺序:先确认 8080 端口有没有服务在监听,这一步排除客户端打错地址和服务端没启动;再去看 Boot 控制台最后二十行日志,有没有 Exception、有没有打印出 SQL;最后才拿 SQL 去数据库客户端里手动执行,确认是 SQL 本身的问题还是数据的问题。顺序不能反。很多人一报错就打开 XML 改 SQL,改了半天发现数据库里根本没有这张表,或者服务端根本没启动。端口探测、日志查看、手动执行 SQL,这三步熟练之后,定位问题基本不会超过五分钟。HTTP 状态码 404 先看 Controller 的 @RequestMapping 路径,500 先看控制台异常栈,Connection refused 先看端口,这是我认为这套工程里最值得背下来的排错口诀。5.2 五条高频踩坑记录坑一:客户端点按钮,弹窗报 Connection refused。现象:JavaFX 启动正常,窗口能打开,但点击查询或登录按钮后,控制台输出 java.net.ConnectException: Connection refused: connect。原因:Spring Boot 服务端没有启动,或者启动后端口被其他进程占用,请求根本打不到接口。解决:先回控制台看有没有 Tomcat started on port 8080 的日志;没有就先启动服务端。有但客户端仍报错,用 netstat 或 lsof 查端口占用,确认端口没变。最后检查客户端代码里的 baseUrl 是不是写成了 https 或者带上了莫名路径。这个坑治愈了我一个坏习惯——以前每次报连接错误都先改客户端代码,现在先看服务端。坑二:一调用列表接口,Boot 控制台抛 BindingException: Invalid bound statement (not found)。现象:Spring Boot 能正常启动,但访问 /api/user/list 时抛异常,提示找不到某个方法。原因:MyBatis 的 XML 和 Mapper 接口没有正确绑定。最常见的是 mapper-locations 配置的路径没匹配到 XML,或者 XML 根本没编译到 target/classes 目录。解决:先打开 target/classes 目录,看有没有对应的 mapper XML。如果文件不在,说明 resources 配置有问题,或者 XML 放错了模块。如果文件在,再核对 XML 的 namespace 和接口全限定名是否完全一致。这个坑通常发生在从别人电脑拷贝资源包时漏掉了某些历史文件。坑三:接口返回 200,但列表里每个字段都是 null。现象:客户端能拿到 JSON,但所有字段值都是 null,数据库里明明有数据。原因:数据库的 created_time 字段和实体里的 createTime 属性没有对上。要么是 map-underscore-to-camel-case 没开,要么是 XML 里手动写了 resultMap 但 column 拼写错误。解决:检查 application.yml 里是否配置了 map-underscore-to-camel-case: true。如果实体字段和数据库字段差异实在太大,在 XML 里写 resultMap 显式映射,但注意 resultMap 和 resultType 不能同时生效。我一般优先用驼峰映射,只有极个别字段才补 resultMap。坑四:界面提示删除成功,刷新后数据还在。现象:客户端显示操作成功,Service 也返回了成功,但数据库里记录一条没少。原因:事务没有真正生效,或者删除 SQL 的 WHERE 条件传参类型不匹配,导致条件失效但影响行数为 0。Spring 的 @Transactional 只对 public 方法生效,如果写在 private 方法上,或者同类内部通过 this 调用,代理不会拦截,事务等于没开。解决:先看 Mapper 的删除方法返回值,确认影响行数是不是 0。影响行数是 0,说明 SQL 的 WHERE 条件没有匹配到记录,检查主键类型是否一致。影响行数正常但数据还在,把 @Transactional 移到 public 方法上,并确保是通过 Controller 注入的 Service 调用的,而不是同类内部自调用。坑五:界面显示中文直接乱码。现象:客户端窗口标题和表格里的中文变成 “???”,或者一个字裂成两个乱码符号。原因:Windows 环境下 JVM 默认文件编码是 GBK,而工程代码是 UTF-8;或者 MySQL 连接 url 没有带 characterEncoding=utf8,数据库客户端和表字符集也不一致。解决:三处统一编码。第一,pom 里设置 maven.compiler.encoding 为 UTF-8;第二,JavaFX 加载 FXML 时确保读取编码为 UTF-8;第三,mysql 连接 url 带上 characterEncoding=utf8,数据库表和字段用 utf8mb4。这三个地方只要漏一个,中文都有可能在某个环节变乱码。5.3 客户端兜底:一错就弹窗,别让界面白屏JavaFX 的 UI 线程只要抛一次未捕获异常,整个窗口就可能卡住或者直接退出。网络请求这种天然不稳定的操作,必须做好兜底。常见做法是把请求和解析包进 try-catch:try { HttpResponse<String> resp = sendRequest(); Result<?> result = parseResult(resp.body()); if (result.code != 0) { showError(result.message); } } catch (IOException e) { showError("服务端未启动或网络异常"); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }这里有两个细节。InterruptedException 不能静默吞掉,捕获后要恢复中断标志,也就是 Thread.currentThread().interrupt(),否则异步任务的中断信号丢失。IOException 分支要区分连接失败和读取超时,连接失败提示“服务端未启动”,读取超时提示“请求超时,请稍后重试”,这两种情况处理方式完全不同。弹窗提示只需要一行 showError,但这一行能阻止九成“点了一下没反应”的困惑。6. 进阶用法:把服务端与客户端打成一套免配置启动包工程在 IDE 里跑通只是第一步。真正要交给同事、放进内网服务器使用的时候,不能让对方先装 JDK、配环境变量、启动两个进程。我的做法是把 Boot 端打成可执行 jar,JavaFX 端也打出可执行包,然后用一个启动脚本按顺序拉起两个进程。Boot 端用 spring-boot-maven-plugin 打成 fat jar,客户端用 javafx-maven-plugin 配合 jlink 打出免安装包。两个产物的默认名称一般长这样:server.jar 和 client。启动脚本的核心逻辑是:先探测服务端健康状态,没启动就后台拉起,等服务端就绪后再启动客户端。#!/bin/bash BASE_DIR=$(cd "$(dirname "$0")" && pwd) LOG_DIR="$BASE_DIR/logs" mkdir -p "$LOG_DIR" if ! curl -s --connect-timeout 2 "http://127.0.0.1:8080/actuator/health" >/dev/null; then nohup java -jar "$BASE_DIR/server.jar" >> "$LOG_DIR/server.log" 2>&1 & echo "服务端启动中,请稍候..." for i in $(seq 1 30); do sleep 1 if curl -s --connect-timeout 2 "http://127.0.0.1:8080/actuator/health" >/dev/null; then break fi done fi java -jar "$BASE_DIR/client.jar" "$@"脚本里的关键点是健康检查。curl 的 connect-timeout 设 2 秒,防止服务端没起来时 curl 自己挂半天;循环最多等 30 秒,给慢机器留足余量,但机器正常的场景下一般三到五秒就能就绪。需要用 actuator 的 /actuator/health 接口,所以服务端 pom 里要加上 spring-boot-starter-actuator 依赖,并在配置文件里暴露 health 端点。如果不引入 actuator,也可以用前面 4.1 的 Socket 探测代替,脚本里换成一段 small Java 程序调用即可。脚本写好后,整个目录就是一个可交付物:server.jar、client.jar、启动脚本、logs 目录。验证方法也简单:双击启动脚本后,浏览器访问 http://127.0.0.1:8080/actuator/health 看到 JSON 返回是 UP,客户端窗口能正常查询数据,这套就绪状态就成立了。从那以后,我每次给别人传这类 Spring Boot + MyBatis + JavaFX 的模板工程,都强制把打包脚本放进根目录,并在交付前完整走一遍“新机器启动流程”——正因为曾经在某台干净的机器上翻过车。希望你拿到这份资源后,也先把启动脚本跑通,再改业务代码。希望帮到你。本文还有配套的精品资源,点击获取