将XXXUtils合而为一:工具类重构的分层设计与迁移实践

发布时间:2026/9/7 14:37:37
将XXXUtils合而为一:工具类重构的分层设计与迁移实践 我这两年接手过的老项目不算少几乎每一个里面都有这么个“神奇”的目录一堆名字叫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-utilframework-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.DateUtilscom.xxx.platform.util.time.DateUtils迁移改造保留时区处理能力com.xxx.order.util.StringUtils删除合并到 base.StringUtils原实现存在空白判断 bugcom.xxx.pay.util.HttpRequestUtilcom.xxx.platform.util.http.HttpUtils迁移重命名原实现无连接池管理com.xxx.common.JsonUtilcom.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 了业务模块的包“顺手优化”导致行为越改越偏复制旧方法时顺手改了逻辑导致行为变化不可控迁移期间只做搬运不做优化优化统一列成后续迭代的独立任务明明新类已经建好同事仍然在新代码里用旧类缺少约束机制或者新类的入口不够显眼旧类加DeprecatedIDE 会有删除线提醒接入 CI 扫描在团队文档里挂一份“新工具集使用指引”其中我最想多说两句的是“顺手优化”。人在做这种搬迁型重构的时候很容易手痒看到一个方法实现不顺眼就想顺手改。我给自己定过一条铁律迁移期间只搬家不改逻辑。任何“这个方法应该改”的想法先记到 backlog 里等合并完成之后作为独立优化任务再做。合并期和优化期分开是保证这次重构不翻车最重要的一条自我约束。4.2 合并后的日常维护套路工具集合并完只是起点后面每三个月我做一次小的“体检”。体检内容不复杂主要是看三件事工具集模块的代码量是否出现异常增长如果有类已经超过 500 行说明职责可能过载了需要拆分。是否有人在业务模块里新建了工具类如果发现分析原因——是统一工具集入口不够好找还是缺少某个能力导致大家另起炉灶。依赖关系是否仍然符合单向分层规则检查工具集模块里有没有 import 业务模块的包。这个“三个月体检”的习惯保证的不是一次性重构的成果而是“合而为一”这件事能持续守得住。4.3 最后聊几句心得体会我在这个项目里最受益的一点是学会用“统一入口 分层职责”的思路来代替“把所有东西塞进一个类”的偷懒思路。市面上有大量的“Utils 合并”教程它们往往只演示了文件层面的合并却没有告诉你合并之后如何维持秩序。真正的“合而为一”不是把混乱压扁成一个更大的混乱而是把混乱梳理成有层级的秩序。这一步做完团队后续做任何公共能力的迭代都有了一个稳固的底座。那次做完之后我又顺手做了个小改造在统一工具集里加入了一个“路由分发”式的工具入口业务侧通过一个约定的方法名去获取能力而不需要关心底层是 JDK 实现还是第三方封装。这其实跟我们在 App 端看到的一些客户端协议设计的思路类似——通过一个统一的凭据去分发能力避免业务跟具体实现强绑定。这套思路对整个工具集的扩展性是很有帮助的。工具类的重构没有太多炫技的空间真正难的是在大量细节问题上连续做出正确的判断。希望这次的分享能让你在面对一堆散装 Utils 的时候少走一些弯路把力气花在真正有价值的事情上。