业务边界驱动的多仓库架构设计与实践
一、背景
最近接手一个历史项目的改造任务,整个过程给我整的焦头烂额,有种“叫天,天不应,叫地,地不灵”的感觉。 简单说一下该项目的历史背景,该项目前期为一个边缘项目,类似于试验型项目,遵循快速实现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全流程优化(实战篇),这里不做过多概述
拆包前环境搭建
- 这里有个前置任务就是拆包前需要整理一下目录结构,比如你要拆uitls,你要把uitls里面所有的js或者ts文件统一在index进行导出,配置统一出口,然后配置好以后全量copy到对应子包当中,hooks、components这些也一样统一index.ts作为出口
- 根目录创建packages/components、packages/utils、packages/hooks、packages/service、packages/models、packages/业务A、packages/业务B
- 根目录package.json添加workspace配置
"workspaces": [
"packages/*"
],
// 如需引用拆包后的子包可以在dependencies添加
"@项目名/utils": "workspace:*",
- 根目录添加pnpm-workspace.yaml
packages:
- 'packages/*'
- 切到子包比如packages/utils,进行初始化
pnpm init
- 配置子包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"
}
- 配置子包tsconfig.json
{
"compilerOptions": {
"declaration": true,
"outDir": "lib",
"module": "ESNext",
"target": "ES2015",
"moduleResolution": "Node",
"esModuleInterop": true,
"strict": true
},
"include": ["index.ts"]
}
- 然后添加主项目迁移后的结构,比如uitls中已经处理了全部统一导出index.ts,然后开始安装依赖,配置babel,逐步踩坑~
Utils packages
- 前置将整理好的utils目录结构以及统一出口index.js或者index.js放入新创建的packages/utils中,运行如下命令做一下初始化安装缺少依赖到子包当中
cd packages/utils
npx depcheck src
- 查看分析结果Unused dependencies、Unused devDependencies,然后逐步安装缺少的依赖,这里需要注意,安装依赖的版本要和主项目一致,防止冲突
Unused dependencies
* axios: .\xxx.js
* react: .\xxx.js
* moment: .\xxx.js
...
Unused devDependencies
* @babel/preset-react
* @umijs/preset-react
...
- 然后运行build并且在主项目运行start,查看是否拆成功,报错缺babel那就添加babel,就跟着报错一点一点走就好了
pnpm run build && pnpm run start
- 比如发生如下错误:babel解析错误那就添加各种依赖包配置babel.config.js 定义编译范围.js,.jsx,.ts,.tsx,然后安装各种babel
- 配置babel.confg.js
module.exports = {
presets: [
['@babel/preset-env', { modules: 'commonjs' }],
['@babel/preset-react', { runtime: 'automatic' }],
'@babel/preset-typescript',
]
};
- 配置build脚本
"scripts": {
"build": "babel src --extensions ".js,.jsx,.ts,.tsx" --out-dir lib"
}
- 或者如下报错:pnpm run build 找不到window,底层原因是node编译解析的时候不支持浏览器的一些预发会找不到目标对象,解决方案是涉及到的加上判断 typeof window !== 'undefined',全量加上就好了
- 直到pnpm run build成功为止,然后在主项目查看package.json中配置的
"@项目名/utils": "workspace:*",在项目中进行全局替换uitls对应的一些工具方法,比如替换替换如下各种关键字/utils/、utils/、'utils'、"utils"、'utils、@utils、@/utils、../utils),umijs alias 'config'、'utils',然后全部替换成import {xxx} from '@项目/utils' - 全部替换完成以后在主项目运行run start查看是否全局替换成功,有报错就修复报错,大部分就是不仔细出现一些别名写错等问题
- 过程实在艰辛,项目光替换uitls这块500+处,各种别名,各种异常,确实需要耐心去踩坑
Components packages
然后就是拆components包,这里过程和拆uitls包差不多,就不多概述了,无非区别在babel这块,比如style-babel缺少或者less-babel等,按照报错去添加就好了,拆好pnpm run build能成功再开始全局替换就行了,这里全局替换1000+,感觉受了内伤,这里给一套替换时候的方式,方便整个过程结构化管理,参考如下表格内容
| 组件名 | 功能含义 | 全局引用次数 | 引用全路径 | 是否拆包完成 | 问题记录 |
|---|---|---|---|---|---|
| EditTable | 可编辑表格 | 2 | xx1/xxx/EditTable xx2/xxx/EditTable | 已完成,以验证 | 1.组件体积过大 |
Hooks packages、Service packages、项目包A、项目包B
这些拆包方式都差不多意思,无非是替换过程比较繁琐枯燥
拆包注意
- 在子包编译过程中pnpm run build会比较繁琐,大家可以配置
--watch,自定义script,这里比较简单就不过多概述 - 子包的依赖一定要和主项目中的保持一致,防止依赖冲突或者各种玄学问题
结论
- 方案可行但是改动量太大,容错率太低,需要全面测试,动不动几百几千处改动,换做谁不迷糊,还比如团队协同,新老代码替换等问题,业务停滞,业务不可能能让你一直搞这种东西,几个前端空出半个月一起努力搞这个东西,那么业务就停滞了,这不太现实。
- 需要重新定制CI/CD方案,npm切pnpm,那么线上构建流程是需要全面改造的,缓存,资源传递等
- 需要单独设计线上回滚和灰度方案,因为涉及改动量巨大,相当于系统整体重构了
- 既然业务系统A和业务系统B或者和业务系统C完全没有关系,那就拆代码仓库吧,真的尽力了,如果是一开始新项目就monorepo方式也不至于现在这样了,如果项目一开始建设的时候就好好对待它,好好规划它,又是怎样的解决呢,没有假如,诚心对待任何事物
方案三实践
多仓库的方式还是比较简单,将原来的代码仓库copy一份为新的代码仓库配置起始路由,加载固定的layout,然后互相解耦,A仓库删除B仓库相关的代码,B仓库删除A仓库相关的代码,然后各种配置pipeline CI/CD流程,删除无用依赖,这样就O了,这里主要拆分为如下几个步骤去完成就行了
- 删除无用页面以及组件(确定边界作用域)
- 删除无用路由、状态管理、service管理
- 删除国际化部分,写脚本去判断关键字,比如项目A目录下对应的所有国际化key,然后再整体配置国际化key做排除比较,删除没有使用的key就行了
- 删除无用依赖包,主要通过depcheck来辅助删除,可以参考如下篇章前端依赖治理全攻略:识别、删除、优化一步到位
四、规约
- 【强制】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
五、结语
对待项目就像对待自己的孩子一样,有一天你会发现,你对它越好,它还是会出现各种烂七八糟的问题,它还是会叛逆,随着年龄的增长问题也会浮现出来,我们能做到的就是提前发现问题,以及规避问题,好好引导它往正确的方向去走,至少在一次次发现和引导中,我们能彼此磨合,不至于瞬间失控。希望能帮助到大家,欢迎点赞、收藏、关注,您的一举一动是对我最大的支持,谢谢~