随着公司业务的不断扩张,无论是后端、前端抑或是客户端,都面临着应用越来越复杂,越来越庞大以至于难以维护和治理的问题,为了解决这个问题,后端的微服务架构就应运而生了,微服务架构可以简单理解如下:
微服务是面向服务架构(SOA)的一种变体,把应用程序设计成一系列松耦合的细粒度服务,并通过轻量级的通信协议组织起来。具体来说就是将应用构建成一组小型服务。这些服务都能够独立部署、独立扩展,每个服务都具有稳固的模块边界,甚至允许使用不同的编程语言来编写不同服务,也可以由不同的团队来管理。
同理,前端的业务也遇到了类似的问题,自然而然的微服务架构的思想照搬到前端,于是前端的微前端架构应用而生,简单的解释如下:
一种由独立交付的多个前端应用组成整体的架构风格。具体来说就是将前端应用分解成一些更小、更简单的能够独立开发、测试、部署的小块,而在用户看来仍然是内聚的单个产品。
对于一些项目来说,可能会由于种种原因而存在一些不那么理想的代码,包括但不限于:
历史项目,祖传屎山
交付压力,快速上线
就近就熟,实现就行
...
这些不理想的代码可能会带来以下问题:
技术栈落后,混用多种技术栈
耦合混乱,牵一发动全身,全家火葬场系列
难以升级/重构
为了解决以上问题,我们引入了微前端方案,通俗的来说就是将一个大块头的应用按照不同的业务模块拆分成诸多的小块头的应用,并通过一定的逻辑将这些小块头的应用组合起来,使其对于用户来说使用起来仍然是在一个应用内,这么做有下面几个优点。
比起一个巨大的前端代码库,微前端架构下的代码库倾向于更小/简单、更容易开发,天然的界定了各个业务模块的职责范畴,降低意外耦合的可能性,每个业务只负责和该业务相关的代码,相对而言易于维护。
每个业务模块可以独立开发、测试、部署,不必关心其他模块,缩小变更范围,进而降低相关风险,不必过多考虑其它代码库的状态。
对于重构不理想的代码,很难有充裕的人力和时间去一步到位,在逐步重构的过程中,既要保证用户体验不受影响,还需要保持持续的交付。
按照业务模块拆分应用比较简单,但是怎么样将拆分之后的诸多应用在浏览器里整合成一个应用呢?至少使用起来要像是同一个应用,因此我们需要一个工具在浏览器端处理这些事情,处理应用的加载、切换、卸载等功能,听起来和懒加载/按需加在优点类似。
对于市面上一些流行的微前端框架,我们重点考察了 qiankun、 single-spa 和 ice stack,并且 qiankun 是基于 single-spa 的,single-spa 相对而言更底层一些,qiankun 基于此封装出了一些常用的 API,提供应用注册、加载、切换、卸载等通用的功能。出于快速上手落地的考虑,我们决定使用 qiankun 这套解决方案。
微前端架构特别适合 B 端系统,常见的有各种控制台系统、业务数据配置系统,这种系统有个特点就是业务模块众多,采用 MPA 模式的话优点在于部署简单、各应用之间硬隔离,天生具备技术栈无关、独立开发、独立部署的特性。缺点则也很明显,应用之间切换会造成浏览器重刷,由于产品域名之间相互跳转,流程体验上会存在断点。采用 SPA 模式的话则天生具备体验上的优势,应用直接无刷新切换,能极大的保证多产品之间流程操作串联时的流程性。缺点则在于各应用技术栈之间是强耦合的,类似滚雪球一样逐渐变成一个巨石应用。
微前端架构可以在一定程度上结合 MPA 和 SPA 的优点,既能部署简单、应用隔离,又能无刷新切换,保证体验的一致性。
我真有一头牛!小组之前接手了一个 B 端数据配置系统,里面有十多个业务模块,构建的前端产物有 13M 之多,标准的巨石应用,用了两套 UI 组件库,两套组件库之间会有一些冲突且难以排查,组件库版本较老,不敢贸然升级。这一个应用有多个同学同时维护多个模块,这给代码仓库、多套测试环境的管理带来了巨大的挑战,经常需要询问其他同学当前分支、测试环境的使用情况,严重影响了开发体验和效率。
前期来看,用这个 B 端数据配置系统来落地微前端架构再适合不过了。找到了落地的土壤之后,就要开始具体考虑如何落地了。
这个比较清晰,直接按照路由切分即可,根目录一般是子应用的列表页面 /foo 表示 Foo 模块,/bar 表示 Bar 模块即可。
对于前端代码的改造,对照 qiankun 的文档,入口里面导出对应的生命周期即可,这里参照文档即可,不展开叙述。
在 qiankun 提供的 registerMicroApps 方法里面,我们选择了 JS Entry,比起 HTML Entry,JS Entry 在加载子应用的时候会少一次 HTML 请求。这一块就会带来一个问题,主应用如何拿到子应用的 JS/CSS 静态资源地址呢?以及子应用如果有更新,主应用怎么及时的同步更新呢?
解决方案其实比较朴素,主应用在 NodeJS 服务端拉取子应用的 HTML,从 HTML 里解析出 JS/CSS 静态资源地址即可,至于怎么更新静态资源地址呢,其实有多种方案,但都不是足够完美足够简洁的,仔细考虑了通过手动配置和定时拉取两种方案,我们最终选择了定时拉取这种方案,优点是足够简单,无需人工干预,子应用更新发布之后,主应用在一个定时周期内就可以更新相应的静态资源地址了,这个定时周期我们设置的是 10s,这个方案的缺点是由于主应用部署是多机器多进程部署,可能存在某一时刻,不同的请求拿到的静态资源地址是不一样的,同时存在新旧两个版本,但是我们的定时周期设置的比较短,这点缺陷我们可以接受。
所以,主应用在点火之前拉取一次子应用,之后每隔 10s 拉取一次,每次拉取都将解析出静态资源地址存入内存,用户请求的时候直接将所有子应用的静态资源地址放到全局变量,前端将静态资源地址传入 registerMicroApps 方法中,实现子应用的注册即可。
之前谈到前端路由按照模块切分,同样的子应用的服务端也是按照路由切分,这里有一点需要说明,我们这个 B 端应用可以由我们完全控制,也就是意味着可以部署在同一个域名下,这样的话情况就简单了许多。由于子应用不应该被用户直接访问到,所以子应用的后端路由其实是要被隐藏的,我们这边的设定是后端路由需要添加 /sub-app 前缀。比如对于子应用来说,前端+后端的路由切分规则如下:
子应用 | 应用 ID | 前端路由 | 后端路由 |
---|---|---|---|
Foo | foo | /foo | /sub-app/foo |
Bar | bar | /bar | /sub-app/bar |
xxxx | xx | /xx | /sub-app/xx |
有了这些前置的设定之后,下面看看主应用拉取子应用的代码:
const entryMap = {} // 最终的静态资源地址会存在这个对象里
const appList = [{ id: 'foo' }, { id: 'bar' }] // 这里是子应用列表,实际情况可能是异步拉取远程的数据,可能是数据库、配置系统等
// 通过 HTTP 请求拉取子应用 HTML,假设域名都是 www.micro-front-end.com
const appHTMLList = await Promise.all(appList.map(app => Utils.httpRequest({
url: `http://www.micro-front-end.com/sub-app/${app.id}`,
timeout: 3000
})))
// 下面主要是使用 cheerio 解析 HTML
appList.forEach((app, index) => {
const html = appHTMLList[index]
if (html) {
// get stylesheet&javascript from sub-app's html
const $ = cheerio.load(html)
const styles = $('link[rel="stylesheet"]').map(function () {
return $(this).prop('href')
}).get()
const scripts = $('script').map(function () {
return $(this).prop('src')
}).get()
// cache stylesheet&javascript to entryMap
if (styles.length || scripts.length) {
entryMap[app.id] = {
scripts: scripts.length ? scripts : undefined,
styles: styles.length ? styles : undefined
}
}
}
})
静态资源地址的问题看似解决了,但是有一点还没能解决,对于一些子应用希望有自己的全局变量,由于我们这种使用的是 JS Entry,子应用无法通过 HTML 暴露全局变量,简单来说一切都是 JS,所以对于子应用的全局变量依然是只能通过静态资源的方式去加载,普通的静态资源是有缓存的,但是对于全局变量的 JS,情况完全相反,要求每次都是最新的,不能有任何缓存。这里的解决方案是采用宏替换。
对于子应用希望加载自己的全局变量,则需要在 HTML 里插入一个script标签,地址后面需要加上宏 __TOKEN__,示例如下
<script src="/sub-app/foo/global-variable?token=__TOKEN__"></script>
主应用拿到这个地址输出到前端的时候,会用 uuid 去替换这个 __TOKEN__,这样每次加载,都会触发这个资源请求,也就实现了子应用自己的全局变量的实时更新,在子应用中可以像下面这样输出变量和值:
app.get('/sub-app/foo/global-variable', (req, res) => {
res.set('Content-Type', 'application/javascript; charset=utf-8')
res.send(`window.__FOO_CONFIG__ = ${JSON.stringify({ foo: 'foo' })};`)
})
对于开发环境,为了支持热更新,我们并没有采用 JS Entry,而采用了 HTML Entry,在搭配 react-hot-loader 和 @hot-loader/react-dom 使用,可以支持开发阶段子应用的热更新,同时支持 React Hooks 的热更新。
虽然微前端的子应用支持不同的技术栈,但我们的应用完全由我们控制,本质上技术栈是一致的,这里面我们面对的情况又简单了许多,所以这块可以做一些资源加载的优化工作,主应用提取共用的库,子应用打包/加载的时候可以忽略这些共用的库库,这对于子应用的打包/加载都有性能上的提升。在我们的项目中我们提取了 AntD、AntD-Icons、Lodash、React、Moment、React-DOM、React-Router-DOM 这些资源。
引入了公共资源,就会遇到另一个问题,怎么更新这些包呢?每个应用都是独立部署的,不可能在同一时刻完成所有应用的升级,类似于 AntD 这种组件库,几乎每周发布一次更新,这里暂时没有一个完美的解决方案来处理这些更新,我们选择的策略是定期更新版本,更新的时候注意阅读对应的 CHANGELOG,评估此次更新的影响范围,大多数情况下是可以无感知的更新,采用小步快走的方式更新版本,及早暴露问题及早修复,避免大粒度更新时候可能会造成的大问题。
隔离分为 JS 和 CSS 隔离两部分,qiankun 首先通过 fetch 拿到 JS 资源,接下来会创建一个匿名自执行函数包裹住获取到的 JS 字符串,最后通过 eval 去创建一个执行上下文执行 JS 代码。qiankun 采用沙箱隔离,主要有以下三种沙箱:
legacySandBox
proxySandBox
snapshotSandBox
其中 legacySandBox、proxySandBox 是基于 Proxy API 来实现的,在不支持 Proxy API 的低版本浏览器中,会降级为 snapshotSandBox。在新版本中,legacySandBox 仅用于 singular 单实例模式,而多实例模式会使用 proxySandBox。
样式隔离的问题,目前没有很好的解决方案,一般的做法是给类名添加前缀,类似于君子协定,属于约定式编程,或者可以尝试 CSS in JS 方案。
我们的微前端架构方案的落地,总共产出了一个主应用,一个子应用模版,十多个子应用,还有一个 SDK 用于处理用户请求的鉴权和日志上报。
主要的收益是趁机修复了一些历史遗留问题,更新了组件库,确立一种组件库,根除了测试环境占用紧张的问题,大幅提升了开发体验和效率。
将一个巨石应用拆解成诸多子应用,会带来一些碎片化的问题,需要管理的应用越来越多,使用的服务器资源也会越来越多。
总体上来说,微前端架构对于这种巨石应用来说是一个非常不错的解决方案,兼具 MPA 和 SPA 的优点,保证用户体验的前提下,大幅提升了开发体验和效率,让开发者关注的模块更细化,让需求迭代可以小步快跑,灵活快速的部署上线。
我们是Trip.com国际事业部研发团队,主要负责集团国际化业务在全球的云原生化开发和部署,直面业界最前沿的技术挑战,致力于引领集团技术创新,赋能国际业务增长,带来更极致的用户体验。
团队怀有前瞻的技术视野,积极拥抱开源建设,紧跟业界技术趋势,在这里有浓厚的技术氛围,你可以和团队成员一同参与开源建设,深入探索和交流技术原理,也有技术实施的广阔场景。
我们长期招聘有志于技术成长、对技术怀有热忱的同学,如果你热爱技术,无惧技术挑战,渴望更大的技术进步,Trip.com国际事业部期待与您的相遇。
目前我们后端/前端/测试开发/安卓,岗位都在火热招聘。简历投递邮箱:IBUHR@trip.com,邮件标题:【姓名】-【携程国际业务研发部】- 【投递职位】