前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >基于 Yarn WorkSpace + Lerna + OrangeCI 搭建 Typescript Monorepo 项目实践

基于 Yarn WorkSpace + Lerna + OrangeCI 搭建 Typescript Monorepo 项目实践

作者头像
QQ音乐技术团队
发布2020-07-13 10:40:16
3.7K0
发布2020-07-13 10:40:16
举报

Lerna 已然成为搭建 monorepo 工程的首选,然而官方文档[1]并没有给出构建 monorepo 项目最后一公里的解决方案。而在这次在迁移搭建全民 K 歌基础库的实践中,在诸如 Orange CI 自动发布 npm 包等问题上就遇到了不少阻碍,我们把经验总结记录如下。

名词解释:

Orange CI:腾讯内部开源的持续集成服务,类似于 Travis CI,一旦代码有变更,就自动运行构建和发布,并输出结果,是实现自动更新版本号及发布npm包的基础。 Monorepo:一种管理组织代码的方式,其主要特点是多个项目的代码存储在同一个 git repo 中 Multirepo:一种管理组织代码的方式,其主要特点是多个项目的代码存储在不同 git repo 中

一. 背景

早期全民 K 歌 web 项目基础库是夹杂在业务项目中,存在着许多问题

  • 基础库潜藏在业务代码
  • 基础库没有按照 package 分类
  • 不适合快速迭代开发
  • 难以对代码追踪溯源
  • 无版本号管理,无代码变更文档
  • 无代码使用文档

所以要更好管理基础库代码,从业务项目迁移基础库代码、独立发布 npm 包是解决问题的关键。

二. 代码管理方案对比

1. Git Submodule 、Git Subtree

优点:方便项目回馈更改 缺点:协同开发分支多、子模块数量多,管理成本高

2. Multirepo 划分为多个模块,一个模块一个 Git Repo

优点:模块划分清晰,每个模块都是独立的 repo,利于团队协作 缺点:由于依赖关系,所以版本号需要手动控制、调试麻烦、issue 难以管理

3. Monorepo 划分多个模块,所有模块均在一个 Git Repo

优点:代码统一管理、方便统一处理 issue 和生成 ChangeLog、调试代码 npm/yarn link 一把梭 缺点:统一构建、CI、测试和发布流程带来的技术挑战、项目体积变得更大

)

一图胜千言,很显然 Monorepo 是解决这次问题的最优解。所以接下来要在项目内采用 lerna + yarn workspace 架构,使用 Typescript 语言编写代码,利用 Orange CI 去完成版本号、ChangeLog 迭代。

三. 改造的实现

1. 依赖管理

由于 Monorepo 的特性,各个 package 之间可能会形成相互依赖,手动进行 npm link 对于多 package 的 Monorepo 来说,无疑是个巨大的负担,因此我们需要一个自动化的 npm link 操作脚本。

其实了解 Lerna 用法的同学都知道,这里只用 Lerna 的命令lerna bootstrap可以完美的解决这个问题,但在这里,我使用 Yarn workSpace 代替 npm,除了保证 package 相互依赖,Yarn还带来显著的优点。

  1. Yarn只使用唯一的yarn.lock文件,而不是每个项目都有一个package-lock.json,这能降低很多潜在性的冲突。
  2. lerna bootstap会重复安装相同的依赖项。
  3. yarn why <query> 命令,能提示为什么安装一个 package,还有什么 package 是依赖该 package,这就方便我们方便理清 monorepo 的依赖关系。
  4. Yarn workspace 是 Lerna 利用的底层机制,而且 Lerna 支持与 Yarn 协同工作。

使用 Yarn workspace,需要在根目录 package.json 添加以下内容

代码语言:javascript
复制
// package.json
{
  "name": "root",
  "private": true,
  "workspaces": ["packages/*"]
}
2. 项目初始化

lerna 初始化项目(采用 independent 管理模式)

代码语言:javascript
复制
lerna init --independent

新增 packages

代码语言:javascript
复制
lerna create @tencent/pkg1lerna create @tencent/pkg2
代码语言:javascript
复制
// pkg1/package.json 配置
// pkg2/package.json 同理
{
 "name": "pkg1",
 "version": "0.0.1",
 "main": "lib/index.js", // 输出目录为lib
 "types": "./lib/index.d.ts" // 声明文件
}

根目录安装 Typescript 依赖

代码语言:javascript
复制
yarn add typescript -W -D

Typescript 完成初始化

