简介:基于Java开发的通信录管理系统完整源码,适合Java初学者、在校学生用于课程设计或毕业设计参考。系统支持联系人信息的增加、删除、修改与查询,并通过JDBC访问MySQL数据库实现数据的持久化存储;整体采用MVC分层设计,将模型、视图与控制逻辑清晰分离,便于维护和扩展。资源共42个文件,包含14个Java源码、14个class编译文件、6个JSP页面,以及SQL初始化脚本、JAR依赖库和Eclipse工程配置文件,压缩包约636KB,结构清晰,可直接导入开发环境运行和二次修改。目前已有145人学习下载。通过研读该项目,可以掌握Java Web开发中的数据库增删改查、前后端数据传递、异常处理与文件导入导出等实用技能,也能理解一个完整小型系统从界面到数据层的组织方式,对提升工程化编码能力很有帮助。
1. 通信录 Java 项目实战:一个不依赖任何重量级框架的联系人增删改查
某开发者从旧硬盘里翻出一个通信录 Java 项目时,第一反应是“怎么还有文件名带 final 的代码”,但读完后觉得,这个项目把核心知识落得很扎实:实体类、DAO、服务层、SQLite 持久化、控制台交互一样不少,没有任何框架包袱,IDE 里打开就能跑。它解决的问题很直接:不靠 MySQL,不靠 Redis,只用一个嵌入式数据库文件,就能完成联系人的添加、删除、修改、查询,并且重启程序数据依然在。它适合三类人:刚学完 Java 基础和 JDBC 的新手,想快速补一个增删改查项目的课程设计选手,以及需要在真实项目里抄一段 SQLite 持久化逻辑的开发者。
2. 技术栈与工程结构:SQLite 为什么够用,Maven 依赖怎么选
2.1 源码包拆解:一个典型的 Maven 目录能理出哪些责任
拿到源码包后先把目录树拉出来看,项目结构非常规矩,没有多余文件:
通信录Java/ ├── pom.xml ├── src/main/java/ │ └── com/example/addressbook/ │ ├── App.java # 程序入口 │ ├── model/ │ │ └── Contact.java # 联系人实体 │ ├── dao/ │ │ └── ContactDao.java # SQLite 数据访问层 │ ├── service/ │ │ └── ContactService.java # 业务校验与转发 │ └── cli/ │ └── MenuHandler.java # 控制台菜单交互 ├── src/main/resources/ │ └── addressbook.db # SQLite 数据库文件 └── src/test/java/ └── com/example/addressbook/ ├── ContactServiceTest.java # 服务层单元测试 └── ContactDaoTest.java # DAO 层集成测试这种分层的妙处在于,每一层只干一件事。model里放纯数据,不掺任何 JDBC 代码;dao只管 SQL 和结果集的转换;service负责判断“姓名不能为空”“手机号格式对不对”;cli则把用户输入的字符串翻译成 service 调用。后续想改成 Web 接口,只需要写一个新的 Controller,复用 service 和 dao。
这里有一个容易被新手忽略的细节:addressbook.db放在resources下并不是让程序从 classpath 里读,而是让 Maven 打包时把空文件带到根目录,防止首次运行时因为找不到数据库路径而怀疑人生。真正连接时用的是相对路径jdbc:sqlite:addressbook.db,工作目录在哪就在哪创建库文件。
2.2 依赖选型:sqlite-jdbc、JUnit 5、Lombok 各自承担什么
pom.xml里的依赖很少,可以一眼看清项目的地基:
<project> <modelVersion>4.0.0</modelVersion> <groupId>com.example</groupId> <artifactId>addressbook</artifactId> <version>1.0.0</version> <properties> <maven.compiler.source>17</maven.compiler.source> <maven.compiler.target>17</maven.compiler.target> </properties> <dependencies> <dependency> <groupId>org.xerial</groupId> <artifactId>sqlite-jdbc</artifactId> <version>3.42.0.0</version> </dependency> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <version>5.10.0</version> <scope>test</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.28</version> <scope>provided</scope> </dependency> </dependencies> </project>sqlite-jdbc一个包搞定驱动和 SQLite 原生库,不需要额外安装数据库服务,连接方式就是DriverManager.getConnection("jdbc:sqlite:数据库路径")这一行。值得说明的是,这里的scope没有写provided,是compile默认级别,意味着最终 jar 包会内含驱动类,避免打包后单独再找 lib。
JUnit 5 的范围是test,所以生产 jar 不会带测试框架。Lombok 在这类小项目里属于可选项,我用它主要是省掉getter/setter的样板代码,但代码生成是编译期完成的,一旦 IDE 没开启注解处理,编译就会一脸懵。如果团队里有人习惯不装 Lombok 插件,干脆不用它,手动写 getter 也不多。
这个依赖组合反映了一个非常重要的选型直觉:通信录场景的数据量顶天几千条,SQLite 的单文件事务机制完全兜得住。如果一开始拍脑袋上 MySQL,不但要安装服务,还要配置账号密码,学习和运维成本直接把“先跑通”门槛抬高了。项目的用户价值是联系人的增删改查,不是高并发,所以 SQLite 是这里的最优解,不是妥协。
3. 核心模块拆解:一个联系人从输入到落盘的完整链路
3.1 实体层 Contact:id 要不要单独设计,DEFAULT 值如何取舍
Contact.java是这个项目里最基础的一块砖。它不需要继承任何父类,也不实现任何接口,就是一个 POJO。
package com.example.addressbook.model; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @Data @NoArgsConstructor @AllArgsConstructor public class Contact { private Integer id; private String name; private String phone; private String email; }为什么 id 用Integer而不是int?因为新增联系人时 id 是空的,DAO 层执行完 INSERT 后要拿自增主键回填到这个对象上,用null来表示“还未落库”会让逻辑很干净。SQLite 的主键AUTOINCREMENT从 1 开始递增,删除掉最后一条再新插,id 也会继续增长,不会复用旧号。这样的好处是 id 的语义永远是“唯一标识”,不会因为删除了某条记录就产生歧义。
字段校验没有用一堆注解,而是放在 Service 层手动判断。因为实体类要尽量纯净,不该依赖jakarta.validation那套运行时注解,万一以后换成 Web 项目,这些注解又会和 Spring 的校验机制混淆。真正的校验逻辑在后面 service 层,这样实体类既可用于数据库映射,也能直接序列化到 JSON。
3.2 持久层 ContactDao:PreparedStatement 参数绑定与自增主键回填
DAO 层是整个项目的黑匣子枢纽。这一层写得好不好,直接决定程序换一台机器跑还会不会出问题。下面是核心增删改查实现:
package com.example.addressbook.dao; import com.example.addressbook.model.Contact; import java.sql.*; import java.util.ArrayList; import java.util.List; public class ContactDao { private final Connection conn; public ContactDao() throws SQLException { String url = "jdbc:sqlite:addressbook.db"; conn = DriverManager.getConnection(url); try (Statement stmt = conn.createStatement()) { stmt.execute(""" CREATE TABLE IF NOT EXISTS contact ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, phone TEXT, email TEXT ) """); } } public int add(Contact c) throws SQLException { String sql = "INSERT INTO contact(name, phone, email) VALUES(?, ?, ?)"; try (PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, c.getName()); ps.setString(2, c.getPhone()); ps.setString(3, c.getEmail()); int affected = ps.executeUpdate(); try (ResultSet keys = ps.getGeneratedKeys()) { if (keys.next()) { c.setId(keys.getInt(1)); } } return affected; } } public int update(Contact c) throws SQLException { String sql = "UPDATE contact SET name = ?, phone = ?, email = ? WHERE id = ?"; try (PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, c.getName()); ps.setString(2, c.getPhone()); ps.setString(3, c.getEmail()); ps.setInt(4, c.getId()); return ps.executeUpdate(); } } public int delete(int id) throws SQLException { String sql = "DELETE FROM contact WHERE id = ?"; try (PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, id); return ps.executeUpdate(); } } public List<Contact> list() throws SQLException { String sql = "SELECT id, name, phone, email FROM contact ORDER BY id"; try (Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery(sql)) { List<Contact> contacts = new ArrayList<>(); while (rs.next()) { contacts.add(mapRow(rs)); } return contacts; } } private Contact mapRow(ResultSet rs) throws SQLException { Contact c = new Contact(); c.setId(rs.getInt("id")); c.setName(rs.getString("name")); c.setPhone(rs.getString("phone")); c.setEmail(rs.getString("email")); return c; } }这里最关键的习惯是:所有拼接 SQL 的地方一律用PreparedStatement的?占位符,绝不把用户输入字符串直接拼进 SQL。通信录项目虽然看起来没有风险,但一旦用户输入张三'; DROP TABLE contact; --这种名字,拼 SQL 就会让整张表瞬间消失。用参数绑定后,SQLite 驱动会把整个字符串当作一个普通字段值处理,注入方案直接失效。
RETURN_GENERATED_KEYS是自增主键回填的常规做法。注意getGeneratedKeys()必须紧跟在executeUpdate()之后调用,中间不能再执行其他 SQL,否则这个结果集可能被重置。如果你在写某个功能时需要同时插入多条联系人和关联表,这个机制同样适用,只是要把GeneratedKeys的结果遍历完整。
DAO 层没有关闭连接,因为这里用的是单例连接,生命周期归整个程序所有。控制台程序退出时 JVM 停止会自动断开连接。但如果是 Web 应用,这种写法要改成连接池管理,否则并发请求会互相干扰。这也是初学者把 DAO 抄进 Spring 项目时最容易踩的坑。
3.3 服务层与菜单层:校验规则、循环状态和异常转译
Service 层把 DAO 的SQLException转成语义明确的业务异常,同时把菜单层可能误输入的脏数据拦在门口。
package com.example.addressbook.service; import com.example.addressbook.dao.ContactDao; import com.example.addressbook.model.Contact; public class ContactService { private final ContactDao dao; public ContactService() throws Exception { dao = new ContactDao(); } public int add(Contact c) throws Exception { if (c.getName() == null || c.getName().isBlank()) { throw new IllegalArgumentException("姓名不能为空"); } if (c.getPhone() != null && !c.getPhone().matches("\\d{3,15}")) { throw new IllegalArgumentException("电话只能包含数字,长度需在3到15位之间"); } return dao.add(c); } public int update(Contact c) throws Exception { if (c.getId() == null) { throw new IllegalArgumentException("修改时 id 不能为空"); } return dao.update(c); } public List<Contact> listAll() throws Exception { return dao.list(); } public int delete(int id) throws Exception { if (id <= 0) { throw new IllegalArgumentException("id 必须为正数"); } return dao.delete(id); } }业务校验放在服务层而不是 DAO 层,是为了让 DAO 专注于“执行 SQL”这一件事。比如未来加一个“批量导入联系人”的功能,服务层仍然要逐条走同样的校验逻辑,不会漏掉。
控制台交互层MenuHandler的循环设计有点小心机:
package com.example.addressbook.cli; import com.example.addressbook.model.Contact; import com.example.addressbook.service.ContactService; import java.util.List; import java.util.Scanner; public class MenuHandler { private final ContactService service; public MenuHandler() throws Exception { service = new ContactService(); } public void run() { Scanner scanner = new Scanner(System.in); boolean running = true; while (running) { try { System.out.println("请选择:1.添加 2.全部联系人 3.修改 4.删除 5.退出"); String command = scanner.nextLine(); switch (command) { case "1" -> addContact(scanner); case "2" -> listContacts(); case "3" -> updateContact(scanner); case "4" -> deleteContact(scanner); case "5" -> running = false; default -> System.out.println("无效选项,请重新输入"); } } catch (Exception e) { System.out.println("操作失败:" + e.getMessage()); } } scanner.close(); } private void addContact(Scanner scanner) { System.out.print("姓名:"); String name = scanner.nextLine(); System.out.print("电话:"); String phone = scanner.nextLine(); System.out.print("邮箱:"); String email = scanner.nextLine(); Contact c = new Contact(null, name, phone, email); try { int rows = service.add(c); System.out.println("添加成功,影响行数:" + rows + ",生成 id:" + c.getId()); } catch (Exception e) { System.out.println("添加失败:" + e.getMessage()); } } }run()方法把整个循环体包在 try/catch 里,是个看起来很糙但极其实用的做法。控制台程序本身就是一次性的短会话,与其在每一个方法里单独 catch 异常,不如在最外层统一拦截并显示e.getMessage(),既能保证程序不因一个小错误崩溃退出,又能让用户看到具体原因。通信录这种工具型项目,稳定输出比花哨的错误处理更有价值。
switch (command)使用 Java 14 之后的箭头语法,每个分支不再需要break,执行完自动跳出。菜单命令用字符串而不是数字,可以避免Scanner.nextInt()与后续nextLine()混用时的那种换行符陷阱,后面避坑章节会详细展开。
4. 从零到跑通:Maven 构建、SQLite 初始化与三种启动姿势
4.1 一条命令链:先 clean,再 test,最后 package
打开终端进入源码根目录,优先执行:
mvn clean test这条命令会把之前编译过的旧 class 清掉,然后跑一遍单元测试。通信录项目的测试不多,但ContactServiceTest里有一个非常关键的用例:验证空格姓名会被拦截。只要这个测试通过,说明 Service 层的校验真的生效了。
接着打包:
mvn package -DskipTests跳过测试是为了快速出 jar,省去二次验证。打完包后在target/下生成addressbook-1.0.0.jar,运行:
java -jar target/addressbook-1.0.0.jar如果你用的是 IDEA,可以直接打开App.java,右键“运行”也能启动。这背后隐含着一条规矩:项目里不该有任何自定义启动脚本,让标准的 Maven 生命周期保证“本地能跑,命令行也能跑”。
4.2 第一次启动:数据库文件在哪里生成,建表语句何时执行
首次启动时,ContactDao构造方法里的CREATE TABLE IF NOT EXISTS会自动创建表结构。表不存在才创建,存在就跳过,所以哪怕你把contact表手动删掉再启动,程序也能自愈,这是往里写业务前必须先做好的底层保障。
此时注意观察工作目录:如果你用java -jar在项目根目录运行,就会在根目录下看到addressbook.db文件。如果你在 IDEA 里运行,则取决于 working directory 的配置。有些同学设置错了工作目录,导致程序重新连到了另一个空库文件上,看起来数据全没了,其实数据还在原来的位置。
一个稳妥的验证方式是看日志或加一行输出。DAO 构造里可以把conn.getMetaData().getURL()打印出来,这样能立刻知道当前连接到了哪个路径。
4.3 持久性验证:用 sqlite3 命令直接查表
程序添加几个联系人并退出后,执行:
sqlite3 addressbook.db .tables SELECT * FROM contact;如果能看到刚插入的记录,说明数据没有只停留在内存里。再去修改和删除一条记录,重复同样查询,会发现 UPDATE 和 DELETE 确实落盘了。这一步跑完,才算真正验证了“持久化”,而不是只看了代码就以为没问题。
需要注意,SQLite 的读锁与写锁会互相阻塞,如果你在程序运行的同时打开 SQLite 命令行执行写操作,可能收到database is locked提示。这是因为程序持有的连接还没释放。通信录这种单用户程序一般不会遇到,但如果你把这段代码搬到一个长连接常驻的服务进程里,就要考虑连接是否被正确关闭。
5. 避坑指南:这类项目最容易翻车的几个点
5.1 现象:重启后联系人全没了,代码看着也没错
原因:SQLite 默认处于自动提交关闭状态,你执行INSERT/UPDATE/DELETE后,事务没有提交。程序正常退出时,未提交的事务被回滚,所以数据消失。
解决:在 DAO 层执行写操作后统一调用conn.commit()。简洁的做法是让Connection开启自动提交:
conn.setAutoCommit(true);很多教程里会写一句话“JDBC 默认自动提交”,这句话在 MySQL 驱动上是真的,但在 SQLite JDBC 上默认行为会坑人。我一般会在构造方法里显式设置,不让默认行为跑偏。
5.2 现象:控制台中文乱码,姓名变成问号
原因:Windows 控制台默认编码是 GBK,而源码和项目配置是 UTF-8。Scanner读入时按平台默认编码解析,中文就裂了。
解决:启动时附加-Dfile.encoding=UTF-8参数,或者把 IDE 的 console output encoding 改成 UTF-8。在 IDEA 里是“Help -> Edit Custom VM Options”加上-Dfile.encoding=UTF-8。如果是在纯命令行跑 jar,就写成:
java -Dfile.encoding=UTF-8 -jar target/addressbook-1.0.0.jar5.3 现象:SQL 执行报错 no such table: contact
原因:Connection连接成功,但建表语句没执行成功,或者当前连接的是另一个路径的库文件。最常见的是把url写成了jdbc:sqlite:src/main/resources/addressbook.db,但打包后运行时没有src/main/resources这层路径。
解决:统一使用相对路径jdbc:sqlite:addressbook.db,并在 DAO 构造里先执行建表语句,再开始增删改查,确保顺序正确。
5.4 现象:输入 5 想退出,程序却直接跳过或死循环
原因:如果菜单用了Scanner.nextInt(),用户输入数字后会残留一个换行符,之后nextLine()会把空字符串读取为下一次命令,导致分支匹配失败。
解决:永远用scanner.nextLine()读取整行,然后把字符串解析成命令。对应本项目里的switch (command)方案。这个坑几乎是控制台项目的通病,我遇到过的翻车现场比 SQL 报错还多。
5.5 现象:打包出的 jar 运行提示 ClassNotFoundException: org.sqlite.JDBC
原因:sqlite-jdbc依赖没有打进 jar。如果你把 Maven 打包插件配置成了maven-assembly-plugin但没指定shadeMainJar,或者误把依赖 scope 改成provided,就会出现这种鬼问题。
解决:直接用maven-shade-plugin配置includes,或者把依赖的scope保持在默认状态。推荐最简单的方式:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-shade-plugin</artifactId> <version>3.4.1</version> <executions> <execution> <phase>package</phase> <goals><goal>shade</goal></goals> </execution> </executions> </plugin>配置后重新mvn clean package,再查看生成的 jar 大小,如果从几十 KB 变成几 MB,就说明驱动被打进去了。
6. 二次开发实战:从控制台升级为 Swing GUI 的最小改动方案
6.1 只替换交互层:把 Scanner 换成 JFrame 事件监听
控制台菜单层是唯一依赖java.util.Scanner的地方,而 Service 和 Dao 都不关心用户如何输入。因此升级 GUI 时只需新增一个view/ContactFrame.java,不动其它任何类。
写一个 200 行的 Swing 面板,表格用JTable展示service.listAll(),按钮监听触发service.add/update/delete。这里最需要小心的是:Swing 的 UI 线程不能执行耗时操作,SQLite 的执行速度极快,可以忽略,但如果在其他项目里换成了网络数据库,务必用SwingWorker包一层,避免忘掉这个边界。
6.2 增加“标签”字段并支持按标签筛选:数据库、实体、菜单三层联动
第一步在表里加字段:
ALTER TABLE contact ADD COLUMN tag TEXT DEFAULT '朋友';第二步在Contact实体加private String tag;,同时更新mapRow、PreparedStatement和INSERT/UPDATESQL。第三步在服务层搜索方法:
public List<Contact> searchByTag(String tag) throws Exception { return dao.searchByTag(tag); }DAO 层里写:
public List<Contact> searchByTag(String tag) throws SQLException { String sql = "SELECT id, name, phone, email, tag FROM contact WHERE tag = ? ORDER BY id"; try (PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, tag); try (ResultSet rs = ps.executeQuery()) { List<Contact> result = new ArrayList<>(); while (rs.next()) { result.add(mapRow(rs)); } return result; } } }这样改动后,旧的控制台菜单里多加一个6.按标签筛选分支就能测试,Swing 版也可以在搜索框里复用同一个 service 方法。
6.3 用 JUnit 钉死回归:先写测试再改代码
二次开发最怕的是改完字段把原来的查询逻辑带崩。我的习惯是先补一个回归测试再动代码。比如验证新增联系人后listAll()能查到带tag字段的完整对象,以及按标签筛选时空列表不会抛异常。等红测用例出现,再补实现,最后全绿收工。这比手工点菜单快得多,也能避免改到一半把 DDL 都忘了。
从那次以后,我每次拿到一个新的 Java 项目都会强制走一遍流程:先看 pom.xml 理清依赖,再跑一遍测试确认红绿状态,然后用一条最简单的 CRUD 链路打断点,最后才敢动手改代码。这种习惯帮我省掉了无数次“明明我没动这里怎么就坏了”的自我怀疑。希望你也能用这个通信录项目,少走几步我曾踩过的冤枉路,早日把整套流程跑顺。
本文还有配套的精品资源,点击获取