Serverless的微服务持续交付案例

本文是GitChat《Serverless 微服务的持续交付》部分内容已做修改。文章聊天实录请见:“顾宇:Serverless 微服务的持续交付解析

Serverless 风格微服务的持续交付(上):架构案例”中,我们介绍了一个无服务器风格的微服务的架构案例。这个案例中混合了各种风格的微服务。

架构图如下:

在这个架构中,我们采用了前后端分离的技术。我们把 HTML,JS, CSS 等静态内容部署在 S3 上,并通过 CloudFront 作为 CDN 构成了整个架构的前端部分。我们把 Amazon API Gateway 作为后端的整体接口连接后端的各种风格的微服务,无论是运行在 Lambda 上的函数,还是运行在 EC2 上的 Java 微服务,他们整体构成了这个应用的后端部分。

从这个架构图上我们可以明显的看到 前端(Frontend)和后端(Backend)的区分。

持续部署流水线的设计和实现

任何 DevOps 部署流水线都可以分为三个阶段:待测试,待发布,已发布

由于我们的架构是前后端分离的,因此我们为前端和后端分别构造了两条流水线,使得前后端开发可以独立。如下图所示:

(整体流水线)

在这种情况下,前端团队和后端团队是两个不同的团队,可以独立开发和部署,但在发布的时候则有些不同。由于用户是最后感知功能变化的。因此,为了避免界面报错找不到接口,在新增功能的场景下,后端先发布,前端后发布。在删除功能的场景下,前端先发布,后端后发布。

我们采用 Jenkins 构建我们的流水线,Jenkins 中已经含有足够的 AWS 插件可以帮助我们完成整个端到端的持续交付流水线。

前端流水线

前端持续交付流水线如下所示:

前端流水线的各步骤过程如下:

  1. 我们采用 BDD/ATDD 的方式进行前端开发。用 NightWatch.JS 框架做 端到端的测试,mochachai 用于做某些逻辑的验证。
  2. 我们采用单代码库主干(develop 分支)进行开发,用 master 分支作为生产环境的部署。生产环境的发布则是通过 Pull Request 合并的。在合并前,我们会合并提交。
  3. 前端采用 Webpack 进行构建,形成前端的交付产物。在构建之前,先进行一次全局测试。
  4. 由于 S3 不光可以作为对象存储服务,也可以作为一个高可用、高性能而且成本低廉的静态 Web 服务器。所以我们的前端静态内容存储在 S3 上。每一次部署都会在 S3 上以 build 号形成一个新的目录,然后把 Webpack 构建出来的文件存储进去。
  5. 我们采用 Cloudfront 作为 CDN,这样可以和 S3 相互集成。只需要把 S3 作为 CDN 的源,在发布时修改对应发布的目录就可以了。

由于我们做到了前后端分离。因此前端的数据和业务请求会通过 Ajax 的方式请求后端的 Rest API,而这个 Rest API 是由 Amazon API Gateway 通过 Swagger 配置生成的。前端只需要知道 这个 API Gateway,而无需知道API Gateway 的对应实现。

后端流水线

后端持续交付流水线如下所示:

后端流水线的各步骤过程如下:

  1. 我们采用“消费者驱动的契约测试”进行开发,先根据前端的 API 调用构建出相应的 Swagger API 规范文件和示例数据。然后,把这个规范上传至 AWS API Gateway,AWS API Gateway 会根据这个文件生成对应的 REST API。前端的小伙伴就可以依据这个进行开发了。
  2. 之后我们再根据数据的规范和要求编写后端的 Lambda 函数。我们采用 NodeJS 作为 Lambda 函数的开发语言。并采用 Jest 作为 Lambda 的 TDD 测试框架。
  3. 和前端一样,对于后端我们也采用单代码库主干(develop 分支)进行开发,用 master 分支作为生产环境的部署。
  4. 由于 AWS Lambda 函数需要打包到 S3 上才能进行部署,所以我们先把对应的构建产物存储在 S3 上,然后再部署 Lambda 函数。
  5. 我们采用版本化 Lambda 部署,部署后 Lambda 函数不会覆盖已有的函数,而是生成新版本的函数。然后通过别名(Alias)区分不同前端所对应的函数版本。默认的 $LATEST,表示最新部署的函数。此外我们还创建了 Prod,PreProd, uat 三个别名,用于区分不同的环境。这三个别名分别指向函数某一个发布版本。例如:函数 func 我部署了4次,那么 func 就有 4个版本(从1开始)。然后,函数 func 的 $LATEST 别名指向 4 版本。别名 PreProd 和 UAT 指向 3 版本,别名 Prod 在 2 版本。
  6. 技术而 API 的部署则是修改 API Gateway 的配置,使其绑定到对应版本的函数上去。由于 API Gateway 支持多阶段(Stage)的配置,我们可以采用和别名匹配的阶段绑定不同的函数。
  7. 完成了 API Gateway 和 Lamdba 的绑定之后,还需要进行一轮端到端的测试以保证 API 输入输出正确。
  8. 测试完毕后,再修改 API Gateway 的生产环境配置就可以了。

部署的效果如下所示:

(API Gateway + Lambda 配置)

无服务器微服务的持续交付新挑战

在实现以上的持续交付流水线的时候,我们踩了很多坑。但经过我们的反思,我们发现是云计算颠覆了我们很多的认识,当云计算把某些成本降低到趋近于 0 时。我们发现了以下几个新的挑战:

  1. 如果你要 Stub,有可能你走错了路。
  2. 测试金字塔的倒置。
  3. 你不再需要多个运行环境,你需要一个多阶段的生产环境 (Multi-Stage Production)。
  4. 函数的管理和 Nanoservice 反模式。

Stub ?别逗了

很多开发者最初都想在本地建立一套开发环境。由于 AWS 多半是通过 API 或者 CloudFormation 操作,因此开发者在本地开发的时候对于AWS 的外部依赖进行打桩(Stub) 进行测试,例如集成 DynamoDB(一种 NoSQL 数据库),当然你也可以运行本地版的 DynamoDB,但组织自动化测试的额外代价极高。然而随着微服务和函数规模的增加,这种管理打桩和构造打桩的虚拟云资源的代价会越来越大,但收效却没有提升。另一方面,往往需要修改几行代码立即生效的事情,却要执行很长时间的测试和部署流程,这个性价比并不是很高。

这时我们意识到一件事:如果某一个环节代价过大而价值不大,你就需要思考一下这个环节存在的必要性。

由于 AWS 提供了很好的配置隔离机制,于是为了得到更快速的反馈,我们放弃了 Stub 或构建本地 DynamoDB,而是直接部署在 AWS 上进行集成测试。只在本地执行单元测试,由于单元测试是 NodeJS 的函数,所以非常好测试。

另外一方面,我们发现了一个有趣的事实,那就是:

测试金字塔的倒置

由于我们采用 ATDD 进行开发,然后不断向下进行分解。在统计最后的测试代码和测试工作量的的时候,我们有了很有趣的发现:

  • End-2-End (UI)的测试代码占30%左右,占用了开发人员 30% 的时间(以小时作为单位)开发和测试。
  • 集成测试(函数、服务和 API Gateway 的集成)代码占 45%左右,占用了开发人员60% 的时间(以小时作为单位)开发和测试。
  • 单元测试的测试代码占 25%左右,占用了10%左右的时间开发和测试。

一开始我们以为我们走入了“蛋筒冰激凌反模式”或者“纸杯蛋糕反模式”但实际上:

  1. 我们并没有太多的手动测试,绝大部分自动化。除了验证手机端的部署以外,几乎没有手工测试工作量。
  2. 我们的自动化测试都是必要的,且没有重复。
  3. 我们的单元测试足够,且不需要增加单元测试。

但为什么会造成这样的结果呢,经过我们分析。是由于 AWS 供了很多功能组件,而这些组件你无需在单元测试中验证(减少了很多 Stub 或者 Mock),只有通过集成测试的方式才能进行验证。因此,Serverless 基础设施大大降低了单元测试的投入,但把这些不同的组件组合起来则劳时费力 。如果你有多套不一致的环境,那你的持续交付流水线配置则是很困难的。因此我们意识到:

你不再需要多个运行环境,你只需要一个多阶段的生产环境 (Multi-Stage Production)

通常情况下,我们会有多个运行环境,分别面对不同的人群:

  1. 面向开发者的本地开发环境
  2. 面向测试者的集成环境或测试环境(Test,QA 或 SIT)
  3. 面向业务部门的测试环境(UAT 环境)
  4. 面向最终用户的生产环境(Production 环境)

然而多个环境带来的最大问题是环境基础配置的不一致性。加之应用部署的不一致性。带来了很多不可重现问题。在 DevOps 运动,特别是基础设施即代码实践的推广下,这一问题得到了暂时的缓解。然而无服务器架构则把基础设施即代码推向了极致:只要能做到配置隔离和部署权限隔离,资源也可以做到同样的隔离效果。

我们通过 DNS 配置指向了同一个的 API Gateway,这个 API Gateway 有着不同的 Stage:我们只有开发(Dev)和生产(Prod)两套配置,只需修改配置以及对应 API 所指向的函数版本就可完成部署和发布。

然而,多个函数的多版本管理增加了操作复杂性和配置性,使得整个持续交付流水线多了很多认为操作导致持续交付并不高效。于是我们在思考:

对函数的管理和“ Nanoservices 反模式 ”

根据微服务的定义,AWS API Gateway 和 Lambda 的组合确实满足 微服务的特征,这看起来很美好。就像下图一样:

但当Lambda 函数多了,管理众多的函数的发布就成为了很高的一件事。而且, 可能会变成“Nanoservice 反模式”:

Nanoservice is an antipattern where a service is too fine-grained. A nanoservice is a service whose overhead (communications, maintenance, and so on) outweighs its utility.

如何把握微服务的粒度和函数的数量,就变成了一个新的问题。而 Serverless Framework ,就是解决这样的问题的。它认为微服务是由一个多个函数和相关的资源所组成。因此,才满足了微服务可独立部署可独立服务的属性。它把微服务当做一个用于管理 Lambda 的单元。所有的 Lambda 要按照微服务的要求来组织。Serverless Framework 包含了三个部分:

  1. 一个 CLI 工具,用于创建和部署微服务。
  2. 一个配置文件,用于管理和配置 AWS 微服务所需要的所有资源。
  3. 一套函数模板,用于让你快速启动微服务的开发。

