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;
}

学到的重构思路

  1. 一个函数只做一件事,如果一个函数做了太多事情,就应该拆分
  2. 拆分的时候,按照职责来分,每个函数有明确的功能
  3. 主函数只做流程控制,具体的逻辑放到子函数中
  4. 拆分之后,每个函数都变短了,可读性和可维护性都提升了

三、重构案例2:全局变量的消除

重构前的烂代码

早期的Redis代码里有很多全局变量,比如server结构体里有几百个字段,各种配置、状态、统计信息都塞在里面。而且还有一些独立的全局变量,比如当前时间、日志级别、事件循环等。

全局变量的问题是:任何函数都可以修改它,很难追踪数据的流向;测试的时候很难mock;多线程的时候容易出问题。

比如有一个全局变量server.time,很多函数都直接读取它。但是这个变量什么时候更新、由谁更新,没有明确的约定。有时候函数里直接用time()系统调用,有时候用server.time,很混乱。

重构后的优雅代码

Redis 7.0对全局变量做了整理:

  1. 把相关的字段分组,用子结构体组织。比如把所有和内存相关的配置放到一个memconfig结构体里,把所有和网络相关的放到netconfig里。
  2. 消除了一些不必要的全局变量,改成函数参数传递。
  3. 对时间这种常用的全局变量,提供了统一的访问函数,比如getCurrentTime(),而不是直接访问全局变量。
  4. 把一些只在某个模块使用的全局变量,改成了静态变量,限制作用域。
// 重构前
if (server.time - last_access > timeout) { ... }

// 重构后
mstime_t now = getCurrentTime();
if (now - last_access > timeout) { ... }

学到的重构思路

  1. 全局变量是万恶之源,能不用就不用
  2. 如果必须用全局变量,也要限制作用域,用static修饰
  3. 对全局变量的访问,最好通过函数来封装,不要直接访问
  4. 相关的数据要组织在一起,用结构体分组,不要散乱地放

四、重构案例3:错误处理的统一

重构前的烂代码

Redis的错误处理一直比较混乱。有的地方用返回值表示错误,有的地方用errno,有的地方直接打印日志然后exit,有的地方设置客户端的错误回复。

比如网络相关的代码,有的函数返回-1表示错误,有的返回NULL,有的设置errno,有的直接打印错误日志。调用者需要记住每个函数的错误处理方式,很容易出错。

重构后的优雅代码

Redis 7.0统一了错误处理方式:

  1. 大部分函数用返回值表示成功或失败,成功返回COK,失败返回CERR
  2. 失败的时候,设置一个统一的错误信息,通过函数参数或者全局的错误缓冲区传递
  3. 提供了统一的错误处理函数,比如setError()、getError()、clearError()
  4. 致命错误才直接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;
}

学到的重构思路

  1. 错误处理要统一,不要每种方式都来一点
  2. 用返回值表示错误状态,用错误信息描述具体错误
  3. 不要在底层函数里直接处理错误(比如打印日志、退出程序),把错误往上抛,让上层决定怎么处理
  4. 提供统一的错误设置和获取函数,方便调用者使用

五、重构案例4:内存管理的改进

重构前的烂代码

Redis是内存数据库,内存管理非常重要。但是早期的代码里,内存分配和释放比较混乱。有的地方用zmalloc/zfree,有的地方用malloc/free,有的地方用对象的引用计数,有的地方直接操作原始指针。

而且有很多内存泄漏和重复释放的bug,都是因为内存管理不统一导致的。

比如有一个字符串处理的模块,有的函数返回新分配的字符串(调用者负责释放),有的函数返回静态字符串(不需要释放),有的函数修改传入的字符串。调用者很容易搞混,导致内存泄漏或者段错误。

重构后的优雅代码

Redis 7.0对内存管理做了改进:

  1. 统一用zmalloc/zfree/zrealloc,不再混用malloc/free
  2. 明确了每个函数的内存所有权:返回新分配内存的函数,在文档中明确说明调用者负责释放
  3. 引入了自动清理的机制,比如用宏来定义自动释放的变量
  4. 增加了更多的内存调试工具,比如内存泄漏检测、使用-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);

学到的重构思路

  1. 内存管理要统一,不要混用不同的分配器
  2. 明确内存的所有权,谁分配谁释放,或者在文档中说明
  3. 用工具来检测内存问题,不要靠人肉review
  4. 能自动管理的就自动管理,减少人为错误

六、重构案例5:重复代码的消除

重构前的烂代码

Redis支持多种数据类型,比如字符串、列表、哈希、集合、有序集合。每种数据类型都有类似的操作,比如添加、删除、查找、遍历。早期的代码里,这些类似的操作是分别实现的,有大量的重复代码。

