业务边界驱动的多仓库架构设计与实践

165 阅读12分钟

业务边界驱动的多仓库架构设计与实践

一、背景

最近接手一个历史项目的改造任务,整个过程给我整的焦头烂额,有种“叫天,天不应,叫地,地不灵”的感觉。 简单说一下该项目的历史背景,该项目前期为一个边缘项目,类似于试验型项目,遵循快速实现MVP,敏捷开发,快速迭代落地,所以对于人员的分配及专业素质没有太多的约束要求,人员投入相对比较杂乱,前期架构直接download个壳子,然后由后端进行相关业务开发,顺带补充上前端的代码。

至今开发过该前端项目的品种也很多,有后端、实习生、前端、外包等,就差产品和业务上来自己动手撸了,据统计前后参与过该前端项目的人员将近20+,进进出出的,项目才三年,算不上老项目,但真是一言难尽。

历史包袱再怎么样,问题还是得解决,我坚信自己可以做到并且要求自己一定要做好,不管结局怎么样,仅凭着内心的一句话,“我要成为砖家,用砖头拍老板脑门的砖家,不出血不停拍”。

二、方案设计

现状分析

不废话了先分析状况:

  • 结构老旧、代码耦合严重:

    • 公共模块(如utils、hooks、components)分散在主业务代码中,复用性低
    • 页面逻辑、状态管理、UI组件强耦合,组件向下引用业务代码,形成死循环依赖
  • 业务线耦合严重:

    • 系统覆盖多个业务线(比如A业务线、B业务线,C业务线,其实A与B与C之间毫无关联),代码量持续增长,没有职责边界,单仓库管理难度巨大
  • 模块边界模糊,技术债堆积:

    • 页面动则千行、万行,无法复用、不可维护
    • 页面组件、结构(业务逻辑&页面样式)没有标准依赖关系,链路长,追溯难,调试困难
  • 构建与部署成本高、协作效率低:

    • 整个项目构建耗时高,本地开发调试切分支4-5分钟开启时间,git pipline单次构建10-15分钟(包括测试、预发布、生产),发布日单次需求上线全流程30-60分钟,效率低下。

方案设计

  • 方案一:不动,混日子就好了,干嘛那么较真。

  • 方案二:前期拆分思路是用monorepo的方式去做项目拆分,因为pnpm天然支持workspace来做空间隔离,将项目中utils、component、hooks、service、models、业务A、业务B,全部拆成子包的方式,因为项目底层框架为umi3.5,考虑兼容性该底座仅作为运行基础环境去使用,然后通过pnpm --filter <package_selector> <command>调用各个子包来实现业务边界隔离,可采用自定义script的方式实现,并确保项目按照业务归属范围独立运行,很简单是吧,那么请继续往下看。

    • 那么既然方案都出来了,那就得确立一下实现目标吧

    • 模块目标
      npm迁移至pnpm将现有npm迁移至pnpm,加快CI/CD
      utils(@项目/utils)提炼通用方法、统一运行环境兼容(Node&Brower),实现全局可用
      hooks(@项目/hooks)解耦业务侧逻辑,向下沉淀至可复用的能力
      components(@项目/components)构建UI原子组件,单个拆分原子组件与业务组件,并规避反向依赖主业务
      项目A(@项目/项目A)业务线边界拆分,实现单职责部署构建业务系统A
      项目B(@项目/项目B)业务线边界拆分,实现单职责部署构建业务系统B
      路径引用统一包内别名引用规则,废除模糊路径(../、@、@/compoent等)
      依赖管理减少项目体积,提升性能,降低安全风险,减少维护成本,提升代码质量和可读性
      数据指标监控监控日常调试、CI/CD性能指标,包括install、build、publish用时等,可精确到子包维度,以时间线排列,方便后期从单次install、构建、发布维度逐一排查各个节点
      测试&预发&线上CI/CD全流程闭环实现按需发布,编写cli接入git pipeline
      灰度策略&回滚方案安全策略
    • 看着也还好是吧,那再考虑一下团队协作问题

      • 目录结构大调整(如 monorepo 改造)会导致大量文件移动、重命名、拆分,git diff 变化巨大。
      • 业务开发分支在旧结构下继续开发,等到要合并时,代码已经“找不到家”了,冲突极多。
    • 做法/策略说明优点缺点/注意事项适用场景
      冻结业务,优先改造暂停新功能开发,集中完成目录结构和架构调整合并无冲突,改造一次到位业务停滞,实际难执行小团队/短期可控
      分阶段迁移,小步快跑逐步迁移底层/通用模块,业务代码暂时不动,每次迁移影响面小风险低,合并成本低迁移周期长,需良好计划大团队/模块化明显
      定期同步分支,双维护架构分支和业务分支并行开发,定期合并主分支业务变更到架构分支冲突可控,业务不中断维护成本高,需专人负责同步业务压力大/改造周期长
  • 方案三:将现有代码仓库拆分为两个代码仓库,业务A、业务B,需确保路由以及静态资源的映射关系,需要自定义CI/CD流程,实现两个业务系统之间的自动化部署等全流程闭环,很简单是吧,那么请继续往下看。

