Java 中抽象抽过头?别让 DRY 原则变成你的技术债

|

在我接手的一些Java项目中,经常看到为复用而做的抽象类,把公共逻辑提取到这个类里,代码表面上是干净了,逻辑全提出来,看起来特别DRY,但一上手维护就觉得别扭得很。每当改之前得小心翼翼,生怕一个改动牵一发而动全身。

在Java代码中还看过这种抽象类:

1
2
3
4
5
6
/**
* 抽取出来发现没啥用,但是抽都抽了,就先放着吧
*/
public abstract class AbstractProcessService {
...
}

是不是现在大家都特别喜欢搞抽象了呢?

image-20250803124738650

很多开发人员在写代码的时候,总爱拿所谓的“最佳实践”当圣经,要是不照着来,好像自己写的代码就是罪过一样。比如DRY原则吧,很多人就特别执着,恨不得代码里有一点重复都忍不了。可你有没有遇到过这种情况:代码写完了,抽也抽了,但怎么看都觉得怪怪的?

其实吧,这种感觉“怪怪的抽象类”,往往就是我们抽过头了。说得直白点,就是抽得不干脆、不彻底,甚至有点“强行抽象”的味道。

为什么总会掉进这个坑?可能是我们对DRY的理解太教条了,总想着“绝不能重复”,结果为了复用去搞了个奇奇怪怪的抽象层,最后反倒给自己挖了个坑。

今天我们就放松一点,重新聊聊DRY原则最初到底想干啥,然后再说说,有哪些情况你其实可以稍微“放自己一马”,没必要那么DRY。

1. 什么是 DRY 原则?

DRY 全称是 Don’t Repeat Yourself,也就是“别重复自己”。简单说,它就是提醒我们,别在代码里写重复的逻辑。重复越多,改起来越头疼。

DRY 的核心,其实不是省几行代码,而是为了让变化发生时,不用到处找重复逻辑来改。如果需求一变,复制粘贴的代码要一个个去改,不仅繁琐,而且容易改漏,但要是提取成了统一的函数,就改一处搞定,避免了很多问题。

正因为如此, DRY 才成了各种最佳实践里的常客。下面这张图形象地解释了 DRY 的思路:

image-20250803113332618

把重复的代码提取出来,方便集中管理。假设哪天需要改逻辑,只动一处,所有用到的地方自动跟着变,不用担心漏改。

但现实开发里就复杂多了。

有时候你抽得太早,或者抽得太随意,比如看起来“差不多”的两个逻辑,其实细节差了一点点,一旦合并起来,反而让代码变得更难维护。

DRY 本身没啥复杂的,它的出发点也挺朴素的。难就难在,你得判断哪些重复真值得抽什么时候抽更合适,还有,抽完之后到底清晰了,还是更绕了?

这种判断没公式,得靠你慢慢磨。多做几个项目,多踩几次抽象过头的坑,慢慢你就有感觉了:哪些地方该忍住不动,哪些地方可以干脆抽出来。

所以这篇文章其实就想聊聊“抽象别太早”这个话题。毕竟大家都听过“过早优化是万恶之源”,那“过早抽象”是不是也差不多?

2. 抽象抽得太早了

你写功能的时候,有没有过这种经历?一边写一边想,“这功能我以后肯定还得扩展,不如现在就抽象个父类,以后扩展起来不就轻松多了?”

刚开始搞面向对象的时候,我也特别喜欢这么干。比如通知功能吧,当时就想:“通知不就那么几种嘛,邮件通知、系统告警、App私信啥的,总得有发送、撤回、写日志,干脆整一个统一的 Notification 基类,多优雅!”

于是我就兴致勃勃地写了如下结构的代码:

---
config:
  themeVariables:
    fontSize: 12px
    fontFamily: Resource Han Rounded CN
