monorepo 改造实践

642 阅读8分钟

背景

在讲具体的实践之前,简单罗列一下项目背景与后期维护的困境,以简述改造原因、改造的技术基础:

先简单将项目概括为 产品业务、接口实现、公共组件 三个部分

最初代码设计

产品业务

  1. 新项目基于某历史项目开发,通过目录隔离实现简单的业务层面的分离、抽象组件的复用

  2. 出于项目规模、项目团队成员构成、项目本身特性等角度考虑,新项目拆分两个代码,采用微前端的方式进行组合

  3. 为减小风险,采用多主分支模式管理 project-a 和 project-b

     注:该微前端框架已于前不久开源,在公司某项目支撑着数十个子应用的大系统的运行。此项目本身也要求同时作为该大系统的子应用进行接入,最终形成多级微前端结构。
    

到这里我们可能已经大概形成了这样一个目录(基于旧项目开发的部分):

- src
    # 业务部分
    - project-a
        - module-1
        - module-2
        - ...
    - project-b
        - module-1
        - ...

当然还有另一个代码库(该项目的独立功能模块):

- src
    - project
        - module-1
        - module-2
        - ...
    - components
    - utils
    - api
    - ...

到这里已经形成了:

  1. 两个代码库(两个大功能模块)
  2. 某一个代码库同时包含两个产品的代码,基于目录的轻隔离
  3. 两个大功能模块与后端服务之间存在交叉依赖(前后端功能模块拆分不完全一致)

接口部分

  1. 采用了 openapitools 自动生成接口代码;
  2. 多个独立的接口 sdk 以对应后端多个服务模块;
  3. sdk 代码的管理以简单的目录的方式进行区分(历史上尝试过 git submodules、npm package, 但对于多人协作的团队、以及代码管理与编译部署的自动化过程并不友好)

到这里我们可能已经大概形成了这样一个目录:

- src
    # 业务部分
    - project-a
        - module-1
        - module-2
        - ...
    - project-b
        - module-1
        - ...
    # 接口部分
    - api-a
        - index.js
        - ...
    - api-b
    - api-c
    - ...

公共组件

  1. 以抽象组件(包括 utils、components 等)为主,即业务无关的组件,可以跨项目复用
  2. 包含部分为实现多个 module(即project目录下的多个功能模块)之间复用的业务组件

到这里我们可能已经大概形成了这样一个目录:

- src
    # 业务部分
    - project-a
        - module-1
        - module-2
        - ...
    - project-b
        - module-1
        - ...
    # 接口部分
    - api-a
        - index.js
        - ...
    - api-b
    - api-c
    - ...
    # 公共组件
    - components
        - comp-a  # 抽象组件,允许跨项目复用
        - comp-b 
        - busi-a  # 业务组件,仅允许某个 project 下的业务代码使用
        - ...
    - filters
    - utils
    - ...

这样的结构,在前期,多团队、多代码库、多主分支、目录隔离的方式,比较简单,各技术层次的人也容易理解,适合前期的快速开发。

到后期,事实上遇到了这样一些问题:

问题暴露

研发风险增加

  1. 微前端部分,即使提供了集成开发的能力,但基于开发习惯、服务复杂度的考虑,部分研发人员更习惯独立开发调试。这与实际生产环境的运行模式并不一致,会存在一些风险。

用户体验较差

  1. 两个项目样式一致性较差;

开发成本/代码维护成本增加

  1. 两个代码库之间需要复用的组件变多,但组件代码复用存在障碍,常复制了事;
  2. 研发队伍成员缩减,两个子团队合并为一个,此时同时维护两个代码库成本变高;
  3. 代码规范不一致
  4. 编译配置不一致,当时考虑引入 webpack5 来解决一些代码复用问题,但同时升级编译代码比较麻烦,也便于统一接入 swc 等工具来优化编译速度;
  5. 由于业务代码仅采用软隔离,使得部分代码引用关系出现混乱,且不好区分哪些组件可跨项目复用
  6. 后续产品上扩展了其它应用,使得5的问题变得突出。

部署成本增加

  1. 部署方式调整为蓝绿部署,两个子项目都需要蓝绿,并保持版本一致,这导致了项目的部署、机器成本变高;
  2. 部署时要同时编译两个项目

这些问题,可能各自都有自己的解决方案,只是综合起来,改造成本、维护成本并不低,所以尝试考虑 monorepo 的方式进行改造。

改造目标

  1. 多主分支合并为一个;
  2. 多代码库合并为一个;
  3. 多应用部署改为单应用部署;
  4. 明确组件范围:项目间共享、项目模块间共享;
  5. 统一编译脚本、编译配置、代码规范;
  6. 拆包后可以利用 pnpm 自动分析项目依赖关系并行打包
  7. 集成 swc,优化编译配置

实践

代码结构设计

image.png

项目拆分为多个层,且每个层拥有多个包:

  1. 业务层(Business)
  2. API SDK (API)
  3. 基础框架/UI 层(common包)、基础工具-编译、文档、配置等(Workspace)

约定引用关系为,上层可以为引用下层的包,但不能反向引用;同一层的不同 package 之间(兄弟关系)不能互相引用。

目录规划

