ARTICLE DETAIL

资讯详情

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

架构不是套娃:简单代码胜过无脑的层次

架构不是套娃:简单代码胜过无脑的层次 架构不是套娃为什么简单的代码胜过无脑的层次上周我参加了一次代码评审项目组的同学把Controller、Service、Manager、DAO、Converter、DTO、VO全给安排上了。一个查询订单列表的接口调用链是这样的Controller → OrderService → OrderManager → OrderMapper → 数据库中间还夹着一层 OrderConverter负责把 Entity 转成 DTO再把 DTO 转成 VO。我随手点开 OrderManager发现整个类只有三个方法其中一个方法就一行return orderMapper.selectById(id);。我问为什么要这层 Manager回答是架构规范要求这样以后业务复杂了用得上。这个场景我见得太多了——它几乎成了很多团队的默认姿势把架构理解为套娃层数越多越专业。但实际体验却是灾难级的读代码要跨五个文件、改一个字段要过五道门、新来的同事看着这叠抽象直接懵。我想借这篇文章把一个问题彻底说透为什么简单的代码能赢过无脑的层次架构到底应该解决什么问题以及我们怎么把已经套上的娃一层层拆下来。这篇文章没有立场之争只聊实践。它基于我个人这些年做业务系统、带团队、做架构治理的真实体感适合被各种架构规范绑架的开发者也适合刚入行一心想搞大设计的同学。1. 一次让我失眠的评审Service 层只是“跑腿的”先说那次评审的完整细节吧它几乎能概括套娃式架构的全部病征。那是一个很普通的订单查询功能需求也就是“查列表展示状态和金额”。代码层面涉及 8 个类OrderController接收请求调用 OrderServiceOrderService定义接口做事务标记调 OrderManagerOrderServiceImpl实现类方法体就是把入参转给 Manager返回结果转给 ConverterOrderManager我原本以为这里会有业务规则结果大部分是透传OrderMapperMyBatis 的 Mapper 接口真正执行 SQLOrderDO数据库实体OrderDTO业务层的数据对象OrderVO给前端展示的数据对象。新增一个“订单来源渠道”字段我算了一下要动多少地方数据库加列OrderDO 加字段OrderDTO 加字段OrderVO 加字段Converter 里加三行转换逻辑Service 接口不用动但实现类可能要加参数Controller 的入参和出参也要对应调整。也就是说一个最简单的字段扩展要触碰 6 到 7 个文件。更让我不理解的是OrderService 的很多方法根本没有业务逻辑就是帮 Manager 传话Override public OrderVO queryOrderById(Long id) { OrderDO orderDO orderManager.queryById(id); return orderConverter.toVO(orderDO); }而 OrderManager 自己呢public OrderDO queryById(Long id) { return orderMapper.selectById(id); }两层加起来四行有效逻辑却要拆在两个类里还配了一个接口、一个实现。我问写代码的同学中间为什么不直接调 Mapper他说 Manager 以后可以扩展。我再问那现在这个 Manager 除了传话有没有任何哪怕一条独有的规则没有。那它凭什么叫 Manager这种代码我看过太多它有一个共同特征当你准备删掉某一层把所有调用直接接到下一层时发现系统完全不会受到任何影响。这话听着像玩笑但它是判断“装饰层次”的黄金标准。你删掉 OrderManagerController 和 Service 直接调 Mapper功能照跑测试照过唯一的损失是“架构图少了一层”。那这一层从一开始就不该存在。那次评审之后我失眠了一小会儿不是为这个项目担心而是想起自己早年也这么干过。当时刚学完“三层架构”觉得不写 Manager 就不是好系统。直到后来在一个老项目里我第一次体验到什么叫“代码很干净但根本没法干活”——每个类都长得很规矩但人和人的协作效率低得惊人。从那以后我养成了一个习惯任何新增的架构层次都必须回答“它承担了什么不可替代的职责”回答不上来就砍掉。2. 层层套娃是怎么长出来的过度分层的三条产生路径过度分层很少是一夜之间拍脑袋拍出来的它通常沿着三条非常具体的路径慢慢长出来。理解了这三条路径你就能在自己的代码里及时踩刹车。2.1 把“设计模式”当成必须执行的部门规章第一类套娃来源于对模式的崇拜。很多开发者在学设计模式时会形成一种错觉一个类不实现个接口、不套一层抽象就“不够面向对象”。我见过有人为了一个只有一种实现的支付服务强行定义了 IPaymentService 接口、PaymentServiceImpl 实现、AbstractPaymentService 基类还嫌不够又加了一个 PaymentStrategy 策略接口来包住实现类。我问他哪里能看出多态的必要性——他回答“以后会有多个支付渠道”。但现实是这个系统从上线到退役始终只有微信支付。那这些抽象层花了多少成本每次改支付参数你都要同时改基类、接口、实现类、策略类改完还要保证四处同步。这已经不是设计模式了这是在为想象中的需求写代码。模式是用来解决已经出现的扩展点不是用来给未来开空头支票的。策略模式真正的价值在于支付宝、微信、银行卡三种支付方式摆在眼前且它们的算法骨架相同、变化点不同抽象能帮你减少重复。如果只有一种支付渠道直接写一个类就是最好的设计。单实现接口、单实现抽象类、没有任何支路的抽象基本都属于给代码“上刑”。2.2 面试八股与团队规范的惯性第二类套娃来源于行业惯性。“Controller-Service-Mapper 三段式”几乎成了 Java 后端的默认出厂设置。这个结构本身没有错它对应的是早期 Web 应用的典型场景接口层、业务层、持久层确实有各自独立的关注点。但它被滥用是因为很多团队把它当成“无论业务大小都必须遵守”的模板而不是“根据你实际复杂度来调整”的参考。结果就是一个只有两张表、一个增删改查接口的小功能也要造出 Controller、Service 接口、ServiceImpl、Mapper、Entity、DTO 六件套。写起来特别慢读起来更慢。我经常和团队说三层架构是主流但主流不等于任何场景下都是最优。你做的是内部管理台的一个小模块业务就几十行你怎么设计都不需要套五层壳。架构规范应该是一把尺子不是一根锁链。2.3 防御式抽象赌未来一定复杂第三类套娃最隐蔽叫“防御式抽象”。它看起来特别理性现在虽然简单但以后业务会复杂现在把层次留好以后就能平滑扩展。问题在于这种“以后”从来没有准确的时间点也从来没有被验证过。等到三年后业务真的复杂了留下的那些空壳层次要么已经被忘干净要么因为当初猜错了方向而不适用。你为了一个永远不会来的复杂度先支付了三年的维护税。《极简主义》里有一句我特别认同的话YAGNI你不是真的需要它。代码里的每一个抽象都是有成本的它要有人写、有人看、有人维护、有人在改 bug 时多跳一层。你留的“扩展点”越多系统越早进入动什么都怕的状态。等新同事看到那一堆接口和 Manager 层时他根本不敢判断哪个能用哪个是摆设。防御式抽象还有一个后遗症——它会自己长大。因为空壳层次放在那儿很碍眼后来的人为了圆这个结构会继续往里填包、填类、填转换器。就像一个塞了太多隔板的柜子为了不浪费隔板你开始往每个格里塞本来不需要的东西。最后复杂度的确变得“很有组织”但也彻底失去控制。3. 套娃的真实代价四个被多数人忽略的成本讨论架构时大家喜欢聊“设计合理性”“扩展性”“整洁性”这些玄的词很少聊具体的账。我这次把它摊开来算一算套娃式架构到底贵在哪。3.1 阅读成本是一笔算术题读代码的成本 你需要在多少个文件之间跳转。跳转次数跟复杂度成正比而且不成线性是超线性。一个没有套娃的查询逻辑从 Controller 到 Mapper 就两层读代码的人脑子里的上下文大概是这样参数进来 → 验证一下 → 查数据库 → 返回数据。四步一个文件或两个文件。整个过程是线性的读到哪理解到哪。但套娃版的查询逻辑呢从 Controller 开始你要先看 Service 接口再看 ServiceImpl然后看 Manager 接口、ManagerImpl再看 Mapper 的 SQL最后还要看 DTO/VO 转换。每跨一个类你的短期记忆里就要多写一行“这个类是干嘛的”多一次方法签名的换算。如果八个类之间的调用关系不是完全直线中间还有分支和循环那复杂度直接爆炸。我做过一次简单的测试拿同一个功能的两版实现一版无分层透传一版五层套娃。让团队里两个水平相近的同事分别读无分层版大概 5 分钟就能讲明白数据流套娃版要 15 分钟左右而且中间会反复翻回去看“这个 Converter 到底转了什么”。这就是硬成本——15 分钟一个人十个人就是 150 分钟。而代码每天都在被读这笔账滚起来非常吓人。3.2 修改链条变长改每个字段都像过安检有人会说读代码慢点没关系改起来扩展性好就行。但套娃架构的修改成本同样被低估了。举一个我实际遇到过的例子某系统的用户信息页需要展示用户的会员等级名称。原本的调用链是 Controller → Facade → Service → Manager → Mapper → BO → DTO → VO。当时数据库里存的是会员等级 ID前端要的是等级名称于是代码里的处理方式是多查一张字典表。改这个需求需要动VO 加字段DTO 加字段BO 加字段Manager 加查询逻辑Service 加转换逻辑Facade 加一次透传Controller 的响应结构加字段。整整七个地方。新人接到这个任务光是把数据从数据库一路“运”到前端就要看五个类的关系其中任何一个类漏加字段编译不报错但线上显示 null。说得重一点套娃层次的每多一层就相当于给数据流加了一道“安检闸口”。数据每走一层都要被检查一遍、转换一遍、复制一遍。至于这些转换到底有没有意义往往是没人能回答的。真正的业务改动很多就被淹没在这种“过安检”的过程中团队把一天的工作时间花在了挪数据上而不是思考业务上。3.3 抽象泄漏分层没有藏住复杂度只是把它分配到更多类上架构界有一个公认的定律任何非平凡的抽象都会有泄漏。意思是你无论怎么封装、怎么分层底层的复杂性迟早会从某个角落渗出来。套娃架构最大的悖论就在这里你以为多包几层能藏住复杂度实际上只是把复杂度拆散了撒在更多的类里。数据库加了个字段DO 要改DTO 要改VO 要改转换器要改这一串本身就是泄漏的结果——你没有通过抽象减少变化点只是把同一份变化复制到了很多地方。真实世界里复杂性是守恒的。订单要有状态字段这个状态要么放在数据库实体里要么放在业务模型里要么放在前端 VO 里但它总归要存在。好的设计不是让这个状态“消失”而是让“改状态的实现”只牵动最少的地方。套娃架构恰恰相反它让状态字段遍布五六个类每一个都是改动时的泄露点。而且层越多你越难说清这个状态到底应该由谁负责——这就是所谓的“局部合理、全局失控”。3.4 测试、调试与“把套娃分布式化”的连锁反应这一层很多人体会不深我多说两句。套娃架构对调试的惩罚非常直接当线上出问题报错栈里从 Controller 到底层 SQL 可能横跨五六个类你要逐个排除。如果中间某一层是那种“空转”的透传方法你查半天才发现它没做任何事那种感觉真的很想摔键盘。更麻烦的是测试。有些人喜欢给这种套娃结构写单元测试结果发现 mock 链比自己写的代码还长mock Controllermock Service 接口mock ServiceImpl 内部调用的 Managermock Manager 里调用的 Mapper。真正被测的逻辑只有一行测试代码却写了一百行。这不是测试的问题是设计的问题——因为你把不可测的复杂性分布在太多层次上了。这套逻辑再往上蔓延就催生了另一个现象很多团队觉得系统复杂了第一反应是上微服务、上分布式架构恨不得把一个类拆成十个服务。结果套娃还在只是从方法级套娃升级成了服务级套娃。原本一个查询要跨 5 个类现在要跨 5 个进程接口联调、网络超时、数据一致性全部变成问题。分布式架构是解决并发规模和团队协作边界的工具不是解决“代码分层太多”的工具。你没把层次理顺就上微服务等于把一栋结构混乱的大楼硬拆成十栋大楼裂缝只会更多。4. 简单代码为什么能赢复杂度守恒与变化边界的账说了这么多套娃的坏话得说说好的架构长什么样了。我的观点很简单好的架构不是为了好看而是为了管理复杂度的变化边界。而大多数简单代码之所以能赢过套娃是因为它在复杂度守恒定律下把该承担的复杂度放对了位置。4.1 复杂度守恒抽象不会消灭复杂度只会转移它先记住一句话任何系统的业务复杂度都是守恒的你不可能通过加一层抽象让它消失。订单有状态机支付有回调库存有并发扣减这些复杂度从你接到需求那一刻就存在了。你写不写 Manager 层它都在那里。那抽象的价值是什么是隔离变化让某种复杂度在变化时只影响一小块区域。数据库字段变化只影响 ORM 实体前端展示变化只影响 VO这是合理的隔离。但如果你的隔离层本身也是同一块区域的一部分比如业务逻辑和数据库访问根本没有分离你却硬拆了两层那这个抽象就没有隔离任何东西它只是在给代码搬家。这就是为什么简单的代码能赢它不假装复杂度不存在而是尽量让一个类、一个方法承担完整而清晰的职责。同样一个订单状态流转在一个类里用状态机写得清清楚楚肯定比拆成“OrderStateService”“OrderStateManager”“OrderStateTransition”再加接口实现让人省心得多。4.2 变化的边界按变化率切分不按职责名切分那什么才算真正的边界我这些年总结了一条很实用的原则看变化率。变化率相近的代码放在一起变化率差异大的地方才是需要做隔离的边界。数据库表结构、ORM 实体和 SQL 的变化率是一致的——表加字段实体跟着加。把实体和 Mapper 放在一起没啥问题。前端展示逻辑VO、DTO的变化率也很高——前端三天两头改字段。让 VO 跟上层的 Controller 直接在一起比让它跟 DO 反复转换更合理因为“给前端什么数据”这个问题本来就属于接口层。业务规则的变化率介于两者之间但它只跟业务本身相关跟数据库结构并不完全同步。比如“订单超过 30 分钟未支付自动关闭”这条规则是业务的核心它被放在 Service 里还是 Domain 里取决于你的架构风格但它的变化频率和数据库表结构的变化频率明显不同所以它不应该和 DO 混在一起。真正的边界就画在这些“变化率不同”的临界处。你画出的每一条边界都是为了挡住一边的变化波及另一边。而套娃架构里的很多层次两侧的变化率根本没差别——Service、Manager、Repository 可能一年都不变一次那它们之间的边界就是摆设白白增加沟通成本。4.3 简单代码的设计质感少即是多有人会误以为“简单代码”等于“没有结构”“一把梭”。真不是。简单代码和草率代码之间有一条明确的分界线简单代码依然有清晰的职责划分、准确的命名、明确的依赖方向只是它不搞没有信息量的转发层。看一个方法理想状态是你能在 30 秒内说出它接收什么输出什么中间做了哪几件事。比如public OrderDetailVO getOrderDetail(Long orderId, Long userId) { Order order orderRepository.findByOrderIdAndUserId(orderId, userId); checkOrderVisible(order, userId); ListItemVO items itemConverter.toItemVOList(order.getItems()); return OrderDetailVO.of(order, items); }三件事三个依赖一眼到底。你不会去猜“orderRepository 是不是还有一层 orderRepositoryImpl 包着”。如果将来业务复杂了需要加缓存你可以在这个方法内部加一行cache.get(orderId)需要加权限判断你已经在做了需要换成读写分离那是 repository 内部的事。它不是没有结构它的结构就是和当下最匹配的结构。把代码拆得多细很容易把一个方法演化到刚刚好很难。刚刚好意味着你增加的每一个类、每一层抽象都能直接回答“删掉它我会失去什么”。回答不上来的就是债务。5. 什么时候分层是必要的边界不是用来凑数的文章写到这儿如果给你留下“所有分层都该砍掉”的印象那是我表达得不够准确。分层是软件工程一个极其重要的武器问题是很多人体会到了它的威名却没体会到它的适用边界。这一节说说我心目中真正“值得分层”的几个场景。5.1 接口层与业务层GUI 时代就存在的合理隔离最简单也最经典的一处分层是接口层Controller/Handler和业务层Service/Domain的分离。它的历史可以追溯到 GUI 时代那时候叫“三层结构”表示层、业务层、数据层目的很明确界面可以随便改底层业务不被牵着走。直到今天这个边界依然成立。Controller 负责处理 HTTP 协议细节——参数校验、状态码、响应包装Service 负责业务规则。你把这段边界拆了让 Service 直接抛一堆 HTTP 状态码给框架系统会乱套。这类分层的价值显而易见变化源不同一个是网络协议一个是业务规则频率不同方向不同必须隔离。5.2 外部依赖隔离数据库和第三方服务是真正的火山口第二类值得做的隔离是围绕“外部世界”的边界。数据库、Redis、第三方接口这些都属于你控制不了、变化也由不得你的外部系统。业务代码直接写 SQL、直接调第三方 HTTP 接口短期能跑但一旦对方接口调整或者数据库分库分表你的业务代码会被牵连得血肉模糊。所以 Repository 模式、防腐层Anti-Corruption Layer在这个语境下是真有用的。它把“外部怎么变”挡在门口让业务代码只说“我要什么数据”不问“数据从哪来”。这个抽象不是装饰它是真正的防弹衣。注意它和那种“业务内部套 Manager”有本质区别它隔离的是不同的变化源而不是在同一个变化源里玩接力。5.3 多团队、多服务边界分布式的边界反而要少而清晰进入微服务架构、分布式架构之后很多人产生一个误解服务拆得越细边界越多架构越好。实际恰恰相反服务间每多一条边界你就要多一份网络通讯、多一份数据一致性负担、多一份部署协调成本。所以分布式架构的边界应该是“少而清晰”不是“多而密集”。一个领域内的强内聚业务哪怕很大也值得放在一个服务里通过模块来划分内部结构只有当某个子域变化频率明显不同、需要独立扩展、需要独立团队维护时才值得把它拆成独立服务。你要是按数据库表来拆服务——用户服务、订单服务、订单项服务那会死得很惨因为一次查询要跨三个服务你等于把套娃从代码层搬到了运维层。从这个角度看微服务的正确用法不是“让层次更多”反而是“让跨进程的调用更少”。真正的高手做微服务都在努力减少服务间调用把高频业务放进同一服务内部完成而不是为了“边界清晰”把一家人硬分成三个户口本。5.4 判断标准删掉这层我会失去什么聊了这么多很多同学可能还是不知道具体怎么判断。我给你一个傻瓜但好用的判断标准拿去套任何一层都行删掉这层把下层的实现直接暴露给上层我会失去什么如果答案是“失去网络隔离、失去安全性、失去外部依赖封装”——这层该留。如果答案是“失去一份整洁感、失去一个以后可能用到的扩展点”——这层就是套娃删。我之前带团队重构一个老项目时就用这个标准逐层审查。结果发现有 70% 左右的名字带 Manager、Facade、CommonHandler 的层次删掉之后系统完全不受影响甚至测试跑了更顺。剩下 30% 是真正负责协议转换、事务控制、外部资源访问的它们留下来项目整体反而清爽了不少。提示判断要不要保留某层永远以“当前真实存在的职责”为准不要以“将来可能出现的职责”为准。未来的职责留给未来去建现在的职责必须现在起作用。6. 动手拆套娃一套可以照做的减法清单理论说完了接下来说说具体怎么操作。如果你现在手上正好有一个堆了四五层的项目别慌按下面这个顺序慢慢拆。6.1 第一步画出调用链圈出“无转发”的类打开 IDE用调用链插件或者手工搜把核心业务的调用路径画出来。然后把每个类标注成三种角色有逻辑的、只传话的、只转换的。“只传话”的类特征很明显方法体就是一行return xxx.yyy()参数不做加工返回值不做处理事务不在这里开启异常不在这里处理。这类就是第一个要删的目标。我一般会先挑一个改动最少的业务线做试点把中间的传话层删掉让上层直接调真正干活的类。改完跑一遍全量测试确认没问题再推广。别想着一天把整个项目拆完那是重写不是重构风险太大了。6.2 第二步合并“动辄改名”的 DTO / VO / Entity第二个重点整治区域是数据模型。Entity、DTO、VO 三者之间动不动就来回转换这种转换代码看着是“规范”实际上大部分只是字段名复制没有任何信息增益。我的建议是同一业务域内如果没有明确的权限隔离或跨服务传输需求尽量复用同一个模型类。你可以在包名上区分model/request/response但不要在类层面造三个长相差不多的类。前端的展示字段和服务内部的字段也许有差异但大多数业务系统里差异远没有想象中大用一个小方法做局部映射就够了没必要搭一套转换器。如果一定要用 DTO 做隔离比如有敏感字段不想输出到前端也只在接口边界做一次转换不要在中间每一层都转一遍。数据从持久层到接口层之间能少复制一次就少复制一次。6.3 第三步用接口表达意图而不是表达层次拆的过程里你会发现有些接口也不是全没用它定义的是“这个服务对外承诺的能力”这对多实现、Mock 测试是有帮助的。但有一种接口纯属层次标志OrderManager接口里只有queryById一个方法实现类也只有一个。这种接口的存在意义只是告诉别人“你看我这有个 Manager 层”。你可以考虑把接口尽量保留在真正需要抽象的边界上比如外部依赖、多策略实现。内部的纯业务调用直接用具体类就够了没必要每个 Service 都配一张接口脸。Java 社区这么做的人很多换来的是代码跳转变少、阅读变快。如果你担心以后要换实现真到了那时候再抽接口也就半天的事。6.4 第四步立一条团队约定——新增层次必须过评审拆完之后最难的是防止它长回去。我建议在团队里立一条约定往项目里增加一个新的架构层次新包、新后缀、新调用层必须走代码评审并且要回答“删掉这层会失去什么”。这条约定看起来严苛但非常有效。很多人在写代码时只是习惯性地想“这里要不要抽一层”一旦要过评审他就会认真想一下这一层到底在解决什么问题。答不上来的自然就会放弃。答得上的留下来也不亏。用这种方法我们团队用了半年时间把核心工程从二十多个包压缩到十几个包代码量没减多少但认知负荷明显下降。新人上手时间从两周缩短到三四天。6.5 最后的话架构是动词不是名词拆完一套娃之后我特别想分享的一点是架构不是一个静态的名词不是一张很厚的分层图而是一个动态的动词是你在每一个需求面前“选择把复杂度放在哪儿”的动作。好的架构师不是在画图而是在砍结构。我见过最好的代码库不是那种层层叠叠、包名一大串的“标准结构”而是那种改动一个字段只要动两个文件、看一个方法从头到尾不用跳转的朴素系统。它不惊艳但特别耐操而且每个接手的人都会感谢前人的克制。如果你现在正被某个“架构规范”压得喘不过气我建议你做一次实验挑一条最常改的业务线把中间所有只会传话的层全部删掉然后感受一下改需求的流畅度。我赌你会上瘾。
返回列表