☰
J2EE书城源码实战:Servlet+JSP+JDBC分层解析与二次开发改造
2026/11/6 2:06:10 网站建设 项目流程

简介:这是一份面向Java Web初学者与课程设计者的J2EE实战项目源码,以「云工厂-网上书城」为主题,完整呈现从用户界面到后台数据库的在线图书销售流程,适合用于毕业设计参考、框架学习与二次开发练习。压缩包共588个文件,约4.09MB,其中83个java源文件与82个class文件对应Servlet、DAO、JavaBean等业务逻辑,45个jsp页面承担前端展示,另有gif、jpg、css等静态资源及jar依赖、sql脚本与db数据库文件,构成可部署运行的完整工程。项目覆盖Servlet请求处理、JSP动态页面、JDBC数据库交互、JavaBeans数据封装与EJB分层设计等知识点,内容预览中可见BookInfoDAO、SaveOrderServlet、GwcServlet等类,便于读者梳理购物车、订单与图书信息管理的实现脉络。目前已有242人学习下载,适合按模块研读并对照部署到Tomcat等容器中调试。

1. 从一份 J2EE 书城源码说起:它到底能跑出什么

第一次拿到「云工厂-网上书城」这份源码,我下意识先翻的是 web.xml 和 lib 目录,而不是 README——因为一个 J2EE 项目能不能跑起来,八成答案都藏在这两个地方。翻完我的判断是:这是一套典型的 Servlet + JSP + JDBC 分层书城系统,前台做图书浏览、搜索、购物车、下单,后台做图书与订单管理,数据库用 MySQL,构建走 Maven 或手动导 jar,容器丢进 Tomcat 就能起。它适合两类人:一类是刚学完 Servlet 生命周期、想找一个「有完整业务闭环」的项目练手的同学;另一类是接手了老系统、需要摸清 J2EE 分层套路再动手改造的开发者。别指望它有什么微服务、缓存中间件,它的价值恰恰在于「朴素」——每一层都摊开给你看,改起来不玄学。

2. 架构与目录拆解:一个请求从 JSP 到 DAO 的完整链路

2.1 分层结构与技术选型为什么是这样

这套书城走的是经典 MVC 三层:JSP 当 View,Servlet 当 Controller,Service + DAO 当 Model 层。技术栈上没有花活,Servlet 3.x、JSP 2.x、JDBC 直连 MySQL、JSTL 标签库渲染列表,前端是原生 HTML/CSS/JS 加少量 jQuery。为什么不用 Spring?因为这类教学向项目的目标是「让你看清请求怎么流转」,一旦上了 Spring 的 IOC 和 AOP,新手很容易只记住注解、不知道底下发生了什么。我一般会把这种项目当成「Servlet 容器行为观察器」来用:一个/book/list请求进来,先过过滤器做编码和登录校验,再进 Servlet 拿参数、调 Service、Service 调 DAO 查库、封装成 List 塞进 request 域,最后 forward 到 JSP 用 JSTL 遍历输出。整条链路没有黑匣子,断点打在哪一层都清楚。

分层职责上,Servlet 只做参数接收和页面跳转,不写 SQL;Service 负责业务组合,比如下单时要同时扣库存、写订单主表、写订单明细,这三步必须在一个事务里;DAO 只做单表 CRUD,返回实体对象。这种划分在真实项目里也站得住脚,只是真实项目会把 Service 拆得更细、DAO 换成 MyBatis 或 JPA。理解了这个骨架,后面换任何 ORM 都是换零件,不是换发动机。

2.2 目录结构长什么样

源码解压后大致是标准 Maven Web 工程布局,我按实际会看到的形态列一下关键路径:

网上书城/ ├── pom.xml # 依赖与打包配置 ├── src/main/java/ │ ├── com/book/controller/ # Servlet 控制器 │ ├── com/book/service/ # 业务逻辑 │ ├── com/book/dao/ # 数据访问 │ ├── com/book/entity/ # 实体类(Book/User/Order...) │ └── com/book/util/ # DBUtil、编码工具等 ├── src/main/resources/ │ └── db.properties # 数据库连接参数 ├── src/main/webapp/ │ ├── WEB-INF/web.xml # Servlet 映射与过滤器配置 │ ├── WEB-INF/views/ # JSP 页面 │ ├── css/ js/ images/ # 静态资源 │ └── index.jsp # 入口页 └── sql/bookstore.sql # 建库建表与初始数据

看到sql/bookstore.sql就说明作者把初始化脚本一起给了,这是好事——省得你对着实体类猜表结构。db.properties单独抽出来也是合格做法,至少改数据库密码不用翻 Java 代码。如果你的解压包里没有pom.xml而是lib/下一堆 jar,那说明是手动管理依赖的老式工程,导入 IDE 时按「Existing Projects into Workspace」走,别硬套 Maven 结构。

