简介:这是一份基于JSP+Servlet+DAO模式的网上书店项目资料,面向正在学习Java Web开发的初学者或需要完成课程设计的学生,帮助理解如何从零搭建一个包含用户登录、图书检索、购物车与订单管理的电商后端系统。压缩包共161个文件,约768KB,其中27个Java源文件与27个class文件对应分层业务代码,17个JSP负责视图渲染,3个JS实现前端交互,64个GIF则直观展示运行效果。项目完整覆盖MVC分层架构、JNDI数据库连接池配置、SQL分页查询、Session购物车会话管理等关键知识点,并包含UserDao、BookDao、BookServlet等核心类,可对照源码学习DAO封装、Servlet请求处理与JSP页面传值逻辑。资源内还附带SQL数据库脚本和项目配置文件,方便导入数据库直接运行。已有162人学习下载,适合作为课程设计参考或Java Web入门实践,能够帮助读者快速掌握经典Java Web项目的开发脉络。
1. 网上书店项目:一个JSP+Servlet+DAO的完整JavaWeb闭环
当年课程设计拿到"网上书店"这个题目时,大多数同学第一反应是上Spring Boot加MyBatis,最后做出来一个连过滤器都不认识的黑盒子。而这个项目刻意绕开了框架,用最纯粹的JSP+Servlet+DAO三层写完整套购书流程:用户注册登录、图书分类浏览、关键词搜索、购物车、下单、订单列表,以及后台图书管理。它的价值在于,请求从JSP页面出发,经过Servlet路由,落到DAO访问MySQL,每一步都能在代码里看到。适合两类人:一是刚学完JavaWeb想找完整项目练手的人,二是需要快速跑通一个课程设计或毕设商城系统的人。
2. 架构设计与数据库建模:三层结构如何支撑书店业务
2.1 技术栈:JSP+Servlet+DAO的选型逻辑
在JavaWeb学习路径里,JSP+Servlet+DAO是很多人绕不开的一课。这个项目选择这套组合,不是因为它比Spring Boot先进,而是因为它把"视图-控制-数据"三条线暴露得最彻底。JSP负责把数据显示成页面,Servlet负责接收参数和决定下一步去哪,DAO负责把SQL翻译成Java对象。三者各管一摊,谁出问题就查谁,不像框架一上来先给你一堆自动装配,出错时都不知道黑匣子在哪。
这套组合对网上书店这种业务量不大的场景也足够。图书查询、用户登录、下单,本质就是几个CRUD加一个事务。用纯Servlet写,代码量虽然比MyBatis多一点,但每条SQL都肉眼可见。我一般建议学完Servlet的人,至少手写一个完整项目,再考虑上框架,否则连Spring MVC里Controller的@RequestMapping到底做了什么都没法真正理解。
这里顺便说下为什么不用纯JSP+JDBC。如果在JSP里直接写JDBC,表面上也能跑通,但页面会堆满Java脚本,改一个字段要在一堆HTML里找SQL,多人协作时基本没法维护。DAO层把这些数据库操作收口到专门的类里,页面只跟Java对象打交道。这个"收口"的思路,也是后面理解MyBatis Mapper的起点。
2.2 项目目录结构逐层拆解
拿到源码后,先看目录结构,基本能猜到项目怎么运转。这个项目的分层很规矩,典型的Maven Web项目结构如下:
src/main/java ├─ com.bookstore.dao │ ├─ BaseDao.java │ ├─ UserDao.java │ ├─ BookDao.java │ ├─ OrderDao.java │ └─ OrderItemDao.java ├─ com.bookstore.entity │ ├─ User.java │ ├─ Book.java │ ├─ Category.java │ ├─ Order.java │ └─ OrderItem.java ├─ com.bookstore.servlet │ ├─ UserServlet.java │ ├─ BookServlet.java │ ├─ CartServlet.java │ └─ OrderServlet.java └─ com.bookstore.util ├─ DBUtil.java └─ StringUtil.java src/main/webapp ├─ index.jsp ├─ login.jsp ├─ register.jsp ├─ book │ ├─ list.jsp │ └─ detail.jsp ├─ cart │ └─ cart.jsp ├─ order │ ├─ confirm.jsp │ └─ list.jsp └─ admin ├─ book_add.jsp └─ book_list.jspentity包对应数据库表,也可以叫model或bean,各字段和表字段一一对应。dao包负责数据访问,一个实体对应一个Dao。servlet包是唯一的请求入口,每个实体模块对应一个Servlet,通过action参数区分增删改查操作。util包放工具类,最核心的是DBUtil,负责创建数据库连接。
webapp下是JSP页面,按功能拆目录。需要注意,admin目录下的页面不能直接在浏览器地址栏访问,后面要通过过滤器做权限控制,否则就把后台管理暴露给所有人了。这个目录结构不是死标准,但如果你的项目要加功能,按这个套路加新实体类、新Dao、新Servlet、新JSP,四步就能接上。
2.3 数据库设计:用户、图书、分类、订单四类核心表
网上书店的数据库设计,核心是围绕"书"和"订单"展开。这个项目的表不多,但每一张都有作用。先看用户表:
CREATE TABLE `user` ( `id` int(11) NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL, `password` varchar(64) NOT NULL, `nickname` varchar(50) DEFAULT NULL, `phone` varchar(20) DEFAULT NULL, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;密码字段varchar(64)是为了存MD5/SHA散列值,不建议明文存。username加唯一索引,防止重复注册。create_time默认当前时间,减少Java代码里的set操作。
图书表需要携带分类信息:
CREATE TABLE `category` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(50) NOT NULL, `description` varchar(255) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `book` ( `id` int(11) NOT NULL AUTO_INCREMENT, `title` varchar(200) NOT NULL, `author` varchar(50) DEFAULT NULL, `publisher` varchar(100) DEFAULT NULL, `price` decimal(10,2) NOT NULL, `stock` int(11) NOT NULL DEFAULT 0, `category_id` int(11) DEFAULT NULL, `cover` varchar(255) DEFAULT NULL, `description` text, PRIMARY KEY (`id`), KEY `idx_category_id` (`category_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;price用decimal(10,2)而不是float/double,避免浮点误差。stock加default 0,防止新书没有库存。category_id建索引,因为列表页经常按分类过滤。
订单表负责记录一次购买行为:
CREATE TABLE `order` ( `id` int(11) NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL, `user_id` int(11) NOT NULL, `total_amount` decimal(10,2) NOT NULL, `status` tinyint(4) NOT NULL DEFAULT 0, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `order_item` ( `id` int(11) NOT NULL AUTO_INCREMENT, `order_id` int(11) NOT NULL, `book_id` int(11) NOT NULL, `book_title` varchar(200) NOT NULL, `price` decimal(10,2) NOT NULL, `quantity` int(11) NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;订单和订单项拆成两张表,是典型的一对多设计。订单项里冗余了book_title和price,这样下单之后即使图书被下架或改价,订单里的历史数据仍然能正确显示。status用tinyint,0表示待支付,1表示已支付,2表示已发货,3表示已完成,比用字符串省空间,改状态也方便。
3. 核心代码拆解:从用户点击到订单落库的完整链路
3.1 DAO层:连接管理与预编译查询
所有数据库操作都从DBUtil拿连接。先看数据库工具类:
package com.bookstore.util; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; 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 { // MySQL 5.x 用 com.mysql.jdbc.Driver,8.x 用 com.mysql.cj.jdbc.Driver Class.forName("com.mysql.jdbc.Driver"); } catch (ClassNotFoundException e) { e.printStackTrace(); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }URL里那三个参数是血泪经验。useUnicode和characterEncoding=utf8保证中文能正确写入和读出;serverTimezone=Asia/Shanghai是MySQL 8新增的时区要求,不加会报 "Server returns invalid timezone" 的错。USER和PASSWORD要改成你自己MySQL的账号密码,很多同学下载源码后直接跑,卡在这一步还以为是数据库没装。
然后看图书DAO里的分页查询。既然是网上书店,列表页不可能一次把全表查出来,分页是必须的:
public List<Book> findByPage(int pageNum, int pageSize) { List<Book> list = new ArrayList<>(); String sql = "SELECT id, title, author, publisher, price, stock, category_id " + "FROM book ORDER BY id DESC LIMIT ?, ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, (pageNum - 1) * pageSize); ps.setInt(2, pageSize); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { Book book = new Book(); book.setId(rs.getInt("id")); book.setTitle(rs.getString("title")); book.setAuthor(rs.getString("author")); book.setPrice(rs.getBigDecimal("price")); book.setStock(rs.getInt("stock")); book.setCategoryId(rs.getInt("category_id")); list.add(book); } } } catch (SQLException e) { e.printStackTrace(); } return list; }这里用PreparedStatement而不是Statement,好处有两个:一是SQL骨架预先编译,占位符用?表示,参数由setInt/setString注入,能挡住常见的SQL注入;二是代码更干净,不用拼字符串。分页的offset是(pageNum - 1) * pageSize,假如pageNum=1,pageSize=12,第一页从0开始取12条;第二页从12开始。这个逻辑可以在Servlet里由用户传入pageNum,但要加校验,防止传负数或非数字。
try-with-resources写法会在代码块结束后自动关闭Connection、PreparedStatement和ResultSet,不用手动在finally里写一堆close。注意某些旧项目的写法是单独把Connection提为成员变量,这在小Demo里没问题,但在并发下连接会被多个线程共用,容易出串数据的问题。
3.2 Servlet控制器:路由、参数校验与视图转发
Servlet是这个项目里唯一接收HTTP请求的地方。以图书模块为例:
@WebServlet("/book") public class BookServlet extends HttpServlet { private BookDao bookDao = new BookDao(); @Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding("UTF-8"); String action = request.getParameter("action"); if ("detail".equals(action)) { int id = Integer.parseInt(request.getParameter("id")); Book book = bookDao.findById(id); request.setAttribute("book", book); request.getRequestDispatcher("/book/detail.jsp").forward(request, response); } else if ("search".equals(action)) { String keyword = request.getParameter("keyword"); if (keyword == null) { keyword = ""; } List<Book> list = bookDao.search(keyword); request.setAttribute("bookList", list); request.getRequestDispatcher("/book/list.jsp").forward(request, response); } else { // 默认走分页列表 int pageNum = 1; String pageNumStr = request.getParameter("pageNum"); if (pageNumStr != null && pageNumStr.matches("\\d+")) { pageNum = Integer.parseInt(pageNumStr); if (pageNum < 1) { pageNum = 1; } } List<Book> list = bookDao.findByPage(pageNum, 12); request.setAttribute("bookList", list); request.getRequestDispatcher("/book/list.jsp").forward(request, response); } } @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { doGet(request, response); } }@WebServlet("/book")是Servlet 3.0之后提供的注解映射,等于在web.xml里配置一个servlet-mapping,路径是/book。这个类里只写了doGet,doPost直接调doGet,这样GET和POST请求都能进同一套逻辑,页面里不管是链接还是表单都能用。
forward是服务器内部跳转,浏览器地址栏不会变,request里的attribute在目标JSP能用。像登录成功后那种"跳转到新地址"的场景,应该用response.sendRedirect,否则刷新页面会重复提交表单。参数解析时我加了一步正则校验,避免用户手动访问/book?pageNum=abc导致NumberFormatException,这是纯Servlet项目最常见的翻车点之一。
action参数是这个项目的"路由器",一个模块的所有操作都走同一个Servlet,再按action分流。好处是类少、好找;缺点是需要自己在每个分支里处理参数,加新功能时记得在doGet里补一个else if分支。
3.3 购物车与下单:Session存储与订单事务
购物车是网上书店的关键一环。这个项目用Session存购物车,Map的key是图书id,value是数量:
@WebServlet("/cart") public class CartServlet extends HttpServlet { @SuppressWarnings("unchecked") private Map<Integer, Integer> getCart(HttpSession session) { Map<Integer, Integer> cart = (Map<Integer, Integer>) session.getAttribute("cart"); if (cart == null) { cart = new HashMap<>(); session.setAttribute("cart", cart); } return cart; } @Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding("UTF-8"); String action = request.getParameter("action"); Map<Integer, Integer> cart = getCart(request.getSession()); if ("add".equals(action)) { int bookId = Integer.parseInt(request.getParameter("bookId")); cart.put(bookId, cart.getOrDefault(bookId, 0) + 1); response.sendRedirect(request.getContextPath() + "/cart?action=view"); } else if ("view".equals(action)) { // 用bookId列表查出图书信息,放进request,转发到cart.jsp request.getRequestDispatcher("/cart/cart.jsp").forward(request, response); } else if ("clear".equals(action)) { cart.clear(); response.sendRedirect(request.getContextPath() + "/cart?action=view"); } } }Session里放购物车有个好处:用户关闭浏览器再打开,短时间内购物车还在,取决于Tomcat的session超时时间默认30分钟。缺点是换一台电脑就没了,真正的商城会把购物车落库。getOrDefault是Java 8的Map方法,书第一次加购物车时默认数量0再加1,第二次就是2,省掉了containsKey判断。
下单涉及写订单表、写订单明细、扣库存三件事,必须放在一个事务里。任何一步失败都要回滚,否则会出现"订单没生成但库存扣了"或者"订单生成了但库存没扣"。JDBC原生事务写法如下:
Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 关闭自动提交,开启事务 Order order = new Order(); order.setOrderNo(generateOrderNo()); // 生成唯一订单号 order.setUserId(userId); order.setTotalAmount(total); orderDao.insert(conn, order); // 传入连接,插入订单主表 int orderId = order.getId(); // 自增主键回填 for (CartItem item : cartItemList) { orderItemDao.insert(conn, orderId, item); // 插入订单明细 bookDao.decreaseStock(conn, item.getBookId(), item.getQuantity()); // 扣库存 } conn.commit(); // 全部成功才提交 } catch (Exception e) { if (conn != null) { conn.rollback(); // 任何异常都回滚 } e.printStackTrace(); } finally { if (conn != null) { try { conn.setAutoCommit(true); conn.close(); } catch (SQLException e) { e.printStackTrace(); } } }注意这里所有DAO方法都接收Connection参数,而不是自己另外getConnection,这样它们才能共享同一个事务。setAutoCommit(false)之后,只有执行到commit()时数据才真正落库。如果中途抛异常,rollback会撤销之前所有写操作。这个模式用起来很容易出错,所以我通常会在DAO里把insert和update方法都写成带连接参数的重载,避免一不小心又开了一个新连接。
4. 部署运行与避坑排查:从导入到跑通的完整指南
4.1 环境准备:JDK、Tomcat、MySQL的版本搭配
能跑起来比什么都重要。这个项目建议用以下组合:
- JDK 8:不要用JDK 17跑老项目,很多JSP编译问题都是JDK版本太高引发的。
- Tomcat 8.5 或 9.0:Tomcat 10开始用了Jakarta命名空间,老代码里的javax.servlet包全部要改成jakarta.servlet,不熟悉的人别碰。
- MySQL 5.7 或 8.0:5.7驱动是com.mysql.jdbc.Driver,8.0是com.mysql.cj.jdbc.Driver,选错了启动就报ClassNotFoundException。
- IDE:IDEA或Eclipse都行,IDEA需要确认能识别Web项目结构。
我有一次图省事直接上了JDK 17 + Tomcat 10,结果项目里所有javax.servlet导入全部标红,JSP也编译不了,最后老老实实退回JDK 8。这不是技术高低问题,是版本匹配的玄学,提前问清楚再动手。
4.2 部署步骤:导入、建库、启动、访问
整个流程可以拆成五步,每一步做完都有可验证的结果:
- 建库。打开MySQL命令行或Navicat,执行项目附带的bookstore.sql,生成数据库、表结构和初始数据。执行后确认book表里有测试数据,否则首页是空的,会误以为项目没跑通。
- 改配置。打开DBUtil.java,把USER和PASSWORD改成你的MySQL账号密码。如果MySQL是8.x,记得把Driver改成com.mysql.cj.jdbc.Driver。
- 导入项目。IDEA里Open或Import项目,选择项目根目录。如果项目没有Maven配置,导入后需要右键项目 Add Framework Support,勾选 Web Application,并确保WEB-INF/web.xml被识别。
- 配置Tomcat。在Run/Debug Configurations里新增Tomcat Server -> Local,Deployment里添加Artifact,Application context填/bookstore。启动控制台看到"Server startup in xxx ms"说明部署成功。
- 访问验证。浏览器打开http://localhost:8080/bookstore/,先注册一个新账号,再登录,进入图书列表,加购,下单。每一步都检查是否能正常跳转。
第4步的Application context是个容易忽略的细节。如果填了空,访问路径要写成http://localhost:8080/;如果填了/bookstore,就必须带上下文路径。项目里的重定向都用了getContextPath()动态拼接,就是怕这个不一致导致404。
4.3 高频踩坑记录:现象、原因、解决
下面这几条是跑这个项目最容易翻车的点,每条都按现象、原因、解决的方式整理:
现象1:启动后访问首页,JSP直接报500,提示"Unable to compile class for JSP"。 原因:Tomcat版本与JDK版本不匹配,或者项目缺少JSTL相关jar包。Tomcat 9不能直接在JDK 17下编译老JSP很正常。 解决:换成JDK 8 + Tomcat 8.5;如果JSP里用了c:forEach这类JSTL标签,把jstl.jar和standard.jar放进WEB-INF/lib,再右键Add as Library。
现象2:页面显示中文正常,但往数据库插中文变成问号或乱码。 原因:字符集链路没打通。JSP页面编码、请求编码、MySQL连接编码、表字段编码四层只要有一层是latin1就乱。 解决:JSP第一行确认pageEncoding="UTF-8";在Servlet过滤器里设置request.setCharacterEncoding("UTF-8")和response.setCharacterEncoding("UTF-8");JDBC URL加useUnicode=true&characterEncoding=utf8;建表SQL统一用utf8mb4。改完重启Tomcat,别只改一处。
现象3:启动时报"ClassNotFoundException: com.mysql.jdbc.Driver"。 原因:项目里用的是MySQL 8的驱动jar,但代码里写的还是旧的Driver类名;或者mysql-connector-java.jar没有打进Artifact。 解决:先看jar包版本。MySQL 8用com.mysql.cj.jdbc.Driver;MySQL 5.x用com.mysql.jdbc.Driver。IDEA里检查Project Structure -> Artifacts -> Output Layout,确认WEB-INF/lib下有这个jar。
现象4:访问/book或/book?action=detail报404。 原因:Servlet注解路径写错,或者Application context配置不对。也有可能是IDEA部署的Artifact没有包含刚编译的class。 解决:检查@WebServlet里的路径必须带斜杠,如"/book";浏览器地址应该是"项目上下文 + /book";到Tomcat的webapps目录看有没有你的项目文件夹,没有说明部署配置有问题,重新执行Rebuild Artifacts。
现象5:表单POST提交后,request.getParameter("username")拿到的是null,或者中文全是?。 原因:POST请求的body是UTF-8编码,但Servlet默认用ISO-8859-1解析,解析出来的字节就错乱了,null则往往是参数名和表单字段名不一致。 解决:在doPost第一行加request.setCharacterEncoding("UTF-8")。更规范的做法是写一个CharacterEncodingFilter,用@WebFilter("/*")拦截所有请求,统一设置编码,避免每个Servlet都重复写。
这五条覆盖了JSP、JDBC、Servlet、Web部署、字符集五个最容易出问题的地方。跑项目时遇到报错,先看控制台完整堆栈,再对照这条排查,大部分能直接定位。
5. 二次开发:分页、后台权限与订单导出的实战改造
这个项目跑通之后,往上加功能并不难,难点在于别把原来的结构打破。我常用的三个改造点,都不需要改数据库结构。
5.1 分页组件化
目前BookServlet里手工计算pageNum和pageSize,每多一个列表就要复制一遍。可以抽一个PageResult类,包含list、total、pageNum、pageSize,再提供一个totalPage计算属性。BookDao返回PageResult,JSP页面上用c:forEach循环bookList,用a标签拼接book?pageNum=${pageResult.nextPage}下一页。改完之后,订单列表、后台图书列表都能复用。
5.2 后台权限过滤
现在admin目录下的JSP可以直接访问,不安全。写一个AdminFilter:
@WebFilter("/admin/*") public class AdminFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpSession session = req.getSession(); User user = (User) session.getAttribute("loginUser"); if (user != null && "admin".equals(user.getUsername())) { chain.doFilter(request, response); } else { ((HttpServletResponse) response).sendRedirect(req.getContextPath() + "/login.jsp"); } } }这样非管理员访问/admin/下的任何页面都会跳回登录页。注意别用路径判断管理员身份,还要在数据库user表里把那个账号的username改成admin。
5.3 订单导出CSV
在OrderServlet里加一个export的action,设置response.setContentType("text/csv;charset=UTF-8"),把订单列表按行写入Writer。这是改造成本最低的"报表功能",导出的文件可以直接用Excel打开。
我在最开始做这个项目时,曾在下单事务里漏写rollback,测试时库存多扣了三件,最后靠手动改数据库才恢复。从那以后我每次改完下单流程,都强制走一遍"制造库存不足异常、确认订单没生成、库存没变"的验证。希望帮到你。
本文还有配套的精品资源,点击获取