ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SpringBoot+JavaFX+MyBatis本地桌面应用实战

SpringBoot+JavaFX+MyBatis本地桌面应用实战

简介:本资源是一个基于SpringBoot+MyBatis+JavaFX构建的轻量级档案扫描管理桌面应用,面向Java全栈初学者与桌面端项目实践者,聚焦于解决传统档案数字化过程中服务端逻辑、数据库交互与本地GUI界面协同开发的学习痛点。压缩包共130个文件,含24个核心Java源码(如LoginStageController、BusDocumentInfo等)、65个XML配置/映射文件(支撑Spring自动装配与MyBatis SQL绑定)、4个FXML界面定义文件(承载JavaFX视图层结构)以及CSS样式、Properties配置等,整体仅146KB,结构紧凑、模块职责清晰。已有4208人学习下载,适合通过小而完整的项目理解三层架构落地:用SpringBoot快速搭建内嵌Web服务与依赖管理,借助MyBatis实现Oracle数据库的增删改查与事务控制,并依托JavaFX构建跨平台、响应式的扫描操作界面。

1. 这不是个“Java桌面程序”Demo,而是一套能跑通完整业务闭环的本地化管理系统:SpringBoot做后端API、MyBatis管数据持久化、JavaFX搭交互界面,三者在单机环境里真正拧成一股绳

你见过多少所谓“SpringBoot + JavaFX”项目?点开一看,要么是JavaFX窗体里硬塞了个RestTemplate调测试接口,后端连@RestController都没写全;要么是MyBatis只配了application.yml,连Mapper.xml都空着,更别说事务和分页。这个项目不一样——它把一个真实可运行的「本地库存管理」场景从头到尾串起来了:JavaFX主界面点击“新增商品”,触发HTTP请求打到本机8080端口的SpringBoot Controller;Controller调用Service层,Service用MyBatis的@SelectProvider动态拼SQL查数据库(H2嵌入式);新增成功后,JavaFX表格实时刷新,连带库存预警颜色自动变红/绿。整个链路不依赖外部服务、不走公网、不启动Tomcat外置容器,打包成JAR双击就能用。适合正在做课程设计、毕设选题或想落地“前后端一体化桌面应用”的开发者——尤其当你被导师问“你这JavaFX怎么跟数据库联动的?”时,能直接打开ProductController.java和ProductTableViewController.java两份文件,指着代码说清楚每一跳。


2. 项目结构与技术栈拆解:三层物理隔离+逻辑紧耦合的设计真相

2.1 目录结构:src/main下三个平行世界,但package命名暴露了强绑定意图

项目采用标准Maven多模块结构,但实际是单模块聚合(无pom.xml子模块声明),目录树如下:

src/ ├── main/ │ ├── java/ │ │ └── com.example.inventory/ │ │ ├── config/ # SpringBoot配置类(DataSourceConfig、MyBatisConfig) │ │ ├── controller/ # REST接口(ProductController、CategoryController) │ │ ├── dao/ # MyBatis Mapper接口(ProductMapper.java) │ │ ├── entity/ # JPA风格实体(Product.java、Category.java),含Lombok注解 │ │ ├── service/ # 业务逻辑(ProductServiceImpl.java,含@Transactional) │ │ └── javafx/ # JavaFX专属包(AppLauncher.java、main.fxml、controllers/) │ ├── resources/ │ │ ├── application.yml # 端口8080、H2数据库路径、MyBatis日志级别 │ │ └── static/ # 空目录(预留Web资源位,当前未启用) │ └── webapp/ # 空目录(传统WAR部署位,本项目不用) └── test/ └── java/ # JUnit5测试(ProductServiceTest.java含@Sql注解初始化H2数据)

提示:javafx/包不在Spring Boot默认扫描路径内,所以AppLauncher.java里必须显式调用SpringApplication.run()并传入InventoryApplication.class,否则JavaFX线程无法注入Spring Bean。这是新手最容易卡住的点——不是FXML加载失败,而是@Autowired ProductController为null。

