实战①:需求分析与整体架构设计

bee2026-10-0866 分钟0 次阅读
BeeOrder 订单中心开篇:把模糊需求变成用户故事与验收标准,定领域模型、模块边界、技术选型与工程结构——写代码前把地基打牢。
1 / 157
小节
〇、30 秒看懂
2 / 157

这一篇不写一行业务代码,但它决定后面三篇是「往上盖楼」还是「往下填坑」。我们要做的事情只有一件:把一个模糊的愿望——「用户能下单」——变成一份谁都不用猜的合同:合同里写清楚谁提什么要求(用户故事与验收标准)、世界里有哪些东西和规矩(领域模型与状态机)、各部分站在哪(分层与模块边界)、以及对外说些什么话(接口清单)。

3 / 157

先把这篇反复出现的六个词一句话解释掉:

4 / 157
  • 需求(requirement):对方嘴里那句「我要一个下单功能」——它不是需求,它是愿望;能写出测试用例的那一句才是需求
  • 用户故事(user story):把愿望写成「作为〈谁〉,我想要〈干什么〉,以便〈得到什么好处〉」的一句话,后面必须跟一串可验证的验收标准
  • 领域模型(domain model):这门生意里真实存在的名词和规矩——订单、库存、支付,以及「库存不能为负」这类铁律,跟数据库表还没有关系
  • 聚合根(aggregate root):一组必须一起变动的对象里那个「唯一入口」。改订单项必须通过订单来改,就像改发票明细得重新开一张发票
  • 分层(layering):按「谁离用户近」把代码排成一列纵队:Controller 在最前,Service 居中,Mapper 最后,依赖只能从前指向后
  • 契约(contract):前后端共同签字的一份说明书,规定请求长什么样、响应长什么样、出错给什么码。签了字就不许单方面改
5 / 157
类比

一家餐厅。 服务员(Controller)只负责记单、报菜名、上菜,绝不能冲进灶台炒菜;厨师长(Service)决定「这道菜先焯水再爆炒、和凉菜同一时间出盘」,也就是业务编排与事务边界;仓库管理员(Mapper)只管按单据取货放货,自己从不决定该发多少货(业务规则);而水电煤、监控摄像头、打卡机(基础设施 Actuator 与日志)不进菜单,但没它们整店当天就得关门。点菜单从服务员传到厨师长时会被改写:顾客说的「来份宫保鸡丁不要辣」变成厨房术语「宫保 ×1/免辣/加饭」——这就是 DTO(顾客话)与实体/VO(后厨话)的区别,#44 会把它落成代码。

6 / 157
类比

订单快照就是「用过的点菜单」。 这家店今天把宫保鸡丁从 38 元涨到 45 元、名字改成「蜂巢秘制鸡丁」,但你三个月前那张单子上的 38 元和老名字必须原样保留,否则月底对账时你这张桌子的钱怎么算都对不上。这条规矩直接决定了下一篇为什么要在 order_item 里冗余存商品名和单价——它抄的是菜单,留的是证据。

7 / 157
架构图
图 · BeeOrder 分层与模块地图
图 · BeeOrder 分层与模块地图
8 / 157

上面这张地图就是本篇的目录:第一条分支是「调用方向」(第六节),后面五条分支是六个业务包各自负责什么(第八节),最后一条 common 是全项目共用的三样小工具。看的时候盯住一件事:图上没有任何一条箭头是从右往左指回去的——这条纪律比任何框架知识都重要,违反它的代价在第十节的风险清单和第十三节的报错速查表里都会现场复现。

9 / 157

学完这一篇,你应该能回答三个问题:

10 / 157
  1. 「下单要快」和「P99 响应 < 500ms」这两句话,为什么只有第二句能进需求文档?判断标准是什么?
  2. order 可以调 inventory,反过来为什么不行?一旦反了,最先出问题的是编译期、启动期还是运行期?第十三节的报错表会给你答案。
  3. 单体项目和微服务项目,真正的分界线画在哪里——是「拆成几个 jar」,还是「谁能独立扩容、独立上线」?
11 / 157
小节
一、项目声明:BeeOrder · 蜂巢订单中心
12 / 157

前 40 篇我们把 Spring 的零件一件件拆开讲完了——IoC、AOP、Web、数据访问、事务、缓存、安全、异步与事件、监控。但知识点是散的,真实开发却要求你把它们装进同一个工程里协同运转。从这一篇开始,我们用 4 篇文章完整交付一个可上线的项目:BeeOrder · 蜂巢订单中心。

13 / 157

先亮明这个项目的边界。它是一个电商订单中台单体应用,把「一个用户从注册到收到商品」的主链路跑通:

14 / 157
对照表
项目项定义
项目名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
15 / 157

同样重要的是不做清单——明确不做什么,比列功能更能防止范围失控:

16 / 157
  • 不做前端:所有交付物是 REST API,用 curl / Postman 即可验证(本教程第 46 篇会给出用 Docker 起前端的可选演示,但非必需)。
  • 不做真实支付:接入真实支付需要商户资质与密钥,我们用「模拟支付网关」+ 回调接口完整还原支付流程的状态与幂等。
  • 不做微服务拆分:先做单体,把边界划清;文末会讲「何时才该拆」,但本项目不拆。
  • 不做大数据与推荐:运营统计只做到「按天汇总订单量与金额」这一层。
17 / 157
说明

范围写得越清楚,后面 3 篇越不会跑偏。这份声明就是后续所有代码的「合同」。

18 / 157
小节
二、需求分析实战:把一句话需求展开成用户故事
19 / 157

最初的需求往往只有一句话:「用户能下单。」但这一句话要写多少代码、怎么算完成,没人说得清。需求分析做的就是把模糊的期望变成可验收的用户故事。

20 / 157

用户故事的标准格式是「作为〈角色〉,我想要〈能力〉,以便〈价值〉」,并附上验收标准(Acceptance Criteria)。BeeOrder 的核心故事卡如下:

21 / 157
对照表
角色用户故事验收标准(可测试)
访客注册并登录,以便下单手机号唯一;密码 BCrypt 存储;登录返回 JWT,有效期 2 小时
用户浏览商品列表与详情,以便挑选支持分页;下架商品不出现在列表
用户提交订单,以便购买商品库存充足才成功;成功后库存扣减、订单状态为 CREATED
用户支付订单,以便完成购买支付成功回调后订单变 PAID;重复回调不重复扣款
用户查询自己的订单,以便查看进度只能查到自己的订单;支持按状态筛选
系统超时未支付自动关单,以便释放库存创建后 30 分钟未支付,订单变 CLOSED,库存回补
运营查看订单统计,以便掌握经营按天返回订单量、成交金额
22 / 157

注意最后两行——它们不是「用户点击」触发的,而是系统自身的行为。真实项目里这类需求最容易被漏掉,却直接决定库存能否正确释放。

23 / 157
提示

验收标准必须「可测试」。写「下单要快」是无效需求,写「P99 响应时间 < 500ms」才能变成一条压测用例。

24 / 157

上面那张表是成品的模样;下面这段动画把它倒放一遍——看一句模糊的愿望,是怎么被三个追问逼成一张可验收的故事卡的:

25 / 157
原理动画
动图 · 一句话需求如何长成故事卡
动图 · 一句话需求如何长成故事卡
26 / 157

三个追问里最容易跳过的是「什么场景」,而它恰好是「30 分钟未支付自动关单」这类系统行为的来源。写不出验收标准的故事,说明追问还没问完。

27 / 157
小节
三、领域模型:实体、聚合与业务规则
28 / 157

需求梳理清楚后,先把世界里的「名词」找出来。BeeOrder 的核心领域对象有 7 个:

29 / 157
对照表
实体关键字段说明
用户 Userid, phone, password, nickname登录主体
商品 Productid, name, price, status, stock_snapshot可售单元,价格以「分」存储
库存 Inventoryproduct_id, available, locked可售数量与锁定数量
订单 Orderid, user_id, total_amount, status聚合根,承载订单项
订单项 OrderItemid, order_id, product_id, price, quantity下单时的价格快照
支付记录 Paymentid, order_id, trade_no, amount, status一次支付尝试
库存流水 InventoryLogid, product_id, change, type, ref_id每次库存变动的审计记录
30 / 157

它们的关系可以这样理解:一个用户有多个订单;一个订单包含多个订单项;每个订单项指向一个商品;每个商品对应一条库存记录;一次支付对应一个订单。 订单是聚合根,改动订单项必须通过订单进行。

31 / 157

关键业务规则必须白纸黑字写下来,因为它们直接对应代码里的分支与约束:

32 / 157
  • 订单总价 = Σ(商品单价 × 数量),且使用 DECIMAL / 分为单位,禁止用 double。
  • 库存不能为负:扣减必须在数据库层面用条件更新(WHERE available >= ?)兜底。
  • 订单状态只能按状态机流转,不允许「从 CREATED 直接跳到 COMPLETED」这种越级。
  • 同一订单的有效支付只有一次:重复回调必须幂等。
  • 金额、库存的每一次变动都要留流水,便于对账。
33 / 157
要点

