简介:这份“云工厂-网上书城”是基于J2EE技术实现的在线图书销售平台项目,适合正在学习Java企业级开发、希望了解完整Web应用从用户界面到后端逻辑再到数据库交互的开发者。资源共588个文件,压缩包约4.09MB,主要包含Java源码与class文件、JSP页面、图片资源、CSS样式和SQL数据库脚本等,可支撑项目部署、阅读与二次开发学习。内容覆盖Servlet与JSP的请求处理流程、JavaBeans封装业务数据、JDBC数据库操作、EJB企业级组件设计,以及前端页面构建等典型环节。包内还附有数据库文件和项目配置信息,便于在Tomcat等容器中还原运行环境。目前已有242人学习下载,适合作为J2EE课程设计参考或企业级应用开发入门练习,通过阅读源码和部署调试,可以加深对多层架构、会话管理和订单流程的理解。
1. 一个值得手搓一遍的 J2EE 网上书城:云工厂能干什么
前阵子帮人在项目答辩前做走场,我见过太多搭得飞快的书城:Spring Boot 一条命令生成骨架,三十分钟录完演示数据。可面试官只问了一句“购物车数据你放 Session 还是数据库表”,台上就冷场了。这套“云工厂-网上书城”恰好相反,它把 J2EE 的经典组件全部手工实现了一遍:Servlet 接收请求,JSP 渲染页面,JavaBean 承载业务与数据,Filter 做登录拦截和编码处理,JDBC 直连 MySQL。它不追求前后端分离,而是把“浏览器请求穿过过滤器、Servlet、DAO、再回到 JSP 页面”这条链路完整摊开给你看。
适合谁?一类是正在上 J2EE 课程、需要一个能跑通又能讲清原理的练手项目的在校生;另一类是工作里用惯了 Spring Boot,想回头补“Servlet 到底怎么处理 HTTP、Session 的生命周期谁在管”这门地基课的初级开发。这个项目没有一行 Spring 代码,但它会逼你把 Web 应用最底层的会话、事务和请求流转重走一遍。
2. 技术选型与项目结构:JSP + Servlet + JavaBean 的经典三段式
2.1 技术栈全景:为什么是 J2EE 而不是 Spring Boot
打开项目第一感受是“干净”,整个目录是传统 Java Web 工程的模样,没有一串串框架依赖。技术栈不复杂:Servlet、JSP、JSTL、Filter、Listener,加上 JDBC 和 MySQL。运行环境常见搭配是 Tomcat 8.5 或 9.x,本地学习用这个组合跑得最稳。数据访问层没有引入 MyBatis,用的是原生 JDBC 封装,底层原理看得明明白白。
我把技术栈按职责拆成一张表,方便你后面改代码时按图索骥:
| 分层 | 技术组件 | 在项目里承担的职责 |
|---|---|---|
| 控制层 | Servlet + Filter | 接收请求、参数校验、登录拦截、统一编码 |
| 视图层 | JSP + JSTL + EL | 渲染图书列表、购物车、订单页面,输出 HTML |
| 模型层 | JavaBean + DAO | 封装 User、Book、Cart 实体,提供增删改查方法 |
| 数据层 | JDBC + 连接池 | 管理 MySQL 连接,执行 SQL,处理事务 |
| 状态管理 | HttpSession + Cookie | 维持登录状态,存放购物车数据 |
如果你之前一直用 Spring Boot 写接口,看这个项目会有一种“返祖”感:一个 Servlet 类对应一个 URL,用 doGet 和 doPost 处理请求,再通过 RequestDispatcher 把数据转发给 JSP。没有 @RequestMapping,没有 DispatcherServlet,没有自动配置。但这正是它的价值——框架帮你省掉的注册路径、管理 Session 生命周期、关闭数据库连接这些琐碎操作,恰恰是 J2EE 面试里最常被追问的点。在这套项目里你能把“Servlet 是怎么被 Tomcat 找到的”“转发和重定向的区别”“同一个请求里如何保持事务”这些问题真正讲出原理。
2.2 目录结构与分层:从一个 war 包看职责边界
项目按标准 Java Web 结构组织,最终打成 war 包部署。我把它简化成下面这棵树:
cloudfactory-bookstore/ ├── src/ │ ├── main/ │ │ ├── java/ │ │ │ └── com/cloudfactory/book/ │ │ │ ├── controller/ # Servlet 控制器,一个业务一个类 │ │ │ │ ├── UserServlet.java │ │ │ │ ├── BookServlet.java │ │ │ │ ├── CartServlet.java │ │ │ │ └── OrderServlet.java │ │ │ ├── entity/ # JavaBean 实体类 │ │ │ │ ├── User.java │ │ │ │ ├── Book.java │ │ │ │ ├── Cart.java │ │ │ │ └── CartItem.java │ │ │ ├── dao/ # JDBC 数据访问对象 │ │ │ │ ├── UserDao.java │ │ │ │ └── BookDao.java │ │ │ ├── service/ # 业务逻辑层 │ │ │ │ └── OrderService.java │ │ │ ├── filter/ # 过滤器 │ │ │ │ ├── EncodingFilter.java │ │ │ │ └── LoginFilter.java │ │ │ └── util/ # DBUtil、Page 分页工具 │ │ └── resources/ │ │ ├── db.properties # 数据库连接参数 │ │ └── c3p0-config.xml # 连接池配置 │ └── webapp/ │ ├── WEB-INF/ │ │ ├── web.xml # 部署描述符,Servlet 映射在这里 │ │ └── lib/ # mysql 驱动等第三方 jar │ ├── css/ js/ images/ │ ├── index.jsp # 首页 │ ├── login.jsp register.jsp # 登录注册页 │ ├── book_list.jsp # 图书列表页 │ └── cart.jsp order_confirm.jsp # 购物车与订单确认页第一眼值得注意的几点:JSP 页面分了两类。登录页、图书列表这类直接放 webapp 根目录,因为浏览器需要能直接访问;订单确认这类需要先经过 Servlet 带数据的页面,则放在 WEB-INF 下,由 Servlet 转发出去,用户无法绕过 Servlet 直接访问裸 JSP。教学项目里通常把大部分 JSP 放根目录,方便同学直接看渲染效果,商用项目则会收敛到 WEB-INF 下。
controller 包采取“一个业务一个 Servlet”的设计,每个 Servlet 用请求参数 action 区分操作。BookServlet 负责图书分页和详情,CartServlet 处理加购和删购,OrderServlet 管下单。这种设计的好处是 URL 数量少,web.xml 里的映射简单,坏处是单个 Servlet 里的 if-else 会膨胀。但理解了这种写法,后面看 Spring MVC 的 @RequestMapping,会觉得那是“顺手优化”而不是“另一套世界”。
2.3 数据层落地:JDBC 封装与连接池配置
数据层是新手最容易抄错的部分,拆开讲。先看数据库连接配置,resources 目录下的 db.properties 通常是这个样子:
jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/bookstore?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8 jdbc.username=root jdbc.password=123456参数说明:
- jdbc.driver 在 MySQL 8.x 驱动下类名必须是 com.mysql.cj.jdbc.Driver;老项目里常见 com.mysql.jdbc.Driver,那是 5.x 驱动的写法,换到 8.0 驱动会直接报类不存在。
- jdbc.url 里的 serverTimezone=Asia/Shanghai 是 MySQL 8 驱动的硬性要求,不加会抛时区异常;useSSL=false 是本地开发免去 SSL 证书校验的麻烦;characterEncoding=utf8 保证中文读写不出现乱码。
- username/password 按你本机数据库改,连接超时或权限问题优先看这两行。
项目里在 util 包写了一个 DBUtil,负责提供连接和回收连接。常见做法是用连接池而不是每次创建新 Connection,避免高并发下数据库连接数打满。用 C3P0 的初始化大概是这样:
public class DBUtil { private static DataSource dataSource; static { try { ComboPooledDataSource cpds = new ComboPooledDataSource(); cpds.setDriverClass("com.mysql.cj.jdbc.Driver"); cpds.setJdbcUrl("jdbc:mysql://localhost:3306/bookstore?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8"); cpds.setUser("root"); cpds.setPassword("123456"); cpds.setInitialPoolSize(5); cpds.setMaxPoolSize(20); dataSource = cpds; } catch (Exception e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } public static void close(ResultSet rs, PreparedStatement ps, Connection conn) { if (rs != null) { try { rs.close(); } catch (SQLException e) { e.printStackTrace(); } } if (ps != null) { try { ps.close(); } catch (SQLException e) { e.printStackTrace(); } } if (conn != null) { try { conn.close(); } catch (SQLException e) { e.printStackTrace(); } } } }代码逻辑说明:
- 静态代码块在类首次加载时执行一次,连接池随应用启动初始化,不需要每次请求都重新建连接。
- getConnection() 拿到的连接来自池,调用 close() 后连接归还池而非物理关闭,所以 DAO 里统一调 DBUtil.close() 是安全的。
- initialPoolSize 和 maxPoolSize 控制池子里初始连接和最大连接数,本地学习 5 和 20 足够;上线前按并发量调大,同时注意 MySQL 端的 max_connections 上限。
数据访问层一律用 PreparedStatement 拼 SQL 参数,从根上避开 SQL 注入,3.2 节的 BookDao 就是现成例子。
3. 核心模块拆解:一次请求从进入到返回的完整链路
3.1 登录模块:Filter 拦截与 Session 会话管理
登录是全站的门禁,正好用它拆“一次请求进来后发生了什么”。web.xml 里配了两道过滤器,先拦编码,再拦登录态。LoginFilter 的核心逻辑是这样:
public class LoginFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; HttpSession session = req.getSession(false); String uri = req.getRequestURI(); // 放行登录、注册页面和静态资源 if (uri.endsWith("login.jsp") || uri.endsWith("register.jsp") || uri.contains("/css/") || uri.contains("/js/") || uri.endsWith(".jpg") || uri.endsWith(".png")) { chain.doFilter(request, response); return; } // 未登录跳回登录页 if (session == null || session.getAttribute("user") == null) { resp.sendRedirect(req.getContextPath() + "/login.jsp"); return; } chain.doFilter(request, response); } @Override public void init(FilterConfig filterConfig) throws ServletException {} @Override public void destroy() {} }这里有三处值得抠细节。第一,req.getSession(false) 的 false 很关键:如果用户没登录过,这里返回 null,不会白白创建一个新会话;若写成 getSession(),一个还没登录的访问反而会促生新 Session,白白占内存。第二,放行逻辑要覆盖静态资源和登录注册页,否则浏览器加载登录页自己的 CSS 和图片也会被重定向,页面样式全挂。第三,sendRedirect 用的是 req.getContextPath() 拼出来的路径,直接写 "/login.jsp" 的话,项目部署路径一变就 404。
认证逻辑在 UserServlet 的 doPost 里做,靠 action 参数区分登录和注册:
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String action = req.getParameter("action"); if ("login".equals(action)) { String username = req.getParameter("username"); String password = req.getParameter("password"); // 库里存的是 MD5 摘要,这里先做摘要再比对,不要明文拼 SQL User user = userDao.findByUsernameAndPassword(username, DigestUtils.md5Hex(password)); if (user != null) { req.getSession().setAttribute("user", user); resp.sendRedirect(req.getContextPath() + "/index.jsp"); } else { req.setAttribute("errorMsg", "用户名或密码错误"); req.getRequestDispatcher("/login.jsp").forward(req, resp); } } else if ("register".equals(action)) { // 注册分支:校验用户名是否重复,再插入 user 表 } }逻辑说明:
- 登录成功用 sendRedirect,失败用 forward。前者的地址栏会变成 /index.jsp,刷新不会重复提交表单;后者是服务器内部的转发,页面地址栏还停留在 login.jsp。很多项目登录后一按 F5 就弹“重新提交表单”,就是因为登录成功后用了 forward,这是个经典翻车点。
- 密码比对的细节:库里存的是 MD5 摘要,所以查询参数也要先做摘要再进 SQL,两边都是摘要才能查出数据。裸 SQL 拼用户输入属于安全红线,任何时候都别这么写。
- Session 里存了 user 对象后,JSP 侧可以直接用 EL 表达式取用户名,不需要再查一遍数据库。
3.2 图书列表与分页:Servlet 怎么把数据交到 JSP
图书列表是首页最核心的数据出口,分页是这里必然会遇到的问题。先看分页工具 Page 类,它放在 util 包:
public class Page<T> { private int pageNum; // 当前页码 private int pageSize; // 每页条数 private long totalCount; // 总记录数 private List<T> dataList; // 当前页数据 public int getTotalPage() { return (int) Math.ceil(totalCount * 1.0 / pageSize); } // 前端分页条里还会用到 isHasPrevPage 等判断,按需补 }说下这个小设计的意义:totalCount 由 COUNT(*) 查询得到,pageNum 由请求参数带过来,pageSize 一般是常量。getTotalPage 用 Math.ceil 向上取整:例如 9 条数据、每页 10 条,总页数是 1,而不是 0。这个取法错了,分页条上会出现“第 0 页”。
BookDao 里的核心方法是 findBookPage,两条 SQL 完成任务:
public Page<Book> findBookPage(int pageNum, int pageSize) throws SQLException { Page<Book> page = new Page<>(); page.setPageNum(pageNum); page.setPageSize(pageSize); String countSql = "SELECT COUNT(*) FROM book"; String dataSql = "SELECT id, title, author, price, stock FROM book LIMIT ?, ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement psCount = conn.prepareStatement(countSql); ResultSet rsCount = psCount.executeQuery()) { if (rsCount.next()) { page.setTotalCount(rsCount.getLong(1)); } try (PreparedStatement psData = conn.prepareStatement(dataSql)) { psData.setInt(1, (pageNum - 1) * pageSize); psData.setInt(2, pageSize); ResultSet rsData = psData.executeQuery(); List<Book> list = new ArrayList<>(); while (rsData.next()) { Book book = new Book(); book.setId(rsData.getInt("id")); book.setTitle(rsData.getString("title")); book.setAuthor(rsData.getString("author")); book.setPrice(rsData.getBigDecimal("price")); book.setStock(rsData.getInt("stock")); list.add(book); } page.setDataList(list); } } return page; }这段代码有两个容易错的地方。第一个是 LIMIT 的偏移量写的是 (pageNum - 1) * pageSize,第一页偏移 0,第二页偏移 10;如果图省事直接写 pageNum * pageSize,第一页就从第 11 条开始了。第二个是 price 字段用的是 BigDecimal 而不是 double,金额计算不会出现 0.1 + 0.2 = 0.30000000000000004 那种浮点误差,财务相关字段要养成这个习惯。
BookServlet 拿到分页参数后,把 Page 对象塞进 request 转发给 JSP:
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { int pageNum = 1; String param = req.getParameter("pageNum"); if (param != null && !param.isEmpty()) { try { pageNum = Integer.parseInt(param); } catch (NumberFormatException e) { pageNum = 1; // 非法参数兜底 } } Page<Book> page = bookService.findBookPage(pageNum, PAGE_SIZE); req.setAttribute("page", page); req.getRequestDispatcher("/book_list.jsp").forward(req, resp); }注意这里的参数兜底:用户手输 /book/list?pageNum=abc 时 Integer.parseInt 会抛 NumberFormatException,catch 住归为第 1 页是常见做法。JSP 侧用 JSTL 渲染:
<c:forEach items="${page.dataList}" var="book"> <div class="book-item"> <h3>${book.title}</h3> <p>作者:${book.author}</p> <p class="price">¥${book.price}</p> <a href="${pageContext.request.contextPath}/cart?action=add&bookId=${book.id}">加入购物车</a> </div> </c:forEach>两个小知识点:${page.dataList} 调用的其实是 Page 对象的 getDataList() 方法,EL 表达式的本质是属性访问;${pageContext.request.contextPath} 拿到的是项目根路径,链接带上它之后,整个项目换名字部署也不会批量 404,比硬写 /bookstore 稳妥得多。
3.3 购物车模块:Session 里的购物车还是数据库表
购物车模块值得单独说,因为它是“数据放哪”的经典权衡。在这个项目里,购物车数据放在 HttpSession 中,而不是数据库。原因很简单:游客不用登录也能加购,Session 天然按浏览器隔离,实现成本低;代价是跨设备不同步、Session 过期购物车就没了。教学项目用这个设计合理,真做商用再考虑持久化购物车。
购物车对象我建议用 LinkedHashMap 存条目,而不是 HashMap,原因写在代码注释里:
public class Cart implements Serializable { // LinkedHashMap 保持加购顺序,下单明细顺序和用户操作一致 private Map<Integer, CartItem> items = new LinkedHashMap<>(); public void addItem(CartItem newItem) { CartItem old = items.get(newItem.getBookId()); if (old != null) { old.setQuantity(old.getQuantity() + newItem.getQuantity()); } else { items.put(newItem.getBookId(), newItem); } } public BigDecimal getTotalPrice() { BigDecimal total = BigDecimal.ZERO; for (CartItem item : items.values()) { total = total.add(item.getSubtotal()); } return total; } }addItem 里的逻辑很直白:先看购物车里有没有同一本书,有就数量累加,没有才新增条目。这避免了同一本书在购物车里出现两行记录,后面下单时也不会对同一本书扣两次库存。
再看加购的 Servlet 处理,cart 为空的判断容易漏:
HttpSession session = req.getSession(); Cart cart = (Cart) session.getAttribute("cart"); if (cart == null) { cart = new Cart(); session.setAttribute("cart", cart); } Book book = bookDao.findById(Integer.parseInt(req.getParameter("bookId"))); cart.addItem(new CartItem(book.getId(), book.getTitle(), book.getPrice(), 1)); resp.sendRedirect(req.getContextPath() + "/cart.jsp");逻辑说明:CartItem 里把商品名、单价快照进购物车项,而不是到时候去 JSP 里再 join 图书表。这样即使后台改了图书价格,用户购物车里显示的仍是加入时的价格,体验合理,这也是“快照”思想在订单模块里还会再出现一次。
另外有个细节值得说:同一个浏览器会话里,先往 Session 放 cart,再登录,Session 对象还是同一个,所以登录后购物车不会消失。这也解释了为什么这模块能放 Session 而不用建表。
3.4 订单提交模块:一次事务里的多表写入
下订单是全站最需要“原子性”的操作:插入订单主表、插入订单明细、扣库存,要么全部成功,要么全部失败。代码最常出错的地方是连接管理。先看 Service 层的写法:
public Long createOrder(Integer userId, Cart cart) throws SQLException { Connection conn = DBUtil.getConnection(); try { conn.setAutoCommit(false); // 关掉自动提交,开启事务 // 1. 插入订单主表,拿到自增主键 String sqlOrder = "INSERT INTO `orders` (user_id, total_price, create_time, status) VALUES (?, ?, NOW(), 1)"; PreparedStatement psOrder = conn.prepareStatement(sqlOrder, Statement.RETURN_GENERATED_KEYS); psOrder.setInt(1, userId); psOrder.setBigDecimal(2, cart.getTotalPrice()); psOrder.executeUpdate(); ResultSet rsKey = psOrder.getGeneratedKeys(); long orderId = 0; if (rsKey.next()) { orderId = rsKey.getLong(1); } // 2. 循环插入订单明细,同时扣库存 String sqlItem = "INSERT INTO order_item (order_id, book_id, quantity, price) VALUES (?, ?, ?, ?)"; String sqlStock = "UPDATE book SET stock = stock - ? WHERE id = ? AND stock >= ?"; for (CartItem item : cart.getItems()) { try (PreparedStatement psItem = conn.prepareStatement(sqlItem)) { psItem.setLong(1, orderId); psItem.setInt(2, item.getBookId()); psItem.setInt(3, item.getQuantity()); psItem.setBigDecimal(4, item.getPrice()); psItem.executeUpdate(); } try (PreparedStatement psStock = conn.prepareStatement(sqlStock)) { psStock.setInt(1, item.getQuantity()); psStock.setInt(2, item.getBookId()); psStock.setInt(3, item.getQuantity()); int rows = psStock.executeUpdate(); if (rows == 0) { throw new SQLException("库存不足,回滚订单:" + item.getBookId()); } } } conn.commit(); // 下单成功后,Session 里的购物车交给 Servlet 清空 return orderId; } catch (SQLException e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(true); DBUtil.close(null, null, conn); } }这段值得耐心读,它是全项目里事务的唯一正确写法。三个关键点:
- 事务必须用同一个 Connection,所有 DAO 操作都走 Service 层传入的连接。如果你图省事,每个 DAO 内部自己 getConnection,那 setAutoCommit(false) 和 commit 各管各的连接,所谓事务完全不生效。这是“事务不生效”类 bug 的头号原因。
- 扣库存的 SQL 后面加了 AND stock >= ?,执行完再看受影响行数。返回 0 行说明库存不足,直接抛异常触发 rollback。这个写法可以理解为乐观锁思路的简化版,课程项目里能防超卖已经很够用。
- 订单明细里 price 存的是下单当时的单价快照,不是实时关联 book 表。这是刻意做的冗余:以后后台调价,历史订单价格不变。面试被问到“为什么做冗余”时,这就是现成例子。
finally 里把 autoCommit 恢复成 true 也是个细节。连接归还连接池后,如果上一次事务忘了恢复自动提交,下一次从池里拿到的连接就会沿用“不自动提交”的状态,届时所有 DAO 操作都不会真实落库。
4. 部署运行与避坑:从本地 Tomcat 到上线前的几道坎
4.1 环境准备与部署:JDK + Tomcat + MySQL 的最小组合
本地要把这个项目跑起来,最小组合就是三件套:JDK 1.8 及以上、Tomcat 8.5 或 9.x、MySQL 5.7 或 8.0。JDK 版本别一上来就升到 17 以上,老一点的 Tomcat 8.5 对高版本 JDK 支持有问题,到时候报 UnsupportedClassVersionError 反而难排查。
部署步骤大概是:
第一步,建库导数据。在 MySQL 里执行项目自带的 bookstore.sql,建好 bookstore 库和 user、book、orders、order_item 四张表,顺带把测试数据插进去。注意导入时选择 utf8 编码,否则后面页面中文全是问号。
第二步,改数据库配置。把 db.properties 里的 username、password 改成你本机的账号密码,url 里的 localhost:3306 按实际环境改。
第三步,配置 Tomcat。用 IDEA 的话,在 Run/Debug Configurations 里新增 Tomcat Server,Deployment 里把 artifact 加进去,Application context 改成 /bookstore。项目依赖的 servlet-api.jar、jsp-api.jar 由 Tomcat 提供,不要打进 WEB-INF/lib,否则会和容器自带版本冲突。
第四步,启动。访问 http://localhost:8080/bookstore/,首页能看到图书列表就算通了。
整套流程里最容易卡住的是第三步的 Application context。IDEA 默认生成的名字可能是 cloudfactory-bookstore_war_exploded,如果按这个访问,代码里的 ${pageContext.request.contextPath} 会自动带上这个长名字,页面一时看着没问题,但网址丑且容易记错。建议统一改成 /bookstore,前端所有链接也跟着 contextPath 走。
4.2 配置细节:编码、路径、JAR 包一个都不能错
字符编码是 J2EE 项目最容易“半个项目乱码”的地方,根源在三层断档:JSP 页面本身、请求的输入编码、数据库连接的传输编码。教学项目最常见的做法是加一个 EncodingFilter,把所有请求先统一成 UTF-8:
public class EncodingFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { request.setCharacterEncoding("UTF-8"); response.setContentType("text/html;charset=UTF-8"); chain.doFilter(request, response); } }说明一下:request.setCharacterEncoding 只对 POST 请求取参生效。GET 请求的参数在 URL 上,Tomcat 默认按 ISO-8859-1 解码,所以 GET 传中文乱码时,要在 Tomcat 的 server.xml 里给 Connector 加 URIEncoding="UTF-8"。两个入口都处理了,中文才彻底稳。
JAR 包位置是另一个隐蔽坑。IDEA 里把 mysql-connector-java.jar 导入项目并点了 Add as Library 后,编译期没问题,但 Tomcat 部署时不一定把这个 jar 带进 WEB-INF/lib。运行时报 ClassNotFoundException 时,第一反应不是去翻 Maven 仓库,而是打开部署产物看 WEB-INF/lib 里有没有这个 jar,没有就手动拷进去。
再把 web.xml 的部署描述符看一眼。Servlet 需要通过 servlet-mapping 把类映射到 URL,浏览器才能访问到。以 UserServlet 为例:
<filter> <filter-name>EncodingFilter</filter-name> <filter-class>com.cloudfactory.book.filter.EncodingFilter</filter-class> </filter> <filter-mapping> <filter-name>EncodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> <servlet> <servlet-name>UserServlet</servlet-name> <servlet-class>com.cloudfactory.book.controller.UserServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>UserServlet</servlet-name> <url-pattern>/user</url-pattern> </servlet-mapping>url-pattern 决定了一个请求会交给哪个 Servlet。访问 /bookstore/user 时,Tomcat 剥掉上下文路径后拿 /user 去匹配映射表,命中后实例化 UserServlet 并调用 doGet/doPost。Filter 的 url-pattern 配的是 /*,表示所有请求先经过 Filter 链再进 Servlet。改了映射后老地址直接 404,这也是为什么项目改名要同时关注 contextPath 和 web.xml 两处地方。
4.3 常见问题与避坑:五条血泪记录
坑一:页面 404,但项目明明启动了
现象:Tomcat 启动日志无异常,访问 http://localhost:8080/bookstore/ 还是 404。
原因:Application context 和访问路径不一致。IDEA 部署名往往是长串的 artifact_id_war_exploded,而你可能按 /bookstore 访问。
解决:在 Run/Debug Configurations 里把 Deployment 的 Application context 统一改为 /bookstore。这个改完记得重启 Tomcat,只 Redeploy 有时不生效。
坑二:报 ClassNotFoundException: com.mysql.cj.jdbc.Driver
现象:启动后第一个数据库请求就抛驱动类找不到。
原因:mysql 驱动 jar 没有出现在部署产物的 WEB-INF/lib 里。IDEA 里 Add as Library 只解决了编译期,运行期容器找不到。
解决:把 mysql-connector-java.jar 复制进 webapp/WEB-INF/lib 目录,重新构建并重启。这个坑隐蔽在编译不报错,只有运行到数据库操作时才炸。
坑三:MySQL 8 连接失败,报 time zone 或 SSL 错误
现象:控制台出现 The server time zone ... is unrecognized,或者 SSL 握手警告。
原因:MySQL 8 驱动对时区有强校验,url 里没带 serverTimezone;而且驱动 5.x 和 8.x 的 driver class 名还不一样。
解决:url 追加 serverTimezone=Asia/Shanghai&useSSL=false,driver class 改成 com.mysql.cj.jdbc.Driver。两份不同版本的驱动 jar 别叠着放,很容易混淆。
坑四:数据库中文正常,页面中文全是问号
现象:数据表里数据正常,页面上显示 ????,或表单提交后中文存进库变成 ???。
原因:编码断了一环。请求输入编码、JSP 输出编码、JDBC 连接传输编码,只要有一层不对,中文就保不住。
解决:JSP 顶部写 pageEncoding="UTF-8";EncodingFilter 在 web.xml 里 url-pattern 配 /*;db.properties 的 url 里加 characterEncoding=utf8;GET 请求再补 Tomcat 的 URIEncoding="UTF-8"。四步全部对齐,乱码基本绝迹。
坑五:登录后过几分钟就掉线,购物车也空了
现象:登录后正常访问,过一段时间再点页面被踢回登录页,购物车条目也没了。
原因:Session 有默认有效期,Tomcat 默认 30 分钟;而且重启 Tomcat 时内存里的 Session 全部清空。浏览器关掉再打开,Cookie 里的 JSESSIONID 没了,服务器也认不出旧 Session。
解决:课程项目里这个行为是正常的。如果演示时要撑住全场,可以在登录成功后设置 session.setMaxInactiveInterval(3600)。要跨重启持久化,就得把 Session 或用户 token 落库,那是另一套方案。先把“Session 由谁管理、什么时候失效”这件事理解透,比调参数更有用。
5. 二次开发实战:给书城加一个“我的订单”模块
5.1 改造思路:先定功能边界,再按三层顺序动手
如果你想把这套项目改成自己的毕设或简历项目,建议别一上来调样式,先加一个“我的订单”页面:用户登录后能看到自己的历史订单列表,点开能看每单明细。这个功能不复杂,但能把你对三层架构的理解完整串一遍。
改造路径按三层往下拆。第一步,OrderDao 加 findByUserId 方法,SQL 按 user_id 查 orders 表,按创建时间倒序。第二步,OrderServlet 增加 action=myOrders 分支,从 Session 取当前用户 id,查询后把订单列表塞进 request,转发到 /my_orders.jsp。第三步,在登录后的导航栏加一个“我的订单”链接,指向 ${pageContext.request.contextPath}/order?action=myOrders。顺手再在 order_confirm.jsp 下单成功的回调里加个跳转,用户下单完成直接落到“我的订单”页,闭环更顺。
验证这套改造有没有改歪,我有一套自己的检查顺序:开两个浏览器,一个登录下单,另一个不登录直接访问 /order?action=myOrders。前者正常、后者被 LoginFilter 踢回登录页,说明“未登录拦截”还活着;同一个浏览器先访问图书详情再加购,然后登录,购物车不丢,说明 Session 没有被主动清掉。这两条过了,改造的核心链路就没破。
做这个功能时最值得练手的是“订单明细页怎么拿数据”:不要在 JSP 里两层循环频繁查库。正确顺序是在 Servlet 里把订单和它的明细拼成 Order 对象,里面挂一个 List<OrderItem>,JSP 只做展示。这样既练了数据装配,也避开了 N+1 查询的坏味道。
从那以后,我每次给同学看 J2EE 项目都强制先走一遍“新环境四部曲”:改库密码、改 contextPath、确认 WEB-INF/lib 的 jar、清 target 后重启。顺序乱了就容易遇上各种玄学报错,顺序对了基本一次跑通。希望这套笔记能帮你也少踩几个坑。
本文还有配套的精品资源,点击获取