Redis 7.0正在开发中,其中一个重要的变化是大量的代码重构。本文从源码角度,分析Redis 7.0中那些从烂代码到优雅代码的重构,包括函数拆分、数据结构优化、错误处理统一、内存管理改进等。通过这些重构案例,我们可以学习到代码重构的思路和技巧,提升自己的代码质量。
一、为什么要重构Redis代码
先说说Redis为什么要做这么多代码重构。
Redis从2009年发布到现在,已经有十几年了。这十几年里,Redis从一个简单的键值存储,发展成了一个功能丰富的内存数据库。功能越来越多,代码也越来越复杂。
早期的Redis代码,为了快速实现功能,有些地方写得比较随意。比如一个函数几百行,一个文件几千行,全局变量到处都是,错误处理不统一。这些代码在当时是没问题的,但是随着功能增加,维护起来越来越困难。
Redis 7.0的一个重要目标就是提升代码质量,为未来的发展打下基础。所以在这个版本中,做了大量的代码重构,把一些历史遗留的烂代码,改写成优雅的、可维护的代码。
作为一个经常读Redis源码的人,我对这些重构很感兴趣。下面就来分享几个我觉得很有代表性的重构案例,以及从中学到的重构思路。
二、重构案例1:巨型函数的拆分
重构前的烂代码
Redis中有一个处理客户端命令的函数,叫processCommand。这个函数负责解析客户端发来的命令,然后分发到对应的处理函数。因为Redis的命令很多,这个函数越写越长,最后有将近500行。
函数里面有大量的if-else和switch,判断命令类型,然后做各种检查和处理。一个函数干了太多事情:权限检查、参数校验、命令分发、统计信息、错误处理,全都混在一起。
这样的代码,可读性很差,维护起来很困难。每次加一个新命令,都要在这个巨型函数里加代码,很容易出bug。
重构后的优雅代码
Redis 7.0把这个巨型函数拆分成了多个小函数:
- processCommand:主入口,只做整体的流程控制
- checkCommandPermissions:检查命令权限
- validateCommandArguments:校验命令参数
- lookupCommand:查找命令处理函数
- callCommand:调用命令处理函数
- updateCommandStats:更新命令统计信息
每个函数只做一件事,代码清晰,职责明确。主函数processCommand变成了一个简洁的流程控制,读起来一目了然。
// 重构后的伪代码
int processCommand(client *c) {
if (checkCommandPermissions(c) != C_OK) return C_ERR;
if (validateCommandArguments(c) != C_OK) return C_ERR;
struct redisCommand *cmd = lookupCommand(c->argv[0]->ptr);
if (!cmd) return replyUnknownCommand(c);
callCommand(c, cmd);
updateCommandStats(c, cmd);
return C_OK;
}学到的重构思路
- 一个函数只做一件事,如果一个函数做了太多事情,就应该拆分
- 拆分的时候,按照职责来分,每个函数有明确的功能
- 主函数只做流程控制,具体的逻辑放到子函数中
- 拆分之后,每个函数都变短了,可读性和可维护性都提升了
三、重构案例2:全局变量的消除
重构前的烂代码
早期的Redis代码里有很多全局变量,比如server结构体里有几百个字段,各种配置、状态、统计信息都塞在里面。而且还有一些独立的全局变量,比如当前时间、日志级别、事件循环等。
全局变量的问题是:任何函数都可以修改它,很难追踪数据的流向;测试的时候很难mock;多线程的时候容易出问题。
比如有一个全局变量server.time,很多函数都直接读取它。但是这个变量什么时候更新、由谁更新,没有明确的约定。有时候函数里直接用time()系统调用,有时候用server.time,很混乱。
重构后的优雅代码
Redis 7.0对全局变量做了整理:
- 把相关的字段分组,用子结构体组织。比如把所有和内存相关的配置放到一个memconfig结构体里,把所有和网络相关的放到netconfig里。
- 消除了一些不必要的全局变量,改成函数参数传递。
- 对时间这种常用的全局变量,提供了统一的访问函数,比如getCurrentTime(),而不是直接访问全局变量。
- 把一些只在某个模块使用的全局变量,改成了静态变量,限制作用域。
// 重构前
if (server.time - last_access > timeout) { ... }
// 重构后
mstime_t now = getCurrentTime();
if (now - last_access > timeout) { ... }学到的重构思路
- 全局变量是万恶之源,能不用就不用
- 如果必须用全局变量,也要限制作用域,用static修饰
- 对全局变量的访问,最好通过函数来封装,不要直接访问
- 相关的数据要组织在一起,用结构体分组,不要散乱地放
四、重构案例3:错误处理的统一
重构前的烂代码
Redis的错误处理一直比较混乱。有的地方用返回值表示错误,有的地方用errno,有的地方直接打印日志然后exit,有的地方设置客户端的错误回复。
比如网络相关的代码,有的函数返回-1表示错误,有的返回NULL,有的设置errno,有的直接打印错误日志。调用者需要记住每个函数的错误处理方式,很容易出错。
重构后的优雅代码
Redis 7.0统一了错误处理方式:
- 大部分函数用返回值表示成功或失败,成功返回COK,失败返回CERR
- 失败的时候,设置一个统一的错误信息,通过函数参数或者全局的错误缓冲区传递
- 提供了统一的错误处理函数,比如setError()、getError()、clearError()
- 致命错误才直接exit,普通错误都通过返回值传递
// 重构后的错误处理模式
int doSomething(client *c) {
if (someCondition) {
setError(c, "something went wrong");
return C_ERR;
}
return C_OK;
}
// 调用者
if (doSomething(c) != C_OK) {
replyError(c, getError(c));
return;
}学到的重构思路
- 错误处理要统一,不要每种方式都来一点
- 用返回值表示错误状态,用错误信息描述具体错误
- 不要在底层函数里直接处理错误(比如打印日志、退出程序),把错误往上抛,让上层决定怎么处理
- 提供统一的错误设置和获取函数,方便调用者使用
五、重构案例4:内存管理的改进
重构前的烂代码
Redis是内存数据库,内存管理非常重要。但是早期的代码里,内存分配和释放比较混乱。有的地方用zmalloc/zfree,有的地方用malloc/free,有的地方用对象的引用计数,有的地方直接操作原始指针。
而且有很多内存泄漏和重复释放的bug,都是因为内存管理不统一导致的。
比如有一个字符串处理的模块,有的函数返回新分配的字符串(调用者负责释放),有的函数返回静态字符串(不需要释放),有的函数修改传入的字符串。调用者很容易搞混,导致内存泄漏或者段错误。
重构后的优雅代码
Redis 7.0对内存管理做了改进:
- 统一用zmalloc/zfree/zrealloc,不再混用malloc/free
- 明确了每个函数的内存所有权:返回新分配内存的函数,在文档中明确说明调用者负责释放
- 引入了自动清理的机制,比如用宏来定义自动释放的变量
- 增加了更多的内存调试工具,比如内存泄漏检测、使用-after-free检测
// 明确内存所有权的函数注释
/* Allocate a new string, caller must free with zfree. */
char *createString(const char *s);
/* Return a pointer to internal buffer, do not free. */
const char *getTempBuffer(void);学到的重构思路
- 内存管理要统一,不要混用不同的分配器
- 明确内存的所有权,谁分配谁释放,或者在文档中说明
- 用工具来检测内存问题,不要靠人肉review
- 能自动管理的就自动管理,减少人为错误
六、重构案例5:重复代码的消除
重构前的烂代码
Redis支持多种数据类型,比如字符串、列表、哈希、集合、有序集合。每种数据类型都有类似的操作,比如添加、删除、查找、遍历。早期的代码里,这些类似的操作是分别实现的,有大量的重复代码。
比如遍历所有键的功能,每种数据类型都有自己的遍历函数,逻辑差不多,只是数据结构不一样。复制粘贴了好几份,修改的时候要改好几个地方,很容易漏改。
重构后的优雅代码
Redis 7.0引入了统一的对象类型系统,用多态的方式来处理不同的数据类型:
- 定义了一个统一的对象结构体robj,里面有类型字段和指向具体数据的指针
- 每种数据类型实现一套统一的操作函数,比如add、del、find、iterate
- 上层代码只需要操作robj,不需要关心具体的数据类型
- 通用的逻辑(比如过期、持久化、复制)只写一份,通过对象的类型来分发
// 统一的对象操作
typedef struct robj {
int type;
void *ptr;
// ...
} robj;
typedef struct objectType {
int (*add)(robj *o, void *value);
int (*del)(robj *o, void *value);
int (*find)(robj *o, void *value);
void (*iterate)(robj *o, iterator *iter);
} objectType;
// 上层代码不需要关心具体类型
int addValue(robj *o, void *value) {
objectType *t = getObjectType(o->type);
return t->add(o, value);
}学到的重构思路
- 重复代码是坏味道,看到重复的代码就要想办法消除
- 用抽象和多态来处理不同类型的相似逻辑
- 定义统一的接口,让不同的实现遵循同一个接口
- 通用逻辑只写一份,通过类型分发到具体实现
七、重构案例6:宏定义的清理
重构前的烂代码
Redis的代码里有很多宏定义,有些是合理的,有些就是为了图省事写的烂代码。比如:
- 用宏来定义函数,导致代码膨胀,调试困难
- 宏的参数没有加括号,导致优先级bug
- 宏的名字不清晰,不知道是宏还是函数
- 嵌套宏,展开之后根本看不懂
比如有一个宏用来计算数组长度,写得很复杂,嵌套了好几个宏,展开之后有几十行,而且有边界情况的bug。
重构后的优雅代码
Redis 7.0对宏做了清理:
- 能用函数的就不用宏,特别是有复杂逻辑的
- 必须用宏的,参数都加括号,避免优先级问题
- 宏的名字用大写,和函数区分开
- 删除了不必要的宏,直接用内联函数或者普通函数代替
- 复杂的宏拆分成简单的宏或者函数
// 重构前:复杂的宏
#define ARRAY_LEN(a) (sizeof(a)/sizeof((a)[0]))
// 重构后:简单清晰的宏(这个其实还好,举个例子)
#define ARRAY_LENGTH(arr) (sizeof(arr) / sizeof((arr)[0]))学到的重构思路
- 宏是C语言的双刃剑,用好了方便,用不好就是灾难
- 能用函数就用函数,不要滥用宏
- 宏的参数一定要加括号,避免优先级问题
- 宏的名字要清晰,最好用大写和函数区分
八、代码重构的原则
通过这些Redis的重构案例,我总结了几个代码重构的原则:
原则1:重构不改变功能
重构是在不改变外部功能的前提下,改善内部代码结构。重构之后,功能应该和之前一样,只是代码更好了。所以重构的时候,一定要有测试来保证功能不变。
Redis在重构的时候,就跑了完整的测试套件,确保重构之后功能没有变化。
原则2:小步快跑
不要一次性重构整个项目,那样风险太大。要小步快跑,一次重构一个函数、一个模块,重构完就测试,没问题了再继续。
Redis 7.0的重构也是分阶段进行的,一个版本重构一部分,不是一口气全部改完。
原则3:先理解再重构
重构之前,一定要先理解原来的代码在做什么,为什么这么写。不要上来就改,改完之后发现原来的代码有特殊的考虑,结果改出了bug。
Redis的重构者在重构之前,都仔细研究了原来的代码,理解了设计意图,然后才开始改。
原则4:持续重构
重构不是一次性的活动,而是持续的过程。每次加新功能、修bug的时候,都可以顺便重构一下附近的代码。这样代码质量会持续提升,不会积累太多技术债务。
Redis的开发者就是这么做的,每次提交代码的时候,都会顺便改进一下附近的代码。
原则5:为未来而重构
重构的目的是为了未来更好地维护和扩展。所以重构的时候,要考虑未来的需求,让代码更容易扩展。比如Redis 7.0的很多重构,就是为了支持多线程、模块化等未来的特性。
九、写在最后
Redis 7.0的这些代码重构,让我学到了很多。从这些从烂代码到优雅代码的改造中,我看到了优秀程序员的代码品味和重构思路。
代码不是写完就完了,而是需要持续地维护和改进。烂代码不可怕,可怕的是烂代码一直没人改,越积越多,最后变成没人敢碰的屎山。
作为程序员,我们要有代码质量意识,写代码的时候尽量写好,维护代码的时候积极重构。让代码保持优雅,是对自己负责,也是对后来者负责。
最后用一句话结束本文:"代码是写给人看的,顺便让机器执行。"愿每一个程序员都能写出优雅的代码,也能勇敢地重构烂代码,让代码越来越好。
评论(0)
暂无评论,快来抢沙发~
评论功能仅对会员开放,请先登录
登录