此外,这个工具由 AWS 自身推广,所以兼容性很好。但是,我们得到了 Serverless 的众多好处,却难以摆脱对 AWS 的依赖。因为 AWS 的这一套架构是和别的云平台不兼容的。

所以,这就又是一个“自由的代价”的问题。

CloudNative 的持续交付

在实施 Serverless 的微服务期间,发生了一件我认为十分有意义的事情。我们客户想增加一个很小的需求。我和两个客户方的开发人员,客户的开发经理,以及客户业务部门的两个人要实现一个需求。当时我们 6 个人在会议室里面讨论了两个小时。讨论两个小时之后我们不光和业务部门定下来了需求(这点多么不容易),与此同时我们的前后端代码已经写完了,而且发布到了生产环境并通过了业务部门的测试。由于客户内部流程的关系,我们仅需要一个生产环境发布的批准,就可以完成新需求的对外发布!

在这个过程中,由于我们没有太多的环境要准备,并且和业务部门共同制定了验收标准并完成了自动化测试的编写。这全得益于 Serverless 相关技术带来的便利性。

我相信在未来的环境,如果这个架构,如果在线 IDE 技术成熟的话(由于 Lambda 控制了代码的规模,因此在线 IDE 足够),那我们可以大量缩短我们需求确定之后到我功能上线的整体时间。

通过以上的经历,我发现了 CloudNative 持续交付的几个重点:

  1. 优先采用 SaaS 化的服务而不是自己搭建持续交付流水线。
  2. 开发是离不开基础设施配置工作的。
  3. 状态和过程分离,把状态通过版本化的方式保存到配置管理工具中。

而在这种环境下,Ops工作就只剩下三件事:

  1. 设计整体的架构,除了基础设施的架构以外,还要关注应用架构。以及优先采用的 SaaS 服务解决问题。
  2. 严格管理配置和权限并构建一个快速交付的持续交付流程。
  3. 监控生产环境。

剩下的事情,就全部交给云平台去做。


更多精彩洞见,请关注微信公众号:思特沃克

Share

你的微服务敢独立交付么?

最近经常在项目或是社区里听到大家谈论微服务架构,但谈论的焦点更多集中在微服务拆分,分布式架构,微服务门槛,DevOps配套设施等话题上。

但是在我眼里,真正能称之为微服务架构的少之又少。原因也很简单,我所见到的很多所谓的微服务架构项目,大多都没有做到微服务架构的一个基本要求:服务的独立部署(交付)。

这里的独立部署和自动化部署还不是一个概念,服务的自动化部署相对简单,已有大量的工具可以帮助我们做到。但是这里所谈的独立部署,我认为关键和难点并不在于“部署”,而在于“独立”。

如果失去了服务独立部署(交付)的能力,一个微服务架构的威力将大打折扣,我们的系统虽然在物理上被拆分成了多个小的服务,但是如果从最终交付的角度来看,仍然是以一个整体存在的,就像单体应用一样,存在诸多的问题。

为什么服务的独立交付并不简单?

那为什么不能让每一个服务都独立部署到产品环境呢?问题的答案是:不是不能,而是不敢

为了表达清楚,让我们来看个例子吧。

像下图一样,我现在就是那个程序员帅哥(本色出演),突然有一天心血来潮,动手开发了一个网上商城。代码Push到Github并通过CI构建持续交付流水线,最终自动化部署到云端产品环境,供用户访问使用。

随着用户和访问量的增加,需求和功能也越来越多,系统也变得越发复杂。

从网上了解到最近有个叫微服务的架构非常火爆,我也赶了回时髦,当然也觉得这种架构确实可以帮助我解决现在的一些问题。

经过对系统的分析,我将商城的后台部分拆分出了3个服务,为了简单我们就称之为ABC三个服务。

我们假设一个比较极端的情况,三个服务相互调用(先不考虑这样是否合理),每个服务通过自己的持续交付流水线独立部署到产品环境。当前产品环境的各个服务的版本是:A:1.0、B:2.0、C:3.0

一切都非常完美是不是?看!我们已经做到了服务的独立部署!So easy~

当然,事情肯定不会那么简单。

问题出现在当我对A服务做了一次新的提交之后,A服务的最新版本升级到了1.1。不幸的是,这个新的版本意外的破坏了A与B之间的契约,错误的调用了B的接口,导致出现了错误。

虽然我的A服务和B服务都有比较完备的UT(单元测试),但因为UT无法发现服务之间的集成是否被破坏,所以只有UT作为质量保障的A服务持续交付流水线也自然没有能力发现AB服务集成被破坏的这个问题。最终导致存在问题的A1.1版本被部署到了产品环境,产品环境出现了严重的Bug。

请问在座的同学,碰到这样的情况,你会如何处理?

“加集成测试啊!”

这位同学说的极是,我这么聪明自然也想到了这一点,不就是要测集成吗?UT干不了就加集成测试不就成了。

为了统一语言,毕竟对于各种测试的叫法太容易引起混淆,参考Martin Fowler在《微服务测试策略》中的定义,我们在本文中将这种测试多服务集成的测试统一称作端到端测试(End-to-End tests,简称E2E测试)。

添加了E2E测试之后,我的交付流水线就变成了下面这个样子。

因为有了E2E测试的存在,问题迎刃而解,当A服务的新版本破坏了与B服务的集成时,E2E测试就会及时诊断出来,并阻止A服务的最新版本向产品环境流动,保证产品环境不被破坏。

这样看似没有什么问题,通过添加E2E测试,解决了服务间集成的验证问题,但在不知不觉中,我们也失去了微服务架构的那个重要的特性:“服务的独立交付”。

怎么讲?别急,我们再往下看。

假设A服务的修复过程中,B和C服务也提交了新的代码,我们假设这两个提交是没有问题的,但因为A服务的1.1版本导致E2E测试挂掉的问题还没有被修复,所以B和C的新版本也被E2E测试拦了下来,此时的E2E测试就像是一个亮起红灯的路口,阻塞了所有服务通往产品环境的通道。

所以说,随着集中E2E测试的添加,质量被保障的同时,我们的“微服务架构”也已悄然失去了服务独立交付的能力,杀敌一千自损八百,损失惨重!

这并不是我假想的场景,在我自己经历的几个真实项目中,这个问题都在一直困扰着我们。带来了各种各样的衍生问题,例如E2E测试长时间失败,无人修复,修复难度大,服务交付堵塞,为了保持交付通路畅通还不得不引入同样存在很大副作用的CodeFrezze机制和提交Token机制等。

可以看到,虽然我们能够在代码库,在部署结构上,甚至在组织上进行服务化拆分,但就因为这最后一个交付的十里路口,最后这一个红绿灯,让所有的服务又纠缠在了一起,所有的服务化拆分形同虚设,最终我们得到的也只是一个看起来像微服务架构的单体应用而已

拆除红绿灯,各行其道,收复失地!

那,如何才能将这个“红绿灯”拆除,让服务可以在有质量保障的前提下还可以做到独立交付呢?这就是本文要解决的问题,让我们继续往下看。

我的解决方法其实也很简单:Inline E2E tests

即并不添加新的集中的Pipeline做E2E测试,而是为每一个服务的Pipeline都添加一个相同的E2E测试的Stage,就相当于将E2E测试Inline到每个服务各自的部署流水线中,如下图所示。

其实Inline E2E测试还不是最关键的,最关键的变化点就是假设A服务有了新的提交,运行到A服务自己Pipeline的E2E测试的时候,此时的E2E测试并不是像之前一样获取B和C服务的最新代码库版本做集成验证,而获取当前产品环境上的B和C服务的已部署当前版本做集成验证

例如,如图所示A服务的版本从1.0升级到了1.1,当前产品环境的B和C的版本是2.0和3.0。在执行A服务Pipeline上的E2E测试时,验证出A1.1和B2.0集成存在问题,测试变红,Pipeline挂掉,从而阻断了A服务的1.1版本部署到产品环境,保证了产品环境不会被A的1.1版本破坏。

同样,假设A还没有被修复之前,B也有了新的提交,产生了一个新的版本B2.1,这时在B服务Pipeline上的E2E测试并不获取当前A服务的代码库最新版本1.1做集成测试,而是获取产品环境上的当前版本A1.0版本做集成测试。我们假设B2.1和A1.0之间的集成没有问题,测试通过,所以B的2.1版本就被成功的交付到了产品环境,而此时产品环境的A服务的版本仍是1.0。

看!服务之间的阻塞被神奇的解决了,服务再也不会被堵在一个统一的十字路口,而是各行其道,A的车道出了事故,是A的问题,应该由A来承担后果和解决问题,不应该影响到其他服务,其他服务依然可以持续的交付到产品环境。

向前看是持续集成,向后看是持续交付!

看到这里可能有些小伙伴会感到有些失望。咋呼半天,不就是将E2E测试整到每个服务的Pipeline里,再把获取版本从最新代码改成产品环境么?有啥厉害的。

但是,在我看来,这个看似简单的变化,意义却是重大的:它揭示了“持续集成”和“持续交付”的一个主要区别。

“持续集成”和”持续交付”,这两个概念相信大家一定都不陌生,在软件领域也被提了不少年头了,不算什么新概念新技术。但对于这两个概念,我们经常一起提及,也经常混淆,搞不清楚两者的区别到底是什么,可能认为持续交付只不过是持续集成的演进版,新瓶装旧酒而已。

但其实它们却有着本质的区别。

“持续集成”关注的是各个集成单元之前最新版本的集成问题,即是不是某个集成单元的最新版本破坏了系统整体的集成,我管这种视角叫:向“前”看。

而“持续交付”关注的应该不是集成单元最新版本之间的集成问题,而是某个集成单元的最新版本是否可以(能和敢)部署到产品环境。换句话说就是维持产品环境的其他服务不变,只将当前集成单元的最新版本部署到产品环境,产品是否依然可用,不被破坏。所以在“持续交付”的视角下,应该关注的是当前集成单元与产品环境上的其他服务的版本是否兼容,我管这种视角叫:向“后”看。