领域模型不是「数据库表的复制」。先想清业务规则和聚合边界,表结构是它的落地结果——顺序别反了。

34 / 157
小节
四、订单状态机:整个项目的心脏(重点)
35 / 157

订单状态是这个项目里最核心的抽象。一旦状态定义乱了、流转约束松了,就会出现「已取消的订单又收到支付成功」这类脏数据。BeeOrder 的状态集合定为六个:

36 / 157

CREATED(已创建)→ PAID(已支付)→ SHIPPED(已发货)→ COMPLETED(已完成),另有 CLOSED(已关闭) 与 REFUNDED(已退款) 两个终态分支。完整流转如下:

37 / 157
对照表
当前状态触发事件目标状态副作用
CREATED支付成功回调PAID记录支付流水、锁定库存转扣减、发通知
CREATED超时 30 分钟 / 用户取消CLOSED释放锁定库存、发通知
PAID运营发货SHIPPED记录发货时间、发通知
SHIPPED用户确认收货COMPLETED订单终态、可触发积分(通知模块)
PAID用户申请退款并成功REFUNDED记录退款流水、库存可选回补
COMPLETED————终态,不再流转
CLOSED / REFUNDED————终态,不再流转
38 / 157

这张表就是状态机的全部法律条文,但条文读三遍,不如图看一眼。把它画成一张可以贴在显示器边上的图:

39 / 157
架构图
图 · 订单状态机全景
图 · 订单状态机全景
40 / 157

读图的口诀是「一条主链路、两个终态、三条铁律」:主链路 CREATED → PAID → SHIPPED → COMPLETED 只许向右;CLOSED 与 REFUNDED 各自从指定入口进入后不再流出;三条铁律(状态与事件匹配、目标在允许集合内、副作用同事务或可靠事件)会在下一篇被逐条写成校验代码。

41 / 157

任何一次状态变更都必须满足三个条件:当前状态与事件匹配、目标状态在允许集合内、副作用在同一事务或可靠事件中执行。把这张表变成代码里的一个枚举 + 校验方法,是下一篇要做的事。

42 / 157
坑

状态机最怕「用字符串硬编码状态」。order.setStatus("paid") 这类写法,拼错一个字母编译器毫无反应,跑到线上才发现状态对不上。请用枚举 OrderStatus.PAID,让错误在编译期暴露。

43 / 157
小节
五、技术选型决策:每个选择都要说清「为什么不选另一个」
44 / 157

技术选型不是堆砌名词,而是做取舍。BeeOrder 的选型表:

45 / 157
对照表
领域选择备选选它的理由(对比备选)
框架Spring Boot 3.2Spring MVC 5 / Quarkus生态与教程最完整;3.2 原生支持虚拟线程,JDK 17 起步
数据库MySQL 8PostgreSQL团队熟悉度最高、运维资料最多;InnoDB 行锁满足库存扣减
缓存Redis 7本地 Caffeine需要多实例共享的热点缓存与分布式锁,本地缓存做不到
持久层MyBatisSpring Data JPA库存扣减、报表统计要写精确 SQL,MyBatis 对 SQL 完全可控
安全Spring Security + JWTSession + Redis无状态、天然适配多实例与前后端分离
监控Actuator + Prometheus自研埋点开箱即用、标准格式,无需重复造轮子
部署Docker ComposeK8s单体项目用 Compose 足够;K8s 的复杂度对单体是负担
46 / 157

每一行都值得追问一句「为什么不用另一个」。例如为什么用 MyBatis 而不是 JPA:本项目最关键的两条 SQL——UPDATE inventory SET available = available - ? WHERE product_id = ? AND available >= ?(原子扣减)和按天聚合的报表查询——都是「精确控制 SQL」的场景,JPA 的自动生成反而碍事。

47 / 157
决策
决策BeeOrder 的持久层,你选 MyBatis 还是 Spring Data JPA?
48 / 157
小节
六、架构分层与模块边界
49 / 157

BeeOrder 采用单体分层架构,自上而下四层,请求的流向是单向的:

50 / 157
架构图
图 1 · BeeOrder 整体架构
图 1 · BeeOrder 整体架构
51 / 157
  • 接入层(Controller):只做协议转换——参数校验、DTO ↔ 领域对象转换、调用 Service、包装统一响应。绝不放业务逻辑。
  • 应用层(Service):业务编排与事务边界所在。下单要「校验库存 → 创建订单 → 扣减库存 → 记录流水」,这一串编排属于应用层。
  • 领域与数据层(Mapper / Repository):MyBatis Mapper 负责持久化,Redis 承担缓存与分布式锁,MySQL 是唯一事实源。
  • 基础设施:Actuator 提供可观测性,日志贯穿全链路,Docker Compose 负责本地与部署编排。
