客户端重构场景分析

原文链接 https://blog.csdn.net/ByteDanceTech/article/details/117970609

[原]致敬《重构》:客户端重构场景分析

字节跳动技术团队官方博客 · 2021-06-16 18:00阅读原文

《重构:改善既有代码的设计》是计算机领域的一本经典之作,本书清晰揭示了重构的过程,解释了重构的原理和最佳实践方式,并给出了何时以及何地应该开始挖掘代码以求改善。

距离该书首次出版已经过去了二十余年,这二十多年里行业发生了很多变化,但有一件事的必要性并没有随着技术的进步,工具的升级,方法论的扩展而被消灭:

这就是重构本身。

因为在大部分工程师的职业经历中,能够从头开始搭建一个项目,并且在时间充裕、需求明确的条件下,用正常节奏设计开发完整系统的过程只是一个美好的理想——更多时候是在接手的现有系统/模块中如何平衡好代码质量和需求迭代

本文的重点不是为了说明重构的细节,而是从工程角度,在客户端场景下给提出一些关于重构的建议;如果大家关心重构方法论本身,直接去阅读《重构》就是最好的解答。

首先,为什么要强调客户端?客户端和其他场景有什么区别?

我们简单和后台研发模式做个对比:

  • 一般意义上的后端研发都是基于 SOA 思想的,通常一个子系统 3 个人一起维护,就已经是很充分的人力了,更多时候是 1 个主力 + 1 个 backup 的人力配置;
  • 而客户端则不同,一个公司战略级的 app,多则一个端 50+人,少则十来个人也是正常配备。

但客户端有个特征:

除非做了非常完善容器化,否则大家就得一起开发一些东西。

而即使做了容器化,很多状态的共享是不可回避的,比如:

  • A 模块的 zombie,就足够造成 B/C/D 任何一个模块的 crash;
  • A 模块的线程设计不合理很容易把接收回调的 B 模块变得不可靠。

这一点和基于 SOA 思路设计的后台服务中,一般故障都局限在某个服务内部的特点有非常大的不同, 所以:

客户端一个模块被写烂了,相比一个基于 MQ/RPC 相互连接的后台模块或子系统,会对整个项目造成更大危害。

(这里只是就代码形式而言——不是说后台就简单了,后端复杂的场景往往在别的方面,会有类似架构设计是否支持平行扩展,故障如何隔离,服务怎么降级等其他问题)

那么什么情况下必须立即重构,什么情况下可以推迟重构?

其实没有标准答案,但这就是造成问题的原因。

很多时候,研发会因为各种原因,推迟重构,直到这件事情被一拖再拖,从重要而不紧急的事情变成了既重要也紧急的事情

下面我会从之前的一些项目经验中给出一些建议,供大家参考。

重构的建议

要提前计划重构,不要赶时间

大部分重构都是代码债务的清偿的过程,赶时间容易积累新的债务。

  • 紧急情况下做短期方案,但是必须比现在的状况更优

比如:费了很大劲才读懂了一个长函数,如果本期没时间优化,至少可以分解下这个大方法,写出更多命名准确的子方法,在函数体内部调用,提升可读性。

而对于一个突发的重构需求,其实已经错过了解决问题的最佳时机。

小步稳跑,尽可能保证肉眼可见的正确性

大多数时候,我们很难找到整块时间重构,这往往意味着你可能会相对分散地修改到正常需求开发不会触碰的逻辑,这种情形对于 QA 同学来说是非常容易发生误判的——即使和对方同步过,但双方仍然可能对问题的影响面判断不一致。

如果某些修改导致了用户可感知的 bug,那么这个时候往往容易因为面临各方压力,而停止重构过程。

什么叫 肉眼可见的正确性? 概念很简单,就是一些相对可靠的推论而已,比如:我改了一个变量类型,只要我足够认真,同时确认过编译器所有警告——那么一个有经验的程序员是可以保证这个修改风险可控的

