简介面向Java Web入门与课程设计人群的jspservlet网上书店项目围绕MVC分层搭建图书展示、用户登录、图书搜索、分页浏览与购物车管理等典型电商流程。视图层由JSP渲染页面Servlet承担请求分发与业务控制DAO封装数据库访问并结合MySQL、JNDI数据源和Session会话管理帮助读者理解从页面交互到持久化操作的完整链路也可作为毕业设计或实训参考。压缩包共161个文件约768KB以27个java源文件、27个class编译文件、17个jsp页面为主另含gif/jpg界面素材、js脚本、xml与sql配置覆盖源码、页面资源及建库脚本便于直接导入调试。已有169人学习下载。项目将分页、购物车、登录过滤等知识点落到可运行代码中适合对照梳理分层结构、理解DAO与Servlet协作并借助现有页面素材快速搭建练习环境。1. 一套 JSPServletDAO 书店源码先看它能替你扛下哪几件事把 jspservletdao 网上书店 这套源码拉到本地第一眼看到的往往是登录页和图书列表但真正决定它能不能改成课程设计、能不能撑住答辩演示的是后面那条从 Servlet 到 DAO 的请求链路。它适合刚学完 Java Web、需要一套完整 CRUD 加会话加事务练手的人不适合想直接找电商级高并发方案的人。第 3 章会把建表和事务扣库存跑通第 5 章把最容易翻车的乱码、404、连接池问题逐条拆开。这套资源的重心不在页面炫技而在 MVC 分层JSP 负责展示Servlet 接参数和做跳转DAO 收口 SQLService 管事务边界。拿到源码后先别急着改 CSS先把数据库、连接参数和过滤器顺序对齐后面加购物车、加订单状态、加后台管理都只是在这条链路上挂模块。下面按“请求怎么走、数据怎么落、页面怎么渲染、哪里容易翻车”的顺序拆。2. 请求链路拆到方法级一次下单从 JSP 到 DAO 要经过几层这套源码最容易让人误判的地方是页面上点一下“结算”看起来只是一个按钮实际背后要经过过滤器、Servlet、Service、DAO、连接池和 JSP 五六个位置。任何一层参数对不上表现都可能是“没反应”或“500”所以先把一次下单请求拆到方法级后面排查才有坐标。常见做法是让 Servlet 只做参数接收和跳转Service 管事务DAO 只认 SQL 和 ConnectionJSP 只读 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 里写 SQLJSP 里写% Connection conn ... %在课程作业里能跑但一旦要加分页、加事务、加库存扣减问题会集中爆发连接忘了关、SQL 散落在页面里、同一个查询复制三份、事务边界说不清。DAO 的价值不是“多写一个接口”而是把 SQL 和连接管理收口到一个可替换的位置Service 只关心“扣库存成功没有”“订单写入没有”不关心底层是 MySQL 还是别的库。public interface BookDao { ListBook 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 ListBook 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()) { ListBook 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 groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency !-- JSP API 同样由容器提供 -- dependency groupIdjavax.servlet.jsp/groupId artifactIdjavax.servlet.jsp-api/artifactId version2.3.3/version scopeprovided/scope /dependency !-- JSTL 是页面标签库需要打进 war -- dependency groupIdjavax.servlet/groupId artifactIdjstl/artifactId version1.2/version /dependency !-- MySQL 驱动版本要和数据库服务端协议对齐 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency /dependencies这段依赖里scope是最容易被忽略的参数provided表示编译时需要、运行时由容器提供打成 war 后不会出现在WEB-INF/libJSTL 没有provided因为它不是 Tomcat 自带的。MySQL 驱动版本不建议随手拿最新驱动 8 的类名是com.mysql.cj.jdbc.DriverURL 里通常要带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支持中文和 emojiInnoDB支持事务和行锁这两项对后面扣库存很关键。金额字段用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 ) ENGINEInnoDB; CREATE TABLE category ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL UNIQUE ) ENGINEInnoDB; 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) ) ENGINEInnoDB; 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) ) ENGINEInnoDB; 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) ) ENGINEInnoDB; 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) ) ENGINEInnoDB;这段 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 ?useUnicodetruecharacterEncodingutf8 serverTimezoneAsia/ShanghaiuseSSLfalse); 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 3000maxWait太大页面会卡很久才报错validationQuery不配MySQL 8 小时空闲断连后第一次查询可能失败。serverTimezone不配在某些驱动版本下会报时区错误useSSLfalse只是本地演示省事正式环境不要照搬。注意数据库密码不要长期硬编码在DbUtil里。课程演示可以先用占位密码交付前改成jdbc.properties或容器数据源至少不要把生产密码提交到代码仓库。连接池参数不是越大越好。maxActive20对单机课程项目够用因为 Tomcat 默认线程池也不是无限大如果每个请求都长时间占用连接调到 100 只会把数据库压垮。排查连接泄漏时先看maxActive是否被打满再看 DAO 里有没有getConnection后没进try-with-resources的分支。3.3 DAO 接口与实现分页、模糊搜索、事务扣库存数据层最核心的一段是下单事务扣库存、写订单、写订单明细必须在一个Connection里完成任何一步失败都要rollback。下面这段OrderService把事务边界放在 ServiceDAO 只接收外部连接这样单元测试时也能换成 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来自登录 sessionbookId和quantity来自下单表单orderNo用时间戳拼前缀只是演示真实项目应换成序列号或雪花算法避免并发重复。setAutoCommit(false)之后decreaseStock、insertOrder、insertItem共用同一个conncatch里先rollback再抛出finally里恢复自动提交并归还连接。顺序不能反先close再rollback就回滚不了。分页查询放在 DAO 里做Servlet 只负责算pageNo和pageSize。常见做法是pageSize固定 10 或 12pageNo从 1 开始offset (pageNo - 1) * pageSize同时再查一次count算总页数。模糊搜索的keyword要做空值处理不要让null直接进like concat否则整条 SQL 返回空集。下面这张表是分页参数的最小约定照着传不会错位。参数来源示例注意pageNo请求参数page1小于 1 时归一到 1pageSize服务端常量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 xmlnshttp://xmlns.jcp.org/xml/ns/javaee xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd version4.0 display-namebookshop/display-name context-param param-nameappName/param-name param-valuebookshop/param-value /context-param servlet servlet-namebookListServlet/servlet-name servlet-classcom.example.bookshop.web.BookListServlet/servlet-class load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namebookListServlet/servlet-name url-pattern/books/url-pattern /servlet-mapping filter filter-nameencodingFilter/filter-name filter-classcom.example.bookshop.web.filter.EncodingFilter/filter-class /filter filter-mapping filter-nameencodingFilter/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 hrefhttps://download.csdn.net/download/lintao_66/2172841 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p