最近,我接手了一个基于ELK的日志分析项目。

ELK,是Elasticsearch、Logstash、Kibana三个开源工具的组合,是目前最流行的日志分析解决方案之一。Logstash负责收集和处理日志,Elasticsearch负责存储和搜索日志,Kibana负责可视化展示和分析。

这个项目的功能,是把公司各个系统的日志(应用日志、Nginx日志、数据库慢查询日志等)收集起来,经过处理之后,存储到Elasticsearch,然后通过Kibana做可视化分析,方便开发和运维人员排查问题、监控系统状态。

项目的功能并不复杂,但是代码写得非常糟糕。我接手的时候,看了半天代码,看得头都大了:一个几千行的Python脚本,所有逻辑都写在一起,没有函数,没有类,没有注释,变量名都是a、b、c、data、tmp这种,复制粘贴的代码到处都是,错误处理几乎没有,配置硬编码在代码里……可以说,这是我见过的最烂的代码之一。

但是,项目已经在线上运行了,不能推倒重写,只能在原有基础上重构。于是,我花了两周时间,对这个项目的代码进行了重构。重构之后,代码从原来的一个3000多行的脚本,变成了十几个模块,每个模块职责单一,代码清晰,有注释,有单元测试,维护起来轻松多了。

今天,我想分享一下这次重构的过程、思路和经验,希望能给正在面对烂代码的朋友一些参考。

一、先搞清楚:烂代码烂在哪里

在重构之前,首先要搞清楚,烂代码到底烂在哪里。只有找到了问题,才能有针对性地重构。

我花了一天时间,把原来的代码通读了一遍,边读边做笔记,总结出了以下几个主要问题:

1. 所有逻辑写在一个文件里,没有模块化

原来的代码,是一个3000多行的Python脚本,所有的逻辑都写在这个文件里:日志收集、日志解析、数据清洗、数据转换、写入Elasticsearch、配置管理、错误处理、定时任务……全部混在一起。

这样的代码,根本无法维护。你想改一个日志解析的逻辑,要在3000行代码里找半天;你想加一种新的日志类型,不知道该在哪里加;你想调试一个问题,根本不知道问题出在哪一段代码。

2. 没有函数和类,全是过程式代码

整个脚本,除了几个if name == 'main',几乎没有函数,更没有类。所有的代码,都是从上到下顺序执行的过程式代码。变量在全局作用域里到处传递,你根本不知道一个变量在哪里被修改了,也不知道它当前的值是什么。

比如,有一个叫data的变量,在脚本的不同位置,有时候是字符串,有时候是字典,有时候是列表,有时候又是自定义对象。你根本猜不到它当前是什么类型,也不知道它的结构是什么。

3. 变量名和函数名没有意义

变量名都是a、b、c、d、data、tmp、result、temp这种没有意义的名字。函数名(虽然很少)也是func1、func2、process这种模糊的名字。

看这样的代码,你根本不知道一个变量代表什么,也不知道一个函数做什么。你必须仔细读代码的上下文,才能猜出来。这大大增加了理解代码的难度,也很容易引入bug。

4. 大量的复制粘贴代码

代码里有大量的复制粘贴。比如,解析不同类型的日志,逻辑差不多,但是每一种日志都复制了一遍代码,只是改了几个字段。写入Elasticsearch的代码,也复制了好几遍,只是索引名不一样。

复制粘贴的代码,是维护的噩梦。如果你要修改一个逻辑,你要找到所有复制的地方,一个一个改,很容易漏掉,导致不一致。而且,复制粘贴的代码,也让代码变得很长,很臃肿。

5. 配置硬编码在代码里

Elasticsearch的地址、端口、索引名、日志文件路径、定时任务的时间间隔……所有的配置,都硬编码在代码里。如果要改一个配置,就要改代码,然后重新部署。

而且,开发环境、测试环境、生产环境的配置不一样,每次部署都要手动改配置,很容易出错,也很麻烦。

6. 几乎没有错误处理

代码里几乎没有错误处理。读取文件失败了怎么办?没有处理。连接Elasticsearch失败了怎么办?没有处理。解析日志失败了怎么办?没有处理。

一旦某个环节出错,整个脚本就崩溃了,而且没有任何错误日志,你根本不知道哪里出了问题。线上运行的时候,经常因为一个小错误,导致整个日志收集停掉,而且没人知道,直到有人发现日志没更新了,才去排查。

7. 没有注释,没有文档

整个脚本,除了开头一行"# ELK日志分析脚本",几乎没有任何注释。复杂的正则表达式、数据转换逻辑、Elasticsearch查询语句,都没有任何解释。

