Lessons Learned | Refactoring makes your code more beautiful and concise

1 Introduction

Recently, the author had the honor to refactor the AutoNavi taxi order Push project, and shared the work experience related to code refactoring with you, hoping to inspire you.

​Sometimes, when we are working on a functional requirement, it takes a lot of time to find the code related to the requirement. Or, when we read the code written by others and take over other people's projects, we always "scalp tingling". When you are faced with a chaotic and unorganized code structure, variable names and method names that do not convey the meaning of words, I will I believe you are not in the mood to read at all. It's not your problem, it's the code in your hands that needs to be refactored.

2. What is refactoring

Everyone has their own definition of refactoring, I'm quoting here from "Martin Fowler", who defines refactoring in two dimensions.

As a noun: Refactoring is an adjustment to the internal structure of software, with the purpose of improving its understandability and reducing its modification cost without changing the observable behavior of the software. 

As a verb: Refactoring is the use of a series of refactoring techniques to adjust the structure of the software without changing its observable behavior.

The code refactoring I mentioned in this article is more inclined to its definition as a verb , and according to the scale of the refactoring and the length of time, we can divide the code refactoring into small refactorings and large refactorings .

Small refactoring : Refactoring the details of the code, mainly for code-level refactoring such as classes, functions, and variables. For example, common canonical naming (renaming variables that do not convey the meaning of words), eliminating super large functions, eliminating duplicate code, etc. Generally, such refactoring and modification are concentrated and relatively simple, with relatively small impact and short time. Therefore, the difficulty is relatively low, and we can do it in the daily development of the version.

Large-scale refactoring : Refactoring the top level of the code, including the refactoring of system structure, module structure, code structure, and class relationship. The methods generally adopted are to carry out service layering, business modularization, componentization, code abstraction and reuse. Such refactoring may require redefinition of principles, redefinition of schemas, or even redefinition of business. There are many code adjustments and modifications involved, so the impact is relatively large, the time consuming is relatively long, and the risks are relatively large (project suspension risk, code bug risk, business vulnerability risk). This requires us to have experience in large-scale project reconstruction, otherwise it is easy to make mistakes, and the gains outweigh the losses in the end.

In fact, most people don't like refactoring work, just like no one wants to "wipe their butt" for others, mainly because of the following concerns:

  • I don't know how to refactor, lack the experience and methodology of refactoring, and it is easy to make mistakes in refactoring.
  • It's hard to see short-term gains, and if those benefits are long-term, why make these efforts now? In the long run, maybe when the project reaps these benefits, you are no longer responsible for this work.
  • Refactoring breaks existing programs and brings unexpected bugs, and you don't want to suffer from those unexpected bugs.
  • Refactoring requires extra work on your part, and you may not write code that needs to be refactored.

3. Why Refactor

If I work purely for today, I won't be able to work at all tomorrow.

A program has two sides: "what it can do for you today" and "what it can do for you tomorrow". Most of the time, we just focus on what we want the program to do today. Whether it's fixing bugs or adding features, it's all about making the program stronger and making it more valuable today. But why I still advocate that everyone should do code refactoring at the right time, the main reasons are as follows:

  • Keep the software architecture well-designed at all times. Improve our software design, let the software architecture develop in a favorable direction, and be able to provide stable services to the outside world and face various unexpected problems calmly.
  • Increasing maintainability and reducing maintenance costs are both positive and virtuous circles for teams and individuals, making software easier to understand. Whether future generations read the code written by predecessors or review their own code afterwards, they can quickly understand the entire logic, clarify the business, and easily maintain the system.
  • 提高研发速度、缩短人力成本。大家可能深有体会,一个系统在上线初期,向系统中增加功能时,完成速度非常快,但是如果不注重代码质量,后期向系统中添加一个很小的功能可能就需要花上一周或更长的时间。而代码重构是一种有效的保证代码质量的手段,良好的设计是维护软件开发速度的根本。重构可以帮助你更快速的开发软件,因为它阻止系统腐烂变质,甚至还可以提高设计质量。

4. 如何重构

小型重构

小型重构大部分都是在日常开发中进行的,一般的参考标准即是我们的开发规范和准则,目的是为了解决代码中的坏味道,我们来看一下常见的坏味道都有哪些?

泛型擦除

