ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

穿越古代商帮搞DDD:用限界上下文拯救烂账本

穿越古代商帮搞DDD:用限界上下文拯救烂账本 人这一辈子总要碰上几次系统崩了的时刻。我脑子里的最后记忆是凌晨三点盯着监控大屏上飙升的错误率然后眼前一黑。再睁开眼时面前没有服务器日志只有一摞墨迹未干的账本。外头有人扯着嗓子喊分号又断账了账房打起来了我摸了摸身上——长衫、算盘还有半块没啃完的干粮。我穿越了成了古代商帮的一名账房先生而整个商帮的记账体系已经乱到了随时破产的边缘。我下意识在袖口里摸手机自然是摸了个空。等冷静下来看着满桌的出入库凭据、银票存根、赊欠契约我忽然意识到一件事这不就是一套没有领域模型、没有限界上下文的遗留系统吗谁说DDD只能在现代软件里用业务就是代码——不管这代码是用Java写的还是用毛笔写的业务规则摆在那里复杂度摆在那里崩塌的方式也摆在那里。这篇文章是这个系列的第1集。我打算从账本崩塌这个事故现场开始把现代软件工程里的领域驱动设计Domain-Driven DesignDDD方法论搬进这家古代商帮。这一集讲清楚三件事旧账房系统是怎么一步步腐烂的、如何用战略设计画清商帮的业务边界、以及设计战术建模时最容易被忽略的坑。后面几集再展开防腐层、领域事件和渐进式迁移。先说一句故事是虚构的但方法论是真的。每一处建模取舍放到今天的业务系统里依然成立。1. 账本崩溃不是算错数旧商帮系统的四条病根1.1 所有业务挤在同一本万能账里我上任第一天干的事情不是去安抚伙计而是把总账房的账本全部搬出来。一摞摞翻下去发现了一个惊人的事实总账、流水账、仓库账、银号往来账全都记在一本通用的大账册里。一笔业务在账上长这样三月十二收东街王记绸缎庄货款三两二钱支库房麻绳两根欠伙计李四月钱百文未付。这行字里至少混了三个业务领域销售结算货款、库存变动麻绳出库、薪资伙计李四月钱。它们被强行塞进同一张流水表里没有单据类型、没有账目分类、没有归属的部门。放到现代系统里这就叫大宽表综合征。所有业务共用一张表、一套字段、一个接口字段名充满歧义备注列里塞了半结构化数据每个团队都往里加一列最后几万行代码耦合在一起谁也拆不动。商帮的账房也一模一样。表面上是一本账齐了实际上每一笔账都牵动多个业务域只要一个环节记错整本账就对不平。这不是算数水平的问题——算盘能算出加减乘除但算不出业务边界。1.2 伙计们说自己的话账本讲账房的理第二个病根是语言混乱。我花了一个下午把店堂、库房、银号、柜房的人分别叫来问昨天那笔出货你们怎么记店堂伙计说卖出去三匹布客人赊着的。 库房管事的说出库三匹布库存少了三匹。 银号掌柜说入账两匹布的钱另一匹兑成了旧票据折了七成。 账房先生则说一笔业务三处都写了凭证编号不一样对账的时候谁也找不到谁。这不是记账方法的问题这是**通用语言Ubiquitous Language**的缺失。一个卖布的动作在不同人嘴里变成了赊销出库入账折兑四套话语体系。账本没有一套统一的业务词汇大家各自为政最后对不上账的时候互相甩锅。现代系统里这个场景更常见。同样一个客户CRM里叫客户订单系统里叫买家财务系统里叫往来单位统计系统里叫用户。字段名五花八门口径互不相认跨部门取数永远要开评审会。业务语言不统一建模就无从谈起。1.3 业务规则藏在人的脑子里第三个病根更隐蔽也更致命关键的经营规则没有任何载体全在关键人物的脑子里。老主顾赊账上限多少我问。 账房老掌柜斜眼看我这还用问王记绸缎庄赊过三百两封顶再多就要东家点头。 那周记粮行呢 他是老东家的亲家四百两也批。 新来的赵家布行 先交一半现银才发货。你会发现信用政策、赊销额度、审批流程、库存调拨规则全凭老掌柜一人记忆。他生病告假整个分号的赊销业务就瘫了他记岔了某个客户的额度账房就多了一笔坏账。这不是管理问题这是业务规则没有显式化Business Rule not Externalized——放到代码世界里就是一堆散落在各个方法里的magic number和隐式判断没有任何配置、没有版本、没有测试覆盖。1.4 最要命的单据之间互相矛盾旧账房有一套自己的凭证体系——进货有进货单销货有销货单汇款有汇票赊账有欠条。听着挺全但单据之间从不互相校验。银号说这张汇票盖了章能兑一百两。 库房说没见货物出库。 店堂说客户拿走了货欠条在我这。 三方各执一词账房对账时只能靠一张一张人工比对碰上有疑点的单据就要翻出积年的原始凭证来核。商帮越大单据量越大对不上账的缺口就越多。到最后账房已经不敢对账了——因为对出来的窟窿补不上。这个场景放到现代系统里就是分布式系统缺乏一致性保证的古典版。订单、库存、支付三个系统之间存在大量异步交互缺了明确的领域事件和补偿机制数据对不平的时候只能靠人肉画Excel表调账。四条病根看下来我确信了一件事商帮需要的不是一本更厚的账本而是一套经过领域建模的业务系统。能不能落地取决于我们用什么样的方法去拆、去建模、去实施。2. 先定疆界再动手给商帮画出限界上下文2.1 从业务活动里挖出真正的业务边界做DDD的第一步永远不是写代码而是画业务地图。我花了两天时间把商帮的完整业务动作梳理了一遍凡是账本里出现过的业务事件全列在纸上店铺卖出货物赊销、现销、折扣、退货库房出入库进货、调拨、盘点、损耗银号汇兑开票、兑付、背书、贴现货栈中转货物仓储、转运、保险分号往来总号与分号的资金结算、利润分成人员薪俸伙计月钱、掌柜分红、季度津贴我把这些动作分组每一组问一句话这一组活动是不是有自己独立的目标、独立的规则、独立的术语答案很明显。店铺销售关心的是卖出去没有、钱收回来没有、客户能不能信任库存管理只关心账实相符、货在哪儿、存量够不够银号汇兑关心的是票据真伪、汇率、兑付时效分号结算关心的是哪家分号赚了、哪家分号欠了总号多少钱。他们虽然都共享同一批物资和银子但彼此的业务语言、业务规则无法直接混用。这就引出了DDD中最重要的概念限界上下文Bounded Context。每个上下文负责一块完整的业务领域有自己的模式语言、自己的数据模型、自己的业务规则。店堂不知道库房内部的盘点周期库房也不需要理解店堂的信用审批——它们之间只需要一份明确定义的契约。在纸上我给这家商帮画出了初步的限界上下文清单上下文名称核心职责关键业务对象关心的指标销售上下文促成交易、管理客户信用客户、订单、赊销单、收款记录销售额、回款率、坏账率库存上下文管理货物出入与存量货品、批次、库存台账、调拨单库存周转、缺货率、损耗率汇兑上下文承兑汇票、资金调拨汇票、印鉴、汇率、台账汇差、兑付延迟、坏票率分号结算上下文总号与分号对账分号账簿、往来款、利润分成分号利润率、往来差额人事上下文伙计薪酬与考核伙计、职位、月钱、考核记录人力成本、绩效偏差2.2 核心域、支撑域、通用域先分清主次再谈投入限定边界之后还有一步更重要判断哪个上下文是商帮的核心竞争力。我做这个判断的基准很简单——如果这家商帮明天倒闭最值得抢救的业务是什么答案是汇兑和信用。商帮和普通货栈的差别就在于它可以凭着一张汇票跨地域调拨资金靠信用让商号之间互欠互还。销售和库存是必要的但它们是有钱就能干的基础业务汇兑、信用评估、分号资金平衡才是这家商帮真正构筑的护城河。这就是DDD里对领域的划分核心域Core Domain、支撑域Supporting Domain、通用域Generic Domain。核心域汇兑上下文、信用/风险上下文分号结算的一部分。支撑域库存上下文、销售上下文——没有它们生意跑不起来但它们不构成壁垒。通用域人事薪酬——市面上任何账房先生都懂不值得投入战略资源。这个划分直接决定后续的资源投入顺序。同理在你的业务系统里订单支付这类环节如果是核心那就值得把建模做到毫厘不差如果只是给核心流程提供支持的边角功能就别浪费太多精力在它的完美抽象上用现成方案快速搭好把省下来的人力投入真正决定产品价值的领域。2.3 上下文之间的关系商帮里的协作契约限界上下文不是孤岛它们之间必然有协作。我把上下文之间的交互方式也梳理了一遍对应到DDD的上下文映射Context Mapping基本是四种模式防腐层Anti-Corruption Layer老账房与新的领域模型之间必须有一层翻译逻辑。老账本的术语、字段格式、业务流程不能污染新系统新系统的业务规则也不强行改造老账房。两侧各走各的规矩中间靠一个翻译层对接。共享内核Shared Kernel销售和库存必须共享货品这个基础概念。货品的编码、名称、单位必须一致否则一笔卖出和一笔出库对上不号。共享内核要小越大耦合越重。客户/供应商Customer-Supplier销售上下文向库存上下文发起出库申请销售是客户库存是供应商。库存的响应速度和规则由销售侧的流程需要驱动但库存保有最终解释权。事件驱动Event-Driven银号兑付完成后发布一个票据已兑付事件分号结算上下文订阅后自动更新往来账。两边不需要同步调用用事件解耦。这一层梳理完商帮的业务地图已经从一团乱账变成了一张有边界、有协作的系统图。乱局之下我们终于找到了下刀的位置。3. 重立契约与记账规则用聚合把账本拆成可落地的领域模型3.1 实体与值对象银子与票据谁该有身份边界画好了下一步是进入每一个限界上下文内部做战术建模Tactical Design。我先从问题最严重的汇兑上下文动手因为它既是核心域又是旧账房崩塌的重灾区。第一件事区分实体Entity和值对象Value Object。我拿出两张纸左边写东西右边写属性。银票是有唯一编号的一张银票在流通过程中要被背书、兑付、注销它有自己的生命周期和身份银票是实体。而上面的金额签发日期付款方这些属性如果单拿出来都只是一组数据可以被替换、可以被比较值相等它们是值对象。这个区分看着简单但实际建模时非常容易踩坑。我在商帮里看到的普遍错误是把金额、日期这类值对象当成实体来管理给每一笔入库的麻绳都建一个长长的流水主键然后为了记录每一根麻绳的轨迹把系统复杂度抬升了一个量级。如果你发现一段数据永远不会单独变化、不会需要独立追踪、只看值就能判定异同那就把它做成值对象省掉一半的样板代码。3.2 聚合边界一张票据管住一组不可分割的业务规则第二个问题更关键如何确定**聚合Aggregate**的边界我们拿赊销来做例子。一笔赊销业务涉及客户信息、赊销单、赊销明细、还款计划。朴素的建模方式是把这几张表独立开来各自有增删改查的接口——但这正是旧账房崩溃的根源之一。我用一个场景来说明。账房伙计收到店堂转来的赊销单他先登记客户欠款又更新库存再往总账写一笔应收账款。三个动作分散在三个地方只要任何一个环节漏了、或者调换了顺序账就塌了。正确的做法是把赊销定义为一个聚合聚合根是赊销单。一切对这笔赊销业务的操作都必须从赊销单这个入口发起新增赊销单同时创建应收记录修改赊销单金额同时校验客户累计欠款结算还款同时登记回款流水。聚合保证的是一组业务规则必须原子地执行不可分割。库房不知道赊销单的还款明细它只需要知道某张赊销单要出库多少货物而客户欠款总额的维护只在赊销单聚合内部完成。这里有一个非常经典的实操教训聚合不是越大越好。刚学DDD时我也倾向于把商帮整个做成一个大聚合客户、店铺、银号、仓库全盘打包一个操作锁一整棵对象树。结果事务范围巨大、锁冲突严重、并发一高就死锁。后来我学乖了聚合边界要小而稳只圈住那些必须一起变化的数据。在商帮场景里我拆出了几个核心聚合赊销单聚合赊销单根、赊销明细、还款记录规则是单笔赊销额度不可超五百两客户累计欠款不可超其授信额度。汇票聚合汇票根、背书记录、兑付记录规则是汇票签发后不可修改票面金额兑付时印鉴必须与存根一致。库存批次聚合商品批次根、入库记录、出库记录、盘点记录规则是任何出库不得导致批次余量为负。每个聚合只负责自己区域内的一致性跨聚合的一致性通过后面的领域事件来协调。3.3 领域服务与业务规则把老规矩变成显式的代码聚合负责管理数据的一致性但有些规则不天然属于某个聚合它们是跨越多个对象、甚至跨越几个上下文的业务决策。我把这类规则放进领域服务Domain Service。举一个真实的例子。商帮有一条老规矩凡赊销超过三百两必须由大掌柜审批超过五百两需东家亲自画押。这条规则放在哪个聚合里都有点尴尬——它涉及客户信用等级、赊销单金额、审批权限三个要素。放在赊销单聚合里会造成聚合过度膨胀放在客户聚合里又管不到订单放在应用服务里又容易被人绕过。最终我在领域层里建了一个信用审批服务专门承载这条规则public class CreditApprovalService { public ApprovalResult approve(Customer customer, CreditRequest request) { if (request.amount 300) { return ApprovalResult.passed(); } if (request.amount 500 customer.grade().isTrusted()) { return ApprovalResult.needManagerApproval(); } return ApprovalResult.needOwnerApproval(); } }这段伪代码的作用不是提供一份标准实现而是展示一个思维转变业务规则从人的脑子里搬进了代码里。老掌柜记得那三百两、五百两的门槛但新来的账房先生不需要记——他要做的只是调用这个领域服务。规则有了载体就有了版本、可测试、可追溯这就是业务即代码最朴素的含义。4. 新账房落地防腐层、领域事件与渐进迁移4.1 防腐层在老账房和新账房之间架一座翻译桥模型建好之后最实际的问题是商帮不能停业。总号还在运行分号的旧账本还在天天流动你不可能说今天账房停工一整天我们换新系统。旧账房就是这么硬的约束它必须继续跑但它的数据模型、字段格式、甚至业务含义和新领域的模型没法直接兼容。在这种情况下我在新旧系统之间加了一层防腐层ACL。防腐层做的事情很简单把旧账房的输入翻译成新领域模型能理解的语言再把新领域的输出翻译回旧账房能记录的格式。举个例子。旧账房记一笔客户付款填的是客户姓名、金额、日期、备注。新模型里的客户是有编码的付款必须关联到一笔赊销单或汇票。防腐层收到旧账房的结构化数据后先根据客户姓名字段去查新模型的客户主数据找不到就创建一条待认领客户记录再根据金额和日期匹配到对应的应收单最后才生成一条标准的收款登记动作。这个过程有脏数据、有极难匹配的边界case我承认防腐层是全场最无趣又最不能少的代码。它不能产生业务价值但没有它老系统一句术语不合就能让整个改造计划停摆。施工时我还留了一个细节防腐层本身不做业务决策。它只是一座桥所有客户逾期未付要不要停止赊账这类判断必须下沉到领域层防腐层里只做格式翻译和路由。4.2 领域事件分号一响全城联动跨上下文的数据一致性我通过**领域事件Domain Event**来解决。用现代的话说这就是一个简单的发布-订阅机制一个上下文完成某件重要的事向其他上下文广播一个已经发生的事实其他上下文按需响应。我做的第一场领域事件实验选在票据兑付这个场景上。以前银号兑付一张汇票后需要手工写两封信一封给总账房更新往来款一封给销售柜房核销客户欠款。两封信走的是不同的邮路经常一封信到了、另一封信还在路上账目永远跨月对不平。改造后银号兑付完成发布一条票据已兑付领域事件。分号结算上下文订阅后自动更新该商号与总号的往来余额销售上下文订阅后自动核销对应客户的欠款记录。整个过程不经过人手的二次录入数据在多上下文之间保持一致的机会大幅提升。我在设计事件时给自己立了三条规矩这里也分享出来事件名称必须是过去时例如票据已兑付赊销单已违约它描述的是一个不可变的事实不是一条指令。事件携带的数据要最小化不要直接把整个聚合对象丢出去只带聚合根ID和必要的状态信息避免订阅方被无意义的细节污染。事件处理必须最终一致订阅方接收事件后自己保证本地一致性重试和补偿都是订阅方的责任发布方不关心。商帮的账目在第二十天的时候第一次做到了日清日结——所有分号的往来、兑付、赊销在次日清晨都能对齐上。这在旧账房时代是无法想象的事。4.3 渐进迁移路线别让老账房在改革当天就停业最后是落地节奏。我没有选择一夜换账本而是采用了一条渐进迁移路径。整个过程分四步走每一步都以老账房仍有唯一权威数据为前提第1阶段并行记录。新账房系统上线但只做影子记账——所有单据仍走旧流程录入旧账本新系统同步记录一份两套数据每天人工比对差异。这一步的目的只有一个验证新模型的领域规则是否完整捕捉了旧账房的业务行为。第2阶段单品试点。选一个影响面最小的单据类型比如店内调拨单切换成新系统记账旧系统仍然保留但停止对这类单据的权威记账。试点期间如果发现规则漏了立刻回退。第3阶段按上下文切换。先切汇兑上下文、再切销售上下文、最后切库存上下文。切换顺序的原则是先切核心域因为它最能验证业务价值同时每切完一个上下文立即处理防漏层的对接。第4阶段退出旧账房。当所有上下文都迁移完成、并稳定运行一个完整账期后旧账房数据归档正式退役。每个阶段之间我都留了一个能回退的兜底。我听过的很多失败案例都是因为切了就回不去了结果上线当天发现规则遗漏又不敢回退只能用补丁的方式硬糊糊到最后新系统又变成了另一个旧账房。讲到这里商帮的账本还没有彻底平稳运行第1集就没必要继续往下说了。但有一件事我觉得比账目本身更有价值——这套方法真正改变的是让上到掌柜、下到伙计的每个人都开始用一套统一的话语体系去谈业务。他们不再说我那笔账他那张条子而是说赊销单汇票分号结算。我个人实际操作中的体会是DDD最难的地方从来不是那些概念——限界上下文、聚合、领域事件都不难理解。真正难的是让人放下用一套全能的账本管所有事的执念。不管是古代的商帮还是今天的研发团队总有人觉得一张大表、一个超大聚合、一套万能接口才能包打天下。而DDD教给我们的恰恰相反业务之所以复杂是因为它有真实的边界承认边界、尊重边界改造才能走得动。下一集我准备聊聊自己在商帮里推行领域事件驱动时踩过的坑——比如事件风暴开到一半团队吵起来、比如订阅方丢了消息怎么补偿、比如两个上下文都想当大哥的治理难题。这些在今天的微服务治理里都是同样要命的真问题。
返回列表