源码科技网
a 当前位置: 源码科技网 » 互联网 » 正文

因此我们将,CoffeeScript,后来我们迁移到了,TypeScript

 小狐 • 2020-06-29 17:50  来源:互联网  E233

if(foo) {bar = // define bar;} else{baz = // define baz;}// Export both regardless.export{bar, baz}

禁用 Coffee 新文件

一开始我们编写了一个遍历代码库的,找到所有.coffee 文件并将其路径加入白名单。对此文件的任何更改都需要经过一位 Web 平台工程师的审核。

同时我们采用了 Bazel 作为构建。在迁移到 Bazel 期间这一暂时失效了,为已有的 Coffee 文件返回了一个空列表,还断言该空列表是已有 Coffee 文件白名单的子集。还好我们很快修复了这个问题,没有造成严重影响。

我们在这里学到了一个教训:如果中带有任何假设,请试着确保它们能够这些假设并在中断时报错。原始应该断言 Coffee 文件列表为非空,这样一旦出错时,就能立刻发现问题。

修复这个问题时,我们对白名单加入了严格的检查,这样文件删除时也必须从白名单中移除,且不能重新引入(除非明确地重新添加文件)这种方法之后用在了所有白名单相关工作上,既能让不符合假设的问题快速暴露,又能避免人们无意间回退迁移工作。这里有一个小的缺陷:缩小白名单会阻断代码审核,但问题不大,我们会尽快(在一个工作日内)接受这些审核。

早期经验:没有遗漏 Coffee 的语法糖

最初选择要迁移的语言时,我们担心的一个问题是:ES6 和 Type 并没有包括 Coffee 的所有特性,比如说没有? 和?. 运算符。

起初,我们以为会遗漏这些:但当采用了 Type2.0 的 --strictNullChecks 后,这就不是问题了。可选链运算符主要用来处理 undefined 或 null 之类的不确定性,而 Type 帮助我们消除了这种不确定性。

有趣的是,optional chaining 和 nulllish coallescing 最近都被重新添加到 vanilla Java 脚本中,并以类型脚本语言显示,尽管有一些小的语法变化与原始 Coffee 变量之间略有差异。

优先级竞争

2016 年下半年,公司成立了一个并行团队,用 React 重新设计和重构我们的网站。他们的目标是:到 2017 年第一季度末(时间接近最初的 M4 里程碑)发布新网站。该项目称为“Maestro”优先级比将他们负责的部分迁移到 Type 的工作更高。此外其他一些团队也会参与其中。

经过讨价还价,Maestro 团队最终承诺在第二季度完成迁移工作。前面他们就用 React 和 Type 重写了很多功能,剩下的文件则在第二季度迁移完毕。

迁移过程中用到“highly edited ”这个工具,强烈鼓励社区转换它们。可惜 100 个文件好像太多了,这个里程碑没有按时交付。

这样来看,删除 Coffee 编译器的计划也得推迟了。除了这 100 个热门文件,后面还有 2000 多个虽然没那么常用,但也时不时用得上的 Coffee 老文件呢。

M5 里程碑在组织中引起了很多混乱,通常把它总结为“去除 Coffee 编译器”

公司内却出现了另一种解释。许多人认为,虽然无法在截止日期之后编写 Coffee,但产品团队可以本应该只读的代码,甚至可以 Coffee,检查新的编译后的代码。

可如果只 check in 已编译的代码,那么大部分代码就不会有 i18n 与 linting 支持了;不想追加投资的话,应假设代码没变才能找回这些支持。

此外,从平台的角度来看,这个里程碑意义不大。去除编译器主要是为了有一个单语言的代码库,并让注意力集中在 Type 工具链上。

不知道“只读 Java”是否比保留为 Coffee 文件更好,用 Bazel 重新实现构建的工作即将完成,并已对 Coffee 和 Type 编译器都了支持。

因此在 6 月,Type 的迁移工作被无限期推迟,完成时间没有 ETA。

事后看来这一决定似乎是不可避免的。假设每个工程日(包括和代码)大约要转换 1000 行代码,那么一位工程师要花一年的时间才能完成迁移。这个速度实际上是非常乐观的,因为实际报告的进度每天大约是 100 行,指望一两个月就完成根本做不到。

至于之前承诺的“20%的时间用于基础工作”我们也没有达成共识。有的人知道这是用来满足基础架构需求的时间,有的人则认为这些时间可以用来偿还自己的技术债。而且 20% 这个限制也形同虚设,没人真的遵守它。

2017 年后,我们再做迁移时就不再开这种空头支票了。

使用 decaffeinate 的新方案

对 decaffeinate 的早期

早在 2017 年 1 月,一些工程师就曾使用 decaffeinate 来简化代码转换工作,甚至开始围绕它构建一些工具来处理 AMD,并通过一些开源代码来清理 React 样式。

不过一些工程师还是决定使用它来手动转换代码,我们在文档中将其记为一种可行的工作流程。基于 decaffeinate 的脚本通常会生成明显无效的代码,这没什么大不了的,因为 Type 在编译时会报告它们。真正的问题是潜在的 bug,它们改变了代码的语义,编译器却发现不了。

六个月后

更令人信服的是,我们的内部人员报告说,使用基于 decaffeinate 的脚本比手动转换的结果更加可靠。

于是我们制定了新计划:将剩余的迁移工作自动化。

现在对于 decaffeinate 无法类型的情况,可以添加为 any,直到 Type 满意为止。这种方法有以下优点:

Web 平台无需再支持 Coffee linting、国际化和编译器

codemod 或静态分析之类工具的改进只需应对一种语言

此时,产品团队的空闲时间不多了,迁移得不到代码所属团队的大量支持。而且要完成目标就要尽量减少引入的错误,有超过 2000 个文件要迁移,但错误超过一打就可能让项目延迟或取消。这意味着我们必须在保持保持现有代码语义的同时进行转换。

两阶段计划

需要针对所有文件创建一个多步流水线方法来完成迁移。

首先,运行 decaffeinate 以生成有效的 ES6。该代码没有类型,甚至包括了 pre-JSX React。我们用一个自制的 ES6 到 Type 转换器处理这段 ES6 代码。

全面 decaffeinate

decaffeinate 有一些选项可以生成更漂亮的代码,代价是降低代码的正确率。这些选项以 --loose 开头。最初包括以下选项:

--loose-for-expressions

--loose-for-includes

--loose-includes

这样就无需用 Array.from 包装代码的大部分内容。但尝试并后,我们发现了很多足以让我们对这些选项失去信心的错误—它们很可能引入了回归。

而下面这些选项引发错误为数不多,因此最终使用了它们:

--prefer-const

--loose-default-params

--disable-babel 构造方法

decaffeinate 会留下有关潜在样式问题的注释,例如。

此后,我们使用了几个 codemod 来清理生成的代码。首先,使用 Java-codemod 转换函数,例如 function {}.bind(this) 转换为箭头函数: => {}。接下来,对于导入了 React 的文件,使用 react-codemod 了旧的 React. 调用,并将 React.createClass 的实例转换为 class MyComponent extends React.Component。

这一过程生成了可运行的 Java,但仍使用 AMD 模块格式。就算修复了这个问题,它也没有使用我们的设置进行类型检查。我们希望最终的 Type 代码使用与其余代码相同的标志,尤其是 noImplicitAny 和 strictNullChecks。

我们必须编写自己的自定义转换才能进行类型检查。

构建一个 ES6 到 Type 转换器

自制转换器有很多工作要做:通过迭代便能解决影响文件的所有问题,为此需要编写一种工具来自动处理以下问题。

将 AMD 转换为 ES6 模块格式

首先,需要将 AMD import 为 ES6 import。

下面的代码:

define( “library1” “library2”, function( lib1, lib2){})

会变成:

import* aslib1 from“library1”import* aslib2 from“library2”

在 Coffee 中,销毁 import 是一种常见的模式,与 named import 关系很近。因此我们将:

define( “m1” “m2”, function( M1, {somethingFromM2}){vartmp = M1(somethingFromM2)})

转换为:

import* asM1 from“m1”import{somethingFromM2} from“m2”vartmp = M1(somethingFromM2)

对导出进行转换。如下代码:

define function {return{ hello: 1}}

变为:

export {1 as hello}

当无法转换为 named export 时,便回退到使用 export = 。例如:

define( function( ){letSomething;returnSomething = ( function( ){Something = classSomething{}returnSomething;})})

变为:

letSomething;Something = ( function( ){Something = classSomething{}returnSomething;})export= Something。

对于未用到的导入,之后会再做清理,以避免某些模块会产生全局副作用。因此我们改为将其转换为 import “x” 样式,并注释说这可能是没必要的。

类型签名

接下来,我们必须将每个函数参数和 var 注解为 any 类型。例如,function(hello) {} 变为 function(hello: any) {} 。

我们还需要为在类内部分配给 this 的每个属性添加一个类属性。例如:

classHello{constructor{this.hi = 1;}someFunc {this.sup = 1;}}

会转换为:

classHello hi: any;sup: any;...

为 React 添加类型

另外,需要使用带有类型的 React.Component 对 React 类组件进行注解。这些更改消除了许多 Type 错误。

为转换编写文档

因为不想丢失任何给定文件的版本控制历史,所以我们自动在每个文件的顶部添加了一条,说明如何查找原始 coffee 版本。

修复类型错误

我们不想添加不必要的 any;但就算经过上述管道处理,仍然会遇到数千种类型错误。因此,转换管道中的最后一步是一个脚本,其运行类型检查,解析类型检查输出,根据每个错误代码尝试在关联的 AST 节点上插入适当的 any 用法。

一开始,我们在脚本里使用了 node-falafel,但发现用它时需要解析 Type,所以我们 fork 了 falafel,进而使用 tslint-eslint-parser 来替代它;这样我们只需重写需要更改的代码即可。

保持专注

我们的目标不是要做出最优秀的转换工具,而是要转换代码库。首先,从小的内部功能入手来工具,用它们来捕获转换工具中的崩溃以及读取输出时发现的明显错误,当不再出现转换崩溃之后,便开始在随机的代码库子集中查看数据类型错误。这暴露出一些非常常见的问题,例如无效变量和复杂表达式中的类型错误,这些问题都不难解决:可以直接删除无效变量,尽管在默认状态下,保留它们的初始化器,以防表达式会产生其它副作用 - 将类似这样的复杂表达式封装成:this as any.foo 。但是:这种方法变得越来越低效,所以后来我们开始改变策略。

当将整个代码库可靠地转换为 Type 后,便开始在整个代码库上试运行,并对结果进行类型检查。我们将类型错误按代码分组 例如。“TS7030”,并统计了发生的情况。这样就可以专心针对最常见的错误修复程序,避免浪费时间和精力了。

这是一个重大转折点。在此之前,我们一直在不停地编写修补程序,以修复我们决定手动的各个文件中不时出现的各种错误。即便这样,我们还是不能确定能否得到一个成熟的工具。通过对每个错误代码的出现情况进行分组和计数,我们能够了解到还有多少工作要做,并且能够集中精力处理发生了十几次以上的类型错误。

对于那些发生频次比较少或至少频次少到不足以需要费力去通过工具修复的类型错误,我们计划稍后再手动进行修复。有一个令人难忘的例子是,在我们更改策略之前我们发现的一个问题:ES6 类构造器在调用 super 之前无法执行任何操作。在 Coffee 类构造器中随时调用 super 都是合法的,因此当将它们转换为 ES6 类时 Type 会报错。下面这种 Coffee 代码最容易出这种问题:

classFoo extendsBarconstructor: ( @bar, @baz) ->super( )

decaffeinate 后变成:

classFooextendsBar{constructor(bar, baz) {this.bar = bar;this.baz = baz;super; // illegal: must come first}}

在几乎每个这样的实例中,在作业之前调用 super 都是有效的,但是需要几分钟读取超类构造器以对此进行检查。我们发现的 super 函数的误调用只有一两次真正存在问题, 这种情况对于自动代码库过程中发生的错误来说,错误次数不算太多(大约有 20 多次)所以手工对它们修复的难度不是太大。将容易修复的代码单列出来,安全地进行重新排序,对于那些较为复杂的情况,需要人工反复检查,不值得花时间重写。

转换完成时,我们的类型错误率约为:每个转换的文件有 0.5–1 个类型检查错误,需要手动修复。

因工具提升了信心

谈谈一个有趣的错误

这个错误是意外覆盖了导出的函数。

Coffee 与大多数语言的不同之处在于:它没有变量阴影的概念。例如在 Java 中,如果你运行:

letmyVar = “top-level”functiontestMyVar( ){letmyVar = “shadowed”console.log(myVar)}testMyVar;console.log(myVar)

它会打印出来:

shadowedtop-level

尽管它们共享相同的名称,但在 testMyVar 中创建的 myVar 与顶级 myVar 是不同的。这在 Coffee 中是不可能做到的。等效代码如下所示:

myVar = “top-level”testMyVar ->myVar = “shadowed”console.log(myVar)testMyVarconsole.log(myVar)

打印出来:

shadowedshadowed

在代码中找到一个实例,如下所示:

define->sortedEntries= ... ->...sortedEntries= entries.sortBygetSortKey, cmpSortKey...return{sortedEntries}

sortedEntries 被为一个函数,但其自身的函数主体被一个实体数组覆盖。第一次调用该模块后,对模块内部 sortedEntries 的任何调用都将失败;但由于 sortedEntries 函数导出的是副本,因此我们从未发现此问题。该代码翻译为:

letsortedEntries = function( ){...sortedEntries = entries.sortBy(getSortKey, cmpSortKey)}export{ sortedEntries }。

由于 Type 代码使用的是 ES6 模块而不是 AMD 模块,因此 sortedEntries 将作为引用而不是副本导出。这意味着当另一个模块导入 sortedEntries 并调用它时,sortedEntries 成为了一个数组,随后对其进行的任何调用均将无效。

遇到过一次这个错误后,我们在翻译代码中添加了一个 assert ,如果发现导出的函数被重新分配时就能解决问题。

降低从稀松模式转换为严格模式的风险

在构建这些工具的过程中,我们意识到从 AMD 转换为 ES6 模块的副作用是:将有史以来第一次为绝大多数代码启用严格模式。

乍听起来,这似乎很可怕;为此我们通读了 严格模式的 MDN 文档,并制作了可预期行为的更改清单,逐一浏览清单,并找出减轻它们影响的方法。

对于大多数更改,我们发现 Type 解析器或类型检查器就能处理了 -—Type 会正常抱怨新的语法错误。有些更改则可以通过我们的代码搜索工具轻松验证。还有些更改则不是问题,因为 Coffee 实际上在其代码生成中并未使用有问题的结构。

关于 eval、.caller 和.callee 的更改:我们在代码库中很少使用 eval,在 Coffee 中都没有使用。并且我们没有使用.caller 和.callee,因此不必担心它们。

剩下的最后一类:只能通过运行代码来验证的更改。其中,与 eval 有关的更改是无关紧要的,而 arguments 很少用,很容易处理。这下需要担心的行为更改只剩下 3 种:

给不可写属性、getter-only 属性以及非扩展对象的属性的分配时会报错。向由 Object.freeze 冻结的对象写入属性是我们最有可能遇到的形式。

删除不可删除的属性现在会报错。

对 this 行为的更改—不再有 boxing,也不再有隐式 this=window 行为。

我们实际上无法提前知道这三个更改是否会带来问题,但现在这份简短的清单使我们更容易风险了。

还值得一提的是,代码库中最古老的部分是在引入 AMD 和 RequireJS 之前就以非模块化代码编写的内容,其中我们最担心的是非严格模式的行为可能是代码正常运行所必需的。

我们发现可以将代码转换为 Type,而无需将其转换为 ES6 模块。这样一来便可以保持稀松模式。虽然这意味着我们在这部分代码中基本上没有跨模块的类型检查,但我们认为这是可以接受的折衷方案。

第一次转换后的特征

我们首先对 Jasmine 套件开始了大规模转换(后来我们迁移到了 Jest)这样一来,便可以确保以后的迁移不会同时更改和代码,于是更有信心不引入静默错误。转换了 Jasmine 之后,我们开始寻找生产代码中第一个转换的候选者。

附带说明:因为我们最近投资采用了 Bazel 作为构建工具,并且以此工具作为我们和集成框架的基础,所以很容易确定一个 bug 是否是由更改引起的。由于我们使用 Bazel 和自己的 itest 工具服务,我们可以轻松查看之前的版本,并对其运行 itest。通过在代码的确切版本上重建和启动 dev 服务的副本,很容易看到错误是否是由更改引入的。Dropbox 工程师本杰明·彼得森(Benjamin Peterson)在 2017 年 Bazel 大会上发表的关于集成的演讲中谈到了 itest 是如何运行的。

严谨的意义

编写代码转换器时我们学到的一条经验是:你必须严谨,涵盖每个角落才行。明确指出哪些内容没有覆盖是非常重要的,因为错过的任何场景都可能会出错。如果要编写自己的转换工具,请参考以下提示:

每当你为一个 node 类型添加转换时,请在文档中查看需要覆盖的所有情况。

如果你认为某个 node 类型不太可能出现并且不值得覆盖,请抛出一个错误;这样一来,如果它确实出现在代码中,你就不会感到惊讶了。为此,我们高度依赖 ESTree 规范 和 ts-estree 源代码。

每当你发现错误时,请搜索你的代码库以查找该错误模式的其他实例并修复它们。否则,你会在生产中不停遇到类似的错误,结果焦头烂额。

尾 声

在项目的最后几周,我们一次转换大约 100-200 个文件。通过改进工具,让这种规模的转换可以在几个小时的工程时间内完成。这意味着可以在一两天内就从零开始集成到主分支中,尽量降低重新部署的开销。大部分时间都花在类型检查和调整上了,因为在前期验证工作中已经解决了 Jasmine 和 Selenium 的大多数问题。

我们的一个技巧是在代码库上运行 tsc --noEmit --watch 快速迭代,这样就可以在大约 10 秒内获得增量类型检查结果。之所以能这么快,部分是因为在迁移过程中从 Type 2.5 升级到了 2.6,后者大幅提升了 --watch 的速度。

为了保持专注,我们还在团队区的白板上写上了剩余的 Coffee 文件的计数,并在每次将代码合并到 master 分支时数据。

转换完最后的 Coffee 之后,我们与内部客户一起畅饮咖啡,欢送 Coffee。

只有两个错误

我们一开始就知道,如果引发了太多错误,整个项目最后都会报销。结果,我只记得有两个错误进入了生产环境。大多数潜在错误是在手动修复类型检查错误时引入的,尽管我们的覆盖率不高,但它们并没有闯过我们 Jasmine 和 Selenium 的考验。

因此,大多数团队除了意识到他们的代码现在是 Type 之外,并没有感到有什么变化。虽然他们需要重做一些工作,但他们很满意新的 Type 环境,因此我们没有收到太多抱怨。

我们最后才转换那些最担心出问题的团队的代码,这样就能用之前零错误的表现说服他们了。但有一个团队还是不放心,于是我们承诺说:即便出现了重大错误,我们也会 24 小时快速响应并修复(只要他们告诉我们如何重现)还会在一个工作日内解决次要错误。

之所以做出这一承诺,是因为我们对转换脚本充满信心。结果他们并没有遇到重大错误,唯一一个小错误我们也是在异常报告中发现的,在他们第二天上班之前就解决掉了。

还有一些错误一开始他们说是我们的转换造成的,但最后都被我们证明来自于其他原因。

回 顾

最终,自动迁移过程仅花费了大约两个月时间,有三名工程师参与,花费了大约 19 个工程师周。当然,迁移输出的不是大多数人最初想要的理想的 Type,而是一些杂乱无章,遍布 any 的 Type。

这一代价是值得的。它让我们更快地摆脱了 Coffee,这样就不用继续支持 Coffee,也不用让新员工学习这种语言。可以在所有地方使用 Type,同时逐步改进代码样式和类型安全。

在整个过程中我们吸取了很多技术教训,其中可能最重要的教训是:应该将政治和组织资源省下来,用在不能为所有人自动化的那些任务上。尽管没有人特别喜欢 Coffee,而且有些团队可能已经自愿将代码转换为 Type,但让其他人在一年时间里手动转换到 Type 的要求太不切实际了。

事后看来,我们应该尽量自动化那些重复性的劳动,遇到无法自动化,真正需要专业编程知识的问题时才去动用宝贵的人力资源。

现 今

后记:快进到 2020 年,Dropbox 已经有了 200 万行 Type 代码。我们的整个代码库都是静态类型的,并且内部有一个繁荣的 Type 社区。Type 使我们能够扩展工程组织,使各个团队可以独立工作,同时在整个代码库中保持清晰的。

Type 这种语言已迅速普及,我们很幸运能成为最早迁移的大公司之一。因此我们得以发展这一领域的专业知识并与外界。我们的 JS 公会定期 Type 的技巧和窍门,我们的工程师喜欢他们使用的语言。一位工程师甚至撰写了一份案例研究,总结 Type 不是 Java 严格超集的那些情况。

仍然有少数文件带有“此文件从 coffee 迁移过来”的注释,但这些文件仅占代码库的一小部分。我们现在的代码有良好的类型,并且一般会 push back 那些 any。最近,我们将所有代码库都升级到了 Type 3.8。—Matthew Gerstman

进群还有超值活动等你参加,快来加入我们吧!

本文相关词条概念解析:

代码

代码就是程序员用开发工具所支持的语言写出来的源文件,是一组由字符、符号或信号码元以离散形式表示信息的明确的规则体系。代码设计的原则包括惟一确定性、标准化和通用性、可扩充性与稳定性、便于识别与记忆、力求短小与格式统一以及容易修改等。源代码是代码的分支,某种意义上来说,源代码相当于代码。在现代程序语言中,源代码可以是以书籍或者磁带的形式出现,但最为常用的格式是文本文件,这种典型格式的目的是为了编译出计算机程序。计算机源代码的最终目的是将人类可读的文本翻译成为计算机可以执行的二进制指令,这种过程叫做编译,它由通过编译器完成。

网友评论Translation