ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

JSP+Servlet+DAO网上书店实战:从部署到二次开发

JSP+Servlet+DAO网上书店实战:从部署到二次开发

简介:这份资源是一套基于Java Web技术栈的网上书店项目源码,采用JSP+Servlet+DAO的经典MVC分层结构,适合正在学习Java Web开发、需要完整项目案例练手的高校学生与初中级开发者,可帮助理解视图渲染、请求控制与数据持久化之间的协作方式。压缩包共161个文件,约768KB,以27个java源文件与27个class编译文件为核心,配合17个jsp页面、64个gif及少量jpg、png图片资源,另有js脚本、xml配置与sql建库脚本,覆盖从业务代码到页面素材的完整工程。已有170人学习下载。项目涵盖用户登录验证、图书搜索、购物车增删改与结算、基于LIMIT与OFFSET的分页展示,以及通过JNDI配置数据库连接池等关键环节,并借助Session跟踪购物状态、LoginFilter实现访问控制,读者可据此梳理分层职责、调试数据库交互并复用分页与购物车实现思路。

1. 从一份“老派”源码说起:jsp+servlet+dao 网上书店能解决什么

前阵子帮一个做毕业设计的朋友看代码,他丢过来一个压缩包,说“哥,这网上书店我跑不起来,Tomcat 一启动就 404”。我解压一看,典型的 jsp+servlet+dao 三层结构,没有 Spring,没有 MyBatis,纯原生 Servlet 加 JDBC。这种项目在培训班和课程设计里出现频率极高,但真正能一次跑通的人不多。它解决的核心问题很明确:用最基础的 Java Web 技术栈,把“图书浏览、购物车、下单、后台管理”这条电商最小闭环完整实现一遍。适合谁?适合刚学完 Servlet 想找个完整项目练手的同学,也适合需要理解 MVC 分层到底怎么落地、DAO 模式怎么写的开发者。这套代码没有花哨的框架,反而把请求怎么进、数据怎么出、页面怎么渲染讲得明明白白,是理解 Java Web 底层链路的好材料。

2. 项目结构与技术栈拆解:三层架构到底怎么分的

2.1 目录结构长什么样

拿到源码先别急着往 Tomcat 里扔,先看目录。典型的 jsp+servlet+dao 网上书店项目,目录结构大致如下:

bookstore/ ├── src/ │ └── com/bookstore/ │ ├── dao/ # 数据访问层接口与实现 │ ├── entity/ # 实体类,对应数据库表 │ ├── service/ # 业务逻辑层 │ ├── servlet/ # 控制层,接收请求 │ └── util/ # 数据库连接工具类 ├── WebContent/ │ ├── WEB-INF/ │ │ ├── web.xml # Servlet 映射配置 │ │ └── lib/ # 依赖 jar 包 │ ├── jsp/ # 页面文件 │ ├── css/ js/ images/ # 静态资源 │ └── index.jsp # 首页入口 └── sql/ └── bookstore.sql # 建库建表脚本

这个结构的关键在于src下的包划分。entity放实体类,一个类对应一张表;dao放数据库操作,接口和实现分开;service放业务逻辑,比如“下单时要先扣库存再生成订单”;servlet放控制层,负责接收参数、调用 service、转发页面。util里通常是一个DBUtil类,封装 JDBC 连接获取和关闭。WebContent是 Web 根目录,WEB-INF下的web.xml是 Servlet 3.0 之前的映射方式,如果项目用的是注解@WebServlet,那web.xml里可能只有欢迎页配置。

提示:如果WEB-INF/lib下没有mysql-connector-java的 jar 包,启动后连数据库必然报ClassNotFoundException,这是新手最容易漏的一步。

2.2 技术栈与依赖关系

这个项目的技术栈非常朴素:JSP 负责视图渲染,Servlet 负责请求控制,DAO 负责数据持久化,JDBC 直连 MySQL。没有连接池的话,DBUtil里就是每次DriverManager.getConnection(),用完close()。常见做法是引入 Druid 或 C3P0 连接池,但很多课程设计项目为了简单直接裸写 JDBC。依赖的 jar 包通常包括:mysql-connector-java、jstl(如果 JSP 里用了<c:forEach>标签)、standard(JSTL 的实现包)。如果页面里出现<c:if>报错,八成是缺了 JSTL 的两个 jar。

