早餐外卖这个场景特别适合JSP+Servlet这套老牌技术栈来练手:业务链路完整,从前台点餐到后台出餐都有,又不至于复杂到失控。今天把这个基于JavaWeb和MySQL的JSP+Servlet早餐外卖店管理系统拆开揉碎讲一遍,从数据库设计到前端交互,从本机部署到常见报错,所有流程都是可以照着重现的,不是那种飘在PPT上的概念方案。
想把这套系统吃透的人,我大概分成两类:一类是刚学完JavaWeb正在找完整项目做课程设计或毕业设计的同学,另一类是工作里被Spring Boot“惯坏了”,想回头补一补原生Servlet、JSP、会话管理这些底层原理的开发者。这个项目能解决的核心问题,就是让你完全不用框架,纯靠Servlet控制请求流转、JSP渲染页面、JDBC操作MySQL,自己手工拼出一套能用的外卖管理系统。这么做最大的价值不是“代码能跑”,而是你能真正看清楚一个Web应用在浏览器和数据库之间到底干了些什么。
项目本身的业务不复杂:顾客浏览早餐分类和菜品,加入购物车,提交订单,填写收货地址和期望送达时间;管理员在后台维护菜品、处理订单状态、查看每日营业额。技术点覆盖了JavaWeb几乎全部核心内容:Filter过滤器做登录拦截、Session维护登录态和购物车、JDBC做增删改查、事务保证下单扣库存的一致性、AJAX局部刷新、JSP标签库遍历列表。下面按我的实际开发顺序,把每个环节的关键细节和坑都说清楚。
1. 整体架构与设计取舍 —— 为什么坚持用 JSP+Servlet 做外卖系统
1.1 技术选型背后的真实考量
有人会问:都2025年了,谁还用JSP+Servlet写新项目?这个质疑本身没错,但得分场景。如果是公司里的正式产品,我绝对推荐Spring Boot + MyBatis / JPA那一套;但如果是用来学习JavaWeb、理解HTTP请求如何被处理、Session如何维持状态、SQL如何被程序调用,那JSP+Servlet是最直接的地图。把所有自动化都去掉之后,底层的轮廓才会浮出水面。
这个早餐外卖系统的业务规模也不大,属于典型的“高并发需求低、业务逻辑直观、页面数量适中”的项目。早餐店高峰时段就是早上七点到九点,单量密集但总量有限,用Servlet线程池处理完全够用。反而如果用Spring Boot起步,你会发现大部分精力耗在理解自动配置上,而不是业务本身。所以我刻意选择了“裸奔”方案:MVC三层全部手写,连接池用简单的DBCP或者干脆自己封装JDBC工具类,分页逻辑自己计算,事务边界自己在Service层控制。写完之后再回头看Spring Boot,很多概念都是自然而然的对应关系。
1.2 系统模块划分与角色权限
我把系统拆成前台门户和后台管理两大块,对应两种角色:普通用户和管理员。前台给顾客看菜单、下单、查自己的订单;后台给店员管理菜品类别、上下架早餐、处理订单状态。权限控制的核心就一句话:Session里有没有登录用户,以及这个用户的role字段是不是1。
目录结构我建议这样组织,注意包名要语义清晰:
com.food ├── controller // Servlet类,处理请求转发和重定向 ├── service // 业务逻辑层,事务控制在这里 ├── dao // JDBC数据访问层 ├── entity // 实体类,对应数据库表 ├── filter // 编码过滤器、登录拦截过滤器 ├── util // DBUtil、MD5工具类等 └── listener // 可选:启动时加载分类缓存JSP页面放在webapp目录下,前台页面和后台页面用文件夹区分:webapp/front、webapp/admin。这样的好处是路径清晰,写Servlet重定向时不会把前台后台弄混。
1.3 一个Servlet还是多个Servlet
这个点是新手最容易纠结的地方。我见过不少人图省事,写一个DishServlet,里面用if ("list".equals(action))、else if ("add".equals(action))分别处理不同请求。项目小的时候这么写没问题,但早餐外卖系统做到后面至少有十几个功能动作——菜品增删改查、分类管理、订单处理、登录注册、购物车操作——全塞一个Servlet会让代码膨胀到几百行,排查问题非常痛苦。
我采用的方案是“一个业务实体一个Servlet”。菜品相关操作放DishServlet,订单相关放OrderServlet,用户相关放UserServlet,购物车相关放CartServlet。每个Servlet里用action参数(通过request.getParameter("action")获取)区分具体方法。这个模式其实已经非常接近Spring MVC的@RequestMapping思路了,理解这种对应关系之后,以后学框架会轻松很多。
2. 数据库设计与核心建模 —— 一份能应付答辩的 MySQL 表结构
2.1 分类表与菜品表的设计
早餐外卖店的菜单很有特点:品类固定、单品价格不高、常有“豆浆+油条”这种套餐逻辑。所以我的设计是“分类表+菜品表”的主从结构,分类表在前台渲染成导航栏分类,菜品表按分类展示。
分类表结构简单,重点是状态字段和排序字段:
CREATE TABLE category ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT '分类ID', name VARCHAR(50) NOT NULL COMMENT '分类名称:粥品/煎饼/豆浆/套餐', sort_order INT DEFAULT 0 COMMENT '排序权重,值小的靠前', status TINYINT DEFAULT 1 COMMENT '1启用 0停用', create_time DATETIME DEFAULT CURRENT_TIMESTAMP );菜品表要注意的字段多一些,我把关键字段贴出来:
CREATE TABLE dish ( id INT PRIMARY KEY AUTO_INCREMENT, category_id INT NOT NULL COMMENT '所属分类ID', name VARCHAR(100) NOT NULL COMMENT '菜品名称', description VARCHAR(500) COMMENT '菜品描述,前台列表展示', price DECIMAL(10,2) NOT NULL COMMENT '现价', original_price DECIMAL(10,2) DEFAULT NULL COMMENT '原价,用于划线价展示', image VARCHAR(200) COMMENT '图片路径,存相对路径', stock INT DEFAULT 0 COMMENT '当日库存,卖完自动下架', sales_count INT DEFAULT 0 COMMENT '累计销量,用于“热销”排序', status TINYINT DEFAULT 1 COMMENT '1在售 0下架', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP );两个细节必须强调:第一,金额字段统一用DECIMAL(10,2),绝对不要用FLOAT或DOUBLE,外卖订单涉及大量金额累加和单价比较,浮点误差会带来对不上账的问题;第二,图片存的是webapp/upload下的相对路径,比如/upload/youitao.jpg,页面渲染时用request.getContextPath()拼接成完整路径。千万别把完整URL写死进数据库,换域名、换端口的时候会疯掉的。
2.2 订单主表与订单明细表建模
早餐外卖的订单和正餐外卖有区别:用户可能是前一天晚上预订第二天早上的餐,所以“期望送达时间”是必须的字段,而且前台下单时要限制只能选明天及以后的时间。订单设计成主从两张表,主表存一次订单的整体信息,明细表存这单里每种餐品的数量、单价、小计。
CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT '订单号:年月日+随机数', user_id INT NOT NULL COMMENT '下单用户ID', total_amount DECIMAL(10,2) NOT NULL COMMENT '订单总金额', status TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付备餐中 2配送中 3已完成 4已取消', receiver_name VARCHAR(50) NOT NULL COMMENT '收货人姓名', receiver_phone VARCHAR(20) NOT NULL COMMENT '联系电话', delivery_address VARCHAR(200) NOT NULL COMMENT '配送地址', expected_time DATETIME NOT NULL COMMENT '期望送达时间', remark VARCHAR(200) COMMENT '订单备注,比如“不要香菜”', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_user_id(user_id), INDEX idx_status(status), INDEX idx_expected_time(expected_time) ); CREATE TABLE order_item ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL COMMENT '订单ID', dish_id INT NOT NULL COMMENT '菜品ID', dish_name VARCHAR(100) NOT NULL COMMENT '下单时的菜品名称(快照)', price DECIMAL(10,2) NOT NULL COMMENT '下单时的单价(快照)', quantity INT NOT NULL COMMENT '数量', subtotal DECIMAL(10,2) NOT NULL COMMENT '小计金额' );这里有一个菜鸟经常忽略的设计:为什么订单明细表里要冗余dish_name和price这两个字段?因为早餐店的菜品是会改价、甚至会删除的。如果订单只存dish_id,一个月后店长把豆浆从两块钱涨到三块钱,你再回去看一个月前的订单报表,金额全变了。这叫“历史数据快照”,外卖系统的订单明细必须保留下单那一刻的商品信息,这是行业通用做法。
2.3 外键、索引和命名规范的实操建议
外键约束在学术上很漂亮,但在实际项目里我建议尽量不要加物理外键,尤其是订单明细表关联菜品表、订单表关联用户表这种高频查询链路。理由有两条:一是早餐外卖高峰期密集下单,物理外键在插入和删除时会有额外的完整性检查开销;二是做订单删除或菜品归档时,物理外键会挡住操作,还得先处理关联数据。业务上的一致性通过Service层事务来控制,数据库只加普通索引。
索引方面,订单表里user_id、status、create_time这三个字段是查询高频字段,前台用户查“我的订单”、后台管理员按状态筛选订单、按时间范围统计营业额,都靠这三个索引。但注意不要无脑加索引——订单状态只有几个固定值,区分度很低,单独加索引收益不大,我更推荐建联合索引(status, create_time),这样既能按状态筛,也能按时间排,一个索引覆盖两种场景。
命名规范方面,我习惯表名用单数,字段名全部小写加下划线:user_id不写成userId,create_time不写成createTime。Java实体类的驼峰命名和数据库下划线命名之间,在DAO层写映射时手动转换即可,项目不大没必要引入ORM框架,但字段命名统一能让代码审查不闹心。主键一律叫id,逻辑外键直接叫xxx_id,一看就能明白关联关系。
3. 核心功能模块实现细节 —— 从登录到结算的完整闭环
3.1 用户登录与会话管理
登录是几乎所有Web系统的入口,早餐外卖系统也不例外。我在UserServlet里写了个login方法,流程就是:拿到用户名和密码,先做非空校验,然后调Service层的login()方法,返回用户对象或null。密码存储那边用了MD5加盐的方式,注册时把盐和密码哈希一起存库,登录时用同规则哈希后比对——虽然是老技术,但配合盐值仍然能挡住大部分“拖库撞库”的低级攻击。加盐的核心是一人一盐,让彩虹表失效。
登录成功后,做法是:request.getSession().setAttribute("loginUser", user)。退出登录时不仅要session.invalidate(),还要记得把Cookie里的JSESSIONID清理掉,不然浏览器还会在响应头里带一个无效的会话ID。
Session这块要提醒一个容易踩的坑:不要把整个User对象直接塞在Session里当JSON用,然后在JSP里取${loginUser.password}这种危险操作。正确做法是把密码字段设为null再放入Session,或者单独建一个SafeUser对象,只保留id、用户名、手机号、角色这些安全字段。
登录拦截用Filter来实现最合适。我建了一个LoginFilter,在web.xml里配置映射路径:
@WebFilter("/*") public class LoginFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; String uri = request.getRequestURI(); // 放行登录页、注册页、静态资源、前台浏览接口 if (uri.endsWith("login.jsp") || uri.endsWith("register.jsp") || uri.contains("/static/") || uri.contains("/upload/") || uri.contains("userServlet?action=login") || uri.contains("userServlet?action=register") || uri.contains("dishServlet?action=list")) { chain.doFilter(request, response); return; } // 其余页面必须登录 HttpSession session = request.getSession(false); if (session != null && session.getAttribute("loginUser") != null) { chain.doFilter(request, response); } else { response.sendRedirect(request.getContextPath() + "/front/login.jsp"); } } }注意request.getSession(false)里的false——意思是如果没有Session就返回null,而不是新创建一个。这个细节很关键:未登录用户访问后台页面时,不应该被“强行创建Session”,白白浪费服务器内存,还让会话ID有了可乘之机。
3.2 菜单展示、分页搜索与图片坐标定位
前台的菜单页是整个系统门面,也是JSP标签库用得最密集的地方。我用JSTL的c:forEach遍历菜品列表,加上c:choose实现“有图显示图、无图显示占位图”的逻辑。分类导航栏用c:if判断当前选中分类高亮。
分页必须自己写清楚:页面传pageNum参数,每页固定6个菜品(因为早餐店数量不大,一页放太少显得空旷,放太多又太长),DAO层先用SELECT COUNT(*)算总量,再执行带LIMIT的查询:
int pageSize = 6; int totalCount = dishDao.countByCategory(categoryId); int totalPages = (int) Math.ceil(totalCount * 1.0 / pageSize); int offset = (pageNum - 1) * pageSize; List<Dish> dishList = dishDao.findByCategory(categoryId, offset, pageSize);页码导航怎么做?我的方案是直接用c:forEach从1循环到totalPages,生成?pageNum=${i}&categoryId=${categoryId}的链接。这里有个细节:翻页时一定要把categoryId也带上,并且当前页码要控制在1和totalPages之间,防止用户直接在地址栏输入pageNum=999导致SQL越界。
图片坐标定位这个需求在早餐外卖里有实际应用场景——比如菜单页的一张菜品展示图上标注“豆浆 2元 / 油条 3元 / 茶叶蛋 1.5元”,点击不同坐标区域跳到对应菜品分类。原生JSP做这个很简单,不算复杂:给图片设置usemap属性,用HTML的<map>和<area>标签划定多个圆形或矩形热区,每个热区的href指向不同查询链接:
<img src="${ctx}/upload/breakfast_map.png" usemap="#breakfastMap" alt="早餐地图"> <map name="breakfastMap"> <area shape="rect" coords="10,10,120,120" href="${ctx}/dishServlet?action=list&categoryId=1" alt="粥品"> <area shape="circle" coords="180,80,40" href="${ctx}/dishServlet?action=list&categoryId=2" alt="煎饼"> </map>coordinates的四个数字分别代表矩形左上角和右下角的x、y坐标值,确定区域时直接对着图片坐标量就行。如果要求更细致的交互,可以嵌入一小段JavaScript监听图片的click事件,用event.offsetX和event.offsetY判断点击位置落入哪个区域范围,再手动跳转。两种方式对比:HTML map标签简单可靠但不灵活;JS判断可以加高亮提示、弹窗预览,体验更好,代价是要自己写逻辑。
3.3 购物车实现——为什么用Session而不用数据库表
购物车这环节错误答案最多。有些人习惯性地建一张cart_item表,用户加一次购物车就写一次数据库,提交订单时再逐条读出来、删除。这个方案在体验上有个硬伤:用户逛了半天加了一堆菜,中途浏览器直接关掉,或者Session超时,这些购物车数据就成了垃圾数据,你还得定时清理。而且早餐外卖的场景里,购物车生命周期极短,从加餐到下单往往不超过几分钟,根本犯不着落到数据库。
我在这个项目里把购物车直接放在Session里,结构是Map<Integer, CartItem>——key是菜品id,value是包含菜品信息、数量、小计的购物车项。实现思路是:
Map<Integer, CartItem> cart = (Map<Integer, CartItem>) session.getAttribute("cart"); if (cart == null) { cart = new HashMap<Integer, CartItem>(); session.setAttribute("cart", cart); } // 加购时判断:key存在就数量+1,不存在就新建一项这个方案的好处和柜台点餐的体验一致:加购不需要访问数据库,零延迟;购物车数据随Session自然销毁,没有脏数据积压。缺点你也能想到——用户在手机上逛着逛着把浏览器关了购物车就没了。但在早餐外卖这种低决策成本、即点即走的场景下,这是完全可以接受的取舍。
购物车页面的“数量加减”按钮要实现一个细节:数量减少到0时,这一项自动从Map里移除。营业额统计和库存扣减都依赖购物车数据的准确性,不要在页面上显示数量为0的餐品后还能提交,会让订单金额算错。
3.4 订单提交流程与事务控制
下单是整个系统的核心交易环节,最容易出数据不一致的地方。我按下面的步骤设计Service层的submitOrder方法:
- 从Session里拿购物车,校验非空;
- 从Session里拿登录用户,校验非空;
- 遍历购物车明细,逐项检查菜品仍在售、库存够数;
- 计算订单总金额,循环累加每个明细的
subtotal; - 生成订单号,形如
20250127093000001,纯数字避免和字母混淆; - 插入订单主表拿到自增id;
- 遍历购物车明细批量插入订单明细表;
- 扣减库存,
UPDATE dish SET stock = stock - ? WHERE id = ? AND stock >= ?; - 清空Session里的购物车;
- 返回订单号,跳转到支付模拟页。
这十步里,第5、6、7、8四步必须处于同一个事务里。如果只插了主表、明细表插入失败,主表订单就是一条“孤儿订单”;如果下单成功但库存没扣,商品就可能超卖。早餐店虽然单量不大,但逻辑上不能有这种漏洞。
我用最朴素的方式管理事务,在DBUtil里封装getConnection()、beginTransaction()、commit()、rollback(),然后在Service层手动控制:
Connection conn = DBUtil.getConnection(); try { conn.setAutoCommit(false); // 插入订单主表、明细表、扣库存 DBUtil.commit(conn); } catch (Exception e) { DBUtil.rollback(conn); throw new RuntimeException("下单失败", e); } finally { DBUtil.release(conn); }由于DAO层的每个方法内部都会独立拿连接和释放连接,开事务时必须用一个外部传入的连接。方法签名设计成insertOrder(Connection conn, Order order),或者在DAO内部增加一个重载方法透传连接。新手在这个地方很容易犯的错是:Service层开启事务后调用DAO方法,DAO又自己DriverManager.getConnection()新拿了一个连接,导致事务根本没作用在同一个数据库会话上。务必保证整个业务链路用的是同一个Connection。
订单状态流转我设计为:0待支付 → 1已支付/备餐中 → 2配送中 → 3已完成,或者0待支付 → 4已取消。后台管理员操作按钮根据当前状态显示对应的下一步操作。这个状态机的实现原则是:状态只能往后走,不能回退,例如不能从“配送中”改成“待支付”,否则财务数据就乱了。
3.5 后台订单管理与营业额统计
后台管理员的首页是一个轻量仪表盘:今日订单数、今日营业额、待处理订单数、菜品销量Top5。这些数据如果用一条SQL硬拼会很痛苦,我的做法是拆成多个SQL聚合查询,分别查COUNT(*)、SUM(total_amount)等。
营业额统计有一个大坑:订单金额的累加时机。如果统计口径是“订单创建时间”,那就要排除已取消状态;如果统计口径是“订单完成时间”,那所有状态都得列入“待支付”但未支付的也算异常。我最终采用的口径是“只统计状态为已支付、配送中、已完成的订单”,把待支付和已取消排除掉。SQL写法类似:
SELECT DATE_FORMAT(create_time, '%Y-%m-%d') AS day, COUNT(*) AS order_count, COALESCE(SUM(total_amount), 0) AS revenue FROM orders WHERE status IN (1, 2, 3) AND create_time >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) GROUP BY DATE_FORMAT(create_time, '%Y-%m-%d') ORDER BY day;COALESCE必须加上——某一天没有任何订单时,SUM返回null,页面直接显示空白,用0填充才合理。统计图不建议后端生成图片,太重了。我的方案是后台JSP页面把统计结果转换成JSON数组,用原生JavaScript画简单的柱状图,或者引入轻量Chart.js库。早餐店老板最关心的是“今天卖了多少单”“哪几样卖得好”,柱状图和排名列表就够了,不需要炫酷的3D图表。
4. 前端交互与JavaScript细节 —— 让页面活起来的小技巧
4.1 表单校验:从正则到提交拦截
外卖系统的表单主要集中在登录、注册、下单这三个场景,登录只需要用户名密码非空,但注册和下单必须做格式校验。我在前端用JavaScript做首道防线,在Servlet端做第二道防线,绝不信前端的校验结果。
手机号校验是最常见的需求,我用一个正则:
function isValidPhone(phone) { var reg = /^1[3-9]\d{9}$/; return reg.test(phone); }这个正则代表:第一位是1,第二位在3到9之间,后面跟着9位数字,总计11位。它基本覆盖了目前国内用户手机号的规律,能在用户输入错误时立刻提示,减少无效请求到达服务器。但正则只是“格式校验”,不是“真实验证”——如果项目需要验证号码真的存在,那就要接短信验证码服务,早餐店系统用不上这个级别。
下单表单里的“期望送达时间”必须用JavaScript校验:不能选过去的日期,不能选今天之前的时间。我的做法是用new Date()获取当前时间戳,然后和输入框的值做对比,时间早于当前时间直接弹框拦截。
4.2 AJAX局部刷新:消息提示和购物车角标
JSP页面默认是同步刷新,但有几个场景用AJAX体验能提升一个档次。最典型的是“加入购物车”:用户点一下“加入购物车”按钮,如果整页刷新,页面滚动位置会重置,用户要重新找到刚才那个菜,很烦。我改成用fetch或XMLHttpRequest异步提交,返回JSON,前端根据结果更新页面上的“购物车商品数量”角标,同时弹一个提示条“已加入购物车”。
原生JavaScript里可以用这个方式实现AJAX请求:
function addToCart(dishId) { fetch(ctx + '/cartServlet?action=add&dishId=' + dishId, { method: 'GET', credentials: 'same-origin' }) .then(function(resp) { return resp.json(); }) .then(function(data) { if (data.success) { document.getElementById('cartCount').textContent = data.cartCount; showMsg('已加入购物车'); } else { showMsg(data.message || '加入失败'); } }); }这里有个细节要注意:AJAX请求同样会被LoginFilter拦截。如果用户没登录就直接点“加购”,返回的不是JSON,而是一个重定向到登录页的HTML页面,前端解析resp.json()会报错。我的处理方案是:在Filter里对AJAX请求做单独分支判断(通过X-Requested-With请求头识别),未登录时返回{"success":false,"message":"请先登录"},前端的提示条会展示这个信息。
购物车角标更新的本质是:后端addToCart逻辑执行完成后,计算cart.size(),把数值放进JSON返回。前端拿到后直接textContent赋值,这样不用刷新页面就能看到角标变化。
4.3 图片坐标交互和旋转的实用技巧
前面提到了HTML map做图片热区,这里再补充一种方案:用JavaScript监听图片点击位置实现更复杂的交互。比如菜品详情页有一张“套餐搭配示意图”,用户点击图上的某个位置,系统判断点击点落在哪个菜品的圆形区域内,然后弹出该菜品的详情卡片,再点一次可以加购。
核心代码是这样的:
document.getElementById('dishMap').addEventListener('click', function(e) { var rect = e.target.getBoundingClientRect(); var x = e.clientX - rect.left; var y = e.clientY - rect.top; // 判断坐标是否落在某个圆内,比如圆心(100,80) 半径40 var dx = x - 100; var dy = y - 80; if (dx * dx + dy * dy <= 40 * 40) { window.location.href = ctx + '/dishServlet?action=detail&id=3'; } });判断一个圆要计算距离平方,这是初中几何——点到圆心的距离小于半径就在圆内。这套逻辑比HTML map灵活在与后端数据联动:菜品坐标可以存在数据库里,前端循环读取后在canvas或div上动态绘制热区,而不需要改HTML代码。
另外提一个小技巧:有些早餐店会拍竖版图片,但页面上想展示横版的效果。传统做法是后端处理图片旋转,其实前端一行JavaScript就能搞定旋转,例如:
document.getElementById('menuImg').style.rotate = '-90deg';CSS的rotate属性可以直接用角度值,注意设置旋转变换后也要配合transform-origin调整旋转中心,不然图片会绕着左上角转,视觉上偏了。这类前端小技巧在处理一些手机拍摄的早餐实拍图时特别实用。
4.4 跨浏览器兼容的几个注意点
现在的浏览器都是现代浏览器了,大部分标准API都能直接用,但做JavaWeb课程设计时要考虑用户可能用IE或者老内核浏览器访问。兼容性风险最高的有四个地方:fetch、Promise、let/const、Arrow Function。
如果项目没有引入任何框架和库,我建议写原生JavaScript时采用ES5语法,或者用最基础的XMLHttpRequest封装。比如上面的addToCart函数改成原生写法:
var xhr = new XMLHttpRequest(); xhr.open('GET', ctx + '/cartServlet?action=add&dishId=' + dishId, true); xhr.onreadystatechange = function() { if (xhr.readyState === 4 && xhr.status === 200) { var data = JSON.parse(xhr.responseText); ... } }; xhr.send();这里不是让你退回老技术,而是在选型阶段就想清楚目标用户是谁。如果这个系统只是校内课程设计,运行环境就是Chrome,那用fetch完全没问题。如果是要交付给某个早餐店实际使用,店里的电脑可能是十年前的老机器、浏览器从来没升级过,这时候ES5的兼容性优势立刻体现出来。
5. 本地环境部署实操 —— IDEA + MySQL 从零跑通项目
5.1 MySQL 安装与版本选择
这个项目用哪个版本的MySQL合适?我推荐5.7 LTS版本,例如5.7.44,原因有三点:5.7还是目前大量高校和中小公司的稳定主力版本,网上教程最多;和8.0相比,5.7的连接驱动配置相对简单,不会碰到8.0的caching_sha2_password验证插件兼容问题;早餐外卖项目的SQL语句没有8.0特有的窗口函数需求,完全不需要硬上8.0。
但如果你电脑上已经装了8.0,也不用卸掉重装。只要在JDBC连接串里额外配置两项:useSSL=false和allowPublicKeyRetrieval=true。8.0默认使用caching_sha2_password认证,老版本的MySQL驱动(5.1.x系列)连接会报“Public Key Retrieval is not allowed”这个错,加上allowPublicKeyRetrieval=true就能解决。
安装MySQL时有两个大坑必须提前说。第一个是初始密码:Windows用ZIP压缩包方式安装时,解压后执行mysqld --initialize-insecure会生成一个root空密码,这样第一次登录不用费劲找临时密码;直接打mysql -uroot就能进。第二个是my.ini配置文件里的字符集:default-character-set要配合MySQL 5.7的写法,服务端设置character-set-server=utf8mb4,客户端连接时在连接串里追加useUnicode=true&characterEncoding=utf8,否则JSP页面显示中文全是问号。
Linux服务器上部署时,RPM包安装有一个常见问题——MySQL 5.7官方仓库有时只显示最新小版本,比如搜索“mysql 5.7.44”可能找不到对应RPM包。我的建议是直接到MySQL官方下载站挑mysql-community-server-5.7.44-1.el7.x86_64.rpm这种完整包安装,不要依赖yum源自动解析版本。
5.2 IDEA 配置 Tomcat 与项目结构
在IDEA里跑JSP+Servlet项目,新手被卡住的地方:新建项目时选错了类型。正确姿势是创建一个普通的“Java Enterprise”项目,勾选Web Application模板,然后配置Tomcat。如果你手里是已经写好的项目代码,导入时要注意三个地方:
第一,项目必须包含webapp目录,且该目录被正确标记为Web资源目录。IDEA里右键webapp→ “Add Framework Support” → 选择“Web”是可以补救的。第二,WEB-INF/web.xml文件必须存在,Servlet全注解开发时代甚至可以没有web.xml,但IDEA的Artifact打包默认依赖这个文件。第三,Tomcat的Lib目录要确认:IDEA里配置Tomcat的“Server”选项时,“Libraries”标签页如果没有把servlet-api.jar加进来,所有Servlet类都会编译报错找不到HttpServlet。
5.3 JDBC 连接与SSL错误处理
JDBC连接MySQL的代码是我见过报错频率最高的地方。核心连接串如下,以MySQL 5.7为例:
private static final String URL = "jdbc:mysql://localhost:3306/food_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai"; private static final String USER = "root"; private static final String PASSWORD = "yourpassword"; Class.forName("com.mysql.jdbc.Driver"); Connection conn = DriverManager.getConnection(URL, USER, PASSWORD);useSSL=false几乎必写。如果你装的是MySQL 8.0却下载了5.x的驱动,如果不加这个参数,会报“SSL connection error: java.net.SocketException: Connection reset”,看起来像网络问题,实际是SSL握手失败。serverTimezone=Asia/Shanghai也要写,因为MySQL 5.7之后驱动要求明确时区,不写会报The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized这种乱码时区错误。
驱动包的jar文件放置位置也是入门常见问题:正确的放法是复制到webapp/WEB-INF/lib目录下,而不是项目根目录某处。IDEA里虽然可以通过“Add as Library”让编译通过,但Tomcat部署时如果驱动只在编译classpath里、不在WEB-INF/lib,运行时就会报ClassNotFoundException: com.mysql.jdbc.Driver。保险起见同时放到外部的Tomcat lib目录也可以,但我更推荐跟随项目打包,这样换一台电脑拉代码不会丢依赖。
5.4 部署过程中最常见的几个404/500问题
部署跑起来之后,如果页面出现404或500,先别慌,按以下列表排查,大部分情况是低级配置问题:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 浏览器访问localhost:8080显示404 Tomcat首页 | 项目没有正确部署到Tomcat的webapps目录 | IDEA里检查Artifact输出,或者直接把项目打成war包放到Tomcat/webapps下 |
| Servlet访问时404 | 注解@WebServlet的URL路径写错,或web.xml里没有正确配置映射 | 确认注解路径以/开头,比如@WebServlet("/dishServlet") |
| 访问时500 ClassNotFoundException | JDBC驱动jar不在WEB-INF/lib下 | 把jar复制进webapp/WEB-INF/lib后Rebuild Artifact |
| 中文乱码 | 页面编码、请求编码、数据库编码不一致 | 统一设置JSP页面pageEncoding="UTF-8",Servlet里request.setCharacterEncoding("UTF-8"),Filter全局接管 |
| 访问jsp页面直接变成源码下载 | Tomcat没有配置JSP支持 | 检查是否误把JSP文件放到了webapp/static或直接用浏览器访问了未编译的源文件 |
| 报错端口被占用 | Tomcat默认8080被其他程序占用 | 用`netstat -ano |
还有一个特别隐蔽的问题:IDEA重新部署项目时常常报“Address localhost:8080 is already in use”。这是因为之前部署的Tomcat实例没有正常关闭,杀进程时注意看java.exe进程,直接全杀容易误伤IDEA自身,我建议用netstat -ano查出监听8080端口的PID,再taskkill /PID xxx /F。
6. 实操过程实录 —— 跑一遍核心流程,看看每一步做了什么
6.1 从建库建表到后台发布菜品的完整流程
拿一台全新的Windows机器举例,整个流程分七个步骤:
第一步,安装MySQL并启动服务。ZIP包解压后在bin目录执行mysqld --install和net start mysql,然后命令行执行mysql -uroot -p登录,执行建库语句CREATE DATABASE food_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;。选择utf8mb4而不是utf8的原因是:utf8mb4能存emoji和生僻字,早餐店铺名和菜品里可能出现的特殊符号不会变成乱码。
第二步,执行表结构SQL建表。依次执行分类表、菜品表、用户表、订单主表、订单明细表的建表语句,然后插入测试数据。比如:
INSERT INTO category(name, sort_order) VALUES ('粥品', 1), ('煎饼', 2), ('豆浆油条', 3); INSERT INTO dish(category_id, name, price, stock, status) VALUES (1, '皮蛋瘦肉粥', 6.00, 50, 1), (2, '加蛋煎饼', 8.00, 30, 1), (3, '豆浆', 2.00, 100, 1);第三步,在IDEA里创建JavaWeb项目,配置好Tomcat;第四步,编写实体类。Dish实体类要包含所有表和前端展示需要的字段,注意originalPrice和categoryName这类冗余展示字段可以加上——它对应分类表的一对一关联,通常用JOIN查询时在DAO层手动组装。第五步,编写JDBC工具类和DAO层;第六步,编写Servlet层和JSP页面;第七步,启动Tomcat,访问http://localhost:8080/food_web/查看菜单页。
后台发布菜品的流程:管理员登录后进入/admin/dish_list.jsp,点“新增菜品”填写基本信息。这里图片上传是个必做的功能,我建议先把图片文件保存到webapp/upload目录,数据库只存“上传后返回的文件名”。实际上传用ServletFileUpload解析multipart表单,一个隐藏坑是Tomcat的默认post体大小限制——maxPostSize默认2MB,早餐菜品图片一般不大但也要注意,如果上传超过2MB的图片会直接抛异常,需要到conf/server.xml的<Connector>标签里设置maxPostSize="-1"(-1表示不受限)。
6.2 顾客端模拟下单流程
顾客打开首页,看到分类导航栏和菜品列表,点“加入购物车”按钮。这里需要注意的是:未登录用户点击加购时,Filter会拦住并跳到登录页,但URL参数丢失,登录后不会自动回到加购动作。我的改进方案是:跳转登录页前把原始请求URI存入Session,登录成功后sendRedirect回去:
session.setAttribute("returnUrl", request.getRequestURI() + "?" + request.getQueryString());这样用户登录一次就能回到刚才浏览的位置,体验好不少。登录后继续加购,右上角购物车角标从0变1,再点进去看到购物车明细列表,修改数量后,页面底部实时刷新总金额——这一项我用JS计算,因为每个菜的小计和总价都能从前端数据拿到,不用每次改动都请求后端。
然后点击“去结算”,填写收货人、手机号、地址、期望送达时间。时间组件我用<input type="datetime-local">,好处是浏览器自带原生选择器,后端解析时用SimpleDateFormat按yyyy-MM-dd'T'HH:mm格式处理。提交订单后,页面跳转到支付模拟页,显示订单号和应付金额;点击“模拟支付”按钮,Ajax请求后端把订单状态从0改成1,页面显示“支付成功,店铺备餐中”。
整个流程在后端做了什么:提交时事务里插了主表和明细表,扣了库存,清空购物车;支付时只更新订单状态字段,一个UPDATE搞定。早餐店场景不需要真实接支付接口,这个模拟流程已经把业务闭环打通了。
6.3 管理员端订单处理流程
管理员登录后看到待处理订单列表,每个订单显示订单号、用户、餐品明细、总金额、期望送达时间。点击“确认接单”按钮,后端把status 1改成status 2。再点“完成配送”,status 2改成status 3。
这里有一个实际操作中的体会:管理员页面最好用定时刷新或手动刷新方式更新列表,不用做WebSocket实时推送。早餐店高峰时段可能同时来十几个订单,管理员每接一单刷新一下页面完全够用,WebSocket的实时性在这个场景里是伪需求,还引入一堆复杂度。如果真想提升体验,可以加一个前端定时器,每分钟自动location.reload(),或者用AJAX轮询刷新订单数量角标,实现简单效果也尚可。
6.4 数据统计校验
后台统计页要显示每日营收曲线和热销菜品榜。菜品类销量我依赖订单明细表里的quantity字段:
SELECT dish_name, SUM(quantity) AS total_sales FROM order_item WHERE order_id IN ( SELECT id FROM orders WHERE status IN (1,2,3) ) GROUP BY dish_name ORDER BY total_sales DESC LIMIT 5;这种SQL在数据量小的时候性能没问题。等订单积累到万级,子查询的性能瓶颈会出现,优化方向是把统计逻辑拆到独立报表表里,每天夜里定时汇总。但在课程设计和创业初期完全够用,不必过度设计。
7. 常见问题与排查技巧速查表
7.1 高频报错的一线解决方案
列举一下我实际跑这个项目时遇到、以及帮别人排查过的最典型问题,直接按表格查就行:
| 症状 | 根因 | 处理方式 |
|---|---|---|
java.sql.SQLException: Access denied for user 'root'@'localhost' | 密码错误或root账号授权问题 | 在命令行用mysql -uroot -p重设密码,或执行ALTER USER 'root'@'localhost' IDENTIFIED BY '新密码'; |
The server time zone value ... is unrecognized | 连接串没指定时区 | 连接串加serverTimezone=Asia/Shanghai,MySQL 8.0时加&useSSL=false&allowPublicKeyRetrieval=true |
| 中文乱码 | 多层编码不一致 | Filter里统一request.setCharacterEncoding("UTF-8"),JSP顶部pageEncoding="UTF-8",MySQL表字段用utf8mb4 |
| 表单提交后原子性问题 | 没有开启事务 | Service层包事务,确认DAO方法复用同一个Connection |
| Tomcat运行后无法访问项目页面 | Artifact配置错误 | IDEA里Project Structure → Artifacts → 确保输出类型为war exploded,Deployment里Application context填/ |
MySQL 8.0连不上报Public Key Retrieval is not allowed | 8.0默认认证插件 | 连接串加allowPublicKeyRetrieval=true,或创建MySQL用户时指定mysql_native_password |
7.2 SQL层面的隐蔽坑
早餐外卖系统的SQL并不复杂,但几个隐蔽问题非常值得记录。第一个是DELETE FROM orders WHERE id=?这种删除订单的SQL,一旦订单有明细表关联,删除主表会产生孤儿明细数据。项目里我的策略是不做物理删除,用status=4(已取消)标记,后台列表默认过滤掉,但数据完整保留。第二个是分页排序混乱问题,如果一个页面上菜品数据多次插入,没有明确的ORDER BY id,MySQL返回顺序可能随机且不稳定。所以我分页SQL统一加ORDER BY id ASC,避免用户翻页时看到菜品顺序跳变。
第三个是COUNT(*)和COUNT(1)的选择——在这类小项目里性能差异可忽略,但注意COUNT(column)会忽略NULL列,统计订单数量时如果列有NULL值就容易数错,直接用COUNT(*)最稳妥。
7.3 前后端联调时的数据格式问题
后端Servlet返回数据给前端JavaScript时,通常用JSON格式。我用的工具是阿里的Fastjson或者Google的Gson,在Java里JSON.toJSONString(resultMap)然后通过response.getWriter().write(json)输出。JSP的<script>块里接收时有一个编码陷阱:如果JSP本身contentType="text/html; charset=UTF-8",但Servlet响应的Content-Type没设置,前端JSON.parse可能因为编码问题解析失败。稳妥做法是Servlet端每次返回JSON都设置:
response.setContentType("application/json;charset=UTF-8");前端拿到中文后不用手动转码,JSON标准要求UTF-8。
8. 从JSP+Servlet到框架的思维迁移
如果这个系统你已经完整写过一遍,我建议你做三个后续的小实验,用最小的成本感受框架演进思路。
第一个实验:把DishServlet的一个action方法改成Spring MVC的@RequestMapping写法,对比一下URL映射和参数获取的异同。你会发现Spring MVC的@RequestParam本质就是request.getParameter()的封装,ModelAndView本质就是request.setAttribute() + forward的封装。
第二个实验:把JDBC手动事务改成声明式事务,感受一下@Transactional为什么能减少重复代码。手写事务要每次开连接、提交、回滚,声明式事务只用加一个注解,底层是用AOP动态代理做的同一件事。
第三个实验:把Session购物车改成Redis缓存购物车,对比Session方案和独立缓存方案的差异。Session方案天然绑定单台服务器,Redis方案可以让用户请求打到不同后端节点而不丢购物车数据。这个对比做完后,你会对无状态设计和水平扩展有切身理解。
每次从老技术迁移到新技术时,试着在代码注释里标注“这个注解背后做了哪些事,如果不用框架我会怎么写”——坚持这样一个习惯,技术底子会打得很扎实。我见过太多只会在Spring Boot里写@RestController、但连HTTP请求是怎么被Servlet容器接住的人。早餐外卖项目这种“裸写”机会,其实是性价比极高的自我训练。
最后分享一点个人看法:技术选型要看场景,课程设计和毕业设计写JSP+Servlet不是落后,而是用最小的复杂度看清整个Web请求链路。你在IDEA里按部就班搭一次这个系统,把Session透传、事务边界、Filter拦截这几块真正吃透,再去学Spring Boot,效率能翻倍。上面这些坑我基本都踩过一遍,写下来算是给后来者省点时间,照着做应该能少走不少弯路。