Skip to content

代码的复用、防腐和职责划分

1.代码复用

常见“复用”场景

在系统设计和写代码的过程中,经常以「复用」为目的来设计技术,业务,组织架构。例如:

  • 抽象出一个类,函数,UI 组件,用于不同的场景

  • 抽象出一个微服务,越细越好,这样可以灵活的组合

  • 抽象出一个业务中台,沉淀各种基础业务功能,供全公司使用

  • 组织(管理)架构中,抽象出一个职能竖井(后端,前端,QA,财务),被不同的产品使用

  • 产品设计中要完成一个性能功能,发现跟之前一个功能相似,就复用之前的功能设计,改改继续用。

“复用”的原因分析

  • 主要目的:既然一部分功能已经存在,为什么还要自己造呢?直接拿来用,这样我可以做得更少,所以能够提升个人的生产效率。

  • 更少的代码也许有更优秀的加载性能

  • 某些情况更容易确定影响面、管理和维护代码(在一处更改而不是多处)

  • 接受的教育:一直以来的教育,大部分工程师都学习过 DRY principle;《设计模式》的的英文书名就是 《可复用面向对象软件的基础》,都在强调复用。

  • 编程习惯:在面向对象语言里,工程师习惯了继承,而继承对于大部分人来说的目的就是复用。

“复用”误区及其思考

复用重用以前写过的代码,我们就能写更少的代码。但写的代码越少,并不意味着我们就会越早完成系统。抽象和改造组件可能花更多的时间。

如果你要写得代码依赖很多其他的「复用」模块,你就要去理解不同的「复用」模块提供的接口,很多时候只看文档都不行;

如果「复用模块」的接口不是正好是适用你的场景,你就必须在讲究使用接口和对方排期之间进行选择。

此外对于如何抽象出一个公共业务模块,大部分人都没什么标准,公共业务模块的负责人成了业务的外包,效率更低。

另一点是任何维护生产环境系统的人都知道写代码只是整个工作中一小步,而且是确定性最高的一步,之后维护才是最占用时间的。我们需要不断地进行调试,部署,再调试,部署。一个用于很多依赖的模块相对依赖少的模块做这些工作的难度是高很多的。

复用不是系统要追求的目标。很多人认为更多模块的「复用」,就可以做到像乐高一样快速搭建系统,但是很多复用并不是乐高,而是器官移植,不仅不能提高效率,要不断面对各种各样的排异反应,降低了速度和稳定性。很多创业公司快速发展时系统腐化的主要问题就出现在了强行复用上。强行复用的案例很常见,例如:

上百个字段的数据表(例如订单表,数据表),不清楚哪个字段在哪个场景下有效

根据名词来设计系统,出现业务中台,例如订单中台,电商中台,各部门之间不断地扯皮

出来无数个小的微服务,然后搞个统一的业务编排调度层(所谓的 gateaway)来处理业务,可能还抽象 DSL;上线十分复杂,需要发布 N 各模块;调度模块是系统大单点,稳定性迭代速度都是问题。

大部分情况下系统的核心复杂度不会减少, 只是转移;不会因为所有代码一个人写,会比分成两个人写更快,分工是应该且必须的。但是请注意的是:

不要为了复用随意通用化,抽象化,复用不是目的。如果要通用化就拿Consistency 来判断是否是抽象合理的。

不要随意复用其他的模块,自治优先,用 Autonomy 衡量;如果要复用一定要确保共识和标准优先,形不成共识和标准就不用复用。

在业务层次是否要从多个业务模块中抽象出一个底层模块,可以通过多个业务模块的这部分公共逻辑部分是否要一起进行改变进行判断,如果不是需要一起变化的就不要抽象出公共模块。

根据个人经验如果你在要从各个业务模块进行抽象出一个模块保持一致,与在模块保持自治之间犹豫时,要毫不犹豫优先选择「自治」。

代码拆分什么结果是“好”的,如果拆分不当其症状是什么

但是要保持代码是“好”的状况很难。代码腐化似乎注定的

