「2019 JSConf.Hawaii - Brie.Bunge」大规模应用 TypeScript

4,510

特别说明

这是一个由 simviso 团队对 JSConf.Hawaii 中关于 TypeScript 相关话题进行翻译的文档,内容并非直译,其中有一些是笔者自身的思考。分享者为 Brie.Bunge,Airbnb(爱彼迎)高级前端工程师。

现如下,TypeScript 已然兴起,如果各位小伙伴你们公司还未将开发迁移到 TypeScript 下,亦或正在迁移或者已经迁移了,那么不妨一起透过本文来看看 Airbnb 是如何做到大规模应用 TypeScript 的。

视频地址:

视频翻译版权归 simviso 所有,未经许可严禁转载

一、前言

大家好,我的名字是 Bree,在 Airbnb 工作。

在大公司中进行大的改革很难,这需要去说服很多人,同时又需要涉及大量的代码迁移。

我想要和大家分享的是,我们是如何将 TypeScript 应用到 Airbnb 的。

我感谢你们能在这里,我知道你们完全可以披着时髦的毛巾在海边娱乐。

但我希望大家都能从中受益,无论你是否正在为你的公司进行重大改革,这都可以作为一种案例进行研究。无论你现在是否正在积极的进行 TypeScript 迁移,我们都将讨论一些可以帮到你的工具和技术。

或许,你已经听过一些 TypeScript 的内容,但想了解更多。

首先,我们会介绍 TypeScript 是什么?规划化又意味着什么?提议使用 TypeScript 的过程又是如何?

基于这些问题和疑问,我会给出相应的解答。我们该通过什么样的迁移策略将 JavaScript 逐步迁移到 TypeScript。

请大家快速举手示意一下以方便我知道大概有多少人之前使用过 TypeScript。酷,有这么多人。

还有一部分人没有举手。那么,让我们快速介绍一下,这样每个人都在同一起跑线上了。

二、快速介绍

假如我们有这么一个 greeter 方法,它接收一个 name 参数,然后返回 'Hello' + name

所以,如果我们传入的是 JSConf,它会和气的说 Hello JSConf。

将刚才的代码用 TypeScript 来表达就是这个样子。

可以看到它们很像,唯一的区别是我们在它的参数这里使用了类型注释。

所以如果我们在我们的 TypeScript 项目中使用这个函数同时我们传入一个字符串,可以看到编译一切正常。

但是在下图的情况下,如果我们传递的参数类型不是字符串,而是一个字符串数组,那么 TypeScript 就会给我们一个报错,即字符串数组不能分配给 string 类型的参数。

我们不需要再通过点击刷新页面这一流程来从我们的控制台中查看错误并确定该错误所发生的位置。可以看到,在我们输入后,立即就从编辑器中得到了这个错误。

我们也可以表达其他对象类型,具体如图

这个接口描述了一个包含名字和姓氏的 person 对象。同时,你可以定义更复杂的构造类型。

TypeScript 通常带有一个编译器,当出现问题时就可以立马告诉你。它还有一个可以与编辑器挂钩的语言服务器,可以帮我们进行自动编译,提供重构相关提示等等。

再看个例子,如图

例子中,我们已经将我们的 React 高阶组件 withStyles 进行了类型绑定。所以它可以自动对数百个 CSS 属性进行联想,包括内联文档。

牛逼吧!这样一来我就不必再来回翻阅文档页面了,我在编辑器中就能得到所有属性。

通过使用 TypeScript,我们在编码时能做更多的事情。

这仅涉及了 TypeScript 非常基础的一些功能,但却让你了解到它捕获类型错误的能力,以及支持它的工具。

三、规模化应用

关于 TypeScript 是什么的内容就到此,那关于规模化应用这一部分呢?

1、具体规模场景

规模化会改变我们的交流方式。

以前我在小团队的时候,在那如果你想用 TypeScript。“是的,听起来很酷,那我们用吧。”

但当你的团队规模达到数百位工程师、同时代码量也越来越多时,交流方式则会发生改变。

我们将进行改革,我们提议在我们的主仓库中使用 TypeScript,并让它成为前端开发的主要语言。

改革影响的人越多,那必须迁移的代码也越多。

让我们来量化一下我们所说的规模。如图

Airbnb 拥有大量的 JS 代码,我们的主仓库中有超过 200W 行的 JS 代码,以及 100 多个内部 npm 包。

