微前端架构:如何由内而外取代单体架构

如何利用微前端技术实现单体应用程序的现代化改造?在本篇教程中,我们将探讨如何将前端从单体架构当中剥离出来,并快速完成微前端架构迁移。本文作者将结合个人项目实践经验为大家介绍心得。

问题所在

我们假设有这么一个单体代码库,它使用了某种后端模板引擎或者系统(例如EJS或者ERB),但没有认真考虑前端的设计需求。或者更糟糕的是,前端的开发早于SPA的出现,或者还可能使用了类似Ruby on Rails那样的框架。因此,JavaScript文件(例如.js.erb文件或AEM片段等等)中可能包含了后端变量。这种粗制滥造且各组件间紧密耦合的代码库几乎无法进行现代化升级。

我们当然希望不要再在这个单体系统中开发前端代码,我们希望转向更加JavaScript化的生态系统——但具体该怎么做?

大多数企业都负担不起(或者不愿负担)这种因工具淘汰带来的重写成本与停机时间。功能的演进需要开发的支持,但要保持同样的速度开发这些功能显然越来越困难。

正因如此,我们应该通过一种渐进且平滑的方式将单体逐步拆分为更多较小的部分,同时保证不让业务发生中断。

说来简单,但单体架构的拆分过程相当棘手。在进行前端迁移时需要为支持JavaScript的应用程序规划和开发新API,拆分就变得尤为困难。

在等待新API开发和发布的过程中,前端迭代开发、微前端(MFE)实现和团队的自主行动都将陷入僵局。但真的要这样子吗?错!我们可以对前端和后端进行解耦,让它们齐头并进。

Zack Jackson — ScriptedAlchemy

下面将介绍一种方法,它能够顺利解耦前端,并将其移植成具有SSR的独立MFE。如此一来,团队不再需要等待后端API被拆分成微服务或者等待后端API可用。这个方法叫作“由内而外替换单体”。

阻碍因素

微前端通常包含以下两大重要依赖项:

  1. 认证;
  2. 提供给应用程序的数据,在浏览器端和在服务器端渲染(SSR)期间。

根据我的个人经验,无论你的遗留系统属于Rails、Java还是.Net,用户身份认证一直是最难与单体后端进行剥离的部分。

将单体作为布局引擎

MFE有多种不同的架构规范。本文将重点介绍其中一种,即在后端微服务中非常流行的一个版本——LOSA(Lots Of Small Applications,大量小应用)。对于“由内而外”迁移来说,这是最理想的选择。

流经单体的LOSA请求/响应流

LOSA应用(通常为微前端)属于独立的Node.js服务,能够在服务器端渲染网页的一部分或者某些片段。每个页面可以由多个LOSA服务组成。这些应用程序/微前端单独进行构建、部署,并运行在容器中。

上图所示为同一页面采用了三种不同的渲染方式,演示了一个增量迁移的过程。先是单体渲染页面,再过渡到LOSA微前端,然后变成垂直的微前端。最后,单体被彻底替换掉。

当然,单体仍然负责处理HTTP请求,并将最终响应发送至客户端。微前端可以放在集群的防火墙后面——仅提供给遗留系统使用,直到API网关和用户身份认证机制剥离完成(或者至少已经转化为API端点)。在此期间,我们不需要做太多的改动。

渲染流程

下图展示了迁移后的请求/响应流程。

首先,发出一个请求:

GET/POST 'https://MFEwebsite.com/parts/header?format=json

渲染页面内容需要各类数据,那些无法从已解耦端点查询到的“缺失”信息可以在请求期间以props的形式发送给MFE。请求会经过一系列中间件,这些中间件负责渲染React应用程序,然后调用已解耦的API,并将响应结果以props的形式返回。这些props最终将组成window.INITIAL_STATE。

代码

关于模板功能或者过滤器的实现方法,我向大家推荐Hypernova。不过我自己并没用过,我已经习惯了一切自己动手,并在Rails、Node以及PHP后端中实现了类似的机制。但考虑到各类后端平台都有自己的特点,所以这里我就用Hypernova作为示例向大家讲解。

下面使用express实现MFE渲染端点:

来自另一个系统的请求(在这里就是那个单体):

GET/POST 'https://MFEwebsite.com/parts/header?format=json
{
   html: '<div> ... </div>',
   css: '/static/header.3042u3298423.css',
   js: '/static/header.idhf93hf23iu.js',
   initial_state: {items:[...]}
}

用于处理响应的中间件:

export function exampleRenderAPIware(req, res) {
  const renderedMarkup = renderHTMLpage(
    req,
    this.index,
    intial_state,
  );
  asyncRender.then(() => {
    const responseObject = {
      html: renderedMarkup,
      initial_state,
      js: jsResource,
      css: cssResource,
    };
    res.status(200).end(JSON.stringify(responseObject));
  });
}

发出这些初始POST请求的控制器也需要处理响应结果,将JS与CSS放在正确的位置,最后在遗留模板的对应位置渲染React。之前通常由其他控制器负责处理的资产现在需要负责将脚本与样式注入到遗留标头与body标签的底部。请注意,单体仍然被作为布局引擎。我们也在替换其他部分,并以React SSR方式添加新功能。最终,这些LOSA应用将通过一个MFE(或者借助Webpack黑魔法,我自己开发了webpack-external-import)整合在一起。

如何从模板数据迁移至新API?

