☰
JSP+Servlet+DAO手搓网上书店:MVC三层架构与事务控制实战
2026/10/21 2:51:59 网站建设 项目流程

简介:这是一套面向Java Web初学者与课程设计者的完整网上书店项目源码,基于JSP+Servlet+DAO技术栈,采用经典MVC架构,适合用于毕业设计、课程实训或自学练手。压缩包共161个文件,约768KB,包含27个java源文件与27个class编译文件、17个jsp页面、64个gif及6个jpg图片资源,另有js、xml、sql脚本与tld标签库等配置,覆盖视图、控制、模型与数据库各层。项目实现了用户登录验证、图书搜索、分页浏览、购物车增删改与结算等核心功能,数据库连接借助JNDI管理连接池,分页通过SQL的LIMIT与OFFSET配合Servlet动态生成查询条件,购物车状态则依托Session跟踪。已有169人学习下载,读者可从中理清Servlet请求处理流程、DAO持久化写法与前后端交互细节,是理解Java Web开发全链路的实用参考。

1. 从零手搓 JSP+Servlet 网上书店:这套 MVC 骨架到底能跑通什么

如果你正在找一个能直接跑起来的 Java Web 练手项目,又不想被 Spring 全家桶的配置淹没,那这套 JSP+Servlet+DAO 的网上书店值得拆一遍。它没有花哨的前端框架,也没有复杂的微服务拆分,核心就是最原始的 Servlet 接收请求、JSP 渲染页面、DAO 层封装 JDBC 操作数据库。听起来像是“上古技术栈”,但恰恰因为原始,它把 HTTP 请求怎么进来、业务逻辑在哪一层处理、数据怎么落库这条链路暴露得清清楚楚。适合谁?刚学完 Servlet 和 JSP 语法、想找个完整项目把知识点串起来的学生,或者需要快速交付一个轻量级图书展示与下单系统的开发者。这套代码能解决的核心问题是:让你在不依赖任何重型框架的前提下,理解一个 Web 应用从浏览器点击到数据库读写的完整闭环。

2. 拆开 MVC 骨架:Servlet 当控制器、JSP 做视图、DAO 管数据

2.1 请求流转路径与三层职责划分

这套网上书店的代码结构是典型的 Model-View-Controller 变体。Servlet 充当控制器,负责接收前端表单提交或超链接传来的参数,调用对应的业务方法,然后把结果存进 request 或 session 域,最后转发到 JSP 页面。JSP 只负责把域里的数据取出来渲染成 HTML,不写任何 JDBC 代码。DAO 层则把对数据库的增删改查封装成独立方法,比如BookDao.findAll()、OrderDao.insertOrder(),Servlet 只跟 DAO 打交道,不直接碰 Connection。

我一般会先看 web.xml 或者注解配置,确认每个 Servlet 的映射路径。常见做法是用@WebServlet("/bookList")这种注解方式,比 XML 配置直观。请求进来后,Servlet 的doGet或doPost方法里第一件事是设置编码,然后拿参数,接着调 DAO,最后request.getRequestDispatcher("/book_list.jsp").forward(request, response)。这条链路走通一次,后面加功能就是复制粘贴改逻辑。

2.2 数据库表设计与实体类映射

网上书店至少需要四张表:用户表、图书表、订单表、订单明细表。图书表里放书名、作者、价格、库存、封面图路径;订单表记录谁在什么时候下了单、总金额多少;订单明细表存每笔订单里买了哪本书、买了几本。实体类跟表字段一一对应,比如Book类里有bookId、title、author、price、stock这些属性,getter/setter 生成好。

-- 图书表建表语句,字段类型按实际需求调整 CREATE TABLE book ( book_id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(200) NOT NULL, author VARCHAR(100), price DECIMAL(10,2) NOT NULL, stock INT DEFAULT 0, cover_img VARCHAR(255), category_id INT );

这段 SQL 里book_id设成自增主键,插入时不用手动赋值。price用DECIMAL(10,2)而不是FLOAT,避免金额计算出现浮点误差,这是血泪经验。stock默认 0,下单时先查库存再扣减,防止超卖。cover_img存相对路径,JSP 里用<img src="${pageContext.request.contextPath}/uploads/${book.coverImg}">拼出来。

2.3 DAO 层封装 JDBC 的通用写法

DAO 层最怕每个方法都写一遍Class.forName、DriverManager.getConnection、close。我一般会抽一个BaseDao工具类,把获取连接、释放资源的方法放进去,子类只关注 SQL 和参数设置。