最初:没有谁是不想好好写的。都有一个宏伟的规划,这次一定 途中:Code Review 如同“堂吉诃德”一般,根本架不住大批量大批量的修改 放弃:躺平了,下次一定 如此循环往复。然而腐化了之后,是无法起死回生的。

两个评价标准自治性和一致性:

  • 自治性(Autonomy):受其他模块的影响程度,观测模块的接口是否稳定。

  • 一致性(Consistency):看我们是否在应该用一个模块的地方,都用了这个模块,也可以衡量是否过早,过度抽象了。

自治性(Autonomy)指标

面临问题:

  • 沟通多:做新需求很难,因为需要牵涉到很多的团队,要和大量的人去沟通才能把需求落地。
  • 需求做了就删不掉:一旦需求做进去了之后,即便愿意把这个功能下线也非常困难。遗留代码日积月累。

Autonomy 的愿景就是尽量解决以上问题,让拆分出来的代码更独立(更具有Autonomy)。

从而新需求需要的沟通可以更少,不需要的功能也可以比较容易被干掉。

会议时间

Autonomy 的问题是高沟通成本,那么是否可以直接度量整个沟通成本。例如参与会议的时间,这是一个可能的指标。

一个劳动者只有8个小时的符合劳动法的工作时间,如果有7个小时都在会议上,显然能够说明一些问题。

会议时间做为观察性的过程指标还是有一定意义,主要的作用是划个红线,超过了红线说明会议太多了。

会议时间这个指标的问题在于无法指导改进。因为开会多,可能仅仅是需求变多了。

“接口改动” / “实现改动” 比率

接口文件不一定只有 RPC,任何形式的跨 Git 仓库的依赖都算接口。

理想的情况下,应该尽可能少的改接口,而是主要去改实现,这样才能减少跨Git仓库的人员沟通。如果一个新需求,需要同时改动N个Git仓库。但是只要不需要改动接口(包括用 Map<string, any> 这样形式搞的隐式接口),仍然是理想的情况。虽然产品经理需要和多个团队沟通每个部分的需求是什么,但是开发团队之间的沟通仍然可以比较少。要每个新需求都只改动一个Git仓库由一个团队负责。

接口完全不修改,开发人员之间完全不沟通也是不可能的。我们要关注的是目前的业务逻辑拆分是不是合理,多个 Git 仓库之间的接口如果需要频繁调整,那么说明 Git 仓库是不是分得过多了,或者边界不是最佳的。要根据新的输入,不断去审视过去做过的拆分决策。而 “接口改动” / “实现改动” 比率可以量化目前业务逻辑拆分是否让每个 Git 仓库有多少 Autonomy。这个值越小,说明仅改动实现情况占比越高,自主性做得就越好。

极端情况下,我们可以不分 Git 仓库,从而让 “接口改动” / “实现改动” 比率比较好看。

这个也说明了分 Git 仓库的成本。把业务逻辑拆得越碎,必然会导致跨团队的沟通会上升。Git 仓库不是分得越多就越好,而是满足了团队的并发数就可以了。

这个指标的另外一个问题是日常性的文案修改会导致实现改动非常多。所以我们要以“Consistency”维度的指标去平衡。假设我们已经有了一种统一的文案配置机制。那么需要有一个“文案配置机制”接入率的指标。这样就可以避免日常性的例行修改破坏这个指标的真实性。

持久状态封装度

把变量的静态类型分为两类,持久状态,非持久状态。

持久状态封装度,度量的目的是考察这些变量多大程度是真正的“全局变量”。

持久状态封装度 = 可以引用到该类型的代码行数 / 总代码行数,该比值越小越好,如果为 1 则代表 100% 全局变量。

就是一个状态越能被全局引用,就越可能被全局滥用。

总是开会是表象,“接口改动” / “实现改动” 比率是原因,而持久状态封装度则是滋生问题的温床。

通过模块间依赖倒置,就是为了让持久状态能被隐藏起来无法被引用到(也就是封装)。

一致性(Consistency)指标

