实战①:需求分析与整体架构设计
这一篇不写一行业务代码,但它决定后面三篇是「往上盖楼」还是「往下填坑」。我们要做的事情只有一件:把一个模糊的愿望——「用户能下单」——变成一份谁都不用猜的合同:合同里写清楚谁提什么要求(用户故事与验收标准)、世界里有哪些东西和规矩(领域模型与状态机)、各部分站在哪(分层与模块边界)、以及对外说些什么话(接口清单)。
先把这篇反复出现的六个词一句话解释掉:
- 需求(requirement):对方嘴里那句「我要一个下单功能」——它不是需求,它是愿望;能写出测试用例的那一句才是需求
- 用户故事(user story):把愿望写成「作为〈谁〉,我想要〈干什么〉,以便〈得到什么好处〉」的一句话,后面必须跟一串可验证的验收标准
- 领域模型(domain model):这门生意里真实存在的名词和规矩——订单、库存、支付,以及「库存不能为负」这类铁律,跟数据库表还没有关系
- 聚合根(aggregate root):一组必须一起变动的对象里那个「唯一入口」。改订单项必须通过订单来改,就像改发票明细得重新开一张发票
- 分层(layering):按「谁离用户近」把代码排成一列纵队:Controller 在最前,Service 居中,Mapper 最后,依赖只能从前指向后
- 契约(contract):前后端共同签字的一份说明书,规定请求长什么样、响应长什么样、出错给什么码。签了字就不许单方面改
一家餐厅。 服务员(Controller)只负责记单、报菜名、上菜,绝不能冲进灶台炒菜;厨师长(Service)决定「这道菜先焯水再爆炒、和凉菜同一时间出盘」,也就是业务编排与事务边界;仓库管理员(Mapper)只管按单据取货放货,自己从不决定该发多少货(业务规则);而水电煤、监控摄像头、打卡机(基础设施 Actuator 与日志)不进菜单,但没它们整店当天就得关门。点菜单从服务员传到厨师长时会被改写:顾客说的「来份宫保鸡丁不要辣」变成厨房术语「宫保 ×1/免辣/加饭」——这就是 DTO(顾客话)与实体/VO(后厨话)的区别,#44 会把它落成代码。
订单快照就是「用过的点菜单」。 这家店今天把宫保鸡丁从 38 元涨到 45 元、名字改成「蜂巢秘制鸡丁」,但你三个月前那张单子上的 38 元和老名字必须原样保留,否则月底对账时你这张桌子的钱怎么算都对不上。这条规矩直接决定了下一篇为什么要在 order_item 里冗余存商品名和单价——它抄的是菜单,留的是证据。

