简介:Java开发中,分层架构与JDBC是构建可维护企业级应用的基础。通过合理划分实体层、数据访问层、业务层和控制层,开发者可以实现业务逻辑与数据访问解耦,显著提升代码复用性与可测试性。JDBC作为Java访问关系型数据库的桥梁,其PreparedStatement防注入、事务管理、结果集映射等核心机制,是每个Java开发者必须掌握的技能。本文以一个学生信息管理系统课程设计为例,深入解析五层架构的划分原则、DAO接口设计、JDBC封装细节,以及事务处理与性能优化实践。从菜单交互到数据库操作,完整呈现请求流转链路,并分享IDEA运行配置与常见坑点,为初学者提供从“能跑”到“像个项目”的进阶路径,助力课程设计答辩与真实项目能力提升。
1. 拿它做课设,最值的不是“跑通”,而是把这套分层代码吃透
很多人的 Java 学生信息管理系统课程设计,交上去的版本就是一个 main 方法里堆了三百行判断的“大泥球”。而我拆这份资源时最深的感受是:它把「学生管理」这个老掉牙的题目,按真实项目的分层方式做了一遍。你拿它交差可以,拿它应付答辩也没问题,但拿它当“分层架构 + JDBC 封装”的入门样本,才是真正不亏。这份资源定位就是 Java 初学者到进阶之间的过渡项目,适合正在做课设的在校生,也适合那些想看看“一个常规 CRUD 系统怎么从零组织代码”的自学者。整套代码覆盖学生信息增删改查、班级管理、成绩录入与统计、用户登录这几个核心模块,数据落在 MySQL,界面走控制台交互,没有花哨框架,骨架却非常完整。
2. 整体架构拆解:从“能用”到“像个项目”只差一层责任划分
2.1 五层结构:实体层、工具层、DAO 层、Service 层、控制层
这份资源最值得学的不是某个算法,而是包的划分方式。我第一次打开源码目录时,先看的不是代码,而是包结构。它长这样:
src/ ├── com/example/student/ │ ├── entity/ # 实体类:Student、Clazz、Score、User │ ├── util/ # 工具类:DBUtil(数据库连接)、ValidationUtil(参数校验) │ ├── dao/ # 数据访问层:StudentDAO、ClazzDAO、ScoreDAO │ ├── dao/impl/ # DAO 实现类:JDBC 实现 │ ├── service/ # 业务层:StudentService、ClazzService、ScoreService │ ├── service/impl/ # 业务实现类 │ ├── controller/ # 控制层:MenuController、StudentController 等 │ └── Main.java # 启动入口这个结构不是随便分的。entity 只装数据;util 里的 DBUtil 只负责拿连接;dao 层只写 SQL 和执行 JDBC;service 层只做业务判断和事务边界;controller 只接收输入、调用 service、打印结果。各层单向依赖,谁都不越界。
我见过太多课设代码把 SQL、业务判断、System.out.println 全塞在一个类里,跑是能跑,但答辩老师问一句“你的业务逻辑和数据访问是怎么解耦的”,就卡住了。这份资源的价值正在这里:它给了你一套能讲清楚的代码组织方式,而不是一个能跑的黑匣子。
2.2 实体层设计:字段对应表结构,命名与表字段严格对齐
实体类设计上,它没有用什么高深技巧,就是老老实实的 POJO。真正值得留意的是命名对齐。比如学生实体,字段名和数据库表列名完全一致:
public class Student { private Integer id; // 数据库自增主键 private String studentNo; // 学号,业务唯一键 private String name; // 姓名 private String gender; // 性别:男 / 女 private Integer clazzId; // 所属班级 ID,外键关联 clazz 表 private String phone; // 联系电话 private String email; // 邮箱 private String createTime; // 创建时间,yyyy-MM-dd HH:mm:ss // 无参构造、全参构造、getter/setter,省略 }这里特别说明一下clazzId为什么不直接存班级名称。很多课设项目喜欢把“班级名”直接当一个字符串字段放学生表里,看起来省事,但后续如果班级改名,你就得写一条 UPDATE 去刷学生表。而用外键关联clazz表,班级名称只维护一份,学生表只存 ID。这是“字段命名对齐”之外的第二个设计意图:用关联字段表达数据关系,而不是用冗余字段。
还有一个细节:createTime用的是字符串。正式项目里建议用java.time.LocalDateTime,但课设场景下用字符串配合VARCHAR也说得过去,前提是你在插入时统一用同一个时间格式,否则后面按时间排序会乱。
2.3 工具类:配置文件读连接,不把密码硬编码在代码里
DBUtil 是这份资源的门面之一。它没有把数据库连接字符串硬编码在 Java 源码里,而是读db.properties配置文件。这一点在答辩时是个加分项,因为老师通常会问“数据库配置改了怎么办”。
public class DBUtil { private static String url; private static String username; private static String password; static { try { InputStream in = DBUtil.class.getClassLoader() .getResourceAsStream("db.properties"); Properties props = new Properties(); props.load(in); Class.forName("com.mysql.cj.jdbc.Driver"); url = props.getProperty("db.url"); username = props.getProperty("db.username"); password = props.getProperty("db.password"); } catch (Exception e) { throw new ExceptionInInitializerError("数据库配置加载失败"); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(url, username, password); } }静态代码块里完成驱动加载和配置读取,类加载时执行一次,之后每次getConnection()都直接拿配置好的参数去创建连接。这里有个容易踩的坑:db.properties必须放在 resources 目录下(或者 classpath 下),否则getResourceAsStream拿到的是 null,后面 NPE 会让你排查半天。
另外说一句:Class.forName在 MySQL 8.x 驱动下其实可以省略,因为 JDBC 4.0 之后驱动会自动注册。但写上也没错,而且能让新手理解“驱动加载”这件事。课程设计项目里保留它没毛病。
3. 数据访问层实战:PreparedStatement + 手动封装结果集
3.1 DAO 接口定义:先定契约再写实现
这份资源在 DAO 层定义了接口,再用 JDBC 实现,这在课设里算是“超额完成”。接口的好处是:后续如果你想换成 JPA 或者 MyBatis,service 层不用动,只换实现类。
public interface StudentDAO { int insert(Student student); int update(Student student); int deleteById(Integer id); Student findById(Integer id); List<Student> findByClazzId(Integer clazzId); List<Student> findAll(); List<Student> findByName(String name); long countByClazzId(Integer clazzId); }接口设计的核心是“按业务需要定义方法”,不是把每张表的每个字段都暴露一遍。比如countByClazzId就是为了后面做“删除班级前检查该班是否还有学生”这个业务而准备的。这个意识比写十个方法更重要:DAO 的方法是为 service 服务的,不是为“操作数据库”服务的。
3.2 新增学生:一个 insert 方法带你熟悉自增主键回填
看具体实现。新增学生是贯穿系统的基础操作,也是理解 PreparedStatement 的好样本:
public int insert(Student student) { String sql = "INSERT INTO student (student_no, name, gender, clazz_id, phone, email, create_time) " + "VALUES (?, ?, ?, ?, ?, ?, ?)"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, student.getStudentNo()); ps.setString(2, student.getName()); ps.setString(3, student.getGender()); ps.setInt(4, student.getClazzId()); ps.setString(5, student.getPhone()); ps.setString(6, student.getEmail()); ps.setString(7, student.getCreateTime()); int rows = ps.executeUpdate(); if (rows > 0) { try (ResultSet rs = ps.getGeneratedKeys()) { if (rs.next()) { student.setId(rs.getInt(1)); // 自增主键回填 } } } return rows; } catch (SQLException e) { e.printStackTrace(); return 0; } }重点是Statement.RETURN_GENERATED_KEYS那一行。JDBC 执行 INSERT 后,数据库生成的自增 ID 不会自动写进你的实体对象,必须通过getGeneratedKeys()拿。很多课设项目插入后直接再查一次“最新的一条记录”来拿 ID,不仅多一次查询,并发下还会拿错。这个回填写法是正确做法,答辩时能讲清楚这点,老师通常会点头。
参数顺序要和 SQL 里?的位置严格对应,少一个错一个,全是对不上的错位。如果后期改表加了字段,记得同时改 SQL 和 setXxx 两处。
3.3 查询列表:手动映射 ResultSet 到实体对象
查询这块比较常规,但它里面有个值得说的习惯:用while (rs.next())逐行读取,手动 set 进对象,最后返回 List。别的框架也是这么干的,只不过 MyBatis 帮你写好了映射,JDBC 需要自己动手:
public List<Student> findAll() { String sql = "SELECT id, student_no, name, gender, clazz_id, phone, email, create_time FROM student ORDER BY id DESC"; List<Student> list = new ArrayList<>(); try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { while (rs.next()) { Student s = new Student(); s.setId(rs.getInt("id")); s.setStudentNo(rs.getString("student_no")); s.setName(rs.getString("name")); s.setGender(rs.getString("gender")); s.setClazzId(rs.getInt("clazz_id")); s.setPhone(rs.getString("phone")); s.setEmail(rs.getString("email")); s.setCreateTime(rs.getString("create_time")); list.add(s); } } catch (SQLException e) { e.printStackTrace(); } return list; }注意try-with-resources的写法。Java 7 引入,作用是在 try 块结束自动关闭Connection、PreparedStatement、ResultSet,不需要手动写 finally 去 close。课设项目里这一步尤其重要,因为连接不关闭,跑几个操作后 MySQL 的连接数就满了,报Too many connections,这个故障我见过太多人遇到。
这个查询方法没有加条件,但在 service 层通常还会做一次包装,比如传入模糊关键字去拼WHERE name LIKE ?。我一般会在 DAO 里加一个带条件的方法,而不是在 service 层改 SQL 字符串。
3.4 事务处理:删除班级时先查学生数,再决定是否执行
事务逻辑没有放在 DAO 层,而是由 service 层控制。这份资源里最典型的一个业务是“删除班级”:如果该班级下还有学生,就不能删。这个业务如果只写一条DELETE FROM clazz WHERE id = ?,学生会变成“孤儿数据”——class_id 指向一个不存在的班级。看 service 层的处理:
public class ClazzServiceImpl implements ClazzService { private StudentDAO studentDAO = new StudentDAOImpl(); private ClazzDAO clazzDAO = new ClazzDAOImpl(); public boolean deleteClazz(Integer clazzId) { long count = studentDAO.countByClazzId(clazzId); if (count > 0) { System.out.println("该班级下还有 " + count + " 名学生,不能删除"); return false; } return clazzDAO.deleteById(clazzId) > 0; } public boolean transferStudents(Integer fromClazzId, Integer toClazzId) { Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 先更新学生班级 // 再处理目标班级的逻辑,比如人数上限检查 conn.commit(); return true; } catch (SQLException e) { if (conn != null) { try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } } e.printStackTrace(); return false; } finally { if (conn != null) { try { conn.setAutoCommit(true); conn.close(); } catch (SQLException e) { e.printStackTrace(); } } } } }这里有两个点值得展开说。第一个是setAutoCommit(false):默认 JDBC 是每条 SQL 执行完自动提交,一旦两步操作之间抛异常,前面一步已经落库了,数据就不一致。改成手动提交后,要么全部成功,要么全部回滚。第二个是finally里把setAutoCommit(true)设回去再 close,这是连接归还前的一个好习惯,不然连接池复用时会继承非自动提交状态。
这个“先查后删”和“事务拆两步”的逻辑,在答辩里几乎是必问的。能讲明白为什么不能只删班级,课设的份量就不一样了。
4. 业务层与界面交互:一个请求从菜单到数据库的完整链路
4.1 控制层:只做输入接收,不碰 SQL
控制层就是控制台菜单的转发逻辑。以“添加学生”为例,它的完整链路是:主菜单 → 学生管理菜单 → 调用 StudentService.addStudent() → 先做业务校验 → 再调 DAO 持久化 → 返回结果 → 控制层打印提示。
public class StudentController { private StudentService studentService = new StudentServiceImpl(); private ClazzService clazzService = new ClazzServiceImpl(); private Scanner scanner = new Scanner(System.in); public void addStudentMenu() { System.out.println("--- 添加学生 ---"); Student s = new Student(); System.out.print("学号: "); s.setStudentNo(scanner.nextLine()); System.out.print("姓名: "); s.setName(scanner.nextLine()); System.out.print("性别(男/女): "); s.setGender(scanner.nextLine()); System.out.print("班级ID: "); s.setClazzId(Integer.parseInt(scanner.nextLine())); System.out.print("电话: "); s.setPhone(scanner.nextLine()); System.out.print("邮箱: "); s.setEmail(scanner.nextLine()); boolean ok = studentService.addStudent(s); if (ok) { System.out.println("添加成功"); } else { System.out.println("添加失败"); } } }控制层能看到它连 SQL 的关键字都不出现。哪怕项目架构再简单,把输入输出剥离开之后,后续你想从控制台改成 Swing 窗体,控制器换成监听器调用同一套 service 就行,DAO 层和数据库都不用动。改造路径清晰,这比“控制台程序只能永远是控制台程序”更重要。
4.2 业务层校验逻辑:学号重复 + 班级存在性检查
service 层接到的任务不只是“把对象塞给 DAO”,它要处理业务规则。以新增学生为例,它做了两件事:学号不能重复、班级必须存在。
public boolean addStudent(Student student) { if (student.getStudentNo() == null || student.getStudentNo().trim().isEmpty()) { System.out.println("学号不能为空"); return false; } if (studentDAO.findByStudentNo(student.getStudentNo()) != null) { System.out.println("学号已存在:" + student.getStudentNo()); return false; } if (clazzDAO.findById(student.getClazzId()) == null) { System.out.println("班级不存在,ID: " + student.getClazzId()); return false; } student.setCreateTime(LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); return studentDAO.insert(student) > 0; }findByStudentNo这个 DAO 方法在接口里虽然没列,但它在实现类里是存在的。它的 SQL 就是SELECT ... FROM student WHERE student_no = ?。这一步先查后插的做法,在并发场景下不严谨(两个请求同时查到不存在,然后都去插),但课设项目里完全够用,而且比“先插再捕获唯一索引异常”更好懂。
业务层返回 boolean,而不是把异常抛给控制层打一大段堆栈。这个设计取舍对课设项目是合适的:用户看到的是“学号已存在”,不是一堆看不懂的红色异常。当然你也可以定义BizException让控制层统一定制错误文案,但那份工作量对一个课设来说有点超纲了。
4.3 成绩统计模块:SQL 聚合还是 Java 计算?
成绩统计是这份资源里稍微有点“含金量”的地方。它的成绩表 score 字段包括student_id、course_name、score_value。统计“某学生的平均分”有两种做法,它选的是 SQL 聚合:
public Double getAvgScoreByStudentId(Integer studentId) { String sql = "SELECT AVG(score_value) FROM score WHERE student_id = ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, studentId); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { return rs.getDouble(1); } } } catch (SQLException e) { e.printStackTrace(); } return null; }这里解释一下为什么用 SQL 而不是查出所有成绩再用 Java 求平均:数据量小的时候二者没差别,但 AVG 是在 MySQL 服务端算的,只回传一个数值,网络传输量小;如果用 Java 算,你得把该学生所有成绩记录拉到应用层,再遍历求和。课设可能感觉不到差距,但这个选择本身就是好习惯。
另一个细节:平均分返回Double而不是double。如果学生一条成绩都没有,AVG 结果是 NULL,rs.getDouble会返回 0.0,和“考了 0 分”混淆。用包装类型Double,就能用null表达“无数据”的语义,控制层看到 null 就提示“该学生尚无成绩”。
4.4 登录模块:明文密码 + MD5 的取舍
登录模块相对基础——输入用户名密码,查 user 表,比对通过则进入主菜单。但这份资源里做了一个保守但合理的决定:密码没有明文存储,而是做了 MD5 加盐后入库。
public boolean login(String username, String password) { String hashed = DigestUtils.md5Hex(password + salt); User user = userDAO.findByUsername(username); if (user != null && user.getPassword().equals(hashed)) { return true; } return false; }这里我不想把这个讲得太高深。MD5 在今天已经不安全了,真实项目应该用 BCrypt。但课设项目里,能做到“不把密码明文放库里”已经是加分项。如果你愿意再多走一步,把DigestUtils.md5Hex换成BCrypt.hashpw,那这段代码的“安全课设”属性就拉满了。加盐字符串可以是固定的,也可以是每个用户随机生成的,后者更稳妥。
5. 运行部署与常见排查:照着做完库表和 IDEA 配置就能跑
5.1 MySQL 建库建表脚本
资源里带了建表 SQL 脚本,但这部分我建议你手动再跑一遍,顺便理解表结构设计。核心三张表如下:
CREATE DATABASE IF NOT EXISTS student_db DEFAULT CHARACTER SET utf8mb4; USE student_db; CREATE TABLE clazz ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL UNIQUE, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE student ( id INT PRIMARY KEY AUTO_INCREMENT, student_no VARCHAR(20) NOT NULL UNIQUE, name VARCHAR(50) NOT NULL, gender VARCHAR(4), clazz_id INT, phone VARCHAR(20), email VARCHAR(100), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (clazz_id) REFERENCES clazz(id) ); CREATE TABLE score ( id INT PRIMARY KEY AUTO_INCREMENT, student_id INT NOT NULL, course_name VARCHAR(50) NOT NULL, score_value DECIMAL(5,2), FOREIGN KEY (student_id) REFERENCES student(id) );utf8mb4别改。如果你用默认的utf8,中文没问题,但遇到生僻字或者 emoji 就会乱码。这个坑很常见,建库时没注意,后面程序里怎么调编码都白搭。
外键约束要保留。有些项目为了“性能”去掉外键,那是生产环境的取舍;课设项目里保留外键能防止无效数据,比如插入 student 时指向一个不存在的 clazz_id,数据库会直接报错。让你写程序时就多了一道防线。
5.2 IDEA 导入与运行参数配置
跑这个项目不需要 Maven,jar 包手动导入就行。这份资源里主要依赖的是 MySQL 驱动mysql-connector-java-8.x.jar,如果你本机 MySQL 是 5.7,驱动换成 5.1.x 版本也是可以的。
IDEA 导入步骤我按常规操作走一遍:
- 打开 IDEA,
File -> New -> Project from Existing Sources,选择项目根目录,选Import project from external model,选 Eclipse 或直接选 Java 都可以,取决于项目源码是怎么导出的。 Project Structure -> Libraries添加依赖包mysql-connector-java-8.0.x.jar。- 配置
db.properties里的db.url。如果连的是本机 MySQL,默认端口 3306,URL 是jdbc:mysql://localhost:3306/student_db?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8。serverTimezone必须加,否则控制台报时区错误(Server returns invalid timezone),这是 MySQL 8.x 最常见的问题。 - 修改
db.username和db.password为你本机 MySQL 的账号密码。 - 找到
Main.java,右键运行。
如果没有意外,控制台会出现菜单。注意一点:IDEA 的 Run 窗口里如果有中文乱码,把Help -> Edit Custom VM Options里加上-Dfile.encoding=UTF-8再重启一次,一般能解决。
5.3 避坑记录:四条来自课设一线的血泪经验
这一节写我在实际帮人调试这个项目时遇到的高频问题,每一条都是“现象 → 原因 → 解决”的结构。
坑一:ClassNotFoundException 或找不到驱动。现象:运行Class.forName那一步直接抛ClassNotFoundException: com.mysql.cj.jdbc.Driver。 原因:驱动 jar 包没有正确引入到项目依赖里,或者你用的是com.mysql.jdbc.Driver老驱动名但实际导的是 8.x 驱动。 解决:MySQL 8.x 驱动类名是com.mysql.cj.jdbc.Driver;同时确认mysql-connector-java-8.0.x.jar出现在Project Structure -> Libraries中,不是在文件夹里摆着但没引入。
坑二:连接报Public Key Retrieval is not allowed。现象:getConnection抛异常,提示Public Key Retrieval is not allowed。 原因:新版 MySQL 8 驱动默认使用caching_sha2_password认证,客户端首次连接需要服务端发公钥,但驱动默认禁止自动获取。 解决:在 JDBC URL 上追加参数allowPublicKeyRetrieval=true。注意不要在公网环境长期开这个选项,本地课设没有安全问题。
坑三:查询结果中文全部是问号。现象:写入数据库的中文变成???,英文正常。 原因:db.properties里的 URL 没有设置characterEncoding=utf8,连接使用了默认的 latin1。 解决:把characterEncoding=utf8加到db.url参数里,并确保建表时DEFAULT CHARACTER SET utf8mb4。两个地方都改掉重启程序。
坑四:删除班级时外键约束报错。现象:执行DELETE FROM clazz WHERE id = 1时抛Cannot delete or update a parent row: a foreign key constraint fails。 原因:score表和student表里有数据外键引用了这个clazz记录,数据库不让删。 解决:删除班级前先处理班级下的学生:要么转班,要么删除学生,再删班级。这也是上文 service 层deleteClazz方法里先查countByClazzId的原因。如果你不管数据完整性、直接想去掉约束,也可以SET FOREIGN_KEY_CHECKS = 0,但那是逃避问题,不推荐。
坑五:IDEA 运行时报Error: java: invalid source release: 17。现象:编译报错,让你确认 source/target 版本。 原因:项目编译级别和本地 JDK 版本不匹配,比如本地是 JDK 17,但代码里有 JDK 8 不兼容的语法配置。 解决:Project Structure -> Project把 SDK 改为本地已装的 JDK;如果项目是 JDK 8 的,不要强行用 JDK 17 跑,有些课设代码用了老 API,17 下行为会变。
6. 二次开发:从控制台到 Swing 界面的低成本改造路线
这份资源给你留的改造空间其实非常大。下面这条路线是我自己带 A 同学做课设升级时常用的“最小改动”思路,目标是从控制台程序升级成带图形界面的版本,同时尽量不动 DAO 层和 service 层。
具体改法是:加一个view包,里面新建LoginFrame、StudentManagePanel、ScoreChartPanel。控制层的逻辑从Scanner接收输入改成按钮事件里读取 JTextField 的值,然后调用同一个studentService。DAO 层、数据库表完全不用动。这等于你只换了界面层,业务和数据层实现了“无缝复用”。
以“添加学生”为例,Swing 改造的核心代码长这样:
String studentNo = txtStudentNo.getText().trim(); String name = txtName.getText().trim(); String gender = (String) comboGender.getSelectedItem(); Integer clazzId = Integer.parseInt(txtClazzId.getText().trim()); Student s = new Student(); s.setStudentNo(studentNo); s.setName(name); s.setGender(gender); s.setClazzId(clazzId); boolean ok = studentService.addStudent(s); if (ok) { JOptionPane.showMessageDialog(this, "添加成功"); refreshTable(); // 刷新表格数据 } else { JOptionPane.showMessageDialog(this, "添加失败,请检查学号是否重复、班级是否存在"); }注意控制台的System.out.println在这里换成了JOptionPane,但 service 返回的 boolean 没有变。这说明当初返回值设计的可替换性就很好。如果你想做得更细一点,把 service 返回字符串错误信息而不是 boolean,那改动就大了,会波及所有 controller 代码——所以这版原文代码里 boolean 的设计我觉得是合适的:简单直接的接口比“什么都想要”的接口更容易被复用。
另一个值得动手的方向是加入“学生成绩可视化”。在 score 表基础上加一个ChartPanel,把某学生的各科成绩用条形图画出来。这不需要改数据层,只需多写一个查询方法List<Score> findByStudentId(Integer studentId),然后在界面层用JFreeChart画图。JFreeChart 的 jar 是开源的,导入依赖后大概三十行代码就能画出一个柱状图,课设的“亮点模块”就有了。
如果不想碰图形界面,也可以做“导出数据到 Excel”。用 Apache POI 把studentDAO.findAll()的结果写进.xlsx文件,同样是新增一个util/ExcelUtil.java,不碰原有代码。这个方向的好处是:它把你对 POI 的学习经历自然融进系统,答辩时聊“为什么用 XSSFWorkbook 而不是 HSSFWorkbook”都能说两句。
回顾我带 A 同学完整走这一遍的经验:整个改造从建 view 包到界面跑起来,一个下午能完成;剩下半天用来解决 JFreeChart 中文乱码——把图表标题字体显式设为new Font("宋体", Font.BOLD, 14)就行,这个坑我已经踩过一次了。从那以后我每次做 Java 课设改造,都强制先确认“哪些层可以不动”,然后再动手新代码。这份资源在分层上的洁癖,反而成了它最适合练手的理由。希望这篇文章帮到你,改完记得回来看看自己当初卡在哪一步——能说清那一步为什么卡住,你就比大多数抄代码的人强了。
本文还有配套的精品资源,点击获取