写代码时,有一种状态会被很多人误当成“认真负责”:一个简单功能,先来三层抽象,再挂两个缓存,一切常量都收进配置中心,公共包不够用就再加一个依赖。表面看,工程化意识很强;实际运行里,同事看代码看不出主流程,领导问为什么又引入新框架,需求一改就要跨六个文件联动。问题不是懒,恰恰相反,是太勤奋了,勤奋到像偏激暴食者:见什么技术都想吞进去,却忘了代码的审美从来不是“多”,而是“准”。
这个标题讨论的不是饮食健康,而是技术审美。软件开发里存在一类“偏激暴食者”:他们不是不学习、不投入,而是对技术没有筛选地吸收,对架构没有节制地堆叠,对代码没有边界地扩展。这种状态一旦被奖励,比如被夸“抽象能力强”“考虑很长远”“技术视野广”,就更容易固化。到后期,项目会变成一座由依赖、封装、动态配置和缓存堆出来的超大卡路里食堂,看着丰富,吃下去难受。
本文会把“偏激暴食者的审美误区”拆成七个典型症状,每个都会给出代码级示例、判断标准和替代方案。读完你能得到一份可执行的“技术节食清单”,以及一套用于代码评审和团队协作的质量检查问题。适读人群包括:刚经历代码评审被反复挑战的开发者,正在重构历史项目的负责人,以及那些意识到自己已经陷入“什么都想上”状态的架构师。
1. 什么是“偏激暴食者”式开发
“暴食”的本意是生理上失去对进食需求的判断。对应到软件开发,本质是失去对技术摄入的判断——不是不摄入,而是摄入的动机已经不是解决当前问题,而是缓解焦虑、证明水平、预防所有想象出来的未来。
一个真实场景能说明问题。需求是:给用户列表页面按最近登录时间排序。最直接的实现方式是:
SELECT id, name, last_login_at FROM user ORDER BY last_login_at DESC;偏激暴食者的实现路径通常会变成这样:
- 担心未来有多个排序维度,先定义 SortEnum 和 SortField 注解。
- 为了让替换排序算法更灵活,引入策略模式。
- 为了让策略支持不同数据库方言,再包一层 repository。
- 发现调用方要传一堆参数,再声明一个 QueryContext。
- 最后写上“考虑到后面要接入新会员体系,这段我先抽象好”。
单看每一步都有理由。但把理由放到一起看,就会发现它们服务的不是当前需求,而是想象中的未来需求。这种无差别吸收和过度准备,就是“偏激暴食者”最核心的判断特征:把可能性当成必然性,把扩展点当成必改点。
在工程实践里,这类问题往往不是技术能力不足导致的,而是技术审美的方向出了问题。写代码时真正稀缺的能力,不是知道多少新特性,而是判断什么不该写。这里要引入一个关键概念:机会成本。任何一个抽象、依赖、配置缓存,都会占用代码库的长期预算——理解预算、维护预算、bug滋生预算。你提前付出这些预算,购买的是“也许某天用得上的灵活性”。
“偏激暴食者”在审美上的误区,不是吃得太少,而是没有理解每一口都有代价。后面七个小节,会展开最常见的七种“吃法”。
2. 误区一:为不存在的抽象写抽象
某些人做接口设计时,会有一种强烈的冲动:把未来三个月可能遇到的情况全部预判一遍,然后交出一份“通用框架”。这种冲动如果脱离真实调用者的存在而落地,最终得到的不是良好的扩展性,而是一堆找不到消费端的空转代码。
一个典型错误长这样:
// 错误示范:只有一个真实调用方,却先为“未来所有数据源”抽象 public interface UserDataProvider<T extends BaseUser, Q extends UserQuery> { PageResult<T> query(Q query, QueryOption option); } public class MysqlUserDataProvider implements UserDataProvider<MysqlUser, MysqlUserQuery> { @Override public PageResult<MysqlUser> query(MysqlUserQuery query, QueryOption option) { // 真正的 SQL 只有一行 return userMapper.selectByQuery(query); } } // 调用方:为了拿到用户列表,需要先构建 QueryOption、MysqlUserQuery、MysqlUser这段代码在文件数量上赢了,但在信息密度上输了。阅读这段代码的人,需要理解 UserDataProvider、BaseUser、UserQuery、QueryOption、PageResult、MysqlUser 这一整套词汇,最后才看到一行 SQL。真正的问题是,当代码库里只有一个实现类时,接口并不等于抽象,它只是目录。
不是“永远不要抽象”,而是要等抽象有了足够的现实收益再动手。一个比较保守的标准是:至少出现了两个真实调用方,并且它们之间存在可以明确说明的共同变化点,才算具备引入接口的条件。如果只有一个调用方,就先老老实实调用实现类。
项目里如果确实判断未来会有第二个数据源,推荐先用普通类把逻辑写出来,然后写一条注释记录扩展方向,比如“当接入 ES 时,将这段查询拆为 UserQueryService 接口”。等第二个真实场景出现时,再做重构。这样做的好处是,即使预测错误,浪费的也只有一行注释。
3. 误区二:依赖越多越安全
重依赖也是“技术暴食”的常见表现。一些项目为了处理一个 trim 操作引入 Apache Commons Lang,为了一个日期格式化引入 hutool 工具包,为了一个集合判空引入 guava。如果这些库本身已经在项目中广泛使用,无可厚非;但很多时候,新人看到项目已有的依赖,会顺手把所有工具类都堆在里面,一个 10KB 的小型服务最后携带了 50MB 依赖。
更隐蔽的问题,是依赖带来的版本冲突、传递依赖和升级成本。一个服务可以通过依赖检查工具看到几百个 jar,但没人知道哪一个是真正被使用且必须升级的。依赖数量越多,出现 CVE 高危漏洞的概率就越高,处理供应链安全的成本也随之上升。
<!-- 错误示范:只是去字符串空格,却额外引用了三个工具库 --> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-lang3</artifactId> </dependency> <dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> </dependency> <dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> </dependency>同样的需求,在大多数语言的标准库里已经能做。以 Java 为例,String.trim()能去掉首尾空格;如果需要去除全角空格或其他空白字符,可以基于Character.isWhitespace()封装一个小方法,而不是额外引入一个包。
public final class StringUtils { private StringUtils() { } public static String trimAllWhitespace(String source) { if (source == null || source.isEmpty()) { return source; } int start = 0; int end = source.length() - 1; while (start <= end && Character.isWhitespace(source.charAt(start))) { start++; } while (end >= start && Character.isWhitespace(source.charAt(end))) { end--; } return source.substring(start, end + 1); } }引入一个依赖前,团队应该先问三个问题:第一,标准库或现有依赖里是否已经有类似能力?第二,为了这个能力引入的新库,会扩大多少依赖树和风险面?第三,如果未来这个工具库停止维护或出现漏洞,我们能快速替代吗?如果三个问题里有两个答不上来,这个依赖就不应该被引入。
这不是说所有依赖都要自己造轮子,关键判断标准是依赖带来的总成本是否明显小于它解决的问题。一个正确的姿势是:如果只是需要一个工具方法,先自己写并配上单测;只有工具使用频率高到写不过来,或者自己维护成本过高时,再考虑统一引入社区成熟工具库。
4. 误区三:把配置中心当“万能膨胀剂”
配置项是另一个容易被暴食的地方。很多团队会觉得,“动态配置”代表先进,“写死在代码里”代表落后,于是把一个常量、一个开关、一个格式规则全部抽到配置中心。抽完以后,配置中心里躺着几千个 key,真正的代码里还要写大量@Value("${xxx.yyy.zzz}"),一旦改错一个,线上行为就会静默变化。
问题的本质在于:配置本身也是一种接口,它的变化成本并不低。配置需要被校验、被默认值保护、被文档说明,还要考虑不同环境下的差异,任何一个配置项生效范围的错误,都可能带来线上事故。
# 错误示范:过度配置,导致应用行为无法从代码角度理解 app: user: list: page-size: 20 max-page-size: 100 cache-enabled: true cache-ttl-seconds: 60 order-by: last_login_time order-desc: true enable-debug-header: false use-v2-optimize: false看到这份配置的维护者,实际面对的是一个“看不见的代码分支”。配置项越多,测试组合越多,线上行为越难从代码评审里判断。真正需要做成动态配置的,通常只有下面几类诉求:上线策略需要灰度切换、线上问题需要不停机应急处理、业务阈值需要定期调整。如果一个配置只是开发期的个性化开关,那它就应该留在代码里。
替代做法:默认值优先。如果一个配置没有显式的默认值,或者默认值只有开发者自己知道,那么它就不应该收进配置中心。对于阈值类配置,给配置项写清楚范围、单位和默认值。
# 更推荐的做法:只保留真正需要动态调整的配置 app: user: order: default-order-by: last_login_time default-order-desc: true同时,代码里不要直接读取一个裸配置数值,而应该把它封装为一个带语义的方法,比如userQueryProperties.getPageSize(),避免业务代码里散落魔法值。配置中心本身没有错,错的是把“可配置”当成“抽象能力强”的证明。真正成熟的设计,是让绝大多数代码在默认值下就能正确运行,动态配置只是少数场景的“控制手柄”。
5. 误区四:封装太多层,认知负载失控
“偏激暴食者”还有一个容易沾沾自喜的点:类拆得很细,方法都很短,感觉结构非常优雅。但如果每读一个方法都要跳到三四个类里找上下文,那这种封装就不是在降低复杂度,只是把复杂度从“能看到的”挪到了“看不到的”。
很多团队喜欢用一套“策略 + 模板 + 责任链 + 观察者”组合,把订单处理、消息分发这类流程写成一个可以任意插拔的框架。表面上看,新增一个 Handler 似乎很方便,但读代码的人需要同时理解 Handler 执行顺序、上下文对象生命周期、终止条件、异常传播规则,才能判断某个逻辑会不会被走到。
// 错误示范:三段 if else 能说清楚的事,被拆成十几个类 public class OrderProcessContext { private Order order; private List<String> messages; private boolean needNotify; } public interface OrderHandler { void handle(OrderProcessContext context); } public class StockCheckHandler implements OrderHandler { ... } public class PriceCalculateHandler implements OrderHandler { ... } public class CouponHandler implements OrderHandler { ... } public class MessageBuildHandler implements OrderHandler { ... } // 再配合一个 HandlerChain、HandlerDispatcher、HandlerOrderConfig...真正的工程审美,不是“每个类都很小”,而是“任何一个层级的读者都能带着最小上下文理解主流程”。封装抽象的价值,必须大于读者理解它所要付出的认知成本。如果拆开后代码行数没减少,测试复杂度和调用链理解成本反而上升,那这个封装就是负资产。
判断封装是否过度,有一个快速测试:尝试把主流程写成一页纸的伪代码。如果伪代码里还需要解释 Handler 的执行机制,基本就是过度设计了。正确的分层应该是:主流程一眼能看懂调了哪几个组件,每个组件内部可以继续分,但不影响最外层阅读。
修改代码时,也要警惕“多包一层能解决”的幻觉。很多时候,与其在调用链上再加装饰器或代理,不如直接修改原本的私有方法。少一层跳转,就少一次上下文切换;少一种执行顺序,就少一类神秘 bug。对一个已经稳定运行的功能,最优雅的改动可能是改三个字符,而绝不是新建一个“功能增强扩展点”。
6. 误区五:用缓存掩盖查询结构性缺陷
为了性能优化而“无脑缓存万物”,是另一个高发审美误区。一个列表接口响应变慢,正确思路是先确认慢在数据库、慢在跨服务调用,还是慢在应用内序列化;但暴食者通常的做法是:先加 Redis,把查询结果整体缓存 10 分钟,然后在下一次出现数据不一致时再加缓存清理逻辑,清理逻辑出错时再引入消息队列通知各节点删除缓存。
本来一条慢 SQL 的问题,最后被改造成一个分布式缓存一致性难题。
一个更常见的错误是 N+1 查询。比如查订单列表时,展示用户名称,代码用循环去查用户表:
// 错误示范:在循环里访问数据库 List<Order> orders = orderMapper.selectRecentOrders(limit); for (Order order : orders) { User user = userMapper.selectById(order.getUserId()); order.setUserName(user.getName()); }这个代码本身在一百条订单以内可能没什么感觉;一旦数据量上来或接口被频繁调用,数据库连接就会被打满。真正的修复方向通常是改写 SQL,让数据库一次性把需要的数据关联出来。
SELECT o.id, o.order_no, o.user_id, u.name AS user_name FROM `order` o LEFT JOIN `user` u ON u.id = o.user_id ORDER BY o.create_time DESC LIMIT #{limit};如果确实存在不便于用 SQL 关联的数据源,也应当先确认“一次批量查询”能不能解决,比如先把所有 userId 收集起来,再用WHERE id IN (...)一次性查出用户信息,最后在内存里做映射。批量查询属于结构收敛,缓存则是引入一个新状态系统,两者的优先级完全不同。
推荐顺序是:先看 SQL 是否走了合适索引,再看能否减少查询次数,然后分析是否能用批量接口,最后才考虑缓存。如果必须加缓存,也该从“局部热数据”开始,而不是一上来就把整个接口结果缓存掉。在缓存设计里,还要明确缓存容量、过期策略、穿透保护、数据一致性要求以及手动清理入口,否则缓存就跟临时补丁没什么区别。
7. 误区六:把开源项目和大厂方案当“满汉全席”
互联网技术分享越来越发达之后,出现了一个奇怪的现象:很多中小团队会照搬大厂的架构,把美团 Leaf 的号段模式、阿里 Sentinel 的流控阈值、字节跳动的内容分发思路全部塞进自己的小项目。这种学习热情值得肯定,但全盘吸收的风险也很大。
大厂方案之所以设计得“重”,是因为它们有足够大的团队、足够复杂的业务场景和足够高的故障容忍预算。一个日活只有几千的后台管理系统,没必要为了“分布式唯一 ID”引入一个独立发号器组件;一台单机 Redis 足够扛住所有读请求时,也没必要为了“高可用”再架一套 Proxy 集群。
另一个常见情况,是从开源项目看到某个组件觉得酷炫,就拉进自己的项目。正确姿势不是看到项目就整体引入,而是分析它的适用条件:它解决的核心问题是什么?它依赖哪些基础设施?它需要在什么数据量级下才能体现出收益?引入后团队有多少人能维护?如果这些答案自己并不清晰,多半只是被“看起来高级”吸引。
想记录为什么选择或拒绝某个方案,可以用一个非常短小的 ADR(Architecture Decision Record)文件,放在 docs 目录里。模板很简单:
# 决策:订单号生成方案选择 ## 背景 后端服务需要在分布式环境中生成全局唯一订单号。 ## 约束 - 单日订单量在 10 万级,不需要跨区超高吞吐。 - 团队运维精力有限,希望尽量少引入新中间件。 ## 决策 使用数据库号段模式,用一个 sequence 表批量取号。 ## 后果 - 优点:实现简单、可控、无额外中间件依赖。 - 缺点:需要保证 sequence 表所在数据库的高可用。 - 备选方案:接入专业发号器组件,但考虑到人力成本和收益,暂缓实施。写 ADR 的过程,本身就是逼自己从“这东西很热闹”切换到“它到底适不适合我们的问题”的过程。面对技术方案的审美,不应该是“多多益善,来者不拒”,而是“如果不上手这个方案,我们原计划要用什么方案?它带来的收益能不能覆盖团队的长期运维成本?”大厂方案的优点值得学习,但“学习其解决思路”和“照搬其全部组件”,通常是两件完全不同的事。
8. 误区七:只做加法,从不为删除留预算
大多数代码库的腐化,不是某一天引入了一个致命 bug,而是长期只增不减的惯性。新接口加上了,老接口没人敢删;新字段加上了,旧字段继续保留兼容;新依赖引入了,旧依赖无人移除。每一样东西单独看都“无害”,累积起来,却让系统变得越来越笨重。
为删除留出预算是工程管理上很重要但经常被忽略的实践。团队可以在每个迭代周期里,安排一次“反向代码评审”:不是看“新增了什么功能”,而是看“有什么代码是可以删的”。重点观察这些信号:超过两个季度没有任何日志打印的接口,注释里写着“暂时保留”的兼容代码,已经不会被新代码调用的老 service,以及只被单测使用而实际并未运行的配置项。
删除代码不是浪费成果,它是让保留的部分变得更容易维护。删除一段逻辑,意味着未来需要理解这段逻辑的读者少了一类;删除一个兼容分支,意味着未来测试矩阵少了一组排列;删除一个配置项,意味着未来排查问题时少了一个变量。
// 删除前:方法已经不再被调用,只是没人敢动 @Deprecated public String buildUserCacheKey(Long userId, String extra) { // 历史遗留,后续统一迁移到 user:info:{id} return "user:" + userId + ":" + extra; }安全删除的方法可以遵循下面的顺序:先通过日志或调用链分析确认没有线上流量;再搜索代码仓库确认没有编译期引用;把方法标记为@Deprecated后观察一个版本周期;最后在正式版本里删除,并把变更写进发布说明。如果因为外部客户还在调用而必须保留老接口,可以在网关层统一收敛,而不是让核心代码库长期背着旧逻辑。
从审美角度看,“删除”和“新增”同样是设计行为。一份干净的代码,它的美感并不来自平均每个文件只有多少行,也不来自用了多少新特性,而是来自剩余部分没有冗余信息,每一条路径都值得读者投入注意力。
9. 如何建立“克制、准确、可持续”的技术审美
分析了这么多误区之后会发现,所有问题都可以归结为一件事:在写代码时,我们很难区分“这是在解决问题”和“这是在缓解焦虑”。“偏激暴食者”通常并不是坏意,只是把代码数量当成了控制感来源。要改变这种状态,可以从几个具体动作开始。
建议一,建立依赖引入的最小门槛。拉新依赖时,在代码评审描述里填写两个字段:它解决了什么问题,为什么不使用现有库。如果是工具类能力,先查看 JDK 或语言标准库是否已经支持。团队可以约定:新引入依赖必须由两个人一起确认,并且明确指定负责人跟进后续升级。
建议二,给“扩展性”相关代码设置反推机制。如果一个抽象目前只有一个真实实现,评审时可以直接问:现在删掉接口,对我们有什么损失?如果答不上来,就删。如果一个配置目前没有真实的使用者,评审时可以问:现在把它硬编码成默认值,会发生什么?如果没有任何影响,就写死。
建议三,让删除成为代码评审的常规议题。每次迭代评审时,除了特性功能,专门安排五分钟看删除候选。每季度清理一次长期不用的依赖和废弃方法。删除记录要写入团队的模块文档,避免后来者出于“不敢动”而保留老代码。
建议四,给技术选型写轻量决策记录。前文的 ADR 模板完全可以直接用,不需要复杂工具,一个 docs/adr 目录加几个 markdown 文件就够。这样既能约束暴食冲动,也能让新人理解哪些方案是被认真评估过但放弃的,减少重复踩坑。
建议五,以“代码可读性”而非“支持未来功能”作为核心评价标准。评审时多问:换一个新人来看,他能快速说出主流程吗?最内层的一个改动,影响范围能被自动测试覆盖吗?如果答案是否定的,说明当前的复杂度已经超过代码库应承担的额度。
这些建议加起来,本质上是把“技术摄入”从一个随缘行为,变成一个可持续的预算管理行为。技术审美并不等于“什么新东西都不碰”,而是清楚每一份新增需要什么样的前提条件,也清楚每一份保留是否仍然有价值。
10. 代码评审时的自查清单与常见误区
如果你正在做代码评审,或者在重构自己的代码,可以带着下面这份清单逐项检查。它比单纯说“这段代码太复杂了”更有指导性。
| 问题现象 | 可能的暴食原因 | 排查方式 | 调整方向 |
|---|---|---|---|
| 一个功能对应十几个类 | 过早抽象、模式堆叠 | 从主流程入口开始列出真实调用链 | 只保留两个以上真实调用方共同的抽象 |
| 项目依赖几十个包,但没人说清用途 | 见工具就引,缺乏依赖预算 | mvn dependency:tree 或等价工具查看 | 为每个核心依赖建立使用范围和升级负责人 |
| 同一个常量在多个模块各自定义 | 没有统一建模,继续打补丁 | 全局搜索魔法值 | 收敛到领域模型或配置封装中 |
| 配置中心 key 数量远多于业务数量 | 把“可配置”当“先进” | 拉取全天只有默认值命中的 key | 关闭未使用配置,写死默认值 |
| 一个接口需要传 8 个参数 | 调用方和上下文被拆得到处都是 | 检查是否所有字段都会被读取 | 用语义对象分组,或减少跨层参数透传 |
| 缓存导致数据更新延迟或丢失 | 用缓存掩盖查询和写入结构问题 | 观察缓存命中率和数据一致性报告 | 先修 SQL、再按需加小粒度缓存 |
| 老接口迟迟不敢删 | 怕影响外部调用方 | 查调用链和网关访问日志 | 先标记废弃,观察一个周期后删除 |
自查时有一个强提醒:指出自己或同事的“暴食”问题,不要变成否定学习热情。健康的代码评审是讨论“新增的成本是不是有即时收益”,而不是讥讽“又引入新框架了”。用数据、调用链和可维护性来讨论,比用口头审美偏好更可靠。
11. 最佳实践与工程治理建议
技术审美问题,光靠个人自觉很难持续,最好落到团队流程里。这里给出几个治理向建议,方便你有选择地落到项目里。
第一,建立依赖预算制度。每次迭代中允许新增依赖数量设置一个上限,或至少让新增依赖时必须经过技术负责人确认。依赖如果只在单测里用到,范围优先设为 test,不要把测试框架依赖打进生产包。升级依赖时,先看 release notes 和 breaking changes,区分安全补丁升级与版本大升级。
第二,让“删除”任务有明确的跟踪载体。可以给清理废弃接口、旧字段、冗余依赖建一个 backlog,状态包括:待分析、可删除(已经无引用)、待观察(标记 deprecated)、可移除。这样清理工作不再是某个工程师闲暇时的自选动作,也不会被新需求无限挤压。
第三,配置项的生命周期要覆盖“新增、变更、下线”。每次新增配置项时填表:默认值是什么、生效范围是什么、由谁修改、监控指标是什么。配置项不再使用后,不能只在配置中心删除 key,还要删除代码里的读取逻辑,避免一个看似无用的配置悄悄恢复默认值。
第四,抽象和模式要绑定真实业务变化点。做代码评审时可以总结一句口头禅:你是看到第二个真实使用方了,还是猜到了第二个使用方?如果是猜的,现在先不要抽。扩展点可以有,但要用注释或文档说明准备解决的具体问题,不能为了扩展而扩展。
第五,性能优化从核心瓶颈分析开始。每做一个性能工作前,先确认指标:接口 p95 是多少、数据库慢查询是什么、连接池等待是什么。优化后要记录变更前后对比。缓存不是万能手段,只是性能方案中的一个选项。
这套治理思路并不新鲜,但它能把“审美”变成一个团队可以共同执行的质量指标。写代码和吃饭有一个共通点:真正健康的状态是通过观察和克制,知道什么对自己有价值,而不是什么最香就吞什么。
12. 收束:少吃不是忍饿,是知道什么不该吃
回到标题。偏激暴食者的审美误区,核心不是“吃得多”本身,而是没有形成对摄入物的判断力、消化力和拒绝力。代码库同理。一个项目的健康程度,不取决于它技术栈有多新、抽象有多远、配置有多灵活、依赖有多全,而取决于每个组成部分是不是仍然被维护者准确理解、被真实业务持续需要。
下次在写新功能前,可以问自己一组问题:这段代码删掉后,系统会不会跑不了?这个抽象如果只服务一个调用方,能不能直接删掉接口?这个依赖如果今天爆出安全问题,团队能否一天内替换?这个配置项如果三个月没人改,为什么它还要占据一处分支?这个缓存如果数据不一致,你要怎样发现和纠正?
如果这些问题都回答清楚,哪怕代码里没有任何所谓“高级”设计,也已经比很多堆满花活的项目更接近健康的审美。真正的代码审美,是在一个又一个“不做”的选择里积累出来的。希望这篇文章能帮你在下一次代码评审、技术选型或重构讨论时,多几个让人点头的问题,少几次让代码库膨胀的机会。如果你正在推动团队往干净、克制的方向走,建议先把文中的数据化自查清单保存成一份评审手册,或写进团队规范和 README,在下一个迭代版本里开始执行。