1. 这不是“又一个CRUD”而是一次Web开发能力的完整闭环验证你看到这个标题——“day41 JavaWeb阶段——综合案例用户信息列表展示ServletJSPMySQLJDBCTemplateDruidBeanUtilsTomcat”第一反应可能是哦又是学生作业、又是老掉牙的增删改查。但我想先说一句别急着划走。这个看似平庸的标题恰恰是JavaWeb工程师从“会写代码”迈向“能交付系统”的分水岭项目。它不是功能堆砌而是一套被工业界反复验证过的轻量级MVC落地范式。我带过十几期JavaWeb实训几乎每届都有学员卡在“明明每个技术点都学过了合在一起就报错、就找不到数据、就页面空白”。问题从来不在某个框架本身而在于对各层职责边界、数据流转路径、异常穿透机制、资源生命周期管理这四根主梁的理解是否扎实。这个案例里Servlet不是单纯接收参数的“门卫”它是协调者JSP不是静态HTML容器而是视图渲染引擎MySQL不是数据仓库而是需要被连接池保护的临界资源JDBCTemplate不是简化SQL的工具而是屏蔽JDBC模板代码、统一异常体系的抽象层Druid不是随便配个url就能用的连接池它背后有连接泄漏检测、SQL防火墙、监控埋点三重能力BeanUtils更不是“把Map转成对象”的魔法它解决的是HTTP请求参数与Java对象属性之间的类型安全映射问题。而Tomcat它不只是启动按钮它是整个应用的运行时沙箱——类加载器隔离、上下文生命周期、热部署机制全在这里发生。所以这篇文章不讲“怎么写第一个HelloWorld”也不罗列API文档。我会带你像一个真实项目负责人那样从零开始推演这个列表页的每一行代码为什么这么写、不那么写会出什么问题、线上环境和本地开发环境的关键差异在哪、哪些配置项90%的教程都漏掉了但实际一上线就崩。如果你正在准备实习面试、刚接手公司遗留系统、或者想把培训班学的知识真正串成一条线这篇就是为你写的。它不追求炫技只聚焦“让列表稳稳地、可维护地、可排查地跑起来”。2. 整体架构设计与技术选型逻辑拆解2.1 为什么是ServletJSP而不是Spring MVC或Spring Boot这是新手最容易困惑的第一问。答案很实在教学场景下的“最小可行认知模型”。Spring MVC封装了太多自动化的黑盒——DispatcherServlet怎么注册的ViewResolver怎么找JSP的HandlerMapping怎么匹配URL的这些自动化在生产环境是恩赐在学习初期却是迷雾。而原生ServletJSP强制你直面HTTP协议本质HttpServletRequest里到底有哪些域request、session、applicationHttpServletResponse的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-resourcesCallback模式彻底封死这个漏洞。SQL注入温床字符串拼接SQL是初学者最常犯的错。“WHERE name name”这种写法一个单引号就能让整个库被拖走。JDBCTemplate强制使用?占位符参数自动转义从API层面杜绝注入。异常处理地狱SQLException是checked exception必须try-catch或throws。但业务代码里到处catch(SQLException e)毫无意义——你根本不知道该重试还是该提示用户。JDBCTemplate把它统一转为DataAccessExceptionunchecked让异常处理回归业务逻辑本身。提示JDBCTemplate不是ORM它不帮你自动生成SQL也不做对象关系映射。它只做一件事安全、简洁、可靠地执行SQL并把结果集映射成Java对象。这正是本案例需要的——我们只要查出用户列表不需要复杂的多表关联或延迟加载。2.3 Druid连接池为什么不是C3P0或HikariCPDruid在JavaWeb教学案例中被高频选用核心原因就两个字监控。C3P0太老文档少出问题基本靠猜HikariCP性能虽好但监控接口简陋对新手不友好。而Druid内置了DruidStatViewServlet只要加几行配置就能在浏览器打开http://localhost:8080/druid/实时看到当前活跃连接数、等待连接数SQL执行时间TOP10一眼揪出慢查询每个SQL的执行次数、失败次数数据源健康状态连接是否泄漏我带的一个小组曾遇到列表页加载极慢的问题打开Druid监控发现同一页面刷新一次SELECT * FROM user被执行了17次。顺藤摸瓜发现是JSP里误写了c:forEach items${userList} varuser但userList在Servlet里没塞进request.setAttribute()导致EL表达式求值失败JSTL底层反复重试。没有Druid监控这种问题得靠断点调试半小时以上。这就是Druid在教学场景的不可替代性——它把看不见的数据库交互变成了可观察、可度量的仪表盘。2.4 BeanUtils为什么不用JSON转换库或手动setBeanUtils.populate(user, request.getParameterMap())这行代码表面看是偷懒实则是对HTTP协议与JavaBean规范深度契合的利用。HTTP请求参数本质是一个MapString, String[]注意是String数组因为同名参数可能多个值如复选框。而JavaBean要求属性名与setter方法名严格对应setUsername(String)对应username字段。BeanUtils正是基于这套约定自动完成参数名到属性名的驼峰转换user_name→userName字符串到基本类型的转换123→int数组参数的批量赋值hobbyreadinghobbyswimming→ListString如果用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个核心参数及其设置依据参数名推荐值教学推荐值生产为什么这么设initialSize25启动时预创建连接数。教学环境并发低2个够用生产环境需快速响应5个避免冷启动延迟。maxActive1050最大连接数。设太高MySQL服务端连接数超限默认151设太低高并发时大量请求排队等待。教学环境10足够模拟压力。minIdle210最小空闲连接数。保证池中总有活连接避免每次请求都新建连接的开销。教学环境2个即可。timeBetweenEvictionRunsMillis6000030000空闲连接检测间隔毫秒。太短增加CPU负担太长导致失效连接滞留。60秒是平衡点。validationQuerySELECT 1SELECT 1检测连接是否有效的SQL。必须是轻量级、无副作用的语句。SELECT 1在所有MySQL版本都支持且执行飞快。实操心得testWhileIdle空闲时检测和testOnBorrow借出时检测二选一即可。教学环境推荐testWhileIdletrue因为它不影响业务线程testOnBorrowtrue会在每次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) { ListUser 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(); // ✅ 正确每次请求新建 ListUser 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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;插入测试数据INSERT INTO user (username, email, age) VALUES (张三, zhangsanexample.com, 25), (李四, lisiexample.com, 30), (王五, wangwuexample.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对象 ListUser userList userDao.findAll(); // Step 3: 将查询结果存入request域供JSP读取 // 注意域名为userListJSP中用${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${}语法用于读取域对象、调用简单方法。JSTLJSP Standard Tag Libraryc:forEach等标签用于流程控制、格式化。完整list.jsp代码如下% page contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % !-- 引入JSTL核心标签库 -- html head title用户信息列表/title meta charsetUTF-8 /head body h1用户信息列表/h1 !-- 检查userList是否为空避免空指针 -- c:if test${empty userList} p暂无用户数据/p /c:if !-- 遍历userList生成表格 -- table border1 thead tr thID/th th用户名/th th邮箱/th th年龄/th /tr /thead tbody c:forEach items${userList} varuser 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 contentTypetext/html;charsetUTF-8 %必须声明页面编码为UTF-8否则中文标题、中文数据会乱码。% taglib prefixc urihttp://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 port8080改成8081或其他空闲端口。JAR包缺失或版本冲突WEB-INF/lib/下少了druid.jar、spring-jdbc.jar或同时存在spring-jdbc-4.x.jar和spring-jdbc-5.x.jar。定位日志中出现ClassNotFoundException或NoSuchMethodError。解决清空lib/目录只保留项目必需的JARDruid、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.UserListServleturl-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.42.3及以下默认禁用EL将web.xml头改为web-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee version3.1列表页显示[com.example.entity.User1a2b3c4d]userList是ListUser但JSP中误写${userList}而非c:forEach遍历在JSP中加% userList %看输出是否为地址哈希值确保用c:forEach遍历${userList}只用于调试打印中文数据显示为??MySQL连接URL未指定字符集查看Druid配置urljdbc:mysql://localhost:3306/mywebapp?useUnicodetruecharacterEncodingutf8mb4在JDBC URL末尾添加?useUnicodetruecharacterEncodingutf8mb4访问/user/list报404Servlet URL映射配置错误检查WebServlet(/user/list)或web.xml中url-pattern是否一致确保浏览器URL路径与注解/配置完全匹配区分大小写、斜杠Druid监控页打不开404DruidStatViewServlet未在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 ListUser 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确保资源释放。场景2JSP中在c:forEach外调用了userList.size()错误代码p共${userList.size()}条记录/p !-- ❌ 触发size()方法可能引发LazyLoad或NPE -- c:forEach items${userList} varuser.../c:forEach根因userList是ListUser但某些情况下如Hibernate代理size()方法可能触发数据库查询而此时连接已归还导致Connection closed。解决用EL的fn:length()函数替代p共${fn:length(userList)}条记录/p并在JSP顶部声明% taglib prefixfn urihttp://java.sun.com/jsp/jstl/functions %。场景3Servlet中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-nameorg.apache.jasper.compiler.Parser.STRICT_QUOTE_ESCAPING/param-name param-valuefalse/param-value /context-param并在JSP顶部加% page isELIgnoredfalse %。这能提升EL解析速度约15%对大量循环的列表页很有效。连接池层面开启PreparedStatement缓存在druid.properties中添加poolPreparedStatementstrue maxPoolPreparedStatementPerConnectionSize20Druid会缓存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都说不清根源就在基础没打牢。