2017年伟大的CoffeeScript到打字脚本迁移

2020-05-16 04:54:33

编者按:当我在2017年5月第一次加入Dropbox时,我们的CoffeeScript到TypeScript的迁移已经接近尾声。如果您想要对CoffeeScript文件进行更改,那么在您使用该文件时将其转换为文字脚本被认为是很有礼貌的做法。我们的部分代码库仍在使用反应域工厂,并且我们有一个早于Redux的自定义通量实现。

我知道我们的网络平台团队正在全速前进,将我们迁移到打字版,但我对迁移的规模和复杂性一无所知。TypeScript已经成为JavaScript事实上的超集,我知道现在是我们讲述这个故事的时候了。其中大部分发生在2017年,但它和以往一样具有相关性。

我联系了大卫·戈尔茨坦(David Goldstein),他是该项目的首席工程师之一,负责撰写这份报告。我们聘请了萨默·马斯特森(Samer Masterson),他是当时网络平台上的另一名工程师,来填写细节。

这个帖子比大多数帖子都长。我们想要捕捉将数十万行CoffeeScript迁移到Tyescript的巨大范围。我们分享了我们最初是如何选择TypeScript的,我们是如何规划迁移的,以及事情什么时候没有按计划进行。

移民于2017年秋季结束。在这个过程中,我们开发了一些相当不错的工具,并成为第一批大规模采用打字稿的公司之一。--马修·格斯特曼(Matthew Gerstman)。

2012年,我们仍然是一家相当好斗的初创公司,大约有150名员工。浏览器中最先进的是jQuery和ES5。HTML5还有两年,ES6还有三年。由于JavaScript本身似乎停滞不前,我们正在寻求一种更现代的Web开发方法。

当时,CoffeeScript风靡一时。它支持箭头函数,智能这种绑定,甚至是可选的链接,比普通的JavaScript早了很多年。因此,两名工程师在2012年花了一周的时间将整个dropbox.com web应用程序从JavaScript迁移到CoffeeScript。由于我们当时还很小,这可以在没有任何重要过程的情况下完成。我们接受了CoffeeScript社区的指导,并采纳了他们的风格建议,最终将Coffeelint集成到了我们的工作流程中。

这种语法方法很流行,我们甚至采纳了来自社区的“如果它是可选的,就不要写它”的建议。

当时,代码库由大约100,000行JavaScript组成。这是通过按照预先指定的顺序连接每个文件而作为单个捆绑包提供的。虽然该公司的许多工程师都接触过这些代码,但全职从事网络工作的人不到10人。

正如您可以想象的那样,这并没有很好地扩展;在2013年,我们采用了RequireJS模块系统,并开始编写所有新代码以符合异步模块定义规范(通常称为AMD)。我们确实考虑了CommonJS,但是NPM和节点生态系统还没有起飞,所以我们选择了为在浏览器中使用而设计的工具。如果我们在几年后做出这个决定,我们很可能会转而选择CommonJS。

这在几年内运行得很好,然而到了2015年底,我们的产品工程师对CoffeeScript感到失望。ES6是在当年早些时候发布的,它包含了CoffeeScript的最佳功能以及更多功能。它支持对象和数组分解、类语法和箭头函数。因此,一些团队开始在他们自己的孤立项目中使用ES6。

同时,我们的CoffeeScript代码库很难维护。因为CoffeeScript(和普通JavaScript)都是非类型化的,所以很容易无意地打破某些东西。防御性编码很常见,但其效果是使我们的代码更难阅读。我们为null和unfined设置了额外的保护措施,在更极端的情况下,我们求助于黑客攻击,使构造函数在没有new的情况下可以安全调用。

class URI构造函数:(X)->;#将URI作为返回新URI实例的全局函数启用,除非@instanceof URI返回新URI(X).。

此外,CoffeeScript是一种基于空格的语言。这意味着制表符和空格可以使代码以不同的功能方式执行。这与构建Dropbox的语言Python非常相似。不幸的是,与Python不同,CoffeeScript对空格更加放任自流,对代码中的标点符号过于宽松;通常,“可选的标点符号”实际上意味着“CoffeeScript会将其编译成与您预期的不同的含义”。

