前言
在项目中,所有微服务(用户服务、课程服务、订单服务、营销服务)都依赖Redis进行缓存存储、分布式锁、库存计数等操作。最初项目中只有一个MyRedis工具类,随着业务增长,这个类膨胀到了1022行------所有数据类型操作(String、Hash、List、Set、ZSet、Geo、Bitmap)全部混在一起,查找方法靠Ctrl+F,新增功能不敢动旧代码。
更痛苦的是日志排查困难、迁移成本高------有一次线上问题排查时定位Redis相关的操作耗时因为类和单一方法过长花费了大量时间;团队新人上手时对着千行类翻找了半小时才找到封装过的那个操作。后续如果需要替换底层Redis客户端或增加缓存降级逻辑,所有调用方都得同步修改,改动范围极大。
这不是代码能力问题,是代码结构到了该重构的时候。本文复盘重构的动机、设计决策、以及实际效果。
本文核心问题:
- 为什么一个工具类能膨胀到1022行?根因是什么?
- 为什么选择门面模式而不是直接拆分成几个独立的类?
- 整个工具类的结构是怎样的?主门面类和七个操作组件如何协同?
- 重构过程中遇到了哪些实际工程问题和挑战?
- 重构前后的对比------代码可维护性、扩展性、测试性分别有什么变化?
- 从这次重构中获得了哪些关于工程设计的思考?
读完本文,你将掌握从实际项目中提炼出的重构原则和门面模式的应用实践。
一、重构前的痛点------一个类的"失控膨胀"
疑问:一个Redis工具类而已,怎么会到1022行?
回答:因为Redis有7种数据类型,每种类型的CRUD操作和方法重载堆在一起,代码量线性叠加。没有做职责分离。
重构前:MyRedis.java(1022行)
├── String操作(set、setEx、setNx、get、increment、decrBy...)
├── Hash操作(hSet、hGet、hDel、hIncrBy...)
├── List操作(lPush、rPush、lPop、rPop...)
├── Set操作(sAdd、sMembers、sInter...)
├── ZSet操作(zAdd、zRange、zRank...)
├── Geo操作(geoAdd、geoDist、geoRadius...)
├── Bitmap操作(setBit、getBit、bitCount...)
├── 通用操作(delete、expire、hasKey、scan...)
└── Lua脚本执行
维护成本随功能增长线性攀升 :String类型的set和Set类型的add语义相近但参数完全不同,全都混在同一个类中,IDE自动提示时一长串不相关的方法名混在一起。新增一个数据类型操作(如后来增加的Bitmap)时所有改动都在同一文件中,Git合并极易冲突。单元测试要覆盖一个1022行的类,测试类本身也要变成另一个庞然大物。
这是典型的"上帝类"------把所有事情都包揽在自己身上,最终失去掌控。
二、重构方案------门面模式做减法
疑问:为什么不直接拆成7个独立的类?加一个门面层不是多此一举吗?
回答:如果拆成7个独立类直接暴露给业务方,每个Service中可能需要注入7个不同的依赖。门面模式在这里的价值是提供统一的入口------业务代码一行注入搞定所有Redis操作。
2.1 为什么不是其他方案?
| 方案 | 做法 | 问题 |
|---|---|---|
| 工具类的静态方法 | 所有方法改成static,类名直接调用 | 无法注入Spring管理的StringRedisTemplate;方法全耦合在一个类内,状态管理和冲突并未解决 |
| 拆成独立Bean分别注入 | StringOps、HashOps等都注解为@Component | Liskov提示变清晰了,但每次使用Redis的业务类都要声明7个字段注入7个不同的依赖,代码冗余 |
| 继承体系 | 抽象父类+7个子类 | 各数据类型之间没有is-a关系,List操作不是"Hash操作的子类型",强行继承语义不通;调用时仍需分别为每个子类创建实例 |
| 门面模式 | 一个门面聚合7个组件,统一暴露 | 调用方只注入一个依赖,门面内部按功能委派。职责清晰且使用方便 |
2.2 门面模式在这个场景下的价值
门面模式的本质是"为子系统中的一组接口提供一个统一的高层入口"。重构后的工具类在业务代码中的使用是一个直观的例子:
java
// 重构前:所有方法都在一个类里,找方法靠搜索
String value = myRedis.get(key);
Boolean locked = myRedis.setNx(lockKey, "1");
// 重构后:按数据类型分组,IDE智能提示自动筛选
String value = myRedis.getStringOps().get(key);
Boolean locked = myRedis.getStringOps().setNx(lockKey, "1");
// 调用方只需注入一个依赖
@Autowired
private MyRedis myRedis;
门面层承担了路由和委派的角色------它不实现任何具体的Redis操作,但它知道每个数据类型对应的操作组件是哪一个,把请求转发给正确的实现者。
2.3 最终架构
MyRedis(门面类)
├── RedisStringOperations # String 类型操作
├── RedisHashOperations # Hash 类型操作
├── RedisListOperations # List 类型操作
├── RedisSetOperations # Set 类型操作
├── RedisZSetOperations # ZSet 类型操作
├── RedisGeoOperations # Geo 地理位置操作
└── RedisBitmapOperations # Bitmap 位图操作
注入链路:
Spring容器 → StringRedisTemplate
↓
MyRedis 门面(构造器注入)
↓
┌──────────┼──────────┐
↓ ↓ ↓
StringOps HashOps ListOps ...(创建时传入template)
三、工程实践中的三个挑战
3.1 API兼容性------不让改动波及所有调用方
重构最大的挑战不是写新代码,而是让已有的调用方平滑迁移 。整个项目有ml-user、ml-order、ml-sale、ml-course四个模块在大量使用旧的API,全部一次性改完风险极高。
解决方式是:保留主类中的常用方法作为直接委托------delete()、hasKey()、expire()、executeLua()这些通用操作仍在门面类中直接可用,只是内部实现委托给StringRedisTemplate。而数据类型操作方法则聚合到各自的组件中,通过getXxxOps()访问。这样调用方只需增加一个getStringOps()的中间调用,改动量从"完全陌生"变成"增加一个语义的中间跳转",范围可控。
3.2 Bitmap操作的陷阱------bitCount和bitField的性能差异
重构Bitmap组件时发现一个已存在的问题:原有的bitCount遍历整个位图的每个位来判断是否为1------数据量大时极慢。但BITFIELD命令可以批量获取位值,性能显著优于逐位检查。
当前版本保留了兼容旧逻辑的bitCount实现,但在使用指南的注意事项中明确标注大数据量时的性能建议。如果后续需要高性能版本,可以在组件内部切换为BITFIELD实现,调用方代码完全不受影响------这正是分层设计带来的可替换性。
3.3 文件位置的混乱------包结构调整
重构后一度出现了源代码仓库中多个文件既存在于旧包路径com.pangxuan.component又存在于新路径com.pangxuan.component.redis的问题。IDE的增量编译没有清理已删除的旧文件,导致运行时有两个MyRedis类被加载,引发Bean注入冲突。
解决方式是彻底删除旧包下的所有Redis文件,清理IDE缓存后全量编译。四个模块全部重新编译通过后,建立一个编译验证检查清单,每次合并前确保无残留的旧路径文件。
四、重构成果
4.1 结构对比
| 维度 | 重构前 | 重构后 |
|---|---|---|
| 文件数量 | 1个类 | 1个门面类 + 7个操作组件 + 1个使用示例 |
| 单类最大行数 | 1022行 | 约250行(门面类),各组件均小于200行 |
| 职责划分 | 全混在一起 | 按数据类型严格分离 |
| IDE智能提示 | 数百个方法排列 | 先选数据类型,再选操作,自动筛选 |
| 新增数据类型 | 修改单一文件 | 新增一个组件类+门面中加一行getter |
| 单元测试 | 需覆盖1022行的方法 | 每个组件可独立Mock测试 |
4.2 调用方式对比
java
// 重构前:不知道是什么类型
myRedis.set("key", "value"); // 是String的set?还是Set集合的操作?
myRedis.add("key", "member"); // 是Set的add?
// 重构后:类型明确
myRedis.getStringOps().set("key", "value"); // 准确:String操作
myRedis.getSetOps().add("key", "member"); // 准确:Set集合操作
4.3 扩展性验证------新增功能零侵入
如果要为String类型新增一个getAndDelete方法(原子性的获取并删除),只需要在RedisStringOperations中新增一个方法,门面类和其他组件完全不变。这在1022行的大类中是不可想象的------新增方法意味着所有人都看到这个方法,且IDE自动提示的负担又加一个。
五、从这次重构中获得的思考
5.1 什么时候该重构?
不要等类膨胀到了不可维护才开始重构。当单一类的职责超过3条、代码行数超过500行,且新增功能需要修改已有公共方法时,就是重构的临界点。 这次重构的最强信号是"Bitmap操作需要新增时发现所有类型全挤在一起"------这证明单一的类已经无法满足扩展需求。
5.2 门面模式在工具类封装中的适用条件
| 特征 | 你的项目是否匹配 |
|---|---|
| 多种类型的子操作需要暴露给同一调用方 | ✅ 7种Redis数据类型 |
| 调用方不需要知道子系统的内部结构 | ✅ 业务Service只需要"操作Redis",不需要知道具体用哪个组件 |
| 希望降低外部耦合度,减少调用方依赖 | ✅ 4个微服务模块全部只注入一个MyRedis |
5.3 重构的真正价值
代码结构清晰是第一层收益------每个人都能在三秒内找到需要的类和方法。真正的长期价值是降低了变更成本------原来修改一种类型操作可能影响1022行文件中的其他方法,现在修改一个组件只影响它自己。此外,新增数据类型时不再需要修改大量现有代码,单元测试可以独立覆盖每个组件。这些都是"代码结构改善"带来的间接收益,而不是为重构而重构。
一个好的工程设计,不是一开始就能预测到所有需求。而是在需求到来时,能以最小的代价应对变化。
专栏预告:本栏目后续将分享更多项目中的工程实践------包括MyBatis-Plus封装、全局异常处理设计、以及微服务模块间的通用组件抽象。这些都是从真实项目中提炼出的可复用经验。