JavaFX整合Spring+MyBatis:桌面端工程化实战
简介这是一套面向Java桌面端开发者的ERP系统完整项目源码将JavaFX界面技术与SSM企业级框架整合适合已掌握Java基础、希望从Web转向桌面应用或需要课程设计/毕业设计参考的中级学习者。项目以JavaFX构建前端借助FXML与CSS实现美观界面并通过双向数据绑定让TableView表格、TextField与DatePicker表单实时同步后台数据后端由Spring管理组件生命周期SpringMVC处理请求调度Mybatis完成数据库增删改查完整呈现从用户交互到持久化的流程。压缩包共2个文件包含1个7z工程包与1个sql数据库脚本整体约75.26MB解压后可直接导入运行并还原数据表结构。目前已有3415人学习下载读者可据此理解SSM分层架构、数据绑定与表格表单处理思路并对照源码梳理ERP模块的目录组织与配置方式。1. JavaFX 完整版项目把 SpringSpringMVCMyBatis 塞进桌面端到底图什么很多人第一次听到「JavaFX 完整版项目SpringSpringMVCMyBatis」这个组合第一反应是桌面端要 SpringMVC 干什么浏览器都没有DispatcherServlet 给谁分发请求这个疑问很合理也是我当年踩过的第一个认知坑。真相是这套组合并不是让你在 JavaFX 里跑一个 Web 容器而是把 Spring 的 IoC 容器、声明式事务、MyBatis 的 SQL 映射能力完整搬到桌面进程里让桌面端也拥有和 Web 后端一样的工程化分层。它解决的核心痛点是传统 JavaFX 项目里new满天飞、数据库连接散落在 Controller、事务靠手写commit/rollback代码一过三千行就没法维护。这套方案适合两类人一是做过 JavaWeb、想转桌面工具开发但不想丢掉 Spring 生态的开发者二是需要交付带本地数据库的单机管理系统比如某实验室的设备台账工具、某公司的内部数据录入客户端的团队。它不适合纯展示型小工具杀鸡用牛刀。下面我按「容器怎么起、MVC 怎么落地、MyBatis 怎么接、坑在哪」的顺序把一套能跑通的骨架讲清楚。2. 容器启动与分层骨架让 Spring 在 JavaFX 里活下来2.1 为什么入口必须是 Application 而不是 mainJavaFX 的启动流程和普通 Java 程序不一样。JVM 启动后main方法只是入口真正的 UI 线程由Application.launch()拉起start(Stage)才在 JavaFX Application Thread 上执行。如果你在main里直接new AnnotationConfigApplicationContext()容器是起来了但 UI 组件和 Bean 的初始化时序会错乱典型症状是Autowired的字段在 Controller 里是 null。常见做法是让主类继承Application在init()里启动 Spring 容器在start()里只做 Stage 装配。init()运行在 JavaFX-Launcher 线程早于start()这个时序刚好能保证容器先就绪。public class Launcher extends Application { private ConfigurableApplicationContext context; Override public void init() { // 在 UI 线程启动前把 Spring 容器拉起来 context new AnnotationConfigApplicationContext(AppConfig.class); // 把容器暴露给静态持有者供 Controller 手动取 Bean SpringContextHolder.setContext(context); } Override public void start(Stage stage) throws Exception { // 从容器里取 FXMLLoader而不是 new FXMLLoader loader context.getBean(FXMLLoader.class); Parent root loader.load(getClass().getResource(/fxml/main.fxml)); stage.setScene(new Scene(root, 1024, 768)); stage.show(); } Override public void stop() { // 关闭容器触发 PreDestroy释放连接池 context.close(); Platform.exit(); } public static void main(String[] args) { launch(args); } }这段代码的关键点有三个。第一init()里启动容器保证start()执行时所有 Bean 已就绪。第二stop()里必须context.close()否则 HikariCP 连接池不会释放反复调试时数据库连接数会一路涨到上限这个坑我在某次连续重启二十次后才发现。第三SpringContextHolder是一个静态工具类因为 FXML 的 Controller 是 FXMLLoader 反射实例化的不走 Spring 容器需要手动桥接。2.2 FXML Controller 怎么拿到 Spring Bean这是整套方案里最容易翻车的地方。FXML 文件里fx:controller指定的类是 FXMLLoader 通过无参构造反射创建的它不受 Spring 管理所以Autowired完全失效。有两种解法。第一种是设置FXMLLoader.setControllerFactory()把 Controller 的创建委托给 SpringConfiguration ComponentScan(com.example.app) public class AppConfig { Bean public FXMLLoader fxmLloader() { FXMLLoader loader new FXMLLoader(); // 关键Controller 由 Spring 容器创建Autowired 才能生效 loader.setControllerFactory(SpringContextHolder.getContext()::getBean); return loader; } }第二种是在 Controller 里手动SpringContextHolder.getBean(XxxService.class)。第一种更优雅但要注意Controller 必须是单例 Bean而 JavaFX 里同一个 FXML 可能被加载多次单例 Controller 会导致状态串台。我的习惯是给 Controller 加Scope(prototype)每次加载都是新实例避免上一次界面的数据残留到下一次。2.3 分层结构怎么切才不别扭Web 项目里是 Controller-Service-DAO 三层桌面端照搬没问题但有一处必须调整Web 的 Controller 返回 JSON桌面端的 Controller 直接操作 UI 控件。所以 Service 层要设计成「返回领域对象或 DTO」绝不能返回ObservableList这类 JavaFX 专有类型否则 Service 就被 UI 框架污染了单元测试也没法写。我一般这样切view包放 FXML 和 Controllerservice包放业务逻辑和事务边界mapper包放 MyBatis 接口entity放数据库映射对象dto放界面展示对象。Controller 负责把 DTO 转成ObservableList绑定到 TableViewService 只管业务。这样切完Service 层可以脱离 JavaFX 单独跑 JUnit 测试这是这套架构最值钱的地方。3. SpringMVC 在桌面端的真实角色不是 Web是事件分发3.1 为什么还要引入 SpringMVC 的注解体系标题里写了 SpringMVC但桌面端没有 HTTP 请求那它到底扮演什么角色我的理解是借用它的注解语义和参数解析思路做界面事件的分发层。Web 里RequestMapping把 URL 映射到方法桌面端可以把「按钮点击」「菜单选择」抽象成事件标识用类似的映射机制分发。不过要诚实说一句完整引入 DispatcherServlet 在桌面端是没必要的它依赖 Servlet 容器硬塞进来只会增加启动负担。常见做法是只引入spring-webmvc的注解和HandlerMapping思想自己写一个轻量的事件总线。下面是一个能用的最小实现。Component public class UiEventDispatcher { // 事件标识 - 处理方法 private final MapString, Method handlerMap new HashMap(); private final MapString, Object beanMap new HashMap(); PostConstruct public void scan() { // 扫描所有带 UiHandler 的方法建立映射 for (Object bean : SpringContextHolder.getContext().getBeansWithAnnotation(Component.class).values()) { for (Method m : bean.getClass().getDeclaredMethods()) { UiHandler ann m.getAnnotation(UiHandler.class); if (ann ! null) { handlerMap.put(ann.value(), m); beanMap.put(ann.value(), bean); } } } } public Object dispatch(String event, Object... args) { Method m handlerMap.get(event); if (m null) { throw new IllegalArgumentException(未注册的事件: event); } try { return m.invoke(beanMap.get(event), args); } catch (Exception e) { // 统一异常出口避免异常吞掉导致界面无响应 throw new RuntimeException(事件处理失败: event, e); } } }配套的注解和用法Retention(RetentionPolicy.RUNTIME) Target(ElementType.METHOD) public interface UiHandler { String value(); } Component public class DeviceController { Autowired private DeviceService deviceService; UiHandler(device.refresh) public ListDeviceDTO refresh() { return deviceService.listAll(); } }逻辑说明scan()在容器启动后遍历所有 Bean把UiHandler(device.refresh)这样的标识和方法绑定。界面上的刷新按钮调用dispatcher.dispatch(device.refresh)拿到返回值后刷新 TableView。参数说明event是唯一标识建议用「模块.动作」命名避免冲突args是可变参数用于传界面上的查询条件。这样做的价值是界面事件和业务处理解耦加一个新功能只需加一个注解方法不用改分发器。3.2 事务边界该放在哪一层桌面端和 Web 端一样事务必须放在 Service 层绝不能放在 Controller。原因很直接Controller 里混着 UI 操作一旦事务方法里弹了个对话框等用户确认数据库连接就被长时间占用连接池很快耗尽。配置方式用EnableTransactionManagement加DataSourceTransactionManagerConfiguration EnableTransactionManagement public class TxConfig { Bean public DataSourceTransactionManager txManager(DataSource dataSource) { return new DataSourceTransactionManager(dataSource); } }Service 方法上加Transactional(rollbackFor Exception.class)。这里有个血泪经验默认Transactional只对RuntimeException回滚如果你抛的是受检异常比如自定义的BusinessException extends Exception事务不会回滚数据就脏了。所以务必写rollbackFor Exception.class。3.3 界面线程与事务线程的隔离JavaFX 规定所有 UI 更新必须在 Application Thread 上执行而 Service 里的数据库操作是阻塞的。如果你在按钮的onAction里直接调 Service界面会卡死用户以为程序崩了。正确做法是把耗时操作丢到后台线程完成后用Platform.runLater()回到 UI 线程更新。UiHandler(device.load) public void loadAsync(TableViewDeviceDTO table) { TaskListDeviceDTO task new Task() { Override protected ListDeviceDTO call() { // 这里在后台线程执行事务正常生效 return deviceService.listAll(); } }; task.setOnSucceeded(e - table.getItems().setAll(task.getValue())); task.setOnFailed(e - { // 失败也要回 UI 线程提示否则异常被吞 Platform.runLater(() - AlertUtil.error(task.getException().getMessage())); }); new Thread(task).start(); }参数说明Task是 JavaFX 提供的可观察异步任务call()在后台线程跑setOnSucceeded的回调自动回到 UI 线程。注意setOnFailed里我额外包了一层Platform.runLater因为某些 JavaFX 版本里失败回调的线程归属不稳定这是踩过的玄学坑。4. MyBatis 接入与本地库选型SQL 映射怎么配才不散4.1 为什么桌面端用 MyBatis 而不是 JPA桌面端项目通常配本地数据库表结构相对固定SQL 可控性要求高。JPA 的自动建表和懒加载在桌面端容易出问题懒加载触发时 Session 可能已经关了报LazyInitializationException自动建表在生产环境又太危险。MyBatis 的 SQL 全写在 XML 或注解里执行时机明确调试时能直接看到最终 SQL这对单机排错非常友好。选型上本地库我一般用 SQLite 或 H2。SQLite 零配置、单文件、方便随程序分发H2 支持内存模式和更完整的 SQL 语法适合做单元测试。生产交付用 SQLite测试用 H2 内存库两套配置通过 Spring Profile 切换。4.2 数据源与 SqlSessionFactory 的最小配置Configuration MapperScan(com.example.app.mapper) public class MyBatisConfig { Bean public DataSource dataSource() { HikariConfig config new HikariConfig(); // SQLite 文件路径实际项目里应放在用户目录下避免权限问题 config.setJdbcUrl(jdbc:sqlite: System.getProperty(user.home) /appdata/app.db); config.setDriverClassName(org.sqlite.JDBC); // 桌面端并发低连接池不用大5 个足够 config.setMaximumPoolSize(5); config.setConnectionTestQuery(SELECT 1); return new HikariDataSource(config); } Bean public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception { SqlSessionFactoryBean factory new SqlSessionFactoryBean(); factory.setDataSource(dataSource); // 指定 mapper xml 位置 factory.setMapperLocations( new PathMatchingResourcePatternResolver().getResources(classpath:mapper/*.xml)); // 开启驼峰映射user_name 自动映射到 userName org.apache.ibatis.session.Configuration cfg new org.apache.ibatis.session.Configuration(); cfg.setMapUnderscoreToCamelCase(true); factory.setConfiguration(cfg); return factory.getObject(); } }参数说明maximumPoolSize设 5 是因为桌面端用户就一个并发极低设大了反而浪费内存。setMapUnderscoreToCamelCase(true)能省掉大量resultMap手写映射但前提是数据库字段用下划线、Java 字段用驼峰这个约定要团队统一。ConnectionTestQuery对 SQLite 是必要的因为它的连接有效性检测和普通数据库不同。4.3 一个完整的 Mapper 与 XML 示例public interface DeviceMapper { ListDevice selectByCondition(Param(name) String name, Param(status) Integer status); int insert(Device device); int updateStatus(Param(id) Long id, Param(status) Integer status); }?xml version1.0 encodingUTF-8? !DOCTYPE mapper PUBLIC -//mybatis.org//DTD Mapper 3.0//EN http://mybatis.org/dtd/mybatis-3-mapper.dtd mapper namespacecom.example.app.mapper.DeviceMapper select idselectByCondition resultTypecom.example.app.entity.Device SELECT id, device_name, status, create_time FROM device where if testname ! null and name ! AND device_name LIKE % || #{name} || % /if if teststatus ! null AND status #{status} /if /where ORDER BY create_time DESC /select insert idinsert useGeneratedKeystrue keyPropertyid INSERT INTO device(device_name, status, create_time) VALUES(#{deviceName}, #{status}, datetime(now,localtime)) /insert update idupdateStatus UPDATE device SET status #{status} WHERE id #{id} /update /mapper逻辑说明where标签会自动处理第一个AND避免拼接出WHERE AND的语法错误。SQLite 的字符串拼接用||而不是 MySQL 的CONCAT这是换库时最容易忽略的差异。useGeneratedKeys配合keyProperty能把自增主键回填到实体插入后直接device.getId()就能拿到。参数说明#{}是预编译占位符能防注入${}是字符串拼接除非做动态表名否则不要用。4.4 数据库初始化和版本管理桌面端没有 DBA建表脚本得随程序走。我的做法是在资源目录放schema.sql程序启动时检测表是否存在不存在则执行。但要注意SQLite 的CREATE TABLE IF NOT EXISTS只保证表存在字段变更它不管。所以正式项目里我会加一张schema_version表记录当前版本号升级时按版本号顺序执行增量脚本。Component public class DbInitializer { Autowired private DataSource dataSource; PostConstruct public void init() { try (Connection conn dataSource.getConnection(); Statement st conn.createStatement()) { // 建版本表 st.execute(CREATE TABLE IF NOT EXISTS schema_version(version INTEGER PRIMARY KEY)); int current queryVersion(conn); if (current 1) { st.execute(CREATE TABLE device(id INTEGER PRIMARY KEY AUTOINCREMENT, device_name TEXT NOT NULL, status INTEGER DEFAULT 0, create_time TEXT)); st.execute(INSERT INTO schema_version(version) VALUES(1)); } // 后续版本在这里追加 if (current 2) { ... } } catch (SQLException e) { throw new IllegalStateException(数据库初始化失败, e); } } }这段代码的价值在于用户升级程序时旧数据库能平滑迁移不会因为缺字段直接崩溃。PostConstruct保证它在所有 Mapper 使用前执行。5. 避坑与排查这套组合最容易翻车的五个地方5.1 现象界面卡死无响应任务管理器显示 CPU 占用不高原因在 JavaFX Application Thread 上执行了阻塞的数据库查询或大批量循环。CPU 不高是因为线程在等 IO但 UI 线程被占住界面自然不刷新。解决所有耗时操作走Task或ServiceJavaFX 自带的javafx.concurrent.Service回调里用Platform.runLater更新界面。排查时可以在可疑方法首行打印Thread.currentThread().getName()如果输出JavaFX Application Thread且方法里有 IO基本就是它。5.2 现象启动报NoClassDefFoundError: javafx/...但 IDE 里能跑原因JDK 11 之后 JavaFX 从 JDK 中剥离成了独立模块。IDE 里配了模块路径所以能跑打包成 jar 后缺少 JavaFX 运行时。解决构建时用org.openjfx:javafx-controls等依赖并用javafx-maven-plugin或jlink打包。如果打 fat jar注意 JavaFX 的 native 库要一起打进去否则运行时报找不到glass.dll之类的错误。这个坑在交付给非开发同事时必现。5.3 现象Transactional加了但数据没回滚原因三种可能。一是方法被同类内部调用代理没生效二是异常类型不在rollbackFor范围内三是方法不是public。解决内部调用改为通过注入自身代理或拆到另一个 Service显式写rollbackFor Exception.class确认方法修饰符是public。排查时在事务方法前后打印连接对象的hashCode如果两次相同说明在同一个事务里不同则事务没生效。5.4 现象SQLite 报database is locked原因SQLite 同一时刻只允许一个写操作多个连接同时写就会锁。桌面端虽然用户少但后台线程和 UI 线程可能同时触发写。解决连接池设maximumPoolSize1是最粗暴有效的办法桌面端性能完全够用。或者开启 WAL 模式PRAGMA journal_modeWAL能显著改善读写并发。另外事务要短别在事务里等用户输入。5.5 现象打包后 FXML 加载失败报Location is not set原因FXML 文件没被打进 jar或者路径用了绝对路径。开发时用getClass().getResource(/fxml/main.fxml)能跑是因为 IDE 把资源目录加进了 classpath。解决确认构建配置里资源目录包含**/*.fxml路径统一用getResourceAsStream或getResource的相对形式。排查时用getClass().getResource(/fxml/main.fxml).toExternalForm()打印实际路径看是否指向 jar 内部。6. 进阶技巧把配置外置与启动加速做到位6.1 配置文件外置让交付不再改代码桌面端交付给不同用户时数据库路径、日志级别、界面主题往往不同。把这些硬编码在AppConfig里每次都要重新打包非常低效。我的习惯是用PropertySource加载外部配置文件优先级设为「程序目录 用户目录 classpath 默认值」。Configuration PropertySource(value { file:./config/application.properties, file:${user.home}/.app/application.properties }, ignoreResourceNotFound true) public class ExternalConfig { Bean public static PropertySourcesPlaceholderConfigurer placeholderConfigurer() { PropertySourcesPlaceholderConfigurer c new PropertySourcesPlaceholderConfigurer(); // 允许后面的源覆盖前面的实现优先级 c.setLocalOverride(true); return c; } }参数说明ignoreResourceNotFound true保证文件不存在时不报错用默认值兜底。setLocalOverride(true)让后加载的配置覆盖先加载的这样用户目录的配置能盖过程序目录的。实际使用时Value(${db.path:./data/app.db})里的冒号后面是默认值配置文件缺失也能启动。6.2 启动加速延迟初始化与组件扫描收窄Spring 容器启动时默认会实例化所有单例 Bean桌面端如果 Bean 多启动会明显变慢。两个手段一是给非关键 Bean 加Lazy二是收窄ComponentScan的包范围别用ComponentScan(com)这种大范围扫描。Configuration ComponentScan(basePackages com.example.app, excludeFilters ComponentScan.Filter( type FilterType.REGEX, pattern com\\.example\\.app\\.view\\..*)) Lazy public class AppConfig { // view 包下的 Controller 由 FXMLLoader 按需创建不参与容器扫描 }逻辑说明把view包排除出扫描因为 Controller 是 prototype 且由 FXMLLoader 创建让容器扫描它们纯属浪费。类级别加Lazy后Bean 在第一次被使用时才创建启动时间通常能砍掉三到五成。注意Lazy对PostConstruct的执行时机有影响像DbInitializer这种必须在启动时跑的要单独排除或显式getBean触发。6.3 一个验证架构是否健康的小技巧判断这套组合有没有搭歪我有个简单的自检方法把view包整个删掉看service和mapper能不能通过 JUnit 测试跑起来。如果能说明分层是干净的业务逻辑没有泄漏到 UI如果编译不过说明 Service 里引用了 JavaFX 类型架构已经污染了。这个检查我每次搭新项目都会做一遍比看代码快得多。最后说个我自己的习惯这套架构里我从不把ApplicationContext到处传只通过SpringContextHolder这一个静态入口暴露且只在 Controller 和 FXMLLoader 工厂里用。其他地方一律用构造器注入这样依赖关系一眼可见测试也好替换。桌面端项目最容易烂在「随手 new」上管住这一个口子代码寿命能长很多。希望帮到你。本文还有配套的精品资源点击获取