JavaFX+MySQL+JDBC+MVC家庭记账系统实战:从Excel到分层桌面应用
简介这是一套面向JavaSE初学者与课程设计学习者的家庭记账系统完整源码基于JavaFX、MySQL、JDBC与MVC模式实现可作为掌握桌面端数据库应用开发的实战项目。系统包含用户注册登录、账单增删改查、账单信息写入文件以及多线程处理等功能覆盖界面编程、数据持久化与并发操作等知识点。压缩包共58个文件约986KB以15个java源文件与16个class文件为核心辅以8个fxml界面文件、9个xml配置、1个sql建表脚本及mysql驱动jar包另附说明文档与README目录按controller、dao、view、domain等分层组织结构清晰。目前已有50人学习下载。读者可借此理解MVC分层设计、JDBC连接MySQL的完整流程并参考其线程使用与文件导出思路快速完成同类记账或管理系统的开发与课程作业。1. 从一张 Excel 记账表说起JavaFX MySQL JDBC MVC 到底能做出什么很多人做家庭记账系统的起点是一张越滚越大的 Excel 表月份多了以后公式开始互相引用分类统计靠透视表硬撑两个人同时改还会冲突。这个标题指向的方案本质是把这张表拆成三层——JavaFX 负责界面、JDBC 负责数据通道、MySQL 负责持久化、MVC 负责把三者解耦。它解决的不是“记账”这件事本身而是“数据量上来之后还能不能维护”的问题。适合谁看会一点 Java 语法、想找一个能写进简历又真能跑起来的桌面项目的人或者已经写过控制台版增删改查想补上界面和分层的人。不适合指望复制粘贴就交差的人因为 JDBC 的连接参数、MySQL 的字符集、JavaFX 的模块化配置每一处都能让你卡半天。下面按“先立结构、再落代码、最后排坑”的顺序讲清楚。2. 先把 MVC 三层架构落到包结构上别让 Controller 变成上帝类2.1 为什么家庭记账系统特别适合用 MVC 拆家庭记账的业务动作其实很固定记一笔收入或支出、按月份查流水、按分类做汇总、改错账、删错账。这些动作里界面变化和数据变化是两条独立的线——你可能想换一套皮肤但查询逻辑不动也可能想从 MySQL 换成别的库但界面不动。MVC 的价值就在这里把“长什么样”和“数据怎么算”分开。常见做法是按职责切三个包model放实体和数据库访问view放 FXML 和控制器controller放业务调度。注意 JavaFX 自己的 Controller 概念和 MVC 的 Controller 容易混我一般把 FXML 绑定的那个类叫XxxViewController只做控件事件转发真正的业务逻辑放在service层由 MVC 的 Controller 调用。这样XxxViewController不会膨胀成什么都往里塞的上帝类。2.2 推荐的包结构与依赖方向一个能长期维护的目录大概长这样依赖方向永远是 view → controller → service → dao → model反向依赖一律不允许src/main/java com.family.ledger Launcher.java // 启动入口绕开模块化限制 model/ Account.java // 实体 Category.java dao/ AccountDao.java // 只跟 JDBC 打交道 impl/AccountDaoImpl.java service/ AccountService.java // 业务规则、校验、事务边界 controller/ MainController.java // 调度不写 SQL view/ MainViewController.java // FXML 绑定只转发事件 src/main/resources fxml/main.fxml css/app.css这样分的好处是DAO 里出现SELECT不奇怪Service 里出现SELECT就是设计漏了ViewController 里出现Connection更是直接打回。判断分层有没有做对有个土办法——把dao包整个删掉如果编译错误只集中在service说明依赖方向是对的。2.3 用 Maven 把 JavaFX 和 MySQL 驱动拉进来JavaFX 从 JDK 11 起不再随 JDK 发布必须显式声明依赖这是新手第一个翻车点。pom.xml里至少要写清楚 JavaFX 控件、FXML 和 MySQL 驱动三块dependencies !-- JavaFX 控件与 FXML版本要和本机 JDK 匹配 -- dependency groupIdorg.openjfx/groupId artifactIdjavafx-controls/artifactId version21.0.2/version /dependency dependency groupIdorg.openjfx/groupId artifactIdjavafx-fxml/artifactId version21.0.2/version /dependency !-- MySQL 8 驱动注意 groupId 是 com.mysql -- dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId version8.3.0/version /dependency /dependencies参数说明javafx-controls提供 Button、TableView 这些控件javafx-fxml提供FXMLLoader不引它 FXML 会加载失败MySQL 驱动的groupId在 8.x 之后从mysql改成了com.mysql写错会一直下载失败。如果 IDE 提示download from maven failed先检查仓库镜像再确认版本号在中央仓库真实存在别硬编一个不存在的版本。3. JDBC 打通 MySQL连接、增删改查与事务边界3.1 建库建表字符集和金额类型先定死数据库设计阶段偷懒后面全是坑。家庭记账的金额千万别用FLOAT或DOUBLE浮点误差会让你的月度汇总差几分钱对不上账。用DECIMAL(12,2)。字符集统一utf8mb4否则备注里的特殊符号会变问号。CREATE DATABASE family_ledger DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci; USE family_ledger; CREATE TABLE account ( id BIGINT PRIMARY KEY AUTO_INCREMENT, type TINYINT NOT NULL COMMENT 1收入 2支出, amount DECIMAL(12,2) NOT NULL, category_id BIGINT NOT NULL, remark VARCHAR(255) DEFAULT , happen_date DATE NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_date (happen_date), INDEX idx_category (category_id) );参数说明DECIMAL(12,2)表示总共 12 位、小数 2 位够记到千万级happen_date单独建索引因为按月份查流水是最频繁的操作created_at用默认值插入时不用手动传。注意type用TINYINT而不是字符串省空间也方便统计。3.2 一个可靠的 JDBC 连接工具类连接信息不要散落在每个 DAO 里抽一个工具类统一管理顺便把驱动加载和关闭逻辑收口public final class DBUtil { // 生产环境建议改成从配置文件读取别硬编码 private static final String URL jdbc:mysql://localhost:3306/family_ledger ?useSSLfalseserverTimezoneAsia/Shanghai characterEncodingutf8allowPublicKeyRetrievaltrue; private static final String USER root; private static final String PWD your_password; static { try { Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { throw new IllegalStateException(MySQL 驱动未加载, e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PWD); } public static void close(AutoCloseable... resources) { for (AutoCloseable r : resources) { if (r ! null) { try { r.close(); } catch (Exception ignored) { } } } } }逻辑说明静态块里Class.forName是显式加载驱动虽然新版 JDBC 能自动发现但显式写出来在打包成可执行 jar 时更稳。URL 里的serverTimezone必须给否则插入DATE类型可能报时区错误allowPublicKeyRetrievaltrue是 MySQL 8 默认加密方式下本地连接常需要的参数。close用可变参数调用时一行就能关掉 Connection、Statement、ResultSet。3.3 插入一笔账PreparedStatement 与事务记账系统里“插入一笔支出”往往还要更新账户余额这两步必须在一个事务里否则中途失败就出现流水有了、余额没扣的脏数据public void addAccount(Account acc) throws SQLException { String sql INSERT INTO account(type, amount, category_id, remark, happen_date) VALUES (?, ?, ?, ?, ?); Connection conn null; PreparedStatement ps null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 ps conn.prepareStatement(sql); ps.setInt(1, acc.getType()); ps.setBigDecimal(2, acc.getAmount()); // 金额用 BigDecimal ps.setLong(3, acc.getCategoryId()); ps.setString(4, acc.getRemark()); ps.setDate(5, java.sql.Date.valueOf(acc.getHappenDate())); ps.executeUpdate(); conn.commit(); // 全部成功才提交 } catch (SQLException e) { if (conn ! null) conn.rollback(); // 出错回滚 throw e; } finally { DBUtil.close(ps, conn); } }参数说明setBigDecimal对应DECIMAL列别用setDoublesetDate接收java.sql.Date从LocalDate转换用Date.valueOf。事务边界放在 Service 层更合适这里为了讲清楚放在方法内。注意setAutoCommit(false)之后如果忘了commit数据不会落库这是新手最常见的“插了没反应”原因。3.4 按月份查询与分类汇总查询用PreparedStatement拼条件日期范围用BETWEEN汇总用GROUP BYpublic ListAccount listByMonth(int year, int month) throws SQLException { String sql SELECT id, type, amount, category_id, remark, happen_date FROM account WHERE happen_date BETWEEN ? AND ? ORDER BY happen_date DESC; LocalDate start LocalDate.of(year, month, 1); LocalDate end start.withDayOfMonth(start.lengthOfMonth()); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setDate(1, java.sql.Date.valueOf(start)); ps.setDate(2, java.sql.Date.valueOf(end)); try (ResultSet rs ps.executeQuery()) { ListAccount list new ArrayList(); while (rs.next()) { // 逐行映射成实体字段顺序与 SQL 保持一致 list.add(mapRow(rs)); } return list; } } }逻辑说明try-with-resources自动关闭连接和语句比手动 finally 更干净。start.lengthOfMonth()自动处理 28/29/30/31 天不用自己判断闰年。分类汇总把SELECT换成SELECT category_id, SUM(amount) ... GROUP BY category_id即可注意SUM结果用getBigDecimal取别用getDouble。4. JavaFX 界面与 JDBC 数据联动TableView 绑定与线程规则4.1 FXML 定义表格ViewController 只做转发界面用 FXML 声明TableView的列通过fx:id关联到控制器字段。控制器里不写 SQL只调用 Servicepublic class MainViewController { FXML private TableViewAccount accountTable; FXML private TableColumnAccount, String dateCol; FXML private TableColumnAccount, BigDecimal amountCol; private final AccountService service new AccountService(); FXML public void initialize() { // 用 lambda 定义单元格取值避免匿名内部类样板 dateCol.setCellValueFactory(c - new SimpleStringProperty(c.getValue().getHappenDate().toString())); amountCol.setCellValueFactory(c - new SimpleObjectProperty(c.getValue().getAmount())); refresh(); } private void refresh() { try { accountTable.getItems().setAll(service.listCurrentMonth()); } catch (SQLException e) { // 界面层只提示不吞异常 new Alert(Alert.AlertType.ERROR, 加载失败 e.getMessage()).show(); } } }逻辑说明setCellValueFactory决定每列从实体里取哪个字段返回类型要和TableColumn的泛型对上。initialize由 JavaFX 在 FXML 加载后自动调用适合做初始绑定。refresh里捕获异常后弹窗提示而不是打印堆栈了事用户至少知道出错了。4.2 别在 UI 线程里查数据库JavaFX 的界面更新必须在 JavaFX Application Thread 上而 JDBC 查询是阻塞操作。如果在按钮事件里直接查库数据量一大界面就卡死这是桌面端最典型的“玄学卡顿”。正确做法是把查询丢到后台线程结果回传后再更新表格FXML private void onQueryClick() { TaskListAccount task new Task() { Override protected ListAccount call() throws Exception { return service.listCurrentMonth(); // 后台线程执行 } }; task.setOnSucceeded(e - accountTable.getItems().setAll(task.getValue())); // 回到 UI 线程 task.setOnFailed(e - new Alert(Alert.AlertType.ERROR, task.getException().getMessage()).show()); new Thread(task).start(); }参数说明Task的call在后台线程跑setOnSucceeded的回调自动切回 UI 线程所以能安全操作控件。别用Platform.runLater包整个查询那样只是把阻塞挪了个位置。线程数不用自己管家庭记账的并发量很低每次查询起一个线程足够。5. 避坑与排查这五个坑我基本每次都能遇到5.1 现象启动报“缺少 JavaFX 运行时组件”原因JDK 11 之后 JavaFX 被剥离直接java -jar会找不到模块。解决要么在pom.xml里用javafx-maven-plugin启动要么在启动类里加一个不继承Application的Launcher由它调用Application.launch绕开模块检查。打包时把 JavaFX 依赖一起打进去。5.2 现象插入中文变问号或报“Incorrect string value”原因数据库、表、连接三处字符集不一致。解决建库时指定utf8mb4连接 URL 加characterEncodingutf8并确认 MySQL 服务端my.ini里character-set-serverutf8mb4。三处缺一处都会出问题别只改一处就以为好了。5.3 现象Error 2002 (HY000): Cant connect to local MySQL server原因MySQL 服务没启动或者 socket 路径不对。解决先确认服务在跑Windows 看服务列表Linux 用systemctl status mysql再确认连接用的是 TCP 端口 3306 而不是本地 socket。JDBC 走的是 TCP只要端口通就行跟 socket 文件无关。5.4 现象金额汇总差几分钱原因用了FLOAT/DOUBLE存金额浮点累加产生误差。解决列类型改DECIMALJava 侧用BigDecimal并且BigDecimal比较用compareTo而不是equals因为equals会比较精度位数。5.5 现象事务里插入了数据但查不到原因setAutoCommit(false)后没commit或者连接被提前关闭触发回滚。解决确认 commit 在 try 块末尾执行异常分支有 rollback用连接池时注意归还连接前事务必须结束否则连接被复用会带着未提交状态。6. 让这个项目真正能拿得出手从能跑到好用的三个进阶点第一个进阶点是引入连接池。上面每次DriverManager.getConnection都新建物理连接家庭记账单机用没问题但一旦你想把它改成多人共用连接开销就上来了。常见做法是换成 HikariCP把DBUtil.getConnection改成从HikariDataSource取其余 DAO 代码一行不用动——这正是分层带来的好处。配置里maximumPoolSize给 10 就够connectionTimeout给 3000 毫秒超时快速失败比一直挂着强。第二个进阶点是把查询条件做成可组合的。现在按月份查是写死的实际用起来你会想按分类、按金额区间、按关键字一起筛。做法是在 Service 层拼一个条件对象DAO 里用StringBuilder动态拼WHERE但值一律走PreparedStatement占位符绝不字符串拼接否则就是 SQL 注入的活教材。这里有个血泪经验动态拼 SQL 时最容易漏掉AND或WHERE的衔接建议先拼一个ListString conditions最后用String.join( AND , conditions)组装逻辑清楚还不容易错。第三个进阶点是给关键操作加验证。删除一笔账之前弹确认框修改金额时校验必须大于零日期不能选未来。这些校验放在 Service 层而不是界面层因为界面可能有好几个入口校验只写一遍才不会漏。验证方法很简单故意传负数金额、传空分类、传未来日期看系统是给出友好提示还是直接抛异常堆栈。能拦住脏数据的系统才敢把数据交给它管。我自己做这类项目最大的教训是一开始总想着先把界面画漂亮结果数据层一团糟改一个字段要动五个文件。后来强迫自己先把表结构和 DAO 写扎实界面反而是最快完成的部分。如果你也打算动手建议先让控制台版本跑通增删改查和事务再接 JavaFX顺序反了会多花一倍时间。希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →