简介:这是一套基于JSP与MVC设计模式、以MySQL为数据库的网上图书购物系统源码,面向Java Web初学者、进阶学习者以及需要完成毕设、课程设计或大作业的学生,帮助其理解分层架构与购物流程的实现思路。压缩包共76个文件,约47.8MB,包含14个jsp页面、12个java源文件与12个class编译文件,另有css样式、xml配置、sql数据库脚本及项目展示视频等,覆盖前端页面、控制层与数据层的主要模块。资源附有数据库文件与README说明,可对照MVC三层结构梳理图书浏览、购物车、订单等核心功能的代码组织方式,并借助演示视频快速了解系统运行效果。目前已有136人学习下载,适合作为参考资料,在具备一定Java Web基础的前提下自行调试、修改与扩展功能。
1. 从零手搓图书购物网站:JSP+MVC+MySQL 这套老架构为什么还值得做
很多人第一次接触 Java Web,都是从一套「网上图书购物系统」开始的。它看起来平平无奇:图书列表、购物车、下单、后台管理,功能就这些。但真正动手写一遍你会发现,这套系统把 JSP、MVC 分层、MySQL 增删改查、会话管理、事务处理全串起来了,是一个能让你把课本知识落到实处的完整闭环。我见过太多人学完 Servlet 和 JDBC 之后不知道下一步做什么,其实缺的就是这样一个能把前后端和数据库打通的实战项目。图书购物网站的好处在于业务逻辑足够清晰——图书有分类、有库存、有价格,用户有登录、有购物车、有订单,每一块都能对应到具体的表结构和代码实现。你不需要花时间理解复杂的业务规则,可以把精力集中在 MVC 模式怎么落地、JSP 页面怎么组织、MySQL 表怎么设计这些真正重要的地方。这篇文章会从架构选型讲到具体实现,再到部署踩坑,把一套能跑起来的图书购物系统拆开揉碎讲清楚。
2. 图书购物系统的 MVC 分层:JSP 到底该写多少 Java 代码
2.1 为什么不用纯 JSP 写到底
新手最容易犯的错误是把所有逻辑都塞进 JSP 页面里。一个book_list.jsp里既有数据库连接代码,又有循环遍历结果集,还夹杂着 HTML 标签,改一个查询条件要翻遍整个文件。这种写法在页面少的时候还能忍,一旦功能超过十个,维护成本就会指数级上升。MVC 模式的核心思路是把「数据从哪来」「数据怎么处理」「数据怎么展示」三件事分开。在 JSP 技术栈里,Model 层用 JavaBean 和 DAO 类来承载数据和数据库操作,Controller 层用 Servlet 来接收请求、调用业务逻辑、决定跳转方向,View 层就是 JSP 页面,只负责把传过来的数据渲染成 HTML。这样分工之后,JSP 里几乎不出现Class.forName和Connection这些字眼,页面代码干净得像纯静态模板。
具体到图书购物系统,Model 层至少需要这几个 JavaBean:Book(图书实体)、User(用户实体)、CartItem(购物车条目)、Order(订单实体)。DAO 层对应BookDao、UserDao、OrderDao,每个 DAO 类里封装增删改查方法。Controller 层按功能模块划分 Servlet,比如BookListServlet负责查询图书列表,AddToCartServlet处理加入购物车,LoginServlet处理登录验证。View 层就是book_list.jsp、cart.jsp、login.jsp这些页面,它们通过 request 作用域拿到数据,用 JSTL 标签库遍历输出。
2.2 一个 Servlet 的完整请求处理流程
以图书列表页为例,用户访问/bookList这个 URL,Tomcat 根据web.xml或注解配置把请求交给BookListServlet。Servlet 的doGet方法里先调用BookDao.findAll()拿到List<Book>,然后request.setAttribute("bookList", list),最后request.getRequestDispatcher("/book_list.jsp").forward(request, response)。JSP 页面里用<c:forEach items="${bookList}" var="book">遍历输出。整个链路清晰得可以画成一条直线。
// BookListServlet.java @WebServlet("/bookList") public class BookListServlet extends HttpServlet { protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 设置编码,防止中文乱码 request.setCharacterEncoding("UTF-8"); response.setContentType("text/html;charset=UTF-8"); BookDao bookDao = new BookDao(); List<Book> bookList = bookDao.findAll(); // 查询所有图书 // 将数据存入 request 作用域,供 JSP 页面使用 request.setAttribute("bookList", bookList); // 转发到图书列表页面 request.getRequestDispatcher("/book_list.jsp").forward(request, response); } }这段代码的关键点有三个:第一,@WebServlet("/bookList")注解代替了web.xml配置,Servlet 3.0 之后推荐这种写法;第二,request.setCharacterEncoding("UTF-8")必须放在获取任何参数之前,否则 POST 请求里的中文会变成乱码;第三,用forward而不是redirect,因为 forward 是服务器内部跳转,request 作用域里的数据不会丢失,redirect 会导致浏览器重新发起请求,bookList属性就没了。
2.3 DAO 层怎么写才不算「裸写 JDBC」
很多教程里的 DAO 就是直接拼 SQL 字符串,然后用Statement执行。这种写法有两个致命问题:SQL 注入风险和连接泄漏。图书购物系统里,用户搜索图书时如果输入' OR '1'='1,用Statement拼接的查询会把整张表都查出来。正确做法是全部使用PreparedStatement,参数用?占位,然后setString、setInt逐个赋值。
// BookDao.java 中的搜索方法 public List<Book> searchByKeyword(String keyword) { List<Book> list = new ArrayList<>(); String sql = "SELECT * FROM book WHERE title LIKE ? OR author LIKE ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { // 前后加 % 实现模糊匹配 ps.setString(1, "%" + keyword + "%"); ps.setString(2, "%" + keyword + "%"); 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")); list.add(book); } } } catch (SQLException e) { e.printStackTrace(); } return list; }这里用了 try-with-resources 语法,Connection、PreparedStatement、ResultSet都会在代码块结束时自动关闭,不需要手动写finally里的close()。DBUtil是一个工具类,内部维护数据库连接参数,可以用简单的DriverManager实现,也可以换成 Druid 或 HikariCP 连接池。对于课程设计级别的项目,DriverManager就够了,但要注意每次获取连接都会新建一个物理连接,并发量大了会拖垮 MySQL。如果想让项目看起来更专业,引入 Druid 连接池只需要改DBUtil一个类,上层 DAO 代码完全不用动。
3. MySQL 表结构设计与购物车、订单的核心逻辑
3.1 图书购物系统需要哪几张表
一个能跑通完整流程的图书购物系统,至少需要五张表:user(用户)、book(图书)、category(分类)、cart(购物车)、orders(订单)和order_item(订单明细)。user表存用户名、密码、邮箱、角色(普通用户/管理员)。book表存书名、作者、出版社、ISBN、价格、库存、封面图片路径、分类 ID。category表存分类名称。cart表存用户 ID、图书 ID、数量。orders表存订单号、用户 ID、下单时间、总金额、收货地址、订单状态。order_item表存订单 ID、图书 ID、购买数量、购买时的单价。
这里有一个容易忽略的细节:order_item里的单价必须单独存一份,不能只靠book表的当前价格。因为图书价格会变,如果订单明细只关联图书 ID,用户三个月后查看历史订单,看到的是涨价后的价格,这显然不合理。同样,图书被下架删除后,订单明细里的图书信息也不能跟着消失,所以order_item里最好冗余存一份书名和单价。
-- 图书表建表语句 CREATE TABLE book ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(200) NOT NULL, author VARCHAR(100), publisher VARCHAR(100), isbn VARCHAR(20), price DECIMAL(10,2) NOT NULL DEFAULT 0.00, stock INT NOT NULL DEFAULT 0, cover_img VARCHAR(255), category_id INT, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (category_id) REFERENCES category(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;ENGINE=InnoDB是必须的,因为后面下单要用事务,MyISAM 不支持事务。CHARSET=utf8mb4而不是utf8,因为utf8在 MySQL 里是残缺的,存不了 emoji 和部分生僻字,utf8mb4才是真正的 UTF-8。price用DECIMAL(10,2)而不是FLOAT,浮点数在金额计算时会出现0.1 + 0.2 = 0.30000000000000004这种玄学问题,DECIMAL是精确十进制,适合金额字段。
3.2 购物车用 Session 还是存数据库
这是图书购物系统设计时第一个要做的选择。用 Session 存购物车,优点是实现简单,不需要建表,读写速度快。缺点是用户换浏览器或清 Cookie 后购物车就丢了,而且服务器重启 Session 也会失效。用数据库存购物车,优点是持久化,用户在任何设备登录都能看到购物车。缺点是每次增删改查都要访问数据库,性能稍差。
我的建议是:课程设计或小型项目用 Session 就够了,因为实现简单,代码量少,答辩时也好解释。但如果想做得更完整,建议用数据库存购物车,同时在用户未登录时用 Cookie 暂存,登录后合并到数据库。这样既保证了用户体验,又体现了对持久化的理解。
// 加入购物车的 Servlet 核心逻辑(Session 方案) protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { int bookId = Integer.parseInt(request.getParameter("bookId")); // 从 Session 获取当前购物车,如果没有则新建 Map<Integer, Integer> cart = (Map<Integer, Integer>) request.getSession().getAttribute("cart"); if (cart == null) { cart = new HashMap<>(); } // 如果已存在则数量加一,否则新增条目 cart.put(bookId, cart.getOrDefault(bookId, 0) + 1); request.getSession().setAttribute("cart", cart); response.sendRedirect("cart.jsp"); }这段代码用Map<Integer, Integer>存购物车,key 是图书 ID,value 是数量。getOrDefault是 Java 8 的方法,如果 key 不存在就返回默认值 0,然后加一。注意最后用sendRedirect而不是forward,因为加入购物车是一个「操作」而不是「查询」,重定向可以防止用户刷新页面时重复提交。
3.3 下单时的事务处理与库存扣减
下单是整个系统里唯一必须用事务的地方。用户点击「提交订单」后,系统要做三件事:在orders表插入一条订单记录,在order_item表插入多条明细记录,在book表扣减对应图书的库存。这三步必须全部成功或全部失败,否则会出现「订单生成了但库存没扣」或者「库存扣了但订单没生成」的脏数据。
// OrderService.java 中的下单方法 public boolean createOrder(int userId, Map<Integer, Integer> cart, String address) { Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 // 1. 计算总金额并插入订单主表 BigDecimal total = BigDecimal.ZERO; for (Map.Entry<Integer, Integer> entry : cart.entrySet()) { Book book = bookDao.findById(entry.getKey()); total = total.add(book.getPrice().multiply(new BigDecimal(entry.getValue()))); } int orderId = orderDao.insertOrder(conn, userId, total, address); // 2. 插入订单明细并扣减库存 for (Map.Entry<Integer, Integer> entry : cart.entrySet()) { int bookId = entry.getKey(); int quantity = entry.getValue(); Book book = bookDao.findById(bookId); // 检查库存是否充足 if (book.getStock() < quantity) { conn.rollback(); return false; } orderDao.insertOrderItem(conn, orderId, bookId, quantity, book.getPrice()); bookDao.reduceStock(conn, bookId, quantity); } conn.commit(); // 全部成功,提交事务 return true; } catch (SQLException e) { if (conn != null) { try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } } e.printStackTrace(); return false; } finally { if (conn != null) { try { conn.setAutoCommit(true); conn.close(); } catch (SQLException e) { e.printStackTrace(); } } } }这段代码的关键在于所有 DAO 方法都接收同一个Connection参数,而不是各自去DBUtil.getConnection()。因为事务是绑定在连接上的,如果每个 DAO 方法都新建连接,事务就失效了。conn.setAutoCommit(false)开启事务后,所有 SQL 操作都在同一个事务里,最后conn.commit()一次性提交。如果中途任何一步失败,conn.rollback()会撤销所有已执行的操作。库存检查放在扣减之前,如果库存不足直接回滚并返回 false,前端提示用户「库存不足」。
4. 图书购物系统部署与联调的避坑记录
4.1 中文乱码的三层排查
现象:图书列表页显示正常,但搜索「Java编程」时返回空结果,或者后台添加图书后书名变成问号。
原因:中文乱码可能出现在三个地方——JSP 页面编码、Servlet 请求编码、MySQL 连接编码。任何一层没设对都会导致乱码。
解决:第一,每个 JSP 页面顶部加<%@ page contentType="text/html;charset=UTF-8" language="java" %>。第二,Servlet 的doGet和doPost第一行加request.setCharacterEncoding("UTF-8"),注意doGet的编码设置对 URL 参数无效,需要在 Tomcat 的server.xml里给 Connector 加URIEncoding="UTF-8"。第三,JDBC 连接 URL 加?useUnicode=true&characterEncoding=utf8,MySQL 8 驱动还要加serverTimezone=Asia/Shanghai,否则会报时区错误。
4.2 数据库连接池耗尽导致页面卡死
现象:本地测试一切正常,部署到服务器后运行一段时间,所有页面都打不开,重启 Tomcat 后恢复,过一会儿又卡死。
原因:每次数据库操作都新建Connection,用完没有关闭,连接数不断累积,最终 MySQL 达到max_connections上限,新请求全部阻塞。
解决:把所有Connection的获取和关闭统一收口到DBUtil,用 try-with-resources 确保自动关闭。如果项目并发量稍大,引入 Druid 连接池,在druid.properties里配置maxActive=20、maxWait=3000,连接用完归还池子而不是物理关闭。另外在 MySQL 端执行SHOW PROCESSLIST可以看到当前所有连接,如果发现大量Sleep状态的连接,说明代码里有泄漏。
4.3 订单重复提交的三种拦截方式
现象:用户点击「提交订单」后网络卡顿,用户以为没成功又点了一次,结果生成两笔一模一样的订单。
原因:HTTP 请求是幂等的,但下单操作不是。每次请求都会执行一次插入,没有任何去重机制。
解决:第一种方式,前端提交后立即禁用按钮,用 JavaScript 把按钮设为disabled。第二种方式,在 Session 里存一个 token,下单时校验 token 是否存在,校验通过后立即删除。第三种方式,在orders表加唯一索引,比如user_id + create_time组合唯一,重复插入会抛异常。三种方式可以叠加使用,前端拦截体验最好,后端 token 校验最可靠。
4.4 JSP 页面引入 CSS 和图片 404
现象:JSP 页面能打开,但样式全丢,图片显示裂图,浏览器控制台报 404。
原因:JSP 页面通过forward跳转后,浏览器地址栏还是 Servlet 的路径,比如/bookList,此时页面里写的相对路径css/style.css会被解析成/css/style.css,而实际文件在/WEB-INF/同级目录下。
解决:在 JSP 页面顶部用<% String path = request.getContextPath(); %>获取项目上下文路径,所有静态资源引用都写成<%=path%>/css/style.css和<%=path%>/images/logo.png。或者用 JSTL 的<c:url value="/css/style.css"/>标签,效果一样但更简洁。如果项目部署在 Tomcat 的根目录下,path为空字符串,这种写法也能兼容。
5. 用过滤器统一字符编码和登录拦截的进阶技巧
5.1 一个 Filter 解决全站编码问题
前面在每个 Servlet 里写request.setCharacterEncoding("UTF-8")是重复劳动,而且容易漏。更好的做法是写一个CharacterEncodingFilter,在web.xml或注解里配置拦截所有请求,统一设置编码。
@WebFilter("/*") public class CharacterEncodingFilter 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 } }@WebFilter("/*")表示拦截所有请求,包括静态资源。chain.doFilter是放行操作,如果不调用,请求就会被拦截在这里,页面永远打不开。这个过滤器要放在所有过滤器的最前面,确保编码设置在其他逻辑之前生效。
5.2 登录拦截器与白名单配置
图书购物系统里,购物车、下单、个人中心这些页面必须登录才能访问。用 Filter 实现登录校验,同时配置白名单,让登录页、注册页、图书列表页不需要登录也能访问。
@WebFilter("/user/*") public class LoginFilter implements Filter { // 不需要登录的路径 private static final String[] WHITE_LIST = {"/login.jsp", "/register.jsp", "/login", "/bookList"}; public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; String uri = request.getRequestURI(); String path = uri.substring(request.getContextPath().length()); // 白名单直接放行 for (String white : WHITE_LIST) { if (path.startsWith(white)) { chain.doFilter(request, response); return; } } // 检查 Session 中是否有登录用户 Object user = request.getSession().getAttribute("user"); if (user == null) { response.sendRedirect(request.getContextPath() + "/login.jsp"); } else { chain.doFilter(request, response); } } }这里把拦截路径设为/user/*,意味着所有/user/开头的请求都会经过这个过滤器。白名单用startsWith匹配,/bookList虽然不在/user/下,但为了保险也加进去。判断登录状态靠 Session 里的user属性,登录成功后session.setAttribute("user", user),退出时session.invalidate()或removeAttribute("user")。
5.3 用 JSTL 和 EL 表达式简化 JSP
JSP 里写<% for(Book book : list) { %>这种脚本片段已经过时了,现在推荐用 JSTL 标签库配合 EL 表达式。EL 表达式${book.title}会自动调用book.getTitle(),不需要 import 任何类。JSTL 的<c:forEach>替代 for 循环,<c:if>替代 if 判断,<fmt:formatDate>格式化日期,<fmt:formatNumber>格式化金额。
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %> <table> <c:forEach items="${bookList}" var="book" varStatus="status"> <tr> <td>${status.index + 1}</td> <td>${book.title}</td> <td>${book.author}</td> <td><fmt:formatNumber value="${book.price}" pattern="¥0.00"/></td> <td> <c:choose> <c:when test="${book.stock > 0}">有货</c:when> <c:otherwise>缺货</c:otherwise> </c:choose> </td> </tr> </c:forEach> </table>varStatus="status"提供了循环状态,status.index从 0 开始,status.count从 1 开始。<c:choose>相当于 switch-case,比多个<c:if>更清晰。<fmt:formatNumber>的pattern属性可以自定义格式,¥0.00表示人民币符号加两位小数。用 JSTL 之后,JSP 页面里几乎看不到 Java 代码,前端人员也能看懂和修改。
5.4 分页查询的 SQL 写法与页码计算
图书列表数据多了必须分页,否则一次查几千条会把内存撑爆。MySQL 分页用LIMIT offset, size,offset是起始行号,size是每页条数。假设每页 10 条,第 1 页是LIMIT 0, 10,第 2 页是LIMIT 10, 10,第 n 页是LIMIT (n-1)*10, 10。
public List<Book> findByPage(int pageNum, int pageSize) { List<Book> list = new ArrayList<>(); String sql = "SELECT * 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 对象,代码同上 } } } catch (SQLException e) { e.printStackTrace(); } return list; }总页数需要单独查一次SELECT COUNT(*) FROM book,然后totalPages = (totalCount + pageSize - 1) / pageSize。这个公式用整数除法向上取整,比如 21 条数据每页 10 条,(21+9)/10 = 3页。前端传页码时要做边界校验,如果pageNum < 1设为 1,如果pageNum > totalPages设为totalPages,防止 SQL 报错或返回空列表。
5.5 图书封面图片上传的路径处理
后台添加图书时需要上传封面图片。JSP 的<form>要加enctype="multipart/form-data",Servlet 端用@MultipartConfig注解开启文件上传支持,然后通过request.getPart("cover")获取文件。
@WebServlet("/admin/addBook") @MultipartConfig(maxFileSize = 5 * 1024 * 1024) // 最大 5MB public class AddBookServlet extends HttpServlet { protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String title = request.getParameter("title"); Part coverPart = request.getPart("cover"); String fileName = System.currentTimeMillis() + "_" + coverPart.getSubmittedFileName(); // 保存到项目部署目录下的 upload 文件夹 String savePath = getServletContext().getRealPath("/upload"); File uploadDir = new File(savePath); if (!uploadDir.exists()) uploadDir.mkdirs(); coverPart.write(savePath + File.separator + fileName); // 数据库里存相对路径 String coverImg = "upload/" + fileName; // 调用 BookDao 插入图书,coverImg 作为参数 } }getSubmittedFileName()获取原始文件名,前面拼时间戳防止重名覆盖。getRealPath("/upload")拿到的是项目在 Tomcat 里的实际部署路径,注意这个路径在重新部署 war 包后会被清空,生产环境应该存到项目目录之外的固定位置。数据库里只存相对路径upload/xxx.jpg,JSP 页面用<img src="${pageContext.request.contextPath}/${book.coverImg}">显示,这样项目迁移时不用改数据。
5.6 用 EXPLAIN 检查慢查询
图书搜索功能如果数据量大了会变慢,用EXPLAIN命令可以看 MySQL 有没有走索引。在book表的title和author字段上建索引,EXPLAIN SELECT * FROM book WHERE title LIKE '%Java%'会显示type=ALL全表扫描,因为LIKE以%开头无法使用索引。解决办法是把搜索改成全文索引,或者用LIKE 'Java%'前缀匹配。如果业务允许,还可以引入 Elasticsearch 做全文检索,但那是另一个话题了。对于课程设计级别的项目,几千条数据全表扫描完全能接受,不需要过度优化。
-- 在 title 和 author 上建普通索引 CREATE INDEX idx_book_title ON book(title); CREATE INDEX idx_book_author ON book(author); -- 查看查询执行计划 EXPLAIN SELECT * FROM book WHERE title LIKE 'Java%';EXPLAIN结果里重点看type列,ALL是全表扫描,ref或range是走了索引,index是全索引扫描。rows列是预估扫描行数,越小越好。Extra列出现Using filesort说明有额外排序,出现Using temporary说明用了临时表,这两个都值得关注。索引不是越多越好,每个索引都会增加插入和更新的开销,一般只在 WHERE、ORDER BY、JOIN 涉及的字段上建索引。
5.7 项目打包与 Tomcat 部署的注意事项
IDEA 里用 Maven 打包,pom.xml里<packaging>war</packaging>,执行mvn clean package生成 war 文件,丢到 Tomcat 的webapps目录下,启动 Tomcat 会自动解压部署。注意 MySQL 驱动包的版本要和 MySQL 服务器版本匹配,MySQL 8 用mysql-connector-java-8.0.x,MySQL 5.7 用mysql-connector-java-5.1.x。驱动类名也不同,MySQL 8 是com.mysql.cj.jdbc.Driver,MySQL 5.7 是com.mysql.jdbc.Driver。连接 URL 里 MySQL 8 必须加serverTimezone,否则启动就报错。
我自己的习惯是本地开发用 Tomcat 插件跑,mvn tomcat7:run或mvn tomcat9:run,不用手动配 Tomcat。部署到服务器时用scp把 war 包传上去,然后systemctl restart tomcat。日志看logs/catalina.out,数据库连接问题、Servlet 初始化异常都在里面。每次改完代码重新打包部署,记得清一下浏览器缓存,JSP 编译后的 class 文件在 Tomcat 的work目录下,有时候改了 JSP 不生效就是缓存问题,删掉work目录重启即可。希望这些踩坑经验能帮你少走弯路。
本文还有配套的精品资源,点击获取