三、实践

方案一实践

直接摆烂,不要废话

方案二实践

首先我当大家都已经默认是pnpm了,如果有疑问或者不知道流程的可以看我往期文章npm切换pnpm&gitlab pipeline全流程优化(实战篇),这里不做过多概述

拆包前环境搭建

  1. 这里有个前置任务就是拆包前需要整理一下目录结构,比如你要拆uitls,你要把uitls里面所有的js或者ts文件统一在index进行导出,配置统一出口,然后配置好以后全量copy到对应子包当中,hooks、components这些也一样统一index.ts作为出口
  2. 根目录创建packages/components、packages/utils、packages/hooks、packages/service、packages/models、packages/业务A、packages/业务B
  3. 根目录package.json添加workspace配置
"workspaces": [
    "packages/*"
],

// 如需引用拆包后的子包可以在dependencies添加
"@项目名/utils": "workspace:*",
  1. 根目录添加pnpm-workspace.yaml
packages:
  - 'packages/*'
  1. 切到子包比如packages/utils,进行初始化
pnpm init
  1. 配置子包package.json
{
  "name": "@项目名/utils",
  "version": "1.0.0",
  "description": "utils",
  "keywords": [],
  "license": "ISC",
  "author": "xipiker",
  "main": "lib/index.js",
  "scripts": {
    "build": "babel src --extensions ".js,.jsx,.ts,.tsx" --out-dir lib",
    "test": "echo "Error: no test specified" && exit 1"
  },
  "dependencies": {

  },
  "devDependencies": {

  },
  "packageManager": "pnpm@10.12.4"
}
  1. 配置子包tsconfig.json
{
  "compilerOptions": {
    "declaration": true,
    "outDir": "lib",
    "module": "ESNext",
    "target": "ES2015",
    "moduleResolution": "Node",
    "esModuleInterop": true,
    "strict": true
  },
  "include": ["index.ts"]
}
  1. 然后添加主项目迁移后的结构,比如uitls中已经处理了全部统一导出index.ts,然后开始安装依赖,配置babel,逐步踩坑~

Utils packages

  1. 前置将整理好的utils目录结构以及统一出口index.js或者index.js放入新创建的packages/utils中,运行如下命令做一下初始化安装缺少依赖到子包当中
cd packages/utils
npx depcheck src
  1. 查看分析结果Unused dependencies、Unused devDependencies,然后逐步安装缺少的依赖,这里需要注意,安装依赖的版本要和主项目一致,防止冲突
Unused dependencies
* axios: .\xxx.js
* react: .\xxx.js
* moment: .\xxx.js
...
Unused devDependencies
* @babel/preset-react
* @umijs/preset-react
...
  1. 然后运行build并且在主项目运行start,查看是否拆成功,报错缺babel那就添加babel,就跟着报错一点一点走就好了
pnpm run build && pnpm run start
  1. 比如发生如下错误:babel解析错误那就添加各种依赖包配置babel.config.js 定义编译范围.js,.jsx,.ts,.tsx,然后安装各种babel
  2. 配置babel.confg.js
module.exports = {
  presets: [
    ['@babel/preset-env', { modules: 'commonjs' }],
    ['@babel/preset-react', { runtime: 'automatic' }],
    '@babel/preset-typescript',
  ]
};
  1. 配置build脚本
"scripts": {
    "build": "babel src --extensions ".js,.jsx,.ts,.tsx" --out-dir lib"
}
  1. 或者如下报错:pnpm run build 找不到window,底层原因是node编译解析的时候不支持浏览器的一些预发会找不到目标对象,解决方案是涉及到的加上判断 typeof window !== 'undefined',全量加上就好了
  2. 直到pnpm run build成功为止,然后在主项目查看package.json中配置的"@项目名/utils": "workspace:*",在项目中进行全局替换uitls对应的一些工具方法,比如替换替换如下各种关键字/utils/、utils/、'utils'、"utils"、'utils、@utils、@/utils、../utils),umijs alias 'config'、'utils',然后全部替换成import {xxx} from '@项目/utils'
  3. 全部替换完成以后在主项目运行run start查看是否全局替换成功,有报错就修复报错,大部分就是不仔细出现一些别名写错等问题
  4. 过程实在艰辛,项目光替换uitls这块500+处,各种别名,各种异常,确实需要耐心去踩坑

