核心收获: 不要上来就重构,第一次不管了,实现功能就行,第二次也可以不管,第三次再去考虑重构 refactor和feature要分开上库 重构架构设计不要想太远了

重构是什么以及为什么?

  1. 重构是在不改变软件可观测行为的前提下,调整代码结构,以提高软件的可理解性降低变更成本
  2. 重构是一种经济行为,而非道德行为。如果它不能让我们更快、更好地开发,那就没有意义。
  3. 代码的写法应该使别人理解它所需的时间最小化,进而使变更代码所需的时间也最小化。
  4. 重构对个体程序员的意义是提高 ROI。
    1. 更快定位问题,节省调试时间。
    2. 最小化变更风险,提高代码质量,减少修复事故的时间。
    3. 获得程序员同行的认可,带来更好的发展机会。
  5. 重构对整个研发团队的意义是提升战斗力。

重构的原则

  1. 重构的目标:提高迭代效率
  2. 获得同行认可的方法:每一次提交代码,都应该使代码变得更好,先重构,再开发
  3. 增量式重构 = 自动化测试 + 持续集成 + TDD 驱动重构。

代码 / 架构的坏味道

所谓优雅的代码,就是易于理解、可扩展性良好。

24 种代码的坏味道和例子

以下是 UP 主的 6 类分类,源于上述 24 种坏味道。

依赖传递

交互产生依赖,多模块之间的影响被交叉放大。

系统困境与软件复杂度,为什么我们的系统会如此复杂

变更放大(涉及的子类太多了,需要全部测试和重新上线)

修改点被交叉放大,一次迭代需要修改 N 个位置。每一处修改点都可能带来调试、编译、构建、测试、上线等环节,致使效率低下,操作变得复杂,容易遗漏或失误。

func packVideo(longVideo, sortVideo) *videoInfo {
    ...
    // logic
    ...
}

关注放大(虽然只需要看 2 行代码,可能需要看上下文几百行代码)

关注点被交叉放大。为了完成一次迭代任务,需要通读修改点附近上下文的代码;而由于依赖被继续传递,附近的代码又会牵扯出更多需要关注的代码。通常,为了弄清楚修改点的影响范围,需要理解超出此次迭代任务 10 倍以上的代码量。需要关注的上下文过多,会造成心智负担,也会让测试、调试、上线极易出错。

最终,演变成未知的未知(变更后不知道是否会出问题的状态)。

// query: 1231232|23423|sdfs|342|dsfsdf|sdfsdfd
func ParseData(dataArray []string) string {
   ....
   if len(dataArray) >= 10 {
       arrLen := len(dataArray)
       deviceID, _ := strconv.ParseInt(dataArray[5], 10, 64)
       userID, _ := strconv.ParseInt(dataArray[arrLen-6], 10, 64)
       itemID, _ := strconv.ParseInt(dataArray[arrLen-5], 10, 64)
       xxxID, _ := strconv.ParseInt(dataArray[arrLen-4], 10, 64)
       xxxID := dataArray[arrLen-3]
       from := dataArray[arrLen-2]
       if len(dataArray) >= 18 {
          a, _ := strconv.ParseInt(dataArray[arrLen-7], 10, 64)
          b = groupIdTmp
          c, _ := strconv.ParseInt(dataArray[arrLen-6], 10, 64)
          from = dataArray[arrLen-3]
       }
   }
   ....
}

神秘命名

代码和注释本质上都是符号的集合。如果这些符号不能被人快速理解,或者因为信息冗余、无效而增加了阅读负担,就会降低可理解性。

好的命名应该有三种境界:信、达、雅。

  • :准确无误地表达清楚行为的意义,做到见名知意
  • :考虑命名对整体架构的影响,与架构的设计哲学风格统一
  • :生动形象,看到名字即可准确理解其在整个程序中的作用,并能产生辅助理解的形象。

Bad casehttps://github.com/dgraph-io/dgraph

过度设计(没必要想太长久,只需要考虑一年;或者遇到同样的问题三次,再去设计。中庸之道。)

当过分考虑程序未来所要面对的需求时,就会陷入过度设计的陷阱。为了未来用不上的能力,而使当下的程序变得复杂。

设计变得复杂,是因为考虑了过多的设计约束,而这些约束很可能是现在和未来都不需要的。错把这些约束条件当作目的,就会使目标被放大,最终设计出没有解决实际问题的系统。

