TypeScript 一站式解决方案:Monorepo 的痛点和收获
在这篇文章中,我不会比较 monorepo 和 polyrepo,因为这都是关于哲学的问题。相反,我将着重讨论构建和演变的经验,并假设你熟悉 JS / TS 生态系统。
引言
我一直有一个梦想,就是 monorepo。
我在为 Airbnb 工作时看到了 monorepo 方法,但是它只用于前端。由于我深爱 JavaScript 生态系统和“快乐”的 TypeScript 开发经验,我开始在大约三年前将前端和后端代码用同一种语言编写。这很好(对于招聘来说)但对于开发来说并不那么好,因为我们的项目仍然分散在多个仓库中。
正如人们所说,“重构一个项目的最好方法就是开始一个新的”。所以,当我大约一年前开始我的创业公司时,我决定使用全面 monorepo 策略:将前端和后端项目,甚至数据库模式,都放入一个仓库。
在这篇文章中,我不会比较 monorepo 和 polyrepo,因为这都是关于哲学的问题。相反,我将着重讨论构建和演变的经验,并假设你熟悉 JS / TS 生态系统。
最终结果可在 GitHub 上查看。
为什么选择 TypeScript?
坦白说,我是 JavaScript 和 TypeScript 的粉丝。我喜欢它的灵活性和严谨性的兼容性:你可以回退到 unknown
或 any
(尽管我们在代码库中禁止了任何形式的 any
),或者使用超严格的 lint 规则集来对齐团队的代码风格。
我们在谈论“全栈”概念之前,通常至少想象两个生态系统和编程语言:一个用于前端,一个用于后端。
有一天,我突然意识到可以更简单:Node.js 足够快(相信我,在大多数情况下,代码质量比运行速度更重要),TypeScript 足够成熟(在大型前端项目中表现良好),而 monorepo 的概念已经被一些著名团队(如 React,Babel 等)实践过 - 那么我们为什么不将所有的代码都合在一起,从前端到后端呢?这样,工程师可以在同一个仓库中不需要进行上下文切换就能完成工作,并用(几乎)一种语言实现一个完整的功能。
选择包管理器
作为一个开发者,和往常一样,我迫不及待地开始编码。但这次,情况有所不同。
包管理器的选择对 monorepo 中的开发体验至关重要。
惯性的痛苦
那是 2021 年 7 月。我开始使用 [email protected]
,因为我已经使用它很长时间了。Yarn 很快,但很快我就遇到了几个问题,比如 Yarn Workspaces。例如,不能正确地提升依赖,还有很多问题都被标记为 “在现代版中修复”,它把我引向了 v2(berry)。
“好吧,我现在进行升级。”我停止了对 v1 的纠结,开始进行迁移。但 berry 的长迁移指南吓到了我,我在几次尝试失败后放弃了。
一切正常
所以,我开始研究包管理器。我试了一下 pnpm
,并被其所吸引:快如 yarn,原生的 monorepo 支持,与 npm
类似的命令,硬链接等。最重要的是,它一切正常。作为一个想要开始一个产品但不开发包管理器的开发者,我只想添加一些依赖项,而无需知道包管理器是如何工作的或者任何其他炫酷的概念。
基于同样的理念,我们选择了一个老朋友 lerna
,用于跨包执行命令和发布 workspace 包。
定义包范围
在一开始,很难清楚地弄清各个包的最终范围。根据现状,你可以做出最好的尝试,记住你可以在开发过程中进行重构。
我们的初始结构包含四个包:
core
:后端单体服务。phrases
:i18n 密钥 - 短语资源。schemas
:数据库和共享 TypeScript 模式。ui
:与core
交互的网络 SPA。
用于全栈的技术栈
既然我们正在接纳 JavaScript 生态系统,并使用 TypeScript 作为我们的主要编程语言,很多选择都是明确的(基于我的偏好 😊):
- 对于后端服务(core)使用
koajs
:我在express
中使用async/await
的经验很痛苦,所以我决定使用具有本地支持的东西。 - 对于 i18n(phrases/ui)使用
i18next/react-i18next
:API 的简单性和 TypeScript 的良好支持让我喜欢它。 - 对于 SPA(ui)使用
react
:只是个人偏好。
关于 schemas?
这里还缺少一些东西:数据库系统和 schema < - > TypeScript 定义映射。
通用 vs 观点明 确
在那个时候,我尝试了两种流行的方法:
- 使用装饰器的 ORM。
- 使用像 Knex.js 这样的查询生成器。
但是在之前的开发中,它们都产生了一种奇怪的感觉:
- 对于 ORM:我不是装饰器的粉丝,而且数据库的另一个抽象层为团队带来了更多的学习成本和不确定性。
- 对于查询生成器:它像写 SQL 但有一些限制(从好的方面来看),但它不是实际的 SQL。因此,我们在许多情况下需要使用
.raw()
来进行原始查询。
然后我看到了这篇文章:“停止使用 Knex.js:使用 SQL 查询生成器是一种反模式”。标题看起来很激进,但内容很棒。它强烈提醒我 “SQL 就是一种编程语言”,我意识到我可以直接写 SQL(就像 CSS,我如何能错过这一点!)以利用原生语言和数据库功能,而不是添加另一层并减少 power。
总的来说,我决定坚持使用 Postgres 和 Slonik(一个开源的 Postgres 客户端),正如文章中所述:
…让用户在不同的数据库方言之间选择的好处是微乎其微,而同时开发多种数据库的开销是巨大的。
SQL <-> TypeScript
编写 SQL 的另一个好处是我们可以容易地把它作为 TypeScript 定义的唯一来源。我写了一个code 生成器 将 SQL 模式转译为 TypeScript 代码,我们将在后端中使用,结果看起来还不错:
我们甚至可以连接jsonb
与 TypeScript 类型,在后端服务中进行类型验证(如果需要)。
结果
最终的依赖结构看起来像:
你可能会注意到这是一个单向图,这大大帮助我们保持清晰的架构,以及在项目增长时的扩展能力。另外,代码(基本上)全都是 TypeScript。
开发体验
包和配置共享
内部依赖
pnpm
和 lerna
在内部 workspace 依赖中做得很好。我们在项目根目录中使用以下命令添加兄弟包:
它将把 @logto/schemas
添加为 @logto/core
的依赖。在保持内部依赖的语义版本的 package.json
,pnpm
还可以在 pnpm-lock.yaml
中正确链接它们。结果会是这样的:
配置共享
我们将 monorepo 中的每个包视为“独立的”。因此,我们可以使用标准方法进行配置共享,这涵盖了 tsconfig
,eslintConfig
,prettier
,stlyelint
,和 jest-config
。参见 此项目 作为示例。
编码, lint,提交
我每天都使用 VSCode 进行开发,简而言之,当项目配置正确时,一切都与往常一样:
- ESLint 和 Stylelint 正常工作。
- 如果你正 在使用 VSCode ESLint 插件,添加以下 VSCode 设置以使它遵守每个包的 ESLint 配置(用你自己的代替
pattern
的值):
- 如果你正 在使用 VSCode ESLint 插件,添加以下 VSCode 设置以使它遵守每个包的 ESLint 配置(用你自己的代替
- husky,commitlint 和 lint-staged 正常工作。
编译器和代理
我们为前端和后端使用不同的编译器:parceljs
对于 UI (React) 和 tsc
对于其他纯 TypeScript 包。我强烈推荐你尝试 parceljs
, 如果你还没有。它的确是一个真正的“零配置”编译器,擅于处理不同的文件类型。
Parcel 托管了自己的前端开发服务器,生产输出只是静态文件。由于我们想要将 APIs 和 SPA 安装在同一个源下以避免 CORS 问题,以下策略有效:
- 在开发环境中,使用一个简单的 HTTP 代理将流量重定向到 Parcel 开发服务器。
- 在生产环境中,直接提供静态文件。
你可以在 这里 找到前端中间件函数实现。
观察模式
我们在每个包的 package.json
中都有一个 dev
脚本,该脚本监视文件更改,并在需要时重新编译。感谢 lerna
,使用 lerna exec
运行包脚本就变得简单了。根脚本会是这样的:
总结
理想情况下,新的工程师/贡献者只需要两步就可以开始:
- 克隆仓库
pnpm i && pnpm dev
结束语
我们的团队已经用这种方式开发了一年,我们对此非常满意。访问我们的 GitHub 仓库 查看项目的最新形态。总结一下:
痛点
- 需要熟悉 JS / TS 生态系统
- 需要选择正确的包管理器
- 需要一些额外的一次性设置
收获
- 在一个仓库中开发和维护整个项目
- 简化了编程技能要求
- 共享代码风格,模式,短语和实用程序
- 提高了沟通效率
- 再也不用问这样的问题:API 是什么定义?
- 所有工程师都在用同一种编程语言进行交流
- 容易实现 CI / CD
- 使用相同的工具链进行构建,测试和发布
这篇文章留下了几个未覆盖的话题:从头开始设置仓库,添加新的包,利用 GitHub Actions 进行 CI / CD 等。如果我详细展开每一个,这篇文章将会很长。请随时评论,让我知道你希望在未来看到哪个话题。