[程序员] TypeScript 全搞定: Monorepo 的痛苦和收获


😊 这是我们团队的第一篇技术分享文章,由 原文 翻译而来。欢迎大家拍砖。

开场

我一直有一个 monorepo 的梦想。

我在 Airbnb 工作时接触到 monorepo ,但仅用于前端。 因为对 JavaScript 生态系统的热爱和「快乐」的 TypeScript 开发经验,从大约三年前开始,我尝试在前端和后端使用同一种语言。 这样对于招聘来说还不错,但对于开发来说并没有那么好,因为我们的项目仍然分散在多个仓库中。

💡 在「快乐」这个词周围有引号,因为 TypeScript 确实给我带来了很多乐趣和惊喜,但它也让我有时会想「这怎么能行不通」。

常言道,「重构项目的最佳方式是开始一个新项目」。因此,大约一年前当我开始创业时,我决定使用一个彻底的 monorepo 策略:将前端和后端项目,甚至是数据库 schema ,都放到一个仓库中。

在本文中,我不会比较 monorepo 和 polyrepo ,因为这都是哲学。我将专注于项目构建和演化历程,并假设你熟悉 JS/TS 生态。

最终结果可在 GitHub 上查看。

为什么是 TypeScript ?

坦率地说,我是 JavaScript 和 TypeScript 的粉丝。它的灵活性和严谨性可以和谐共处,这点很让人喜欢:你可以回退到 unknownany(尽管我们在我们的代码库中禁止了任何形式的 any),或者使用超严格的 lint 规则集来统一整个团队的代码风格。

在之前,当我们谈论「全栈」的概念时,我们通常会想象至少有两种编程语言和生态:一种用于前端,一种用于后端。

有一天,我突然意识到它可以更简单:Node.js 足够快(相信我,在大多数情况下代码质量比运行速度更重要),TypeScript 足够成熟(在大型前端项目中运行良好),以及 monorepo 的概念已经被一堆著名的团队( React 、Babel 等)实践了——那为什么不把从前端到后端的所有代码组合在一起呢?这可以让工程师在一个仓库中完成工作而无需切换上下文,并用(几乎)仅一种语言实现完整的功能。

选择包管理器

作为一名开发人员,和往常一样,我迫不及待地想开始写代码。 但这一次情况有所不同。

包管理器的选择对于 monorepo 中的开发体验至关重要。

🔨 太长不看版:我们选择了 lerna 和 pnpm 。

惯性带来的痛苦

那是 2021 年 7 月。自然地,我开始尝试 yarn@1.x,因为之前已经使用了很长时间。Yarn 速度不错,但很快我就遇到了 Yarn Workspaces 的几个问题。例如,未正确提升依赖项,大量 issues 被标记为「fixed in modern」,并都将用户引导至 v2 (berry)。

「 OK ,好的,我现在就去升级。」我不再纠结于 v1 ,并开始尝试迁移。但是 berry 的长篇 迁移指南 把我吓到了,在几次失败的尝试后我便放弃了。

我只是想要它能正常工作

于是关于包管理器的研究开始了。在简单试用后,我被 pnpm 完全吸引:与 yarn 一样快,原生的 monorepo 支持,类似于 npm 的命令,硬链接等。最重要的是,它可以正常工作。作为一个只想要开发产品但不想开发包管理器的程序员,我只是想添加一些依赖项并启动项目,而「不需要也不想要知道」包管理器是如何工作的或任何其他 fancy 的概念。

基于同样的想法,我们选择了一个老朋友 lerna 来执行跨包命令和发布 workspace packages 。

ℹ️ 现在 pnpm 有一个 -w 选项可以在 workspace 根目录中运行命令,同时使用 --filter 可以进行过滤。 因此,你可能可以用专注于发布的 CLI 来替换 lerna 。

定义包职责

一开始很难清楚地确定每个包的最终职责。所以根据现状来制定最佳方案即可,并始终记住可以在开发过程中进行重构。

我们的 初始结构 有四个包:

  • core:后端单体服务。
  • phrases:i18n key → 短语资源。
  • schemas:数据库和共享的 TypeScript schemas 。
  • ui:一个与 core 交互的 web SPA 。