过分放大未来的某些风险,而这些风险发生的概率又过低,在项目可见的生命周期内都不太可能遇到,因此也没必要为此进行设计。

Bad case:对于一个 50w DAU 的 IM 系统,考虑过多的未来因素。

结构泥团

对于核心的数据结构,如果没有规范化的设计,就会导致混乱。

艰难引用

未充分考虑数据结构的读取场景,导致在需要使用某些数据时无法简单地获得其引用;或者为了使用某个字段,需要了解一堆中间封装的数据结构。

a.b.c.d.e()

全局盲区

大型项目开发中,由于大家缺乏全局视角,对数据结构或者接口的设计不可避免地会造成冗余或混乱。接口与结构的设计充满局部最优解,但从项目整体上看却成为一团泥球。

什么时候需要重构?

  1. Code review:在给别人做 code review 时嗅出坏味道,并在不失礼貌的前提下提出建议。
  2. 每次 commit 代码时:每一次经你之手提交的代码,都应该比之前更加干净。
  3. 当你接手一个异常难读的项目时:说服项目组将重构作为一项需求任务来做。
  4. 当迭代效率低于预期时:将重构当作一个专项任务来做,必要的时候停下来迭代需求。

重构的手段

一种自底向上的演进方法

名称目的场景做法
提炼变量让表达式更加可读当存在难以阅读的表达式时(逻辑 / 计算)1. 划分子表达式
2. 使用有意义的名称命名子表达式
提炼函数将意图与实现分离1. 有大量重复的逻辑段时
2. 如果需要浏览一段代码才能理解其在做什么时
1. 识别变量依赖
2. 给函数命名
3. 确定函数体的存放位置
4. 构造参数,在目标位置进行调用
5. 编译、测试
封装类型高内聚,低耦合1. 大量重复的相同 / 相似变量被同时传递
2. 存在大量参数与逻辑无需关心
1. 从函数的变量和行为上发现相关性
2. 从高度相关的函数中抽象出一个概念
3. 将概念具象化为一种类型 / 对象
4. 为其设计合理的名称以及生命周期
模块化逻辑划分,代码复用当一些类需要频繁配合完成一个独立功能时1. 关注点分离,抽象出模块 / 类库
2. 模块内部类相互紧密协作
3. 模块外部通过接口相互调用
4. 模块之间保持单一方向依赖
封装阶段 pipeline保持单一原则1. 一段函数中同时处理多种事情时
2. 多个 if / else 代码杂糅在一起时
3. 逻辑分支膨胀或循环冗余时
1. 抽象出多个任务阶段,并对阶段命名
2. 将阶段和处理的参数对象进行分离
3. 利用接口 / 多态特性替换 if / else 模式
委托模式分离变化与不变的逻辑被调用方会频繁变更时封装接口,通过依赖倒置隔离下游变更
服务化 / 多进程资源隔离(机器 / 人力)1. 当某一个接口需要大量状态与资源时
2. 为了提高运行时稳定性时
1. 注册一个新的 Git 仓库
2. 迁移代码,搭建 CI/CD 流程
配置化提高研发效率,减少重复需求1. 当存在大量反复出现的重复需求时
2. 业务需求频繁,团队疲于应付时
抽象通用业务模型
领域化微服务划分,拆分核心领域当具有一组完整业务交付价值的功能需要复用时1. 提供完整的架构方案
2. 设计对外接口,提供 SDK
3. 完善接入流程
中台化研发效能复用当公司多个业务具有相同功能时1. 在系统化的基础上进行多租户化
2. 做好权限 / 资源管理
平台化自动化业务流程1. 当业务需求模式清晰,需要边界明确时
2. 当研发成本成为最大成本时
3. 当要规模化获客接入时
1. 抽象自动化流程 pipeline
2. 将研发介入的流程配置化
3. 将配置通过平台调度自动化
4. 做好权限 / 资源 / 计量 / 计费 / 数分
产品 / 开源化1. 向公司外部提供产品服务
2. 榨取研发资源的剩余价值
3. 产品复杂度过高,超出内部研发能力
1. 技术资源不能创造更大价值时
2. 市场需求明确,规模化获客与续约不成问题时
3. 用户增长逻辑明确时
1. 提供对外 Open API
2. 提供产品化的角色 / 权限 / 资源管理能力
3. 恰到好处地处理客户 / 用户使用问题