---
classDiagram
    class Notification {
        +boolean send(String recipient)
        +boolean cancel(String id)
        +boolean log()
    }
    class EmailNotification {
        // 直接继承所有方法
    }
    class InAppNotification {
        +boolean send(String recipient)
        // log() 方法可选,使用父类默认实现
    }
    class SystemAlert {
        +boolean send(String recipient)
        +boolean log()
        // cancel() 方法不应调用
    }
    note for SystemAlert "cancel() 方法不支持
    log() 日志记录方式不同,具体方法已自定义"

    Notification <|-- EmailNotification
    Notification <|-- InAppNotification
    Notification <|-- SystemAlert

等后来真正的业务来了,才发现自己挖了个大坑。比如 SystemAlert 系统告警,居然也有了“撤回”这种诡异功能,写到用户审计日志功能又不一样了。后来业务越来越复杂,每个通知类型的功能差异越来越大,那些莫名其妙的继承关系反倒成了负担,每次开发都要绕过那些根本用不到的功能。

出现这个问题的核心在于:你以为自己已经找到了一个通用模式,但其实只是想象的通用。等到需求一个个落地,你会发现这些看上去差不多的场景,其实有很多细节差异。而这些差异一旦忽略,就很容易让抽象变成干扰。说白了,就是因为还没真正理解业务的前提下,就太早动手提炼结构了,最终导致为设计上的混乱埋下了种子。有时候也会遇到一些需求产品经理讲不清楚,说就暂时这么做的情况。如果这个时候你就开始做各种抽象业务设计,你迟早要为你的抽象代码买单。

现在很多软件设计都更推崇一个理念:先从具体做起,让抽象自己浮现出来。 这个理念推崇的不是一上来就想着搞一套通用结构,而是在多个真实案例里,慢慢看到共性,再去总结。这样提炼出来的抽象才有生命力,才不会经不起推敲。

语义过载

搞过领域驱动设计(DDD)的朋友应该听过一个关键词:通用语言(Ubiquitous Language)。意思就是在某个限界上下文(Bounded Context)里,团队内部的各种业务概念得统一,不管是建模还是沟通,都别各说各话,免得鸡同鸭讲。

但在现实开发里,这事儿就没有那么好实施了。我们经常会往一个类里塞了太多不相干的逻辑,看起来是一个模型,抱着复用的心态,试图用一个通用模型来 hold 住多个上下文的不同需求,但是其实模型后面却藏着好几套完全不同的业务语义。久而久之,这个模型就膨胀成了一个“谁都能用、但谁用都嫌弃”的四不像,shi山代码逐步开始成行。

举个更具体的栗子:你在做电商系统,里面有各种用户类型:买家、卖家、管理员,看起来都是用户,于是搞一个 User 类,把各种用户类型的逻辑全往里丢。最后这个类看起来功能超全,实际上变得又重又乱,Java类图如下:

---
config:
  themeVariables:
    fontSize: 12px
    fontFamily: Resource Han Rounded CN
---
classDiagram
direction TB
    class User {
	    String id
	    String name
	    String email
	    String passwordHash
	    Address shippingAddress
	    Address billingAddress
	    int permissionLevel  // 仅用于后台管理面板
	    Date lastLogin       // 安全审计相关
	    UserPreferences preferences  // 前端界面相关
	    int loyaltyPoints    // 仅在客户上下文中使用
    }
    class Address {
    }
    class UserPreferences {
    }
    User --> Address : shippingAddress
    User --> Address : billingAddress
    User --> UserPreferences

实际情况往往没那么理想:在客服那边,用户是工单的联系人;到支付系统里,用户只是个付款方;前端页面上,用户也许就只是个头像加昵称而已。

你要是把这些不同业务需求,全塞进一个“万能用户模型”里,看着是方便复用,实际就是把风马牛不相及的东西强拼在一块儿。久了之后,各个系统的边界就逐渐模糊了。

我们之前就遇到过,维护个字段的时候,突然发现改了一行代码,另一个团队的接口就收到影响了。这个“User”模型成了谁都不敢动的定时炸弹,谁改谁挨骂。

