学 Java 到第 14 天,终于不再是照着教程敲语法 demo 了。这篇博文记录的是一套完整的“部门系统”案例开发过程,核心功能就是部门的增删查改。选它当第一个完整案例,是因为它足够小,却能覆盖 Java Web 开发里最要命的一整条链路:页面提交请求 → Servlet 接收参数 → Service 做业务判断 → DAO 访问数据库 → 再把结果返回页面。
如果你也处在 Java 基础刚过完、想找一个能练手的闭环项目,或者准备课程设计却不知道从哪下手,这份 Day14 笔记应该能直接抄作业。我自己的进度是:第 1~13 天过完了变量、流程控制、面向对象、集合、异常、IO,还花了两天把 JDBC 的基础 API 摸了一遍,用的是 MySQL 8 + 原生 JDBC,从第 14 天开始进入 Web 层。技术栈是 JDBC + Servlet + JSP,没有引入 Spring 那一套。正因为还没上框架,原生方式反而把问题暴露得更彻底,后面再换框架时,你才会知道框架到底帮我们省了什么。
1. 为什么拿“部门系统”当第一个完整案例
1.1 案例选型:它小到什么程度,又大到什么程度
很多人学完 Java 基础之后会面临同一个问题:资料看了一堆,真正动手时脑子里一片空白。我当时也这样。Day14 之前我试着写过图书管理、学生信息管理,发现要么是表字段太多,写着写着就去调样式了;要么是业务太抽象,根本理解不了为什么要分 Service 和 DAO 两层。
部门系统刚好卡在一个很舒服的位置。部门这个对象只有几个核心属性:部门编号、部门名称、部门描述、创建时间。增删查改的每一个功能都直白得不需要解释,但背后的工程问题一个都不少:表单怎么提交、参数怎么校验、数据库连接什么时候关闭、列表查询结果为空怎么办、删除一个被引用的部门会出什么问题。它小到一天能跑完,大到足够把 Java Web 的分层思想完整串一遍。
1.2 前后 13 天的知识在这里完成第一次合流
我整理了一下这套案例到底用到了哪些前 13 天的内容,列出来之后连我自己都吓一跳:
- 面向对象:Department 实体类封装,属性私有、提供 getter/setter;
- 集合框架:查询结果用
List<Department>承载,循环渲染到表格; - 异常处理:SQLException 的上抛与 finally 中关闭资源;
- JDBC:Connection、PreparedStatement、ResultSet 的常规使用;
- 流程控制:根据 action 参数分发到不同业务方法。
更关键的是,它第一次强迫你关注“一个请求从浏览器到数据库再回来”这件事。以前写 JDBC 是在 main 方法里打印结果,一旦放进 Web 容器,你会发现控制台输出的时代结束了,数据的去处变成了 JSP 页面上的表格。这个转变是第 14 天最重要的收获。
1.3 技术栈为什么不直接上 Spring Boot
我在网上搜了很多案例,教程清一色用 Spring Boot 一键生成 CRUD。对初学者来说,这其实是有毒的。一键生成之后,你只知道点“运行”按钮网页就出来了,但数据是怎么流过去的、请求是怎么被路由的、SQL 是怎么拼接的,全部被框架吞掉了。
所以我坚持用 JDBC + Servlet + JSP 裸写一遍。目的很简单:让每一条数据流肉眼可见。Servlet 里你亲眼看着request.getParameter()取出字符串,DAO 里你亲手把字符串填进PreparedStatement,JSP 上用 EL 表达式把数据渲染出来。等以后接触 MyBatis 和 Spring Data JPA,你会觉得那些框架的底层设计全是顺理成章的,而不是一片黑盒。
2. 搭工程和设计表:动手前先想清楚这三件事
2.1 环境清单和项目目录长什么样
动手前先确认环境,别到写代码时才发现问题:
- JDK 11;
- Tomcat 9;
- MySQL 8.0,数据库名
company_db; - IDE 用的 IDEA,直接配置本地 Tomcat 运行;
- Maven 管理依赖,项目是 war 包结构。
如果你是手动导入 jar 包的流程,思路完全一样,区别只是把依赖文件放进WEB-INF/lib而已。Maven 的pom.xml里我引入了三个依赖:Servlet API(provided 作用域)、MySQL 驱动、JSTL 标签库。后三个用不到 EL 也许可以不引,但我为了给列表页做遍历用到了 JSTL 的<c:forEach>,所以提前补上了。
项目包结构我按照 Web 项目的经典分层来建:
src/main/java ├── com.demo.entity -- Department 实体类 ├── com.demo.dao -- DepartmentDao 数据访问类 ├── com.demo.service -- DepartmentService 业务逻辑类 ├── com.demo.servlet -- DepartmentServlet 控制器 └── com.demo.util -- DBUtil 数据库连接工具 src/main/webapp ├── department │ ├── list.jsp -- 列表页 │ ├── add.jsp -- 新增页 │ └── edit.jsp -- 修改页 └── WEB-INF/web.xml包结构划分的意义不在好看,而在隔离变化。DAO 只管数据库读写,Service 只管业务规则,Servlet 只管接收参数和转向页面。后面你哪怕把 JDBC 换成 MyBatis,前端页面不用动一行,这就是分层的价值。
2.2 department 表设计的三个关键字:唯一、默认、可空
表结构我第一版建得很偷懒,只有 id 和 name,后来加搜索功能时发现没有描述字段,搜索不知道该匹配什么,只好回头重建表。第二版我稳了一点,建表语句如下:
CREATE TABLE department ( id INT NOT NULL AUTO_INCREMENT COMMENT '部门编号', name VARCHAR(50) NOT NULL COMMENT '部门名称', description VARCHAR(255) DEFAULT NULL COMMENT '部门职责描述', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (id), UNIQUE KEY uk_name (name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;三个细节值得说。第一个是 name 加了唯一约束,这样重名部门在数据库层面就被拦住了,比在 Java 代码里先查再插更可靠。并发场景下“先查后插”会有竞态问题,唯一索引才是最终防线。第二个是 create_time 用了DEFAULT CURRENT_TIMESTAMP,插入的时候完全不用管它,数据库自动写当前时间。第三个是 description 允许为空,因为不是所有部门都需要填职责描述,强制 NOT NULL 反而给用户制造麻烦。
2.3 实体类与数据库连接工具的最小骨架
实体类没什么技术含量,但字段类型要和数据库对齐。MySQL 的DATETIME对应 Java 的java.util.Date,TINYINT类型很容易被顺手写成Integer,在这里还没遇到,后面做员工状态字段时要小心。
public class Department { private Integer id; private String name; private String description; private Date createTime; public Department() {} public Department(String name, String description) { this.name = name; this.description = description; } // getter 和 setter 省略,IDE 自动生成 }DBUtil 工具类是整个项目的地基。我最初写的时候把驱动注册放到每次获取连接里,结果每调用一次就 Class.forName 一次,虽然影响不大,但看着别扭。后来改成了静态代码块,类加载时只注册一次:
public class DBUtil { private static final String URL = "jdbc:mysql://localhost:3306/company_db" + "?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=UTF-8"; private static final String USER = "root"; private static final String PASSWORD = "123456"; static { try { Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError("MySQL驱动加载失败,请检查依赖"); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }注意这里的 URL 参数:serverTimezone=Asia/Shanghai解决 MySQL 8 的时区警告,characterEncoding=UTF-8解决下文中文字符乱码。这两个参数不写,后面一定会回来补课。
3. 查询和删除:两个越简单越容易漏的功能
3.1 列表查询和关键字搜索:SQL 别这么拼
查询是所有页面的入口。列表页一打开就要把全部部门查出来,然后渲染成表格。DAO 里最基础的findAll方法:
public List<Department> findAll() throws SQLException { String sql = "SELECT id, name, description, create_time FROM department"; List<Department> list = new ArrayList<>(); try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { while (rs.next()) { Department dept = new Department(); dept.setId(rs.getInt("id")); dept.setName(rs.getString("name")); dept.setDescription(rs.getString("description")); dept.setCreateTime(rs.getDate("create_time")); list.add(dept); } } return list; }这里我要特意强调 try-with-resources 的写法。第 13 天之前我习惯用 finally 手动关闭 ResultSet、Statement、Connection,代码又臭又长。JDK 7 之后 try-with-resources 会自动关闭实现了 AutoCloseable 的资源,而且关闭顺序是反着的,ResultSet 先关、Connection 最后关。初学阶段就该养成这个习惯,不然写数据库代码一半时间都在处理资源泄漏。
关键字搜索是列表查询的自然延伸。我在列表页上面加了一个搜索框,提交一个keyword参数过来。SQL 的写法是最容易翻车的地方:
// 错误写法 String sql = "SELECT * FROM department WHERE name LIKE '%" + keyword + "%'"; // 正确写法 String sql = "SELECT id, name, description, create_time FROM department WHERE name LIKE ?"; ps.setString(1, "%" + keyword + "%");错的那种写法有两个问题:一是字符串拼接容易引发 SQL 注入,二是如果 keyword 里有单引号,整个 SQL 直接语法错误。用PreparedStatement占位符,然后传%关键字%,既防注入又干净。我一开始甚至想过写LIKE '%?%',执行后毫无悬念地报错,占位符不能嵌在字符串字面量里。想实现前后模糊匹配,要么用concat('%', ?, '%'),要么在 Java 测把百分号拼进参数里,我选了后者,可读性更好。
Servlet 层通过 action 参数分发,列表和搜索共用一套方法:
@WebServlet("/department") public class DepartmentServlet extends HttpServlet { private DepartmentService service = new DepartmentService(); @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String action = req.getParameter("action"); if ("list".equals(action) || action == null) { listWithSearch(req, resp); } else if ("delete".equals(action)) { delete(req, resp); } else if ("edit".equals(action)) { showEditPage(req, resp); } } private void listWithSearch(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String keyword = req.getParameter("keyword"); List<Department> list = service.findDepartments(keyword); req.setAttribute("deptList", list); req.getRequestDispatcher("/department/list.jsp").forward(req, resp); } }action == null时也默认走列表,这样直接访问/department就能看到列表页,减少一次手输参数的麻烦。
3.2 删除单个部门:外键约束第一次教做人
删除功能的代码很短:
private void delete(HttpServletRequest req, HttpServletResponse resp) throws IOException { int id = Integer.parseInt(req.getParameter("id")); boolean success = service.deleteDepartment(id); if (success) { resp.sendRedirect(req.getContextPath() + "/department?action=list"); } else { resp.sendRedirect(req.getContextPath() + "/department?action=list&error=1"); } }真正有戏的是 Service 层。删除一个部门之前,必须考虑“这个部门下面还有员工”。我为了验证这个问题,专门建了一张employee表,里面dept_id外键指向department.id。然后试着删除一个部门,数据库直接报错:
Cannot delete or update a parent row: a foreign key constraint fails这个报错是好事,它说明数据完整性被保护住了。但直接把异常抛给用户不友好。我的处理方式是在 Service 里显式检查:
public boolean deleteDepartment(int id) { try (Connection conn = DBUtil.getConnection()) { // 先查部门下是否有员工 String checkSql = "SELECT COUNT(*) FROM employee WHERE dept_id = ?"; try (PreparedStatement ps = conn.prepareStatement(checkSql)) { ps.setInt(1, id); try (ResultSet rs = ps.executeQuery()) { rs.next(); if (rs.getInt(1) > 0) { return false; // 有关联员工,删除失败 } } } // 再执行删除 String deleteSql = "DELETE FROM department WHERE id = ?"; try (PreparedStatement ps = conn.prepareStatement(deleteSql)) { ps.setInt(1, id); return ps.executeUpdate() > 0; } } catch (SQLException e) { throw new RuntimeException("删除部门失败", e); } }列表页上的删除按钮我加了一个onsubmit确认,问一句“确定删除该部门吗”。这个交互虽然简单,但能挡住很多手滑误删。删除成功后必须用sendRedirect重定向回列表页,不能用forward,原因后面第 5 章专门讲。
3.3 两个容易踩的坑:空集合与重复提交
第一个坑是查询结果为空。有人喜欢在 DAO 里返回null,然后在 JSP 里判断if (null != deptList)。这个习惯非常危险,因为你无法保证 Service 层的每一处都对 null 做了判空,一个疏忽就是 NullPointerException。我在自己的项目里定了条规矩:查询集合永远返回空集合,而不是 null。
List<Department> list = new ArrayList<>(); // 初始就是空集合 return list;这样 JSP 页面用<c:forEach>遍历空集合时会自动什么都不显示,不用单独写判空逻辑。
第二个坑是删除和新增之后的重复提交。我刚写完删除功能时,用的是forward转发到列表页。结果发现,删除一次之后按一下 F5,浏览器提示“是否重新提交表单”,确认之后又删了一次。原因是转发只在服务器内部跳转,浏览器地址栏还是原来的删除请求地址。改成重定向之后,浏览器先收到一个 302,然后重新发起一次 GET 请求到列表页,地址栏变成/department?action=list,刷新就只会重新查询,不会再次删除。
4. 新增和修改:把表单校验写进 Service 层
4.1 新增部门的完整数据流与自增主键回显
新增功能的页面是add.jsp,表单里有两个字段:部门名称和部门描述。提交之后走doPost,我照样通过 action 分发:
@Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String action = req.getParameter("action"); if ("add".equals(action)) { add(req, resp); } else if ("update".equals(action)) { update(req, resp); } }add 方法接收参数、调用 Service、重定向:
private void add(HttpServletRequest req, HttpServletResponse resp) throws IOException { String name = req.getParameter("name"); String description = req.getParameter("description"); boolean success = service.addDepartment(name, description); if (success) { resp.sendRedirect(req.getContextPath() + "/department?action=list"); } else { resp.sendRedirect(req.getContextPath() + "/department?action=add&error=1"); } }Service 层负责最核心的插入逻辑。这里有一个知识点很多人会忽略:插入之后要拿到数据库自动生成的自增主键。业务场景很常见,比如新增部门成功后,跳转到这个部门的编辑页,就需要先有这个 id 才能回显。
public boolean addDepartment(String name, String description) { if (name == null || name.trim().isEmpty() || name.length() > 50) { return false; } String sql = "INSERT INTO department(name, description) VALUES(?, ?)"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, name.trim()); ps.setString(2, description); ps.executeUpdate(); try (ResultSet rs = ps.getGeneratedKeys()) { if (rs.next()) { int generatedId = rs.getInt(1); System.out.println("新生成的部门ID = " + generatedId); } } return true; } catch (SQLException e) { if (e instanceof SQLIntegrityConstraintViolationException) { return false; // 部门重名,唯一约束触发 } throw new RuntimeException("新增部门失败", e); } }注意prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)这个重载。如果不传第二个参数,执行完executeUpdate后getGeneratedKeys()很可能拿不到自增 id。这是 MySQL 驱动的一个实际行为,我当时查了很久才发现是这个细节。
4.2 修改部门的三步走:查详情、回显、提交更新
修改功能比新增多了一个步骤:回显。用户点“编辑”时,需要先把这条部门数据查出来,填到表单里,用户改完再提交更新。
private void showEditPage(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { int id = Integer.parseInt(req.getParameter("id")); Department dept = service.findDepartmentById(id); req.setAttribute("dept", dept); req.getRequestDispatcher("/department/edit.jsp").forward(req, resp); }edit.jsp 的表单和 add.jsp 几乎一样,区别只有两点:第一,form 里多了个隐藏域id;第二,输入框的 value 从dept对象里取。EL 表达式写起来很爽:
<input type="hidden" name="id" value="${dept.id}"> <input type="text" name="name" value="${dept.name}" required> <textarea name="description">${dept.description}</textarea>这里有个小坑:${dept.description}如果数据库里是 null,页面上会显示字符串"null",而不是空白。解决办法是回显时在 Service 里做一次 null 到空串的转换,或者直接用 JSTL 的<c:out>加默认值。我嫌麻烦,直接在实体类的 getter 里兜底:
public String getDescription() { return description == null ? "" : description; }修改的 update 方法就是 SQL 的 UPDATE 语句,把 name 和 description 按 id 更新。执行完同样重定向回列表页。核心点在于:修改不涉及主键,别把 id 写在 SET 子句里;Where 条件只写 id。有的初学者把 SQL 写成UPDATE department SET name=?, id=? WHERE id=?,数据错乱得很隐蔽。
4.3 前后端双重校验:别把希望全押在 required 上
很多人以为 HTML 表单里写了required属性,后端就能高枕无忧了。这完全是错觉。required只是浏览器层面的提示,用户可以绕过前端直接构造请求。所以后端必须有一层真正的校验。
我把校验逻辑统一放在 Service 层,新增和修改复用同一个方法:
private String validate(String name, String description) { if (name == null || name.trim().isEmpty()) { return "部门名称不能为空"; } if (name.trim().length() > 50) { return "部门名称长度不能超过50"; } if (description != null && description.length() > 255) { return "部门描述长度不能超过255"; } return null; }校验不通过时,直接把错误消息返回给 Servlet,Servlet 把消息放到 request 域里,转发回原表单页面,并在页面上显示。这套流程比返回一个笼统的 false 要友好得多,用户能立刻知道错在哪。
可能有人会问:为什么不在 Servlet 里写校验?因为校验属于业务规则,放在 Servlet 会让你以后复用很痛苦。假设将来出现一个批量导入部门的接口,如果校验逻辑只在表单提交的 Servlet 里,批量导入就得重写一遍。放在 Service 层,所有入口共用一套规则,这才是分层设计的意义。
5. 乱码和运行时错误:两段高价值排错记录
5.1 中文变问号:从 JSP 到 MySQL 的四层排查
这是整套案例里我花时间最长的一个 bug。新增部门时填“技术部”,点击提交,到数据库里看变成“????”。问题出在四个环节,任何一个不对,中文都会翻车。
第一层是 JSP 页面本身的编码。新建的 JSP 文件顶部必须有:
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>pageEncoding告诉 JSP 引擎用 UTF-8 读这个文件,contentType告诉浏览器响应用 UTF-8 显示。
第二层是请求编码。POST 请求的表单数据在请求体里,Servlet 读取时必须先设定编码,而且必须在第一次调用getParameter之前。我一开始写在doPost末尾,完全无效。
req.setCharacterEncoding("UTF-8");如果漏了这行,浏览器用 UTF-8 编码提交的中文,到了 Servlet 里会被当成 ISO-8859-1 解码,妥妥乱码。
第三层是 JDBC 连接 MySQL 时的编码。这一步就是前面 2.3 节 DBUtil 里 URL 上的characterEncoding=UTF-8参数。它告诉 MySQL 驱动,客户端发过来的字符串是 UTF-8 编码的,请按 UTF-8 处理。
第四层是表和数据库的字符集。我建表时用的CHARSET=utf8mb4,可以放心存中文。如果建库时默认字符集是 latin1,那前面三层全对了也白搭。
排查顺序建议从数据库往浏览器倒着查:先看表结构字符集,再看 JDBC URL,再看请求编码,最后看 JSP 头。实际踩坑时往往是同时改好几处才恢复正常的,所以最好一次性把四层全部检查到位再测试。
5.2 NullPointerException:多半是参数名叫错了
Day14 里我遇到的最多的运行时错误就是 NullPointerException。排查下来有一个规律:绝大多数 NPE 的前一行都是request.getParameter的返回值直接调了方法。
举个例子,表单里<input name="deptName">,Servlet 里写成:
String name = req.getParameter("deptName"); if (name.trim().isEmpty()) { ... } // NPEgetParameter拿不到参数时返回 null,然后对 null 调用.trim(),必炸。而且这个 bug 很隐蔽,因为你肉眼扫代码看不出问题,只有跑起来在控制台看到异常栈,才能顺着at com.demo.servlet.DepartmentServlet.add()定位到具体哪一行。
我的解决习惯是写一个安全取值方法,所有 Servlet 入口统一走它:
private String getParam(HttpServletRequest req, String name) { String value = req.getParameter(name); return value == null ? "" : value.trim(); }这样至少不会因为一个 null 就把整个请求打崩。
还有一类 NPE 来自 rs 取不到数据。比如findDepartmentById方法里,如果查不到结果,rs.next()返回 false,你再去rs.getInt("id")就会抛SQLException,不是 NPE。但如果你把查询结果直接赋给实体类,代码又不判空,在 Servlet 里传dept.getId()就会 NPE。所以查询单个对象时,查不到就返回 null 没问题,但使用方必须判空;查多条的,就按 3.3 节的习惯返回空集合。
5.3 刷新就重复提交:转发和重定向到底差在哪
这个坑其实在 3.2 节已经提过,但我还想单独拎出来说,因为它太典型了。用forward转发做新增成功跳转,会有什么后果?
req.getRequestDispatcher("/department/list.jsp").forward(req, resp);转发是服务器内部动作,浏览器地址栏还停留在/department?action=add。用户看到新增成功想刷新一下列表,浏览器重新提交上一次的 POST 请求,结果又插了一条一模一样的部门记录,而且因为 name 有唯一约束,这次刷新会触发重复键异常。
重定向写的是:
resp.sendRedirect(req.getContextPath() + "/department?action=list");浏览器收到 302 响应后,会主动发起一个新的 GET 请求,地址栏变成列表中。/GET 请求天然是安全幂等的,刷新一万次只是重新查一次列表,不会产生副作用。这个原则记住一句话:凡是会修改数据的请求,处理完一定用重定向,不要用转发。
此外还有一个很小的性能细节,重定向比转发多一次网络往返,但这个代价换来的正确性完全值得。
6. 从部门系统到员工系统:下一步扩展建议
这一整套部门系统跑通之后,第 15 天的计划就非常清晰了:直接套用同样的分层结构去做员工系统。员工表的字段更多,卡片更复杂,还需要在设计部门删除时处理“部门下有员工”的联动。我的具体建议是往这几个方向扩展:
第一,列表页加上分页。LIMIT ? OFFSET ?两行 SQL 就能实现,还要配合一个SELECT COUNT(*)算总页数。这个练习能帮你理解为什么前端分页和后端分页对大数据量来说完全是两个概念。
第二,删除改成批量删除。用 checkbox 勾选多个部门,提交时把 id 拼成逗号分隔的字符串,后端拆开后用动态拼接生成 IN 占位符。这个练的是字符串处理和 SQL 动态拼接的功力。
第三,把查询条件从“部门名称模糊搜索”扩展到“按创建时间范围查询”。你会发现写一条带日期参数的 SQL 也没多难,但日期格式的前后端传递会小小折磨你一下,早点踩坑早点长记性。
我做完这套部门 CRUD 之后最大的变化,是不再害怕看完整代码了。以前点开一个项目总觉得眼花缭乱,现在哪怕是大项目的 Controller-Dao 分层,我也能顺着请求路径逐个摸清。这就是第 14 天这一整套案例开发给我的最大回报。
最后再分享一个习惯:每次写完一个功能,我会在浏览器里把“正常操作、空输入、超长输入、连续点击提交”这四类情况各测一遍。这套部门系统后来能顺利扩展成员工系统而没有返工,靠的就是前期这些看起来有点笨的测试。如果你也在学 Java 的道路上走到了类似阶段,建议别急着开新章节,先把手头的这套 CRUD 改到滚瓜烂熟,你会发现后面很多 Web 框架的引入都变得顺理成章了。