☰
JSP+Servlet+DAO网上书店源码实战:MVC请求链路与事务扣库存
2026/10/29 2:35:01 网站建设 项目流程

简介:面向Java Web入门与课程设计人群的jsp+servlet网上书店项目,围绕MVC分层搭建图书展示、用户登录、图书搜索、分页浏览与购物车管理等典型电商流程。视图层由JSP渲染页面,Servlet承担请求分发与业务控制,DAO封装数据库访问,并结合MySQL、JNDI数据源和Session会话管理,帮助读者理解从页面交互到持久化操作的完整链路,也可作为毕业设计或实训参考。压缩包共161个文件,约768KB,以27个java源文件、27个class编译文件、17个jsp页面为主,另含gif/jpg界面素材、js脚本、xml与sql配置,覆盖源码、页面资源及建库脚本,便于直接导入调试。已有169人学习下载。项目将分页、购物车、登录过滤等知识点落到可运行代码中,适合对照梳理分层结构、理解DAO与Servlet协作,并借助现有页面素材快速搭建练习环境。

1. 一套 JSP+Servlet+DAO 书店源码:先看它能替你扛下哪几件事

把 jsp+servlet+dao 网上书店 这套源码拉到本地,第一眼看到的往往是登录页和图书列表,但真正决定它能不能改成课程设计、能不能撑住答辩演示的,是后面那条从 Servlet 到 DAO 的请求链路。它适合刚学完 Java Web、需要一套完整 CRUD 加会话加事务练手的人;不适合想直接找电商级高并发方案的人。第 3 章会把建表和事务扣库存跑通,第 5 章把最容易翻车的乱码、404、连接池问题逐条拆开。

这套资源的重心不在页面炫技,而在 MVC 分层:JSP 负责展示,Servlet 接参数和做跳转,DAO 收口 SQL,Service 管事务边界。拿到源码后先别急着改 CSS,先把数据库、连接参数和过滤器顺序对齐,后面加购物车、加订单状态、加后台管理都只是在这条链路上挂模块。下面按“请求怎么走、数据怎么落、页面怎么渲染、哪里容易翻车”的顺序拆。

2. 请求链路拆到方法级:一次下单从 JSP 到 DAO 要经过几层

这套源码最容易让人误判的地方,是页面上点一下“结算”看起来只是一个按钮,实际背后要经过过滤器、Servlet、Service、DAO、连接池和 JSP 五六个位置。任何一层参数对不上,表现都可能是“没反应”或“500”,所以先把一次下单请求拆到方法级,后面排查才有坐标。常见做法是让 Servlet 只做参数接收和跳转,Service 管事务,DAO 只认 SQL 和 Connection,JSP 只读 request/session 里的属性。

2.1 一次下单请求在容器里怎么走

浏览器提交POST /order/create后,容器先按url-pattern找 Servlet,再按过滤器映射顺序执行 EncodingFilter 一类的前置逻辑。进入 Servlet 后从 session 取登录用户,从表单取bookId和quantity,调用 Service 创建订单,成功就重定向到订单详情,库存不足就转发回图书详情并带错误提示。这个过程中最忌讳前端传userId就直接用,正确做法是登录成功后把用户对象放进 session,下单时只认 session 里的 id。

