阅读 486

# 抽象泄漏(Leaky Abstractions)

翻译 & 编写:Jasmine Xie

在 5 月 23 日 Online Meetup With Evan You 的问答环节中,Evan 在说到 low code 时提到一个概念 —— “abstraction leak”. 在前端开发过程中接触过很多内部平台和工具,包括 low code 建站平台、组件库、框架和二次封装的元框架。在这个过程中会发现一个比较普遍的现象:

  • 即便文档覆盖相对比较全面,开发者在实现或排查某一些特定的问题时仍然不可避免地需要阅读对方平台或库的源码,或了解更底层的原理。
  • 一些特定的场景下,平台或库索提供的接口、界面或规范不再适用;它们的抽象层次不再能满足业务场景要求。

但是往往这些痛点并不是系统本身的管理或设计缺陷;更多的是某些应用场景下的“抽象泄漏”导致。本文翻译多篇相关英文文章,并在此基础上整合、提炼,就系统设计中的抽象层级和抽象泄漏现象进行讨论。


这篇文章将会介绍:

  • 什么是抽象泄漏法则
  • 抽象机制如何“泄漏”
  • 开发者如何应对抽象泄漏

为了避免翻译歧义,部分概念在特定场景下还会保留英文表述:

英文翻译
abstraction抽象、抽象层级,名词(在某种意义上也包含“封装”的意思)
leak泄漏、漏洞、漏出
interface接口(为更高一个抽象层级开发者、调用者、消费者、使用者所展示的“界面”)
consistency一致性;连贯、前后一致

引言

现在环顾四周,我们会发现日常生活中常常会用到一些非常复杂的系统:智能手机、计算机、打印机、汽车、电视、烤面包机…… 虽然我们自己很难自行从零制造这样的一个机器,但是不论这些设备或系统多么复杂,我们都可以正常使用它们来完成日常所需的工作。

这个小小的奇迹归功于我们称为 “抽象”的概念(译者:其中也离不开 encapsulation, 即“封装”)。抽象是一种设计概念,它简用洁的用户界面 (interface) 屏蔽了复杂的细节,使得开发者不再需要关注这些细节就可以完成工作。抽象 (abstraction) 在每个软件程序中都起着核心的作用,这样的设计向站在更高抽象层级的调用者和使用者隐藏或屏蔽了 API 背后的实现细节。但这些抽象层级常常也会发生“泄漏”。

“抽象”是什么?

用一个实际例子解释抽象和封装——我们可以在浏览器的地址栏中输入网址来访问网站。在大部分前端开发场景中我们不需要了解浏览器如何执行 DNS 查找找到正确的网站,也不需要了解设备如何与网络服务器进行 TCP 握手,也常常不需要知道网站如何渲染一个 DOM. 这个过程非常详细、复杂,而很庆幸浏览器底层的逻辑帮我们完成了这些操作,我们不需要实现这些能力,在大部分场景中也不需要关心这些实现。

在计算机软件设计中,随着软件本身的迭代、软件系统体积和复杂度增加,我们会不断构建新的抽象层级,并将其添加到已有的抽象层级中、丰富已有的抽象和封装。做任何设计都是思考如何创建正确合理的抽象层级的过程。一个设计合理的抽象层次会向上层暴露所有重要的和必要的实现,但同时隐藏所有不必要的细节。一个合理的抽象层次会掌握好控制度复杂度之间的平衡。一个合理的抽象层次可以轻松把它调用者的行为或执行任务映射到自身方法或属性上。如果抽象层次设计得当,它会让人感觉使用它很直接、便利,合乎常规逻辑。

To design something — anything — is to think about creating the right abstraction.

在软件工程领域中,对抽象层级设计的关注会更加突出。在编写任何的代码时都需要考虑易用性和可维护性,一个开发者需要思考如何向其他代码隐藏这部分的内部原理,又需要思考如何让使用者顺利地消费这段代码的功能。抽象设计这个庞大的工程中至关重要的环节包括我们耳熟能详的设计模式、命名、单元测试等等,这些看似关联不密切的关注点都有一个共同的目标——在开发者设计抽象层时,帮助我们做出正确的决策,并保持其效果可控。