这些都是分离的仓库,通过打包到内部的 npm 注册中心的,这样就能跨仓库共享了。

这真的有很多代码,我甚至能看到一些 Backbone 的影子。可以想下 JavaScript 走过了多少年,它在 Airbnb 这也走过了十年之久,所以我们也有大量的开发在维护这些代码。

目前公司有超过 1300 名的开发,其中有 200 个是前端,这些前端工程师大多数都参与了主仓库的贡献。

这些数字描绘了我们当时提议使用 TypeScript 所面临的困境。

那么,它在如此规模下我们需要做什么呢?

每个月,我们都会举行一次有趣的会议,公司所有前端工程师都会聚在一起,一起讨论新的前端技术和模式。

为了可以做到深思远虑,我们起草了份提案,它概述了诸如优点、权衡、替代方案、弃用策略以及长期拥有者等事项。

大家会权衡这些提议的利弊,我们会站在团队的角度去决定向前迈出这步是否有意义。

这确保了我们可以作为一个集思广益的团队,对所做的事情做出深思熟虑的决定,避免在没有正当技术理由的情况下就“上车”了。

从 2016 年起,Airbnb 已经在一些小规模团队中探索使用 TypeScript。

在 2017 年的前端调查中,静态类型系统的呼声最高。

基于这些,Joe 和我起草了一个关于 TypeScript 的提案,并将它交给前端工作组,这项提案详细说明了为什么在 Airbnb 使用 TypeScript 是有意义的。

让我来讲讲主要的原因。

Airbnb 的使命是:

让每个人在哪都有归属感

用户每遇到的一个问题都可能导致我们背道而驰,对于你正在开发的产品也是如此,而TypeScript 可以帮助我们减少 bug 的发生。

TypeScript 还为开发人员提供大量的生产力效益和工具,像我们之前看到,自动编译和重构。

使用 TypeScript,工程师可以更安全快速的迁移代码。在 Airbnb,我们引入了 GraphQL 和 Apollo ,它允许我们在 GraphQL 模式中直接生成 TS 声明。

这意味着我们能保证端到端的类型类型,因为前后端本质上使用的数据类型共享了同一申明。后端工程师能够在不影响客户端的情况下对 API 进行修改,而前端工程师则可以明确哪些数据将从服务器返回。

类型不匹配一直是我们的主要 bug 所在。因此,这种端到端的类型安全性是一个主要的卖点。

这听起来很棒,但对于我们的初步提案,还有很多问题和疑虑。让我们更深入地了解其中的一些。

我们的 mono repo(译者注:mono repo 意为代码都在一个仓库)是依赖我们内部 npm 包的。

为了获得自动完成和类型检查,我们需要先将它们转换为 TypeScript 吗?

这就是我们面临的困境,我们的 TypeScript 项目依赖 JS npm 包,我们如何拿到那些包的类型定义?

这似乎的确需要先将那些包转换成 TypeScript。

但这有个问题,由于维护人员不允许我们对它转换,或许他们不愿这么做吧。因为在我们提案的早期阶段,我们也不确定是否能继续推进下去。

但从另一方面来讲,使用 TypeScript 是为了让开发人员在有一个更好的开发体验,我们需要 TypeScript 提供的类型安全性。

那么我们该如何解决这个看似鸡和蛋的问题呢?

2、解决方案 - 申明文件

TypeScript 有一个称为声明文件的特性,即扩展名为 .d.ts 的文件,我们可以为 JavaScript 文件定义类型。

让我们来看一个例子,继续拿我们前面提到的 greeter 方法来做说明,如图

它上面是对应的.d.ts文件。方法里没有实现细节,它只描述了类型。TypeScript 将它们组合到一起,这样,编译时使用的是这个声明文件,而运行时使用的则是这个原生的 JS 文件。

所以,让我们回到刚才我们提到的问题(要不要一开始就做转换),看看声明文件是如何提供帮助的。

当然,如果这个项目已经转换成 TypeScript 了,那么则没必要再去生成一个 .d.ts 文件来作为 TypeScript 构建时的一部分了。

但我认为这不止一种选择,相反,我们可以将声明文件放在我们的 TypeScript 项目中。

另一个选择则是,我们可以创建一个分离的 npm 包,将申明文件丢到那。

