简介:这是一份融合 JavaFX 与 SSM 框架的 ERP 系统完整项目,面向需要将桌面 GUI 与企业级后端整合开发的 Java 学习者、毕业设计或项目实战人群,既能学习界面设计,也能掌握经典后端框架的落地方式。资源共包含 2 个文件:一个主压缩包为 7z 格式,内含工程源码及 FXML、CSS 等界面资源;另附一份 SQL 数据库脚本,用于快速初始化业务表,整体约 75.26MB,已有 3415 人学习/下载。项目以典型 ERP 业务为背景,覆盖 Spring 的依赖注入与事务管理、SpringMVC 的请求调度、Mybatis 的注解与 XML 映射,以及 JavaFX 的 FXML 布局、CSS 美化、TableView 实时刷新与表单双向绑定等关键技术点,能直观看到各层之间的调用关系。7z 压缩包内工程目录组织清晰,SQL 脚本包含初始化数据,导入 IDE 后即可还原演示环境;通过实际代码可以学到桌面端与后端服务如何分层协作,界面设计与持久层映射也能直接复用,是一份适合进阶开发或课题设计的完整参考。1. 这到底是个什么项目:为什么有人会用 JavaFX 去套 SSM第一次见到这个组合的时候我也愣了一下,JavaFX 是桌面客户端技术,SSM 是 Web 后端三板斧,这俩怎么能凑到一个项目里?跑起来才明白,这个项目把 JavaFX 当成「界面渲染器」,把 SSM 当成「数据服务层」,两者通过 HTTP 接口通信。换句话说,这是一个典型的「瘦客户端 + 数据服务端」架构——界面负责展示和交互,业务逻辑和数据访问全部下沉到 SpringMVC 的 Controller 和 Mybatis 的 Mapper 里。这种结构最适合谁?一类是刚学完 SSM 想做点东西却不知道往哪放的同学,另一类是想把老 Web 系统改造成桌面工具的内部开发者。JavaFX 负责把数据变成表格、表单、图表,SSM 负责把 MySQL 里的数据洗干净再吐出来,分工干净,测试也方便。如果你正愁「SSM 学完了只会写网页」,这个项目能给你另一种落地姿势。2. 架构与技术栈:JavaFX 和 SSM 是怎么各司其职的2.1 分层设计:从 FXML 到 Mapper 的数据流整个项目按「两端三层」的方式组织。两端指 JavaFX 客户端和 SSM 服务端,三层指服务端的 Controller、Service、Mapper 三层。客户端不直接连数据库,所有数据操作都通过 HTTP 请求打到 SpringMVC 的接口上,返回 JSON 再渲染到界面。这种设计有一个明显的好处:JavaFX 端的代码只关心「怎么把数据画出来」,服务端只关心「数据从哪来、怎么算」。职责边界清楚了,两个人同时改同一个功能也不会打架。我在二次开发时甚至能把 JavaFX 端整个扔掉,换成 Flutter 或 Vue 的 Web 页面,后端一行不用动——接口定了,前端随便换。服务端的目录结构是这样的:src/main/java/com/example/ssm/ ├── controller/ # SpringMVC 控制器,唯一对外的入口 │ ├── UserController.java │ ├── OrderController.java │ └── StatsController.java ├── service/ │ ├── UserService.java │ ├── OrderService.java │ └── impl/ │ ├── UserServiceImpl.java │ └── OrderServiceImpl.java ├── mapper/ # Mybatis 的 Mapper 接口 │ ├── UserMapper.java │ └── OrderMapper.java ├── entity/ # 数据库实体类 │ ├── User.java │ └── Order.java └── config/ ├── SpringConfig.java └── SpringMvcConfig.java服务端是标准的 SSM 三层。客户端这边,JavaFX 按界面拆分成 FXML 文件和对应的 Controller 类,一个 FXML 对应一个界面模板,一个 Controller 负责一个界面的交互逻辑。2.2 技术选型:为什么用 Mybatis 而不是 JPA这个项目选了 Mybatis 而不是 JPA,老开发一看就懂——Mybatis 对 SQL 的控制是粒度的,尤其是统计类报表,写起来比 JPA 的 Criteria 舒服得多。项目中有一个订单统计模块,要吃聚合函数、日期分组、多表关联,Mybatis 里几行 XML 就搞定了,JPA 那边要折腾半天。Spring 的容器负责把所有 Bean 串起来。SpringMvc 的 DispatcherServlet 拦截 /api 路径的请求,分发到 Controller;Service 层做事务管理;Mapper 层通过 Mybatis 的 SqlSession 操作数据库。集成方式用的是全注解 + 少量 XML,数据源交给 Druid 管理,连接池参数在 jdbc.properties 里调。客户端到服务端的通信,项目里用的是 Spring 自带的 RestTemplate。每个 JavaFX Controller 在初始化时注入一个 RestTemplate 实例,调用后端接口拿数据。这里有个小设计值得学:他把所有接口地址集中放到了一个 ApiUrls 常量类里,前端代码里不会散落一堆字符串 URL,后期接口地址调整一处改完。2.3 关键取舍:JavaFX 端的线程模型处理JavaFX 的界面线程(FX Application Thread)是不能被阻塞的,否则界面直接卡死。这个项目在客户端封装了一个简单的异步工具类,把每一条 HTTP 调用丢到线程池里执行,拿到结果后再通过 Platform.runLater() 切回界面线程刷新数据。这个封装虽然简单,但是是 JavaFX 客户端能不能「用起来」的关键,后面核心代码部分会专门拆开讲。3. 核心模块拆解:一条订单数据从前端到数据库的完整链路3.1 入口与配置:Spring 容器如何与 JavaFX 共存这个项目的启动方式比较特殊,它不是 Web 项目那种丢进 Tomcat 就能跑,也不是纯 JavaFX 的 main 方法直接 launch。它的启动分两步:先初始化 Spring 容器,再启动 JavaFX 的 Application 类。public class MainLauncher { public static void main(String[] args) { // 第一步:初始化 Spring 容器,加载所有 Bean ApplicationContext context = new AnnotationConfigApplicationContext( SpringConfig.class, SpringMvcConfig.class); // 第二步:把 Spring 容器挂到全局,供 JavaFX 的 Controller 使用 SpringContextHolder.setContext(context); // 第三步:启动 JavaFX 界面 Application.launch(FxApplication.class, args); } }这里的逻辑说明两点。第一,Spring 容器必须先生成,否则 JavaFX 界面加载时拿不到 Service 层的 Bean;第二,用 SpringContextHolder 这个静态类做全局桥接,让 FXML 对应的 Controller 类可以在界面的 initialize() 方法里直接取需要的 Bean,这是一种常见的「非 Spring 管理的类里使用 Spring Bean」的做法,在不引入额外框架的前提下最干净。参数说明:SpringConfig.class 是纯注解配置类,负责扫描 service 和 mapper 包,配置数据源与事务管理器;SpringMvcConfig.class 负责启用 SpringMVC 注解驱动和设置 JSON 序列化器。如果你的项目还需要集成其他组件,往这两个 Configuration 类里继续添加 @Bean 即可。3.2 Controller 层设计:REST 接口是前后端的分界线Controller 层是两面胶。对前端来说,它是数据接口;对后端来说,它是忙碌的入口。这个项目里 Controller 写得很克制,每个方法都很薄,主要做三件事:收参数、调 Service、封装返回结果。@RestController @RequestMapping("/api/order") public class OrderController { @Autowired private OrderService orderService; @GetMapping("/list") public Result list(@RequestParam(name = "page", defaultValue = "1") int page, @RequestParam(name = "limit", defaultValue = "10") int limit, @RequestParam(name = "keyword", required = false) String keyword) { // 分页查询订单,keyword 是模糊搜索条件 PageResult<Order> pageResult = orderService.queryPage(page, limit, keyword); return Result.success(pageResult); } @GetMapping("/detail/{id}") public Result detail(@PathVariable("id") Integer id) { // 查询订单详情,包含订单项和关联的用户信息 OrderDetailVO detail = orderService.getDetailById(id); return Result.success(detail); } @PostMapping("/update") public Result update(@RequestBody Order order) { // 更新订单,参数直接是 JSON 体,SpringMVC 自动反序列化 orderService.update(order); return Result.success(null); } }Result 是项目里封装的一个统一返回体,包含 code、message、data 三个字段。这个设计非常有用:JavaFX 端只用判断 code 是否为 200,不用每次解析不同的返回结构。接口路径上的 /api 前缀让前端一眼就知道哪些请求走服务端,如果需要做权限拦截,在 SpringMvcConfig 里配置拦截器只拦 /api/** 就行。这里有个细节值得二次开发时注意:update 方法用的是 @RequestBody,JavaFX 端发请求时必须设置 Content-Type 为 application/json,而且 Order 实体类必须有对应的 setter 方法,Jackson 才能把 JSON 字段塞进去。很多新手在这里踩坑,明明接口签名没问题就是 400,多半是实体类缺了无参构造器或 setter。3.3 Service 层与事务管理:别把业务逻辑写进 ControllerController 薄,Service 层就得厚一点。这个项目的 Service 层做得比较讲究,几个关键场景都有体现,比如订单下单时同时扣库存、写流水,这些操作必须放在同一个事务里。@Service public class OrderServiceImpl implements OrderService { @Autowired private OrderMapper orderMapper; @Autowired private StockMapper stockMapper; @Autowired private FlowMapper flowMapper; @Override @Transactional(rollbackFor = Exception.class) public void createOrder(Order order, List<OrderItem> items) { // 1. 插入订单主表 orderMapper.insertOrder(order); // 2. 插入订单明细 for (OrderItem item : items) { item.setOrderId(order.getId()); orderMapper.insertItem(item); // 3. 扣减库存,注意这里要开启行锁 int rows = stockMapper.deductStock(item.getProductId(), item.getQuantity()); if (rows == 0) { throw new RuntimeException("库存不足:" + item.getProductId()); } } // 4. 写资金流水 flowMapper.insertFlow(buildFlowFromOrder(order)); } }扣库存那步是关键。stockMapper.deductStock 的 SQL 用了 UPDATE ... SET stock = stock - #{quantity} WHERE id = #{productId} AND stock >= #{quantity},返回值是受影响的行数,如果为 0 说明库存不够,直接抛出异常触发事务回滚。这种「乐观锁 + 原子扣减」的写法在库存类业务里是标准做法,避免先查再改带来的并发超卖问题——这也是 SSM 三件套做业务系统时最有含金量的部分,面试时能把这套链路讲清楚,比背一百道八股文管用。事务注解用了 rollbackFor = Exception.class,把运行时异常和受检异常都纳入回滚。我见过不少项目只写 @Transactional 不写 rollbackFor,结果业务抛出 IOException 时事务静默提交,数据就花了。3.4 Mapper 层与动态 SQL:Mybatis 的 XML 怎么写才不乱Mapper 接口和 XML 文件是这个项目的持久层核心。接口定义方法签名,XML 写 SQL 和结果映射。看一段订单分页查询的动态 SQL:<select id="queryPage" resultType="com.example.ssm.entity.Order"> SELECT id, order_no, user_id, total_amount, status, create_time FROM t_order <where> <if test="keyword != null and keyword != ''"> AND (order_no LIKE CONCAT('%', #{keyword}, '%') OR user_id IN (SELECT id FROM t_user WHERE name LIKE CONCAT('%', #{keyword}, '%'))) </if> </where> ORDER BY create_time DESC LIMIT #{offset}, #{limit} </select>标签是 Mybatis 动态 SQL 的常用技巧,它会自动处理条件前缀,如果关键字为空就只查全部,不为空才会追加 AND 子句,省去手动写 WHERE 1=1 这种不那么体面的写法。LIMIT #{offset}, #{limit} 是 MySQL 的分页语法,#{} 是预编译占位符,Mybatis 会生成 PreparedStatement,能有效防止 SQL 注入。这里有一个项目里没用但值得补的建议:如果数据量上来了,不要用 LIMIT 大偏移量分页,改成基于上一页最后一条订单时间或 ID 的键集分页(Keyset Pagination),性能会好一个量级。这属于二次开发时的优化方向,后面会再提到。4. JavaFX 客户端核心逻辑:界面怎么与服务端稳定对接4.1 FXML 与 Controller 绑定:画界面和写逻辑分离JavaFX 端的界面文件用 FXML 编写,类似 HTML 之于网页。FXML 里写控件布局,Controller 类里写交互逻辑,通过 fx:id 和 @FXML 注解关联起来。看一个订单列表界面的 FXML 片段:<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml" fx:controller="com.example.fx.controller.OrderListController"> <HBox spacing="10" padding="10"> <TextField fx:id="keywordField" promptText="输入订单号或客户姓名"/> <Button text="查询" onAction="#handleSearch"/> <Button text="刷新" onAction="#handleRefresh"/> </HBox> <TableView fx:id="orderTable"> <columns> <TableColumn text="订单号" fx:id="orderNoCol"/> <TableColumn text="金额" fx:id="amountCol"/> <TableColumn text="状态" fx:id="statusCol"/> <TableColumn text="下单时间" fx:id="timeCol"/> </columns> </TableView> </VBox>fx:controller 属性把 FXML 文件绑定到 OrderListController 类,fx:id 和 Controller 里标注 @FXML 的字段一一对应。onAction="#handleSearch" 表示查询按钮点击后调用 Controller 类里的 handleSearch 方法。这样设计的好处是,美工调样式改 FXML,开发调逻辑改 Controller,互不干扰。4.2 异步 HTTP 调用:不让界面线程卡死的封装JavaFX 单一界面线程的约束是所有桌面端项目最难受的点,也是这个项目的核心处理逻辑所在。我见过有人直接用 HttpURLConnection 同步调用,一点查询按钮整个界面冻住好几秒,像是程序崩溃了,其实只是网络请求阻塞了 UI 线程。这个项目封装了一个 AsyncHttp 工具类,思路值得借鉴:public class AsyncHttp { private static final RestTemplate restTemplate = new RestTemplate(); // 在后台线程中发送 GET 请求,结束后回调 onSuccess 方法 public static <T> void get(String url, ParameterizedTypeReference<T> type, Consumer<T> onSuccess, Consumer<Exception> onError) { executor.execute(() -> { try { ResponseEntity<T> response = restTemplate.exchange( url, HttpMethod.GET, null, type); // 切回 JavaFX 界面线程执行回调 Platform.runLater(() -> { if (response.getStatusCode() == HttpStatus.OK) { onSuccess.accept(response.getBody()); } }); } catch (Exception e) { Platform.runLater(() -> onError.accept(e)); } }); } private static final ExecutorService executor = Executors.newFixedThreadPool(4); }这个工具类的逻辑说明:executor 是固定大小为 4 的线程池,所有 HTTP 请求在线程池里执行,不占用 JavaFX 的界面线程;拿到响应后用 Platform.runLater() 把回调丢回界面线程,保证 UI 操作安全。RestTemplate.exchange 方法接收一个 ParameterizedTypeReference,这是为了处理泛型返回类型——比如 Result<PageResult > 这种嵌套泛型,如果不传这个参数,Jackson 反序列化时会把泛型信息丢掉,这是很多同学遇到「接口返回正常但前端一直拿不到 List 数据」的根因之一。在多线程代码里有一个常见的坑是:在回调函数里直接更新 TableView 的 items。JavaFX 要求对界面控件的修改必须在 FX 线程上执行,所以项目把 onSuccess 包装在 Platform.runLater() 里再调用。这个封装跨项目通用,我在做其他 JavaFX 项目时也直接复制过。4.3 TableView 数据绑定:把 Service 拿到的列表灌进表格TableView 是 JavaFX 中最常用的数据表格控件,但它的数据绑定方式比 Web 端的表格要绕一些。TableView 本身不直接接收 List,而是接收 ObservableList,这样才能监听数据变化并自动刷新界面。@FXML private TableView<OrderVO> orderTable; @FXML private TableColumn<OrderVO, String> orderNoCol; @FXML private TableColumn<OrderVO, BigDecimal> amountCol; // 数据集合必须是 ObservableList,TableView 会自动监听并刷新 private final ObservableList<OrderVO> dataList = FXCollections.observableArrayList(); @FXML private void handleSearch() { String keyword = keywordField.getText(); String url = ApiUrls.ORDER_LIST + "?page=1&limit=10&keyword=" + URLEncoder.encode(keyword, StandardCharsets.UTF_8); // 清空旧数据,避免界面残留上一条查询结果 dataList.clear(); AsyncHttp.get(url, new ParameterizedTypeReference<Result<PageResult<OrderVO>>>() {}, result -> { // 从服务端返回的分页对象中取出列表数据 dataList.addAll(result.getData().getRecords()); }, error -> { error.printStackTrace(); // 生产环境应该在这里弹出 Alert 提示用户 }); } @FXML private void initialize() { // 把每个列绑定到 OrderVO 的属性名 orderNoCol.setCellValueFactory(new PropertyValueFactory<>("orderNo")); amountCol.setCellValueFactory(new PropertyValueFactory<>("totalAmount")); statusCol.setCellValueFactory(new PropertyValueFactory<>("status")); orderTable.setItems(dataList); // 启动后自动加载一次数据 handleRefresh(); }这段代码有两个需要留意的节点。第一,PropertyValueFactory 通过反射调用 OrderVO 的 getOrderNo() 等方法取数,所以 OrderVO 属性名要和 FXML 里的字符串对应,而且必须有 getter,否则表格列会一直空白不报错,这种情况排查起来很头疼,从这入手能很快定位;第二,URL 中的中文关键字必须先 URLEncoder.encode,否则服务端接收到的参数是乱码,有时候能查到值有时候查不到,就是因为编码不一致,这是个经典翻车点。5. 运行环境与避坑:把这套项目拉起来需要踩过哪些坑5.1 从零启动:数据库初始化到界面弹窗的完整步骤先把运行环境讲清楚。JDK 推荐 8 或 11,JavaFX 在 JDK 8 里是自带组件,到 JDK 11 之后变成了独立模块,需要额外引入 org.openjfx:javafx-controls 依赖。Maven 项目直接在 pom.xml 里声明即可。数据库用 MySQL 5.7 以上版本,项目带了一个 init.sql 初始化脚本,建库建表,另外配好 Druid 连接池的连接信息。# 第一步:初始化数据库 mysql -uroot -p123456 < init.sql # 第二步:修改数据库连接配置 vim src/main/resources/jdbc.properties # 第三步:启动服务端(先启动 Spring 容器) mvn spring-boot:run # 第四步:启动客户端(Launch MainLauncher 即可) mvn javafx:run服务端如果用的是内嵌 Tomcat,启动后默认监听 8080 端口。JavaFX 客户端的 ApiUrls 常量类里 BaseUrl 默认是 http://localhost:8080,如果服务端部署在远程机器,只需要改这一处常量,不用动任何其他代码。这是我比较喜欢这个项目的原因之一——接口集中定义了,运行环境的差异被压到了最小,改动成本变得很低。5.2 常见问题排查:从端口冲突到中文乱码到泛型丢失这个项目能跑通不算难,真正恼人的是那些「环境没问题但就是跑不出数据」的场景,我把在调试中遇到过的问题整理成了排查清单。现象一:服务端日志显示请求进来了,但 JavaFX 界面一直转圈没有数据。原因多半是 RestTemplate 请求线程抛了异常,但异常被吞掉没有反馈到界面。解决方法是先看后台线程的 error 回调有没有被调用,在 onError 里加日志输出。这是我见过最多的一个隐蔽问题,排查时要强制走通这条回调链路再谈其他。现象二:窗口打开了,Table 列表数据加载不出来,但后端 Postman 测接口有返回。这个现象让人摸不着头脑。原因基本出在泛型反序列化丢失上。如果直接用 RestTemplate.getForObject(url, Result.class) 接收返回值,Result 里的 data 字段会被转成 LinkedHashMap,取不到 Order 对象。解决方法是必须用 ParameterizedTypeReference 带完整的泛型信息去请求,或者对 Result 的 data 字段做手动 TypeReference 转换。写 RestTemplate 的时候要养成「带泛型信息请求」的习惯,可以避免很多类似问题。现象三:前端提交中文数据,服务端接收乱码。原因有两层:第一层是 JavaFX 端 URL 参数没有编码,第二层是 SpringMVC 的字符集过滤器没有配置。解决方法是前端用 URLEncoder.encode 编码,后端在 web.xml 或 SpringBoot 的配置类里加 CharacterEncodingFilter,强制 UTF-8。我遇到过前后端都检查了还是乱码的情况,最后发现是 MySQL 表字段的 collation 是 latin1 开头,建表时指定 ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 才能彻底解决。现象四:Maven 编译报错 JavaFX 类找不到。原因是在 JDK 11 及以上版本没有引入 JavaFX 依赖时,代码里 import javafx.* 会直接编译失败。解决办法是 pom.xml 增加 org.openjfx 依赖并配置 maven-compiler-plugin,同时注意 Windows/Mac/Linux 的 classifier 不同。当然,有的项目为了省事直接退回 JDK 8,倒也是个可行的方案,但长期维护还是建议按标准方式配好依赖。5.3 开发阶段的调试技巧:日志和接口文档两手都要硬开发这套项目时,建立一个简单的调试习惯能省不少时间。服务端接口用 Postman 先测过一轮,确认返回 JSON 结构再动前端;前端调不通的时候,不要盲目猜,先看服务端控制台有没有报错,再看返回的 JSON 结构是否符合前端 parse 的预期,最后才怀疑是不是界面线程的问题。6. 二次开发思路:围绕这套骨架扩展新功能的具体做法6.1 从订单模块扩展到报表模块这个项目最值得改的切入点是把现有订单数据变成统计报表。服务端新增一个报表 Controller,JavaFX 端使用 BarChart 控件展示柱状图,整条链路走的是项目里已经跑通的分层套路,不需要引入额外框架。@RestController @RequestMapping("/api/stats") public class StatsController { @Autowired private StatsService statsService; @GetMapping("/monthly") public Result monthly(@RequestParam("year") Integer year) { // 返回 1 到 12 月订单总额,供前端图表展示 List<Map<String, Object>> data = statsService.monthlySales(year); return Result.success(data); } }Service 层的实现用一句 SQL 就能聚合出月度销售数据,比 Java 内存里循环统计高效得多,这正是 Mybatis 适合干的事。JavaFX 端把 AsyncHttp.get() 请求的结果灌进 XYChart.Series,再把 Series 加到 BarChart 上,十几行代码就能做出一张月度营收趋势图。如果业务上还需要导出 Excel,可以在服务端集成一个 EasyExcel 依赖,新增一个导出接口,复用现成的查询逻辑。6.2 给服务端加上权限控制项目当前的接口是裸奔的,谁拿到地址就能调。如果要把这套骨架用于内部工具或生产环境,建议第一件事就是加登录认证。技术路线上最轻量的是拦截器加 Token。public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 从请求头中取 Token,校验通过才放行 String token = request.getHeader("Authorization"); if (StringUtils.isEmpty(token) || !TokenStore.validate(token)) { response.setStatus(HttpStatus.UNAUTHORIZED.value()); return false; } return true; } }TokenStore 可以是 Redis 的封装或内存 Map,取决于部署规模。JavaFX 客户端那边,只需要在发送请求时从一个全局登录会话里取 Token,塞进请求头。两个端改动各集中在一处,因为这属于跨切面的横切关注点,可以放到已有的工具类中集中处理,不需要每个界面改一遍,这也体现了项目分层合理的优势。6.3 替换 RestTemplate 为轻量级 HttpClient如果客户端请求量比较大,或者需要做连接复用、超时控制,可以考虑把 RestTemplate 换成 java.net.http.HttpClient 或 OkHttp。项目里所有 HTTP 调用都走了 AsyncHttp 工具类,所以替换时只需要改这一个类——这也说明当初的封装给二次开发留了后路。private static final java.net.http.HttpClient httpClient = java.net.http.HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(5)) .build(); private static <T> void getHttpClient(String url, TypeReference<T> type, Consumer<T> onSuccess, Consumer<Exception> onError) { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(url)) .timeout(Duration.ofSeconds(15)) .GET() .build(); httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()) .thenApply(HttpResponse::body) .thenApply(json -> JsonUtils.parse(json, type)) .whenComplete((data, throwable) -> { Platform.runLater(() -> { if (throwable != null) { onError.accept(throwable); } else { onSuccess.accept(data); } }); }); }JDK 自带的 HttpClient 在 JDK 11 以后 API 已经是稳定的了,配合同步转异步的 CompletableFuture,代码甚至比 RestTemplate 还要干净一些。注意 JavaFX 的界面刷新仍然要包在 Platform.runLater() 里,这是绕不开的原则。换完以后,原有的调用方代码一行都不用动,全链路也就十分钟的事。6.4 改造后的验证方法改动完成后,先按「查询订单 → 打开详情 → 修改金额 → 刷新列表」这条主链路回归一遍。如果改了通信层,用抓包工具看一眼请求格式,确认 Content-Type 和参数名没变。如果改了数据库表结构,重点看 Mapper XML 里 SQL 的字段引用是否同步更新了。这套项目骨架的可贵之处在于,服务端和客户端之间隔了一条清晰的 HTTP 接口线。我后来再做桌面工具项目时,始终保持着这条线的纪律——接口先定、双边各自 mock、联调阶段再对齐边界。从那以后我每次启动新功能都会强制走一遍「Controller 接口签名先行,Service 实现跟进,JavaFX 端最后填充」的流程,翻车次数就肉眼可见地少了。希望这次的拆解也能帮你把这套代码顺顺当当跑起来,改出你自己想要的效果。本文还有配套的精品资源,点击获取
简介:这是一份融合 JavaFX 与 SSM 框架的 ERP 系统完整项目,面向需要将桌面 GUI 与企业级后端整合开发的 Java 学习者、毕业设计或项目实战人群,既能学习界面设计,也能掌握经典后端框架的落地方式。资源共包含 2 个文件:一个主压缩包为 7z 格式,内含工程源码及 FXML、CSS 等界面资源;另附一份 SQL 数据库脚本,用于快速初始化业务表,整体约 75.26MB,已有 3415 人学习/下载。项目以典型 ERP 业务为背景,覆盖 Spring 的依赖注入与事务管理、SpringMVC 的请求调度、Mybatis 的注解与 XML 映射,以及 JavaFX 的 FXML 布局、CSS 美化、TableView 实时刷新与表单双向绑定等关键技术点,能直观看到各层之间的调用关系。7z 压缩包内工程目录组织清晰,SQL 脚本包含初始化数据,导入 IDE 后即可还原演示环境;通过实际代码可以学到桌面端与后端服务如何分层协作,界面设计与持久层映射也能直接复用,是一份适合进阶开发或课题设计的完整参考。1. 这到底是个什么项目:为什么有人会用 JavaFX 去套 SSM第一次见到这个组合的时候我也愣了一下,JavaFX 是桌面客户端技术,SSM 是 Web 后端三板斧,这俩怎么能凑到一个项目里?跑起来才明白,这个项目把 JavaFX 当成「界面渲染器」,把 SSM 当成「数据服务层」,两者通过 HTTP 接口通信。换句话说,这是一个典型的「瘦客户端 + 数据服务端」架构——界面负责展示和交互,业务逻辑和数据访问全部下沉到 SpringMVC 的 Controller 和 Mybatis 的 Mapper 里。这种结构最适合谁?一类是刚学完 SSM 想做点东西却不知道往哪放的同学,另一类是想把老 Web 系统改造成桌面工具的内部开发者。JavaFX 负责把数据变成表格、表单、图表,SSM 负责把 MySQL 里的数据洗干净再吐出来,分工干净,测试也方便。如果你正愁「SSM 学完了只会写网页」,这个项目能给你另一种落地姿势。2. 架构与技术栈:JavaFX 和 SSM 是怎么各司其职的2.1 分层设计:从 FXML 到 Mapper 的数据流整个项目按「两端三层」的方式组织。两端指 JavaFX 客户端和 SSM 服务端,三层指服务端的 Controller、Service、Mapper 三层。客户端不直接连数据库,所有数据操作都通过 HTTP 请求打到 SpringMVC 的接口上,返回 JSON 再渲染到界面。这种设计有一个明显的好处:JavaFX 端的代码只关心「怎么把数据画出来」,服务端只关心「数据从哪来、怎么算」。职责边界清楚了,两个人同时改同一个功能也不会打架。我在二次开发时甚至能把 JavaFX 端整个扔掉,换成 Flutter 或 Vue 的 Web 页面,后端一行不用动——接口定了,前端随便换。服务端的目录结构是这样的:src/main/java/com/example/ssm/ ├── controller/ # SpringMVC 控制器,唯一对外的入口 │ ├── UserController.java │ ├── OrderController.java │ └── StatsController.java ├── service/ │ ├── UserService.java │ ├── OrderService.java │ └── impl/ │ ├── UserServiceImpl.java │ └── OrderServiceImpl.java ├── mapper/ # Mybatis 的 Mapper 接口 │ ├── UserMapper.java │ └── OrderMapper.java ├── entity/ # 数据库实体类 │ ├── User.java │ └── Order.java └── config/ ├── SpringConfig.java └── SpringMvcConfig.java服务端是标准的 SSM 三层。客户端这边,JavaFX 按界面拆分成 FXML 文件和对应的 Controller 类,一个 FXML 对应一个界面模板,一个 Controller 负责一个界面的交互逻辑。2.2 技术选型:为什么用 Mybatis 而不是 JPA这个项目选了 Mybatis 而不是 JPA,老开发一看就懂——Mybatis 对 SQL 的控制是粒度的,尤其是统计类报表,写起来比 JPA 的 Criteria 舒服得多。项目中有一个订单统计模块,要吃聚合函数、日期分组、多表关联,Mybatis 里几行 XML 就搞定了,JPA 那边要折腾半天。Spring 的容器负责把所有 Bean 串起来。SpringMvc 的 DispatcherServlet 拦截 /api 路径的请求,分发到 Controller;Service 层做事务管理;Mapper 层通过 Mybatis 的 SqlSession 操作数据库。集成方式用的是全注解 + 少量 XML,数据源交给 Druid 管理,连接池参数在 jdbc.properties 里调。客户端到服务端的通信,项目里用的是 Spring 自带的 RestTemplate。每个 JavaFX Controller 在初始化时注入一个 RestTemplate 实例,调用后端接口拿数据。这里有个小设计值得学:他把所有接口地址集中放到了一个 ApiUrls 常量类里,前端代码里不会散落一堆字符串 URL,后期接口地址调整一处改完。2.3 关键取舍:JavaFX 端的线程模型处理JavaFX 的界面线程(FX Application Thread)是不能被阻塞的,否则界面直接卡死。这个项目在客户端封装了一个简单的异步工具类,把每一条 HTTP 调用丢到线程池里执行,拿到结果后再通过 Platform.runLater() 切回界面线程刷新数据。这个封装虽然简单,但是是 JavaFX 客户端能不能「用起来」的关键,后面核心代码部分会专门拆开讲。3. 核心模块拆解:一条订单数据从前端到数据库的完整链路3.1 入口与配置:Spring 容器如何与 JavaFX 共存这个项目的启动方式比较特殊,它不是 Web 项目那种丢进 Tomcat 就能跑,也不是纯 JavaFX 的 main 方法直接 launch。它的启动分两步:先初始化 Spring 容器,再启动 JavaFX 的 Application 类。public class MainLauncher { public static void main(String[] args) { // 第一步:初始化 Spring 容器,加载所有 Bean ApplicationContext context = new AnnotationConfigApplicationContext( SpringConfig.class, SpringMvcConfig.class); // 第二步:把 Spring 容器挂到全局,供 JavaFX 的 Controller 使用 SpringContextHolder.setContext(context); // 第三步:启动 JavaFX 界面 Application.launch(FxApplication.class, args); } }这里的逻辑说明两点。第一,Spring 容器必须先生成,否则 JavaFX 界面加载时拿不到 Service 层的 Bean;第二,用 SpringContextHolder 这个静态类做全局桥接,让 FXML 对应的 Controller 类可以在界面的 initialize() 方法里直接取需要的 Bean,这是一种常见的「非 Spring 管理的类里使用 Spring Bean」的做法,在不引入额外框架的前提下最干净。参数说明:SpringConfig.class 是纯注解配置类,负责扫描 service 和 mapper 包,配置数据源与事务管理器;SpringMvcConfig.class 负责启用 SpringMVC 注解驱动和设置 JSON 序列化器。如果你的项目还需要集成其他组件,往这两个 Configuration 类里继续添加 @Bean 即可。3.2 Controller 层设计:REST 接口是前后端的分界线Controller 层是两面胶。对前端来说,它是数据接口;对后端来说,它是忙碌的入口。这个项目里 Controller 写得很克制,每个方法都很薄,主要做三件事:收参数、调 Service、封装返回结果。@RestController @RequestMapping("/api/order") public class OrderController { @Autowired private OrderService orderService; @GetMapping("/list") public Result list(@RequestParam(name = "page", defaultValue = "1") int page, @RequestParam(name = "limit", defaultValue = "10") int limit, @RequestParam(name = "keyword", required = false) String keyword) { // 分页查询订单,keyword 是模糊搜索条件 PageResult<Order> pageResult = orderService.queryPage(page, limit, keyword); return Result.success(pageResult); } @GetMapping("/detail/{id}") public Result detail(@PathVariable("id") Integer id) { // 查询订单详情,包含订单项和关联的用户信息 OrderDetailVO detail = orderService.getDetailById(id); return Result.success(detail); } @PostMapping("/update") public Result update(@RequestBody Order order) { // 更新订单,参数直接是 JSON 体,SpringMVC 自动反序列化 orderService.update(order); return Result.success(null); } }Result 是项目里封装的一个统一返回体,包含 code、message、data 三个字段。这个设计非常有用:JavaFX 端只用判断 code 是否为 200,不用每次解析不同的返回结构。接口路径上的 /api 前缀让前端一眼就知道哪些请求走服务端,如果需要做权限拦截,在 SpringMvcConfig 里配置拦截器只拦 /api/** 就行。这里有个细节值得二次开发时注意:update 方法用的是 @RequestBody,JavaFX 端发请求时必须设置 Content-Type 为 application/json,而且 Order 实体类必须有对应的 setter 方法,Jackson 才能把 JSON 字段塞进去。很多新手在这里踩坑,明明接口签名没问题就是 400,多半是实体类缺了无参构造器或 setter。3.3 Service 层与事务管理:别把业务逻辑写进 ControllerController 薄,Service 层就得厚一点。这个项目的 Service 层做得比较讲究,几个关键场景都有体现,比如订单下单时同时扣库存、写流水,这些操作必须放在同一个事务里。@Service public class OrderServiceImpl implements OrderService { @Autowired private OrderMapper orderMapper; @Autowired private StockMapper stockMapper; @Autowired private FlowMapper flowMapper; @Override @Transactional(rollbackFor = Exception.class) public void createOrder(Order order, List<OrderItem> items) { // 1. 插入订单主表 orderMapper.insertOrder(order); // 2. 插入订单明细 for (OrderItem item : items) { item.setOrderId(order.getId()); orderMapper.insertItem(item); // 3. 扣减库存,注意这里要开启行锁 int rows = stockMapper.deductStock(item.getProductId(), item.getQuantity()); if (rows == 0) { throw new RuntimeException("库存不足:" + item.getProductId()); } } // 4. 写资金流水 flowMapper.insertFlow(buildFlowFromOrder(order)); } }扣库存那步是关键。stockMapper.deductStock 的 SQL 用了 UPDATE ... SET stock = stock - #{quantity} WHERE id = #{productId} AND stock >= #{quantity},返回值是受影响的行数,如果为 0 说明库存不够,直接抛出异常触发事务回滚。这种「乐观锁 + 原子扣减」的写法在库存类业务里是标准做法,避免先查再改带来的并发超卖问题——这也是 SSM 三件套做业务系统时最有含金量的部分,面试时能把这套链路讲清楚,比背一百道八股文管用。事务注解用了 rollbackFor = Exception.class,把运行时异常和受检异常都纳入回滚。我见过不少项目只写 @Transactional 不写 rollbackFor,结果业务抛出 IOException 时事务静默提交,数据就花了。3.4 Mapper 层与动态 SQL:Mybatis 的 XML 怎么写才不乱Mapper 接口和 XML 文件是这个项目的持久层核心。接口定义方法签名,XML 写 SQL 和结果映射。看一段订单分页查询的动态 SQL:<select id="queryPage" resultType="com.example.ssm.entity.Order"> SELECT id, order_no, user_id, total_amount, status, create_time FROM t_order <where> <if test="keyword != null and keyword != ''"> AND (order_no LIKE CONCAT('%', #{keyword}, '%') OR user_id IN (SELECT id FROM t_user WHERE name LIKE CONCAT('%', #{keyword}, '%'))) </if> </where> ORDER BY create_time DESC LIMIT #{offset}, #{limit} </select>标签是 Mybatis 动态 SQL 的常用技巧,它会自动处理条件前缀,如果关键字为空就只查全部,不为空才会追加 AND 子句,省去手动写 WHERE 1=1 这种不那么体面的写法。LIMIT #{offset}, #{limit} 是 MySQL 的分页语法,#{} 是预编译占位符,Mybatis 会生成 PreparedStatement,能有效防止 SQL 注入。这里有一个项目里没用但值得补的建议:如果数据量上来了,不要用 LIMIT 大偏移量分页,改成基于上一页最后一条订单时间或 ID 的键集分页(Keyset Pagination),性能会好一个量级。这属于二次开发时的优化方向,后面会再提到。4. JavaFX 客户端核心逻辑:界面怎么与服务端稳定对接4.1 FXML 与 Controller 绑定:画界面和写逻辑分离JavaFX 端的界面文件用 FXML 编写,类似 HTML 之于网页。FXML 里写控件布局,Controller 类里写交互逻辑,通过 fx:id 和 @FXML 注解关联起来。看一个订单列表界面的 FXML 片段:<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml" fx:controller="com.example.fx.controller.OrderListController"> <HBox spacing="10" padding="10"> <TextField fx:id="keywordField" promptText="输入订单号或客户姓名"/> <Button text="查询" onAction="#handleSearch"/> <Button text="刷新" onAction="#handleRefresh"/> </HBox> <TableView fx:id="orderTable"> <columns> <TableColumn text="订单号" fx:id="orderNoCol"/> <TableColumn text="金额" fx:id="amountCol"/> <TableColumn text="状态" fx:id="statusCol"/> <TableColumn text="下单时间" fx:id="timeCol"/> </columns> </TableView> </VBox>fx:controller 属性把 FXML 文件绑定到 OrderListController 类,fx:id 和 Controller 里标注 @FXML 的字段一一对应。onAction="#handleSearch" 表示查询按钮点击后调用 Controller 类里的 handleSearch 方法。这样设计的好处是,美工调样式改 FXML,开发调逻辑改 Controller,互不干扰。4.2 异步 HTTP 调用:不让界面线程卡死的封装JavaFX 单一界面线程的约束是所有桌面端项目最难受的点,也是这个项目的核心处理逻辑所在。我见过有人直接用 HttpURLConnection 同步调用,一点查询按钮整个界面冻住好几秒,像是程序崩溃了,其实只是网络请求阻塞了 UI 线程。这个项目封装了一个 AsyncHttp 工具类,思路值得借鉴:public class AsyncHttp { private static final RestTemplate restTemplate = new RestTemplate(); // 在后台线程中发送 GET 请求,结束后回调 onSuccess 方法 public static <T> void get(String url, ParameterizedTypeReference<T> type, Consumer<T> onSuccess, Consumer<Exception> onError) { executor.execute(() -> { try { ResponseEntity<T> response = restTemplate.exchange( url, HttpMethod.GET, null, type); // 切回 JavaFX 界面线程执行回调 Platform.runLater(() -> { if (response.getStatusCode() == HttpStatus.OK) { onSuccess.accept(response.getBody()); } }); } catch (Exception e) { Platform.runLater(() -> onError.accept(e)); } }); } private static final ExecutorService executor = Executors.newFixedThreadPool(4); }这个工具类的逻辑说明:executor 是固定大小为 4 的线程池,所有 HTTP 请求在线程池里执行,不占用 JavaFX 的界面线程;拿到响应后用 Platform.runLater() 把回调丢回界面线程,保证 UI 操作安全。RestTemplate.exchange 方法接收一个 ParameterizedTypeReference,这是为了处理泛型返回类型——比如 Result<PageResult > 这种嵌套泛型,如果不传这个参数,Jackson 反序列化时会把泛型信息丢掉,这是很多同学遇到「接口返回正常但前端一直拿不到 List 数据」的根因之一。在多线程代码里有一个常见的坑是:在回调函数里直接更新 TableView 的 items。JavaFX 要求对界面控件的修改必须在 FX 线程上执行,所以项目把 onSuccess 包装在 Platform.runLater() 里再调用。这个封装跨项目通用,我在做其他 JavaFX 项目时也直接复制过。4.3 TableView 数据绑定:把 Service 拿到的列表灌进表格TableView 是 JavaFX 中最常用的数据表格控件,但它的数据绑定方式比 Web 端的表格要绕一些。TableView 本身不直接接收 List,而是接收 ObservableList,这样才能监听数据变化并自动刷新界面。@FXML private TableView<OrderVO> orderTable; @FXML private TableColumn<OrderVO, String> orderNoCol; @FXML private TableColumn<OrderVO, BigDecimal> amountCol; // 数据集合必须是 ObservableList,TableView 会自动监听并刷新 private final ObservableList<OrderVO> dataList = FXCollections.observableArrayList(); @FXML private void handleSearch() { String keyword = keywordField.getText(); String url = ApiUrls.ORDER_LIST + "?page=1&limit=10&keyword=" + URLEncoder.encode(keyword, StandardCharsets.UTF_8); // 清空旧数据,避免界面残留上一条查询结果 dataList.clear(); AsyncHttp.get(url, new ParameterizedTypeReference<Result<PageResult<OrderVO>>>() {}, result -> { // 从服务端返回的分页对象中取出列表数据 dataList.addAll(result.getData().getRecords()); }, error -> { error.printStackTrace(); // 生产环境应该在这里弹出 Alert 提示用户 }); } @FXML private void initialize() { // 把每个列绑定到 OrderVO 的属性名 orderNoCol.setCellValueFactory(new PropertyValueFactory<>("orderNo")); amountCol.setCellValueFactory(new PropertyValueFactory<>("totalAmount")); statusCol.setCellValueFactory(new PropertyValueFactory<>("status")); orderTable.setItems(dataList); // 启动后自动加载一次数据 handleRefresh(); }这段代码有两个需要留意的节点。第一,PropertyValueFactory 通过反射调用 OrderVO 的 getOrderNo() 等方法取数,所以 OrderVO 属性名要和 FXML 里的字符串对应,而且必须有 getter,否则表格列会一直空白不报错,这种情况排查起来很头疼,从这入手能很快定位;第二,URL 中的中文关键字必须先 URLEncoder.encode,否则服务端接收到的参数是乱码,有时候能查到值有时候查不到,就是因为编码不一致,这是个经典翻车点。5. 运行环境与避坑:把这套项目拉起来需要踩过哪些坑5.1 从零启动:数据库初始化到界面弹窗的完整步骤先把运行环境讲清楚。JDK 推荐 8 或 11,JavaFX 在 JDK 8 里是自带组件,到 JDK 11 之后变成了独立模块,需要额外引入 org.openjfx:javafx-controls 依赖。Maven 项目直接在 pom.xml 里声明即可。数据库用 MySQL 5.7 以上版本,项目带了一个 init.sql 初始化脚本,建库建表,另外配好 Druid 连接池的连接信息。# 第一步:初始化数据库 mysql -uroot -p123456 < init.sql # 第二步:修改数据库连接配置 vim src/main/resources/jdbc.properties # 第三步:启动服务端(先启动 Spring 容器) mvn spring-boot:run # 第四步:启动客户端(Launch MainLauncher 即可) mvn javafx:run服务端如果用的是内嵌 Tomcat,启动后默认监听 8080 端口。JavaFX 客户端的 ApiUrls 常量类里 BaseUrl 默认是 http://localhost:8080,如果服务端部署在远程机器,只需要改这一处常量,不用动任何其他代码。这是我比较喜欢这个项目的原因之一——接口集中定义了,运行环境的差异被压到了最小,改动成本变得很低。5.2 常见问题排查:从端口冲突到中文乱码到泛型丢失这个项目能跑通不算难,真正恼人的是那些「环境没问题但就是跑不出数据」的场景,我把在调试中遇到过的问题整理成了排查清单。现象一:服务端日志显示请求进来了,但 JavaFX 界面一直转圈没有数据。原因多半是 RestTemplate 请求线程抛了异常,但异常被吞掉没有反馈到界面。解决方法是先看后台线程的 error 回调有没有被调用,在 onError 里加日志输出。这是我见过最多的一个隐蔽问题,排查时要强制走通这条回调链路再谈其他。现象二:窗口打开了,Table 列表数据加载不出来,但后端 Postman 测接口有返回。这个现象让人摸不着头脑。原因基本出在泛型反序列化丢失上。如果直接用 RestTemplate.getForObject(url, Result.class) 接收返回值,Result 里的 data 字段会被转成 LinkedHashMap,取不到 Order 对象。解决方法是必须用 ParameterizedTypeReference 带完整的泛型信息去请求,或者对 Result 的 data 字段做手动 TypeReference 转换。写 RestTemplate 的时候要养成「带泛型信息请求」的习惯,可以避免很多类似问题。现象三:前端提交中文数据,服务端接收乱码。原因有两层:第一层是 JavaFX 端 URL 参数没有编码,第二层是 SpringMVC 的字符集过滤器没有配置。解决方法是前端用 URLEncoder.encode 编码,后端在 web.xml 或 SpringBoot 的配置类里加 CharacterEncodingFilter,强制 UTF-8。我遇到过前后端都检查了还是乱码的情况,最后发现是 MySQL 表字段的 collation 是 latin1 开头,建表时指定 ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 才能彻底解决。现象四:Maven 编译报错 JavaFX 类找不到。原因是在 JDK 11 及以上版本没有引入 JavaFX 依赖时,代码里 import javafx.* 会直接编译失败。解决办法是 pom.xml 增加 org.openjfx 依赖并配置 maven-compiler-plugin,同时注意 Windows/Mac/Linux 的 classifier 不同。当然,有的项目为了省事直接退回 JDK 8,倒也是个可行的方案,但长期维护还是建议按标准方式配好依赖。5.3 开发阶段的调试技巧:日志和接口文档两手都要硬开发这套项目时,建立一个简单的调试习惯能省不少时间。服务端接口用 Postman 先测过一轮,确认返回 JSON 结构再动前端;前端调不通的时候,不要盲目猜,先看服务端控制台有没有报错,再看返回的 JSON 结构是否符合前端 parse 的预期,最后才怀疑是不是界面线程的问题。6. 二次开发思路:围绕这套骨架扩展新功能的具体做法6.1 从订单模块扩展到报表模块这个项目最值得改的切入点是把现有订单数据变成统计报表。服务端新增一个报表 Controller,JavaFX 端使用 BarChart 控件展示柱状图,整条链路走的是项目里已经跑通的分层套路,不需要引入额外框架。@RestController @RequestMapping("/api/stats") public class StatsController { @Autowired private StatsService statsService; @GetMapping("/monthly") public Result monthly(@RequestParam("year") Integer year) { // 返回 1 到 12 月订单总额,供前端图表展示 List<Map<String, Object>> data = statsService.monthlySales(year); return Result.success(data); } }Service 层的实现用一句 SQL 就能聚合出月度销售数据,比 Java 内存里循环统计高效得多,这正是 Mybatis 适合干的事。JavaFX 端把 AsyncHttp.get() 请求的结果灌进 XYChart.Series,再把 Series 加到 BarChart 上,十几行代码就能做出一张月度营收趋势图。如果业务上还需要导出 Excel,可以在服务端集成一个 EasyExcel 依赖,新增一个导出接口,复用现成的查询逻辑。6.2 给服务端加上权限控制项目当前的接口是裸奔的,谁拿到地址就能调。如果要把这套骨架用于内部工具或生产环境,建议第一件事就是加登录认证。技术路线上最轻量的是拦截器加 Token。public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 从请求头中取 Token,校验通过才放行 String token = request.getHeader("Authorization"); if (StringUtils.isEmpty(token) || !TokenStore.validate(token)) { response.setStatus(HttpStatus.UNAUTHORIZED.value()); return false; } return true; } }TokenStore 可以是 Redis 的封装或内存 Map,取决于部署规模。JavaFX 客户端那边,只需要在发送请求时从一个全局登录会话里取 Token,塞进请求头。两个端改动各集中在一处,因为这属于跨切面的横切关注点,可以放到已有的工具类中集中处理,不需要每个界面改一遍,这也体现了项目分层合理的优势。6.3 替换 RestTemplate 为轻量级 HttpClient如果客户端请求量比较大,或者需要做连接复用、超时控制,可以考虑把 RestTemplate 换成 java.net.http.HttpClient 或 OkHttp。项目里所有 HTTP 调用都走了 AsyncHttp 工具类,所以替换时只需要改这一个类——这也说明当初的封装给二次开发留了后路。private static final java.net.http.HttpClient httpClient = java.net.http.HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(5)) .build(); private static <T> void getHttpClient(String url, TypeReference<T> type, Consumer<T> onSuccess, Consumer<Exception> onError) { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(url)) .timeout(Duration.ofSeconds(15)) .GET() .build(); httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()) .thenApply(HttpResponse::body) .thenApply(json -> JsonUtils.parse(json, type)) .whenComplete((data, throwable) -> { Platform.runLater(() -> { if (throwable != null) { onError.accept(throwable); } else { onSuccess.accept(data); } }); }); }JDK 自带的 HttpClient 在 JDK 11 以后 API 已经是稳定的了,配合同步转异步的 CompletableFuture,代码甚至比 RestTemplate 还要干净一些。注意 JavaFX 的界面刷新仍然要包在 Platform.runLater() 里,这是绕不开的原则。换完以后,原有的调用方代码一行都不用动,全链路也就十分钟的事。6.4 改造后的验证方法改动完成后,先按「查询订单 → 打开详情 → 修改金额 → 刷新列表」这条主链路回归一遍。如果改了通信层,用抓包工具看一眼请求格式,确认 Content-Type 和参数名没变。如果改了数据库表结构,重点看 Mapper XML 里 SQL 的字段引用是否同步更新了。这套项目骨架的可贵之处在于,服务端和客户端之间隔了一条清晰的 HTTP 接口线。我后来再做桌面工具项目时,始终保持着这条线的纪律——接口先定、双边各自 mock、联调阶段再对齐边界。从那以后我每次启动新功能都会强制走一遍「Controller 接口签名先行,Service 实现跟进,JavaFX 端最后填充」的流程,翻车次数就肉眼可见地少了。希望这次的拆解也能帮你把这套代码顺顺当当跑起来,改出你自己想要的效果。本文还有配套的精品资源,点击获取