上面这张地图就是本篇的目录:第一条分支是「调用方向」(第六节),后面五条分支是六个业务包各自负责什么(第八节),最后一条 common 是全项目共用的三样小工具。看的时候盯住一件事:图上没有任何一条箭头是从右往左指回去的——这条纪律比任何框架知识都重要,违反它的代价在第十节的风险清单和第十三节的报错速查表里都会现场复现。
学完这一篇,你应该能回答三个问题:
- 「下单要快」和「P99 响应 < 500ms」这两句话,为什么只有第二句能进需求文档?判断标准是什么?
order可以调inventory,反过来为什么不行?一旦反了,最先出问题的是编译期、启动期还是运行期?第十三节的报错表会给你答案。- 单体项目和微服务项目,真正的分界线画在哪里——是「拆成几个 jar」,还是「谁能独立扩容、独立上线」?
前 40 篇我们把 Spring 的零件一件件拆开讲完了——IoC、AOP、Web、数据访问、事务、缓存、安全、异步与事件、监控。但知识点是散的,真实开发却要求你把它们装进同一个工程里协同运转。从这一篇开始,我们用 4 篇文章完整交付一个可上线的项目:BeeOrder · 蜂巢订单中心。
先亮明这个项目的边界。它是一个电商订单中台单体应用,把「一个用户从注册到收到商品」的主链路跑通:
| 项目项 | 定义 |
|---|---|
| 项目名 | BeeOrder · 蜂巢订单中心 |
| 形态 | 电商订单中台单体应用(Monolith) |
| 六大模块 | 用户 user / 商品 product / 库存 inventory / 订单 order / 支付 payment / 通知 notify |
| 核心功能 | 注册登录、商品浏览、下单、库存扣减、支付回调(模拟)、订单查询、超时关单 |
| 技术栈 | Spring Boot 3.2 + MySQL 8 + Redis 7 + MyBatis + Spring Security + JWT + Actuator + Docker Compose |
| 包根 | com.beeorder |
同样重要的是不做清单——明确不做什么,比列功能更能防止范围失控:
- 不做前端:所有交付物是 REST API,用 curl / Postman 即可验证(本教程第 46 篇会给出用 Docker 起前端的可选演示,但非必需)。
- 不做真实支付:接入真实支付需要商户资质与密钥,我们用「模拟支付网关」+ 回调接口完整还原支付流程的状态与幂等。
- 不做微服务拆分:先做单体,把边界划清;文末会讲「何时才该拆」,但本项目不拆。
- 不做大数据与推荐:运营统计只做到「按天汇总订单量与金额」这一层。
范围写得越清楚,后面 3 篇越不会跑偏。这份声明就是后续所有代码的「合同」。
最初的需求往往只有一句话:「用户能下单。」但这一句话要写多少代码、怎么算完成,没人说得清。需求分析做的就是把模糊的期望变成可验收的用户故事。
用户故事的标准格式是「作为〈角色〉,我想要〈能力〉,以便〈价值〉」,并附上验收标准(Acceptance Criteria)。BeeOrder 的核心故事卡如下:
| 角色 | 用户故事 | 验收标准(可测试) |
|---|---|---|
| 访客 | 注册并登录,以便下单 | 手机号唯一;密码 BCrypt 存储;登录返回 JWT,有效期 2 小时 |
| 用户 | 浏览商品列表与详情,以便挑选 | 支持分页;下架商品不出现在列表 |
| 用户 | 提交订单,以便购买商品 | 库存充足才成功;成功后库存扣减、订单状态为 CREATED |
| 用户 | 支付订单,以便完成购买 | 支付成功回调后订单变 PAID;重复回调不重复扣款 |
| 用户 | 查询自己的订单,以便查看进度 | 只能查到自己的订单;支持按状态筛选 |
| 系统 | 超时未支付自动关单,以便释放库存 | 创建后 30 分钟未支付,订单变 CLOSED,库存回补 |
| 运营 | 查看订单统计,以便掌握经营 | 按天返回订单量、成交金额 |
注意最后两行——它们不是「用户点击」触发的,而是系统自身的行为。真实项目里这类需求最容易被漏掉,却直接决定库存能否正确释放。
验收标准必须「可测试」。写「下单要快」是无效需求,写「P99 响应时间 < 500ms」才能变成一条压测用例。
上面那张表是成品的模样;下面这段动画把它倒放一遍——看一句模糊的愿望,是怎么被三个追问逼成一张可验收的故事卡的:

三个追问里最容易跳过的是「什么场景」,而它恰好是「30 分钟未支付自动关单」这类系统行为的来源。写不出验收标准的故事,说明追问还没问完。
需求梳理清楚后,先把世界里的「名词」找出来。BeeOrder 的核心领域对象有 7 个:
| 实体 | 关键字段 | 说明 |
|---|---|---|
| 用户 User | id, phone, password, nickname | 登录主体 |
| 商品 Product | id, name, price, status, stock_snapshot | 可售单元,价格以「分」存储 |
| 库存 Inventory | product_id, available, locked | 可售数量与锁定数量 |
| 订单 Order | id, user_id, total_amount, status | 聚合根,承载订单项 |
| 订单项 OrderItem | id, order_id, product_id, price, quantity | 下单时的价格快照 |
| 支付记录 Payment | id, order_id, trade_no, amount, status | 一次支付尝试 |
| 库存流水 InventoryLog | id, product_id, change, type, ref_id | 每次库存变动的审计记录 |
它们的关系可以这样理解:一个用户有多个订单;一个订单包含多个订单项;每个订单项指向一个商品;每个商品对应一条库存记录;一次支付对应一个订单。 订单是聚合根,改动订单项必须通过订单进行。
关键业务规则必须白纸黑字写下来,因为它们直接对应代码里的分支与约束:
- 订单总价 = Σ(商品单价 × 数量),且使用
DECIMAL/ 分为单位,禁止用double。 - 库存不能为负:扣减必须在数据库层面用条件更新(
WHERE available >= ?)兜底。 - 订单状态只能按状态机流转,不允许「从 CREATED 直接跳到 COMPLETED」这种越级。
- 同一订单的有效支付只有一次:重复回调必须幂等。
- 金额、库存的每一次变动都要留流水,便于对账。
领域模型不是「数据库表的复制」。先想清业务规则和聚合边界,表结构是它的落地结果——顺序别反了。
订单状态是这个项目里最核心的抽象。一旦状态定义乱了、流转约束松了,就会出现「已取消的订单又收到支付成功」这类脏数据。BeeOrder 的状态集合定为六个:
CREATED(已创建)→ PAID(已支付)→ SHIPPED(已发货)→ COMPLETED(已完成),另有 CLOSED(已关闭) 与 REFUNDED(已退款) 两个终态分支。完整流转如下:
| 当前状态 | 触发事件 | 目标状态 | 副作用 |
|---|---|---|---|
| CREATED | 支付成功回调 | PAID | 记录支付流水、锁定库存转扣减、发通知 |
| CREATED | 超时 30 分钟 / 用户取消 | CLOSED | 释放锁定库存、发通知 |
| PAID | 运营发货 | SHIPPED | 记录发货时间、发通知 |
| SHIPPED | 用户确认收货 | COMPLETED | 订单终态、可触发积分(通知模块) |
| PAID | 用户申请退款并成功 | REFUNDED | 记录退款流水、库存可选回补 |
| COMPLETED | —— | —— | 终态,不再流转 |
| CLOSED / REFUNDED | —— | —— | 终态,不再流转 |
这张表就是状态机的全部法律条文,但条文读三遍,不如图看一眼。把它画成一张可以贴在显示器边上的图:

读图的口诀是「一条主链路、两个终态、三条铁律」:主链路 CREATED → PAID → SHIPPED → COMPLETED 只许向右;CLOSED 与 REFUNDED 各自从指定入口进入后不再流出;三条铁律(状态与事件匹配、目标在允许集合内、副作用同事务或可靠事件)会在下一篇被逐条写成校验代码。
任何一次状态变更都必须满足三个条件:当前状态与事件匹配、目标状态在允许集合内、副作用在同一事务或可靠事件中执行。把这张表变成代码里的一个枚举 + 校验方法,是下一篇要做的事。
状态机最怕「用字符串硬编码状态」。order.setStatus("paid") 这类写法,拼错一个字母编译器毫无反应,跑到线上才发现状态对不上。请用枚举 OrderStatus.PAID,让错误在编译期暴露。
技术选型不是堆砌名词,而是做取舍。BeeOrder 的选型表:
| 领域 | 选择 | 备选 | 选它的理由(对比备选) |
|---|---|---|---|
| 框架 | Spring Boot 3.2 | Spring MVC 5 / Quarkus | 生态与教程最完整;3.2 原生支持虚拟线程,JDK 17 起步 |
| 数据库 | MySQL 8 | PostgreSQL | 团队熟悉度最高、运维资料最多;InnoDB 行锁满足库存扣减 |
| 缓存 | Redis 7 | 本地 Caffeine | 需要多实例共享的热点缓存与分布式锁,本地缓存做不到 |
| 持久层 | MyBatis | Spring Data JPA | 库存扣减、报表统计要写精确 SQL,MyBatis 对 SQL 完全可控 |
| 安全 | Spring Security + JWT | Session + Redis | 无状态、天然适配多实例与前后端分离 |
| 监控 | Actuator + Prometheus | 自研埋点 | 开箱即用、标准格式,无需重复造轮子 |
| 部署 | Docker Compose | K8s | 单体项目用 Compose 足够;K8s 的复杂度对单体是负担 |
每一行都值得追问一句「为什么不用另一个」。例如为什么用 MyBatis 而不是 JPA:本项目最关键的两条 SQL——UPDATE inventory SET available = available - ? WHERE product_id = ? AND available >= ?(原子扣减)和按天聚合的报表查询——都是「精确控制 SQL」的场景,JPA 的自动生成反而碍事。
BeeOrder 采用单体分层架构,自上而下四层,请求的流向是单向的:

- 接入层(Controller):只做协议转换——参数校验、DTO ↔ 领域对象转换、调用 Service、包装统一响应。绝不放业务逻辑。
- 应用层(Service):业务编排与事务边界所在。下单要「校验库存 → 创建订单 → 扣减库存 → 记录流水」,这一串编排属于应用层。
- 领域与数据层(Mapper / Repository):MyBatis Mapper 负责持久化,Redis 承担缓存与分布式锁,MySQL 是唯一事实源。
- 基础设施:Actuator 提供可观测性,日志贯穿全链路,Docker Compose 负责本地与部署编排。
同一张图再读一遍,这次换个读法:从上往下逐层问一句「这一层有没有伸手够到别人家的活」——接入层的四个芯片全是协议活儿(鉴权、校验、转换、包装响应),应用层的四个芯片是「谁编排谁」,数据层只有存取没有判断。读完整张图,你会发现没有任何一条线从下面指回上面:

模块边界用包来体现。单模块内按业务分包,每个包内部再分 controller / service / mapper / domain / dto:
com.beeorder├── user # 注册、登录、JWT 颁发与解析├── product # 商品列表与详情(读多写少,走缓存)├── inventory # 库存查询与原子扣减、库存流水├── order # 订单聚合根、状态机、下单/查单/关单├── payment # 支付单生成、回调处理、幂等├── notify # 短信/站内信通知(事件驱动,异步)└── common # 统一响应、异常、JWT 工具、常量、配置边界纪律:跨模块调用只允许「上层调下层」「order 调 inventory」,禁止双向依赖。order 依赖 inventory 和 product 是允许的;inventory 反过来依赖 order 则会造成循环,必须避免。
把这条纪律想成餐厅的传菜通道——前厅(Controller)往后厨(Service)递单、后厨往仓库(Mapper)领料,都是单向的。如果哪天规定「仓库管理员要回头吩咐服务员怎么招待客人」,两家就会互相等对方先开口,这家店当天就开不了张。启动阶段报的循环依赖错误,就是这个「互相等对方先开口」。
这条纪律管住了什么,一格一格点着看——前四格是四层各自的「不许」,最后一格是违反它的账单:
点完最后一格再回头看第七节的接口清单:清单上的每个接口都只会沿着这条链往下走,没有任何一个绕开 Service 直接摸 Mapper。
那到底哪些模块值得从单体里拆出去?别凭感觉,用两个轴量一量:横轴是这个包改动的频率,纵轴是它是否需要独立的扩容能力。