// BaseDao 里封装通用查询与更新方法 public abstract class BaseDao { protected Connection getConn() throws SQLException { return DriverManager.getConnection( "jdbc:mysql://localhost:3306/bookstore?useSSL=false&serverTimezone=UTC", "root", "your_password"); } protected void close(Connection conn, PreparedStatement ps, ResultSet rs) { try { if (rs != null) rs.close(); } catch (Exception e) {} try { if (ps != null) ps.close(); } catch (Exception e) {} try { if (conn != null) conn.close(); } catch (Exception e) {} } }

getConn里的 URL 参数useSSL=false和serverTimezone=UTC是 MySQL 8 驱动必须带的,少了会报时区错误或 SSL 警告。close方法按 ResultSet、PreparedStatement、Connection 的顺序关闭,每个都包 try-catch,避免一个关失败影响后面。子类 DAO 里写String sql = "SELECT * FROM book WHERE category_id = ?",然后ps.setInt(1, categoryId),最后把 ResultSet 映射成 List 返回。

2.4 分页查询与购物车 Session 管理

图书列表页通常要分页,不能一次查全表。分页 SQL 用LIMIT ?, ?,第一个参数是偏移量(pageNo - 1) * pageSize,第二个是每页条数。Servlet 里接收pageNo参数,默认 1,算出偏移量传给 DAO,同时再查一次总记录数算出总页数,一起塞进 request 域给 JSP 渲染页码链接。

购物车我一般直接放 session 里,用Map<Integer, CartItem>存,key 是图书 ID,value 是包含图书信息和数量的对象。加购时判断 map 里有没有这个 key,有就数量加一,没有就新建 CartItem 放进去。JSP 页面上循环遍历 map 的 values 显示购物车列表。注意 session 里的购物车对象要实现Serializable接口,某些容器在重启时会序列化 session,不实现会报错。

3. 从登录到下单:一条完整业务链的代码落地

3.1 用户登录与 Session 拦截

登录 Servlet 接收用户名密码,调UserDao.findByUsername拿到 User 对象,比对密码。密码存库时建议用 MD5 或 SHA-256 加盐,别存明文。比对成功后session.setAttribute("currentUser", user),然后重定向到图书列表页。后续需要登录才能访问的页面,比如下单、查看订单,在 Servlet 开头判断session.getAttribute("currentUser")是否为空,为空就跳登录页。

// 登录校验核心逻辑 User user = userDao.findByUsername(username); if (user != null && user.getPassword().equals(md5(password + user.getSalt()))) { request.getSession().setAttribute("currentUser", user); response.sendRedirect("bookList"); } else { request.setAttribute("msg", "用户名或密码错误"); request.getRequestDispatcher("/login.jsp").forward(request, response); }

md5方法自己写个工具类,把密码和盐拼起来再哈希。sendRedirect和forward的区别要清楚:重定向是浏览器发新请求,地址栏变,forward 是服务器内部转发,地址栏不变。登录成功用重定向,防止刷新页面重复提交表单。

3.2 图书列表展示与条件查询

图书列表 Servlet 里先判断有没有搜索关键词参数,有就调BookDao.findByTitleLike(keyword),没有就调BookDao.findAllWithPage(pageNo, pageSize)。查出来的 List 放进 request 域,转发到book_list.jsp。JSP 里用 JSTL 的<c:forEach>遍历,每本书显示封面、书名、作者、价格和一个“加入购物车”链接。

<!-- book_list.jsp 里遍历图书的核心片段 --> <c:forEach items="${bookList}" var="book"> <div class="book-item"> <img src="${pageContext.request.contextPath}/uploads/${book.coverImg}" /> <h3>${book.title}</h3> <p>${book.author} 著</p> <span>¥${book.price}</span> <a href="addToCart?bookId=${book.bookId}">加入购物车</a> </div> </c:forEach>

${pageContext.request.contextPath}拿到项目部署路径,避免硬编码/bookstore这种上下文。addToCart链接带上bookId参数,Servlet 里request.getParameter("bookId")拿到后转成 int,再去 DAO 查完整图书信息放进购物车。

3.3 下单流程与事务控制

下单是整个项目里最需要小心的地方。用户点击“提交订单”后,Servlet 要做三件事:生成订单主记录、插入订单明细、扣减图书库存。这三步必须在一个数据库事务里完成,任何一步失败都要回滚。

// 下单事务的核心代码结构 Connection conn = null; try { conn = baseDao.getConn(); conn.setAutoCommit(false); // 开启事务 int orderId = orderDao.insertOrder(conn, order); // 插入订单主表 for (CartItem item : cartItems) { orderDao.insertOrderItem(conn, orderId, item); // 插入订单明细 bookDao.reduceStock(conn, item.getBookId(), item.getQuantity()); // 扣库存 } conn.commit(); // 全部成功才提交 } catch (Exception e) { if (conn != null) conn.rollback(); // 任何异常都回滚 throw e; } finally { baseDao.close(conn, null, null); }

