1. 项目概述:一个真实可跑、不注水的Java Web基础骨架
“Java--小项目(登录、增删改查、分页、搜索)”——这行标题在初学Java Web的开发者社区里,几乎每天都会被复制粘贴几十次。它不像“Spring Boot微服务架构设计”那样高大上,也不像“AI模型推理优化”那样前沿炫技,但它恰恰是横亘在“学完语法”和“能接活儿”之间最真实、最不可绕过的那道门槛。我带过不少刚从培训班出来的同学,也帮某高校实验室的本科生调试过课程设计,发现一个共性问题:他们能手写HashMap遍历,却卡在登录成功后跳转不到首页;能背出JDBC四大步骤,但一加个模糊搜索就SQL报错;知道PageHelper能分页,却搞不清为什么前端传了page=2,后端查出来还是第一页的数据。这个小项目,表面看是五个功能点的拼凑,实则是一套完整的Web请求生命周期演练:从前端表单提交、后端参数绑定、数据库交互、结果封装,到响应渲染,环环相扣。它不追求技术栈的最新潮,而是用最朴素的Servlet+JSP(或轻量Thymeleaf)+ JDBC + MySQL组合,把每个环节的“毛边”都暴露出来,逼你亲手去打磨。适合谁?不是给已经能独立开发后台管理系统的中级工程师看的,而是给那些代码能跑通但心里没底、遇到报错只会百度前两行、对“为什么这样写”始终存疑的入门者。它解决的不是“能不能做”,而是“为什么这么做才稳”。接下来我会带你从零开始,不跳步、不省略任何一行关键代码,把登录校验的密码加盐逻辑、增删改查里事务边界的划定、分页参数如何防篡改、搜索条件如何动态拼接这些教科书里一笔带过的细节,全部摊开讲透。
2. 整体架构与技术选型:为什么拒绝“一步到位”的框架堆砌
2.1 技术栈选择背后的现实考量
很多人看到“小项目”第一反应就是:“直接上Spring Boot+MyBatis Plus,三分钟生成CRUD,不香吗?”香,但容易飘。我试过用脚手架生成一套完整后台,让一个刚学完Java基础的同学去理解其中的自动配置原理、代理对象生成、SQL执行器链路,结果他花了三天时间,只搞懂了application.yml里server.port=8080这一行。所以这个项目,我坚持用最“原始”的组合:JDK 8 + Tomcat 9 + Servlet 4.0 + JSP 2.3 + JDBC + MySQL 5.7 + Bootstrap 4。这不是复古情怀,而是基于三个硬性约束:
- 学习成本可控:Servlet的doGet/doPost方法,就是HTTP请求最直白的映射;JSP里的<%= %>表达式,就是Java代码嵌入HTML的最直观形式。没有自动注入、没有AOP织入、没有启动时的上百个Bean初始化日志,所有流程都在你眼皮底下发生。
- 问题定位清晰:当登录失败时,你能立刻判断是
request.getParameter("username")取不到值(前端问题),还是userDao.findByUsername()返回null(数据库问题),或是passwordEncoder.matches()校验失败(密码逻辑问题)。而如果用Spring Security,一个403错误可能来自Filter链、Session管理、权限注解、甚至CSRF Token校验,新手根本无从下手。 - 资源占用极低:整个WAR包打出来不到5MB,部署到本地Tomcat,启动时间<3秒。对比Spring Boot内嵌Tomcat动辄200MB内存占用和15秒启动,这种轻量级更适合在学生笔记本、老旧开发机上反复调试,不卡顿。
提示:这里刻意避开Spring MVC,不是因为它不好,而是因为它的“约定优于配置”会掩盖很多底层细节。比如
@RequestParam自动类型转换,会让人忽略String到Integer的NumberFormatException风险;@ResponseBody自动JSON序列化,会让人忘记手动处理null值导致的空指针。我们先学会“造轮子”,再用“高级轮子”,路才走得稳。
2.2 分层结构设计:四层而非三层,把“控制”真正交还给开发者
传统教学常提MVC三层:Model(实体类)、View(JSP页面)、Controller(Servlet)。但这在实际操作中极易混乱。我见过太多同学把数据库查询逻辑直接写在Servlet里,或者在JSP里写<% out.print(user.getName().toUpperCase()) %>这种业务逻辑。因此,本项目采用四层结构,多出的一层叫“Service Facade”(服务门面),它像一道闸门,明确划清各层职责:
- Web层(Servlet):只做三件事——接收HTTP参数、调用Service层方法、决定跳转到哪个JSP。绝不碰SQL,绝不做业务计算。
- Service层(Service Facade):处理核心业务规则。例如登录:检查用户名是否为空、密码长度是否达标、账户是否被禁用、登录失败次数是否超限。它调用DAO,但不关心DAO怎么实现。
- DAO层(Data Access Object):只做一件事——和数据库对话。
insert(User user)、updateById(User user)、selectByCondition(Map<String, Object> params)。它不处理分页参数,不分页是Service层的事。 - Model层(POJO):纯粹的数据载体,只有字段和getter/setter,不包含任何方法(连
toString()都不加,避免序列化污染)。
这种分法带来的直接好处是:当你需要把MySQL换成Oracle时,只需重写DAO层的SQL语句,Service和Web层代码一行不动;当你想给搜索功能加全文索引,只改DAO层的selectByCondition()方法,其他层完全无感。我在某公司给实习生布置任务时,就要求他们先用纯JDBC写完DAO,再用MyBatis重写一遍,对比两者的代码量和可读性——结果90%的人发现,MyBatis的XML配置反而比手写JDBC更难调试,因为错误堆栈指向的是SqlSessionTemplate,而不是你自己的UserDaoImpl.java第37行。
2.3 数据库设计:从“够用”到“防坑”的字段定义
很多人建表只图快,user表随手写个id int primary key auto_increment, username varchar(20), password varchar(50)就完事。但实际运行中,问题立刻浮现:用户注册“admin' --”导致SQL注入;密码明文存储被拖库;用户名重复插入报错却不友好提示。所以本项目的user表,字段定义如下(MySQL语法):
CREATE TABLE `user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID', `username` varchar(32) NOT NULL COMMENT '用户名,唯一,支持邮箱/手机号', `password` char(64) NOT NULL COMMENT 'BCRYPT加密后的密码,固定64位', `salt` char(16) NOT NULL COMMENT '随机盐值,用于密码加盐', `status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '状态:0-禁用,1-启用', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户信息表';关键点解析:
username用varchar(32)而非20:现代系统用户名支持邮箱(如zhang.san@example.com)和手机号(13812345678),32位足够覆盖。password用char(64):BCRYPT算法输出固定60字符,加2位标识符(如$2a$10$...),64位精确匹配,避免varchar的隐式转换开销。- 强制添加
salt字段:不是为了“更安全”,而是为了可重现性。很多教程教“用UUID当盐”,但UUID每次生成都不同,导致同一密码加密后结果不同,无法校验。这里的salt是用户注册时生成并存库的固定值,确保BCryptPasswordEncoder.matches(rawPassword, storedHash)能稳定工作。 status用tinyint(1)而非enum:MySQL的enum在JDBC中映射为String,需要额外转换;tinyint直接映射为Integer,Service层用if (user.getStatus() == 0)判断,干净利落。create_time和update_time用DEFAULT CURRENT_TIMESTAMP:避免在Java代码里手动new Date(),防止服务器时区不一致导致的时间错乱。
3. 核心功能实现:从登录校验到动态搜索的逐行拆解
3.1 登录功能:不止于“用户名密码匹配”,而是完整的会话生命周期管理
登录看似简单,但它是整个项目安全性的基石。很多初学者只写SELECT * FROM user WHERE username=? AND password=?,这存在三大致命缺陷:密码明文传输、无防暴力破解、无会话超时。本项目采用以下方案:
第一步:前端密码加盐哈希(非必须,但强烈推荐)
在登录页面的JS中,不直接提交明文密码,而是用bcryptjs在浏览器端预哈希:
<!-- login.jsp --> <script src="https://cdn.jsdelivr.net/npm/bcryptjs@2.4.3/dist/bcrypt.min.js"></script> <script> function submitLogin() { const rawPass = document.getElementById('password').value; // 使用固定盐值(实际应由后端下发动态盐,此处简化) const salt = "my_static_salt_123"; const hashedPass = bcrypt.hashSync(rawPass + salt, 12); // 12轮迭代 document.getElementById('password').value = hashedPass; document.getElementById('loginForm').submit(); } </script>注意:这并非替代后端校验,而是增加第一道防线。即使HTTPS被中间人劫持(概率极低),攻击者拿到的也是哈希值,无法直接用于登录。真正的校验仍在后端进行。
第二步:后端双重校验与会话绑定LoginServlet.java的核心逻辑:
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String username = request.getParameter("username"); String clientHashedPass = request.getParameter("password"); // 前端传来的哈希值 // 1. 基础校验(空值、长度) if (StringUtils.isBlank(username) || StringUtils.isBlank(clientHashedPass)) { request.setAttribute("error", "用户名或密码不能为空"); request.getRequestDispatcher("/login.jsp").forward(request, response); return; } // 2. 查询用户(含盐值) User user = userDao.findByUsername(username); if (user == null) { request.setAttribute("error", "用户名不存在"); request.getRequestDispatcher("/login.jsp").forward(request, response); return; } // 3. 密码校验:用数据库中的salt + clientHashedPass 进行BCRYPT比对 // 注意:这里不是比对两个哈希值,而是用user.salt重新哈希clientHashedPass String serverHashedPass = BCrypt.hashpw(clientHashedPass, user.getSalt()); if (!BCrypt.checkpw(clientHashedPass, user.getPassword())) { // 密码错误,记录失败次数(可扩展为Redis计数) request.setAttribute("error", "密码错误"); request.getRequestDispatcher("/login.jsp").forward(request, response); return; } // 4. 状态校验 if (user.getStatus() == 0) { request.setAttribute("error", "该账户已被禁用,请联系管理员"); request.getRequestDispatcher("/login.jsp").forward(request, response); return; } // 5. 登录成功:创建HttpSession并绑定用户信息 HttpSession session = request.getSession(true); session.setMaxInactiveInterval(30 * 60); // 30分钟无操作超时 session.setAttribute("currentUser", user); // 存储User对象,非ID // 6. 重定向到主页,避免F5刷新重复提交 response.sendRedirect(request.getContextPath() + "/home.jsp"); }关键细节说明:
session.setMaxInactiveInterval(30 * 60):设置会话超时为30分钟,这是Tomcat默认值,但显式写出是为了强调其重要性。测试时可设为60秒快速验证。session.setAttribute("currentUser", user):存储整个User对象,而非仅ID。理由:后续所有页面(如个人中心)都需要用户姓名、头像等信息,如果只存ID,每次都要查库,违背“一次登录,全程可用”原则。内存占用可接受,User对象通常<1KB。- 重定向(
sendRedirect)而非转发(forward):这是防止用户按F5刷新导致重复登录的关键。转发是在服务器内部跳转,URL不变;重定向是告诉浏览器去访问新URL,刷新时只会刷新新页面。
第三步:全局登录拦截(Filter实现)
创建LoginFilter.java,拦截所有需要登录的请求(如/user/list,/user/edit):
public class LoginFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) res; // 放行登录页、静态资源、登录接口 String uri = request.getRequestURI(); if (uri.contains("/login.jsp") || uri.contains("/login") || uri.contains("/css/") || uri.contains("/js/") || uri.contains("/images/")) { chain.doFilter(req, res); return; } // 检查Session中是否存在currentUser HttpSession session = request.getSession(false); if (session == null || session.getAttribute("currentUser") == null) { // 未登录,重定向到登录页,并携带原请求URL作为参数 String originalUrl = request.getRequestURL().toString(); response.sendRedirect(request.getContextPath() + "/login.jsp?redirect=" + URLEncoder.encode(originalUrl, "UTF-8")); return; } chain.doFilter(req, res); } }实操心得:
URLEncoder.encode()必不可少。如果用户直接访问/user/edit?id=123,不编码会导致重定向URL变成/login.jsp?redirect=http://localhost:8080/myapp/user/edit?id=123,问号后的id=123会被当成登录页的参数,丢失原始意图。编码后变为%3Fid%3D123,登录成功后再解码跳转,体验无缝。
3.2 增删改查(CRUD):事务边界与空值安全的实战守则
CRUD是数据操作的核心,但新手常犯的错误是:新增后不刷新列表、删除时没确认弹窗、修改时忽略并发更新。本项目通过以下设计规避:
新增(Create):表单验证 + 主键回填 + 列表自动刷新AddUserServlet.java:
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 1. 接收参数并封装为User对象 String username = request.getParameter("username"); String rawPassword = request.getParameter("password"); // 2. 服务层校验(非空、长度、唯一性) String validateMsg = userService.validateForAdd(username, rawPassword); if (validateMsg != null) { request.setAttribute("error", validateMsg); request.getRequestDispatcher("/user/add.jsp").forward(request, response); return; } // 3. 生成盐值并加密密码 String salt = RandomStringUtils.randomAlphanumeric(16); // Apache Commons Lang String hashedPass = BCrypt.hashpw(rawPassword + salt, BCrypt.gensalt(12)); User user = new User(); user.setUsername(username); user.setPassword(hashedPass); user.setSalt(salt); // 4. 调用Service新增(含事务) try { userService.addUser(user); // 新增成功后,将新用户的ID回填到request,供JSP显示 request.setAttribute("newUserId", user.getId()); request.setAttribute("success", "用户添加成功!ID:" + user.getId()); } catch (Exception e) { request.setAttribute("error", "添加失败:" + e.getMessage()); } request.getRequestDispatcher("/user/add.jsp").forward(request, response); }关键点:
userService.validateForAdd()在Service层统一校验,避免在Servlet里写一堆if。校验逻辑包括:username长度3-32、不能包含特殊字符(正则^[a-zA-Z0-9_\\.@-]+$)、数据库唯一性检查(用SELECT COUNT(*),非INSERT ... ON DUPLICATE KEY,因后者异常堆栈不友好)。RandomStringUtils.randomAlphanumeric(16)生成16位随机盐,比UUID.randomUUID().toString()更短且无横线,存储更省空间。- 新增成功后,
user.getId()能获取到自增主键值,前提是DAO层使用PreparedStatement.RETURN_GENERATED_KEYS:
// UserDaoImpl.java public void insert(User user) throws SQLException { String sql = "INSERT INTO user (username, password, salt) VALUES (?, ?, ?)"; try (PreparedStatement ps = connection.prepareStatement(sql, PreparedStatement.RETURN_GENERATED_KEYS)) { ps.setString(1, user.getUsername()); ps.setString(2, user.getPassword()); ps.setString(3, user.getSalt()); ps.executeUpdate(); // 获取自增主键 try (ResultSet rs = ps.getGeneratedKeys()) { if (rs.next()) { user.setId(rs.getLong(1)); // 将主键值赋给user对象 } } } }删除(Delete):软删除 + 二次确认 + 批量操作
不物理删除,而是更新status=0:
// UserService.java public boolean deleteUser(Long id) { // 先检查是否为超级管理员(ID=1),禁止删除 if (id == 1L) { throw new IllegalArgumentException("超级管理员不可删除"); } return userDao.updateStatus(id, 0) > 0; // 返回影响行数 }前端JSP中,删除按钮触发JS确认:
<a href="javascript:void(0)" onclick="if(confirm('确定要删除用户ID ${user.id}?此操作不可恢复!')){location.href='${pageContext.request.contextPath}/user/delete?id=${user.id}'}">删除</a>注意:
confirm()是阻塞式弹窗,比Ajax异步弹窗更简单可靠。对于批量删除,只需在列表页加复选框,提交时用request.getParameterValues("ids")接收字符串数组,再转为Long[]批量处理。
修改(Update):乐观锁防并发 + 字段级更新
为防A、B两人同时编辑同一用户,A保存后B覆盖,引入版本号字段version:
ALTER TABLE `user` ADD COLUMN `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号';更新SQL改为:
UPDATE user SET username=?, password=?, salt=?, version=version+1 WHERE id=? AND version=?;DAO层执行后检查executeUpdate()返回值:
int rows = ps.executeUpdate(); if (rows == 0) { throw new OptimisticLockException("数据已被他人修改,请刷新后重试"); }JSP表单中,将当前version作为隐藏域提交,确保更新时校验。
3.3 分页功能:从“limit m,n”到“性能与体验的平衡术”
分页是CRUD的标配,但新手常写SELECT * FROM user LIMIT 0,10,然后LIMIT 10,10,LIMIT 20,10……这在数据量>10万时,LIMIT 10000,10会全表扫描前10001行,慢得无法忍受。本项目采用游标分页(Cursor-based Pagination)与传统偏移分页(Offset-based)双模式:
方案一:传统偏移分页(适合中小数据量)UserListServlet.java接收page=1&size=10参数:
int page = Math.max(1, Integer.parseInt(request.getParameter("page"))); int size = Math.min(100, Integer.parseInt(request.getParameter("size"))); // 防恶意大size int offset = (page - 1) * size; List<User> userList = userDao.findUsersWithPagination(offset, size); long total = userDao.countAll(); // 单独count,不走分页SQL int totalPages = (int) Math.ceil((double) total / size); // 封装分页对象 PageResult<User> result = new PageResult<>(); result.setList(userList); result.setPage(page); result.setPageSize(size); result.setTotal(total); result.setTotalPages(totalPages); request.setAttribute("pageResult", result);DAO层SQL:
-- 查数据 SELECT id, username, status, create_time FROM user ORDER BY id DESC LIMIT ?, ? -- 查总数(注意:不要用COUNT(*) OVER(),MySQL 5.7不支持) SELECT COUNT(*) FROM user方案二:游标分页(推荐,高性能)
当数据量大时,用WHERE id < ? ORDER BY id DESC LIMIT ?替代OFFSET。假设第一页查出最后一条记录id=1000,第二页就查WHERE id < 1000。优势:无需计算偏移量,索引直达。缺点:不能跳页(如直接跳到第100页)。本项目在list.jsp中提供两种切换:
<!-- 分页导航 --> <div class="pagination"> <c:if test="${pageResult.page > 1}"> <a href="?page=${pageResult.page-1}&size=${pageResult.pageSize}">上一页</a> </c:if> <c:forEach begin="1" end="${pageResult.totalPages}" var="p"> <c:if test="${p == pageResult.page}"> <span class="current">${p}</span> </c:if> <c:if test="${p != pageResult.page}"> <a href="?page=${p}&size=${pageResult.pageSize}">${p}</a> </c:if> </c:forEach> <c:if test="${pageResult.page < pageResult.totalPages}"> <a href="?page=${pageResult.page+1}&size=${pageResult.pageSize}">下一页</a> </c:if> </div>实操心得:分页参数必须严格校验。曾有学员把
size=9999999传进来,导致LIMIT 0,9999999查出几百万条数据,Tomcat直接OOM。所以Math.min(100, size)是保命线。另外,totalPages计算用Math.ceil((double) total / size),而非(total + size - 1) / size,避免整数除法误差。
3.4 搜索功能:动态SQL拼接与SQL注入的“零容忍”防御
搜索是用户最常用的功能,但也是SQL注入的重灾区。新手常写:
// 危险!拼接SQL String sql = "SELECT * FROM user WHERE 1=1"; if (!StringUtils.isBlank(username)) { sql += " AND username LIKE '%" + username + "%'"; }这等于把匕首递给黑客。本项目采用预编译参数化查询 + 动态条件构建:
DAO层:用Map传递动态条件
public List<User> findUsersByCondition(Map<String, Object> params) { StringBuilder sql = new StringBuilder("SELECT id, username, status, create_time FROM user WHERE 1=1"); List<Object> values = new ArrayList<>(); // 存放?对应的参数 if (params.containsKey("username") && StringUtils.isNotBlank((String) params.get("username"))) { sql.append(" AND username LIKE ?"); values.add("%" + params.get("username") + "%"); } if (params.containsKey("status") && params.get("status") instanceof Integer) { sql.append(" AND status = ?"); values.add(params.get("status")); } if (params.containsKey("startTime") && params.get("startTime") instanceof Date) { sql.append(" AND create_time >= ?"); values.add(params.get("startTime")); } sql.append(" ORDER BY id DESC"); // 执行查询(需自行实现参数绑定,此处略) return executeQuery(sql.toString(), values.toArray()); }Service层:封装搜索入口
public PageResult<User> searchUsers(String username, Integer status, String startTimeStr, int page, int size) { Map<String, Object> params = new HashMap<>(); if (StringUtils.isNotBlank(username)) { params.put("username", username.trim()); // 去首尾空格 } if (status != null) { params.put("status", status); } if (StringUtils.isNotBlank(startTimeStr)) { try { Date startTime = new SimpleDateFormat("yyyy-MM-dd").parse(startTimeStr); params.put("startTime", startTime); } catch (ParseException e) { // 解析失败,忽略该条件 } } List<User> list = userDao.findUsersByCondition(params); long total = userDao.countByCondition(params); // 同样动态count return new PageResult<>(list, page, size, total); }前端:搜索表单与重置逻辑
<!-- search.jsp --> <form action="${pageContext.request.contextPath}/user/search" method="get"> 用户名:<input type="text" name="username" value="${param.username}" placeholder="支持模糊搜索"> 状态:<select name="status"> <option value="">全部</option> <option value="1" ${param.status == '1' ? 'selected' : ''}>启用</option> <option value="0" ${param.status == '0' ? 'selected' : ''}>禁用</option> </select> 创建时间:<input type="date" name="startTime" value="${param.startTime}"> <button type="submit">搜索</button> <a href="${pageContext.request.contextPath}/user/list">重置</a> </form>关键技巧:
<a href=".../list">重置</a>比<button type="reset">更可靠,因为reset只清空表单,不改变URL参数,用户刷新页面还会带着旧参数搜索。而/list是全新路径,彻底清空。
4. 常见问题与排查技巧实录:那些只在深夜调试时才会浮现的坑
4.1 中文乱码:从URL到数据库的全链路攻防
乱码是Java Web的“传统艺能”,但根源往往不在编码设置,而在环节遗漏。以下是真实排查记录:
| 现象 | 可能原因 | 排查步骤 | 解决方案 |
|---|---|---|---|
浏览器地址栏显示%E7%94%A8%E6%88%B7%E5%90%8D,但页面正常 | URL编码正常,无需处理 | 在Chrome开发者工具Network标签页,查看Request URL原始值 | 无须处理,这是标准URL编码 |
表单提交后,request.getParameter("username")得到???? | Tomcat默认ISO-8859-1解码 | 在Servlet开头打印new String(request.getParameter("username").getBytes("ISO-8859-1"), "UTF-8") | 在web.xml中配置<filter>强制解码,或在doPost开头统一request.setCharacterEncoding("UTF-8") |
数据库存入??,但JSP显示正常 | MySQL连接URL未指定编码 | 检查JDBC URL:jdbc:mysql://localhost:3306/mydb?useUnicode=true&characterEncoding=UTF-8 | 补全URL参数,重启Tomcat |
| JSP页面中文显示为方块 | JSP文件本身编码非UTF-8 | 用Notepad++打开JSP,查看右下角编码,若为ANSI则转为UTF-8无BOM | 文件另存为UTF-8格式,并在JSP顶部加<%@ page contentType="text/html;charset=UTF-8" %> |
我踩过的坑:某次部署到Linux服务器,Tomcat启动脚本里
JAVA_OPTS="-Dfile.encoding=UTF-8"被注释掉了,导致new String(bytes)默认用平台编码(Linux是UTF-8,Windows是GBK),结果Windows开发正常,Linux上线全乱码。解决方案:永远在代码里显式指定编码,不依赖系统默认值。
4.2 会话失效:30分钟超时背后的“隐形杀手”
session.setMaxInactiveInterval(1800)设了30分钟,但用户明明在操作,页面却突然跳回登录页。常见原因:
- 浏览器禁用Cookie:Session ID通过Cookie传递,禁用后每次请求都是新Session。解决方案:在
web.xml中配置<session-config><tracking-mode>URL</tracking-mode></session-config>,Tomcat会自动在URL后追加;jsessionid=xxx。 - 跨域请求丢失Cookie:前端用Ajax调用
/api/user,但withCredentials: true未设置,或后端未返回Access-Control-Allow-Credentials: true。解决方案:前后端联调时,用浏览器Network面板检查Request Headers是否有Cookie字段。 - Nginx反向代理未透传Cookie:生产环境常用Nginx,若配置中漏了
proxy_cookie_path / "/";,会导致Cookie路径不匹配。解决方案:在Nginx配置的location块中加入:proxy_cookie_path / "/; HttpOnly; Secure"; proxy_set_header Cookie $http_cookie;
4.3 分页错乱:第2页数据显示第1页内容的诡异现象
这是最让人抓狂的问题。典型场景:点击“第2页”,URL变成?page=2&size=10,但页面仍显示第1页的10条数据。排查顺序:
- 检查Servlet中
page参数获取:Integer.parseInt(request.getParameter("page"))若参数为空,会抛NumberFormatException,导致后续逻辑跳过,page保持默认值1。解决方案:用NumberUtils.toInt(request.getParameter("page"), 1)(Apache Commons)。 - 检查DAO层
offset计算:(page-1)*size若page为字符串"2 "(带空格),Integer.parseInt会截断为2,但"2 "转int是2,计算正确;但若page="abc",则toInt返回0,offset=0,永远查第1页。所以必须校验page>0。 - 检查SQL执行:在DAO层打印最终SQL,确认
LIMIT 10,10是否真的执行。曾有学员在PreparedStatement中写错ps.setInt(1, offset); ps.setInt(2, size);,但SQL是LIMIT ?, ?,参数顺序正确,却把offset和size值弄反了,导致LIMIT 10,10执行成LIMIT 10,10(没错),但LIMIT 100,10执行成LIMIT 10,100,查出100条数据。
4.4 搜索失效:输入关键词无结果的“幽灵bug”
用户输入“张三”,数据库有记录,但搜索返回空。可能原因:
- LIKE匹配方式错误:写了
username LIKE ?,但传参是"张三",应为"%张三%"。解决方案:在Service层统一加%,DAO层只负责拼接。 - 大小写敏感:MySQL默认
utf8mb4_general_ci排序规则是大小写不敏感的,但若建表时用了utf8mb4_bin,则"zhangsan"≠"ZhangSan"。解决方案:在SQL中用LOWER(username) LIKE LOWER(?),或建表时指定COLLATE utf8mb4_unicode_ci。 - 全角空格干扰:用户复制粘贴的关键词含全角空格 ,而数据库存的是半角空格 。解决方案:在Service层
keyword = keyword.replace(" ", " ").trim()。
最后分享一个小技巧:在
web.xml中配置<error-page>,将500错误导向自定义页面,并在页面中打印exception.stackTrace,能极大提升调试效率。但切记上线前关闭,避免泄露敏感信息。
5. 项目扩展与进阶路径:从“能跑”到“能用”的跃迁
这个小项目不是终点,而是起点。根据你的目标,可以沿着不同方向延伸:
如果目标是求职面试:
- 给DAO层加上Druid连接池,配置监控页面(
/druid/index.html),展示SQL执行时间、慢SQL列表。 - 将JSP替换为Thymeleaf,实践
th:each、th:if等模板语法,体会前后端分离思想。 - 用JUnit 4写单元测试,覆盖
UserService的addUser、deleteUser方法,用@Mock模拟DAO,验证业务逻辑。
如果目标是性能优化:
- 为
username字段添加B+树索引:ALTER TABLE user ADD INDEX idx_username (username); - 将频繁查询的
status=1用户缓存到Redis,设置TTL=300秒,DAO层先查缓存,未命中再查库。 - 用Logback配置
<logger name="com.xxx.dao" level="DEBUG"/>,开启MyBatis SQL日志,分析慢查询。
如果目标是工程化:
- 用Maven管理依赖,
pom.xml中定义<properties>统一spring.version、junit.version。 - 将数据库配置抽离到
src/main/resources/jdbc.properties,用ResourceBundle.getBundle()加载。 - 编写Shell脚本
deploy.sh,一键编译、打包、拷贝