简介:面向Java Web入门者与课程实践需求的一套网上书店项目源码,围绕JSP、Servlet与DAO组织经典MVC分层,涵盖图书展示、用户登录、购物车维护、订单结算和分页浏览等电商核心流程,可用于对照理解视图渲染、请求控制与数据访问之间的协作关系,也适合作为课程设计或毕业设计原型。压缩包共161个文件,约768KB,内含java源文件、class编译文件、jsp页面、gif与jpg界面素材、js脚本、xml配置及sql建库脚本,源码、页面资源和部署配置相对完整,便于还原项目结构并梳理JNDI连接池、会话管理与分页查询的实现思路。已有169人学习,读者可以借助现有分层代码快速定位图书查询、购物车增删改与库存更新的处理链路,同时体会登录过滤与自定义分页标签的用法,减少从零搭建页面和调试基础功能的时间。
1. 先看清这套 JSP+Servlet+DAO 网上书店:三层结构到底解决什么问题
有人把一份书店源码丢过来,解压后是一堆.jsp和.java,双击index.jsp打开浏览器,满屏乱码加 404。这类 JSP+Servlet+DAO 网上书店项目的正确打开方式从来不是双击页面,而是扔进 Tomcat 跑起来。它本质是一套标准的 Java Web 三层单体应用:JSP 负责渲染,Servlet 接请求做流程控制,DAO 把 JDBC 操作收口,底下是图书、分类、用户、订单、订单明细五张表。功能覆盖面很实在——图书列表、分类筛选、关键字检索、分页、详情页、Session 购物车、下单结算,外加一个后台的图书增删改查。
这套东西适合谁?课程设计要交活的、刚学完 Servlet 想找完整例子跑通的、以及想拿一个能改的小型电商骨架练手的。它不追求高并发,追求的是分层清晰、代码能读懂、改一处不影响另一处。下面按「跑起来」和「改得动」两条线拆。
2. 架构与目录拆解:从 web.xml 到 DAO 接口的分层落地
拿到包先别急着导进 IDE,先看目录。这类项目的目录结构是最能说明设计意图的东西,一眼扫过去就能判断作者是老老实实分层,还是把 SQL 全塞进 JSP 里。老项目通常不是 Maven 结构,而是 Eclipse 时代的 Dynamic Web Project 布局,src和WebContent两个根并列,很多人第一次导入就栽在这。
2.1 目录清单与每一层负责什么
典型目录大致如下,不同作者命名略有出入,但骨架基本一致:
| 目录/文件 | 内容 | 职责 |
|---|---|---|
src/com/xxx/entity | Book、User、Order、OrderItem | 纯 POJO,与表字段一一对应 |
src/com/xxx/dao | BaseDao、BookDao、OrderDao | 只做 SQL 与结果集映射 |
src/com/xxx/service | BookService、OrderService | 事务边界、业务规则 |
src/com/xxx/servlet | 各*Servlet | 取参、校验、调 service、转发 |
src/com/xxx/filter | EncodingFilter、LoginFilter | 全局字符集、后台鉴权 |
src/com/xxx/util | DBUtil、PageBean | 连接管理、分页载体 |
WebContent/jsp | list.jsp、detail.jsp、cart.jsp | 只做展示,不写业务判断 |
WebContent/WEB-INF/lib | mysql-connector-java-*.jar | 驱动必须放这里,放外面必炸 |
WebContent/WEB-INF/web.xml | 欢迎页、过滤器、错误页 | 部署描述符 |
这张表里最容易被忽略的是WEB-INF/lib。很多人把 JDBC 驱动丢在项目根目录或者加到 IDE 的 Build Path 里,本地跑得好好的,一打成 war 部署到服务器就是ClassNotFoundException: com.mysql.cj.jdbc.Driver——这是血泪经验,驱动 jar 必须跟着 war 一起进WEB-INF/lib。
分层的核心约定只有两条:Servlet 里不出现SELECT,JSP 里不出现业务判断。听起来是废话,但改这类项目时你会发现,一半的维护成本来自有人破了这两条规矩。
2.2 web.xml 与注解:两套配置怎么共存
老项目用web.xml注册 Servlet,新一点的用@WebServlet注解。同一份代码里两种混着出现很常见,因为作者在不同阶段写的。共存没问题,但有个前提:web.xml的metadata-complete属性不能设成true,否则容器会忽略所有注解,你会看到一半 Servlet 访问 404。
<!-- web.xml 片段:只留必须手写的部分 --> <web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" version="3.1"> <!-- 没写 metadata-complete,注解才会生效 --> <welcome-file-list> <welcome-file>index.jsp</welcome-file> </welcome-file-list> <filter> <filter-name>EncodingFilter</filter-name> <filter-class>com.shop.filter.EncodingFilter</filter-class> </filter> <filter-mapping> <filter-name>EncodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> <error-page> <error-code>404</error-code> <location>/jsp/404.jsp</location> </error-page> </web-app>这段配置干三件事:指定欢迎页、把编码过滤器挂到/*上拦所有请求、给 404 兜个底。url-pattern写/*而不是*.do是有讲究的——中文乱码不只出现在 Servlet 请求里,JSP 里的静态输出也会受影响,全拦最省心。
注解那侧对应的是:
@WebServlet(name = "bookListServlet", urlPatterns = "/book/list") public class BookListServlet extends HttpServlet { /* ... */ }urlPatterns建议统一带模块前缀(/book/、/cart/、/admin/),别全用/list、/detail这种平铺路径。等你要加一个后台图书列表时,就会发现BookListServlet和AdminBookListServlet抢同一个/list路径,改起来很别扭。
2.3 实体类与 DBUtil:两个地基件
实体类没什么好说的,字段名和列名保持一致就行。真正的分歧点在连接管理。这类项目里DBUtil一般两种写法:一是每次DriverManager.getConnection()新开连接,二是走DataSource(Druid 或 C3P0)。教学向的项目九成是前者。
public class DBUtil { // 常见做法是把四项写进 jdbc.properties,这里内联只为看清结构 private static final String URL = "jdbc:mysql://localhost:3306/bookstore?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai"; private static final String USER = "root"; private static final String PWD = "123456"; static { try { Class.forName("com.mysql.cj.jdbc.Driver"); // MySQL 8 驱动类名 } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError("JDBC 驱动未加载,检查 WEB-INF/lib: " + e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PWD); } public static void close(Connection c, Statement s, ResultSet r) { // 顺序必须反着来:ResultSet -> Statement -> Connection try { if (r != null) r.close(); } catch (SQLException ignored) {} try { if (s != null) s.close(); } catch (SQLException ignored) {} try { if (c != null) c.close(); } catch (SQLException ignored) {} } }useUnicode和characterEncoding两个参数是中文入库乱码的第一道防线,serverTimezone不写会抛The server time zone value is unrecognized,这在 MySQL 8 上几乎是必踩。close方法把三个资源都包了 try-catch,看着啰嗦,但比在 DAO 里到处写finally干净得多。这种「每次新建连接」的写法在单机小流量下没问题,一旦并发上来就会撞Too many connections,后面第 6 章会讲怎么换掉它。
3. 核心链路实现:分页查询、购物车与下单的代码细节
书店看起来功能多,真正撑起整个项目的只有三条链路:图书列表分页、Session 购物车、下单事务。把这三条拆透,剩下的详情页、后台 CRUD 都是同一个套路的复制。
3.1 分页:PageBean 与两条 SQL
分页是这类项目里第一个真正需要设计的点。新手常见写法是把全表查出来扔进List,再在 JSP 里for循环截取——数据量一过千就卡成幻灯片。稳妥做法是让数据库分页,前端只拿当前页那 10 条。
public class PageBean<T> { private int pageNo = 1; // 当前页码,从 1 开始 private int pageSize = 8; // 每页条数 private int totalCount; // 总记录数 private List<T> list; // 当前页数据 public int getTotalPage() { // 向上取整,注意 totalCount 为 0 时至少给 1 页,否则前端分页条会消失 return totalCount == 0 ? 1 : (totalCount + pageSize - 1) / pageSize; } public int getStartIndex() { return (pageNo - 1) * pageSize; } // getter / setter 省略 }getTotalPage里那个totalCount == 0的判断是关键补丁。不加的话,搜索一个不存在的关键字时总页数是 0,JSP 里c:forEach begin="1" end="0"什么都不渲染,用户看到的是空白页,还以为系统挂了。
DAO 侧对应两条 SQL,一条查总数一条查数据:
public PageBean<Book> findByPage(int pageNo, int pageSize, String keyword) { PageBean<Book> pb = new PageBean<>(); pb.setPageNo(pageNo); pb.setPageSize(pageSize); String like = (keyword == null || keyword.isEmpty()) ? "%" : "%" + keyword + "%"; String countSql = "SELECT COUNT(*) FROM book WHERE title LIKE ? OR author LIKE ?"; String dataSql = "SELECT * FROM book WHERE title LIKE ? OR author LIKE ? " + "ORDER BY book_id DESC LIMIT ?, ?"; try (Connection conn = DBUtil.getConnection()) { // 1) 总数 try (PreparedStatement ps = conn.prepareStatement(countSql)) { ps.setString(1, like); ps.setString(2, like); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) pb.setTotalCount(rs.getInt(1)); } } // 2) 当前页数据,LIMIT 的两个参数必须是 int,不能包成字符串 try (PreparedStatement ps = conn.prepareStatement(dataSql)) { ps.setString(1, like); ps.setString(2, like); ps.setInt(3, pb.getStartIndex()); ps.setInt(4, pageSize); try (ResultSet rs = ps.executeQuery()) { List<Book> list = new ArrayList<>(); while (rs.next()) list.add(mapRow(rs)); pb.setList(list); } } } catch (SQLException e) { throw new RuntimeException("分页查询失败", e); } return pb; }mapRow单独抽出来做ResultSet到Book的映射,查详情、查后台列表都能复用,避免每个方法里复制一遍rs.getString("title")。LIMIT ?, ?两个占位符用setInt绑定,用setString在部分驱动上会拼成带引号的字符串,直接语法错误。
提示:
ORDER BY一定要带确定性字段(这里是book_id DESC)。只按价格或上架时间排序时,同值记录的返回顺序不稳定,翻页会出现同一条书在第二页又出现一次。
3.2 Servlet 层:一个列表请求的完整动作
Servlet 的活儿很固定:拿参数、做防御性处理、调 service、塞 request、转发。别在这一层写 SQL,也别在这层拼 HTML。
@WebServlet("/book/list") public class BookListServlet extends HttpServlet { private final BookService bookService = new BookServiceImpl(); @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 页码参数可能为空、非数字、负数,三种都要兜住 int pageNo = parsePageNo(req.getParameter("pageNo")); int categoryId = parseInt(req.getParameter("categoryId"), 0); String keyword = trim(req.getParameter("kw")); PageBean<Book> page = bookService.queryByPage(pageNo, 8, categoryId, keyword); req.setAttribute("page", page); req.setAttribute("categoryList", bookService.listAllCategories()); // 回填搜索条件,否则翻页后搜索框清空,用户得重新输一遍 req.setAttribute("kw", keyword); req.setAttribute("categoryId", categoryId); req.getRequestDispatcher("/jsp/book/list.jsp").forward(req, resp); } private int parsePageNo(String raw) { if (raw == null || raw.trim().isEmpty()) return 1; try { int n = Integer.parseInt(raw.trim()); return n < 1 ? 1 : n; } catch (NumberFormatException e) { return 1; // 手改 URL 传入 abc 时不抛 500,回第一页 } } }parsePageNo那几行看着冗余,实际是防 500 的。用户手改地址栏?pageNo=abc,不处理就是NumberFormatException直冲浏览器。更隐蔽的是pageNo超过总页数的情况——LIMIT越界不报错,只是返回空列表,页面显示「暂无图书」,看不出问题。稳妥做法是在 service 里拿到总数后把pageNo夹到[1, totalPage]区间内。
转发用forward不用redirect,因为request域里的page对象要带给 JSP。用sendRedirect会丢域数据,页面直接空。
3.3 购物车:Session 里的 LinkedHashMap
购物车的存储位置决定了整个项目的复杂度。放数据库要建cart表、处理登录态绑定、定时清理僵尸数据;放 Session 简单到几十行代码,代价是换浏览器购物车就没了,服务重启也丢。这类项目九成选 Session,是合理取舍。
public class Cart implements Serializable { // key = bookId,LinkedHashMap 保留加入顺序,购物车列表不会每次刷新都乱跳 private Map<Integer, CartItem> items = new LinkedHashMap<>(); public void add(Book book, int num) { if (num <= 0) num = 1; CartItem item = items.get(book.getBookId()); if (item == null) { items.put(book.getBookId(), new CartItem(book, num)); } else { item.setNum(item.getNum() + num); // 重复加同一本书只累加数量 } } public void update(int bookId, int num) { CartItem item = items.get(bookId); if (item == null) return; if (num <= 0) items.remove(bookId); // 数量改成 0 等价于删除 else item.setNum(num); } public void remove(int bookId) { items.remove(bookId); } public void clear() { items.clear(); } public double getTotal() { double sum = 0; for (CartItem i : items.values()) sum += i.getBook().getPrice() * i.getNum(); return Math.round(sum * 100) / 100.0; // 保留两位,避免 0.1+0.2 那种尾巴 } public int getCount() { return items.size(); } public Collection<CartItem> getItems() { return items.values(); } }Cart实现Serializable不是可有可无。Tomcat 重启或触发热部署时,容器会尝试钝化 Session,Cart不可序列化就会抛NotSerializableException,日志里刷一片。同理,CartItem里持有的Book也得可序列化。
加购的 Servlet 只做三件事:从 Session 取cart(没有就 new 一个放回去)、调add、回写到 Session。
@WebServlet("/cart/add") public class CartAddServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { int bookId = Integer.parseInt(req.getParameter("bookId")); int num = Integer.parseInt(req.getParameter("num")); Book book = bookService.getById(bookId); // 价格以数据库为准,不信前端传的 if (book == null) { resp.sendRedirect(req.getContextPath() + "/book/list"); return; } HttpSession session = req.getSession(); Cart cart = (Cart) session.getAttribute("cart"); if (cart == null) { cart = new Cart(); session.setAttribute("cart", cart); } cart.add(book, num); resp.sendRedirect(req.getContextPath() + "/cart/list"); // 重定向防刷新重复加购 } }价格必须从库里查,不能把页面上的price一起 POST 上来直接用。前端改个隐藏域就能一块钱买书,这是这类项目最典型的安全漏洞。重定向而不是转发,是为了避免用户按 F5 时浏览器重发 POST,购物车里数量翻倍。
3.4 下单:一个不能省的事务
下单是整个项目唯一必须上事务的地方:写orders主表、写order_item明细、扣book库存,三步任何一步失败都得整体回滚,否则就是「订单有了、库存没扣」或者「库存扣了、明细没写」的对不上账。
public String createOrder(int userId, Cart cart) { if (cart == null || cart.getItems().isEmpty()) throw new IllegalStateException("购物车为空"); Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 关掉自动提交,事务从这里开始 // 1) 主订单 String orderNo = System.currentTimeMillis() + "" + userId; // 简易订单号 double total = cart.getTotal(); String sql1 = "INSERT INTO orders(order_no, user_id, total, status, create_time) " + "VALUES(?,?,?,0,NOW())"; PreparedStatement ps1 = conn.prepareStatement(sql1, Statement.RETURN_GENERATED_KEYS); ps1.setString(1, orderNo); ps1.setInt(2, userId); ps1.setDouble(3, total); ps1.executeUpdate(); ResultSet keys = ps1.getGeneratedKeys(); int orderId = 0; if (keys.next()) orderId = keys.getInt(1); // 2) 明细 + 扣库存,库存不足直接抛异常整体回滚 String sql2 = "INSERT INTO order_item(order_id, book_id, price, num) VALUES(?,?,?,?)"; String sql3 = "UPDATE book SET stock = stock - ? WHERE book_id = ? AND stock >= ?"; for (CartItem item : cart.getItems()) { PreparedStatement ps2 = conn.prepareStatement(sql2); ps2.setInt(1, orderId); ps2.setInt(2, item.getBook().getBookId()); ps2.setDouble(3, item.getBook().getPrice()); ps2.setInt(4, item.getNum()); ps2.executeUpdate(); PreparedStatement ps3 = conn.prepareStatement(sql3); ps3.setInt(1, item.getNum()); ps3.setInt(2, item.getBook().getBookId()); ps3.setInt(3, item.getNum()); // 第三个 ? 是条件:库存必须够 if (ps3.executeUpdate() == 0) { throw new RuntimeException("库存不足:" + item.getBook().getTitle()); } } conn.commit(); cart.clear(); return orderNo; } catch (Exception e) { if (conn != null) try { conn.rollback(); } catch (SQLException ignored) {} throw new RuntimeException("下单失败", e); } finally { if (conn != null) try { conn.setAutoCommit(true); conn.close(); } catch (SQLException ignored) {} } }几个细节值得说。RETURN_GENERATED_KEYS用来拿自增主键,不拿的话明细表没法关联订单。扣库存的 SQL 用stock >= ?做条件更新,靠数据库的行锁保证并发下不会超卖,比先SELECT stock再判断再UPDATE靠谱得多——后者在两人同时下单时必然翻车。finally里恢复autoCommit再关闭,是因为连接如果来自池化实现,不恢复会把脏状态带给下一个使用者。
4. 环境与部署:Tomcat 与 MySQL 把项目跑起来
代码看懂了,跑不起来也白搭。这类项目的启动门槛不在代码,在环境版本对不上。
4.1 环境组合与数据库初始化
稳妥组合是 JDK 8 + Tomcat 8.5/9 + MySQL 5.7/8.0。JDK 太高(17+)会遇到javax.servlet相关问题,因为 Tomcat 10 起把包名换成了jakarta.servlet,老代码里import javax.servlet.*全部编译不过。判断方法很简单:看代码里import的是javax还是jakarta,前者只能上 Tomcat 9 及以下。
建库走一遍:
CREATE DATABASE bookstore DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE bookstore; CREATE TABLE book ( book_id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(120) NOT NULL, author VARCHAR(60), publisher VARCHAR(80), price DECIMAL(10,2) NOT NULL DEFAULT 0, stock INT NOT NULL DEFAULT 0, category_id INT, cover VARCHAR(200), description TEXT ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE orders ( order_id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(40) NOT NULL UNIQUE, user_id INT NOT NULL, total DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0, -- 0 待付款 1 已付款 2 已发货 3 已完成 create_time DATETIME NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;建库语句里utf8mb4别写成utf8。utf8在 MySQL 里是三字节的,存表情符号或生僻字会截断报错,改成四字节的utf8mb4一劳永逸。price用DECIMAL不用DOUBLE,金额用浮点迟早出现 0.01 的误差。InnoDB是必须的,MyISAM不支持事务,第 3 章那段下单逻辑直接失效。
4.2 改配置:三处路径与四行连接
导入项目后按顺序改三个地方。第一处是DBUtil或jdbc.properties里的 URL、用户名、密码,改成自己机器上的。第二处是项目 JDK 版本,IDE 里Project Structure和Java Compiler两处都要改成 8,只改一处会出现「代码没错但编译报错」。
第三处最隐蔽——Tomcat 的部署路径。Eclipse 里默认可能是wtpwebapps,在服务器上找不到。手动部署的话直接把项目打成 war 丢进webapps:
# 在项目根目录(有 WEB-INF 的那一层)打包,注意排除 IDE 配置 jar -cvf bookstore.war . # 拷到 Tomcat 并启动 cp bookstore.war $CATALINA_HOME/webapps/ $CATALINA_HOME/bin/startup.sh # 看日志确认没有异常,再访问 tail -f $CATALINA_HOME/logs/catalina.out用jar命令打包时,当前目录必须是WEB-INF的父目录,打成bookstore.war后解压出来直接就是WEB-INF、jsp、index.jsp。如果在上一层目录打包,war 里会多套一层文件夹,访问路径变成/bookstore/bookstore/,所有相对路径全崩。
访问地址是http://localhost:8080/bookstore/,bookstore是 war 包名,也就是上下文路径。改了 war 名,上下文就跟着变,代码里所有硬编码的"/bookstore/..."都得同步改——这也是为什么第 3 章的 Servlet 里统一用req.getContextPath()拼路径。
4.3 启动自检:四个检查点
启动后别急着点功能,按顺序过四关。
- 看 catalina.out 有没有
SEVERE。有ClassNotFoundException说明驱动没进WEB-INF/lib;有Access denied for user说明数据库账号密码不对。 - 访问首页。首页能出说明静态资源和 JSP 编译没问题;出 404 说明
welcome-file配的index.jsp不在根目录,或者 contextPath 写错了。 - 点一次图书列表。这一步同时验证 Servlet 映射、DAO 查询、JSON/JSP 渲染三段,能出数据说明主干通了。
- 加购一次再看购物车。验证 Session 是否正常,如果加完购物车还是空,八成是浏览器禁了 Cookie,或者
Cart没实现Serializable导致钝化丢失。
提示:第一次访问 JSP 会明显慢,因为要编译。这不是性能问题,别急着去优化。第二次访问还是慢,才需要看是不是每次都在重新编译(检查 JSP 文件时间戳有没有被工具反复改动)。
5. 常见问题排查:编码乱码、空指针与连接泄漏这些坑
跑通之后开始改功能,坑就来了。下面五条是我在不同机器上重复踩过的,每条都按「现象 → 原因 → 解决」写。
5.1 中文乱码:POST 好了 GET 还是乱
现象:表单提交的中文书名入库正常,但地址栏带参数的搜索?kw=java编程查不到结果,后台打印出来是java编程。
原因:request.setCharacterEncoding("UTF-8")只对请求体生效,管不了 URL 查询串。GET 参数的解码用的是容器Connector的URIEncoding,Tomcat 8 以后默认是 UTF-8,但老配置里被显式改成了 ISO-8859-1 就会出问题。还有一种情况是过滤器没生效,web.xml里filter-mapping写成了*.do而请求路径是/book/list。
解决:先确认过滤器url-pattern是/*;再看 Tomcatconf/server.xml的Connector有没有URIEncoding="UTF-8"。实在改不动容器,就在 Servlet 里对 GET 参数手动转一次new String(kw.getBytes("ISO-8859-1"), "UTF-8")——这是后悔药,不该长期留着,但应急有效。
5.2 空指针:一半的 500 都是它
现象:访问详情页抛NullPointerException,堆栈指向 Servlet 里某行book.getTitle()。
原因:bookService.getById(id)返回了null,代码没判空就往下走。触发场景通常是手改 URL 传了个不存在的bookId,或者数据库里那条记录被删了但列表页有缓存。
解决:所有「按 ID 查单条」的返回值必须判空,为空就resp.sendError(404)或重定向回列表页。更彻底的做法是在BaseDao里提供一个queryOne方法统一处理,别让每个 Servlet 各写一遍。
5.3 连接泄漏:跑一阵就 Too many connections
现象:本地测试一切正常,部署后过几小时所有请求都 500,日志里Too many connections。
原因:某个 DAO 方法在异常分支里没关连接。try-catch只 catch 了没 finally,或者finally里关连接的代码本身抛异常把后面的关闭跳过了。
解决:全部改成 try-with-resources,从语法层面杜绝漏关。改造时顺手查一下SHOW PROCESSLIST,能看到一堆Sleep状态的连接堆在那里,就是泄漏的铁证。真要上生产,DBUtil换成 Druid 连接池,加上removeAbandoned能兜住漏网的。
// 改造前:靠人记得关,迟早漏 Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql); // ... 异常一抛,下面全不执行 // 改造后:语法保证关闭,顺序自动反序 try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { // ... }5.4 静态资源 404:CSS 加载不出来页面裸奔
现象:页面能出内容,但样式全丢,F12 里一片 404,路径显示/bookstore/css/style.css请求到了别的地方。
原因:JSP 里写的是相对路径css/style.css,而当前页面 URL 是/bookstore/book/list,浏览器会按当前目录解析成/bookstore/book/css/style.css。列表页在第一层时正常,点进子路径就崩。
解决:JSP 里所有静态资源路径统一用${pageContext.request.contextPath}/css/style.css开头。这一步改完,页面从哪个层级访问都不会错。用<c:url>标签也行,效果一样。
5.5 重复提交:刷新一下多一笔订单
现象:下单成功后按 F5,订单列表里出现两条一模一样的记录。
原因:下单走的是 POST 转发到结果页,刷新时浏览器重发 POST。这是最经典的重复提交。
解决:下单成功后用sendRedirect跳到订单详情,别用forward。更严谨的做法是下单页生成一个一次性 token 放 Session,提交时校验并立即移除,重复提交时 token 对不上直接拦掉。这类项目用重定向就够,token 方案等真要接支付时再加。
6. 二次开发:把书店改成能承接真实业务的形态
跑通只是起点,多数人拿到这份代码是要改成自己的题目或者塞进真实场景。改之前先想清楚一件事:这套三层单体的价值不在功能多,在于每层边界清楚,所以改造应该是「换掉某一层的实现」,而不是推倒重写。
6.1 三个低风险高收益的改造点
第一个是把DBUtil里的DriverManager换成连接池。改动只在一个文件,DAO 层一行都不用动,因为对外暴露的还是getConnection()。
// Druid 版 DBUtil,方法签名完全不变,DAO 无需改动 private static final DataSource DS; static { DruidDataSource ds = new DruidDataSource(); ds.setUrl(URL); ds.setUsername(USER); ds.setPassword(PWD); ds.setInitialSize(5); ds.setMaxActive(20); ds.setMaxWait(3000); ds.setValidationQuery("SELECT 1"); DS = ds; } public static Connection getConnection() throws SQLException { return DS.getConnection(); }注意close()的语义变了——池化连接的close()是归还而不是真关,所以原来的DBUtil.close()方法可以留着不动,行为自动正确。
第二个是给 DAO 抽泛型基类。现在BookDao、OrderDao、UserDao里各写一遍增删改查,字段名一换就得改三处。抽一个BaseDao<T>,用反射拿表名和字段,通用 CRUD 一次写完。
第三个是把 JSP 里的 Java 小脚本清干净。老项目常见<% for(...) { %>混在 HTML 里,改一个字段要翻半天。统一换成 JSTL 的<c:forEach>和 EL 表达式,页面立刻可读。
| 改造点 | 改动范围 | 风险 | 收益 |
|---|---|---|---|
| 换连接池 | 1 个文件 | 低 | 解决并发下的连接耗尽 |
| DAO 泛型化 | dao 包 | 中 | 新增表时少写 80% 重复代码 |
| JSP 去脚本 | jsp 目录 | 低 | 页面可维护性直线上升 |
| 前后端分离 | 全局 | 高 | 不建议一步到位,先并行加接口 |
6.2 分阶段验证:别一次改完再测
改造最忌讳一口气全改完再启动。我的做法是每次只动一层,动完必跑一遍固定清单:首页 → 列表 → 搜索 → 详情 → 加购 → 改数量 → 下单 → 后台加一本书 → 后台删一本书。九步全过才算这一步完成。
比如换连接池那次,前八步都过了,唯独下单失败。查了半天发现是事务里conn.setAutoCommit(false)之后,池化连接的归还逻辑把未提交的事务直接回滚了——因为我漏了finally里的commit/rollback分支。这个 bug 在DriverManager版本里不会暴露,因为连接直接关了。这类差异只有跑一遍完整链路才能撞见。
再往远走一步,如果想加 REST 接口给小程序或前端用,不用重写项目:新开一个api包,Servlet 里resp.setContentType("application/json;charset=UTF-8"),复用现有 service,把PageBean序列化成 JSON 返回即可。JSP 那套原样保留,两套入口并行,改坏了随时能退回去。
我现在的习惯是:拿到任何一份这类老项目,先不改代码,先把完整链路手动点一遍并截图存档,改完再点一遍逐项对照。不看基线就动手,出问题时根本分不清是新引入的还是原本就有。希望这套拆法能帮你少走几段弯路。
本文还有配套的精品资源,点击获取