52 / 157

同一张图再读一遍,这次换个读法:从上往下逐层问一句「这一层有没有伸手够到别人家的活」——接入层的四个芯片全是协议活儿(鉴权、校验、转换、包装响应),应用层的四个芯片是「谁编排谁」,数据层只有存取没有判断。读完整张图,你会发现没有任何一条线从下面指回上面:

53 / 157
架构图
图 · 依赖方向自上而下的架构图
图 · 依赖方向自上而下的架构图
54 / 157

模块边界用包来体现。单模块内按业务分包,每个包内部再分 controller / service / mapper / domain / dto:

55 / 157
text
com.beeorder├── user        # 注册、登录、JWT 颁发与解析├── product     # 商品列表与详情(读多写少,走缓存)├── inventory   # 库存查询与原子扣减、库存流水├── order       # 订单聚合根、状态机、下单/查单/关单├── payment     # 支付单生成、回调处理、幂等├── notify      # 短信/站内信通知(事件驱动,异步)└── common      # 统一响应、异常、JWT 工具、常量、配置
56 / 157

边界纪律:跨模块调用只允许「上层调下层」「order 调 inventory」,禁止双向依赖。order 依赖 inventory 和 product 是允许的;inventory 反过来依赖 order 则会造成循环,必须避免。

57 / 157
类比

把这条纪律想成餐厅的传菜通道——前厅(Controller)往后厨(Service)递单、后厨往仓库(Mapper)领料,都是单向的。如果哪天规定「仓库管理员要回头吩咐服务员怎么招待客人」,两家就会互相等对方先开口,这家店当天就开不了张。启动阶段报的循环依赖错误,就是这个「互相等对方先开口」。

58 / 157

这条纪律管住了什么,一格一格点着看——前四格是四层各自的「不许」,最后一格是违反它的账单:

59 / 157
交互图解
流程四层站位与那条不许逆行的箭头1 / 5
从左到右逐格点:前四格是各层的「不许」,最后一格是违反它的代价
→
→
→
→
① Controller · 接单台
只做协议转换:参数校验、DTO 与领域对象的互相转换、包装统一响应。它桌上不许出现业务编排——出现一次,改接口就要连业务一起动。
全部看懂了四层的「不许」加上一条只许向下的箭头;违规的账单,永远在你按下启动键之后才寄到。
60 / 157

点完最后一格再回头看第七节的接口清单:清单上的每个接口都只会沿着这条链往下走,没有任何一个绕开 Service 直接摸 Mapper。

61 / 157

那到底哪些模块值得从单体里拆出去?别凭感觉,用两个轴量一量:横轴是这个包改动的频率,纵轴是它是否需要独立的扩容能力。

62 / 157
架构图
图 · 模块该按什么拆
图 · 模块该按什么拆
63 / 157

读这张图只用记两条结论:右上角的 common 改动会波及全项目,所以要用测试冻住它;左上角的 inventory 是唯一同时满足「写压力大 + 接口窄」的包,真到需要拆的那天,它是第一个切口。有边界不等于要拆服务——右下角的 order 与 product 天天在变,但一条单向依赖就够了。

64 / 157
说明

什么时候拆微服务? 当「某个模块需要独立扩容(如库存服务写压力远大于其他)、独立上线节奏、或独立数据库」时,才值得拆。本项目单体足够;而且因为我们用包把边界划清了,未来要把 inventory 拆出去,改动面可控——这正是「单体优先,边界清晰」的价值。

65 / 157
小节
七、接口清单概览
66 / 157

接口契约是前后端协作的「合同」。BeeOrder 的接口统一前缀 /api/v1,完整清单如下(约 15 个):

67 / 157
对照表
方法路径说明权限
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运营订单统计(按天)管理员
68 / 157

约定:所有响应统一包装为 { "code": 0, "message": "ok", "data": ... };code != 0 表示业务错误。/payments/callback 虽不校验 JWT,但必须校验网关签名并做幂等——它是唯一对外暴露的写接口,也是攻击面最大的地方。

69 / 157
提示

接口一旦写进清单,第三篇实现时就只能增不能改(改要同步前端的契约)。所以宁可现在多花十分钟把路径、动词、权限定死。

70 / 157
小节
八、工程结构:单模块还是多模块?
71 / 157

进入编码前,最后一个结构决定:项目是拆成多模块(Maven multi-module),还是单模块内分包? 很多团队一上来就拆 beeorder-user、beeorder-order 等一堆模块,结果改一个接口要动三个 pom,编译一次等半天。