向前看是持续集成,向后看才是持续交付,如果前后都不看那就是在裸奔。

但是肯定早有同学在心里疑惑,将E2E测试下放到每一个服务自己的Pipeline中,靠谱么?是不是太重了?根据测试金字塔,E2E测试应该是属于靠近金字塔顶端的测试种类,无论从数量和覆盖范围应该也都不会太多,怎么能靠它来保障服务之间的所有集成点和契约呢?

主角登场-契约测试

细心的同学肯定已经发现上面最后一张图中,我已经悄悄的把E2E测试变为了CT,即Contract Test,契约测试。

契约测试也是这两年伴随微服务架构的兴起,经常被提及的一种比较新的测试类型。在测试金字塔中,他的位置介于E2E和Component Tests(可以理解成单个服务的API测试)之间。

简单的理解,契约测试就是一种可以用类似于单元测试的技术验证两两服务之间集成的测试技术。它相比于更低层次的单元测试的优势是可以测集成(两两服务之间),相比于更高层次的E2E测试的优势是实现方式上又类似于单元测试,更轻量,跑的更快,覆盖的范围也自然可以更广更细。

使用契约测试替换掉E2E测试之后,整个架构也会变得更复杂一些,目前契约测试的框架也有很多,如大家常常提到的Pact或是SpringContracts等等。这里我先以Pact为例予以说明,其他框架实现上可能有些差别,但是思路是一致的。

A服务调用B服务的一个API,我们就称为A和B之间存在了一个契约,即B应该按照这个契约提供一个满足契约要求的API,而A也应该按照这个契约约定的方式来调用B的这个API。在这个过程中A作为调用方,我们称之为Consumer端。B作为被调用方,我们称之为Provider端。

如果A和B都履行契约,按照契约定义的约定调用和被调用,我们就可以认为集成不会有问题。但无论是B擅自修改了API破坏了契约,还是A擅自修改了调用API的方式破坏了契约,都会导致契约被破坏,反应到测试上就是契约测试会失败,反应到产品上就是功能被破坏,出现Bug。

每个契约,例如A->B,都会有Consumer端和Provider端生成的两个产出物:分别是a-b.consumer.json.1.1(由Consumer端生成的契约文件,所以版本也是Consumer端A的版本号)和a-b.provider.jar.2.0(由Provider端生成的契约验证测试包,他由Provider端生成,所以版本是B的版本)。这个jar包其实就是一组测试,他的输入是a-b.consumer.json,产出则是测试的结果,也就是契约的验证结果:成功或是失败。

可以把A服务产出的契约文件a-b.consumer.json.1.1想象成一把钥匙,把B服务产出的Provider端的测试a-b.provider.jar.2.0想象成一把锁。那契约测试的执行过程就像是用这把钥匙试着去打开这把锁:如果可以打开,我们认为这A1.1->B2.0的契约是满足的,反之契约就是被破坏了。

值得注意的一点就是,契约测试不像E2E测试,它是有方向的,所以我们看到a-b和b-a是两个不同的契约。

所以,只有当A1.1->B2.0和B2.0->A1.1双向的契约都被验证通过后,我们才能认为A1.1版本和B2.0版本的集成是没有问题的。

用契约测试替换E2E测试

回到前面的例子上,假设我们已经构建了ABC三个服务两两之间的契约测试。此时,A服务有了新的提交升级到了1.1版本,那我们如何才能通过契约测试来验证A1.1版本能否交付到产品环境呢?

答案就是只要通过A的1.1版本的最新代码,生成所有A作为Consumer端的契约文件(a-b.consumer.json.1.1和a-c.consumer.json.1.1),用这两把“钥匙”去试着开(作为输入执行Provider端测试)产品环境对应的两把“锁”(a-b.provider.jar.2.0和a-c.provider.jar.3.0)。

如果都可以打开(测试通过)的话,就证明A的新版本1.1作为Consumer端与产品环境的B和C服务是兼容的。

等等,别着急,还没完……

因为我们还需要考虑A作为Provider的情况,做法还是通过A的1.1版本的最新代码生成A版本作为Provider端的契约测试(b-a.provider.jar.1.1和c-a.provider.jar.1.1),拿着这两把“新锁”,然后试着用产品环境上的两把“钥匙”(b-a.consumer.json.2.0和c-a.consumer.json3.0)去开。

如果也都可以打开(测试通过)的话,就证明A的新版本1.1作为Provider端与产品环境的B和C服务也是兼容的。

至此,当验证了A的新版本1.1无论是作为调用端还是被调用端都与产品环境上的其他服务契约满足后,我们就认为A1.1与B2.0和C3.0集成是没有问题的,也就代表A1.1可以被放心地部署到产品环境中,替代现在的1.0版本。

这块稍微有些复杂,用文字也很难讲的特别清楚,如果大家对我上边讲的内容感兴趣,但又没有完全理解。请大家移步去看一下我2017年9月份在北京CDConf(持续交付大会)上针对这个主题做的一次分享,讲的应该比写的更清楚一些。在那个分享的最后,也详细介绍了一些我们在这个方案实施过程中碰到的一些问题:例如对于契约变更,并发提交,多环境支持的解决方案,感兴趣的也可以拖到最后看一下。

《契约测试-微服务持续交付的金钥匙》(CDConf 北京 2017)

最后,敲黑板划重点

  • 微服务架构下的独立部署(交付)很重要,但往往容易被忽视,没有被引起足够重视。
  • 为了实现微服务的独立持续交付,我们要向“后”看,不要向“前”看,即关注当前变更服务与部署环境中其他服务的兼容性而不是关注当前变更服务与其他服务最新版本的兼容性。
  • 用契约测试来替代E2E测试,降低测试成本,提高测试覆盖,尽早测试。并通过不断地完善契约管理,保障微服务架构质量和避免微服务架构腐化僵化。

更多精彩洞见,请关注微信公众号:思特沃克

Share

微服务下使用GraphQL构建BFF

微服务架构,这个在几年前还算比较前卫的技术在如今遍地开花。得益于开源社区的支持,我们可以轻松地利用 Spring Cloud 以及 Docker 容器化快速搭建一个微服务架构的原型。不管是成熟的互联网公司、创业公司还是个人开发者,对于微服务架构的接纳程度都相当高,微服务架构的广泛应用也自然促进了技术本身更好的发展以及更多的实践。本文将结合项目实践,剖析在微服务的背景下,如何通过前后端分离的方式开发移动应用。

对于微服务本身,我们可以参考 Martin Fowler 对 Microservice 的阐述。简单说来,微服务是一种架构风格。通过对特定业务领域的分析与建模,将复杂的应用分解成小而专一、耦合度低并且高度自治的一组服务。微服务中的每个服务都是很小的应用,这些应用服务相互独立并且可部署。微服务通过对复杂应用的拆分,达到简化应用的目的,而这些耦合度较低的服务则通过 API 形式进行通信,所以服务之间对外暴露的都是 API,不管是对资源的获取还是修改。

微服务架构的这种理念,和前后端分离的理念不谋而合,前端应用控制自己所有的 UI 层面的逻辑,而数据层面则通过对微服务系统的 API 调用完成。以 JSP (Java Server Pages) 为代表的前后端交互方式也逐渐退出历史舞台。前后端分离的迅速发展也得益于前端 Web 框架 (Angular, React 等) 的不断涌现,单页面应用(Single Page Application)迅速成为了一种前端开发标准范式。加之移动互联网的发展,不管是 Mobile Native 开发方式,还是 React Native / PhoneGap 之流代表的 Hybrid 应用开发方式,前后端分离让 Web 和移动应用成为了客户端。客户端只需要通过 API 进行资源的查询以及修改即可。

BFF 概况及演进

Backend for Frontends(以下简称BFF) 顾名思义,是为前端而存在的后端(服务)中间层。即传统的前后端分离应用中,前端应用直接调用后端服务,后端服务再根据相关的业务逻辑进行数据的增删查改等。那么引用了 BFF 之后,前端应用将直接和 BFF 通信,BFF 再和后端进行 API 通信,所以本质上来说,BFF 更像是一种“中间层”服务。下图看到没有BFF以及加入BFF的前后端项目上的主要区别。

1. 没有BFF 的前后端架构

在传统的前后端设计中,通常是 App 或者 Web 端直接访问后端服务,后台微服务之间相互调用,然后返回最终的结果给前端消费。对于客户端(特别是移动端)来说,过多的 HTTP 请求是很昂贵的,所以开发过程中,为了尽量减少请求的次数,前端一般会倾向于把有关联的数据通过一个 API 获取。在微服务模式下,意味着有时为了迎合客户端的需求,服务器常会做一些与UI有关的逻辑处理

2. 加入了BFF 的前后端架构

加入了BFF的前后端架构中,最大的区别就是前端(Mobile, Web) 不再直接访问后端微服务,而是通过 BFF 层进行访问。并且每种客户端都会有一个BFF服务。从微服务的角度来看,有了 BFF 之后,微服务之间的相互调用更少了。这是因为一些UI的逻辑在 BFF 层进行了处理。

BFF 和 API Gateway

从上文对 BFF 的了解来看,BFF 既然是前后端访问的中间层服务,那么 BFF 和 API Gateway 有什么区别呢?我们首先来看下 API Gateway 常见的实现方式。(API Gateway 的设计方式可能很多,这里只列举如下三种)

1. API Gateway 的第一种实现:一个 API Gateway 对所有客户端提供同一种 API

单个 API Gateway 实例,为多种客户端提供同一种API服务,这种情况下,API Gateway 不对客户端类型做区分。即所有 /api/users的处理都是一致的,API Gateway 不做任何的区分。如下图所示:

2. API Gateway 的第二种实现:一个 API Gateway 对每种客户端提供分别的 API

单个 API Gateway 实例,为多种客户端提供各自不同的API。比如对于 users 列表资源的访问,web 端和 App 端分别通过 /services/mobile/api/users, /services/web/api/users服务。API Gateway 根据不同的 API 判定来自于哪个客户端,然后分别进行处理,返回不同客户端所需的资源。