注意这里 DAO 方法都多传了一个Connection参数,目的是让多个操作共用同一个连接,否则事务控制不住。setAutoCommit(false)之后,所有 SQL 执行完必须显式commit(),否则数据不会落库。rollback()放在 catch 里,保证出错时库存和订单数据一致。下单成功后清空 session 里的购物车,重定向到订单详情页。

3.4 订单查询与状态流转

订单查询分两种:用户查自己的订单,管理员查所有订单。用户查询时 SQL 带WHERE user_id = ?,管理员查询不带这个条件。订单状态一般有“待付款”“已付款”“已发货”“已完成”几种,用一个status字段存整型或字符串。状态流转通过单独的 Servlet 处理,比如发货 Servlet 接收orderId,调OrderDao.updateStatus(orderId, "已发货"),然后重定向回订单列表。

-- 订单表关键字段 CREATE TABLE orders ( order_id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, total_amount DECIMAL(10,2), status VARCHAR(20) DEFAULT '待付款', create_time DATETIME DEFAULT CURRENT_TIMESTAMP );

create_time用DEFAULT CURRENT_TIMESTAMP让数据库自动填时间,省得 Java 里再new Date()。status用字符串存可读性好,但查询效率不如整型,数据量大了可以改成 tinyint 映射枚举。

4. 部署与联调:Tomcat 配置、驱动版本与路径问题排查

4.1 Tomcat 部署方式与上下文路径

项目打包成 war 丢进 Tomcat 的 webapps 目录是最省事的部署方式。启动 Tomcat 后,访问http://localhost:8080/项目名/就能看到首页。项目名就是 war 包的名字,比如bookstore.war部署后上下文路径就是/bookstore。如果不想带项目名,可以把 war 包重命名为ROOT.war,但这样会覆盖 Tomcat 默认首页,一般不建议。

在 IDEA 里开发时,配置 Tomcat 运行环境要注意 Application context 设成/bookstore,跟 war 包名一致。JSP 里所有链接和表单 action 都用相对路径或者${pageContext.request.contextPath}开头,避免路径错乱。我见过太多因为路径写死/bookList导致部署后 404 的情况,加上上下文路径就解决了。

4.2 MySQL 驱动版本与连接池选择

MySQL 驱动版本要和数据库版本匹配。MySQL 5.7 用mysql-connector-java-5.1.x,MySQL 8.0 用mysql-connector-java-8.0.x。驱动类名也不一样:5.x 是com.mysql.jdbc.Driver,8.x 是com.mysql.cj.jdbc.Driver。用错了会报ClassNotFoundException或者连接被拒绝。

连接池方面,练手项目直接用DriverManager获取连接就够了,但每次请求都新建连接开销大。想稍微优化一下可以引入 Druid 或 HikariCP,配好initialSize、maxActive、validationQuery这几个参数。不过对于学习 MVC 分层来说,连接池不是重点,先把 DAO 层逻辑写清楚更重要。

4.3 中文乱码与静态资源过滤

中文乱码是 JSP+Servlet 项目的高频翻车点。POST 请求乱码在 Servlet 里request.setCharacterEncoding("UTF-8")解决,这行必须写在拿参数之前。GET 请求乱码要改 Tomcat 的server.xml,在 Connector 标签里加URIEncoding="UTF-8"。响应乱码用response.setContentType("text/html;charset=UTF-8")。

