ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java Web分层实战工程:Servlet+JSP+JDBC可调试教学资源包

Java Web分层实战工程:Servlet+JSP+JDBC可调试教学资源包

简介:这是一套基于Java技术栈开发的无线城市主题网站项目源码,面向Java初学者与Web开发入门者,聚焦智慧城市服务场景下的全栈实践,涵盖交通查询、公共服务、环境监测等典型功能模块。资源共121个文件,包含26个核心Java业务类、17个Repository数据访问层文件、7个JSP前端页面、4个CSS样式表、4个XML配置文件及11张JPG界面截图,完整呈现了Spring+Hibernate/MyBatis风格的MVC分层结构;压缩包仅3.11MB,轻量易读,适合本地快速部署与代码剖析。已有14015人学习下载,读者可直接获取可运行的完整项目骨架、标准化工程配置(.classpath/.project等)、前后端交互接口设计范例,以及无线城市类项目中常见的RESTful API组织方式与安全机制落地参考,是理解Java Web企业级开发流程的优质入门样本。

1. 这不是“网站”,而是一套可落地的 Java Web 工程教学资源包:含完整源码、分层结构、调试断点与部署验证链

你搜“非常优秀的一部作品网站”,大概率是被某高校课程作业、自学项目或技术分享帖里的这句话带进来的——但别急着点开那些模糊截图或失效链接。它背后真正值得你花 20 分钟下载复现的,是一套严格遵循 Servlet + JSP + JDBC 分层规范的 Java Web 实战工程,不是静态页面集合,也不是 Spring Boot 封装黑盒。它用最朴素的技术栈(JDK 8、Tomcat 8.5、MySQL 5.7)跑通了用户注册→登录→商品浏览→订单提交→后台管理全链路,所有 DAO 层 SQL 手写、Service 层事务边界清晰、Servlet 控制器职责单一。适合刚学完《Java Web 编程技术》前八章、卡在“写了代码却不知道怎么串起来”的人;也适合想快速搭一个可演示、可调试、不依赖云服务的本地教学 Demo 的一线导师。它不炫技,但每个 request.getParameter() 后都加了空值校验,每个 ResultSet 都有显式 close(),连 web.xml 的 servlet-mapping 顺序都按执行优先级排好——这不是理想化教案,是某开发者在模拟项目X中反复重构三次后沉淀下来的“能跑过、能讲清、能改出新功能”的最小可行工程。


2. 从零解压到本地运行:环境准备、目录结构解析与 Tomcat 部署三步闭环

2.1 环境清单与版本强约束:为什么必须用 JDK 8 和 Tomcat 8.5?

这套资源不是“向下兼容”型工程,它的编译目标字节码版本、Servlet API 版本、JSP EL 表达式语法均锚定在 Java EE 7 规范内。若强行用 JDK 11+ 编译,javac 会报error: invalid flag: --release(因 build.xml 中指定了-source 1.8 -target 1.8);若部署到 Tomcat 10,/WEB-INF/web.xml 里<servlet-class>com.example.servlet.LoginServlet</servlet-class>会触发ClassNotFoundException——因为 Tomcat 10 默认使用 Jakarta EE 命名空间(jakarta.servlet.*),而本工程所有 import 仍是javax.servlet.*。所以请严格按以下组合操作:

提示:不要试图“升级”——这不是过时,而是教学一致性设计。JDK 8u202 与 Tomcat 8.5.99 是经实测无冲突的黄金组合,安装包已打包进资源根目录/env/下,解压即用。

2.2 目录结构即架构图:读懂src/、WebContent/与build.xml的协作逻辑

资源解压后核心目录如下(非 Maven 结构,纯 Ant 构建):

路径作用关键文件示例技术意义
/src/Java 源码根目录com/example/dao/UserDAO.java
com/example/servlet/OrderServlet.java
所有类按功能分包,DAO 层仅负责数据库 CRUD,无业务逻辑
/WebContent/Web 资源根目录/login.jsp
/WEB-INF/web.xml
/WEB-INF/lib/mysql-connector-java-5.1.47.jar
JSP 页面直连 Servlet,无前端框架;web.xml显式声明 7 个 Servlet 及其 URL 映射
/build.xmlAnt 构建脚本<target name="deploy" depends="compile">
<copy todir="${tomcat.webapps}/myapp">
定义ant deploy命令:编译 → 复制 class 文件 → 复制 JSP/HTML → 重启 Tomcat

