内容快速导读
读完本文,这几个问题会有答案:
- 面对分支爆炸的复杂业务逻辑,怎么用排列组合把规则边界理清楚?
- 长链路过程式代码有哪些典型症状,病根究竟落在哪一处?
- 怎么用业务对象把数据库操作与内存计算解耦,让跨单据的判断收敛成一次内存查表?
- 什么情况下抽一个业务对象就够了,什么情况下必须引入行为型设计模式?
- 同为行为型模式,模板方法 与策略模式的适用边界在哪,这次为什么选了前者?
- 重构到底创造了什么价值,该拿什么来验证?
技术栈与场景
应用场景:医疗信息系统(HIS)的医生工作站服务。单服务 36 万行代码,一套代码交付多家医院。
| 层次 | 选型 |
|---|---|
| 语言 / 运行时 | JDK 8 |
| 应用框架 | Spring Boot 2.x、Spring Cloud Alibaba(Nacos / Sentinel / Seata) |
| 持久层 | MyBatis-Plus、Druid 连接池 |
| 消息中间件 | Apache RocketMQ |
| 数据库 | Oracle(Flyway 管理多方言脚本) |
| 服务调用 | OpenFeign |
引子:重构不是想做了才做,是被需求顶出来的
很多重构文章喜欢从"代码太烂了"讲起。但真实的工程里,重构往往不是主动发起的------它是一个具体需求做不下去之后,被迫做出的选择。
这篇文章讲的就是这么一次:一个跨单据的检查计费需求,越做越发现原来的过程式实现撑不住,于是有了后面两轮抽象。下面就从那个需求说起。
一、起点:一个"跨单据"的计费需求
1.1 需求本身
在同一家医院里,同一位患者可能在不同时间、不同申请单上,针对同一个检查项目 做不同的部位 。费用规则要求:这些部位要跨单据累计序号------累计到第 N 个部位时,触发"多部位计费一次"或对应的折扣规则。
这条规则最麻烦的地方在于:
计费所依赖的状态,根本不在当前这张申请单里。
要算清楚这一次开单该收多少钱,得先知道这位患者此前做过哪些部位、做过几次、在哪个时间范围内。而且这个"此前"是跨就诊、跨单据的。
1.2 先做了一件"看起来慢"的事
面对一个分支密度极高的计费场景,我没有直接改代码,而是先把规则穷举出来。
医嘱侧与收费项侧共有 6 项关键布尔标志位(如医嘱字典的多部位规则标志、报告胶片标志,收费项的多部位一次计费标志、胶片标志等),它们互相组合会直接影响费用结果。
处理过程:
6 项布尔标志位
↓ 全排列展开
64 种组合的场景矩阵
↓ 依据逻辑 / 业务互斥关系排除 50 项无效组合
剩余的 14 种有效组合
↓ 归纳归类
8 类典型计费场景
这一步的价值,比它看起来大得多:
- 把隐式规则变成显式资产------原本散落在几十个 if-else 里的判断,变成了一张可讨论、可评审、可验证的表;
- 让后续工作有依据------写测试用例、和产品对齐口径、评估后续需求兼容性,全部建立在这张表上;
- 降低风险------整件事从"改代码"变成"改一张表"。
经验一 :遇到分支爆炸的遗留逻辑,先别急着 refactor。把隐式规则显式化,是把不可控问题变成可控问题的第一步。
二、问题诊断:五类症状,一个病根
需求做完之后,我复盘了这段代码为什么这么难改。问题不在于"写得丑",而在结构层面缺东西:
| # | 症状 | 具体表现 | 本质 |
|---|---|---|---|
| 1 | 业务链路极长 | 一次开单从就诊信息推到费用明细,跨越十余个环节 | 缺少阶段边界 |
| 2 | 面向过程的编码惯性 | 逻辑按"流程走到哪写到哪"堆叠,没有领域对象承载 | 状态无归属 |
| 3 | DB 操作散落 | 走到哪查到哪,同一份基础数据被反复查询 | 取数与计算耦合 |
| 4 | 循环内查表 | 多层 for 循环里嵌套数据库查询 |
性能红线 |
| 5 | 上下文丢失 | 下游拿不到上游已算出的中间结果,只能重算或补查 | 无可传递载体 |
其中第 4 条最危险:数据量小的时候看不出来,一旦某个机构的数据规模上来,直接表现为接口超时。第 5 条最隐蔽:它会让人不断"再查一次",从而放大第 3、4 条。
但这五条其实是同一个病根:
过程式代码把状态放在调用栈上。栈一深,状态就散,谁都拿不到全局。
想清楚这一点,解法就清楚了------需要一个显式的状态载体。
三、第一次抽象:计费业务对象
3.1 设计目标
把"取数 "和"算钱"彻底分开,并给这条长链路一个显式的状态归属。
业务对象(BO)的本质,就是把原本散在调用栈上的中间状态,收拢到一个对象里。
3.2 对象结构
字段按职责分三类:
- 驱动参数:这次计算"算谁";
- 过程状态:计算中"算到哪了";
- 上下文开关:影响计算路径的全局开关。
3.3 查询与计算解耦
- 取数侧:多个装配方法重载,对接不同入口 DTO,把外部数据搬进对象;
- 计算侧 :一个统一的计费出口方法,内部按场景分派,全程内存运算。
分开之后,计算逻辑变成了"可单独构造输入、可单独断言输出"的东西------在金额相关的业务里,这个性质的价值极高。
3.4 计费分派:两维分组降维
内部不是一堆平铺的 if,而是根据业务特征做了两次正交分组:
第一步:按「是否胶片」分组
├─ 胶片收费项 → 规则独立,可先行计算
└─ 非胶片收费项 → 参与后续多部位计费逻辑
第二步:按「多部位计价模式」分派
├─ 累加折扣模式 → 走折扣计算
└─ 独立计价模式 → 再按「是否多部位一次计费」细分
├─ 是 → 按部位序号分组,同序号的多条计费项合并处理
└─ 否 → 每个部位各计一次
为什么这么拆 :一方面把嵌套做浅,另一方面------交付给客户的规则文档本来就是"胶片怎么算、多部位怎么算"分开写的,代码结构与业务描述的粒度对齐了,后面改规则能直接定位到分支。
3.5 跨单据序号:把最难的部分变成一次查表
跨单据累计是核心难点,做法是三步:
- 把历史单据的部位记录一次性装载成映射表;
- 需要时结合当前部位与申请项,从映射表算出累计序号;
- 前端若已指定序号,则直接采用,避免重复计算。
关键取舍 :把"跨单据查询"这个 IO 行为,收敛成"一次装载 + 多次内存查表"。链路上任何一处需要部位序号,都不再触发数据库访问。
3.6 收益
| 维度 | 改善 |
|---|---|
| 数据库访问 | 重复查询收敛为一次性装配 |
| 可测试性 | 计算逻辑与 IO 分离,可独立构造用例 |
| 可扩展性 | 接入同类项目时只扩展装配入口,计算主体复用 |
| 可读性 | 业务规则与技术分支一一对应 |
四、一个业务对象不够:问题是全局性的
这个对象只在检查类医嘱的计费 上解决了问题。后续我接手了检验检查申请单创建 和药品医嘱审核的调整,才发现同一个病根到处都是:
凡是"要开一条医嘱并算出费用"的地方,都在重复同一套过程式代码。
- 检验有检验的开单流程,检查有检查的,药品有药品的,治疗、费用、嘱托、耗材各有一套;
- 每一套里都各自处理"明细怎么建、处方怎么拆、费用怎么算";
- 更要命的是门诊、急诊、住院三端的实现还不一致,同一个业务问题要改三遍,漏一处就出生产问题。
到这里我确认:局部抽象解决不了系统性问题,需要的是流程层面的复用结构。
回头看,判断标准其实不复杂:问题局限在"一次计算的内部",一个业务对象就够了;问题一旦横跨"多种类型共享同一套流程",建议上升到流程层面的抽象。 前者解决的是状态归属,后者解决的是流程复用。
五、第二次抽象:模板方法构建体系
5.1 为什么选模板方法
| 方案 | 判断 |
|---|---|
| 继续用工具类静态方法 | 治标不治本,公共逻辑仍在各调用点复制 |
| 策略模式 | 只解决"算法可替换",但流程骨架本身是重复的,用策略会把公共流程也复制多份 |
| 继承 + 模板方法 | ✅ 流程骨架相同、差异点明确且有限------正是模板方法的适用场景 |
| 组合 + 责任链 | 对当前问题过度设计,链路阶段固定,不需要动态编排 |
判断依据 :各类医嘱的构造过程"大框架一样(建明细 → 建下游单据 → 算费用),细节不同(数据来源与字段规则) "。这就是模板方法的定义域。
两者的边界可以这样看:策略模式替换的是"算法",前提是流程骨架已经稳定;模板方法固化的是"流程",把差异通过钩子下放给子类。 一个是"同一个流程里换算法",一个是"多个流程里抽共性"。
不过这两种模式并不互斥。如果是在开发阶段做功能设计,我大概会两个都用上:先用模板方法把构造骨架固定下来,再在骨架之上做一层更高层次的策略抽象------每个策略类负责一类医嘱的操作行为,彼此独立可替换。这样横向新增医嘱类型时,既不用改公共流程,也不用动既有策略,开闭原则才算真正落到实处。
5.2 骨架设计
基类把"开一条医嘱"的公共流程固化,只留一个扩展点给子类:
scss
构造基类
│
├─ 模板方法(基类实现,公共流程)
│ ├─ buildOrderItems() 医嘱明细
│ │ 1. 从医嘱字典复制基础属性
│ │ 2. 填充就诊信息 / 医生信息 / 医保信息
│ │ 3. 初始化 20+ 项业务默认值
│ │ 4. loadOrderItems() ← 钩子:最后调用,交给子类
│ │
│ ├─ buildTestItem() 检验申请明细
│ │ ├─ 前置依赖:明细为空则先构建(懒构建)
│ │ └─ 复制字典属性 + 填充申请科室、检验类型、申请状态 ...
│ ├─ buildCheckItem() 检查申请明细
│ ├─ buildDrugPrescDetail() 药品处方明细
│ ├─ buildOtherPrescDetailList() 非药品处方明细
│ ├─ buildSuppPrescDetail() 耗材处方明细
│ └─ buildOrderFeeList() 住院医嘱费用
│
└─ 抽象钩子(子类实现,差异化取数)
loadOrderItems() / loadTestItems() / loadCheckItems() ...
覆盖 7 类医嘱 (检验、检查、药品、耗材、治疗、费用、嘱托),每类各有单条与批量两个实现,共 14 个构建器。
其中值得一提的是:批量构建器不重复实现构造逻辑,而是编排单条构建器完成实际构造,自身只处理批量场景特有的编排工作(医嘱套分组、明细序号分配等)。这样单条与批量的行为天然一致,不会出现"两套实现慢慢跑偏"的问题。
5.3 三个值得说的设计点
① 钩子在骨架末尾调用
基类把 90% 的通用字段填完后,最后一行才调钩子。子类拿到的已经是一个"填好底子的医嘱对象",只需补齐自己的业务属性,不用关心通用字段。
② 懒构建保证依赖顺序
构建申请明细时会先判断前置的医嘱明细是否已构建,没有就先构建。调用方不必记住"必须先建明细再建申请项"这个顺序约束------顺序知识被封装进基类,而不是留给每个调用者。
③ 构造函数只装配、不查询
基类构造函数的 10 个入参全部是已取好的数据,构造过程零 IO。
这是刻意的:把"取数"推给调用方,基类才能保持纯粹;同时让"谁负责查、查几次"这件事变得显式可见------重构前那种"走到哪查到哪"的隐蔽性被彻底消除。
但代价也要讲清楚:它把查询侧的复杂度推给了调用方------需要在设计阶段就推演出计算用到的全部数据及其规模,并以合适的形式组织好。像器械护士递器械,讲究递得准,而不是把整间器械室搬进来。
5.4 配套的工程改造
重构不是只加几个类就完事,还要把下游的"地面"铺好:
- mapper 层补齐批量新增 SQL:检查单、检验单、检查明细、检验明细、部位关联、住院医嘱费用。原来逐条插入,改为批量写入,把 N 次数据库往返压成 1 次;
- 统一落库出口:抽出统一的落库方法,所有由构建器产出的对象都走同一条路径,避免"各写各的"再次分裂。
5.5 结构性收益
| 维度 | 重构前 | 重构后 |
|---|---|---|
| 新增一类医嘱 | 复制一整套开单 + 计费流程 | 实现一个取数钩子类 |
| 公共逻辑位置 | 散落在各 Service 方法中 | 集中在基类,单点维护 |
| 三端一致性 | 门诊/急诊/住院各自实现,易漏改 | 共用同一构建体系 |
| 循环内查表 | 存在于多处 | 随取数上移消除 |
| 落库方式 | 逐条 insert | 批量 SQL |
| 可测试性 | 逻辑与 IO 交织,难以单测 | 计算主体可独立构造用例 |
其中性能 这一项值得单独展开:重构前,多层 for 循环里嵌套数据库查询是常态------数据量小的时候看不出来,一旦某个机构的数据规模上来,就是实打实的接口超时。重构把取数从循环中上移到装配阶段,循环体内不再有任何数据库访问;配合批量 SQL,把 N 次数据库往返压成 1 次。这类低性能编码陋习的消除,是这次重构最直接的性能收益。
六、重构的价值兑现:它撑起了后面两个功能
判断一次重构是否成功,标准从来不在代码本身------如果重构之后新需求依然难做,那这次重构就只是审美活动。下面这两件事,是"重构创造了价值"最直接的证明。
6.1 患者类别优惠计费
业务背景:不同患者类别(自费、各类医保、离休等)开单享受不同优惠。这件事的复杂在于------优惠要与"是否多部位""是否使用胶片""是否跨单据叠加""绑费优惠"等因素交织。
为什么能做成 :优惠计算必须落到每一类医嘱上。若没有统一构建体系,就需要在 7 类医嘱 × 3 个端 = 21 处分别改造。有了基类,公共流程与优先级裁决在基类统一,各类型只处理自己的取数。
配置模型也做了一次演进 :从"患者类别 + 医嘱字典直绑"的单表模型,拆成"折扣规则表 + 关联中间表"。
旧模型一行只代表"某类别下某医嘱的优惠价",优惠规则本身没有独立表达;新模型让规则可以复用、可以按端生效、可以带封顶保底和退款口径。
一处我觉得值得一提的实现细节:优惠有两个来源------配置侧的患者类别规则,与业务侧已算好的优惠价。两者形态不同(一个是规则对象,一个是金额),如果放任两条路径各自算下去,后面代码就要到处判断"这次是哪种优惠"。
处理方式是:当患者类别优惠不生效、而业务侧提供了优惠后单价时,用"原价 − 优惠价"的差额,当场构造一个等价的固定金额折扣规则,让它并入同一条计算路径。
ini
// 若患者类别优惠不生效 且 业务属性明确指定了优惠后单价, 则覆盖优惠金额
if (orderDiscountRule == null && bizProp.getDiscountPrice() != null) {
orderDiscountRule = new TDiscountRules(2L, 原价.subtract(优惠价));
}
orderDiscountRule == null 这一行就是优先级裁决 的落点;构造出的对象与配置侧规则同类型同结构,所以能无缝并入后续计算。
经验二 :这是"适配器思维"在业务代码里的应用------不是让核心逻辑去兼容所有来源,而是要求所有来源先适配成核心逻辑认得的形状。
交付策略上还有一个我认为很关键的点 :所有新增能力的配置开关默认值全部为 false,并且支持运行期动态刷新。这换来三个性质------对存量机构零影响、可按机构灰度、出问题秒级回退(不需要回滚发版)。
在一套服务十几家医院的系统里,"新功能默认关闭 + 按机构开启"不是保守,而是必要的交付纪律:它把发版风险降级成了配置风险。
6.2 检验检查绑费医嘱
业务背景 :开一张检验申请单时,系统要自动带出配套的绑费医嘱(如采血耗材)。它和检验医嘱之间存在强关联------撤销、作废、删除检验医嘱时,绑费医嘱的归属必须同步维护,否则就会出现"申请单撤了、费用医嘱还挂着"的脏数据。
难点有四个:跨单据去重、历史医嘱复用、撤销产生的"孤儿"、同批次内多张申请单的相互影响。
处理器的解法是两套对账机制:
机制 1:历史不存在同类型
→ 降级为「按医嘱明细匹配」:能匹配到就复用,匹配不到再新增
机制 2:历史存在同类型
→ 按「绑费类型明细」与「历史明细」双向对账:
缺的补上(新增)
多的舍弃(不建立关联)
为什么要分两套?因为历史数据的形态不确定 ------早期开单未必严格遵守规则,历史里会出现"该有的没有、不该有的却有"。用一套严格逻辑硬套,会把历史脏数据当成本次错误处理。分两套的本质是:先探测历史状态,再决定用宽松模式还是严格模式。
最精彩的一处是"孤儿"的处理:
孤儿 = 它依赖的检验医嘱都已撤销或删除的绑费医嘱。
挑选候选绑费医嘱时,策略是:跳过已消耗的 → 优先返回非孤儿 → 孤儿仅作兜底。
功能上,复用孤儿也能跑通;但复用它会让一条已经失去归属的费用记录重新挂到新申请单上,后续撤销链路里就产生歧义。能跑通和语义干净是两回事------这里选择了后者。
还有一个设计上的呼应:这个处理器用了十几个内存映射结构,把对账需要的判断依据在装载阶段一次性装完(包括"存活检验医嘱 ID 集合"),主流程零数据库查询。
这跟前面医嘱重构解决的是同一类问题 :把长链路中反复需要的判断依据,在入口处一次性装载成索引结构。区别只是------那边解决"取数散落",这边解决"关系判定散落" 。
七、复盘
做对了什么
- 先梳理规则,再动代码。64 种组合矩阵让整件事从"改代码"变成"改一张表",风险显著下降。
- 两次抽象,各司其职:业务对象解决"单次计算内的状态管理",构建体系解决"跨类型的流程复用"------没有混为一谈。
- 不只改业务代码,还改下游:批量 SQL、统一落库出口,否则重构收益会被下游的逐条 IO 吃掉。
可以做得更好的
- 如果一开始就意识到问题是全局性的,可以直接从基类入手,省去中间抽象的过渡成本。不过我不认为第一步是浪费------第一次抽象暴露了差异点在哪,才让第二次抽象有了准确的切分依据。
- 重构与功能开发并行推进,承担了较高的回归风险。更理想的是先补关键用例再重构,但存量系统通常不具备这个条件。
- 金额相关的逻辑没有单元测试护栏------这是这次改造最大的风险点,也是我最想在下个项目里补齐的能力。
关于遗留代码的一点感想
站在今天的角度看,重构前的功能设计问题不少,说是历史包袱沉重并不为过。但翻一遍历史提交记录,才理解什么叫"复杂的需求是在业务中一点点长出来的"------长线运营的产品,难免在客户快速增长的时候贴一堆补丁,时间一长便积累成技术债。
我早期参与过的一家工业企业的信息化项目也是如此:需求分析与方案设计粗糙,功能定位和系统边界模糊,需求又不停在变。当时的我对此很不适应,老领导安慰我说,软件行业就是这样,需求会像胡子一样不断长出来,刮也刮不完,得学会接受这个客观事实。现在回头看,那时候我还没真正理解这句话;如今大概算是入了这一行的门了。
小结
这次重构真正的价值,不在于用了模板方法,而在于它让后续两个功能可落地且易维护:
- 一次改造,把 21 个分散的改造点位收敛成 1 个公共骨架;
- 一套配置模型,从"逐条维护"演进为"规则化复用";
- 一个对账处理器,把跨单据的关系判定收敛成内存操作。
如果要用一句话总结这次经历,大概是:
重构的收益,不由重构本身证明,而由它之后新功能的上线速度与性能优化证明。