因为有“抽象” (abstraction) 设计的存在,我们可以在 HTML 文档中直接编写 <button> 而不需要绘制单个像素。我们可以编写 SQL 查询来获取客户的订单历史记录,但不需要知道每一条记录存储的位置和大小。我们可以在不了解打印机语言的情况下打印文件,在不了解视频编解码器的情况下播放视频文件,在不手动从硬盘上一个群集跳转到另一个群集的情况下读取文本文件,在不管理内存地址的情况下存储数据集合。(当然,如果真的想这样做,也是可以的。)

抽象如何“泄漏”

有一个真理:所有非简单抽象层级都会泄漏。这个原则是由 Stack Overflow 联合创作者 Joel Spolsky 在 2002 年提出的,在国内文献中,有些人也将其翻译为“抽象漏洞”、“技术露底”。它的含义是:任何试图减少或隐藏复杂性的抽象,其实都并不能完全屏蔽细节;试图被隐藏的复杂细节总是可能会从抽象层级中“泄漏”出来

以下图中黑色实线可以理解为“已定义的复杂度”;红色实现为“超出定义范围、超出预期的复杂度”:

1.png

图片来源:javadevguy.wordpress.com/2017/11/02/…

一种定义是:在软件中,假设第 n 层抽象与第 n+1 层抽象和第 n-1 层抽象交互。第 n 层的实现复杂度为 N(n), 且它向第 n-1 层提供了范围为 A(n, n-1) 的 API. 当第 n-1 层需要了解 N(n) - A(n, n-1) 的部分以实现某些功能,则发生了抽象泄露。

另一种定义是:在软件中,如果第第 n 层抽象与第 n+1 和 n-1 层交互,但是第 n-1 层应该保证第 n 层不需要知道第 n-2 层的细节。如果第 n-2 层的实现细节出于某种原因暴露至第 n 层的细节,则发生了抽象泄漏。

通过建立抽象,我们可以在更高的层次上思考和编程。

抽象泄漏定律意味着:软件市场上出现一个有趣的新工具,而且这个工具声称可以如何如何提高我们的工作效率时,更资深一些的开发者会说:“你先要学习怎么手动操作,然后再使用这些新工具来节省时间。” 在学 Vue 和 React 的 VDOM 之前我们需要先了解什么是实体 DOM. 新的编程工具实际上创造了一个抽象层级,它抽象出某种东西,而这个抽象层级如同其他所有抽象一样在实际使用场景中总难免需要开发者在一定程度上了解它们的细节和实现原理:举最简单的例子,我们需要了解 Vue 双向绑定的机制,以避免出现响应式对象无法更新的情况,这就是一个抽象泄漏。有效处理这些“泄漏” (leak) 的唯一方法是了解这个抽象层级的工作方式、了解它们到底向我们屏蔽了什么内容。抽象层级节省了我们的工作时间,但并没有节省我们学习的时间。这也意味随着技术的发展,我们拥有越来越高级的编程工具,建立越来越好的抽象、模型、设计理念,但精通编程这件事可能反而变得越来越困难。

但是这个规则为什么会存在?我们为什么不能建立完美的抽象层级?

这个问题在于,虽然抽象存在的意义是为了屏蔽细节,但抽象 (abstraction) 的价值也正是在于它所屏蔽或隐藏的细节当中。一个好的抽象应该做减法,也就需要将一些细节隐藏在调用者视线之外。但原 API 的设计范畴是有限的,其复杂度和操作支持范围必定是它更下层抽象的一个子集。

The value of an abstraction is in the details that it hides.

在一个抽象层级“泄漏”得过于频繁或泄漏规模过大时,导致开发者需要真正了解所有本应该被隐藏的细节,抽象层级实际上就已经失效了。这个所谓“便利”的抽象层级其实就没有节省开发者任何时间或精力。软件设计中的真正艺术是如何正确识别抽象层级、学会处理这些 abstractions 的漏洞,学会什么时候、以什么方式补全这些抽象层级上的缺口。


分割线内为译者注解

为什么会出现抽象泄漏 (abstraction leak)?简单说可能有几个原因:

  • 接口 (interface) 暴露的细节太多
  • 接口 (interface) 所屏蔽的细节太多
  • 抽象层级设计缺乏一致性 (consistency)
  • 抽象层级缺乏完好的注解

接口暴露细节太多

🌰 例1:

Low code 或 no code 平台是抽象泄漏的典型。部分 Low code 建站平台的一个重要目标是赋能产品运营或非技术人员,但 low code 平台在设计时往往无法完全屏蔽技术。一部分类似平台会提供自定义代码的功能,在实现产品赋能的同时满足一定的研发灵活度。另一些 low code 平台会屏蔽这些抽象层,取消页面内代码编辑器的支持。(提出这种平台有抽象泄漏的存在只是陈述事实、不是带有任何价值观色彩;辩证地看,有些抽象泄露不一定是一个坏事。)

某个 low code 平台初期会提供几乎所有可以映射到 CSS 的属性功能,但在后续的迭代版本中去除了这些属性,只保留最简单必要的“位置”、“背景图”等属性,引导使用者将自身的需要功能往已有属性上映射。如文字需求映射为图片、动画需求映射为 gif 等,大大的降低了业务人员对平台功能的认知成本。

🌰 例2:

2.png

这一个设计的优化空间非常大——但是设计出来一个直接后果往往是因为暴露太多内容,导致优化成本过高。

实际上优化方式很多:使用状态管理,父组件不需要处理这些参数,在子组件内直接处理数据;定义几个合理的模型:shareConfig, activityConfig, 将数据在模型层封装;放弃父组件的数据抽象层、子组件按需加载数据(GraphQL 是一种方案);…… 在一定程度都涉及到了抽象边界的划分、两个抽象层级之间 API 的重新定义和设计。

接口屏蔽的细节太多

细节屏蔽的太多,接口范围太窄,导致开发者真正想实现一些其他功能时只能去关注内部的实现细节。

🌰 例1:

前端框架和库可能是一个很好的例子。React 16 及之前的事件封装导致使用原生方法挂在 DOM 事件可能产生不可预期的后果;一个组件库可能会重写原生方法,如 Input 组件只暴露 onChangeonFocus 这类属性,导致无法支持像 onCompositionStart 等小众但真实存在的需求。

🌰 例2:

初学前端封装某些组件时,可能会倾向于只封装自己用到的部分:

3.png

4.png 一旦业务需求有变动、要求样式变化,如果不重构已有的这个组件,就只能在它的调用方里关注这个组件实现的细节(即样式和结构)在其父组件里覆盖样式,造成了抽象泄漏。但一个更好的设计可能是如图2:在提供了一定的规范的基础上,向调用方提供足够灵活的 API、暴露可控的复杂度。

抽象层级设计缺乏一致性

5.png

6.png

当调用者或使用者难以理解抽象层级所提供的接口时,和业界规范不符合、组件或库内部方法调用不一致会造成抽象泄漏现象——消费者或调用者(consumer)需要去了解或确认内部的实现。这个是命名在抽象层级设计中重要性的一个例子。

但更多的不一致性不是命名这么简单,可能是这个抽象 (abstraction) 内部本身设计的不一致、设计缺陷导致的。比如服务内部不同函数返回是否缓存、缓存机制不一致;再比如后台访问一个学生的某些信息,A 接口需要传入 parentId, B 接口需要 studentId;这个时候调用方就需要了解这两个 id 之间的映射关系、甚至相互获取的逻辑。

抽象层级缺乏完好的注解

除了常规定义的“缺乏注释”以外,缺少注解还可以表现为:缺乏类型定义和功能定义,调用者从抽象层级外观察难以低成本理解功能(接收什么参数、返回什么内容、在什么时机触发);因为设计本身导致在调用层级中无法轻松看到或理解所调用的 API. 有些开发者认为最好的代码是自注释的代码。

在 React hooks 之前,代码复用通常会使用 mixin 或 HOC (高级组件). 在使用 mixin vs. HOC 的争论中 React 官方是更推荐 HOC 的:mixin 会引入大量不可控因素,引入隐形依赖、潜在命名冲突、复杂度急剧增加。

Composition over inheritance.

在 hooks 之后,设计模式的变动和编程范式转移,使得代码共享就更加简单、直接,代码在一定程度上可以更加“自动注释”化 (self-documenting).

7.png

8.png 在一定程度上,“缺乏一致性”和“缺乏完好的注解”是抽象层级本身设计的一个缺陷。

”暴露的信息太多“、“屏蔽的信息太多”有一部分是设计缺陷;有一部分更可能是使用的场景或面向的受众 (consumer) 已经偏离抽象层级设计的初衷,抽象层级在这一个场景内或面对这样的调用者时已经不再完全适用。


