Skip to content

名字说的是什么:动作、角色与改名的边界 ​

一个服务里有 OrderManager.process(order)、handleData(list)、flag2。想知道下单时到底做了什么,只能一层层点进实现去读。后来有人把 qty 改成了更清楚的 quantity,IDE 一键完成,测试全绿,上线后对账服务读到的数量全是 0。

名字是读者理解代码的第一层索引。名字说清了意图,读者看调用处就能判断「这里发生了什么」;名字含糊,就得展开实现,把局部的细节都装进脑子里。所以改名是最常见、收益最高的重构之一。

但名字同时也可能是契约。字段名会变成 JSON 的键,方法名会写进配置,枚举名会存进数据库。代码里改了,这些地方不会跟着变。本文先讲名字应该说什么,再用实验看改名会在哪里出问题。实验见文末。

一、名字要回答的问题 ​

动作说的是意图,不是步骤。 process、handle、manage、do 只说明「这里做了些事」。读者需要的是这件事在业务上是什么:reserveStock、confirmPayment、cancelUnpaidOrder。判断方法很简单:把方法名念给业务方听,他能不能说出「对,下单时会做这一步」。说不出来的名字,通常是把几件事装进了一个方法,这时候真正要做的是拆开它,改名只是第一步。

名字描述的是结果或目的,不是实现。 saveToRedisAndSendMq 把实现写进了名字,将来换存储就得改名;recordPendingPayment 说的是业务上发生了什么,实现怎么变都不用改名。反过来,在缓存、序列化、连接池这些技术模块里,技术词就是准确的领域语言,LruEvictionPolicy 没有什么需要改的。技术词出现在业务核心里,才提示可能缺了一个业务概念。

集合的名字说装着什么。 userList、orderMap 把容器类型写进了名字,而读者关心的是里面是什么、按什么组织:pendingOrders、ordersById、attendeesBySession。类型已经写在声明里,不必再重复一遍。

同一个人在不同场景里是不同的角色。 一个用户在活动里可能是组织者,也可能是报名者,在订单里是买家,在客服工单里是提交人。把它们都叫 user,读者就得自己推断这里的 user 是哪个角色、有哪些权限。organizer、attendee、buyer 让角色直接出现在代码里,角色不同的规则(组织者能取消活动,报名者只能取消自己的报名)也更容易放到对的地方。同一实体承担多个角色时怎样建模,见领域建模。

布尔值说的是条件成立时意味着什么。 flag、status2、check 什么都没说。isOverdue、hasShipped、canBeCancelled 读起来是一个完整的判断。参数里的布尔值尤其要小心:send(message, true) 在调用处完全看不出 true 是什么,见参数放在哪里。

布尔方法的名字和返回值方向要一致。 一个判断「父类方法是否被子类覆盖」的方法叫 isOverrideBy(sub),实现却是 subMethods.noneMatch(...):名字说的是「被覆盖了」,返回 true 时的意思却是「没有被覆盖」。调用处写成 filter(m -> isOverrideBy(m)),读者要先点进去、再在脑子里取一次反,才知道筛出来的是哪一类方法。重构时提取出来的谓词尤其容易出现这种情况:提取前的表达式在原地读得懂,提取后只剩名字,名字就必须准确。改成 isNotOverriddenBy,或者让方法返回正向的判断、在调用处取反,都比一个方向相反的名字好。

二、词汇表:同一个概念只用一个词 ​

团队里常见的情况是:同一个概念有三个英文词(client、customer、user),或者同一个词指两个概念(order 有时是订单,有时是排序)。读者每次遇到都要重新判断。

维护一份带上下文的词汇表能减少这类混用:中文术语、英文标识符、含义、适用范围、不该与什么混淆。几个实际的做法:

  • 新词先提案,再使用。 开发时发现了一个业务方没说过的概念(例如「待确认的取消」),先跟业务方确认它是否真实存在、叫什么,再写进代码。开发者单方面造的词,业务方不认,词汇表就分成了两套。
  • 同一个中文词,场景不同可能是不同的英文概念。 「结算」在支付里是 settlement,在购物车里是 checkout,在财务里可能是 reconciliation。字典里的第一个释义不一定对。
  • 拼写错误往往是另一个合法单词。 adress、recieve 拼写检查能抓到;form 写成 from、lose 写成 loose 不会报错。评审时念一遍名字比看字母更容易发现。
  • 遵守所在生态的约定。 Java 的 getter 是 isActive(),JSON 字段通常是 camelCase 或 snake_case 之一,数据库列名有自己的规则。词汇表统一的是概念,不是要求所有层用同一种拼写形式。

词汇表与通用语言的关系,见从统一语言到限界上下文。

三、改名改的不只是代码 ​

IDE 的重命名会改掉所有编译器看得到的调用点。实验把同一类改名放到四种「按名字查找」的位置:

直接调用编译器检查,IDE 一起改按名字查找编译器看不到JSON 字段qty 改成 quantity:旧消费方读到 qty=0,旧消息读进新类也是 0反射调用配置里写着方法名 calc:改名后 NoSuchMethodException存储的枚举库里存 WAIT_PAY:常量改名后 valueOf 抛异常;存序号则插入常量后读错配置键order.max-qty 改了键名:环境里没改,读到 null,悄悄用默认值代码里的名字可以随时改;对外的名字(协议、存储、配置)是契约,改它要走兼容流程@JsonProperty 让两者分开:代码叫 quantity,消息里仍是 qty,新旧双方都读到 3
图 1 · 实测(JDK 25.0.4.1、Jackson 3.2.3):IDE 改名会改掉所有编译器看得到的调用点;凡是「按字符串找名字」的地方,它都看不到。字段名变成了 JSON 里的键,方法名写在配置里,枚举名存进了数据库,配置键写在部署环境里。改名之后它们不会报编译错误,而是在运行时读到 0、null 或者抛异常。