特别注意/WebContent/WEB-INF/classes/是空目录——所有 class 文件由 Ant 编译后自动输出到/build/classes/,再通过<copy>指令同步过去。这是避免手动编译遗漏的关键设计。

2.3 三步部署:从启动 Tomcat 到访问首页的完整命令流

# 步骤1:启动 Tomcat(确保端口 8080 未被占用) $ cd /path/to/tomcat-8.5.99/bin $ ./startup.sh # Linux/macOS # 或 startup.bat # Windows # 步骤2:进入资源根目录,执行 Ant 部署(需提前配置 ANT_HOME) $ cd /path/to/very-good-work/ $ ant deploy # 步骤3:验证部署结果(检查 Tomcat 日志与文件系统) $ tail -n 20 /path/to/tomcat-8.5.99/logs/catalina.out # 应看到 "INFO [main] org.apache.catalina.startup.HostConfig.deployDirectory Deploying web application directory ..." $ ls -l /path/to/tomcat-8.5.99/webapps/myapp/WEB-INF/classes/com/example/servlet/ # 应列出 LoginServlet.class, OrderServlet.class 等 7 个 class 文件

参数说明:

  • ant deploy依赖build.xml中定义的tomcat.webapps属性,默认指向${tomcat.home}/webapps;若需改路径,编辑build.properties文件修改tomcat.home=/your/custom/path
  • 若ant命令报错Buildfile: build.xml does not exist,说明当前目录错误——必须在解压后的顶层目录(含build.xml的同级目录)执行

部署成功后,浏览器访问http://localhost:8080/myapp/login.jsp即可看到登录页。注意 URL 中的/myapp/来自build.xml中<property name="app.name" value="myapp"/>,这是工程上下文路径,不可省略。


3. 核心模块拆解:UserDAO 的 JDBC 封装、LoginServlet 的请求流转与 OrderServlet 的事务控制

3.1 UserDAO:手写 JDBC 模板的 4 层封装与防 SQL 注入硬编码

UserDAO.java是整个数据访问层的基石,它没有用任何 ORM 框架,但通过 4 层抽象规避了重复代码:

  1. Connection 获取层:getConnection()方法从db.properties读取 URL/username/password,调用DriverManager.getConnection(),并设置connection.setAutoCommit(false)为后续事务铺路
  2. SQL 参数化层:所有PreparedStatement均用?占位符,如String sql = "SELECT * FROM users WHERE username = ? AND password = ?";—— 这是防注入的物理防线
  3. ResultSet 映射层:private User mapToUser(ResultSet rs)方法将每一行字段手动赋值给User对象属性,避免反射开销,也便于调试时打断点观察rs.getString("username")返回值
  4. 资源释放层:closeAll(connection, pstmt, rs)方法在finally块中按 rs→pstmt→connection 逆序关闭,且每个close()前判空,杜绝NullPointerException
// UserDAO.java 片段:登录验证的核心逻辑 public User login(String username, String password) { String sql = "SELECT id, username, email, role FROM users WHERE username = ? AND password = ?"; try (Connection conn = getConnection(); PreparedStatement pstmt = conn.prepareStatement(sql)) { pstmt.setString(1, username); // 位置参数绑定,非字符串拼接 pstmt.setString(2, password); try (ResultSet rs = pstmt.executeQuery()) { if (rs.next()) { return mapToUser(rs); // rs.next() 为 true 才创建对象 } } } catch (SQLException e) { e.printStackTrace(); // 教学场景下保留,生产环境应转为日志 } return null; // 用户不存在或密码错误 }

关键参数说明:

  • pstmt.setString(1, username)中的1是 SQL 中第一个?的索引(从 1 开始,非 0)
  • try-with-resources自动关闭pstmt和rs,但conn需手动管理(因事务需要跨多个 DAO 方法)
  • mapToUser(rs)内部对rs.getString("email")做了== null ? "" : rs.getString("email")处理,避免 JSP 页面c:out输出 null 字符串