3. API Gateway 的第三种实现:多个 API Gateway 分别对每种客户端提供分别的 API

在这种实现下,针对每种类型的客户端,都会有一个单独的 API Gateway 响应其 API 请求。所以说 BFF 其实是 API Gateway 的其中一种实现模式。

GraphQL 与 REST

GraphQL is a query language for APIs and a runtime for fulfilling those queries with your existing data. GraphQL provides a complete and understandable description of the data in your API, gives clients the power to ask for exactly what they need and nothing more, makes it easier to evolve APIs over time, and enables powerful developer tools.

GraphQL 作为一种 API 查询语句,于2015年被 Facebook 推出,主要是为了替代传统的 REST 模式,那么对于 GraphQL 和 REST 究竟有哪些异同点呢?我们可以通过下面的例子进行理解。

按照 REST 的设计标准来看,所有的访问都是基于对资源的访问(增删查改)。如果对系统中 users 资源的访问,REST 可能通过下面的方式访问:

Request:

GET http://localhost/api/users

Response:

[
  {
    "id": 1,
    "name": "abc",
    "avatar": "http://cdn.image.com/image_avatar1"
  },
  ...
]
  • 对于同样的请求如果用 GraphQL 来访问,过程如下:

Request:

POST http://localhost/graphql

Body:

query {users { id, name, avatar } }

Response:

{
  "data": {
    "users": [
      {
        "id": 1,
        "name": "abc",
        "avatar": "http://cdn.image.com/image_avatar1"
      },
      ...
    ]
  }
}

关于 GraphQL 更详细的用法,我们可以通过查看文档以及其他文章更加详细的去了解。相比于 REST 风格,GraphQL 具有如下特性:

1. 定义数据模型:按需获取

GraphQL 在服务器实现端,需要定义不同的数据模型。前端的所有访问,最终都是通过 GraphQL 后端定义的数据模型来进行映射和解析。并且这种基于模型的定义,能够做到按需索取。比如对上文 /users 资源的获取,如果客户端只关心 user.id, user.name 信息。那么在客户端调用的时候,query 中只需要传入 users {id \n name}即可。后台定义模型,客户端只需要获取自己关心的数据即可。

2. 数据分层

查询一组users数据,可能需要获取 user.friends, user.friends.addr 等信息,所以针对 users 的本次查询,实际上分别涉及到对 user, frind, addr 三类数据。GraphQL 对分层数据的查询,大大减少了客户端请求次数。因为在 REST 模式下,可能意味着每次获取 user 数据之后,需要再次发送 API 去请求 friends 接口。而 GraphQL 通过数据分层,能够让客户端通过一个 API获取所有需要的数据。这也就是 GraphQL(图查询语句 Graph Query Language)名称的由来。

{
  user(id:1001) { // 第一层
    name,
    friends { // 第二层
      name,
      addr { // 第三层
        country,
        city
      }
    }
  }
}

3. 强类型

const Meeting = new GraphQLObjectType({
  name: 'Meeting',
  fields: () => ({
    meetingId: {type: new GraphQLNonNull(GraphQLString)},
    meetingStatus: {type: new GraphQLNonNull(GraphQLString), defaultValue: ''}
  })
})

GraphQL 的类型系统定义了包括 Int, Float, String, Boolean, ID, Object, List, Non-Null 等数据类型。所以在开发过程中,利用强大的强类型检查,能够大大节省开发的时间,同时也很方便前后端进行调试。

4. 协议而非存储

GraphQL 本身并不直接提供后端存储的能力,它不绑定任何的数据库或者存储引擎。它利用已有的代码和技术进行数据源的管理。比如作为在 BFF 层使用 GraphQL, 这一层的 BFF 并不需要任何的数据库或者存储媒介。GraphQL 只是解析客户端请求,知道客户端的“意图”之后,再通过对微服务API的访问获取到数据,对数据进行一系列的组装或者过滤。

5. 无须版本化

const PhotoType = new GraphQLObjectType({
  name: 'Photo',
  fields: () => ({
    photoId: {type: new GraphQLNonNull(GraphQLID)},
    file: {
      type: new GraphQLNonNull(FileType),
      deprecationReason: 'FileModel should be removed after offline app code merged.',
      resolve: (parent) => {
        return parent.file
      }
    },
    fileId: {type: new GraphQLNonNull(GraphQLID)}
  })
})

GraphQL 服务端能够通过添加 deprecationReason,自动将某个字段标注为弃用状态。并且基于 GraphQL 高度的可扩展性,如果不需要某个数据,那么只需要使用新的字段或者结构即可,老的弃用字段给老的客户端提供服务,所有新的客户端使用新的字段获取相关信息。并且考虑到所有的 graphql 请求,都是按照 POST /graphql 发送请求,所以在 GraphQL 中是无须进行版本化的。

GraphQL 和 REST

对于 GraphQL 和 REST 之间的对比,主要有如下不同:

1. 数据获取:REST 缺乏可扩展性, GraphQL 能够按需获取。GraphQL API 调用时,payload 是可以扩展的;

2. API 调用:REST 针对每种资源的操作都是一个 endpoint, GraphQL 只需要一个 endpoint( /graphql), 只是 post body 不一样;

3. 复杂数据请求:REST 对于嵌套的复杂数据需要多次调用,GraphQL 一次调用, 减少网络开销;

4. 错误码处理:REST 能够精确返回HTTP错误码,GraphQL 统一返回200,对错误信息进行包装;

5. 版本号:REST通过 v1/v2 实现,GraphQL 通过 Schema 扩展实现;

微服务 + GraphQL + BFF 实践

在微服务下基于 GraphQL 构建 BFF,我们在项目中已经开始了相关的实践。在我们项目对应的业务场景下,微服务后台有近 10 个微服务,客户端包括针对不同角色的4个 App 以及一个 Web 端。对于每种类型的 App,都有一个 BFF 与之对应。每种 BFF 只服务于这个 App。BFF 解析到客户端请求之后,会通过 BFF 端的服务发现,去对应的微服务后台通过 CQRS 的方式进行数据查询或修改。

1. BFF 端技术栈

我们使用 GraphQL-express 框架构建项目的 BFF 端,然后通过 Docker 进行部署。BFF 和微服务后台之间,还是通过 registrator 和 Consul 进行服务注册和发现。

  addRoutes () {
    this.express.use('/graphql', this.resolveFromRequestScopeAndHandle('GraphqlHandler'))
    this.serviceNames.forEach(serviceName => {
      this.express.use(`/api/${serviceName}`, this.routers.apiProxy.createRouter(serviceName))
    })
  }

在 BFF 的路由设置中,对于客户端的处理,主要有 /graphql/api/${serviceName}两部分。/graphql 处理的是所有 GraphQL 查询请求,同时我们在 BFF 端增加了 /api/${serviceName} 进行 API 透传,对于一些没有必要进行 GraphQL 封装的请求,可以直接通过透传访问到相关的微服务中。

2. 整体技术架构

整体来看,我们的前后端架构图如下,三个 App 客户端分别使用 GraphQL 的形式请求对应的 BFF。BFF 层再通过 Consul 服务发现和后端通信。

关于系统中的鉴权问题

用户登录后,App 直接访问 KeyCloak 服务获取到 id_token,然后通过 id_token 透传访问 auth-api 服务获取到 access_token, access_token 以 JWT (Json Web Token) 的形式放置到后续 http 请求的头信息中。

在我们这个系统中 BFF 层并不做鉴权服务,所有的鉴权过程全部由各自的微服务模块负责。BFF 只提供中转的功能。BFF 是否需要集成鉴权认证,主要看各系统自己的设计,并不是一个标准的实践。

3. GraphQL + BFF 实践

通过如下几个方面,可以思考基于 GraphQL 的 BFF 的一些更好的特质:

GraphQL 和 BFF 对业务点的关注

从业务上来看,PM App(使用者:物业经理)关注的是property,物业经理管理着一批房屋,所以需要知道所有房屋概况,对于每个房屋需要知道有没有对应的维修申请。所以 PM App BFF 在定义数据结构是,maintemamceRequestsproperty 的子属性。

同样类似的数据,Supplier App(使用者:房屋维修供应商)关注的是 maintenanceRequest(维修工单),所以在 Supplier App 获取的数据里,我们的主体是maintenanceRequest。维修供应商关注的是 workOrder.maintenanceRequest

所以不同的客户端,因为存在着不同的使用场景,所以对于同样的数据却有着不同的关注点。BFF is pary of Application。从这个角度来看,BFF 中定义的数据结构,就是客户端所真正关心的。BFF 就是为客户端而生,是客户端的一部分。需要说明的是,对于“业务的关注”并不是说,BFF会处理所有的业务逻辑,业务逻辑还是应该由微服务关心,BFF 关注的是客户端需要什么。

GraphQL 对版本化的支持

假设 BFF 端已经发布到生产环境,提供了 inspection 相关的 tenantslandlords 的查询。现在需要将图一的结构变更为图二的结构,但是为了不影响老用户的 API 访问,这时候我们的 BFF API 必须进行兼容。如果在 REST 中,可能会增加 api/v2/inspections进行 API 升级。但是在 BFF 中,为了向前兼容,我们可以使用图三的结构。这时候老的 APP 使用黄色区域的数据结构,而新的 APP 则使用蓝色区域定义的结构。

GraphQL Mutation 与 CQRS

mutation {
  area {
    create (input: {
      areaId:"111", 
      name:"test", 
    })
  }
}

如果你详细阅读了 GraphQL 的文档,可以发现 GraphQL 对 querymutation 进行了分离。所有的查询应该使用 query { ...},相应的 mutaition 需要使用 mutation { ... }。虽然看起来像是一个convention,但是 GraphQL 的这种设计和后端 API 的 读写职责分离(Command Query Responsibility Segregation)不谋而合。而实际上我们使用的时候也遵从这个规范。所以的 mutation 都会调用后台的 API,而后端的 API 对于资源的修改也是通过 SpringBoot EventListener 实现的 CQRS 模式。

如何做好测试