重构的基本步骤

代码分析

通读代码,分析现状,找到代码在各个层面的坏味道。

重构计划

重构应该永远是一种经济驱动的决定。

对坏味道进行宣讲,并向团队给出重构的理由,以及重构的计划。

确定重构的目标,明确的描述出重构后能达到的预期是什么。

重构计划中必须给出测试验证方案,保证重构前与重构后软件的行为一致。

如果没有这样的方案,那就必须先让软件具有可测试性。

如果无法得到团队的认可,那就偷偷进行,因为重构始终是对自己有利的(减少工作量以及获得同事的认可)

将重构任务当作项目来管理,对指定任务的人明确的排期和进度同步。

小步子策略

将重构任务拆分成每周都能见到一点效果的小任务

每一步重构都要具有收益,并且可测试,不能阻断当前需求的迭代。

重构任务必须被跟踪,要定期的开会同步进度,来不断加强团队的重构意识。

测试驱动

对于小型软件,需要先补充单元测试再进行重构。

对于大型软件,先搭建自动化测试流程,再进行重构。

对于复杂的不确定性业务,也可以使用ab test来验证重构对指标的影响,避免造成效果/广告的损失。

要保证测试的完备性与可复用性,尽可能的做到团队级的复用。

保证测试环境与生产环境的一致性也是测试驱动的重要环节。

提交规范

每次提交尽量控制在2分钟可以给code review的同事讲明白的程度

重构应该被当作一次专门的commit中完成,在commit中写清楚改动点&测试点

提交规范有助于定位bug,也是代码可读性的一个重要环节

自动化测试

构建可测试的软件,首先要构建可测试的环境。

对于简单应用软件可以使用单元测试,mock数据进行测试,并与ci/cd流程集成。

对于复杂应用软件可以采样收集线上真实用户行为日志,mock数据周期性巡检测试。

对于幂等性业务,可以mock user进行全方位的端到端自动化巡检测试。

每一次功能的提交应该对应一套完整的自动化测试的策略脚本以及&监控指标与报警规则

调试BUG

  1. 亲自复现问题,关注第一现场,确定是必现还是偶现?
  2. 区分是人的问题还是环境的问题?
  3. 如果是人的问题,那是配置参数的问题还是代码逻辑的问题?
  4. 如果是配置参数的问题,则通过对比正常运行的配置参数发现问题
  5. 如果是代码逻辑的问题,则通过cimmit的历史二分查找缩小出现问题的逻辑范围
  6. 如果是机器的问题,确定是单机问题还是集群问题。
  7. 如果是单机问题,则替换机器,如果是集群问题则考虑升级硬件设备。

高质量上线

每次一次上线都必须具有上线计划,发布上线单可追溯可排查问题,关注上线前和上线后指标变化。

上线单写明: 改动点,风险点,止损方案,变更代码,相关负责上下游人员。

一些实际的问题

代码所有权

代码仓库的所有权会阻碍重构,调用方难以重构被调用方的代码(接口),进而导致自身重构的受阻,使得效率降低,为提高开发的效能,允许代码仓库在内部开源化,其他团队的工程师可以通过pr自己来实现代码,并提交给仓库的onwer,来code review即可。

没有时间重构

这是重构所面临最多的借口,是自己也是团队的借口。 为此必须要明确重构是经济行为而不是一种道德行为,重构使得开发效率变得更高,因此仅对必要的代码进行重构,某个工作行为如果重复三次就可以认为未来也会存在重复,因此通过重构使得下次工作更加高效,这是一种务实的作法,而重构不一定是需要大规模的展开的任务,重构应该是不断持续进行的,将任务拆解为多个具有完备性的任务,每周完成一个,每个任务的上线都不会引起问题,并使项目变得更好,这是一种持续重构的精神态度,是高效能程序员最应该具有的工作习惯。

如果你在给项目添加新的特性,发现当前的代码不能高效的完成这个任务,并且同样的任务出现三次以上,那么这时你应该先重构,再开发新特性。

重构导致bug

历史遗留的代码实在太多,难以阅读理解,如果无法理解谁也不敢轻易重构,害怕招致bug引起线上事故,因此在重构之前必须有一套相对完备的测试流程,他能给予程序员信心,也是重构的开始,反过来想对于谁也不愿意重构的代码进行重构,将收益巨大(这个项目还会继续迭代时)