ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从48个Java文件拆解电商平台:Servlet/JSP三层架构与事务一致性实战

从48个Java文件拆解电商平台:Servlet/JSP三层架构与事务一致性实战 简介这份源码是一套基于Java的电子商务平台设计实现可视为开源项目参考定位于Java学习者和毕业设计开发者覆盖商品展示、购物车、订单管理等典型电商业务场景可用于课程实训、系统性学习与二次开发。压缩包共334个文件大小约35.26MB主要包含138个JPG图片、50个JAR依赖库、48个Java源代码以及JSP动态页面、JavaScript脚本、CSS样式、XML配置文件等其中JAR提供第三方功能支持JPG多为页面设计素材Java源码和前端资源共同构成完整可运行的项目。项目目录层次分明代码结构规范阅读时能够理解JavaWeb分层开发、前后端请求响应流程、常用框架集成方式以及电商模块的数据组织逻辑同时附带的静态资源与页面模板便于直接部署调试也可作为课程设计或毕业设计的参考蓝本。目前已有638人学习下载适合希望快速掌握电商系统开发思路的读者系统参考。1. 从 48 个 Java 文件开始拆一个 Java 电商平台把这套 Java 电子商务平台源码导入工作区时我第一眼看的不是 src 目录而是 .classpath 和 org.eclipse.wst.common.component。这两个文件的出现说明工程在 Eclipse 里就是一个 Dynamic Web Project前端静态资源和 Java 类要按约定输出到 WEB-INF/classes。335 个文件中 48 个 Java 文件、50 个 jar、138 张产品图演示环境所需的素材基本齐了。源码没有采用微服务或 Spring Boot 全家桶而是更接近教科书与生产折中后的 Java EE 做法JSP 配合 Servlet 做控制层Service 处理业务DAO 封装数据库访问。面试八股文里反复追问的事务、会话和 jar 冲突刚好会在这个规模里暴露出来。接下来按我拆这套代码的顺序从依赖和包结构推断分工还原订单主链路的数据模型再把前端资源和后端交易串起来最后处理构建部署时容易翻车的几步。2. 从 jar 清单反推架构servlet、service、dao 谁在干活2.1 先盘点 WEB-INF/lib 里的 jar再猜技术边界项目给出的 50 个 jar 和一堆 Eclipse 元数据拿到手第一步不是写代码而是把依赖名列出来。因为 jar 的名字已经暴露了分层工具servlet-api 代表容器由 Tomcat 提供mysql-connector-java 负责 JDBC 驱动fastjson 或 gson 负责把 Java 对象序列化成 JSONcommons-dbutils 或 dbcp 则说明 DAO 层多半是手写 JDBC 简易连接池。这里我不逐个列文件名直接给一段检查 WEB-INF/lib 是否混入容器非兼容 jar 的脚本因为这是导入 IDEA 后最常踩的坑find WEB-INF/lib -name *.jar | while read f; do if jar tf $f 2/dev/null | grep -q javax/servlet/Servlet.class; then echo conflict: $f contains servlet-api fi done这段脚本先把 WEB-INF/lib 下所有 jar 逐个读取再用 jar tf 列出包内容通过过滤 javax/servlet/Servlet.class 判断该 jar 是否包含了 Servlet API。如果你的 lib 目录里确实有 servlet-api 或 tomcat-catalina 这类 jarTomcat 启动时会出现 java.lang.NoClassDefFoundError 或方法签名不一致的报错常见做法是把这个 jar 从 WEB-INF/lib 里移除保留容器自带的那份。Maven 构建后复制依赖到 lib 时经常顺手把 servlet-api 一起拷进来这条命令可以当作快速体检脚本。提示如果本机没有安装 JDKjar tf命令会提示找不到命令先确认JAVA_HOME已经正确配置。接下来要判断 Java 源码是按 MVC 分包的。48 个 Java 文件按典型约定会出现这样的结构。2.2 48 个 Java 文件的包结构决定了你能复习什么把 src 目录展开后最常见的一组包名是 com.xxx.servlet、com.xxx.service、com.xxx.dao、com.xxx.entity 和 com.xxx.util偶尔还会出现 filter 和 listener。表 1 给出拆这类电商源码默认的分层职责你可以对照手里的目录修正包名分层职责典型类servlet参数接收、URL 路由、Session 读写ProductServlet, CartServletservice业务判断、事务边界、库存计算OrderService, UserServicedaoSQL 执行、ResultSet 映射ProductDao, OrderItemDaoentity数据表对应的 POJOProduct, CartItem, OrdersutilDB 连接、字符串处理、Json 转换DBHelper, JsonUtil三层结构里最容易被误写的一层是 Service。很多人把 SQL 写进 Servlet或者把事务控制放到了 DAO结果一个方法调多次 DAO 时一个成功一个失败数据库留下半截数据。在原生 Servlet 项目里Service 层要保住事务边界常见做法是抽一个 TransActionProxy利用 JDK 动态代理在业务方法前后自动完成 commit/rollback。下面这段代码可以放进 util 包public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { Connection conn DataSourceUtils.getConnection(); boolean oldAutoCommit conn.getAutoCommit(); try { conn.setAutoCommit(false); Object result method.invoke(target, args); conn.commit(); return result; } catch (Exception e) { conn.rollback(); throw new RuntimeException(transaction failed, e); } finally { conn.setAutoCommit(oldAutoCommit); DataSourceUtils.removeConnection(); } }这段拦截逻辑在每个 Service 方法调用前关闭自动提交方法正常结束就 commit抛异常就 rollback。这里关键是 DataSourceUtils 内部用 ThreadLocal 持有同一个 Connection让同一线程里的多个 DAO 方法共享事务上下文而不是每个 dao 重新取连接否则事务根本覆盖不到第二条 SQL。如果你想深入面试八股文里的 AOPSpring 的 Transactional 基本都是基于这套思路包装出来的区别只是通过切面扫描切入点。2.3 集合和 Json从数据库到页面的中间形态DAO 查询商品列表通常返回 List 让 Servlet 循环转发购物车则常用 MapInteger, CartItem 以 productId 为 key方便做累加。这是 Java 集合在这个项目里的两个最常见使用场景。处理完业务后Servlet 会把 List 或 Map 转成 JSON。例如response.setCharacterEncoding(UTF-8); response.setContentType(application/json;charsetUTF-8); PrintWriter out response.getWriter(); out.print(JSON.toJSONString(cartItemList)); out.flush();第一行设置响应编码避免中文乱码第二行声明 MIME 类型为 application/json第三行通过 PrintWriter 输出。cartItemList 如果是装 CartItem 的 ArrayListJSON.toJSONString 会把每个对象的 getter 转成字段。如果你要控制哪些字段不给前端在实体对应的 getter 上放 JSONField(serialize false) 即可。一个小坑JSON 序列化循环引用的对象比如 orders 里放 useruser 里再放 orderList容易堆栈溢出电商源码常在 entity 里忽略这类双向关联字段。3. 数据模型与超卖场景ER 图、JDBC 事务边界3.1 主链路只需要五张表一个可运行的 Java 电商平台订单主链路不会太复杂用户、商品、购物车、订单、订单项。购物车表有人会做成 session 内存态有人会持久化到数据库。这个项目如果带 jpg 商品图且支持用户登录通常在数据库建购物车表来保存用户未下单的商品。ER 关系上是用户一对多购物车、一对多订单订单一对多订单项订单项多对一商品。表 2 给出核心字段建议字段名和类型可以参考实际代码调整表名关键字段说明userid, username, password登录与私有数据productid, name, price, stock, image库存是订单并发关键cart_itemid, user_id, product_id, quantity每个用户一份购物车ordersid, user_id, total_amount, status, create_time一个订单一个总金额快照order_itemid, order_id, product_id, quantity, price商品单价快照保留购买时价格创建订单的 SQL 可参照下面这段简化结构CREATE TABLE product ( id bigint(20) NOT NULL AUTO_INCREMENT, name varchar(128) NOT NULL, price decimal(10,2) NOT NULL DEFAULT 0.00, stock int(11) NOT NULL DEFAULT 0, image varchar(255) DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE orders ( id bigint(20) NOT NULL AUTO_INCREMENT, user_id bigint(20) NOT NULL, total_amount decimal(12,2) NOT NULL, status tinyint(4) NOT NULL DEFAULT 0, create_time datetime NOT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE order_item ( id bigint(20) NOT NULL AUTO_INCREMENT, order_id bigint(20) NOT NULL, product_id bigint(20) NOT NULL, quantity int(11) NOT NULL, price decimal(10,2) NOT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意两处细节金额一律用 decimal不用 float/double否则累计金额会有二进制误差订单项必须冗余一份 price 快照因为商品表价格会变历史订单不能跟着变。status 用 tinyint 表示 0 待支付、1 已支付、2 已发货比字符串状态节省空间也方便写 switch 映射。3.2 下单扣库存先锁行再插记录电商平台的下单动作是典型的有状态事务先扣库存再创建订单与订单项。如果把查询库存和扣减分开并发请求读到同一个剩余库存就会出现超卖。我一般会在 OrderService.createOrder 方法里用一条带条件的 UPDATE 完成预占UPDATE product SET stock stock - #{quantity} WHERE id #{productId} AND stock #{quantity};这条语句的 WHERE stock #{quantity} 是关键数据库行锁会让多个并发事务排队执行先执行的那一个把 stock 扣下去后执行的影响行数为 0。Java 侧拿到 int rows productDao.updateStock(...) 返回值后rows 1 才继续插入订单rows 0 直接返回“库存不足”。配合上一章的动态代理整个 createOrder 方法的事务范围是先执行 update stock再 insert orders最后 insert order_item任意一步抛异常整体回滚。实际拆源码时你可以重点观察三个地方连接是否从同一个 DataSource 获取Connection 是否被 finally 关闭updateStock 是否有返回值得到解析。很多作业版电商参考代码会把 updateStock 写成 void导致并发压测时超卖查不出来。3.3 购物车合并与转换节点购物车到订单的转换往往藏在 CartServlet 的 buy 方法里先读取 cart_item 表中的商品 id、数量连同商品价格算出总金额再生成订单快照。这里用 MapString, Object 收集 result 是很顺手的选择。涉及分页查询商品列表时通常用 LIMIT offset, size 实现页码参数 pageNo 从 request 拿size 固定 12 或 20。如果项目用的数据库是 MySQL这个分页能用如果迁移到 SQL Server 或达梦SQL 就要改。分页之外的另一个节点是库存校验的位置。有人会在前端用商品数量乘单价算总价这个值只能用来展示后端下单时必须重新查询数据库里的价格和库存。价格快照应该以订单项里保存的 price 为准否则用户篡改表单里的价格字段订单金额就和实际对不上。4. 六份样式与购物车 Ajax前端资源和 Java 交易链路串起来4.1 从 CSS 文件名反推原型素材这个项目的前端资源里有 bootstrap.min.css、animate.css、font-awesome.css、chosen.css、pe-icon-7-stroke.css 和 jquery.bxslider.css。把它们按职责拆开会发现它们正好覆盖一个商城页面的全部模块Bootstrap 负责栅格与基础组件animate 负责进页面时的入场动画font-awesome 和 pe-icon-7-stroke 提供图标字体chosen 负责下拉框增强bxslider 是首页商品轮播图。表 3 是拆素材时常做的功能对照资源典型用途放的位置bootstrap.min.css按钮、表格、栅格布局每个页面 headanimate.css首屏 banner 或促销弹层动画index.jspfont-awesome.css / pe-icon-7-stroke.css图标、按钮符号header、导航栏chosen.css后台商品筛选下拉多选admin 相关页面jquery.bxslider.css首页商品轮播index.jsp注意看工程里如果这些 css 都躺在 css 目录而 html 目录没有对应页面说明前端页面由 JSP 动态生成静态资源只是被 include 进 JSP。电商项目的页面结构一般分成 header.jsp、footer.jsp再被商品列表、详情页 include头部和尾部的链接用 ${pageContext.request.contextPath} 拼上下文路径避免部署到非根路径后样式全部丢失。4.2 购物车添加走一个 Ajax 闭环页面侧添加商品到购物车不提交整个 form 刷新是更好的体验。常见做法是给商品列表里的“加入购物车”按钮绑定 jQuery 事件通过 Ajax 提交 productId 和 quantity然后由 Servlet 返回 JSON前端再根据返回值改变按钮文案。代码片段如下function addToCart(productId, quantity) { $.ajax({ url: ctx /cart/add, type: POST, data: {productId: productId, quantity: quantity}, success: function(json) { if (json.code 200) { $(#cartCount).text(json.data.cartCount); } else { alert(json.code : json.msg); } } }); }参数说明productId 是商品的唯一标识quantity 默认是 1点击商品卡片里的“立即购买”时 quantity 可以改为详情页的输入值。ctx 表示上下文路径通常在 JSP 里用 var ctx ${pageContext.request.contextPath} 提前声明防止在二级目录部署时请求 404。CartServlet 侧收到参数后把自己加到 session 中的购物车集合再调用 service 层把购物车同步进 cart_item。注意 Session 里的 MapInteger, Integer 和数据库表字段一一对应数量相同时直接更新数量不同则新增或累加。4.3 商品图片和 Chosen 组件的两个坑项目里有 138 张 jpg 图片通常放在 WebContent/images/product/ 下。商品表 product.image 存的往往是相对路径例如 images/product/p1001.jpg。JSP 输出时需要在 src 属性前拼上 ctx否则浏览器会到根目录去找导致图片裂掉。Server 下载或导出图片时需要指定 Content-Type 为 image/jpeg并设置 Content-Disposition 做下载命名。如果遇到图片无法访问先看 image 字段是相对路径还是以斜杠开头的绝对路径再统一处理。另一类问题是 Chosen 组件对动态生成的 select 不生效。Ajax 往后台返回了新分类列表后如果只是在 select 里 append 了 optionChosen 的界面不会变化需要执行 $(#categorySelect).trigger(chosen:updated) 刷新样式。这个坑在后台商品筛选页特别常见加完新选项后没有触发更新选择框里仍然看不到新数据。改法是在回调里找到对应 select 再触发一次事件属于纯前端提示。5. 构建到部署从环境变量配置到一次并发验证5.1 三个环境变量和一条 war 命令拿到源码要本地跑起来JAVA_HOME、CATALINA_HOME、Path 三个环境变量必须配置正确。装好 JDK 后Linux 或 macOS 可以在 ~/.bashrc 里加两行Windows 则在系统变量里设置export JAVA_HOME/opt/jdk1.8 export CATALINA_HOME/opt/tomcat9 export PATH$JAVA_HOME/bin:$CATALINA_HOME/bin:$PATHJAVA_HOME 指向 JDK 安装目录Tomcat 启动脚本靠它找到 java 命令CATALINA_HOME 指向解压后的 Tomcat 目录Path 里的 bin 让 javac、startup.sh 直接可用。配置完成后执行 java -version 能看到版本再把项目打成 war 复制到 $CATALINA_HOME/webapps 下启动Tomcat 会按 war 名自动展开。5.2 用 ab 命令压一个下单接口验证库存是否超卖启动后可以用 ApacheBench 模拟并发。假设你的订单创建地址是 http://localhost:8080/order/create参数为 productId1quantity2执行ab -n 200 -c 20 -T application/x-www-form-urlencoded \ -p postdata.txt http://localhost:8080/order/createpostdata.txt 里写 productId1quantity2。压完之后查数据库SELECT stock FROM product WHERE id 1; SELECT COUNT(*) FROM orders;如果 product 表 stock 出现负数或者 orders 的数量明显大于库存可售数量说明下单代码没有使用带条件的 UPDATE。这时回看 OrderService 里 updateStock 的返回值是否被判断。若压测时发现大量连接超时检查 Tomcat 的 connector 配置 maxThreads 和 acceptCountJDBC 连接池的最大连接数也要大于并发数否则线程会在 getConnection 上死等。5.3 构建排错把 NoClassDefFoundError 看成 jar 顺序问题Tomcat 启动时报 java.lang.NoClassDefFoundError: javax/servlet/... 这类异常时先不要怀疑代码逻辑八成是 WEB-INF/lib 里打进了 servlet-api 或 tomcat 专用的 jar。处理办法就是用第 2 章的 find 脚本扫一遍把冲突 jar 删掉再重启。另外 50 个 jar 里如果包含旧版本 xerces 或 xml-apis容易和容器自带 XML 解析器冲突报错时同样优先移除 lib 下的重复依赖。保留一份干净的依赖清单再去看 catalina.out 的完整堆栈比盲目改代码更省时间。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进