分层之间的调用关系是单向的:Servlet 调 Service,Service 调 DAO,DAO 返回实体对象,Servlet 把实体放进request域,转发给 JSP 渲染。这种链路虽然原始,但把 MVC 的职责边界划得很清楚。我一般会建议初学者先顺着一条“查询图书列表”的请求走一遍,从index.jsp的链接点到BookListServlet,再到BookDao.findAll(),最后回到bookList.jsp,走通这一条线,整个项目的骨架就清楚了。

2.3 数据库表设计要点

建表脚本通常在sql/bookstore.sql里。核心表一般有四张:book(图书信息)、user(用户)、orders(订单主表)、order_item(订单明细)。book表字段包括id、name、author、price、stock、category_id、cover等;orders表有id、user_id、total_price、create_time、status;order_item表记录每笔订单里买了哪本书、买了几本、单价多少。这里有个容易翻车的点:order_item里的单价要单独存一份,不能只靠book表的当前价格,否则书价一改,历史订单金额就对不上了。这是电商类项目的通用教训,课程设计里经常被忽略。

3. 核心链路跑通:从登录到下单的代码拆解

3.1 数据库工具类与 DAO 基类

先看DBUtil,这是整个项目的数据入口。典型写法如下:

public class DBUtil { private static final String URL = "jdbc:mysql://localhost:3306/bookstore?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai"; private static final String USER = "root"; private static final String PASSWORD = "123456"; static { try { Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { throw new RuntimeException("MySQL驱动加载失败", e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(Connection conn, PreparedStatement ps, ResultSet rs) { // 依次关闭,注意判空,关闭顺序与打开顺序相反 try { if (rs != null) rs.close(); } catch (SQLException e) { e.printStackTrace(); } try { if (ps != null) ps.close(); } catch (SQLException e) { e.printStackTrace(); } try { if (conn != null) conn.close(); } catch (SQLException e) { e.printStackTrace(); } } }

这段代码的逻辑很直白:静态块加载驱动,getConnection返回连接,close统一释放资源。参数说明:URL里的serverTimezone在 MySQL 8 以上必须加,否则连接会报时区错误;characterEncoding=utf8防止中文乱码。close方法里每个关闭都单独 try-catch,是为了避免一个关失败影响后面的关闭。我一般会把URL、用户名、密码抽到db.properties里,用Properties加载,这样换环境不用改代码。

DAO 层通常有一个BaseDao或者每个 DAO 自己写 JDBC 模板。以BookDao的查询方法为例:

public List<Book> findAll() { List<Book> list = new ArrayList<>(); String sql = "SELECT id, name, author, price, stock, cover FROM book ORDER BY id DESC"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { while (rs.next()) { Book book = new Book(); book.setId(rs.getInt("id")); book.setName(rs.getString("name")); book.setAuthor(rs.getString("author")); book.setPrice(rs.getBigDecimal("price")); book.setStock(rs.getInt("stock")); book.setCover(rs.getString("cover")); list.add(book); } } catch (SQLException e) { e.printStackTrace(); } return list; }

这里用了 try-with-resources,连接、语句、结果集自动关闭,比手动close更稳妥。rs.getBigDecimal("price")对应实体类里BigDecimal类型的price字段,金额用BigDecimal是常识,用double做加减会出现精度问题。ORDER BY id DESC让最新上架的书排在前面,首页展示时更合理。

3.2 登录 Servlet 的请求处理流程

登录是第一个要跑通的链路。LoginServlet的doPost方法大致如下:

@WebServlet("/login") public class LoginServlet extends HttpServlet { private UserService userService = new UserServiceImpl(); @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); String username = req.getParameter("username"); String password = req.getParameter("password"); User user = userService.login(username, password); if (user != null) { req.getSession().setAttribute("currentUser", user); resp.sendRedirect(req.getContextPath() + "/book/list"); } else { req.setAttribute("msg", "用户名或密码错误"); req.getRequestDispatcher("/jsp/login.jsp").forward(req, resp); } } }

逻辑说明:先设置请求编码,防止中文用户名乱码;取参数后调userService.login,返回User对象说明验证通过;把用户放进session,后续页面通过session.getAttribute("currentUser")判断是否登录;登录成功用sendRedirect跳转到图书列表,失败用forward回到登录页并带错误提示。参数说明:req.getContextPath()拿到项目上下文路径,避免部署路径变化导致跳转 404。这里有个细节:sendRedirect后浏览器地址栏会变,forward不会,登录成功用重定向可以防止刷新重复提交。

3.3 购物车与下单的事务控制

购物车通常用session存一个Map<Integer, CartItem>,key 是图书 id,value 是数量和图书信息。加入购物车的 Servlet 逻辑是:取图书 id,查BookDao.findById,如果购物车里已有就数量加一,没有就新建CartItem放进去。下单时才是重头戏,OrderService里要处理事务:

public boolean createOrder(int userId, Map<Integer, CartItem> cart) { Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 BigDecimal total = BigDecimal.ZERO; for (CartItem item : cart.values()) { // 扣库存,stock >= quantity 才更新成功 int rows = bookDao.reduceStock(conn, item.getBookId(), item.getQuantity()); if (rows == 0) { conn.rollback(); return false; // 库存不足,整单回滚 } total = total.add(item.getBook().getPrice() .multiply(new BigDecimal(item.getQuantity()))); } int orderId = orderDao.insertOrder(conn, userId, total); for (CartItem item : cart.values()) { orderItemDao.insert(conn, orderId, item); } conn.commit(); return true; } catch (SQLException e) { try { if (conn != null) conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } e.printStackTrace(); return false; } finally { DBUtil.close(conn, null, null); } }

这段代码的关键在于conn.setAutoCommit(false)和手动commit/rollback。扣库存的 SQL 是UPDATE book SET stock = stock - ? WHERE id = ? AND stock >= ?,用stock >= quantity作为条件,返回影响行数为 0 就说明库存不够,直接回滚。所有 DAO 方法都接收同一个Connection参数,保证在同一个事务里。参数说明:total用BigDecimal累加,multiply乘数量;orderId是插入订单后返回的自增主键,需要Statement.RETURN_GENERATED_KEYS配合获取。这套事务写法虽然原始,但把“要么全成功要么全失败”讲透了,比直接调框架的@Transactional更能看清本质。

4. 部署与运行:把 war 包跑起来的完整步骤

4.1 环境准备与数据库初始化

运行前需要三样东西:JDK(建议 8 或 11)、Tomcat(8.5 或 9)、MySQL(5.7 或 8)。先建库:打开 MySQL 客户端,执行source /path/to/bookstore.sql,脚本里会CREATE DATABASE bookstore并建表插初始数据。检查DBUtil里的连接信息是否和本地一致,尤其是端口和密码。然后把项目导入 IDE,Eclipse 的话用File -> Import -> Existing Projects into Workspace,IDEA 的话用Open选项目根目录,注意把src标记为 Sources Root,WebContent标记为 Web Resource Directory。

依赖 jar 包要放到WEB-INF/lib下,或者在 IDE 的 Build Path 里 Add External JARs。常见做法是建一个lib目录统一管理,避免换机器后路径失效。Tomcat 的配置在 IDE 里加一个 Server,把项目 add 进去,启动前确认端口没被占用。

4.2 启动与访问验证

启动 Tomcat 后,浏览器访问http://localhost:8080/bookstore/。如果首页能出来图书列表,说明数据库连通、DAO 查询正常。接着点登录,用脚本里预置的账号(通常是admin/123456或test/123456)登录,看能否跳转到列表页。再试加入购物车、下单,下单后去数据库查orders和order_item表,确认数据写入且库存扣减正确。后台管理入口一般是/admin/login或/admin/book/list,用管理员账号登录后能增删改图书。

注意:如果访问首页报 500 且堆栈里是NullPointerException,先检查DBUtil的静态块是否执行成功,再看book表是否有数据。空表时findAll返回空列表,JSP 里<c:forEach>不会报错,但如果 JSP 里直接取list.get(0)就会 NPE。

4.3 常见部署失败点速查

部署阶段翻车的概率最高,列几个高频问题。第一,ClassNotFoundException: com.mysql.cj.jdbc.Driver,原因是 jar 包没放进WEB-INF/lib或者版本不对,MySQL 8 用com.mysql.cj.jdbc.Driver,5.x 用com.mysql.jdbc.Driver。第二,中文乱码,检查三处:数据库和表的字符集是否为utf8mb4、DBUtil的 URL 是否带characterEncoding=utf8、Servlet 里是否setCharacterEncoding("UTF-8")。第三,404,检查@WebServlet的路径和访问路径是否一致,web.xml里servlet-mapping的url-pattern有没有写错。第四,JSTL 标签不解析,原样输出<c:forEach>,说明缺jstl.jar和standard.jar,或者 JSP 页头没加<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>。

5. 避坑与排查:那些年踩过的 JDBC 和 JSP 坑

5.1 连接泄漏导致 Tomcat 越跑越慢

现象:项目跑一段时间后响应变慢,最后报Too many connections。原因:DAO 方法里Connection没关,或者异常分支里跳过了close。解决:统一用 try-with-resources,或者在finally里关闭;如果用了连接池,确认close是归还连接而不是真正关闭。我一般会在DBUtil.close里加日志,排查时看连接数是否持续增长。

5.2 事务里混用了不同的 Connection

现象:下单时库存扣了但订单没生成,或者订单生成了库存没扣。原因:OrderService里调bookDao.reduceStock()时,DAO 内部自己DBUtil.getConnection()拿了一个新连接,和外面的事务连接不是同一个,导致事务失效。解决:所有参与事务的 DAO 方法都接收外部传入的Connection参数,不要在方法内部自己获取连接。这是手写事务最容易犯的错。

5.3 JSP 页面里写 Java 代码导致维护困难

现象:bookList.jsp里出现<% for(...) { %>这种脚本片段,改一个字段要翻半天。原因:早期 JSP 教程习惯在页面里直接写 Java 逻辑。解决:用 JSTL 的<c:forEach>和 EL 表达式${book.name}替代脚本片段,Servlet 负责把数据放进request域,JSP 只负责展示。改造时先把request.setAttribute("bookList", list)加上,再把页面里的循环换成标签,逐步替换。

5.4 表单重复提交生成重复订单

现象:用户点“提交订单”后刷新页面,订单表里出现两条相同记录。原因:下单成功后用forward转发到结果页,刷新时浏览器重新提交了 POST 请求。解决:下单成功后用sendRedirect重定向到订单列表页,这是 PRG(Post-Redirect-Get)模式。如果业务上需要防重,可以在session里放一个 token,提交时校验并移除。

5.5 图片上传路径写死导致换环境失效

现象:图书封面上传后本地能看,部署到另一台机器图片 404。原因:上传路径写成了D:/upload/这种绝对路径,或者存了本地磁盘路径但没配 Tomcat 虚拟目录。解决:上传目录配置成项目外的固定路径,通过 Tomcat 的<Context docBase="..." path="/upload"/>映射,数据库里只存相对文件名。或者简单点,课程设计直接把图片放WebContent/images/下,数据库存文件名,省去上传逻辑。

6. 二次开发改造:从能跑到能用的几个切入点

这套代码跑通之后,大多数人会想改点什么。我一般建议从三个方向入手,按难度递增。第一个是加连接池,把DBUtil里的DriverManager换成 Druid,配置写在druid.properties里,getConnection从DruidDataSource取。改动量小,但能明显提升并发下的稳定性,也顺便理解了连接池的作用。第二个是抽公共 DAO 基类,把findAll、findById、update、delete这些重复的 JDBC 模板抽到BaseDao<T>里,用泛型和反射做结果集映射,子类只写 SQL 和实体类型。这个改造能让你真正体会 DAO 模式的价值,也是从“会写”到“会设计”的一步。

第三个方向是引入分页。图书列表数据一多,全量查询会拖慢页面。改造思路是在BookDao里加findByPage(int page, int size),SQL 用LIMIT ?, ?,Servlet 里接收page参数,计算总页数放进request,JSP 里渲染上一页/下一页链接。分页逻辑虽然简单,但涉及参数校验(页码越界、负数)、总条数查询、URL 参数拼接,是练习前后端配合的好场景。

改造点改动文件难度收益
引入 Druid 连接池DBUtil、新增配置文件低连接复用,避免频繁创建销毁
抽 BaseDao 泛型基类新增 BaseDao,改造各 DAO中减少重复代码,统一结果集映射
图书列表分页BookDao、BookListServlet、bookList.jsp中大数据量下页面响应更快
订单状态流转OrderService、后台订单页高支持待付款/已发货/已完成

改造时有个习惯我一直保持:每改一个点,先把原来的功能回归一遍。比如加了连接池,先确认登录、列表、下单三条链路都正常,再继续下一步。有次我图快,连接池配完没测下单,结果事务里拿不到同一个连接,订单数据错乱,排查了半天才发现是连接池配置的autoCommit默认值和手写事务冲突。从那以后我每次改数据层,都强制走一遍“登录→加购→下单→查库”的完整链路,确认事务和连接行为没变再往下做。希望这些拆解能帮你把这套网上书店顺利跑起来,也能在改的过程中看清 Java Web 最朴素的那层骨架。

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

RELATED READING

延伸阅读

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