72 / 157
对照表
方案优点缺点适用
多模块编译边界强、可独立打包pom 复杂、跨模块重构烦、启动慢团队大、确有独立上线需求
单模块分包结构简单、IDE 友好、重构快边界靠纪律而非编译强制中小项目、单体阶段
73 / 157

BeeOrder 选单模块分包。目录树如下:

74 / 157
代码对照
代码text
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 下
75 / 157

第八节的结论加上第六节那张象限图,正好凑成一局配对闯关:左边是每个模块或方案的处境,右边是给你的处置结论,配错了当场解释:

76 / 157
配对闯关
闯关模块怎么处置:处境配决策已配对 0/6 · 配错 0
左边是每个模块/方案的处境,右边是第六、八节给出的处置结论。配错了当场解释。
先点左边一个
77 / 157
要点

单模块不等于「不讲边界」。我们用包来划分模块,并约定「跨模块只能单向依赖」,把复杂度控制在不牺牲开发效率的范围内。

78 / 157
小节
九、迭代计划:四篇交付物对应关系
79 / 157

整个项目拆成 4 个迭代,恰好对应本系列的 4 篇文章,每篇都有明确交付物:

80 / 157
对照表
迭代对应篇目交付物
① 设计本篇(#43)用户故事、领域模型、状态机、接口清单、目录结构
② 建模与 API#44建表 DDL、实体与 Mapper、Controller 与 DTO 契约
③ 核心业务#45下单、原子扣库存、支付回调幂等、超时关单(事务与缓存)
④ 交付上线#46单元/集成测试、CI/CD、Docker Compose 部署与 Actuator 观测
81 / 157
原理动画
动图 · 四步走向上线
动图 · 四步走向上线
82 / 157

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

83 / 157
原理动画
动图 · 交付计划与本篇位置
动图 · 交付计划与本篇位置
84 / 157
说明

迭代之间的依赖是单向的——③ 的实现严格遵循 ① 定下的接口清单与状态机。这也是为什么这一篇必须把定义「定死」:它是后续三篇的共同事实源。

85 / 157
小节
十、风险清单:提前把坑写下来
86 / 157

项目启动时列出风险,比出事后复盘更有价值。BeeOrder 的四个主要风险:

87 / 157
对照表
风险影响应对
库存超卖卖出不存在的货,资损条件更新 WHERE available >= ? + 事务;预扣与实扣分离
重复支付同一订单扣两次款回调幂等(唯一键 trade_no)+ 状态机校验
消息丢失通知/积分丢失,用户投诉本地消息表 + 定时补偿 + 幂等消费(见 #41)
大表查询慢订单列表越来越卡user_id + status 联合索引;分页游标;必要时归档历史订单
88 / 157
类比

这张风险清单就是飞行前的检查单。机长不是不懂飞,而是不肯相信自己的记性——每一项必须在起飞前用一个动作确认(写下应对方案),而不是「我心里有数」。表里第二行的「重复支付」同理:同一张电影票绝不能出两次,靠的从来不是「我先看一眼有没有出过」,而是检票系统对那个票号只认第一次。这条纪律落到代码里的名字就叫幂等键,#44 第八节会把它写成唯一索引。

89 / 157
内核实验
90 / 157

设计定稿之前,先用四个实验把「这套架构到底在防什么」亲手跑一遍。实验一:分层调用与依赖方向——它把餐厅那条传菜通道画成真实调用:

91 / 157
内核实验
TeaVM一次下单自上而下穿过四层未启动
默认参数「一次下单自上而下」:盯住箭头只往下走;再切「越层调用代价」,看 Controller 直接调 Mapper 之后,同样的校验逻辑要在几个地方各写一遍
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
92 / 157

实验二:一个请求穿全站。架构图上只有四个盒子,可请求真正走过的路比这长得多——鉴权、校验、事务、扣库存、发消息各有各的位置:

93 / 157
内核实验
TeaVM一个下单请求的完整旅程未启动
先跑「成功链路」数一共几站;再切「参数校验失败」和「业务异常」,看请求分别死在哪一站、谁负责把它翻译成给用户看的错误码
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
94 / 157

实验三:条件装配。它解决「同一个功能,测试环境和生产环境想不一样」的问题。条件注解(@Conditional 家族——按「类在不在、Bean 在不在、配置项是什么」决定要不要装配的那个开关) 就是自动配置的守门人:

95 / 157
内核实验
TeaVM模拟支付网关怎么被「按需」装配未启动
选「@ConditionalOnBean」看两个 Bean 的装配顺序如何决定成败;再切「条件评估报告」,读一份真实的自动配置判定清单
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
96 / 157

实验四:自定义 Starter,把上面三件事打包。Starter(把一堆依赖 + 一组自动配置预先装好、引入即用)就是把「别人踩过的坑」装进盒子里。BeeOrder 后面要用 spring-boot-starter-web、mybatis-spring-boot-starter 等一堆盒子,值得知道盒子里装了什么:

97 / 157
内核实验
TeaVM一个自定义 Starter 的装配全过程未启动
依次切「自动配置清单 → 配置属性绑定 → Bean 生成 → 关掉它」,看清 beeorder-pay-starter 从引入 pom 到真正生效的四步,以及一行配置怎么把它整体关掉
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
98 / 157

实验五:把以上结论敲进一台真容器。 前面四张图给的是「看」,这一台给的是「敲」——boot 起容器、beans 点名,再用 lab 把分层、越层、请求旅程、条件判定依次跑成一条流水:

99 / 157
内核控制台
100 / 157

敲完 beans 再回忆第六节那张分层图:容器里每个 Bean 的座位,都由它所在的包与依赖方向决定——这比背目录树直观得多。

101 / 157
决策
决策BeeOrder 起步就拆微服务,还是先做单体?
102 / 157
总结

这一篇没有写一行业务代码,却决定了后面三篇的成败。核心是三件事:把「用户能下单」展开成带验收标准的用户故事,把订单状态机与领域规则白纸黑字定死,把模块边界与技术选型讲清取舍。BeeOrder 的边界、状态机、接口清单与目录结构已经冻结,下一篇我们就照着这份「合同」建表、写 Mapper、定 Controller 契约。地基打牢了,往上盖楼才快。

103 / 157
小节
十一、沙盘:同一个项目,三种切法
104 / 157

架构决定不了一天的工作量,但能决定改一个需求要动几个文件。下面这个沙盘把第六节和第八节的两个选择合成一个三档开关:切一档,同屏看「启动能不能起来、加字段要改几处、跨模块调用要不要走网络」三个结果。

105 / 157
沙盘
沙盘单模块分包 / 多模块 Maven / 微服务
运行结果
新增一个接收人字段:改 4 个文件(实体 / DTO / VO / 一条 ALTER TABLE)
mvn test 全量跑完:38s
启动耗时:2.4s
# 包边界靠纪律维持——IDEA 里点错一下就能 import 到隔壁包
风险:有人图省事让 inventory 反向 import order,编译器不会拦
单体分包:开发效率最高,代价是边界全靠自觉。所以第十三节那条循环依赖报错必须认识。
106 / 157
提示

三档里唯一「不可逆」的是第三档——从单体拆成微服务能做,但从微服务合回单体几乎没人真做。所以顺序永远是「先单体,边界画清楚,撑不住了再按现成的缝拆」。

107 / 157
小节
十二、随堂自测
108 / 157

第一道是热身题,考第五节那张选型表的读法:

109 / 157
随堂自测
随堂自测团队讨论 BeeOrder 持久层时说「上 JPA 吧,省得手写 SQL」。按本篇的判断标准,这条理由最大的问题在哪?
先自己选一个,选中立刻告诉你对不对
110 / 157

第二道是综合题,把第六节、第七节和第十节串起来:

111 / 157
随堂自测
随堂自测编码阶段,同事为了让定时关单任务早点跑通,在 inventory 包里加了 import com.beeorder.order.OrderService;order 包本来就依赖 inventory。最可能出现的后果是?
先自己选一个,选中立刻告诉你对不对
112 / 157
小节
十三、常见报错速查
113 / 157

设计稿定得再漂亮,第一天写代码照样会撞墙。下面每一行「报错原文」都可以整段复制去搜索,别意译也别缩写。

114 / 157
对照表
报错原文(片段)真实原因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 自动配置
115 / 157
提示

这七条里有五条的共同点是「容器压根没扫到那个东西」。新手看到 required a bean ... could not be found 的第一反应常常是去加注解,正确反应应该是先回答三个问题:它在启动类的子包里吗?它的 starter 引了吗?自动配置的条件判定成立吗?

116 / 157

表格第二行那种环,读文字和看原件是两回事。下面就是当时的原始报错,每一段都能点,点错也会告诉你为什么不是它:

117 / 157
报错急救
报错急救APPLICATION FAILED TO START: form a cycle
跨模块反向调用的报错现场

第十二节那道题如果真动手做过,启动日志的结尾就是这一段。第一次读很容易盯着最后那句话,而真正的信息在更上面。

***************************
APPLICATION FAILED TO START
***************************
Description:
The dependencies of some of the beans in the application context form a cycle:
┌─────┐
| inventoryService (field com.beeorder.order.service.OrderService com.beeorder.inventory.service.InventoryService.orderService)
↑ ↓
| orderService (field com.beeorder.inventory.service.InventoryService com.beeorder.order.service.OrderService.inventoryService)
└─────┘
Action:
Relying upon circular references is discouraged and they are prohibited by default.
To allow circular references, set spring.main.allow-circular-references to true.
点你认为的「凶手行」(可反复试)
不会也没关系:先猜异常名,再猜哪一行在做决定。
118 / 157

复盘只需一句:这条报错里真正要改的地方,就是 inventory 包里那一行反向 import;其余每一帧都在告诉你「为什么不是别的东西」。

119 / 157
小节
十四、动手练习
120 / 157

三档难度,第一档照做就能跑通,第三档做完你就有了自己的架构评审清单。

121 / 157
小节
第一档 · 照做
122 / 157

目标:亲手搭出 BeeOrder 的工程骨架,并且故意制造一次包位置错误,亲眼看到那条 could not be found。

123 / 157

第一步,新建 pom.xml(Spring Boot 3.2.5 + Web + JDBC,MySQL 先不接也能启动):

124 / 157
xml
<?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>
125 / 157

这份 pom 不用逐字抄,勾一遍就有。默认勾的三项(Web / Validation / JDBC)正是第一档要跑通的最小骨架;再把后面三篇才会真正吃进来的依赖也看全——先知道总数,再决定哪些现在就引:

126 / 157
生成器
生成器BeeOrder 的 pom:先勾出最小可跑骨架pom.xml3 / 10
默认勾的三项就是第一档要跑通的最小组合;再把 MyBatis、MySQL、Redis、Security、Actuator 逐个勾上,看总数涨到几行——每多一个 starter,启动期就多跑一批自动配置,排错面也跟着变大,所以别一次全引。
产物
<?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>
勾了这些,代价与理由在这里
parent继承 3.3.4 的 starter-parent 之后,所有 spring-boot-starter-* 都不用写版本号;一旦有人手写给某个 starter 加 version,就以那条为准——这是依赖版本漂移最常见的原因。
Web做接口就绕不开它: DispatcherServlet、内嵌 Tomcat、JSON 序列化全在这个 starter 里。
Validation给了 @Valid 才有人干活;漏掉它,@NotNull 会安静地什么也不校验。
JDBC只要模板类和 HikariCP,不想被 ORM 绑住时的最小选择。
127 / 157

第二步,按第八节的目录建包,写启动类和两个最小类。注意启动类必须放在 com.beeorder,也就是所有业务包的共同父包:

128 / 157
java
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);    }}
129 / 157
java
package com.beeorder.order.mapper;import org.springframework.stereotype.Repository;@Repositorypublic class OrderMapper {    public String ping() {        return "mapper-ok";    }}
130 / 157
java
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();    }}
131 / 157