比如遍历所有键的功能,每种数据类型都有自己的遍历函数,逻辑差不多,只是数据结构不一样。复制粘贴了好几份,修改的时候要改好几个地方,很容易漏改。

重构后的优雅代码

Redis 7.0引入了统一的对象类型系统,用多态的方式来处理不同的数据类型:

  1. 定义了一个统一的对象结构体robj,里面有类型字段和指向具体数据的指针
  2. 每种数据类型实现一套统一的操作函数,比如add、del、find、iterate
  3. 上层代码只需要操作robj,不需要关心具体的数据类型
  4. 通用的逻辑(比如过期、持久化、复制)只写一份,通过对象的类型来分发
// 统一的对象操作
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);
}

学到的重构思路

  1. 重复代码是坏味道,看到重复的代码就要想办法消除
  2. 用抽象和多态来处理不同类型的相似逻辑
  3. 定义统一的接口,让不同的实现遵循同一个接口
  4. 通用逻辑只写一份,通过类型分发到具体实现

七、重构案例6:宏定义的清理

重构前的烂代码

Redis的代码里有很多宏定义,有些是合理的,有些就是为了图省事写的烂代码。比如:

  • 用宏来定义函数,导致代码膨胀,调试困难
  • 宏的参数没有加括号,导致优先级bug
  • 宏的名字不清晰,不知道是宏还是函数
  • 嵌套宏,展开之后根本看不懂

比如有一个宏用来计算数组长度,写得很复杂,嵌套了好几个宏,展开之后有几十行,而且有边界情况的bug。

重构后的优雅代码

Redis 7.0对宏做了清理:

  1. 能用函数的就不用宏,特别是有复杂逻辑的
  2. 必须用宏的,参数都加括号,避免优先级问题
  3. 宏的名字用大写,和函数区分开
  4. 删除了不必要的宏,直接用内联函数或者普通函数代替
  5. 复杂的宏拆分成简单的宏或者函数
// 重构前:复杂的宏
#define ARRAY_LEN(a) (sizeof(a)/sizeof((a)[0]))

// 重构后:简单清晰的宏(这个其实还好,举个例子)
#define ARRAY_LENGTH(arr) (sizeof(arr) / sizeof((arr)[0]))

学到的重构思路

  1. 宏是C语言的双刃剑,用好了方便,用不好就是灾难
  2. 能用函数就用函数,不要滥用宏
  3. 宏的参数一定要加括号,避免优先级问题
  4. 宏的名字要清晰,最好用大写和函数区分

八、代码重构的原则

通过这些Redis的重构案例,我总结了几个代码重构的原则:

原则1:重构不改变功能

重构是在不改变外部功能的前提下,改善内部代码结构。重构之后,功能应该和之前一样,只是代码更好了。所以重构的时候,一定要有测试来保证功能不变。

Redis在重构的时候,就跑了完整的测试套件,确保重构之后功能没有变化。

原则2:小步快跑

不要一次性重构整个项目,那样风险太大。要小步快跑,一次重构一个函数、一个模块,重构完就测试,没问题了再继续。

Redis 7.0的重构也是分阶段进行的,一个版本重构一部分,不是一口气全部改完。

原则3:先理解再重构

重构之前,一定要先理解原来的代码在做什么,为什么这么写。不要上来就改,改完之后发现原来的代码有特殊的考虑,结果改出了bug。

Redis的重构者在重构之前,都仔细研究了原来的代码,理解了设计意图,然后才开始改。

原则4:持续重构

重构不是一次性的活动,而是持续的过程。每次加新功能、修bug的时候,都可以顺便重构一下附近的代码。这样代码质量会持续提升,不会积累太多技术债务。

Redis的开发者就是这么做的,每次提交代码的时候,都会顺便改进一下附近的代码。

原则5:为未来而重构

重构的目的是为了未来更好地维护和扩展。所以重构的时候,要考虑未来的需求,让代码更容易扩展。比如Redis 7.0的很多重构,就是为了支持多线程、模块化等未来的特性。

九、写在最后

Redis 7.0的这些代码重构,让我学到了很多。从这些从烂代码到优雅代码的改造中,我看到了优秀程序员的代码品味和重构思路。

代码不是写完就完了,而是需要持续地维护和改进。烂代码不可怕,可怕的是烂代码一直没人改,越积越多,最后变成没人敢碰的屎山。

作为程序员,我们要有代码质量意识,写代码的时候尽量写好,维护代码的时候积极重构。让代码保持优雅,是对自己负责,也是对后来者负责。

最后用一句话结束本文:"代码是写给人看的,顺便让机器执行。"愿每一个程序员都能写出优雅的代码,也能勇敢地重构烂代码,让代码越来越好。