在引入了 BFF 的项目,我们的测试仍然使用金字塔原理,只是在客户端和后台之间,需要添加对 BFF 的测试。

  • Client 的 integration-test 关心的是 App 访问 BFF 的连通性,App 中所有访问 BFF 的请求都需要进行测试;
  • BFF 的 integration-test 测试的是 BFF 到微服务 API 的连通性,BFF 中依赖的所有 API 都应该有集成测试的保障;
  • API 的 integration-test 关注的是这个服务对外暴露的所有 API,通常测试所有的 Controller 中的 API;

结语

微服务下基于 GraphQL 构建 BFF 并不是银弹,也并不一定适合所有的项目,比如当你使用 GraphQL 之后,你可能得面临多次查询性能问题等,但这不妨碍它成为一个不错的尝试。你也的确看到 Facebook 早已经使用 GraphQL,而且 Github 也开放了 GraphQL 的API。而 BFF, 其实很多团队也都已经在实践了,在微服务下等特殊场景下,GraphQL + BFF 也许可以给你的项目带来惊喜。

参考资料

【注】部分图片来自网络


更多精彩洞见,请关注微信公众号:思特沃克

Share

Serverless的微服务架构案例

本文首发于GitChat《Serverless 风格微服务的持续交付(上):架构案例》,部分内容已做修改。文章聊天实录请见:“顾宇:构建Serverless 风格微服务实战解析(上)

一次微服务架构的奇遇

2016年12月初,当时我正在以一名 DevOps 咨询师的身份参与某客户的 DevOps 转型项目。这个项目是提升该部门在 AWS (Amazon Web Services)云计算平台上的 DevOps 能力。

自助服务的应用系统基于 Ruby on Rails 框架开发,前端部分采用 AngularJS 1.0,但是没有采用前后端分离的设计,页面代码仍然是通过 ERB 组合而成。移动端则采用 Cordova 开发。为了降低开发难度和工作量, 移动端的应用内容实际上是把 AngularJS 所生成的 Web 页面通过响应式样式的方式嵌入到移动端。但因为经常超时,所以这款 APP 体验并不好。

整套 Rails 应用部署在 AWS 上,并且通过网关和内部业务系统隔离。BOSS 系统采用 SOAP 对外暴露服务,并由另外一个部门负责。因此,云上的应用所做的业务是给用户展现一个使用友好的界面,并通过数据的转化和内部 BOSS 系统进行交互。系统架构如下图所示:

应用的交互流程如下

  1. 浏览器或者移动端通过域名(由 AWS Route 53托管)转向 CDN(采用 AWS Cloudfront)。
  2. CDN 根据请求的内容类别进行区分,静态文件(图片,JS,CSS 样式等),会转向 AWS S3 存储。动态请求会直接发给负载均衡器 (AWS Elastic Load Balancer)。
  3. 负载均衡器会根据各 EC2 计算实例的负载状态将请求转发到不同的实例上的 Ruby On Rails 应用上。每一个应用都是一个典型的 MVC Web 应用。
  4. EC2 上的应用会将一部分数据存储在关系型数据服务(AWS RDS,Relational Database Service)上,一部分存储在本地文件里。经过应用的处理,转换成 SOAP 请求通过 网关发送给 BOSS 系统处理。BOSS 系统处理完成后会返回对应的消息。

根据业务的需要,一部分数据会采用 AWS ElastiCache 的 Redis 服务作为缓存以优化业务响应速度。

团队痛点

这个应用经历了多年的开发,前后已经更换过很多技术人员。但是没有人对这个应用代码库有完整的的认识。因此,我们对整个团队和产品进行了一次痛点总结:

组织结构方面

运维团队成为瓶颈,60 个人左右的开发团队只有 4 名 Ops 支持。运维团队除了日常的事务以外,还要给开发团队提供各种支持。很多资源的使用权限被限制在这个团队里,就导致各种问题的解决进度进一步拖延。

随着业务的增长,需要基础设施代码库提供各种各样的能力。然而 Ops 团队的任何更改都会导致所有的开发团队停下手头的进度去修复更新所带来的各种问题。

应用架构方面

应用架构并没有达到前后端分离的效果,仍然需要同一个工程师编写前后端代码。这样的技术栈对于对于开发人员的要求很高,然而市场上缺乏合适的 RoR 工程师,导致维护成本进一步上升。经过了三个月,仍然很难招聘到合适的工程师。

多个团队在一个代码库上工作,新旧功能之间存在各种依赖点。加上 Ruby 的语言特性,使得代码中存在很多隐含的依赖点和类/方法覆盖,导致了开发进度缓慢。我们一共有 4 个团队在一个代码库上工作,3个团队在开发新的功能。1 个团队需要修复 Bug 和清理技术债,这一切都要同时进行。

技术债方面

代码库中有大量的重复 Cucumber 自动化测试,但是缺乏正确的并行测试策略,导致自动化测试会随机失败,持续集成服务器 (Jenkins)的 slave 节点本地难以创建,导致失败原因更加难以查找。如果走运的话,从提交代码到新的版本发布至少需要 45 分钟。如果不走运的话,两三天都无法完成一次成功的构建,真是依靠人品构建。

基础设施即代码(Infrastructure as Code)建立在一个混合的遗留的 Ruby 代码库上。这个代码库用来封装一些类似于 Packer 和 AWS CLI 这样的命令行工具,包含一些 CloudFormation 的转化能力。由于缺乏长期的规划和编码规范,加之人员变动十分频繁,使得代码库难以维护。

此外,基础设施代码库作为一个 gem 和应用程序代码库耦合在一起,运维团队有唯一的维护权限。因此很多基础设施上的问题开发团队无法解决,也不愿解决。

我参与过很多 Ruby 技术栈遗留系统的维护。在经历了这些 Ruby 项目之后,我发现 Ruby 是一个开发起来很爽但是维护起来很痛苦的技术栈。大部分的维护更改是由于 Ruby 的版本 和 Gem 的版本更新导致的。此外,由于 Ruby 比较灵活,人们都有自己的想法和使用习惯,因此代码库很难维护。

虽然团队已经有比较好的持续交付流程,但是 Ops 能力缺乏和应用架构带来的局限阻碍了整个产品的前进。因此,当务之急是能够通过 DevOps 提升团队的 Ops 能力,缓解 Ops 资源不足,削弱 DevOps 矛盾。

DevOps 组织转型中一般有两种方法:一种方法是提升 Dev 的 Ops 能力,另一种方法是降低 Ops 工作门槛。在时间资源很紧张的情况下,通过技术的改进,降低 Ops 的门槛是短期内收益最大的方法。

微服务触发点:并购带来的业务功能合并

在我加入项目之后,客户收购了另外一家业务相关的企业。因此原有的系统要同时承载两个业务。恰巧有个订单查询的业务需要让当前的团队完成这样一个需求:通过现有的订单查询功能同时查询两个系统的业务订单。

这个需求看起来很简单,只需要在现有系统中增加一个数据源,然后把输入的订单号进行转化就可以。但由于存在上述的痛点,完成这样一个简单的功能的代价是十分高昂的。几乎 70% 的工作量都和功能开发本身没有关系。

在开发的项目上进行 DevOps 转型就像在行进的汽车上换车轮,一不留心就会让所有团队停止工作。因此我建议通过设立并行的新团队来同时完成新功能的开发和 DevOps 转型的试点。

这是一个功能拆分和新功能拆分需求,刚好订单查询是原系统中一个比较独立和成熟的功能。为了避免影响原有各功能开发的进度。我们决定采用微服务架构来完成这个功能。

构建微服务的架构的策略

我们并不想重蹈之前应用架构的覆辙,我们要做到前后端分离。使得比较小的开发团队可以并行开发,只要协商好了接口之间的契约(Contract),未来开发完成之后会很好集成。

这让我想起了 Chris Richardson 提出了三种微服务架构策略,分别是:停止挖坑,前后端分离和提取微服务。

停止挖坑的意思是说:如果发现自己掉坑里,马上停止。

原先的单体应用对我们来说就是一个焦油坑,因此我们要停止在原来的代码库上继续工作。并且为新应用单独创建一个代码库。所以,我们拆分策略模式如下所示:

在我们的架构里,实现新的需求就要变动老的应用。我们的想法是:

  1. 构建出新的业务页面,生成微服务契约。
  2. 根据 API 契约构建出新的微服务。
  3. 部署 Web 前端到 S3 上,采用 S3 的 Static Web Hosting (静态 Web 服务) 发布。
  4. 部署后端微服务上线,并采用临时的域名和 CDN 加载点进行测试。
  5. 通过更新 CDN 把原应用的流量导向新的微服务。
  6. 删除旧的服务代码。

我们原本要在原有的应用上增加一个 API 用来访问以前应用的逻辑。但想想这实际上也是一种挖坑。在评估了业务的复杂性之后。我们发现这个功能如果全新开发只需要 2人2周(一个人月)的时间,这仅仅占我们预估工作量的20%不到。因此我们放弃了对遗留代码动工的念头。最终通过微服务直接访问后台系统,而不需要通过原有的应用。

在我们拆微服务的部分十分简单。对于后端来说说只需要修改 CDN 覆盖原先的访问源(Origin)以及保存在 route.rb 里的原功能访问点,就可以完成微服务的集成。

构建出新的业务页面,生成微服务契约

结合上面的应用痛点和思路,在构建微服务的技术选型时我们确定了以下方向:

  1. 前端框架要具备很好的 Responsive 扩展。
  2. 采用 Swagger 来描述 API 需要具备的行为。
  3. 过消费者驱动进行契约测试驱动微服务后端开发。
  4. 前端代码库和后端代码库分开。
  5. 前端代码框架要对持续交付友好。

因此我们选择了 React 作为前端技术栈并且用 yarn 管理依赖和任务。另外一个原因是我们能够通过 React-native 为未来构建新的应用做好准备。此外,我们引入了 AWS SDK 的 nodejs 版本。用编写一些常见的诸如构建、部署、配置等 AWS 相关的操作。并且通过 swagger 描述后端 API 的行为。这样,后端只需要满足这个 API 规范,就很容易做前后端集成。

部署前端部分到 S3 上