- build # 统一存放编译脚本
- packages
    # 业务部分
    
    # @scope/project-a
    - project-a
        - src
            - components # 项目内公共组件
            - modules  # 项目业务模块
                - module-1
                - module-2
            - ...
        package.json
        
    # @scope/project-a
    - project-b
        - src
            - components # 项目内公共组件
            - modules  # 项目业务模块
                - module-1
                - module-2
            - ...
        package.json
        - ...
        
    # 接口部分
    
    # @scope/api-a
    - api-a 
        - src
            index.ts
        package.json
    
    # @scope/api-b
    - api-b 
        - src
            index.ts
        package.json
        
    # 公共组件, 可跨项目复用
    - common # @scope/common
        - src
        package.json
        
  .eslintrc.js   # 基础规范
  package.json
  tsconfig.json   # 基础 ts 配置
  openapitools.json  # 统一管理 API sdk 配置
  pnpm-workspace.yaml # 项目空间声明
  ... # 其它辅助配置

重写编译脚本

这部分当时是完全重写了,细节实现较多,不好详述,所以简单记一下。

build部分设计主要考虑:

  1. 包类型, 该项目只有三种类型,即:

    1. api 的打包,仅需要处理 ts -> js 转换、压缩,且无需代码规则检查,输出为 umd
    2. common包, 包含 tsx\ts\js\jsx\less 各种文件,输出为 umd, 无 html entry
    3. web 的打包,文件类型同2,输入 html 文件入口、JModule框架入口文件
  2. 脚本兼容:macos, windows,

    1. 这里在 import 文件的时候,需要考虑 windows 路径兼容的问题,url.pathToFileURL, url.fileURLToPath 可以解决这些问题。
    2. webpack exclude 规则需要考虑 (\|/) 路径
  3. 执行类型: build(生产)、dev(开发)、API sdk 生成,即编译的入口脚本将有三个:

    1. bin/build.mjs
    2. bin/dev.mjs
    3. bin/createApiSdk.mjs
  4. 配置继承,以实现 ts 配置规范、代码规范:

#### ESLint ####

const path = require('path');

module.exports = {
    extends: [
        path.resolve(__dirname, '../../.eslintrc.js'),
    ],
    rules: {
        "import/no-extraneous-dependencies": [
            "error",
            {
                "packageDir": [
                    path.resolve(__dirname, './'),
                    path.resolve(__dirname, '../../')
                ],
            },
        ],
    }
}
##### Typescript #####
{
  "extends": "../../tsconfig.json",
  "include": [
    "src/**/*.ts",
    "global.d.ts"
  ]
}
注:webpack 的 context 目录需要正确配置,babel\ts\eslint 都是基于这个目录来查找的配置文件。

5. 功能规划

- build
    - bin
        dev.mjs
        createApiSDK.mjs
        build.mjs
    - configs
        features.mjs   # 通过webpack的rule\plugin 提供每一个编译特性功能的实现
        jmodule.mjs    # 统一提供 JModule 微前端配置解析,便于集成开发的实现
        env.mjs        # .env 文件支持
        webpackBase.mjs   # webpack 基础配置
     bundles.mjs # 每个package 的特性编译配置
     bundle.mjs  # bundle class 的实现,解析 bundles, 并转换为webpack config

最终 bundles 配置文件demo 参考:

[{
        id: 'common',
        bundleType: BundleTypes.PACKAGE,  # 包类型
        optimization: optimizationForPackage, # 优化策略,‘true/false/swc'
        entry: './src/index.ts',   # 入口文件
        loaders: ['lint', 'ts', 'js', 'css', 'vue', 'assets'], # 通过 features实现,即支持 eslint, ts, js 等特性
        outputPath: 'dist',
        enableSourceMap: true,
        alias: {
            '@': resolve(__cwd, './packages/common/src'),
        },
        devConfig: {
            port: 8080,
            jmodule: {
                platform: {...},
            },
        },
    }]
  1. 改写 package.json 的 scripts, 所有包统一编译命令为 build\dev

代码改造

主要是按 规划的目录,把代码迁移到各自的位置,这个过程需要分离一些代码、调整代码引用方式,比如公共组件的引用统一调整为 @scope/common,代码规范适配。主要是一些体力活了。

这里通常会遇到 ts 编译类型声明查找、类型文件输出(ts 默认不支持输出到单个dts文件)问题,为保证始终能正确找到类型声明,在 common 包里引用代码,也采取 @scope/common/xxx 的方式引用,相对于webpack alias,可以很好规避 ts 类型查找的问题。

代码仓库合并

主要是如何保留两个代码仓库的 commit history,这个有不少相关文章,eg:segmentfault.com/a/119000002…

问答

之前有同事问过一些问题,在这里记录一下

Q1. 何为 monorepo, 是不是把几个代码库放到一个仓库就行了

A1. 感觉这个有点像,我练了一套剑法,招式都有了,但内功心法全无,算不算学会了这套剑法?个人理解,通常单纯的合并代码库,但相互之前没有联系,各自管理依赖,各自编译,合并的意义不大。曾经有个朋友跟我讲monorepo 就像一个龙身9个头,我可以统一控制他们,很便利;而如果是单纯的合并,则似乎更像是,把9条独立的龙关到了一个笼子里。

Q2. 每个包的 package.json 的依赖声明,是单独自己写,还是写到最外层?

A1. 我没有一个完美的答案,但通常,不管怎么做,好像问题都不大。如果某个依赖包是某个package自己需要的,那就放在自己的package.json里面;至于共享的依赖,比如当前这个项目下 build 过程中的所有依赖则都在 workspace下;另外,这个项目里的 @scope/*,谁依赖谁一般自己的package 里写清楚,这样包管理工具才好分析依赖关系,来确定谁先编译,谁跟谁可以同时编译。

还有一些问题,想起来了再补充...