一致性指标是对“相乘组合关系”的Git仓库的额外要求,是为了防御常见的设计错误:

  • 过度抽象:强行把一堆不相关的东西拧巴到一起。所以引入了“必要参数占比”和“咨询量”这两个指标。
  • 过早抽象:没想清楚的情况下,根据局部的一两处重复就抽出一个可复用的东西。所以引入了“使用次数”,“使用率”和“阻断率”这三个指标。

Git 仓库的关系

可以把 Git 仓库分为“相加的关系”和“相乘的关系”。

1

  • 相加的关系

线之上的 Git 仓库定位就是一次性的,通过“相加”组合满足特定的业务需求。 通俗而言我们会称之为为“业务”。 对于业务我们仅仅要求 Autonomy 和 Feedback 的指标。

  • 相乘的关系

线之下的 Git 仓库定位是“保障整体的一致性”,是“相乘”的组合关系。 通俗而言我们会称之为“基础设施”。 除了要求 Autonomy 和 Feedback,还额外增加了 Consistency 的度量指标。

这些 Consistency 指标的主要目的就是防止过度抽象,什么都想“可复用”,导致 Autonomy 受损。 可以说这些 Consistency 指标实际上是度量了“可复用性的质量”。

实际上这些“基础设施”,与编程语言,操作系统,物理硬件能有多少区别呢? 区别仅仅是“开源社区共识”,还是“公司部门范围内的共识”罢了。

有野心一点的公司,或者业务模式内在一致性比较高的公司,可以发明自己的领域特定语言(Domain Specific Language)。 只要这个发明出来的 DSL 不是只适用于一两个地方,能够通过 Consistency 指标的检验,那就是有价值的。

必要参数占比

把可复用Git仓库对外提供的函数参数分为两类,必要参数和非必要参数。非必要参数的计算口径是只有 10% 的调用方传递了的参数。

指标为必要参数的数量占总参数数量的占比。

为了抽取出可复用的模块是不是做得过度了? 是不是把一些小众场景的具体业务也以额外参数的方式 到可复用Git仓库里了?

每一个额外参数都增加了调用者的负担,是额外的学习和维护成本。

咨询量

但是为了保证 Consistency,可复用的 Git 仓库,应该努力降低使用者的成本。 使用者的最大成本来自于沟通问询。如果文档不清楚,接入开通方式是手工的,必然会体现在咨询量上。在不同的团队里,咨询量的体现方式各有不同。总之这个指标就是越接近零越好。

接入次数

使用次数只有1次或者2次,就不应该被抽取成可复用的Git仓库。至少要被使用3次。

接入率

每个可复用的Git仓库,要定义清楚自己的适用范围。在适用范围内需要度量接入率。如果说不清楚啥情况该复用,啥情况不该复用的东西,就应该当成一次性的业务逻辑,不要让其他Git仓库对其产生依赖关系。 接入率基于代码扫描自动计算,可接入的通过 pattern match 算得,已接入的直接看代码符号的引用关系。

阻断率

阻断率指所有可接入的地方,有多少处上了强制检查,确保了违规行为会被阻断。

强制要求所有的 Git 仓库都接入同样的标准库可以保证互操作的低摩擦且有标准可排查。

当我们使用了 Java 这样的编程语言的时候,Java 会阻断你在代码中使用汇编语言直接操纵 CPU。这是比较典型的“阻断”。 阻断是最强有力地保障一致性的措施。

2.代码防腐

信息隐藏

通过信息隐藏,我们把代码分为“可以随便写的”和“不可以随便写的”两部分。从而控制代码腐化的蔓延。

例如在class中,通过只暴露 Public 成员,我们就把 Private 和 Protected 成员给隐藏起来了。

Class 有封装和依赖关系。Git 仓库也有封装和依赖关系。

  • 应当禁止主板Git仓库反向依赖插件Git仓库

  • 应当禁止插件Git仓库之间互相依赖

