架构不是套娃:识别和拆解冗余分层的实用指南

发布时间:2026/10/4 4:15:27
架构不是套娃:识别和拆解冗余分层的实用指南 不知道你们有没有过这种经历接手一个老模块打开代码发现 Controller 调 ServiceService 调 ManagerManager 调 DAO 封装DAO 封装底层还有一个 Repository 包装中间穿插着 DTO、VO、BO、Entity 四个对象互相拷贝。你只是想把用户昵称改一下结果顺着代码跳了八层每层都是免检转发最后在某个角落找到一行user.setName(name)。那一刻你会怀疑自己是不是在拆俄罗斯套娃——打开一个还有一个拆到最后发现最小的那个娃娃跟外面那个长得一模一样。这个现象在行业里太常见了甚至被不少人当成了“架构感强”的标配。但架构从来不是套娃简单朴素、能一口气读到底的代码往往比那些层次齐全却无事可干的代码健康得多。这篇就想认真聊聊这个问题为什么无脑的层次会拖垮项目以及我们到底应该怎么区分“必要的分层”和“多余的套娃”。这篇内容不是写给刚入门的新手看的也不是写给架构师看的而是写给所有每天都在写业务代码、对分层这事既敬畏又头疼的工程师。如果你也在为一个“不这么套就不安心”的代码库发愁或者你正打算重构一个被层包成粽子的老系统那这篇文章值得你花十分钟从头读到尾。1. 别急着骂套娃到底是从哪来的说“无脑层次”之前得先搞清楚它们是怎么长出来的。这不是某一个人拍脑袋的恶趣味而是多种因素凑在一起最后长出了一棵“层叠式”的代码蘑菇。1.1 被大厂模板和教材带偏的默认动作多数人第一次接触企业级项目都是从一套所谓“标准三层架构”开始的Controller、Service、DAO。这个结构本身没毛病它把输入输出、业务逻辑、数据访问分开是合理的关注点分离。问题出在大家都喜欢往上叠东西却不知道什么时候该停。比如我们见过很多项目把 Service 又拆成 Service 接口 ServiceImpl 实现哪怕这个 Service 这辈子只有一个实现类也要先定义接口再写实现。问为什么这么做回答通常是“以后可能有多种实现”。可实际情况是三年过去了这个项目还是只有一个实现类但每个读代码的人都要在接口和实现之间来回切三次才能看懂一个方法。同样的事情发生在 DAO 层。JPA 时代findById本身就在 Repository 里代码却还要在 DAO 接口、DAO 实现、Repository 接口之间再包一层。数据访问根本没被“隔离”只是被“转发”了三遍。这种套娃的真正来源不是需求而是模板。当一部分人默认“好项目都是这么写的”套娃就成了政治正确。1.2 把“可扩展”理解成了“多套一层”我见过太多架构评审核心问题不是“这个设计能不能解决问题”而是“这个设计能不能应对我们想象中的未来”。为了应对未来大家默认给每个模块加一层抽象加一层接口加一层适配器好像加得越多就越能抵御需求变化的风险。真实情况恰恰相反。未来是不可预测的你为了应对未来加的每一层抽象都会变成现在的负担。如果你加的抽象点刚好和未来的变化点对上了那当然赚了但如果对不上——而十有八九对不上——你就是在用现在的复杂度为过去的猜测买单。举个例子。某同事在设计一个导出功能时给数据源加了一层“策略接口”理由是以后可能支持不同格式的导出。但实际上这次只支持 Excel而且内部也没有第二个策略实现。结果这个策略接口除了让人看不懂“为什么一个分支还要接口化”没有带来任何好处。等真正需要支持 CSV 时改动方案大概率是要把这个接口重新设计一遍——因为当时的抽象维度按格式拆分跟实际的变化维度按字段配置拆分完全不是一回事。1.3 “无脑层”的心理成因安全感、洁癖、怕背锅除了技术和模板因素套娃还有心理层面的原因。一个是安全感多套一层好像就多了一层缓冲就算底下烂了表面也挺光鲜。一个是洁癖总想把代码分得特别干净但分层的标准不是“看着舒服”而是“每个层都有独立的存在价值”。还有一个更现实的怕背锅。你做一个模块如果直接在一个方法里完成了全部逻辑写起来是快但代码写长了项目里其他人就会说“这代码太乱了为什么要全部塞在一起”。于是你开始拆拆出一个又一个类一个又一个层哪怕很多类只是把参数往里传了一下。因为你发现别人判断你的代码好不好第一眼看的是“结构”而不是“效率”。说到底套娃是一种集体无意识。大家都觉得“多一层比少一层更专业”于是层次就开始膨胀。要想打破这个局面得先从认知上改变。2. 无脑套娃的三个真实代价如果说套娃只是“难看、冗余”那忍忍也就算了。但它的代价是实打实的三个方向都疼。2.1 认知成本代码读完就已经被累死了程序员读代码的时间远远大于写代码的时间这是行业共识。每多一层转发读代码的人就要多一次“解码”。你要从 A 层跳到 B 层再从 B 层跳到 C 层心里还要记住每一层有没有做额外的处理。当这些层只是透传时你跳完三次之后会发现什么都没发生但你已经被折腾了三次。这种认知成本很难量化但它真实存在。我接手过一个网关模块一个请求从入口到落库一共经历了九个类。每个类都是单方法转发方法名几乎一样参数也一样。我花了一个多小时画调用链最后发现核心逻辑其实就 200 行。那 200 行并不复杂复杂的是它们被埋在 9 个文件里每个文件看起来都“很规范”但组合在一起就是一场灾难。还有更坑的因为层多IDE 的调用链追踪经常断在半路你得手动找下一层藏在哪个包。读这种代码就像在走一个岔路极多的迷宫走着走着就忘了自己为什么进来。2.2 运行成本虽然没有网络调用但空转也是成本无脑分层的第二个代价是运行时开销。很多人觉得 Java 或者 C# 里每层只是个方法调用开销可以忽略不计。单次调用确实可以忽略但在高并发、大流量的场景下方法调用本身就是有成本的。更不用说中间还夹着大量的对象转换。举一个真实的例子。之前我们优化过一个列表接口链路是 Controller - Service - Facade - Manager - DAO其中 Service 把 ListEntity 转成 ListBOManager 又把 BO 转成 DTOController 再转成 VO。一次请求三份对象、三次拷贝200 条数据时就不明显2000 条数据时每次请求光对象拷贝就多花几十毫秒。一压测CPU 全烧在 getter/setter 上。还有人说“多转一层是为了解耦”可这些对象之间字段是重叠的转来转去字段根本没变。这种拷贝不仅慢了性能还容易产生 Bug——少拷贝一个字段、类型对不上、漏掉嵌套属性全是靠拍脑袋调出来的。如果链路简单一点一个对象从头传到尾这些问题压根不会存在。2.3 维护成本改一个字段要动六层第三个代价可能是最痛的维护成本。假设产品提了个需求给用户实体加一个“会员等级”字段。看似简单对吧实际上你可能会经历这些先在数据库表加列然后在 Entity 加字段再然后在 BO 加字段接着在 DTO 加字段还要在 VO 加字段最后在 Controller 层做字段映射。一个字段六处改动任何一处漏了前端就显示不出来或者会有数据返回到一个没人看的对象里。最无语的是这些对象之间压根没有任何代码层面的联系全靠手工维护映射。你要是用了 MapStruct 这种工具还好一点如果还是手写 BeanUtils.copyProperties 那更惨——它靠反射干活压根不检查字段名是否真的对应。改一次漏一次上线之后出问题还要靠抓数据才能定位是谁没映射上。“分层清晰”应该是改一个行为只改一个地方而不是改一个行为要动一整条链路。当你的层次越叠越多、每个层都在做复制转发时改一个字段就是六层地狱。3. 不是所有分层都是套娃识别“健康层次”的三把尺说到这里肯定有人会想那是不是不要分层了所有代码塞一个类里就完事了当然不是。分层的初衷是对的问题出在“为了分层而分层”。要区分出健康的分层和多余的套娃我自己有三个很实用的判断标准三把尺子量完之后基本就有答案了。3.1 尺子一这一层是不是有独立的业务语义真正的层应该像公司里的部门一样每个部门都有自己独立的职能。财务部管钱人事部管人研发部管系统。你不会在一个只有十几个人的小公司里专门设置一个“部门对接部”来对接其他部门对吧代码也一样。Service 应该是业务逻辑的承载者DAO 应该是数据访问的承载者Controller 应该是 HTTP 语义的转换者。它们的名字本身就说明了自己的“存在理由”。如果某层的名字听起来只是另一个层的同义词比如“Facade 就是 Service 的另一种写法”“Manager 就是 Service 的小弟”那它大概率没有独立的业务语义纯粹是在凑层数。我见过一些代码库Service 和 Manager 的区别连写代码的人自己都说不清楚。两个人写同一套系统一个写业务放 Service一个写业务放 Manager最后互相调用调用链乱成一锅粥。这种“双层”不是设计出来的是写漂了之后硬凑的。3.2 尺子二这一层有没有“独立变化”的理由“开闭原则”说软件实体应该对扩展开放、对修改关闭。很多人就把“多建接口、多建一层”当成开闭的实践但开闭的本质是“找到变化点在变化点处封装”。判断一个层有没有存在必要最直接的问题就是这一层在未来有没有“独立的、跟其他层不同的”变化理由比如你封装一个 Redis 缓存操作后面可能换成 Memcached那抽象一层 CacheProvider 就有理由。如果你封装一个 CustomerService但后面没有任何迹象表明 Customer 会有多个来源、多种规则那这个 Service 就是要直接面向业务写的不应该再多套一层接口。这个尺子也可以反过来用如果这一层不管是改为现在的还是未来的需求每次变化都必须跟它上下层的代码一起动那它就不是一个独立变化的点它就是一个被套上去的中间层。3.3 尺子三删掉它代码能不能照常工作这个尺子最狠也很好用。你看一个方法问自己如果我把中间这层类删掉让调用方直接调用被调用方行为会变吗如果不会那这层就是纯转发层属于套娃如果会说明这个层至少夹带了某种行为或状态转换那它还有存在的价值。纯转发层典型长这样public class UserFacade { private final UserService userService; public UserDto getUserById(Long id) { return userService.getUserById(id); // 完完全全的转发 } }这种类就是“代码填空题”没有任何智能删掉 UserFacade 之后Controller 直接注入 UserService行为和之前一模一样还少了一次方法跳转。这种层在团队里往往特别多因为写它只需要一分钟而且写完感觉很“齐全”。3.4 一个拿得出手的对比示例说了这么多理论放一个真实对比。业务需求很简单用户下单后要给他加积分、发站内信、记录操作日志。套娃写法可能会是这样Controller - OrderFacade - OrderService - OrderManager然后 OrderManager 再去调积分 Service、消息 Service、日志 Service。OrderFacade 就是转发一遍OrderService 也只是把流程编排了一下实际业务逻辑全都散落在各个 Manager 里。简化的写法是Controller 直接调 OrderServiceOrderService 里做编排三个职责清楚的“助手”各自做好自己的事Service public class OrderService { private final OrderRepository orderRepository; private final BonusService bonusService; private final MessageService messageService; private final OperationLogService logService; public PlaceOrderResult placeOrder(Order order) { Order saved orderRepository.save(order); bonusService.addBonus(saved.getUserId(), saved.calcBonusPoints()); messageService.sendNotice(saved.getUserId(), 下单成功); logService.record(saved.getUserId(), PLACE_ORDER, saved.getId()); return PlaceOrderResult.success(saved.getId()); } }这种写法没有多一层“转发型 Facade”也没有额外抽一个 Manager 包一层。每个类的职责都看得见调用链一眼到底。真要以后加了新步骤也只改这个 Service 的编排不会动 Controller、不会动 Repository。这就是健康的层次没有无谓的中间层但该有的边界一个不少。4. 拆解实操把套娃改简单的具体步骤如果看完前面你已经确认自己的代码库里有套娃层那接下来要面对的是“怎么拆”。拆层级不能脑子一热对着代码目录一顿乱删那样迟早删出事。这里分享一套我实践中提炼出来的操作步骤。4.1 第一步画出数据流找出“零贡献层”拿到一个模块先不动手先把调用链画出来。可以用 IDE 的 Find Usages 或者调用层次功能一层层往下看直到你找到真正干活的代码。我通常画完调用链之后会做一张表把每个类的“职责”和“转发行为”列出来。如果某个类的所有方法都只是调下一个类且不做任何额外处理那它就是一个零贡献层属于首先要处理的“拆解候选”。这一步可以慢一些但一定要画完整。因为套娃代码最坑的地方就是它有时会在转发的中途夹带一段你没发现的逻辑只盯着一个方法看很容易误删。4.2 第二步把行为向上提合并转发型类确认零贡献层之后下一步就是把调用方改成直接调被调用方。这个操作要小心一次改一条链路改完立刻跑测试。比如原先 Controller - Facade - Service确认 Facade 是纯转发后就把 Controller 里注入的 Facade 替换成 Service改完去掉 Facade。如果 Service 也是纯转发那就继续把逻辑上提。合并类的时候注意检查有没有 AOP 切面、代理、事务注解挂在被合并的类上。Spring 的项目里事务注解经常被贴在中间的“伪类”上你合并之后要把它挪到真正的事务边界类上。还有一种情况是中间层虽然不用了但别的模块还在引用它。这时候要先查引用把引用改到更底层再删除避免上线前爆出 NoClassDefFoundError。4.3 第三步用“延迟抽象”换掉“预判抽象”拆了一堆层之后很多人又忍不住想那以后要扩展怎么办以后要加新逻辑怎么办我的经验是——以后的事情以后再说。如果当前只有一个实现、一条链路直接把代码写成最简单、最完整的样子当你真遇到第二个变化点时再为它引入抽象。这个原则在工程上叫“延迟抽象”也叫 Rule of Three三次法则。意思是你要至少遇到三次重复的需求才考虑抽公共抽象遇到两次先忍住用简单方式解决一次更不需要。因为抽抽象本身有成本要设计接口、要定义边界、要考虑改动影响。抽早了大概率是抽歪了抽晚了最坏的结果也就是多做一次小的重构。两个风险相比抽早了更严重。具体到代码上我建议一个类里都别上来写泛型工厂、策略模式、观察者模式。把这些“设计模式”存进脑子里等看到代码里有重复的 if-else、重复的 switch、重复的工厂创建逻辑再动手上模式。这样你的代码永远是先“够用”再“好看”不会陷入“为了好看而变成套娃”的坑。4.4 第四步验证行为不变删除层级的最后一定要跑一遍全量相关测试。逻辑合并时最容易遇到的问题就是事务边界漂移。比如原先 Manager 层标了Transactional合并到 Service 层之后事务依然生效但粒度可能变了该被事务保护的逻辑如果没包住就会出现“执行到一半失败了但前面成功的数据没有回滚”的诡异问题。所以重构期间的验证不能只看功能跑不跑得通还要看事务、缓存、权限这三个常见的横切逻辑有没有被“顺手拆掉”。我自己的习惯是把关键路径的调用链再画一遍跟重构前的调用链做对比核对每个原有行为都还被保留着然后再合并代码。这一步看起来慢实际上能避免上线后炸雷。5. 常见问题与翻车实录拆套娃这事我也不是一次就干顺的中间踩过好几个坑事后复盘都挺有意思。写在这里帮大家提前避一避。5.1 拆了之后扩展反而更难说明你没找对抽象点有人问我我按照你说的把中间层拆了结果产品提新需求我发现代码还是改了多处是不是拆错了我看了他代码后发现他的问题不是拆错了而是拆的时候根本没想清楚扩展点到底在哪。比如一个消息通知逻辑原来统一走 NotifyManager他拆完之后直接把 NotifyManager 干掉了改动散落在 Controller、Service、定时任务三处。要支持新的通知渠道时他又把所有调用点翻出来改了一遍。拆层级的时候要把“转发层”和“真正的扩展点”区分开。如果某个类虽然充当了转发层但它同时是一个你能预见的、肯定会变化的边界比如不同渠道、不同数据源、不同协议那你就应该把它当扩展点保留而不是简单删除。换句话说要拆的是“没有独立语义的套娃层”不是“有价值的策略边界”。5.2 别把必要的边界也拆了跟上面那条相关的就是有些层虽然看起来是“转发”但它其实是一种“边界保护”。最典型的就是防腐层。比如你的系统对接了一个外部第三方接口内部其它模块不应该直接依赖第三方 SDK 的返回值模型而应该经由一个转换层接回内部领域模型。这个转换层即使方法体只是透传也有存在价值因为它的存在阻止了第三方模型的“污染”扩散。如果我只靠“删掉也能工作”这个标准来拆它那这个防腐层看似可以删但删完之后下次第三方接口一改你就得去所有调用它的业务代码里改那是灾难。所以拆解层次的时候要区分“为了转发而转发”和“为了隔离而转发”。后者的转发是有边界的不能拆。5.3 重构的节奏小步快跑别大爆炸我有一次手痒想用周末时间把一堆套娃层全部拆掉。结果拆到一半发现这个类被 A 服务引用着那个接口被 B 模块实现着改了一个又连带暴露三个问题。最后我和“半成品代码”纠缠了两天才把全链路理顺。后来学乖了重构不搞大爆炸式一次只拆一两个层每拆一个就完整跑一遍测试。这个方法虽然看起来慢但因为影响面小、定位方便整体效率反而更高。核心原则只有一个每次提交要保证代码库是可发布的状态不让别人甚至是你自己陷入一个“改了一半、还没完工”的尴尬期。另外一个实用技巧是拆套娃的时候顺手写测试哪怕只是传一个假数据进 Controller 验证返回值对不对也比没有好。有了测试兜底你拆层级的信心会完全不一样。6. 最后说几句心里话这篇文章写到现在核心观点无非就一句话架构的价值在于解决问题而不在于堆叠结构。如果你把一个请求处理链路拉出来看每一层都能讲清楚自己在解决什么问题那这个架构就是好架构如果一层只是为了“让代码看起来更分层”那它就是在拖后腿。我个人这些年最大的体会是写代码和做别的手艺活很像——真正厉害的人不是把东西做得复杂的人而是能把复杂的事情做得简单的人。一个新人看到一个项目里层次分明、类多得像图书馆会觉得很“专业”但一个老手看到同样的代码第一反应往往是这些类里到底有几个真的在干活所以如果让我给一条最通俗的建议那就是先让代码能跑再让代码好读最后才考虑代码“架构”。很多人把顺序搞反了一上来就在想怎么分层、怎么抽象结果代码写了一堆真正跑通的功能没几个。这个顺序倒过来之后你会发现很多所谓的架构问题根本就不会出现。最后再分享一个小技巧每次写完一个模块我会退出 IDE打开项目目录看看这个模块到底需要多少个文件才能说清楚。如果文件数多于“功能数”乘以“边界数”我就要重新审视是不是又多写了几个套娃类。这个方法听起来很土但在我手上真的拦下了不少无谓的层次。希望它对你有用。