1. 这不是“又一个CRUD”,而是一次Web开发能力的完整闭环验证
你看到这个标题——“day41 JavaWeb阶段——综合案例:用户信息列表展示(Servlet+JSP+MySQL+JDBCTemplate+Druid+BeanUtils+Tomcat)”,第一反应可能是:哦,又是学生作业、又是老掉牙的增删改查。但我想先说一句:别急着划走。这个看似平庸的标题,恰恰是JavaWeb工程师从“会写代码”迈向“能交付系统”的分水岭项目。它不是功能堆砌,而是一套被工业界反复验证过的轻量级MVC落地范式。我带过十几期JavaWeb实训,几乎每届都有学员卡在“明明每个技术点都学过了,合在一起就报错、就找不到数据、就页面空白”。问题从来不在某个框架本身,而在于对各层职责边界、数据流转路径、异常穿透机制、资源生命周期管理这四根主梁的理解是否扎实。
这个案例里,Servlet不是单纯接收参数的“门卫”,它是协调者;JSP不是静态HTML容器,而是视图渲染引擎;MySQL不是数据仓库,而是需要被连接池保护的临界资源;JDBCTemplate不是简化SQL的工具,而是屏蔽JDBC模板代码、统一异常体系的抽象层;Druid不是随便配个url就能用的连接池,它背后有连接泄漏检测、SQL防火墙、监控埋点三重能力;BeanUtils更不是“把Map转成对象”的魔法,它解决的是HTTP请求参数与Java对象属性之间的类型安全映射问题。而Tomcat,它不只是启动按钮,它是整个应用的运行时沙箱——类加载器隔离、上下文生命周期、热部署机制,全在这里发生。
所以,这篇文章不讲“怎么写第一个HelloWorld”,也不罗列API文档。我会带你像一个真实项目负责人那样,从零开始推演这个列表页的每一行代码为什么这么写、不那么写会出什么问题、线上环境和本地开发环境的关键差异在哪、哪些配置项90%的教程都漏掉了但实际一上线就崩。如果你正在准备实习面试、刚接手公司遗留系统、或者想把培训班学的知识真正串成一条线,这篇就是为你写的。它不追求炫技,只聚焦“让列表稳稳地、可维护地、可排查地跑起来”。
2. 整体架构设计与技术选型逻辑拆解
2.1 为什么是Servlet+JSP,而不是Spring MVC或Spring Boot?
这是新手最容易困惑的第一问。答案很实在:教学场景下的“最小可行认知模型”。Spring MVC封装了太多自动化的黑盒——DispatcherServlet怎么注册的?ViewResolver怎么找JSP的?HandlerMapping怎么匹配URL的?这些自动化在生产环境是恩赐,在学习初期却是迷雾。而原生Servlet+JSP强制你直面HTTP协议本质:HttpServletRequest里到底有哪些域(request、session、application)?HttpServletResponse的sendRedirect()和forward()底层调用链有何不同?JSP编译后生成的.java文件长什么样?这些问题,用Spring Boot一行@RestController根本看不到答案。
我试过让学员先学Spring Boot再回溯Servlet,结果80%的人连web.xml里<servlet-mapping>的作用都说不清。就像学开车,先上自动驾驶,再教你怎么踩离合、换挡、看后视镜,顺序反了,肌肉记忆就废了。所以这个案例用Servlet,不是守旧,而是刻意暴露复杂度,把“魔法”变成“手艺”。
2.2 JDBCTemplate为何不可替代?手写JDBC的坑有多深?
很多教程直接跳过JDBC,说“太原始,现在都用MyBatis”。但JDBCTemplate的价值,恰恰在于它精准切中了手写JDBC的三大致命痛点:
- 资源泄漏黑洞:
Connection、PreparedStatement、ResultSet必须按顺序close(),且要放在finally块里。漏关一个,连接池就慢慢耗尽。JDBCTemplate内部用try-with-resources+Callback模式,彻底封死这个漏洞。 - SQL注入温床:字符串拼接SQL是初学者最常犯的错。“
WHERE name = '"+name+"'”这种写法,一个单引号就能让整个库被拖走。JDBCTemplate强制使用?占位符,参数自动转义,从API层面杜绝注入。 - 异常处理地狱:
SQLException是checked exception,必须try-catch或throws。但业务代码里到处catch(SQLException e)毫无意义——你根本不知道该重试还是该提示用户。JDBCTemplate把它统一转为DataAccessException(unchecked),让异常处理回归业务逻辑本身。
提示:JDBCTemplate不是ORM,它不帮你自动生成SQL,也不做对象关系映射。它只做一件事:安全、简洁、可靠地执行SQL,并把结果集映射成Java对象。这正是本案例需要的——我们只要查出用户列表,不需要复杂的多表关联或延迟加载。
2.3 Druid连接池:为什么不是C3P0或HikariCP?
Druid在JavaWeb教学案例中被高频选用,核心原因就两个字:监控。C3P0太老,文档少,出问题基本靠猜;HikariCP性能虽好,但监控接口简陋,对新手不友好。而Druid内置了DruidStatViewServlet,只要加几行配置,就能在浏览器打开http://localhost:8080/druid/,实时看到:
- 当前活跃连接数、等待连接数
- SQL执行时间TOP10(一眼揪出慢查询)
- 每个SQL的执行次数、失败次数
- 数据源健康状态(连接是否泄漏?)
我带的一个小组曾遇到列表页加载极慢的问题,打开Druid监控发现:同一页面刷新一次,SELECT * FROM user被执行了17次。顺藤摸瓜,发现是JSP里误写了<c:forEach items="${userList}" var="user">,但userList在Servlet里没塞进request.setAttribute(),导致EL表达式求值失败,JSTL底层反复重试。没有Druid监控,这种问题得靠断点调试半小时以上。这就是Druid在教学场景的不可替代性——它把看不见的数据库交互,变成了可观察、可度量的仪表盘。
2.4 BeanUtils:为什么不用JSON转换库或手动set?
BeanUtils.populate(user, request.getParameterMap())这行代码,表面看是偷懒,实则是对HTTP协议与JavaBean规范深度契合的利用。HTTP请求参数本质是一个Map<String, String[]>(注意是String数组,因为同名参数可能多个值,如复选框)。而JavaBean要求属性名与setter方法名严格对应(setUsername(String)对应username字段)。BeanUtils正是基于这套约定,自动完成:
- 参数名到属性名的驼峰转换(
user_name→userName) - 字符串到基本类型的转换(
"123"→int) - 数组参数的批量赋值(
hobby=reading&hobby=swimming→List<String>)
如果用ObjectMapper.readValue(),得先把request.getParameterMap()转成JSON字符串,再解析,中间多出两次序列化开销,且对日期、枚举等类型处理更复杂。手动user.setUsername(request.getParameter("username"))则重复代码爆炸,一个10字段的表单就得写10行。BeanUtils用约定代替配置,用反射代替硬编码,是Web层参数绑定的黄金标准。
3. 核心细节解析与实操要点
3.1 项目结构必须遵循的“铁律”:WEB-INF的绝对权威
很多学员的项目跑不起来,第一步就栽在目录结构上。Tomcat对Web应用的目录有强约束,任何违反web.xml规范的结构,都会导致404或500错误,且错误日志极其晦涩。正确结构如下:
mywebapp/ ├── src/ # Java源码(Servlet、DAO、实体类) │ └── main/ │ ├── java/ │ │ └── com/example/web/ # Servlet类(UserListServlet.java) │ │ └── com/example/dao/ # DAO类(UserDao.java) │ │ └── com/example/entity/ # 实体类(User.java) │ └── resources/ # 配置文件(druid.properties) ├── webapp/ # Web资源根目录 │ ├── WEB-INF/ # 核心!所有敏感资源必须放这里 │ │ ├── web.xml # 部署描述符(Servlet映射、监听器配置) │ │ ├── lib/ # 第三方jar包(druid-1.2.16.jar, spring-jdbc-5.3.32.jar等) │ │ └── classes/ # 编译后的.class文件(由IDE自动输出) │ ├── index.jsp # 入口JSP(可放外面,但不能放WEB-INF下) │ └── list.jsp # 列表页JSP(同上)注意:
WEB-INF是Tomcat的“禁区”,浏览器无法直接访问其下任何文件。所以list.jsp必须放在webapp/根目录或子目录(如webapp/pages/list.jsp),绝不能放WEB-INF/jsp/list.jsp。否则你在浏览器输入http://localhost:8080/mywebapp/list.jsp,得到的永远是404。这是新手最高频的“以为写对了,其实路径错了”的坑。
3.2 Druid连接池配置:5个关键参数的取舍逻辑
druid.properties文件里的参数,不是随便填数字,每个都关乎连接池的生死。以下是生产环境和教学环境都必须关注的5个核心参数及其设置依据:
| 参数名 | 推荐值(教学) | 推荐值(生产) | 为什么这么设? |
|---|---|---|---|
initialSize | 2 | 5 | 启动时预创建连接数。教学环境并发低,2个够用;生产环境需快速响应,5个避免冷启动延迟。 |
maxActive | 10 | 50 | 最大连接数。设太高,MySQL服务端连接数超限(默认151);设太低,高并发时大量请求排队等待。教学环境10足够模拟压力。 |
minIdle | 2 | 10 | 最小空闲连接数。保证池中总有活连接,避免每次请求都新建连接的开销。教学环境2个即可。 |
timeBetweenEvictionRunsMillis | 60000 | 30000 | 空闲连接检测间隔(毫秒)。太短增加CPU负担,太长导致失效连接滞留。60秒是平衡点。 |
validationQuery | SELECT 1 | SELECT 1 | 检测连接是否有效的SQL。必须是轻量级、无副作用的语句。SELECT 1在所有MySQL版本都支持,且执行飞快。 |
实操心得:
testWhileIdle(空闲时检测)和testOnBorrow(借出时检测)二选一即可。教学环境推荐testWhileIdle=true,因为它不影响业务线程;testOnBorrow=true会在每次getConnection()时执行validationQuery,轻微增加延迟。但生产环境建议两者都开,双重保险。
3.3 JDBCTemplate的初始化:为什么必须用Spring的JdbcTemplate,而非自己new?
初学者常犯的错:在DAO类里直接new JdbcTemplate()。这会导致严重问题——JdbcTemplate本身不持有Connection,它依赖DataSource获取连接。而DataSource是重量级资源,必须全局单例。如果你每个DAO都new JdbcTemplate(new DruidDataSource()),等于每个DAO都创建了一个独立的Druid连接池,内存爆满,MySQL连接数瞬间打满。
正确做法是:将DataSource作为Spring容器的Bean管理,JdbcTemplate依赖注入。即使不用Spring容器,也必须手动确保DataSource单例。标准代码如下:
// 在工具类中(如JDBCUtils.java) private static DataSource dataSource; static { try { Properties props = new Properties(); props.load(JDBCUtils.class.getClassLoader().getResourceAsStream("druid.properties")); dataSource = DruidDataSourceFactory.createDataSource(props); } catch (Exception e) { throw new RuntimeException("Druid DataSource init failed", e); } } public static DataSource getDataSource() { return dataSource; // 全局唯一实例 } // 在DAO类中 private JdbcTemplate template = new JdbcTemplate(JDBCUtils.getDataSource());关键点:
JDBCUtils是饿汉式单例,dataSource在类加载时就初始化完毕。JdbcTemplate构造时传入这个单例dataSource,后续所有DAO操作共享同一个连接池。这是资源复用的基石,一步错,全盘崩。
3.4 Servlet的生命周期与线程安全:为什么成员变量是毒药?
UserListServlet里,你绝不能这样写:
public class UserListServlet extends HttpServlet { private UserDao userDao = new UserDao(); // ❌ 错误!成员变量! protected void doGet(HttpServletRequest req, HttpServletResponse resp) { List<User> users = userDao.findAll(); // 多线程并发调用,userDao被共享! req.setAttribute("userList", users); req.getRequestDispatcher("/list.jsp").forward(req, resp); } }问题在于:Servlet实例是单例的,Tomcat用一个Servlet对象处理所有请求。userDao作为成员变量,会被所有线程共享。如果UserDao内部有非线程安全的状态(比如缓存Map、计数器),就会出现数据错乱。
正确姿势是:所有依赖对象都在方法内创建,或通过局部变量传递。
public class UserListServlet extends HttpServlet { protected void doGet(HttpServletRequest req, HttpServletResponse resp) { UserDao userDao = new UserDao(); // ✅ 正确!每次请求新建 List<User> users = userDao.findAll(); req.setAttribute("userList", users); req.getRequestDispatcher("/list.jsp").forward(req, resp); } }注意:
UserDao本身可以是无状态的(只包含方法,不保存数据),所以每次new开销极小。而JdbcTemplate和DataSource是重量级的,必须复用。这个“轻对象方法内建,重对象全局复用”的原则,是JavaWeb线程安全的黄金法则。
4. 实操过程与核心环节实现
4.1 数据库准备:MySQL建表脚本与字符集陷阱
建表不是复制粘贴就完事。MySQL的字符集配置,是中文显示乱码的终极元凶。必须严格执行以下步骤:
创建数据库时指定字符集:
CREATE DATABASE mywebapp CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;为什么是
utf8mb4?因为MySQL的utf8其实是阉割版,最多存3字节字符(不支持emoji和部分生僻汉字)。utf8mb4才是真正的UTF-8,兼容所有Unicode字符。建表时显式声明字符集:
USE mywebapp; CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL, email VARCHAR(100), age INT ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;插入测试数据:
INSERT INTO user (username, email, age) VALUES ('张三', 'zhangsan@example.com', 25), ('李四', 'lisi@example.com', 30), ('王五', 'wangwu@example.com', 28);
常见问题:如果跳过第1步,直接
CREATE DATABASE mywebapp;,MySQL会用默认字符集(通常是latin1),后面无论怎么改表字符集,已存在的数据仍是乱码。所以建库是第一步,且必须带CHARACTER SET utf8mb4。
4.2 Servlet核心代码:从请求到响应的完整链路
UserListServlet是整个流程的中枢,它的每一行代码都承担明确职责。以下是精简但完整的实现(含关键注释):
@WebServlet("/user/list") // 使用注解配置URL映射,替代web.xml public class UserListServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // Step 1: 创建DAO实例(无状态,每次请求新建) UserDao userDao = new UserDao(); // Step 2: 调用DAO方法查询所有用户 // 这里会触发:JDBCTemplate -> DataSource -> MySQL连接池 -> 执行SQL -> 映射为User对象 List<User> userList = userDao.findAll(); // Step 3: 将查询结果存入request域,供JSP读取 // 注意:域名为"userList",JSP中用${userList}获取 req.setAttribute("userList", userList); // Step 4: 请求转发到list.jsp // forward是服务器内部跳转,URL不变,request域数据可传递 // redirect是客户端重定向,URL改变,request域数据丢失 req.getRequestDispatcher("/list.jsp").forward(req, resp); } }关键细节解释:
@WebServlet("/user/list"):这是Servlet 3.0+的注解配置方式,比web.xml更简洁。访问http://localhost:8080/mywebapp/user/list即触发此Servlet。req.setAttribute("userList", userList):request域是单次请求生命周期,数据只在本次请求的转发链中有效。这是MVC中“Model”传递给“View”的标准方式。forward()vsredirect():初学者常混淆。forward()是服务器内部行为,浏览器地址栏URL不变;redirect()是发给浏览器一个302响应,浏览器再发起新请求,URL会变,且request.setAttribute()的数据丢失。列表页展示必须用forward()。
4.3 JSP页面渲染:EL表达式与JSTL标签的协同作战
list.jsp是最终呈现给用户的界面。它不是静态HTML,而是动态模板。核心是两套技术协同:
- EL表达式(Expression Language):
${}语法,用于读取域对象、调用简单方法。 - JSTL(JSP Standard Tag Library):
<c:forEach>等标签,用于流程控制、格式化。
完整list.jsp代码如下:
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <!-- 引入JSTL核心标签库 --> <html> <head> <title>用户信息列表</title> <meta charset="UTF-8"> </head> <body> <h1>用户信息列表</h1> <!-- 检查userList是否为空,避免空指针 --> <c:if test="${empty userList}"> <p>暂无用户数据</p> </c:if> <!-- 遍历userList,生成表格 --> <table border="1"> <thead> <tr> <th>ID</th> <th>用户名</th> <th>邮箱</th> <th>年龄</th> </tr> </thead> <tbody> <c:forEach items="${userList}" var="user"> <tr> <td>${user.id}</td> <td>${user.username}</td> <td>${user.email}</td> <td>${user.age}</td> </tr> </c:forEach> </tbody> </table> </body> </html>实操要点:
<%@ page contentType="text/html;charset=UTF-8" %>:必须声明页面编码为UTF-8,否则中文标题、中文数据会乱码。<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>:引入JSTL,否则<c:forEach>会报错。需要jstl-1.2.jar在WEB-INF/lib/下。${empty userList}:EL的empty运算符,安全判断集合是否为null或空,比${userList == null || userList.size() == 0}简洁且防NPE。${user.id}:EL自动调用User对象的getId()方法,无需写user.getId()。这是JSP的便利性所在。
4.4 Tomcat部署与启动:常见启动失败的3个定位点
把项目丢进Tomcat,点击启动,结果控制台刷屏报错?别慌,90%的问题集中在这三个地方:
端口冲突:Tomcat默认用8080端口。如果你的电脑已运行了另一个Tomcat、MySQL(有时也占8080)、或其他程序,启动会失败。
定位:看Tomcat日志首行SEVERE: Failed to initialize connector [Connector[HTTP/1.1-8080]]。
解决:修改conf/server.xml,找到<Connector port="8080",改成8081或其他空闲端口。JAR包缺失或版本冲突:
WEB-INF/lib/下少了druid.jar、spring-jdbc.jar,或同时存在spring-jdbc-4.x.jar和spring-jdbc-5.x.jar。
定位:日志中出现ClassNotFoundException或NoSuchMethodError。
解决:清空lib/目录,只保留项目必需的JAR(Druid、Spring JDBC、commons-beanutils、commons-logging、mysql-connector-java),并确保版本兼容(如Spring JDBC 5.x需搭配Druid 1.2+)。web.xml配置错误:Servlet类名写错、URL-pattern斜杠多写或少写。
定位:访问http://localhost:8080/mywebapp/user/list返回404,且Tomcat日志无相关Servlet初始化日志。
解决:检查web.xml中<servlet-class>是否为完整类名(如com.example.web.UserListServlet),<url-pattern>是否为/user/list(注意开头的/)。
实操心得:启动前,先在IDEA/Eclipse里右键项目 →
Run As→Maven build...→Goals: clean compile,确保target/classes/下有编译好的.class文件。很多“启动成功但404”的问题,根源是代码根本没编译进去。
5. 常见问题与排查技巧实录
5.1 经典问题速查表:从现象到根因的快速映射
| 现象 | 可能根因 | 快速验证方法 | 解决方案 |
|---|---|---|---|
| 页面空白,控制台无报错 | JSP中EL表达式未启用 | 查看web.xml中<web-app>的version是否≥2.4(2.3及以下默认禁用EL) | 将web.xml头改为<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" version="3.1"> |
列表页显示[com.example.entity.User@1a2b3c4d] | userList是List<User>,但JSP中误写${userList}而非<c:forEach>遍历 | 在JSP中加<%= userList %>,看输出是否为地址哈希值 | 确保用<c:forEach>遍历,${userList}只用于调试打印 |
中文数据显示为?? | MySQL连接URL未指定字符集 | 查看Druid配置url=jdbc:mysql://localhost:3306/mywebapp?useUnicode=true&characterEncoding=utf8mb4 | 在JDBC URL末尾添加?useUnicode=true&characterEncoding=utf8mb4 |
访问/user/list报404 | Servlet URL映射配置错误 | 检查@WebServlet("/user/list")或web.xml中<url-pattern>是否一致 | 确保浏览器URL路径与注解/配置完全匹配(区分大小写、斜杠) |
| Druid监控页打不开(404) | DruidStatViewServlet未在web.xml中注册 | 查看web.xml是否有<servlet>和<servlet-mapping>配置 | 按Druid官方文档配置Servlet映射,URL-pattern设为/druid/* |
5.2 “Connection closed”异常的深度排查:连接池泄漏的3种典型场景
java.sql.SQLException: Connection closed是Druid最常抛出的异常,表面是连接关闭了,实则是连接被提前归还或未正确使用。以下是三种高发场景:
场景1:在DAO方法中手动关闭了Connection
错误代码:
public List<User> findAll() { Connection conn = null; PreparedStatement stmt = null; ResultSet rs = null; try { conn = dataSource.getConnection(); stmt = conn.prepareStatement("SELECT * FROM user"); rs = stmt.executeQuery(); // ... 结果集处理 } finally { if (rs != null) rs.close(); // ❌ 错误!JDBCTemplate已管理 if (stmt != null) stmt.close(); if (conn != null) conn.close(); // ❌ 错误!连接应归还给池 } }根因:conn.close()在Druid中不是真关闭,而是将连接归还给池。但JDBCTemplate内部已做了同样的事,你再调用一次,等于把一个已归还的连接再次“归还”,Druid认为这是非法操作,标记连接为closed。
解决:彻底删除DAO中所有close()调用。JDBCTemplate的query()方法内部已用try-with-resources确保资源释放。
场景2:JSP中在<c:forEach>外调用了userList.size()
错误代码:
<p>共${userList.size()}条记录</p> <!-- ❌ 触发size()方法,可能引发LazyLoad或NPE --> <c:forEach items="${userList}" var="user">...</c:forEach>根因:userList是List<User>,但某些情况下(如Hibernate代理),size()方法可能触发数据库查询,而此时连接已归还,导致Connection closed。
解决:用EL的fn:length()函数替代:<p>共${fn:length(userList)}条记录</p>,并在JSP顶部声明<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %>。
场景3:Servlet中forward()后继续执行代码
错误代码:
req.getRequestDispatcher("/list.jsp").forward(req, resp); System.out.println("This line will execute!"); // ❌ 危险!forward后不应有代码根因:forward()只是把请求交给JSP处理,当前Servlet线程并未终止。如果后续代码操作了request或response,而JSP已开始写响应,就会出现IllegalStateException: getWriter() has already been called,进而引发连接池混乱。
解决:forward()或redirect()后,必须立即return,终止当前方法执行。
5.3 性能优化实战:列表页加载慢的3个加速点
当列表数据超过1000条,页面明显卡顿,不要急着换框架,先检查这三个低成本优化点:
SQL层面:避免
SELECT *,只查必要字段
将DAO中的"SELECT * FROM user"改为"SELECT id, username, email, age FROM user"。减少网络传输量和内存占用,尤其当user表有TEXT大字段时,效果立竿见影。JSP层面:关闭EL表达式自动类型转换
在web.xml中添加:<context-param> <param-name>org.apache.jasper.compiler.Parser.STRICT_QUOTE_ESCAPING</param-name> <param-value>false</param-value> </context-param>并在JSP顶部加
<%@ page isELIgnored="false" %>。这能提升EL解析速度约15%,对大量循环的列表页很有效。连接池层面:开启PreparedStatement缓存
在druid.properties中添加:poolPreparedStatements=true maxPoolPreparedStatementPerConnectionSize=20Druid会缓存
PreparedStatement对象,避免重复编译SQL,对频繁查询的列表页提升显著。
我个人在实际操作中发现,这三个改动加起来,1000条数据的列表页加载时间从1.2秒降到0.4秒。它们都不需要改业务逻辑,成本极低,但收益巨大。记住:性能优化的第一步,永远是审视现有代码,而不是升级框架。
6. 项目扩展与工程化思考:从Demo到产品的距离
这个列表展示案例,看似简单,但它像一块乐高积木,可以无缝嵌入更大的系统。我在某高校实验室指导的一个毕业设计项目,就是以它为起点,逐步扩展成一个完整的学籍管理系统。以下是几个自然、实用的扩展方向,以及它们带来的工程化挑战:
扩展1:分页功能——从“全量加载”到“按需获取”
- 怎么做:在Servlet中接收
pageNum和pageSize参数,DAO层用LIMIT ? OFFSET ?实现物理分页。 - 工程挑战:分页SQL的
OFFSET在大数据量时性能陡降(OFFSET 100000会扫描前10万行)。解决方案是改用WHERE id > ? LIMIT ?的游标分页,但这要求前端记录上一页最后ID,增加了前后端协作复杂度。
扩展2:搜索过滤——从“静态列表”到“动态筛选”
- 怎么做:JSP增加搜索框,Servlet接收关键词,DAO构建动态SQL(用
StringBuilder拼接WHERE username LIKE ? OR email LIKE ?)。 - 工程挑战:动态SQL易出SQL注入漏洞。必须用
?占位符,且对关键词做%keyword%包装,严禁字符串拼接。更安全的做法是引入MyBatis的<if>标签或JPA Criteria API。
扩展3:权限控制——从“公开访问”到“角色隔离”
- 怎么做:在
User实体中增加role字段,Servlet中req.getSession().getAttribute("userRole")获取当前角色,决定是否显示“删除”按钮。 - 工程挑战:角色判断散落在各个Servlet中,违反单一职责。应抽离为Filter过滤器,在请求到达Servlet前统一校验,未授权则重定向到登录页。这引出了拦截器(Interceptor)和AOP的概念。
最后再分享一个小技巧:这个案例的所有技术栈(Servlet/JSP/Druid/JDBCTemplate),在Spring Boot中都有完美对应物(
@Controller/Thymeleaf/spring-boot-starter-jdbc/JdbcTemplate)。当你把这个原生案例吃透,再学Spring Boot,你会清晰看到:Spring Boot不是凭空创造,而是把这里的手动配置、样板代码,全部自动化、标准化了。所以,别把原生JavaWeb当作过时技术,它是理解所有现代框架的基石。我见过太多人,一上来就学Spring Boot,结果连@Autowired为什么能注入Bean都说不清,根源就在基础没打牢。