从零手搓JSP+Servlet+JDBC网上购物商城:基础练习值不值得跑一遍

发布时间:2026/10/11 14:13:15

从零手搓JSP+Servlet+JDBC网上购物商城:基础练习值不值得跑一遍 简介这是一套面向Java Web入门者的网上购物商城系统实战源码适合刚接触Servlet与JSP、希望打通前后端数据交互的初学者。项目以原生JDBC连接数据库前端引入layui框架完整实现了登录、购物车与商品展示三大核心功能并附带SQL脚本可直接导入运行。压缩包共201个文件约5.5MB包含31个Java源文件与对应class、4个JSP页面、30个JS脚本、5个CSS样式及6个XML配置另有gif演示图与jar依赖结构清晰便于对照学习。作者在layui table传值、总价与订单价格实时刷新、Servlet数据组装等环节做了拓展能帮助读者理解框架底层原理。目前已有1655人学习下载适合作为从基础向MyBatis、Spring Boot过渡的练手项目。1. 从零手搓 JSPServletJDBC 网上购物商城这套基础练习到底值不值得跑一遍如果你正在学 Java Web大概率绕不开一个坎看完了 Servlet 生命周期、JDBC 六步走、JSP 九大内置对象但真让你从零搭一个能登录、能加购物车、能下单的完整系统脑子里还是一团浆糊。这套「JSPServletJDBC 网上购物商城」基础练习就是冲着这个痛点来的——它不依赖 Spring、不依赖 MyBatis纯原生三件套加一个 Layui 前端带完整 SQL 脚本解压就能跑。适合谁刚学完 Java Web 基础、想找一个能贯穿 MVC 分层、Session 管理、事务控制的练手项目的人。不适合谁已经熟练用 Spring Boot 的熟手你会觉得它啰嗦。但恰恰是这种啰嗦能让你看清框架帮你藏了多少东西。下面我按实际拆包复现的顺序把环境、表结构、核心链路和几个血泪坑一次讲透。2. 环境搭建与数据库初始化把 SQL 脚本跑通再谈别的2.1 技术栈选型与版本对齐这套项目用的是原生 Servlet 3.1、JSP 2.3、JDBC 4.2前端 Layui 2.x数据库 MySQL 5.7/8.0 都兼容。为什么强调版本对齐因为 Servlet 3.0 之后才支持注解配置WebServlet如果你拿到的代码里全是web.xml配置那说明它可能基于更老的规范Tomcat 版本就得往下压。我一般会先看两个地方WEB-INF/web.xml的version属性以及pom.xml里javax.servlet-api的 scope 是不是provided。后者很关键如果打成compile部署到 Tomcat 会跟容器自带的 Servlet API 冲突报ClassCastException或者NoSuchMethodError这种玄学问题排查起来很费时间。JDK 建议用 8 或 11别上 17。原因不是代码语法不支持而是老版本 Tomcat8.5 以下在高版本 JDK 上跑 JSP 编译容易出Unsupported class file major version错误。MySQL 驱动用mysql-connector-java 8.0.x对应驱动类名是com.mysql.cj.jdbc.DriverURL 要带serverTimezoneAsia/Shanghai否则 8.0 版本会直接抛时区异常。2.2 导入 SQL 脚本与表结构核对项目自带的 SQL 脚本一般包含建库、建表、插初始数据三部分。常见做法是先建库再执行-- 建库字符集必须用 utf8mb4否则中文商品名会变问号 CREATE DATABASE shop_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE shop_db; -- 用户表注意密码字段长度明文和 MD5 需求不同 CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, phone VARCHAR(20), address VARCHAR(255), reg_time DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 商品表价格用 DECIMAL别用 FLOAT CREATE TABLE product ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, price DECIMAL(10,2) NOT NULL, stock INT DEFAULT 0, category_id INT, image VARCHAR(255), status TINYINT DEFAULT 1 ); -- 订单表与订单项表一对多关系外键约束看需求加 CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, total DECIMAL(10,2), status TINYINT DEFAULT 0, 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, quantity INT NOT NULL, price DECIMAL(10,2) NOT NULL );执行顺序不能乱先user再orders因为订单表有user_id逻辑关联。如果你用的是 Navicat 或 DataGrip直接运行整个脚本文件即可。跑完后用SHOW TABLES;核对正常应该有 5 到 7 张表具体看项目是否包含购物车表、分类表、管理员表。这里有个细节price字段用DECIMAL(10,2)而不是FLOAT因为浮点数在金额计算时会出现0.10.20.30000000000000004的问题下单金额对不上这是电商类项目的铁律。2.3 数据库连接配置与连接池原生 JDBC 每次DriverManager.getConnection()都新建物理连接并发一上来数据库直接爆。项目里通常会封装一个DBUtil或BaseDao我一般会把它改成 Druid 或 C3P0 连接池但如果是纯练习至少要把配置抽到db.properties# db.properties jdbc.drivercom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://localhost:3306/shop_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse jdbc.usernameroot jdbc.passwordyour_password然后在工具类里用静态代码块加载public class DBUtil { private static String url; private static String username; private static String password; static { try (InputStream in DBUtil.class.getClassLoader() .getResourceAsStream(db.properties)) { Properties props new Properties(); props.load(in); Class.forName(props.getProperty(jdbc.driver)); url props.getProperty(jdbc.url); username props.getProperty(jdbc.username); password props.getProperty(jdbc.password); } catch (Exception e) { throw new ExceptionInInitializerError(数据库配置加载失败); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(url, username, password); } }逻辑说明静态代码块只执行一次避免每次取连接都读配置文件。参数说明useSSLfalse关掉 SSL 警告characterEncodingutf8保证中文不乱码serverTimezone解决 8.0 驱动时区问题。如果你要上连接池把getConnection()换成dataSource.getConnection()其余 DAO 代码不用动这就是分层的好处。3. MVC 分层与核心业务链路Servlet 到底该写多薄3.1 包结构与职责划分拿到项目先看包结构标准的 MVC 分层一般长这样com.xxx.entity放实体类com.xxx.dao放数据库操作com.xxx.service放业务逻辑com.xxx.servlet或controller放请求分发com.xxx.util放工具类。JSP 统一放WEB-INF/jsp/下这样浏览器不能直接访问必须走 Servlet 转发安全性高一层。为什么强调 Servlet 要薄因为 Servlet 里一旦混入 JDBC 代码后面换数据库、加事务、做单元测试全是灾难。我见过太多练习项目把PreparedStatement直接写在doPost里一个方法两百行改一个字段要翻半天。正确的做法是Servlet 只负责收参数、调 Service、存 Session、转发或重定向业务判断和事务边界全在 Service 层。3.2 登录与 Session 管理登录是最能体现分层价值的链路。前端 Layui 表单提交到/loginServlet 接收WebServlet(/login) public class LoginServlet extends HttpServlet { private UserService userService new UserServiceImpl(); Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username req.getParameter(username); String password req.getParameter(password); // 参数校验空值直接打回别等数据库查完再报错 if (username null || username.trim().isEmpty() || password null || password.isEmpty()) { req.setAttribute(msg, 用户名或密码不能为空); req.getRequestDispatcher(/WEB-INF/jsp/login.jsp).forward(req, resp); return; } User user userService.login(username, password); if (user null) { req.setAttribute(msg, 账号或密码错误); req.getRequestDispatcher(/WEB-INF/jsp/login.jsp).forward(req, resp); } else { // 登录成功写 Session注意别把密码放进 Session req.getSession().setAttribute(loginUser, user); resp.sendRedirect(req.getContextPath() /product?actionlist); } } }逻辑说明先做空值校验减少无效数据库查询登录失败用forward保留请求域消息成功用sendRedirect防止刷新重复提交。参数说明req.getContextPath()拿到项目上下文路径避免硬编码项目名。Service 层里login方法负责调 DAO 查用户如果密码是 MD5 存储这里要做加密比对。Session 超时时间在web.xml里配默认 30 分钟练习项目够用。3.3 商品列表、购物车与下单事务商品列表链路ProductServlet接收actionlist调ProductService.findAll()返回ListProduct存 request 域转发到product_list.jsp。JSP 里用 JSTL 的c:forEach遍历Layui 的表格组件负责渲染。这里注意JSP 里别写 Java 脚本片段% %用 EL 表达式${product.name}和 JSTL 标签代码干净且便于维护。购物车有两种实现Session 版和数据库版。练习项目多用 Session 版把MapInteger, CartItem存 Session加购时判断商品是否已在 Map 里有就数量加一没有就新建条目。下单时才是重头戏必须用事务public boolean createOrder(int userId, MapInteger, CartItem cart) { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 // 1. 插入订单主表拿到自增主键 int orderId orderDao.insertOrder(conn, userId, totalAmount); // 2. 循环插入订单项同时扣减库存 for (CartItem item : cart.values()) { orderItemDao.insertItem(conn, orderId, item); int affected productDao.reduceStock(conn, item.getProductId(), item.getQuantity()); if (affected 0) { throw new RuntimeException(库存不足 item.getProductName()); } } conn.commit(); return true; } catch (Exception e) { if (conn ! null) { try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } } e.printStackTrace(); return false; } finally { if (conn ! null) { try { conn.setAutoCommit(true); conn.close(); } catch (SQLException e) { e.printStackTrace(); } } } }逻辑说明所有 DAO 方法接收同一个Connection保证在同一个事务里扣库存用UPDATE product SET stock stock - ? WHERE id ? AND stock ?返回影响行数为 0 说明库存不够抛异常触发回滚。参数说明setAutoCommit(false)关闭自动提交commit()提交rollback()回滚finally里恢复自动提交并关闭连接。这套写法是 JDBC 事务的标准模板面试也常问。4. 避坑与常见问题排查那些让你怀疑人生的报错4.1 中文乱码POST 和 GET 处理方式不一样现象表单提交中文商品名数据库里存进去变成???或者乱码。原因Tomcat 8 以后 GET 请求默认 URI 编码是 UTF-8但 POST 请求体默认还是 ISO-8859-1。解决在 Servlet 里第一行加req.setCharacterEncoding(UTF-8)并且要在getParameter之前调用。如果是 GET 传参乱码检查 Tomcat 的server.xml里 Connector 有没有URIEncodingUTF-8。数据库连接 URL 也要带characterEncodingutf8三层缺一不可。4.2 空指针异常Service 没注入DAO 返回 null现象点登录按钮直接 500控制台报NullPointerException。原因Servlet 里private UserService userService new UserServiceImpl();这行如果漏了或者 Service 里 DAO 没初始化调用方法时就是空指针。解决检查每个new是否到位或者改用工厂模式统一管理。另一个常见场景是request.getParameter拿到 null 后直接.trim()也会空指针所以参数校验必须前置。4.3 数据库连接未关闭跑一会儿就Too many connections现象项目跑几分钟后所有请求卡死日志报Too many connections。原因DAO 里Connection、PreparedStatement、ResultSet没在finally里关闭每次请求泄漏一个连接。解决用 try-with-resources 语法自动关闭String sql SELECT * FROM product WHERE id ?; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, id); try (ResultSet rs ps.executeQuery()) { if (rs.next()) { // 封装对象 } } } catch (SQLException e) { e.printStackTrace(); }逻辑说明try-with-resources要求资源实现AutoCloseableJDBC 三大接口都满足按声明逆序自动关闭。参数说明ps.setInt(1, id)里 1 是占位符序号从 1 开始不是 0。这个习惯养成了连接泄漏问题基本绝迹。4.4 JSP 页面 404路径写错或放错目录现象登录成功后跳转商品列表浏览器报 404。原因JSP 放在WEB-INF/jsp/下浏览器不能直接访问必须通过 Servlet 转发如果sendRedirect直接写/product_list.jsp必然 404。解决转发用req.getRequestDispatcher(/WEB-INF/jsp/product_list.jsp).forward(req, resp)重定向用resp.sendRedirect(req.getContextPath() /product?actionlist)。记住转发是服务器内部行为重定向是浏览器行为路径规则不同。4.5 Layui 表格数据不显示JSON 格式对不上现象商品列表页 Layui 表格一直转圈或者显示「数据接口请求异常」。原因Layui 的table组件默认要求返回{code:0, msg:, count:100, data:[...]}格式如果 Servlet 直接返回List的 JSON 数组前端解析不了。解决在 Servlet 里手动拼 JSON或者引入 Gson/Jackson 封装统一响应对象。注意response.setContentType(application/json;charsetUTF-8)必须设置否则中文乱码且浏览器不识别。5. 进阶技巧把原生 JDBC 封装成迷你 ORM 与分页查询5.1 用反射加泛型写一个通用 BaseDao原生 JDBC 最烦的是每个实体都要写一遍rs.getString(name)、rs.getInt(id)。我一般会写一个BaseDaoT用反射把 ResultSet 映射成对象代码量直接砍半public class BaseDaoT { private ClassT clazz; public BaseDao() { // 通过泛型父类获取实际类型 Type type getClass().getGenericSuperclass(); ParameterizedType pt (ParameterizedType) type; clazz (ClassT) pt.getActualTypeArguments()[0]; } // 通用查询返回 ListT public ListT queryList(String sql, Object... params) { ListT list new ArrayList(); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { for (int i 0; i params.length; i) { ps.setObject(i 1, params[i]); } try (ResultSet rs ps.executeQuery()) { ResultSetMetaData meta rs.getMetaData(); int columnCount meta.getColumnCount(); while (rs.next()) { T obj clazz.getDeclaredConstructor().newInstance(); for (int i 1; i columnCount; i) { String columnName meta.getColumnLabel(i); Object value rs.getObject(i); // 反射设置字段值字段名要和列名一致或做驼峰转换 Field field clazz.getDeclaredField(columnName); field.setAccessible(true); field.set(obj, value); } list.add(obj); } } } catch (Exception e) { e.printStackTrace(); } return list; } }逻辑说明构造函数里通过ParameterizedType拿到子类指定的泛型实参queryList里用ResultSetMetaData获取列名再反射赋值。参数说明params是可变参数对应 SQL 里的?占位符顺序必须一致。注意数据库列名如果是create_time实体字段是createTime需要做下划线转驼峰处理否则getDeclaredField会抛NoSuchFieldException。这个迷你 ORM 虽然比不上 MyBatis但能让你彻底理解「映射」到底在映射什么。5.2 手写分页查询LIMIT 加 COUNT 双 SQL商品列表数据一多必须分页。原生 JDBC 分页就两条 SQL一条查总数一条查当前页数据。public PageResultProduct findByPage(int pageNo, int pageSize) { PageResultProduct result new PageResult(); // 1. 查总数 String countSql SELECT COUNT(*) FROM product WHERE status 1; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(countSql); ResultSet rs ps.executeQuery()) { if (rs.next()) { result.setTotal(rs.getInt(1)); } } catch (SQLException e) { e.printStackTrace(); } // 2. 查当前页数据LIMIT 第一个参数是偏移量 String dataSql SELECT * FROM product WHERE status 1 LIMIT ?, ?; int offset (pageNo - 1) * pageSize; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(dataSql)) { ps.setInt(1, offset); ps.setInt(2, pageSize); try (ResultSet rs ps.executeQuery()) { ListProduct list new ArrayList(); while (rs.next()) { Product p new Product(); p.setId(rs.getInt(id)); p.setName(rs.getString(name)); p.setPrice(rs.getBigDecimal(price)); p.setStock(rs.getInt(stock)); list.add(p); } result.setList(list); } } catch (SQLException e) { e.printStackTrace(); } return result; }逻辑说明offset计算公式是(当前页 - 1) * 每页条数LIMIT offset, pageSize是 MySQL 特有语法。参数说明pageNo从 1 开始pageSize一般设 10 或 20。注意COUNT(*)在数据量大时慢可以换成COUNT(id)或加缓存但练习项目无所谓。分页数据返回给 Layui 时count字段填总数data字段填列表code填 0前端就能正常渲染。5.3 验证清单与我的固定习惯跑完这套项目我一般会按这个清单过一遍登录空值校验是否生效、错误密码是否提示、Session 超时后是否跳回登录页、加购同一商品数量是否正确累加、库存为 0 时下单是否回滚、分页第二页数据是否正确、中文商品名在数据库和页面是否都正常。这套流程走下来基本能把 90% 的坑提前踩掉。从那以后我每次拿到一个原生 JDBC 项目都强制先看连接关闭和事务边界再看字符编码最后才看业务逻辑。顺序反了后面全是后悔药。希望帮到你。本文还有配套的精品资源点击获取
延伸阅读