开发者如何处理上游的抽象泄漏?

抽象和封装降低了系统复杂度,但它们不是完美的解决方案。如果抽象泄漏太严重,我们可以直接删除这个抽象层级,或创造一个更好的抽象。我们可以以文档和注释形式清清楚楚地记录下它的功能和局限性。抽象和封装是好事,但过多的抽象也反而会增加系统的复杂度。David J. Wheeler 指出:“计算机科学中的所有问题——除了“中间层太多”这个问题以外——都可以通过增加一个中间层解决。”

All problems in computer science can be solved by another level of indirection, except for the problem of too many layers of indirection.

--David J. Wheeler

增加一个抽象层

开发人员可以在这个抽象层级的基础上二次封装,增加一个抽象层级,达到屏蔽一些信息的目的。下游应用层会改调用这个新的抽象层级,这个抽象层级也会在它的层面收敛逻辑来完成下游应用所期望的行为。中台是一个典型的例子:业务发展到一定规模,复杂度变高,原有的抽象层级泄漏严重不能满足需求,所以抽象出一个新的中间层去统一处理逻辑、向调用方屏蔽实现细节。字节 Web Infra 一部分团队成员也认为:长久以来 UX 和 DX 之间的矛盾是因为开发者所面临的抽象层级过低;如果做更高一级的基础建设,可以实现二者的双赢——这就是企业内部元框架的诞生了。

重写或抛弃抽象层

在更极端的情况下,开发者可以重新实现功能,甚至抛弃原有的抽象层次。这不是一个好习惯;随着抽象层次的丢失,应用程序复杂度会提高。抛弃抽象层次的选择是在两种复杂度之间进行权衡取舍:取因为抽象缺失带来的项目复杂度,还是取抽象泄漏的复杂度。

如果重新实现的抽象层级不能像原抽象层级那么优雅、达到原抽象 (abstraction) 的可用度,重新实现基础功能还可能导致应用程序的其余部分(仅使用重新实现的功能的代码部分)变得更加复杂。开发者由于某种原因无法在新的接口 (interface) 下兼容旧的接口 (interface) 时,也容易出现这个问题。如果业务程序员被迫放弃旧的接口、转而开始自己思考软件设计,这些开发者的产出其实很可能达不到原抽象层级的水准。客观、不带有价值判断地说,业务开发者当前的第一优先级仍然是业务,业务开发者中大部分人可能没有时间(或没有兴趣)去真正设计清晰优雅的系统。同样,重写抽象层也是一种权衡取舍:开发者接受了新引入的潜在抽象泄漏;放弃了“现在”的抽象泄漏、接受了“未来”的抽象泄漏。至于如何取舍也需要辩证地、根据实际情况判断。

绕过抽象泄漏

另一种方法是所谓 "code between the lines":在了解抽象泄漏的基础上,绕过它,或专门“为了它”编写代码。开发者需要了解抽象背后的实现细节,强扭业务代码来适配抽象层级的实现。这会使得代码复杂度增加,可读性和移植性也会变差。

Coding between the lines 的一个典型的例子是虚拟内存。一个程序给多个对象分配内存时,通常会有一个“自然”的分配顺序。但如果对象很多、内存分页行为变得很关键时,人们通常会重写程序让“对象内存分配得靠近一些”,从而提升程序的性能。尽管虚拟内存着一层抽象相关的文档没有提及对象存储的物理位置,但是程序员设法“扭曲”了自己的代码,让自己的代码直接和抽象层级的内部实现一个“对话”,来获得所需的性能提升。显而易见当程序员被迫这样编码时,他们的程序复杂度将会显著提高——而且更重要的是,这样的代码可移植性会降低。

一般而言开发者最开始的代码实现会更简单、清晰、直接,并且最大程度地复用了底层的抽象,这个时候开发者是面向一个最理想状态(理想内存、理想 CPU、理想网络、理想数据等等)进行编程的。但当程序需要实际交付共时可能会出现一些实际和底层抽象绑定或耦合问题:如何适配不同机器?如何利用交付环境提高程序的性能?……围绕抽象泄漏编码就相当于引进来一个魔法师,这个魔法师实际上运用他对内部工作原理的知识,将已有的简单的代码实现和涉及到的抽象层级背后的原理相结合(结合,原文为 convolve, 直译为“卷积”,即扭曲在一起)去“神奇地”实现这个功能,也就是我们常常说的 "hack 一下"。原本的代码可以实现局部的控制,将复杂度限定在某一个范围内,但在这样为了抽象泄漏专门编码会将代码打散、外露复杂度、外露细节实现。这个过程中,代码也隐含地和交付平台或所依赖的底层抽象更加耦合;耦合度提高也意味着可移植性降低。

