
简介这是一份基于JspBeanServlet的经典Java Web网上购书系统课程设计资源采用B/S结构实现了用户注册、登录、图书信息查询与浏览等核心功能功能设计简洁完整特别适合Java Web初学者或需要完成课程设计的在校学生参考使用。资源包共含55个文件以Java源码、JSP页面、class编译文件为主另附带数据库脚本SQL、配置文件XML/Properties、项目说明README及课程综合训练PDF文档整体大小仅1.21MB目录结构清晰便于按模块学习与二次开发。已有173人学习下载资源完整度较高涵盖了从代码实现到部署运行的关键文件读者可据此快速搭建一个可运行的网上购书系统理解ServletJSPJavaBean的三层协作方式并迁移到其他Web项目中。1. 基于JspBeanServlet的网上购书系统一次点击背后的三次跳转你点下“加入购物车”的按钮浏览器发出一条请求Servlet接住请求Bean把数据和状态装好JSP再把页面渲染出来——三者在一次点击里各干各的少一环订单就落不了库。这套基于JspBeanServlet实现的网上购书系统是Java Web课设里最高频的题目之一也是把“Web项目从零到能跑”这条路走通的最小样本。它面向的人群很明确正在做Java Web课程设计的学生、刚学完Servlet和JSP想练手的开发者以及想快速搭一个包含登录、购物车、下单全流程的简版商城来验证想法的人。它不追求高并发也不追求微服务只追求把请求-处理-响应这条主链路讲清楚、跑明白。读完这篇文章你应该能独立复现这个系统并且知道遇到404、乱码、驱动缺失时先去查哪里。2. 先拆角色Servlet管路由Bean管数据JSP管显示网上购书系统的需求一句话能说清用户登录后浏览图书、加购物车、下单。但这句话落到代码里要先回答一个问题——这三件事分别交给谁。JspBeanServlet的组合对应的正是MVC里的View、Model、Controller。很多人在这个项目里翻车不是因为不会写SQL而是因为把登录校验写进了JSP、把数据库连接写在Servlet里最后页面和逻辑糊成一团。2.1 Servlet是路由不是页面一次“登录成功”请求的完整流转Servlet在购书系统里的定位是控制器只负责接请求、调业务、决定跳到哪。以登录为例完整流转是这样的浏览器把表单POST到/loginTomcat根据web.xml里的servlet-mapping找到LoginServlet容器调它的doPost方法LoginServlet里取出username和password两个参数new出一个User对象封装起来交给UserDao核对数据库核对通过后把User对象放进session再让浏览器重定向到/bookList浏览器重新发一次GET请求BookListServlet查完图书表后forward到book_list.jsp由JSP渲染出HTML。这里有一个细节值得注意登录成功后我一般用sendRedirect而不是forward。forward是服务器内部转发浏览器地址栏不变用户刷新页面时会重复提交表单sendRedirect是302重定向浏览器重新发请求天然避免这个问题。而查询图书列表这种只读操作用forward就好少一次网络往返。分清这两个跳转方式是这个项目里最基础的请求流转经验。2.2 Bean的两副面孔实体Bean和业务Bean分工Bean在购书系统里有两层含义别混着写。第一层是实体Bean对应数据库表比如User、Book、Order里面只放私有字段、无参构造、getter和setter遵循JavaBean规范。第二层是业务Bean比如CartService、OrderService封装“加购物车”“下单扣库存”这些操作。很多课设代码只有一个实体类包把SQL直接写在Servlet里短期能跑但调试时每个方法都拖着一堆数据库代码改一个字段要翻好几个文件。Bean的实例化方式在这个阶段不需要任何容器直接new就行。比如在LoginServlet里写User user new User()把request里的参数set进去。到了Spring里Bean的生命周期交给IoC容器管理那是后话但在JspBeanServlet这个题目里手动new是最直观、也最符合考核意图的写法。你要是想在答辩时多说两句可以提一句“实体Bean与业务Bean分离是为了让视图层不接触SQL”这句话比任何设计模式都实在。2.3 为什么不用Spring Boot这个题目的真实考核点你可能会问现在企业级web开发都用Spring Boot为什么课设还要用Servlet原因很简单这个题目考的就是Servlet生命周期、请求响应模型、JDBC和Session状态管理。Spring Boot把这些全部封装掉了你在里面写一个RestController加一个Mapper接口就能跑但老师问“请求进来之后到底发生了什么”你答不上来项目做得再漂亮也拿不到分。反过来把JspBeanServlet这套结构完全吃透再去看Spring MVC会非常顺——DispatcherServlet就是那个最大的ServletController就是Servlet里抽出来的方法ModelAndView就是request里attribute的封装。这个项目不是过时是台阶。用一个最简单的购书系统把Web开发的主干逻辑走一遍后面换成任何框架都是换皮不换骨。3. 搭骨架数据库四张表 IDEA 2024里的Web项目初始化一个能跑通的购书系统书、用户、订单三样数据必须落库。常见做法是建四张表user存账号book存图书orders存订单主表order_item存订单明细。订单主表和明细分开是因为一张订单可能包含多本书从订单表能直接算出总额从明细表能对账这是关系型设计的基本形态。3.1 建四张表就能撑起整个购书流程user、book、order、order_item四张表的字段设计以“够用、好查”为原则不需要过度设计。用户表要username做唯一索引防止注册重名图书表要有price和stock价格用DECIMAL(10,2)而不是floatfloat做金额计算会有精度误差这是做电商的基本常识订单表要冗余一个total_price这样查订单列表时不用每次都去关联明细表算总额。CREATE DATABASE bookshop CHARACTER SET utf8mb4; USE bookshop; CREATE TABLE user ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, phone VARCHAR(20), email VARCHAR(100) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE book ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL, author VARCHAR(50), price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0, category VARCHAR(50), cover_path VARCHAR(255) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE orders ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, total_price DECIMAL(10,2) NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, status INT DEFAULT 0, FOREIGN KEY (user_id) REFERENCES user(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE order_item ( id INT AUTO_INCREMENT PRIMARY KEY, order_id INT NOT NULL, book_id INT NOT NULL, price DECIMAL(10,2) NOT NULL, quantity INT NOT NULL, FOREIGN KEY (order_id) REFERENCES orders(id), FOREIGN KEY (book_id) REFERENCES book(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;建表SQL里有三个参数值得说明。第一个是utf8mb4MySQL里的utf8最多存3字节存不了emoji和生僻字utf8mb4是完整的4字节UTF-8编码购书系统里书名可能出现特殊字符直接用它。第二个是DECIMAL(10,2)整数部分8位、小数2位对课设级别的书价完全够用而且不会出现0.1 0.2不等于0.3的浮点问题。第三个是外键约束user_id和order_id都加了FOREIGN KEY数据库层面保证不会出现“订单属于一个不存在的用户”这种脏数据。3.2 IDEA 2024创建Web项目的三步准备Tomcat版本、依赖JAR和部署目录IDEA 2024的IntelliJ IDEA有两个版本配置Web项目的路径不太一样。专业版有Jakarta EE向导新建项目时可以直接选Web Application社区版没有这个入口常见做法是新建一个普通Java项目后右键项目名选Add Framework Support勾选Web ApplicationIDEA会自动生成web目录和web.xml。如果你用的是Maven也可以直接用maven-archetype-webapp骨架结构更干净后面加依赖不用手动找jar。Tomcat版本这一步最容易踩坑。2024年很多教程已经默认用Tomcat 10或11但Tomcat 10开始把javax.servlet包名改成了jakarta.servlet你从老教程抄来的代码会大面积编译失败。做这个课设项目最省事的方案是用Tomcat 9.0.x它兼容所有javax开头的旧代码。如果你已经装了Tomcat 10后面避坑章节会单独讲怎么处理。数据库驱动jar必须出现在部署产物里这一步在IDEA里很隐蔽。你哪怕在项目里把mysql-connector-java.jar加入Library编译能过但运行时会报ClassNotFoundException因为Tomcat启动的是war包或war exploded目录jar必须在WEB-INF/lib下面。我的习惯是直接在web/WEB-INF/lib目录下放一份驱动jar让IDEA把它当成库引用这样编译和部署用的同一份文件不会出现“本地能跑、一部署就崩”的玄学问题。3.3 第一个能跑通的页面图书列表从BookListServlet到book_list.jsp骨架搭好后先别急着写登录和购物车把图书列表跑通就能验证数据库连接、Servlet映射、JSP渲染这三段链路是否正常。我一般先写DBUtil负责拿连接再写Book这个Bean接着写BookDao的查询方法最后写Servlet和JSP。这个顺序是自底向上的每写完一层都可以单独测试。package com.bookshop.util; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; public class DBUtil { private static final String URL jdbc:mysql://localhost:3306/bookshop?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai; private static final String USER root; private static final String PASS 你的密码; static { try { Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASS); } }这段代码有三个参数说明。URL里的serverTimezoneAsia/Shanghai是必须的MySQL 8.x驱动如果没设时区连接时会直接报错useUnicode和characterEncoding保证中文从数据库到JDBC不会乱码与数据库的utf8mb4是对应的密码直接写在常量里是课设的常规做法真实项目要放到配置文件中这个后面不用细究。Class.forName在Java 6之后其实可以省略但写上能让代码更明确也兼容老环境的习惯。package com.bookshop.bean; public class Book { private int id; private String name; private String author; private double price; private int stock; private String category; private String coverPath; // 无参构造、所有字段的 getter/setter 由 IDE 生成 }Book这个Bean就是一张表映射成一个对象字段名和表字段一一对应。需要注意coverPath对应表里的cover_pathJDBC查出来后要手动set或者用别名让ResultSet直接按Java字段名取值。这里不推荐在Bean里写任何业务逻辑它的职责只有一个装数据。package com.bookshop.dao; import com.bookshop.bean.Book; import com.bookshop.util.DBUtil; import java.sql.*; import java.util.ArrayList; import java.util.List; public class BookDao { public ListBook findByKeyword(String keyword) { String sql SELECT id, name, author, price, stock, category, cover_path AS coverPath FROM book WHERE name LIKE ? OR author LIKE ?; ListBook list new ArrayList(); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { String like % keyword %; ps.setString(1, like); ps.setString(2, like); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { Book b new Book(); b.setId(rs.getInt(id)); b.setName(rs.getString(name)); b.setAuthor(rs.getString(author)); b.setPrice(rs.getDouble(price)); b.setStock(rs.getInt(stock)); b.setCategory(rs.getString(category)); b.setCoverPath(rs.getString(coverPath)); list.add(b); } } } catch (SQLException e) { throw new RuntimeException(查询图书失败, e); } return list; } }这里有两个关键选择。PreparedStatement一定要用而不是Statement拼字符串哪怕课设项目也要养成这个习惯否则用户输入书名时带一个单引号SQL就拼接出语法错误甚至被注入。ResultSet里的列名写了别名coverPath就是为了让getString(“coverPath”)直接映射到Book的coverPath字段省掉一次手工转换。try-with-resources写法会自动关闭Connection、PreparedStatement和ResultSet顺序是倒着关的不会出现连接泄漏。package com.bookshop.servlet; import com.bookshop.bean.Book; import com.bookshop.dao.BookDao; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.List; WebServlet(/bookList) public class BookListServlet extends HttpServlet { Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String keyword req.getParameter(keyword); if (keyword null) { keyword ; } BookDao dao new BookDao(); ListBook books dao.findByKeyword(keyword.trim()); req.setAttribute(bookList, books); req.setAttribute(keyword, keyword.trim()); req.getRequestDispatcher(/book_list.jsp).forward(req, resp); } }Servlet里的逻辑控制在“取参数、调方法、存数据、跳页面”四步。keyword为null时给空字符串是为了让后面LIKE %查到所有书也就是默认展示全部图书。trim()去掉用户误输的空格避免搜索“ 小说 ”这种带空格的关键词查不到结果。forward到book_list.jsp的时候JSP和Servlet处在同一个request里所以JSP里能直接用${bookList}拿数据。如果你在这里用了sendRedirect那request里的attribute会全部丢失这是新手最常见的跳转错误。4. 跑通主链路登录、购物车、下单与减库存的代码写法图书列表能显示出来后剩下四个链路决定这个题目能不能真正“用起来”登录态怎么维护、购物车数据放哪、下单怎么保证不超卖、搜索怎么传参。这几个功能每个都不复杂但组合在一起就是一次完整的电商交易闭环。4.1 登录注册Session里存User对象JSP个人信息展示页面直接取登录逻辑的关键不是校验密码而是登录成功后往session里存什么。我见过不少代码只存一个username字符串页面要用手机号、邮箱时就得再查一次库。正确的做法是把整个User对象放进sessionJSP个人信息展示页面里直接取字段登录一次整个会话期间都能用。package com.bookshop.servlet; import com.bookshop.bean.User; import com.bookshop.dao.UserDao; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.*; import java.io.IOException; WebServlet(/login) public class LoginServlet extends HttpServlet { Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username req.getParameter(username); String password req.getParameter(password); UserDao dao new UserDao(); User user dao.findByUsernameAndPassword(username, password); if (user null) { req.setAttribute(error, 用户名或密码错误); req.getRequestDispatcher(/login.jsp).forward(req, resp); return; } HttpSession session req.getSession(); session.setAttribute(user, user); resp.sendRedirect(req.getContextPath() /bookList); } }这段代码里有一个细节登录失败用forward登录成功用sendRedirect。失败时forward能让login.jsp继续用同一个request里的error属性页面回显错误提示成功时重定向避免用户按F5刷新时把登录表单再提交一次。session.setAttribute之后JSP里通过${sessionScope.user.username}就能拿到当前登录用户注意后面需要加sessionScope前缀不然${user}会先查request里的attribute取不到就空在那里这是后面避坑章节要单独讲的问题。注册逻辑比登录多一个步骤先按username查一次库查到就返回“用户名已存在”没查到才执行INSERT。密码的存储建议至少做一次MD5或SHA-256哈希不要明文入库。考虑到课设评分一般不要求加密算法实现你可以用一个简单的DigestUtils工具类或者只在答辩时说明“生产环境会用BCrypt加盐”这属于加分项不属于必做项。4.2 购物车用Session暂存的课设方案与数量加减购物车是这个项目里唯一需要仔细想状态的地方。真实电商的购物车要落库、要支持多端同步、要处理过期但课设规模下把这些全部塞进代码只会让项目失控。我一般把购物车放在Session里用MapInteger, CartItem来存key是bookIdvalue是购物车条目这样加购、改数量、删条目都只需要操作一个Map。package com.bookshop.bean; public class CartItem { private Book book; private int quantity; public CartItem(Book book, int quantity) { this.book book; this.quantity quantity; } public double getSubtotal() { return book.getPrice() * quantity; } // getter/setter 省略 public void addQuantity(int num) { this.quantity num; } }CartItem里的getSubtotal是个计算属性不算业务逻辑它只是把单价乘数量这个展示逻辑集中在一个地方避免每个页面都重复算一次。quantity字段要有addQuantity方法因为用户连续点两次“加入购物车”购物车里的同一本书应该是数量加1而不是出现两个重复条目。WebServlet(/addCart) public class AddCartServlet extends HttpServlet { Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { int bookId Integer.parseInt(req.getParameter(bookId)); HttpSession session req.getSession(); MapInteger, CartItem cart (MapInteger, CartItem) session.getAttribute(cart); if (cart null) { cart new HashMap(); session.setAttribute(cart, cart); } if (cart.containsKey(bookId)) { cart.get(bookId).addQuantity(1); } else { BookDao bookDao new BookDao(); Book book bookDao.findById(bookId); cart.put(bookId, new CartItem(book, 1)); } resp.sendRedirect(req.getContextPath() /cart.jsp); } }AddCartServlet放在doGet而不是doPost里是为了方便直接在JSP页面用 加入购物车 触发少写一个表单。这个取舍在课设里可以接受但严格来说修改购物车这种操作应该用POST因为它会改变服务端状态。你先按GET写完能跑在答辩时能说出“我知道这里应该用POSTGET会触发缓存和重复提交”这句话就已经比大多数同学强了。购物车放在Session里的代价是用户关浏览器就丢了这正是课设和真实电商的差距值得在文档里写清楚。4.3 下单与扣库存JDBC手动事务和SELECT ... FOR UPDATE下单是安全性最高的代码。扣库存、建订单、建订单明细这三件事要么全成功要么全失败不能出现“钱扣了订单没建出来”的情况。JDBC默认executeUpdate后自动提交所以必须手动开启事务。public void createOrder(int userId, MapInteger, CartItem cart) { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); double total 0; for (CartItem item : cart.values()) { // 锁行把这本书的 stock 行锁住防止并发超卖 String lockSql SELECT stock FROM book WHERE id ? FOR UPDATE; try (PreparedStatement ps conn.prepareStatement(lockSql)) { ps.setInt(1, item.getBook().getId()); try (ResultSet rs ps.executeQuery()) { if (rs.next() rs.getInt(stock) item.getQuantity()) { throw new RuntimeException(库存不足); } } } String updateSql UPDATE book SET stock stock - ? WHERE id ?; try (PreparedStatement ps conn.prepareStatement(updateSql)) { ps.setInt(1, item.getQuantity()); ps.setInt(2, item.getBook().getId()); ps.executeUpdate(); } total item.getSubtotal(); } // 插入订单 String orderSql INSERT INTO orders (user_id, total_price) VALUES (?, ?); int orderId; try (PreparedStatement ps conn.prepareStatement(orderSql, PreparedStatement.RETURN_GENERATED_KEYS)) { ps.setInt(1, userId); ps.setDouble(2, total); ps.executeUpdate(); try (ResultSet rs ps.getGeneratedKeys()) { rs.next(); orderId rs.getInt(1); } } // 插入订单明细 String itemSql INSERT INTO order_item (order_id, book_id, price, quantity) VALUES (?, ?, ?, ?); try (PreparedStatement ps conn.prepareStatement(itemSql)) { for (CartItem item : cart.values()) { ps.setInt(1, orderId); ps.setInt(2, item.getBook().getId()); ps.setDouble(3, item.getBook().getPrice()); ps.setInt(4, item.getQuantity()); ps.addBatch(); } ps.executeBatch(); } conn.commit(); } catch (Exception e) { if (conn ! null) { try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } } throw new RuntimeException(下单失败已回滚, e); } finally { if (conn ! null) { try { conn.setAutoCommit(true); conn.close(); } catch (SQLException e) { e.printStackTrace(); } } } }这段代码里最值得说明的是SELECT ... FOR UPDATE。它把book表里对应id的那一行锁住直到事务提交或回滚才释放。如果不加这行锁两个用户同时买同一本书两个请求都读到库存为1都通过校验都执行UPDATE最后库存变成-1这就是超卖。FOR UPDATE是MySQL InnoDB的行级锁只锁命中的那一行课设规模下性能完全够用。RETURN_GENERATED_KEYS是为了拿到自增订单ID它是插入order_item的前提。executeBatch()把多个明细的INSERT合并成一次数据库往返比循环executeUpdate快代码上也更紧凑。4.4 图书搜索一个keyword参数在Servlet、DAO、SQL里的传递搜索功能的参数链路很短但有个细节容易漏。BookListServlet里从request取keyword后给一个空字符串默认值DAO的findByKeyword用LIKE %keyword%模糊匹配JSP的搜索框要回显用户输入的关键词不然用户搜完“小说”后输入框是空的体验很奇怪。form action${pageContext.request.contextPath}/bookList methodget input typetext namekeyword value${param.keyword} placeholder输入书名或作者 button typesubmit搜索/button /form${param.keyword}是EL表达式里的隐式对象直接取URL参数等价于request.getParameter(keyword)。用它做value属性可以让搜索框自动带出刚才输入的内容。注意form用的是GET因为搜索是一个幂等的只读操作URL可以收藏、可以分享也方便看到查询参数。如果这里误用POST刷新页面时会反复弹“确认重新提交”的提示用户会直接关上页面。5. 避坑排查五个把购书系统卡死的经典问题下面五个问题是我见过这个项目里出现频率最高的按“现象→原因→解决”的方式写你在本地跑的时候可以拿来当排查手册用。5.1 javax.servlet变成jakarta.servletTomcat 10把老代码全盘打回现象IDEA 2024里新建项目选了Tomcat 10或11代码里import javax.servlet.http.HttpServlet编译直接报“程序包javax.servlet不存在”或者项目能编译但启动后访问所有Servlet路径都404。原因Tomcat 10开始Java EE移交给了Eclipse基金会包名从javax.整体换成jakarta.。所有老教程、老博客里的代码import开头只要是javax.servlet的在Tomcat 10上全部失效。解决最省事的是把本地Tomcat换成9.0.x课设代码一行都不用改。如果必须用Tomcat 10就把所有import javax.servlet改成import jakarta.servletweb.xml头部的命名空间也要改成Jakarta EE版本。Maven项目则把javax.servlet-api依赖替换为jakarta.servlet-api。我的建议是除非老师指定了Tomcat 10否则这个阶段不要在这个问题上浪费半小时。5.2 中文全变问号POST乱码和GET乱码各查各的现象注册时输入中文用户名登录成功后个人信息页显示“???”图书列表里中文书名正常但搜索框输入“小说”后页面乱码。原因这条链路四段都要保持UTF-8只要一段不是就乱码。数据库表要utf8mb4JDBC URL要带characterEncodingutf8JSP文件要pageEncodingUTF-8POST请求要在取任何参数之前调用request.setCharacterEncoding(UTF-8)。四个条件缺一个表现形式还不一样所以这问题特别像玄学。解决先开一张检查表。查数据库表编码SHOW CREATE TABLE确认是utf8mb4查JDBC URL确认有useUnicodetruecharacterEncodingutf8查JSP页面第一行确认% page pageEncodingUTF-8 %查Servlet的doPost里setCharacterEncoding是不是写在第一次getParameter之前。Tomcat 8以上的GET请求已经默认按UTF-8解码不需要额外配置如果你用的是Tomcat 7或改过server.xml才需要手动加URIEncodingUTF-8。这一节的血泪经验是与其在每个Servlet里磨蹭不如直接上第6章那个Filter一次性解决。5.3 Servlet映射“/”把JSP、CSS、图片全拦截URL后缀的取舍现象首页能打开但所有CSS样式全部失效图片裂开控制台刷出一排Failed to load resource: 404。原因web.xml里把某个Servlet的url-pattern配成了“/”等于把所有请求都交给了这个Servlet。JSP文件本身能通过过滤器链但CSS、JS、图片这些静态资源在Servlet里没有对应的处理方式直接404。解决Servlet映射用“*.do”或“/bookList”这种具体路径不要用“/”。如果老师指定了Restful风格的路径设计必须在web.xml里增加一个default servlet的映射兜底静态资源。JSP页面里引用资源不要用相对路径“css/style.css”因为页面所在目录可能和URL路径不一致用绝对路径${pageContext.request.contextPath}/css/style.css才是稳妥写法。图片显示不出来的时候先看浏览器Network面板里的请求URL八成是路径少了项目名。5.4 ClassNotFoundException: com.mysql.jdbc.Driver驱动没进WEB-INF/lib现象代码编译不报错启动Tomcat后一点查询功能就500堆栈第一行是ClassNotFoundException: com.mysql.cj.jdbc.Driver或旧驱动包的com.mysql.jdbc.Driver。原因IDEA里把mysql-connector-java.jar加入Project Structure的Libraries编译期能找到类但部署产物里没有这个jar。Tomcat运行时用的是war或war exploded目录类加载器只从WEB-INF/classes和WEB-INF/lib下找类而不是从IDEA的Library路径找。解决打开Project Structure→Artifacts→Output Layout展开WEB-INF/lib确认mysql驱动jar在里面。如果不在右键点Available Elements里的jar选择Put into WEB-INF/lib。用Maven的项目相对省心只要依赖的scope不是provided打包时会自动带进去。我的习惯是直接把jar复制一份到web/WEB-INF/lib目录下物理路径可见就不会出现这种部署层面的隐藏问题。5.5 JSP页面取不到值先看EL开关再看forward还是redirect现象book_list.jsp页面上${book.name}位置空白或者原样输出“${book.name}”这几个字符登录后个人信息页面${sessionScope.user.username}取不到任何值。原因三个可能。第一JSP页面的EL表达式被禁用了常见于老项目迁移web.xml版本声明低于2.4或者page指令里写了isELIgnoredtrue。第二Servlet里用了sendRedirect跳转request.setAttribute的数据在新请求里不存在这一条专门坑那些不分场景乱用重定向的人。第三实体Bean的getter方法缺失或字段名拼写不一致EL表达式是反射调用getter而不是直接读字段。解决先看浏览器渲染出的HTML源码如果${book.name}原样躺在HTML里那就是EL被禁用在页面顶部加% page isELIgnoredfalse %或者把web.xml的schema版本升级到2.4以上。如果页面空白那就是attribute没到JSP检查跳转方式查询列表用forward、写完状态用redirect。如果前两个都排除了打开Bean类确认每个字段都有对应的getter方法名是getCoverPath而不是getcoverpath。这五个排查点按顺序走一遍90%的取值问题五分钟内能定位。6. 一个收尾技巧用CharacterEncodingFilter把乱码问题一次性埋掉前面避坑章节里提到POST乱码要在取参数前调用setCharacterEncoding。但如果每个Servlet里都写这一行十个Servlet就得写十遍漏一个就有一个功能乱码。这里有一个更干净的方案写一个Filter在请求进入任何Servlet之前统一把编码设好。package com.bookshop.filter; import javax.servlet.*; import javax.servlet.annotation.WebFilter; import java.io.IOException; WebFilter(/*) public class EncodingFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { req.setCharacterEncoding(UTF-8); resp.setCharacterEncoding(UTF-8); chain.doFilter(req, resp); } }这段代码本身只有三句有效逻辑。req.setCharacterEncoding在chain.doFilter之前调用确保后续Servlet取参数时已经是UTF-8解码resp.setCharacterEncoding确保JSP响应给浏览器的内容带UTF-8声明chain.doFilter放行请求继续往下走。三个顺序不能乱把doFilter写在setCharacterEncoding前面Filter就白写了。这里有一个细节值得留意如果Tomcat里同时有多个Filter执行顺序按web.xml里的filter-mapping顺序用WebFilter注解时则按类名顺序逻辑不复杂但要记住Filter的责任是“放行”不能把业务写死在Filter里。这个Filter并不只解决乱码稍微改一下还能做登录拦截。在doFilter里判断request.getSession().getAttribute(user)是不是null如果是就跳回login.jsp。我现在的习惯是不管题目要不要新建Web项目的第一个类永远是EncodingFilter先把编码这件事钉死再开始写业务。往后的项目里只要你坚持这个固定动作中文乱码在开发前期就不会出现而不是等到答辩前夜才发现登录名全是问号。希望这个习惯也能帮你省掉那几个小时的排查时间。本文还有配套的精品资源点击获取