大量的新人不断地涌入这个行业。如果指望一个项目上所有的人都能遵守很高的标准,能够独立对“好”和“坏”的设计做正确的判断,这是不现实的。并不是说软件工程教育不重要,教育仍然是要做的。但是仅仅靠教育来提高软件工程的质量是不现实的。

“信息隐藏”的做法,其实质是为了“代码防腐”。在这样的依赖关系下,插件的 Git 仓库是不会造成全局的影响的。这样我们就可以放心的把新人分配去写一个独立的插件,而不用担心其设计选择造成大面积的代码腐化。Code Review 仅需要集中关照主板。并且评价“高内聚低耦合”的标准也可以用主板的代码行数进行量化(在完成需求的前提下,主板的代码行数越少就是越好)。

信息(细节)隐藏的目的

  • 提高信噪比:当我们读一段的代码时候,更大概率快速获得自己想要的信息,而不是被无关的噪音淹没

  • 提高可移植性:例如我们为微信小程序开发的代码,应该能移植到字节小程序上去,而不是和具体的平台完全绑死

  • 装配式低代码开发:如果能限定需求的多样性,发明领域特定语言(DSL)来装配“预制件”,从而比“现浇混凝土”实现常规需求更快

持续改进

我们不需要提前设计,但是要基于共识进行分工。

共识应该包含两部分内容:

  • 我们总是要做出一个“如何分工”的决定,才能开始工作。没有完美的决定,Autonomy/Consistency/Feedback 三个方面总是有被取舍掉的方面。但是我们要对“取舍了什么”达成共识。

  • 敏捷开发需要我们持续地调整,持续地去响应变化。“如何保持敏捷”同样需要计划,需要达成共识。要有日常反思的计划。什么症状应该触发大家去调整业务逻辑的拆分?这些症状能不能被量化?能不能排上议程?

关于反馈性(Feedback)指标

Feedback 的愿景是尽量减少获得反馈的摩檫力。

业务逻辑拆分为什么会影响到 Feedback 呢?这仍然是要归结为人的沟通效率问题。能用标准化的方式解决的,就可以减少沟通。

能尽量减少信息传递次数的,就可以有效减少传递过程中的信息衰减。

微服务的起源是 devops 运动。鼓励 dev 拥有自己的线上服务,dev 自己来做 ops 的工作从而减少沟通成本。

当一个 7*24 的互联网高并发应用稳定性成问题的时候,最合理的做法是什么?据 Google SRE 统计,线上70%的故障都是由某种变更而触发的。 控制变更的速度,尽量延长灰度发布的时间是最重要的事情。 如果变更的粒度只有进程,而进程又只有一个,势必上线的队列会过长。 此时拆分微服务就是延长灰度发布时间最有效的手段。 同时让每个 dev 直接负责线上服务的稳定性告警,可以极大加快故障的定位和处置速度。

很多过度使用微服务的分布式系统无一例外地遇到了严重的 Feedback 问题。

怎么分技术的进程,应该取决于业务部门的组织架构。 但是业务部门的组织架构也是三天两头调整的。就是技术部门想要跟着调,业务部门每调整一次,集群就完全重部署一次SRE也不会同意的。

如果能够做好 函数边界(FunctionBoundary),插件边界(PluginBoundary),多租户(MultiTenancy) 以及 多变体(MultiVariant),怎么分进程根本就不是一个问题。 所有的代码都跑在一个进程内,一样可以把 Feedback 做得很好。

那微服务的拆分,其实就等价于Git仓库的拆分,怎么拆团队拆Git仓库的问题。 其主旨原则就一条:拆分之后,接口的定义要稳定,不要天天修改,导致频繁的跨团队强协作。

工单流转时长

企业的外部用户创建的工单,如果最终发现需要开发来处置,到转到对应的开发手里。

这个从工单创建时间,到开发开始处置的时间差,就是工单流转延迟。

度量这个指标主要是为了避免后台模块缺乏对企业最终用户的体验缺乏同情心,减少中间的层次。

故障定位时长