后来我们干脆不折腾了,用户模型就地拆开,每个模块自己定义自己的结构,别互相掺和。像下面这样:

---
config:
  themeVariables:
    fontSize: 12px
    fontFamily: Resource Han Rounded CN
---
classDiagram
    class Customer {
        +String id
        +String name
        +String email
        +int loyaltyPoints
        +UserPreferences preferences
    }
    class AdminUser {
        +String id
        +String name
        +String email
        +int permissionLevel
        +Date lastLogin
    }
    class PaymentIdentity {
        +String id
        +Address billingAddress
    }

    Customer --> UserPreferences : preferences
    AdminUser --> Date : lastLogin
    PaymentIdentity --> Address : billingAddress

这样一拆清爽多了,谁的逻辑归谁管,业务需求来了就自己搞定,不用提心吊胆会不会动到别的地方的 cheese。

其实这也就是 DDD 里说的“限界上下文”——每个业务域用自己的模型,各自为政,接口清楚、责任分明。最明显的好处就是,沟通也变得轻松,没人天天跑来问:“你们那个字段到底是干嘛的?”

那个听起来啥都能装的“超级用户模型”?听着挺高级,用起来就知道,它根本不是在帮你复用,是在给你制造混乱。

软件过度耦合

大家都知道 DRY(Don’t Repeat Yourself)原则的初衷是好的,减少重复,让代码保持一致。但真落到实际开发里,有时候你会发现,抽象抽着抽着,反而把系统抽“死”了。

我们团队之前就踩过这种坑。为了统一邮箱处理逻辑,我们写了个 EmailAddress 类,长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class EmailAddress {
private final String value;

private EmailAddress(String value) {
this.value = value;
}

public static EmailAddress create(String input) {
if (!input.contains("@")) {
throw new IllegalArgumentException("Invalid email address format");
}
return new EmailAddress(input.trim().toLowerCase());
}

public String getValue() {
return value;
}
}

这么做初衷就是让邮箱统一转小写,所有模块都用这个类,包括注册、找回密码、发送通知,省得每个地方都写一遍邮箱校验。

然后某天,有人为了兼容一些特殊企业邮箱,把 create() 方法改了:

1
2
3
4
5
6
7
public static EmailAddress create(String input) {
if (!input.contains("@")) {
throw new IllegalArgumentException("Email must contain '@'");
}
// 不再统一转小写,也不再强制清洗
return new EmailAddress(input.trim());
}

问题就爆发了,下游业务很多地方是默认邮箱已经是小写的,比如做比较、做去重、做命中判断。现在不转小写了,逻辑就全乱套了。这种变化在上游看起来完全合法,单测也过了,但下游根本不知道出了啥问题,只能等线上用户出事了才发现。

也许有人会想,不是下游的代码写得不够健壮吗?如果需要小写格式的邮箱,自己转换不就好了吗?其实,我觉得问题更多是在于最初设计时,EmailAddress 已经默认是小写邮箱地址了,大家都这么理解。可是,它的“角色”变了,支持了大小写,结果其他地方对它的印象却还停留在修改前。提取邮箱逻辑本身没错,是不是错在最初把邮箱都是小写的提取到一个公共逻辑中?

当你将某段逻辑抽象出来并复用时,其实就为系统模块间创建了一种隐性的契约。一旦这些抽象发生变化,之前那些没有明确说明的假设,可能就会被打破。

那么,怎么才能正确的使用DRY原则呢?

3. DRY 使用指导原则

避免过早抽象

有时候我们一看到代码里有重复,就忍不住想:“这不行,得抽一下。”但是往往抽出来之后,坑可能就在后面。

前阵子我重构订单模块,发现大家都往一个 BaseOrderService 里塞逻辑,什么下单、配货、支付全往里堆。表面上看着是复用了,但动一个地方就怕影响别的子类,改起来特别别扭。