Components packages

然后就是拆components包,这里过程和拆uitls包差不多,就不多概述了,无非区别在babel这块,比如style-babel缺少或者less-babel等,按照报错去添加就好了,拆好pnpm run build能成功再开始全局替换就行了,这里全局替换1000+,感觉受了内伤,这里给一套替换时候的方式,方便整个过程结构化管理,参考如下表格内容

组件名功能含义全局引用次数引用全路径是否拆包完成问题记录
EditTable可编辑表格2xx1/xxx/EditTable xx2/xxx/EditTable已完成,以验证1.组件体积过大

Hooks packages、Service packages、项目包A、项目包B

这些拆包方式都差不多意思,无非是替换过程比较繁琐枯燥

拆包注意

  1. 在子包编译过程中pnpm run build会比较繁琐,大家可以配置--watch,自定义script,这里比较简单就不过多概述
  2. 子包的依赖一定要和主项目中的保持一致,防止依赖冲突或者各种玄学问题

结论

  1. 方案可行但是改动量太大,容错率太低,需要全面测试,动不动几百几千处改动,换做谁不迷糊,还比如团队协同,新老代码替换等问题,业务停滞,业务不可能能让你一直搞这种东西,几个前端空出半个月一起努力搞这个东西,那么业务就停滞了,这不太现实。
  2. 需要重新定制CI/CD方案,npm切pnpm,那么线上构建流程是需要全面改造的,缓存,资源传递等
  3. 需要单独设计线上回滚和灰度方案,因为涉及改动量巨大,相当于系统整体重构了
  4. 既然业务系统A和业务系统B或者和业务系统C完全没有关系,那就拆代码仓库吧,真的尽力了,如果是一开始新项目就monorepo方式也不至于现在这样了,如果项目一开始建设的时候就好好对待它,好好规划它,又是怎样的解决呢,没有假如,诚心对待任何事物

方案三实践

多仓库的方式还是比较简单,将原来的代码仓库copy一份为新的代码仓库配置起始路由,加载固定的layout,然后互相解耦,A仓库删除B仓库相关的代码,B仓库删除A仓库相关的代码,然后各种配置pipeline CI/CD流程,删除无用依赖,这样就O了,这里主要拆分为如下几个步骤去完成就行了

  1. 删除无用页面以及组件(确定边界作用域)
  2. 删除无用路由、状态管理、service管理
  3. 删除国际化部分,写脚本去判断关键字,比如项目A目录下对应的所有国际化key,然后再整体配置国际化key做排除比较,删除没有使用的key就行了
  4. 删除无用依赖包,主要通过depcheck来辅助删除,可以参考如下篇章前端依赖治理全攻略:识别、删除、优化一步到位

四、规约

  1. 【强制】Monorepo架构的文件组织形式如下:
  • packages目录与src平级,该目录中包含的每个子目录,是一个子包,子目录是子包名

  • 每个package都有自己的package.json等配置文件

  • 每个package都有自己的src目录,其内容和根目录src基本一致

    packages/项目1-system
        src 
            components 
            pages 
            model 
            utils 
         package.json 
         tsconfig.json 
    packages/项目2-system
        src 
            components 
            pages 
            model 
            utils 
        package.json 
        tsconfig.json 
    
  • @开头是访问各个repo对应的model,如@项目1-system/model代表引用项目2-system子包的model

  • 非@开头是各个repo对应的文件路径,如项目2-system/pages等效于packages/项目2-system/src/pages

  • 根目录别名为root,如root/utils等效于src/utils,@root/model代表引用根目录的model

五、结语

对待项目就像对待自己的孩子一样,有一天你会发现,你对它越好,它还是会出现各种烂七八糟的问题,它还是会叛逆,随着年龄的增长问题也会浮现出来,我们能做到的就是提前发现问题,以及规避问题,好好引导它往正确的方向去走,至少在一次次发现和引导中,我们能彼此磨合,不至于瞬间失控。希望能帮助到大家,欢迎点赞、收藏、关注,您的一举一动是对我最大的支持,谢谢~