例如,2013年秋天,由于一个错放的空格字符,我们出现了一个重大的生产缺陷。在Python中,这根本不会编译;但是在CoffeeScript中,它编译成了错误的内容。虽然早在2012年,CoffeeScript与Python的相似之处可能有助于它在Dropbox上的采用,但这种差异往往是有问题的。我们的一些更有经验的开发人员选择将输出JavaScript与他们的CoffeeScript代码并排打开。

2015年11月,我们感觉到人们对CoffeeScript越来越反感,于是我们对Dropbox的前台工程师进行了一项调查,发现只有15%的人认为我们应该继续使用CoffeeScript,62%的人认为我们应该抛弃它:

有了这个反馈,我们查看了前端环境,并决定同时尝试打字和普通ES6。我们将这两种技术集成到我们的dropbox.com堆栈中,以确定哪一种最适合我们。我们也考虑了Flow,但它没有Tyescript那么受欢迎,对开发人员工具的支持似乎也较少。我们决定,如果我们要使用一种打字语言,那么打字是更好的选择。虽然这在2020年显然是正确的决定,但回到2015年,它要简单得多。

在2016年上半年,我们让一名工程师将巴别塔和打字稿集成到我们的构建脚本中。我们现在可以在主网站上试用这两种语言了。通过在生产中的测试,我们得出结论,TypeScript实际上是带有类型的ES6;由于团队对类型有偏好,我们决定迁移到TypeScript。

然而,有一个小问题:自2012年以来,我们的代码库已经增长到329,000行CoffeeScript;我们的工程团队也有了显著的增长。因此,不再有一个团队负责整个网站。我们不能像从JavaScript迁移到CoffeeScript时那样快地迁移。

我们着手制定一项移民计划。我们最初的计划有5个主要里程碑:

让M2更上一层楼,更多的教育,完整的调试和测试支持,转换其他重要的图书馆。

M4:将我们编辑次数最多的文件列表迁移到TypeScript;计划于2017年4月发布。

手动将~100个常用编辑文件的列表从Coffeescript转换为TypeScript。原始的CoffeeScript将在git历史记录中可用。

编译并提交任何剩余CoffeeScript代码的输出JavaScript。源CoffeeScript将在GIT历史记录中可用。

需要对此JavaScript代码进行任何修改,以要求首先将整个文件迁移到TypeScript。

2016年下半年,M1、M2、M3均平稳运行。我们构建了一个健壮的咖啡/打字互操作系统。测试很简单:我们重用现有的基于Jasmine的基础设施来运行测试,而不考虑语言(我们后来迁移到Jest,但是这是另一次的故事)。我们集成了TSLint并编写了我们的风格指南,这是Airbnb风格指南的调整版本。

M4和M5造成了更多的麻烦,因为它们实际上需要产品团队将预先存在的代码移植到打字脚本。我们希望先前存在的代码将由拥有它的团队负责。作为一个工程组织,我们已经决定留出20%的产品团队时间用于“基础工作”,并且我们认为这张空白支票的一部分将应用于这个项目。稍后会详细介绍这一点。

我们实现了CoffeeScript和TypeScript的互操作,如下所示:对于每个CoffeeScript文件,我们在我们的Typeings文件夹中创建了一个相应的.d.ts声明文件。我们自动创建了这些,大多数看起来是这样的:

也就是说,我们键入的所有内容都是任意的。对于我们关心的模块,我们可以将它们转换为类型脚本,或者增量地改进类型。对于流行的外部库(如jQuery或Reaction),我们找到了我们可以从D efinally Typed中执行的类型。对于不太受欢迎的库,我们采用了相同的默认存根方法。

我们将所有的TypeScript和CoffeeScript文件放在同一个文件夹中,这样无论是CoffeeScript还是TypeScript,文件的模块ID都是相同的。“我们在了解AMD导入/导出如何对应于打字稿导入和导出语法时短暂地遇到了一些障碍;幸运的是,这大多是直截了当的。我们没有使用--esModuleInterop,它直到TypeScript2.7才可用。

通过导入模块,然后分解{foo},可以在CoffeeScript中读取像export const foo;这样的命名导出。这提供了与名为Imports的普通ES6的良好语法关系。

最令人惊讶的是,在导入到AMD模块时,TypeScript的默认导出等同于对象{Default:.}。

我们的大多数模块都能够使用这些等价物,但我们确实有一些模块可以动态确定它们要导出的内容。作为一种解决办法,我们从每个文件导出了所有可能的导出,但在以前不会返回的情况下将其设置为未定义。