全栈的技术栈

由于我们正在拥抱 JavaScript 生态系统并使用 TypeScript 作为我们的主要编程语言,所以很多选择都很简单(基于我的个人偏好 😊):

  • koajs 用于后端服务 (core):在 express 中使用 async/await 有一段艰难的体验,所以我决定使用有原生支持的框架。
  • i18next/react-i18next 用于 i18n (phrases/ui):喜欢它的简单 API 和良好的 TypeScript 支持。
  • react 用于 SPA (ui):只是个人喜好。

那 schemas 呢?

这里仍然缺少一些东西:数据库选型和 schema <> TypeScript 类型定义之间的映射。

「通用的」与「武断的」

我尝试了两种流行的方法:

  • 使用 ORM (并伴随许多装饰器)。
  • 使用像 Knex.js 这样的 query 生成器。

但在之前的开发过程中,两者都产生了一些奇怪的感觉:

  • 对于 ORM:我不喜欢装饰器,并且数据库的另一个抽象层会导致团队的学习成本增加和更多不确定性。
  • 对于 query 生成器:就像在编写带有一些限制的 SQL (以一种好的方式),但它不是真正的 SQL 。 因此,我们需要在许多场景中使用 .raw() 进行原始 query 。

然后我看到了这篇文章:「停止使用 Knex.js:使用 SQL 查询生成器是一种反模式」。 标题看起来很激进,但内容很棒。 它强烈地提醒我「 SQL 是一种编程语言」,我意识到可以直接编写 SQL (就像 CSS 那样,我怎么就忘记了这件事!)来利用原生语言和数据库特性,而不是添加另一层抽象来损失一些能力。

总之,我决定「武断地」使用 Postgres 和 Slonik(一个开源 Postgres 客户端),正如文章所述:

…the benefit of allowing user to choose between the different database dialects is marginal and the overhead of developing for multiple databases at once is significant.

SQL <> TypeScript

直接编写 SQL 的另一个优势是我们可以轻松地将其用作 TypeScript 类型定义的 single source of truth 。 我编写了一个 代码生成器 来将 SQL schemas 转换为在后端使用的 TypeScript 代码,结果看起来还不错:

// THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. import { OidcClientMetadata } from '../foundations'; export type OidcClient = { clientId: string; metadata: OidcClientMetadata; createdAt: number;
};
// ...

如果需要,我们甚至可以将 jsonb 与 TypeScript 类型连接起来,并在后端服务中处理类型验证。

🤔 为什么不使用 TypeScript 作为 SSOT ?

这是曾想到的一个方案。 一开始听起来很吸引人,但 SQL 可以精确地描述数据库 schemas 并保持一个方向的工作流(参见下一节),而不是使用 TypeScript ,然后「转译回」 SQL

结果

最终的依赖结构如下所示:

Dependency architecture

你可能会注意到它是一个单向图,这极大地帮助我们保持了清晰的架构以及随着项目的增长而扩展的能力;同时,代码(基本上)都使用 TypeScript 编写。

开发体验

由于这部分内容比较偏技术细节,感兴趣的伙计可以移步 原文 的 Dev experience 章节查看。

结束语

我们的团队已经在这种方法下开发了一年,并对此非常满意。访问我们的 GitHub 仓库 以查看该项目的最新状态。总结一下:

不爽的地方

  • 需要熟悉 JS/TS 生态
  • 需要选择合适的包管理器
  • 需要一些额外的一次性设置

收获

  • 在一个仓库中开发和维护整个项目
  • 简化编码技能要求
  • 共享代码风格、schemas 、phrases 和 utilities
  • 提高沟通效率

    • 不再有类似的问题:API 定义是什么?
    • 所有工程师都在使用同一种编程语言进行沟通
  • 轻松的 CI/CD

    • 使用相同的工具链进行构建、测试和发布

这篇文章还有几个主题没有涉及:如何从头开始设置仓库、添加新包、利用 GitHub Actions 进行 CI/CD 等。限于篇幅在此先不展开,如果有感兴趣或未来想看到的内容欢迎留言讨论。

发表回复

您的电子邮箱地址不会被公开。