不同程度的工程师,能看出不同深度的问题,这一点需要执行重构的人,对自己的段位有客观的评价,不要贪图效率

如果我没有完全的把握确保这里的线程使用是正确的,此时应该通过认真自测 + 和 QA 同学详细同步影响面来确保安全,并且最好将自己怀疑会出问题的情形和对方指明。

多做系统性方案,考虑越周到越好

之所以会重构,有很大一部分原因是因为补丁方案已经快要 hold 不住了,这个时候再次使用小规模,单点方案,大概率会退化成另一种补丁

时间紧的情况下,想 100%,每次落地 x%, 保持方向正确,一步到位不了,两步,三步……

坚持做,代码会累积的你的努力。

在复杂度面前,任何可以累积的努力,都最终会变得有效。

不过,是否这个假设过于乐观了,有累积不了的情况吗?有的,看下面。

单兵收敛重点

有些复杂的事情必须要有单点管控,人越多越乱:

因为很多重构必须要有全局观,才能抽象出比较符合项目实际情况的方案。

如果发现单兵速度跟不上团队其他人驱动的工程演进速度,这一点需要认真想办法解决,否则就是浪费资源,原因例如:

  • 单兵修改和团队修改有重叠:人力有限的时候做组件化开荒很容易遇到这种情况;
  • 单兵无法控制在较短时间内完成修改,完成之后和 team 合码遇到大量冲突:当重构涉及到一些高速开发中的大型模块会碰到。

可能的场景:

  • 组件化:接口打沉,代码规范
  • 代码逻辑收敛:大量散装代码的封箱
  • 不易切分的大块修改:复杂模块线程模型更新

这可能是重构里面最棘手情况了,解法需要看实际情况来决定,没有定式,我举个例子说明:

场景分析:组件化

关键点:

  • 组件化做的越晚,累积的工作量越大;
  • 组件化如果改动工程结构,势必和新特性迭代产生冲突;
  • 模块需要移动位置,也可能需要改名,适应业务 pod 的命名;
  • 模块内接口需要抽离,也需要引用别的模块的接口;
  • 假设人力有限,需要单兵推进同时确保团队正常功能迭代。

问题: 模块移动 + 模块本身修改 + 改名 如果导致冲突,此冲突 不可解

分析: 此时应该考虑的点是如何锚定修改的 可累积性:

让冲突可解,以及在这个基础上,把握好提交节奏,不要影响正常的功能研发。

  • 列出所有需要挪移的文件,分批做挪移,逐步提交
  • 挪移完毕之后,分批抽接口,逐步提交

只看步骤,很容易知道,这个过程是肉眼可确认正确性的,那么在把握好提交节奏+做好沟通的前提下,不容易产生重大风险。

可以选择纯手工做,但是对于人肉来说,往往非常枯燥,而人天生讨厌枯燥的事情,后面我们会专门讲讲利用工具的思路。

把握节奏

重构往往影响比较大,需要综合考虑和 QA 同学的协作。

我们先从时间维度看看:

img

上面是重构提交时机的风险分析,中间的圆表示重构合入后 bug 泄漏到用户侧的风险。实际项目如果重构没有让 QA 知道,属于违规操作。

这里的基本论点是:

  • 没提测时越早越好——会有更多人使用你重构过的代码,有问题一般有充分的处理时间
  • 提测后要谨慎——QA 同学的时间也不是无止尽的,如果你的重构有些没有估计到的影响此时容易造成 bug 泄漏
  • 临近发布不合重构——淹死的都是会游泳的,对于重构这件事来说,没有必要在这个时间点做

另外,在不同情况下重构落地的步骤也不尽相同,下面简单分析一下。

稳定的老模块