我干脆回归最简单的做法,先不搞抽象,直接写清楚一条业务链路再说:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class OrderService {
private final ConsignmentService consignmentService;

public OrderService(ConsignmentService consignmentService) {
this.consignmentService = consignmentService;
}

public Order placeOrder(Order order) {
Consignment c = consignmentService.createConsignment(order);
processPayment(order);
order.setConsignment(c);
return order;
}

private void processPayment(Order order) {
// 支付逻辑...
}
}

你看这就很直观,配货和支付是两个独立模块,我通过构造方法把配货逻辑注进来,测试的时候想换掉也容易。要改需求也不痛,完全不用担心别的地方跟着出问题。

就像你家吸尘器坏了,换一个新的就行,总不会因为吸尘器挂了就把整间屋子装修一遍吧?

所以我现在基本不会一上来就搞什么“通用父类”。先把流程写清楚,跑通了,等有几个地方真的用到了同样逻辑,再考虑抽一层也不迟。你要是太早抽,抽来抽去反而把自己套死了。

优先抽象业务规则

有些重复是真的不能留,特别是那种关键业务规则。你不抽出来,早晚有人踩坑。

比如我们之前的购物车模块,添加商品、更新商品数量两个接口,看起来都差不多,但都写了自己的“最多10件”限制。结果后来业务一改,只改了一个地方,另一个忘了改,线上就直接出锅了。

当时那两段逻辑,看着都差不多,但你细看,其实写法还真不一样:

1
2
3
if (cart.totalItems() + quantity > 10) {
throw new IllegalArgumentException("不能超过10件");
}

1
2
3
if (cart.totalItems() - cart.getQuantity(productId) + quantity > 10) {
throw new IllegalArgumentException("不能超过10件");
}

表面雷同,实则都是坑。后来我们直接把这个限制抽出来做了个 CartPolicy.validateCartLimit(...),统一掉,之后就没人再改漏了。

但话说回来,如果你看到的是一些什么“日志打印、对象映射”这种纯基础设施的重复,真没必要急着抽。写得越抽象,耦合越严重,还不如复制一份用得干脆。而且日志、映射、配置等基础设施,一般业界已有成熟方案,无需自己写抽象层。

DRY 原则不是用来束缚你的,而是帮你识别哪些“该抽”的规则值得维护。别一看到重复就条件反射地抽象——真正该抽的,是那些改错了就出事的代码。

共享代码保持精小

很多时候我们一看到一段逻辑可以复用,就会想着:“要不我把它提出来放到 shared/utils 里?”听起来很对,但其实也会有坑的。

共享代码不是放得越高越好,相反,越靠近上下文的共享,越安全、越靠谱。以前一个项目模块就有同事吃过这个亏:把一段金额格式化的逻辑封到了全局 FormatUtils 里,结果报表模块和结算模块都在用。有一天结算那边要加个人民币符号,刚改完上线,报表那边直接点不开了,说展示错乱。真的是“一个人放屁,全屋遭殃”。

所以后来换了个思路:逻辑要共享没问题,但范围要“管住”。像这种金额格式化的东西,就直接放进 reporting.utils.MoneyFormatter 里,报表专用,其他人别碰。这样将来报表独立出去做服务化部署时,根本不用担心依赖一堆不相干的工具类。

看看下面这张图,可以更清楚地看到局部共享的好处:

局部共享的好处

这样做不仅确保了代码的用途明确,还能为未来的模块拆分、迁移或独立部署留下空间。

别迷信 DRY 到处抽工具类,更别随便封装进 shared。封装不是“集市”,是“分区”。共用可以,但最好只在熟人之间共用。

做好契约测试(Contract Test)

在支付团队里,有一段小插曲说明了为什么契约测试这么重要。

他们一直在用同一个共享的 PriceCalculator 组件来算折扣:

1
2
3
4
5
6
7
public BigDecimal applyDiscount(BigDecimal price, int discountPercentage) {
if (discountPercentage < 0 || discountPercentage > 100) {
throw new IllegalArgumentException("Invalid discount");
}
// 原来这里直接返回原始计算结果
return price.multiply(BigDecimal.valueOf(1 - discountPercentage / 100.0));
}