2.3 请求链路的关键代码

先看一个最典型的图书列表 Servlet,这是理解整条链路的入口:

// BookListServlet.java —— 图书列表控制器 @WebServlet("/book/list") public class BookListServlet extends HttpServlet { private BookService bookService = new BookServiceImpl(); @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 1. 接收分页与搜索参数,做空值兜底 String keyword = req.getParameter("keyword"); int page = parseInt(req.getParameter("page"), 1); int size = 8; // 2. 调 Service 拿分页结果,Service 内部再调 DAO PageResult<Book> result = bookService.search(keyword, page, size); // 3. 结果塞进 request 域,forward 给 JSP 渲染 req.setAttribute("pageResult", result); req.setAttribute("keyword", keyword); req.getRequestDispatcher("/WEB-INF/views/book_list.jsp") .forward(req, resp); } }

这段代码有三个点值得说。第一,@WebServlet注解替代了 web.xml 里的<servlet-mapping>,Servlet 3.0 以后都这么写,但如果你在 web.xml 里也配了同名映射,会冲突启动失败,这是新手常翻的车。第二,parseInt兜底很关键,page参数被用户手动改成abc时不能直接抛 NumberFormatException,得回落到第 1 页。第三,JSP 放在WEB-INF/views/下,意味着不能通过 URL 直接访问,必须经 Servlet forward,这是防止用户绕过控制器直接看页面的基本手段。

再看 DAO 层怎么把 JDBC 封装得不那么难看:

// BookDaoImpl.java —— 分页查询实现 public PageResult<Book> findByKeyword(String keyword, int page, int size) { String sql = "SELECT * FROM book WHERE name LIKE ? ORDER BY id DESC LIMIT ?, ?"; List<Book> list = new ArrayList<>(); int total = countByKeyword(keyword); // 单独查总数,供分页组件用 try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, "%" + (keyword == null ? "" : keyword) + "%"); ps.setInt(2, (page - 1) * size); // 偏移量 = (页码-1)*每页条数 ps.setInt(3, size); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { list.add(mapRow(rs)); // 结果集到实体的手工映射 } } } catch (SQLException e) { throw new RuntimeException("查询图书失败", e); } return new PageResult<>(list, total, page, size); }

参数说明:keyword为空时拼成%%等价于全量查询;page从 1 开始,偏移量公式(page-1)*size是最容易写错的地方,写成page*size就会漏掉第一页数据。try-with-resources保证连接和语句自动关闭,但注意DBUtil.getConnection()如果每次新建连接而不走连接池,并发一上来就会打满 MySQL 的 max_connections,这是这类项目最典型的性能短板,后面改造章节会讲怎么补。

2.4 数据库与配置

db.properties一般长这样,改成本机参数即可:

jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/bookstore?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai jdbc.username=root jdbc.password=你的密码

注意serverTimezone这个参数,MySQL 8 的驱动不配它会报时区异常,这是血泪经验。DBUtil里用Class.forName加载驱动、DriverManager.getConnection拿连接,属于最原始的写法,能跑但不够好,改造时优先换成 Druid 或 HikariCP。

3. 本地跑通:从建库到 Tomcat 起服务的实操步骤

3.1 环境准备与建库

先把环境对齐:JDK 8 或 11(别上 17,老项目的 JSP 编译容易出兼容问题)、Tomcat 8.5 或 9、MySQL 5.7 或 8、Maven 3.6+。版本对不上是启动失败的头号原因,我见过用 Tomcat 10 跑这套代码直接 404 的——因为 Tomcat 10 把javax.servlet换成了jakarta.servlet,包名全变,老代码一个都加载不了。

建库步骤很直接,进 MySQL 命令行执行初始化脚本:

mysql -u root -p -e "CREATE DATABASE bookstore DEFAULT CHARSET utf8mb4;" mysql -u root -p bookstore < sql/bookstore.sql

执行完用SHOW TABLES;确认 book、user、orders、order_item 这几张表都在。如果脚本里带了CREATE DATABASE语句,第一条命令可以省掉,但重复执行会报库已存在,忽略即可。

3.2 改配置与打包部署

把db.properties里的账号密码改成你本机的,然后走 Maven 打包:

mvn clean package -DskipTests

产物在target/下,是个 war 包。把它丢进 Tomcat 的webapps/目录,启动bin/startup.sh(Windows 用startup.bat),访问http://localhost:8080/网上书城/或按 war 包名访问。如果 IDE 里直接跑,IDEA 配一个 Tomcat Server 运行配置,Deployment 里加 Artifact 即可,改代码热部署更方便。

启动后先看日志有没有ClassNotFoundException: com.mysql.cj.jdbc.Driver,有就是驱动 jar 没进WEB-INF/lib;再看有没有Access denied for user,那是密码没改对。这两类错误占了启动失败的绝大多数。