代码语言:javascript
复制
// 根目录新建tsconfig.json
{
  "compilerOptions": {
    "module": "es2015",
    "target": "es5",
    "lib": ["esnext", "dom"],
    "baseUrl": "./packages",
    "paths": {
      "@tencent/*": ["*/src"]
    },
  },
  "include": ["packages/*"],
  "exclude": [
    "node_modules",
    "lib"
  ]
}

这个配置对于每个包都是相同的,并且是完全可选的。如果想为每个包分别定制设置,那么可以创建一个该 package 的tsconfig.json,否则根目录的tsconfig.json就会起作用。

这里根目录 tsconfig.json 的paths是这里的神奇之处:它告诉 TypeScript 编译器,每当一个模块尝试从 monorepo 导入另一个模块时,它都应该从 packages 文件夹中解析它。具体来说,它应指向该包的 src 文件夹,因为这是构建时将编译的文件夹。除此之外,在 IDE 点击依赖包的方法,就会跳转对应的源代码。

然而 compilerOptions.outDir compilerOptions.include不能提升至根目录的 tsconfig,因为它们是相对于它们所在的配置进行解析的。(详见issue[2])

代码语言:javascript
复制
// 各package的tsconfig.json
{
  "extends": "../../tsconfig.json",

  "compilerOptions": {
    "outDir": "./lib"
  },

  "include": [
    "src/**/*"
  ]
}

到目前为止,最基本的 Monorepo + Yarn + Typescript 项目目录结构如下。

代码语言:javascript
复制
├── lerna.json
├── yarn.lock
├── package.json
├── packages
│   ├── pkg1
│   │   ├── package.json
│   │   ├── src
│   │   │   └── index.ts
│   │   └── tsconfig.json
│   └── pkg2
│       ├── package.json
│       ├── src
│       │   └── index.ts
│       └── tsconfig.json
└── tsconfig.json
3. 项目构建

Monorepo 的构建区别普通项目在于,各个 package 之间会存在相互依赖,比如 packageA 依赖 packageB,必须 packageA 构建完毕后 packageB 才能进行构建,否则就会报错。

这里就涉及到项目构建的执行顺序问题,实际上是要求项目以一种拓扑排序的规则进行构建,这里我们有两种解决方案:

  1. 使用lerna run构建所有 package,并依靠lerna通过查看每个 package 的依赖关系以正确的顺序构建软件包。
  2. 使用 Typescript 3.0 的新特性 Project References[3]

lerna run

@lerna/run[4] 按照拓扑顺序运行每个 package 的<script>里的命令,这意味着如果 pkg1 依赖于 pkg2,那么 pkg2<script>里的命令将在 pkg1 之前运行。这个执行顺序是通过每个 package 的 package.json 中的dependenciesdevDependencies来确立的。

通常情况,在发布npm run publish 之前,通常是需要触发<script>里的prepublishOnly来运行npm run build完成项目的构建。但在 monorepo 项目发布则需要注意一些注意事项。

当发布单个 package 时,lerna 不会为其依赖包运行prepublishOnly 脚本。所以当 package 的依赖包没发布到 npm 前,npm install 该 package 时,npm 就会报错。

解决问题的方法是不要依赖每个 package 的prepublishOnly脚本,而是在发布任何一个 package 之前构建所有的 package。我们可以通过在 lerna 发布之前调用 lerna run build 来实现这一点,这将运行每个 package 的build脚本。

或者我们可以使用 lerna 发布命令lerna publishlerna publish也支持了拓扑顺序的发布,确保发布某个 package 前,其依赖项已经发布出去,

代码语言:javascript
复制
lerna publish --graph-type all
# all 包括dependencies、devDependencies 和 peerDependencies

如果经常遇到发布单独几个 package 的情况,或者只是希望能够轻松调试构建,那么 Project References 的解决方案可能更适合。

Project References

使用 Project References 可以达到 lerna 以正确的顺序运行构建项目的效果,而且还允许我们一次构建一个包。

代码语言:javascript
复制
// pkg1/tsconfig.json
{
  "extends": "../../tsconfig.json",

  "compilerOptions": {
    "composite": true,
    "outDir": "./lib",
    "rootDir": "./src"
  },

  "references": [
    {
      "path": "../pkg2/tsconfig.json"
    }
  ],

  "include": ["src/**/*"]
}

