简介:在Java Web开发领域,MVC(Model-View-Controller)架构模式是构建清晰、可维护应用程序的经典范式。其核心原理在于分离关注点:Model层处理数据和业务逻辑,View层负责界面展示,Controller层作为协调者接收请求并调度响应。这种分层设计的技术价值在于提升了代码的可读性、可测试性和可扩展性,是理解更复杂框架(如Spring MVC)的基础。在电商、内容管理等典型应用场景中,MVC能有效组织用户模块、商品展示、购物车和订单处理等核心功能。本文通过一个完整的在线购物系统项目,具体阐述了如何运用JSP+Servlet+JavaBean实现经典MVC,并深入探讨了数据库设计中表结构规划与SQL注入防护等关键安全实践,为初学者提供了从原理到实现的清晰路径。
1. 项目概述与核心价值
最近在整理硬盘,翻出来一个压箱底的老项目——“基于Java的在线购物系统”。这个压缩包(.7z格式)里包含了完整的源代码和可运行的系统,算是我早期Java Web开发学习的一个里程碑。现在回头看,这个项目虽然技术栈不算新潮,但麻雀虽小五脏俱全,涵盖了从前端展示、后端业务逻辑到数据库设计的完整流程,对于理解一个电商系统的核心骨架非常有帮助。如果你正在学习Java Web开发,或者想找一个完整的项目来练手、丰富简历,这个系统绝对是一个绝佳的“标本”。它不依赖任何复杂的微服务或云原生架构,就是用最经典的JSP+Servlet+JDBC+MySQL技术栈搭建的,能让你清晰地看到数据是如何从浏览器请求,经过服务器处理,最终存入数据库并返回页面的全过程。接下来,我会把这个项目彻底拆解,从设计思路、技术选型到每一行关键代码的实现逻辑,毫无保留地分享给你。
2. 系统整体架构与设计思路拆解
2.1 为什么选择经典MVC三层架构?
拿到一个“在线购物系统”的需求,首要任务是确定架构。当时流行的选择有SSH(Struts+Spring+Hibernate)和SSM(Spring+SpringMVC+MyBatis),但对于学习核心原理而言,它们封装度太高,像黑盒子。因此,我选择了最原始、也最透明的JSP(View)+ Servlet(Controller)+ JavaBean(Model)的MVC模式。这样做的核心考量是“可控”和“可理解”。每一层的职责非常清晰:JSP负责渲染HTML页面;Servlet作为控制器,接收请求、调用业务逻辑、决定跳转哪个视图;JavaBean则封装数据和业务规则。数据库访问直接使用JDBC,虽然繁琐,但能让你亲手编写SQL、处理Connection和ResultSet,这对理解ORM框架(如MyBatis)为何存在、解决了什么问题至关重要。
设计上的一个关键取舍是:不使用任何前端框架(如Vue、React)。项目前端采用了基础的HTML、CSS、JavaScript和JSP标签。这并非技术落后,而是为了聚焦后端逻辑。在JSP中直接使用<%= %>表达式和<c:forEach>标签来渲染动态数据,能让你直观地看到后端数据是如何“流”到前端的。这种“复古”的做法,对于建立Web开发最基础的数据绑定和页面渲染概念,效果反而更好。
2.2 核心功能模块规划
一个可用的购物系统,至少需要支撑用户从浏览商品到完成支付的完整链路。我将系统划分为以下几个核心模块:
- 用户模块:注册、登录、登出、个人信息管理。这是所有系统的基础。
- 商品模块:商品分类展示、列表分页、详情查看、搜索功能。这是吸引用户的“门面”。
- 购物车模块:添加商品、查看购物车、修改商品数量、删除商品。核心的交互环节。
- 订单模块:从购物车生成订单、订单列表查看、订单详情、简单的订单状态管理(如待付款、已发货)。
- 后台管理模块(简易版):商品上架/下架、订单处理。通常这是一个独立的子系统,但在这个学习项目中,我将其集成在了同一个Web应用内,通过权限控制来区分。
数据库设计是这一切的基石。我设计了五张核心表:
user:用户表,存储用户名、密码(MD5加密)、邮箱、电话等信息。category:商品分类表,实现简单的层级分类。product:商品表,关联分类,包含名称、价格、库存、图片路径等。cart:购物车表。这里有一个设计决策点:购物车数据是用户登录前暂存于Session,还是登录后持久化到数据库?为了体验的连贯性,我采用了“混合模式”。用户未登录时,购物车数据存在Session中;一旦登录,则合并Session中的购物车数据到数据库的cart表,实现跨会话持久化。orders和order_item:订单主表和订单项表。这是一个典型的“一对多”关系。orders表记录订单总额、收货地址、状态、创建时间等;order_item表记录订单中每个商品的具体信息(商品ID、购买时单价、数量)。这样设计避免了数据冗余,如果商品价格后续变动,订单历史中的价格依然是下单时的快照。
3. 核心细节解析与实操要点
3.1 用户会话管理与安全性
Web应用是无状态的,识别用户依赖会话(Session)。在Servlet中,通过request.getSession()可以获取或创建当前用户的Session。登录成功后,我会将用户对象(如User实体)存入Session:session.setAttribute("user", loginUser)。后续的权限校验,比如“只有登录用户才能访问购物车页面”,就在相应的Servlet的doGet/doPost方法开头进行判断:if (session.getAttribute("user") == null) { response.sendRedirect("login.jsp"); return; }。
安全性是重中之重,这里有几个必须注意的坑:
- 密码存储:绝对不能用明文。我使用了MD5哈希算法(虽然现在更推荐加盐的SHA-256或bcrypt,但MD5对于学习项目足够)。在用户注册和登录校验时,都是对密码进行MD5加密后再进行比对或存储。代码类似:
DigestUtils.md5Hex(password)。 - SQL注入防护:直接拼接SQL字符串是致命危险。
String sql = "SELECT * FROM user WHERE username='" + username + "' AND password='" + password + "'";如果用户输入admin' --,就会注释掉后面所有条件,导致登录绕过。必须使用PreparedStatement。它的原理是预编译SQL语句,将参数用占位符?代替,之后传入的参数会被当作纯数据处理,不会被解析为SQL指令。这是最基本也是最重要的安全实践。 - XSS跨站脚本防护:用户输入的内容(如商品评论)如果直接输出到JSP页面,可能包含恶意脚本。在JSP中,默认使用
<c:out value="${input}">标签输出,它会自动进行HTML转义,将<转为<,从而中和脚本。如果不得已要在JSP中直接使用<%= %>,务必对动态内容进行过滤。
3.2 购物车与订单的并发考量
购物系统最经典的并发场景就是“超卖”。当商品库存只剩1件时,两个用户同时点击购买,如果不加控制,两个订单可能都成功,导致库存变为-1。
在这个项目中,我通过数据库的“乐观锁”机制来简单模拟解决。在product表中,我增加了一个version字段(版本号)。更新库存的SQL不再只是UPDATE product SET stock = stock - 1 WHERE id = ?,而是:
UPDATE product SET stock = stock - 1, version = version + 1 WHERE id = ? AND version = ? AND stock > 0在减库存前,我先查询出当前商品的version值。执行更新时,将查询到的version作为条件。如果两个线程同时读到相同的version,第一个更新成功后,version会+1,第二个线程的更新条件version = ?就不成立了,影响行数为0,从而可以判定更新失败(库存不足或数据已变更),然后回滚或提示用户。
注意:这是一个简化的模型。在高并发真实场景中,通常会结合分布式锁、Redis预减库存、消息队列异步下单等更复杂的方案。但理解这个基于数据库版本的乐观锁,是处理并发问题一个非常好的起点。
3.3 前后端数据交互与状态保持
在前后端不分离的架构中,数据流转主要靠请求属性(Request Attribute)和会话(Session)。
- 查询数据展示:在Servlet中,从数据库查询出
List<Product>,然后通过request.setAttribute("productList", productList)将其放入请求域。转发到JSP页面后,使用JSTL的<c:forEach items="${productList}" var="prod">循环渲染。 - 表单提交:在JSP页面,表单
<form action="AddToCartServlet" method="post">。在对应的Servlet中,通过request.getParameter("productId")获取参数。 - 购物车状态保持:如前所述,采用Session和数据库结合。登录前,购物车对象存在
session.setAttribute("cart", cartMap)中,这是一个Map<Product, Integer>结构。登录后,遍历这个Map,将每一项持久化到数据库cart表。
一个实操技巧:统一编码过滤器。中文乱码是新手常遇到的问题。与其在每个Servlet里都写request.setCharacterEncoding("UTF-8"),不如写一个CharacterEncodingFilter,实现javax.servlet.Filter接口,在doFilter方法中统一设置请求和响应的编码,并在web.xml中配置它过滤所有请求(/*)。这是一劳永逸的做法。
4. 关键代码模块实现详解
4.1 数据库连接池工具类(JDBCUtil)
直接在每个Servlet中获取和关闭数据库连接(Connection)是灾难性的,会导致性能极差和连接泄露。必须使用连接池。这里我实现了一个简易的、基于javax.sql.DataSource(如DBCP或C3P0)的工具类。
public class JDBCUtil { private static DataSource dataSource = null; static { // 初始化连接池,例如使用C3P0 ComboPooledDataSource cpds = new ComboPooledDataSource(); try { cpds.setDriverClass("com.mysql.cj.jdbc.Driver"); cpds.setJdbcUrl("jdbc:mysql://localhost:3306/shop?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai"); cpds.setUser("root"); cpds.setPassword("yourpassword"); // 配置连接池参数 cpds.setInitialPoolSize(5); cpds.setMaxPoolSize(20); dataSource = cpds; } catch (PropertyVetoException e) { e.printStackTrace(); } } // 获取连接 public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } // 关闭资源(Connection, Statement, ResultSet) public static void close(Connection conn, Statement st, ResultSet rs) { // ... 反向关闭资源的代码 } }关键点:连接池参数需要根据实际压力调整。InitialPoolSize是应用启动时创建的连接数,MaxPoolSize是允许的最大连接数。务必在finally块或使用try-with-resources语句确保close方法被调用,将连接归还给池子,而不是真正关闭。
4.2 商品分页查询Servlet实现
分页是电商列表页的核心功能。前端传递currentPage(当前页)和pageSize(每页条数)参数。后端需要计算总记录数totalCount、总页数totalPage,并查询当前页的数据。
// ProductListServlet 片段 String currentPageStr = request.getParameter("currentPage"); int currentPage = 1; // 默认第一页 if (currentPageStr != null && !"".equals(currentPageStr)) { currentPage = Integer.parseInt(currentPageStr); } int pageSize = 10; // 每页10条 ProductDao productDao = new ProductDaoImpl(); // 1. 查询总记录数 int totalCount = productDao.findTotalCount(); // 2. 计算总页数 int totalPage = (totalCount + pageSize - 1) / pageSize; // 向上取整的巧妙写法 // 3. 限制当前页范围 if (currentPage > totalPage) currentPage = totalPage; if (currentPage < 1) currentPage = 1; // 4. 查询当前页数据 List<Product> productList = productDao.findByPage((currentPage - 1) * pageSize, pageSize); // 5. 将数据放入请求域,转发到JSP request.setAttribute("productList", productList); request.setAttribute("currentPage", currentPage); request.setAttribute("totalPage", totalPage); request.getRequestDispatcher("/product_list.jsp").forward(request, response);DAO层findByPage方法的SQL:SELECT * FROM product LIMIT ?, ?。第一个参数是偏移量((currentPage-1)*pageSize),第二个参数是每页条数。务必为ORDER BY的字段和WHERE条件字段建立索引,否则在数据量大时LIMIT分页会非常慢。
4.3 购物车添加与合并逻辑
购物车的添加逻辑需要区分用户状态,并处理合并(同一商品数量相加)。
// AddToCartServlet 片段 String pid = request.getParameter("pid"); int productId = Integer.parseInt(pid); Product product = productDao.findById(productId); // 根据ID查询商品 HttpSession session = request.getSession(); // 获取购物车,可能来自Session或数据库 Map<Product, Integer> cart = null; User user = (User) session.getAttribute("user"); if (user != null) { // 用户已登录,从数据库获取购物车 cart = cartService.getCartFromDB(user.getId()); } else { // 用户未登录,从Session获取 cart = (Map<Product, Integer>) session.getAttribute("cart"); if (cart == null) { cart = new HashMap<>(); } } // 添加或合并商品 int count = 1; if (cart.containsKey(product)) { count = cart.get(product) + 1; } cart.put(product, count); // 保存回去 if (user != null) { cartService.saveCartToDB(user.getId(), productId, count); // 保存到数据库 } else { session.setAttribute("cart", cart); // 保存到Session }合并时机:当用户登录时,需要有一个LoginServlet在验证成功后,主动调用一个mergeCart方法,将Session中的购物车数据遍历,合并到该用户的数据库购物车记录中,并清空Session购物车。
5. 项目部署、运行与常见问题排查
5.1 本地环境搭建与运行步骤
- 准备环境:安装JDK 8+、MySQL 5.7+、Tomcat 8.5+、Maven 3.6+(如果项目是Maven结构)和一个IDE(Eclipse或IntelliJ IDEA)。
- 导入项目:解压
.7z文件,用IDE导入为一个Maven项目或Dynamic Web Project。 - 初始化数据库:在MySQL中创建一个名为
shop的数据库,然后执行项目sql/目录下的建表脚本和数据初始化脚本。 - 修改配置:找到数据库连接配置文件(可能是
db.properties或jdbc.properties),将里面的url、username、password改成你自己的MySQL配置。 - 部署到Tomcat:在IDE中配置Tomcat服务器,将项目添加到Server并启动。或者将项目打包成WAR文件,放入Tomcat的
webapps目录下,启动Tomcat。 - 访问系统:打开浏览器,访问
http://localhost:8080/你的项目名/。通常首页是index.jsp或一个跳转到首页的Servlet。
5.2 常见问题与解决方案速查表
在运行这个(或任何类似)Java Web项目时,你几乎一定会遇到下面这些问题。我把它们和解决思路整理成了表格,方便你快速排查。
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| 访问页面报404错误 | 1. 项目未成功部署。 2. 访问路径错误。 3. web.xml配置错误或缺失。 | 1. 检查Tomcat日志,确认项目已启动且无严重错误。 2. 确认URL中的项目上下文路径(Context Path)。在IDE中部署时,路径可能被修改。查看Tomcat管理页面或IDE的Server配置。 3. 检查 web.xml中<welcome-file-list>是否配置了正确的首页(如index.jsp)。 |
| 页面中文乱码 | 1. 数据库连接字符集不是UTF-8。 2. JSP页面编码声明错误。 3. Servlet未设置请求/响应编码。 | 1. 检查数据库连接URL,确保包含?characterEncoding=UTF-8。2. 确保JSP文件头部有 <%@ page contentType="text/html;charset=UTF-8" language="java" %>。3.最佳实践:添加一个全局的 CharacterEncodingFilter。 |
| 无法连接到数据库 | 1. 数据库服务未启动。 2. 连接配置(IP、端口、库名、用户名、密码)错误。 3. 数据库驱动JAR包缺失或版本不匹配。 | 1. 用命令行或图形工具(如Navicat)测试是否能连接MySQL。 2. 逐字核对配置文件中的参数。 3. 检查 WEB-INF/lib目录或Maven的pom.xml,确保有mysql-connector-java的JAR包(版本建议8.0+)。 |
| 点击提交按钮后页面无反应或报500错误 | 1. Servlet类未在web.xml中配置或注解配置错误。2. Servlet代码中有空指针或其他运行时异常。 3. 依赖的JAR包冲突。 | 1. 如果使用web.xml,检查<servlet>和<servlet-mapping>配置。如果使用@WebServlet注解,检查路径是否正确。2.查看Tomcat控制台日志(Catalina.out或localhost.log),这是最重要的调试信息源!日志会打印出异常的完整堆栈信息,根据它定位代码行。 3. 检查是否有多个不同版本的相同JAR包。 |
| 静态资源(CSS、JS、图片)无法加载 | 1. 资源路径错误。 2. 被Servlet的URL模式(如 /*)拦截。 | 1. 使用绝对路径:以${pageContext.request.contextPath}/css/style.css的方式引用。2. 确保你的Servlet(特别是配置了 /*的过滤器)不会拦截对静态资源的请求。可以在过滤器的doFilter方法中,判断请求路径是否包含.css、.js、.png等后缀,如果是则直接放行。 |
| 登录成功后Session丢失 | 1. Session超时时间过短。 2. 浏览器Cookie被禁用。 3. 服务器端Session被意外清空。 | 1. 在web.xml中配置<session-config><session-timeout>30</session-timeout></session-config>(单位:分钟)。2. 确保浏览器未禁用Cookie,因为Session ID默认通过Cookie传递。 3. 检查代码中是否有不当的 session.invalidate()或session.removeAttribute("user")调用。 |
5.3 从学习项目到生产级项目的思考
这个项目是一个完美的学习起点,但距离真正的生产系统还有巨大差距。理解这些差距,就是你下一步学习的方向:
- 框架升级:将Servlet替换为Spring MVC,用
@Controller和@RequestMapping简化开发;将JDBC替换为MyBatis或Spring Data JPA,告别繁琐的CRUD代码。 - 架构演进:引入Spring Boot实现快速搭建和自动配置;考虑前后端分离,后端提供RESTful API,前端使用Vue/React。
- 性能与扩展:用Redis缓存热点数据(如商品信息、首页数据);用消息队列(如RabbitMQ)解耦耗时的操作(如发送订单确认邮件);在数据库层面进行读写分离、分库分表。
- 安全加固:使用Spring Security进行全面的权限控制和攻击防护(CSRF、XSS等);密码加密升级为BCrypt。
- 部署与运维:使用Docker容器化部署;通过Nginx实现负载均衡和静态资源服务;引入ELK(Elasticsearch, Logstash, Kibana)进行日志收集和分析。
回过头来看,这个“基于Java的在线购物系统”项目就像是一张清晰的地图,它标记出了一个Web应用从零到一的所有关键地标。虽然今天的技术栈日新月异,但核心的MVC思想、HTTP协议的理解、数据库操作、会话管理、基础的安全意识,是永远不会过时的基石。我建议你在运行通这个项目后,不要就此停下。尝试着去改造它:给它换个前端界面;把JDBC改成MyBatis;甚至尝试拆出一个独立的订单服务。在这个过程中踩的每一个坑,都会让你对“系统”这两个字有更深的理解。编程的路上,很多时候不是要你发明轮子,而是先看懂轮子是怎么造的,然后才知道什么时候该换一个更耐磨的轮胎。
本文还有配套的精品资源,点击获取