开发者如何设计抽象层?

抽象层级的设计是很复杂的。但是我们可以先列举出四个相互联系的初步设计原则:范围控制、概念分离、增量性、健壮性。

  • 【范围控制】指抽象层级应在一定的范围内给予调用者适当的控制权。范围控制有很多种。几个更贴切和实际的例子可能是 low code 平台对定义好的配置项有充分的控制;前端框架对函数中抛出异常的处理;组件库组件在封装一些基本能力以外都会提供 className 或类似属性支持调用者在一定程度上覆盖样式。
  • 【概念分离】意味着使用者或调用者应当不需要了解整个抽象层级的实现原理,就可以使用抽象层的接口实现某些特定的功能。在系统设计层面上这是很困难的,因为具体的实现有时不同的变量、方法、属性之间的交互可能会产生令人意想不到的深远的影响。
  • 【增量原则】意味着如果一个开发者决定自定义这个抽象层的某一个部分,调用方应当可以声明式地改变他们想自定义的内容,然后完全复用抽象层其他的部分。一个开发者不应该为了部分的自定义实现承担全部抽象层级范围的责任,他们也不应该需要从零开始重新编写一个新的实现方式。
  • 【健壮性】意味着客户程序中的错误的影响应受到适当的限制,一部分的错误对系统其余部分的影响可控。

抽象泄漏在所难免;除此之外,针对抽象层级如何泄露的具体方法有:

  • 确保所提供的抽象层级具有一致性 (consistency). 确保在这一个层级中提供统一的、一致的、同一个认知层面的抽象。
  • 明确抽象层级的适用范围。告知开发者或用户这个抽象层级的明确的适用范围、应用背景,在超出适用范围后哪里可能存在抽象泄漏、他们可以从什么角度处理。
    复制代码
  • 引入辅助或并行的抽象,让调用方有更多选择。如提供简单模式、复杂模式;用户不认为多模式这件事本身是抽象泄漏。同上条一样,管理抽象、防止抽象泄漏在很大程度上是管理用户的期望和认知
  • 拥抱抽象泄漏,并把它当作抽象层级的一部分,在制定好规范的基础上鼓励调用者填补框架的认知空白。典型例子如 webpack, eslint 等工具的插件机制。与其逼迫调用者 "hack" 你的抽象层,不如提供一个入口、邀请他们共建。

Exposing an abstraction leak might be the most effective solution to hide it

总结 TL;DR

  • 计算机领域各处存在抽象和封装。设计任何东西都是思考如何创建正确的抽象层级的一个过程。
  • 任何试图减少或隐藏复杂性的抽象其实并不能完全屏蔽实现细节;试图被隐藏的复杂细节总是可能会从抽象层级中“泄漏”出来。
  • 抽象泄漏的几个直接表现可能是:
    • 暴露细节太多
    • 暴露细节太少
    • 设计缺乏一致性
    • 缺乏完好的注解
  • 针对上游服务抽象泄漏,开发者可以:
    • 增加一个抽象层
    • 重写或抛弃上游抽象层
    • 绕过抽象漏洞去编码、或“针对”抽象漏洞去编码
  • 开发者设计抽象层时,需要注意:
    • 抽象层级需要在一定的范围内给予下游适当的控制权
    • 抽象层级之间概念互相分离(高内聚、低耦合)
    • 支持让调用方声明式地改变他们想自定义的内容
    • 客户程序中的错误对系统其余部分的影响需要可控、有限
  • 更具体一些,针对已有的抽象漏洞,可以:
    • 确保所提供的接口 (interface) 的一致性
    • 明确抽象层级的适用范围
    • 引入一个辅助或并行的抽象
    • 拥抱抽象泄漏,并把它当作抽象层级的一部分

参考文献

除译者注部分和中间穿插少部分举例,其他均翻译+整合自:

文章分类
前端
文章标签