前言
最近笔者在工作上一直听到后端工程师们在谈论 Microservices(微服务) 的架构设计,听到的当下立马去查询才知道原来 Microservices 这麽潮,身为前端工程师的我当然也希望前端也可以有这麽新颖的架构,于是这篇文章就要来跟读者介绍 Micro Frontends(微前端)。
在开始进入本篇文章主题之前要先跟读者们介绍什麽是 Microservices。
Microservices 是一种软件架构,专注开发在每一个小型功能或者服务上,最后再利用模组化的方式组合出一个大型的应用程式。
如果读者是前端工程师的话可能会觉得上面的叙述很像是 ES6 module 的架构,开发者只需要专注在每个 module 上的开发,最后再利用 Bundler 打包这些 module 形成一个完整的页面甚至是应用程序,像下图这样。
不过后端跟前端完全不一样,后端是藉由一个又一个的 request
来 real time
的执行相关的代码,所以在 Microservices 的架构中,想要让一个又一个的服务能互相沟通,这时候就是要仰赖各个 API 了。
但这时候会有一个很大的问题,假如这三个 Service 对于前端来讲有高度相依性,以上图为例:一个完整的购物网站必须要先让使用者登入后才可以进行购买商品以及去购物车结帐,这时候在 Client 端就必须要分别打三次 API 并且互相等待才可以完成这整个流程,甚至假如刚好不小心有一个 Service 坏了需要重新启动,这时候可能会先产生一个过渡期的 API 避免 Client 端打到有问题的 Service,可是 Client 端也不可能每次都会去记住这个新产生的 API,所以这势必会造成一个很大问题。
这时候其实可以在这些 Services 上添加一个 API Gateway,对于 Client 端来说我只需要对到一个 Gateway 就好,对于这个 Gateway 来说我一样是去呼叫各个 Service 并且把资源都处理完后再回传给前端。
如果有读者本身是 SRE 熟悉 Docker 或者 K8s 这种用来自动部属容器化应用程式的平台,对于上面这张图应该更熟悉了!像 K8s 就有类似 API Gateway 的 Ingress 而 Docker 则有 routing mesh。
不过光有 API Gateway 其实还没办法凸显 Microservices 的特色,在 Microservices 的架构中其实每个 Service 都可以有自己的 DB,目的就是为了不要让每个 Service 之间会互相关联。
但这样做其实有几个缺点
很难保证数据的一致性
以上图为例,假如今天有一个 member 被注销帐号,但这个 member 在被注销帐号之前在 shopping cart 这个 Service 中有待结帐商品,这时候就会出现 member 数据不一致的问题。
DB 数据遗失
当某一个 Service 坏了需要重启,这时候 DB 的数据有可能就会遗失导致后续的数据出现错误。
为了改善这些缺点这时候就可以将这些 Service 的 DB 设计成可弃性,换句话说就是这些 DB 只是用来作为短期的数据存取而已,背后还有一个共用的大数据库去更新这些数据,通常都会利用 Redis 这种 cache DB 来进行设计。
Microservices 的好处就是可以专注开发在每个小服务上,举例来说以一个购票网站可能会在短时间内涌入大量的流量,这时候 race condition 就显得相当重要,这时候就可以利用 Go 语言进行开发,亦或者是要开发一个以效能为主的服务,这时候就可以用 Rust 进行开发。
上面也提到 Microservices 必须要仰赖 API 之间的沟通,所以通常在企业等级的产品上都会有拆分模组贩售的需求,假如这时候就是利用 Microservices 架构进行开发的话就很好拆分每个服务了。
这些都是使用 Microservices 后所能带来的好处,所以假如读者今天需要开发一个非常複查且庞大的平台,这时候不妨可以利用 Microservices 的架构进行开发喔!
讲完了 Microservices 后相信读者应该对于这种架构有了初步的认识,与 Microservices 不一样的架构就是 Monolithic Architecture(单体式架构)。
一般来说我们正常开发都是使用 Monolithic Architecture,在 Monolithic Architecture 的架构中都会把平台中所有内容都包装起来,像下图这样:
这种架构不是不好,但假如今天想要拆分或者扩充平台上的 Service 其实都会比较麻烦一些,而且也会怕牵一发而动全身,甚至所有的 Service 背后都会连到同一个 DB,这样极有可能会让 DB connection 同时连线过高导致 request 一直发送失败,所以这也是 Microservices 想要解决的其中一个痛点。
Micro Frontends 可以想像成前端版的 Microservices,在后端的世界中强调一个又一个 Service 而在前端的世界中则是强调一个又一个的 modules
,如何将网页中每一个 module
有效的拆分就是 Micro Frontends 要做的工作。
接下来要跟读者介绍的是 Micro Frontends 的实现方式,其实 Micro Frontends 有蛮多种实现方式的:
iframe
透过 iframe
的特性让每个被载入的区块页面都可以独立运行,假如需要有数据上的沟通也可以利用 window.postMessage
来完成,但这样做会有非常多的缺点,像是有可能载入同样的代码、UI 难以控制、甚至可能会有潜在的安全风险,所以笔者还是建议读者不要用此方式来进行 Micro Frontends 的实现方式。
Client side 利用 JavaScript 载入 module
这个方法简单又粗暴,就是利用 JavaScript create 出 script tag
后,接著再用 script tag 去载入相关的 module,最后再将其内容塞进去对应的 div
内,但缺点就是无法使用 SSR
,整体写法会像下图这样。
Web Components
Web Components 可以说是最多人拿来讨论的 Micro Frontends 的实现方式了,虽然我们在现今的网页架构中可以自由地 import
大佬们写好的组件,但难免都会遇到以下几个问题:
node_modules
相当庞大。className
来进行样式上的改动,但有可能因为该组件也有撰写一样的 className
导致很多时候都需要各种 override,长期下来也会是一种项目维护上的负担。因此 Web Components 的出现就是希望可以解决上述的问题,而 Web Components 一共由以下三种元素组成:
Custom elements
自订一个语意化标签来引用 components,并且利用 JavaScript 来建立 custom elements、shadow DOM、HTML templates 三者之间的关联,这种自定义标签会像这样: <my-custom-element></my-custom-element>
。
Shadow DOM
Shadow DOM 简单来说就是在现有的 DOM tree 中产生出一个完全独立于其他元素的 sub DOM tree
,藉由此方法就可以让 sub DOM tree 自己独立运作并且不会干扰到其他 DOM tree 上的元素。
HTML templates
利用 <template>
以及 <slot>
元素产生出具有複用性的 HTML 样板。
感觉上 Web Components 的设计准则就可以当作是 Micro Frontends 的最好的实现方式,透过上述的三种方式就可以产生出一个独立于原本项目而且又不会影响到页面的 modules。
可是这种开发方式其实不太符合每一种框架的设计理念(可能只有 Angular 体系最适合XD),而且会让一个 reusable component
开发起来蛮冗长的,所以 Micro Frontends 还是没有被很多前端工程师所重视。
Webpack Module Federation
假设我们今天有 A、B 两个项目,其中 A 项目有组件发现是可以让 B 项目进行複用的,这时候我们会有以下几种作法达到这个需求:
相信不管是哪个方法都相当複杂而且后续的维护成本也很高,而且随著项目越来越大,之后的网站性能也是问题,即便用了 code splitting 或者是 dynamic import 能提昇的效能也有限。
不过这个声音 Webpack 听到了,在 Webpack 5 诞生后推出了一个全新的技术叫 Module Federation,Module Federation 的诞生就是为了帮助开发者可以 import
外部项目已经 bundle 好的文件,不用再经由额外的安装步骤相当方便。
上面介绍完 Micro Frontends 的实现方式后,接下来就要进入本篇文章的实战篇了,这边笔者会以 Webpack Module Federation 进行 Demo。
首先笔者先介绍 Webpack Module Federation 的相关设定,在 Webpack Module Federation 的世界中我们一共会分为 Remote 以及 Host 两种设定区块,分别代表的是 modules export 以及 import 的部分,首先先介绍 Remote 的部分,在这个 plugin 中我们一共可以设定几个内容:
name
设定 export 出去的名字。
filename
设定 export
出去的文件名称,最终的使用方式会在下方的 import
区块做介绍,官方建议使用 remoteEntry.js
作为命名。
exposes
exposes
就是负责设定 modules
中需要被 export
的 components
文件 path alias
,通常这边的写法都会是 './componentName': 'componentPath
' ,在 key
的部分会用相对路径的写法原因是未来在 import
的时候就可以用上方设定好的 name
作为文件抓去的进入点。
举例来说:我们有一个 Button.jsx
的文件,这时候我们就可以在 exposes
的区块写上 './Button': './src/Button'
,到时候在 import
的时候就可以写上 name/Button
的方式抓去这个文件了。
shared
shared 则是当 components
有使用到相同的第三方组件时,可以用来设定的相关规范,基本上 shared 一共有三种呈现方式:
shared: { lodash: '^4.17.0' }
在 Object with sharing hints 这边一共可以设定非常多种内容,笔者这裡只单纯说明几个最常用的 sharing hints,想要了解所有的 sharing hints 的读者可以参考这个连结。
在 Shared 内笔者建议的写法是基于目前项目的 package dependencies 上再额外叠加一些自己想要设定的 Object with sharing hints,最终设定完的长像会像下图这样:
这裡笔者提醒一下读者,假如读者在设定的过程中有遇到这个错误:Uncaught Error: Shared module is not available for eager consumption
代表读者需要额外在新增一个档案将 component render 到 DOM 的相关程式码都複制上去,最后再到 index.jsx
中去 import
这个文件。
最终呈现在网页上的画面就会像下图这样:
再来是 Host 的部分,基本上设定的内容跟 Remote 大同小异,差别在于 Remote 需要设定 exposes 的 components 以及 filename ,而 Host 则是要设定该 Remote 的文件位置。
在文件位置中我们会以三种内容组合,写法上会像这样: name@remoteURL/filename
,其中 name 就是上方介绍到的 export
出去的名称,remoteURL 则是 Remote 区块内 Webpack config 中所设定的 output path 相关设定,而 filename 则是上方介绍到的 export 出去的文件名称。
最终的写法就会像下方这样:
于笔者在 shared 的区块都没有加上 eager: true
,所以这边在抓取 components 时都是用 async 的方式去抓取,因此笔者建议会使用 <Suspense>
的方式进行 data fetching 这样会比较安全喔!
最终呈现在网页上的画面就会像下图这样:
事例地址:https://github.com/w5151381gu...
这次介绍了 Micro Frontends 以及 Microservices 这两种目前最多人讨论的架构,希望可以让读者更了解一些新时代的架构所带来的优缺点,文章的篇幅可能有点多,读者可以边看边消化一下这样才能更好的理解所有的内容。
代码部署后可能存在的BUG没法实时知道,事后为了解决这些BUG,花了大量的时间进行log 调试,这边顺便给大家推荐一个好用的BUG监控工具 Fundebug。