☰
从零手搓JSP+Servlet+JDBC网上购物商城:基础练习值不值得跑一遍
2026/10/11 14:13:04 网站建设 项目流程

简介:这是一套面向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. 从零手搓 JSP+Servlet+JDBC 网上购物商城:这套基础练习到底值不值得跑一遍

如果你正在学 Java Web,大概率绕不开一个坎:看完了 Servlet 生命周期、JDBC 六步走、JSP 九大内置对象,但真让你从零搭一个能登录、能加购物车、能下单的完整系统,脑子里还是一团浆糊。这套「JSP+Servlet+JDBC 网上购物商城」基础练习就是冲着这个痛点来的——它不依赖 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。原因不是代码语法不支持,而是老版本 Tomcat(8.5 以下)在高版本 JDK 上跑 JSP 编译容易出Unsupported class file major version错误。MySQL 驱动用mysql-connector-java 8.0.x,对应驱动类名是com.mysql.cj.jdbc.Driver,URL 要带serverTimezone=Asia/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.1+0.2=0.30000000000000004的问题,下单金额对不上,这是电商类项目的铁律。

2.3 数据库连接配置与连接池

原生 JDBC 每次DriverManager.getConnection()都新建物理连接,并发一上来数据库直接爆。项目里通常会封装一个DBUtil或BaseDao,我一般会把它改成 Druid 或 C3P0 连接池,但如果是纯练习,至少要把配置抽到db.properties:

# db.properties jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/shop_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false jdbc.username=root jdbc.password=your_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); } }

逻辑说明:静态代码块只执行一次,避免每次取连接都读配置文件。参数说明:useSSL=false关掉 SSL 警告,characterEncoding=utf8保证中文不乱码,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 表单提交到/login,Servlet 接收:

@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?action=list"); } } }

逻辑说明:先做空值校验,减少无效数据库查询;登录失败用forward保留请求域消息,成功用sendRedirect防止刷新重复提交。参数说明:req.getContextPath()拿到项目上下文路径,避免硬编码项目名。Service 层里login方法负责调 DAO 查用户,如果密码是 MD5 存储,这里要做加密比对。Session 超时时间在web.xml里配,默认 30 分钟,练习项目够用。

3.3 商品列表、购物车与下单事务

商品列表链路:ProductServlet接收action=list,调ProductService.findAll(),返回List<Product>存 request 域,转发到product_list.jsp。JSP 里用 JSTL 的<c:forEach>遍历,Layui 的表格组件负责渲染。这里注意:JSP 里别写 Java 脚本片段<% %>,用 EL 表达式${product.name}和 JSTL 标签,代码干净且便于维护。

购物车有两种实现:Session 版和数据库版。练习项目多用 Session 版,把Map<Integer, CartItem>存 Session,加购时判断商品是否已在 Map 里,有就数量加一,没有就新建条目。下单时才是重头戏,必须用事务:

public boolean createOrder(int userId, Map<Integer, 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 有没有URIEncoding="UTF-8"。数据库连接 URL 也要带characterEncoding=utf8,三层缺一不可。

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要求资源实现AutoCloseable,JDBC 三大接口都满足,按声明逆序自动关闭。参数说明: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?action=list")。记住:转发是服务器内部行为,重定向是浏览器行为,路径规则不同。

4.5 Layui 表格数据不显示:JSON 格式对不上

现象:商品列表页 Layui 表格一直转圈,或者显示「数据接口请求异常」。原因:Layui 的table组件默认要求返回{code:0, msg:"", count:100, data:[...]}格式,如果 Servlet 直接返回List的 JSON 数组,前端解析不了。解决:在 Servlet 里手动拼 JSON,或者引入 Gson/Jackson 封装统一响应对象。注意response.setContentType("application/json;charset=UTF-8")必须设置,否则中文乱码且浏览器不识别。

5. 进阶技巧:把原生 JDBC 封装成迷你 ORM 与分页查询

5.1 用反射加泛型写一个通用 BaseDao

原生 JDBC 最烦的是每个实体都要写一遍rs.getString("name")、rs.getInt("id")。我一般会写一个BaseDao<T>,用反射把 ResultSet 映射成对象,代码量直接砍半:

public class BaseDao<T> { private Class<T> clazz; public BaseDao() { // 通过泛型父类获取实际类型 Type type = getClass().getGenericSuperclass(); ParameterizedType pt = (ParameterizedType) type; clazz = (Class<T>) pt.getActualTypeArguments()[0]; } // 通用查询:返回 List<T> public List<T> queryList(String sql, Object... params) { List<T> 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 PageResult<Product> findByPage(int pageNo, int pageSize) { PageResult<Product> 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()) { List<Product> 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 项目,都强制先看连接关闭和事务边界,再看字符编码,最后才看业务逻辑。顺序反了,后面全是后悔药。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询