从上面的 tsconfig.json 可见,我们通过设置composite:true,并指定该 package 所依赖 monorepo 的其他 package,设置解释如下。

  • references是路径的数组,在这里需要指定依赖包的tsconfig.json的路径。
  • 每个 package 都需要设置composite: true,即使它们只是引用树中的一个叶节点,也应为 true,否则 tsc 会报错。
  • rootDir是输出正确的输出文件夹路径所必需的,否则 TypeScript 可能会推断出根文件夹目录输出不必要的嵌套文件夹。

针对构建某个 package 的情况,我们可以修改该 package 的package.json

compile脚本是运行 tsc --build,而build脚本除了运行compile脚本外,还前置清除了所有 package 的输出目录,以及tsconfig.build.tsbuildinfotsc 的构建缓存,不然tsc -b将不会重新构建它。

代码语言:javascript
复制
// pkg1/package.json
{
  "scripts": {
    "dev": "npm run clean && tsc --build --watch",
    "build": "npm run clean && npm run compile",
    "clean": "rm -rf ./lib && rm -rf tsconfig.build.tsbuildinfo",
    "compile": "tsc --build",
    "prepublishOnly": "npm run build"
  }
}

而这个方案下,lerna run将像以前一样工作,所以这个解决方案的主要优点是它允许我们调试包的构建而不用担心其他包。

回到本次基础库构建,我们并不需要针对某几个 package 发布,所以我们也可以在根目录的tsconfig.json设置references,引用所有的需要构建的 package,这样我们在根目录的 package.json 就能使用单个命令就能完成所有 packge 的构建,而不需要在每个 package 重复新增一个构建的脚本。

代码语言:javascript
复制
//tsconfig.json
{
    "compilerOptions": {
     // 忽略
    },
    "references": [{
        "path": "packages/pkg1"
    }, {
        "path": "packages/pkg2"
    }]
}

// package.json
{
  "dev": "yarn clean && tsc --build --watch",
  "clean": "rm -rf ./lib && rm -rf tsconfig.build.tsbuildinfo",
  "build": "yarn clean && tsc --build",
}

4. 版本升级及发包

到本次文章的最后了,也是最重要的关键点,发布 npm 包。当然,结合 lerna 的文档,搞出一个能用的发布脚本是很简单的,但结合团队的实际情况,当前发布 npm 包有以下几点痛点是需要解决的:

  1. 基础库发布前,需要 Code Review
  2. 限制特定的分支发布 npm 包
  3. 通过 CI 完成项目构建,并标记修改的 package,修改其版本号以及 changelog
  4. 在个人的开发分支,需要发布临时测试用的 npm 包
Code Review

首先针对 Code Review,git repo 可以限制开发分支合并 master 前需要提Merge Request,Review 者通过Merge Request即代表该基础库通过了 Code Review,问题 1 解决。

限定 Master 分支发布 npm 包

问题 2 的解决是在问题 1 解决的基础上延伸的,当开发分支合并至 master 后,理论上在 master 分支发布 npm 包是最好的选择,所以要在限定 master 分支上发布 npm 包

代码语言:javascript
复制
//lerna.json
{
  "packages": ["packages/*"],
  "version": "independent",
  "command": {
    "version": {
      "allowBranch": "master"
    }
  },
  "useWorkspaces": true,
  "npmClient": "yarn"
}

设置"allowBranch": "master",那运行 lerna version或者lerna publish都只能在 master 分支上运行。

自动化流水线完成构建,生成版本号、changlog,发布

问题 3,我们使用的是 Orange CI,在 master 分支触发 git push 事件时,通过注册 orange ci 的 master push 钩子实现构建以及发布。

构建这块实现相对简单,在package.json包装好构建的脚本,package.json 如下:

代码语言:javascript
复制
{
  "scripts": {
    "clean": "rm -rf ./lib && rm -rf tsconfig.build.tsbuildinfo",
    "build": "yarn clean && tsc --build",
    "prepublishOnly": "npm run build"
  }
}

这里使用prepublishOnly,在 lerna 执行 npm publish 命令前运行,保证lerna publish执行前完成项目的构建。

发版的时候需要更新版本号,这时候如何更新版本号就是个问题,一般来说,版本号都是遵循 semver [5]语义。这里需要 Orange CI 自动完成版本号更新,更好的办法是根据 git 的提交记录自动更新版本号,实际上只要我们的 git commit message 符合 Conventional commit[6] 规范,即可通过lerna version根据 git 提交记录,更新版本号,简单的规则如下

  1. 存在 feat 提交:需要更新 minor 版本
  2. 存在 fix 提交:需要更新 patch 版本
  3. 存在BREAKING CHANGE提交:需要更新大版本

