首页 / 资讯中心 / 文章详情

Servlet+JSP手搓JavaEE商城:从环境搭建到订单事务的完整实战

Servlet+JSP手搓JavaEE商城:从环境搭建到订单事务的完整实战 ★ FEATURED ARTICLE
简介这是一套面向计算机相关专业学生与Java Web初学者的在线购物商城完整项目采用JavaEE技术栈以原生Servlet与JSP实现适合作为毕业设计、期末大作业或课程设计的参考方案也便于新手通过阅读源码理解MVC分层与请求处理流程。压缩包共约2000个文件整体11.55MB涵盖54个Java源文件、30个JSP页面、312个HTML与262个CSS样式文件以及107个JS脚本、193张PNG与128张JPG图片素材另含SQL数据库脚本、properties配置与jar依赖前端页面、后端逻辑与数据脚本一应俱全。项目代码附有注释结构清晰下载后简单部署即可运行已有742人学习关注。内容覆盖商品管理、用户账户、订单处理、商品分类等核心模块Servlet与DAO分层明确便于读者对照梳理业务流程、理解数据库交互与页面跳转逻辑并在此基础上进行功能扩展与二次开发。1. 从零手搓 JavaEE 商城为什么 ServletJSP 这套“老古董”还值得做一遍打开招聘软件搜“Java 后端”满屏 Spring Boot、微服务、云原生再看课程设计选题十个里有八个是“基于 SSM 的 XX 管理系统”。这时候如果有人跟你说“用原生 Servlet JSP 写个在线购物商城”你大概率会觉得这人是穿越过来的。但我要说一个反直觉的结论恰恰是这套看起来过时的技术栈最能帮你把 JavaWeb 的底层链路一次性打通。Servlet 是 JavaEE 规范里处理 HTTP 请求的最小单元JSP 是服务端渲染的经典实现把它们串起来做一个完整的购物商城你会被迫直面请求怎么进来、会话怎么保持、数据库连接怎么管、页面怎么拼这些平时被框架封装掉的问题。这个项目适合两类人一是正在做课程设计、需要一份能跑通、有数据库、有文档的高分项目二是写了半年 CRUD 但说不清HttpServletRequest和HttpServletResponse到底怎么协作的初中级开发者。接下来我会按“环境怎么搭 → 数据库怎么设计 → 核心功能怎么落地 → 坑在哪”的顺序把整个商城从零到能下单的路径讲清楚。2. 环境搭建与项目骨架Servlet 容器选型与目录结构定稿2.1 为什么选 Tomcat 9 而不是 Tomcat 10原生 Servlet 项目第一个要定的事就是 Servlet 容器版本。Tomcat 10 开始把javax.servlet包名整体迁移到了jakarta.servlet这是 Jakarta EE 的命名空间变更。如果你用的是 Tomcat 10但代码里写的是import javax.servlet.http.HttpServlet启动直接报ClassNotFoundException连编译都过不去。我一般建议课程设计场景锁定Tomcat 9.x JDK 8 或 JDK 11原因是这个组合的教程、报错信息、Stack Overflow 答案最丰富遇到问题搜一下基本都有现成解法。JDK 选 8 还是 11 看学校机房环境11 在var局部变量推断和HttpClient上更舒服但 8 的兼容性最稳。IDE 方面IntelliJ IDEA 社区版够用但要注意社区版对 Tomcat 集成支持不如旗舰版顺滑常见做法是手动配置 Tomcat 运行配置或者用 Maven 的tomcat7-maven-plugin插件直接跑。VS Code 配 JavaEE 环境也能做装 Extension Pack for Java 和 Community Server Connectors但调试体验比 IDEA 差一截适合轻量修改。2.2 Maven 依赖与 webapp 目录结构项目用 Maven 管理依赖pom.xml里核心就三块Servlet API、JSP API、MySQL 驱动。注意 Servlet 和 JSP 的依赖要标scopeprovided/scope因为 Tomcat 容器自带这两个库打 war 包时不能重复带进去否则可能引发类加载冲突。dependencies !-- Servlet APITomcat 9 对应 javax.servlet 4.0 -- dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency !-- JSP API同样由容器提供 -- dependency groupIdjavax.servlet.jsp/groupId artifactIdjavax.servlet.jsp-api/artifactId version2.3.3/version scopeprovided/scope /dependency !-- JSTL 标签库JSP 页面里做循环和判断用 -- dependency groupIdjavax.servlet/groupId artifactIdjstl/artifactId version1.2/version /dependency !-- MySQL 驱动8.x 版本驱动类名带 cj -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency /dependencies目录结构按 Maven 标准 webapp 布局来src/main/java放 Servlet、Service、DAO、实体类src/main/webapp放 JSP、CSS、JS、图片src/main/webapp/WEB-INF/web.xml是部署描述符。虽然 Servlet 3.0 以后可以用WebServlet注解替代 web.xml 配置但我建议两者都保留注解写路由映射web.xml 配欢迎页、错误页、编码过滤器。这样既现代又兼容老教程里的配置方式答辩时老师问起来也好解释。2.3 数据库连接池别用 DriverManager 硬扛新手最容易犯的错是在每个 DAO 方法里DriverManager.getConnection()一个页面加载触发十几次数据库连接并发一上来连接数直接爆。正确做法是用连接池。课程设计场景我推荐Druid或HikariCPDruid 自带监控页面答辩演示时能直观看到 SQL 执行情况加分项。配置文件放src/main/resources/druid.propertiesdriverClassNamecom.mysql.cj.jdbc.Driver urljdbc:mysql://localhost:3306/shop_mall?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse usernameroot password你的密码 initialSize5 maxActive20 maxWait3000参数说明initialSize是启动时创建的连接数maxActive是最大活跃连接数maxWait是获取连接的超时毫秒数。serverTimezone必须设否则 MySQL 8 驱动会报时区错误。useSSLfalse在本地开发关掉避免证书警告干扰日志。加载连接池的代码放在一个ServletContextListener里应用启动时初始化全局只做一次WebListener public class AppContextListener implements ServletContextListener { Override public void contextInitialized(ServletContextEvent sce) { try { Properties props new Properties(); // 从类路径加载配置文件 props.load(AppContextListener.class.getClassLoader() .getResourceAsStream(druid.properties)); DataSource ds DruidDataSourceFactory.createDataSource(props); // 把数据源塞进 ServletContext全局可访问 sce.getServletContext().setAttribute(dataSource, ds); } catch (Exception e) { throw new RuntimeException(数据源初始化失败, e); } } }逻辑说明contextInitialized在 Web 应用启动时执行一次把DataSource存到ServletContext里后续所有 Servlet 通过getServletContext().getAttribute(dataSource)拿到连接池。这样避免了每个请求都重新建池的开销也方便统一管理连接生命周期。3. 数据库设计从商品表到订单表的字段取舍与索引策略3.1 核心表结构与字段类型选择在线购物商城的数据库至少需要六张表用户表、商品表、商品分类表、购物车表、订单表、订单明细表。字段设计有几个容易翻车的地方我提前说金额字段绝对不能用 float/double浮点运算会出现0.10.20.30000000000000004这种精度问题必须用DECIMAL(10,2)商品图片存路径字符串而不是 BLOB图片文件放服务器磁盘或对象存储数据库只存 URL用户密码存 MD5 或 BCrypt 哈希值别存明文答辩时这是安全常识考点。CREATE TABLE user ( user_id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL COMMENT 存BCrypt哈希值, email VARCHAR(100), phone VARCHAR(20), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE product ( product_id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(200) NOT NULL, price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0, category_id INT, image_url VARCHAR(500), description TEXT, status TINYINT DEFAULT 1 COMMENT 1上架 0下架, INDEX idx_category (category_id), INDEX idx_name (name) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE orders ( order_id VARCHAR(32) PRIMARY KEY COMMENT 用时间戳随机数生成, user_id INT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status TINYINT DEFAULT 0 COMMENT 0待付款 1已付款 2已发货 3已完成, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_user (user_id), INDEX idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意orders表的主键用 VARCHAR(32) 而不是自增 INT原因是订单号需要对外暴露自增 ID 会泄露业务量而且分布式场景下自增 ID 有冲突风险。生成规则用System.currentTimeMillis()加随机数拼一个 32 位字符串简单够用。3.2 索引怎么加才不白加索引不是越多越好每个索引都会拖慢写入速度。商品表的category_id和name加索引是因为列表页会按分类筛选、按名称搜索订单表的user_id和status加索引是因为“我的订单”页面要按用户查、后台要按状态筛。不要给status这种区分度极低的字段单独建索引比如商品状态只有 0 和 1全表扫描可能比走索引还快。复合索引的顺序遵循最左前缀原则比如INDEX idx_user_status (user_id, status)查询WHERE user_id? AND status?能命中只查status就命中不了。3.3 用 DBX 或 Navicat 做结构变更时的注意点热词里提到的 dbx 数据库工具、Navicat 都是常见的 MySQL 客户端。用这些工具改表结构时生产环境千万别直接点“保存”因为某些工具默认会生成DROP TABLE再CREATE TABLE的语句数据直接没了。正确做法是先在 SQL 编辑器里写好ALTER TABLE语句确认无误再执行。比如给商品表加一个sales销量字段ALTER TABLE product ADD COLUMN sales INT DEFAULT 0 COMMENT 累计销量 AFTER stock;另外用 IDEA 导出数据库脚本时注意勾选“包含数据”还是“仅结构”。课程设计提交通常要求两者都有但数据量大的话脚本会很大建议只导出表结构和少量测试数据。4. 核心功能落地商品列表、购物车与订单提交的 Servlet 实现4.1 商品列表分页SQL LIMIT 与 JSP 渲染配合商品列表页是商城流量最大的页面必须分页。分页的核心 SQL 是LIMIT offset, sizeoffset (pageNum - 1) * pageSize。Servlet 负责接收页码参数、调用 Service 查数据、把结果集和分页信息塞进 request 域然后转发到 JSP。WebServlet(/product/list) public class ProductListServlet extends HttpServlet { Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 页码默认第1页每页12条 int pageNum 1; int pageSize 12; String pageParam req.getParameter(page); if (pageParam ! null !pageParam.isEmpty()) { try { pageNum Integer.parseInt(pageParam); if (pageNum 1) pageNum 1; } catch (NumberFormatException e) { pageNum 1; // 参数非法时回退到第一页 } } DataSource ds (DataSource) getServletContext().getAttribute(dataSource); ProductDao dao new ProductDao(ds); // 查当前页数据 ListProduct products dao.findByPage(pageNum, pageSize); // 查总记录数用于计算总页数 int total dao.countAll(); int totalPages (int) Math.ceil((double) total / pageSize); req.setAttribute(products, products); req.setAttribute(pageNum, pageNum); req.setAttribute(totalPages, totalPages); req.getRequestDispatcher(/WEB-INF/jsp/product_list.jsp).forward(req, resp); } }逻辑说明doGet里先解析页码做边界保护然后从ServletContext取数据源调 DAO 查当前页数据和总数最后把三个属性放进 request 域转发给 JSP。参数说明pageSize设 12 是因为商品卡片一行 4 个、共 3 行视觉上整齐totalPages用Math.ceil向上取整避免最后一页数据被截断。JSP 页面用 JSTL 的c:forEach遍历商品列表用c:if控制分页按钮的禁用状态。注意 JSP 里取 request 域数据用 EL 表达式${products}不要写% request.getAttribute(products) %这种老式脚本片段可读性差且容易被老师扣分。4.2 购物车Session 存储还是数据库存储购物车有两种实现路线存 Session 和存数据库。未登录用户必须用 Session因为数据库里没有他的 user_id登录用户建议存数据库这样换设备登录购物车还在。课程设计为了简化可以统一用 Session 存购物车登录后也不落库答辩时说明“购物车为临时数据下单时直接生成订单”即可。Session 里存购物车的数据结构用MapInteger, CartItemkey 是商品 IDvalue 是包含商品信息和数量的对象。加购逻辑要处理“已存在则数量累加”的情况WebServlet(/cart/add) public class CartAddServlet extends HttpServlet { Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { int productId Integer.parseInt(req.getParameter(productId)); int quantity Integer.parseInt(req.getParameter(quantity)); HttpSession session req.getSession(); // 获取或创建购物车 MapInteger, CartItem cart (MapInteger, CartItem) session.getAttribute(cart); if (cart null) { cart new LinkedHashMap(); // 保持插入顺序 } DataSource ds (DataSource) getServletContext().getAttribute(dataSource); ProductDao dao new ProductDao(ds); Product product dao.findById(productId); if (product null || product.getStock() quantity) { resp.getWriter().write({\code\:400,\msg\:\库存不足\}); return; } if (cart.containsKey(productId)) { CartItem item cart.get(productId); item.setQuantity(item.getQuantity() quantity); } else { CartItem item new CartItem(); item.setProduct(product); item.setQuantity(quantity); cart.put(productId, item); } session.setAttribute(cart, cart); resp.getWriter().write({\code\:200,\msg\:\已加入购物车\}); } }逻辑说明用LinkedHashMap而不是HashMap是为了让购物车列表按加购顺序展示用户体验更好。库存校验放在加购时做一次下单时还要再校验一次因为加购到下单之间库存可能被其他人买走。返回 JSON 而不是跳转页面是因为加购通常用 AJAX 异步请求不刷新页面。4.3 订单提交事务与库存扣减的原子性下单是整个项目最核心也最容易出 bug 的环节。它涉及三个操作写订单主表、写订单明细表、扣减商品库存。这三个操作必须在一个数据库事务里完成否则可能出现“订单写了但库存没扣”或者“库存扣了但订单没写”的数据不一致。public String createOrder(int userId, MapInteger, CartItem cart) throws SQLException { DataSource ds (DataSource) getServletContext().getAttribute(dataSource); Connection conn null; try { conn ds.getConnection(); conn.setAutoCommit(false); // 开启事务 OrderDao orderDao new OrderDao(conn); ProductDao productDao new ProductDao(conn); String orderId generateOrderId(); BigDecimal total BigDecimal.ZERO; for (CartItem item : cart.values()) { total total.add(item.getProduct().getPrice() .multiply(BigDecimal.valueOf(item.getQuantity()))); } // 1. 写订单主表 orderDao.insertOrder(orderId, userId, total); // 2. 写明细并扣库存 for (CartItem item : cart.values()) { orderDao.insertOrderItem(orderId, item.getProduct().getProductId(), item.getQuantity(), item.getProduct().getPrice()); // 扣库存时加 stock quantity 条件防止超卖 int affected productDao.reduceStock( item.getProduct().getProductId(), item.getQuantity()); if (affected 0) { throw new SQLException(库存不足 item.getProduct().getName()); } } conn.commit(); return orderId; } catch (SQLException e) { if (conn ! null) conn.rollback(); // 任何一步失败整体回滚 throw e; } finally { if (conn ! null) { conn.setAutoCommit(true); conn.close(); } } }逻辑说明setAutoCommit(false)开启事务所有 SQL 在同一个连接上执行最后commit()提交。扣库存的 SQL 写成UPDATE product SET stock stock - ? WHERE product_id ? AND stock ?用affected 0判断是否扣减成功这样即使并发下单也不会把库存扣成负数。参数说明generateOrderId()用System.currentTimeMillis()加 6 位随机数保证订单号唯一且可读。提示事务里的 Connection 必须从连接池获取后全程复用不能在事务中途又去连接池拿新连接否则新连接不在事务里回滚不到。5. 避坑与排查JSP 中文乱码、404 与连接池耗尽的处理5.1 中文乱码POST 和 GET 要分开治现象表单提交后数据库里存的是??????或者页面显示ä½ å¥½。原因POST 请求默认用 ISO-8859-1 解码请求体GET 请求的查询参数由 Tomcat 的URIEncoding决定。解决POST 乱码用过滤器统一设request.setCharacterEncoding(UTF-8)GET 乱码要改 Tomcat 的server.xml在 Connector 标签加URIEncodingUTF-8。响应端统一设response.setContentType(text/html;charsetUTF-8)。JSP 页面头部加% page contentTypetext/html;charsetUTF-8 languagejava %。WebFilter(/*) public class EncodingFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { req.setCharacterEncoding(UTF-8); resp.setContentType(text/html;charsetUTF-8); chain.doFilter(req, resp); } }5.2 404路径对不上是主因现象访问/product/list报 404。原因通常有三种WebServlet的路径写错、web.xml 里metadata-completetrue导致注解被忽略、war 包没部署到 Tomcat 的 webapps 目录。排查顺序先看 Tomcat 启动日志里有没有Deployment of web application archive这行确认应用部署成功再检查访问 URL 是否带了项目 context path比如http://localhost:8080/shop/product/listshop是 war 包名最后确认WebServlet的 value 和访问路径完全一致大小写敏感。5.3 连接池耗尽连接没关是元凶现象应用跑一段时间后报GetConnectionTimeoutException或wait millis 3000, active 20。原因DAO 里拿了 Connection 但没在 finally 里 close连接池的 20 个连接全被占满。解决所有数据库操作必须用 try-with-resources 或者在 finally 里关闭 Connection、PreparedStatement、ResultSet。注意从连接池拿到的 Connection 调用close()是归还而不是真正关闭所以放心关。public Product findById(int id) { String sql SELECT * FROM product WHERE product_id ?; try (Connection conn ds.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, id); try (ResultSet rs ps.executeQuery()) { if (rs.next()) { return mapRow(rs); } } } catch (SQLException e) { throw new RuntimeException(查询商品失败, e); } return null; }5.4 JSP 页面加载后刷新一次的问题热词里有人问“jsp 页面让加载完后刷新一次”这通常是因为表单重复提交或者页面数据没更新。正确做法不是无脑location.reload()而是用PRG 模式Post-Redirect-Get表单 POST 提交后Servlet 处理完用response.sendRedirect()重定向到列表页而不是forward。这样浏览器地址栏变成 GET 请求用户按 F5 不会重复提交表单。如果确实需要定时刷新用meta http-equivrefresh content5但购物车页面慎用会打断用户操作。5.5 图片路径与坐标定位的常见误解热词里提到“jsp 图片如何对坐标定位”这通常是想做图片热区或者验证码点击。JSP 本身不处理图片坐标坐标定位靠前端实现用img标签的usemap属性配合map和area标签定义热区或者用 JavaScript 监听click事件读取event.offsetX和event.offsetY。商品图片展示只需要正常img src${product.imageUrl}即可路径建议存相对路径如/upload/xxx.jpg部署时把 upload 目录放在 webapp 下。6. 从能跑到能答辩三个让项目加分的技术细节第一个细节是用 Filter 做登录拦截。不要在每个 Servlet 里写if (session.getAttribute(user) null)而是写一个LoginFilter拦截/order/*、/cart/*等需要登录的路径未登录直接重定向到登录页。这样代码干净答辩时也能体现你对 AOP 思想的理解。WebFilter(urlPatterns {/order/*, /cart/*}) public class LoginFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; HttpSession session request.getSession(false); if (session null || session.getAttribute(user) null) { response.sendRedirect(request.getContextPath() /login.jsp); return; } chain.doFilter(req, resp); } }第二个细节是订单状态用状态机管理。不要允许任意状态跳转比如从“待付款”直接跳到“已完成”。在 Service 层加校验只有“待付款”能改成“已付款”只有“已付款”能改成“已发货”。这样后台管理不会出现逻辑混乱答辩时老师问“订单状态怎么保证正确”你就有话可说。第三个细节是给关键操作加日志。用java.util.logging或 Log4j 在用户登录、下单、支付成功时打日志记录用户 ID、操作时间、订单号。出问题时能快速定位是谁在什么时候做了什么。日志文件按天切割别让一个文件无限增长。加分项实现方式答辩价值登录拦截Filter Session 校验体现权限控制意识订单状态机Service 层状态校验体现业务逻辑严谨性操作日志Logger 记录关键动作体现可维护性思维连接池监控Druid 内置 StatViewServlet可视化展示 SQL 性能最后说个我自己的习惯每次改完 DAO 层的 SQL我都会在 Navicat 或 DBX 里先手动跑一遍确认结果集正确再写进 Java 代码。这个习惯帮我省了无数次“代码没问题但数据不对”的排查时间。还有数据库脚本一定要用 Git 管起来每次改表结构都提交一次答辩前想回滚到某个版本随时可以。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站