@WebServlet("/order/create") public class OrderCreateServlet extends HttpServlet { private final OrderService orderService = new OrderService(); @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 用户身份只从 session 取,避免前端伪造 userId User user = (User) req.getSession().getAttribute("loginUser"); if (user == null) { resp.sendRedirect(req.getContextPath() + "/login.jsp"); return; } int bookId; int quantity; try { bookId = Integer.parseInt(req.getParameter("bookId")); quantity = Integer.parseInt(req.getParameter("quantity")); } catch (NumberFormatException e) { req.setAttribute("msg", "参数格式不正确"); req.getRequestDispatcher("/books").forward(req, resp); return; } try { // 事务在 Service 层收口,Servlet 不碰 Connection String orderNo = orderService.createOrder(user.getId(), bookId, quantity); req.getSession().setAttribute("lastOrderNo", orderNo); resp.sendRedirect(req.getContextPath() + "/order/detail?orderNo=" + orderNo); } catch (StockException e) { req.setAttribute("msg", "库存不足,请调整数量"); req.getRequestDispatcher("/book/detail?bookId=" + bookId).forward(req, resp); } catch (SQLException e) { throw new ServletException("创建订单失败", e); } } }

这段 Servlet 的关键点有三个:@WebServlet把路径固定为/order/create,避免和 JSP 页面路径混淆;req.getSession().getAttribute("loginUser")是登录态唯一来源;resp.sendRedirect之后必须return,否则继续往下写响应会报IllegalStateException。参数上,bookId和quantity来自表单,user.getId()来自会话对象,orderNo由 Service 生成后放进 session 只是为了详情页展示,不是业务主键。

真正容易漏的是异常分支:库存不足用自定义StockException转发回详情页,SQL 异常包装成ServletException交给容器日志。这样前端能看到可读提示,后端日志里又能保留堆栈。很多源码把try/catch写成空块,页面只显示“系统繁忙”,排查时等于黑匣子,这一步不要省。

2.2 为什么用 DAO 而不是在 JSP 里写 SQL

JSP 里写<% Connection conn = ... %>在课程作业里能跑,但一旦要加分页、加事务、加库存扣减,问题会集中爆发:连接忘了关、SQL 散落在页面里、同一个查询复制三份、事务边界说不清。DAO 的价值不是“多写一个接口”,而是把 SQL 和连接管理收口到一个可替换的位置,Service 只关心“扣库存成功没有”“订单写入没有”,不关心底层是 MySQL 还是别的库。

public interface BookDao { List<Book> findByPage(String keyword, int offset, int pageSize) throws SQLException; int count(String keyword) throws SQLException; int decreaseStock(Connection conn, int bookId, int quantity) throws SQLException; Book findById(Connection conn, int bookId) throws SQLException; } public class JdbcBookDao implements BookDao { @Override public List<Book> findByPage(String keyword, int offset, int pageSize) throws SQLException { String sql = "select id,title,author,price,stock from book " + "where (? = '' or title like concat('%',?,'%')) " + "order by id desc limit ?,?"; try (Connection conn = DbUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { String kw = keyword == null ? "" : keyword.trim(); ps.setString(1, kw); ps.setString(2, kw); ps.setInt(3, offset); ps.setInt(4, pageSize); try (ResultSet rs = ps.executeQuery()) { List<Book> list = new ArrayList<>(); while (rs.next()) { Book b = new Book(); b.setId(rs.getInt("id")); b.setTitle(rs.getString("title")); b.setAuthor(rs.getString("author")); b.setPrice(rs.getBigDecimal("price")); b.setStock(rs.getInt("stock")); list.add(b); } return list; } } } @Override public int decreaseStock(Connection conn, int bookId, int quantity) throws SQLException { // 条件更新:库存不足时 affected 为 0,由 Service 决定回滚 String sql = "update book set stock = stock - ? where id = ? and stock >= ?"; try (PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, quantity); ps.setInt(2, bookId); ps.setInt(3, quantity); return ps.executeUpdate(); } } }

findByPage的四个参数分别是关键字、偏移量、每页条数和返回列表,offset由(pageNo - 1) * pageSize算出,不要直接把页码塞进limit。decreaseStock特意接收外部Connection,因为它要和订单写入共用一个事务;如果这里自己DbUtil.getConnection(),扣库存和写订单就会落在两个连接上,回滚时只能回滚一半。

另一个细节是where (? = '' or title like ...)这种写法在数据量大时不利于索引,但作为课程设计够用。真正要优化时改成动态拼条件,或者把空关键字分支单独走一条 SQL,不要为了“一条 SQL 打天下”把可读性搭进去。DAO 接口保留throws SQLException也比在 DAO 里吞异常好,Service 才能根据异常决定回滚。

2.3 目录与依赖基线:JDK、Tomcat、MySQL 驱动怎么对齐

拿到源码先看WEB-INF/lib和pom.xml,不要先改代码。常见翻车不是业务写错,而是 Servlet API 被打进 war、MySQL 驱动没进WEB-INF/lib、Tomcat 10 用jakarta.servlet但代码里还是javax.servlet。下面这份依赖基线是这类项目最常见的组合,核心原则是容器提供的 API 用provided,要打进 war 的驱动和 JSTL 用默认作用域。

<dependencies> <!-- Servlet API 由 Tomcat 提供,打进 war 会和容器类冲突 --> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <!-- JSP API 同样由容器提供 --> <dependency> <groupId>javax.servlet.jsp</groupId> <artifactId>javax.servlet.jsp-api</artifactId> <version>2.3.3</version> <scope>provided</scope> </dependency> <!-- JSTL 是页面标签库,需要打进 war --> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> <!-- MySQL 驱动版本要和数据库服务端协议对齐 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> </dependencies>

这段依赖里,scope是最容易被忽略的参数:provided表示编译时需要、运行时由容器提供,打成 war 后不会出现在WEB-INF/lib;JSTL 没有provided,因为它不是 Tomcat 自带的。MySQL 驱动版本不建议随手拿最新,驱动 8 的类名是com.mysql.cj.jdbc.Driver,URL 里通常要带serverTimezone,老代码如果还写com.mysql.jdbc.Driver,启动时就会报ClassNotFoundException或驱动加载警告。

容器版本也要先确认。Tomcat 9 及更早版本用javax.servlet命名空间,Tomcat 10 以后迁到jakarta.servlet,包名一换,web.xml命名空间、Servlet 注解、JSP 标签库坐标都要跟着换。下面这张表用来快速对照,不要在同一个项目里混用两套包名。

容器代际Servlet 包名web.xml 命名空间常见问题
Tomcat 9 及以前javax.servlet.*http://xmlns.jcp.org/xml/ns/javaee驱动、JSTL 坐标按 javax 选
Tomcat 10 及以后jakarta.servlet.*https://jakarta.ee/xml/ns/jakartaee旧 JSTL 1.2 不能直接用,需换 jakarta 版本

提示:先把容器版本、JDK 版本、MySQL 驱动版本三行写在项目 README 里,后面换机器部署时少走很多回头路。

3. 数据库与 DAO 先跑通:6 张表、3 个连接参数和 1 个事务边界

页面能打开不代表数据层对。这套书店源码的业务闭环是“图书列表 → 加购物车 → 下单 → 扣库存 → 写订单”,对应至少 6 张表:用户、分类、图书、购物车项、订单、订单明细。先把建表脚本按顺序跑通,再调 DAO,最后接 Servlet,顺序反过来会出现“页面有数据但下单 500”的假象。下面按表结构、连接池、事务三个层次落地。

3.1 书店库表设计与字段约束

建表时先定字符集和存储引擎:utf8mb4支持中文和 emoji,InnoDB支持事务和行锁,这两项对后面扣库存很关键。金额字段用DECIMAL(10,2),不要用FLOAT或DOUBLE;订单明细要保存书名和价格快照,不能只存book_id,否则图书改价后历史订单金额会跟着变。下面这份 SQL 是这套源码能跑通的最小表结构,字段名和 DAO 里的列名一一对应。

CREATE DATABASE IF NOT EXISTS bookshop DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE bookshop; CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password_hash VARCHAR(100) NOT NULL, nickname VARCHAR(50) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB; CREATE TABLE category ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL UNIQUE ) ENGINE=InnoDB; CREATE TABLE book ( id INT PRIMARY KEY AUTO_INCREMENT, category_id INT NOT NULL, title VARCHAR(200) NOT NULL, author VARCHAR(100) NOT NULL, price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0, cover_url VARCHAR(255) DEFAULT NULL, status TINYINT NOT NULL DEFAULT 1, CONSTRAINT fk_book_category FOREIGN KEY (category_id) REFERENCES category(id), CONSTRAINT ck_book_stock CHECK (stock >= 0) ) ENGINE=InnoDB; CREATE TABLE cart_item ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, book_id INT NOT NULL, quantity INT NOT NULL DEFAULT 1, UNIQUE KEY uk_cart_user_book (user_id, book_id), CONSTRAINT fk_cart_user FOREIGN KEY (user_id) REFERENCES user(id), CONSTRAINT fk_cart_book FOREIGN KEY (book_id) REFERENCES book(id) ) ENGINE=InnoDB; CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, user_id INT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'CREATED', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_order_user FOREIGN KEY (user_id) REFERENCES user(id) ) ENGINE=InnoDB; CREATE TABLE order_item ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, book_id INT NOT NULL, title_snapshot VARCHAR(200) NOT NULL, price_snapshot DECIMAL(10,2) NOT NULL, quantity INT NOT NULL, CONSTRAINT fk_item_order FOREIGN KEY (order_id) REFERENCES orders(id), CONSTRAINT fk_item_book FOREIGN KEY (book_id) REFERENCES book(id) ) ENGINE=InnoDB;

这段 SQL 的参数重点不在语法,而在约束:uk_cart_user_book保证同一用户同一本书在购物车里只有一条记录,加购时用insert ... on duplicate key update quantity = quantity + ?就能避免重复行;ck_book_stock在 MySQL 8 会真正生效,5.7 会忽略,所以扣库存仍然要靠where stock >= ?兜底;order_item里的title_snapshot和price_snapshot是历史快照,不要省。

外键约束对课程设计是加分项,但调试时也会带来顺序问题:必须先插category再插book,先插user再插cart_item。如果导入测试数据报Cannot add or update a child row,先检查插入顺序,不要急着删外键。测试数据可以先用一条分类、三本书、一个演示用户,够跑通列表和下单即可。

3.2 JDBC 工具类与连接池参数

很多源码的DbUtil是每次DriverManager.getConnection,本机演示没问题,一压测就暴露连接创建开销和泄漏。更稳的做法是接一个连接池,Druid 或 Hikari 都行,核心是maxActive、maxWait、validationQuery这三个参数要能解释清楚。下面的DbUtil用静态块初始化连接池,对外只暴露getConnection(),DAO 里拿到的连接用完close()就是归还池子,不是真正断开。

public final class DbUtil { private static final DataSource DS; static { DruidDataSource ds = new DruidDataSource(); // useUnicode 和 characterEncoding 解决中文入库乱码 ds.setUrl("jdbc:mysql://127.0.0.1:3306/bookshop" + "?useUnicode=true&characterEncoding=utf8" + "&serverTimezone=Asia/Shanghai&useSSL=false"); ds.setUsername("bookshop"); ds.setPassword("change_me"); ds.setDriverClassName("com.mysql.cj.jdbc.Driver"); ds.setInitialSize(5); // 启动时先建 5 个连接 ds.setMinIdle(5); // 空闲连接少于 5 个时补建 ds.setMaxActive(20); // 同时最多 20 个连接 ds.setMaxWait(3000); // 拿不到连接最多等 3 秒 ds.setValidationQuery("select 1"); ds.setTestWhileIdle(true); DS = ds; } public static Connection getConnection() throws SQLException { return DS.getConnection(); } private DbUtil() { } }

参数含义要能对上现象:initialSize太小,第一次访问会慢半拍;maxActive太小,并发一高就出现wait millis 3000;maxWait太大,页面会卡很久才报错;validationQuery不配,MySQL 8 小时空闲断连后第一次查询可能失败。serverTimezone不配在某些驱动版本下会报时区错误,useSSL=false只是本地演示省事,正式环境不要照搬。

注意:数据库密码不要长期硬编码在DbUtil里。课程演示可以先用占位密码,交付前改成jdbc.properties或容器数据源,至少不要把生产密码提交到代码仓库。

连接池参数不是越大越好。maxActive=20对单机课程项目够用,因为 Tomcat 默认线程池也不是无限大;如果每个请求都长时间占用连接,调到 100 只会把数据库压垮。排查连接泄漏时先看maxActive是否被打满,再看 DAO 里有没有getConnection后没进try-with-resources的分支。

3.3 DAO 接口与实现:分页、模糊搜索、事务扣库存

数据层最核心的一段是下单事务:扣库存、写订单、写订单明细必须在一个Connection里完成,任何一步失败都要rollback。下面这段OrderService把事务边界放在 Service,DAO 只接收外部连接,这样单元测试时也能换成 mock 连接。decreaseStock返回受影响行数,等于 0 就抛库存不足,不要先查库存再更新,那样在并发下会超卖。

public class OrderService { private final BookDao bookDao = new JdbcBookDao(); private final OrderDao orderDao = new JdbcOrderDao(); public String createOrder(int userId, int bookId, int quantity) throws SQLException, StockException { if (quantity <= 0) { throw new IllegalArgumentException("quantity must be positive"); } Connection conn = null; try { conn = DbUtil.getConnection(); conn.setAutoCommit(false); // 事务起点 // 条件更新,库存不足时 affected 为 0 int affected = bookDao.decreaseStock(conn, bookId, quantity); if (affected == 0) { throw new StockException("库存不足"); } Book book = bookDao.findById(conn, bookId); BigDecimal total = book.getPrice().multiply(BigDecimal.valueOf(quantity)); String orderNo = "BK" + System.currentTimeMillis(); int orderId = orderDao.insertOrder(conn, orderNo, userId, total); orderDao.insertItem(conn, orderId, book, quantity); conn.commit(); return orderNo; } catch (SQLException | StockException e) { if (conn != null) { conn.rollback(); // 扣库存成功但写订单失败时,把库存加回去 } throw e; } finally { if (conn != null) { conn.setAutoCommit(true); conn.close(); // 连接池里 close 是归还,不是断开 } } } }

这段代码的参数关系是:userId来自登录 session,bookId和quantity来自下单表单,orderNo用时间戳拼前缀只是演示,真实项目应换成序列号或雪花算法避免并发重复。setAutoCommit(false)之后,decreaseStock、insertOrder、insertItem共用同一个conn;catch里先rollback再抛出,finally里恢复自动提交并归还连接。顺序不能反,先close再rollback就回滚不了。

分页查询放在 DAO 里做,Servlet 只负责算pageNo和pageSize。常见做法是pageSize固定 10 或 12,pageNo从 1 开始,offset = (pageNo - 1) * pageSize;同时再查一次count算总页数。模糊搜索的keyword要做空值处理,不要让null直接进like concat,否则整条 SQL 返回空集。下面这张表是分页参数的最小约定,照着传不会错位。

参数来源示例注意
pageNo请求参数page1小于 1 时归一到 1
pageSize服务端常量10不要由前端随意放大
offset(pageNo - 1) * pageSize0用int时注意超大页码溢出
keyword请求参数kwJava空串走全量查询

4. Servlet 与 JSP 落地:路由映射、会话作用域和 4 个过滤器位置

数据层跑通后,接下来是把 Servlet 路由、session 登录态和 JSP 渲染接上。这部分最容易出现“代码没错但页面 404”“登录了但购物车是空的”“JSP 里一写 Java 脚本就编译报错”。核心原则是:Servlet 只做控制层,JSP 只做展示层,session 只存身份和临时状态,过滤器按职责排序。下面按映射、会话、渲染三个点落地。

4.1 用 web.xml 还是注解:Servlet 映射的两种写法

这套源码可能同时存在web.xml和@WebServlet,两种写法都能用,但不能对同一个 Servlet 重复映射,否则启动时可能报冲突。web.xml的好处是映射集中、改路径不用重编译;注解的好处是 Servlet 类旁边就能看到路径,适合小项目。下面的web.xml片段把图书列表、编码过滤器和上下文参数放在一起,load-on-startup设为 1 表示容器启动时就初始化,第一次访问不用再等类加载。

<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd" version="4.0"> <display-name>bookshop</display-name> <context-param> <param-name>appName</param-name> <param-value>bookshop</param-value> </context-param> <servlet> <servlet-name>bookListServlet</servlet-name> <servlet-class>com.example.bookshop.web.BookListServlet</servlet-class> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>bookListServlet</servlet-name> <url-pattern>/books</url-pattern> </servlet-mapping> <filter> <filter-name>encodingFilter</filter-name> <filter-class>com.example.bookshop.web.filter.EncodingFilter</filter-class> </filter> <filter-mapping> <filter-name>encodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> </web-app>

参数上,url-pattern写/books是精确匹配,写/book/*是前缀匹配,写*.do是扩展名匹配,三种不要混着猜。load-on-startup的数字越小越先初始化,只影响启动时机,不影响访问路径。过滤器的执行顺序按filter-mapping出现顺序来,编码过滤器要排在权限过滤器前面,否则读参数时编码还没设,后面再设也来不及。

如果改用注解,写法是@WebServlet(name = "bookListServlet", urlPatterns = "/books", loadOnStartup = 1),效果和上面 XML 等价。混用时先检查web.xml里有没有同名servlet-name,再检查注解的urlPatterns有没有和 JSP 路径撞车。一个常见翻车是 JSP 放在WEB-INF/jsp下,Servlet 转发路径写成/WEB-INF/jsp/list.jsp是对的,但浏览器直接访问这个路径会 404,这是预期行为,不是映射错。

4.2 登录会话与购物车:HttpSession 的边界

登录成功后把User放进 session 是最常见的做法,但 session 里不要存密码、不要存整个购物车明细的大对象。购物车可以分两种:未登录时放 session 做临时车,登录后合并到cart_item表;或者干脆登录后才能加购,逻辑更简单。无论哪种,session 超时时间要显式设置,登录成功后调用changeSessionId()换一个会话 id,降低会话固定风险。

@WebServlet <p> <a href="https://download.csdn.net/download/lintao_66/2172841" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询