为了方便查看每个 package 每个版本解决了哪些功能,我们需要给每个 package 都生成一份 changelog 方便用户查看各个版本的功能变化。同理只要我们的 commit 记录符合 conventional commit 规范,即可通过工具为每个 package 生成 changelog 文件

由于开发者数量较多,发布 npm 包统一使用公共账号,至于 npm 包关联开发者信息,则可以根据 git merge request 来回溯

总结下来,在 Orange CI 输出以下命令

代码语言:javascript
复制
npx lerna version --conventional-graduate --yes

npx lerna publish from-package --legacy-auth \$TNPM_USERPASSWORD_BASE64 --yes

--conventional-graduate该标志会按照conventional commit 规范 生成版本号以及输出 changlog

from-package:将发布的 package 列表 和 npm registry 做比较。npm registry 中没有的 package 都将被发布。当一个发布失败时,这成为一个失败发布重试机制。

--legacy-auth: 输入发布 npm 包的公共账号密码,形式为 username:password,将该字符串进行 base64 转化。这里也可以用环境变量来注入提升安全性。

--yes:运行 lerna version、lerna publish 将跳过所有确认提示

临时发布 npm 包

当开发者开发基础库时,需要在业务测试该 package,但不能以 release 的版本号发布,需要在每个 commit 能够发布一个 beta 预览版,参考 lerna 文档,建议如下:

代码语言:javascript
复制
lerna publish --canary --preid beta
# 1.0.0 => 1.0.1-beta.0+${SHA}

然而这个命令并不好使,存在几个问题,首先这个 npm 包发布至 npm dist-tag 为 latest,直接 npm install 就会安装 beta 预览版;其次,1.0.1-beta.0+${SHA}并不符合semver 语义,发布到 npm 后,版本号变为``1.0.1-beta.0`,beta 后的数字,很多时候并不会随着发布次数增加而增加,这里就造成了冲突

所以这时把发布命令修改如下:

代码语言:javascript
复制
lerna publish -y --canary --preid \"beta.$(git rev-parse --short HEAD)\" --pre-dist-tag=beta --legacy-auth xxx
# `0.5.7` => `0.5.7-beta.${SHA}.1

可以看出,版本号通过 preid 配置,添加了 git sha 值,保证了每个版本号是相对于 git commit 唯一的。

四. 效果 & 总结

整个流程下来,得益于企业微信的消息推送,我们能很直观的看到整个构建发布流程。

以及发布的变更也通过上述过程自动化生成 changelog.md 并周知出来。

整个开发构建发布 npm 包的流程图总结如下所示:

目前方案已在团队内多个项目上线,整体提升了团队迭代维护的秩序和效率。

注:文中使用的 CI 是腾讯内部开源的 Orange CI,但万变不离其宗,利用 CI 去发布 npm 包的核心要义是,把 CI 模拟为本地环境,编写脚本完成构造、更新版本标签、发布 npm 这一流水线。所以即便用别的 CI 服务,如 GItHub 的 GitHub Action、GitLab 的 CI,只要围绕这核心要义,巧妙使用 lerna,打造一个 CI 发布 npm 包的流水线也是不难的。

文中相关链接:

  1. https://lerna.js.org/
  2. http://github.com/microsoft/TypeScript/issues/29172
  3. https://www.typescriptlang.org/docs/handbook/project-references.html
  4. https://github.com/lerna/lerna/tree/master/commands/run#lernarun
  5. https://semver.org/lang/zh-CN/
  6. https://www.conventionalcommits.org/zh/v1.0.0-beta.2/

腾讯音乐全民k歌招聘客户端、web前端、后台开发,点击查看原文投递简历!或邮箱联系: godjliu@tencent.com

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2020-07-10,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 腾讯音乐技术团队 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一. 背景
  • 二. 代码管理方案对比
  • 三. 改造的实现
    • 1. 依赖管理
      • 2. 项目初始化
        • 3. 项目构建
          • Code Review
          • 限定 Master 分支发布 npm 包
          • 自动化流水线完成构建,生成版本号、changlog,发布
          • 临时发布 npm 包
      • 四. 效果 & 总结
      相关产品与服务
      持续集成
      CODING 持续集成(CODING Continuous Integration,CODING-CI)全面兼容 Jenkins 的持续集成服务,支持 Java、Python、NodeJS 等所有主流语言,并且支持 Docker 镜像的构建。图形化编排,高配集群多 Job 并行构建全面提速您的构建任务。支持主流的 Git 代码仓库,包括 CODING 代码托管、GitHub、GitLab 等。
      领券
      问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档