上个月,我接手了一个HTTP/2协议相关的项目,代码写得非常烂。

耦合严重,一个函数几百行,什么都干;命名混乱,变量名都是a、b、c、tmp1、tmp2,根本不知道是什么意思;没有注释,复杂的逻辑没有任何说明;bug满天飞,改一个地方,另一个地方就出问题;根本没法维护,看了头都大。

我花了两周时间,对这套代码进行了全面的重构,从架构设计、到模块拆分、到命名规范、到错误处理、到性能优化、到单元测试,把一套烂代码重构成了优雅、可维护、可扩展的代码。

今天就来分享一下这次代码重构的实战经历,以及代码重构的一些经验和方法。

一、烂代码的症状

先说说这套烂代码有哪些症状,大家可以对照一下,看看自己的项目有没有类似的问题。

1. 函数过长

一个函数几百行,甚至上千行,什么都干。解析请求、处理业务逻辑、访问数据库、生成响应、记录日志,全在一个函数里。看这个函数,看到后面忘了前面,根本没法理解。

比如有一个函数叫handle_request(),有800多行,从解析HTTP/2帧,到处理流,到多路复用,到头部压缩,到响应生成,全在里面,简直是一个"万能函数"。

2. 命名混乱

变量名、函数名、类名,都是随便起的。变量名是a、b、c、data、tmp、tmp1、tmp2,根本不知道是什么意思。函数名是func1、func2、do_something、process,不知道具体干什么。

比如有一个变量叫$data,在不同的地方代表不同的意思,有时候是请求数据,有时候是响应数据,有时候是帧数据,有时候是头部数据,看的时候经常搞混。

3. 耦合严重

模块之间耦合严重,你中有我,我中有你,改一个地方,要改好几个文件,而且不知道会不会影响其他地方。

比如帧解析模块,直接调用了流处理模块的函数,流处理模块又直接调用了头部压缩模块的函数,头部压缩模块又反过来调用了帧解析模块的函数,形成了循环依赖,根本没法单独测试和修改。

4. 没有注释

复杂的逻辑没有任何注释,HTTP/2协议的一些特殊处理,比如多路复用、流量控制、优先级,没有任何说明,看代码根本不知道为什么要这么写。

比如有一段代码,处理流的优先级,写了几个魔数,没有任何注释,不知道这些数字是什么意思,也不知道为什么要这么算。后来查了HTTP/2协议文档,才知道这些是优先级权重的计算方式。

5. 错误处理混乱

错误处理很混乱,有的地方返回false,有的地方返回-1,有的地方抛异常,有的地方直接die(),没有统一的错误处理机制。出了问题,根本不知道哪里出错了,也不知道错误原因是什么。

比如解析帧出错的时候,有的地方返回false,有的地方返回-1,有的地方直接echo错误信息然后exit,调用方根本不知道怎么处理。

6. 重复代码多

很多代码是重复的,同样的逻辑,在好几个地方复制粘贴,改的时候要改好几个地方,很容易漏改,导致bug。

比如帧的校验逻辑,在帧解析、流处理、响应生成三个地方都有,而且每个地方的实现还不太一样,有的地方校验了长度,有的地方没有,有的地方校验了类型,有的地方没有,很混乱。

7. 没有单元测试

没有任何单元测试,改完代码,不知道有没有改对,也不知道有没有引入新的bug。每次改完代码,都要手动测试,效率很低,而且很容易漏测。

这些症状加在一起,导致这套代码根本没法维护,改一个bug要花好几天,而且改完还不知道会不会引入新的bug。我接手的时候,真的很头疼,一度想推翻重写,但是考虑到项目已经上线,重写风险太大,最后还是决定重构。

二、重构的原则

在开始重构之前,我先确定了几个重构的原则,避免重构走偏。

1. 保持功能不变

重构的目的是改善代码的结构和质量,而不是改变功能。重构过程中,要保持对外的接口和功能不变,不能因为重构而引入新的bug,或者改变原来的行为。

这是重构最重要的原则,如果重构改变了功能,那就不是重构,而是重写了。