这种情况下,重构会给 QA 带来额外的回归成本,需要提前确认好资源:

  • 渐进式落地:每个修改应该在研发的角度具备肉眼可确认的正确性

  • 利用好提交窗口:每个 sprint 的开始的时候,提交重构成果,让 QA 有充分的时间回归

  • 一开始就要明确一个重构有没有可能在 sprint 之内落地

    • 如果没有,需要做好比较具体的落地步骤拆解
    • 很多落地步骤之间有依赖,需要自底向上推进

这里说的比较抽象,举个例子:

我们可以把画架构图/抽离接口/模块内代码修改分散到多个 sprint 中去,最后再加一个 sprint 的工时来做点宏观修正。

改版类需求

一般的改版都意味着好的重构窗口已经出现。

因为往往这种需求会需要 QA 比较细致的回归,此时重构可以一定程度上节约 QA 的资源。

如果该模块有重构的必要,需要做的就是在比较早期的时候,将这部分成本计算在内。

重构的工具

重构的时候大概率会遇到巨大而繁冗的工作量,这也是阻止很多重构被落地的重要原因,下面从个人经验出发,介绍一下可能的工具。

首先,我们需要明确一点,工具的使用必须是在能保证正确性的前提下,使用场景可能有:

  • 模块重命名
  • 文件改名
  • import 语句整理
  • 模块替换
  • ……

这里最想讲的就是正则,基本所有工具都支持,无论是替换文本还是用来发现问题都是非常好用的,比如:

img

上面这个例子,用来将所有 XX 开头的模块替换为 YY 开头的模块,$1 对应正则的第一个分组,replace 的输入框里都是纯文本,不用想的太复杂

值得说明的是,这里的 Matching Case 可以用来控制正则是否大小写敏感,做大规模替换的时候,一定注意。

类似的工具还有 sed 比如:

sed -r -i 's/XX(.*?)/YY\1/' *

差别只在于\1 表示第一个分组。

再如:

img

\s\S 这两个可以用来匹配换行符,这个例子里用来发现哪些 onFinally 回调中使用了 let,当然这里可能会因为括号嵌套导致有误判,但是对于一般的定位需求,是基本可以满足的

再提供一个文件批量重命名的 case:

find . -name ".swift" -print0 | rename -0 's/XX([a-zA-Z]?).swift/YY$1.swift/'

rename 是一个 perl 工具,mac 上可以通过很多方式安装;上面命令会把所有 XX 开头的文件改成 YY 开头的文件——这对于做了组件化的工程基本无影响,如果不是那可能还要修复一下工程文件引用

剩下就是一些相对复杂的逻辑了,当量级小的时候,可以简单点手动处理掉

但是当量级非常大的情况下,使用代码重构代码,就变成了一种可能值得实施的做法

这里不讲具体 case,主要看希望做什么,下面举一个 python 作为重构脚本的例子。

img

img

img

这里的大体思想是根据实际需求,建立必要的 SourceModel,根据实际对 SourceModel 进行 Transform,比较适合操作大量相对复杂的重构;

但是,有一点,大规模推进的时候,一定要:

  1. 考虑清楚
  2. 做好备份

很多时候代码逻辑是复杂的,就和一开始很多人写宏的时候会写出来:

#define MUL(x, y) x * y

这种考虑不周的形式一样——MUL1+2,3就会得出非预期的结果。

脚本操作很容易遇到代码情况和预期不符,最终做了一些 有问题 的大规模修改。

这个时候如果正确修改和错误修改混在一起,除了推倒重来,基本无解;推荐的做法是:

每次做一种修改,验证正确之后提交——遇到改错的情况使用 git reset。

代码重构代码的方式很多,之前也有些情况使用 Grunt 来做。Grunt 的用途是打包,对文件处理有非常强的支持。

最后,可以考虑准备一套基础设施直接放在项目里,常常会发现实际需要的时候会比一开始认为的多很多。

团队杠杆

在实际工作中,劣币逐良币是非常常见的:一旦烂代码成为普遍现象,写好的模块的成本就会变得非常高——这好比在浮沙筑高台。

这里的核心思路是:

把明确的道理做成好的示范,把良币做成榜样。