定义([.],函数(.){.)if(Foo){return{bar};}否则{return{baz};}})。

让foo,bar;if(Foo){bar=//定义bar;}否则{baz=//定义baz;}//都导出。导出{bar,baz}。

对于M2,我们在代码库中实现了对新CoffeeScript文件的硬禁令。这并没有阻止人们编辑现有的CoffeeScript-因为周围有很多这样的东西-但它确实迫使大多数工程师开始学习打字脚本。

我们最初实现这一禁令的方式是编写一个测试,该测试遍历代码库,找到所有的.ee文件,并断言找到的所有文件都在白名单上。该列表填充了编写测试时存在的.ee文件的路径。我们的代码审查工具使我们能够要求Web平台工程师审查对此测试文件的任何更改。

与此迁移并行的是,我们采用Bazel作为我们的构建系统。在Bazel迁移期间,此测试短暂中断,返回所有CoffeeScript文件列表的空列表,并开始断言该空列表是旧CoffeeScript文件白名单的子集。幸运的是,在测试失败和我们注意到并修复它之间的这段时间里,只引入了2个新的.cafee文件,这些作者的意图都是好的。

我们在这里学到了一个教训:如果您的测试做了任何假设,请尽量确保它们测试这些假设并在它们被破坏时失败,而不是不测试任何有用的东西就通过。如果断言CoffeeScript文件列表是非空的,我们最初的测试将会受益-因为如果存在这样的断言,任何破坏我们构建该列表能力的事情都会被注意到。

在修复时,我们针对白名单添加了严格的等价性检查,以便在删除文件时,强制它们也从我们的白名单中删除,然后不能重新引入(除非显式重新添加)。自那以后,我们在所有的白名单工作中都采用了这种方法,因为它往往会使测试假设中的错误变得非常明显,并确保我们不会让人们在不知不觉中倒退正在进行的迁移。这有一个小的缺点:缩小白名单的更改会招致我们的阻塞代码审查,但这些是没有争议的,我们试图迅速(在一个工作日内)接受这些审查。

当我们最初选择要迁移到的语言时,我们担心的一个问题是ES6&;TypeScript不包括CoffeeScript的所有功能。虽然我们得到了箭头函数、析构和类语法,但明显缺少的操作是CoffeeScript?然后呢?接线员。

我们原本以为我们会怀念那些。然而,一旦我们采用了TypeScript2.0的strictNullChecks,我们就发现我们不需要它们了。可选链接运算符的大多数用法只是为了处理有关哪些内容可能未定义或为NULL的不确定性,而TypeScript帮助我们消除了这种不确定性。

有趣的是,可选链接和空值合并都是最近重新添加到普通JavaScript中的,并且已经出现在文字脚本语言中,尽管与最初的CoffeeScript变体相比有一些语法上的小变化和不同之处。

2016年下半年,成立了一个平行团队,使用REACT对我们的网站进行重新设计和重新架构。年底,他们得到了新网站在发货前达到的性能目标。该团队的目标是在2017年第一季度末推出新网站,恰好是我们最初的M4里程碑。运输重新设计的网站,代号为“大师”,优先于安排工作,将他们的网站部分迁移到打字;许多其他有网站存在的团队也被请来更新他们的页面,以匹配新设计的布局、颜色和一般设计语言。

Maestro团队最终承诺,虽然他们不会在第一季度完成这项工作,但他们会在第二季度完成。他们坚持了这笔交易;新网站出厂时有许多用Reaction和TypeScript重写的功能,还有那些没有完全重写但在我们经常编辑的CoffeeScript文件列表中的功能,在第二季度移植了。

我们在迁移过程中使用的工具之一是“高度编辑的”CoffeeScript文件的最新列表。我们强烈鼓励社区改装这些。不幸的是,这个问题仍然存在。我们试图访问M4,但是这个列表包含了大约100个文件,并且需要很多社区的鼓励才能得到转换。这个里程碑没有按时发货。

由此推断,很明显,实际删除CoffeeScript编译器的计划不会很快实现。虽然高度编辑的CoffeeScript文件列表只有100个文件,但我们的代码库中仍然有2000多个文件。即使一个文件不被认为是“高度编辑”列表的一部分,它们中的任何一个都只是一个单一的功能请求,而不是有一个团队直接指向它们。

M5里程碑在组织中造成了很多混乱;虽然文档对它的含义非常清楚,但我们经常将其简洁地总结为“去掉CoffeeScript编译器”。

出现了另一种解释。许多人认为,虽然不可能在截止日期之后编写CoffeeScript,但产品团队可以只编辑所谓的只读代码,甚至编辑CoffeeScript,然后签入新编译的代码。

作为一名平台工程师,这个想法令人毛骨悚然。如果我们只是签入编译的代码,我们将失去对I18N和我们代码库的大量子集上的linting的支持;这些东西要与编译的代码一起工作的唯一方法-没有我们没有计划的额外投资-就是假设代码不变。

此外,从平台的角度来看,这个里程碑没有太多意义。摆脱编译器的关键驱动因素之一是拥有单一语言代码库,并将我们的注意力集中在打字工具上。

虽然我们可以假装“只读JavaScript”就是这种情况,但不清楚这是否比将它们保留为CoffeeScript文件更好。正如我们前面提到的,我们还在使用Bazel重新实现我们的构建系统。这项工作即将完成,我们已经支付了实现对CoffeeScript和TypeScript编译器支持的费用。

因此,在6月份,打字稿的迁移被无限期推迟。虽然它仍将发生,但没有预计的实际完成时间。

事后看来,考虑到我们最初的迁移战略,这一决定似乎是不可避免的。假设每个工程日大约有1000行代码转换(包括测试和代码审查),则需要一名工程师一年的时间才能完成迁移。这个速度实际上是非常乐观的,因为实际报告的进度更像是每天100条线路,这将需要近10年的工程时间,或整整10名工程师的时间。

即使我们折衷一下,称其为3-5年工程师年限,指望任何人想要这份全职工作一两个月都是荒谬的。

虽然很容易将一半的代码库声明为不需要迁移的废弃软件,但这是站不住脚的。它不会解决根本的问题,即我们的手动转换计划所需的时间至少比任何人实际想要花费的时间多一个数量级。此外,可能需要在这些部分中的某些部分进行功能开发。

至于承诺的“20%的基础工作时间”,我们认为这将部分花在从CoffeeScript到Tyescript的手动转换上:现实情况是,我们没有仔细考虑过这一点。我们没有就什么是“基础”达成高层协议,也没有就这一时间如何预算达成一致。虽然一半的组织理解这是针对基础设施组织的请求,但一些团队认为这包括他们偿还自己的技术债务的时间。

在基础设施中,没有人真正确保我们对产品团队的要求实际上加起来只有20%。我们在相互竞争,但在规划时没有考虑到这一点。例如,今年上半年最重要的事情之一是将我们的生产系统迁移到更新的Ubuntu发行版和Linux内核,因为Ubuntu 12.04已经到了生命周期的尽头。

虽然许多团队继续为基础改进与新功能工作做一些基于百分比的预算,但自2017年以来,我们没有重复过空白支票的概念来花费在迁移上。

回溯到2017年1月,一些工程师已经开始使用去咖啡因来简化代码的转换,甚至开始围绕它构建一些工具,使其通过一些开源代码来处理AMD和清理反应风格。

不幸的是,我们第一次尝试使用脱咖啡因导致了一次严重的停电。我们转换了我们的i18n库,对其进行了检查、测试并将其交付生产,结果却发现去咖啡因错误地转换了我们未经测试的地区感知排序函数。这只有一个页面使用,但它完全打破了Safari中的那个页面。在这之后,我们看了一下无咖啡因的bug backlog,并被我们看到的几十个类似的问题吓倒了。虽然我们中的一些人对此很感兴趣,但我们不能确定这是否需要我们几个月的时间,还是几年的时间,直到我们可以信任去咖啡因在我们的代码库上运行。考虑到这一点,我们的经理当时不想投资于这种方法。

尽管如此,一些工程师还是决定使用它来帮助他们进行手动转换,我们将其记录为一种可能的工作流程。我们的基于去咖啡因的脚本经常生成明显无效的代码,例如语法上无效的import语句,或者涉及无效重新声明的变量的导入语句。这没什么大不了的,因为TypeScript会在编译时抱怨这些问题。真正的问题是巧妙地注入了错误-更改了代码语义的错误,而不会使代码对编译器明显无效。因此,虽然我们没有足够信任去咖啡因,不能在整个代码库上运行它,但是还是有一些人成功地使用了这个工具。

2017年夏天,一场有趣的比赛。

..