静态资源比如 CSS、图片、JS 文件,如果被 Servlet 的 url-pattern 拦截了,会返回 404 或者乱码。检查 web.xml 里有没有把/*映射到某个 Servlet,有的话改成*.do或者具体路径。Tomcat 默认的 DefaultServlet 会处理静态资源,别把它覆盖了。

5. 避坑与排查:那些让我加班到凌晨的报错

5.1 空指针异常:DAO 返回 null 没判断

现象:登录时输入正确用户名密码,页面报 500,控制台堆栈指向user.getPassword()这一行。原因:UserDao.findByUsername查不到记录时返回了 null,Servlet 里直接拿 null 调方法。解决:DAO 查询返回 null 是正常情况,调用方必须先判空。我一般在 Servlet 里写if (user == null) { 跳转错误页 },再继续比对密码。

5.2 事务未回滚:库存扣成负数

现象:下单时某本书库存只有 1 本,两个人同时下单,结果两个订单都成功,库存变成 -1。原因:扣库存的 SQL 是UPDATE book SET stock = stock - ? WHERE book_id = ?,没有加AND stock >= ?条件,也没有在事务里锁行。解决:SQL 改成UPDATE book SET stock = stock - ? WHERE book_id = ? AND stock >= ?,根据返回的受影响行数判断是否扣减成功,返回 0 就抛异常触发回滚。

5.3 JSP 页面取不到域对象

现象:Servlet 里request.setAttribute("bookList", list)明明放了数据,JSP 里${bookList}渲染出来是空白。原因:转发路径写错,或者 JSP 页面里用了sessionScope.bookList但数据实际在 request 域。解决:确认forward的目标 JSP 路径正确,确认 JSP 里用${bookList}而不是${sessionScope.bookList}。如果用了 JSTL 标签,检查 taglib 指令有没有引入。

5.4 数据库连接泄漏:Tomcat 跑一段时间就卡死

现象:项目刚启动正常,跑十几分钟后所有请求都超时,重启 Tomcat 又好了。原因:DAO 方法里某个分支提前 return 了,没有走到close方法,连接一直没归还。解决:把close放在 finally 块里,确保无论是否异常都执行。更彻底的做法是用 try-with-resources 语法,让编译器自动关资源。

5.5 表单重复提交:刷新页面多下了一单

现象:用户下单成功后按 F5 刷新,又生成了一笔相同订单。原因:下单 Servlet 处理完用forward转发到了订单页,浏览器地址栏还是下单请求的 URL,刷新就重发。解决:下单成功后用response.sendRedirect("orderList")重定向,让浏览器地址栏变成订单列表页,刷新也不会重复提交。

6. 进阶技巧:用 Filter 统一编码、用监听器统计在线人数

6.1 编码过滤器替代每个 Servlet 里的 setCharacterEncoding

每个 Servlet 都写一遍request.setCharacterEncoding("UTF-8")太啰嗦,而且容易漏。写一个 EncodingFilter 实现javax.servlet.Filter接口,在doFilter方法里统一设置请求和响应编码,然后在 web.xml 里配<url-pattern>/*</url-pattern>拦截所有请求。

// EncodingFilter 核心代码 public class EncodingFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; request.setCharacterEncoding("UTF-8"); response.setContentType("text/html;charset=UTF-8"); chain.doFilter(request, response); // 放行,交给下一个过滤器或 Servlet } }

chain.doFilter这行是关键,不写的话请求就被拦截了,后面的 Servlet 收不到。过滤器顺序按 web.xml 里<filter-mapping>的配置顺序执行,编码过滤器要放在最前面。

6.2 在线人数统计与 Session 监听

想统计当前在线人数,实现HttpSessionListener接口,在sessionCreated方法里给一个静态 AtomicInteger 加一,在sessionDestroyed里减一。把计数器存到 ServletContext 域里,JSP 页面用${applicationScope.onlineCount}显示。

// 在线人数监听器 public class OnlineCountListener implements HttpSessionListener { private static final AtomicInteger count = new AtomicInteger(0); public void sessionCreated(HttpSessionEvent se) { se.getSession().getServletContext().setAttribute("onlineCount", count.incrementAndGet()); } public void sessionDestroyed(HttpSessionEvent se) { se.getSession().getServletContext().setAttribute("onlineCount", count.decrementAndGet()); } }

AtomicInteger保证多线程环境下计数准确,别用普通 int。session 超时时间在 web.xml 里配<session-config><session-timeout>30</session-timeout></session-config>,单位是分钟。用户关闭浏览器后 session 不会立刻销毁,要等超时或者手动调session.invalidate()。

6.3 用 BaseServlet 减少 Servlet 数量

项目里如果每个功能都写一个 Servlet,类会爆炸。常见做法是写一个 BaseServlet 继承 HttpServlet,在service方法里根据请求参数中的method值反射调用子类的对应方法。子类只需要写public String bookList(...)这样的方法,返回 JSP 路径字符串,BaseServlet 统一做转发。

// BaseServlet 根据 method 参数分发请求 protected void service(HttpServletRequest req, HttpServletResponse resp) { String method = req.getParameter("method"); Method m = this.getClass().getMethod(method, HttpServletRequest.class, HttpServletResponse.class); String url = (String) m.invoke(this, req, resp); if (url != null) req.getRequestDispatcher(url).forward(req, resp); }

这样 BookServlet 里可以放bookList、bookDetail、addToCart多个方法,前端请求写成bookServlet?method=bookList就行。反射调用要注意异常处理,NoSuchMethodException和InvocationTargetException都要捕获,否则报错信息很难看。

从那以后我每次搭新项目,都会先把 BaseServlet、EncodingFilter、BaseDao 这三个基础类写好,再开始写业务代码。这套 JSP+Servlet+DAO 的骨架虽然老,但把 Web 开发最核心的请求响应模型、分层思想、事务控制都练到了。希望帮到你。

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

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

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

立即咨询