为了抓住重点,我们只分析最棘手的情况——整体架构出问题了。

首先,如何判别这种情况已经出现?最明确的信号——两个以上核心业务模块出现了牵一发动全身的情况。这种时候就需要将重构的优先级提升了,否则情况容易随着迭代每况愈下。

这里涉及到两个关键问题:

  • 治理已经比较乱的模块
  • 保持重构的结果不滑坡

说实话,我个人觉得第一个问题的解决难度远远低于第二个问题。

从治理的角度来说,一个足够有经验的研发,如果被给予充裕的处理时间,往往是能够比较好的完成任务的(特别是如果这个模块只是因为项目节奏太快导致没有时间完成架构演进的情况下)

但是这里仍然有几个关键因素可能会影响最终结果,按重要性:

  • 人——这个人必须足够了解过去的需求,以及用来填坑的代码,需要确保重构完的代码必须能够等效达到之前的体验。

不要小看填坑的代码,这是一个原设计无法完全 cover 体验要求的信号,在新设计中,如果不考虑这一点,很容易变成原设计的另一个版本,而不是从更高的设计维度来解决问题。

(这也是有人将工程设计问题称为“险恶问题”WickedProblem的一个原因,你不解决它,你不知道你一定能解决它)

  • 时间——如果要项目停下来等重构完成,是一个过于奢侈的要求。

之前提到过,时间不足设计会变形,就和球场上体力不够的情况下,再优秀的球员动作也会变形一样,下面部分会专门详细阐述这一块,这里先不展开。

  • 务实——重构容易过于理想化,比如为了代码的整洁,某些回调会被提前或者压后,类似的场景还有网络请求等。

对于调用方,这种变化可能会造成体验上明显的不同,导致一些模块看起来很优雅,但是用起来比较脏的情况。

上面这些问题需要执行重构的人和使用方达成共识,才不会产生过于理想主义的问题;不过,这里没有标准答案,下面这条建议是很多即便很优秀的研发也容易弄反的东西。

如果模块内部设计优雅和模块外部使用简洁发生冲突的时候,优先保证模块外部使用简洁——对复杂度的封装既是模块本身存在的意义,也是代码治理的目的。


下面我们来讲讲如何确保重构的结果,这里的答案很简单,但是不同团队做起来难度不同——共识。

如果 100 个工程师对优秀有 100 个标准,那么哪怕他们都是出色的工程师,一起合作出来的东西仍然可能是一团乱麻。

不过,这里讨论“优秀”有些主观,coding 是一个非常复杂,非常广阔的领域——每个人的认知中哪怕是对同一个功能的,可以称得上“优秀”的代码写法的认知也是非常不同的。

所以,我们需要换一个角度看待问题,除了“优秀”之外,很多维度都可以来度量代码的设计,其中最重要的参考是“复杂度”的控制,借用《代码大全》的一个观点——所有软件工程手段的最终目的都是为了控制复杂度。

那么回到主题,重构成果的本质是复杂度的降低,或者对于复杂度可控性的提升;在这个基础上,根据团队状况会有所不同:

  • 团队是非常依赖梯队的——有牛人牛到天上了,带着大部分相对普通的工程师(这种团队一般出现在创业公司)
  • 团队属于正规军甚至是特种部队——大家综合能力都很强,能力差异相对小(这种团队多见于已成规模的平台)

对于第一种团队,可能好的实践是,用大家都能理解的方式,确保团队里短板的部分也能基本 hold 住复杂度;

对于第二种团队,或许业界最前沿而有效的做法,就是大家认为好的方式——因为很多人会时刻跟踪业界变化,对这些熟稔于心。


最后一个建议是随手除草:

  • 拼写错误
  • 散装属性
  • 接口命名模糊
  • ……

共性是这些东西都是一些可改可不改的东西,但是改起来成本又比较可控 。

