简介面向Java Web入门者与课程实践需求的一套网上书店项目源码围绕JSP、Servlet与DAO组织经典MVC分层涵盖图书展示、用户登录、购物车维护、订单结算和分页浏览等电商核心流程可用于对照理解视图渲染、请求控制与数据访问之间的协作关系也适合作为课程设计或毕业设计原型。压缩包共161个文件约768KB内含java源文件、class编译文件、jsp页面、gif与jpg界面素材、js脚本、xml配置及sql建库脚本源码、页面资源和部署配置相对完整便于还原项目结构并梳理JNDI连接池、会话管理与分页查询的实现思路。已有169人学习读者可以借助现有分层代码快速定位图书查询、购物车增删改与库存更新的处理链路同时体会登录过滤与自定义分页标签的用法减少从零搭建页面和调试基础功能的时间。1. 先看清这套 JSPServletDAO 网上书店三层结构到底解决什么问题有人把一份书店源码丢过来解压后是一堆.jsp和.java双击index.jsp打开浏览器满屏乱码加 404。这类 JSPServletDAO 网上书店项目的正确打开方式从来不是双击页面而是扔进 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/entityBook、User、Order、OrderItem纯 POJO与表字段一一对应src/com/xxx/daoBaseDao、BookDao、OrderDao只做 SQL 与结果集映射src/com/xxx/serviceBookService、OrderService事务边界、业务规则src/com/xxx/servlet各*Servlet取参、校验、调 service、转发src/com/xxx/filterEncodingFilter、LoginFilter全局字符集、后台鉴权src/com/xxx/utilDBUtil、PageBean连接管理、分页载体WebContent/jsplist.jsp、detail.jsp、cart.jsp只做展示不写业务判断WebContent/WEB-INF/libmysql-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 里不出现SELECTJSP 里不出现业务判断。听起来是废话但改这类项目时你会发现一半的维护成本来自有人破了这两条规矩。2.2 web.xml 与注解两套配置怎么共存老项目用web.xml注册 Servlet新一点的用WebServlet注解。同一份代码里两种混着出现很常见因为作者在不同阶段写的。共存没问题但有个前提web.xml的metadata-complete属性不能设成true否则容器会忽略所有注解你会看到一半 Servlet 访问 404。!-- web.xml 片段只留必须手写的部分 -- web-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee version3.1 !-- 没写 metadata-complete注解才会生效 -- welcome-file-list welcome-fileindex.jsp/welcome-file /welcome-file-list filter filter-nameEncodingFilter/filter-name filter-classcom.shop.filter.EncodingFilter/filter-class /filter filter-mapping filter-nameEncodingFilter/filter-name url-pattern/*/url-pattern /filter-mapping error-page error-code404/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()新开连接二是走DataSourceDruid 或 C3P0。教学向的项目九成是前者。public class DBUtil { // 常见做法是把四项写进 jdbc.properties这里内联只为看清结构 private static final String URL jdbc:mysql://localhost:3306/bookstore?useUnicodetruecharacterEncodingUTF-8serverTimezoneAsia/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 PageBeanT { private int pageNo 1; // 当前页码从 1 开始 private int pageSize 8; // 每页条数 private int totalCount; // 总记录数 private ListT 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的判断是关键补丁。不加的话搜索一个不存在的关键字时总页数是 0JSP 里c:forEach begin1 end0什么都不渲染用户看到的是空白页还以为系统挂了。DAO 侧对应两条 SQL一条查总数一条查数据public PageBeanBook findByPage(int pageNo, int pageSize, String keyword) { PageBeanBook 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()) { ListBook 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)); PageBeanBook 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 的。用户手改地址栏?pageNoabc不处理就是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 bookIdLinkedHashMap 保留加入顺序购物车列表不会每次刷新都乱跳 private MapInteger, 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.10.2 那种尾巴 } public int getCount() { return items.size(); } public CollectionCartItem getItems() { return items.values(); } }Cart实现Serializable不是可有可无。Tomcat 重启或触发热部署时容器会尝试钝化 SessionCart不可序列化就会抛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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;建库语句里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 还是乱现象表单提交的中文书名入库正常但地址栏带参数的搜索?kwjava编程查不到结果后台打印出来是java编程。原因request.setCharacterEncoding(UTF-8)只对请求体生效管不了 URL 查询串。GET 参数的解码用的是容器Connector的URIEncodingTomcat 8 以后默认是 UTF-8但老配置里被显式改成了 ISO-8859-1 就会出问题。还有一种情况是过滤器没生效web.xml里filter-mapping写成了*.do而请求路径是/book/list。解决先确认过滤器url-pattern是/*再看 Tomcatconf/server.xml的Connector有没有URIEncodingUTF-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 静态资源 404CSS 加载不出来页面裸奔现象页面能出内容但样式全丢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里各写一遍增删改查字段名一换就得改三处。抽一个BaseDaoT用反射拿表名和字段通用 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;charsetUTF-8)复用现有 service把PageBean序列化成 JSON 返回即可。JSP 那套原样保留两套入口并行改坏了随时能退回去。我现在的习惯是拿到任何一份这类老项目先不改代码先把完整链路手动点一遍并截图存档改完再点一遍逐项对照。不看基线就动手出问题时根本分不清是新引入的还是原本就有。希望这套拆法能帮你少走几段弯路。本文还有配套的精品资源点击获取