也没有任何文档,不知道这个脚本的输入输出是什么,不知道怎么部署,怎么运行,怎么配置,出了问题怎么排查。新人接手,完全是一头雾水,只能靠读代码来猜。

8. 没有日志输出

脚本运行的时候,几乎没有任何日志输出。你不知道它有没有在运行,不知道它处理了多少条日志,不知道有没有出错,不知道写入Elasticsearch成功了没有。

出了问题,只能靠猜,或者在代码里加print语句来调试,非常低效。

二、重构的原则和思路

找到了问题之后,我没有急着动手改代码,而是先想清楚重构的原则和思路。

重构的原则:

  1. 保持功能不变:重构不是重写,不是改变功能,而是在保持功能不变的前提下,改善代码的结构和质量。重构的过程中,要确保每一步修改之后,功能都是正常的。
  2. 小步快跑,逐步重构:不要想着一次性把所有代码都重构完,那样风险太大,也容易出问题。要小步快跑,一步一步来,每一步只做一个小的重构,确保没问题了,再做下一步。
  3. 每一步都要可验证:每一步重构之后,都要验证功能是否正常。最好有单元测试或者集成测试,如果没有,至少要手动验证关键功能。
  4. 先理解,再重构:在重构一段代码之前,先要理解这段代码做什么,怎么做的,为什么这么做。只有理解了,才能安全地重构,不会改变原有功能。

重构的思路:

  1. 先加日志和错误处理:在重构之前,先给代码加上日志输出和基本的错误处理。这样,在重构的过程中,如果出了问题,能及时发现,也方便排查。
  2. 提取配置:把硬编码的配置提取出来,放到配置文件里。这样,修改配置不需要改代码,也方便不同环境的部署。
  3. 拆分模块:把一个大文件,按照功能拆分成多个模块。每个模块职责单一,只做一件事。
  4. 提取函数和类:把重复的代码提取成函数,把相关的数据和行为封装成类。
  5. 重命名变量和函数:把没有意义的变量名和函数名,改成有意义的名字。
  6. 加注释和文档:给复杂的逻辑加上注释,给项目加上文档。
  7. 加单元测试:给核心逻辑加上单元测试,确保重构之后功能正常,也方便以后的维护。

三、重构的具体步骤

按照上面的思路,我开始了重构。整个过程,大概分了以下几个步骤:

第一步:加日志和错误处理

我首先给代码加上了日志输出。用Python的logging模块,替代了原来的print语句。在关键的位置,加上了日志:脚本启动的时候、开始处理一种日志的时候、处理了多少条日志、写入Elasticsearch成功了多少条、失败了多少条、出错的时候……

同时,我也加上了基本的错误处理。用try-except包裹了可能出错的地方,比如读取文件、连接Elasticsearch、解析日志、写入数据等。出错的时候,记录错误日志,然后继续处理下一条,而不是让整个脚本崩溃。

这一步做完之后,脚本的可观测性大大提高了。运行的时候,能看到详细的日志,出了问题也能及时发现,方便排查。

第二步:提取配置

然后,我把硬编码在代码里的配置,全部提取出来,放到了一个YAML配置文件里。包括:

  • Elasticsearch的地址、端口、索引名、文档类型
  • 各种日志文件的路径、编码、读取方式
  • 日志解析的规则(正则表达式、字段映射)
  • 定时任务的时间间隔
  • 日志输出的级别和格式

同时,写了一个配置加载模块,负责读取和解析配置文件,校验配置的合法性,提供统一的配置访问接口。

这一步做完之后,修改配置不需要改代码了,不同环境的部署也方便了,只需要用不同的配置文件就行。

第三步:拆分模块

接下来,是最核心的一步:拆分模块。

我把原来的一个3000多行的脚本,按照功能拆分成了以下几个模块:

  1. config.py:配置加载模块,负责读取和解析配置文件。
  2. logger.py:日志模块,负责初始化和配置日志输出。
  3. collector.py:日志收集模块,负责从各种来源(文件、网络等)读取日志。
  4. parser.py:日志解析模块,负责把原始的日志文本,解析成结构化的数据。
  5. transformer.py:数据转换模块,负责对解析后的数据进行清洗、转换、 enrichment。
  6. output.py:输出模块,负责把处理好的数据写入Elasticsearch。
  7. pipeline.py:管道模块,负责把收集、解析、转换、输出这几个步骤串联起来,形成一个完整的处理流程。
  8. scheduler.py:定时任务模块,负责定时执行日志收集和处理。
  9. main.py:主程序入口,负责初始化和启动整个系统。