代码每天被阅读的次数往往超过你的认知,这种做法具有对团队的引导效果——投入几个热键修改代码的成本,产出大家渐渐共识的过程。

此时【果断要改】,这是值得一个长期做的事情。

看到公认的不良做法随手重构掉,做好必要的沟通或给相关人 code review。

久而久之,代码质量会慢慢提升,尤其对于一开始赶工比较严重的项目,往往有不错的效果。

通常这种场景做一些流程也很有用,比如 swift/oclint,sonar 等都可以考虑——只要有一个负责人和大家同步好哪些规则是大家可以接受的,以及它的好处是什么。

总的来说,要确保重构结果不滑坡,最需要的是团队需要共识方案的合理性——除了方案本身必须是自洽的之外,和团队同步设计考量并且形成共识是非常必须的

所以,团队内部多分享,多沟通,多讨论是非常重要的过程,并且如果可能要 留下文档。

设计文档的重要性可能是:

你花时间一次性的投入,然后在这个项目演进的几年时间里,它会被越来越多的阅读。

或许有些模块的实现形式会变迁,但是一个设计方向正确的文档,会持续帮助人们达成设计共识。

除了文档之外,在字节还可以建立一个 lark 讨论组,将一些架构问题抛在里面:

img

重构本身需要充分的讨论,为了这个事专门开会很多项目的节奏不允许,如果发在 lark 群里,容易被刷走,导致问题没有充分讨论。

话题组的好处就是节奏慢,信息集中,非常合适讨论一些重要不紧急的问题。

重构的方向

并不鼓励为了重构而重构,下面列出的点是我们考虑重构方案的时候,可以努力去兼顾的一些目标:

  • 更符合业务的轻量级框架
  • 更好的文件组织形式
  • 更好的代码接口
  • 更可预期的时序
  • 更稳定的线程模型
  • ……

重构的目的是为了让架构变得更加合理,但是架构变好和气候变好一样是一个缓慢而又不容易量化的过程,在效果的观测上,可以根据一些现象来判断是否在正确的轨道上。

  • 统一的观察点:比如可以在一个枚举中看到工程中所有相关状态
  • 更小的修改面:open / close 原则在大部分场景下得到了印证
  • 重复代码减少:比如 jscpd 这种工具可来量化这件事
  • 共识加速形成:通过私下/会上了解到的一些关于代码的讨论,直接/间接地说明大家设计思路更有一致性
  • ……

隐含收益

从人的角度来说,重构本身有一项非常重要,但是往往大家认知有限的收益:

增加团队中专家的密度。

因为没有任何一个过程能够比一个人通过设计,编码,调试来了解之前业务/技术细节的来的更有效率。

很多优秀的设计在基础代码质量存在缺陷的情况下,往往带来的是负收益。这是因为所有设计都是建立在合理解耦的基础上。

在解耦关系不佳的情况下,很多设计就必须迁就现有代码实现,从而模糊了设计思想,反而让其他人更加看不明白。

人对代码的了解程度的提升,加上代码结构的改善,会带来更低的 bug 量。

总结

最后分享一个博弈论问题:

如果两个敌对国家的特工理解情报的能力不一样,且需要根据情报互相博弈——A 可以 100%理解,B 只能理解 10%,那么在获取相同信息的前提下,什么情况下 B 可以获得高于 10%胜率?

答案很有意思:大量增加情报数量和复杂度,直到远远超出 A 的个人能力,这个时候 B 就有可能获得 50%的胜算

这个可能也是为什么很多非常优秀的团队仍然会陷在 bug 泥潭里的原因,系统的熵太大

正如热力学中所说的熵增一样,宇宙万物永远都在经历着从有序到混乱的过程,代码也不例外,但

生命不同,它是反熵的。

优秀工程师始终会通过各种手段来控制复杂度,从而赋予代码某种生命的特征,也许能从侧面印证一个说法——好的代码是活的。

这些信息有用吗?
Do you have any suggestions for improvement?

Thanks for your feedback!