Java Swing+MySQL开发通讯录:JDBC连接与桌面应用实战
简介这是一份面向Java初、中级开发者的通讯录管理系统完整项目基于Java Swing设计图形界面搭配MySQL数据库存储联系人数据适合用作课程设计、毕业设计或自学Swing与JDBC编程的参考。系统包含联系人添加、编辑、删除、搜索等核心操作前端使用Swing组件搭建菜单、工具栏和表格后端通过JDBC完成增删改查并兼顾异常处理与数据一致性可帮助读者掌握桌面应用开发的完整链路。压缩包共23个文件主要涵盖5个Java源文件、5个class可执行文件、5张界面效果图、表结构SQL脚本、项目配置文件与依赖jar包整包约2.16MB目录结构包含src、bin、sql、lib等模块导入开发工具后即可对照学习。该资源已有3698人学习下载适合需要快速上手JavaMySQL结合开发、并希望从项目源码中获取分层设计与编码规范的人。通过界面截图和数据库脚本还能省去自行设计表与界面的时间一举获得可运行、可拓展的基础项目。1. 从通讯录开始把 Java 桌面开发的整条链路跑通手机里的联系人越存越多电脑上想按姓名、地址、备注快速检索却找不到趁手的工具。用 Java Swing 手写一个通讯录管理系统恰恰是把 JDBC、MySQL、事件监听、表结构设计一次走完的最小成本项目。Swing 虽然是 1990 年代的 UI 框架但它帮新手把「界面怎么刷、事件怎么丢、数据从哪来」这三件事拆得明明白白5 年以上经验的工程师回看这个项目也能在分层、线程和连接管理上看出不少值得推敲的门道。这篇博文不打算复述一遍教科书式的 MVC 概念直接按「建表 → 搭界面 → 接事件 → 优化 → 打包」的顺序把每一步该写的代码、该设的参数和踩过的坑都放在明面上。2. 用 MySQL 建一张通讯录表再写一个干净的 JDBC 入口2.1 通讯录最少需要哪几张表刚开始做通讯录系统不要一上来就设计用户表、分组表、标签表。单机桌面应用的核心数据就是联系人本身最稳的做法是先建一张contact主表把 ID、姓名、联系电话、邮箱、地址和备注放进去。后续如果要加分组或标签再单独建关联表而不是在一开始就把外键和级联弄得满屏都是。CREATE DATABASE address_book DEFAULT CHARACTER SET utf8mb4; USE address_book; CREATE TABLE contact ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, phone VARCHAR(20) DEFAULT , email VARCHAR(100) DEFAULT , address VARCHAR(200) DEFAULT , remark VARCHAR(255) DEFAULT , created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINE InnoDB;上面这段建表语句里utf8mb4比utf8更适合通讯录场景因为联系人备注里随时可能出现 emoji 或生僻字utf8mb4在 MySQL 5.7 以上版本是默认推荐字符集。phone字段刻意设计成VARCHAR(20)而不是BIGINT这是新手最容易踩的坑手机号可能带86前缀也可能含分机号用数值类型存储会丢失前导零而且 MySQL 对整数除法会自动隐式转换出问题的时候很难排查。created_at用DEFAULT CURRENT_TIMESTAMP可以让插入操作少写一个字段查询列表时按它排序新加的联系人永远排在前面。字段类型说明idINT PK自增主键不参与业务nameVARCHAR(50) NOT NULL姓名创建时必填phoneVARCHAR(20)电话默认空串emailVARCHAR(100)邮件可选addressVARCHAR(200)住址可选remarkVARCHAR(255)备注最长 255 字符created_atTIMESTAMP插入时间由数据库维护2.2 JDBC 连接串里的参数宁可多写不可少写建完表之后Java 程序连 MySQL 需要一套稳定的连接基础。常见做法是把连接参数抽到一个JdbcUtil工具类里避免每个 DAO 方法都重复写DriverManager.getConnection。连接串里的serverTimezone、useUnicode和characterEncoding不是可有可无的装饰参数少写一个就可能出现中文乱码或The server time zone value的运行时异常。package com.addr.util; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; public class JdbcUtil { private static final String URL jdbc:mysql://localhost:3306/address_book ?useUnicodetruecharacterEncodingutf8 serverTimezoneAsia/Shanghai useSSLfalseallowPublicKeyRetrievaltrue; private static final String USER root; private static final String PASSWORD your_password; static { try { Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError(缺少 MySQL JDBC 驱动); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }Class.forName在 JDBC 4.0 以后其实可以省略因为驱动 jar 包里有META-INF/services自动注册机制。但显式写出来的好处是如果项目构建时没有把驱动打进去静态代码块会立刻抛出ClassNotFoundException而不是等到第一次查询才报错排错成本明显更低。useSSLfalse只是关闭加密通道本地开发够用生产环境倒是建议打开。allowPublicKeyRetrievaltrue是 MySQL 8.0 用caching_sha2_password认证时经常要求的参数不写或者写错连接阶段就会收到Public Key Retrieval is not allowed的报错这是网上提问频率最高的一个点。2.3 用 PreparedStatement 代替 Statement 做参数化查询通讯录系统的增删改查离不开 SQL 拼接这里有一条红线任何包含用户输入的条件都不能用字符串拼接进 SQL。Statement的executeQuery(SELECT * FROM contact WHERE name name )在单机工具里看着没什么问题但姓名里出现单引号或反斜杠时轻则语法报错重则埋下注入隐患。换成PreparedStatement后参数通过占位符传递驱动会负责转义代码也更容易复用。public ListContact queryByKeyword(String keyword) { String sql SELECT id, name, phone, email, address, remark FROM contact WHERE name LIKE ? OR phone LIKE ? ORDER BY id DESC; ListContact list new ArrayList(); try (Connection conn JdbcUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, % keyword %); ps.setString(2, % keyword %); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { Contact c new Contact(); c.setId(rs.getInt(id)); c.setName(rs.getString(name)); c.setPhone(rs.getString(phone)); c.setEmail(rs.getString(email)); c.setAddress(rs.getString(address)); c.setRemark(rs.getString(remark)); list.add(c); } } } catch (SQLException e) { e.printStackTrace(); } return list; }这段代码用了 Java 7 的 try-with-resources 语法Connection、PreparedStatement和ResultSet都会在方法结束时自动关闭不需要手写finally块。模糊查询的LIKE ?占位符需要在传参时才拼接%这一笔容易写漏直接在 SQL 里写LIKE %?%是错的因为?不能替代字符串字面量内部的内容。ORDER BY id DESC让新插入的联系人排在上面符合通讯录的使用直觉如果联系人数量超过几百条这个排序也可以在应用层做但现阶段让 MySQL 做更省事。3. Swing 界面不是拼控件主窗口、表格模型与表单对话框怎么组织3.1 主窗口采用边界布局工具栏放上、表格居中Swing 程序的起点是JFrame但一个合格的通讯录主窗口不能只把控件往里一丢。常见做法是BorderLayout分区北部放JToolBar承载新增、编辑、删除、搜索按钮中部放JTable展示联系人列表南部放状态栏显示当前记录数和选中行信息。这样安排的好处是窗口缩放时表格自动撑满剩余空间工具栏和状态栏高度不变不会出现布局被拉伸变形的情况。JFrame frame new JFrame(通讯录管理系统); frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); frame.setSize(900, 600); frame.setLocationRelativeTo(null); JToolBar toolbar new JToolBar(); toolbar.setFloatable(false); JButton addButton new JButton(新增); JButton editButton new JButton(编辑); JButton deleteButton new JButton(删除); toolbar.add(addButton); toolbar.add(editButton); toolbar.add(deleteButton); toolbar.addSeparator(); JTextField searchField new JTextField(15); JButton searchButton new JButton(搜索); toolbar.add(searchField); toolbar.add(searchButton); frame.add(toolbar, BorderLayout.NORTH);toolbar.setFloatable(false)这行容易被忽略不设置的话用户可以拖拽工具栏把它变成独立悬浮窗界面布局会因此混乱。searchField默认宽度设为 15 个字符列回车事件我一般会顺手绑到searchButton上这样用户在输入关键字后不需要再拿鼠标点搜索按钮操作效率会高一些。搜索按钮的监听器写法在后面第 4 章会展开这里先只搭建控件骨架。3.2 用 DefaultTableModel 管理表格数据单元格禁止直接编辑JTable本身不存储数据数据由TableModel提供。直接new JTable(data, columns)虽然能显示但后续刷新、删除行、按列排序都会变得很别扭。通用的做法是维护一个DefaultTableModel实例并在构造时重写isCellEditable方法让它返回false避免用户在表格里直接双击改内容——通讯录的修改动作应该统一走编辑对话框否则输入不合法数据时很难做校验。String[] columns {ID, 姓名, 电话, 邮箱, 住址, 备注}; DefaultTableModel tableModel new DefaultTableModel(columns, 0) { Override public boolean isCellEditable(int row, int column) { return false; } }; JTable table new JTable(tableModel); table.getColumnModel().getColumn(0).setMaxWidth(60); table.getColumnModel().getColumn(1).setPreferredWidth(120); table.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); table.setRowHeight(28); frame.add(new JScrollPane(table), BorderLayout.CENTER);DefaultTableModel的第二参数 0 表示初始行数为 0后续通过addRow填充数据。SINGLE_SELECTION限制用户只能选中一行这个约束对通讯录场景是合理的多选删除反而容易误操作。列宽的设置需要放在setModel或new JTable(model)之后因为列模型是根据TableModel动态生成的。ID列设置maxWidth(60)可以让主键列保持紧凑姓名列给一个相对宽的偏好值其他列让布局自动分配即可。3.3 新增与编辑共用一个模态对话框新增联系人和编辑联系人的表单字段完全一致区别只在于对话框标题和初始值。最省事的方案是写一个ContactDialog构造时接收Contact对象为null时表示新增否则表示编辑。对话框用JDialog而不是再开一个JFrame并在构造时传父窗口和true作为模态参数这样用户不关闭对话框就无法操作主窗口从交互上避免了数据不一致。public class ContactDialog extends JDialog { private JTextField nameField new JTextField(20); private JTextField phoneField new JTextField(20); private JTextField emailField new JTextField(20); private JTextField addressField new JTextField(20); private JTextArea remarkArea new JTextArea(4, 20); private Contact contact; public ContactDialog(JFrame owner, Contact contact) { super(owner, contact null ? 新增联系人 : 编辑联系人, true); this.contact contact; // 此处省略 GridBagLayout 布局代码 if (contact ! null) { nameField.setText(contact.getName()); phoneField.setText(contact.getPhone()); emailField.setText(contact.getEmail()); addressField.setText(contact.getAddress()); remarkArea.setText(contact.getRemark()); } } }模态对话框是setModal(true)的语义这意味着dialog.setVisible(true)之后的代码要等对话框关闭后才继续执行。这个特性让「点击新增 → 弹窗 → 填数据 → 点保存 → 刷新表格」的逻辑可以线性地写在一起不需要回调。contact null的判断放在构造方法里比在外面分写两个类要干净得多。保存按钮的事件里先校验nameField是否为空姓名必填是通讯录的底线其他字段可以宽松处理。4. 把增删改查接到按钮上注意事件线程和 PreparedStatement4.1 查询列表的加载路径从 DAO 到表格的一行转换主窗口打开时应该自动加载全部联系人搜索按钮触发时按关键字过滤。这个流程在 DAO 层返回的是ListContact在界面层要把每一条记录转换成Object[]数组通过DefaultTableModel.addRow塞进表格。转换逻辑涉及列顺序必须和表格的列头数组严格保持一致顺序错位是新手最常见的问题。public void refreshTable(ListContact contacts) { tableModel.setRowCount(0); for (Contact c : contacts) { tableModel.addRow(new Object[]{ c.getId(), c.getName(), c.getPhone() null ? : c.getPhone(), c.getEmail() null ? : c.getEmail(), c.getAddress() null ? : c.getAddress(), c.getRemark() null ? : c.getRemark() }); } }setRowCount(0)是清空表格内容的标准姿势比在循环里removeRow(0)效率高得多因为前者只触发一次结构变更事件后者每删除一行都通知模型刷新。字段判空后回填空字符串是必要的防御性设计ResultSet.getString对数据库中的NULL值返回 Java 的null如果不处理表格里会直接显示字符串 null这个细节在真实项目里很容易被忽略。操作界面入口调用的 DAO 方法关键点查询全部主窗口初始化findAll()排序交给 SQL 的ORDER BY关键字搜索搜索按钮/回车queryByKeyword(keyword)LIKE参数在边界拼%新增对话框保存insert(contact)返回自增主键修改对话框保存update(contact)用id定位记录删除删除按钮deleteById(id)先弹确认框4.2 保存动作的真伪校验姓名必填 主键回填新增和编辑对话框的保存按钮共用一套校验逻辑不现实但可以在对话框里提供一个getContact()方法由调用方决定是走insert还是update。核心区别在于Contact对象是否携带id携带了就是更新否则是插入。插入成功后需要把数据库自增的id回填到Contact对象里刷新列表时才能让选中行对应上记录。public void insert(Contact contact) throws SQLException { String sql INSERT INTO contact(name, phone, email, address, remark) VALUES(?, ?, ?, ?, ?); try (Connection conn JdbcUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, contact.getName()); ps.setString(2, contact.getPhone()); ps.setString(3, contact.getEmail()); ps.setString(4, contact.getAddress()); ps.setString(5, contact.getRemark()); ps.executeUpdate(); try (ResultSet keys ps.getGeneratedKeys()) { if (keys.next()) { contact.setId(keys.getInt(1)); } } } }prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)这个重载是拿自增主键的关键。不写这个参数后面调用getGeneratedKeys()返回的结果集可能是空的这是 MySQL 驱动的一个隐含行为不同版本表现还不完全一致。插入成功后回填id还有一个好处新增完立刻点编辑时对话框能正确进入编辑态而不是再次变成新增。4.3 删除操作必须先确认再执行误删没有后悔药删除按钮的监听器里第一步不是调 DAO而是先拿当前选中行。table.getSelectedRow()返回的是视图层的行索引如果表格被排序过这个索引和模型层行索引不一定一样。通讯录这个项目没有启用排序但保险起见还是用table.convertRowIndexToModel转换一次。拿到选中行后从表格里取ID列的值弹JOptionPane.showConfirmDialog让用户二次确认确认了才执行deleteById。int selectedRow table.getSelectedRow(); if (selectedRow 0) { JOptionPane.showMessageDialog(frame, 请先选中要删除的联系人); return; } int id (int) tableModel.getValueAt(selectedRow, 0); int option JOptionPane.showConfirmDialog( frame, 确定删除 id id 的联系人吗, 删除确认, JOptionPane.YES_NO_OPTION); if (option JOptionPane.YES_OPTION) { contactDAO.deleteById(id); refreshTable(contactDAO.findAll()); }这里有个 Swing 界面刷新的潜在问题findAll()是数据库 IO 操作如果在事件分发线程EDT里同步执行数据量大时窗口会短暂卡顿。通讯录的数据量通常几百条直接同步加载问题不大但作为一个可以写进简历的项目用SwingWorker把查询放到后台线程、在done()回调里再刷新表格是更成熟的写法。EDT 上做耗时操作的代价是界面假死Windows 上甚至会显示「未响应」面试官问 Swing 线程模型时这就是一个能展示深度的加分项。4.4 搜索回车的监听写法与刷新时机搜索框和搜索按钮的事件可以共用同一个方法。给searchField添加ActionListener回车时会触发actionPerformed和按钮点击走的是同一个接口。搜索的边界条件也要想清楚关键字为空白时应该回退到查询全部关键字包含前后空格时先trim()再去拼 SQL。DAO 层已经做了LIKE模糊匹配所以界面层不需要关心具体 SQL 语法只管传关键字。searchField.addActionListener(e - performSearch()); searchButton.addActionListener(e - performSearch()); private void performSearch() { String keyword searchField.getText().trim(); ListContact result keyword.isEmpty() ? contactDAO.findAll() : contactDAO.queryByKeyword(keyword); refreshTable(result); }trim()在前端和后端之间选择放在哪一层答案是不放后端。DAO 只接收参数、执行 SQL不应该知道界面传来的字符串是否干净界面层负责控制输入质量DAO 负责数据访问这个边界一乱后续在同一个 DAO 里兼容多个调用方就会处处被绊。注意如果关键字是英文引号%或_在 MySQL 的LIKE里会被当成通配符需要转义的话要在 DAO 里用ESCAPE子句这个小知识点属于 MySQL 面试八股的一部分但放到真实项目里能主动处理的人不多。5. 通讯录做好这三件事分层、分页、索引5.1 按 View、DAO、Service 拆包别让 SQL 散落在监听器里项目规模到 1500 行代码时如果所有 SQL 都写在ActionListener的匿名内部类里改一个表名要全局搜索。常见做法是拆三层view包放MainFrame和ContactDialogdao包放ContactDAOservice层处理「先查重再插入」「删除前检查关联」这类业务规则。Service 层存在的意义不是套概念而是当你给insert增加「同名联系方式已存在则拒绝插入」的规则时不用改动任何界面代码。public class ContactService { private ContactDAO contactDAO new ContactDAO(); public boolean addOrMerge(Contact contact) throws SQLException { if (contactDAO.existsByPhone(contact.getPhone())) { return false; } contactDAO.insert(contact); return true; } }existsByPhone的实现走SELECT COUNT(*)比先SELECT *再在 Java 层判断list.size()更省内存和带宽。这个分层让主窗口的监听器简洁到一行contactService.addOrMerge(contact)出问题的时候按堆栈能直接定位到 Service 的哪一步而不是在一堆getConnection代码里翻来翻去。分层职责是否出现 SQL典型类view控件布局、事件转发、弹窗校验否MainFrameservice业务规则、事务边界否ContactServicedaoSQL 执行、结果集映射是ContactDAO5.2 数据超过 500 条时用 MySQL 的 LIMIT 分页而不是全量加载通讯录管理系统的数据量一般不会很大但「只登记了 200 人却要把 5000 人的表全查出来」在联调环境里很常见。给查询加LIMIT ? OFFSET ?是成本最低的优化界面层维护一个page和pageSize翻页时重新查询。OFFSET的值等于pageSize * (page - 1)这个公式写错会导致第一页和第二页数据重叠。public ListContact findByPage(int page, int pageSize) throws SQLException { String sql SELECT id, name, phone, email, address, remark FROM contact ORDER BY id DESC LIMIT ?, ?; try (Connection conn JdbcUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, (page - 1) * pageSize); ps.setInt(2, pageSize); try (ResultSet rs ps.executeQuery()) { ListContact list new ArrayList(); while (rs.next()) { // 映射逻辑略 } return list; } } }LIMIT的第一个参数是偏移量第二个是返回行数和很多人的直觉相反。偏移量从 0 开始所以第一页的偏移是 0第二页是 pageSize。如果page是从 1 开始计数的就必须先page - 1不然第二页会漏掉第一页已经展示过的最后几条记录。分页查询还需要一个配套的count()方法来算总页数这一步用SELECT COUNT(*) FROM contact WHERE ...过滤条件和列表查询保持一致否则页数会算错。5.3 姓名列建索引但得知道 LIKE 的坑才不白建通讯录的检索字段主要是姓名和电话给name建索引不难难的是索引生效的条件。WHERE name LIKE 张%能用到索引改用WHERE name LIKE %张%时索引就会失效因为前导通配符让 MySQL 无法从索引树定位起点。通讯录的搜索框从体验上说更接近%张%的模糊匹配这时候索引的作用有限数据量上万时只能接受全表扫描或者引入全文索引但单机桌面应用通常没这个必要。ALTER TABLE contact ADD INDEX idx_contact_name (name); ALTER TABLE contact ADD INDEX idx_contact_phone (phone);电话字段建索引的价值比姓名更高因为电话号码通常是精确匹配WHERE phone 13800138000在索引支持下能瞬间返回。建索引不是越多越好contact表的总数据量不超过 1 万条且写入频繁时索引反而拖慢INSERT性能。这个分寸感是面试官愿意听的内容索引是查询和写入之间的权衡不是无脑加就能提速。通讯录项目建这两个索引足够备注字段remark经常做模糊查询但那是LIKE %xxx%建了索引也用不上。6. 打包成可执行 jar再解决三个 JDBC 老问题6.1 用 Maven Shade 插件打带依赖的胖 jarSwing 项目直接jar -cvf打出来的包运行时十有八九报ClassNotFoundException: com.mysql.cj.jdbc.Driver因为 MySQL 驱动没有被打进去。常见做法是用 Mavenmaven-shade-plugin在pom.xml里配置好主类它会把项目依赖和驱动类一起重打包生成一个双击或命令行可运行的 fat jar。plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-shade-plugin/artifactId version3.5.1/version executions execution phasepackage/phase goalsgoalshade/goal/goals configuration transformers transformer implementationorg.apache.maven.plugins.shade.resource.ManifestResourceTransformer mainClasscom.addr.view.MainFrame/mainClass /transformer /transformers /configuration /execution /executions /pluginMainFrame里需要把main方法写在这个类里或者在main中调用new MainFrame()。打包产物在target/目录下文件名通常是原始名-shaded.jar直接运行java -Dfile.encodingutf-8 -jar address-book.jar启动。-Dfile.encodingutf-8在 Windows 的 GBK 默认编码下特别管用不加这个参数的话界面中文可能变成乱码。6.2 三个排在排查列表前端的 JDBC 异常排错顺序比排错本身更重要。通讯录项目部署到别的机器上时最常见的三个报错和对应解法如下面表格所示按照出现频率从高到低排列。异常信息根因解法ClassNotFoundException驱动未打包进 jar确认 shade 插件生效Public Key Retrieval is not allowedMySQL 8 认证插件限制连接串加allowPublicKeyRetrievaltrueThe server time zone value数据库时区未指定加serverTimezoneAsia/Shanghai如果机器上 MySQL 版本是 5.7连接串里的com.mysql.cj.jdbc.Driver也完全兼容不需要降级驱动。反过来如果用旧版 5.x 驱动连 MySQL 8就会在建立连接时报SSL connection error这时换新版驱动往往比在连接串里调 SSL 参数更省事。连接参数不要硬编码在 Java 类里用db.properties文件维护数据库地址、账号和端口改环境时只需要编辑文本文件而不用重新编译打包个别依赖包路径的处理在其他项目里可以用ClassPathResource加载放在资源目录下编译时会自动进 jar。6.3 数据备份一张表的事用 mysqldump 加两句命令通讯录系统真正上线给人用以后「数据丢了」比「程序报错」严重得多。MySQL 单表备份用mysqldump就能覆盖不需要装额外的图形工具。Windows 计划任务加一个带mysqldump的 bat 文件每天凌晨把 address_book 库导出成带日期后缀的 SQL 文件这个操作比在 Java 程序里写备份模块简单且可靠得多。echo off set timestamp%date:~0,4%%date:~5,2%%date:~8,2% C:\mysql-8.0\bin\mysqldump -uroot -p123456 address_book D:\backup\addr_%timestamp%.sqlbat 脚本里日期变量%date%的格式依赖系统区域设置中文 Windows 上通常是2025/05/18 周一的格式所以用:~0,4这种字符串截取提取年月日。如果机器上装了 MySQL 服务但 shell 里找不到mysqldump命令多半是二进制路径没加到PATH环境变量直接在 bat 里写绝对路径是最省心的方式。备份完成后用type命令打开生成的 SQL 文件确认不是 0 字节一个 0 字节的备份文件是最容易骗过人的假成功。本文还有配套的精品资源点击获取