业务逻辑不可避免地要拆分成多个进程。如何找到出问题的进程。 一个进程也很难仅由一个 Git 仓库构建而来。业务逻辑不可避免地要拆分成多个 Git 仓库,需要找到进程的问题是哪个 Git 仓库造成的。

故障定位延迟是指故障从开始定位,到找到根本原因所花的时间。

进程边界,Git 仓库的边界,越不依赖人的经验,越不依赖人的现场沟通,就越可能降低故障定位延迟。

代码集成时长

从修改一行代码,到把这行代码修改和其他进程集成到一起,用真实流量验证,这个端到端的延迟是多少。

开发自己的笔记本能把所有的进程都能启动起来是一种办法。 开发能用单元测试模拟试也是一种办法。 每个小时上一次线也是一种办法。 只要能对刚才改的那行代码,集成起来不出问题有信心就可以。

我们没有把“本地开发环境设置时间”做为一个指标,因为能够本地启动进程是手段,而集成到一起测才是实际目的。

3.代码职责划分

从人的角度拆分和隔离代码和业务(只对自己写的代码负责)

只对自己写的代码负责要体现在发布变更,告警定位这两个环节里。

发布变更

变更之前可以工作,加入了我的变更之后不工作了,那就是我的变更引起的问题。如果不能有效地隔离自己的变更,就要被迫去处理别人写的代码。

单体应用最大的原罪就是变更的粒度太大了。而大粒度变更是稳定性的最大敌人。

多进程、多租户、多变种是切分变更有三种主要的做法。

  • 多进程:控制的是代码的变更
  • 多租户:控制的是数据的变更
  • 多变种:控制的是配置的变更

理想的快 Feedback 工作环境里,上线应该是每个小时都发车,随意可以搭车的。

多个团队的Git仓库其实是跑在一个进程里。 每个团队通过 Feature Flag 来做自己的灰度测试,出了问题可以一键回滚。

一次可以只变更一个租户,确保不出大面积故障。

开发者不需要费心维护自己私有的开发环境,而是直接 Test in Production。

需要全链路压测的时候,创建一个新租户,放心大胆地随便随便测。

多进程

把单体进程切分成多进程。一次只变更其中的一个。

因为大部分线上故障都是由变更引起的,所以SRE会非常强调部署流程的小心谨慎。 一旦发现有问题,就会被要求立即回滚。这也就导致了搭车上线是非常讨厌的事情,谁知道你搭车进来的改动会不会翻车。 所以拆分成多进程,各上各的就会变成非常强烈的需求。多进程当然是有其合理性的,替换进程是最简单最可靠的变更形式。 开源的 kubernetes 等项目也提供了完善的基础设施来支持多进程的部署模式。

用拆分进程的方式来解决上线慢的问题也会有一些缺点:

  • 反馈不集中:小流量的时候,引起的小规模的故障可能观察不出来
  • 灰度数有限:集群规模比较小的时候,总共就几个进程,灰度的刻度就会很大
  • 灰度时间有限:上线观察一天已经很夸张了,要更长时间的观察是不好用上线的方式来实现的
  • 上线顺序:进程之间经常有数据依赖,并不能总是保持向后兼容。当要同时升级多个进程的时候,上线顺序的确定是比较头疼的事情

多租户

把所有的业务数据分成租户。一次只升级一个租户的数据和代码。

多租户和多进程不同,租户可能是按游戏大区分,按城市分,也可能是按商家分。租户是业务相关的概念。 这意味着开源社区是不能给你现成的基础设施的,所有的多租户功能都需要自研。 拆分成多租户之后,变更就可以一个一个租户来做。挂了也只会影响一个租户,不太可能引起大面积的故障。

租户整体做升级也可以避免上线顺序的问题,把租户短暂停服,全部升级完了再继续提供服务。 可以只停服一个租户,可以让很多复杂的变更很好做,比如改变数据库的表结构。