每个模块,职责单一,只做一件事。模块之间通过明确的接口通信,不直接依赖内部实现。这样,修改一个模块,不会影响其他模块,维护起来轻松多了。

拆分模块的时候,我是一个一个拆的。先把配置相关的代码提取出来,做成config.py,验证没问题了,再把日志相关的代码提取出来,做成logger.py,验证没问题了,再拆收集模块……每拆一个模块,都要运行一下,确保功能正常。这样,即使出了问题,也知道是哪一步引入的,方便回滚和排查。

第四步:提取函数和类

模块拆分好了之后,我开始在每个模块内部,提取函数和类。

比如,parser.py模块里,原来解析不同类型日志的代码,都是复制粘贴的。我把公共的解析逻辑提取成了一个基类BaseParser,然后每种日志类型写一个子类,继承BaseParser,只实现自己特有的解析逻辑。这样,公共的逻辑只写一遍,子类只需要关注自己的差异,代码大大减少,也更容易维护。

再比如,output.py模块里,原来写入Elasticsearch的代码,复制了好几遍。我把它提取成了一个ElasticsearchClient类,封装了连接、写入、批量写入、错误重试等逻辑。需要写入Elasticsearch的时候,只需要调用这个类的方法就行,不需要每次都写一遍连接和写入的代码。

提取函数和类的时候,我遵循了几个原则:

  • 单一职责:一个函数只做一件事,一个类只负责一个功能。
  • 不要重复(DRY):相同的逻辑,只写一遍,不要复制粘贴。
  • 高内聚,低耦合:相关的代码放在一起,模块之间、类之间的依赖要尽量少。

第五步:重命名变量和函数

函数和类提取好了之后,我开始重命名变量和函数。

原来的变量名,都是a、b、c、data、tmp这种没有意义的名字。我把它们全部改成了有意义的名字。比如:

  • a → log_line(日志行)
  • b → parsed_data(解析后的数据)
  • c → es_client(Elasticsearch客户端)
  • data → raw_log(原始日志)
  • tmp → cleaned_data(清洗后的数据)
  • result → output_records(输出记录)

函数名也是一样,原来的func1、func2、process,都改成了有意义的名字,比如parsenginxlog、cleanlogdata、writetoelasticsearch等。

重命名之后,代码的可读性大大提高了。看变量名和函数名,就知道它代表什么,做什么,不需要再去猜了。

第六步:加注释和文档

代码结构清晰了之后,我开始加注释和文档。

注释主要加在以下几个地方:

  • 复杂的正则表达式:解释这个正则匹配什么,每个分组代表什么。
  • 复杂的数据转换逻辑:解释为什么要这么转换,转换的规则是什么。
  • Elasticsearch的查询和配置:解释这个查询的作用,每个参数的含义。
  • 容易出错的地方:提醒注意事项,比如这里为什么要加try-except,这里为什么要做特殊处理。

文档方面,我写了一个README.md,包括:

  • 项目简介:这个项目是做什么的,解决什么问题。
  • 架构说明:整体架构,各个模块的职责和关系。
  • 安装部署:怎么安装依赖,怎么配置,怎么运行。
  • 配置说明:配置文件里每个配置项的含义和默认值。
  • 扩展开发:怎么加一种新的日志类型,怎么加一个新的输出目的地。
  • 常见问题:常见的问题和排查方法。

有了注释和文档,新人接手的时候,就不需要完全靠读代码来猜了,看文档和注释就能快速上手。

第七步:加单元测试

最后,我给核心逻辑加上了单元测试。

主要测试了以下几个模块:

  • parser.py:测试各种日志类型的解析是否正确,包括正常情况和异常情况。
  • transformer.py:测试数据清洗和转换是否正确。
  • config.py:测试配置加载和校验是否正确。

单元测试用Python的unittest框架写的,每个测试用例只测一个功能,输入明确,输出可验证。

加了单元测试之后,重构的信心更足了。每次修改代码之后,跑一遍单元测试,就能知道有没有破坏原有功能。以后维护的时候,也有了保障,修改代码之后跑测试,就能知道有没有引入bug。

四、重构的效果

两周之后,重构完成了。来看看重构的效果:

代码量:

  • 重构前:一个文件,3200多行。
  • 重构后:12个文件,总共2500多行(去掉了大量的复制粘贴代码,虽然加了注释和文档,但是总行数反而减少了)。

