微服务与微前端,都是希望将某个单一的单体应用,转化为多个可以独立运行、独立开发、独立部署、独立维护的服务或者应用的聚合,从而满足业务快速变化及分布式多团队并行开发的需求。如康威定律 (Conway’s Law) 所言,设计系统的组织,其产生的设计和架构等价于组织间的沟通结构;微服务与微前端不仅仅是技术架构的变化,还包含了组织方式、沟通方式的变化。微服务与微前端原理和软件工程,面向对象设计中的原理同样相通,都是遵循单一职责 (Single Responsibility)、关注分离 (Separation of Concerns)、模块化 (Modularity) 与分而治之 (Divide & Conquer) 等基本的原则。
在某些场景下,微前端也包含了对于系统的纵向切分;即不同的团队会负责系统中某个特性 / 模块,从数据库、服务端到用户界面完整的流线。每个团队会更多地着眼于业务模型与特点。独立并不意味着完全的切割,各个特性 / 模块之间的共现组件可以通过 NPM/Git Submodule 等方式进行协同开发与复用。
微前端的落地,需要考虑到产品研发与发布的完整生命周期;我们会关注如何保证各个团队的独立开发与灵活的技术栈选配,如何保证代码风格、代码规范的一致性,如何合并多个独立的前端应用,如何在运行时对多个应用进行有效治理,如何保障多应用的体验一致性,如何保障个应用的可测试与可依赖性等方面。具体而言,我们可能从应用组合、应用隔离、应用协调与治理、开发环境等几个方面进行考虑:
应用组合:
组合时机,在构建时组合,还是在运行时组合
应用路由,如何根据 URL 加载 / 导航到不同的页面,如何根据子应用界面的变化切换 URL
应用加载,确定加载应用的版本,依赖于框架的加载机制,还是采用 AMD 或者 SystemJS 异步加载
应用隔离:
应用容错,某个应用的崩溃不应影响到其他应用或容器应用
样式隔离,避免 CSS 相互污染
DOM 隔离,避免子应用操作非自身作用域内的结点
应用协调与治理:
统一配置与切换,主题,利用 CSS Variables 等方式动态换肤
应用的生命周期,规范化子应用的生命周期,并且在不同生命周期中执行不同的操作
数据共享,子应用间数据共享
服务共享,跨应用数据共享与服务调用
组件共享,可能将某个纯界面组件或者业务组件以插件 (Plugin) 或者部件 (Widget) 的方式共享出去;提供某个计算能力。
开发环境:
跨技术栈支持
统一的构建流程与规范
打桩、埋点与 Hijack
此外值得一提的是,微前端化本身是为了保证系统的持续集成与快速迭代,那么对于各个子模块与系统本身的可用性与稳定性势必会带来挑战,这就要求我们在设计微前端解决方案时,考虑持续构建的时机与对应的测试方案;除了标准的单元测试、集成测试、端到端测试之外,我们还需要保证模块的依赖一致性与功能模块的可生成性;关于此部分的详细讨论参阅 Web 自动化测试概述。(https://github.com/wxyyxc1992/Awesome-CheatSheet/blob/master/Web/DevPractices/Test/Web-Test-CheatSheet.md)
微服务是一个简单而泛化的概念,不同的行业领域、技术背景、业务架构对于微服务的理解与实践也是不一致的。与微服务相对的,即是单体架构的巨石型 (Monolithic) 应用,典型的即是将所有功能都部署在一个 Web 容器中运行的系统。
虽然很多的文章对于巨石型应用颇多诟病,但并不意味着其就真的一无是处,毕竟微服务本身也是有代价的。除了组织的结构之外,微服务往往还要求组织具备快速的环境提供 (Rapid Provisioning) 与云开发、基本的监控 (Basic Monitoring)、快速的应用发布 (Rapid Application Deployment)、DevOps 等能力。
微服务应用往往由多个粒度较小,版本独立,有明确边界并可扩展的服务构成,各个服务之间通过定义好的标准协议相互通信。
在构建微服务架构时,模块化 (Modularity) 和分而治之 (Divide & Conquer) 是基本的思路。然后需要考虑单一职责 (Single Responsibility) 原则,即一个服务应当承担尽可能单一的职责,服务应基于有界的上下文 (Bounded Context),通常是边界清晰的业务领域构建。从系统衍化的角度,在系统早期流量较少时,只需一个应用将所有功能都部署在一起,以减少部署节点和成本。
随着流量逐步增大,我们过渡为了包含多个相互隔离应用的垂直应用架构;即是将不同职能的模块分成不同的服务,也逐步开始了微服务化的步伐。接下来,随着垂直应用越来越多,应用之间交互不可避免,将核心业务抽取出来,作为独立的服务,逐渐形成稳定的服务中台。
基于这些思考,我们可以将微服务中的挑战与关注点,划分为以下方面:
组合与隔离,本就是一体两面,往往某种组合方案就自然解决了隔离的痛点,而某种隔离方案又会限制组合的方式。
笔者首先从硬 / 软隔离的角度来对方案进行分类,服务端路由分发与 iFrame 是典型的基于浏览器的硬隔离方案,其天然支持多技术栈、多源的灵活组合,不过其在应用协调与治理方面需要投入较大的精力。Web Components 及其衍生方案同样能带来浏览器级别的隔离与松散的应用协调,但是较差的浏览器兼容性也限制了其应用场景。
iFrame 可以创建一个全新的独立的宿主环境,iFrame 的页面和父页面是分开的,作为独立区域而不受父页面的 CSS 或者全局的 JavaScript 影响。iFrame 的不足或缺陷也非常明显,其会进行资源的重复加载,占用额外的内存;其会阻塞主页面的 onload 事件,和主页面共享连接池,而浏览器对相同域的连接有限制,所以会影响页面的并行加载。
iFrame 的改造门槛较低,但是从功能需求的角度看,其无法提供 SEO,并且需要我们自定义应用管理与应用通讯机制。iFrame 的应用管理不仅要关注其加载与生命周期,还需要考虑到浏览器缩放等场景下的界面重适配问题,以提供用户一致的交互体验;这里我们再简要讨论下同源场景中的跨界面通讯解决方案。
详细解读参阅 DOM CheatSheet:
https://github.com/wxyyxc1992/Awesome-CheatSheet/blob/master/Web/Syntax/DOM-CheatSheet.md
BroadcastChannel 能够用于同源不同页面之间完成通信的功能。它与 window.postMessage 的区别就是,BroadcastChannel 只能用于同源的页面之间进行通信,而 window.postMessage 却可以用于任何的页面之间;BroadcastChannel 可以认为是 window.postMessage 的一个实例,它承担了 window.postMessage 的一个方面的功能。
const channel = new BroadcastChannel('channel-name');
channel.postMessage('some message');
channel.postMessage({ key: 'value' });
channel.onmessage = function(e) {
const message = e.data;
};
channel.close();
Shared Worker 类似于 Web Workers,不过其会被来自同源的不同浏览上下文间共享,因此也可以用作消息的中转站。
// main.js
const worker = new SharedWorker('shared-worker.js');
worker.port.postMessage('some message');
worker.port.onmessage = function(e) {
const message = e.data;
};
// shared-worker.js
const connections = [];
onconnect = function(e) {
const port = e.ports[0];
connections.push(port);
};
onmessage = function(e) {
connections.forEach(function(connection) {
if (connection !== port) {
connection.postMessage(e.data);
}
});
};
localStorage 是常见的持久化同源存储机制,其会在内容变化时触发事件,也就可以用作同源界面的数据通信。
localStorage.setItem('key', 'value');
window.onstorage = function(e) {
const message = e.newValue; // previous value at e.oldValue
};
Web Components 的目标是减少单页应用中隔离 HTML,CSS 与 JavaScript 的复杂度,其主要包含了 Custom Elements, Shadow DOM, Template Element,HTML Imports,Custom Properties 等多个维度的规范与实现。Shadow DOM 它允许在文档(document)渲染时插入一棵 DOM 元素子树,但是这棵子树不在主 DOM 树中。
因此开发者可利用 Shadow DOM 封装自己的 HTML 标签、CSS 样式和 JavaScript 代码。子树之间可以相互嵌套,对其中的内容进行了封装,有选择性的进行渲染。这就意味着我们可以插入文本、重新安排内容、添加样式等等。其结构示意如下:
简单的 Shadow DOM 创建方式如下:
<html>
<head></head>
<body>
<p id="hostElement"></p>
<script>
// 创建 shadow DOM
var shadow = document.querySelector('#hostElement').attachShadow({mode: 'open'});
// 给 shadow DOM 添加文字
shadow.innerHTML = '<p>Here is some new text</p>';
// 添加 CSS,将文字变红
shadow.innerHTML += '<style>p { color: red; }</style>';
</script>
</body>
</html>
我们也可以将 React 应用封装为 Custom Element 并且封装到 Shadow DOM 中:
import React from 'react';
import retargetEvents from 'react-shadow-dom-retarget-events';
class App extends React.Component {
render() {
return <div onClick={() => alert('I have been clicked')}>Click me</div>;
}
}
const proto = Object.create(HTMLElement.prototype, {
attachedCallback: {
value: function() {
const mountPoint = document.createElement('span');
const shadowRoot = this.createShadowRoot();
shadowRoot.appendChild(mountPoint);
ReactDOM.render(<App />, mountPoint);
retargetEvents(shadowRoot);
}
}
});
document.registerElement('my-custom-element', { prototype: proto });
Shadow DOM 的兼容性较差,仅在 Chrome 较高版本浏览器中可以使用。
与硬隔离相对的,笔者称为单体应用软隔离,其更多地依赖于应用框架或者开发构建流程,来实现容错与样式、DOM 等隔离。
单体应用软隔离又可以从应用的组合时机与技术栈的支持情况这两个维度,划分不同的解决方案。对于需要支持不同技术栈 (React, Angular, Vue.js, etc.) 的场景,我们往往需要彻底的类后端微服务化,每个前端应用都是独立的服务化应用,而宿主应用则提供统一的应用管理和启动机制;此时若需要解决资源重复加载、冗余的问题,则需要依赖统一构建或者由宿主应用提供公共依赖库,子应用打包时仅打包自身或非公用库代码。
如果是相同技术栈的场景,那么我们可以方便地利用框架本身的懒加载能力,在开发阶段以模块划分为微应用进行开发,构建时以单体应用的形式构建,在运行时是以应用模块的形式存在。
典型的应用组合方式分为构建时 (Build Time) 组合与运行时 (Runtime) 组合,如下图所示即是典型的构建时组合方案:
构建时组合的优势在于能够进行较好地依赖管理,抽取公共模块,减少最终的包体大小,不过其最终的产出仍是单体应用,各个应用模块无法进行独立部署。与之相对的,运行时组合能够保障真正地独立开发与独立部署:
运行时组合中,我们可以选择在使用 Tailor 这样的工具进行服务端组合 (SSI),也可以使用 JSPM, SystemJS 这样的动态导入工具,进行客户端组合。运行时组合同时能提供按需加载的特性,优化首页的加载速度。不过运行时组合可能重复加载依赖项(通过浏览器缓存或 HTTP2 适度解决),并且不同于 iFrame 的硬隔离,运行时组合仍可能面临难以预料的第三方依赖冲突。
React 这样的声明式组件框架,天然就支持应用的组合,我们可以传入渲染锚点以进行应用组合,也可以将不同框架的应用封装为 Web Components。首先我们可以将 React 应用定义为自定义元素:
window.customElements.define(
'react-app',
class ReactApp extends HTMLElement {
...
render() {
render(<App title={this.title} />, this);
}
...
}
);
然后在前端中直接使用该自定义元素:
<react-app title="React Separate Running App" />
在单体应用中,框架将路由指定到对应的组件或者内部服务中;而微前端中,我们需要将应用内的组件调用变成了更细粒度的应用间组件调用,即原先我们只是将路由分发到应用的组件执行,现在则需要根据路由来找到对应的应用,再由应用分发到对应的组件上。
具体的实践中,可能宿主应用使用 Hash Router 已经占用了 Hash 标记位,那么就需要为子应用提供专属的查询键,来进行子应用内跳转。
在 React 中可以使用 ErrorBoundary, 来限制应用崩溃的影响;如果是自定义的应用加载器,也可以实现 Promise 容错方案。Redux 可以考虑在宿主应用创建统一的 Store,每个应用中按照命名空间划分使用子状态空间:
const subConnect = subAppName => (mapStateToProps, mapDispatchToProps) =>
connect(
state => mapStateToProps({ ...state[subAppName] }, state),
mapDispatchToProps
);
对于 Action 可以使用命名空间形式:
`app/service-name/action`;
而对于应用治理方面,single-spa 或者 ueact-component 都定义了跨框架的组件生命周期,譬如在 single-spa 中,可以将 React 生命周期归一化:
const reactLifecycles = singleSpaReact({
React,
ReactDOM,
rootComponent,
domElementGetter: () => document.getElementById('main-content')
});
export const bootstrap = [reactLifecycles.bootstrap];
export const mount = [reactLifecycles.mount];
export const unmount = [reactLifecycles.unmount];
然后将其导出为单一应用并且异步加载:
// src/index.js
import { registerApplication, start } from 'single-spa';
registerApplication(
// Name of our single-spa application
'root',
// Our loading function
() => import('./root.app.js'),
// Our activity function
() => true
);
start();
https://zhuanlan.zhihu.com/p/41879781
推荐给你极客时间火爆专栏 --《深入拆解 Java 虚拟机》,作者为 Oracle Labs 高级研究员,已突破 1w+订阅。四大模块,覆盖 JVM 所有知识点,带你全方位拆解 JVM,系统学习 Java 性能分析、调优,掌握进阶必备技能!
扫码,即可试读此专栏的前三篇文章。