
1. 一次让我崩溃的代码评审预构是如何把简单事情搞复杂的1.1 那个适配未来的通知模块先讲一个我记忆犹新的经历入职第一周我 review 一个 Java 项目的通知模块。需求文档写得很清楚——用户下单后给用户发一封邮件。代码却完全不是这个体量Notifier 接口、三个实现类Email、Sms、Wechat、一个工厂类、一套渠道枚举、一份消息模板抽象……基础框架占了几百行真正在线上生效的只有 EmailNotifier 里的十几行代码。看着眼熟吗这种为未来准备的代码在 Java 项目里非常常见。我管它叫预构——在需求还没出现之前先把扩展点、抽象层、框架结构都搭好。它跟提前设计架构有本质区别预构建立在假设上而设计基于确认的事实。YAGNI 原则全称 You Arent Gonna Need It就是专门用来治这个毛病的你不会需要它的。后来我认真做了个复盘这些未雨绸缪的通知渠道在项目后续两年里一个都没用上代码还得继续维护、继续给新人解释。从那时起我开始把 YAGNI 作为 Java 代码评审里的第一道过滤器。1.2 预构的三大典型特征我在评审中逐渐总结出预构的三个特征基本可以当警报器用。第一接口和抽象层的数量比真实调用方多。最典型的情况是接口只有一个实现类接口存在的唯一理由是以后可能有第二个实现。第二枚举、常量、配置项提前铺了一大片。需求里只有两种状态代码里定义十几种状态、几十个配置项注释还写着为后续扩展预留。第三为了性能或灵活性提前引入重量级组件。单机业务量一天几千次请求消息队列、分布式缓存、分库分表方案已经全副武装。这三个特征有一个共同点都是站在现在去猜未来而且未来往往还猜得不够准。我见过最讽刺的情况是预构的抽象最终确实用上了但用法的方向跟当初设计完全反了——不是扩展了第二个实现而是第一个实现改了八遍。1.3 YAGNI到底在说什么YAGNI 源自极限编程是 XP 实践中一条朴素又强硬的原则除非你有确凿证据表明某个功能在可预见的将来会被需要否则不要为它编写任何代码。注意可预见和确凿证据这两个词——不是可能用顺便加而是明确会来。这句话经常被误解成别做设计了或写代码不用动脑。恰恰相反YAGNI 要求你把设计精力集中花在当前已经确认的需求上为它做出当下最优、最简的设计而不是为若干个可能的未来预支复杂度。这里有一个关键认知现在不设计不等于永远不设计。真正的高手不是不做抽象而是知道什么时候做抽象。抽象应该在出现第二个真实的变体之后再做而不是在第一个变体的想象之前提前做。当你只有一种实现时接口和策略模式解决的都是一个不存在的问题。提示如果一段代码的存在需要靠未来假设来解释那么它现在就大概率不该存在。2. Java项目为什么最容易滋生预构2.1 语言本身的诱惑泛型、反射与接口Java 是一门非常有设计感的语言。接口、泛型、反射、注解处理、字节码增强……每个特性都在向你招手用我啊用了我就优雅了用了我就面向对象了。问题在于语言特性本身没有成本意识。写一个GenericNotifierT extends Message比写一个具体的EmailNotifier显得水平高但水平高不等于解决问题。Java 的强类型接口优先文化让很多开发者形成了肌肉记忆拿到需求先抽接口再定义泛型再写实现类再搞工厂。我见过一个非常典型的三行需求根据用户ID查询用户名展示在订单页。 结果代码里躺了五个类UserQueryService接口、UserQueryServiceImpl实现、UserQueryRepository、UserDTO、QueryUserCommand。当时代码里只有这一处查询用户。这五个类没有解决任何真实问题只是满足了看起来架构很正规的想象。语言本身没有错错的是把语言特性当成了装饰品——装饰越多维护越累。2.2 设计模式被当成了KPI《设计模式》这本书本身没有错错的是把模式当成目标。在简历上写精通23种设计模式很容易在业务代码里证明我用对了模式很难。很多我评审过的 Java 项目里状态模式、策略模式、装饰者模式、观察者模式被成套堆叠在简单的业务逻辑上代码看起来专业维护的人叫苦不迭。印象最深的一次一个库存扣减逻辑本可以用十行synchronized加update写完同事硬是搞出了库存领域事件 事件处理器 幂等表 乐观锁重试四层结构。问他为什么回答是这样未来对接多个渠道更稳。结果呢未来三个月一次渠道对接都没发生倒是修事件重试的 bug 修了四次。这里并不是说这些模式没用而是模式是给确实出现的复杂结构用的。只有一个变化维度的时候强行套模式不是设计是预构。2.3 框架生态的顺手陷阱Spring Boot 太强大了强大到你可以很轻松地把一堆暂时用不到的组件塞进工程。pom.xml 里加一个 starter 的成本低到可以忽略于是各种顺手就出现了顺手加 Redis 缓存、顺手接 MQ、顺手配多数据源、顺手引入 Feign、顺手开通分布式事务。这些组件在架构图上非常漂亮在真实运行环境里却变成了额外的运维负担。我接手过一个单体服务pom 里躺了十几个 starter运行时有一大半配置根本不生效。问项目负责人为什么加这些他说当时想着后面做微服务要用。 这个后面已经过了三年。所以我现在对组员只有一个要求pom.xml 和 application.yml 里的每一行都得回答当前哪个业务在用这个问题。如果在业务代码里找不到使用者那这行配置本身就是预构。3. 我判断要不要现在做的三把尺子3.1 三问定位法现在、确定、单一预构和设计之间那条线我后来总结成三把尺子每次想再加一层抽象、再引入一个组件、再预留一个配置的时候先问自己三个问题。第一当前需求要求它存在吗如果当前业务运行起来根本用不到这段代码那它基本就是预构。第二未来需求是否已经确定注意是确定不是可能。迭代排期里白纸黑字写着下个版本要做短信通知那叫确定也许以后会做短信那不叫确定。第三将来再补的成本是否高到无法承受比如数据库表结构设计错误要迁移历史数据、支付接口协议无法后补、安全合规要求必须一开始就到位——这类场景预构是被允许的而对于普通业务方法将来再加一个分支的成本极低就不该现在预构。三个问题组合起来能拦下绝大部分无效抽象和无效组件。这也解释了为什么在代码注释里写为将来预留是我最反感的行为它意味着写的人已经意识到这段代码现在不需要但还是选择把它留下。3.2 对照例子两版订单状态处理代码拿订单状态处理来演示预构版和YAGNI 版的差异。当前需求就三个状态待支付、已支付、已发货处理逻辑是简单校验加更新。预构版长这样典型的状态模式加处理器链public interface OrderProcessor { OrderState supports(); void process(Order order); } public class PendingOrderProcessor implements OrderProcessor { Override public OrderState supports() { return OrderState.PENDING; } Override public void process(Order order) { // 校验并更新待支付订单 } } public class PaidOrderProcessor implements OrderProcessor { Override public OrderState supports() { return OrderState.PAID; } Override public void process(Order order) { // 校验并更新已支付订单 } } public class OrderProcessorChain { private final MapOrderState, OrderProcessor processors; public OrderProcessorChain(ListOrderProcessor processorList) { this.processors processorList.stream() .collect(Collectors.toMap(OrderProcessor::supports, Function.identity())); } public void process(Order order) { OrderProcessor processor processors.get(order.getState()); if (processor null) { throw new IllegalStateException(不支持的订单状态: order.getState()); } processor.process(order); } }从结构看无可挑剔接口、Map、策略、链式调用。但它解决的问题是将来状态多了怎么办而不是现在三个状态怎么处理。三个简单的逻辑硬写了五个类。YAGNI 版public class OrderService { public void handleOrder(Order order) { switch (order.getState()) { case PENDING - handlePending(order); case PAID - handlePaid(order); case SHIPPED - handleShipped(order); default - throw new IllegalStateException(未知订单状态: order.getState()); } } private void handlePending(Order order) { // 校验并更新待支付订单 } private void handlePaid(Order order) { // 校验并更新已支付订单 } private void handleShipped(Order order) { // 校验并更新已发货订单 } }十行能解决的问题不要写成五十行。有人会说现在不抽象以后状态多了重构成本不是更高吗 我的经验恰恰相反状态真的多了之后真实的流转路径跟现在的假设大概率不一样。到那时你基于真实状态流转重新设计模式远比现在猜一个模式准确。而且 IDEA 的 Extract Interface、Extract Method 能帮你完成大量机械重构成本没有想象中高。为了想象中的未来牺牲眼前的简单可靠是笔亏本买卖。3.3 真正需要破除的边界YAGNI不是万能药如果 YAGNI 被理解成绝对不做未来需求那它就变成了鼠目寸光。它真正反对的是在可逆的、低成本的变更上预构而在不可逆的、高成本的变更上预构是被允许且必要的。我目前在四类场景里允许自己预构。一是外部接口协议对接第三方支付、开放平台协议规定了字段和签名必须把协议基础框架搭对后续接口从底座上长出来。二是核心领域模型金融、医疗这类模型改错代价极高设计阶段多花时间是值得的那些客观存在但暂未使用的信息可以预留。三是长期维护的公共组件库你发布的是给很多人用的基础组件API 演进策略、弃用机制、兼容性设计不预构就是失职。四是基础设施层面的容量预留主键用雪花 ID、日志链路预留 traceId 这类成本极低、迁移成本极高的选择可以放宽红线。一句话总结业务功能可以 YAGNI基础设施和契约不能随便 YAGNI。4. 从过度设计到克制的重构实战4.1 对一个万能工具类的拆除讲完理念给一段可落地的重构路径。很多 Java 项目里都有StringUtils、DateUtils、JsonUtils这种复制来的万能工具类里面几十个方法实际项目里被调用的不到 20%。这些方法往往没有测试没人敢随便改但每次升级 JDK、换依赖版本都要重新验证一大圈。我的拆除路径分五步统计调用点。用 IDEA 的 Find Usages把每个方法的被调次数列出来画一张真实的调用表。标注必须保留的方法——正在被使用且逻辑正确的才留。逐个内联把保留的方法按业务域移动到真正使用它的类里。比如DateUtils.formatTimestamp(...)只有订单服务在调就直接挪进OrderService变成私有方法或独立的小工具类。删除冗余方法。没有调用点的直接删删之前用 git 历史做锚点未来真要找回来很容易。跑全量测试。没有测试就先补关键路径的测试再动手。这个操作听起来简单最关键的一步是内联而不是复制。把方法挪进业务类之后原来工具类里的引用必须全部删干净不能同一个方法名同时存在两个地方。否则拆到一半出现两套逻辑、两套结果后续排查问题会把团队心态搞崩。我见过一个项目就是因为工具类和方法内联后的代码共存导致同一个日期格式化逻辑在两个地方表现不一致最后查了整整一个下午。4.2 怎么留扩展余地而不预构团队里最常听到的反对声音是我现在真的不抽接口以后第二个实现出现时岂不是要改一堆调用方 我的回答是先让调用方依赖最具体的类型等第二个真实变体出现时用 IDEA 的 Extract Interface 一键抽取。扩展点不是靠一开始就画出来的而是在重构过程里顺理成章浮现出来的。具体做法很朴素先写具体类方法签名直接针对真实业务不写接口、不写工厂、不注册到 Spring 容器里以备替换等真的出现第二个实现类时再执行 Extract Interface把调用方的依赖类型改成接口注入点改成工厂或配置选择。这样做的好处是抽取出来的接口方法签名是从真实调用场景坍缩出来的不会多出几个感觉将来会用的占位方法。你最后得到的抽象是长出来的不是猜出来的准确率高得多。我在不少团队见过反例先写接口再写实现还专门用Qualifier或配置文件来选择具体实现。结果三年过去这个接口只有唯一一个实现却在所有依赖注入点留下了选择逻辑、判空逻辑、fallback 逻辑。这些全是对未来的假设在收税。4.3 在团队评审里推行YAGNI的沟通方式推行 YAGNI最大的障碍不是技术而是沟通。我早期在评审里问这个方法为什么要放在接口里同事第一反应是你让我直接退代码效率呢 后来换了问法效果好很多。我不再问你为什么这么设计改成问当前哪个需求导致了你必须这么设计。如果对方说以后可能用得到我就追问这个以后在最近的迭代计划里吗如果对方说多几行代码而已又不复杂我会跟他算总账接口 工厂 配置 文档 测试 新人理解成本不是多几行是多几十处认知负担。另外我强烈建议一条提交原则一次提交只做一件事。新功能和顺手重构不要混在一个 MR 里。当需求方和 reviewer 看到一个十行需求对应五百行 diff 的提交时任何人都挡不住顺手预构的冲动。把变更集控制到最小预构就失去了生长的土壤。这条规则配合 YAGNI 一起用效果比单独喊口号好得多。5. 几个用真金白银换来的教训5.1 预构其实是一笔负收益投资我认真算过一笔账。有一段预构代码写的时候花了两天维护期间它给全组带来的理解成本累计超过两周最后因为当初的假设被推翻整个模块推倒重写又花了一周。也就是说为了省下以后可能的一个小时重构时间预支了将近一个月的时间成本。这笔账还没算认知成本和机会成本——当你被一堆可能有用的代码包围真正重要的业务逻辑反而被稀释了。新人看代码一半时间在猜这个是干嘛的现在有人用吗这是团队长期的效率黑洞。如果你也是 Java 开发者这里有一条非常实用的判断捷径当你读到某段代码的注释发现它不是解释这段逻辑做了什么而是声明这段代码是为将来准备的那你大概率在看预构。将来一旦没有如期到来这些代码就是负资产。我宁可看到注释里写清楚这个 switch 为什么这样写也不愿意看到Im prepared for the future这种自我安慰。5.2 我允许自己预构的几种场景说实话完全的 YAGNI 在商业项目里很难执行到位。所以我现在给自己定了四条可以破例的清单也算是对 YAGNI 的现实主义补充。第一改数据模型成本极高的场景。数据库字段、索引、表结构的变更牵扯历史数据迁移建模时多想一步某些字段即使当前用不到只要确认它是业务里客观存在的信息就值得预留。第二外部系统的契约。支付、物流、税票这些对接接口字段由外部决定不按对方协议预构后面根本接不上。第三安全与审计。权限模型、操作日志、数据脱敏这些合规性需求几乎不能后补而且不是靠未来的想象力支撑的是当前就要遵守的底线。第四底层基础设施的不可逆选择。日志链路 traceId 的透传、分布式 ID 生成策略、通用异常结构这些决定一旦上线牵涉所有系统值得现在做对。除此之外业务逻辑、数据处理、展示层代码我统统严格按 YAGNI 走——用最简单的方式满足今天的业务让复杂结构随着需求真实生长。5.3 最后想说的最近总在 Java 社区看到一句话代码要少一点八股味。我觉得说得挺准。很多 Java 代码的问题不是能力不够而是能力太强又没地方使于是对着简单的需求练了一身屠龙技。克制预构不是让你退化成只会写 if-else 的码农。恰恰相反它要求你在做之前多思考一层我现在写的这个抽象是基于确认的事实还是基于想象的剧情从我自己走过的弯路来看能长期稳定迭代的 Java 项目往往不是架构最壮丽的那一个而是代码量跟需求匹配的那一个。好的代码像合身的衣服不是最大最华丽的那件是每一针都缝在需要的地方的那件。如果你也在 code review 里被一堆预留扩展的代码搞得心累不妨把 YAGNI 请回来让它帮你把那句以后可能会用轻轻改成用的时候再说。