最近,我接手了一个基于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语句来调试,非常低效。
二、重构的原则和思路
找到了问题之后,我没有急着动手改代码,而是先想清楚重构的原则和思路。
重构的原则:
- 保持功能不变:重构不是重写,不是改变功能,而是在保持功能不变的前提下,改善代码的结构和质量。重构的过程中,要确保每一步修改之后,功能都是正常的。
- 小步快跑,逐步重构:不要想着一次性把所有代码都重构完,那样风险太大,也容易出问题。要小步快跑,一步一步来,每一步只做一个小的重构,确保没问题了,再做下一步。
- 每一步都要可验证:每一步重构之后,都要验证功能是否正常。最好有单元测试或者集成测试,如果没有,至少要手动验证关键功能。
- 先理解,再重构:在重构一段代码之前,先要理解这段代码做什么,怎么做的,为什么这么做。只有理解了,才能安全地重构,不会改变原有功能。
重构的思路:
- 先加日志和错误处理:在重构之前,先给代码加上日志输出和基本的错误处理。这样,在重构的过程中,如果出了问题,能及时发现,也方便排查。
- 提取配置:把硬编码的配置提取出来,放到配置文件里。这样,修改配置不需要改代码,也方便不同环境的部署。
- 拆分模块:把一个大文件,按照功能拆分成多个模块。每个模块职责单一,只做一件事。
- 提取函数和类:把重复的代码提取成函数,把相关的数据和行为封装成类。
- 重命名变量和函数:把没有意义的变量名和函数名,改成有意义的名字。
- 加注释和文档:给复杂的逻辑加上注释,给项目加上文档。
- 加单元测试:给核心逻辑加上单元测试,确保重构之后功能正常,也方便以后的维护。
三、重构的具体步骤
按照上面的思路,我开始了重构。整个过程,大概分了以下几个步骤:
第一步:加日志和错误处理
我首先给代码加上了日志输出。用Python的logging模块,替代了原来的print语句。在关键的位置,加上了日志:脚本启动的时候、开始处理一种日志的时候、处理了多少条日志、写入Elasticsearch成功了多少条、失败了多少条、出错的时候……
同时,我也加上了基本的错误处理。用try-except包裹了可能出错的地方,比如读取文件、连接Elasticsearch、解析日志、写入数据等。出错的时候,记录错误日志,然后继续处理下一条,而不是让整个脚本崩溃。
这一步做完之后,脚本的可观测性大大提高了。运行的时候,能看到详细的日志,出了问题也能及时发现,方便排查。
第二步:提取配置
然后,我把硬编码在代码里的配置,全部提取出来,放到了一个YAML配置文件里。包括:
- Elasticsearch的地址、端口、索引名、文档类型
- 各种日志文件的路径、编码、读取方式
- 日志解析的规则(正则表达式、字段映射)
- 定时任务的时间间隔
- 日志输出的级别和格式
同时,写了一个配置加载模块,负责读取和解析配置文件,校验配置的合法性,提供统一的配置访问接口。
这一步做完之后,修改配置不需要改代码了,不同环境的部署也方便了,只需要用不同的配置文件就行。
第三步:拆分模块
接下来,是最核心的一步:拆分模块。
我把原来的一个3000多行的脚本,按照功能拆分成了以下几个模块:
- config.py:配置加载模块,负责读取和解析配置文件。
- logger.py:日志模块,负责初始化和配置日志输出。
- collector.py:日志收集模块,负责从各种来源(文件、网络等)读取日志。
- parser.py:日志解析模块,负责把原始的日志文本,解析成结构化的数据。
- transformer.py:数据转换模块,负责对解析后的数据进行清洗、转换、 enrichment。
- output.py:输出模块,负责把处理好的数据写入Elasticsearch。
- pipeline.py:管道模块,负责把收集、解析、转换、输出这几个步骤串联起来,形成一个完整的处理流程。
- scheduler.py:定时任务模块,负责定时执行日志收集和处理。
- 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日志分析项目的重构,让我对代码重构有了更深的理解,也积累了更多的经验。希望我的这些经验,能给正在面对烂代码的你,一些参考和启发。
最后,用一句话来结束这篇文章:"烂代码不可怕,可怕的是面对烂代码却不去改善。重构,是程序员的基本功,也是程序员的责任。在保持功能不变的前提下,小步快跑,逐步改善,烂代码终会变成优雅的代码。"
愿我们都能写出优雅的代码,也愿我们都能勇敢地面对和改善烂代码。
评论(0)
暂无评论,快来抢沙发~
评论功能仅对会员开放,请先登录
登录