第三步,启动并访问:

132 / 157
bash
mvn spring-boot:runcurl http://localhost:8080/api/v1/ping
133 / 157

预期响应(正文就一行,没有 JSON 信封——信封是下一篇的活):

134 / 157
text
mapper-ok
135 / 157

第四步,制造一次「扫不到 Bean」。把 OrderMapper.java 改成 MyBatis 那种接口 + @Mapper 的写法(真实项目里就是这么写的),并把包声明从 com.beeorder.order.mapper 挪到 com.beeorder.dao——注意它仍然在启动类 com.beeorder 的子树里,所以「包在外头」这个借口这次不成立:

136 / 157
java
package com.beeorder.dao;import org.apache.ibatis.annotations.Mapper;@Mapperpublic interface OrderMapper {    String ping();}
137 / 157

重启,预期控制台直接停在这里:

138 / 157
text
***************************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.
139 / 157

真正的原因有两层,一层比一层隐蔽:① 接口没有实现类,容器没法反射把它 new 出来——Mapper 接口的实例是靠 MyBatis 的动态代理现造的,不是靠组件扫描;② @Mapper 这个注解本身不带 @Component 语义,所以哪怕包在子树里也一样扫不到。少了 mybatis-spring-boot-starter(或没告诉它去哪找接口),这条链路就断在装配阶段。