JSON 字段。 record 字段 qty 改成 quantity:

text
改名前写出 {"sku":"A1","qty":3};改名后写出 {"sku":"A1","quantity":3}
还没升级的消费方(按 qty 读、忽略不认识的字段)读新消息:OrderLineV1[sku=A1, qty=0]
已升级的一方读队列里积压的旧消息:OrderLineV2[sku=A1, quantity=0]

两个方向都读到 0,没有异常,因为消费方忽略不认识的字段(这本身是正确的兼容设置,见上游加了一个字段,下游全部反序列化失败),缺失的 int 字段就是 0。开头那个对账服务的故障就是这样来的。

反射调用。 配置里写着要调用的方法名:

text
配置里写着方法名 calc,改名前反射调用:fee=10
方法改名为 calculateFee 后,同一份配置:抛出 NoSuchMethodException(RenameLab$ServiceV2.calc(int))

框架里大量的「按名字找」是反射:表达式语言里的属性访问、模板引擎、按方法名注册的处理器、序列化库。

存储下来的枚举。

text
数据库里存的是 WAIT_PAY;枚举常量改名为 AWAITING_PAYMENT 之后读回:抛出 IllegalArgumentException(No enum constant RenameLab.StatusV2.WAIT_PAY)
如果存的是序号 ordinal():WAIT_PAY 存为 0;新版本在最前面插入 CREATED 之后,按序号读回来是 CREATED

存名字,改名就读不回来;存序号,改名没事,但插入或调整常量的顺序就会读成另一个状态,而且不报错。持久化的枚举应当存一个独立的、稳定的代码值,与 Java 里的常量名和顺序都解耦。

配置键。

text
代码把配置键 order.max-qty 改成 order.max-quantity,部署环境里的配置没改:读到 null,回退到代码里的默认值

这是最难发现的一种:没有异常,服务正常启动,只是用了默认值。

以上都是编译器看不到的地方。日志里按名字聚合的指标、监控告警里的字段名、前端代码里的属性名、数据库列名,都属于同一类。

四、怎样安全地改名 ​

先分清这个名字是内部名还是对外名。只在代码里出现、编译器能检查到所有引用的,是内部名,放心改;出现在协议、存储、配置、其他服务里的,是对外名,它是契约。

对外名不要随内部名一起改。实验里最后一行:

text
改代码里的名字、用 @JsonProperty 保留对外名字:写出 {"sku":"A1","qty":3},读旧消息得到 OrderLineV2Compat[sku=A1, quantity=3]

代码叫 quantity,消息里仍然是 qty,新旧双方都读到 3。确实要改对外名时,按扩展—迁移—收缩的顺序:先让读取方同时认新旧两个名字,再让写入方改用新名字,等旧数据和旧调用方都消失后,再去掉旧名字。数据库列的同类做法见能重新部署旧版本,不等于能回滚。

内部名改了,对外名不动代码字段 quantity内部名,IDE 改名即可@JsonProperty("qty")对外名单独定义{"qty":3}新旧双方都读到 3确实要改对外名:扩展 → 迁移 → 收缩1读取方认两个名字qty 或 quantity2写入方改用新名字写出 quantity3去掉旧名字旧数据与旧调用方都已消失跳过第 1 步直接改:还没升级的消费方读新消息、已升级的一方读积压旧消息,两个方向都读到 0,没有异常枚举持久化存独立代码值;配置键读新键、兼容旧键并告警
图 2 · 实测(Jackson 3.2.3):代码字段改成 quantity,用 @JsonProperty("qty") 保留对外名字,写出的仍是 {"qty":3},读旧消息得到 quantity=3,新旧双方都读到 3。下半部分是示意:要改对外名字时,先让读取方认两个名字,再让写入方改名,旧数据和旧调用方都消失后再收缩

改名前的检查清单:

  • 在整个仓库(包括配置、模板、SQL、前端、测试数据)里按字符串搜索旧名字,不只看 IDE 的引用。
  • 对外名单独定义:JSON 用注解或显式映射,枚举持久化用独立代码值,配置键在一处集中声明。
  • 配置键改名时,读取新键、兼容旧键,读到旧键时打一条告警,一段时间后再删。
  • 有序列化格式的类型,加一条测试:把上一个版本写出的样例数据读进来,检查关键字段不是零值。

五、注释补的是名字说不了的东西 ​

名字和结构说清楚了「做什么」,注释就不必再复述。但有些信息代码表达不了,这时候注释是必要的:

  • 为什么这样做:选了一个看起来更绕的写法,是因为直接写法在某个条件下有问题。
  • 约束与协议:这个字段必须与某个外部系统的格式一致,这个顺序是对方 API 要求的。
  • 非显而易见的后果:改这里会影响到哪里,哪个数字是测出来的而不是随便定的。

以注释多少衡量质量是错的,把删注释当目标同样是错的。判断标准是:一个第一次读这段代码的人,缺了这条信息会不会做出错误的修改。

六、检查清单 ​

  • 方法名能念给业务方听懂;读调用处就知道发生了什么。
  • 名字描述目的而不是实现;业务核心里出现的技术词,检查是否缺了业务概念。
  • 集合名说装着什么;角色有自己的名字;布尔值读起来是一个判断。
  • 同一个概念只用一个词,新词经过确认再进代码。
  • 改名前分清内部名与对外名;对外名单独定义,改它走兼容流程。
  • 序列化、持久化、配置的名字有回归测试,用旧样例数据检查。

配套实验

参考资料

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