最近我们团队对Nacos配置中心的客户端代码做了一次彻底的重构,从之前的"能跑就行"的烂代码,重构成了结构清晰、可维护、可扩展的优雅代码。
这套代码,是两年前一个同事快速写出来的,当时项目时间紧,需求也不明确,就先写了一版能用的,后来需求不断变化,代码就不断地打补丁,越写越乱,到最后,除了当初写的那个人,没人敢改,而当初写的人已经离职了,代码就成了一个"黑盒",出了问题只能猜,改一个bug可能引入三个新bug,维护成本非常高。
之前我们一直想重构,但因为业务忙,一直没腾出时间,直到最近,这套代码又出了好几次问题,而且新需求来了,根本没法在旧代码上加,加了就出问题,我们才下定决心,彻底重构一次。重构的过程中,我们发现了很多问题,也学到了很多东西,今天来完整记录这次代码重构的过程,希望能给做代码重构的朋友一些参考。
一、重构前:烂代码长什么样
先说说重构前的代码有多烂,给大家一个直观的感受。
1. 一个类几千行,什么都干
最核心的一个类,叫ConfigClient,有将近三千行代码,什么都干:连接Nacos、拉取配置、解析配置、缓存配置、监听配置变更、推送配置变更、处理异常、重试、日志、统计,全在这一个类里。这个类就是一个"上帝类",什么都知道,什么都干,耦合度极高,改任何一个功能,都可能影响其他功能。
而且,这个类里的方法,很多都是几百行的大方法,一个方法里有十几层if-else嵌套,变量名都是a、b、c、data、result这种,看半天都不知道在干什么。注释也很少,只有零星几行,还是当初写代码的人随手写的,很多已经和代码对不上了。
2. 复制粘贴严重,大量重复代码
因为当初写的时候图快,很多功能都是复制粘贴改一改,导致大量重复代码。比如,解析配置的逻辑,在好几个地方都有,每处都稍微不一样,但核心逻辑是一样的;处理异常和重试的逻辑,也重复了很多次;日志打印的格式,也是各处不一样,有的打了,有的没打,有的打了关键信息,有的只打了一句"出错了"。
重复代码的危害是,改一个逻辑,要改好几个地方,很容易漏改,导致有的地方改了,有的地方没改,出现不一致的bug。而且,重复代码多了,代码量就大,维护成本就高,看代码的时候,到处都是似曾相识的逻辑,很容易混淆。
3. 硬编码满天飞,配置散落在各处
代码里有大量的硬编码,比如Nacos的地址、超时时间、重试次数、缓存大小、线程池大小,这些都直接写死在代码里,不同的地方还写了不同的值,有的地方超时是3秒,有的地方是5秒,有的地方是10秒,根本不知道哪个是对的。要改一个配置,得全局搜索,改好几个地方,还容易漏。
而且,没有统一的配置管理,配置散落在代码的各个角落,有的写在常量类里,有的写在方法里,有的写在配置文件里,还有的写在环境变量里,要找一个配置,得翻好几个地方,非常麻烦。
4. 异常处理混乱,吞异常、乱抛异常
异常处理非常混乱,有的地方catch了异常,什么都不做,直接吞掉,出了问题根本不知道;有的地方catch了异常,打印一行日志,然后继续往下跑,导致后续逻辑用错误的数据继续执行,产生更严重的问题;有的地方catch了异常,直接抛一个RuntimeException,把原始异常丢了,排查问题的时候看不到原始异常,很难定位;还有的地方,该抛异常的不抛,不该抛的乱抛,调用方根本不知道该怎么处理。
而且,没有统一的异常体系,各种异常满天飞,有Nacos的异常,有自定义的异常,有JDK的异常,还有直接抛字符串的,调用方catch的时候,根本不知道该catch哪个,只能catch Exception,甚至catch Throwable,非常粗糙。
5. 没有单元测试,改代码全靠猜
最可怕的是,这套代码几乎没有单元测试,只有几个最简单的方法有测试,核心逻辑、异常处理、边界条件,都没有测试。改代码的时候,全靠猜,猜这个改动会不会影响其他地方,猜这个逻辑对不对,改完之后,只能手动测几个主要场景,很多边界条件和异常场景根本测不到,经常是改完上线,过几天发现某个场景出问题了,又得改,改了又可能引入新问题,陷入恶性循环。
而且,因为没有测试,重构的时候风险特别大,不知道改完之后功能是不是还正常,只能一点点改,改一点测一点,非常慢,也非常没有安全感。
6. 可扩展性差,加新功能很痛苦
因为代码耦合度高,结构混乱,可扩展性非常差,加一个新功能,比如支持一种新的配置格式,或者支持一种新的监听方式,都得改核心类,而且要在好几个地方加逻辑,很容易改出问题。每次加新功能,都是一次煎熬,开发人员都不愿意碰这套代码,能躲就躲,实在躲不过去,就硬着头皮改,改完心里也没底。
这就是重构前的代码状态,用一个字形容就是"烂",两个字就是"很烂",三个字就是"非常烂"。相信很多团队都有这样的代码,当初快速写出来,后来不断打补丁,越写越烂,最后变成没人敢碰的"祖传代码"。
二、重构的思路和原则
面对这样的烂代码,我们没有一上来就重写,而是先制定了重构的思路和原则,确保重构是有序的、可控的,而不是越改越乱。
1. 重构不是重写,要循序渐进
很多人一提到重构,就想全部推倒重写,觉得重写最干净、最彻底。但实际上,重写的风险非常大,尤其是对于这种已经在线上运行、有很多用户的代码,重写很容易引入新的bug,而且重写需要很长时间,期间旧代码还得维护,两边都要顾,很容易出问题。
我们的原则是:重构不是重写,要循序渐进,小步快跑,每次改一点,每次改完都能运行、能测试、能上线,逐步把烂代码改造成好代码。这样,风险可控,即使出了问题,影响范围也小,容易回滚。
2. 先加测试,再改代码
对于没有测试的代码,重构的第一步不是改代码,而是加测试。只有有了测试,改代码的时候才有安全感,才能知道改完之后功能是不是还正常,才能放心大胆地改。
我们的做法是,先给核心逻辑加单元测试,把主要的场景、边界条件、异常场景都覆盖到。加测试的过程,也是理解代码的过程,通过写测试,能更深入地理解代码的逻辑和行为,为后续的重构打下基础。
当然,给烂代码加测试也不容易,因为代码耦合度高,依赖多,不好mock。我们的做法是,先从最独立、最核心的逻辑开始加测试,逐步扩展,对于实在不好测试的代码,先通过集成测试来覆盖,等重构解耦之后,再补单元测试。
3. 保持功能不变,先优化结构,再优化逻辑
重构的一个重要原则是:重构过程中,保持功能不变,先优化代码结构,再优化逻辑。也就是说,重构的时候,不要一边改结构,一边改功能,那样很容易出问题,而且出了问题不知道是结构改的还是功能改的。
我们的做法是,先在功能不变的前提下,优化代码结构,比如拆分大类、提取方法、消除重复、统一异常、统一配置,让代码结构清晰、可读性好。等结构优化好了,再根据需要优化逻辑,比如性能优化、功能增强、bug修复。这样,每一步的改动都很清晰,出了问题也容易定位。
4. 定义清晰的接口和分层
重构的核心,是定义清晰的接口和分层,把耦合在一起的代码拆开,让每个模块各司其职,接口清晰,依赖明确。
我们先分析了代码的功能,把它拆分成几个层次:
- 传输层:负责和Nacos服务器通信,建立连接、发送请求、接收响应、处理网络异常。
- 解析层:负责解析Nacos返回的配置数据,把原始数据转换成内部的配置对象。
- 缓存层:负责缓存配置,提供本地缓存的读写、过期、刷新。
- 监听层:负责监听配置变更,注册监听器,推送变更事件。
- 门面层:对外提供统一的客户端接口,封装内部的复杂逻辑,给调用方一个简单易用的API。
每个层次都定义清晰的接口,层次之间通过接口依赖,而不是直接依赖实现,这样,每个层次都可以独立修改、独立测试,可维护性和可扩展性大大提升。
5. 代码规范和最佳实践
重构后的代码,要严格遵守代码规范,应用最佳实践,比如:
- 类和方法的职责单一,一个类只做一件事,一个方法只做一件事。
- 方法不要太长,一般不超过50行,超过的就拆分。
- if-else嵌套不要太深,一般不超过3层,超过的就用提前返回、策略模式等方式优化。
- 变量名、方法名、类名要有意义,见名知意,不要用a、b、c这种无意义的名字。
- 注释要适量,解释"为什么",而不是"做什么",代码本身应该能说明做什么。
- 异常处理要规范,该抛的抛,该catch的catch,不要吞异常,不要丢原始异常。
- 配置要统一管理,不要硬编码。
- 要有完善的单元测试,核心逻辑覆盖率要达到80%以上。
这些规范和最佳实践,说起来简单,但真正做到,需要在重构的过程中严格要求自己,每一行代码都认真写,不能图快、图省事。
三、重构的具体步骤
思路和原则确定了之后,我们就开始了具体的重构,大致分了这么几个步骤:
第一步:加测试,建立安全网
如前所述,第一步是加测试。我们花了一周的时间,给核心逻辑加了单元测试和集成测试,覆盖率从原来的不到10%,提升到了60%以上。加测试的过程中,我们还发现了几个隐藏的bug,顺便修了。
有了测试这个安全网,后面的重构就有底气了,改完代码跑一遍测试,就能知道功能是不是还正常,大大降低了重构的风险。
第二步:拆分大类,提取小类
接下来,我们开始拆分那个三千行的ConfigClient大类。我们先分析了这个类里的方法,按照功能进行分组,然后把每组方法提取成一个独立的小类,比如:
- 把和Nacos通信相关的方法,提取成NacosConnector类。
- 把配置解析相关的方法,提取成ConfigParser类。
- 把配置缓存相关的方法,提取成ConfigCache类。
- 把配置监听相关的方法,提取成ConfigWatcher类。
- 把异常处理和重试相关的方法,提取成RetryHandler类。
提取小类的时候,我们先把方法和相关的字段移过去,然后让原来的ConfigClient持有这些小类的实例,原来的方法改成调用小类的方法,这样,外部调用方不需要改,功能也保持不变,只是内部结构变了。
拆分完之后,原来的ConfigClient从三千行变成了不到三百行,变成了一个门面类,负责组合各个小类,对外提供统一的接口。每个小类都只有几百行,职责单一,结构清晰,可读性和可维护性大大提升。
第三步:提取方法,消除重复
大类拆成小类之后,我们开始处理每个小类里的大方法和重复代码。对于几百行的大方法,我们按照功能拆分成多个小方法,每个小方法只做一件事,方法名清晰地说明做什么。对于重复的代码,我们提取成公共方法或者工具类,在需要的地方调用,消除重复。
比如,解析配置的逻辑,原来在好几个地方都有,我们提取成ConfigParser类的parse方法,统一解析逻辑,所有需要解析的地方都调用这个方法,这样,解析逻辑只在一个地方,改的时候只改一处,不会出现不一致的问题。
再比如,异常处理和重试的逻辑,原来重复了很多次,我们提取成RetryHandler类,封装了重试的逻辑,包括重试次数、重试间隔、重试条件、异常处理,需要重试的地方,调用RetryHandler的execute方法,传入一个回调,就自动处理重试了,非常方便,也统一了重试的行为。
提取方法、消除重复之后,代码量减少了将近三分之一,而且结构更清晰,逻辑更统一,维护起来轻松多了。
第四步:统一异常体系
接下来,我们处理异常处理的问题。我们先定义了统一的异常体系:
- 定义了一个基类ConfigException,继承自RuntimeException,作为所有配置相关异常的父类。
- 定义了具体的异常子类,比如ConfigConnectException(连接异常)、ConfigParseException(解析异常)、ConfigTimeoutException(超时异常)、ConfigNotFoundException(配置不存在异常)等,每种异常对应一种具体的错误场景。
- 所有异常都要保留原始异常(cause),不要丢,方便排查问题。
- 定义了异常的错误码和错误消息,统一格式,方便调用方处理和日志排查。
然后,我们把代码里原来的各种异常,都替换成统一的异常体系,该抛什么异常就抛什么异常,不再乱抛RuntimeException,也不再吞异常。对于需要检查的异常,在合适的层级catch,处理或者转换后再抛出,不要在底层就吞掉。
统一异常体系之后,异常处理变得清晰、规范,调用方知道该catch什么异常,该怎么处理,排查问题的时候,也能通过异常类型和错误码,快速定位问题,效率大大提升。
第五步:统一配置管理
然后,我们处理硬编码和配置分散的问题。我们定义了一个统一的配置类ConfigClientConfig,把所有的配置项都集中在这个类里,包括Nacos地址、超时时间、重试次数、缓存大小、线程池大小等,每个配置项都有默认值,也可以通过构造方法或者setter方法设置。
同时,我们提供了配置的加载机制,可以从配置文件、环境变量、系统属性中加载配置,优先级明确,方便不同环境下的配置管理。
把所有硬编码都替换成从ConfigClientConfig读取,这样,要改配置,只需要改一个地方,或者通过配置文件修改,不需要改代码,非常方便。而且,配置集中管理,也方便查看和维护,不会出现配置散落在各处、值不一致的问题。
第六步:优化接口设计,提供流畅的API
内部结构优化好了之后,我们开始优化对外的接口设计。原来的接口很混乱,方法名不统一,参数多,返回值不清晰,调用方用起来很别扭。
我们重新设计了接口,采用建造者模式来创建客户端,提供流畅的API:
ConfigClient client = ConfigClient.builder()
.serverAddr("127.0.0.1:8848")
.timeout(5000)
.retryTimes(3)
.build();获取配置的接口,也设计得简单清晰:
String config = client.getConfig("dataId", "group", 3000);监听配置变更的接口,采用回调的方式:
client.addListener("dataId", "group", new ConfigListener() {
@Override
public void onChange(String config) {
// 处理配置变更
}
});接口设计遵循"最小惊讶原则",方法名见名知意,参数少而清晰,返回值明确,异常规范,调用方用起来很顺手,学习成本也低。
第七步:补全测试,提升覆盖率
重构完成之后,我们补全了单元测试,把核心逻辑、边界条件、异常场景都覆盖到,覆盖率从60%提升到了85%以上。同时,我们还加了集成测试,模拟真实的Nacos环境,测试端到端的功能,确保重构后的代码和原来的功能一致。
有了完善的测试,后续的维护和迭代就有了保障,改代码的时候有底气,不怕改出问题。
第八步:文档和注释
最后,我们补全了文档和注释。给每个类、每个接口、每个核心方法,都写了清晰的Javadoc,说明用途、参数、返回值、异常。同时,写了一份使用文档,包括快速开始、配置说明、API说明、最佳实践、常见问题等,方便其他团队使用。
文档和注释,虽然不影响代码运行,但对代码的可维护性和可传播性非常重要。好的文档和注释,能让后来者快速理解代码,减少沟通成本,也能让代码的价值得到更好的发挥。
四、用到的设计模式和最佳实践
在重构的过程中,我们用到了一些设计模式和最佳实践,在这里分享几个比较重要的:
1. 建造者模式(Builder)
用于创建ConfigClient对象,把复杂的创建过程封装起来,提供流畅的API,同时保证对象创建的一致性和不可变性。
2. 门面模式(Facade)
ConfigClient作为门面,封装了内部各个子系统的复杂逻辑,对外提供一个简单统一的接口,让调用方不需要了解内部的复杂结构,用起来简单方便。
3. 策略模式(Strategy)
用于配置解析,不同的配置格式(JSON、YAML、Properties等),对应不同的解析策略,通过策略接口统一调用,方便扩展新的格式,也符合开闭原则。
4. 观察者模式(Observer)
用于配置变更监听,ConfigClient是被观察者,Listener是观察者,配置变更时,通知所有观察者,实现了配置变更的推送和解耦。
5. 模板方法模式(Template Method)
用于重试和异常处理,定义了重试的模板流程(执行、判断是否重试、等待、重试),把具体的执行逻辑留给子类实现,统一了重试的行为,也避免了重复代码。
6. 单一职责原则
每个类、每个方法都只做一件事,职责单一,这样代码清晰,容易理解,也容易测试和维护。这是重构中最重要的原则之一,也是我们拆分大类、提取小方法的依据。
7. 开闭原则
对扩展开放,对修改关闭。通过定义清晰的接口,把不变的部分封装起来,把可变的部分通过接口扩展,这样,加新功能的时候,不需要改核心代码,只需要加新的实现类,降低了引入新bug的风险。
8. 依赖倒置原则
高层模块不依赖低层模块,都依赖抽象;抽象不依赖细节,细节依赖抽象。我们的代码里,各个层次之间都通过接口依赖,而不是直接依赖实现,这样,实现可以随时替换,不影响高层逻辑,也方便测试(可以mock接口)。
这些设计模式和原则,不是为了用而用,而是在重构的过程中,发现某个问题,某个模式正好能解决,就用了。不要为了炫技而强行用设计模式,那样反而会让代码更复杂。合适的才是最好的。
五、重构后的效果
重构完成后,效果非常明显,主要体现在这几个方面:
1. 代码质量大幅提升
代码量从原来的五千多行(包括重复代码),减少到了三千多行,而且结构清晰,职责单一,可读性和可维护性大大提升。现在,新同事看代码,半天就能理解整体结构,一周就能上手改bug、加功能,而原来,看一个月都不敢改。
2. bug率大幅下降
重构后的代码,因为结构清晰,逻辑统一,加上完善的测试,bug率大幅下降。重构前,平均每个月要出两三个线上bug,重构后,连续三个月没有出过因为代码质量问题导致的bug,稳定性大大提升。
3. 开发效率提升
加新功能的效率大大提升。原来加一个新功能,要改好几个地方,还要担心影响其他功能,开发加测试要一周;现在,因为结构清晰,接口规范,加新功能只需要加一个实现类,或者在合适的层次加逻辑,开发加测试一两天就能完成,效率提升了好几倍。
4. 团队信心提升
重构前,团队成员都不愿意碰这套代码,能躲就躲,提到这套代码就头疼。重构后,代码结构清晰,测试完善,大家都愿意改,也有信心改,团队的技术氛围也更好了。而且,通过这次重构,团队成员对代码质量、设计模式、重构技巧都有了更深的理解,技术能力也得到了提升。
当然,重构也不是没有代价的,我们花了将近一个月的时间,两个人投入,期间还要兼顾业务需求,确实比较辛苦。但从长期来看,这个投入是非常值得的,重构后的代码,维护成本大大降低,开发效率大大提升,bug率大大下降,这些收益,远远超过了重构的投入。
六、经验总结和建议
最后,总结一下这次代码重构的经验,给需要做重构的朋友一些建议:
1. 重构要趁早,不要等烂到不能再烂才重构
很多团队都是等代码烂到不能再烂、实在维护不下去了,才想到重构,这时候重构的成本和风险都非常大。最好是在代码还没那么烂的时候,就持续重构,每次改代码的时候,顺手把周围的烂代码优化一下,小步快跑,持续改进,这样成本低,风险小,代码也不会越积越烂。
2. 重构前一定要加测试
没有测试的重构,就是赌博,你不知道改完之后功能是不是还正常。重构前,一定要先给核心逻辑加测试,建立安全网,这样改代码的时候才有底气。如果实在加不了单元测试,至少要加集成测试,或者手动测试用例,确保改完之后能验证功能。
3. 小步快跑,每次只改一点
不要想着一次就把所有问题都解决,那样风险太大,也容易半途而废。要小步快跑,每次只改一点,改完测试,没问题了再改下一点。这样,即使出了问题,影响范围也小,容易回滚,也容易坚持下去。
4. 重构过程中保持功能不变
重构的时候,不要一边改结构,一边改功能,那样出了问题不知道是哪改的。要先在功能不变的前提下优化结构,等结构稳定了,再优化功能和逻辑。每一步的改动都要清晰,可验证,可回滚。
5. 不要为了重构而重构
重构的目的是让代码更好维护、更好扩展、更少bug,而不是为了用设计模式、为了代码好看。不要为了重构而重构,不要过度设计,合适的才是最好的。重构的时候,要始终围绕"提升可维护性、降低维护成本"这个目标,不要偏离。
6. 争取团队和领导的支持
重构需要投入时间和人力,而且短期内看不到明显的业务产出,很容易被认为是"不务正业"。所以,重构前,要和团队、领导充分沟通,说明重构的必要性和预期收益,争取支持,把重构时间排进计划里,不要用业余时间偷偷重构,那样既累,也容易半途而废。
7. 重构后要保持,不要又写回烂代码
重构完成后,要建立代码规范和代码审查机制,确保新写的代码符合规范,不要又写回烂代码,让重构的成果付诸东流。代码质量是持续维护的结果,不是一次重构就能一劳永逸的。
七、写在最后
这次Nacos配置中心代码的重构,是我们团队做过的最成功的一次重构,从烂代码到优雅代码,过程虽然辛苦,但收获非常大。不仅代码质量提升了,团队的技术能力和信心也提升了,更重要的是,我们建立了对代码质量的重视和持续重构的习惯。
很多团队都有"祖传烂代码",大家都吐槽,但很少有人真正去重构,要么是觉得太麻烦、风险太大,要么是业务忙、没时间。但实际上,烂代码的维护成本,远远超过重构的成本,而且越晚重构,成本越高,风险越大。与其每天被烂代码折磨,不如花点时间,彻底重构一次,一劳永逸。
当然,重构不是一件容易的事情,需要规划、需要耐心、需要团队配合,但只要方法对、步骤对,烂代码是可以变成优雅代码的。希望我们的经验,能给正在被烂代码折磨的朋友一些参考和勇气,早点开始重构,早点摆脱烂代码的折磨。
最后,用一句话结尾:"好代码不是写出来的,是改出来的。"愿大家都能写出优雅的代码,也能勇敢地重构烂代码,让代码世界更美好。
评论(0)
暂无评论,快来抢沙发~
评论功能仅对会员开放,请先登录
登录