140 / 157

第五步,把它救活:在启动类上加一行 @MapperScan("com.beeorder.dao"),同时确保 pom.xml 里有 MyBatis 的 starter,重启后再 curl 一次,仍然返回 mapper-ok。这一步的意义在于你会永久记住:「包能不能被扫到」和「这类东西归谁装配」是两个问题——普通类靠组件扫描,Mapper 接口靠 @MapperScan,第三方组件靠 starter 的自动配置。

141 / 157

验收清单:① 说得出第四行为什么失败、第五行为什么成功;② 能指出这条报错里真正决定成败的那个词(scan);③ 把 PingController 的构造器注入改成字段 @Autowired,其余不动,重启——仍然启动失败,因为「有没有这个 Bean」发生在装配阶段,跟用哪种注入方式无关。能讲清这一点,你就分清了「装配缺失」和「装配时序」两类问题。

142 / 157
小节
第二档 · 变体
143 / 157

每条只改一个地方,观察结论完全不同:

144 / 157
  1. 造一个跨层反向依赖:给 OrderMapper 加 private final OrderService orderService;(构造器注入),同时 OrderService 构造器注入 OrderMapper。你会观察到:启动直接失败,日志里画出 orderService ==> orderMapper ==> orderService 的环。再把 OrderMapper 那侧改成字段注入,Boot 3 默认仍然报错——因为 Spring Boot 2.6 起 spring.main.allow-circular-references 默认为 false;只有显式把它设成 true,字段注入那一侧才会靠三级缓存被哄起来,程序能跑,但模块边界已经塌了。
  2. 把 @Transactional 从 OrderService 的 public 编排方法上挪走,改成标在同类另一个私有方法上,并在编排方法里用 this.helper() 自调用它。你会观察到:两种写法既不报错也不回滚——事务靠代理包装,而代理只能拦到「从外面调进来的公开方法」。再把注解放回 public 方法、由 Controller 通过注入的 Bean 调用,第二次写入失败时第一次会被撤销。对照第十节实验一的「事务边界落在哪」那一档,两边结论应当完全一致。(注意别指望 getClass() 打印出 CGLIB 类名来验证:只有真的命中切面的 Bean 才会被代理,控制器通常不在其中。)
  3. 把 spring-boot-starter-jdbc 从 pom.xml 删掉。你会观察到:如果代码里没有注入 JdbcTemplate,应用照常启动(少引一个 starter 只是少一批能力,不一定报错);一旦注入了 JdbcTemplate,启动就报 No qualifying bean。对照 --debug 输出的条件评估报告,找到 DataSourceAutoConfiguration 那一行的 matched: false。