2. 小步快跑,逐步重构

不要想着一口吃成胖子,一次把所有代码都重构完。那样风险太大,很容易出问题,而且出了问题不知道是哪里改的。

要小步快跑,一次只改一个小地方,改完测试没问题,再改下一个地方。这样即使出了问题,也很容易定位和回滚。

3. 每一步都可测试

每重构完一个模块,都要能单独测试,确保这个模块的功能是正确的。这样可以保证重构的质量,也能为以后的维护提供测试保障。

4. 先理解再重构

重构之前,一定要先理解原来的代码,理解它的功能、逻辑、依赖、边界情况,不能在没理解的情况下盲目重构,那样很容易改出问题。

我花了整整三天时间,读原来的代码,查HTTP/2协议文档,画流程图,理清楚每个模块的功能和依赖,然后才开始重构。

5. 保留回滚能力

重构过程中,要保留回滚的能力,万一重构出了问题,能快速回滚到原来的版本。所以,每重构完一个阶段,都要提交一次代码,保留历史记录,方便回滚。

三、重构的步骤

确定了原则之后,我开始按照以下步骤进行重构。

第一步:建立测试基线

在重构之前,先建立一套测试用例,覆盖主要的功能和边界情况,确保重构之后,这些测试用例都能通过,功能没有改变。

虽然原来的代码没有单元测试,但是我可以写一些集成测试,模拟HTTP/2的请求和响应,验证主要的功能是否正确。这些测试用例,就是重构的基线,重构之后,这些测试必须全部通过。

第二步:梳理架构和依赖

然后,梳理整个系统的架构,理清楚有哪些模块,每个模块的功能是什么,模块之间的依赖关系是什么,哪些地方耦合严重,哪些地方有循环依赖。

我画了一张架构图,把每个模块的功能和依赖都标出来,然后根据HTTP/2协议的分层,重新设计了架构,把系统分成了几个独立的层次:帧解析层、流处理层、头部压缩层、业务处理层、响应生成层。每个层次只负责自己的事情,层次之间通过接口通信,降低耦合。

第三步:拆分大函数

接下来,开始拆分那些几百行的大函数,把它们拆成一个个小函数,每个函数只做一件事,函数名清晰地描述函数的功能。

比如那个800多行的handle_request()函数,我把它拆成了:

  • parse_frame():解析HTTP/2帧
  • process_stream():处理流
  • decompress_headers():解压头部
  • handlerequestlogic():处理业务逻辑
  • generate_response():生成响应
  • compress_headers():压缩头部
  • serialize_frame():序列化帧

每个函数几十行,功能单一,命名清晰,一看就知道是干什么的,维护起来方便多了。

第四步:统一命名规范

然后,统一命名规范,变量名、函数名、类名,都要有意义,能清晰地描述它的用途,不要再用a、b、c、tmp1、tmp2这种没有意义的名字。

我制定了一套命名规范:

  • 变量名:用名词,描述变量的内容,比如$framedata$streamid$header_list
  • 函数名:用动词+名词,描述函数的功能,比如parseframe()processstream()generate_response()
  • 类名:用名词,描述类的职责,比如FrameParserStreamProcessorHeaderCompressor
  • 常量名:全大写,用下划线分隔,比如FRAMETYPEDATAMAXFRAMESIZE

统一命名之后,代码的可读性大大提高,看变量名和函数名,就知道是干什么的,不用再猜了。

第五步:解耦模块

接下来,解耦模块,消除循环依赖,让每个模块都能独立工作,独立测试。

我为每个模块定义了清晰的接口,模块之间只通过接口通信,不直接调用对方的内部函数。比如帧解析模块,只负责解析帧,解析完之后,通过回调或者接口,把解析结果传给流处理模块,而不是直接调用流处理模块的函数。

这样,每个模块都是独立的,可以单独测试,单独修改,不会影响其他模块。耦合度大大降低,可维护性大大提高。

第六步:统一错误处理

然后,统一错误处理机制,定义统一的错误码和异常类,所有的错误都通过异常来处理,调用方可以通过try-catch来捕获和处理错误。

