美文网首页
Monorepo实战

Monorepo实战

作者: Moon_f3e1 | 来源:发表于2020-08-31 12:11 被阅读0次

    场景

    维护多个仓库的公共代码是一件头疼的事情,每次对公共代码的改动都要全量仓库同步,最后决定用 monorepo 改造一番。

    Monorepo

    Monorepo(monolithic repository) 是管理项目代码的一个方式,指在一个项目仓库 (repo) 中管理多个模块/包 (package),不同于常见的每个模块建一个 repo。

    目前不少大型开源项目采用了这种方式,如 BabelReactVue 等。monorepo 管理代码只要搭建一套脚手架,就能管理(构建、测试、发布)多个 package。

    在项目的第一级目录的内容以脚手架为主,主要内容都在 packages 目录中、分多个 package 进行管理。目录结构大致如下:

    ├── packages
    |   ├── pkg1
    |   |   ├── package.json
    |   ├── pkg2
    |   |   ├── package.json
    ├── package.json
    

    此时会有一个问题,虽然拆分子 npm 包管理项目简单了很多,但是当仓库内容有关联时,调试变得困难。所以理想的开发环境应该是只关心业务代码,可以直接跨业务复用而不关心复用方式,调试时所有代码都在源码中。

    目前最常见的 monorepo 解决方案是 lerna 和 yarn 的 workspaces 特性。用 yarn 处理依赖问题,lerna处理发布问题。

    Lerna

    Lerna是npm模块的管理工具,为项目提供了集中管理package的目录模式,如统一的 repo 依赖安装、package scripts和发版等特性。

    安装

    建议全局安装

    npm i -g lerna
    

    初始化项目

    lerna init
    

    初始化后,会生成 packages 空目录和 package.json 和 lerna.json 配置文件,配置文件如下:

    //package.json
    {
      "name": "root",
      "private": true, // 私有的,不会被发布,是管理整个项目,与要发布的npm包解耦
      "devDependencies": {
        "lerna": "^3.22.1"
      }
    }
    
    //lerna.json
    {
      "packages": [
        "packages/*"
      ],
      "version": "0.0.0"
    }
    

    创建npm包

    执行命令后可修改包信息,这里创建 @monorepo/components 和 @monorepo/utils

    lerna create @monorepo/components
    

    安装依赖

    lerna和yarn workspace安装依赖的方法都鸡肋

    lerna add lodash // 为所有 package 增加 lodash 模块 
    
    // 为 @monorepo/utils 增加 lodash 模块(lodash可替换为内部模块,如@monorepo/components)
    lerna add lodash --scope @monorepo/utils  
    

    lerna add的鸡肋之处是一次只能安装一个包...

    依赖包管理

    一般情况下 package 的依赖都是在各自的 node_modules 目录下,这不仅增加了包的安装和管理成本,还可能会出现同一个依赖有多个的情况。所以可把所有 package 的依赖包都提升到工程根目录。

    lerna 和 yarn workspace都可以把依赖包提升到 repo 根目录管理。lerna 在安装依赖时(lerna bootstrap)提供了--hoist选项,但其鸡肋的地方在于,由于 lerna 直接以字符串对比 dependency 的版本号,同一个依赖在版本号完全相同时才会提升到根目录下,举个栗子:

    A 依赖了 @babel/core@^7.10.0
    B 依赖了 @babel/core@^7.11.4
    

    lerna 会在 A 的 node_modules 目录下安装 7.11.4 版本的 babel/core,并在 A 目录下生成了一份 package-lock.json,这无疑加大了维护成本和包的体积。

    而yarn workspace在这种情况下只会在根目录有一份yarn-lock.json,也不会重复在子目录下安装依赖。

    yarn workspace

    搭建环境

    主要是为安装依赖的配置调整。

    在monorepo管理的项目中,各个库之间存在依赖,如A依赖于B,因此我们通常需要将B link到 A 的node_module里,一旦仓库很多的话,手动的管理这些link操作负担很大,因此需要自动化的link操作,按照拓扑排序将各个依赖进行link

    解决方式:通过使用 workspace,yarn install 会自动的帮忙解决安装和 link 问题

    yarn install # 等价于 lerna bootstrap --npm-client yarn --use-workspaces
    

    package.json & lerna.json 如下:

    //lerna.json
    {
      "packages": ["packages/*"],
      "npmClient": "yarn",
      "useWorkspaces": true, // 使用yarn workspaces
      "version": "0.0.0"
    }
    
    //package.json
    {
      "name": "root",
      "private": true,
      "workspaces": [ //指定workspace路径
        "packages/*"
      ],
      "devDependencies": {
        "lerna": "^3.22.1"
      }
    }
    

    清理环境

    在依赖乱掉或者工程混乱的情况下,清理依赖

    lerna clean # 清理所有packages的node_modules目录,不能删除根目录的node_modules
    yarn workspaces run clean # 执行所有package的clean操作(应是需自行写脚本)
    

    大部分资料都说lerna clean可以删除根目录的依赖,实际感觉是不行,官网原话是
    lerna clean does not remove modules from the root node_modules directory, even if you have the --hoist option enabled.

    安装/删除依赖

    一般分为三种场景

    • 给某个 package 安装/删除依赖
    • 给 root 安装/删除依赖,一般的公用的开发工具都是安装在 root 里,如 typescript
    • 给所有 package 安装/删除依赖

    关于最后一种情况,网上均显示 yarn workspaces add/remove lodash 可以给所有包安装/删除依赖,然而用就是报错 - error Invalid subcommand. Try "info, run",yarn 2.0 只能用 yarn workspaces foreach,运行前要装 workspace-tools 插件,但还没有时间尝试过...所以这里只说前两种情况。

    安装/删除依赖对应场景依次如下:

    yarn workspace packageA add/remove packageB [packageC -D] //为 packageA 安装/删除 packageB、C 依赖
    yarn add/remove typescript -W -D // 给 root 安装/删除 typescript
    

    需注意一点,当使用 yarn workspace packageA add xxx 安装时,将会再次安装 packageA 的所有依赖且安装在到 packageA 目录下。

    对于安装local dependency,yarn的实现暂时有bug,第一次安装需要指明版本号,否则会安装失败如下
    如果ui-button没有发布到 npm 则 yarn workspace ui-form add ui-button 会安装失败,但是 yarn workspace ui-form add ui-button@1.0.0会成功 ,详情

    安装完依赖文件结构如下:


    提交规范

    在构建和发布之前还需要做一些关于代码提交的配置

    commitizen && cz-lerna-changelog

    commitizen 是用来格式化 git commit message 的工具,它提供了一种问询式的方式去获取所需的提交信息。

    cz-lerna-changelog 是专门为 Lerna 项目量身定制的提交规范,在问询的过程,会有类似影响哪些 package 的选择。如下:



    我们使用 commitizen 和 cz-lerna-changelog 来规范提交,为后面自动生成日志作好准备。
    因为这是整个工程的开发依赖,所以在根目录安装:

    yarn add commitizen cz-lerna-changelog -D -W
    

    安装完成后,在 package.json 中增加 config 字段,把 cz-lerna-changelog 配置给 commitizen。同时因为commitizen不是全局安全的,所以需要添加 scripts 脚本来执行 git-cz

    {
      "name": "monorepo",
      "private": true,
      "workspaces": [
        "packages/*"
      ],
      "scripts": {
        "commit": "git-cz"
      },
      "config": {
        "commitizen": {
          "path": "./node_modules/cz-lerna-changelog"
        }
      },
      "devDependencies": {
        "commitizen": "^4.2.1",
        "cz-lerna-changelog": "^2.0.3",
        "lerna": "^3.22.1"
      }
    }
    

    之后在常规的开发中就可以使用 yarn run commit 来根据提示一步一步输入,来完成代码的提交。

    commitlint && husky

    以下配置是强制开发者遵循上述规范,可暂时跳过,因为提交起来略久……

    上面我们使用了 commitizen 来规范提交,但很难靠开发自觉使用 yarn run commit 。万一忘记了,或者直接使用 git commit 提交怎么办?所以在提交时校验提交信息,如果不符合要求就不让提交,并提示。校验的工作由 commitlint 来完成,校验的时机则由 husky 来指定。husky 继承了 Git 下所有的钩子,在触发钩子的时候,husky 可以阻止不合法的 commit,push 等等。

    安装 commitlint 以及要遵守的规范:

    yarn add -D -W husky @commitlint/cli @commitlint/config-conventional
    

    在工程根目录为 commitlint 增加配置文件 commitlint.config.js 为commitlint 指定相应的规范

    module.exports = { 
        extends: ['@commitlint/config-conventional'] 
    }
    

    在 package.json 中增加如下配置

    "husky": { 
            "hooks": { 
                "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" 
         }
     }
    

    "commit-msg"是git提交时校验提交信息的钩子,当触发时便会使用 commitlint 来校验。安装配置完成后,想通过 git commit 或者其它第三方工具提交时,只要提交信息不符合规范就无法提交。从而约束开发者使用 yarn run commit 来提交。

    eslint && lint-staged

    原本想先跳过eslint的规范的……然而如果项目已经有eslint,而npm包没有,调试都会报错,原以为一个.eslintlrc文件和装eslint插件就能解决问题,然而并没有这么简单,累了……后期补上解决问题的过程

    除了规范提交信息,代码本身肯定也少了靠规范来统一风格。

    yarn add -D -W standard lint-staged
    

    eslint就是完整的一套 JavaScript 代码规范,自带 linter & 代码自动修正。自动格式化代码并修正,提前发现风格以及程序问题, 同时也支持javascript的代码规范校验,eslintrc.json:

    module.exports = {
      env: {
        browser: true,
        es2020: true
      },
      extends: ["eslint:recommended", "plugin:vue/essential"],
      parserOptions: {
        parser: "babel-eslint"
      },
      plugins: ["vue"],
      rules: {
        "prettier/prettier": [
          "off",
          {
            quotes: 0
          }
        ]
      }
    }
    

    lint-staged staged 是 Git 里的概念,表示暂存区,lint-staged 表示只检查并矫正暂存区中的文件。一来提高校验效率,二来可以为老的项目带去巨大的方便。

    package.json配置

    {
      "name": "monorepo",
      "private": true,
      "workspaces": [
        "packages/*"
      ],
      "scripts": {
        "c": "git-cz"
      },
      "config": {
        "commitizen": {
          "path": "./node_modules/cz-lerna-changelog"
        }
      },
      "husky": {
        "hooks": {
          "pre-commit": "lint-staged"
        }
      },
      "lint-staged": {
        "*.(vue|js)": [
          //"eslint --fix",
          "prettier --write"
        ]
      },
      "devDependencies": {
        "commitizen": "^4.2.1",
        "cz-lerna-changelog": "^2.0.3",
        "lerna": "^3.22.1",
        "lint-staged": "^10.2.13",
        "standard": "^14.3.4"
      }
    }
    

    安装完成后,在 package.json 增加 lint-staged 配置 "prettier --write",校验时机定在pre-commit,在husky的配置中增加pre-commit的钩子用来执行 lint 校验。

    eslint --fix 校验并自动修复慎用,分分钟提交不了……

    使用 Lerna 构建和发布

    项目构建

    各个package之间存在相互依赖,如packageB只有在packageA构建完之后才能进行构建,否则就会出错,这实际上要求我们以一种拓扑排序的规则进行构建。

    我们可以自己构建拓扑排序规则,很不幸的是yarn的workspace暂时并未支持按照拓扑排序规则执行命令,虽然该 rfc已经被accepted,但是尚未实现



    幸运的是lerna支持按照拓扑排序规则执行命令, --sort参数可以控制以拓扑排序规则执行命令

    lerna run --stream --sort build
    

    可在根目录的package.json下配置

    "scripts": {
        "build": "lerna run --stream --sort build"
      },
    

    版本升级及发包

    历经重重困难终于到了发布最后一步了

    项目测试完成后,就涉及到版本发布,版本发布一般涉及到如下一些步骤

    • 条件验证: 如验证测试是否通过,是否存在未提交的代码,是否在主分支上进行版本发布操作
    • version_bump:发版的时候需要更新版本号,这时候如何更新版本号就是个问题,一般大家都会遵循 semVer语义
    • 生成changelog: 为了方便查看每个package每个版本解决了哪些功能,我们需要给每个package都生成一份changelog方便用户查看各个版本的功能变化。
    • 生成git tag:为了方便后续回滚问题及问题排查通常需要给每个版本创建一个git tag
    • git 发布版本:每次发版我们都需要单独生成一个commit记录来标记milestone
    • 发布npm包:发布完git后我们还需要将更新的版本发布到npm上,以便外部用户使用

    yarn官方并不打算支持发布流程,只是想做好包管理工具,因此这部分还是需要通过lerna支持

    lerna提供了publish和version来支持版本的升级和发布, publish的功能可以即包含version的工作,也可以单纯的只做发布操作。

    只发布某个package

    lerna官方不支持仅发布某个package,https://github.com/lerna/lerna/issues/1691,如果需要,只能自己手动的进入package进行发布,这样lerna自带的各种功能就需要手动完成且可能和lerna的功能相互冲突

    由于 lerna 会自动的监测 git 提交记录里是否包含指定 package 的文件修改记录,来确定版本更新,这要求设置好合理的 ignore 规则(否则会造成频繁的,无意义的某个版本更新),好处是其可以自动的帮助 package 之间更新版本

    例如如果 ui-form 依赖了 ui-button,如果 ui-button 发生了版本变动,会自动的将 ui-form 的对 ui-button 版本依赖更新为 ui-button 的最新版本。 如果 ui-form 发生了版本变动,对 ui-button 并不会造成影响。

    经测试 version_bump 是依赖于文件检测和 subject 结合,并不依赖于 scope,scope 的作用是用来生成 changelog 的吧,即如果是修改了 ui-form 的文件,但是 commit 记录写的是 fix(ui-button),lerna 是会生成 ui-form 的版本更新,并不会去更新 ui-button 的版本。

    发布自动生成日志

    有了之前的规范提交,自动生成日志便水到渠成了。leran publish 时主要做了以下事情:

    lerna version 更新版本

    • 找出从上一个版本发布以来有过变更的 package
    • 提示开发者确定要发布的版本号
    • 将所有更新过的的 package 中的package.json的version字段更新
    • 将依赖更新过的 package 的 包中的依赖版本号更新
    • 更新 lerna.json 中的 version 字段
    • 提交上述修改,并打一个 tag
    • 推送到 git 仓库


    lerna publish

    版本自动更新可使用--conventional-commits 参数会自动的根据conventional commit规范和git commit message记录帮忙确定更新的版本号:

    // lerna.json
    {
      "packages": ["packages/*"],
      "npmClient": "yarn",
      "useWorkspaces": true,
      "command": {
        "version": {
          "conventionalCommits": true # 生成changelog文件以及根据commit来进行版本变动
        }
      },
      "ignoreChanges": ["**/*.md"], # md文件更新,不触发版本变动
      "version": "0.0.0"
    }
    

    最后包内的 package.json 还需 publishConfig 配置

    "publishConfig": {
       "access": "publish" // 如果该模块需要发布,对于scope模块,需要设置为publish,否则需要权限验证
      }
    

    最后

    lerna publish [from-git]
    

    如果第一次没发成功但是却显示成功了,则要加from-git才能重新发,发布也还有一些坑……

    完善的测试用例

    monorepo项目:测试有两种方式

    • 使用统一的 jest 测试配置这样方便全局的跑 jest 即可,好处是可以方便统计所有代码的测试覆盖率,坏处是如果 package 比较异构(如小程序,前端,node 服务端等),统一的测试配置不太好编写
    • 每个 package 单独支持test命令,使用 yarn workspace run test,坏处是不好统一收集所有代码的测试覆盖率

    此处附上 typescript 的测试例子,初始化配置 jest.config.js:

    module.exports = {
      preset: 'ts-jest',
      moduleFileExtensions: ['ts'],
      testEnvironment: 'node'
    }
    

    最后附上 github 地址:https://github.com/moon-bonny/monorepo

    readme 还未来得及完善……

    未完待续……

    后续有时间再出踩坑篇和vue组件打包篇

    相关文章

      网友评论

          本文标题:Monorepo实战

          本文链接:https://www.haomeiwen.com/subject/dteusktx.html