前端大型工程微服务架构拆分最佳实践!!!
前言
先屡一下目前存在的问题
- 打包速度和
启动速度
包括热更新
因为项目体积大的缘故导致都很慢
- 因为当前目录结构和项目中引入资源的问题导致
webpack很难升级
- 因为
项目耦合性强
导致更改一些文件时可能对其他业务会有影响
- 版本上线之后如果其中一个业务线的代码有问题就得
全部回滚在上线代价很大
以上这几条是目前已知存的在问题而且大概率随着时间越长项目体积越大后面就越难解决的问题,基于上述的情况才决定去做微服务拆分
篇幅很长如果有涉及到大型项目拆分的同学强烈建议花上一杯茶的时间可以看一下,应该会有收获的可以帮你避免一些可能遇到的问题
调研
前期做了很多调研设想和拆分流程图
-
比如按照业务模块或者小的场景拆分会更合理方便一些
-
直接新启动一个cli或者从原本项目直接copy出来一份?
-
阿里的
qiankun
或者是其他的方式比如京东的micro、monorepo、webpack5
提供的模块联邦等 -
可以看一下
single-spa
,qiankun是对这个上层的一个封装,看看原理是怎么实现的对后续可能遇到的问题,做解决思考是有一定帮助的
-
结果是按照大的业务模块去拆分,有想过先按照小的场景拆分,但是最后梳理下来不太合理,原因是场景虽小但是这个过程和拆大业务模块经历是基本相似的,并且后期还得组合到一起,而且最后拆完拿到的
结果收益
肯定是不如直接拆大业务模块 -
工具上采用了qiankun经过了多方面的对比,qiankun已经出来很久是比较成熟的微前端方式,并且社区也比较完善,主要我们的项目是比较庞大的,
从18年到现在经历了4年
,有过经验的人应该知道对于这种老而旧历史包袱
比较多的项目,做拆分的过程是会有很多意想不到的问题的,所以采用成熟一点的尽量能把问题风险减少到最低
-
对于
新启动一个cli
还是从原本项目copy
出来一份,最终选择了后者,之前设想的很美好因为新建一个工程包括仓库配置全都弄最新的,可以完全按照qiankun提供的配置来做对接这样可以避免很多问题,但是真正做的时候会发现有很多问题比如当前拆分的业务要怎么迁移到新项目中、之前的业务模块之间错综复杂没办法完全识别出来当前业务模块的依赖到底有那些,或者说这件事要做的话成本非常的高所以最后选择直接copy出来一份做删减的操作
准备工作
1、开始按照qiankun官网做了一些相关demo
,自己先体验了一下流程(如果没有实践过的,强烈建议先体验再开始真正的工作)
2、从主项目copy出来一份项目开始进行删减工作,思路是可以从路由根部开始做删减
如果你的项目路由是按照业务模块规划的好的,那恭喜你删起来会容易很多,之所以要先做删减是因为代码量少路由加载的资源也会少,后面拆分的过程中可能出现问题的风险就会小很多,而且这个事是早晚必须要做的
3、删减的过程中可以做相关的记录后续在进行其他业务模块拆分的时候,也可以用得到并且在后续出问题的时候可以看相关记录代码恢复也可以用得到
4、删减工作持续了半个月左右才开始真正的联调工作
开始
1、可以按照qiankun官网对项目进行基本改造,主要是基座的webpack配置以及子服务的webpack配置还有根目录文件,之前写过一篇基准配置流程和一些前期踩过的坑大家可以先看一下,后面有改动的会在这篇文章呈现
主应用的改动
主项目根目录的改动 --- Index.js 这一块相比较上一篇是有部分改动的,里面大部分都有注释
const payPublicPath = `${Fin_PathPrefix}/home/payPublic`//子应用里面的路径都是基于这个路径后面拼上的,这样保证后续在开发其他子服务的时候不会互相影响
import ShareToPayPublic from './shareComponents/toPayPublic' //需要传递到子应用中的公共组件,里面就是整体引入了组件做了抛出的操作
import { registerMicroApps, start } from 'qiankun'
registerMicroApps([
{
name: 'PayPublic',
entry: BASE_URL.includes('localhost') //这里为了兼容本地开发所以做了区分
? '//localhost:8001/'
: BASE_URL + '/publicpay', //这里后面拼的publicpay标识是代表一个子应用服务加载的前缀,后面会详细说
container: '#webapp-toPublicHome', //主应用里面的容器用来承载子应用的内容
activeRule: location => {
//这里根据url中的前缀判断如果存在就正常加载子应用内容
const path = location.pathname.startsWith(payPublicPath)
return path
},
props: { ...ShareToPayPublic }, //传递的公共组件
},
])
start() //启动服务
主应用容器存放的位置
一定不要放在路由里面
1.放在路由中当你切换路由的时候就会关闭子应用
2.会导致子服务的第二个页面加载白屏
3.正确的做法是一定要保证子应用和主应用是共存的
4.可以采用display根据url匹配规则
做子应用或主应用显隐
<>
//主应用
<div id="webapp-micro-main" style={{ display: 'block' }}>
<div id="webapp-micro-main-content">
<RoutesComp />
</div>
</div>
//子应用
<div id="webapp-micro-app-pay" style={{ display: 'none' }}>
<div id="webapp-micro-app-pay-content">
{/* 子应用页面头部 */}
{Fin_PathPrefix === '/nav/normal' ? (
<CommonNavBar key="container-nav-micro-pay" />
) : (
<CommonNavBarWeChat key="wechat-micro-pay" />
)}
<div id="webapp-micro-app-pay-content-header">
<TabsHeader></TabsHeader>
</div>
{/* 子应用容器页面 */}
<div id="webapp-toPublicHome"></div>
{/* 子应用页面footer */}
<div id="webapp-micro-app-pay-content-foot">
<CommonTabBar key="common-tab-bar" isNoFetch />
</div>
</div>
</div>
</>
根目录渲染index.js
ReactDom.render(
<App /> ,
document.getElementById('container'),
() => {
setTimeout(() => {
isShowMicroApp() //监听react渲染完成之后做主应用or子应用的容器显隐
}, 1000)
}
)
看下
isShowMicroApp
函数内部执行逻辑
1.获取容器元素
2.根据url匹配规则
做容器或者相关头部尾部显示隐藏,具体可以看下面注释
const isShowMicroApp = () => {
//获取相关容器元素
const webappMicroAppPay = document.getElementById('webapp-micro-app-pay')
const webappMicroMain = document.getElementById('webapp-micro-main')
const webappMicroAppHeader = document.getElementById(
'webapp-micro-app-pay-content-header'
)
const webappMicroAppFoot = document.getElementById(
'webapp-micro-app-pay-content-foot'
)
if (webappMicroAppHeader && webappMicroAppFoot) {
//获取url进行规则匹配展示相应header或者footer这个根据自己的实际情况来
const pathNameArr = window.location.pathname.split('payPublic')
if (pathNameArr[1] && pathNameArr[1] !== '/compatible') {
webappMicroAppHeader.style.display = 'none'
webappMicroAppFoot.style.display = 'none'
} else {
webappMicroAppHeader.style.display = 'block'
webappMicroAppFoot.style.display = 'block'
}
}
//先把所有容器都隐藏 然后再打开对应的容器
// 其他
if (webappMicroMain) webappMicroMain.style.display = 'none'
// 对公付款
if (webappMicroAppPay) webappMicroAppPay.style.display = 'none'
// 确认是跳转到子服务内部做子应用or主应用显隐
if (
webappMicroAppPay &&
window.location.pathname.indexOf('payPublic') !== -1
) {
webappMicroAppPay.style.display = 'block'
return
}
if (webappMicroMain) {
// 其余的服务
webappMicroMain.style.display = 'block'
}
}
isShowMicroApp除了在
react渲染完成
之后调用,还需要再路由切换的时候
1.需要判断路由切换的目标页面是否属于子应用or主应用
的某个页面
2.如果目标页面是当前子应用内部或者是另一个子应用内部我们需要去加载不同的容器
3.我这面采用的是在Router组件render的时候调用这个函数进行url规则匹配
判断
<Switch>
{ChannelRoutesCfg.map(item => {
const { path, loadRoutes } = item
const lastPath = `${sessionStorage.getItem(
'Fin_PathPrefix'
)}/${path}`
return (
<Route
path={lastPath}
key={lastPath}
render={props => {
// 主要是这里的调用控制应用之间的切换
isShowMicroApp()
return (
<Bundle load={loadRoutes}>
{Cmp => <Cmp {...props} />}
</Bundle>
)
}}
/>
)
})}
</Switch>
index.html模版文件中做
react的cdn
引入,主要是解决主应用传递hooks组件
到子应用使用报错情况,具体报什么错误可以看上一篇
<script crossorigin src="https://static.lhc.com/js/react/react.js"></script>
<script crossorigin src="https://static.lhc.com/js/react/react-dom.js"></script>
webpack中配置
externals
打包的时候排除package里面的对应依赖
externals:{
'react':'React',
'react-dom':'ReactDOM',
},
主应用的开始的配置大概是以上这些,可以根据实际情况按照这个思路进行配置
子应用改动
子应用的基础配置改动比如入口文件index的配置和webpack的基础配置就不在这里贴了,也可以看上一篇文章或者qiankun官网也有相关的配置
子应用的打包
配置publicpay
是我们子服务的工程项目这个我们是在nginx做了转发
mode: 'production',
output: {
publicPath: '/publicpay/bundles/',
},
子应用路由配置
const BASE_URL = location.origin.includes('localhost')
const Routes = ({ list = routeList, ...props }) => {
return (
<Router
history={history}
basename={
window.__POWERED_BY_QIANKUN__ //这个我这默认写死了就是false
? '/'
: BASE_URL //判断是本地调试还是测试环境或者生产环境
? '/'
: 'publicpay' //访问的路径配置和上面的标识一样
}
>
<Switch>
.....
</Switch>
</Router>
)
}
nginx服务配置,主要是publicpay这一块,没有单独设置二级域名,就做了个转发,这个配置背后对应的我们的工程目录
主子服务开始做的一些基础配置大概就以上这些,可能有漏掉的地方,如果大家走下来之后可以尝试启动一下看看会不会有异常问题
因为对于一个老而旧的项目去拆分问题可太多了
有碰到问题可以发在评论区看到一定会回复的!!!
过程
从这一步开始做子服务的删减操作
中间持续了大概半个月时间,这一步就不给大家展示了可以按照自己工程的目录或者路由进行删减(如果大家的项目本身不是很大或者目录依赖很清晰这样还是建议可以单独启动一个cli
,免去删除这一步操作)
开始一些操作、
样式做隔离、公共组件传递接收、路由跳转方式更改、公共store使用方式、组件内部store使用方式、工具类函数
传递等
1、样式隔离
采用的是css Module
的方式进行样式隔离,避免和主应用或者其他子应用之间的样式产生冲突, 这个配置方式可以直接按照百度的css module去配置就好了
2、公共组件传递
传递方式上面有写到可以看下主应用里面的ShareToPayPublic
内容,这是一些组件示例后面的主应用history或者公共store等都会通过这种方式传递,最后通过qiankun提供的方式传递到子应用里面
// 费用归属V3
import CostBelongCustom from '@app/cssModulePart/common/costBelongCustom'
// 费用归属V3 详情内容
import CostBelongCustomDetailContent from '@cssModulePart/common/costBelongCustom/detailContent'
export default {
CostBelongCustom,
CostBelongCustomDetailContent,
}
3、子应用接收组件的方式
这里采用了两种方式第一种是直接传递到每个页面中通过路由传参
function render(props) {
//props中包含主应用传递下来的组件
ReactDOM.render(
<React.Suspense fallback={null}>
//通过路由的方式可以保证在每个页面的Props里面是可以拿到组件直接使用的
<RoutesComp {...props} />
</React.Suspense>,
container
? container.querySelector('#pay_public_container')
: document.querySelector('#pay_public_container')
)
}
第二种方式是直接注册的一般是公共Store这样子用,因为涉及到Store与Store之间的调用
import { setShareMainComponent } from '@utils/shareMain'
/**
* 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
*/
export async function mount(props) {
render(props)
//在这里进行注册
setShareMainComponent(props)
}
看一下这个方法内部的挂载方式,后面如果其他地方有需要用到直接从这个方法中解构出来
let shareMainComponent: Record<string, any> = {}
// 获取共享的组件
export const getShareMainComponent = () => {
return shareMainComponent
}
// 设置共享的组件
export const setShareMainComponent = (
currShareMainComponent: Record<string, any>
) => {
for (const key in currShareMainComponent) {
if (Object.prototype.hasOwnProperty.call(currShareMainComponent, key)) {
shareMainComponent[key] = currShareMainComponent[key]
}
}
}
4、路由之间的跳转方式
主应用与子应用之间可以使用
主应用的history
进行跳转子应用与主应用之间使用
主应用传递下来的history
进行跳转子应用与子应用之间需要使用
主应用传递下来的history
进行跳转子应用与子应用之间可以使用
子应用内部history
进行跳转
大部分情况下都是使用主应用的路由进行跳转的,子应用内部可以看自己的情况是否要做区分
import { createBrowserHistory } from 'history'
const history = createBrowserHistory()
//封装的push
const push = (path, state, search) => {
const Phistory = window.Phistory || history //优先使用主应用传递下来的history
const PayPath = `${window.finPathPrefix}home/payPublic` //对公付款子应用跳转路径
//这里是需要把路由按照规则匹配成携带子应用前缀的路由保证qiankun一直是激活状态,可以按照自己的实际情况进行设置
if (path.startsWith('views')) {
path = path.replace('views', PayPath)
} else if (path.startsWith('payPublic')) {
path = path.replace('payPublic', PayPath)
} else {
path = `${window.finPathPrefix}${path}`
}
if (state) {
let searchCondition = {}
const searchIndex = path.indexOf('?'),
searchStr = searchIndex >= 0 ? path.slice(searchIndex) : '',
realPth = searchIndex >= 0 ? path.slice(0, searchIndex) : path
if (search ? Object.keys(search).length > 0 : false) {
searchCondition = {
search: search,
}
}
if (searchStr) {
searchCondition = {
search: searchStr,
}
}
Phistory.push({
pathname: realPth,
state: JSON.stringify(state),
...searchCondition,
})
} else {
Phistory.push(path)
}
}
5、公共store的使用方式
有些时候需要用在子应用里面更改全局Store的状态,这种的就需要使用主应用传递下来的Store,但是有个问题是如果原本的业务中使用公共Store的地方比较多的话可能需要更改每一个地方的引用路径
这样更改的范围就太大了,有个解法看下面👇🏻
我们用的是mobx做的状态管理,可以在子应用store的constructor中
使用autorun监听
子应用数据更改,具体看下面步骤
1.需要在子应用的index目录做主应用store的注册
function render(props) {
//在子应用中注册公共配置store
CommonNavBarStore.getMainCommonNavBarStore(props.CommonNavBarStore)
}
2.子应用store内部接收
import { observable, action, autorun } from 'mobx'
class CommonNavBarStore {
constructor() {
autorun(() => {
//监听this.title更改
if (this.MainCommonNavBarStore) {
this.MainCommonNavBarStore.title = this.title
}
})
}
@observable title = '令狐冲'
@observable MainCommonNavBarStore = null
//接收主应用store
@action.bound
getMainCommonNavBarStore(MainCommonNavBarStore) {
this.MainCommonNavBarStore = MainCommonNavBarStore
}
}
3.当监听到title更改之后可以直接更改被注册的主应用store
,这样可以保证子应用内部做全局状态变更的时候是可以及时生效的
6、组件内部store使用方式
有一种情况是在子应用内部使用了主应用传递下来的组件,但是在使用完成之后可能需要
清除该组件对应的状态
,以保证下次使用正常,这里有个问题在不同的mobx实例下面使用store会有问题,会发现主应用传递下来的数据未拿到或者做清除处理不会及时生效
,看解法👇
如果我们在子应用中使用的是同一个mobx实例
就不会出现这样的问题,所以我们需要将主应用的mobx传递到子应用去使用
1.先在主应用中做传递
//mobx相关
import { mobx } from './mobx'
import { mobxReact } from './mobx-react'
import { mobxReactLite } from './mobx-react-lite'
//因为一些历史的原因这里用到了mobx-react和mobx-react-lite,大家可以看自己具体情况
export default {
mobx,
mobxReact,
mobxReactLite,
}
2.在子服务导出的render方法中做注册
function render(props) {
window.MainMobx = props.mobx
window.MainMobxReact = props.mobxReact
window.MainMobxReactLite = props.mobxReactLite
ReactDOM.render(
<React.Suspense fallback={null}>
<RoutesComp {...props} />
</React.Suspense>,
container
? container.querySelector('#pay_public_container')
: document.querySelector('#pay_public_container')
)
}
3.在子应用工程中建立项目目录做导出
//mobx.js
export const { action, observable, toJS, runInAction, computed, autorun } = window.MainMobx
//mobx-react.js
export const { observer, PropTypes } = window.MainMobxReact
//mobx-react-lite.js
export const { observer, PropTypes } = window.MainMobxReactLite
4.在webpack中做路径别名配置
,这样后面有和主应用组件做交互的时候可以使用配置的mobx进行操作
resolve: {
alias: {
'@mobx': path.join(__dirname, 'app/mobx'),
'@mobx-react': path.join(__dirname, 'app/mobx-react'),
'@mobx-react-lite': path.join(__dirname, 'app/mobx-react-lite'),
},
},
7、工具类函数传递就可以按照普通的组件的方式去传递注册就好可以看楼上的2、3条
8、共用同一个react
在上一篇文章有讲过react需要用
同一个实例
是因为有些主应用的hooks组件
,要传递到子应用中使用就需要遵循这种原则,否则会报异常错误具体错误提示可以看一下上一篇文章有贴图,这里继续说一下是因为想把已知的具体解法都列举一下大家可以自行选择
1.可以使用CDN的方式
然后主子服务配置externals排除依赖
中的react以及react-dom,这算是一种比较简单的方式但是有个问题是可能在上线之后出现想不到的异常情况毕竟是从原本依赖变成CDN,所以采用这种方式的同学建议在测试环境可以多跑一跑
2.可以采用路径别名配置的方式,从主应用传递下来和上面的Mobx比较类似但是这种方式配置比较繁琐,而且项目如果比较大的话改完之后测试成本还是比较高的
3.还有一种方式可以选择把react和react-dom挂载到主应用的window上面
,因为子应用和主应用共用的同一个window,但是也需要配置子应用的externals
排除相关依赖,这种目前看来是一种比较好的选择
import React, { useLayoutEffect } from 'react'
useLayoutEffect(() => {
window.React = React
window.ReactDOM = ReactDom
}, [])
整个过程大概是这个样子,后面具体的就是代码的迁移了这个可以看大家的选择
注意事项
需求同步
拆分的过程中需要考虑一些情况,因为在做子应用拆分的时候同时正常的需求肯定是迭代的,但是我们怎么保证最后子应用提测的时候需求是和主应用是同步的呢?
1.当子应用拆分的状态可以支持测试跑起来的阶段吗,可以在子仓库拉一下主仓库的git源
//step1 子仓库拉取之后本地会有两个源
git remote add mainOrigin 主应用仓库源地址
//step2 将主应用远程仓库的分支最新代码拉到子仓库中
git fetch mainOrigin
//step3 将主应用的分支合并到子应用的分支中
git merge mainOrigin/主应用开发分支名字
2.这种方式我们可以同步到主应用的最新代码,因为之前做过删减操作所以有commit记录
,所以之前删过的代码不会合并过来,但是需要注意一个问题同时也会合并很多当前子应用之外的最新代码
,这种情况给的建议是可以先放在子项目里面,因为在子项目中运行的时候只需要保证路由里面的list是干净的就好,剩余的额外代码可以最后子应用上线之后慢慢删除就好了
3.在合并的时候大家可能会遇到一个问题就是冲突可能会比较多
,如果一个一个解决会比较麻烦,除了要合并的子应用相关需求之外,剩余的完全可以批量处理
//设置冲突全部使用当前的
git checkout --ours ./app/...
//设置冲突全部使用传入的
git checkout --theirs ./app/...
上线部署
申请仓库配置转发运维这一层就不讲了,主要需要考虑一下
紧急切换方案
,要保证用户在使用出问题的时候可以做到秒切换
,尽量把损失降到最低,可以看下我们的操作
1.在主应用中配置开关,可以根据开关去判断是否加载主应用里面的模块还是加载子应用
//开关配置接口
export const getSteroConfigApi = async token => {
const domain = sessionStorage.getItem('Fin_Request_Domain')
const origin = window.location.origin
if (token) {
try {
const res = await fetch(
`${domain}/...`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-Auth-Token': token,
},
}
).then(res => res.json())
// 默认转成 0 / 1 在应用中获取配置做相关加载
sessionStorage.setItem('company_sub_app', +res.data.company_sub_app)
} catch (e) {
throw Error('设置子应用标识异常')
}
}
}
2.可以在主应用的入口文件进行调用,因为我们项目本身涉及到免登页面所以会提前调一次,也就是在登录页面,具体大家可以看自己情况而定
const App = () => {
useLayoutEffect(() => {
reFreshSteroConfigApi()
}, [])
//获取子应用开关配置
const reFreshSteroConfigApi = async () => {
await getSteroConfigApi(sessionStorage.getItem('Fin_Login_Info_Token'))
}
}
3.这个开关背后需要服务端同学的支持做一个接口配置,后面在用户可能出现问题的时候引导用户做刷新
或者退出重新进入
解决这个问题,而不用做回滚等操作
4.还有一个建议最好做灰度企业测试
可以先跑几个版本,没问题之后可以把主应用的具体模块业务清除掉,后面就完全访问子服务就好了
踩到的坑
看到上面的一些建议和配置方式,这些其实都是在拆分的过程中遇到的坑,还有很多大大小小的坑有记录的也有没有记录,我会在下面列一部分后面如果大家真正有需求做拆分的时候,遇到实际问题可以在评论区发出来
跳转白屏
有些情况是从子应用中直接跳到主应用的某个页面在重新进入子应用的时候,发现访问的是离开前的页面而不是应该显示的子应用首页
解法是在子应用中配置了路由重定向
,当监听到上面路由是子应用首页的时候默认在mount里进行二次路由跳转,这样可以保证每次重新进入子应用时一定是首页
/**
* 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
*/
export async function mount(props) {
//兼容子应用内部跳转到主应用之后在进入子应用展示异常
if (location.pathname === `${dirFin}home/payPublic`) {
props.Phistory.replace(location.pathname + '/compatible')
}
render(props)
}
资源丢失
子应用可能因为之前的项目比较老没有做
资源懒加载
,但是在做了懒加载之后发现进入子应用之后在开始跳转发现白屏问题,从Network
中可以看到是资源找不到
解法是可以在子应用的webpack种配置publicPath
将资源打包到这个路径下面
测试环境报跨域问题
大概率是运维配置的nginx转发的问题可以让相关负责配置的同学看一下是不是要配置
允许跨域
等操作
上面说的基本是解决的比较实用的方案,包括提到的mobx的问题、react的问题等,也是其中遇到的比较坑的情况
结果
因为这个是属于底层架构上的改造并不属于正常的业务需求,可能在每一个公司都一样永远是业务需求高于技术需求,所以这个改造我们团队并没有持续去做,中间断断续续经历了几个月的时间一路踩着石头过河,到2月份总算是上到了测试环境进行测试,这里还有一点提测的时候最好留两个入口这样测试同学在测试的时候可以对比着看比较方便
收益
- 项目冷启动速度在拆分之前需要
184秒
,拆分之后冷启动速度大概需要30秒
,速度提升了5倍之多
- 分支合并从之前的15个人可能参与的项目变成3个人参与,冲突率从
15%降低到3%
- 解耦后协作开发成本从之前36人可能参与的项目变成7个人参与,协作耦合率从
36%降低到7%
- 支付需求上线风险降到最低,不会再因为其他需求当前版本上不了线导致一起回滚,可以支持
独立上线部署
总结
这几个月下来其实还是比较艰难的一个是因为项目又比较庞大原本的历史债务
就比较多,另一个对于这种大型项目的拆分社区内很少有这样的文章或者记录来分享,所以基本上是解决了一个问题又出现了新的问题,但是总体来看结果还是好的,经过团队的努力拿到了一个结果,并且在这个过程中大家也得到了很多的经验
,其实这种经验我个人认为还是很宝贵的,因为不是每一个公司都能有这种特别老有比较大的项目让你去做拆分,另外就算有可能也不一定能有领导支持去做这些事
感谢观看☕️
转载自:https://juejin.cn/post/7201849928208072759