传统的测试一般都是在一个离线的环境里进行。但是对于性能压测,复杂的多进程业务,搭建独立的离线环境复制在线环境是成本很高的事情。 保持两个环境的一致性也需要非常强的纪律和日复一日的努力。 直接 Test in Production 是越来越明显的趋势。 做全链路压测的时候,经常把修改的数据隔离在“影子表”里,避免污染正式数据。这其实就是多租户,只是把压测跑在一个隔离的租户里了。 各种 QA 账号,测试订单也可以用租户的方式来实现,而不需要 case by case 的硬编码在代码,甚至引起安全隐患。

同时跨数据中心搬迁等复杂的变更场景也需要数据是能切开的。从加快 Feedback,控制变更的角度,第一天就应该考虑多租户的问题。后来添加进来的成本会非常高。

多变种

分租户还是粒度太粗了,比如说挂掉一个城市也是不可接受的。那么可以在线上同时运行多个版本的代码,然后逐步的切流量。

考虑以下三种做法:

  • 在代码中埋入 Feature Flag,通过配置中心下发开或者关,或者对一部分流量打开
  • 在本地的笔记本上启动一个独立的精简集群做测试开发
  • 在本地的笔记本上只启动一个进程,集群的其余的进程使用生产环境的

我把这三种做法都归纳为 Multi Variant,多个变种。它们都是在不改变进程的情况下,通过配置等方式更灵活地进行装配组合。 这样发布和上线就可以解开为两个操作了。上线就是上线,改变进程里的可执行代码。 上线可以不把 Feature Flag 打开,而是保持原有的行为。除非 Feature Flag 本身实现得有bug,上线过程的风险就小了很多。 然后再慢慢地打开 Feature Flag 的流量开关。

Feature Flag 未必一定是用 if/else 实现的,它可以是基于插件机制来实现的。 根据不同的 Feature Flag 的配置信息,选择执行同一个插件的不同版本。 其实质是运行时的插件动态装配。把发布从进程粒度,降低到插件的粒度。

Feature Flag 虽然好用,但是仍然要先完成上线。从 Feedback 周期来说仍然不够理想。 如何在上线之前就测试好呢?本地搭建一个完整的环境困难重重,不仅仅可能笔记本性能不够,而且还有一大堆的数据配置依赖。 理想情况当然是可以把业务拆成更小的部分,只做一部分的集成就能完成测试。 比如说一个app里包括了打车和外卖,那需要打车和外卖都打包进去吗?显然可以只跑打车的代码嘛。 但是部分集成不是免费的,每添加一种部分集成的跑法,就给代码增加了一个运行模式,是需要额外维护的东西。 当团队比较多的时候,只有维护生产环境是大家共同的目标,你自己搞出来的一个部分集成的环境,未必能够得到其他人的认同和资源支持。

所以 Test in Production 才会逐渐火起来,因为只有 Production 才是唯一公认必须保持稳定的环境。 在自己的笔记本上只启动自己修改了代码的进程,才更符合高效分工的原则。 根据概率论,一个系统的稳定性是其构成模块稳定性的乘积。 如果要本地搭建一个完整的集群,必然是一个很不稳定的东西。 在有了多租户的前提下,Test in Production 仅仅需要解决部分替换进程这个问题。 通过在 Http Header 中附加路由信息(例如 Istio Request Routing), 是可以实现一个集群中其他进程都用生产环境,但是你修改的进程替换成你本地启动的。

告警定位

接到告警了如何能快速定位到问题。其核心就是需要在你的代码和别人的代码之间有统一方式定义的边界。不需要知道边界里面的代码是怎么写的,只要看一眼边界上的监控数据就能快速排除不是自己的问题。然后把锅甩出去。

进程边界

把不同的 Git 仓库跑在不同的进程里。这样只要看是哪个进程出的问题,就可以知道是由哪个 Git 仓库引起的了。

进程边界的好处
  • 完善的跨进程调用监控:相比进程内调用,跨进程调用的监控基础设施要完善得多。因为处理的数据量要小得多。
  • 操作系统强制的配额和安全性:就是基础设施更好。都是提前做好,而且充分测试的。
  • 隔离的内存状态:进程之间不会共享内存,不会因为共享内存而产生逃逸监控的影响。
