前端微服务架构综述
目前最主流的前端方案都是以组件为基本单位构建一个单页应用程序,通过REST API和后端交互,最终将所有前端代码打包为静态资源部署,如果有SEO需求,会采用服务端渲染方案,前后端分别打包再做同构。这套方案确实能应付绝大多数中小型应用场景,但量变会产生质变,随着业务发展,前端会越来越臃肿,可能组件=>应用的结构会慢慢演变成组件=>服务=>应用,不同的服务由不同的项目组负责开发,甚至会有接入完全不同技术栈的服务的需求,作为这个大型单页应用的一部分,至此不同服务之间就会有不小的沟通成本,代码编译打包性能,开发体验也会迅速下降,急需一种更有层次的解耦方案。受到后端微服务的启发,前端微服务架构也就应运而生。
选型概览
那么在深入微服务的细节之前,有必要了解到它究竟能解决什么问题,以及伴随着的风险和挑战。毕竟使用新技术不是因为它很潮流很有逼格,而是因为它真的能满足需求。
使用场景
在什么情况下需要考虑微服务的架构呢?大致可以从如下两种情况考虑。
其一是从零到一的一个技术栈统一项目,由于业务升级导致项目过于庞大,需要做更多层次的拆分,解耦为多个服务各自完成自己的开发部署流程。
其二是解决旧系统的聚合问题。相信各位开发者都遇到过这样的问题,一个早期项目稳定运行了很长时间,随着时代发展新的项目使用新的技术栈,但新项目又需要结合旧项目的功能,那么对于新项目,我们就要使用新的微服务架构兼容这些旧应用部分。
核心优势
分析了以上场景,实际上微服务的优势也就显而易见了。
- 高度解耦,兼容不同技术栈,各自部署。
- 保证各个服务的低复杂度并能实现高速编译,提升开发和维护体验。
- 容错和拓展性高,各个服务相互独立更容易定位模块错误以及横向的拓展业务。
实现方案
目前微服务的实现大致分为如下两个大类。
- 硬隔离:完全隔离不同服务,天然支持多技术栈的组合,实际上仍然是互不影响的不同应用,不能在编译和打包阶段做一些共用依赖的优化,以及较弱的服务间通信能力。
- 软隔离:隔离层次更低,入口项目通常会为隔离的子服务提供通用注册机制以及路由分发,公共依赖管理,相比于硬隔离有更强的服务间通信能力,对应的,在处理作用域内的资源冲突方面需要花费更多精力。
硬隔离
路由分发
将URL映射到不同的服务器上各自部署的独立应用,这是最容易实现也是目前应用最多的微服务方案,但本质上只是将不同应用放在一起而已,不同服务的切换都会刷新页面,用户体验差,基本上也不能实现任何跨服务的优化,是不希望花任何时间在旧系统改造上,集成要求又不高时采用的方案。
iframe
将每个子服务嵌入到各自的<iframe>
中,这样可以解决不同服务间切换刷新的问题,相比于路由分发,用户体验显得不那么分离,但随着iframe
本身的局限,又会带来一系列的问题。
- SEO无力
- 阻塞主页面onload事件,和主页面共享连接池,影响性能
- URL没有记录,页面手动刷新就会返回首页
- iframe的样式以及兼容性都有各种各样的问题
这种方案不会成为微服务化的主要手段,通常可能用于某个极小的服务的集成,毕竟优点就是隔离完全使用方便,如果服务极小的话,上面的问题很可能都不会暴露出来。
Web Components
基本上可以理解为浏览器原生支持的组件化, 原理就是将各自的服务入口封装为各自的Web Components自定义元素,不仅能实现天然的隔离,也能通过特定的API进行公共资源的共享以及不同服务间的高效率通信。
优点看起来很多,但缺点也是过于明显,那就是太依赖于浏览器自身的这套规范,随之而来的就是如下问题。
- 兼容性差
- 周边生态基本为零,缺乏三方支持
属于一种想象很美好现实很骨感的方案,基本上了解就好,IE的问题现在都不能完全解决,这套方案能落地的时候,我可能都退休了。
软隔离
实际上软隔离就是相对于硬隔离而言,在抽象层次上取最合适的部分。最软的隔离其实基本上都使用过,那就是组件的lazyload,同时延伸出来的代码分割,如果在一个路由边界清晰的应用里根据路由进行代码分割,每个子页面都可以看做是一个“微服务”。
当然如果仅仅是如此那和正常开发也没区别,依然是统一构建部署,同一技术栈,只是借此阐明微服务这个概念。我理解的有质变的软隔离式微服务,就是再往上追溯一层,把某一个前端工程类比为一个组件,将多个这样的工程组件,通过主入口工程进行统一的管理,包括维护入口路由以及数据分发,确定当前进入到哪个工程组件后,再将控制权下放到该工程。如此做,就能实现微服务真正的目的。
- 工程组件相互之间实现隔离,实现不同技术栈的各自管理
- 通过主入口工程统一管理,保证整合后在公共部分提取,打包,子工程切换等场景有可优化空间
这种软隔离架构的核心思想就是把复杂组件的粒度上升到工程级别,让这个复杂组件脱离于整个项目,仅通过主工程提供的统一接口保留通信权利,如此做既保留了单页应用一体化的高效体验,又解耦了一体化带来的复杂度,可以说是目前微服务最有可行性以及收益的一种拆分粒度了。
软隔离细节
当然,确认了思路,随之而来的就是执行下去需要解决的问题,核心挑战有如下几类。
工程组件注册
首先要解决的就是工程组件的注册和注销问题,当应用入口分发路由到各个工程组件时,各个组件需要根据路由信息做出对应的渲染。那么可以考虑在入口处初始化一个模块加载器统一进行管理,功能函数大概可以按如下形式组织。
// 模块加载器 register.js
// 注册中心
class RegisterHub {
start() {
// ...
}
register() {
// ...
}
// ...
}
const registerHub = new RegisterHub();
// 工程组件注册
export async function register(params) {
registerHub.register(params.name, () => param.output);
}
export async function start() {
registerHub.start();
}
// 工程组件配置文件
{
"name": "..." // 工程组件名
"path": "..." // 工程组件注册url匹配
"output": '...' // 工程组件入口js
// ...
}
// 多份配置文件聚合为child-config.js
// 主工程入口注册
import { register, start } from './register';
import childConfig from './child-config.js';
childConfig.forEach(config => {
register(config);
});
start();
工程组件的入口js都通过全局的注册中心管理,当匹配到对应路由时,动态加载对应入口文件并通过一个特定的方法(上例为start)启动后续的挂载及渲染步骤。
工程组件通信
现在完成了工程组件的注册和渲染,之后便会将后续的动作各自交付给对应的工程组件,每一个工程组件对于总工程都是一个黑盒。但大多数情况下,我们需要和内部保持通信决定总线的行为,组件通信是必不可少的。这一步可以借鉴状态管理框架的思想,让每个工程组件在入口处导出自己管理工程组件层面通信数据的store,作为入口工程的模块store通过一个全局分发器管理。
// GlobalDistributor
export class GlobalDistributor {
constructor() {
this.stores = [];
}
registerStore(store) {
this.stores.push(store);
}
dispatch(event) {
this.stores.forEach((s) => {
s.dispatch(event);
});
}
// ...
}
然后在将store的导出路径存入各个工程组件的配置文件,在注册时一并传入到各自对应的根组件,就可以通过全局分发器作为中介者保证组件间通信了。
import { GlobalDistributor } from './GlobalDistributor'
const globalDistributor = new GlobalDistributor();
export async function register(params) {
let storeModule = await import(params.store);
let props = {
globalDistributor
}
props.store = storeModule.store;
globalDistributor.registerStore(storeModule.store);
registerHub.register(params.name, () => param.output, props);
}
路由分发
解决了注册和通信的问题之后,就可以梳理一下整个应用工作的流程了:
输入url => 根据url注册对应工程组件 => 工程组件内部接管路由对应组件的渲染
大致上就是经过了一个总应用分发路由到子应用,子应用分发路由到子组件的过程。到这里为止,整个应用的首屏也就完成了,接下来需要考虑的是,如果某个子工程组件更新路由后,如何保证整个应用中其他子工程组件的同时响应?现在的单页应用路由基本就是两种模式:Hash路由和History路由。
Hash路由通信很简单,每次变化都会触发浏览器的onhashchange事件,所有子工程组件都可以保证监听该事件同步更新。
但History路由的变化依靠于History对象的pushState和replaceState方法,不同的前端路由框架对History路由的实现都不一样,那么如何保证不同路由实现的子工程组件在其中某一个路由触发时,其他路由也同步更新呢?这里的思路就是在一个路由变化前,通知其他路由即将发生的变化,其他路由按各自的实现去跳转即可。就是需要一个中介者传递通信信息,上文实现的全局分发器就可以做到这一点,让每一个工程组件通过store对外输出一个全局跳转接口,当其中一个工程组件跳转时调用所有工程组件的该接口。大致实现原理如下:
// 每个工程组件的store输出
function to(state, action) {
// 这里实现各自的路由跳转逻辑
history.replace(action.path);
return {...state, path: action.path};
}
export const store = createStore(combineReducers({
// ...,
to
}))
子工程打包构建
整个应用的核心结构已经搭建完成,接下来就要思考打包的问题。微服务的一个优势就是在于不侵入到各个工程组件中,那么自然的,打包也不应该由总工程统一管理,最佳思路是各个工程组件按自己原来的思路打包,额外打包出一份供全局通信的store,并把入口js输出为一个library供总工程入口按需引用即可。一个工程组件的简单示例配置如下:
// 工程组件配置文件引入
const projectComponentConfig = require('./project-xx-config.json')
const config = {
entry: {
main: // 子工程入口
store: // 子工程store入口
},
output: {
//...
libraryTarget: // 总工程使用的模块系统类型
library: projectComponentConfig.name // 子工程名称
}
// ...
}
总工程打包构建
现在我们已经各自子工程的构建输出到各自的dist目录,dist目录下除了一些常规资源外,会多出如下两个核心文件
- 项目配置文件
- 总工程通信store文件
总工程打包就是起一个index.html给所有子工程提供挂载点,然后通过入口js引入所有打包好的子工程入口文件,通过之前提到的注册机制完成和路由的关联即可。这其中有很多可以优化的点,这里就抛砖引玉说两个比较重要的点。
项目配置文件和store文件的合并
子工程注册时必须要加载所有配置文件和store,虽然文件都不大,但当模块增多时,http请求过多会延缓项目的启动。合理的做法是通过webpack再次将子工程的所有store和配置再合成为两个总配置和总store文件。
子工程共用库管理
由于我们的思路是不入侵子工程构建,那么必然的每个工程的重复三方库都会多次打包到各自的js,这是不可接受的,要通过一种低侵入的方式将子工程的公共三方库引用提前到总工程入口中来。
我想到的最佳思路应该是通过以前提到过的webpack的dll插件统一维护一份三方库打包文件,所有的工程都从该份文件中用dllreference插件引入三方库,这份三方库的加载提前到总工程的index.html中即可。
小结
以上就是目前我所看到的前端微服务架构中可以谈到的一些点,当然也只是一个总体框架和概览,根据思路应该能从零到一的完成一个前端微服务项目,其中也肯定有很多细节坑要踩,目前也有一个名为single-spa的现成解决方案,有兴趣的也可以研究其中的实现思路。
-- EOF --