不知道你们有没有过这种经历:接手一个老模块,打开代码发现 Controller 调 Service,Service 调 Manager,Manager 调 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 把 List<Entity> 转成 List<BO>,Manager 又把 BO 转成 DTO,Controller 再转成 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 直接调 OrderService,OrderService 里做编排,三个职责清楚的“助手”各自做好自己的事:
@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,打开项目目录,看看这个模块到底需要多少个文件才能说清楚。如果文件数多于“功能数”乘以“边界数”,我就要重新审视是不是又多写了几个套娃类。这个方法听起来很土,但在我手上真的拦下了不少无谓的层次。希望它对你有用。