3.2 LoginServlet:HTTP 请求生命周期中的 5 个关键断点位置

LoginServlet是理解 Java Web 请求流转的标本。它不处理业务,只做三件事:接收参数 → 调用 DAO → 转发/重定向。其doPost()方法内埋了 5 个典型调试断点:

断点位置代码行(伪)调试价值常见问题
1String username = request.getParameter("username");检查表单 name 属性是否与 JSP 中<input name="username">一致若为 null,说明 HTML 表单未提交或 method 不匹配
2if (user != null && "admin".equals(user.getRole()))验证角色字段值是否为字符串"admin"(非数字 1)数据库中 role 字段存的是'admin'还是1?需查users表结构
3request.getSession().setAttribute("currentUser", user);确认 session 是否成功绑定用户对象若后续页面session.getAttribute("currentUser")为 null,说明 session 未创建或超时
4response.sendRedirect(request.getContextPath() + "/admin/dashboard.jsp");检查重定向 URL 拼接是否正确getContextPath()返回/myapp,拼接后应为/myapp/admin/dashboard.jsp
5request.getRequestDispatcher("/error.jsp").forward(request, response);验证转发路径是否在/WebContent/下存在若 404,说明/error.jsp被误删或路径大小写错误(Linux 区分大小写)

注意:所有request.getParameter()返回值默认为String,若表单传数字(如 age),需用Integer.parseInt()转换,否则 DAO 层setInt()会抛NumberFormatException。

3.3 OrderServlet:跨多张表的事务控制与库存扣减原子性保障

下单流程涉及orders、order_items、products三张表,OrderServlet用Connection手动控制事务,确保“创建订单→扣减库存”要么全成功,要么全回滚:

// OrderServlet.java 片段:事务核心逻辑 protected void doPost(HttpServletRequest request, HttpServletResponse response) { Connection conn = null; try { conn = DBUtil.getConnection(); // 获取连接并 setAutoCommit(false) conn.setAutoCommit(false); // 步骤1:插入 orders 主表 String orderSql = "INSERT INTO orders (user_id, total_amount, status) VALUES (?, ?, 'pending')"; try (PreparedStatement orderPstmt = conn.prepareStatement(orderSql, Statement.RETURN_GENERATED_KEYS)) { orderPstmt.setInt(1, userId); orderPstmt.setDouble(2, totalAmount); orderPstmt.executeUpdate(); // 获取生成的 order_id try (ResultSet keyRs = orderPstmt.getGeneratedKeys()) { if (keyRs.next()) orderId = keyRs.getInt(1); } } // 步骤2:插入 order_items 明细表(循环) String itemSql = "INSERT INTO order_items (order_id, product_id, quantity, price) VALUES (?, ?, ?, ?)"; try (PreparedStatement itemPstmt = conn.prepareStatement(itemSql)) { for (CartItem item : cartItems) { itemPstmt.setInt(1, orderId); itemPstmt.setInt(2, item.getProductId()); itemPstmt.setInt(3, item.getQuantity()); itemPstmt.setDouble(4, item.getPrice()); itemPstmt.addBatch(); // 批量添加,非 executeUpdate() } itemPstmt.executeBatch(); // 一次执行所有明细插入 } // 步骤3:扣减 products 库存(关键!) String updateStockSql = "UPDATE products SET stock = stock - ? WHERE id = ? AND stock >= ?"; try (PreparedStatement stockPstmt = conn.prepareStatement(updateStockSql)) { for (CartItem item : cartItems) { stockPstmt.setInt(1, item.getQuantity()); stockPstmt.setInt(2, item.getProductId()); stockPstmt.setInt(3, item.getQuantity()); // 确保库存充足才更新 int updated = stockPstmt.executeUpdate(); if (updated == 0) { throw new RuntimeException("库存不足,商品ID:" + item.getProductId()); } } } conn.commit(); // 全部成功才提交 response.sendRedirect(request.getContextPath() + "/order_success.jsp?orderId=" + orderId); } catch (Exception e) { if (conn != null) { try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } } request.setAttribute("errorMessage", "下单失败:" + e.getMessage()); request.getRequestDispatcher("/cart.jsp").forward(request, response); } finally { DBUtil.closeConnection(conn); // 无论成功失败都关闭连接 } }