在迁移中,解耦并上线新的API到底会带来怎样的影响?

之前,在单体把数据传给MFE时,express访问HTTP的请求正文。而现在,express向API异步获取数据。虽然数据格式可能会发生变化,但React仍然能够正确获取到props。

性能差异

与旧单体相比,LOSA架构的性能还不够好,通常需要400到600毫秒才能渲染出页面的特定部分。我们采用了异步Worker结构,这样就可以同时请求多项服务来渲染应用程序的不同部分(而不是渲染单个应用)。但这种作法提高了应用下线的难度,因为“生产故障”会导致侧边栏或页脚部分长时间缺失。因此,进一步拆分才是最好的选择。

我所说的LOSA异步Worker是这样的:我们使用大量的Node服务,每一个服务负责渲染页面的一个或多个组件。

遗留控制器(图中的灰色齿轮部分)可以将视图数据转给POST请求,而非后端模板引擎。回收数据机制则能够帮助后端减少支持负担。由于无需做出重大修改,后端开发人员能够腾出时间,专注于解耦数据服务,而前端也可以进行独立的开发。

视图数据被发送给了外部的React服务,而响应消息(包含了HTML、样式表、初始状态以及CSS URL)则被发送给后端模板引擎。现在,模板引擎只需要渲染POST请求所对应的响应,从而将视图或视图的一部分与原有单体剥离开来。

React渲染时间

React真的很慢!SSR也不怎么快——因此新的LOSA架构解决方案无法带来理想的性能表现。我们的解决方案是:在React内部进行片段缓存。

  • 黄色:无React片段缓存——端到端(400毫秒左右)
  • 深紫:有React片段缓存——端到端(150毫秒左右)
  • 橙色:全优化架构(20毫秒左右)
  • 绿色(底部):来自后端的原生片段缓存

React优化工作相当复杂,受篇幅所限,恐怕只能另起一篇文章详加说明了。总之,Graphana数据显示,我们至少将渲染性能提高了一倍,不过轮循时间仍然很长。尽管React已经能够在内部快速完成渲染,但150毫秒的端到端时间还没有达到我们的预期。在下一篇文章中,我们将具体聊聊片段后端与片段缓存。

渲染时间 VS 轮回时间

渲染时间一直是个麻烦事,即使是在React中采用了片段缓存之后,性能仍然无法令人满意。令我感到失望的是,虽然Node.js内部的渲染速度很快(约20毫秒),但整个流程仍然需要140到200毫秒才能完成。

瓶颈所在

  1. JSON大小,特别是初始应用状态——即渲染页面所需要的最少state。我们不再在初始渲染中放置太多字符串化的state,只发送足够让React完成渲染并让折叠组件变得可交互的必要state。
  2. 需要渲染的DOM节点数量——不再将代码放在无用的DIV中,只需要给它们加个class。利用HTML的语义特性以及 CSS的级联效果,我们可以少写一些HTML代码,这样也就减少了React.createComponent函数的生成。
  3. 垃圾回收——我们将在下一篇文章中讨论更多细节。
  4. 速度由数据服务决定——在中间层使用Redis。很多朋友认为“缓存失效问题难以解决”,我建议各位认真考虑一下事件溯源,或者我们可以使用CQRS与异步Worker来处理读写操作。
  5. 单体架构与MFE之间的HTTP开销——也就是gRPC、CQRS、UDP以及Protobuf。二者之间的通信应该通过内部Kubernetes网络进行。POST速度很慢,但也不是不能用。遇到问题时个别处理即可。

如何提升后端渲染性能

简单来说,模板化、片段缓存与gRPC/CQRS,移除JSON中臃肿的数据。React在服务器端速度较慢,但请记住,一切拆分都只会让速度变得稍慢,而不是更快。

伸缩问题如何解决?

对于一切解决方案,如果不能在规模化场景下实现良好的成本效益,那么都将只是空谈。我们绝对不能容忍天文数字级的运营成本或者糟糕的性价比。大规模且廉价的解决方案才是好的解决方案。下面来看几点容易被忽视的成本要素:

  1. 昂贵的第三方服务费用;
  2. 更多/更大的容器环境;
  3. 由于性能不佳而导致的收入损失;
  4. 由于两个分支无法同时被合并到master,因此单体架构会导致发布周期或部署流程阻塞;
  5. 开发人员在风险较低的环境中可以快速行动,业务人员能够将新想法推向市场,并对出现问题的部分及时回滚——快速行动的能力正是实现高成本效益的必要前提。

最终结果

流量: 1000万次渲染/天

资源分配:

  • 实例: 5
  • 内存: 100mi (100 MB内存)
  • CPU: 100 (单核)
  • 最大CPU使用率阈值: 65%
  • 响应时间:20至25毫秒
  • DOM复杂度:高
  • 响应时间缩短了95%
  • 绿色:后端渲染时间
  • 蓝色:使用了片段缓存和state优化的React

我的单线程JavaScript应用程序要比使用完整片段缓存的多线程后端系统更快。

原文链接: https://levelup.gitconnected.com/micro-frontend-architecture-replacing-a-monolith-from-the-inside-out-61f60d2e14c1

  • 发表于:
  • 本文为 InfoQ 中文站特供稿件
  • 首发地址https://www.infoq.cn/article/sINZ0kRVMAaBpjvBeBvv

扫码关注云+社区

领取腾讯云代金券

年度创作总结 领取年终奖励