首页 / 资讯中心 / 文章详情

架构图之外:常见软件架构模式的依赖约束与选型

架构图之外:常见软件架构模式的依赖约束与选型 ★ FEATURED ARTICLE
接手一个项目的第二天我在代码仓库里看到三个包互相引用订单相关的包调用库存相关的包库存相关的包反过来又调用订单相关的包中间还夹着一个名字里带 common 的包被两边同时依赖。项目文档里那张架构图画得很干净箭头从上往下整整齐齐。代码里的依赖关系却是另一回事。这件事让我意识到大多数人理解的软件架构模式和实际代码里跑着的架构模式中间隔着一条很宽的沟。架构图是给人看的依赖关系是给编译器看的两者对不上的时候出问题的永远是后者。我写这篇东西的出发点很直接把几种常见软件架构模式从图上拽回代码里讲清楚它们各自约束了什么、在什么场景下能省事、什么场景下反而是自找麻烦。适合已经写过一两个完整项目、开始觉得代码越来越难改但又说不清为什么的人看。如果你刚入门也能看懂因为我会尽量用具体场景和代码片段去解释而不是堆术语。1. 架构图画得再漂亮也挡不住依赖关系在代码里长歪1.1 一个很典型的翻车现场几年前我参与过一个后台系统的重构。原系统的结构是这样的一个巨大的业务包里面既有订单、结算也有库存、物流还有一堆工具类。业务包外层是一个接口层负责接收请求、校验参数、拼装返回。听起来是标准的分层结构问题出在接口层里塞了大量业务判断。比如这个订单能不能取消这件事判断逻辑在接口层的某个方法里写了三十多行同时业务包里也有一份几乎一样的判断两份代码后来改得不一致线上出现过一次取消成功但状态没同步的问题。这就是典型的情况图纸上是分层架构代码里其实是分层目录 平铺逻辑。判断一个项目属于什么架构模式不能看目录名要看依赖方向。把所有的 import 或者 require 梳理一遍画成依赖图如果出现环那它就不是分层架构是一个被目录名伪装起来的单体。一个很简单的自查方式随便挑一个业务类看它引用了几层之外的东西。如果业务逻辑层直接引用了接口层的 DTO或者反过来接口层直接操作了数据访问层的对象分层就已经被打破了。1.2 模式真正约束的是依赖方向很多人把架构模式理解成一堆必须遵守的目录结构这理解偏了。架构模式的核心价值是给依赖关系定一条规矩谁可以知道谁谁不能知道谁。分层架构的规矩是只允许上层知道下层事件驱动的规矩是发布者不需要知道订阅者是谁端口与适配器的规矩是核心业务不认识任何外部技术细节。定了这条规矩之后带来的是三个实际的好处。第一是可替换性下层换实现上层不用动。第二是可测试性核心逻辑不依赖外部资源测试可以跑得飞快。第三是可理解性新人读代码时知道去哪儿找什么不会在一个方法里从参数校验一路读到 SQL 拼接。三个好处里我认为第二个最被低估。我见过太多项目业务逻辑和数据库操作搅在一起写一个单元测试要先起一个数据库。测试跑得慢团队就懒得写久而久之测试覆盖趋近于零任何改动都靠手工回归迭代速度自然就慢下来了。1.3 先给你的项目做一次形态体检在讨论具体模式之前我建议先做一次简单的体检。拿张纸把项目里的主要模块列出来然后两两之间画箭头表示依赖方向。有几个信号值得警惕信号说明的问题常见后果出现循环依赖模块边界基本失效无法单独打包或部署改动影响面不可控工具包被所有模块依赖工具包里混进了业务逻辑改工具包等于全量回归业务模块直接引用接口层对象分层被穿透接口字段变更引发业务层编译失败模块之间大量共享同一个实体类领域边界模糊任何字段调整牵一发动全身只有一个模块承担了大部分依赖事实上的中心化这个模块成了变更瓶颈这张表我在不同的项目里反复用过基本上能覆盖八成以上的结构问题。体检完再谈选哪种模式才有意义。否则容易变成为了用模式而用模式最后结构更复杂问题一个没解决。2. 分层架构最老的一套为什么现在还在用2.1 那条只能单向下行的硬约束分层架构的思路极其朴素把系统切成若干水平层每层只对相邻的下一层负责。经典的四层是接入层、应用层、领域层、基础设施层。接入层处理协议和参数应用层编排用例领域层承载业务规则基础设施层对接数据库、缓存、外部服务。这条约束的威力在于它把业务规则这件事从技术细节里剥出来了。领域层不需要知道请求是从 HTTP 来的还是从命令行来的也不需要知道数据存在关系型数据库还是文件里。当业务规则不依赖这些的时候它就可以被单独测试、单独复用甚至可以跑到另一个完全不同的技术栈上。我见过不少团队把分层做成五层、六层中间加一个转换层、一个防腐层。层数一多一个请求要穿过七八个类每层做的也就是字段拷贝。这种分层不是架构是搬运。2.2 业务逻辑往接口层渗的三条路径业务逻辑跑进接入层通常不是因为有人故意违反规范而是因为这三种情况第一种是顺手判断。写接口的时候发现需要校验某个业务状态才能继续就地在接口层写了个 if。这个 if 单独看没什么但它和真正的业务规则形成了两份实现迟早不一致。第二种是组装返回。接口层需要拼一个包含多个业务对象的视图于是它自己去调了两个数据访问方法在接口层完成了业务数据的组合。这一步越界之后接口层就掌握了业务知识。第三种是参数预加工。接口层为了方便把外部传入的原始值先做了一轮业务含义上的换算比如把价格单位从分转成元、把状态码映射成枚举。这些换算本质上属于业务规则放在接口层之后其他入口比如定时任务、消息消费者就享受不到了。这三种情况的共同点是当时看起来省事。我的处理办法很简单在代码评审时只问一个问题——这段逻辑如果明天加了一个消息消费者入口还需要再写一遍吗如果答案是需要那它就不属于接口层。2.3 层切多细算合适关于层数我有三条偏经验的判断线第一如果一个层里的类只做字段复制没有任何判断、计算或状态转换这个层大概率可以合并掉。字段复制是纯开销读代码的人要跳三次才能看懂一条数据的流向。第二如果两个层之间的调用关系永远是一对一没有分支、没有跳过、没有任何一个方法不经过中间层那这两层可以合并。第三领域层的划分标准应该是业务规则变化的频率而不是数据表的数量。订单和订单明细经常被拆成两个服务类但它们的变化节奏几乎是一致的拆开之后每次改都要动两处。一个三层结构接入、领域、基础设施能撑起绝大多数业务系统。层数不是能力的体现边界清晰才是。2.4 一段薄接入层大概长什么样下面这段是接入层该有的样子只做协议转换和调用编排不含任何业务判断public class OrderEndpoint { private final OrderApplicationService appService; public OrderEndpoint(OrderApplicationService appService) { this.appService appService; } public Response cancel(String orderId, String operatorId) { // 只做参数适配和异常映射 CancelCommand cmd new CancelCommand(orderId, operatorId); appService.cancel(cmd); return Response.ok(); } }真正的能不能取消判断放在应用服务或领域对象里public class OrderApplicationService { private final OrderRepository repo; public void cancel(CancelCommand cmd) { Order order repo.findById(cmd.getOrderId()); // 业务规则收敛在领域对象内部 order.cancel(cmd.getOperatorId()); repo.save(order); } }Order.cancel里面才是状态检查、权限检查、取消时间窗判断这些内容。这样做的好处很实在定时任务批量取消、消息触发的自动取消走的是同一份规则不存在两套实现打架的问题。3. 事件驱动解耦省下的调用最后都变成排查成本3.1 从直接调用改成发消息改的是整套思维方式同步调用的思维方式是我做完这一步才继续下一步。事件驱动的思维方式是我宣布一件事发生了至于谁关心、关心之后做什么我不管。这两者的差别不只是代码写法是责任边界。我在一个项目里推动过这个改造。原来的流程是下单成功后依次调用积分服务、通知服务、报表服务任何一步慢都会拖住下单接口。改成发一条订单已创建的消息之后下单接口的耗时从几百毫秒降到了几十毫秒体验肉眼可见地变好。代价也随之而来。原来调用失败会抛异常调用方立刻知道。现在消息发出去就结束了下游处理失败调用方毫无感知。有一次积分服务因为一个空指针挂了两个小时期间所有订单都没有加积分用户投诉上来才发现。这类问题的排查链路比同步调用长得多先确认消息发出去没有再确认消费端收到没有再确认处理逻辑有没有异常。3.2 重复、丢失、乱序这三件事怎么兜上事件驱动之前这三件事必须先有答案否则不要上。重复消费。消息中间件为了保证不丢通常会给至少一次的投递承诺重复是必然的。解决办法是幂等消费端维护一张处理记录表以业务唯一键比如订单号 事件类型作为主键处理前先插入插入冲突就说明处理过了直接跳过。这张表要定期清理否则会无限膨胀。消息丢失。链路上有三个可能丢的点生产者发送失败、中间件持久化失败、消费者处理完但确认没送达。第一个点靠发送端的重试加本地消息表兜底第二个点靠中间件的持久化配置第三个点靠处理完再确认的顺序。三个点都要覆盖缺一个就等于没做。乱序。同一个订单的创建和取消两条消息如果落到不同分区或者被并发消费取消可能先到处理时找不到订单。做法有两种一是按业务键做分区保证同一业务键的消息进同一个分区二是在消费端做状态机收到不合法状态转移的消息就暂存或丢弃。3.3 我坚决不上事件驱动的几种情况不是所有解耦都值得。以下场景我会明确拒绝业务流程本身是强一致的比如扣款和记账必须同时成功。硬拆成两个事件中间失败就得做补偿补偿逻辑的复杂度往往超过原来的同步实现。调用链路只有两三个下游且都很稳定。为了两个调用方引入一整套消息基础设施运维成本远大于收益。团队里没有人能独立排查消息堆积的问题。消息堆积是必然会发生的没人能处理的时候它就是一颗定时炸弹。4. 微服务拆分切出分布式单体的几种典型手法4.1 按团队边界切还是按业务能力切这两种切法我都试过结论是短期看团队长期看业务能力。按团队切的好处是沟通成本低一个团队负责的服务接口变更自己就能拍板。坏处是团队调整的时候服务边界跟着乱。我见过一次组织架构调整两个团队合并原本分开的两个服务立刻出现了大量交叉调用因为业务上它们本来就是一件事。按业务能力切的好处是边界稳定业务能力的变化频率远低于组织架构。判断标准是这个能力能不能独立回答谁、在什么条件下、可以做什么这一组问题。能就是一个独立的服务候选。现实里的折中方案通常是先按业务能力划分出候选边界再看团队能不能对齐。如果不能宁可先在一个服务里用模块隔开等人和边界都稳定了再拆。4.2 拆分前的六项自查我在动刀之前会逐条过一遍这几项任何一项打不上勾我都会暂缓自查项判断标准不满足的后果数据能否独立这个服务的数据能否单独建库不跨库联表拆完还是要跨库查询等于没拆事务能否收敛核心流程的事务能否在一个服务内闭合需要引入分布式事务复杂度陡增调用链是否可观测有没有统一的追踪 ID 贯穿全链路出问题定位不到是哪一跳部署能否独立发布这个服务是否不需要同时发布别人变成分布式单体的典型特征团队是否配套有没有明确的人对这个服务负责出问题互相推诿回滚方案是否清晰出问题能不能单独回滚这个服务一滚就是全量回滚拆分无意义第三项和第五项最容易被忽略。没有链路追踪的微服务系统排查问题的体验比单体还差因为单体至少日志在一个文件里。4.3 拆完之后最先出问题的往往是数据大多数人以为拆服务难在接口设计实际难在数据。原来一个事务能保证的事拆开之后要靠最终一致。我遇到过一个很典型的坑订单服务和库存服务拆开之后下单流程变成创建订单 → 扣减库存中间任何一步失败都要补偿。补偿逻辑写了两百多行还漏掉了库存扣减成功但订单创建超时这种中间态导致过一批库存被锁死但没订单的数据。后来我们的做法是把扣减库存改成预占库存预占记录带一个过期时间订单创建成功后转成正式扣减超时未转正则自动释放。这样即使中间态卡住系统也能自愈。这个思路不新鲜但确实需要在上线前就想清楚而不是等出了数据问题再补。5. 端口与适配器让换数据库从口号变成可执行的事5.1 六边形架构真正想解决的是替换成本端口与适配器这个模式也常被叫作六边形架构的结构是核心业务在中间对外提供一组端口就是接口定义外部技术细节通过适配器实现这些端口。数据库是适配器消息队列是适配器HTTP 接口也是适配器。它想解决的问题很具体核心业务不应该因为外部技术变化而改代码。听起来理想化但在一种场景下非常实用——同一个业务逻辑需要多个入口。比如一个下单逻辑既要通过接口调用又要通过消息触发还要提供一个命令行工具给运维做数据修复。如果业务逻辑直接依赖了 HTTP 的请求对象那另外两个入口就得重写一遍。我见过一个反例一个核心的结算逻辑写在一个方法里入参是框架的请求对象。后来要做批量结算只能硬造一个假的请求对象传进去。这种代码能跑但每次看到都让人心里发毛。5.2 过度设计的红线在哪这个模式最大的风险是接口泛滥。一个简单的增删改查硬要拆成仓储端口 查询端口 命令端口 事件发布端口四个接口五个实现类改一个字段要动六个文件。我的红线是只有一个实现、且短期内不打算替换的外部依赖不要抽端口。数据库只有一个、短期内不会换那在领域层直接依赖一个仓储接口就够了不需要再在接口外面套一层数据源抽象。真有替换需求的时候重构的成本也不高因为仓储接口已经在正确的位置上了。判断标准可以简化为一句话这个抽象是为了让测试更容易还是为了应对未来的变化前者值得做后者大多数时候是自我安慰。5.3 一个能落地的折中做法完全按六边形架构组织的项目我很少见到更常见的是折中版本我把它总结成三条第一领域层的对象不引用任何框架的类。参数校验、序列化注解统统放到接入层的对象上领域对象保持纯净。这一条收益最大成本最低。第二外部依赖统一从接口注入。数据库、缓存、外部服务都定义成接口实现在基础设施层。这样测试时可以替换成内存实现跑得快。第三接口数量跟实现数量挂钩。有第二个实现的时候再抽接口不要提前抽。按这三条做代码结构不会太教科书但能拿到八成的收益剩下的两成留给真正需要的时候再补。6. 管道、CQRS 与插件化几个容易被忽略但很好用的模式6.1 管道-过滤器数据加工场景的默认解当一个需求是一堆数据依次经过若干处理步骤时管道-过滤器几乎是最自然的写法。每个步骤是一个过滤器输入输出格式统一步骤之间可以自由组合和排序。典型场景是数据导入读取文件 → 校验格式 → 字段映射 → 去重 → 落库。如果写成一个三百行的方法中间任何一步要调整都得通读全文。拆成五个过滤器之后加一步敏感字段脱敏只需要新增一个类插到合适的位置就行。这个模式的关键是定义好过滤器之间的数据契约。我的做法是定义一个上下文对象包含原始数据和一份处理结果每个过滤器读自己关心的部分、写自己产出的部分。避免让过滤器之间互相知道对方的存在那样组合的自由度就没了。class ImportContext: def __init__(self, raw): self.raw raw self.rows [] self.errors [] self.stats {} class Validator: def handle(self, ctx): for row in ctx.rows: if not row.get(id): ctx.errors.append((missing_id, row)) return ctx class Deduplicator: def handle(self, ctx): seen set() kept [] for row in ctx.rows: if row[id] in seen: continue seen.add(row[id]) kept.append(row) ctx.rows kept ctx.stats[dedup_removed] len(seen) return ctx调用方按顺序执行过滤器列表就行顺序调整、步骤增删都不影响已有代码。6.2 CQRS读和写本来就是两种形态CQRS 的核心主张很简单读操作和写操作使用不同的模型。写模型关注业务规则的完整性和一致性读模型关注查询效率和数据形状。为什么值得拆因为两者的优化目标天然冲突。写模型希望结构规范、约束完整、便于校验读模型希望字段扁平、索引合适、一次查询出结果。强行用一套模型满足两边结果往往是写的时候为了满足查询加了冗余字段读的时候为了拼装完整对象做了多次关联。我实际用过的场景是报表和列表页。写模型保持规范的三范式结构读模型针对列表页建宽表通过消息同步更新。查询接口直接打宽表响应时间从原来的几百毫秒降到几十毫秒。需要注意的坑是同步延迟。用户提交之后立刻刷新列表可能看到旧数据。处理办法是提交成功后让前端等待一小段时间或者把刚提交的那条直接合并到返回结果里。这两种做法都不完美业务上要能接受才行。6.3 插件化把变化点收进一个接口插件化架构的适用场景是主干稳定、扩展点明确。主干流程固定不同的业务方或不同的场景需要不同的处理逻辑把差异部分定义成扩展接口运行时按配置或按条件加载不同的实现。我参与过一个内容审核相关的系统审核规则有十几条不同业务线启用的规则不一样。如果全写在一个方法里是一串 if-else加一条规则要改动核心类。改成插件化之后每条规则是一个实现类注册到一个规则注册表里按业务线配置启用哪些规则。加规则变成新增一个类加一行配置核心流程完全不用动。插件化最容易失控的地方是插件之间的执行顺序和相互影响。我的经验是强制要求每个插件无副作用、不依赖其他插件的执行结果如果做不到就说明这两个插件其实是一个插件。7. 选型时我实际在权衡什么7.1 一张我常用的对照表模式最适用的场景主要代价什么时候别用分层架构大多数业务系统的默认选择层数过多会变成字段搬运极简的脚本类工具事件驱动多下游、允许最终一致排查链路变长需要幂等和补偿强一致的核心资金流程微服务多团队并行、独立发布诉求强运维与可观测性投入大团队小于十人、业务还在探索期端口与适配器核心逻辑需要多入口或高频测试抽象层数增加阅读跳转变多只有单一实现且短期不变管道-过滤器数据加工、导入导出上下文对象容易变成大杂烩步骤之间有强耦合的判断CQRS读写负载差异大、报表复杂存在同步延迟数据量小、查询简单插件化主干稳定、扩展点清晰的规则类系统插件间的隐式依赖难管理规则之间需要互相参考结果这张表我一般会拿出来跟团队过一遍重点不是选出最好的而是让所有人对我们要付出什么代价有共识。选模式这件事本质是选一组你能承受的代价。7.2 三次判断失误的复盘第一次是给一个刚起步的内部工具上了微服务。当时觉得以后会变大提前拆了四个服务。结果头半年业务一直在变每次改一个流程要同时发布三个服务联调时间比开发时间长。后来合并回一个服务开发效率立刻上来了。教训是拆分应该由当前的痛点驱动不是由对未来的预期驱动。第二次是在一个必须强一致的场景硬上事件驱动。扣款和记账拆成两个事件中间失败靠补偿。补偿逻辑上线第一个月就出了两次数不平的问题最后加了对账任务每天跑。其实这个场景同步调用完全够用。教训是解耦的红利要用能否接受不一致来换换不了就别换。第三次是把分层做得太细。接入层、应用层、领域层、基础设施层之外又加了转换层和防腐层。一个查询请求要经过七个类其中有四个类只做字段复制。半年后新人入职看了两天没理清数据流向。后来砍掉两层代码量少了三成可读性反而提高。教训是每增加一层都要能说清楚它挡住了什么变化说不清就是多余。7.3 模式复杂度和团队规模要匹配这一条我觉得比前面所有技术细节都重要。模式的复杂度本质上是在用结构复杂度换协作复杂度。团队小、沟通顺畅的时候用简单结构就够了因为人少口头对齐成本低。团队大了沟通成本上升这时候才需要更明确的结构来约束行为。一个五人的团队用微服务加分库分表大概率是在给自己找麻烦。反过来一个五十人的团队还把所有代码放在一个工程里每次发布都要排队那也是灾难。复杂度不是越高越好是跟协作规模对齐最好。我在实际项目里判断的标准很朴素如果我请一天假团队里其他人能不能顺利发布能说明结构够用不能说明结构里还有太多只有某个人知道的隐式知识那要补的不是更多模式是文档和边界。
阅读完成 · 觉得有帮助?
咨询建站