进程边界的缺点
  • 适用场景:在前端里启动独立的进程不是常规的做法。
  • 性能优化会漏掉依赖:经常我们会把配置等数据读取一次之后就缓存在进程内。这部分依赖就很容易逃逸出监控的范围。
  • 远离用户:拆分出来的进程往往是越来越靠后台,离用户越远,就越难以倾听到用户的声音。

函数边界

除了进程之外,函数是一个所有编程语言,所有运行时平台都有的概念。 每次函数调用都有一个运行时的 StackFrame 的数据结构来代表这次调用,某种程度上这就造成了函数的边界。

假设我们要在函数这个层面获得Feedback,那么有如下两种糟糕的结果:

  • 日志量太大了:无时不刻不在发生函数调用。全部都记录下来,那看也看不过来呀。
  • 一个函数的调用记录啥都说明不了:假设你看到了一个错误报告,仅仅报告了最后一个被调用函数是什么,参数是什么,其余的信息都没有。显然这样的Feedback也是不够的。 所以函数边界的关键不在函数,而在“调用”。我们以 React 渲染界面为例,至少有三种类型的“调用”,同步调用栈,异步调用栈,组件树。

无论是编程语言原生支持的同步调用栈,还是需要自研的异步调用栈,组件树,其目的都是隔离。 隔离就是给caller/callee 关系建索引,在找问题的时候尤其有用。

另外因为内存成本低很多,先把日志都整理好记录在内存中,如果有必要的时候再持久化下来。

目前的硬件还无法 7*24 的把调用栈落盘,或者说大部分业务的客单价从商业价值上还无法支持这样的成本。 这也是进程隔离的优势,进程间调用是精心设计的,其数据量也小得多。

同步调用栈

所有的编程语言都有 StackTrace 来记录同步的调用链。同步调用链是不需要额外参数来记录的,编程语言甚至CPU都有基础设施来跟踪 caller/callee 的关系。 比如我们调用 React.createElement 的时候,React 内部又会调用几个子函数,执行完了之后以 React.createElement 返回值的形式返回给我们一个 React Element。

异步调用栈

React 的界面刷新过程是异步的。当我们调用 this.setState 的时候,我们并不能拿到返回值,也不能肯定在函数调用返回的时刻,界面已经刷新完成了。 这个时候我们只能确定 React 已经把要拿什么新的 state 刷新界面这个事情记录下来了,将来会执行的。 那我们想要知道谁触发了渲染,从哪里调用过来的,怎么办? 这个时候就无法依赖 javascript 内置的同步调用链了,而需要依赖 React 提供的异步调用链。 React 的开发者工具也提供了可视化的界面来展示这个异步调用链。 详情参见 unstable_trace

javascript
import {
  unstable_trace as trace,
  unstable_wrap as wrap
} from "scheduler/tracing";

trace("Some event", performance.now(), () => {
  this.setState(newState)
});

这样我们调用 require('scheduler/tracing').unstable_getCurrent 的时候,就可以从返回的 interactions 里找到 Some event 这个 interaction 了。 似乎这里并没有给 this.stateState 提供额外参数,那么 React 是如何把异步函数调用给串起来的呢? 这里 React 使用了 unstable_trace 设置的全局变量。如果不依赖全局变量,写法应该是

javascript
const newContext = context.trace('Some event');
this.setState(newContext, newState);

以显式传递一个 context 参数的形式来把调用链给串起来。

这种做法也是 Go 等异步编程语言的常见模式。 在非前端的场景下,一般都无法使用全局变量,所以 Java 等语言会用 Thread Local 来代替全局变量,实现与 React 类似的模式。

组件树

React 的每个组件都是一个函数。

javascript
function A() {
    return <div><B/><C/></div>
}
function B() {
    return <span>B</span>
}
function C() {
    return <span>C</span>
}

从语法层面上来看,这个组件就是一个函数调用栈。组件嵌套了组件,也就是函数嵌套调用了函数。

