简介:面向初学者的仿小米在线商城ShoppingMall项目资料,采用JSP、Servlet、MySQL、JDBC等Java Web技术整合开发,覆盖商品浏览、详情查看、购物车添加、价格自动计算等典型电商功能,适合用于课程设计、毕业设计或实训项目。压缩包共2个文件,包含项目源码压缩包与商城数据库SQL脚本,整体大小约39.94MB;源码与数据库脚本分开存放,便于导入后快速搭建运行环境。该资料已有28045人学习,属于热度较高的电商类项目。阅读源码可以观察JSP页面、Servlet控制层与JDBC数据访问层的调用关系,理解从前端请求到后端处理的完整流程;SQL脚本提供商品表、用户表、订单表等核心结构和初始数据,配合作者给出的项目文章可快速还原系统环境。资源结构紧凑,适合从零搭建电商系统的学习者对照拆解。
1. JavaWeb仿小米在线商城ShoppingMall:一个Servlet+JSP+MySQL的完整练手闭环
JavaWeb仿小米在线商城ShoppingMall是很多人在学完黑马程序员javaweb笔记之后动手做的第一个完整项目,也是简历上出现频率最高的“javaweb项目完整案例mysql”。它的价值在于不依赖SpringBoot把细节藏起来,而是用Servlet控制请求、JSP渲染页面、JDBC读写MySQL,把用户登录、商品列表、购物车、下单结算这一整条电商链路亲手打通。
我见过不少人把这个项目照抄一遍就跑,最后面试被追问“购物车并发扣库存怎么处理”就卡住。这里要讲清楚的是:怎么从建表到部署完整复现它,以及那些容易让新手翻车的配置和边界。适合已经学完JavaWeb语法、想用完整项目巩固一遍的人。
2. 技术选型与分层:为什么仿小米商城要用Servlet+JSP而不是直接SpringBoot
2.1 技术栈对比:黑马javaweb笔记里的经典组合与SpringBoot的取舍
如果你刚好翻过一篇计算机英文文献里关于SpringBoot和JavaWeb对比的内容,可能会冒出同一个疑问:既然SpringBoot一个注解就能起接口,为什么还折腾Servlet+JSP?我的看法是:仿小米在线商城ShoppingMall这个项目,练的就是Servlet生命周期、HttpSession状态、RequestDispatcher转发、JDBC事务边界这些被框架藏起来的机制。做这个项目时直接用SpringBoot,相当于把阻力最小的一条路让出来——数据库访问被MyBatis接管,参数绑定被注解接管,最后你得到的只是一个没有踩过底层坑的业务demo,面试时很难讲出深度。
常见做法是使用Servlet 3.1 + JSP 2.3 + MySQL 5.7/8.0 + Tomcat 8.5/9。如果你手头有黑马javaweb笔记,里面几乎所有案例都是这个组合。选它的理由有三个:第一,Servlet规范稳定,市面上教材和网课都基于它,遇到问题一搜一大把;第二,JSP在早期JavaWeb项目里承担了模板引擎的角色,能直接看到request和session对象在页面上怎么取值,这对理解“服务端渲染”特别有帮助;第三,MySQL配合Navicat导出sql脚本很方便,适合把完整项目导入导出。等这份代码完全跑通,你再去看MyBatis Plus或者Spring Data JPA,会发现它们本质上还是在解决你手动写过的那几个问题:连接管理、参数映射、事务提交。到那时候,你的视角就不再是“框架实现了一个功能”,而是“框架帮我省了哪些步骤”。
2.2 仿小米商城的前端页面与静态资源组织
仿小米商城ShoppingMall的前端并不需要从零写CSS。小米商城官网早年版本结构不复杂,常见做法是下载一份开源商城模板,把首页、列表页、详情页、购物车页、结算页的HTML改成JSP。需要注意,静态资源不要和JSP页面混在同一个目录。项目里通常有两种布局方式:一是把所有JSP直接放在webapp根目录,二是放在WEB-INF/views下,用Servlet转发进去。我更推荐第二种,原因是用户直接访问WEB-INF下的资源会被Tomcat拒绝,可以避免未登录就打开后台页面这类越权问题。静态文件放在webapp/static/css、webapp/static/js、webapp/static/images,页面里用${pageContext.request.contextPath}/static/...来引用,这样才能保证部署到带contextPath的Tomcat后路径不404。
目录结构可以这样组织:
webapp/ static/ css/ js/ images/ WEB-INF/ views/ user/login.jsp user/register.jsp product/list.jsp product/detail.jsp cart/cart.jsp order/confirm.jsp web.xml这个目录的意思很明确:所有能直接访问的入口只有Servlet路径,JSP一律作为视图放在WEB-INF下面,用户没法在地址栏手输/views/order/checkout.jsp来绕过结算流程。注意WEB-INF下还应该放lib目录。传统JavaWeb项目不使用Maven时,JDBC驱动、Druid连接池、JSTL的jar包都放在WEB-INF/lib下。如果你用IDEA创建的是Dynamic Web Project,IDEA默认会维护一个Libraries集合,但最终部署时还是要把jar拷进lib。这一步经常被忽略,最后Tomcat启动报ClassNotFoundException,排查半天才发现依赖没有打进去。
2.3 项目分层与核心包:Controller、Service、DAO、Entity
仿小米商城这类项目,最忌讳把所有代码塞进一个Servlet里。常见做法是分成五层:entity放数据库映射对象,dao放JDBC操作,service放业务逻辑,web放Servlet控制器,util放工具类。一个标准包结构如下:
com.shoppingmall entity User.java, Product.java, CartItem.java, Order.java, OrderItem.java dao UserDao.java, ProductDao.java, CartDao.java, OrderDao.java service UserService.java, ProductService.java, CartService.java, OrderService.java web UserServlet.java, ProductServlet.java, CartServlet.java, OrderServlet.java, AdminServlet.java util DBUtil.java, PageResult.java, Md5Util.java不要小看这个分层。很多黑马javaweb笔记里的demo,DAO里直接写了System.out.println,Service层空无一物,Servlet里拼SQL字符串。模仿商城项目一旦到了订单模块,你会遇到一个事务要同时插入订单表和订单明细表的问题,如果没有Service层做事务边界,事务就不知道该放在哪里。我见过最典型的写法是在CartServlet里先new OrderDao插入订单,再new OrderItemDao插入明细,两段代码中间隔了好几行其他业务,结果订单表插入成功、明细表插入失败时,数据库里留下半截数据。这种问题在分层清晰的项目里不会出现,因为Service层把这些操作包成了一个原子方法。
2.4 JSP页面里的EL与JSTL:别在页面上写Java脚本
仿小米商城ShoppingMall的页面如果沿用老式写法,会在JSP里看到一堆<% for(int i=0; i<list.size(); i++){ %>。不是说不能用,而是页面一旦复杂,脚本片段和HTML混在一起,浏览器报错时根本分不清是HTML标签闭合问题还是Java语法问题。常见做法是引入JSTL标签库,用<c:forEach>遍历商品列表,用<c:if>判断是否登录,用${fn:length(page.list)}取列表长度。在JSP顶部声明:
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %>注意这个uri在JSP 2.3规范下其实不建议再写java.sun.com,很多Tomcat会报警告,但功能正常。更干净的写法是用jakarta前缀,不过为了兼容Tomcat 8.5,我一般还是按传统写法。JSTL标签不仅能简化页面,还能避免在JSP里直接调用Service,一箭双雕。页面里唯一允许出现Java代码的地方,是偶尔需要在<c:set>里做一点数值计算,其他全部交给EL表达式。
3. 从建库到JDBC:把基础设施一次写对
3.1 数据库表设计:用户、商品、分类、购物车、订单五张核心表
仿小米在线商城ShoppingMall的数据库设计,最常用到的是五到六张表。参考黑马javaweb笔记里常见的数据模型,我一般这样设计:user表存账号密码和收货信息,category表存商品分类,product表存商品名称、价格、库存、缩略图和上架状态,cart_item表存用户加购的商品和数量,order表存订单主信息,order_item表存订单里的商品快照。之所以订单明细里存商品快照而不是直接关联商品ID,是因为商品价格和名称随时可能改,订单历史必须保留下单那一刻的数据。
下面是精简建表SQL,我刻意去掉了外键,用逻辑外键代替物理外键,这样导入数据时不会被约束卡住。
CREATE TABLE `user` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `username` VARCHAR(30) NOT NULL UNIQUE, `password` CHAR(32) NOT NULL COMMENT 'MD5后的密文', `phone` VARCHAR(20), `address` VARCHAR(255), `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `category` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `name` VARCHAR(50) NOT NULL, `sort` INT DEFAULT 0 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `product` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `category_id` INT NOT NULL, `name` VARCHAR(100) NOT NULL, `subtitle` VARCHAR(200), `price` DECIMAL(10,2) NOT NULL, `stock` INT NOT NULL DEFAULT 0, `main_image` VARCHAR(255), `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1上架 0下架', `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, KEY `idx_category_status` (`category_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `cart_item` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `user_id` INT NOT NULL, `product_id` INT NOT NULL, `quantity` INT NOT NULL DEFAULT 1, `checked` TINYINT NOT NULL DEFAULT 1, UNIQUE KEY `uk_user_product` (`user_id`, `product_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `order` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL UNIQUE, `user_id` INT NOT NULL, `total_price` DECIMAL(10,2) NOT NULL, `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已发货 3已完成 4已取消', `receiver_name` VARCHAR(30), `receiver_phone` VARCHAR(20), `receiver_address` VARCHAR(255), `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `order_item` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `order_id` INT NOT NULL, `product_id` INT, `product_name` VARCHAR(100) NOT NULL, `product_image` VARCHAR(255), `price` DECIMAL(10,2) NOT NULL COMMENT '下单时快照价格', `quantity` INT NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;表设计有几个参数要注意。user.password用CHAR(32),对应后文用MD5加密后的32位十六进制,不要存明文。price用DECIMAL(10,2),不要用FLOAT,否则购物车算总价会出现0.1+0.2的误差。cart_item上加了(user_id, product_id)唯一键,同一用户第二次加购时应该走UPDATE而不是INSERT。product表里(category_id, status)的组合索引,是为了支持“分类下只看上架商品”的查询,后文分页搜索也依赖这个索引。很多同学在建表时喜欢给每列都加VARCHAR(255),但下单时间、状态这类列用DATETIME和TINYINT更合适,既节省空间,排序时语义也更明确。
3.2 写一个够用的JDBC工具类:Druid连接池的配置
传统JavaWeb项目里用Druid连接池是很常见的做法,配置简单,而且有监控页可以帮助你排查连接泄漏。先准备src/druid.properties:
driverClassName=com.mysql.cj.jdbc.Driver url=jdbc:mysql://localhost:3306/shoppingmall?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username=root password=123456 initialSize=5 maxActive=20 maxWait=3000 minIdle=5 validationQuery=SELECT 1注意url里的useSSL=false在MySQL 8.0下是必须的,allowPublicKeyRetrieval=true解决的是MySQL 8.0 caching_sha2_password插件导致连不上报Public Key Retrieval is not allowed的问题。serverTimezone必须显式设置,否则你拿到的java.sql.Date会比北京时间差8小时。下面是对应的DBUtil类:
public class DBUtil { private static DruidDataSource dataSource; static { try (InputStream in = DBUtil.class.getClassLoader() .getResourceAsStream("druid.properties")) { Properties props = new Properties(); props.load(in); dataSource = (DruidDataSource) DruidDataSourceFactory.createDataSource(props); } catch (Exception e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } }这段代码的逻辑说明:把连接池初始化的动作放到静态代码块里,进程启动时只执行一次,避免每次请求都重新读properties。getConnection返回的是Druid包装后的代理连接,调用close()时不是真的关闭连接,而是归还到池里。很多新手在这里犯的错是在Servlet里手动DriverManager.getConnection,用完不关,最后把MySQL连接数打满。有了Druid,至少在连接管理上帮你兜了一层。如果发现连接还是不够用,优先检查是不是哪段try-with-resources没写,或者事务里catch了异常却没有回滚,导致连接一直不释放。
3.3 分页与结果封装:列表页的地基
商品列表、订单列表都需要分页,常见做法是把分页参数封装成PageResult对象。不要每次都分别在Servlet里拼pageNo,页面里再重复统计totalPage,后面维护非常痛苦。这个类可以长这样:
public class PageResult<T> { private int pageNo; private int pageSize; private long total; private List<T> list; public PageResult(int pageNo, int pageSize, long total, List<T> list) { this.pageNo = pageNo; this.pageSize = pageSize; this.total = total; this.list = list; } public long getTotalPage() { return total % pageSize == 0 ? total / pageSize : total / pageSize + 1; } // 省略getter/setter }使用时的查询模板是:
SELECT id, name, price, main_image FROM product WHERE status = 1 AND category_id = ? ORDER BY created_at DESC LIMIT ?, ?LIMIT的第一个参数是偏移量,计算方式是(pageNo-1)pageSize,第二个参数是pageSize。注意LIMIT后面不能直接用JSTL表达式拼接,必须用PreparedStatement的setInt传参,否则就是SQL注入的活靶子。分页统计总数时用SELECT COUNT() FROM product WHERE status = 1 AND category_id = ?,再把total赋给PageResult。只要把PageResult存进request.setAttribute("page", result),JSP页面里就可以直接用${page.list}、${page.totalPage}取值,不需要在页面里再写计算逻辑。页面底部生成页码时,可以循环${page.totalPage}次,当前页高亮,其他页拼链接。注意页码不能任由前端传,超过总页数时要截断到最后一页,否则下一页是空的。
3.4 初始化数据与MD5加密脚本:让项目导入就能跑
数据库建好之后,顺手做一套初始化脚本,可以省掉后面反复造数据的时间。常见做法是在项目根目录放sql/init_data.sql,里面插入管理员账号、测试用户、少量商品和一个“小米MIX”风格的手机分类。密码不要写明文,直接用事先算好的MD5值。比如明文123456对应的MD5是e10adc3949ba59abbe56e057f20f883e,初始化脚本里写:
INSERT INTO user (username, password, phone, address) VALUES ('admin', 'e10adc3949ba59abbe56e057f20f883e', '13800000000', '北京市');这样做的另一个好处是,你可以把这套初始化脚本和建表脚本一起提交到Git,别人clone下来直接执行两个sql文件就能跑起来,不用再问你“后台密码是多少”。MD5加密虽然已经被证明不安全,但做练习项目足够了,面试时能说清楚“为什么不用明文”才是重点。如果你想把安全性做得更好,就加盐:update user set password = md5(concat(salt, password)),salt存在user表里。我不建议在这个项目里上BCrypt,因为JDBC手动实现起来比较啰嗦,容易在密码校验上出错。
4. 核心功能落地:登录、商品列表、购物车与下单
4.1 用户登录与Session管理:验证码和超时是两座山
仿小米商城ShoppingMall的登录模块,最容易出问题的不是密码验证,而是三件事:密码加密、中文参数编码、Session超时。密码这块,常见做法是在Service层用Md5Util把用户输入的明文转成32位密文再比对,永远不要查SELECT * FROM user WHERE password = '输入的明文'。验证码我建议用Java内置的BufferedImage生成,不要引第三方包,实现一个GET请求输出图片、把答案放进Session、登录时再比较的流程就够了。
下面是登录Servlet的核心代码:
@WebServlet("/login") public class LoginServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { String username = req.getParameter("username"); String password = Md5Util.md5(req.getParameter("password")); String code = req.getParameter("code"); String sessionCode = (String) req.getSession().getAttribute("code"); if (sessionCode == null || !sessionCode.equalsIgnoreCase(code)) { req.getSession().setAttribute("msg", "验证码错误"); resp.sendRedirect(req.getContextPath() + "/login.jsp"); return; } User user = userService.login(username, password); if (user == null) { req.getSession().setAttribute("msg", "用户名或密码错误"); resp.sendRedirect(req.getContextPath() + "/login.jsp"); } else { HttpSession session = req.getSession(); session.setAttribute("loginUser", user); session.setMaxInactiveInterval(30 * 60); resp.sendRedirect(req.getContextPath() + "/product/list"); } } }这段代码有几个细节:验证码比较统一转成大写或小写,防止用户区分大小写;登录失败用redirect而不是forward,是为了避免刷新页面时表单重复提交;Session里只存User对象,不要存整个购物车集合,购物车数据应该从cart_item表查,否则Session一旦过期购物车就丢了。session.setMaxInactiveInterval(30 * 60)设置30分钟超时,单位是秒。有的同学在web.xml里也配session-timeout,两处都配时实际生效的以更严格的那个为准,建议只在一个地方配,别同时维护两套配置。
4.2 商品列表与搜索分页:SQL优化从索引开始
商品列表页在仿小米商城首页要给用户看到“最新上架”和“按分类筛选”,对应的Servlet处理逻辑是:接收categoryId和pageNo,调用ProductService查询当前页数据,把结果放进request域以后forward到list.jsp。下面是ProductServlet的doGet:
@Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { int categoryId = 0; String categoryIdStr = req.getParameter("categoryId"); if (categoryIdStr != null && !categoryIdStr.isEmpty()) { categoryId = Integer.parseInt(categoryIdStr); } int pageNo = 1; String pageNoStr = req.getParameter("pageNo"); if (pageNoStr != null && !pageNoStr.isEmpty()) { pageNo = Integer.parseInt(pageNoStr); } PageResult<Product> page = productService.findByCategory(categoryId, pageNo, 12); req.setAttribute("page", page); req.setAttribute("categoryId", categoryId); req.getRequestDispatcher("/WEB-INF/views/product/list.jsp").forward(req, resp); }这里的pageSize设成12,是小米商城早年间网格布局的常用数字,4列3行正好铺满。categoryId传0表示“全部分类”,在SQL里用条件判断:if (categoryId == 0) 就不拼category_id约束,只保留status = 1。很多同学在这里拼SQL时习惯用字符串相加,我建议用MyBatis那种思路:先给一个基础SQL,再按条件追加片段。虽然Servlet里没有标签库,但可以把SQL片段写在ProductDao的findByCategory方法里,用List
还有一点容易踩坑:Integer.parseInt在参数不是数字时会抛NumberFormatException,页面直接500。标准做法是先try-catch,解析失败时给默认页号1。黑马javaweb笔记数据里多半是用正则校验,但我觉得更稳妥的是catch异常后重置默认值,因为参数一旦来自恶意输入,正则也可能被绕过。
4.3 购物车加购与下单:事务边界必须放在Service层
购物车加购的逻辑是:先判断用户是否登录,未登录跳登录页;再查cart_item表里有没有该用户和该商品的记录,有则UPDATE quantity加一,没有则INSERT。注意不能把商品价格快照放到cart_item,购物车只存ID和数量,价格永远实时取product表。下单模块是这个项目里事务最重的部分:要操作order表、order_item表、product库存表、cart_item表,四张表必须在一个事务里完成。
我在ServiceImpl里写的一个简化下单方法:
// 事务边界:订单、订单项、库存扣减、购物车删除要么全部成功,要么全部回滚 public Order createOrder(Long userId, Long addressId, List<CartItem> cartItems) { Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); conn.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED); Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setStatus(0); order.setTotalPrice(calculateTotalPrice(cartItems)); OrderDao orderDao = new OrderDao(conn); order.setId(orderDao.insert(order)); for (CartItem item : cartItems) { int rows = productDao.deductStock(item.getProductId(), item.getQuantity()); if (rows == 0) { throw new RuntimeException("库存不足"); } orderDao.insertOrderItem(order.getId(), item); cartDao.delete(item.getId()); } conn.commit(); return order; } catch (Exception e) { if (conn != null) { conn.rollback(); } throw new RuntimeException(e); } finally { if (conn != null) { conn.close(); } } }参数说明:设置setAutoCommit(false)后,所有JDBC操作都在同一个事务里;TRANSACTION_READ_COMMITTED是读已提交隔离级别,能避免读到别的事务未提交的脏数据,对商城场景够用。deductStock返回受影响行数,如果库存不足,SQL里的stock >= ?条件让UPDATE影响0行,此时立刻抛异常触发回滚。这种“乐观扣减”比先SELECT再UPDATE更稳,因为减少了检查与更新之间的并发窗口。注意这里的catch里不能只打印异常,必须调用rollback,否则连接归还到连接池时事务还开着,下一个请求拿到这个连接会看到奇怪的数据。
提示:如果你在IDEA里给这段Service代码加了@Transactional的Spring注解,但它实际上没有生效,多半是因为项目还是纯Servlet环境,根本没引入Spring。先想清楚项目的运行环境,再看注解是否需要。
4.4 后台商品管理:图片上传与部署路径
后台管理模块一般是一个AdminServlet,拦截/admin/*路径。商品上下架最简单,UPDATE product SET status = ? WHERE id = ?。麻烦的是图片上传。传统做法是用Apache Commons FileUpload组件,但更简单的方案是让前端传一个图片URL,或者用Base64编码直接存数据库字段。如果是毕设Demo,我建议不要做服务器本地磁盘保存,原因是IDEA部署时Tomcat会临时拷贝一份webapp到catalina.base下的webapps目录,你保存在本开发目录的图片在重启后可能被清掉,这是很典型的翻车点。
如果你一定要上传本地,常见做法是保存到Tomcat安装目录外的一个绝对路径,比如D:/upload/,然后通过Tomcat的虚拟目录映射访问。在conf/server.xml里加一行Context配置,或者用Spring Boot那套静态资源映射思路,在Servlet里写一个/img/*的映射。做这个项目时我一般选择保存到数据库BLOB字段或直接转Base64字符串,虽然查询慢一点,但对课堂项目来说数据迁移最方便,不用拷图片目录。
4.5 用Filter做登录拦截:比在每个Servlet里判断更省事
登录拦截是仿小米商城必做的功能。常见错误是在每个Servlet开头写if (session.getAttribute("loginUser") == null),漏写一个就留下越权入口。更好用的做法是写一个LoginFilter,拦所有需要登录的路径,比如/order/、/cart/、/user/*。后台管理再单独写一个AdminFilter,校验Session里是否存在admin标识。
@WebFilter({"/cart/*", "/order/*", "/user/*"}) public class LoginFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; HttpSession session = request.getSession(false); if (session == null || session.getAttribute("loginUser") == null) { response.sendRedirect(request.getContextPath() + "/login.jsp"); return; } chain.doFilter(req, resp); } }注意getSession(false)表示不主动创建Session,如果一个请求本来没有会话,就不会无端生成一个带JSESSIONID的cookie。Filter的urlPatterns可以写多个,用通配符/*匹配子路径。写完之后,你还要自己模拟一次“未登录直接访问购物车”的请求,确保Filter真的生效。很多人Filter写在包路径里,但web.xml里没有配置扫描,或者用了@WebFilter注解却忘了在Servlet上配@WebServlet,导致整个拦截逻辑根本没有注册。
5. 避坑排查:IDEA运行JavaWeb项目的常见问题与处理
IDEA运行JavaWeb项目配置这块,很多坑不是代码问题,而是工具链不一致。下面这5条是我在带学员做仿小米商城时被问得最多的,每一条都按现象、原因、解决的顺序写。如果你之前是靠头歌实训答案过的JSP练习,到这里会发现真正的分水岭就是排错能力。
5.1 Tomcat端口被占用导致项目起不来
现象:IDEA启动Tomcat时日志报Address already in use: JVM_Bind,或者8080端口被其他进程占用,点Run后控制台一闪而过。原因:之前残留的Tomcat进程没被杀掉,或者别的服务占了8080。解决:先netstat -ano | findstr 8080找到PID并结束进程,再把IDEA里Tomcat的HTTP port改到8081,避免和本机已有服务冲突。这一步几乎是每个第一次用IDEA运行JavaWeb项目配置的人都会遇到的事,别急着重装Tomcat,先看端口。还有一个隐藏坑:IDEA里配置Tomcat时,Application server如果指向了Tomcat的bin目录而不是安装根目录,启动时会找不到catalina脚本,控制台报Cannot find /apache-tomcat/conf/server.xml,这时候重新选择一下Tomcat根目录就行。
5.2 请求参数中文乱码:从浏览器到MySQL要过三关
现象:登录用户名是中文时,Servlet里getParameter取出来是乱码;商品名称在列表页显示一堆问号;往数据库里插入中文报Incorrect string value。原因:三个环节的字符集不一致。第一关是Tomcat 8.5之后POST请求默认UTF-8,但GET请求的queryString还是按ISO-8859-1解码,所以GET传中文必须在Servlet里先按ISO-8859-1还原再转UTF-8;第二关是JSP页面要写pageEncoding="UTF-8";第三关是MySQL建库和连接URL都要utf8mb4。解决:写一个CharsetFilter,统一在doFilter里设置req.setCharacterEncoding("UTF-8"),resp.setContentType("text/html;charset=UTF-8")。不要每个Servlet都设置一遍,也不要依赖JSP contentType属性。还有一点,IDEA里Tomcat的运行配置VM options加-Dfile.encoding=UTF-8,能顺便解决控制台输出乱码。
5.3 静态资源404:CSS样式全部丢失
现象:页面能打开,但所有CSS、JS、图片都404,按F12看请求路径是/css/style.css开头的相对路径。原因:JSP在/WEB-INF/views下,浏览器地址栏路径是/product/list,页面里的相对路径css/style.css会解析成/product/css/style.css,当然找不到。解决:所有静态资源引用必须写绝对路径,表达式为${pageContext.request.contextPath}/static/css/style.css,或者在JSP顶部用<c:set var="ctx" value="${pageContext.request.contextPath}"/>,后面统一用${ctx}/static/...。另一个坑是webapp/static目录不存在,很多模板解压后直接把静态文件放在根目录,你没调整就把旧路径抄过来,也会404。如果用的是IDEA,还要确认Artifact配置里有没有把webapp目录下的static文件夹打进去,有时候开发环境正常,部署到WAR包就少一层目录,就是因为Artifact输出的资源没包含static。
5.4 Druid监控里Active连接数打满
现象:运行一段时间后页面变慢,数据库连接池报wait millis 3000,active 20,pool 20,所有请求都卡在获取连接。原因:某个Service方法里conn没有在finally关闭,异常时连接没归还;或者事务内把conn当作成员变量放在Dao里,没有确保同一个事务中多个Dao共享同一个连接。解决:先看Druid监控页面/druid/index.html里的活跃连接数,如果一直不降,基本可以断定有连接泄漏。排查方法是把每个Dao的close写成finally块,或者更彻底一点,用commons-dbutils的QueryRunner,它内部会自动释放连接。最隐蔽的一种是事务方法里conn.setAutoCommit(false)后catch到异常只rollback没有close,finally里再close,我遇到过一次,就是因为重复close导致连接被错误归还。具体表现是监控里的逻辑连接数波动异常,但代码看了一遍都close了,最后发现是同一个conn被Service传给了两个Dao,两个Dao的close都被执行,Druid代理连接连续归还两次直接报错。
5.5 Servlet版本与Tomcat版本不匹配导致报错
现象:Eclipse或IDEA里创建的是Servlet 3.1版本,但本机Tomcat是7.0,启动后报java.lang.NoSuchMethodError,或者@WebServlet注解完全不起作用。原因:Tomcat 7只支持Servlet 3.0,Tomcat 8.5支持Servlet 3.1,Tomcat 9对应Servlet 4.0。解决:检查项目的web.xml头部版本号,如果是3.1就至少用Tomcat 8.5;如果是Tomcat 10及以上,包名从javax.servlet变成jakarta.servlet,你的代码要批量改import。做仿小米商城ShoppingMall时我建议固定Tomcat 8.5 + JDK 1.8 + Servlet 3.1,这是配合黑马程序员javaweb笔记最保守的组合,也最适合面试时讲清楚原理。另外注意:IDEA里创建项目时选择的JavaEE版本要和Tomcat对应,别建了一个JavaEE 8的Project又让Tomcat 7去跑,这种版本错位比代码报错更难排查。
6. 进阶与验证:把仿小米商城变成简历上的亮点
6.1 给商品详情加一层Redis缓存
当项目能跑通后,最值得做的第一个优化是给商品详情页加Redis缓存。代码并不复杂:详情接口先查Redis,key用product:detail:{id},查不到再查MySQL并把结果反序列化后写入缓存,设置600秒过期。这样一个很小的改动,就解决了“每次刷新都全表查一遍”的性能问题,面试时也能顺势引出缓存穿透、缓存雪崩的讨论。
String key = "product:detail:" + productId; String json = redis.get(key); if (json == null) { Product product = productDao.findById(productId); redis.setex(key, 600, JSON.toJSONString(product)); return product; } return JSON.parseObject(json, Product.class);这里要注意两点:写缓存前先判断product是否为null,不要把空对象写进去;如果商品下架或价格变化,记得主动删掉redis里的key,否则用户看到的还是旧数据。旧的不对,新的不来,缓存设计里删除比更新更可靠。
6.2 上线前的验证清单
我每次做完一个JavaWeb项目,都会按这张清单过一遍:未登录直接访问/order/checkout是否被拦截;购物车数量改成负数提交是否被后端校验;商品库存为0时能否继续下单;两个用户同时下单同一件商品会不会超卖;后台管理接口是否可以用普通用户身份直接访问。这些点如果答不上来,就立刻回到代码里补充。用这套方法验证完,简历上写“掌握事务并发控制”才有底气。如果时间紧,至少跑一遍从注册、登录、加购、结算到支付的完整链路,别只点开首页就说跑通了。
6.3 从仿小米商城到下一个项目
如果你时间充足,下一步建议把整套代码迁到Maven多模块,再写一个Spring Boot版本对照,你会发现Servlet里的很多手动操作,框架只是换了一种写法。我现在做新项目还会回头翻这套代码,看当时哪些设计是拍脑袋定的,哪些坑是真正值得写进笔记的。做仿小米商城ShoppingMall不是终点,把它讲明白才是。希望帮到你。
本文还有配套的精品资源,点击获取