//{"param1":"v1", "param2":"v2", "param3":30, ……}
Map map = JSON.parseObject(msg); //【1】
……
// 将map作为参数传递给底层接口
xxxService.handle(map); //【2】

//看一下底层接口定义
void handle(Map<String, String> map); //【3】
复制代码

【2】处将已经泛型擦除的map传递给底层已经泛型限定的接口中,相信在接口实现中都是使用“String value = map.get(XXX)”这种方式获取值的,这样一旦map中有非String类型的值,这里就会出现类型转换异常。读者肯定和我一样好奇,为何该业务系统中未抛出类型转换异常,原因是业务系统取值的方式并未转换成String类型。可想而知,一但有人使用标准的方式获取值时,就会踩雷。

// 文本1${param1}文本2${param2}文本3${param3}
String[] terms = ["文本1","$param1", "文本2", "$param2", "文本3", "$param3"];
StringBuilder builder = new StringBuilder();
for(String term: terms){
  if(term.startsWith("$")){
    builder.append(map.get(term.substring(1)));
  }else{
    builder.append(term);
  }
}
复制代码

无病呻吟

Config config = new Config();
// 设置name和md5
config.setName(item.getName());
config.setMd5(item.getMd5());
// 设置值
config.setTypeMap(map);
// 打印日志
LOGGER.info("update done ({},{}), start replace", getName(), getMd5());


......

ExpiredConfig expireConfig = ConfigManager.getExpiredConfig();
// 为空初始化
if (Objects.isNull(expireConfig)) {
  expireConfig = new ExpiredConfig();
}

......
Map<String, List<TypeItem>> typeMap = ……;   
Map<String, Map<String, Map<String, List<Map<String, Object>>>>> jsonMap = new HashMap<>();