3.3 功能自检清单

跑起来不等于跑对,按这条路径走一遍能覆盖主要业务:首页图书列表是否分页正常 → 搜索框输入关键词是否过滤 → 点详情进图书页 → 加入购物车 → 未登录时点结算是否跳登录 → 注册登录 → 提交订单 → 后台管理页能否看到新订单。每一步都对应一个 Servlet,哪一步 500 了就去那层的日志里找堆栈,定位效率比盲猜高得多。

提示:首次启动如果页面样式全丢,检查静态资源路径是不是写死的绝对路径,部署到带 contextPath 的环境下要改成${pageContext.request.contextPath}/css/...。

4. 核心模块拆解:购物车、下单与登录校验怎么写的

4.1 购物车为什么放 Session

这套项目的购物车没有落库,而是存在HttpSession里,结构是Map<Integer, CartItem>,key 是图书 id,value 是数量加小计。这么做的好处是未登录用户也能加购,实现成本低;代价是换浏览器或清 session 就丢,且服务端重启(非持久化 session 配置下)也会清空。教学项目这么选合理,真实电商肯定要落库或走 Redis。

// CartServlet.java —— 加入购物车 protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { int bookId = Integer.parseInt(req.getParameter("bookId")); int num = parseInt(req.getParameter("num"), 1); HttpSession session = req.getSession(); Map<Integer, CartItem> cart = (Map<Integer, CartItem>) session.getAttribute("cart"); if (cart == null) { cart = new LinkedHashMap<>(); // 保持加入顺序,展示更自然 session.setAttribute("cart", cart); } CartItem item = cart.get(bookId); if (item == null) { Book book = bookService.findById(bookId); // 查库拿价格和库存 cart.put(bookId, new CartItem(book, num)); } else { item.setNum(item.getNum() + num); // 已存在则累加 } resp.sendRedirect(req.getContextPath() + "/cart/list"); }

关键点在LinkedHashMap和重定向。用LinkedHashMap是为了购物车列表按加入顺序展示,用HashMap顺序会乱;末尾用sendRedirect而不是 forward,是为了避免用户刷新页面重复提交(PRG 模式),这是表单提交的标配做法,漏了就会出现「刷新一次多一件」的经典 bug。

4.2 下单事务:三步操作必须绑在一起

下单是整个项目里唯一必须用事务的地方:扣库存、写订单主表、写订单明细,任何一步失败都得回滚,否则会出现「库存扣了订单没生成」的对账灾难。

// OrderServiceImpl.java —— 创建订单 public int createOrder(int userId, Map<Integer, CartItem> cart) { Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 关闭自动提交,开启事务 int orderId = orderDao.insertOrder(conn, userId, totalAmount(cart)); for (CartItem item : cart.values()) { // 扣库存时带 stock >= num 条件,防止超卖 int affected = bookDao.reduceStock(conn, item.getBook().getId(), item.getNum()); if (affected == 0) { throw new RuntimeException("库存不足:" + item.getBook().getName()); } orderDao.insertItem(conn, orderId, item); } conn.commit(); // 全部成功才提交 return orderId; } catch (Exception e) { if (conn != null) conn.rollback(); // 任一失败整体回滚 throw new RuntimeException("下单失败", e); } finally { DBUtil.close(conn); } }

参数与逻辑说明:conn.setAutoCommit(false)是事务开关,忘了这行后面 commit/rollback 全是摆设。扣库存的 SQL 写成UPDATE book SET stock = stock - ? WHERE id = ? AND stock >= ?,把库存判断放进 WHERE 里,靠数据库行锁保证并发下不超卖——这是不用悲观锁也能挡住大部分超卖场景的实用写法。affected == 0说明库存不够,主动抛异常触发回滚。注意这里 DAO 方法都接收同一个conn参数,保证三步在同一个连接同一个事务里,如果每个 DAO 自己getConnection,事务就形同虚设,这是新手最容易犯的错。

4.3 登录校验过滤器

后台管理和下单接口都需要登录态,逐个 Servlet 判断太啰嗦,标准做法是写一个 Filter:

// AuthFilter.java —— 登录拦截 @WebFilter(urlPatterns = {"/admin/*", "/order/*"}) public class AuthFilter implements Filter { public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; Object user = req.getSession().getAttribute("loginUser"); if (user == null) { resp.sendRedirect(req.getContextPath() + "/login.jsp"); return; // 不放行,请求到此为止 } chain.doFilter(request, response); // 已登录,继续走后续链路 } }

