Skip to content

抽一个公共方法之前:同一条知识,还是只是长得像 ​

税率从 6% 调到 9%,开票模块改了,退款模块里还有一处 1.06 没人记得。用户退款时发现退回的钱比付的少。另一个团队吸取了教训,把所有长得一样的校验都合并成了公共方法;三个月后,产品要求昵称支持中文,改完公共方法,优惠码也开始接受中文了。

「不要重复自己」是最广为人知的设计原则,也是最容易被用偏的一条。它说的不是「不要有两段一样的代码」,而是同一条知识只应该有一个权威来源。两段代码一样,可能是同一条知识写了两遍,也可能只是两条不同的规则今天恰好一样。前一种要合并,后一种合并了反而有害。

决定合并之后,还有第二个问题:把重复的代码提取成方法时,有几种语义会悄悄改变。本文把这两件事都放进实验里跑一遍。实验见文末。

同一条知识写在两处税率 6% 写在开票与退款里改成 9% 只改到开票:开票 109,退款 106长得像,不是同一条规则昵称与优惠码今天都是 4—20 位字母数字合并后按昵称需求加中文,优惠码也接受「七折优惠」→ 合并成一个来源→ 保持分开,各自命名决定提取之后,比较改前改后的三种语义失败语义少了内层 try原异常被通知失败盖住求值次数日志参数提前算好关闭日志时仍算 1000 次短路a && b 变成 both(a, b)不该扣的额度扣了 100 次修正:通知失败挂到原异常的 suppressed 上;参数改成 Supplier;短路条件留在调用处判断一段重复是否该合并,看它们会不会因为同一个原因、在同一时间改变「今天一样」不够;「需求变了要一起改」才是同一条知识
图 1 · 实测(JDK 25.0.4.1):先问两段相同的代码是不是同一条规则。同一条规则写在两处,改一处漏一处,开票和退款就对不上;只是今天恰好一样的两条规则被合并,按其中一条的需求修改,另一条也被改了。决定提取之后,还要检查三件事是否保持:异常是哪个、参数被求值了几次、短路是否还在。

一、同一条知识写在两处 ​

text
开票与退款各写一遍税率 6%:开票 106.00,退款 106.00
税率改成 9%,只改到了开票:开票 109.00,退款仍是 106.00
两处都调用同一个 TaxPolicy,改一处:开票 109.00,退款 109.00

税率是一条业务规则,开票和退款都在使用它。规则写在两处,改的时候就要两处都记得改。这类重复的特征是:它们会因为同一个原因、在同一时间改变。税率调整,两处必须同时改;改了一处没改另一处,系统就处于一个业务上不存在的状态。

这类重复往往不是逐字相同的。可能一处写的是 * 1.06,另一处是 + amount * 0.06,还有一处在 SQL 里。搜索 1.06 找不全。识别它们要从业务问题出发:「税率在哪些地方被用到」,而不是「哪些代码长得一样」。

合并的方式是给这条知识一个名字和一个位置:TaxPolicy、ShippingRules、RefundWindow。调用方表达「按税率计算」,而不是自己算一遍。

二、长得像,但不是同一条规则 ​

text
昵称与优惠码今天的规则一样(4 到 20 位字母数字):昵称 "bob2026" true,优惠码 "七折优惠" false
合并成 isValidShortText 之后按昵称的新需求修改:昵称 "小明" true,优惠码 "七折优惠" 也变成 true

昵称的长度规则来自用户体验,优惠码的格式规则来自营销系统和打印的限制。它们今天碰巧都是「4 到 20 位字母数字」,但改变它们的人、原因和时间都不同。合并成 isValidShortText 之后,两条规则被绑在了一起:昵称的需求一变,优惠码的规则也跟着变了,而改代码的人只想改昵称。

判断方法是问一句:如果需求变了,这两处是否一定要一起改? 一定要,就是同一条知识;不一定,就是偶然的相似。偶然相似的代码保持分开,各自用业务名称命名(isValidNickname、isValidCouponCode),即使今天内容一样。它们可以共享更底层的、真正通用的部分(例如一个字符集判断),但规则本身要分开。

这类错误合并在跨上下文时尤其常见:订单服务和库存服务里都有一个「商品」,字段也差不多,于是合并成一个公共的 Product 类。之后任何一方的需求变化都要两边协调。同一个词在不同上下文里是不同的概念,见从统一语言到限界上下文。

三、提取时会改变的三种语义 ​

确认是同一条知识、决定提取之后,还要保证提取前后的行为一致。下面三种变化,代码看起来几乎一样,编译器不会提醒。

失败语义 ​

两处都有一段「执行任务,失败时通知」的代码。原来的写法里,通知本身失败会被忽略,原异常照常抛出;提取公共方法时少写了一层 try:

java
static void runAndNotify(Runnable task) {
    try { task.run(); } catch (RuntimeException e) { notifier.fail(e); throw e; }
}
text
原写法:调用方收到 DbError(写入失败)
提取成 runAndNotify 之后:调用方收到 NotifierDown(通知服务不可用)
提取时把通知失败挂到原异常上:调用方收到 DbError(写入失败),suppressed 里有 NotifierDown

通知服务一出问题,所有调用方看到的错误都变成了「通知服务不可用」,真正的写入失败被盖住了,排查的方向完全错误。正确的提取要保留原来的语义:通知失败不能取代原异常,挂到它的 suppressed 上:

java
try { task.run(); } catch (RuntimeException e) {
    try { notifier.fail(e); } catch (RuntimeException n) { e.addSuppressed(n); }
    throw e;
}