由于 AWS S3 服务自带 Static Web Hosting (静态页面服务) 功能,这就大大减少了我们构建基础环境所花费的时间。如果你还想着用 Nginx 和 Apache 作为静态内容的 Web 服务器,那么你还不够 CloudNative。

虽然AWS S3 服务曾经发生过故障,但 SLA 也比我们自己构建的 EC2 实例处理静态内容要强得多。此外还有以下优点:

  1. 拥有独立的 URL,很容易做很多 301 和 302 的重定向和改写操作。
  2. 和 CDN(CloudFront)集成很好。
  3. 很容易和持续集成工具集成。
  4. 最大的优点:比 EC2 便宜。

根据 API 契约构建出新的微服务

在构建微服务的最初,我们当时有两个选择:

采用 Sinatra (一个用来构建 API 的 Ruby gem) 构建一个微服务 ,这样可以复用原先 Rails 代码库的很多组件。换句话说,只需要 copy 一些代码,放到一个单独的代码库里,就可以完成功能。但也同样会面临之前 Ruby 技术栈带来的种种问题。

采用 Spring Boot 构建一个微服务,Java 作为成熟工程语言目前还是最好的选择,社区和实践都非常成熟。可以复用后台很多用来做 SOAP 处理的 JAR 包。另一方面是解决了 Ruby 技术栈带来的问题。

然而,这两个方案的都有一个共同的问题:需要通过 ruby 语言编写的基础设施工具构建一套运行微服务的基础设施。而这个基础设施的搭建,前前后后估计得需要至少 1个月,这还是在运维团队有人帮助的情况下的乐观估计。

所以,要找到一种降低环境构建和运维团队阻塞的方式避开传统的 EC2 搭建应用的方式。

这,只有 Lambda 可以做到!

基于上面的种种考量,我们选择了 Amazon API Gateway + Lambda 的组合。而 Amazon API Gateway + Lambda 还有额外好处:

  1. 支持用 Swagger 规范配置 API Gateway。也就是说,你只要导入前端的 Swagger 规范,就可以生成 API Gateway。
  2. 可以用数据构建 Mock API,这样就可以很大程度上实现消费者驱动契约开发。
  3. 通过 Amazon API Gateway 的 Stage 功能,我们无需构建 QA 环境,UAT 环境和 Staging 环境。只需要指定不同的 Stage,就可以完成对应的切换。
  4. Lambda 的发布生效时间很短,反馈很快。原先用 CloudFormation 构建的 API 基础设施需要至少 15 分钟,而 Lambda 的生效只需要短短几秒钟。
  5. Lambda 的编写很方便,可以采用在线的方式。虽然在线 IDE 并不很好用,但是真的也写不了几行代码。
  6. Lambda 自动根据请求自扩展,无需考虑负载均衡。

虽然有这么多优点,但不能忽略了关键性的问题:AWS Lambda 不一定适合你的应用场景!

很多对同步和强一致性的业务需求是无法满足的。所以,AWS Lambda 更适合能够异步处理的业务场景。此外,AWS Lambda 对消耗存储空间和 CPU 很多的场景支持不是很好,例如 AI 和 大数据。(PS: AWS 已经有专门的 AI 和大数据服务了,所以不需要和自己过不去)

对于我们的应用场景而言,上文中的 Ruby On Rails 应用中的主要功能(至少60% 以上)实际上只是一个数据转换适配器:把前端输入的数据进行加工,转换成对应的 SOAP 调用。因此,对于这样一个简单的场景而言,Amazon API Gateway + Lambda 完全满足需求!

部署后端微服务

选择了Amazon API Gateway + Lambda 后,后端的微服务部署看起来很简单:

  • 更新 Lambda 函数。
  • 更新 API 规范,并要求 API 绑定对应 Lambda 函数处理请求。

但是,这却不是很容易的一件事。我们将在下一篇文章《Serverless 风格微服务的持续交付》中对这方面踩过的坑详细介绍。

把原应用的请求导向新的微服务

这时候在 CDN 上给新的微服务配置 API Gateway 作为一个新的源(Origin),覆盖原先写在 route.rb 和 nginx.conf 里的 API 访问规则就可以了。CDN 会拦截访问请求,使得请求在 nginx 处理之前就会把对应的请求转发到 API Gateway。

当然,如果你想做灰度发布的话,就不能按上面这种方式搞了。CloudFront 和 ELB 负载均衡 并不具备带权转发功能。因此你需要通过 nginx 配置,按访问权重把 API Gateway 作为一个 upstream 里的一个 Server 就可以。

删除旧的服务代码

不要留着无用的遗留代码!

不要留着无用的遗留代码!

不要留着无用的遗留代码!

重要且最容易被忽略的事情要说三遍。斩草要除根,虽然我们可以保持代码不动。但是清理不再使用的遗留代码和自动化测试可以为其它团队减少很多不必要的工作量。

最终的架构

经过6个人两个月的开发(原计划8个人3个月),我们的 Serverless 微服务最终落地了。当然这中间有 60% 的时间是在探索全新的技术栈。如果熟练的话,估计 4 个人一个月就可以完成工作。

最后的架构如下图所示:

在上图中,请求仍然是先通过域名到 CDN (CloudFront),然后:

  1. CDN 根据请求点的不同,把页面请求转发至 S3 ,把 API 请求转发到 API Gateway。
  2. 前端的内容通过蓝绿部署被放到了不同的 S3 Bucket 里面,只需要改变 CDN 设置就可以完成对应内容的部署。虽然对于部署来说蓝绿 Bucket 乍看有一点多余,但这是为了能够在生产环境下做集成在线测试准备的。这样可以使环境不一致尽可能少。
  3. API Gateway 有自己作用的 VPC,很好的实现了网络级别的隔离。
  4. 通过 API Gateway 转发的 API 请求分成了三类,每一类都可以根据请求状况自扩展。
  5. 身份验证类:第一次访问会请求 ElastCache(Redis),如果 Token 失效或者不存在,则重新走一遍用户验证流程。
  6. 数据请求类:数据请求类会通过 Lambda 访问由其他团队开发的 Java 微服务,这类微服务是后台系统唯一的访问点。
  7. 操作审计类:请求会记录到 DynamoDB (一种时间序列数据库)中,用来跟踪异步请求的各种日志。
  8. API Gateway 自己有一些缓存,可以加速 API 的访问。
  9. 消息返回后,再有三类不同的请求的结果统一通过 API Gateway 返回给客户端。

Serverless 风格微服务架构的优点

由于没有 EC2 设施初始化的时间,我们减少了至少一个月的工作量,分别是:

  1. 初始化网络配置的时间。
  2. 构建 EC2 配置的时间。
  3. 构建反向代理和前端静态内容服务器的时间。
  4. 构建后端 API 应用基础设施的时间。
  5. 构建负载均衡的时间。
  6. 把上述内容用 Ruby 进行基础设施即代码化的时间。

如果要把 API Gateway 算作是基础设施初始化的时间来看。第一次初始化 API Gateway 用了一天,以后 API Gateway 结合持续交付流程每次修改仅仅需要几分钟,Serverless 风格的微服务大大降低了基础设施配置和运维门槛。

此外,对于团队来说,Amazon API Gateway + Lambda 的微服务还带来其它好处:

  1. 开发效率高,原先至少 45 分钟的开发反馈周期缩短为 5 分钟以内。
  2. 无关的代码量少,需要维护的代码量少。除了专注业务本身。上游和 API Gateway 的集成以及下游和后端服务的集成代码量很少。
  3. 应用维护成本低。代码仅仅几十行,且都为函数式,很容易测试。避免了代码库内部复杂性的增加。

此外,我们做了 Java 和 NodeJs 比较。在开发同样的功能下,NodeJS 的开发效率更高,原因是 Java 要把请求的 json 转化为对象,也要把返回的 json 转化为对象,而不像 nodejs 直接处理 json。此外, Java 需要引入一些其它 JAR 包作为依赖。在 AWS 场景下开发同样一个函数式微服务,nodejs 有 4 倍于 java 的开发效率提升。

最后

Serverless 风格的微服务虽然大大减少了开发工作量以及基础设施的开发维护工作量。但也带来了新的挑战:

  1. 大量函数的管理。
  2. SIT,UAT 环境的管理。
  3. 持续交付流水线的配置。
  4. 面对基础设施集成带来的测试。

这让我们重新思考了 Serverless 架构的微服务如何更好的进行持续交付。


更多精彩洞见,请关注微信公众号:思特沃克

Share

「微前端」- 将微服务理念扩展到前端开发(实战篇)

前言与大纲

本文分为理论和实战上下两篇。本篇为微前端的实战篇,共计约 5k 字,预计阅读时间 10 mins。

技术雷达之「微前端」- 将微服务理念扩展到前端开发(上:理论篇)中,我们介绍了微前端在单体应用和微服务的架构演进中所产生的缘由,将微服务理念运用到前端开发就是为了解决臃肿前端的当前现状。与此同时,合理拆分微前端也给我们的应用开发带来显而易见的好处,在本篇当中我们将逐一介绍微前端的实践方案与可能遇到的问题和对应的优化建议。

文章大纲

  • 微前端的可选实践方案(4 种 +)
    • 创建更小的 Apps(而不是 Components)
    • 如何组合微前端的 App 模块?
      • Option 1: 使用后端模板引擎插入 HTML
        • Option 1.1: 渐进式从后端进行加载
      • Option 2: 使用 IFrame 隔离运行时
      • Option 3: 客户端 JavaScript 异步加载
      • Option 4: WebComponents 整合所有功能模块
    • 不同 App 模块之间如何交互?
    • More Options…
  • 微前端的页面优化与实例
    • 多模块页面加载问题与优化建议
    • 微前端在 AEM(CMS)项目的应用
    • 现成解决方案:Single-SPA “meta framework”
  • 总结与思考:微前端的优缺点
    • 微前端的优点
    • 微前端的缺点
    • 持续思考…
  • 附:参考资料

微前端的可选实践方案(4 种+)

创建更小的 Apps(而不是 Components)

首先让我们来创建一个典型 Web 应用程序的基本组件(Header、ProductList、ShoppingCart),以 Header 组件为例:

# src/App.js
export default () =>
  <header>
    <h1>Logo</h1>
    <nav>
      <ul>
        <li>About</li>
        <li>Contact</li>
      </ul>
    </nav>
  </header>;

然后需要注意的是我们会用到 Express 对刚刚创建的 React 组件进行服务器端渲染,使之成为一个 App 模块:

# server.js
fs.readFile(htmlPath, 'utf8', (err, html) => {
  const rootElem = '<div id="root">';
  const renderedApp = renderToString(React.createElement(App, null));

  res.send(html.replace(rootElem, rootElem + renderedApp));
});

再依次创建其他 Apps 并独立部署:

如何组合微前端的 App 模块?

在每个独立团队创建好各自的 App 模块后,我们就可以将网站或 Web 应用程序视为由各种模块的功能组合。下文将介绍多种技术实践方案来重新组合这些模块(有时作为页面,有时作为组件),而前端(不管是不是 SPA)将只需要负责路由器(Router)如何选择和决定要导入哪些模块,从而为最终用户提供一致性的用户体验。

Option 1: 使用后端模板引擎插入 HTML

# server.js
Promise.all([
    getContents('https://microfrontends-header.herokuapp.com/'),
    getContents('https://microfrontends-products-list.herokuapp.com/'),
    getContents('https://microfrontends-cart.herokuapp.com/')
  ]).then(responses =>
    res.render('index', { header: responses[0], productsList: responses[1], cart: responses[2] })
  ).catch(error =>
    res.send(error.message)
  )
);
# views/index.ejs
  <head>
    <meta charset="utf-8">
    <title>Microfrontends Homepage</title>
  </head>
  <body>
    <%- header %>
    <%- productsList %>
    <%- cart %>
  </body>

但是,这种方案也存在弊端,即某些 App 模块可能会需要相对较长的加载时间,而在前端整个页面的渲染却要取决于最慢的那个模块。

比如说,可能 Header 模块的加载速度要比其他部分快得多,而 ProductList 则因为需要获取更多 API 数据而需要更多时间。通常情况下我们希望尽快将网页显示给用户,而在这种情况下后台加载时间就会变得更长。

Option 1.1: 渐进式从后端进行加载

当然,我们也可以通过修改一些后端代码来渐进式地(Progressive)往前端发送 HTML,但与此同时却徒增了后端复杂度,并且又将前端的渲染控制权交回了后端服务器。而且我们的优化也取决于每个模块加载的速度,若是进行优化就必须按一定顺序进行加载。

Option 2: 使用 IFrame 隔离运行时

<body>
  <iframe width="100%" height="200" src="https://microfrontends-header.herokuapp.com/"></iframe>
  <iframe width="100%" height="200" src="https://microfrontends-products-list.herokuapp.com/"></iframe>
  <iframe width="100%" height="200" src="https://microfrontends-cart.herokuapp.com/"></iframe>
</body>

我们也可以将每个子应用程序嵌入到各自的 <iframe> 中,这使得每个模块能够使用任何他们需要的框架,而无需与其他团队协调工具和依赖关系,依然可以借助于一些库或者 Window.postMessageAPI 来进行交互。

  • 优点
    • 最强大的是隔离了组件和应用程序部分的运行时环境,因此每个模块都可以独立开发,并且可以与其他部分的技术无关
    • 可以各自使用完全不同的前端框架,可以在 React 中开发一部分,在 Angular 中开发一部分,然后使用原生 JavaScript 开发其他部分或任何其他技术。
    • 只要每个 iframe 来自同一个来源,消息传递也就相当直接和强大。参考文档 Window.postMessageAPI
  • 缺点
    • Bundle 的大小非常明显,因为可能最终会多次发送相同的库,并且由于应用程序是分开的,所以在构建时也不能提取公共依赖关系。
    • 至于浏览器的支持,基本上不可能嵌套两层以上的 iframe(parent - > iframe - > iframe)。
  • 如果任何嵌套的框架需要能够滚动或具有 Form 表单域,那样的情况处理起来就会变得特别痛苦。Option 3: 客户端 JavaScript 异步加载
function loadPage (element) {
  [].forEach.call(element.querySelectorAll('script'), function (nonExecutableScript) {
    var script = document.createElement("script");
    script.setAttribute("src", nonExecutableScript.src);
    script.setAttribute("type", "text/javascript");
    element.appendChild(script);
  });
}

document.querySelectorAll('.load-app').forEach(loadPage);
<div class="load-app" data-url="header"></div>
<div class="load-app" data-url="products-list"></div>
<div class="load-app" data-url="cart"></div>

简单来说,这种方式就是在客户端浏览器通过 Ajax 加载应用程序,然后将不同模块的内容插入到对应的 div 中,而且还必须手动克隆每个 script 的标记才能使其工作。

需要注意的是,为了避免 Javascript 和 CSS 加载顺序的问题,建议将其修改成类似于 Facebook bigpipe 的解决方案,返回一个 JSON 对象 { html: ..., css: [...], js: [...] } 再进行加载顺序的控制。

Option 4: WebComponents 整合所有功能模块

Web Components 是一个 Web 标准,所以像 Angular、React/Preact、Vue 或 Hyperapp 这样的主流 JavaScript 框架都支持它们。你可以将 Web Components 视为使用开放 Web 技术创建的可重用的用户界面小部件,也许会是 Web 组件化的未来。