更多相关文章

2026/10/11 14:13:15

EVCapture录屏实战:参数配置、避坑指南与高效录屏技巧

简介:EVCapture是一款面向Windows系统的免费录屏与直播工具,主打高清录制、低资源占用与流畅输出,适用于教学视频、游戏录制、在线会议及远程协作等场景。压缩包共106个文件,整体约25.28MB,以动态库和可执行程序为主&a…

2026/10/11 14:08:15

QT实现FTP上传下载工具:协议选型、核心类与避坑指南

简介:一套基于Qt5框架、使用C语言编写的FTP客户端工具源码,面向需要在桌面或嵌入式Linux平台集成文件上传与下载功能的开发者。源码包共12个文件,其中4个cpp源文件与3个h头文件组成核心逻辑,pro工程文件和ui界面文件帮助快速构建项…

2026/10/11 18:38:30

PyQt5+OpenPose太极拳姿态识别系统实战指南

简介:这是一套面向Python初学者与计算机视觉爱好者的太极拳姿态识别实践项目,聚焦运动分析与人机交互场景,助力武术教学数字化与动作规范性评估。资源包含115个文件,以13个核心Python脚本(如ProcessImage.py姿态提取、…

2026/10/11 18:38:30

哈工程数字图像处理英文课件:空域频域实战解析与Python复现指南

简介:本资源为哈尔滨工程大学《Digital Image Processing》英文原版教学课件PPT,面向计算机视觉、人工智能、遥感与医学影像等方向的本科生及研究生,系统支撑数字图像处理核心理论学习与工程实践入门。课件共五章,覆盖图像基础与数…

2026/10/11 18:38:30

331张行人车辆数据集:YOLO小样本目标检测实战指南

简介:这是一份面向YOLO系列目标检测学习者的行人车辆标注数据集,适用于yolov5、yolov8、yolov9、yolov7、yolov10及yolo11等主流算法,可直接用于模型训练与验证测试,帮助初学者和算法工程师快速搭建目标检测实验环境。资源包共994…

2026/10/11 18:38:30

智能体工程化实战:从 API 计费到合规分发的关键设计

把智能体从 demo 推进到生产,难点往往不在模型调用本身,而在工程化:如何稳定聚合多模型、如何按调用计费、如何把能力合规地分发出去。本文结合一线落地经验,梳理几个关键设计点。一、多模型聚合:别把业务绑死在单一模…

2026/10/11 0:02:13

Python调用Gemini Structured Outputs实现工单路由门禁

客服工单最怕的不是模型“答错一句话”,而是它给出一段看起来合理的说明,程序却从中猜错优先级。通俗做法是:要求模型只交 JSON(JavaScript Object Notation,轻量数据格式),再让代码验证它。Gem…

2026/10/11 0:02:13

Spring Boot超市进销存系统毕设实战:从需求拆解到答辩通关

最近带的一个学生项目组里,有A同学跑来问我:选什么毕设题目最稳妥,既能让评审老师觉得工作量够,又不会在答辩时被问到语无伦次。我第一反应就是推荐基于Spring Boot的超市仓库管理系统——也就是超市进销存系统。这个题目乍一看平…

2026/10/11 0:02:13

Flutter StatefulWidget 生命周期核心解析

很多刚开始接触 Flutter 的朋友,在看完一堆“Hello World”和基础组件之后,大概率都会撞上同一堵墙:StatefulWidget 里那堆 initState、build、dispose 方法,到底什么时候被调用?为什么顺序是那样?在里面到…

2026/10/11 0:02:13

Python调用Gemini Structured Outputs实现工单路由门禁

客服工单最怕的不是模型“答错一句话”,而是它给出一段看起来合理的说明,程序却从中猜错优先级。通俗做法是:要求模型只交 JSON(JavaScript Object Notation,轻量数据格式),再让代码验证它。Gem…

2026/10/11 0:02:13

Spring Boot超市进销存系统毕设实战:从需求拆解到答辩通关

最近带的一个学生项目组里,有A同学跑来问我:选什么毕设题目最稳妥,既能让评审老师觉得工作量够,又不会在答辩时被问到语无伦次。我第一反应就是推荐基于Spring Boot的超市仓库管理系统——也就是超市进销存系统。这个题目乍一看平…

2026/10/11 0:02:13

Flutter StatefulWidget 生命周期核心解析

很多刚开始接触 Flutter 的朋友,在看完一堆“Hello World”和基础组件之后,大概率都会撞上同一堵墙:StatefulWidget 里那堆 initState、build、dispose 方法,到底什么时候被调用?为什么顺序是那样?在里面到…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