urlPatterns里/admin/*覆盖后台全部路径,/order/*覆盖订单相关,比在每个 Servlet 里写 if 判断干净得多。注意return之后不能再调chain.doFilter,否则拦截等于没拦。过滤器顺序如果和编码过滤器冲突,在 web.xml 里用<filter-mapping>的声明顺序控制,注解方式下顺序不保证,这点要留意。

5. 避坑与排查:启动、乱码、事务失效的踩坑记录

5.1 启动报 404 或 ClassNotFound

现象:Tomcat 起来了,访问首页 404,或日志里ClassNotFoundException。原因通常是两类:一是 Tomcat 10 的 jakarta 包名不兼容老代码;二是 Maven 依赖没打进 war 的WEB-INF/lib,常见于 scope 写成provided的驱动包。解决:换 Tomcat 8.5/9,检查 pom 里 mysql-connector 的 scope 是否为 runtime 或默认,必要时手动把 jar 放进 lib 目录验证。

5.2 中文乱码三连

现象:表单提交到后台变问号、数据库里存进去是乱码、页面输出乱码。原因分别是请求编码、数据库连接编码、响应编码三处没统一。解决:加一个CharacterEncodingFilter设request.setCharacterEncoding("UTF-8"),JDBC URL 带characterEncoding=utf8,JSP 页头写<%@ page contentType="text/html;charset=UTF-8" %>,建库用 utf8mb4。三处缺一处都会翻车,别只改一处就以为好了。

5.3 事务不回滚

现象:下单时库存不足抛了异常,但库存还是被扣了。原因多半是 DAO 各自拿连接,或者异常被 catch 后没重新抛出,事务管理器感知不到失败。解决:确认 Service 层统一持有 Connection 并传给 DAO,catch 里必须rollback且把异常往外抛,别吞掉。

5.4 分页数据错位

现象:第二页显示的是第一页的后半段,或最后一页空白。原因就是偏移量公式写错,或者总数查询和列表查询的 WHERE 条件不一致。解决:偏移量统一用(page-1)*size,count SQL 和 list SQL 的过滤条件保持完全一致,最好抽成同一个条件拼接方法。

5.5 静态资源 404

现象:页面能开但 CSS/JS 全丢。原因是路径写成了/css/style.css这种根路径,部署在非根 contextPath 下就找不到。解决:全部改成${pageContext.request.contextPath}/css/style.css,或在 JSP 顶部用<c:set var="ctx" value="${pageContext.request.contextPath}"/>统一引用。

6. 二次开发改造:连接池、ORM 替换与接口化的落地思路

拿到这套代码想往真实项目靠,我建议按「先稳后快」的顺序改,别一上来就推翻重写。第一步补连接池,这是投入产出比最高的改造:引入 Druid 或 HikariCP,把DBUtil.getConnection内部换成从DataSource取连接,外部调用方一行不用改。改完压一下首页列表,并发 50 的情况下响应时间通常能从秒级降到百毫秒级,因为省掉了每次建连的 TCP 握手和认证开销。

第二步把 JDBC 手工映射换成 MyBatis。这套项目的 DAO 里全是mapRow手工赋值,字段一多就是体力活且容易漏。替换路径是:保留 Service 层不动,把 DAO 接口化,用 MyBatis 的 Mapper XML 承接 SQL,SqlSession的生命周期交给工具类管理。改造时注意事务边界要从「手动传 Connection」迁移到「SqlSession 提交/回滚」,别两套事务机制混用,否则会出现一半提交一半回滚的诡异状态。

第三步做接口化,为前后端分离留口子。现在页面靠 JSP 渲染,改造时可以把 Servlet 的 forward 换成 JSON 输出,引入 Jackson 序列化,前端另起一个静态页调接口。这一步不用全量做,先挑图书列表和购物车两个模块试点,验证通了再铺开。下面是一个最小改造示例:

// 把原来的 forward 改成 JSON 响应 resp.setContentType("application/json;charset=UTF-8"); ObjectMapper mapper = new ObjectMapper(); Map<String, Object> data = new HashMap<>(); data.put("code", 0); data.put("data", result.getList()); data.put("total", result.getTotal()); resp.getWriter().write(mapper.writeValueAsString(data));

验证方法很朴素:改造一个模块就跑一遍第 3 章那份功能自检清单,确保老功能没退化,再对比改造前后的 SQL 执行次数和响应时间。我一般会在 DAO 层加一个简单的计时拦截,把每条 SQL 的耗时打到日志里,改造前后各跑一轮,数据说话比感觉靠谱。

最后说个习惯。早年我改这类老项目,总想着一次性重构到位,结果改到一半新旧逻辑打架,回滚都回不干净。从那以后我每次动这种分层项目,都强制走一遍「先加测试入口、再小步替换、每步可回滚」的流程,哪怕只是换个连接池也先备份一份能跑的 war 包。这套书城代码不复杂,但正因为不复杂,才适合拿来练这种改造节奏——把它跑通、改顺,再去看那些框架封装的东西,心里就有底了。希望帮到你。

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

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

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

立即咨询