Web Components 由以下四种技术组成(尽管每种技术都可以独立使用):

  • 自定义元素(Custom Elements)对外提供组件的标签,实现自定义标签:可以创建自己的自定义 HTML 标签和元素。每个元素可以有自己的脚本和 CSS 样式。还包括生命周期回调,它们允许我们定义正在加载的组件特定行为。
  • HTML 模板(HTML <template>定义组件的 HTML 模板能力:一种用于保存客户端内容的机制,该内容在页面加载时不被渲染,但可以在运行时使用 JavaScript 进行实例化。可以将一个模板视为正在被存储以供随后在文档中使用的一个内容片段。
  • 影子 DOM(Shadow DOM)封装组件的内部结构,并且保持其独立性:允许我们在 Web 组件中封装 JavaScript,CSS 和 HTML。在组件内部时,这些东西与主文档的 DOM 分离。
  • HTML 导入(HTML Imports)解决组件组合和依赖加载:在微前端的上下文中,可以是包含我们要使用的组件在服务器上的远程位置。
# src/index.js
class Header extends HTMLElement {
  attachedCallback() {
    ReactDOM.render(<App />, this.createShadowRoot());
  }
}
document.registerElement('microfrontends-header', Header);
<body>
    <microfrontends-header></microfrontends-header>
    <microfrontends-products-list></microfrontends-products-list>
    <microfrontends-cart></microfrontends-cart>
</body>

在微前端的实践当中:

  • 每个团队使用各自的技术栈创建他们的组件,并把它包装到自定义元素(Custom Element)中(如 <microfrontends-header></microfrontends-header>)。
  • Web 组件就是应用程序中包含的组件的本地实现,如菜单,表单,日期选择器等。每个组件都是独立开发的,主应用程序项目利用它们组装成最终的应用程序。
  • 特定元素(标签名称,属性和事件)的 DOM 规范还可以充当跨团队之间的契约或公共 API。
  • 创建可被导入到 Web 应用程序中的可重用组件,它们就像可以导入任何网页的用户界面小部件。
<link rel="import" href="/components/microfrontends/header.html">
<link rel="import" href="/components/microfrontends/products-list.html">
<link rel="import" href="/components/microfrontends/cart.html">
  • 优点
    • 代码的可读性变得非常清晰,组件资源内部高内聚,组件资源由自身加载控制,作用域独立。
    • 功能团队可以使用组件及其功能,而不必知道实现,他们只需要能够与 HTML DOM 进行交互。
    • 使用 PubSub 机制,组件可以发布消息,其他组件可以订阅特定的主题。幸运的是浏览器内置了这个功能。比如购物车可以在 window 订阅此事件并在应该刷新其数据时得到通知。
  • 缺点
    • 可惜的是,Web 组件规范跟服务器渲染无关。没有 JavaScript,就没有所谓的自定义元素。
      • 浏览器和框架的支持不够,需要更多的 polyfills 从而影响到用户页面的加载体验。
      • 我们需要在整个 Web 应用程序上做出改变,把它们全部转换成 Web Components。
      • 社区不够活跃,Web Components 还没有真正流行起来,也许永远也不会。

不同 App 模块之间如何交互?

# angularComponent.ts
const event = new CustomEvent('addToCart', { detail: item });
window.dispatchEvent(event);
# reactComponent.js
componentDidMount() {
  window.addEventListener('addToCart', (event) => {
    this.setState({ products: [...this.state.products, event.detail] });
  }, false);
}
  • 得益于浏览器的原生 API,Custom Event 可以与其他任何技术和框架一起工作。比如,我们可以将消息从 Angular 组件发送到 React 组件。其实这也是现在 API 之间普遍使用 JSON 进行通信的原因,即使没有人使用 NodeJS 作为服务器端。
  • 但是,新的问题又出现了。我们该如何测试这种跨模块之间的交互?需要编写类似于后端微服务之间的 Contract Testing 或 Integration Testing 吗?并没有答案。

More Options…

  • 组件库 – 根据主 App 的技术栈,不同的组件和 App 模块拆分作为库的形式提供给主App,所以主 App 是由不同组件组成的。但是组件库的升级将成为一个大麻烦,比如对 Header 组件进行了更改,那么如果已经有 50 个页面使用了 Header 组件该怎么办?必须要求每一页都升级它的 Header,而且升级过程中用户还会在整个网站不同页面上看到不一致的标题。并且,在两边还必须都使用相同的技术,比如 Header 组件中使用了 ClojureScript,而 Content 组件中又用了 Elm,那么该怎么办?构建工具就必须在编译时处理不同的语言。
  • 将 App 模块作为 React 黑盒组件分发给消费者模块 – 应用程序的状态完全包含在组件中,API 只是通过 props 暴露出来。这种方式其实增加了应用程序之间的耦合,因为它迫使每个人都使用 React,甚至会使用相同版本的 React,但是这似乎也是一个比较好的折衷。
  • Edge Side Includes(ESI)/Server Side Includes(SSI) – 通过特殊的文件后缀 (shtml,inc) 或简单的标记语言来对那些可以加速和不能加速的网页中的内容片断进行描述,将每个网页划分成不同的小部分分别赋予不同的缓存控制策略。SSI / ESI 方法的缺点是,最慢的片段决定了整个页面的响应时间。

微前端的页面优化与实例

多模块页面加载问题与优化建议

  • 使用 skeleton screen 响应式布局:如上图 LinkedIn 所做的那样,首先展现给用户一个页面的空白版本,然后在这个页面中逐渐加载和填充相应的信息。否则中间的信息流部分的内容最初是空白的,然后在 JavaScript 被加载和执行过后,信息流就会因为需要占用更多的空间而推动整个页面的布局。虽然我们可以控制页面来固定中间部分的高度,但在响应式网站上,确定一个确切的高度往往很难,而且不同的屏幕尺寸可能会有所不同。但更重要的问题是,这种高度尺寸的约定会让不同团队之间产生紧密的联系,从而违背了微前端的初衷。
  • 使用浏览器异步加载加快初始渲染:对于加载成本高且难以缓存的碎片,将其从初始渲染中排除是一个好主意。比如说 LinkedIn 首页的信息流就是一个很好的例子。
  • 共享 UI 组件库保证视觉体验一致:在前端设计中,必须向用户呈现外观和感觉一致的用户界面。建议可以建立一个共享组件库(包含 CSS、字体和 JavaScript)。将这些资源托管在 CDN,每个微前端就可以在其 HTML 输出中引用它们的位置。每个组件库的版本都正确地对资源进行版本控制,而每个微前端都指定要使用的组件库的版本和显式更新依赖关系。
  • 使用集中式服务(Router)来管理 URL:可以理解为前端的 Gateway,不同的 URL 对应不同应用程序所包含的内容。建议通过一个集中式的 URLs Router 来为应用程序提供一个 API 来注册他们自己的 URL,Router 将会位于 Web 应用程序的前面,根据不同的用户请求指向不同的 App 模块组合。
  • 提取共同依赖作为 externals 加载:虽然说不同 App 模块之间不能直接共享相同的第三方模块,当我们依然可以将常用的依赖比如 lodashmoment.js等公共库,或者跨多个团队共同使用的 reactreact-dom。通过 Webpack 等构建工具就可以把打包的时候将这些共同模块排除掉,而只需要在 HTML <header> 中的 <script>中直接通过 CDN 加载 externals 依赖。
<script
  src="https://cdnjs.cloudflare.com/ajax/libs/react/16.2.0/react.min.js"
  crossorigin="anonymous"></script>
<script
  src="https://cdnjs.cloudflare.com/ajax/libs/react/16.2.0/react-dom.min.js"
  crossorigin="anonymous"></script>

微前端在 AEM(CMS)项目的应用

我们在「三靠谱」(已和谐客户名称)的 Marketplace 项目当中也曾经探索过 AEM + React 混合开发的解决方案,其中就涉及到如何在 AEM 当中嵌入 React 组件,甚至将 AEM 组件又强行转化为 React 组件进行嵌套。现在回过头来其实也算是微前端的一种实践:

  • AEM 仅仅包含网页内容,不包含 domain 相关的结构化数据。
  • React 组件被托管在 AEM 组件当中,再经由 AEM 传递给组件所需要的属性,比如 IDs 或 APIs 的 URL 等等
  • 后端微服务则包含 domain 结构化数据,由对应的 React 组件通过 Ajax 进行数据查询。
  <div id="cms-container-1">
    <div id="react-input-container"></div>
    <script>
      ReactDOM.render(React.createElement(Input, { ...injectProps }), document.getElementById('react-input-container'));
    </script>
  </div>
  <div id="cms-container-2">
    <div id="react-button-container"></div>
    <script>
      ReactDOM.render(React.createElement(Button, {}), document.getElementById('react-button-container'));
    </script>
  </div>

现成解决方案:Single-SPA “meta framework”

开源的 single-spa 自称为「元框架」,可以实现在一个页面将多个不同的框架整合,甚至在切换的时候都不需要刷新页面(支持 React、Vue、Angular 1、Angular 2、Ember 等等):

  • Build micro frontends that coexist and can each be written with their own framework.
  • Use multiple frameworks on the same page without refreshing the page (React, AngularJS, Angular, Ember, or whatever you’re using)
  • Write code using a new framework, without rewriting your existing app
  • Lazy load code for improved initial load time.
  • Hot reload entire chunks of your overall application (instead of individual files).

请看示例代码,所提供的 API 非常简单:

import * as singleSpa from 'single-spa';

const appName = 'app1';

const loadingFunction = () => import('./app1/app1.js');
const activityFunction = location => location.hash.startsWith('#/app1');

singleSpa.declareChildApplication(appName, loadingFunction, activityFunction);
singleSpa.start();
# single-spa-examples.js

declareChildApplication('navbar', () => import('./navbar/navbar.app.js'), () => true);
declareChildApplication('home', () => import('./home/home.app.js'), () => location.hash === "" || location.hash === "#");
declareChildApplication('angular1', () => import('./angular1/angular1.app.js'), hashPrefix('/angular1'));
declareChildApplication('react', () => import('./react/react.app.js'), hashPrefix('/react'));
declareChildApplication('angular2', () => import('./angular2/angular2.app.js'), hashPrefix('/angular2'));
declareChildApplication('vue', () => import('src/vue/vue.app.js'), hashPrefix('/vue'));
declareChildApplication('svelte', () => import('src/svelte/svelte.app.js'), hashPrefix('/svelte'));
declareChildApplication('preact', () => import('src/preact/preact.app.js'), hashPrefix('/preact'));
declareChildApplication('iframe-vanilla-js', () => import('src/vanillajs/vanilla.app.js'), hashPrefix('/vanilla'));
declareChildApplication('inferno', () => import('src/inferno/inferno.app.js'), hashPrefix('/inferno'));
declareChildApplication('ember', () => loadEmberApp("ember-app", '/build/ember-app/assets/ember-app.js', '/build/ember-app/assets/vendor.js'), hashPrefix('/ember'));

start();

值得一提的是,single-spa 已经进入到最新一期技术雷达的评估阶段。这意味着 single-spa 会是值得研究一番的技术,以确认它将对你产生何种影响,你应该投入一些精力来确定它是否会对你所在的组织产生影响。

摘自技术雷达:

SINGLE-SPA是一个 JavaScript 元框架,它允许我们使用不同的框架构建微前端,而这些框架可以共存于单个应用中。一般来说,我们不建议在单个应用中使用多个框架,但有时却不得不这么做。例如当你在开发遗留系统时,你希望使用现有框架的新版本或完全不同的框架来开发新功能,single-spa 就能派上用场了。鉴于很多 JavaScript框架 都昙花一现,我们需要一个解决方案来应对未来框架的变化,以及在不影响整个应用的前提下进行局部尝试。在这个方向上,single-spa 是一个不错的开始。

总结与思考:微前端的优缺点

微前端的优点

  • 敏捷性 – 独立开发和更快的部署周期:
    • 开发团队可以选择自己的技术并及时更新技术栈。
    • 一旦完成其中一项就可以部署,而不必等待所有事情完毕。
  • 降低错误和回归问题的风险,相互之间的依赖性急剧下降。
  • 更简单快捷的测试,每一个小的变化不必再触碰整个应用程序。
  • 更快交付客户价值,有助于持续集成、持续部署以及持续交付。
  • 维护和 bugfix 非常简单,每个团队都熟悉所维护特定的区域。

微前端的缺点

  • 开发与部署环境分离
    • 本地需要一个更为复杂的开发环境。
    • 每个 App 模块有一个孤立的部署周期。
    • 最终应用程序需要在同一个孤立的环境中运行。
  • 复杂的集成
    • 需要考虑隔离 JS,避免 CSS 冲突,并考虑按需加载资源
    • 处理数据获取并考虑用户的初始化加载状态
    • 如何有效测试,微前端模块之间的 Contract Testing?
  • 第三方模块重叠
    • 依赖冗余增加了管理的复杂性
    • 在团队之间共享公共资源的机制
  • 影响最终用户的体验
    • 初始 Loading 时间可能会增加
    • HTML 会需要服务器端的渲染

持续思考…

  • 变幻莫测)前端的技术选型?
    • 前端 JavaScript 框架工具穷出不穷,过几个月就要重写前端项目?比如最近又出来了声称要取代 Webpack(Parcel)和 Yarn(Turbo)的工具。伴随着前端框架的更新换代,如果整个项目一起升级/重构的话压力大、风险高,那不如拆分微前端直接支持多 framework,或者同一 framework 的不同版本?`
  • 在 Mobile/Mobile Web 上的悖论
    • 受限于 Mobile 尺寸大小,单一页面所能展现的内容本就有限。
    • 既然已经分出了不同的子页面,那何不如直接 Route 即可?
  • 合理划分的边界:DDD(领域驱动开发)
    • 最大的挑战是搞清楚如何合理拆分应用程序。
    • 糟糕的设计可能成为开发和维护的噩梦。
  • Don’t use any of this if you don’t need it
    • Do not use the ideas described here until it is needed, it will make things more complex.
    • If you are in a big company, those ideas could help you.
  • 软件架构到底在解决什么问题?—— 跨团队沟通的问题
    • 在正常情况下,每个团队拥有开发和维护其特性所需的一切,都应该有自己的能力来完成自己的特性,并最大限度地减少团队要求其他部门获得许可和/或帮助。
    • 当引入 library 或 framework 时的好处是只需要少数人讨论,而不用涉及超过 100 人的决策和他们的各种需求。这样一场大讨论不仅会耗费时间和精力,而且会迫使我们采用最不起眼的方法来选择 library,而不是选择专门针对每个 team 的问题领域的方案。

所谓架构,其实是解决人的问题;所谓敏捷,其实是解决沟通的问题;

附:参考资料

本次技术雷达「微前端」主题的宣讲 Slides 可以在我的博客找到:「技术雷达」之 Micro Frontends:微前端 – 将微服务理念扩展到前端开发 – 吕立青的博客


更多精彩洞见,请关注微信公众号:思特沃克

Share