这很好,因为现在我们可以跨仓库共享声明文件。

这就好比 @types/react 中的类型定义。你安装 react 的同时,你也可以安装 @types/react 作为其类型定义的包。

@types/reactDefinitelyTyped 5000 多个包中的一个,它由一个社区专门进行维护。

我们主库中绝大多数的公共依赖都在 DefinitelyTyped 中定义了。

TypeScript 社区活跃也是它的一大卖点。我们也回馈了一些力量,我相信在座的各位也做过贡献,谢谢啦。

公共 npm 包已经有 DefinitelyTyped 做定义了,但那些内部包该怎么办呢?

我们通过创建一个独立的 npm 作用域对 DefinitelyTyped 做了一个内部镜像,所以当你 install @types/* 时,实际上是 install @airbnb-types/* 的。

这个仓库的设置跟 DefinitelyTyped 差不多,所以我们能在那加上自己的类型定义,然后在内部发布。

我们开源了一个入门的工具包(types-starter),如果你对这些设置感兴趣可以看下。它里面没有任何类型定义,它只教你如何配置,以便你测试并发布自己的类型定义。

那么,TypeScript 究竟能帮忙避免多少 bug 呢?我们看张图

近期,一个叫做 “该不该做类型定义” 的研究表明,在选择 TypeScript 的 github 仓库中,有 15% 的 bug 得到了避免。

在我们内部,有个记录生产环境事故的流程。这个流程的本意并不是为了责怪谁,而是要从错误中进行学习,这样我们之后就不会再犯类似的错误。

所以我坐下来读了六个月的总结报告,看这些报告很有意思,其中我最喜欢看到的莫过于 未捕获的异常危险的参数计算 等。

好吧,或许这些报错的名字没有让人那么激动。

无论如何,我将这些错误归类为与 JavaScript 相关或无关,以此确定哪些错误可以通过使用 TypeScript 来避免。

四、案例讲解

让我们一起看个例子,看看使用 TypeScript 能带来哪些帮助。

1、参数传递

我们对共享组件 Input 做些修改,通过一些设置来还原 bug,用户无法提交表单是因为它不再通过验证。

组件 Input 的更改前的简化版本具体如图

它接收一个 onBlur 变量,并将其直接传递给 input 元素。所做的改变就是添加一个新的 onBlur 事件处理。

但这存在一个不明显的bug,你能发现它吗?就是事件参数不再传递给 onBlur 事件属性。

这就导致了在好几个仓库中都出现了这同一个问题。Input 组件作为 Redux Form 的一部分使用,期望传递参数是 event 或 value,以便验证正常工作。

如果没有该参数,表单将不再通过验证,这就意味着提交按钮始终处于禁用状态。

TypeScript 在这是如何帮到我们的呢?

我们从文档中可以看到 Redux Form 有类型约束,onBlur 事件属性必须传递一个 event 或 value 类型参数。因此,如果我们使用了做了 TS 申明的 Redux Form,那么在函数调用那则可以看到一个不传递事件参数的报错。具体如图

2、其它特性

另一类常见的问题就是涉及严格的空值检查,即使用属性来构造或尝试调用可能为 nullundefined 的内容。你可能以前有见过这个错误,如图

另一种是类型不匹配。当我们尝试使用彼此不匹配的类型时,TypeScript 就会提示我们。 所以现在我们对常见的检查出来的问题有了更好的理解,TypeScript 可以帮助预防这种 bug。

3、日志数据

那事故日志中表现出来的总体百分比是多少呢?

38%!

我们发现有 38% 的事故导致了生产阶段的bug。

这些对我们用户产生实际影响的 bug,可以使用 TypeScript 来阻止,这对我们来说是个巨大的发现。

它有助于做案例复现。之前,我们复制了一些 bug 事件,并向大家展示了 TypeScript 所给出的 error 提示,然后对 bug 进行修复。

的确,我们能通过写测试案例来捕获这些,但通过静态类型检查则可以再额外加一层保护。

因此,如果你所在的公司也有类似的历史,那你可能就有必要和那些玩过 TypeScript 的小伙伴一起看看我提及的这些问题在你们代码中所占比例了。

4、团队推进

那么团队是否希望切换到 TypesSript 呢?我们在几个团队试用了 TypeScript,专门针对之前没有使用过 TypeScript 的团队来获取更多的使用反馈。我们帮他们设置好 TypeScript 环境,然后收集他们的反馈。

在用了一段时间后,我们向他们发送了一份调查问卷,询问他们是否应该继续使用 TypeScript?

从上图能看出,答案是非常肯定的。

我们建议使用金丝雀模式来测试新技术或模式,前端工作组的开发也是基于这种形式来进行的。由于它是独立的,所以它很容易就能回滚到之前的版本。

这样对提案也很有帮助,因为我们可以判断团队是否真的喜欢使用 TypeScript。

当然,可能还会有一些别的担忧。

  • 构建时间如何?

关于构建时间上的担心,我们测过了,发现并没有明显的影响。

  • eslint rule 如何处理?

在我们主仓库中启用了超过 500 条的 eslint 规则,配合 @typescript-eslint/parser 使用。

  • 弃用策略如何?

我们很高兴的发现它们中的大多数都能工作,所以如果我们将来我们要弃用 TypeScript 的话,我们可以剥离类型,最后得到大致相同的 JS 代码。

所以我们逐一记录、思考、跟进,并且针对提出的问题和担忧找到解决方法。与质疑的人合作并听取他们的意见对我们来说非常重要。

最后这些质疑的人大部分都转向来支持我们,我们的提案也因为他们的反馈变得更加健壮。

在充分解决了这些问题之后,针对所有前端工程师我们又做了份《我们是否应该采用 TypeScript?》的调查。结果如图

在收到肯定的回答后,我们有足够的证据向前推进,并通过这项提案。

五、渐进式迁移

在此基础上,我们逐步扩大了采用范围。

此时,我们已经度过了 Pilot 试验阶段,这对于验证 TypeScript 和打好基础是很有用的。

我们已经解决了早期的矛盾,并改进了工具和文档,所以之后的团队成员会更容易入门。

我们一直都与使用 TypeScript 的团队保持着联系,并帮助他们解决一些问题,比如更好的默认属性优先级处理。

在这个阶段,我们内部的 TypeScript 社区也得到了发展,但大部分 Airbnb 的员工还不知道 TypeScript,这意味着更多的人可以去帮助和回答他们的问题。

接下来,我们会进入到 Beta 测试状态。

团队可以选择使用它,为了帮助团队,我们创建了内部文档和风格指南,并举办了一些学习课程。

我们建了一些聊天组,内部的 Stack Overflow 组、谷歌 Email 主题小组、GitHub 组等,供组内成员交流,我们想确保人们能得到他们需要的帮助。

最后一步是将 TypeScript 完全普及化。

此时就意味着它是稳定状态,每个人都应该开始使用它, 我们目前正在努力地去接近这个目标。

剩下的步骤就是巩固风格指南、文档、加强内部培训和迁移更多代码。

到目前为止,我们大约有 50% 的团队使用 TypeScript,在主仓库中,有 10% 的文件已经被转换成 TypeScript。通过这种渐进的方法,使团队迁移至 TypeScript 的过程更加顺畅。

如果从第一天开始就要求每个人应该使用 TypeScript, 那么接下来的每个人都会遇到同样的问题。

相反,我们先在小范围内使用 TypeScript ,然后总结一些经验技巧,当我们进行大规模推广时,这些经验技巧也会用得上。

六、迁移策略

我们已经探索出了几种将代码迁移至 TypeScript 的方式。

1、混合使用

我们最初的迁移策略的是混合使用 JS/TS。

让我们来看看,在主仓库中这个策略是如何进行的。

这是我在 airbnb.com 上找到的一个简化版本,并且给它们起了一个比较合理的名字, 所以这里不存在公司的隐私信息。 让我们放大 homes 项目,看看使用混合策略转换它会是什么样子。

我们添加了一个 TypeScript 配置文件,并将各个文件从 js 重命名为 ts,或 jsx 重命名为 tsx。

TypeScript 报错了,那我们动手去修复他们吧。

TypeScript 的一个很棒的特性是,在编译和运行之前,并不需要转换所有代码。这个配置选项(allowJS)允许 JS 和 TS 文件共存,在这一点上,我们可以看到网站仍能继续运行。

我们不需要暂停开发而去迁移整个项目,我们可以一步步来,我们可以挨个迁移文件。我们会重复这个过程,直到整个项目被迁移。

2、迁移技巧

在关于迁移的话题上,我想花些时间和大家分享一些我们认为有用的技巧。

  • any 大法

第一个是 $TSFixMe,我们通过 TypeScript 的 any 类型添加了一个全局类型别名,这意味着它可以为任何类型。

我们将它称之为 $TSFixedMe,表明在代码向 TypeScript 迁移完成后,再来将类型修正。

平时最佳实践是避免使用 any,因为它会造成类型安全丢失,但它在迁移过程中会很有帮助。

  • @ts-ignore 注释

使用 @ts-ignore 注解可以做到忽略下一行错误。

正确地输入一个文件可能涉及一些深层依赖链解析 — 类似于复杂对象。

我们可以尝试通过首先转换子文件来避免这种情况,但有时这是不可避免的。

因此,$TSFixedMe@ts-ignore 注释能够帮助拆分这些内容,同时则会增加这些检查工作。

这些都是暂时的,我们计划添加类型覆盖工具,在我们后面做类型改进时提供帮助。

  • 类型组合

在 JSX 中,我们在 React 组件上使用 propTypes 进行运行时类型检查。

在将 jsx 转换为 tsx 的时候,我们可以删除 propTypes 直接用 TypeScript,也可以在 propTypes 基础上添加 TypeScript。

在我们所分享的 React 项目中,我们想保留传参类型,以便别人使用的时候仍然可以获得运行时检查。

为了避免重复两次类型声明,那就需要与这些类型保持同步。我们创建了一个 Props 类型,通过它将给定的 propTypesdefaultProps 来派生出一个 TypeScript 类型。

这样,propTypesdefaultProps 组合并得到这个最终类型,如图

如果你好奇它是如何工作的,你可以查看我在 git 上分享的 代码片段

3、All-in TS 策略

最近我们已经在使用 修订迁移策略 All-in TS 进行实验, 让我们回过来再看看这个 homes 项目,然后对它们进行使用 All-in 策略,然后在看它工作怎么样。

我们从 JS 形式的文件开始,我们把所有的文件都改成TS形式的。

然后让项目编译,可能我们使用一些比我们想要的更宽松的类型,但其实我们已经开启了TS最严格的检查配置。

然后我们接下来再继续改进类型,移除 $TSFixedMe 以及 @ts-ignore 语句,这与 JS/TS 混合策略相比起来有一些优势。

通过类型逐步改进比通过文件逐步改进更为简单(两种策略的对比)。

如果你正在开发一个新功能,你只需要关注新添加的类型,然后简单的修复这个它即可。而不是先转换整个文件来修复所有错误,然后再添加你所需要类型。

不用重命名文件也意味着更方便查看。

有时候,如果一个文件在一次提交时被重命名,然后在别的提交中修改。他们会在 code review 中单独出现,程序员必须要合在一起看才能知道变化了什么。

后一种策略还能清楚地知道缺少哪些类型。

TypeScript 类型推导能力十分强大,我们可以在编写代码的时候大量使用它。为了通过编译,有些文件需要对 TS 做一些调整,TS 就可以推断出剩余部分。

还有就是开发者们可能有一个固定的思维模式,他们并不会根据文件扩展名来切换思维。

于是就出现了:为什么我不能在这里添加类型?为什么我不能在那里得到编译错误的疑问(JS/TS 混用)?之类的问题。 那些类型在所有文件中都可以添加、使用、检查。

4、大型项目迁移策略(AST)

译者注:如果有小伙伴想更多的了解 AST,可以参考我之前的文章《AST 与前段工程化实战》

上面的方案听起来都很不错,但是我们该如何迁移我们整个代码呢?

对于大规模代码修改而言,Codemods 是一种十分强大的工具。

拿最简单的形式来说,就好比是我们在我们的项目中所使用的全局搜索和替换,你也许在你之前的 IDE 里面干过这件事

这些 Codemod 库可以通过正则来替换,但它们很不稳定,可能会因细微的代码风格变化而终止。

或者我们可以使用之前某人已经讲过的抽象语法树。

这就是这段代码用 AST 来表达的形式。从上图能看到,左侧的代码都一一对应着右侧抽象语法树上的节点,所以为了好玩,我们想写一个 Codemod 来反转代码中的所有标识符。

我们将我们的代码作为输入参数, 根据这个创建出 AST, 修改 AST 然后产生新的代码。

这里的关键是我们以编程方式进行此更改。如果你手上需要修改的文件数并不多的话,我们可以一个个的去修改, 但如果一旦文件数量达到数千个以上,这种手动去修改的想法可能会令人感到十分心累。

因此我们 Airbnb 采用了 Facebook 的 jscodeshift 来进行这种大量的代码重构,这个转换库可以捕获我们刚刚对该 AST 进行的修改并且反转标识符。

我们找到与标识符对应的所有节点,用名字反转,用新节点去替换这些节点,然后得到新的代码。如图

Missy Elliott(歌手)也将会我们感到自豪,所以我们反转了它。大家笑了,很棒!

我在演讲就像 Joy Division 做的那样,我们拿到了代码并且重新改装,找到了成员的标识符然后翻转它。(这段分享者直接现场唱起了 Rap,手动扣 6)

AST explorer 这个网站无法帮你掌握好说唱技巧,但可以帮助你查看你的 Codemods。

在这个网页下,它有一个可以通过源代码输出对应的 AST 的功能,以及在你对代码的改变同时反映到AST树上。

我也在 DefinitelyTyped 提交了关于 jscodeshift 的 PR,这样的话可以来降低大家在使用 TypeScript 与 Codemod 的交互门槛。

5、方案总结

在将 JavaScript 代码迁移到 TypeScript 时,有这几种模式。

对于 React 组件,我们一次次的将静态类属性移动到 class body里面。创建一个 PropsType 表示 React 生命周期方法。

我们将它们编码为Codemod,以便我们可以在更多代码上重复运行它们。

我们通过使用一个叫作 TS Migrate 的工具来将它们进行打包,这个工具的功能是传入一个 JS 项目,然后得到一个编译好的 TS 项目。

随着时间的推移,你仍然需要慢慢找到类型,但它为你提供了一个工作前提。

我们将此工具应用于我们的内部公共的 React 组件库,现在在我们的网站上已经频繁地使用。

我们有内部的类型定义库(DefinitelyTyped),但是因为 React 公共组件库的快速发展,所以做到与时俱进地更新太难了,所以,我们想直接从源码类型出发,这也是我们迁移 TS 的第一个目标。

我们已经将超过 3W 行以上的代码都做了 TypeScript 化。

你们可能认为我们整个团队花了四周的时间才能完成这个。事实上,我们用了一套我们自己的 Codemod 工具,仅需数分钟就完成了。

我们使用来自 propTypes 的类型信息,同时使用 $TSFixMe,并基于此来继续进行优化。

但即便如此,我们也生成了有意义的 TS 声明文件,这样我们可以在其他仓库中进行使用。

在这个例子中,我们可以看到需要合并的代码行数多的有点可怕。

通过使用TypeScript编译器以及在可视化回归测试的帮助下,我们将在 CI 上运行测试。

通过这些测试我可以很自信的说,我的这些改变不会对原来的系统产生任何不利的影响,当然我们还能确保我们的站点仍旧在正常工作。并不需要回滚代码,不可思议吧。

我们现在已经将 TS Migrate 运用在其它的一些地方,同时也在不断优化和迭代它。我们计划在以后会将它运用于更多的 JS 代码上。

我们打算之后将它开源,这样你们也能将它运用在你们的自己的代码迁移上。

七、总结

感谢你们听我讲了这么久, 我想给你一些我们可以从 TypeScript 迁移中得出关键点,并且是可以广泛应用的。

在大型组织中实施变革可能是一项挑战,但强有力的事实依据和相关问题和担忧的解决,可以使我们信服。

采用逐步变化的方式有助于减少摩擦并证明其价值。

一条明确的迁移路线能帮助团队更好的转向新的模式,同时好的工具也能促进这个过渡的过程。

我之所以开始这个工作,是因为之前有个产品组对我的工具感到失望。当我得知公司内部其他人也有这种改变的想法的时候,我便与他们合作并将之进行下去。

与其怨天尤人不如接受现状,只有通过行动才能发生积极的改变。

所以我鼓励你去追求那些可以让你对组织充满激情的事情,让你和你周围的人的生活变得更好。

感谢大家的倾听,同时感谢 AirBnb 为这个项目作出贡献的每一个人,尤其是台下的 Joe 和 Mohsen。还有对其他一些优秀的 Airbnb 工程师表示感谢。

感谢大家的倾听。