我定义了一个Http2Exception异常类,包含错误码和错误信息,所有出错的地方,都抛出这个异常,而不是返回false或者-1。调用方通过try-catch捕获异常,根据错误码进行相应的处理。

统一错误处理之后,错误的定位和处理都方便了很多,出了问题,一看异常信息,就知道哪里出错了,错误原因是什么。

第七步:消除重复代码

接下来,消除重复代码,把重复的逻辑提取成公共函数或者公共类,在需要的地方调用,而不是复制粘贴。

比如帧的校验逻辑,原来在三个地方都有,而且实现还不一样,我把它提取成了一个validate_frame()公共函数,三个地方都调用这个函数,这样逻辑统一了,改的时候只需要改一个地方,也不会出现不一致的问题。

消除重复代码之后,代码量减少了很多,逻辑也更统一了,维护起来方便多了。

第八步:补充注释和文档

然后,补充注释和文档,特别是复杂的逻辑、协议的特殊处理、魔数的含义,都要有清晰的注释,说明为什么要这么写,依据是什么。

比如流的优先级计算,那些魔数,我加了详细的注释,说明这些数字是HTTP/2协议规定的优先级权重,计算方式是什么,依据是协议文档的哪一节。

另外,我还写了一份架构文档,说明整个系统的架构、每个模块的功能和接口、模块之间的依赖关系,方便以后的维护者理解和维护。

第九步:补充单元测试

最后,补充单元测试,为每个模块、每个函数都写单元测试,覆盖正常情况、边界情况、错误情况,确保代码的质量。

我用PHPUnit写了一套单元测试,覆盖率达到了80%以上,每个模块都能单独测试,每次改完代码,跑一遍单元测试,就能知道有没有改对,有没有引入新的bug。

有了单元测试,代码的可维护性大大提高,以后再改代码,就有了保障,不用担心改出问题。

四、重构的手法

在重构过程中,我用到了一些经典的重构手法,这里分享几个常用的。

1. 提取函数(Extract Method)

把一段逻辑从大函数里提取出来,变成一个独立的小函数,函数名描述这段逻辑的功能。这是最常用的重构手法,用来拆分大函数,提高代码的可读性和可维护性。

2. 提取变量(Extract Variable)

把一个复杂的表达式,提取成一个有意义的变量,变量名描述表达式的含义。比如if ($frame['type'] == 0 && $frame['flags'] & 1),可以提取成$isdataframe = $frame['type'] == 0; $isendstream = $frame['flags'] & 1; if ($isdataframe && $isendstream),这样可读性就好多了。

3. 重命名(Rename)

给变量、函数、类起一个更有意义的名字,清晰地描述它的用途。这是最简单但是效果最明显的重构手法,好的命名能大大提高代码的可读性。

4. 替换魔法数字(Replace Magic Number with Named Constant)

把代码里的魔数(没有意义的数字),替换成有意义的命名常量。比如if ($frame['type'] == 0),可以定义一个常量define('FRAMETYPEDATA', 0);,然后写成if ($frame['type'] == FRAMETYPEDATA),这样就知道这个0是什么意思了。

5. 分解条件表达式(Decompose Conditional)

把复杂的条件表达式,分解成几个有意义的变量,或者提取成独立的函数,提高可读性。比如复杂的if条件,可以提取成几个变量,每个变量描述一个条件,然后if里用变量组合,可读性就好多了。

6. 合并重复的条件片段(Consolidate Duplicate Conditional Fragments)

把在条件分支的不同分支里重复的代码,提取到条件外面,消除重复。比如if和else里都有同一段代码,就可以把这段代码提到if-else外面,只写一次。

这些重构手法,都是很基础但是很实用的,掌握了这些手法,就能有效地改善代码的质量。

五、踩坑经验

在重构过程中,也踩了一些坑,分享一下。

1. 不要在重构的同时加新功能

重构的时候,要专注于改善代码结构,不要同时加新功能。因为加新功能会改变代码的行为,和重构混在一起,出了问题不知道是重构的问题还是新功能的问题,很难定位。

