
1.双11订单雪崩1.1 凌晨1点的告警2023 年 11 月 11 日 01:03某电商平台监控大屏一片血红核心订单服务 CPU 100%、平均响应 9.7 秒、错误率 23%。运营同事在群里发出第一条求救「优惠叠加算错了所有订单都要重算」值班工程师拉起代码OrderUtil.calculate()这个函数从 2016 年的 47 行长成了1284 行里面塞着 9 种折扣逻辑、4 种运费规则、3 种税费表外加 17 个if (activityType ...)分支。每一次大促前都有人在这里加一行小逻辑没有人敢删旧的。直接故障的代码片段脱敏后大致是这样java代码解读复制代码// OrderUtil.java L843-L1062 if (activityType 1) { total subtotal * 0.9; } else if (activityType 2 || activityType 22) { total subtotal - 50; if (isVip) total - 30; // ... 200 行后 total total * (1 - couponDiscount); // ← 重复打了一次折 } else if (activityType 3) { // ... }短短 220 行else if链里同一个total被读写了 17 次任何一处疏忽全盘错算。1.2 五次反转排查事故复盘那天我们以为答案显而易见。但真相经历了五次反转反转1是不是新加的优惠券逻辑错了反转2不是新代码是 calcVipPrice 算错反转3vip 函数没问题是参数被外面改了反转4参数也没问题是 totalPrice 字段被双写反转5真正的根因这一切都不该是函数操作的事反转 1以为是新加的「双 11 满 300 减 50」逻辑算错。回滚错误依旧。反转 2以为是会员价计算函数calcVipPrice内部 bug。单测通过。反转 3以为是参数subtotal在调用前被某个上游函数改写。日志干净。反转 4以为是OrderDTO.totalPrice字段被多个函数同时写入。确实如此。反转 5但堵掉双写还是会出事因为下一个加新逻辑的人照样会再去写它。真正的根因不是代码错是架构错OrderDTO是一个裸数据袋子谁拿到都能读、能改而OrderUtil是一堆漂浮在数据之外的函数这个组合注定让算错成为时间问题而非概率问题。1.3 真正的根因把这次事故抽象一层会发现它根本不是一个 bug而是一类 bug现象本质同一个字段被 17 处函数写入数据没有守门人加一个新优惠就要改 17 处行为没有归属单测覆盖率 92%仍然出事测试只测函数不测「业务规则」改不动、又删不掉复杂度被均匀摊在所有调用点四行现象根因只有一句话数据和行为没被绑定到同一个边界里。而把数据和行为绑到同一个边界这件事本身就叫「面向对象」。1.4 灵魂五连问为了把这次事故讲透本文将围绕五个层层递进的问题展开它们就是全篇的骨架markdown代码解读复制代码Q1 ── 同样一段业务为什么过程式写法过几年就一定烂 └─→ §1 用订单案例对比两种风格 Q2 ── 「对象」到底是什么只是带方法的结构体吗 └─→ §2 拆开「对象」的本质 Q3 ── 既然都能跑为什么我们要从过程式迁到对象式 └─→ §3 范式演化的内在动力 Q4 ── 「想清楚再写」具体是想清楚什么 └─→ §4 OOA / OOD / OOP 三阶段 Q5 ── 是不是所有项目都该用对象式 └─→ §5 复杂度阈值与伪 OOP 风险读完全文再回头看这次双 11 雪崩你会发现它不是一个加班能解决的问题是一个范式选错了的问题。2.从一个案例切入2.1 看订单需求设想电商平台一个朴素需求给定一个订单计算实付金额含商品总价、折扣、运费、税费并打印。刚接到这个需求时几乎所有人脑海里浮现的第一版伪代码都是相似的取出商品列表 → 把单价乘以数量加起来 → 减去折扣 → 加上运费 → 加上税。它如此自然以致我们很容易低估它在工程上的演化复杂度。但需求看似简单工程中却会被叠加会员折扣、优惠券、跨境税费、海运/空运运费、不同地区税率、跨币种结算、活动期叠加规则……更糟的是这些规则不是一次性写进文档的而是一年内每一两周就有人提出新的小调整。这些小调整中的每一项都会落到一段被无数下游函数共享的代码里。我们用这个订单案例贯穿全篇对比两种范式在面对持续叠加的复杂度时表现有何不同。看到第 5 篇你会发现几乎所有面向对象设计原则要解决的都是这同一类问题只是抽象层级不同而已。2.2 看过程式实现最直观的写法函数 数据。代码如下所示java代码解读复制代码public class OrderProcedural { public static void main(String[] args) { double[] prices {100.0, 200.0, 50.0}; int[] counts {1, 2, 3}; double discount 0.9; double shipFee 20.0; double taxRate 0.06; double subtotal calcSubtotal(prices, counts); double afterDisc subtotal * discount; double total afterDisc shipFee afterDisc * taxRate; System.out.println(应付 total); } static double calcSubtotal(double[] p, int[] c) { double s 0; for (int i 0; i p.length; i) s p[i] * c[i]; return s; } }数据是裸数组行为是静态函数二者各自漂浮、靠参数串起来。这种写法在脚本工具或者算法题里完全没问题甚至效率高、易理解。但它隐含了一个致命假设所有调用方都懂规矩知道prices和counts必须等长、知道discount是乘数而非百分数、知道taxRate不能为负。一旦项目成员超过 3 人这种心照不宣的规矩就会被一次次破坏。2.3 把痛点暴露出来把需求滚动一轮加一种折扣策略 → 改main的拼装顺序同一个订单要既导出 PDF 又发短信 → 又得新增两组函数多人协作时数组下标含义全靠注释维护调用方写错下标 → 静默 bug。这些问题都不是单一的代码风格不好看而是会让线上事故率随代码规模呈指数增长的真实风险。在百万行规模的工程里过程式代码每多一个全局函数就多一份潜在被调错的可能。根因数据和行为没有边界复杂度随需求线性扩散到调用点。换言之复杂度并没有消失只是被你摊到了未来每一个调用方头上这正是面向对象设计要修复的根本问题。2.4 对象式重构把订单当作一等公民java代码解读复制代码class Order { private ListItem items; private DiscountPolicy discount; // 多态扩展点 private ShippingPolicy shipping; private TaxPolicy tax; public Order(ListItem items, DiscountPolicy d, ShippingPolicy s, TaxPolicy t) { this.items items; this.discount d; this.shipping s; this.tax t; } public Money total() { Money sub items.stream().map(Item::amount) .reduce(Money.ZERO, Money::add); Money afterDisc discount.apply(sub); return afterDisc.add(shipping.fee(this)) .add(tax.of(afterDisc)); } }调用方只关心order.total()新增折扣只需新加一个DiscountPolicy实现不改Order、不改调用点。请仔细体会这句话它是面向对象与面向过程在扩展成本上的根本差距。在过程式版本里新增一种折扣策略意味着main函数里的拼装顺序、参数清单、判断分支都要随之调整而在对象版本里这种新增几乎是加法式的新增一个文件、新增一个类、注入到容器里已有代码完全不需要触碰。更深一层看Order不再是一个被动的数据袋子而成了一个主动的业务概念它知道自己由哪些商品构成、知道自己应当套用什么折扣、知道自己最终的总价应当怎么算。调用方的代码因而变成了声明式告诉对象做什么不告诉它怎么做。这是面向对象与面向过程在心智模型上的根本分歧。2.5 两种风格对比维度过程式对象式组织单元函数 全局数据类数据行为扩展方式改函数/加分支加新类旧码不动复杂度承载全部压在调用点切片到各类内部协作友好度靠纪律靠类型与边界过程式对象式需求新增编程风格修改函数修改调用点新增子类调用方零修改影响面发散影响面收敛3.对象到底是什么3.1 现实的映射OOPObject Oriented Programming的核心隐喻是用对象模拟现实世界。一辆车、一张订单、一次远程连接都可以是对象对象之间的关系聚合、依赖、组合就是现实关系的映射。这种映射不是装饰而是降低认知负担人脑天然擅长以名词动词理解世界过程式则强迫你切换到步骤序列的思维。在产品经理嘴里说出来的需求几乎从来不是先做 A再做 B最后做 C而是用户应该能下单、订单可以取消、商家可以发货。需求天然以实体 行为的方式存在而面向对象的代码只是把这种自然语言低损地翻译进了程序。这种贴近需求语言的特性让代码在长期演化中更容易被理解和修改你不需要先在脑海里把步骤反推回业务概念再去做改动。3.2 数据加行为对象 属性数据/状态方法行为/能力。Order-List items-Money discounttotal() : MoneyaddItem(item)cancel()属性是它是什么方法是它能做什么。两者绑定是对象式与过程式最根本的区别。在过程式编程里数据是被加工的原料函数是加工车间二者通过参数链接。这种分离在小规模代码里很轻巧但当业务规则越来越多时数据走到哪里规则就要在哪里被重新校验一次校验逻辑因此散落在系统各处没人能保证它们彼此一致。而对象把数据与守护它的行为锁在同一个边界里规则只写一次永远不会被绕过。这就是后面要讲的封装特性的真正价值。3.3 类是模板类class是对象的蓝图对象object是类的实例。vbnet代码解读复制代码Class: Order 定义结构与行为 ↓ new Object: order1, order2, order3 …带具体状态类是编译期的概念对象是运行期的实体类描述形状对象拥有内容。4.从过程到对象4.1 过程式范式把大象装冰箱作为典型样本打开冰箱放入大象关上冰箱每一步都是参与者要亲自完成的动作面向过程就是这种我是执行者的视角。它在小脚本、单一线性流程里高效、直观。4.2 演化的动力需求一旦从装一头大象变成装多种动物、多种容器、还要校验空间过程式就不堪重负步骤会爆炸 → 函数列表越来越长数据散落 → 每个函数都要传一堆参数复用困难 → 每个新场景重写一遍流程。工程界对此的回应就是把高内聚的步骤数据打包成一个类。4.3 对象式范式scss代码解读复制代码冰箱.open() 冰箱.put(大象) 冰箱.close()调用者从亲自做每一步转变为指挥对象做事角色从执行者→指挥者。3.4 思维差异对象式思维识别名词需求设计类与协作组合对象完成过程式思维拆步骤需求每步写函数串行调用过程式问怎么做对象式问谁来做。问法不同复杂度的归宿就不同。4.4 看个演进案例TODO补充一个从过程到对象式的代码案例。然后在总结5.OOP 三阶段软件开发中三个连贯阶段OOA 分析做什么OOD 设计怎么做OOP 编程翻译成代码5.1 OOA 分析搞清楚做什么。从需求中识别名词候选类、动词候选方法、关系候选关联输出领域模型草图。5.2 OOD 设计搞清楚怎么做。把候选类细化为哪些类、各自属性方法、类之间是聚合/继承/依赖、接口边界在哪。这一阶段直接决定后续编码的难易。5.3 OOP 编程把 OOD 的产物翻译为具体语言代码。这是最易被简化甚至被跳过的一步但OOA/OOD 做得好OOP 才会顺畅。5.4 UML 工具UMLUnified Modeling Language是 OOA/OOD 的可视化沟通工具图类用途类图静态结构类/属性/方法/关系时序图动态调用顺序用例图用户角度的功能边界状态图单对象的状态机不必苛求全部掌握会画类图与时序图即可应付 90% 设计沟通。6.两种范式取舍6.1 复杂度阈值简单脚本百行以内、单一线程、一次性使用选过程式可演进系统多模块、多人协作、长期维护选对象式。复杂度是范式选择的唯一硬指标。6.2 网状 vs 线性复杂业务-网状N2N1N3N4N5简单业务-线性S4S1S2S3线性流程过程式贴合网状协作对象式才能把局部复杂度封住。6.3 伪 OOP 风险最常见的误区用面向对象语言写面向过程代码比如全是static工具类、几百行的上帝类、一切公开字段。判断标准很简单把字段全设 public 之后程序行为是否依然正确如果是说明类没承担任何不变量保护等于过程式包了一层壳。7.综合实战案例这是 11 篇主线案例的第 1 站电商订单系统的裸版。后续 10 篇会在它身上一次次重塑。每一次重塑都对应一次认知跃迁。7.1 营销系统接需求PM 给到这次的小需求「我们要支持三种活动满减、折扣、买二送一。每个活动都可能叠加会员价。最终输出一个订单总价。」听起来很普通对不对我们就用它把过程式与对象式两种实现各跑一遍。7.2 过程式版本翻车工程师 A 接到需求10 分钟写完java代码解读复制代码public class OrderCalc { public static double calc(double[] prices, int[] counts, int activityType, boolean isVip) { double sub 0; for (int i 0; i prices.length; i) sub prices[i] * counts[i]; double total sub; if (activityType 1) { // 满减 if (sub 300) total sub - 50; } else if (activityType 2) { // 折扣 total sub * 0.9; } else if (activityType 3) { // 买二送一 // 这里其实需要细到 item 级但 item 已经被拍扁成数组 total sub * (counts.length - 1) / counts.length; } if (isVip) total * 0.95; return total; } }它能跑。但下面任何一个新需求都会让它原地爆炸新需求改动范围满减改成满 300 减 50、满 500 减 100、满 1000 减 250阶梯改if (activityType1)分支加一种「优惠券」加activityType4且要排满减优惠券叠加规则「买二送一」要支持指定商品入参prices/counts必须升级为Item[]调用方全改不同会员等级有不同折扣isVip升级为vipLevel所有调用点修改每一次需求都不是加一段而是全身手术。这正是开篇 §1 双 11 雪崩的来源。7.3 对象式三步演化工程师 B 拿到同一个需求先停下来问 §4 的三个问题vbnet代码解读复制代码OOA: 这里有什么名词 → 订单 Order、商品 Item、活动 Activity、会员 Customer OOD: 它们之间什么关系 → Order 聚合 ItemOrder 适用 ActivityOrder 归属 Customer OOP: 让谁守哪条规则 → 订单守总价正确活动守打折规则会员守会员价规则这三个问题想清楚了代码自然长成这样java代码解读复制代码// 第 1 步把名词建模为类 public class Item { private final Money price; private final int count; public Money amount() { return price.times(count); } } public class Order { private final ListItem items; private final Activity activity; private final Customer customer; public Money total() { Money sub items.stream() .map(Item::amount) .reduce(Money.ZERO, Money::add); Money afterActivity activity.apply(sub, items); return customer.applyVipPrice(afterActivity); } } // 第 2 步把会变化的部分做成抽象 public interface Activity { Money apply(Money subtotal, ListItem items); } // 第 3 步每种活动是一个独立实现 public class FullReductionActivity implements Activity { /*满减*/ } public class DiscountActivity implements Activity { /*折扣*/ } public class BuyTwoGetOneActivity implements Activity { /*买二送一*/ }注意三个细节它们已经预演了后面 10 篇的全部主题把price包成Money而不是double预告了 11 篇 DDD 的值对象把Activity设成接口预告了 04 篇接口编程让Order.total()是唯一入口预告了 02 篇封装 与 11 篇聚合根。7.4 类图与时序调用方一直只看到order.total()一个方法。新增第 4 种活动只新增 1 个Activity实现类其余文件零修改这就是面向对象在扩展成本上的胜利。调用方OrderItemActivityCustomertotal()amount() (循环)子小计apply(sub, items)活动后金额applyVipPrice(...)最终金额Money调用方OrderItemActivityCustomer7.5 留下三道思考题这三道题的答案会在第 02 篇开头揭晓。 易上面Order.total()里假设我把items字段改成public会发生什么坏事请至少举出 2 种。 中「买二送一」需要按最便宜的那一件免费来送请你修改BuyTwoGetOneActivity但不能修改Order类一行代码。你做得到吗 难如果同一个订单可以叠加多个活动满减 优惠券 会员价你会怎么改造Activity接口说明你的取舍是改成ListActivity、还是改成装饰器链还是改成管道三种方案各有什么代价8.认知跃迁总结8.1 一句话回望回到开篇双 11 雪崩。如果当年订单系统不是OrderUtil.calculate(...)这一堆漂浮的函数而是order.total()这一个有边界的对象那些 17 个else if根本没机会出现。复杂度并不会因为我们写了 OOP 而消失它只是被切片进了不同的对象内部每个对象只看自己那一片。这也是本篇最想送给你的一句话过程式问怎么做对象式问谁来做问法不同复杂度的归宿就不同。