读这张图只用记两条结论:右上角的 common 改动会波及全项目,所以要用测试冻住它;左上角的 inventory 是唯一同时满足「写压力大 + 接口窄」的包,真到需要拆的那天,它是第一个切口。有边界不等于要拆服务——右下角的 order 与 product 天天在变,但一条单向依赖就够了。
什么时候拆微服务? 当「某个模块需要独立扩容(如库存服务写压力远大于其他)、独立上线节奏、或独立数据库」时,才值得拆。本项目单体足够;而且因为我们用包把边界划清了,未来要把 inventory 拆出去,改动面可控——这正是「单体优先,边界清晰」的价值。
接口契约是前后端协作的「合同」。BeeOrder 的接口统一前缀 /api/v1,完整清单如下(约 15 个):
| 方法 | 路径 | 说明 | 权限 |
|---|---|---|---|
| POST | /api/v1/auth/register | 用户注册 | 公开 |
| POST | /api/v1/auth/login | 登录,返回 JWT | 公开 |
| GET | /api/v1/auth/me | 当前登录用户信息 | 登录 |
| GET | /api/v1/products | 商品列表(分页) | 公开 |
| GET | /api/v1/products/{id} | 商品详情 | 公开 |
| GET | /api/v1/inventory/{productId} | 查询商品库存 | 公开 |
| POST | /api/v1/orders | 提交订单(下单) | 登录 |
| GET | /api/v1/orders | 我的订单列表(按状态筛选) | 登录 |
| GET | /api/v1/orders/{id} | 订单详情 | 登录 |
| POST | /api/v1/orders/{id}/cancel | 取消订单 | 登录 |
| POST | /api/v1/payments | 为订单发起支付 | 登录 |
| POST | /api/v1/payments/callback | 支付网关异步回调 | 公开(验签) |
| GET | /api/v1/payments/{orderId} | 查询订单支付记录 | 登录 |
| GET | /api/v1/notifications | 我的通知列表 | 登录 |
| GET | /api/v1/admin/stats/orders | 运营订单统计(按天) | 管理员 |
约定:所有响应统一包装为 { "code": 0, "message": "ok", "data": ... };code != 0 表示业务错误。/payments/callback 虽不校验 JWT,但必须校验网关签名并做幂等——它是唯一对外暴露的写接口,也是攻击面最大的地方。
接口一旦写进清单,第三篇实现时就只能增不能改(改要同步前端的契约)。所以宁可现在多花十分钟把路径、动词、权限定死。
进入编码前,最后一个结构决定:项目是拆成多模块(Maven multi-module),还是单模块内分包? 很多团队一上来就拆 beeorder-user、beeorder-order 等一堆模块,结果改一个接口要动三个 pom,编译一次等半天。
| 方案 | 优点 | 缺点 | 适用 |
|---|---|---|---|
| 多模块 | 编译边界强、可独立打包 | pom 复杂、跨模块重构烦、启动慢 | 团队大、确有独立上线需求 |
| 单模块分包 | 结构简单、IDE 友好、重构快 | 边界靠纪律而非编译强制 | 中小项目、单体阶段 |
BeeOrder 选单模块分包。目录树如下:
beeorder├── pom.xml├── docker-compose.yml├── src/main/java/com/beeorder│ ├── BeeOrderApplication.java│ ├── common│ │ ├── response/ # Result、ErrorCode、全局异常处理│ │ ├── security/ # JwtUtil、JwtFilter、SecurityConfig│ │ └── config/ # MyBatis、Redis、Async 配置│ ├── user/ # controller / service / mapper / domain / dto│ ├── product/│ ├── inventory/│ ├── order/│ ├── payment/│ └── notify/├── src/main/resources│ ├── application.yml│ └── mapper/ # MyBatis XML(复杂 SQL 放这里)└── src/test/java/com/beeorder- 每个业务包内部保持
controller / service / mapper / domain / dto的固定分层,全项目一致 - 复杂 SQL 放
resources/mapper/*.xml,简单查询用注解,兼顾可读与可控 - 测试目录结构与主代码镜像,
order包的单测就放在test/.../order下
第八节的结论加上第六节那张象限图,正好凑成一局配对闯关:左边是每个模块或方案的处境,右边是给你的处置结论,配错了当场解释:
单模块不等于「不讲边界」。我们用包来划分模块,并约定「跨模块只能单向依赖」,把复杂度控制在不牺牲开发效率的范围内。
整个项目拆成 4 个迭代,恰好对应本系列的 4 篇文章,每篇都有明确交付物:
| 迭代 | 对应篇目 | 交付物 |
|---|---|---|
| ① 设计 | 本篇(#43) | 用户故事、领域模型、状态机、接口清单、目录结构 |
| ② 建模与 API | #44 | 建表 DDL、实体与 Mapper、Controller 与 DTO 契约 |
| ③ 核心业务 | #45 | 下单、原子扣库存、支付回调幂等、超时关单(事务与缓存) |
| ④ 交付上线 | #46 | 单元/集成测试、CI/CD、Docker Compose 部署与 Actuator 观测 |

把这张动图当成进度条来用:它走的六帧正好落在上面那张表的四行里——第 1 帧是本篇,第 2 帧是 #44,第 3 帧是 #45,第 4~6 帧全在 #46。读到哪一帧卡住了,就回头对照自己现在站在哪一行。每帧只交付一样东西,前一样不验收就不许进下一帧,这条纪律比任何工具都更能保证四篇写完真能跑起来:

迭代之间的依赖是单向的——③ 的实现严格遵循 ① 定下的接口清单与状态机。这也是为什么这一篇必须把定义「定死」:它是后续三篇的共同事实源。
项目启动时列出风险,比出事后复盘更有价值。BeeOrder 的四个主要风险:
| 风险 | 影响 | 应对 |
|---|---|---|
| 库存超卖 | 卖出不存在的货,资损 | 条件更新 WHERE available >= ? + 事务;预扣与实扣分离 |
| 重复支付 | 同一订单扣两次款 | 回调幂等(唯一键 trade_no)+ 状态机校验 |
| 消息丢失 | 通知/积分丢失,用户投诉 | 本地消息表 + 定时补偿 + 幂等消费(见 #41) |
| 大表查询慢 | 订单列表越来越卡 | user_id + status 联合索引;分页游标;必要时归档历史订单 |
这张风险清单就是飞行前的检查单。机长不是不懂飞,而是不肯相信自己的记性——每一项必须在起飞前用一个动作确认(写下应对方案),而不是「我心里有数」。表里第二行的「重复支付」同理:同一张电影票绝不能出两次,靠的从来不是「我先看一眼有没有出过」,而是检票系统对那个票号只认第一次。这条纪律落到代码里的名字就叫幂等键,#44 第八节会把它写成唯一索引。
设计定稿之前,先用四个实验把「这套架构到底在防什么」亲手跑一遍。实验一:分层调用与依赖方向——它把餐厅那条传菜通道画成真实调用:
实验二:一个请求穿全站。架构图上只有四个盒子,可请求真正走过的路比这长得多——鉴权、校验、事务、扣库存、发消息各有各的位置:
实验三:条件装配。它解决「同一个功能,测试环境和生产环境想不一样」的问题。条件注解(@Conditional 家族——按「类在不在、Bean 在不在、配置项是什么」决定要不要装配的那个开关) 就是自动配置的守门人:
实验四:自定义 Starter,把上面三件事打包。Starter(把一堆依赖 + 一组自动配置预先装好、引入即用)就是把「别人踩过的坑」装进盒子里。BeeOrder 后面要用 spring-boot-starter-web、mybatis-spring-boot-starter 等一堆盒子,值得知道盒子里装了什么:
实验五:把以上结论敲进一台真容器。 前面四张图给的是「看」,这一台给的是「敲」——boot 起容器、beans 点名,再用 lab 把分层、越层、请求旅程、条件判定依次跑成一条流水:
敲完 beans 再回忆第六节那张分层图:容器里每个 Bean 的座位,都由它所在的包与依赖方向决定——这比背目录树直观得多。
这一篇没有写一行业务代码,却决定了后面三篇的成败。核心是三件事:把「用户能下单」展开成带验收标准的用户故事,把订单状态机与领域规则白纸黑字定死,把模块边界与技术选型讲清取舍。BeeOrder 的边界、状态机、接口清单与目录结构已经冻结,下一篇我们就照着这份「合同」建表、写 Mapper、定 Controller 契约。地基打牢了,往上盖楼才快。
架构决定不了一天的工作量,但能决定改一个需求要动几个文件。下面这个沙盘把第六节和第八节的两个选择合成一个三档开关:切一档,同屏看「启动能不能起来、加字段要改几处、跨模块调用要不要走网络」三个结果。
新增一个接收人字段:改 4 个文件(实体 / DTO / VO / 一条 ALTER TABLE)mvn test 全量跑完:38s启动耗时:2.4s# 包边界靠纪律维持——IDEA 里点错一下就能 import 到隔壁包风险:有人图省事让 inventory 反向 import order,编译器不会拦
三档里唯一「不可逆」的是第三档——从单体拆成微服务能做,但从微服务合回单体几乎没人真做。所以顺序永远是「先单体,边界画清楚,撑不住了再按现成的缝拆」。
第一道是热身题,考第五节那张选型表的读法:
第二道是综合题,把第六节、第七节和第十节串起来:
设计稿定得再漂亮,第一天写代码照样会撞墙。下面每一行「报错原文」都可以整段复制去搜索,别意译也别缩写。
| 报错原文(片段) | 真实原因 | 30 秒自救 | 深挖看第几篇 |
|---|---|---|---|
Field orderMapper in com.beeorder.order.service.OrderService required a bean of type 'com.beeorder.order.mapper.OrderMapper' that could not be found | 包放错了位置:@SpringBootApplication 所在的 BeeOrderApplication 只扫描自己所在包及其子包。你把 Mapper 放到 com.beeorder.dao(跟 com.beeorder.order 平级)或干脆漏在启动类之外,容器压根没看见它 | 先看启动类的包名是不是所有业务包的共同父包;确实在外面就用 @MapperScan("com.beeorder.**.mapper") 显式点名,别用 @ComponentScan 大范围乱扫 | 本篇第六节 · #16 Hello Spring Boot |
APPLICATION FAILED TO START + The dependencies of some of the beans in the application context form a cycle + orderService ==> inventoryService ==> orderService | 跨层或跨模块的反向调用形成了环。最常见的触发点就是第十二节那道题:低层的 inventory 反过来 import 了高层的 order。注意 Spring Boot 2.6 起 spring.main.allow-circular-references 默认为 false,所以构造器注入和字段注入都会在这里停下来;把它设成 true 之后,只有字段注入那一侧能被三级缓存哄起来 | 顺着日志画的环只拆一条边:把两边共用的那段逻辑抽进 common 或新开一个包,让箭头重新单向。别用那个开关当解药——它只是把架构债藏到运行期 | 本篇第六节 · #10 循环依赖 |
org.springframework.dao.DuplicateKeyException: Duplicate entry '10015-9f2c1e0a' for key 'order.uk_order_idem' | 数据库的唯一索引把重复提交挡下了——这不是 Bug,是第十节风险清单里防重复下单的那道防线真的生效了 | 别让它冒到用户面前:在 Service 捕获后查出首次结果原样返回(幂等),而不是抛 500。写法见 #44 第八节 | #44 幂等设计 · #31 事务 internals |
对已支付订单调 cancelOrder() 返回 code=0,钱退了状态却还是 PAID | @Transactional 放错了层:它标在 Service 的私有方法上、或被同类里 this.xxx() 自调用;又或者你把「退款 + 改状态」这两步直接写进了 Controller,各调一次 Mapper。事务靠代理包装,这几种写法代理都插不进来,于是几步更新各自提交、互不回滚 | 把 @Transactional 挪到应用层 Service 的 public 编排方法上,并确保它是从外面被调进来的(第十节实验一里「事务边界落在哪」那一档正是这个现场) | #31 事务 internals · #12 动态代理 |
WARN ... : Cannot enhance @Configuration bean definition 'xxxConfig' since its singleton instance has been created too early 或某个 @Bean 意外取不到 | 自动配置与你自己的配置抢顺序:条件装配的判定发生在 Bean 定义注册阶段,用 @ConditionalOnBean 去匹配一个普通 @Component 时,很可能「判定那一刻它还不存在」 | 普通 Bean 的装配条件改用 @ConditionalOnProperty 或 @ConditionalOnMissingBean;@ConditionalOnBean 只用在自动配置类之间。用 --debug 打开条件评估报告核对结论 | #19 条件装配 · #18 自动配置 |
No qualifying bean of type 'com.beeorder.common.config.RedisProperties' available (自定义 starter 引了却没生效) | starter 的坑三连:① 自动配置类没登记进 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports;② 配置属性类少了 @EnableConfigurationProperties 或 setter;③ application.yml 里的前缀拼错,绑定不上就等于没配 | 按「清单 → 属性绑定 → Bean 生成 → 关掉它」四步逐个验证,第十节实验四把这四步做成了可切换的开关 | #20 自定义 Starter · #21 配置与 Profile |
Error starting ApplicationContext. ... Consider defining a bean of type 'org.springframework.jdbc.core.JdbcTemplate' in your configuration | 只加了 mysql-connector-j 驱动却没引入数据源 starter,自动配置的条件「类不在」直接跳过,连池子都不会建 | 补 spring-boot-starter-jdbc(或 MyBatis 的 starter),再用 logging.level.org.springframework=DEBUG 看装配报告里它到底被跳过了没有 | #27 JDBC 演进 · #18 自动配置 |
这七条里有五条的共同点是「容器压根没扫到那个东西」。新手看到 required a bean ... could not be found 的第一反应常常是去加注解,正确反应应该是先回答三个问题:它在启动类的子包里吗?它的 starter 引了吗?自动配置的条件判定成立吗?
表格第二行那种环,读文字和看原件是两回事。下面就是当时的原始报错,每一段都能点,点错也会告诉你为什么不是它:
第十二节那道题如果真动手做过,启动日志的结尾就是这一段。第一次读很容易盯着最后那句话,而真正的信息在更上面。
复盘只需一句:这条报错里真正要改的地方,就是 inventory 包里那一行反向 import;其余每一帧都在告诉你「为什么不是别的东西」。
三档难度,第一档照做就能跑通,第三档做完你就有了自己的架构评审清单。
目标:亲手搭出 BeeOrder 的工程骨架,并且故意制造一次包位置错误,亲眼看到那条 could not be found。
第一步,新建 pom.xml(Spring Boot 3.2.5 + Web + JDBC,MySQL 先不接也能启动):
<?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.5</version> <relativePath/> </parent> <groupId>com.beeorder</groupId> <artifactId>beeorder</artifactId> <version>0.0.1-SNAPSHOT</version> <name>beeorder</name> <properties> <java.version>17</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build></project>这份 pom 不用逐字抄,勾一遍就有。默认勾的三项(Web / Validation / JDBC)正是第一档要跑通的最小骨架;再把后面三篇才会真正吃进来的依赖也看全——先知道总数,再决定哪些现在就引:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.4</version> <!-- 版本由 BOM 统管,子依赖不写 version -->
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>demo-service</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>17</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>第二步,按第八节的目录建包,写启动类和两个最小类。注意启动类必须放在 com.beeorder,也就是所有业务包的共同父包:
package com.beeorder;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;@SpringBootApplicationpublic class BeeOrderApplication { public static void main(String[] args) { SpringApplication.run(BeeOrderApplication.class, args); }}package com.beeorder.order.mapper;import org.springframework.stereotype.Repository;@Repositorypublic class OrderMapper { public String ping() { return "mapper-ok"; }}package com.beeorder.order.controller;import com.beeorder.order.mapper.OrderMapper;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;@RestController@RequestMapping("/api/v1/ping")public class PingController { private final OrderMapper orderMapper; public PingController(OrderMapper orderMapper) { // 构造器注入:出生即完整 this.orderMapper = orderMapper; } @GetMapping public String ping() { return orderMapper.ping(); }}第三步,启动并访问:
mvn spring-boot:runcurl http://localhost:8080/api/v1/ping预期响应(正文就一行,没有 JSON 信封——信封是下一篇的活):
mapper-ok第四步,制造一次「扫不到 Bean」。把 OrderMapper.java 改成 MyBatis 那种接口 + @Mapper 的写法(真实项目里就是这么写的),并把包声明从 com.beeorder.order.mapper 挪到 com.beeorder.dao——注意它仍然在启动类 com.beeorder 的子树里,所以「包在外头」这个借口这次不成立:
package com.beeorder.dao;import org.apache.ibatis.annotations.Mapper;@Mapperpublic interface OrderMapper { String ping();}重启,预期控制台直接停在这里:
***************************APPLICATION FAILED TO START***************************Description:Field orderMapper in com.beeorder.order.controller.PingController required a bean of type 'com.beeorder.dao.OrderMapper' that could not be found.Action:Consider defining a bean of type 'com.beeorder.dao.OrderMapper' in your configuration.真正的原因有两层,一层比一层隐蔽:① 接口没有实现类,容器没法反射把它 new 出来——Mapper 接口的实例是靠 MyBatis 的动态代理现造的,不是靠组件扫描;② @Mapper 这个注解本身不带 @Component 语义,所以哪怕包在子树里也一样扫不到。少了 mybatis-spring-boot-starter(或没告诉它去哪找接口),这条链路就断在装配阶段。
第五步,把它救活:在启动类上加一行 @MapperScan("com.beeorder.dao"),同时确保 pom.xml 里有 MyBatis 的 starter,重启后再 curl 一次,仍然返回 mapper-ok。这一步的意义在于你会永久记住:「包能不能被扫到」和「这类东西归谁装配」是两个问题——普通类靠组件扫描,Mapper 接口靠 @MapperScan,第三方组件靠 starter 的自动配置。
验收清单:① 说得出第四行为什么失败、第五行为什么成功;② 能指出这条报错里真正决定成败的那个词(scan);③ 把 PingController 的构造器注入改成字段 @Autowired,其余不动,重启——仍然启动失败,因为「有没有这个 Bean」发生在装配阶段,跟用哪种注入方式无关。能讲清这一点,你就分清了「装配缺失」和「装配时序」两类问题。
每条只改一个地方,观察结论完全不同:
- 造一个跨层反向依赖:给
OrderMapper加private final OrderService orderService;(构造器注入),同时OrderService构造器注入OrderMapper。你会观察到:启动直接失败,日志里画出orderService ==> orderMapper ==> orderService的环。再把OrderMapper那侧改成字段注入,Boot 3 默认仍然报错——因为 Spring Boot 2.6 起spring.main.allow-circular-references默认为false;只有显式把它设成true,字段注入那一侧才会靠三级缓存被哄起来,程序能跑,但模块边界已经塌了。 - 把
@Transactional从OrderService的 public 编排方法上挪走,改成标在同类另一个私有方法上,并在编排方法里用this.helper()自调用它。你会观察到:两种写法既不报错也不回滚——事务靠代理包装,而代理只能拦到「从外面调进来的公开方法」。再把注解放回 public 方法、由 Controller 通过注入的 Bean 调用,第二次写入失败时第一次会被撤销。对照第十节实验一的「事务边界落在哪」那一档,两边结论应当完全一致。(注意别指望getClass()打印出 CGLIB 类名来验证:只有真的命中切面的 Bean 才会被代理,控制器通常不在其中。) - 把
spring-boot-starter-jdbc从pom.xml删掉。你会观察到:如果代码里没有注入JdbcTemplate,应用照常启动(少引一个 starter 只是少一批能力,不一定报错);一旦注入了JdbcTemplate,启动就报No qualifying bean。对照--debug输出的条件评估报告,找到DataSourceAutoConfiguration那一行的matched: false。
提示:做完第 1 条回头翻第十三节表格的第二行,两边应当完全对得上。
给自己写一份架构体检脚本,以后接手任何项目先跑一遍。
需求:
- 一个
LayerRulesTest(纯 JUnit + 反射,不许修改任何业务类),扫描src/main/java下所有.java文件的package与import声明 - 校验三条规则:①
controller包不许 importmapper包;② 任何包不许 importorder包里再 import 回来的类(检测长度为 2 的依赖环);③common包不许 import 任何业务包 - 违规时输出一张表:文件、行号、违规 import、违反了哪条规则、建议怎么拆
- 附带一份
ARCHITECTURE.md模板,把第六节的分层图、第七节的接口清单和这张规则表钉在一起
验收清单:① 在当前骨架上跑,规则 ① 立刻抓到 PingController 直接 import OrderMapper 这条违规;② 按建议插入一个 OrderService 后测试转绿;③ 人为制造一个 inventory → order → inventory 的环,脚本能在测试阶段报出而不必等到应用启动;④ 全流程不需要修改业务代码一行。
不看上文,把 BeeOrder 的四层按「离用户由近到远」排一遍,并说出每层绝对不许干的一件事。
「用户能下单」这句话缺了什么才算不上需求?把它补成一张带验收标准的用户故事卡,至少两条标准能直接变成测试用例。
order 调 inventory 可以,inventory 调 order 不行——请用餐厅类比讲一遍理由,再说出不合规时最先炸裂的阶段(编译 / 启动 / 运行)。
为什么持久层选型要看「本项目最吃力的两条 SQL」而不是「哪个框架更省力」?把 BeeOrder 那两条 SQL 念出来。
什么信号出现时,才值得把 inventory 从单体里拆出去?拆之前必须已经具备的前提是什么?
需求要能测,状态要走机,箭头只朝下,边界靠包守,选型看最难的活,拆分等真撑不住。