异常信息在传播路径上的其他丢失方式,见出了错,日志里却没有可用的信息。

求值次数 ​

java
if (debug) log(buildExpensiveDump());          // 原写法
logIf(debug, buildExpensiveDump());            // 提取后
text
日志关闭时循环 1000 次:内联的 if 里 expensive() 调用 0 次;提取成 logIf(on, expensive()) 后 1000 次;参数改成 Supplier 后 0 次

Java 的方法参数在调用前就求值了。原来 if 挡住了昂贵的构造,提取成方法之后,不管日志开没开,参数都先算好再传进去。如果这个「昂贵的构造」还有副作用(读数据库、计数),行为就变了。参数改成 Supplier,由方法内部决定要不要求值。

短路 ​

text
cheapCheck() && consumeQuota() 执行 100 次,额度被扣 0 次;提取成 both(cheapCheck(), consumeQuota()) 后被扣 100 次

&& 在左边为假时不会计算右边。提取成 both(a, b) 之后,两个参数都会先求值,右边那个扣额度的调用每次都执行了。条件里带副作用的调用,提取时要特别小心;更好的做法是让条件本身没有副作用。

三种变化的共同点是:提取改变了代码的执行时机。原来在某个条件之内或之后执行的东西,变成了在方法调用之前执行。检查一个提取是否保行为,要逐项比较:返回值、抛出的异常、副作用的次数和顺序。怎样系统地做这种比较,见把长方法拆开,哪些行为最容易被改掉。

四、提取的时机 ​

两段代码一样不必立刻合并。几个实用的判断:

  • 第一次出现,写下来;第二次出现,注意到;第三次出现,再考虑提取。 两个例子往往还看不出哪部分是共同的、哪部分是变化的,过早提取容易提取错。
  • 提取的是规则,不是行数。 把三行相同的代码提取成方法,如果这三行没有一个业务名称(只能叫 helper1),通常说明它不是一个独立的概念。
  • 参数越来越多是信号。 公共方法为了适应每个调用方,加了一个又一个参数和布尔开关,说明这些调用方其实不是同一条知识,应该拆回去。
  • 提取之后读调用处,应该更清楚。 如果读者为了理解调用处,必须点进公共方法看它对这组参数做了什么,提取就没有帮上忙。

五、两份同构的流程:把差别收敛成一个参数 ​

另一种常见的重复不是几行代码,而是两份结构相同的流程。HTTP 框架里的根资源和子资源就是一个例子:两者都要「用剩余路径匹配资源方法,匹配不上就尝试子资源定位器,再递归」,唯一的区别是资源实例从哪里来:根资源由容器创建,子资源是定位器方法返回的对象。

两份流程各写一遍,以后修改匹配规则(例如增加 HTTP 方法和媒体类型的选择)就要改两处。把差别收敛成一个参数,流程只保留一份:

java
// 之前:RootResource 和 SubResource 各自实现 match(path, context)
// 之后:一个 ResourceHandler,差别由「实例来源」这个参数表达
record ResourceHandler(Class<?> type, Function<ResourceContext, Object> instance) {
    Optional<Result> match(String remaining, ResourceContext ctx) { ... }
}
ResourceHandler root = new ResourceHandler(Orders.class, ctx -> ctx.create(Orders.class));
ResourceHandler sub  = new ResourceHandler(item.getClass(), ctx -> item);   // 复用定位器返回的同一个对象

这次提取要注意两点:

  • 共享的是流程,不是生命周期。 根资源每个请求新建,子资源复用定位器返回的实例。参数化之后,测试仍要分别证明两条来源各自正确:根资源每次都是新对象,子资源就是定位器返回的那一个,而不是按声明类型重新构造。
  • 通用的匹配函数要把完整的匹配结果交回调用方。 一个只返回「选中了哪个处理器」的通用 match,丢掉了剩余路径和路径参数,调用方只好再匹配一次,或者干脆拿不到这些信息。抽象的返回值要按调用方后续需要什么来设计,而不是按「这几个调用方共同需要的最少信息」来设计。
之前:两份流程RootResource.match剩余路径 → 资源方法→ 子资源定位器 → 递归SubResource.match剩余路径 → 资源方法→ 子资源定位器 → 递归改匹配规则要改两处之后:一份流程 + 一个参数ResourceHandler(type, instance).match(remaining, ctx)返回完整的匹配结果:方法、剩余路径、路径参数根资源ctx.create(Orders.class)子资源定位器返回的 item每个请求新建复用同一个对象共享的是流程,不是生命周期:两条来源各有一条测试
图 2 · 示意:两份流程都是「用剩余路径匹配资源方法,匹配不上就找子资源定位器,再递归」,唯一的差别是实例的来源。把差别收敛成 instance 参数之后,匹配规则只改一处;但根资源每个请求新建、子资源复用定位器返回的对象,两条来源仍要分别测试。匹配结果要把剩余路径和路径参数交回调用方

参数化的扩展点怎样设计、什么时候应该改用策略对象,见多个产品共用一套流程。

六、检查清单 ​

  • 判断重复时问:需求变了,这几处是否一定要一起改。
  • 同一条知识给一个名字和一个位置;偶然相似保持分开,各自用业务名称。
  • 跨上下文的「同名概念」不合并。
  • 提取前后比较返回值、异常、副作用的次数与顺序。
  • 失败处理里的二次失败不能盖住原异常;带副作用或昂贵的参数用 Supplier;条件里不放副作用。
  • 公共方法的参数和开关越加越多时,考虑拆回去。

配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权