只讨论架构,不讨论框架
1、名词解释
由一群尽可能将数量最小化的软件程序组成,他们负责提供、实现一个操作系统所需要的各种机制和功能。这些最基础的机制,包括了底层地址空间管理,线程管理,与进程间通讯。
2、设计理念
将系统的实现,与系统的基本操作规则区分开来。它实现的方式是将核心功能模块化,划分成几个独立的进程,各自运行,这些进程被称为服务。所有的服务进程,都运行在不同的地址空间。
让服务各自独立,可以减少系统之间的耦合度,易于实现与除错,也可以增进可移植性。它可以避免单一组件失效,而造成整个系统崩溃,内核只需要重启这个组件,不至于影响其他服务器的功能,使系统稳定度增加。同时业务功能可以视需要,抽换或新增某些服务进程,使功能更有弹性。
就代码数量来看,一般来说,因为功能简化,核心系统使用的代码比集成式系统更少。更少的代码意味更少的潜藏程序bug。
3、具体应用
微内核架构在使用时主要考虑两个方面『核心系统』和『插件模块』。应用逻辑被划分为独立的『核心系统』和『插件模块』,这样就提供了良好的可扩展性与灵活性,应用的新特性和基础业务逻辑也会被隔离。
一、核心系统
核心系统通常是一个可以独立运行的最小化模块,操作系统(Windows NT、Mac OS X)就是这么实现的。从商业应用的角度来看,核心系统为那些特定的场景、规则、复杂的条件判断提供了通用的业务逻辑,而插件模块则提供了更为具体的业务逻辑。可以增加或扩展核心系统以达到产生附加的业务逻辑的能力。
二、插件模块
插件模块通常是一个专业处理额外特性的独立组件。通常,插件模块之间是没有依赖的,当然你也可以创建一个依赖其他插件模块的插件,但不管怎么样,让插件模块之间可以彼此通讯又不产生依赖是一个很重要的问题。
三、获取插件模块并判断可用性
核心系统需要知道每个插件的可用性并且知道如何获取它们,一个通常的实现方式是使用一组注册表。注册表包括了每个插件的基本信息,包括名称、数据规范、远程访问协议(取决于插件模块如何和核心系统进行连接)以及其他自定义数据。比如百度网盘中用于上传文件的上传插件提供了插件名称、数据规范(输入、输出数据)、数据格式(json、xml),如果这个插件是通过异步进行加载的,那么还会有一个具体远程HTTP访问协议地址。
四、连接到核心系统
插件模块可以通过多种方式连接到核心系统,包括OSGI(open service gateway initiative)、消息机制、web服务以及点对点的绑定(对象实例化,既依赖注入)。使用何种方式主要取决于具体的应用场景和特殊需求(单机部署、分布式部署),微内核架构默认没有要求具体的实现方式,但是必须保证插件模块之间不能产生任何依赖。
五、通信规范
插件模块和核心系统之间的通信规范分为标准规范和自定义规范,自定义规范通常是指某个插件模块是由第三方服务开发的。这种情况下,就需要在自定义规范和标准规范之间提供一个Adapter,这样核心系统就不需要关心每个插件模块的具体实现。在设计标准规范之前制定一个版本策略很重要。
六、事件模式
核心系统提供了多种事件模式,主要包括常用的点对点模式、发布订阅模式。同时,事件的类型分为全局(系统级)事件、系统内部事件以及插件模块内部事件。由于点对点模式中发送者和接收者之间没有依赖关系并且一条消息只对应一个接收者,所以可以用作广播全局(系统级)事件,比如调起某个插件模块。而发布订阅模式中订阅者和发布者之间存在时间上的依赖性,可以用于系统内部事件和插件模块的内部事件。此外,核心模块也可以通过发布订阅模式向外发布某些属于业务基本操作规则的事件。
七、接口设计
当插件模块注册到核心系统之后,通过系统级事件可以调起具体的某插件模块。此时就需要核心模块提供属于基本操作规则的接口供插件模块使用,同样的,插件模块也必须按照通信规范提供运行入口(类似于java的Main方法)和数据规范(参数格式,返回的数据格式),以此保障插件模块可以在核心系统上正确运行。插件模块是独立于核心系统之外的,但是根据具体的需求(提供单纯的数据服务、处理系统数据和信息)可能会需要操作核心模块的系统服务做一些定制化功能,此时核心系统需要提供一个上下文对象(Context),且插件模块与外部进行交互只能通过此上下文对象。上下文对象提供了基础操作(调起其他插件模块、调起系统服务、获取系统信息)的API和事件。
4、在前端系统中使用
把前端系统当成一个操作系统,业务基本操作的业务逻辑抽象成一个可以独立运作的系统内核,而不属于业务基本操作的业务逻辑都当成一个应用程序,完成安装、卸载、禁用、调用以及开机启动等功能。
在功能越来越多,依赖越来越负责的大型前端系统中,如果在项目初期没有很好地考虑后期兼容的灵活性、扩展性以及弹性,很容易出现项目难以维护或者谁都不想碰的尴尬场面,所以初期的设计很重要。
目前的大型前端单页面系统使用的都是根据业务划分独立组件,进行解耦和复用,最后通过组件进行堆叠、编译、上线。这样虽然完成依赖良好的组件化设计考虑到了系统的扩展性和灵活性以及弹性,但是整个系统还是紧紧绑在一起的,并没有根据基础业务和附加业务进行很好的拆分。当然很多优秀的前端工程师也考虑到了这一方面,提出来微前端的概念。不过微前端还是一个比较新的技术概念,没有经过很多大型前端系统的实践。而微内核架构已经在操作系统和很多的产品的后端服务及前端APP中经过了很多的实践。
一、定义核心模块和系统服务
上面提到核心模块是一个可以独立运行起来,包含系统基本操作规则的最小化模块。没有任何插件模块依然可以正常运行并处理基本的业务逻辑,所以在大型前端系统中将基础页面以及基础功能单独包装起来,组成一个最小化的模块,称之为core system。而这个core system可以通过包方式在多个系统间进行复用(NPM、bower、bundle、js chunk)。同时,将那些和业务相关的操作按照类型和场景封装为多个系统服务,并挂载(依赖注入)到核心系统中,称之为system service。需要注意的是core system可操作system service,而system service不可操作core system。
此外,core system根据具体的模块规范(AMD、CMD、CommonJS、ESM、SystemJS、UMD)向外部暴露了可交互的API和事件,称之为标准接口。后续在编写插件模块时要严格按照标准接口进行开发。
二、定义插件模块
插件模块是一个独立于核心系统的专业处理不属于系统基本操作的业务的模块(组件),比如网盘中的上传、下载、分享等功能。每个插件模块必须遵照定义好的标准接口和通信规范进行开发,而且每个插件模块都是相互独立的,所以没有对每个插件的实现细节做过多要求,如A插件模块使用React开发,B插件模块使用Vue开发,C模块使用jQuery开发。
每个插件模块都应该提供一个包含本插件模块签名信息(Mainfest)的JSON文件,签名信息包括了这个插件的名称、数据规范、依赖、远程访问地址(异步加载的js下载地址)和其他自定义字段。在前端加载核心系统时将该Mainfest文件注册进去,完成核心系统和插件模块的连接。
每个插件模块都应该提供一个统一名称的运行入口,比如start方法。也可以按照标准接口提供插件的生命周期事件,方便更细粒度的控制。
三、注册和调起
每个插件都提供了各自的Mainfest签名文件和可执行文件(JS文件、CSS文件)。所以当服务器接收到浏览器请求时可以将所要求的插件Manifest进行merge,合并成一个大的JSON结构,然后返回给浏览器。浏览器接收后,执行核心系统并注册Manfiest信息,然后启动。在注册过程中可以按照需求完成开机启动(默认执行)、预加载以及后台运行等不同类型的操作。
在业务逻辑和插件内部逻辑中可以能存在调起其他插件模块的需求,由于插件模块之间不产生依赖并且独立于核心系统,所以无法直接进行调起。不过由于注册表和点对点事件模式的存在,可以通过核心系统向外暴露的API传入插件名称和组、插件模块ID等信息进行调起。在调起之前先判断该插件在是否已注册,是否已加载(同步加载、异步加载),是否为单例和互斥,参数信息和数据格式,保证它可以正确的调起。
在插件运行过程中出现异常时,通过系统级事件通知核心模块。核心模块根据签名信息中的标识选择重启或关闭该插件模块。
四、多入口管理
在复杂的前端系统中同一个功能可能会存在过个入口的情况,比如上传、下载、分享等功能都是通过不同位置的按钮点击进行调起。通常,将具体功能(插件模块)和插件模块入口的UI展示进行隔离。首先,在基础页面结构中按照需求进行分块,分成不同的功能块,如菜单栏、右键菜单、列表项、右侧区域、左侧区域,并为这些区域定义唯一的名称和ID。在需要进行入口展示的插件模块的Manifest中,标识入口的区域和展示方式(按钮、图片、引导块、菜单项、下拉菜单)。
核心系统在注册表注册完毕后,解析那些需要展示入口的的字段并交给专门渲染插件模块入口的系统服务,这样就通过配置完成了多入口的管理,在后续需求变动和修改时,只需要更改Manifest文件即可,更加完善了系统的扩展性、灵活性、弹性。
5、技术选型
架构是独立于框架和类库的存在。
微内核架构的核心就是使业务的基本操作和专业处理额外特性的操作相隔离,提高系统的扩展性、灵活性和弹性。所以在技术选型时我们需要考虑三个方面:核心系统、系统服务、插件模块。
核心系统通常包含一个项目所需要的基本功能,包括基本的展示页面、交互操作、业务处理,代码量通常很少;系统服务提供业务处理的通用功能,比如列表操作、弹框、提示、异步化接口处理等,通常将系统中通用的需求抽象到这一层中;所以,这两个方面可以使用目前常见的react或vue通过webpack工具进行规范化开发,但如何向外部暴露核心系统的API和事件给插件模块调用是一个十分重要的问题。
插件模块更倾向于一个专业处理额外特性的lib库,所以推荐使用rollup或者webpack的lib模式进行开发和打包,产出一个『干净』的bundle(也可以发布到NPM中,实现独立发布和维护)。需要注意的是,如果这个bundle按照定义好的标准规范进行开发,那么它可以在任意一个微内核架构下运行,达到跨系统的能力。就像按照X86规范编写的程序可以在任意一个X86架构的系统上运行一样。
调起插件模块时如何异步加载插件模块bundle?
一、插件模块开发阶段
方案一:source code
插件模块的代码放置在一个根文件夹中,通过源代码进行开发和编译。每次更改后通过rollup或webpack产出一个bundle与Manifest文件,然后将它们上线更新即可。
这种模式下,插件模块的代码更新后,对应的Manifest文件也会更新,所以核心系统加载到插件模块也会被更新,不需要基础业务逻辑执行任何操作。
优点:不需要更新并上线基础业务代码。
缺点:没有版本号的管理功能以及不方便测试。
方案二:npm install
插件模块发布到github、gitlab等其他托管平台中,通过npm进行安装到基础业务逻辑中。插件模块每次更改后需要重新发布到托管平台,并在需要在业务逻辑中更新版本号重新执行npm install xxx,然后重新编译业务代码进行上线。
插件模块更新后,不需要像方案一那样上线插件模块。而是更新业务逻辑的依赖,安装最新版本的插件模块。
优点:可以通过版本号加载不同的阶段的插件模块以及方便测试。
缺点:更改后需要重新安装插件模块,并对依赖此插件模块的业务逻辑重新进行编译和上线。回归成本大,除了回归插件模块还要回归其他基础业务逻辑(当然也可以像方案一那样做,但是这样就抛弃了npm的最大优点 -> 版本号管理)。
二、获取插件模块的Manifest签名信息
方案一:服务器渲染直出到HTML中
服务器收到浏览器的页面请求时,将该页面需要的插件模块的Manifest签名文件进行Merge操作,然后统一输出到HTML中并返回给浏览器。
方案二:通过异步化获取
通过script标签的async和defer功能或AJAX,异步从服务器获取Merge之后的Manifest签名信息集合。
三、远程访问协议
核心系统调起插件模块时,可以通过插件声明的远程访问协议的HTTP地址,进行异步加载。
方案一:Manifest签名文件
在Manifest签名信息中放置插件模块的远程访问协议,比如上传插件模块的签名示例:
- {
- // 插件名称
- "name": "upload",
- // 组
- "group": "com.xxx.xxx",
- // 预加载插件模块资源
- "preload": true,
- // 数据规范,要求输入的参数
- "arguments": {
- // 核心系统提供的上下文对象
- "ctx": {
- "type": "Object",
- "required": true
- },
- // 需要上传的文件信息
- "file": {
- "type": "Object",
- "required": false
- }
- },
- // 远程访问协议
- "entrance": "http://www.a.com/static/plugin-bundles/upload-0.0.1.min.js"
- }
方案二:异步化接口 + import()
该方案是系统插件模块的远程访问协议不放置在插件模块的Manifest中,而是额外通过异步化接口请求得到远程访问协议。然后通过webpack提供的require.ensure()或esm的import()加载插件资源。
- // ctx为核心系统上下文对象
- ctx.loadPlugInAdapter = (pluginName, group) => {
- // 通过接口请求上传插件模块的远程访问协议
- fetchEntrance(pluginName, group).then(url => {
- // 核心系统执行插件模块
- ctx.invoke(pluginName, url);
- });
- }
- // 调起插件模块
- ctx.loadPlugInAdapter('upload', 'com.xxx.xxx');
最后
架构和框架是独立的,本文仅仅是提出一种架构思路,而且这个架构也在百度的某款用户量很大的复杂前端产品中得以应用。基于这一套弹性架构并结合Vue/React的现代化开发理念,可以很好的完成高复杂度的前端系统。希望本文可以给你们提供了除微前端之外的构建高弹性前端系统的另外一种思路。