2.2 技术栈选型逻辑:为什么用H2而不是MySQL?为什么MyBatis没上XML?

  • H2数据库:application.yml中配置spring.datasource.url: jdbc:h2:file:./data/inventory;DB_CLOSE_ON_EXIT=FALSE,意味着数据文件inventory.mv.db会生成在项目根目录。好处是零安装、免运维,关机重启数据不丢;坏处是并发写入能力弱(本项目单用户场景够用)。若要换MySQL,只需改三处:url、username、password,并确保h2依赖换成mysql-connector-java。

  • MyBatis纯注解驱动:所有Mapper接口方法均用@Select、@Insert、@Update等注解定义SQL,例如:

    @Mapper public interface ProductMapper { @Select("SELECT * FROM product WHERE category_id = #{categoryId} AND stock < #{lowStockThreshold}") List<Product> findLowStockProducts(@Param("categoryId") Long categoryId, @Param("lowStockThreshold") Integer lowStockThreshold); }

    这种写法省去XML文件维护成本,且IDE能直接跳转SQL,适合中小型CRUD。但复杂动态SQL(如多条件组合查询)会变得臃肿——本项目用@SelectProvider解决,见3.2节。

  • JavaFX与Spring Boot通信机制:JavaFX线程(Platform.runLater)不直接调用Spring Bean,而是通过HttpURLConnection发HTTP请求(非RestTemplate,避免引入额外依赖)。ProductTableViewController.java中新增商品代码片段:

    private void handleAddProduct() { String urlStr = "http://localhost:8080/api/products"; try { URL url = new URL(urlStr); HttpURLConnection conn = (HttpURLConnection) url.openConnection(); conn.setRequestMethod("POST"); conn.setRequestProperty("Content-Type", "application/json; utf-8"); conn.setDoOutput(true); String jsonPayload = new ObjectMapper().writeValueAsString(product); try (OutputStream os = conn.getOutputStream()) { os.write(jsonPayload.getBytes(StandardCharsets.UTF_8)); } int responseCode = conn.getResponseCode(); if (responseCode == 201) { // CREATED refreshTable(); // 刷新JavaFX表格 } } catch (Exception e) { showErrorAlert("保存失败:" + e.getMessage()); } }

    关键点:conn.getResponseCode()必须判断201而非200,因为Controller返回的是ResponseEntity.created();refreshTable()需在JavaFX主线程执行,否则抛IllegalStateException。

2.3 模块间依赖关系图:箭头方向即调用流向,虚线表示跨进程通信

调用方被调用方通信方式是否跨进程备注
ProductTableViewControllerProductControllerHTTP POST/GET是JavaFX进程 → SpringBoot进程
ProductControllerProductServiceImplSpring Bean注入否同进程,IoC容器管理
ProductServiceImplProductMapperMyBatis代理对象否接口代理,底层JDBC执行
ProductMapperH2数据库文件JDBC Driver否文件IO,非网络连接

注意:JavaFX和SpringBoot虽在同一JVM启动(AppLauncher.main()先启SpringBoot再启JavaFX Stage),但HTTP通信本质是进程间调用——这意味着你能用Postman直接访问http://localhost:8080/api/products,验证后端是否独立可用。这是调试分层问题的黄金法则:先确认后端API能跑通,再查JavaFX前端调用逻辑。


3. 核心模块代码逐行解析:从JavaFX按钮点击到H2数据库落盘的7个关键节点

3.1 JavaFX界面层:FXML绑定与事件处理的隐式契约

main.fxml中商品列表表格定义:

<TableView fx:id="productTable" prefHeight="400.0" prefWidth="700.0"> <columns> <TableColumn text="ID" fx:id="idColumn"/> <TableColumn text="名称" fx:id="nameColumn"/> <TableColumn text="分类" fx:id="categoryColumn"/> <TableColumn text="库存" fx:id="stockColumn"/> <TableColumn text="操作" fx:id="actionColumn"/> </columns> </TableView>

对应ProductTableViewController.java中字段注入:

@FXML private TableView<Product> productTable; @FXML private TableColumn<Product, Long> idColumn; @FXML private TableColumn<Product, String> nameColumn; @FXML private TableColumn<Product, String> categoryColumn; @FXML private TableColumn<Product, Integer> stockColumn; @FXML private TableColumn<Product, Void> actionColumn; // Void类型用于放置按钮列

关键细节:TableColumn泛型必须与TableView泛型一致(Product),否则setCellValueFactory()会编译报错;actionColumn用Void是因为该列只放按钮,不绑定数据属性。setCellValueFactory()设置单元格值来源:

idColumn.setCellValueFactory(new PropertyValueFactory<>("id")); nameColumn.setCellValueFactory(new PropertyValueFactory<>("name")); // ...其他列同理

这里"id"是Product类的getter方法名(getId()),不是字段名——若写成"Id"或"ID",运行时抛NoSuchMethodException,且IDE不会提示。

3.2 SpringBoot后端:Controller接收JSON并校验的防御性写法

ProductController.java中新增接口:

@PostMapping("/api/products") public ResponseEntity<Product> createProduct(@Valid @RequestBody Product product, BindingResult result) { if (result.hasErrors()) { throw new IllegalArgumentException("参数校验失败:" + result.getFieldErrors().stream() .map(e -> e.getField() + ":" + e.getDefaultMessage()) .collect(Collectors.joining("; "))); } Product saved = productService.save(product); return ResponseEntity.status(HttpStatus.CREATED) .header("Location", "/api/products/" + saved.getId()) .body(saved); }
  • @Valid触发JSR-303校验,Product实体类需加注解:
    public class Product { @NotNull(message = "ID不能为空") private Long id; @NotBlank(message = "名称不能为空") @Size(max = 50, message = "名称长度不能超过50字符") private String name; @Min(value = 0, message = "库存不能为负数") private Integer stock; // ...getter/setter }
  • BindingResult result必须紧跟@RequestBody参数后,否则Spring无法注入校验结果;
  • ResponseEntity.created()返回201 Created状态码,并设置Location头指向新资源URI,符合RESTful规范;
  • 若校验失败,手动抛IllegalArgumentException而非返回400 Bad Request,因全局异常处理器(GlobalExceptionHandler.java)会捕获并转为统一JSON错误格式。

3.3 MyBatis持久层:动态SQL与事务边界的精确控制

ProductServiceImpl.java中保存逻辑:

@Service public class ProductServiceImpl implements ProductService { @Autowired private ProductMapper productMapper; @Override @Transactional(rollbackFor = Exception.class) // 显式声明回滚异常类型 public Product save(Product product) { if (product.getId() == null) { productMapper.insert(product); // 新增 } else { productMapper.updateById(product); // 更新 } return product; } }

ProductMapper.java中动态SQL实现:

@Mapper public interface ProductMapper { @Insert("INSERT INTO product (name, category_id, stock, created_time) " + "VALUES (#{name}, #{categoryId}, #{stock}, NOW())") @Options(useGeneratedKeys = true, keyProperty = "id") void insert(Product product); @SelectProvider(type = ProductSqlProvider.class, method = "selectByCondition") List<Product> selectByCondition(ProductQuery query); } // 动态SQL提供类 public class ProductSqlProvider { public String selectByCondition(ProductQuery query) { return new SQL(){{ SELECT("*"); FROM("product"); if (query.getCategoryId() != null) { WHERE("category_id = #{categoryId}"); } if (query.getLowStockThreshold() != null) { WHERE("stock < #{lowStockThreshold}"); } if (StringUtils.hasText(query.getSearchKeyword())) { WHERE("name LIKE CONCAT('%', #{searchKeyword}, '%')"); } }}.toString(); } }

注意:@Options(useGeneratedKeys = true, keyProperty = "id")让MyBatis将H2生成的自增ID回填到product.id字段,否则JavaFX端拿到的id仍是null;ProductQuery是专门用于查询条件的DTO,与Product实体分离,避免污染领域模型。


4. 运行与调试全流程:从零开始启动的6个必做步骤及3个致命陷阱

4.1 启动前检查清单:6个动作决定你能否看到第一个窗口

  1. 确认JDK版本:项目pom.xml中<java.version>17</java.version>,必须用JDK 17+(JavaFX 17已集成进JDK,无需额外下载);
  2. 检查H2数据库路径权限:application.yml中./data/inventory目录需有读写权限,Windows建议放在非系统盘(如D:/inventory/data),避免UAC拦截;
  3. 验证Maven依赖完整性:执行mvn dependency:tree | grep -E "(spring-boot|mybatis|javafx)",应看到:
    [INFO] +- org.springframework.boot:spring-boot-starter-web:jar:3.1.0:compile [INFO] +- org.mybatis.spring.boot:mybatis-spring-boot-starter:jar:3.0.2:compile [INFO] +- org.openjfx:javafx-controls:jar:17.0.2:compile
  4. 编译JavaFX模块:若用IntelliJ IDEA,需在Project Structure → Modules → Dependencies中添加javafx.controls和javafx.fxml库,并勾选Export;
  5. 设置VM Options(关键!):运行配置中添加:
    --module-path "C:\path\to\javafx-sdk-17.0.2\lib" --add-modules javafx.controls,javafx.fxml
    路径需替换为本地JavaFX SDK解压路径,否则启动报java.lang.module.FindException: Module javafx.controls not found;
  6. 首次运行前清空H2数据:删除项目根目录下的data/文件夹,避免旧数据导致主键冲突。

4.2 启动命令与预期输出:终端里出现这三行才算成功

在项目根目录执行:

mvn clean compile exec:java -Dexec.mainClass="com.example.inventory.javafx.AppLauncher"

终端应依次出现:

[INFO] Starting AppLauncher using Java 17.0.2 on DESKTOP-XXXX with PID 12345 [INFO] Started AppLauncher in 3.2 seconds (process running for 3.5) [INFO] JavaFX Application started. Server running at http://localhost:8080

此时浏览器访问http://localhost:8080/actuator/health应返回{"status":"UP"};JavaFX主窗口弹出,表格显示“暂无数据”。

4.3 常见问题排查:3条血泪经验,专治启动失败、数据不更新、HTTP 404

现象1:JavaFX窗口一闪而退,控制台无报错

原因:AppLauncher.java中launch()调用位置错误。常见误写:

// ❌ 错误:在SpringApplication.run()前调用launch() public static void main(String[] args) { launch(args); // 此时JavaFX Toolkit未初始化 SpringApplication.run(InventoryApplication.class, args); }

解决:必须在Spring Boot完全启动后,再启动JavaFX线程:

// ✅ 正确:用ApplicationRunner延迟执行 @Component public class JavaFXStarter implements ApplicationRunner { @Override public void run(ApplicationArguments args) throws Exception { Platform.startup(() -> { // JavaFX初始化代码 AppLauncher.launch(AppLauncher.class, args.getSourceArgs()); }); } }
现象2:点击“新增商品”按钮,JavaFX报错java.net.ConnectException: Connection refused

原因:SpringBoot服务未启动成功,或端口被占用。检查application.yml中server.port: 8080是否与其他程序冲突(如Chrome DevTools、旧版IDEA内置服务器)。
解决:

  • 执行netstat -ano | findstr :8080(Windows)或lsof -i :8080(Mac/Linux)查PID;
  • 杀死进程后重试;
  • 或临时改server.port: 8081,同步修改JavaFX端URL字符串。
现象3:JavaFX表格始终空白,但Postman调GET /api/products能返回JSON数据

原因:TableView未设置ObservableList数据源,或setCellValueFactory()绑定字段名与实体getter不匹配。
解决:

  • 在ProductTableViewController.initialize()中必须调用:
    productTable.setItems(FXCollections.observableArrayList(productService.findAll()));
  • 检查Product类是否有getId()方法(非getid()或getID()),IDEA可按Alt+Insert生成Getter确保命名规范。

5. 二次开发实战:如何快速接入MySQL、增加导出Excel功能、改造为多窗口架构

5.1 替换H2为MySQL:5步完成数据库迁移,不改一行业务代码

步骤操作配置文件位置关键参数说明
1添加MySQL驱动依赖pom.xml<artifactId>mysql-connector-java</artifactId><version>8.0.33</version>
2修改数据库连接配置application.ymlspring.datasource.url: jdbc:mysql://localhost:3306/inventory?useSSL=false&serverTimezone=Asia/Shanghai
3创建MySQL数据库MySQL客户端CREATE DATABASE inventory CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
4初始化表结构schema.sql(新建文件)复制H2建表SQL,调整AUTO_INCREMENT和DATETIME类型
5关闭H2控制台application.ymlspring.h2.console.enabled: false(避免端口冲突)

注意:MySQL时区必须与JVM一致,否则NOW()函数存入时间偏移8小时。serverTimezone=Asia/Shanghai是刚需,不是可选项。

5.2 增加Excel导出功能:Apache POI集成与JavaFX进度条联动

在ProductTableViewController.java中新增导出按钮事件:

@FXML private void handleExportToExcel() { FileChooser fileChooser = new FileChooser(); fileChooser.setTitle("导出Excel"); fileChooser.getExtensionFilters().add( new FileChooser.ExtensionFilter("Excel Files", "*.xlsx")); File file = fileChooser.showSaveDialog(productTable.getScene().getWindow()); if (file != null) { // 启动后台任务,避免阻塞UI Task<Void> exportTask = new Task<Void>() { @Override protected Void call() throws Exception { List<Product> products = productService.findAll(); XSSFWorkbook workbook = new XSSFWorkbook(); XSSFSheet sheet = workbook.createSheet("商品列表"); // 写入表头 Row headerRow = sheet.createRow(0); String[] headers = {"ID", "名称", "分类", "库存", "创建时间"}; for (int i = 0; i < headers.length; i++) { Cell cell = headerRow.createCell(i); cell.setCellValue(headers[i]); } // 写入数据 for (int i = 0; i < products.size(); i++) { Row row = sheet.createRow(i + 1); Product p = products.get(i); row.createCell(0).setCellValue(p.getId()); row.createCell(1).setCellValue(p.getName()); row.createCell(2).setCellValue(p.getCategoryName()); // 假设已关联分类名 row.createCell(3).setCellValue(p.getStock()); row.createCell(4).setCellValue(p.getCreatedTime().toString()); } try (FileOutputStream out = new FileOutputStream(file)) { workbook.write(out); } return null; } }; // 绑定进度条 ProgressBar progressBar = new ProgressBar(); progressBar.progressProperty().bind(exportTask.progressProperty()); exportTask.setOnSucceeded(e -> { showAlert("导出成功!", "文件已保存至:" + file.getAbsolutePath()); }); exportTask.setOnFailed(e -> { showAlert("导出失败", exportTask.getException().getMessage()); }); new Thread(exportTask).start(); } }

关键点:必须用Task封装耗时操作,否则Excel生成过程冻结JavaFX界面;progressBar.progressProperty().bind()实现进度实时更新;XSSFWorkbook支持.xlsx格式,比HSSFWorkbook(.xls)更现代。

5.3 改造为多窗口架构:主窗口+编辑弹窗的父子级联控制

当前项目所有操作在单窗口完成,扩展性差。改造思路:

  • 主窗口(MainViewController.java)只负责列表展示和新增/编辑按钮;
  • 编辑弹窗(EditProductDialogController.java)独立FXML,通过Stage模态显示;
  • 弹窗关闭后,主窗口监听onHidden事件刷新表格。

MainViewController.java中打开弹窗逻辑:

@FXML private void handleEditProduct() { try { FXMLLoader loader = new FXMLLoader( getClass().getResource("/fxml/edit_product_dialog.fxml")); Parent root = loader.load(); EditProductDialogController controller = loader.getController(); controller.setProduct(selectedProduct); // 传入待编辑对象 Stage dialogStage = new Stage(); dialogStage.setTitle("编辑商品"); dialogStage.initModality(Modality.WINDOW_MODAL); dialogStage.initOwner(productTable.getScene().getWindow()); dialogStage.setScene(new Scene(root)); // 监听弹窗关闭事件 dialogStage.setOnHidden(e -> { if (controller.isSaved()) { // controller内设标志位 refreshTable(); // 刷新主表格 } }); dialogStage.showAndWait(); // 阻塞直到弹窗关闭 } catch (IOException e) { showErrorAlert("打开编辑窗口失败:" + e.getMessage()); } }

优势:职责分离清晰,编辑逻辑不污染主窗口;模态窗口(Modality.WINDOW_MODAL)防止用户切换到主窗口操作;showAndWait()保证弹窗关闭后才执行后续代码,避免竞态。

从那以后我每次接到“桌面+后端”需求,第一件事就是检查三点:JavaFX线程是否绕过Spring IoC直接new对象、HTTP通信是否用HttpURLConnection而非RestTemplate(减少依赖)、H2路径是否写死相对路径。这三条踩过坑,现在成了我新建项目的checklist前三项。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进