简介:这是一套基于JSP、Servlet与MySQL实现的JavaWeb购物商城项目,面向学习JavaWeb开发的学生和初级开发者,也可作为课程设计或毕业设计参考。压缩包共270个文件,约7.08MB,包含31个java源文件、9个jsp页面、31个class、21个xml配置、10个jar依赖,以及css、js、图片和1个sql数据库脚本,覆盖视图展示、控制器处理、数据库访问和前端静态资源等部分。已有1288人学习下载,说明其作为完整练习案例具有一定参考价值。项目围绕商品浏览、购物车管理、订单提交、用户登录注册和管理员后台等流程展开,读者可据此理解JSP与Servlet协作、DAO分层、MySQL表结构设计及Tomcat部署要点,适合用来对照源码梳理JavaWeb项目的组织方式并开展二次修改。
1. 一套跑不起来的 JavaWeb 购物商城,问题多半不在代码
前阵子有人找我帮忙查看一套 JavaWeb 购物商城的源码。压缩包解开后,文件夹命名很直白,README 只有三行启动说明。对方说折腾了两天没跑起来,我扫了一眼配置就发现问题所在:本机是 JDK 17,项目却是按 JDK 8 写的,MySQL 装的是 8.x,驱动包还停在 5.1 版本。这类购物商城项目的典型构成其实很稳定,前台有商品列表、分类筛选、关键词搜索、购物车、下单、订单查询,后台能上架商品、改价格、发货、看订单列表。它适合两类人:一类是想完整走一遍 Servlet 请求链路、把 HTTP 到数据库这条线搞明白的新手;另一类是手里有个能改的架子,想在上面加促销、加优惠券、换成前后端分离的熟手。下面按能跑、能读、能改的顺序拆。
2. 技术栈与工程骨架:这套购物商城的目录怎么读
拿到一个 JavaWeb 购物商城,先别急着点运行。花十分钟把技术栈和目录结构摸清楚,后面改代码时能省下大量翻找时间。判断一个 JavaWeb 商城用的是哪套技术,不用逐行读代码,看三个地方就够了:依赖清单、WEB-INF 下的配置文件、DAO 包的类名。手写 JDBC 的项目,DAO 里必然有 Connection、PreparedStatement、ResultSet 三件套;SSM 项目会有 applicationContext.xml 和一堆 Mapper.xml;Spring Boot 版本则有一个 main 方法启动类和 starter 依赖。三种形态的调试方式完全不同,先归类再动手,这是我拆任何 JavaWeb 项目的固定第一步。
2.1 技术选型:Servlet + JSP + JDBC 与 SSM 是两条常见路线
课程设计和入门实战类的购物商城,绝大多数是 Servlet + JSP + JDBC 的组合,也就是常说的原生 JavaWeb。它的分层逻辑非常直白:JSP 负责渲染页面,Servlet 收请求做参数校验,Service 编排业务,DAO 用 JDBC 拼 SQL 访问数据库,实体类在各层之间传递。好处是每一行业务代码都裸露在眼前,没有框架帮你隐藏任何细节,出了问题从堆栈第一行就能定位到具体的 Servlet 方法。代价是代码量大、重复度高,一个商品表的增删改查就要写四五个 DAO 方法。
另一条路线是 SSM(Spring + SpringMVC + MyBatis),近几年的毕设和外包项目里很常见。它的标志是applicationContext.xml、spring-mvc.xml、mybatis-config.xml三个配置文件,DAO 层换成 Mapper 接口加 XML 映射。这种结构下,购物车的读写、订单的事务控制大多交给 Spring 容器管理,改动时更多是调整注解和 XML,而不是在 Servlet 里手写conn.commit()。还有第三种,Spring Boot 单体,pom 里是 spring-boot-starter-web,模板引擎可能是 Thymeleaf 或直接返回 JSON 给前端页面。
对第一次读商城源码的人来说,原生 JDBC 版本反而更容易上手,因为所有环节都能看到;但真要往里加功能,SSM 或 Spring Boot 的改动成本明显更低。判断清楚手里这份是哪一种,直接决定后面第 4 章的下单链路该怎么读。
2.2 目录结构:从 webapp 到 src 的每一层对应什么
一个标准 Maven 布局的 JavaWeb 购物商城,目录大致如下。这段结构是我从多份同类项目里归纳出来的通用形态,具体项目可能少一两个包,但分层逻辑基本一致:
shop-mall/ ├── pom.xml # 依赖清单,判断技术栈的第一入口 ├── src/main/java/ │ ├── com/shop/controller/ # Servlet 层,接收请求、封装参数 │ ├── com/shop/service/ # 业务层,事务边界基本都在这 │ │ └── impl/ │ ├── com/shop/dao/ # 数据访问层,SQL 都写在这 │ │ └── impl/ │ ├── com/shop/entity/ # 实体类,与数据库表一一对应 │ ├── com/shop/util/ # 工具类:数据库连接、MD5、分页、日期 │ └── com/shop/filter/ # 过滤器:编码、登录校验 ├── src/main/resources/ │ ├── db.properties # 数据库连接四要素 │ └── mapper/ # MyBatis 版本的 XML 映射放这里 ├── src/main/webapp/ │ ├── WEB-INF/ │ │ ├── web.xml # Servlet 注册、过滤器配置 │ │ └── lib/ # 非 Maven 项目的 jar 包直接扔这 │ ├── css/ js/ images/ # 静态资源 │ ├── index.jsp # 首页,商品列表入口 │ ├── cart.jsp # 购物车页 │ ├── order.jsp # 订单确认与提交页 │ └── admin/ # 后台管理页面 └── sql/ └── shop.sql # 建库建表脚本,导入后才能启动读目录的顺序我一般是从entity开始,再到dao,最后看controller。因为实体类决定了数据模型,DAO 决定了能查什么,控制器的流程看完,一个请求的完整路径就闭合了。反过来从 JSP 看起容易被页面上一堆<c:forEach>标签绕晕,尤其是商品列表页里嵌套了分类和分页两重循环。
这里有个容易被忽略的点:webapp/WEB-INF/lib目录。非 Maven 管理的传统项目,依赖包全靠手工丢进这个文件夹,一旦少了 mysql-connector 的 jar,启动时不会报错,直到第一次查数据库才抛ClassNotFoundException。所以看项目结构时,如果发现没有 pom.xml,一定要把 lib 目录里的 jar 列一遍。
2.3 关键配置逐个过:web.xml、db.properties、pom.xml
三份配置文件决定项目能不能起来。先看db.properties,这是最常被改错的地方:
# 数据库连接四要素,MySQL 8.x 的驱动类和 URL 与 5.x 不同 jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://127.0.0.1:3306/shop_mall?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false jdbc.username=root jdbc.password=123456第一行的驱动类名很关键,com.mysql.cj.jdbc.Driver是 MySQL 8 的写法,5.1 版本是com.mysql.jdbc.Driver。写错了不会在编译期报错,只会在启动后第一次取连接时抛异常。URL 里的serverTimezone也是 8.x 才要求的参数,漏了会报时区无法识别。useSSL=false是本地开发环境的常见配置,避免控制台刷一堆连接警告。
再看web.xml,Servlet 3.0 之后可以用@WebServlet注解省掉这部分,但老项目基本还是 XML 注册:
<!-- 编码过滤器必须放在最前面,否则中文参数大概率乱码 --> <filter> <filter-name>encodingFilter</filter-name> <filter-class>com.shop.filter.EncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> </filter> <filter-mapping> <filter-name>encodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> <!-- 商品列表 Servlet 的映射 --> <servlet> <servlet-name>productListServlet</servlet-name> <servlet-class>com.shop.controller.ProductListServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>productListServlet</servlet-name> <url-pattern>/product/list</url-pattern> </servlet-mapping><url-pattern>直接决定浏览器里访问的路径。上面这个配置对应的是http://localhost:8080/项目名/product/list。很多新手改了类名却忘了同步改 XML 里的servlet-class,启动时报ClassNotFoundException,日志里只有一行,很容易被当成依赖缺失。
pom.xml 里要关注的是打包方式和依赖范围。购物商城最终要打成 war 包丢进 Tomcat,<packaging>必须是 war;servlet-api 和 jsp-api 的 scope 必须是 provided,因为这两个包由 Tomcat 自带,打进 war 包里反而会冲突,导致启动时类加载报错。这两个细节我在第 5 章的排查清单里还会再提一次。
3. 数据建模与 DAO 层:商品、用户、订单三张主表怎么落地
购物商城的业务复杂度几乎全压在数据模型上。商品要有分类、有库存、有上下架状态;订单要有主表和明细表,一张订单对应多个商品;用户要有普通用户和管理员两种角色。表设计得好,DAO 层就是机械的增删改查;表设计得差,一个订单查询能写出三层嵌套 join。下面这套表结构是同类项目里比较通行的做法,字段名可能不同,但关系一定跑不掉。
3.1 建表脚本:主键、金额类型、订单明细的拆法
-- 商品分类表,支持一级分类够用,做多级加 parent_id 即可 CREATE TABLE category ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, sort_order INT DEFAULT 0 ); -- 商品表,价格用 DECIMAL 不用 FLOAT,浮点数算钱迟早出问题 CREATE TABLE product ( id INT PRIMARY KEY AUTO_INCREMENT, category_id INT NOT NULL, name VARCHAR(100) NOT NULL, price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0, cover_img VARCHAR(255), status TINYINT DEFAULT 1, -- 1 上架 0 下架 create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_category (category_id) ); -- 订单主表,存总金额和收货信息 CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, -- 对外展示的订单号 user_id INT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status TINYINT DEFAULT 0, -- 0 待付款 1 已付款 2 已发货 3 已完成 receiver VARCHAR(50), phone VARCHAR(20), address VARCHAR(255), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 订单明细表,一个订单多条记录,商品信息做快照冗余 CREATE TABLE order_item ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, product_id INT NOT NULL, product_name VARCHAR(100), -- 冗余快照,商品改名后历史订单不受影响 price DECIMAL(10,2), -- 下单时的成交价,不是当前售价 quantity INT NOT NULL );几个字段的取舍值得说清楚。金额统一用DECIMAL(10,2),不要用FLOAT或DOUBLE,浮点类型的精度误差在累加多件商品总价时会体现出来,最后支付金额对不上账,这类问题排查起来很麻烦。订单明细表里的product_name和price是故意冗余的,商品下架或改名之后,历史订单仍然要显示当时的名字和成交价,如果只靠product_id去 join 商品表,展示出来的就是当前数据,用户看到的订单会“变样”。
订单主表的id和order_no也分开。id是自增主键,内部使用;order_no是对外展示的订单号,常见做法是“日期 + 用户 ID 后四位 + 随机数”或者干脆用 UUID 截断。直接用自增 ID 当订单号有两个麻烦:一是暴露了订单总量,二是并发下容易被人猜到别人的订单。
3.2 实体类与 DAO:把 ResultSet 映射成对象的笨办法与新办法
实体类就是表结构的 Java 映射,字段名对应列名,驼峰与下划线自动转换:
// Product.java,字段与 product 表一一对应 public class Product { private Integer id; private Integer categoryId; private String name; private BigDecimal price; // 与数据库 DECIMAL 对应,不要用 double private Integer stock; private String coverImg; private Integer status; // getter / setter 省略 }原生 JDBC 的 DAO 写法很固定,一个查询方法大概长这样:
public List<Product> findByPage(Integer categoryId, int page, int size) { List<Product> list = new ArrayList<>(); // 分类为 null 表示查全部,SQL 拼两套,避免 where 后面跟 1=1 的坏习惯 String sql = categoryId == null ? "SELECT * FROM product WHERE status = 1 ORDER BY id DESC LIMIT ?, ?" : "SELECT * FROM product WHERE status = 1 AND category_id = ? ORDER BY id DESC LIMIT ?, ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { int idx = 1; if (categoryId != null) { ps.setInt(idx++, categoryId); } ps.setInt(idx++, (page - 1) * size); // 偏移量 = (页码-1) * 每页条数 ps.setInt(idx, size); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { list.add(mapRow(rs)); // 单行映射抽成独立方法,方便复用 } } } catch (SQLException e) { throw new RuntimeException("商品分页查询失败", e); } return list; }这段代码有几个点可以当成模板抄。try-with-resources保证 Connection、PreparedStatement、ResultSet 三个资源按逆序关闭,漏关连接在并发稍高的场景下很快就把连接池耗尽,表现是页面卡住不报错,这是新手最常遇到的异常现象之一。mapRow单独抽出来是为了让findById、findByPage、search几个方法共用同一套映射逻辑,字段加一个就只改一处。
idx这个游标变量的用法也值得记一下。参数按 SQL 里?出现的顺序绑定,分类查询比全量查询多一个参数,用递增游标就不用手写ps.setInt(1, ...)、ps.setInt(2, ...)去数位置,改 SQL 时不容易错位。
MyBatis 版本里,这些代码会变成 Mapper 接口加 XML:
<select id="findByPage" resultType="com.shop.entity.Product"> SELECT * FROM product WHERE status = 1 <if test="categoryId != null"> AND category_id = #{categoryId} </if> ORDER BY id DESC LIMIT #{offset}, #{size} </select>#{}是预编译占位符,${}是字符串拼接,后者有注入风险,商品搜索框如果用了${}去拼关键词,输入一个单引号就能把 SQL 结构打乱。看到 Mapper 里出现${}并且拼的是用户输入,基本上就是一个需要修复的缺陷。
3.3 分页与模糊搜索:limit 偏移量的算法
商品搜索框是用户输入直接进 SQL 的地方,也是中文和特殊字符最容易出问题的地方。搜索的 SQL 通常写成:
// 关键词模糊查询,前后都加 %,配合 LIMIT 做分页 String keyword = "%" + request.getParameter("keyword").trim() + "%"; String sql = "SELECT * FROM product WHERE status = 1 AND name LIKE ? ORDER BY id DESC LIMIT ?, ?"; ps.setString(1, keyword); ps.setInt(2, (page - 1) * size); ps.setInt(3, size);关键点在LIKE ?而不是LIKE '%?%'。后者在 PreparedStatement 里不生效,?会被当成普通字符,查出来永远是空。正确写法是把通配符加在 Java 侧的参数字符串上,再整体绑定。这个细节我在不止一份源码里见过写错,症状是搜索框输入任何词都没结果,但控制台不报错。
分页偏移量的算法是(page - 1) * size,页码从 1 开始。page和size都要在 Servlet 层做兜底,用户手动改 URL 里的page=0会算出负偏移量,MySQL 直接抛语法错误。常见做法是page = Math.max(1, page),size限制在 5 到 50 之间。
分页还需要一个总数查询配合,SELECT COUNT(*) FROM product WHERE status = 1,拿到总数后算出总页数传给 JSP 渲染页码条。这两条 SQL 条件必须完全一致,否则会出现“第 3 页是空的但页码条显示有 5 页”这种看起来很奇怪的现象。
4. 下单主链路:从加入购物车到订单落库的完整请求
购物商城的核心链路只有一条:用户点“加入购物车”,进购物车页,点“去结算”,填收货信息,提交订单,订单落库、库存扣减、购物车清空。这条链路上任何一环出问题,表现都是“下单失败”四个字,但原因可能分散在 Session、事务、库存三个地方。拆解顺序就从购物车开始。
4.1 购物车存哪里:Session、Cookie 与数据库的取舍
入门级购物商城的购物车,九成放在 HttpSession 里,数据结构是Map<Integer, CartItem>,key 是商品 ID,value 是商品加数量:
// 加入购物车的 Servlet 核心逻辑 Integer productId = Integer.valueOf(request.getParameter("productId")); int quantity = Integer.parseInt(request.getParameter("quantity")); HttpSession session = request.getSession(); Map<Integer, CartItem> cart = (Map<Integer, CartItem>) session.getAttribute("cart"); if (cart == null) { cart = new LinkedHashMap<>(); // 用 LinkedHashMap 保持加入顺序,页面展示更自然 session.setAttribute("cart", cart); } CartItem item = cart.get(productId); if (item == null) { Product p = productService.findById(productId); if (p == null || p.getStatus() == 0) { response.sendRedirect(request.getContextPath() + "/product/list"); return; } cart.put(productId, new CartItem(p, quantity)); } else { item.setQuantity(item.getQuantity() + quantity); // 同一商品累加数量 } response.sendRedirect(request.getContextPath() + "/cart.jsp");LinkedHashMap而不是HashMap,是因为购物车页面按加入顺序展示更符合用户预期,HashMap的遍历顺序不可控。getSession()不带参数时,没有 session 会自动创建,如果项目里用了登录过滤器,这里拿到的就是登录时写入的那个 session。
Session 方案的问题很明显:换浏览器、清 cookie、换个设备,购物车就没了;服务重启(非持久化配置)也会丢。所以正式一点的项目会把购物车落到数据库,表结构是cart(user_id, product_id, quantity),登录后从库里读,未登录时用 cookie 存一个临时标识。改造成本不高,但涉及登录态和未登录态的合并逻辑,二次开发时这是个常见需求点。
这里有个容易出问题的细节:数量累加没有上限校验。用户连点十次“加入购物车”,数量就变成十份,下单时库存不够才失败。合格的做法是在累加前判断item.getQuantity() + quantity <= p.getStock(),超了就给提示。
4.2 下单 Servlet 与 Service 的事务边界
下单是整个项目里唯一必须用事务的地方。一次下单要写订单主表、写 N 条订单明细、扣 N 个商品的库存、清购物车,这四步要么全成功,要么全回滚。原生 JDBC 项目里,事务控制放在 Service 层,Connection 从 Service 开,一路传给各个 DAO 方法:
public String createOrder(Integer userId, Map<Integer, CartItem> cart, String receiver, String phone, String address) { if (cart == null || cart.isEmpty()) { return "购物车为空"; } Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 关闭自动提交,开启事务 BigDecimal total = BigDecimal.ZERO; for (CartItem item : cart.values()) { total = total.add(item.getProduct().getPrice() .multiply(BigDecimal.valueOf(item.getQuantity()))); } String orderNo = OrderNoUtil.generate(userId); // 1. 插入订单主表,返回自增主键 int orderId = orderDao.insert(conn, orderNo, userId, total, receiver, phone, address); for (CartItem item : cart.values()) { // 2. 扣库存,返回受影响行数,0 表示库存不足 int affected = productDao.reduceStock(conn, item.getProduct().getId(), item.getQuantity()); if (affected == 0) { conn.rollback(); return "商品【" + item.getProduct().getName() + "】库存不足"; } // 3. 写订单明细,价格取商品当前价做快照 orderItemDao.insert(conn, orderId, item.getProduct(), item.getQuantity()); } conn.commit(); cart.clear(); // 只有提交成功才清购物车 return null; // null 表示成功,非 null 是给前端看的错误提示 } catch (Exception e) { if (conn != null) { try { conn.rollback(); } catch (SQLException ignore) {} } throw new RuntimeException("下单失败", e); } finally { if (conn != null) { try { conn.setAutoCommit(true); conn.close(); } catch (SQLException ignore) {} } } }这段代码的结构值得逐段读。setAutoCommit(false)是事务的起点,之后所有 DAO 方法必须共用这一个conn,如果某个 DAO 内部自己DBUtil.getConnection()拿了个新连接,那一步就不在事务里,回滚也回滚不到它。这是原生 JDBC 项目最典型的翻车点,所以 DAO 方法的签名里我特意把Connection作为第一个参数传进去。
库存不足时直接rollback()并返回错误文案,而不是抛异常。这样前端拿到的是一个友好的提示,日志里也不会出现一堆堆栈。但要注意rollback()之后finally里还会执行setAutoCommit(true),这个顺序没问题;如果忘了在 return 前 rollback,前面的订单主表就白写了。
cart.clear()放在 commit 之后,是另一个容易出错的地方。写在前面的情况下,一旦 commit 失败,购物车已经被清空,用户既没下成单,东西也没了,只能重新加一遍。
total用BigDecimal累加,multiply和add都是返回新对象,不能写成total.add(...)不接收返回值。这个坑和 String 的拼接是同一类,写过一次就记住了。
4.3 库存扣减:超卖是怎么发生的,怎么用一行 SQL 堵住
库存扣减的写法决定了会不会超卖。先看错误示范:
// 错误写法:先查后改,两步之间存在并发窗口 Product p = productDao.findById(productId); // 查到 stock = 1 if (p.getStock() >= quantity) { productDao.updateStock(productId, p.getStock() - quantity); // 另一个线程也走到这 }两个用户同时下单最后一件商品,都查到库存是 1,都判断通过,都执行更新,库存变成 -1,两个订单都成功。这就是超卖。修法不是加 synchronized,那样只能锁住单机,集群下照样失效,而且会把下单接口的吞吐压到很低。
正确的做法是把判断和更新合并成一条带条件的 SQL:
-- 只有库存足够时才更新,affected rows 为 0 就说明扣减失败 UPDATE product SET stock = stock - #{quantity} WHERE id = #{productId} AND stock >= #{quantity}MySQL 在执行这条 UPDATE 时会对目标行加锁,判断和扣减在同一个原子操作里完成。Java 侧只看返回的受影响行数:
int affected = productDao.reduceStock(conn, productId, quantity); if (affected == 0) { conn.rollback(); return "库存不足"; }affected == 0有两种可能:商品不存在,或者库存不够。前者在加入购物车时已经校验过,走到下单这一步基本只剩后者。这条 SQL 配合 4.2 里的事务,就构成了下单链路的安全边界:要么订单、明细、库存三者一起成功,要么一起回滚。
顺带说一句订单状态机。status字段的流转是 0 待付款 → 1 已付款 → 2 已发货 → 3 已完成,中间还可能有 4 已取消。每次状态变更都要校验当前状态,比如“已发货”的订单不能直接改成“待付款”。常见做法是在 Service 里写一个Map<Integer, List<Integer>>记录允许的流转方向,改状态前查一下,避免后台管理页面出现状态来回跳的混乱数据。
5. 环境搭建与常见问题排查:Tomcat 端口、中文乱码、空指针
代码读懂了,接下来是让它在本地真正跑起来。购物商城这类项目对环境的敏感度比想象中高,JDK、Tomcat、MySQL 三个版本任意一个对不上,表现可能都是页面 500 或者白屏。下面这套步骤是我自己复现这类项目时固定走的流程,顺序不要乱。
5.1 环境搭建:JDK、Tomcat、MySQL 的版本对齐与启动顺序
先确认版本组合。原生 JavaWeb 项目一般配 JDK 8 + Tomcat 8.5/9 + MySQL 5.7/8.0,这个组合的兼容性最好。JDK 17 跑 Tomcat 9 会有模块化相关的告警,某些老版本的反射工具类直接抛InaccessibleObjectException。SSM 项目对 JDK 版本更敏感,Spring 5 配 JDK 17 需要额外加启动参数。
# 1. 确认 JDK 版本,输出应为 1.8.x java -version # 2. 建库并导入脚本,shop.sql 一般在项目的 sql 目录下 mysql -uroot -p -e "CREATE DATABASE shop_mall DEFAULT CHARSET utf8mb4;" mysql -uroot -p shop_mall < sql/shop.sql # 3. 回到项目根目录,Maven 打包,跳过测试加快速度 mvn clean package -DskipTests # 4. 把 war 包丢进 Tomcat 的 webapps 目录 cp target/shop-mall.war $CATALINA_HOME/webapps/ # 5. 启动 Tomcat,观察 catalina.out 里有没有异常 $CATALINA_HOME/bin/startup.shmvn package之后 target 目录下会生成 war 包,包名就是访问路径。如果 pom 里的<finalName>是shop,那访问地址就是http://localhost:8080/shop/,不是根路径。这一点经常被忽略,直接访问localhost:8080看到的是 Tomcat 默认页,会让人误以为部署失败。
启动后先看日志再开浏览器。catalina.out里如果出现ClassNotFoundException: com.mysql.cj.jdbc.Driver,说明驱动 jar 没进 war 包;如果出现Unknown database 'shop_mall',说明建库那步没执行或者库名和db.properties里不一致。这两类错误日志里都写得很清楚,比在浏览器里看 500 页面高效得多。
数据库字符集统一用utf8mb4,不要用utf8。后者在 MySQL 里是三个字节的简化版,存不了 emoji,商品标题里带个表情符号就会插入失败或者变成问号。建库、建表、连接 URL 三处的字符集要保持一致,任何一处不一致都可能出现部分中文正常、部分乱码的奇怪现象。
5.2 常见问题排查:五条踩坑记录
下面这五条是我在同类项目里反复遇到的,按出现频率排序。每条都按“现象 → 原因 → 解决”写,遇到问题时可以对着症状直接找。
现象一:启动报Port 8080 required by Tomcat is already in use。原因通常是上一次 Tomcat 没正常关闭,进程还挂在后台,或者本机开了别的 Web 服务占了 8080。先用lsof -i:8080或 Windows 下的netstat -ano | findstr 8080查到进程号,杀掉再启动。如果不想动别的服务,改conf/server.xml里 Connector 的port属性换个端口,但要注意改了之后项目里的绝对路径跳转如果写死了 8080,也要一起改。
现象二:页面能打开,但所有中文都是问号或方块。原因有三个层次,要逐层排查。第一层是请求编码,request.setCharacterEncoding("UTF-8")必须在读取任何参数之前调用,写在方法中间就失效;第二层是响应编码,response.setContentType("text/html;charset=UTF-8")要写在getWriter()之前;第三层是 JSP 页面头部的<%@ page contentType="text/html;charset=UTF-8" language="java" %>。三层都对了还乱码,就去查数据库连接 URL 里的characterEncoding和表的字符集。用过滤器统一处理前两层是最省事的做法,第 2 章的 web.xml 里已经配了。
现象三:登录成功但一刷新就退回登录页,或者后台页面拿不到当前用户。原因基本是 session 没拿到或者被覆盖。检查登录 Servlet 里是不是用了request.getSession()之后又新建了一个 session,或者过滤器里判断登录时用的 key 和登录时写入的 key 不一致。还有一种情况是 JSP 里session="false"禁用了内置 session 对象,页面上取${sessionScope.user}永远是空。改法是统一一个常量类存 session 的 key,登录、过滤器、页面三处都引用它,不要各写各的字符串。
现象四:下单时抛NullPointerException,堆栈指向 Service 层的某一行。这类空指针八成是容器没注入成功或者参数字段缺失。SSM 项目里检查@Autowired的字段有没有被 Spring 扫描到,包扫描路径写错是最常见的原因;原生项目里检查 Servlet 里 new 出来的 Service 对象是不是 null。还有一种隐蔽的情况:用户提交表单时某个字段没填,request.getParameter返回 null,直接.trim()就炸了。所有外部输入在进入业务层之前都要判空,这一步没有捷径。
现象五:后台加了商品,前台列表看不到。先查商品表的status字段是不是 0(下架),再查分类 ID 是否有效,最后查列表 SQL 有没有加status = 1的条件而新商品默认值是 0。还有一种情况是加了商品但没刷新缓存,如果项目用了二级缓存或者页面静态化,新数据要等缓存过期才出现。排查顺序建议从数据库直接SELECT开始,确认数据在不在,再往上查 SQL,最后查缓存,从下往上定位比从上往下猜快得多。
提示:这五条里有四条最终都落在配置上,而不是业务代码上。所以复现这类项目时,我一般先花时间把配置对齐,跑通一个最简单的列表页,再去碰下单流程,能省掉一大半排查时间。
6. 二次开发:给购物商城加一个限时促销模块要动哪些文件
跑通之后,多数人会想加点自己的东西,限时促销是最常见的改造需求:给商品配一个促销价和起止时间,在有效期内前台显示促销价、下单按促销价算。这个功能不大,但能把整个项目的分层串一遍,很适合当第一个二次开发练习。
改动涉及的层次和文件如下:
| 层次 | 文件 | 改动内容 |
|---|---|---|
| 数据库 | product表 | 加promo_price、promo_start、promo_end三个字段 |
| 实体层 | Product.java | 对应加三个属性,类型分别是 BigDecimal 和 Date |
| DAO 层 | ProductDao | 查询 SQL 里带出促销字段,findById同步修改 |
| 业务层 | ProductService | 加一个getEffectivePrice(Product)方法判断当前是否在促销期 |
| 控制层 | ProductListServlet | 列表页给每个商品算好展示价,塞进 request |
| 视图层 | product_list.jsp | 判断有促销价时显示原价划线加促销价 |
| 下单 | OrderService | 明细里的价格改用getEffectivePrice的返回值 |
核心判断逻辑写成一个方法,放在 Service 里,展示和下单都调它,保证两边价格一致:
/** * 返回商品的当前有效价格 * 促销价为空、未开始、已结束,一律回退到原价 */ public BigDecimal getEffectivePrice(Product p) { Date now = new Date(); if (p.getPromoPrice() == null || p.getPromoStart() == null || p.getPromoEnd() == null) { return p.getPrice(); } boolean inRange = !now.before(p.getPromoStart()) && !now.after(p.getPromoEnd()); return inRange ? p.getPromoPrice() : p.getPrice(); }这段代码的关键是“回退”逻辑。展示层和下单层如果各写一套判断,很容易出现列表页显示促销价、下单按原价算的情况,用户投诉价格不符。抽成一个方法两边都调,改规则时只改一处。
时间边界也要注意,!now.before(start)和!now.after(end)这种写法把起止时刻都包含在内,比用after和before直接比较少一个边界漏洞。促销开始和结束的那一刻恰好下单的用户,不应该被漏掉。
改造完之后怎么验证?我的做法是准备三条数据:一条促销未开始、一条正在促销、一条已结束,分别走一遍列表页展示和下单流程,看订单明细里的price字段是不是预期值。再手动改数据库把时间调到边界(比如当前时间正好等于promo_start),确认边界判断没漏。这种“三条数据 + 边界值”的验证方式,比在页面上反复点要可靠得多。
还有一个坑要提前说:促销字段加上去之后,所有SELECT *的地方都会自动带出来,但如果有手写的INSERT语句逐列列举字段,新加的列会被漏掉,导致新增商品时促销字段全是 null。加字段之后全局搜一遍 insert 语句,比等测试发现问题再补要省事。
血泪经验是,我在早期改这类项目时,图省事直接在 JSP 里用当前时间去和促销时间比较,结果服务器时区和数据库时区差了八小时,页面上的促销状态和订单里的价格对不上,查了半天才定位到serverTimezone参数。从那以后我每次给这类项目加时间相关的功能,都强制走一遍“改数据库时区 → 重启 → 三条数据验证”的流程,宁可多花十分钟,也不要在线上对着两个不一致的时间发呆。希望这些能帮到你少走点弯路。
本文还有配套的精品资源,点击获取