我这两年接手过的老项目不算少,几乎每一个里面都有这么个“神奇”的目录:一堆名字叫XXXUtils、YYYUtil、ZZZHelper的类散落在各个业务模块里,谁都能往里加方法,谁也不敢删。直到前阵子被拉去处理一个迭代了三年的服务端工程,里面的工具类数量已经膨胀到让人头皮发麻的程度——光DateUtils就有四份,其中两份功能几乎一样,只是其中一份修过时区 bug,另一份没有。这种状态下,团队每次改动都要先做一轮考古,排查到底哪儿引的是哪个版本。那个项目的重构任务,简单来说就是标题里那句话:把散落各处、重复造轮子又互相矛盾的 Utils,合而为一。
这个活儿听起来不高大上,但做起来非常考验基本功。合并工具类不是把几个类粘贴到一起就完事,而是要解决“入口收敛、职责归位、行为统一、增量受控”四件事。这篇文章就把我这次重构过程中的完整思路、分层设计、迁移步骤和踩过的坑全部梳理出来,不藏私,方案可以直接抄。
1. 先搞清楚病灶:XXXUtils 为什么要合而为一
1.1 我看到的 Utils 乱象
先描述一下我接手时的典型现场。这个工程是在中大型业务团队里很常见的状态:业务迭代快,新人入职先写业务代码,写着写着发现有些通用逻辑没有现成封装,就直接在自己模块里写了一个小工具类。
时间一长,工具类的分布就变成了这样:
- 多个业务模块各自持有一套
StringUtils、DateUtils、JsonUtils,各自实现的判断逻辑、格式化规则不完全一致。 - 同一种能力有多个不同名字,比如
HttpUtils、HttpClientUtil、HttpRequestHelper,实现方式不同,有的走 Apache HttpClient,有的走 OKHttp,异常处理策略各异。 - 没有统一的包路径,工具类散落在
com.xxx.common、com.xxx.framework、com.xxx.biz.common甚至com.xxx.biz.order.util这种业务包下。 - 没有单元测试,行为对不对靠调用方“试”出来,改一个底层方法,影响的调用方根本没法提前感知。
- 没有归属人,人人都能加,没人负责维护,公共方法被业务短接的现象非常普遍。
我用脚本扫了一遍,整个工程里文件名带Utils或Util的类一共有 137 个。这还没算那些名字不带 Util 但干的就是工具活的类。最离谱的是,同一个字符串是否为空的判断,在两个工具类里居然会有三种不同写法,其中一种甚至把空字符串""和空白字符串" "混为一谈,业务上出现过一次比较隐蔽的数据问题。
这些散装的工具类不仅带来重复代码,更危险的是它们形成了“法外之地”。公共代码本来应该有过审、有测试、有稳定版本,可这些散装 Utils 完全没有这些保障。每个团队、每个模块都觉得自己那一份“够用了”,直到跨模块联调时才发现大家处理同一个标准的方式居然不一样。
1.2 合并的收益,算得清的账
有人可能会问:工具类虽然乱,但也没出过大事故,有必要花一个迭代来合并吗?
我的观点是,工具类的治理不只是在修技术债,它直接影响的是后续每一个迭代的交付效率。就这次项目来说,合并带来的收益非常明确:
- 行为统一。日期格式化、JSON 序列化、加解密这些跨模块通用操作,不再担心两端结果不一致。之前出现过同一份订单数据,在两个模块里序列化出来字段名大小写不一致的问题,合并后以一份实现为准,这种坑直接断根。
- 依赖收敛。工具类不再东南西北各引一方,公共依赖集中管理,
pom.xml里的版本冲突一下子就少了很多。 - 代码量缩减。去重和合并之后,工具类数量从 137 个减少到 41 个,删除的重复代码超过 6000 行。代码量少意味着维护成本低,新同学接手时不需要理解 N 套实现。
- 审查聚焦。改动公共方法时,影响面可以通过静态调用分析提前列出来,Code Review 时重点关注关键调用链即可。
- 为后续基础设施铺路。工具集统一之后,日志规范、异常码、时间格式、TraceId 透传这些横切逻辑都有了统一的挂载点,后续做可观测性改造会顺手很多。
我之前有段时间也纠结过:合并工具类很难量化成“上线后 DAU 涨了几个点”,很难在周报里写出亮眼的业务数字。但在这个项目里,我们做了一个真实的统计:重构前,某个新同学接到一个“导出对账单 Excel”的需求,光排查三个 ExcelUtils 各有什么差异就用了一天多;重构后,同样一件事只需要看官方工具集里的ExcelUtils方法签名和文档,半小时以内能做决定。这种效能提升是切切实实的。
1.3 什么情况值得合,什么情况不建议
工具类合并不是放之四海皆准。我这次做之前先拉了一张判断清单,至少满足大部分条件才建议动手:
- 项目还在持续迭代,后续有至少半年以上的维护周期。如果只是一个即将下线的老系统,合并工具类纯属自嗨。
- 工具类数量已经明显膨胀,重复实现达到 3 组以上。只有一两处重复时,直接删掉冗余引用即可,不值得大动干戈。
- 团队还有足够的人力进行迁移和测试。全部搞完需要至少 2 到 3 周的集中投入,如果大家各忙各的,很难保证迁移质量。
反过来,如果项目正在做大规模架构拆分、核心服务正在重写、或者团队已经决定短期内用新技术栈重做,那“合而为一”这件事就先放一放。等新的统一模块建立起来之后,直接把散装工具类留到旧系统里让它自生自灭,比现在花力气去收敛要划算得多。
2. 合而为一的大原则:不是摊大饼,而是重新分层
2.1 先明确一个关键认知:合并不是把所有东西塞进一个类
在网上搜XXXUtils合并相关的文章时,有一种方案是把所有方法静态引入到一个巨大的CommonUtils里,对外只暴露这一个门面。这种做法的初衷是调用方便,但实际坑非常大。
一个几千行的CommonUtils会迅速沦为新的垃圾堆,职责不清,依赖复杂。我见过有团队把数据库操作、JSON 解析、MD5 加密全部塞进一个大 Util 类,结果是任何一个方法改动都要重新处理所有不相关的依赖,编译速度变慢,测试更是无从下手。
我这次定的原则是:入口可以收敛,但是类必须有边界。“合而为一”说的是整体上的统一治理和统一出口,而不是物理上只有一个文件。合理的目标形态应该像一个组织良好的工具箱,外面是一个统一的品牌,里面按功能分成格挡:这一格放扳手,那一格放螺丝刀,不会把螺丝刀和电钻混在一起。
2.2 合并时的三层切分方案
我在项目里把工具类按职责切成了三层,这也是我个人最推荐的一种方式:
- 基础工具层(common-util):不依赖任何业务和外部框架,只做纯 JDK 层面的能力封装,比如字符串、集合、日期、文件路径、数字运算。这一层要求足够稳定,公共方法签名一旦定型尽量不变化。
- 框架适配层(framework-util):对项目里已引入的第三方框架做二次封装,比如 Jackson 的序列化封装、HttpClient 的连接管理封装、RedisTemplate 的 key 结构封装。这一层可以依赖 Spring、Jackson 等基础框架,但依然不感知具体业务。
- 业务脚手架层(biz-support):面向具体业务域的通用方法,比如订单号生成、脱敏规则、金额精度处理。这一层允许依赖基础工具层和框架适配层,但不能反向依赖。
这样拆分之后,依赖关系是单向的:biz-support依赖framework-util,framework-util依赖common-util,业务代码只能依赖下面的层。谁依赖谁一目了然,不会出现两个工具类互相引用导致循环依赖的问题。
拆层的时候有一个细节值得注意:很多老的工具方法其实横跨多层,比如一个ExcelExportUtils里既有文件路径拼装这类基础操作,又有 Jackson 序列化导出数据结构这种框架适配操作。这种时候不要贪图方便直接塞进某一层,而是先把内部逻辑拆开,基础部分下沉,适配部分留在中间层。虽然拆分过程麻烦一点,但后续维护时的清晰感是值得的。
2.3 包结构设计与类名规范
包结构我采用的是按“能力域”聚合而不是按“调用方”聚合。具体来说,最终工具集放在一个独立的 Maven 模块里,包名结构如下:
com.公司名.platform.util ├── base # 字符串、集合、数字、枚举等基础能力 │ ├── StringUtils │ ├── CollectionUtils │ ├── NumberUtils │ └── EnumUtils ├── time # 日期时间相关 │ ├── DateUtils │ └── DateTimeUtils ├── codec # 编码、加密、摘要 │ ├── Md5Utils │ ├── AesUtils │ ├── RsaUtils │ └── Base64Utils ├── json # JSON 序列化/反序列化 │ └── JsonUtils ├── http # HTTP 客户端封装 │ └── HttpUtils ├── id # 分布式 ID、业务单号 │ └── IdGeneratorUtils └── sensitive # 脱敏相关 └── DesensitizedUtils类名的规范我定了几条硬性要求:
- 统一后缀为
Utils,不再使用Util、Helper、Tool等五花八门的叫法,搜索时就只搜一个关键词。 - 类名必须体现能力域,比如字符串类就叫
StringUtils,不要出现CommonUtils这种谁都能塞一笔的名字——这是对后续垃圾堆积的预防性约束。 - 方法名在语义保留的前提下统一,比如判断空值统一用
isEmpty,不要同一个类里isBlank和isEmpty混用,两个方法名对应两种不同的判断尺度,同事之间不沟通很容易踩混。
命名规范看似是小事,其实直接影响合并后的使用体验。团队里如果每个人命名风格都自由发挥,那“合而为一”只完成了物理搬家,精神上还是一堆散装。
3. 实操全过程:从散装 Utils 到统一工具集
3.1 第一步:做盘点,给工具类建立“户籍档案”
动手合并之前,我先花了大半天时间做全局盘点。这一步不建议省,数据越全,后面决策越稳。
我是用几个手段交叉来做的:
- 全局搜索类名:正则匹配所有
.java文件里的class XxxUtils和class XxxUtil,列出完整清单。 - 依赖和引用扫描:用 IDEA 的 Find Usages 逐个统计引用方,同时借助项目的依赖分析插件生成了调用矩阵。这个矩阵非常有用,它告诉我每个工具类被多少个类、多少个模块引用,哪些实际上已经是“死代码”。
- 人工抽查核心实现:重点看日期、JSON、集合这三个最容易出现“近义词”的领域,抽样对比不同工具类的实现差异。
盘点完成后,我建了一张表,每一行是一个工具类,字段包括当前路径、功能描述、引用数量、是否与其他类重复、是否有测试、风险评级。137 个类整理完,整个人都清醒了不少。
基于这张表,我把所有工具类分成了四类:
- 保留迁移型:功能唯一、实现靠谱,需要进行包移动和类名统一。
- 合并去重型:多份实现里保留一份最合理、测试最完整的,其余删除并在调用方重定向。
- 内联删除型:只有一个调用方的方法,直接把方法体内联到调用处,工具类本体删除。
- 废弃清理型:没有任何引用的死类,直接删。
这一步的产出物是一份清单文档,我把它作为整个迁移的动作底稿,也方便后面团队 review 时逐条对照。
3.2 第二步:定新家,写迁移白名单
清单列完之后,我按前文说的三层模型,给每一个需要保留的工具类分配了新家路径,并整理成一张迁移白名单。白名单的作用是防止迁移过程中“顺手牵羊”,把不该动的也改了。
迁移白名单的格式大致是:
| 旧路径 | 新路径 | 处理方式 | 说明 |
|---|---|---|---|
| com.xxx.order.util.DateUtils | com.xxx.platform.util.time.DateUtils | 迁移+改造 | 保留时区处理能力 |
| com.xxx.order.util.StringUtils | 删除 | 合并到 base.StringUtils | 原实现存在空白判断 bug |
| com.xxx.pay.util.HttpRequestUtil | com.xxx.platform.util.http.HttpUtils | 迁移+重命名 | 原实现无连接池管理 |
| com.xxx.common.JsonUtil | com.xxx.platform.util.json.JsonUtils | 迁移+重命名 | 统一序列化配置 |
这张表我反复跟团队对了两次,重点确认一件事:有没有哪个类虽然看起来是重复的,但其实在某处被以特殊方式调用,直接删了会出问题。比如有一个OrderNoUtils,表面上看就是生成订单号的工具,但它内部依赖了业务表里的自增序列,如果粗暴合到通用的IdGeneratorUtils里会导致生成规则变化。后来我把它留在biz-support层,作为业务脚手架类迁移,而不是强行塞进基础层。
确定白名单的时候还有一个技巧:不要一次性把所有类都列入迁移范围。第一批先选风险最低、依赖最少的类试水,等流水线跑顺了再逐步扩大范围。我这次分了三批,第一批只迁纯基础类,第二批迁框架适配类,第三批才动业务脚手架类。
3.3 第三步:用“兼容层 + 灰度切换”优雅搬家
在真正动手迁移代码引用的时候,最容易翻车的操作方式是“改完新类,全局替换旧引用,一次合入”。这种方式一旦中间有遗漏或行为偏差,影响面会瞬间爆炸。
我采用的是“兼容层先行”的策略:
先在统一工具集模块里完成新类和方法,行为以调研认定的“正确版本”为准。然后把旧的工具类改造成“兼容壳”,内部直接调用新工具类的对应方法,并标注@Deprecated。
举个例子。旧的com.xxx.common.JsonUtil里有一个toJson(Object)方法,新的统一工具集里叫JsonUtils.toJsonString(Object)。我不直接删旧类,而是先这样改:
@Deprecated public final class JsonUtil { public static String toJson(Object obj) { // 兼容层:统一走新实现,避免两端序列化规则不一致 return JsonUtils.toJsonString(obj); } }这样的好处非常明显:
- 旧调用方的代码一行都不用改,功能就已经切换到新实现。
- 因为实现只剩一份,行为不一致的问题瞬间消失。
- 旧类不会在本次迭代被直接删除,风险被隔离。
接下来,利用 IDEA 的重构功能和全局替换,逐批把调用方从旧类切到新类。每切换完一批,就运行该模块的测试和相关联调用例。等旧类的引用数降到 0,再彻底删除兼容壳。如果旧类被多模块依赖,可以先把兼容壳保留一个版本,下个迭代再删除,避免一次性改动过大。
灰度切换在工具类合并里听起来有点重,但我实际操作之后觉得非常值。理由很简单,散装工具类往往没有人能说清所有调用方的行为预期,兼容层给了你一个“随时可以回滚”的缓冲垫。如果发现某个方法的新实现和预期不符,只需要把对应旧类的兼容逻辑改回原来的实现,而不需要去翻遍所有调用方。
3.4 第四步:给公共代码上“保险”——单元测试与文档
很多散装 Utils 没有测试,这是合并后必须补上的缺口。我这次对所有迁入统一工具集的方法,都要求有对应的单元测试。测试的重点不一定追求 100% 覆盖率,但至少要覆盖三类用例:
- 边界值。比如字符串判空要测
null、""、" "、"abc"四种输入;日期转换要测跨月、跨年、闰年、闰月。 - 异常路径。比如 JSON 解析传入非法字符串是否抛出预期异常,异常信息是否包含足够定位上下文的信息。
- 跨时区稳定性。日期时间是重灾区,统一固定测试环境的时区为
Asia/Shanghai,同时补充一组 UTC 时区的用例,确保方法行为不随环境漂移。
举个例子,我在收敛DateUtils时写过一个非常典型的测试用例:
@Test void testParseWithTimeZone() { // 需求:解析用户传入的“2023-08-15 10:00:00”,统一返回 UTC 毫秒时间戳 String input = "2023-08-15 10:00:00"; long expected = Instant.parse("2023-08-15T02:00:00Z").toEpochMilli(); long actual = DateUtils.parseToEpochMilli(input, "yyyy-MM-dd HH:mm:ss"); assertEquals(expected, actual); }某次回归时我注意到旧实现解析"2023-08-15 10:00:00"不会把字符串当成东八区时间,而是直接按系统默认时区解析,导致在不同机器上结果不一致。这个 bug 就是靠这种用例暴露出来的。没有测试兜底,这种隐性坑还得继续埋下去。
文档方面,我现在要求统一工具集里每个公共类都有一段简短说明,内容包括:类的职责边界、常见用法示例、不适用场景、依赖的外部框架。这块不必写成长篇论文,但关键信息必须有。
我用 Javadoc 的形式写在类注释里,让使用者直接在 IDE 里就能看。比如 HttpUtils 的注释会写明“本类基于 HttpClient 5 封装,自带连接池和超时配置;不适用于长连接场景,如需 WebSocket 请走网关通道”。这样新同学不会拿到一个工具类就盲目使用。
3.5 第五步:推广落地,别让新工具集变成“有城无民”
代码迁移完成,并不代表合并结束。最后这一步其实是整个过程中最容易“回潮”的环节——如果没有配套的约束机制,过不了多久,散装工具类又会出生。
我做三件事来控制回潮:
第一件,在 Code Review 规范里增加一条规则:新增工具方法只能进统一工具集模块,并且需要说明理由,禁止在业务模块里新写私有工具类。业务代码里如果发现可以抽取的通用逻辑,一律提到统一工具集里来实现,不许就地新建。
第二件,用静态扫描工具做挽留。我在项目的 CI 流程里加了一个简单的扫描任务,用正则和 AST 检查新提交的代码里是否出现了非统一工具集路径下的Utils类引用。扫描到的话直接标记为 review 不通过。具体可以用 Qodana、SonarQube 自定义规则,或者简单的 shell 脚本加一个团队自维护的“非法包路径清单”。我在这次项目里是先写脚本跑在流水线上,后面再逐步固化成 Sonar 规则。
第三件,把统一工具集做成“文档化 + 版本化”的正式模块。它不像以前那样是一个谁都能改的公共目录,而是有 owner、有发布节奏、有版本号独立的模块。业务模块升级工具集依赖时,通过版本变更记录了解变化,而不是每次都要去源码里翻。
到这里,“将 XXXUtils 合而为一”的整个链路就走完了。盘点建表、分层设计、兼容迁移、测试补课、规范约束——每一步都踩在前一步的产出物上,没有一步是多余的。
4. 常见问题与排查技巧实录
4.1 高频翻车现场
合并工具类的过程中,我踩过和见别人踩过的坑不少,挑几个典型的做个速查表,你在动手前可以先对照一下:
| 症状 | 核心原因 | 解决办法 |
|---|---|---|
| 合并后某个方法输出和之前不一样 | 新旧方法存在隐藏行为差异,比如时区、字符集、默认值 | 迁移前先写“行为对比测试”,同一输入分别调新旧实现,输出不一致时以偏符合业务预期的一方为准 |
| 旧的工具类被 A 模块引用,全局替换时漏了 B 模块 | 引用分布广,靠肉眼找不全 | 必须依赖 Find Usages 和依赖分析工具,替换后跑全量编译 + 自动化测试兜底 |
| 新类里出现不可预期的循环依赖 | 两个工具类互相调用,或者工具类反向依赖业务类 | 严格执行分层原则,检查工具集里是否 import 了业务模块的包 |
| “顺手优化”导致行为越改越偏 | 复制旧方法时顺手改了逻辑,导致行为变化不可控 | 迁移期间只做搬运,不做优化;优化统一列成后续迭代的独立任务 |
| 明明新类已经建好,同事仍然在新代码里用旧类 | 缺少约束机制,或者新类的入口不够显眼 | 旧类加@Deprecated,IDE 会有删除线提醒;接入 CI 扫描;在团队文档里挂一份“新工具集使用指引” |
其中我最想多说两句的是“顺手优化”。人在做这种搬迁型重构的时候很容易手痒,看到一个方法实现不顺眼就想顺手改。我给自己定过一条铁律:迁移期间只搬家,不改逻辑。任何“这个方法应该改”的想法,先记到 backlog 里,等合并完成之后作为独立优化任务再做。合并期和优化期分开,是保证这次重构不翻车最重要的一条自我约束。
4.2 合并后的日常维护套路
工具集合并完只是起点,后面每三个月我做一次小的“体检”。体检内容不复杂,主要是看三件事:
- 工具集模块的代码量是否出现异常增长,如果有类已经超过 500 行,说明职责可能过载了,需要拆分。
- 是否有人在业务模块里新建了工具类,如果发现,分析原因——是统一工具集入口不够好找,还是缺少某个能力导致大家另起炉灶。
- 依赖关系是否仍然符合单向分层规则,检查工具集模块里有没有 import 业务模块的包。
这个“三个月体检”的习惯,保证的不是一次性重构的成果,而是“合而为一”这件事能持续守得住。
4.3 最后聊几句心得体会
我在这个项目里最受益的一点是,学会用“统一入口 + 分层职责”的思路来代替“把所有东西塞进一个类”的偷懒思路。市面上有大量的“Utils 合并”教程,它们往往只演示了文件层面的合并,却没有告诉你合并之后如何维持秩序。真正的“合而为一”,不是把混乱压扁成一个更大的混乱,而是把混乱梳理成有层级的秩序。这一步做完,团队后续做任何公共能力的迭代,都有了一个稳固的底座。
那次做完之后,我又顺手做了个小改造:在统一工具集里加入了一个“路由分发”式的工具入口,业务侧通过一个约定的方法名去获取能力,而不需要关心底层是 JDK 实现还是第三方封装。这其实跟我们在 App 端看到的一些客户端协议设计的思路类似——通过一个统一的凭据去分发能力,避免业务跟具体实现强绑定。这套思路对整个工具集的扩展性是很有帮助的。
工具类的重构没有太多炫技的空间,真正难的是在大量细节问题上连续做出正确的判断。希望这次的分享,能让你在面对一堆散装 Utils 的时候,少走一些弯路,把力气花在真正有价值的事情上。