我们之前将React前端从JavaScript移植到了TypeScript,但将后端仍使用 Ruby。最近,我们也将后端移植到了 TypeScript。
本文最初发布于 execute program 官方博客,经原作者授权由 InfoQ 中文站翻译并分享。
使用 Ruby 后端,我们有时会忘记一个特定的 API 属性包含一个字符串数组,而不是一个字符串。有时我们更改了一个在多个地方引用的 API,但却忘记了更新其中一个地方。在测试覆盖率不到 100%的系统中,这些都是动态语言常见的问题。(即使覆盖率百分之百,也仍然会发生这种情况;只是可能性小些。)
然而,在前端移植到 TypeScript 之后,这类问题就消失了。我后端开发经验更丰富些,但我在后端犯的低级错误比在前端多。这说明移植后端是一个好主意。
在 2019 年 3 月,大约两个周的时间内,我将后端从 Ruby 移植到了 TypeScript。很顺利!我们在 2019 年 4 月 14 日将其投入生产,当时处于封闭测试阶段。没出什么问题;用户都没有注意到。这是后端移植准备以及刚刚移植之后的时间线:
在移植期间,我编写了大量的自定义基础设施。我们有一个 200 行的自定义测试执行器;120 行的自定义数据库模型库;一个更大的、横跨前后端代码的 API 路由库。
在我们的自定义基础设施中,路由是最值得讨论的部分。它封装了Express,增强了在客户端和服务器代码之间共享的 API 类型。这意味着,当 API 的一端发生变化时,另一端甚至无法编译,直到更新到相互匹配为止。
这是博文列表的后端处理程序,是系统中最简单的一个:
如果我们将 posts 键重命名为 blogPosts,我们将得到下面这行编译错误。(简单起见,我在这里删除了错误消息中实际的对象类型。)
每个端点由一个 api.someNameHere 对象定义,它在客户端和服务器之间共享。注意,处理程序定义没有直接命名任何类型;它们都是从 api.blogIndex 参数推断出来的。
这既适用于像 blog 这样的简单端点,也适用于复杂端点。例如,我们的课程 API 端点有一个很深的键.lesson.steps[index].isInteractive,它是一个布尔值。现在,下面所有这些错误都不可能出现了:
如果我们试图在客户端访问 isinteractive 或从服务器返回它,则无法编译;必须是 isInteractive,大写的“I”。
如果对于 isInteractive,服务器返回一个数值,则无法编译。
如果客户端将 isInteractive 存储在类型为 number 的变量中,则无法编译。
如果我们修改 API 定义,将 isInteractive 由布尔型改为数值型,那么客户端和服务器都无法编译,直到它们被修复。
这些都不涉及代码生成;这是用io-ts和几百行自定义路由代码完成的。
定义这些 API 类型有一些工作量,但并不困难。当修改 API 的结构时,我们必须知道结构是如何修改的。我们将我们的理解写在 API 类型定义中,然后编译器向我们显示所有需要修改的地方。
你得使用一段时间之后,才能意识到它的价值。我们可以在 API 中将大型子对象从一个地方移到另一个地方,重命名它们的键,将一个对象分割成两个独立的对象,将多个对象合并为一个新对象,或者分割和合并整个端点,而不用担心我们在客户端或服务器中是否遗漏了相应的变化。
这是一个真实的例子。最近,我在四个周末,花了大约 20 个小时,重新设计了Execute Program的 API。整个 API 的结构都发生了变化,API、服务器和客户端的差异代码总计达数万行。我重新设计了服务器端的路由定义代码(如上面的 handleGet);重写了 API 的所有类型定义,对其中许多 API 的结构进行了大幅的更改;重写了客户端调用 API 的每一部分。在这个过程中,292 个源文件中有 246 个被修改。
在整个重新设计的过程中,我只依赖于类型系统。在这 20 个小时的最后一个小时里,我开始运行测试,大部分都通过了。最后,我们做了一次完整的手工测试,发现了三个小 Bug。
这三个 Bug 都是逻辑错误:条件语句意外出错,而类型系统通常无法检测到此类错误。错误在几分钟内就解决了。重新设计的 API 是在几个月前完成部署的,所以这篇文章就是它提供的(以及Execute Program的其他所有内容)。
(这并不是说,静态类型系统可以保证我们的代码总是正确的,或者保证我们不需要测试。但是重构变得容易多了。我们将在下一篇文章中讨论更大的测试问题。)
有一个地方我们使用了代码生成:我们使用schemats从数据库结构生成类型定义。它连接到 Postgres 数据库,查看列的类型,并将相应的 TypeScript 类型定义转储为普通的“.d.ts”文件,供应用程序的其余部分使用。
每次运行迁移时,迁移脚本都会重新生成数据库模式类型文件,因此,我们不必对这些类型做任何手工维护。数据库模型使用数据库类型定义来确保应用程序代码可以正确地访问数据库的每个部分。没有丢失的表;没有丢失的列;没有向不可为空的列中存入空值;不要忘记在可为空的列中处理 null;所有这些都是在编译时静态验证的。
所有这些一起创建了一个完整的静态类型链,从数据库一直到前端 React props:
如果数据库列的类型发生变化,其他服务器端代码(如 API 处理程序)将无法编译,直到所有东西都更新到与之相匹配。
如果服务器端 API 处理程序与客户端 API 消费者不匹配,则一个或两个都无法编译。
如果客户端 React 组件与来自 API 的数据不匹配,它们将无法编译。
自完成这次迁移之后,就再也没有出现过任何 API 不匹配却通过编译的情况。我们再也没有因为 API 双方在数据格式上的差异而导致生产环境失败。这不是因为自动化测试;我们不为 API 本身编写任何测试。
有这些保证非常棒:我们可以专注于应用程序中重要的部分!我花在争论类型上的时间非常少——远远少于我花在追踪那些透过 Ruby 或 JavaScript 代码层传播的令人困惑的错误上的时间,这些错误在远离错误原始来源的地方导致了令人困惑的异常。
下面是我们后端移植的开发时间线。为了评估结果,我们花了很多时间,并写了很多新代码:
对于类似本文这样的文章,有一个常见的反对意见我们还没有讨论:难道通过编写测试就不能得到相同的结果吗?绝对不能,我们将在下一篇文章中讨论!
原文链接:
Porting to TypeScript Solved Our API Woes
评论