在 React 内部,要记录 A/B/C 三次调用,以及调用与被调用的 caller/callee 关系。 那为什么我们在 B组件 的调用上,为什么看不到把 A 做为 caller 参数 <B caller={A}> 传递过去的呢? 这是因为 React 是一个“虚拟机”,<div><B/><C/></div> 是这个“虚拟机”支持的“代码”。 在解释执行 <div><B/><C/></div> 的过程中,React 自然可以任意把上下文的参数塞进去。

当然,实际执行的时候,React并不是虚拟机,<div><B/><C/></div> 也并不是虚拟机的执行代码。 但是 caller/callee 的关系是真的要被记录下来的,在查找问题的时候,其 caller/callee 的关系也是非常有用的。 之所以要把“组件树”假装成函数调用,是想要启发你 StackTrace 其实意味着什么。

当 React 组件的行为异常的时候:

仅仅告诉你同步调用栈够吗?不够,因为不知道是谁触发了我重渲染,我的父组件又是谁 仅仅告诉你异步调用栈够吗?不够,因为不知道具体是哪个直接的同步函数调用出了问题,也不知道是界面哪个角落的组件出的问题。 仅仅告诉你是界面上哪个位置的组件出的问题够吗?不够,因为只知道哪里出了问题,并不能告诉我谁引起的问题

跟踪“调用”是为了调查“因果关系链”。只要是能回答“因果关系”的信息,都是 Feedback 所需要的信息。

插件边界

通过多进程,我们可以实现业务逻辑的动态组合。但是用普通函数也可以组合

javascript
function f(g: () => void) {
    // do something
    g();
    // do something else
}

这样 f 和 g 就组合起来了,我们称 g 为一个插件。 插件边界是指当 f 和 g 来自于不同的 git 仓库的时候,f 调用 g 就是跨越了 git 仓库的边界。 那怎么能实现 f 和 g 来自不同的 git 仓库呢?

  • 动态二进制链接:windows 的 .dll,linux 的 .so
  • 动态源码链接:javascript 可以远程下载 js 文件,然后动态执行
  • 静态二进制链接:windows 的 .lib,linux 的 .a 或者 .o
  • 静态源码链接:webpack

一些运行时环境,例如 iOS 是不鼓励从网络动态加载代码的。 插件并不一定等于动态化,插件完全可以是静态装配的。

无论代码是怎么组合成运行时的进程整体的,拼装后总是有拼接缝的。 那我们就可以在“插件loader”这样的地方去添加打日志与监控的代码。 并不是 RPC 调用才有错误率,请求延迟这些指标。插件也可以有。

虽然函数调用的频次是非常高,暴力记录所有的函数调用是吃不消的。 但是我们完全不用关心 Array.map,Set.add 这样的函数被调用了多少次, 只需要区分好哪些是插件,然后把这些高价值的插件函数给监控好就足够提供 Feedback 了。 不同 git 仓库的所有者,仍然可以从日志里拿到自己的日志,和进程隔离没有区别。

如果有强制的插件规范,那甚至可以做到“内存状态”的隔离,用编译检查等手段禁止全局变量,禁止偷偷地访问另外一个插件的内部状态。 一个进程要读取一份配置,只有第一次RPC的时候我们才能监控到,后续缓存在内存里的访问就是不可见的了。 而插件则不用担心跨插件调用的开销,我们可以把配置缓存在另外一个插件里,这样每次读取配置都是跨插件的调用,从而可以被监控到。

插件和进程的核心区别就是插件可以适用于更多的运行时(比如iOS,微信小程序),可以用于拆分运行时调用关系更频繁更紧密的界面和流程。

进程边界的优势来自于社区共识,提前提供了大量开源的优秀基础设施。 但是并不意味着,除了进程边界,我们不能在进程内再为每个 git 仓库划出边界来,只是要付出一些自研代价罢了。

社区共识是不够的,在函数边界(特别是同步调用栈之外的其他调用关系),以及插件边界上都没有足够强的规范.

只要能在公司或者部门级别建立好共识,函数边界和插件边界完全可以满足问题定位的需求,甚至比进程边界做得更好。