1. 为什么还在做JSP+Servlet项目——技术选型的真实考量
聊这个话题前,先说个可能让不少同学意外的事实:JavaWeb 领域的课程设计、毕业设计和部分中小型企业内部系统,至今仍有大量项目跑在 JSP+Servlet 这套经典组合上。 不信你打开招聘软件搜索"Java开发工程师",要求里大概率还会写"熟悉 Servlet/JSP 规范";去看看 GitHub 上那些 javaweb 项目完整案例,下载量靠前的也多是这套技术栈。Spring Boot 再香,也改变不了它是建立在 Servlet 容器之上这个事实。
我们做这个"嘟嘟蛋糕商城系统",初衷不是逆潮流去复古,而是用一套完整业务场景,把 JavaWeb 的核心骨架真正打通:前端 JSP 渲染页面,Servlet 接收请求和分发,JDBC 操作 MySQL,AJAX 做异步交互,FileUpload 处理商品图片上传。 这套链路做完,你对"一个请求从浏览器到数据库再返回浏览器,中间到底发生了什么"的体感,会远超直接上手 Spring Boot 时那种"注解一贴、方法一写就完事"的稀里糊涂。
尤其适合三类人看:
- 正在做 JavaWeb 课程设计或毕业设计的在校生,需要一套可复现、可扩展的完整项目;
- 想补 Servlet/JSP/JDBC 底层基础、准备面试的初级开发,尤其是被问"Servlet 生命周期、Session 原理、JDBC 事务"时容易卡壳的;
- 需要接手或维护传统 JSP 项目的在职开发,这套系统的踩坑经验能帮你少走弯路。
下面内容全部围绕我实际开发"嘟嘟蛋糕商城"的过程展开,含数据库表设计、用户端购物下单链路、管理后台图片上传与 AJAX 交互,以及最后部署上线的一系列坑。代码片段不是完整源码,但核心逻辑和关键配置都保留,照着搭一遍完全可以跑通。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据库建模:一个蛋糕商城到底需要几张表
2.1 从订单反推表结构
蛋糕商城这种电商类项目,表结构相对固定,但我建议别一上来就凭感觉建表,而是从"一次下单行为"倒推:用户在商城挑蛋糕、加入购物车、提交订单、付款(本项目简化成货到付款或直接模拟支付成功)、管理员发货。这个流程里必然涉及用户信息、商品信息、购物车记录、订单主表、订单明细表、商品分类,再加上后台管理员账号,一共七张核心表足够覆盖所有功能。
我当时用的是 MySQL 5.7,字符集默认 utf8mb4。这里强调一下,既然你已经决定用 MySQL,就尽量选 utf8mb4,不要为了省空间用 utf8,因为 emoji 表情和部分生僻字在 utf8 下直接存不进去,而且 MySQL 8.0 里 utf8mb4 已经是默认选项了。如果你下载的是最新版 MySQL 8.x,需要注意驱动要换成 com.mysql.cj.jdbc.Driver,同时连接串里加上 serverTimezone=Asia/Shanghai,不然会报时区错误。
2.2 核心表的字段设计和关联关系
直接看表结构,我把自己实际落地的字段贴出来:
user 用户表
| 字段名 | 类型 | 说明 |
|---|---|---|
| user_id | INT 主键自增 | 用户ID |
| username | VARCHAR(32) 唯一 | 登录名 |
| password | VARCHAR(64) | 密码(MD5加密) |
| phone | VARCHAR(11) | 手机号 |
| address | VARCHAR(255) | 默认收货地址 |
| created_time | DATETIME | 注册时间 |
密码我没做 salt,直接 MD5。生产环境肯定不行,至少加盐或者用 BCrypt,但作为教学项目演示够了。我遇到过不少同学用明文存密码,这在课程答辩时是会被老师直接点名的。
category 商品分类表和 product 商品表
分类表比较简单:category_id、name。商品表字段稍多:
| 字段名 | 类型 | 说明 |
|---|---|---|
| product_id | INT 主键自增 | 商品ID |
| name | VARCHAR(100) | 蛋糕名称 |
| price | DECIMAL(10,2) | 价格 |
| stock | INT | 库存 |
| image | VARCHAR(255) | 图片URL路径 |
| description | TEXT | 商品描述 |
| category_id | INT | 外键关联分类表 |
| is_hot | TINYINT | 是否热卖 0/1 |
| status | TINYINT | 上架/下架状态 |
价格一定要用 DECIMAL 而不是 FLOAT/DOUBLE,这是电商系统最常见的低级错误。FLOAT 是浮点数,存 19.99 这种价格在运算时可能变成 19.990000000000002,订单结算金额一旦出这种问题,整个项目都显得不专业。
cart 购物车表
购物车有两种方案:一种是把购物车数据存在 Session 里,用户不登录也能加购,下单时一次性写入数据库;另一种是存数据库表。我实际做的时候选择了数据库表 cart 方案,因为这样用户在网页上把商品加入购物车后,换个设备登录数据还在,真正用起来体验更接近真实项目。表字段:cart_id 主键、user_id、product_id、quantity,再加一个唯一索引 (user_id, product_id),防止同一用户重复添加同一商品。
需要说明的是,Session 方案实现更快,适合赶进度的课程设计;数据库方案多几步 DAO 操作但逻辑更完整。如果你用 Session 存储,购物车对象用 Map<Product, Integer>,加购时判断 key 是否存在,存在则数量加一,最后下单时把 Map 遍历生成订单明细。这种方案在并发量低的场景完全够用。
orders 订单主表
| 字段名 | 类型 | 说明 |
|---|---|---|
| order_id | VARCHAR(32) 主键 | 订单号,不用自增 |
| user_id | INT | 下单用户 |
| total_price | DECIMAL(10,2) | 订单总金额 |
| status | TINYINT | 订单状态 0待发货 1已发货 2已完成 3已取消 |
| create_time | DATETIME | 下单时间 |
订单号建议用时间戳加随机数的形式生成,比如 yyyyMMddHHmmss 加 4 位随机数,不要用数据库自增当订单号给人看,会暴露系统每天的单量。
order_item 订单明细表
字段:item_id 主键、order_id、product_id、product_name(冗余商品名称,防止商品删除后订单里看不到名字)、price(下单时的快照价格)、quantity。
2.3 外键和索引怎么取舍
很多教材喜欢强调外键约束,但实际项目里,订单明细和商品表之间我不会建物理外键,只保留逻辑关联,理由有两点:一是外键会增加每次插入和删除时的约束检查开销,二是后续如果要做分库分表或者数据归档,物理外键会成为迁移的阻碍。但索引是必须的,order_id 在明细表里一定要建索引,因为每次查询订单详情都要按 order_id 过滤;user_id 在订单表里也要建索引,用户查"我的订单"全靠它。
关于在 MySQL 里能不能删掉有外键关联的表这种细节要是操作错了会卡半天,实际建表顺序应该是:先建分类表,再建商品表,然后用户表、购物车表,最后订单表和明细表。我自己建表时踩过一个坑:先删了商品表再删分类表,结果因为商品表里 category_id 有外键指向分类表,删分类表直接报错,得先删商品表或者先禁用外键检查(SET FOREIGN_KEY_CHECKS=0)才行。
3. 用户端完整购物链路:从商品列表到订单落库
3.1 用 Servlet 做控制器,JSP 只负责显示
在动手写代码前,先明确这套系统里 Servlet 和 JSP 各自的职责。我在项目里严格按照 Model1/Model2 混合风格来分层:JSP 负责展示,Servlet 负责接收请求、调用业务逻辑、控制页面跳转,JavaBean/DAO 负责数据访问。千万不要把大量 Java 代码塞进 JSP 的 <% %> 里,那会让页面维护变成噩梦。这也是很多 javaweb 项目最常见的坏味道——JSP 里又是 while(resultSet.next()) 又是 out.print(),看一次崩溃一次。
产品列表页的请求链路是这样的:
- 浏览器访问
ProductListServlet?categoryId=1; - Servlet 调用
ProductDao.queryByCategory(categoryId)拿到商品集合; - 把集合
setAttribute("productList", list)放进 request 域; request.getRequestDispatcher("/product_list.jsp").forward(request, response)转发到 JSP;- JSP 里用
c:forEach循环渲染商品卡片。
转发和重定向的区别这里值得说透。转发的地址栏不变,只能跳转站内资源,request 域里的数据能带过去;重定向地址栏变新地址,浏览器会重新发起一次请求,request 域数据丢失,需要传参只能拼在 URL 后面。我在做用户登录成功后跳首页时用重定向(防止刷新页面重复提交表单),在做商品详情跳转时用转发(因为要携带商品对象到页面)。
3.2 分页查询:LIMIT 的边界与总页数计算
商品列表不能一页全塞,分页是必须的。分页的核心 SQL 是:
sql复制SELECT * FROM product
WHERE category_id = ? AND status = 1
ORDER BY product_id DESC
LIMIT ?, ?;
LIMIT 第一个参数是起始偏移量,第二个是每页条数。假设每页显示 8 个商品,第 3 页的偏移量就是 (3-1) * 8 = 16。这里最容易出的 bug 是:MySQL 的 LIMIT 偏移量从 0 开始,如果你在 Servlet 里直接用了前端传来的页码 pageNum 而没有减一,第一页正常,第二页开始就会漏数据或者重复显示第一页的商品。
总页数怎么算?先查总数:
sql复制SELECT COUNT(*) FROM product WHERE category_id = ? AND status = 1;
然后 totalPages = (totalCount + pageSize - 1) / pageSize,用向上取整的方法避免浮点数运算。我之前见过有人用 Math.ceil(totalCount / pageSize),但因为整数除法会先截断,当 totalCount 是 10、pageSize 是 8 时,10/8 结果是 1,Math.ceil(1) 还是 1,反而错了。用 (totalCount + pageSize - 1) / pageSize 最稳。
分页还要注意一个细节:当用户当前在第 5 页,突然把商品数据删除导致总页数变成 3 页时,分页条上要处理越界,直接把页码重置为 1 或者重定向到最后一页,不然白屏。
3.3 购物车加入:AJAX 异步请求的传参细节
用户点击"加入购物车"这个动作,我选择用 AJAX 异步完成,而不是整页刷新。前端用 jQuery 的 $.ajax(这套老项目没用 axios,jQuery 在 JSP 时代是标配):
javascript复制$.ajax({
url: '${pageContext.request.contextPath}/cart/add',
type: 'POST',
data: {
productId: productId,
quantity: 1
},
dataType: 'json',
success: function(res) {
if (res.code === 200) {
$('#cartCount').text(res.data.cartTotalCount);
} else {
alert(res.msg);
}
}
});
这里面有几个反复被人问的坑,逐一说明:
- URL 必须加
${pageContext.request.contextPath},也就是项目上下文路径。如果项目部署名是cake_shop,那么实际请求地址是http://localhost:8080/cake_shop/cart/add。不加 contextPath 时,部署在根路径下没问题,一旦换了部署名,所有 AJAX 请求 404,这是 JSP 项目里的高频事故。 - POST 请求的 Content-Type:
$.ajax默认会用application/x-www-form-urlencoded提交表单格式数据,Servlet 端用request.getParameter("productId")能正常取到。如果你手动设了contentType: 'application/json',那后端就得用 Jackson 之类的工具解析 JSON 字符串了,两种方式别混。 - dataType 和 response 的 Content-Type 一致性:Servlet 返回 JSON 时一定要执行
response.setContentType("application/json;charset=UTF-8"),如果漏了,部分浏览器会把响应当 HTML 解析,虽然 jQuery 按 dataType 强转也能进 success,但中文可能乱码。
购物车表插入逻辑在 2.2 节提到过唯一索引,所以写入时用 INSERT ... ON DUPLICATE KEY UPDATE quantity = quantity + 1,一步搞定"存在则加数量、不存在则新增",避免先查再改两步操作带来的并发问题。
3.4 下单的事务管理:Connection 必须掌握在自己手里
下单是这个项目里最需要事务保障的环节。一次下单包含以下操作:
- 查询用户默认收货地址;
- 查询购物车所有条目;
- 计算总金额;
- 往 orders 表插入订单主记录;
- 遍历购物车条目,逐条往 order_item 插入明细;
- 扣减 product 表对应商品库存;
- 删除该用户购物车记录。
第 4 到第 7 步,任何一步失败,前面的操作必须全部回滚。否则会出现"订单主表有记录但明细缺失"或者"库存扣了但订单没生成"这种数据不一致。
JDBC 手动事务的标准写法:
java复制Connection conn = null;
try {
conn = DBUtil.getConnection();
conn.setAutoCommit(false); // 关闭自动提交,事务从这里开始
// 1. 插入订单主表,拿到自增主键
// 2. 遍历明细插入 order_item
// 3. 扣库存 UPDATE product SET stock = stock - ? WHERE product_id = ? AND stock >= ?
// 4. 清空购物车
conn.commit(); // 全部成功,提交
} catch (SQLException e) {
if (conn != null) {
conn.rollback(); // 任何异常,整体回滚
}
throw new RuntimeException("下单失败", e);
} finally {
if (conn != null) {
conn.setAutoCommit(true); // 恢复自动提交
conn.close();
}
}
这里有几个关键点,很多人第一次写 JDBC 事务会栽跟头:
- 必须用同一个 Connection 对象。如果你在 DAO 里每个方法都独立
getConnection()再关闭,那事务完全无效——因为每个操作各用各的连接,根本没有一个统一的事务边界。我在项目里用了ThreadLocal<Connection>的方式,在业务层开启事务前把连接绑到当前线程,后续 DAO 操作从 ThreadLocal 取连接,保证所有操作共用一个连接。 - 扣库存的 SQL 要带库存条件:
UPDATE product SET stock = stock - ? WHERE product_id = ? AND stock >= ?,这样在并发下也能靠数据库行锁保证不会出现超卖。如果先查库存再判断再更新,两步之间存在空窗期,并发下单时可能把库存打成负数。 - 失败要及时 rollback,但 rollback 本身也可能抛异常(比如连接已经断了),所以要在 catch 里包一层 try-catch。
下单成功后,页面跳转用重定向,这招能防止用户按 F5 刷新时重复提交订单。这个经典问题叫作"表单重复提交",重定向就算不能根治,也能在绝大多数场景下避免重复下单。
3.5 Session 管理用户登录态
用户登录成功之后,我把用户对象放进 Session:
java复制HttpSession session = request.getSession();
session.setAttribute("loginUser", user);
session.setMaxInactiveInterval(30 * 60); // 30分钟过期
JSP 页面里判断是否登录:
jsp复制<c:if test="${not empty sessionScope.loginUser}">
欢迎,${sessionScope.loginUser.username}
</c:if>
<c:if test="${empty sessionScope.loginUser}">
<a href="login.jsp">登录</a>
</c:if>
购物车角标的数量,我是在每次加载导航栏时用 AJAX 请求 /cart/count 获取并更新。这样用户不管加购还是删除,只需要刷新角标数字,不用整个页面重新加载。这也是 AJAX 在 JSP 项目里最有存在感的地方之一。
Session 失效时间默认是 30 分钟(Tomcat 默认配置)。如果你希望"记住我"功能,可以额外生成一个 token 存 Cookie,下次访问时通过过滤器自动登录——这是进阶玩法,课程设计能写出来非常加分。
4. 管理后台的图片上传与 AJAX 交互:FileUpload 组件的正确姿势
4.1 后台功能范围与权限控制
管理后台支撑管理员登录、商品增删改查、商品图片上传、订单状态变更。后台所有 Servlet 都放在 /admin 路径下,通过一个 AdminLoginFilter 做登录拦截:
java复制public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) {
HttpServletRequest request = (HttpServletRequest) req;
HttpSession session = request.getSession(false);
Admin admin = (session != null) ? (Admin) session.getAttribute("admin") : null;
if (admin == null) {
((HttpServletResponse) resp).sendRedirect(request.getContextPath() + "/admin/login.jsp");
return;
}
chain.doFilter(req, resp);
}
Filter 在 web.xml 里注册,映射到 /admin/*。这里特别提醒:Filter 拦截的路径要包住所有后台 Servlet,但绝对不能拦截 JSP 页面里引用的静态资源(CSS、JS、图片),不然页面样式会全部加载不出来。一个常见做法是把静态资源单独放在 /static 目录下,Filter 只映射 /admin/*,而静态资源通过 resources 映射放行。
4.2 图片上传:磁盘路径、虚拟目录与 Tomcat 配置
商品图片上传是管理后台的核心功能。我用的是 Apache 的 commons-fileupload 组件,搭配 commons-io。需要引入两个 jar:commons-fileupload-1.3.3.jar 和 commons-io-2.6.jar。表单要设置:
html复制<form action="admin/product/add" method="post" enctype="multipart/form-data">
<input type="text" name="name" />
<input type="file" name="image" />
<input type="submit" value="提交" />
</form>
后端解析:
java复制DiskFileItemFactory factory = new DiskFileItemFactory();
factory.setSizeThreshold(1024 * 1024); // 1MB 内存阈值
ServletFileUpload upload = new ServletFileUpload(factory);
upload.setFileSizeMax(5 * 1024 * 1024); // 单文件最大5MB
upload.setHeaderEncoding("UTF-8"); // 解决中文文件名乱码
List<FileItem> items = upload.parseRequest(request);
for (FileItem item : items) {
if (!item.isFormField()) {
String fileName = new File(item.getName()).getName(); // 只取文件名,防路径攻击
String suffix = fileName.substring(fileName.lastIndexOf("."));
String newName = UUID.randomUUID().toString().replace("-", "") + suffix;
// 保存到指定目录
File uploadDir = new File("D:/cake_shop_upload");
if (!uploadDir.exists()) uploadDir.mkdirs();
item.write(new File(uploadDir, newName));
imagePath = "/upload/" + newName; // 这是存进数据库的路径
}
}
这里最核心的坑在于图片保存的磁盘路径和浏览器访问的 URL 路径不一致。数据库存的是相对路径 /upload/xxx.jpg,但文件实际写在 D:/cake_shop_upload 目录下,如果不做映射,浏览器访问 http://localhost:8080/cake_shop/upload/xxx.jpg 必然 404。
解决办法有两个:
方案一:配置 Tomcat 虚拟目录(推荐,无需改代码)
在 Tomcat 的 conf/server.xml 的 <Host> 标签内加:
xml复制<Context docBase="D:/cake_shop_upload" path="/cake_shop/upload" reloadable="true" />
意思是:URL 中访问 /cake_shop/upload/ 时,实际读取 D:/cake_shop_upload 目录下的文件。这样上传和读取就打通了。
方案二:把图片直接保存到项目部署目录下
通过 request.getServletContext().getRealPath("/upload") 获取部署路径下的 upload 目录再保存。这方案简单,但有致命问题:每次重新部署项目(重新发布 war 包),上传的图片会被一起清掉。课程设计演示时可以接受,真实项目中必须用方案一。
上传时还有两个细节必须处理:
- 文件名不能用用户原始名称,因为不同用户可能上传同名文件导致覆盖,所以我用 UUID 重命名,顺便把原始文件名里的路径分隔符处理掉(
new File(item.getName()).getName()),防止用户构造../../xxx.jpg这种路径穿越攻击。 - 表单里的普通字段和文件字段要区分:
item.isFormField()为 true 的是文本框、下拉框等普通字段,用item.getString("UTF-8")取值;为 false 的才是文件内容。
4.3 后台商品管理的 AJAX 操作
后台改商品状态(上架/下架)、删除商品、更新推荐位,这些操作我用 AJAX 完成,保证管理员点击后不用整页刷新。典型的删除操作:
javascript复制$.ajax({
url: ctxPath + '/admin/product/delete',
type: 'POST',
data: { productId: id },
success: function(res) {
if (res.code === 200) {
$('#productRow_' + id).remove();
} else {
alert(res.msg);
}
}
});
Servlet 端返回 JSON:
java复制response.setContentType("application/json;charset=UTF-8");
PrintWriter out = response.getWriter();
out.write("{\"code\":200,\"msg\":\"删除成功\"}");
这里要注意的一个点是:AJAX POST 请求的编码问题。如果你前端传的是中文参数(比如商品名搜索),后端取到的是乱码,十有八九是 Tomcat 8.0 之前版本的 POST 请求默认编码不是 UTF-8,需要加一个 CharacterEncodingFilter 统一设置:
java复制public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) {
req.setCharacterEncoding("UTF-8");
resp.setCharacterEncoding("UTF-8");
chain.doFilter(req, resp);
}
Tomcat 8.0 及以上版本,GET 请求的 UTF-8 编码需要在 conf/server.xml 的 Connector 上配置 URIEncoding="UTF-8",这个不改的话,URL 上的中文参数会乱码。而 POST 请求则靠上述 Filter 解决。很多同学查半天乱码问题,最后发现是 GET 和 POST 的编码配置搞混了。
4.4 前端传参给 AJAX 的常见坑
热搜词里有关"给 ajax 请求参数赋值"的搜索,我在开发中也确实踩过:在 JSP 里用 EL 表达式给 JavaScript 变量赋值时,要注意引号嵌套和特殊字符。
一个典型场景:点击"编辑"按钮时把商品名称传到 JS 函数里:
javascript复制function editProduct(productId, productName) {
// 正确做法:外层单引号,内层双引号
$('#nameInput').val(productName);
}
JSP 渲染:
jsp复制<button onclick="editProduct(${p.productId}, '${p.name}')">编辑</button>
如果蛋糕名称里包含单引号(比如"妈妈的爱'经典款'"),这个拼接就会把 JS 代码搞坏。稳妥做法是用一个 data 属性存值:
jsp复制<button class="edit-btn" data-id="${p.productId}" data-name="${p.name}">编辑</button>
JS 里再用 $(this).data("name") 获取。这样既避免了引号问题,也把数据和行为分离,代码更干净。
5. 部署上线前必须处理好的几个环境级问题
5.1 MySQL 安装完连不上的排查思路
很多同学卡在第一步:MySQL 装好了,但 Java 程序连不上。常见排查链路是:
- 确认 MySQL 服务已启动。Windows 下在服务管理里看 MySQL80 是否运行,没启动就手动启动。MySQL 5.7 安装完服务启动报错的话,优先检查
data目录权限和my.ini配置的路径是否正确。 - 确认驱动 jar 已放到 WEB-INF/lib 下。JavaWeb 项目的 jar 包必须放在
WEB-INF/lib目录,不是随便 build path 里引一下就行,部署到 Tomcat 时不会带上 IDE 里的引用。 - 确认连接串参数正确。JDBC URL 写法:
jdbc:mysql://localhost:3306/cake_shop?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=UTF-8。MySQL 5.7 用com.mysql.jdbc.Driver,MySQL 8.0 必须换成com.mysql.cj.jdbc.Driver,用错会直接报ClassNotFoundException或Unable to load authentication plugin。 - 确认 root 用户密码和远程访问权限。如果 MySQL 装的时候设了密码,驱动里密码必须一致;
caching_sha2_password认证插件在 5.7 没有,8.0 默认有,旧版本驱动对它兼容不好,要么升级驱动,要么在 MySQL 里把用户认证方式改回mysql_native_password。
我之前做过一次 MySQL 8.0 和 mysql-connector-java 5.1.49 的组合,程序启动直接报认证协议不支持。最后把驱动换成了 8.0.33 版本才解决。经验是:驱动版本尽量与 MySQL 大版本一致,别混搭太老的驱动。
5.2 数据库连接池:为什么要用,怎么选
如果你的项目直接用 DriverManager.getConnection(),每次请求都新建连接,10 个用户同时访问就会有 10 个连接在建立和销毁,MySQL 默认最大连接数是 151,很容易被打满。这个项目我引入了 DBCP 连接池,配置在 dbcp.properties 里:
properties复制driverClassName=com.mysql.jdbc.Driver
url=jdbc:mysql://localhost:3306/cake_shop?useSSL=false&characterEncoding=UTF-8
username=root
password=xxxxx
initialSize=5
maxActive=20
maxIdle=10
minIdle=5
maxWait=3000
使用连接池之后,DBUtil.getConnection() 实际是从池里拿连接,用完调用 conn.close() 也不是真正关闭,而是归还池里。这个机制不理解的话,容易犯"用完没关连接"的错误——不关的连接会一直占用池资源,最后连接池耗尽,程序假死。
JDBC 里 Connection、Statement、ResultSet 这三个资源一定要在 finally 里释放,释放顺序是 ResultSet 先关、Statement 其次、Connection 最后。如果你用 try-with-resources 语法,记得它们的关闭顺序是声明逆序,刚好符合要求。
5.3 JSP 文件 Not Found 的高频原因
热搜词里有个 jsp file [/hotline.jsp] not found,这种问题在 JavaWeb 项目里太常见了。总结一下我遇到的几类原因:
- JSP 文件没放在正确的目录里。WEB-INF 下的 JSP 不能通过浏览器地址栏直接访问,必须通过 Servlet 内部转发。如果你把
admin.jsp放在了WEB-INF/jsp/admin.jsp,访问http://localhost:8080/admin.jsp当然 404,正确路径是访问某个 Servlet,由它 forward 到该 JSP。 - URL 路径大小写不一致。Linux 服务器上区分大小写,
Product_list.jsp和product_list.jsp是两个完全不同的文件。Windows 本地没问题,一部署到 Linux 就 404。 - 多个项目部署时的路径冲突。一个 Tomcat 下部署多个项目,访问时必须带上下文路径,
http://localhost:8080/cake_shop/product_list.jsp,不带cake_shop会访问到 ROOT 项目去,同样 404。
5.4 项目完整目录结构参考
最后给出我实际用的目录结构,作为参考:
code复制cake_shop/
├── src/
│ ├── com/cake/entity/ # 实体类 User, Product, Cart, Order
│ ├── com/cake/dao/ # DAO 接口和实现
│ ├── com/cake/service/ # 业务逻辑层
│ ├── com/cake/servlet/ # 控制器
│ │ ├── user/ # 用户端 Servlet
│ │ └── admin/ # 后台 Servlet
│ ├── com/cake/filter/ # 过滤器
│ ├── com/cake/util/ # DBUtil, MD5Util 等
│ └── com/cake/listener/ # 监听器
├── web/
│ ├── static/ # CSS, JS, 图片
│ ├── WEB-INF/
│ │ ├── web.xml
│ │ └── lib/ # jar 包
│ ├── index.jsp
│ ├── product_list.jsp
│ ├── cart.jsp
│ ├── order_confirm.jsp
│ └── admin/ # 后台 JSP
实体类里的属性最好用包装类型 Integer、Double 而不是基本类型 int、double,这样数据库字段为 NULL 时不会在 BeanUtils 封装时报空指针。这是一个很细但很实用的经验。
6. 关于这套技术栈,我最后想说的心里话
前面讲了具体的表和代码,最后聊点实在的。很多人会质疑:都什么年代了还写 JSP+Servlet?我觉得这种质疑要分两面看。如果你要开发一个高并发、高可用的商业系统,那 Spring Boot + Spring Cloud + MyBatis 全家桶当然是最优解。但如果你是在打基础、做课设、复习底层原理,那 JSP+Servlet+JDBC 反而是一块最好的磨刀石——它逼迫你把 HTTP 协议、请求转发与重定向、Session 生命周期、JDBC 事务边界、线程安全这些概念一个一个搞清楚。
我自己带过几个实习生,他们直接从 Spring Boot 起步,写接口很溜,但有个问题:让他们解释一次请求从浏览器到 Controller 的完整路径,说不清楚;让他们手写一个 JDBC 事务,不知道要控制 Connection;让他们排查一个中文乱码,不知道 GET 和 POST 的编码差异。这些问题在 JSP+Servlet 时代,都是绕不过去的基本功。
做这个嘟嘟蛋糕商城系统的时候,我最大的感受是:项目本身不难,难的是每一个环节都要自己亲手搭起来、亲自踩一遍坑。 数据库表设计要考虑字段类型和索引,购物车要决定 Session 还是数据库存储,下单要处理事务和并发扣库存,上传图片要处理磁盘路径和 URL 映射,部署上线会被 MySQL 版本、驱动、编码反复折磨。这些经历,比读十遍教材都有用。
如果你也想拿这个项目练手,建议按照"数据库→工具类→实体类→DAO→Servlet→JSP→联调"的顺序推进,每完成一个模块就实际跑一遍,别攒到最后再看结果。遇到报错先看堆栈第一行,定位是 SQL 问题、类加载问题还是路径问题,再针对性地搜。最后再分享一个调试技巧:在 DAO 层把最终执行的 SQL 打印出来,用 System.out.println 或者 log4j 都行,90% 的 SQL 报错靠这个就能定位,比对着代码猜效率高太多。