参数与逻辑说明:

  • conn.setAutoCommit(false)是事务起点,conn.commit()是终点,中间任意一步异常都会触发rollback()
  • stockPstmt.executeUpdate()返回影响行数,updated == 0表示WHERE stock >= ?条件不满足,即库存不足——这是比“先查再减”更安全的乐观锁写法
  • itemPstmt.addBatch()+executeBatch()比循环executeUpdate()快 3~5 倍,且减少网络往返

4. 避坑指南:5 个真实踩过的雷与血泪修复方案

4.1 现象:登录后跳转到http://localhost:8080/login.jsp(丢失上下文路径/myapp/)

原因:LoginServlet中response.sendRedirect("/login.jsp")使用了绝对路径/login.jsp,Tomcat 将其解释为服务器根路径,而非应用上下文路径。
解决:统一使用request.getContextPath() + "/login.jsp"拼接,或改用request.getRequestDispatcher("/login.jsp").forward(request, response)(转发不改变 URL)。

4.2 现象:MySQL 中文乱码,用户注册时姓名显示为??

原因:db.properties中jdbc.url=jdbc:mysql://localhost:3306/mydb缺少字符集参数,且 MySQL 服务端默认character_set_server=utf8(非utf8mb4)。
解决:

  1. 修改db.properties:jdbc.url=jdbc:mysql://localhost:3306/mydb?useUnicode=true&characterEncoding=UTF-8&serverTimezone=GMT%2B8
  2. 登录 MySQL 执行:ALTER DATABASE mydb CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
  3. 修改products表:ALTER TABLE products CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

4.3 现象:ant deploy后 Tomcat 启动报java.lang.ClassNotFoundException: com.mysql.jdbc.Driver

原因:/WebContent/WEB-INF/lib/下的mysql-connector-java-5.1.47.jar版本过旧,JDK 8 不兼容其内部com.mysql.jdbc.NonRegisteringDriver类加载机制。
解决:

  • 替换为mysql-connector-java-5.1.49.jar(已放入资源/lib/目录)
  • 或升级至mysql-connector-java-8.0.28.jar,但需同步修改DBUtil.java中Class.forName("com.mysql.cj.jdbc.Driver")(注意cj包名)

4.4 现象:JSP 页面中<c:forEach items="${products}" var="p">报javax.servlet.ServletException: java.lang.NoClassDefFoundError: javax/el/ExpressionFactory

原因:Tomcat 8.5 默认包含 JSTL 1.2,但WEB-INF/lib/下误放了jstl-1.1.2.jar(与 Tomcat 内置冲突),或缺少standard.jar(JSTL 1.1 必需)。
解决:

  • 删除/WebContent/WEB-INF/lib/下所有jstl*.jar和standard*.jar
  • 确保仅保留 Tomcat 自带的jstl-api.jar和jstl-impl.jar(位于$CATALINA_HOME/lib/)
  • JSP 页面顶部声明改为<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>(URI 不变)

4.5 现象:后台管理页/admin/userList.jsp显示空白,查看 Tomcat 日志有org.apache.jasper.JasperException: /admin/userList.jsp (line: 25, column: 11) Attribute value request.getAttribute("users") is quoted with " which must be escaped when used within the value

原因:JSP EL 表达式写法错误,如<c:forEach items="${request.getAttribute('users')}" var="u">——request.getAttribute()是 Java 方法调用,不能在 EL 中直接使用。
解决:

  • 在UserListServlet.java中:request.setAttribute("users", userList);
  • 在 JSP 中直接用 EL:<c:forEach items="${users}" var="u">
  • EL 中${}内只能是对象属性(如user.username)或隐式对象(如pageContext.request.contextPath),不能写 Java 方法

5. 进阶验证:用 Postman 模拟接口调用、用 Log4j 记录关键路径、用 Chrome DevTools 抓包分析重定向链

5.1 用 Postman 绕过前端,直击 Servlet 接口验证业务逻辑

