记一次复杂业务功能重构——从过程式长链路,到模板方法构建体系

内容快速导读

读完本文,这几个问题会有答案:

  • 面对分支爆炸的复杂业务逻辑,怎么用排列组合把规则边界理清楚?
  • 长链路过程式代码有哪些典型症状,病根究竟落在哪一处?
  • 怎么用业务对象把数据库操作与内存计算解耦,让跨单据的判断收敛成一次内存查表?
  • 什么情况下抽一个业务对象就够了,什么情况下必须引入行为型设计模式
  • 同为行为型模式,模板方法策略模式的适用边界在哪,这次为什么选了前者?
  • 重构到底创造了什么价值,该拿什么来验证?

技术栈与场景

应用场景:医疗信息系统(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 类典型计费场景

这一步的价值,比它看起来大得多:

  1. 把隐式规则变成显式资产------原本散落在几十个 if-else 里的判断,变成了一张可讨论、可评审、可验证的表;
  2. 让后续工作有依据------写测试用例、和产品对齐口径、评估后续需求兼容性,全部建立在这张表上;
  3. 降低风险------整件事从"改代码"变成"改一张表"。

经验一 :遇到分支爆炸的遗留逻辑,先别急着 refactor。把隐式规则显式化,是把不可控问题变成可控问题的第一步。


二、问题诊断:五类症状,一个病根

需求做完之后,我复盘了这段代码为什么这么难改。问题不在于"写得丑",而在结构层面缺东西:

# 症状 具体表现 本质
1 业务链路极长 一次开单从就诊信息推到费用明细,跨越十余个环节 缺少阶段边界
2 面向过程的编码惯性 逻辑按"流程走到哪写到哪"堆叠,没有领域对象承载 状态无归属
3 DB 操作散落 走到哪查到哪,同一份基础数据被反复查询 取数与计算耦合
4 循环内查表 多层 for 循环里嵌套数据库查询 性能红线
5 上下文丢失 下游拿不到上游已算出的中间结果,只能重算或补查 无可传递载体

其中第 4 条最危险:数据量小的时候看不出来,一旦某个机构的数据规模上来,直接表现为接口超时。第 5 条最隐蔽:它会让人不断"再查一次",从而放大第 3、4 条。

但这五条其实是同一个病根

过程式代码把状态放在调用栈上。栈一深,状态就散,谁都拿不到全局。

想清楚这一点,解法就清楚了------需要一个显式的状态载体


三、第一次抽象:计费业务对象

3.1 设计目标

把"取数 "和"算钱"彻底分开,并给这条长链路一个显式的状态归属。

业务对象(BO)的本质,就是把原本散在调用栈上的中间状态,收拢到一个对象里。

3.2 对象结构

flowchart LR subgraph BO[&#34;计费业务对象&#34;] direction TB A[&#34;驱动参数层<br/>医嘱字典 / 关联收费项 / 折扣规则&#34;] B[&#34;过程状态层<br/>部位组合 / 历史部位序号映射 / 折扣规则映射&#34;] C[&#34;开关与上下文<br/>患者类别开关 / 前序部位号 / 后处理标识&#34;] D[&#34;输出层<br/>收费明细列表&#34;] A --> B --> C --> D end E[&#34;外部调用方<br/>负责取数&#34;] -->|装配| BO BO -->|统一计费出口| F[&#34;纯内存计算<br/>输出费用与折扣率&#34;]

字段按职责分三类:

  • 驱动参数:这次计算"算谁";
  • 过程状态:计算中"算到哪了";
  • 上下文开关:影响计算路径的全局开关。

3.3 查询与计算解耦

  • 取数侧:多个装配方法重载,对接不同入口 DTO,把外部数据搬进对象;
  • 计算侧 :一个统一的计费出口方法,内部按场景分派,全程内存运算

分开之后,计算逻辑变成了"可单独构造输入、可单独断言输出"的东西------在金额相关的业务里,这个性质的价值极高。

3.4 计费分派:两维分组降维

内部不是一堆平铺的 if,而是根据业务特征做了两次正交分组

复制代码
第一步:按「是否胶片」分组
   ├─ 胶片收费项   → 规则独立,可先行计算
   └─ 非胶片收费项 → 参与后续多部位计费逻辑
​
第二步:按「多部位计价模式」分派
   ├─ 累加折扣模式 → 走折扣计算
   └─ 独立计价模式 → 再按「是否多部位一次计费」细分
         ├─ 是 → 按部位序号分组,同序号的多条计费项合并处理
         └─ 否 → 每个部位各计一次

为什么这么拆 :一方面把嵌套做浅,另一方面------交付给客户的规则文档本来就是"胶片怎么算、多部位怎么算"分开写的,代码结构与业务描述的粒度对齐了,后面改规则能直接定位到分支。

3.5 跨单据序号:把最难的部分变成一次查表

跨单据累计是核心难点,做法是三步:

  1. 历史单据的部位记录一次性装载成映射表;
  2. 需要时结合当前部位与申请项,从映射表算出累计序号
  3. 前端若已指定序号,则直接采用,避免重复计算。

关键取舍 :把"跨单据查询"这个 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 处分别改造。有了基类,公共流程与优先级裁决在基类统一,各类型只处理自己的取数。

配置模型也做了一次演进 :从"患者类别 + 医嘱字典直绑"的单表模型,拆成"折扣规则表 + 关联中间表"。

erDiagram t_discount_rules ||--o{ t_discount_rule_rela : &#34;ruleId&#34; t_discount_rules { number id PK number patient_type_id &#34;患者类别&#34; number discount_mode &#34;1比例 2固定金额&#34; number discount_value &#34;折扣值&#34; number max_discount_amount &#34;封顶金额&#34; number min_charge_amount &#34;保底金额&#34; number refund_calculate_mode &#34;退款口径&#34; string effective_scope &#34;1住院 2门诊 3急诊&#34; number sort_no &#34;多规则命中取优先级最高&#34; number enable_flag } t_discount_rule_rela { number id PK number rule_id FK number data_type &#34;区分医嘱 / 收费项&#34; number data_id }

旧模型一行只代表"某类别下某医嘱的优惠价",优惠规则本身没有独立表达;新模型让规则可以复用、可以按端生效、可以带封顶保底和退款口径。

一处我觉得值得一提的实现细节:优惠有两个来源------配置侧的患者类别规则,与业务侧已算好的优惠价。两者形态不同(一个是规则对象,一个是金额),如果放任两条路径各自算下去,后面代码就要到处判断"这次是哪种优惠"。

处理方式是:当患者类别优惠不生效、而业务侧提供了优惠后单价时,用"原价 − 优惠价"的差额,当场构造一个等价的固定金额折扣规则,让它并入同一条计算路径。

ini 复制代码
// 若患者类别优惠不生效 且 业务属性明确指定了优惠后单价, 则覆盖优惠金额
if (orderDiscountRule == null && bizProp.getDiscountPrice() != null) {
    orderDiscountRule = new TDiscountRules(2L, 原价.subtract(优惠价));
}

orderDiscountRule == null 这一行就是优先级裁决 的落点;构造出的对象与配置侧规则同类型同结构,所以能无缝并入后续计算。

经验二 :这是"适配器思维"在业务代码里的应用------不是让核心逻辑去兼容所有来源,而是要求所有来源先适配成核心逻辑认得的形状。

交付策略上还有一个我认为很关键的点 :所有新增能力的配置开关默认值全部为 false,并且支持运行期动态刷新。这换来三个性质------对存量机构零影响、可按机构灰度、出问题秒级回退(不需要回滚发版)。

在一套服务十几家医院的系统里,"新功能默认关闭 + 按机构开启"不是保守,而是必要的交付纪律:它把发版风险降级成了配置风险。

6.2 检验检查绑费医嘱

业务背景 :开一张检验申请单时,系统要自动带出配套的绑费医嘱(如采血耗材)。它和检验医嘱之间存在强关联------撤销、作废、删除检验医嘱时,绑费医嘱的归属必须同步维护,否则就会出现"申请单撤了、费用医嘱还挂着"的脏数据。

难点有四个:跨单据去重、历史医嘱复用、撤销产生的"孤儿"、同批次内多张申请单的相互影响。

处理器的解法是两套对账机制

复制代码
机制 1:历史不存在同类型
   → 降级为「按医嘱明细匹配」:能匹配到就复用,匹配不到再新增
​
机制 2:历史存在同类型
   → 按「绑费类型明细」与「历史明细」双向对账:
        缺的补上(新增)
        多的舍弃(不建立关联)

为什么要分两套?因为历史数据的形态不确定 ------早期开单未必严格遵守规则,历史里会出现"该有的没有、不该有的却有"。用一套严格逻辑硬套,会把历史脏数据当成本次错误处理。分两套的本质是:先探测历史状态,再决定用宽松模式还是严格模式。

最精彩的一处是"孤儿"的处理

孤儿 = 它依赖的检验医嘱都已撤销或删除的绑费医嘱。

挑选候选绑费医嘱时,策略是:跳过已消耗的 → 优先返回非孤儿 → 孤儿仅作兜底

功能上,复用孤儿也能跑通;但复用它会让一条已经失去归属的费用记录重新挂到新申请单上,后续撤销链路里就产生歧义。能跑通和语义干净是两回事------这里选择了后者。

还有一个设计上的呼应:这个处理器用了十几个内存映射结构,把对账需要的判断依据在装载阶段一次性装完(包括"存活检验医嘱 ID 集合"),主流程零数据库查询。

这跟前面医嘱重构解决的是同一类问题 :把长链路中反复需要的判断依据,在入口处一次性装载成索引结构。区别只是------那边解决"取数散落",这边解决"关系判定散落"


七、复盘

做对了什么

  1. 先梳理规则,再动代码。64 种组合矩阵让整件事从"改代码"变成"改一张表",风险显著下降。
  2. 两次抽象,各司其职:业务对象解决"单次计算内的状态管理",构建体系解决"跨类型的流程复用"------没有混为一谈。
  3. 不只改业务代码,还改下游:批量 SQL、统一落库出口,否则重构收益会被下游的逐条 IO 吃掉。

可以做得更好的

  1. 如果一开始就意识到问题是全局性的,可以直接从基类入手,省去中间抽象的过渡成本。不过我不认为第一步是浪费------第一次抽象暴露了差异点在哪,才让第二次抽象有了准确的切分依据
  2. 重构与功能开发并行推进,承担了较高的回归风险。更理想的是先补关键用例再重构,但存量系统通常不具备这个条件。
  3. 金额相关的逻辑没有单元测试护栏------这是这次改造最大的风险点,也是我最想在下个项目里补齐的能力。

关于遗留代码的一点感想

站在今天的角度看,重构前的功能设计问题不少,说是历史包袱沉重并不为过。但翻一遍历史提交记录,才理解什么叫"复杂的需求是在业务中一点点长出来的"------长线运营的产品,难免在客户快速增长的时候贴一堆补丁,时间一长便积累成技术债。

我早期参与过的一家工业企业的信息化项目也是如此:需求分析与方案设计粗糙,功能定位和系统边界模糊,需求又不停在变。当时的我对此很不适应,老领导安慰我说,软件行业就是这样,需求会像胡子一样不断长出来,刮也刮不完,得学会接受这个客观事实。现在回头看,那时候我还没真正理解这句话;如今大概算是入了这一行的门了。


小结

这次重构真正的价值,不在于用了模板方法,而在于它让后续两个功能可落地且易维护

  • 一次改造,把 21 个分散的改造点位收敛成 1 个公共骨架;
  • 一套配置模型,从"逐条维护"演进为"规则化复用";
  • 一个对账处理器,把跨单据的关系判定收敛成内存操作。

如果要用一句话总结这次经历,大概是:

重构的收益,不由重构本身证明,而由它之后新功能的上线速度与性能优化证明。

相关推荐
星禾元亨1 小时前
生成式 AI 时代的架构演进与 GEO 优化:实体企业 AI 落地避坑指南
大数据·人工智能·架构·自动化·创业创新
Bs_MoneyMagnet2 小时前
基于springboot+vue的旅游景区点评系统的设计与实现 源码+文档
java·vue.js·spring boot·后端·spring·旅游
这个DBA有点耶2 小时前
MySQL大表DDL锁表锁到崩溃?Online DDL的3个关键参数和实战避坑
数据库·mysql·架构
依依东望9612 小时前
还在用 Electron?6 种跨平台桌面方案横评:Rust + Vue 把安装包从 224MB 干到 4.7MB
架构
夜不会漫长2 小时前
C++:类和对象(2)
java·javascript·c++
IT毕设实战小研2 小时前
基于大数据的国内主要农作物产量趋势分析与可视化
android·java·大数据·django·课程设计
星辞秋2 小时前
Java 入门实战:从零搭建环境到写出第一个可用的命令行工具
java
ch.ju2 小时前
Java—idea知识:如何创建新项目
java·开发语言·intellij-idea
土司大王2 小时前
LeetCode hot100——74.搜索二维矩阵:Java 二分模板
java·算法·leetcode