某次结算规则改了,要“四舍五入到分”,组件组很快跟进,测试也一起改了,CI 全绿。直到下游服务上线才发现:他们原本用不带舍入的结果去判断满减阈值,一下子都错乱了。

这时候,支付团队才在自己项目加了一条“契约测试”——从使用者角度,硬编码验证旧行为

1
2
3
4
5
@Test
void shouldApplyDiscountWithoutRounding() {
BigDecimal result = calculator.applyDiscount(new BigDecimal("100.00"), 15);
assertEquals(new BigDecimal("85.000"), result);
}

把它放到自己的 CI 流程里,一旦组件核心行为变了(不管组件组怎么更新测试),这个用例就会立刻红灯报警。

验证“组件是否正常”并不能确保“我的使用是否仍然安全”

契约测试不是要取代单元测试,而是让使用方也帮着“把关”,确保每一次升级都不会悄悄破坏自己的业务逻辑。多服务共享组件时,这一步尤其不能省——要不然等到生产环境再发现,麻烦可就大了。

做好版本管理

有一次一个同事在项目中给好几个服务同时加了同一份业务逻辑,结果更新的时候才发现:有的服务还在跑旧代码,另一部分已经上线了新逻辑,数据完全对不上。排查花了不少时间。后来我就坚定地把“共享模块”也当成一个独立的小服务来看待。

比如我们公司做的 inventory-core,我会直接在各个项目里用 Maven 引入:

1
2
3
4
5
<dependency>
<groupId>com.mycompany.inventory</groupId>
<artifactId>inventory-core</artifactId>
<version>1.2.3</version>
</dependency>

这样一来,想升级到 1.3.0,就得在每个服务里主动改一下版本号、跑个冒烟测试,确认没问题才发布。要是发现兼容性有风险,我还能迅速回滚到 1.2.3,根本不用担心某台机器“偷偷”跑了代码副本。

所以,不管你用 Maven、Gradle,或者随便打包成一个 Jar,我建议都把共享逻辑当成有自己生命周期的小模块来管理——就像你依赖的第三方 API 一样,要升级就得“敲门”、得评估,才能保证大家都在同一个频道上,避免“版本幽灵”乱入的麻烦。

4. 说在最后

DRY(Don’t Repeat Yourself,不要重复自己)是编程中的黄金法则,提倡我们减少代码的重复,提升可维护性。这个法则的出发点是好的,但是我们不要被这个原则“绑架”了,更不要在需求还没完全清晰的时候,就开始盲目地抽象,特别是在推崇OO编程思想的Java这类编程语言中,结果反而让代码变得复杂难懂,维护起来更是头疼。

你是不是也有过这种经历:为了避免重复逻辑,抽了个 BaseXXX 出来,结果发现后续加功能的时候越来越难改?

比如写个 BaseExporter,想着以后导出成 CSV、XML、PDF 都能继承它,各搞各的逻辑,看起来挺“面向对象”的。但后来导出逻辑一个比一个复杂,CSV 要分页、XML 要带命名空间、PDF 还要套模板,最后那个抽象父类成了空壳,还处处掣肘。加一个逻辑还得改接口,要不就是硬塞进子类,产生奇怪的代码。

所以抽象不是为了“省代码”,而是为了“统一不可变的规则”。比如那些明确的业务限制——商品数量最多10个、手机号必须唯一,这类规则值得抽。DRY 最大的误区是“看到重复就想抽”,而忽略了这些重复到底是不是“稳定的模式”

有时候,你不是在复用,而是在用一堆看不懂的抽象,把自己绑进了逻辑迷宫

如果你曾经经历过过度抽象带来的困扰,深知其中的痛苦,那就把这段经验告诉更多人吧。软件设计中并不是每个重复的地方都需要立即抽象,聪明的开发者知道在哪里适当地保持重复

有同感吗?可以让身边的同事或朋友也看看,帮助更多开发者避免这些常见的误区!