145 / 157

提示:做完第 1 条回头翻第十三节表格的第二行,两边应当完全对得上。

146 / 157
小节
第三档 · 造一个
147 / 157

给自己写一份架构体检脚本,以后接手任何项目先跑一遍。

148 / 157

需求:

149 / 157
  • 一个 LayerRulesTest(纯 JUnit + 反射,不许修改任何业务类),扫描 src/main/java 下所有 .java 文件的 package 与 import 声明
  • 校验三条规则:① controller 包不许 import mapper 包;② 任何包不许 import order 包里再 import 回来的类(检测长度为 2 的依赖环);③ common 包不许 import 任何业务包
  • 违规时输出一张表:文件、行号、违规 import、违反了哪条规则、建议怎么拆
  • 附带一份 ARCHITECTURE.md 模板,把第六节的分层图、第七节的接口清单和这张规则表钉在一起
150 / 157

验收清单:① 在当前骨架上跑,规则 ① 立刻抓到 PingController 直接 import OrderMapper 这条违规;② 按建议插入一个 OrderService 后测试转绿;③ 人为制造一个 inventory → order → inventory 的环,脚本能在测试阶段报出而不必等到应用启动;④ 全流程不需要修改业务代码一行。

151 / 157
小节
十五、要点自查
152 / 157
自检

不看上文,把 BeeOrder 的四层按「离用户由近到远」排一遍,并说出每层绝对不许干的一件事。

153 / 157
自检

「用户能下单」这句话缺了什么才算不上需求?把它补成一张带验收标准的用户故事卡,至少两条标准能直接变成测试用例。

154 / 157
自检

order 调 inventory 可以,inventory 调 order 不行——请用餐厅类比讲一遍理由,再说出不合规时最先炸裂的阶段(编译 / 启动 / 运行)。

155 / 157
自检

为什么持久层选型要看「本项目最吃力的两条 SQL」而不是「哪个框架更省力」?把 BeeOrder 那两条 SQL 念出来。

156 / 157
自检

什么信号出现时,才值得把 inventory 从单体里拆出去?拆之前必须已经具备的前提是什么?

157 / 157
口诀

需求要能测,状态要走机,箭头只朝下,边界靠包守,选型看最难的活,拆分等真撑不住。