名字说的是什么:动作、角色与改名的边界
一个服务里有
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 的重命名会改掉所有编译器看得到的调用点。实验把同一类改名放到四种「按名字查找」的位置:
JSON 字段。 record 字段 qty 改成 quantity:
改名前写出 {"sku":"A1","qty":3};改名后写出 {"sku":"A1","quantity":3}
还没升级的消费方(按 qty 读、忽略不认识的字段)读新消息:OrderLineV1[sku=A1, qty=0]
已升级的一方读队列里积压的旧消息:OrderLineV2[sku=A1, quantity=0]两个方向都读到 0,没有异常,因为消费方忽略不认识的字段(这本身是正确的兼容设置,见上游加了一个字段,下游全部反序列化失败),缺失的 int 字段就是 0。开头那个对账服务的故障就是这样来的。
反射调用。 配置里写着要调用的方法名:
配置里写着方法名 calc,改名前反射调用:fee=10
方法改名为 calculateFee 后,同一份配置:抛出 NoSuchMethodException(RenameLab$ServiceV2.calc(int))框架里大量的「按名字找」是反射:表达式语言里的属性访问、模板引擎、按方法名注册的处理器、序列化库。
存储下来的枚举。
数据库里存的是 WAIT_PAY;枚举常量改名为 AWAITING_PAYMENT 之后读回:抛出 IllegalArgumentException(No enum constant RenameLab.StatusV2.WAIT_PAY)
如果存的是序号 ordinal():WAIT_PAY 存为 0;新版本在最前面插入 CREATED 之后,按序号读回来是 CREATED存名字,改名就读不回来;存序号,改名没事,但插入或调整常量的顺序就会读成另一个状态,而且不报错。持久化的枚举应当存一个独立的、稳定的代码值,与 Java 里的常量名和顺序都解耦。
配置键。
代码把配置键 order.max-qty 改成 order.max-quantity,部署环境里的配置没改:读到 null,回退到代码里的默认值这是最难发现的一种:没有异常,服务正常启动,只是用了默认值。
以上都是编译器看不到的地方。日志里按名字聚合的指标、监控告警里的字段名、前端代码里的属性名、数据库列名,都属于同一类。
四、怎样安全地改名
先分清这个名字是内部名还是对外名。只在代码里出现、编译器能检查到所有引用的,是内部名,放心改;出现在协议、存储、配置、其他服务里的,是对外名,它是契约。
对外名不要随内部名一起改。实验里最后一行:
改代码里的名字、用 @JsonProperty 保留对外名字:写出 {"sku":"A1","qty":3},读旧消息得到 OrderLineV2Compat[sku=A1, quantity=3]代码叫 quantity,消息里仍然是 qty,新旧双方都读到 3。确实要改对外名时,按扩展—迁移—收缩的顺序:先让读取方同时认新旧两个名字,再让写入方改用新名字,等旧数据和旧调用方都消失后,再去掉旧名字。数据库列的同类做法见能重新部署旧版本,不等于能回滚。
quantity,用 @JsonProperty("qty") 保留对外名字,写出的仍是 {"qty":3},读旧消息得到 quantity=3,新旧双方都读到 3。下半部分是示意:要改对外名字时,先让读取方认两个名字,再让写入方改名,旧数据和旧调用方都消失后再收缩改名前的检查清单:
- 在整个仓库(包括配置、模板、SQL、前端、测试数据)里按字符串搜索旧名字,不只看 IDE 的引用。
- 对外名单独定义:JSON 用注解或显式映射,枚举持久化用独立代码值,配置键在一处集中声明。
- 配置键改名时,读取新键、兼容旧键,读到旧键时打一条告警,一段时间后再删。
- 有序列化格式的类型,加一条测试:把上一个版本写出的样例数据读进来,检查关键字段不是零值。
五、注释补的是名字说不了的东西
名字和结构说清楚了「做什么」,注释就不必再复述。但有些信息代码表达不了,这时候注释是必要的:
- 为什么这样做:选了一个看起来更绕的写法,是因为直接写法在某个条件下有问题。
- 约束与协议:这个字段必须与某个外部系统的格式一致,这个顺序是对方 API 要求的。
- 非显而易见的后果:改这里会影响到哪里,哪个数字是测出来的而不是随便定的。
以注释多少衡量质量是错的,把删注释当目标同样是错的。判断标准是:一个第一次读这段代码的人,缺了这条信息会不会做出错误的修改。
六、检查清单
- 方法名能念给业务方听懂;读调用处就知道发生了什么。
- 名字描述目的而不是实现;业务核心里出现的技术词,检查是否缺了业务概念。
- 集合名说装着什么;角色有自己的名字;布尔值读起来是一个判断。
- 同一个概念只用一个词,新词经过确认再进代码。
- 改名前分清内部名与对外名;对外名单独定义,改它走兼容流程。
- 序列化、持久化、配置的名字有回归测试,用旧样例数据检查。
配套实验
- codesphere-labs/refactoring/rename-boundaries:JDK 25.0.4.1、Jackson 3.2.3:同一类改名在 JSON 字段、反射调用、按名字或序号存储的枚举、配置键四个位置的结果,以及用
@JsonProperty保留对外名字(验证记录)
参考资料