我一开始就犯了这个错误,重构的时候,顺便加了一个新功能,结果出了bug,找了半天,才发现是新功能的问题,浪费了很多时间。后来我就严格遵守,重构的时候只重构,不加新功能,重构完了,测试通过了,再加新功能。

2. 不要过度设计

重构的时候,不要过度设计,不要为了可能的未来需求,把架构设计得过于复杂。比如,现在只有一种帧类型,就不要为了以后可能有多种帧类型,就设计一个复杂的抽象层和继承体系。那样会增加代码的复杂度,反而不好维护。

重构要遵循YAGNI原则(You Aren't Gonna Need It),只针对当前的需求进行设计和重构,不要为了不确定的未来需求过度设计。

3. 注意边界情况

重构的时候,要特别注意边界情况和异常情况,原来的代码可能对这些情况有特殊处理,重构的时候不要漏掉了。

我就犯过这个错误,重构帧解析的时候,只考虑了正常情况,漏掉了一个边界情况(帧长度为0的情况),结果重构之后,遇到这种情况就崩溃了,后来查了半天,才发现是漏掉了边界情况的处理。

所以,重构的时候,一定要仔细看原来的代码,注意那些边界情况和异常情况的处理,不要漏掉了。

4. 保留原来的代码一段时间

重构完之后,不要马上删掉原来的代码,保留一段时间,等新代码稳定运行一段时间,确认没有问题了,再删掉原来的代码。这样万一新代码出了问题,还能快速回滚到原来的代码。

我重构的时候,是新写了一套代码,和原来的代码并存,通过配置切换,新代码测试没问题了,再切换到新代码,运行一段时间稳定了,再删掉原来的代码。这样风险很小,出了问题能快速回滚。

六、重构后的效果

经过两周的重构,效果很明显。

代码量:虽然拆分了很多函数,增加了接口和类,但是因为消除了重复代码,总代码量反而减少了20%左右,从原来的8000多行,减少到了6000多行。

可读性:命名规范了,函数短小了,注释齐全了,代码的可读性大大提高,新人看代码,半天就能理解大概的架构和逻辑,原来要看两三天。

可维护性:模块解耦了,错误处理统一了,有单元测试了,代码的可维护性大大提高,改一个bug,原来要花好几天,现在几个小时就能搞定,而且不用担心引入新的bug。

性能:因为消除了重复计算,优化了一些低效的逻辑,性能也有一定提升,处理请求的速度比原来快了15%左右。

团队反馈:团队的其他同事,都说重构之后的代码好读多了,也好维护多了,都很支持以后继续重构。

总体来说,这次重构是成功的,虽然花了两周时间,但是大大提高了代码的质量和可维护性,以后的维护成本会大大降低,是值得的。

七、写在最后

HTTP/2协议代码重构:从烂代码到优雅代码。

这次重构,让我深刻体会到,代码重构是程序员的基本功,也是程序员的责任。写代码的时候,不能只想着能跑就行,要想着以后有人会维护,可能那个人就是你自己。写出优雅、可维护的代码,是每个程序员的追求,也是对自己和他人负责。

烂代码就像烂摊子,越拖越难收拾,越早重构越好。不要因为"能跑就行"就放任烂代码存在,那样只会让以后的维护成本越来越高,最后变成没人敢碰的"祖传代码"。

当然,重构也要讲究方法和策略,不能盲目重构,要遵循重构的原则,小步快跑,逐步重构,每一步都可测试,保留回滚能力,这样才能安全、有效地完成重构。

希望我的这些经历和经验,能对大家有所帮助,特别是正在被烂代码折磨的朋友。勇敢地去重构吧,只要方法得当,重构没有那么可怕,重构之后的代码,会让你和你的团队都受益。

最后,用一句话结尾:

"代码是写给人看的,顺便给机器执行。写出优雅、可维护的代码,是每个程序员的责任和荣耀。"

愿我们都能写出优雅的代码,远离烂代码的折磨。