代码质量:

  • 模块化:12个模块,每个模块职责单一。
  • 函数和类:每个模块都有清晰的函数和类,没有全局变量满天飞。
  • 命名:变量名和函数名都有意义,可读性强。
  • 注释和文档:复杂逻辑有注释,项目有完整的文档。
  • 错误处理:关键位置都有错误处理,不会因为一个错误导致整个脚本崩溃。
  • 日志:详细的日志输出,方便排查问题。
  • 单元测试:核心逻辑有单元测试,共80多个测试用例。

维护性:

  • 加一种新的日志类型:只需要写一个Parser子类,在配置里加一行,不需要改其他代码。
  • 修改配置:改配置文件就行,不需要改代码。
  • 排查问题:看日志就能定位问题,不需要在代码里加print调试。
  • 新人接手:看文档和注释,一两天就能上手。

性能:

  • 重构之后,因为优化了Elasticsearch的写入(用批量写入替代了单条写入),性能反而提升了,处理速度比原来快了将近一倍。

总的来说,重构的效果非常好。代码从原来的"无法维护的烂代码",变成了"清晰、优雅、可维护的好代码"。虽然花了两周时间,但是从长期来看,是非常值得的。以后维护这个项目的人,会轻松很多。

五、重构的经验和教训

这次重构,我也总结了一些经验和教训:

1. 重构之前,先理解代码

不要一上来就改代码。先花时间通读代码,理解代码做什么,怎么做的,为什么这么做。只有理解了,才能安全地重构,不会改变原有功能。

我这次重构,花了整整一天时间读代码,做笔记,画流程图,把整个流程搞清楚了,才开始动手改。这一天的时间,花得非常值。

2. 小步快跑,每一步都要验证

不要想着一次性重构完。要小步快跑,一步一步来,每一步只做一个小的重构,验证没问题了,再做下一步。

我这次重构,每拆一个模块,每提取一个函数,都会运行一下,验证功能正常。虽然慢了一点,但是很稳,几乎没有出过大问题。

3. 先加日志和错误处理,再重构

在重构之前,先给代码加上日志和错误处理。这样,在重构的过程中,如果出了问题,能及时发现,也方便排查。

如果代码本身没有日志,没有错误处理,你重构的时候出了问题,可能都不知道,直到线上出了故障才发现,那就晚了。

4. 保持功能不变,不要边重构边加功能

重构的目标是改善代码结构,不是加新功能。在重构的过程中,不要顺手加新功能,也不要顺手改业务逻辑。那样会让重构变得复杂,也容易出问题。

如果有新功能要加,等重构完了,代码结构清晰了,再加也不迟。那时候加新功能,反而更容易,因为代码结构好了。

5. 有单元测试最好,没有的话要手动验证

如果代码有单元测试,重构的时候就有保障,跑测试就知道有没有破坏功能。如果没有单元测试,那每一步重构之后,都要手动验证关键功能,确保没问题。

我这次重构,原来的代码没有单元测试,所以每一步都手动验证。重构到后期,代码结构清晰了,我才开始加单元测试。加了单元测试之后,后面的重构就轻松多了。

6. 不要追求完美,够用就行

重构的时候,不要追求完美,不要想着把代码改造成"最优雅的代码"。重构的目标是让代码可维护,不是追求艺术。只要代码结构清晰,职责单一,可读性好,容易维护,就够了。

过度重构,反而会浪费时间,也可能引入不必要的风险。适可而止,够用就行。

六、写在最后

烂代码,是每个程序员都会遇到的。可能是别人写的,也可能是自己以前写的。面对烂代码,不要抱怨,也不要逃避,更不要推倒重写(大多数情况下,推倒重写的风险很大,成本也很高)。

重构,是处理烂代码的最好方式。在保持功能不变的前提下,一步一步地改善代码的结构和质量,让烂代码慢慢变成好代码。

当然,重构需要时间,需要耐心,也需要方法。不要急,小步快跑,一步一步来。每一步都验证,确保功能正常。只要坚持下去,烂代码终会变成优雅的代码。

这次ELK日志分析项目的重构,让我对代码重构有了更深的理解,也积累了更多的经验。希望我的这些经验,能给正在面对烂代码的你,一些参考和启发。

最后,用一句话来结束这篇文章:"烂代码不可怕,可怕的是面对烂代码却不去改善。重构,是程序员的基本功,也是程序员的责任。在保持功能不变的前提下,小步快跑,逐步改善,烂代码终会变成优雅的代码。"

愿我们都能写出优雅的代码,也愿我们都能勇敢地面对和改善烂代码。