这套资源虽是 JSP 页面驱动,但每个 Servlet 都可视为 RESTful 风格接口(尽管未用 JSON)。用 Postman 发送POST /myapp/login可跳过登录页,直接测试认证逻辑:

Postman 设置值说明
MethodPOST必须与LoginServlet.doPost()匹配
URLhttp://localhost:8080/myapp/login注意/myapp/上下文路径
Body → x-www-form-urlencodedusername=admin
password=123456
键名必须与request.getParameter("username")一致
HeadersCookie: JSESSIONID=ABC123...(可选)若需维持 session,先从浏览器复制 JSESSIONID

预期响应:HTTP 302 重定向到/admin/dashboard.jsp,响应头Location: http://localhost:8080/myapp/admin/dashboard.jsp。若返回 200 且页面内容为login.jsp,说明用户名密码错误或 DAO 查询无结果。

技巧:在 Postman 中勾选"Automatically persist cookies",即可自动携带JSESSIONID,后续请求无需手动填 Cookie,模拟真实用户会话。

5.2 用 Log4j 替换System.out.println():在关键路径埋点记录执行耗时

System.out.println()在生产环境不可控,且无法分级。将LoginServlet中的调试输出替换为 Log4j:

// 在 LoginServlet.java 顶部添加 private static final Logger logger = LogManager.getLogger(LoginServlet.class); // 替换原 System.out.println("Login start"); long startTime = System.currentTimeMillis(); logger.info("Login start for user: {}", username); // ... 执行 DAO 查询 ... long endTime = System.currentTimeMillis(); logger.info("Login completed in {} ms, result: {}", (endTime - startTime), user != null ? "success" : "failed");

Log4j 配置要点(/WebContent/WEB-INF/classes/log4j2.xml):

  • <Appenders><File name="FileAppender" fileName="logs/login.log">指定日志文件路径
  • <Logger name="com.example.servlet.LoginServlet" level="info" additivity="false">单独为该类设日志级别
  • 启动 Tomcat 后,logs/login.log中将出现:2024-06-15 10:23:45 INFO LoginServlet - Login completed in 127 ms, result: success

5.3 用 Chrome DevTools Network 面板抓包,看透重定向与 Session 创建全过程

打开 Chrome,按F12→ 切到Network标签 → 勾选Preserve log→ 访问http://localhost:8080/myapp/login.jsp→ 输入账号密码提交。观察请求列表:

列名值解读
Namelogin请求 URL 的 path 部分
MethodPOST提交方式
Status302重定向状态码,说明登录成功
Headers → Request Headers → CookieJSESSIONID=7D8A1B2C...浏览器自动发送的 session ID
Headers → Response Headers → Set-CookieJSESSIONID=7D8A1B2C...; Path=/myapp; HttpOnlyTomcat 创建新 session 并返回给浏览器
Headers → Response Headers → Location/myapp/admin/dashboard.jsp重定向目标地址

关键发现:若第一次访问login.jsp时,Request Headers中无Cookie,但Response Headers中有Set-Cookie,说明 session 是本次请求新建的;若第二次提交时Request Headers已有Cookie,则说明 session 复用成功。

5.4 一个必须养成的习惯:每次修改web.xml后,强制重启 Tomcat 并清空work/Catalina/目录

web.xml是 Tomcat 的部署契约,其修改不会被热加载。常见翻车场景:

  • 修改<servlet-mapping>的url-pattern后未重启,导致 404
  • 添加新<filter>后未清空work/Catalina/,旧的 JSP 编译缓存仍引用老web.xml

标准操作流:

# 1. 停止 Tomcat $ cd /path/to/tomcat-8.5.99/bin $ ./shutdown.sh # 2. 清空工作目录(删除所有编译缓存) $ rm -rf /path/to/tomcat-8.5.99/work/Catalina/localhost/myapp/ # 3. 启动 Tomcat $ ./startup.sh # 4. 重新执行 ant deploy(确保最新 class 文件覆盖) $ cd /path/to/very-good-work/ && ant deploy

从那以后我每次改完web.xml或db.properties,都强制走一遍这个四步流程——哪怕只是改了一个字符。因为 Tomcat 的缓存机制太“聪明”,聪明到会让你花两小时排查一个根本不存在的 bug。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进