// 循环一级map
jsonMap.forEach((k1, v1) -> {
    // 循环里面的二级map
    v1.forEach((k2, v2) -> {
        // 循环里面的三级map
        v2.forEach((k3, v3) -> {
            // 循环最里面的list,哎!
            v3.forEach(e -> {
                // 生成key
                String ck = getKey(k1, k2, k3);
                // 为空处理
                List<TypeItem> types = typeMap.get(ck);
                if (CollectionUtils.isEmpty(types)) {
                    types = new ArrayList<>();
                    typeMap.put(ck, types);
                }
                // 设置类型
            }
       }
  }
}
复制代码

代码本身一眼就能看明白是在干什么,写代码的人非要在这个地方加一个不关痛痒的注释,这个注释完全是口水话,毫无价值可言。

if-else过多

try {
  if (StringUtils.isEmpty(id)) {
    if (StringUtils.isNotEmpty(cacheValue)) {
      if (StringUtils.isNotEmpty(idPair)) {
        if (cacheValue.equals(idPair)) {
          // xxx
        } else {
          // xxx
        }
      }
    } else {
      if (StringUtils.isNotEmpty(idPair)) {
        // xxx
      }
    }
    if(xxxx(xxxx){
      // xxx
    }else{
      if(StringUtils.isNotEmpty(idPair)){
        // xxx
      }
      // xxx
    }
  }else if(!check(id, param)){
    // xxx
  }
} catch (Exception e) {
  log.error("error:", e);
}
复制代码

这样的代码,让代码的阅读性大大降低,令很多人望而却步。除非被逼的迫不得已,否则估计开发人员是不会动这样的代码的,因为你不知道你动的一小点,可能会让整个业务系统瘫痪。

其他坏味道

这里就不再罗列相关案例了,相信大家在日常也经常看到很多代码书写不合理,让人不适应的地方,总结一下代码中常见的坏味道和解决办法:

重复代码

代码坏味道最多的恐怕就是重复代码,如果你在一个以上的地方看到相同的代码结构,那么可以肯定:设法将它们合而为一,程序会变得更好。

最常见的一种重复场景就是在“同一个类的两个函数含有相同的表达式”,这种形式的重复代码可以在当前类提取公用方法,以便在两处复用。

还有一种和这类场景相似,就是在“两个互为兄弟的子类含有相同的表达式”,这种形式可以将相同的代码提取到共同父类中,针对有差异化的部分,使用抽象方法延迟到子类实现,这就是常见的模板方法设计模式。如果两个毫不相干的类出现了重复代码,这个时候应该考虑将重复代码提炼到一个新类中,然后在这两个类中调用这个新类的方法。

函数过长

一个好的函数必须满足单一职责原则,短小精悍,只做一件事。过长的函数体和身兼数职的方法都不利于阅读,也不利于进行代码复用。

命名规范

一个好的命名需要能做到“名副其实、见名知意”,直接了当,不存在歧义。

不合理的注释

注释是一把双刃剑,好的注释能够给我们好的指导,不好的注释只会将人误导。针对注释,我们需要做到在整合代码时,也把注释一并进行修改,否则就会出现注释和逻辑不一致。另外,如果代码已清晰的表达了自己的意图,那么注释反而是多余的。

无用代码

无用代码有两种方式,一种是没有使用场景,如果这类代码不是工具方法或工具类,而是一些无用的业务代码,那么就需要及时的删除清理。另外一种是用注释符包裹的代码块,这些代码在被打上注释符号的时候就应该被删除。

过大的类

一个类做太多事情,维护了太多功能,可读性变差,性能也会下降。举个例子,订单相关的功能你放到一个类A里面,商品库存相关的也放在类A里面,积分相关的还放在类A里面……试想一下,乱七八糟的代码块都往一个类里面塞,还谈啥可读性。应该按单一职责,使用不同的类把代码划分开。

这些都是比较常见的代码“坏味道”,实际开发中当然还会存在其他的一些“坏味道”,比如代码混乱,逻辑不清晰,类关系错综复杂,当闻到这些不同的“坏味道”时,都应该尝试去解决掉,而不是放纵不管不顾。

大型重构

相对小型重构,大型重构需要考虑的事情比较多,需要定好节奏,按部就班的执行,因为在大型重构中,情况多变。

将大象装进冰箱的步骤一般可以分成三步:1)把冰箱门打开(事前);2)把大象推进去(事中);3)把冰箱门关上(事后)。日常所有的事情都可以采用三步法进行解决,重构也不例外。

事前

事前准备作为重构的第一步,这一部分涉及到的事情比较杂,也是最重要的,如果之前准备不充分,很有可能导致在事中执行或重构上线后产生的结果和预期不一致的现象。

在这个阶段大致可分为三步:

  • 明确重构的内容、目的以及方向、目标

在这一步里面,最重要的是把方向明确清楚,而且这个方向是经得起大家的质疑,能够至少满足未来三到五年的方向。另外一个就是这次重构的目标,由于技术限制、历史包袱等原因,这个目标可能不是最终的目标,那么需要明确最终目标是怎么样的,从这次重构的这个目标到最终的目标还有哪些事情要做,最好都能够明确下来。

  • 整理数据

这一步需要对涉及重构部分的现有业务、架构进行梳理,明确重构的内容在系统的哪个服务层级、属于哪个业务模块,依赖方和被依赖方有哪些,有哪些业务场景,每个场景的数据输入输出是怎样的。这个阶段就会有产出物了,一般会沉淀项目部署、业务架构、技术架构、服务上下游依赖、强弱依赖、项目内部服务分层模型、内容功能依赖模型、输入输出数据流等相关的设计图和文档。

  • 项目立项

项目立项一般是通过会议进行,对所有参与重构的部门或小组进行重构工作的宣讲,周知大概的时间计划表(粗略的大致时间),明确各组主要负责的人。另外还需要周知重构涉及到哪些业务和场景、大概的重构方式、业务影响可能有哪些,难点及可能在哪些步骤出现瓶颈。

事中

事中执行这一步骤的事情和任务相对来说比较繁重一些,时间付出相对比较多。

  • 架构设计与评审

架构设计评审主要是对标准的业务架构、技术架构、数据架构进行设计与评审。通过评审去发现架构和业务上的问题,这个评审一般是团队内评审,如果在一次评审后,发现架构设计并不能被确定,那就需要再调整,直到团队内对方案架构设计都达成一致,才可以进行下一步,评审结果也需要在评审通过后进行邮件周知参与人。

该阶段产出物:重构后的服务部署、系统架构、业务架构、标准数据流、服务分层模式、功能模块UML图等。

  • 详细落地设计方案与评审

这个落地的设计方案是事中执行最重要的一个方案,关系到后面的研发编码、自测与联调、依赖方对接、QA测试、线下发布与实施预案、线上发布与实施预案、具体工作量、难度、工作瓶颈等。这个详细落地方案需要深入到整个研发、线下测试、上线过程、灰度场景细节处包括AB灰度程序、AB验证程序。

在方案设计中最重要的一环是AB验证程序和AB验证开关,这是评估和检验我们是否重构完成的标准依据。一般的AB验证程序大致如下:

在数据入口处,使用相同的数据,分别向新老流程都发起处理请求。处理结束之后,将处理结果分别打印到日志中。最后通过离线程序比较新老流程处理的结果是否一致。遵循的原则就是在相同入参的情况下,响应的结果也应该一致。

在AB程序中,会涉及到两个开关。灰度开关(只有它开启了,请求才会被发送到新的流程中进行代码执行)。执行开关(如果新流程中涉及到写操作,这里需要用开关控制在新流程写还是在老流程中写)。转发之前需要将灰度开关和执行开关(一般配置到配置中心,能随时调整)写入到线程上下文中,以免出现在修改配置中心开关时,多处获取开关结果不一致。

  • 代码的编写、测试、线下实施

这一步就是按照详细设计的方案,进行编码、单测、联调、功能测试、业务测试、QA测试。通过后,在线下模拟上线流程和线上开关实施过程,校验AB程序,检查是否符合预期,新流程代码覆盖度是否达到上线要求。如果线下数据样本比较少,不能覆盖全部场景,需要通过构造流量覆盖所有的场景,保证所有的场景都能符合预期。当线下覆盖度达到预期,并且AB验证程序没有校验出任何异常时,才能执行上线操作。

事后

这个阶段需要在线上按照线下模拟的实施流程进行线上实施,分为上线、放量、修复、下线老逻辑、复盘这样几个阶段。其中最重要最耗费精力的就是放量流程了。

  • 灰度开关流程

逐步放量到新的流程中进行观察,可以按照1%、5%、10%、20%、40%、80%、100%的进度进行放量,让新流程逐步的进行代码逻辑覆盖,注意这个阶段不会打开真实执行写操作的开关。当新流程逻辑覆盖度达到要求、并且AB验证的结果都符合预期后,才可以逐步打开执行写操作开关,进行真实业务的执行操作。

  • 业务执行开关流程

在灰度新流程的过程中符合预期后,可以逐步打开业务执行写操作开关流程,仍然可以按照一定的比例进行逐步放量,打开写操作后,只有新逻辑执行写操作,老逻辑将关闭写操作。这个阶段需要观察线上错误、指标异常、用户反馈等问题,确保新流程没有任何问题。

放量工作结束后,在稳定一定版本后,就可以将老逻辑和AB验证程序进行下线,重构工作结束。如果有条件可以开一个重构复盘会,检查每个参与方是否都达到了重构要求的标准,复盘重构期间遇到的问题、以及解决方案是什么样的,沉淀方法论避免后续的工作出现类似的问题。

5. 总结

代码技巧

  • 写代码的时候遵循一些基本原则,比如单一原则、依赖接口/抽象而不是依赖具体实现。
  • 严格遵循编码规范、特殊注释使用 TODO、FIXME、XXX 进行注释。
  • 单元测试、功能测试、接口测试、集成测试是写代码必不可少的工具。
  • 我们是代码的作者,后人是代码的读者。写代码要时刻审视,做前人栽树后人乘凉、不做前人挖坑后人陪葬的事情。
  • 不做破窗效应的第一人,不要觉得现在代码已经很烂了,没有必要再改,直接继续堆代码。如果是这样,总有一天自己会被别人的代码恶心到,“出来混迟早是要还的”。

重构技巧

  • 从上至下,由外到内进行建模分析,理清各种关系,是重构的重中之重。
  • 提炼类,复用函数,下沉核心能力,让模块职责清晰明了。
  • 依赖接口优于依赖抽象,依赖抽象优于依赖实现,类关系能用组合就不要继承。
  • 类、接口、抽象接口设计时考虑范围限定符,哪些可以重写、哪些不能重写,泛型限定是否准确。
  • 大型重构做好各种设计和计划,线下模拟好各种场景,上线一定需要AB验证程序,能够随时进行新老切换。

Guess you like

Origin juejin.im/post/6985419328145489927