做Java Web课程设计,或者想拿一个完整项目练手的时候,JSP+Servlet+MySQL这套组合基本上是绕不开的。今天聊的这个鲜花商城系统就是典型代表:后端用Java类处理业务,JSP负责渲染页面,Servlet承担请求分发,数据全部落在MySQL里,前端配合Bootstrap和JavaScript做布局与交互。它覆盖了电商系统最核心的用户、商品、购物车、订单这条主线,同时又没有Spring Boot那么重的封装,特别适合刚学完Java基础、想搞懂Web请求到底是怎么一步步走的同学。整篇文章我会从技术选型、数据库设计、后端逻辑、前端交互、环境搭建与排坑五个维度把这个项目完整拆开,尽量做到你照着就能复现。
1. 项目定位与技术选型思路
1.1 一个小小的商城系统到底要解决哪些问题
很多人以为商城系统就是"把商品列出来,点购买,完事"。真动手做就会发现,一个哪怕只是应付课程设计的鲜花商城,也要把下面这条链路完整跑通:
用户进来先看到首页,顶部分类导航、中间轮播图、下面是按分类排列的鲜花商品卡片。点击某个商品进入详情页,能看到图片、价格、库存、描述。加入购物车之后,购物车页面要支持修改数量、删除商品、实时计算总价。确认订单后要生成订单记录,扣减库存,清空购物车。如果用户没登录,访问购物车和结算页面还要被拦回去先登录。
这条链路看起来简单,落到技术层面就涉及:页面模板渲染、Servlet请求分发、Session会话状态、JDBC数据库访问、SQL查询与事务控制、前端表单校验和异步交互。每一环都是Java Web的基础考点,这也是它适合当课程设计的原因——不是说它多先进,而是它把你学过的零散知识点全部串了起来。
1.2 为什么JSP+Servlet至今仍是课程设计的首选方案
近两年Spring Boot成了主流,很多学生一上来就学Boot,反而把Servlet和JSP扔在一边。但我个人看法是,如果你是想靠这个项目巩固Web基础,JSP+Servlet反而比Spring Boot更合适。
原因很简单:Servlet的生命周期、doGet和doPost的分发逻辑、请求参数获取、转发与重定向、Session机制,这些是Java Web的地基。Spring Boot把这些全部封装成了注解和自动配置,你写起来省事,但出了问题完全不知道请求在哪一步断的。而用Servlet写一遍,你能亲眼看到浏览器发来的请求是怎么被web.xml或注解映射到某个Java类,再通过req.getParameter拿到参数,调用DAO查询数据库,最后把结果setAttribute到request域,转发到JSP渲染出HTML。
那是不是说JSP+Servlet就一定好?当然也不是。它的痛点也很明显:JSP里混着Java代码和HTML标签,前端页面复杂以后维护起来很痛苦;后端也没有Spring那样的依赖注入和AOP,业务代码多了容易膨胀。所以我的态度是:学习阶段用它打底,理解了Web本质之后再往Spring Boot迁移,这套项目里你写的DAO和Servlet逻辑,迁移过去照样能复用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据库设计:六张表撑起整个商城
2.1 核心表结构与字段设计要点
数据库设计是这类项目最先动工的部分,表结构定得合理,后面Servlet和DAO写起来会顺手很多。这个鲜花商城我建议控制在六张表左右:用户表、商品分类表、商品表、购物车表、订单表、订单明细表。字段不要贪多,但关键的约束和类型必须拿捏准。
用户表主要存用户名、密码、手机号、收货地址、注册时间。密码字段这里要提醒一下:哪怕只是课程设计,也尽量不要明文存储,至少用MD5加盐处理一次。商品分类表很简单,就是分类ID、分类名称、描述。商品表是核心,包含分类外键、商品名称、价格、库存、主图路径、描述。购物车表记录用户和商品的对应关系,外加一个数量字段。订单表存订单编号、用户ID、总金额、订单状态、下单时间。订单明细表用来记录每个订单里包含哪些商品、各自买了多少、下单时价格是多少。
在设计上有几个细节值得注意。价格字段千万别用double或float,用decimal(10,2),不然算总价的时候会出现0.1+0.2不等于0.3这种尴尬问题。库存字段加上无符号约束,防止库存被扣成负数。订单号不要直接用自增主键,建议用时间戳加随机数拼一个业务订单号,这样订单号可以对外展示,而自增主键只做内部关联。订单状态用tinyint,0表示未支付,1表示已支付,2表示已发货,3表示已完成,4表示已取消,比直接用字符串更规范。
下面给一个商品表的建表SQL参考:
sql复制CREATE TABLE t_product (
id INT PRIMARY KEY AUTO_INCREMENT COMMENT '商品ID',
category_id INT NOT NULL COMMENT '分类ID',
name VARCHAR(100) NOT NULL COMMENT '商品名称',
price DECIMAL(10,2) NOT NULL COMMENT '售价',
stock INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '库存',
image VARCHAR(255) DEFAULT NULL COMMENT '图片路径',
description TEXT COMMENT '商品描述',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
这里要强调两点:表引擎必须用InnoDB,因为后面订单结算需要事务控制,MyISAM不支持事务;字符集统一用utf8mb4,不然插入表情符号或者某些特殊字符时,数据库直接报错或者存成乱码。
2.2 JDBC连接池与DAO层的封装方案
JSP+Servlet架构里没有MyBatis帮你管数据库连接,JDBC这块必须自己封装好。最基础的要求是写一个DBUtil工具类,负责加载驱动、获取连接、释放资源。但这里我强烈建议不要用最原始的DriverManager.getConnection来扛,因为每次请求都要创建物理连接,数据库连接开销非常大,高并发场景下分分钟把MySQL拖死。
课程设计级别用连接池就够了。DBCP和C3P0都是老牌方案,配置也简单。我自己做这类项目习惯用DBCP,在项目里放一个db.properties配置连接信息:
properties复制driverClassName=com.mysql.jdbc.Driver
url=jdbc:mysql://localhost:3306/flower_shop?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
username=root
password=123456
initialSize=5
maxActive=20
然后写一个DBUtil,静态代码块里通过BasicDataSourceFactory创建DataSource,之后所有DAO都从dataSource.getConnection()拿连接。这样连接复用、性能有保障,代码也不复杂。
DAO层的封装思路是写一个BaseDAO基类,把获取连接、关闭连接的通用逻辑放在里面,子类DAO只负责写自己的SQL。这里有个特别容易被忽略的细节:关闭资源一定要在finally块里做,而且要按照ResultSet、PreparedStatement、Connection的顺序关。很多新手只关Connection不关Statement,短时间没问题,跑一阵子就报Too many connections,原因就是连接池的连接没被正确归还。
另外,所有查询都必须用PreparedStatement,不要用Statement拼SQL字符串。这不仅是防SQL注入的安全问题,PreparedStatement还有预编译的性能优势。举个例子,执行按分类查询商品的SQL时:
java复制String sql = "SELECT * FROM t_product WHERE category_id = ?";
PreparedStatement ps = conn.prepareStatement(sql);
ps.setInt(1, categoryId);
ResultSet rs = ps.executeQuery();
?占位符贯穿整个查询、更新、删除操作,等你以后接触MyBatis里的#{},会发现底层逻辑是一样的。
3. 后端核心逻辑实现拆解
3.1 用户模块:注册、登录与Session管理
用户模块是商城系统的基础,因为购物车和订单都依赖登录状态。注册流程很直接:register.jsp表单提交用户名、密码、确认密码、手机号到RegisterServlet,Servlet先判断两次密码是否一致,再调UserDAO的findByUsername检查用户名是否被占用,没占用就插入数据,然后重定向到登录页。这里有个细节:注册成功和失败都应该给用户明确的反馈,失败了要回显之前填写的用户名和手机号,不然用户要重新填一遍表单,体验非常差。
登录逻辑比注册稍微复杂一点。用户提交账号密码到LoginServlet,查询数据库比对密码,比对成功后把用户对象塞进Session,再根据是普通用户还是管理员决定跳转到首页还是后台。这里有一个安全提醒:用户密码在数据库里如果是密文,比对时要把用户输入的密码做同样的加密处理再比对,而不是查出来明文比较。还要注意Session失效时间,要么在web.xml里配置session-timeout,要么在用户长时间不操作后主动让Session过期,防止账号信息一直挂在浏览器上。
权限控制是用户模块里很容易漏掉的部分。如果不做任何拦截,用户不登录直接访问cart.jsp或order.jsp,页面就会因为拿不到Session里的用户信息而报空指针。我的做法是写一个LoginFilter,在web.xml里配置拦截规则:
java复制public class LoginFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse resp = (HttpServletResponse) response;
Object user = req.getSession().getAttribute("loginUser");
if (user == null) {
resp.sendRedirect(req.getContextPath() + "/login.jsp");
return;
}
chain.doFilter(request, response);
}
}
web.xml里把需要登录才能访问的路径全部match进去,比如/cart/、/order/。这样既保护了页面,也保护了后端的Servlet接口。记住过滤器的顺序很重要,如果同时有编码过滤器和登录过滤器,编码过滤器要放在最前面。
3.2 商品模块:分类加载、列表分页与详情跳转
商品模块是用户打开系统后最先接触的部分。主页顶部的分类导航,我建议不要写死在JSP里,而是通过ProductServlet查询t_category表动态加载。这样以后增加"永生花""绿植"这些分类时,不用改页面代码,只往数据库插一条记录就行。
商品列表页要处理的核心问题是分页。不管商品有多少,一次性把所有数据查出来渲染到页面上都会让页面变卡,而且数据量大的时候SQL查询也不够高效。分页逻辑通常是前端传currentPage和pageSize两个参数,Servlet接收后计算offset,拼出SQL语句:
sql复制SELECT * FROM t_product WHERE category_id = ? ORDER BY id DESC LIMIT ?, ?
LIMIT第一个参数是偏移量,第二个是每页条数。比如每页显示8个商品,第一页就是LIMIT 0,8,第二页是LIMIT 8,8,第三页是LIMIT 16,8。DAO里除了查当前页数据,还要查总记录数,方便前端计算总页数。分页查询其实不难,但要把几个边界情况处理好:当前页小于1时重置为1,当前页大于总页数时重置为总页数,搜索关键字为空时不要拼出多余的SQL条件。
商品详情页相对简单,商品ID通过URL参数传递,例如product_detail.jsp?id=3,ProductServlet根据id查出一条商品数据,setAttribute到request域,转发到detail.jsp渲染。这里有一个细节建议:详情页的URL尽量用RESTful风格的路径,比如/product/detail?id=3,而不是直接在页面上访问jsp文件。这样做的目的不只是好看,更重要的是避免用户绕过Servlet直接访问JSP,导致JSP里拿不到后台传过来的数据而报错。
3.3 交易模块:购物车与订单的流程设计
购物车是这个项目里逻辑最多的模块。实现方式有两种选择:一种是把购物车数据放到Session里用List对象存,用户一关浏览器购物车就没了;另一种是建独立的购物车表,用户登录后数据持久化到数据库。课程设计的话我推荐用数据库表存,因为这样购物车能跨会话保留,也顺便练习了JOIN查询和增删改查。
添加购物车的核心逻辑是:先检查当前用户的购物车里是否已经有这件商品,如果已经有,就把数量加1,否则插入一条新记录。删除和改数量都是按主键ID操作,逻辑简单。但购物车页面有一个容易出错的地方:修改数量后,前端要实时计算小计和总价,这部分我放在后面JavaScript的章节里讲,后端只需要提供一个updateCartServlet或者通过表单提交数量即可。
下单购物车需要在同一事务内完成。
用户点击"去结算"后,后端要做的事情比较多。我的实现步骤是这样的:第一步,根据当前用户ID从购物车表查出所有商品和数量;第二步,遍历购物车,计算订单总金额,同时检查每个商品的库存是否充足;第三步,插入订单主表记录,拿到自增的订单ID;第四步,把购物车里的每一项商品插入订单明细表;第五步,更新商品库存,把商品表里对应的库存字段减掉购买数量;第六步,清空该用户的购物车记录。
这六步操作必须放在同一个数据库事务里,否则会出现订单生成了但库存没扣、或者购物车清空了但订单失败的情况。JDBC里手动开启事务的写法很固定:
java复制conn.setAutoCommit(false);
try {
// 插入订单、插入明细、扣库存、清购物车
conn.commit();
} catch (Exception e) {
conn.rollback();
throw e;
} finally {
conn.setAutoCommit(true);
// 关闭连接
}
特别提醒:setAutoCommit(false)必须在执行任何SQL之前设置,而且commit和rollback之后一定要把自动提交恢复成true,否则连接池里的连接状态会被污染,下次从连接池拿到这个连接时,事务行为会变得不可预期。
4. 前端页面与交互实现细节
4.1 Bootstrap组件布局与响应式栅格
前端部分用Bootstrap来搭建页面框架确实省了不少事。导航栏用navbar组件,首页轮播图用carousel,商品卡片用card组件组合栅格系统布局,登录注册弹出框可以直接用modal模态框,按钮和表单的默认样式也比裸写CSS好看得多。
Bootstrap的栅格系统是这个项目前端布局的核心。比如商品列表页,我让商品卡片在中等屏幕上每行显示4个,在小屏幕上每行显示2个。实现方式就是给每个商品卡片外层div加上col-md-3和col-sm-6这两个类。实际做的时候要注意Bootstrap的栅格是12列等分,想一行4个就要用12除以4得到col-md-3。如果一行想放3个,就用col-md-4。把栅格理解成"每行12份,每列占几份"就完全够了。
这里有一个实用建议:Bootstrap的资源文件最好下载到项目本地,不要用CDN线上地址。课程设计提交给老师检查时,答辩教室的网络环境不一定靠谱,万一CDN加载不出来,整个页面直接裸奔。把bootstrap.min.css、bootstrap.bundle.min.js放进webapp的static目录下,离线也能跑,演示的时候不会出幺蛾子。
Bootstrap默认样式比较大众脸,建议做一点简单的定制。我通常会在自定义CSS里覆盖几个变量:主色调改成粉色系,契合鲜花商城的定位;导航栏高度微调;商品卡片的hover效果加上阴影和上浮动效。这些改动不用太多,但能让整个项目在千篇一律的Bootstrap项目中显得稍微用心一点。
4.2 JavaScript实现购物车实时联动与表单校验
购物车页面是JavaScript发挥作用的主战场。用户点"+""-"按钮时,商品数量要变化、小计要变化、底部总价要跟着变,这些如果用传统表单提交的方式实现,每点一次按钮都要刷新一次页面,体验很差。原生JavaScript就能解决。
我实现的时候给每个数量输入框绑定change事件和按钮的click事件,事件处理函数里读取当前数量,乘法得到小计,然后把所有小计累加得到总价,更新到页面指定的DOM节点上。这里有个细节:数量为1的时候,减号按钮应该禁用,不然用户一直点减就把数量减到0甚至负数了,后端还要再处理非法数据。
另一个实现重点是表单校验。注册页面的用户名、密码、手机号都要校验,纯前端校验的目的是提升用户体验,让用户第一时间知道哪里填错了,不把非法请求发送到服务器。手机号可以用一个简单的正则去判断:
javascript复制var phoneReg = /^1[3-9]\d{9}$/;
if (!phoneReg.test(phoneInput.value)) {
alert('请输入正确的手机号');
return false;
}
密码至少要6位,确认密码要和密码一致。这些校验在Servlet后端也要再做一遍,前端校验只是用户体验,后端校验才是安全底线。前面注册功能里说过,会写程序的都知道可以直接绕过前端页面构造HTTP请求,所以两个层面都不能省。
4.3 商品图片路径与坐标定位的那些坑
热词里有人问"JSP图片如何对坐标定位",我猜问的是两类问题:一是图片路径定位,二是图片上叠加内容的坐标定位。这两个问题在商品展示页面里都特别典型,我分别说一下。
图片路径定位是最容易踩坑的地方。很多新手在JSP里写图片标签时用相对路径,比如:
html复制<img src="img/flower1.jpg">
如果当前页面在项目根目录下,这个路径没问题;但如果JSP页面位于pages子目录下,浏览器解析相对路径时就会到pages/img/下去找图片,结果404。解决方法是所有图片路径都用应用上下文根路径拼接,JSP页面最稳妥的写法是在页面头部声明base标签:
html复制<base href="<%=request.getContextPath() + "/" %>">
或者在每个img标签的src前面加上EL表达式${pageContext.request.contextPath},这样无论JSP放在哪个目录,图片都能正确加载。
图片坐标定位的问题,说的应该是图片和文字、标签的叠加效果。比如商品卡片上要显示"热卖""促销"角标。Bootstrap里最常见也最稳妥的做法是外层div用position:relative,角标用position:absolute,然后通过top和left/right来控制位置。别用背景图片定位去实现这种叠加,背景图用background-position控制精灵图的时代已经过去了,直接叠加一个绝对定位的DOM元素最简单,也方便后续动态添加文字内容。商品图片本身如果宽高不统一,记得给img加object-fit: cover,否则不同比例的图片撑开卡片布局很难看。
另外,如果做后台商品管理功能,图片上传后要存到webapp下的upload目录,数据库里只存相对路径,比如upload/1628888888888_flower.jpg。这里要提醒的是,上传目录的写入权限在Tomcat里要确认好,不然保存图片时可能抛FileNotFoundException。
5. 环境搭建、部署与问题排查实录
5.1 IDEA+Tomcat+MySQL的环境配置步骤
这篇文章假设你已经装了JDK和IDEA,我重点说容易出错的几个环节。首先说版本匹配,强烈建议JDK 8配Tomcat 8.5配MySQL 5.7或者8.0,这是最稳的组合。千万别图新用JDK 17加Tomcat 10,Tomcat 10把javax.servlet改成jakarta.servlet包名,网上大部分资料和代码示例都是javax的,跑起来全是编译错误,自己折腾半小时还找不到原因。
IDEA里配置Tomcat的步骤:打开Run/Debug Configurations,点加号选择Tomcat Server,Local,然后在Application Server里指定Tomcat安装目录。Deployment选项卡里点加号添加Artifact,选择war exploded格式,Application Context设置为/项目名。这里有个经验:IDEA部署到Tomcat时,默认会把项目打包到Tomcat的webapps目录,如果你改了Tomcat的conf/server.xml里的端口,注意Debug这块别和别的程序端口冲突。
MySQL安装环节,Windows下建议直接下载MySQL Installer,一路默认配置,但有两处要手动改。第一,字符集选择utf8mb4;第二,root账户的密码要记清楚,后面JDBC配置文件里要填。如果用MySQL 8.0,JDBC驱动要选8.x的版本,驱动类名是com.mysql.cj.jdbc.Driver,连接URL必须带serverTimezone参数;用MySQL 5.7则用com.mysql.jdbc.Driver就行。很多人报Communications link failure,十有八九是驱动版本和数据库版本不匹配,或者URL里少了时区参数。
数据库脚本的导入也很关键。我习惯在IDEA右侧Database面板里连上MySQL,直接打开项目的init.sql文件,选中所有语句一次性执行。也可以用命令行source命令,但要注意脚本文件里的字符集和当前控制台编码一致,否则中文数据导进去就乱码。
5.2 高频报错与排查思路速查表
这类项目跑不起来或者运行中报错,绝大多数问题都集中在下面这几个地方,我整理成速查表供你对照排查:
| 报错现象 | 可能原因 | 解决方案 |
|---|---|---|
| ClassNotFoundException: com.mysql.jdbc.Driver | JDBC驱动jar包没放进WEB-INF/lib目录 | 把mysql-connector-java的jar包复制到WEB-INF/lib并reimport |
| Communications link failure | 连接URL配错、驱动版本与MySQL版本不匹配、缺时区参数 | 检查url、账号密码,加上useSSL=false&serverTimezone=Asia/Shanghai |
| 查询结果中文乱码 | 页面编码、请求编码、数据库编码、JDBC URL编码四层不一致 | 全部统一为UTF-8,配置编码过滤器,url加characterEncoding=utf8 |
| 商品图片404 | JSP里用了相对路径 | 用${pageContext.request.contextPath}拼接图片路径 |
| 访问JSP报500空指针 | JSP直接访问,request域里没有后台setAttribute的数据 | 所有页面都通过Servlet转发访问,做好空值判断 |
| Tomcat端口被占用 | 8080被其他程序占用 | 改server.xml端口号,或netstat -ano查占用进程杀之 |
| Session里用户信息丢失 | session超时或过滤器拦截了静态资源 | 静态资源放行,给session设置合理timeout |
| 下单后库存没扣或购物车没清 | 多个SQL操作没有放在同一事务里 | 用setAutoCommit(false)包裹全部SQL,统一commit和rollback |
这里面最想多说一句的是编码过滤器。很多人只在JSP页面里写了pageEncoding,却忘了Servlet接收请求时也要设置编码。直接在web.xml里配一个CharacterEncodingFilter最省事,对所有的请求和响应统一处理:
xml复制<filter>
<filter-name>encodingFilter</filter-name>
<filter-class>org.springframework.web.filter.CharacterEncodingFilter</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>
即使不引入Spring,也可以自己写一个几行的Filter类实现同样的功能,原理完全一样。
5.3 上线部署前的检查清单
项目开发完准备打包部署的时候,不要直接点完Build就完事,先按下面这个清单过一遍,能省掉很多现场翻车的麻烦。
第一件是确认数据库初始化脚本完整,包括建库语句、建表语句、分类和商品的初始化数据。给别人演示时最尴尬的情况就是数据库表是空的,首页上一片空白。我一般会在init.sql里把首页要用到的分类和商品数据都插好,保证解压即跑。
第二件是修改数据库连接配置。本地开发用的root密码和服务器上不一致,打包之前一定要把db.properties改成目标环境的值,不然部署上去连接数据库直接失败。如果是MySQL 8.0,驱动类名和URL参数也别忘改。
第三件是检查JDBC驱动jar是否真正打进了war包。IDEA有时候会出现本地编译能跑,但打war包后驱动jar没进去的情况。解压war包看一眼WEB-INF/lib目录,确认有mysql-connector-java,没有就手动补上。
第四件是梳理一下外部依赖,确认没有使用CDN链接,Bootstrap和jQuery的本地文件都齐全。在无外网环境答辩时,本地静态资源是唯一的保障。
6. 写在最后的一点体会
做完这个项目回头看,我最深的体会是:JSP+Servlet这套老技术承载的恰恰是Web开发最本质的原理。你通过Servlet拿到HTTP请求,用Session管理用户状态,用JDBC和事务保证数据一致性,用JSP把后端数据渲染成页面——这些概念在Spring Boot里被各种注解和starter隐藏掉了,但理解它们和理解框架在做什么,是完全不同的层次。这个鲜花商城虽然简单,但它在"原理"和"框架"之间搭了一座很好的桥。如果你做完之后还有余力,我建议尝试两个方向:一是把前端购物车交互改成Fetch异步提交,提前感受前后端分离;二是把DAO层换成MyBatis重写一遍,体会ORM框架到底替你做了什么。等你把这些都搞清楚了,再回头学Spring Boot,你会看得特别通透。
