前端架构:从入门到微前端 (黄峰达)
- 您在位置 #3517-4076的标注 | 添加于 2019年9月3日星期二 下午11:28:30
第8章 架构设计:前后端分离架构
在现今的前端开发方式里,单页面应用与前后端分离是主流的趋势。采用单页面应用框架,也就意味着前后端分离,以及一系列开发模式的改变——代码库分离、独立部署等。其中多多少少对于传统应用来说,是一些新的挑战。可一说到前后端分离,人们最大的痛楚莫过于对API的管理和维护:
◎ 前端找不到某个字段。原因可能是后端删除了该字段,或者进行了重命名。
◎ 某个返回值是错误的。原因可能是计算错误,或者是业务理解不一致。
◎ 前端获取不到任何数据。原因可能是后端误删了数据库,或者在重新部署应用。 ◎ 状态码预期不一致。某个结果预期应该是服务端错误,却是用200的HTTP状态码返回的。 对于上述问题的解决,便是本章我们要讨论的内容之一。结合前后端分离及API管理相关的内容,本章将介绍如下内容: ◎ 前后端分离是如何协作进行的。 ◎ 如何进行API的文档管理。 ◎ 如何创建API的MockServer。 ◎ 使用测试来保证API是符合预期的。 ◎ 采用BFF架构来提升前后端API的开发效率。 这些仍然是我们在每个项目开始时所要面临的挑战。在进入细致的讨论之前,我们先了解一下前后端分离应用是如何进行开发的。 8.1 前后端分离 前后端分离,即前后端各自作为一个半自治的技术独立团队,协作开发同一业务功能。统一中的不统一性,便是前后端分离的有趣之处。 8.1.1 为什么选择前后端分离 前端与后端之间,原本就是技术栈近乎独立的两端。只是在传统的Web应用中,使用后端的模板罢了。因此,即使前后端分离了,带来的技术挑战也不大。对于传统的技术团队来说,之所以存在大量的技术挑战是因为,由多页面应用转向了单页面应用。 前后端分离更多的是,带来工程、协作、沟通、测试上的挑战。同一个团队的人,如果共同实现前端逻辑和后端逻辑,那么就不存在这一类型的挑战。一旦分离开来,同一个团队就仿佛变成两个部门,相互间在斗争。后端开发人员认为:“前端不就是数据展示嘛!”,前端开发人员认为:“后端不就是CRUD嘛!”当然这只是个玩笑,我们不得不承认前端交互的复杂度,不得不承认后端并发并行的复杂度。 前后端分离的原因是多种多样的,移动优先(Mobile First)战略将单页面应用带到了移动领域,使得后端需要提供RESTful风格的API,也进一步加剧了前后端分离;并且,知识的专业化使得传统的后端工程师,已经无法满足前端开发的要求。 前后端分离所面临的挑战是:除了提供一个API(如RESTful API的方式),还要面临API管理带来的挑战。特别是在后端还没有准备好的情况下,需要去推进前端的开发。这个时候,前端还需要一个“勉强”可以用于开发的API,并且在后端完成后,尽可能少地进行修改。 除了上述的一些缺点,它也带来了一系列的优点: ◎ 独立部署。前端应用可以独立运行在自己的服务器上,而不受后端上线计划的影响。 ◎ 分清职责。后端将视图层(View)从系统架构中拆分出去,让系统变得更加简洁。 ◎ 技术栈独立。分离之前,技术选型受一定限制,如模板引擎等。分离之后,只要保证API是一致的,前后端之间就会互不影响。 ◎ 方便系统演进。一旦前后端都使用自己的技术栈,转换技术栈就变得相当容易。后端可以迁移到微服务,前端可以迁移到微前端架构。 ◎ 提高效率(相对的)。对于复杂项目而言,拆分可以降低维护成本;而对于简单的项目而言,拆分则会提高维护成本。 无论如何,一旦选择了前后端分离,就意味着我们要熟悉相关的开发模式。
8.1.2 前后端分离的开发模式
在不同的软件开发模式下,前后端分离模式也略微有所差异。瀑布模式的前后端分离,仍然是预先制定API的文档,再进行联调。敏捷模式的前后端分离,则是一个业务一个API,每个API单独集成。 两者之间,敏捷模式更能响应变化,瀑布模式更依赖于前期的设计能力。如果是团队内的项目,那么大抵采用的都是敏捷模式,是团队的成员所能接受的。可一旦对接第三方的API和服务,大家想要的就是瀑布模式,让第三方先把API测试好,再提供给团队使用。但是,有时第三方的API也是在开发中的,所以集成的过程可能会相当痛苦——停下手中的工作,及时地响应API变化。 无论哪种方式都需要从业务逻辑出发,从业务的角度出发,Web应用的前后端是一个整体,它们无法独立运行。于是,我们的开发模式变成了下面这样,如图8-1所示。 图8-1 图中的API文档,可以是一个模拟后端接口的JSON API,也可以是文档编写的API。在这个过程中,我们要做的事情有:
(1)按业务的展示逻辑,确认出待展示的内容。
(2)前后端根据内容,一起细致化每个字段名,直至接口确认完毕。 (3)遇到对接第三接口时,需要往复进行第(2)步。
(4)当各种开发完成时,在测试环境进行集成。
(5)将完整的业务功能交给QA进行测试。
如果这几点,都能按我们的预期来进行,那就很完美。可实际上,并不会完全按上面的模式来进行。在开发的过程中,我们不得不面对API的变化,它可能来自于业务变化、性能影响或者接口限制。如果API发生变更时没有及时通知使用方,那么当应用上线时,事故就不可避免。对于大中型的组织,有测试人员能进行回归测试;小型团队,则往往只能听天由命了。 在进一步深入API管理之前,让我们先对前后端分离中的API有一个基础的认识。
8.1.3 前后端分离的API设计
由于API设计的话题,不是一小节、一大章能介绍得完的,有相当多的书籍在介绍相关的内容。这里我们只介绍与前后端分离相关的RESTful API设计和安全。当然,关于RESTful API和安全相关的书籍,又已经有一大堆了。API并不是和前端开发人员没有任何关系的。作为一个专业的前端开发人员,考虑后端接口对于前端的合理性,也是日常工作的一部分。后端API不合理、不符合规范,必然带来前端的额外工作量。 1.RESTful API RESTful API,几乎是前后端分离的标准实践。值得注意的是,它并没有明确数据的格式是JSON、XML,只是JSON格式更适合于前端开发。顺带一提,实际上很多自己宣称是RESTful API的后端服务,都不能符合RESTful API的所有要求——毕竟要实现按规范完成太难了。 虽然后端才是API的实现者,但是作为一个前端开发人员,我们还要确认接口的规范性,才能保证代码不出问题。下面是一个HTTP Get请求的示例: 如果在这个示例中,后端将一个返回的状态码200变成404,那么逻辑就永远在错误的逻辑里。当然,这种不合理的状态码一般是不会存在的,通常我们要借助于HttpInterceptor进行全局统一的非法授权处理,即401处理。通过统一的非法授权处理,可以清除用户权限相关的显示。 总之,后端都应该提供靠谱的RESTful API。而前端开发人员作为使用方,是确保后端API规范的又一个环节。为此,作为一个Web开发人员,我们还是应该了解一下相关的基本规范: ◎ 标准的HTTP动词(又称为HTTP请求方法)。GET、PUT、POST、DELETE、PATCH等,每个动词的用法应该和它的行为一致。 ◎ 状态码。20x、40x、50x等常见的状态码,都应该正确地使用。 ◎ 资源路径。RESTful API中的URL用于代表资源,应该确保资源能遵循相关的规范,例如,/comments/1返回第一条评论,/comments/返回所有的评论。 ◎ 参数处理。如果存在大量的参数,那么我们就需要通过GET带查询字符串(Query String)的方式,或者POST带body的方式来进行传递。如果能统一,那么开发起来也就方便了。 此外,还有一系列和RESTful相关的细节。这里罗列的只是在日常工作中,前端开发人员经常接触的一些相关实践。除了与API相关的话题,另外一个有趣的关乎前后端的话题,便是安全。 2.API与安全 安全,在哪都是一个重要的话题。对于前端开发来说,也存在一些与安全相关的内容。前端的安全措施能大大地拖延黑客的破解时间。从这种意义上来说,前端安全只处于一个“聊胜于无”的状态——真正的安全措施,都需要毫无保留地在后端实施。 依笔者过去的经验来看,前端有如下一些与API和安全相关的要素,是我们在每个项目中要考虑的。 ◎ Token管理。对于前端开发人员来说,无论是保存会话状态的Cookie,还是无状态的Token,都没有太大区别,无非是在管理方式上的区别。后端出于安全考虑,会有各种复杂的管理机制,常用的有超过时间跨度则过期、二次登录失效、多个客户端可以同时登录、Token永不过期等。而对于前端来说,只是在遇到401未授权的时候进行相应的逻辑处理。 ◎ 表单校验。表单是一个特别有意思的话题,因为所有的表单都是两端校验的,既需要前端校验,又需要后端校验。前端进行表单校验往往是出于用户体验的目的,提醒用户哪里做错了,需要重新填写。后端进行检验时默认前端是不可信的。后端开发人员应该确保后端的健壮性。稍显麻烦的一点在于,保证前后端校验逻辑的一致性。在测试人员进行测试的时候,会发现诸多前后端不一致的逻辑校验问题。 ◎ 权限管理。从某种意义上来说,用户只能访问自己能访问的数据,这看上去又是一个与后端相关的话题。前端也有与之相关联的一部分,前端往往只是根据相关的角色和权限来展示现在的页面,至于权限则要在后端进行判断。与这些权限相关的处理,要么在路由跳转时进行判断,要么在页面初始化时进行判断。在复杂的权限管理系统里,它几乎充斥在每个页面中。 这些API看上去很平凡,但却是我们在实现业务的过程中不得不多次考虑、经常接触的问题。在开发相关业务时,时刻与后端对齐,同时还可能不断地修改API。 3.应对API变更 在实施前后端分离架构的过程中,最让人苦恼的莫过于API发生了变化。API发生变化的原因很多,如业务变化、字段名出错、第三方接口不匹配等,但是这些都不重要——一个字段从string变成number,对于前端的影响并不大。重要的是,当后台API发生变化时,前端却不知道或者没有人对此做出响应。例如,某一天你下班后,后台的同事告诉你,这个API变了。结果,第二天这事被忘记了,于是就造成了一个bug。 API变更是不可避免的,如果不修改API,那么会带来更多问题。长痛不如短痛,如果将来需要修改,那么不如从现在开始修改。相比于后端进行的修改,早期的修改因为改动范围较小,修改的成本往往较低。合理的API修改,大部分前端开发人员都能接受。可是如果API反复修改,从A变成B,又从B变成A,那么就会遭到一番异议。 修改API时,对于前端来说,往往意味着字段的改变。字段需要从代码和模板里发生变化,修改对应的值。如果只是简单的字段,修改起来难度也不大,无非就是data.name和data.username的区别。如果修改的地方很多,或者是修改的API字段多,那么就会引起开发者的怨言。开了一场会议后的结果是,作为API使用方,可能还要修改相应的字段。 这时,我们可以尝试用以下方式来降低API修改带来的bug。 ◎ 统一API接口服务。将领域相关的、同一类型的API请求集成到一个服务之下,如与账户相关的AccountServices。它所做的事情是统一接口请求,以及简化相关参数。当修改API地址时,我们只需要修改相应的文件,就可以全局生效。而一旦遇到参数、变量修改时,通过寻找被依赖的代码,就可以快速实现相应的修改,这也是编辑器自带的功能。如WebStorm编辑器的Find Usage快捷功能、Visual Studio Code的Find All References快捷功能。 ◎ API数据模型。这里依赖于使用TypeScript编写应用的前端项目。编写API相关逻辑的时候,对应于后台返回的数据模型,编写一个接口(Interface)文件。如果我们修改了API模型的Interface文件,那么使用API的地方就会在编辑器上进行提示。并且,在编译的过程中,也会直接显式地告诉开发人员,该类型不存在某一属性。 ◎ 一致化处理方式。我们从API返回的结果中获取了数据,我们可能将这些数据命名为result、data、response,同时给它赋予一个变量。对应于后端返回的模型或者接口,可以给这个变量命名为userData等类似的名称。即使发生变更,也能方便地进行查找、修改和替换。 ◎ 可选的模型适配层。即从后端返回的数据中取回我们需要的数据,并赋予新的变量名称。对于面向匈牙利命名法的Web应用来说,这种方式相当合理。匈牙利命名法对代码阅读不友好,如果有一层适配,可以降低代码的阅读难度。它的缺点是,需要同时维护两个数据模型。它的优点是,修改API时,不需要修改对应的前端代码,只需要修改适配模型即可。与此同时,可以在这层映射里,对字段进行统一的处理。 而在这个过程中,我们还会遇到一系列与API相关的难题。
8.2 API管理模式:API文档管理方式
每当我们开启一个新的项目时,总会去寻找合适的方式来管理API文档。总是觉得过去的那种API管理方式存在诸多的问题,想试试新的管理方式。 当我们拿到一个API文档时,会预期在这个文档里拥有使用API时需要的所有信息。对于SDK类型的API文档,我们预期包含有关函数、类、返回类型、参数等的详细信息,并且还需要附有教程和示例。对于前后端分离项目的API文档,则是预期在API文档中包含有后端URL地址、HTTP请求方法、输入参数、返回参数、返回格式等相关的内容。 API在此时便是一种契约,将不同的团队关联起来,能够让他们高效协作。而文档则是记录这个契约的工具之一。 1.传统方式 传统的API管理方式多种多样,它们涉及的应用有桌面应用、移动客户端开发等。这一类型的应用往往与互联网模式不同,传统模式的API更新比较缓慢。追求稳定的同时,还需要支持不同版本应用的开发。这个版本的SDK功能或许比较多,也意味着收费的价格会比较高。因此,便出现了大量的API相关的管理方式。 在没有专业的API工具之前,API文档都是手写的。有的写在Word里,有的写在专业的文档工具里,还有的写在项目目录下README.md中。每个公司都有各自的写法,无所谓好坏。 口头约定API是最不靠谱的方式,在今天大抵是已经不存在的。即使在同一个项目里,它也远没有聊天工具和邮件可靠。聊天工具和邮件,都可以作为核对API的证据。 离线API文档。采用Word文档来传递API文档,仍然在一定范围内是可见的。对于提供SDK的软件厂商来说,仍然存在这样的文档,如提供移动应用的SDK,或者提供闭源的收费组件。通过单一文件来记录文档,难免存在滞后的问题。在文档上,既要标明相应的软件版本,又要拥有一个相应的文档中心,以知晓客户当前使用的API是哪个版本。如果不向第三方提供API,那么Web应用的API就不存在版本的问题。URL上也没有了/v3/这种版本号,也不需要维护多个版本号的后端。而如果没有了版本化,就要进行API变更。 在线协作API文档。由于我们的API文档需要多人、多地、多项目协作,所以采用本地的API文档,必然是不可行的。既然本地不行,就采用在线的工具。这类工具可以是Google Docs,也可以是Wiki,还可以是其他在线协作文档工具。它们都能满足协作的需求,也能保证API文档尽可能是最新的。 版本化API文档。在有了Git和Git服务器之后,我们对Web开发的前后端分离应用就有了更好的方式:文档代码化。与在线协作工具相比,它更加灵活,让我们拥有了可追溯的历史,以及可回退的版本工具。并且,每个开发人员都拥有一份离线版本,可以随时使用和修改。 代码即文档。在代码编写函数和功能的同时,也编写应用相应的文档。然后,通过诸如Javadoc、JSdoc等来生成应用的相应文档。 无论怎样,对于敏捷模式的互联网应用来说,上面的方式都不太适用。尤其是前后端分离的应用,API文档应该不只是文档,还可以作为前端的工具来使用。 2.互联网模式 对于互联网企业来说,文档化的方式,一方面落后,另一方面需要花费大量的时间和成本来维护。为了追求开发效率,维护API文档变成了一个负担。如果不采用文档的方式,那么我们就需要一种额外的方式,来作为前后端API的桥梁。 这种方式便是代码。它可不是普通的代码,而是一份可运行的代码,可以在后端不可用时,提供可供前端使用的API。此外,如果我们愿意,那么只需要补充细节,便可以成为完整的API文档。最重要的是,它可以作为服务使用,并随时修改。 代码化的方式如下: ◎ HTTP服务即API文档。对于多数互联网应用来说,前端需要的只是一个可运行的HTTP服务,可以在没有后端接口的情况下进行开发。因此,API文档并不是必需的,可运行的HTTP服务才是最重要的。如果只有API文档,前端开发人员还需要自己去创建一个Mock Server,即HTTP服务,来逐一模拟API接口。这时我们可以将HTTP服务当成API文档来使用,这种HTTP服务的形式比较简单,通常由一个个JSON文件构成。 ◎ 代码生成可交互的API文档。它可以提供一个可编辑的在线工具如Swagger,它以代码的方式保存API,还能提供生成HTTP服务的功能。通过编写相应的API文档代码和HTTP服务代码,就可以在网页上直接测试API,如图8-2所示。 图8-2 每种方式都有自己的特点,适合于不同的场景。Mock Server也存在不同的方式,有的使用JSON格式,有的使用YAML格式(.yml文件)。面对这种情况,我们需要在组织内部拥有一个可用的API规范。 如果只有文档,那么对于前后端分离的项目没有多大用处。过去我们会在前端代码里硬编码(Hard Code)一些数据,这些数据又被称为假数据,它往往不能验证接口和请求的准确性。因此,我们需要Mock Server来做更多的事情。
8.3 前后端并行开发:Mock Server
Mock Server(仿造服务器),即用于仿造后端接口的模拟HTTP服务器。它是一个简单的HTTP服务,在后端未准备好的情况下,它可以为前端提供一个可用的API服务。在工程实践做得好的项目里,它几乎是前后端分离应用的标准配置。
8.3.1 什么是Mock Server
在迭代0的时候,我们还需要讨论Mock Server的形式,它是前后端关于API开发的最重要的会议。创建Mock Server就意味着,我们开始创建基本的API规范。如果我们在Mock Server中使用JSON作为数据格式,那么往后的API都以JSON来提供。 不同项目有不同的业务场景,因此对于Mock Server的要求各有不同。根据模仿后台API的程度不同,划分出了不同的模仿精度: ◎ 低。只用于前端显示相关的逻辑,即只返回某个API的相关字段。 ◎ 中。带权限相关的功能,需要在使用时,进行权限验证。 ◎ 高。除了上述的功能,还需要做出更高级的响应,如二次请求结果不一样。 可以看到,精度越高,所需要的开发成本就越高。好在情况并没有那么差,这些成本主要是前期的设计成本。模仿精度高的Mock Server虽然开发成本高,但在后期开发时,大都只需要Ctrl+C / Ctrl+V(复制、粘贴)。基于精度考虑,可以用几种不同类型的Mock Server来实现: 普通Mock Server。我们在API配置文件中定义了什么,便返回什么内容。其特点是简单、易维护,缺点是不容易模拟所有情况。如果只是简单的页面显示,不涉及复杂的权限逻辑,那么就可以考虑这种类型的Mock Server。 DSL形式的Mock Server。DSL,即领域特定语言,它是专门针对某一特定问题的计算机语言。这种方式与普通的Mock Server有所不同,其配置文件(通常是JSON)是通过特定格式编写的,返回的数据只是API配置的一部分。在这个文件中,我们还需要登录生成一个Token,根据不同的请求和Token返回不同的内容。相比之下,比较接近于真实的服务,登录完成后获取Token,每次请求的时候都会验证Token。一旦Token不匹配,就返回错误的结果。 编程型Mock Server。它需要我们编写简单的代码,才能返回对应的API数据。它的优点是灵活性好,但是缺点是维护成本高。它需要花费一两个小时来编写代码,才能返回对应的数据,这会带来额外的开发成本。这种接口适合于复杂的项目(大部分API需要四五天的时间来实现),如果项目不复杂,那么使用这种类型的Mock Server,则会有画蛇添足的味道。 因此,要按照自己的需求来选择合适的方式。从笔者的角度看,第二种类型的DSL更为合适。一方面,它不是一个简单的JSON服务器;另一方面,它的简易的DSL设计能满足我们大部分的业务场景。 1.是否保持一致的业务逻辑 在Mock Server中,需要尽可能地包含一切API响应。要打造一个与业务高度一致的Mock Server是一件可怕的工作。编写这样的API接口,相当于预先编写API文档,同时思考可能的错误因素。 考虑Happy Pass(成功情况)的代码,是我们需要做的事情,但是是否返回大量的异常情况,则是一件值得考虑的事情。很多时候,我们面向异常编程,即处理错误的代码,这种情况可能比处理正常代码的情况还多。所以,返回失败的API也就有了一定的数量,编写和维护都不是一件容易的事。 对于初次接触Mock Server的后端人员来说,他们会觉得考虑这么多情况的Mock Server是不能接受的。开发一个模拟API,会加重开发人员的负担,其开发成本接近于真实API的成本。因为在编写这个模拟API的过程中,需要梳理一遍业务逻辑。 倘若拥有统一的规范来处理返回失败情况的API,那么这部分的模拟API可以适当地减少。或者如果觉得编写相应的API没有必要,也可以适当地裁剪。至于返回成功的API,则一个也不能少。 在实施Mock Server的过程中,还有一个问题值得考虑:这个Mock Server由前端还是后端开发? 2.由前端还是后端开发? 维护Mock Server,意味着一些额外多出来的工作量。这些工作量根据业务,分配到相关的开发人员,并由相应的前后端开发人员来完成。 相对于由前端开发人员来维护Mock Server,由后端开发人员维护Mock Server则会更加方便。后端是接口的提供方,他们是经验丰富的API编写者,能提供准确的模拟API。此外,作为API和Mock Server的提供方,一旦修改了API接口,就会去更新模拟API。因此,在多数时候,往往由后端的开发人员来维护Mock Server。 如果后端开发人员的进度落后于前端,那么重任便会落到前端开发人员的头上。完成这个工作,需要依赖于一定的接口经验,同时进行二次确认。如果项目已经实施了一段时间,自然问题不大。如果项目实施的时间比较短,则需要多次确认,才能确保后期API的修改较少。 一旦后端在实现API的时候不能提供某个功能,便需要修改对应的模拟API。这种API变动会进一步带来契约上的变动,便需要进一步通知到使用方。若只是日常的一两个API修改,倒也是能理解的。一旦后端的API因为某种原因,如安全,需要进行大规模的修改。那么,这种大规模的API变动会导致开发人员花费大量的时间。 8.3.2 三种类型Mock Server的比较 如8.3.1节所述,不同类型的Mock Server都有各自的特色。因此我们在建立Mock Server的时候,需要对这方面的内容进行细致的比较。在这里,我们将进一步地对不同类型的Mock Server进行比较。 1.普通Mock Server:HTTP服务器 普通的Mock Server看上去就像一个简单的HTTP文件服务器。在文件中定义好相关的API,启动服务时,它便是这些模拟API的服务器。当然,有些框架提供的功能不只这些: ◎ 支持相关字段的查询、过滤。 ◎ 支持文件中内容的全文搜索。 ◎ 支持正则表达式路由。 …… 主流的后端API都使用JSON格式提供的数据,它相当于一个JSON Server,顾名思义,使用JSON文件快速创建Mock Server。下面是Node.js中的Mock Server框架json-server的示例: 我们只需要访问http://localhost:3000/projects就可以返回对应的JSON API。 当我们在前端应用里请求/projects API时,会返回所有的数据;当我们访问/projects/1或/projects?id=1 API时,则会返回id为1的那条数据。而在这个过程中,我们只需要编写上述配置文件即可。 通过路由配置,我们可以简化大量的JSON文件编写。下面是json-server配置路由的示例: 在这个框架里,如果想提供自定义header、授权等功能,则需要通过插件和编码来完成。 如果我们想要的Mock Server用途比较简单(只用于提供API),那么我们就可以使用这个框架。在笔者经历的前端项目上,都需要进行授权等一系列相关的操作。使用普通Mock Server的方式,不是很友好。这时,我们需要寻找其他类型的Mock Server。 2.DSL形式的Mock Server 如我们之前所说,代码的复杂度是不会消失的,它只是以一种形式转换为另外一种形式。既然我们希望拥有功能更全的Mock Server,那么就寻找一个支持这种形式的Mock Server,这种方式便是DSL(领域特定语言)形式的Mock Server。 与普通的Mock Server相比,DSL形式的Mock Server的最大特点是用配置代替代码。即将原本需要实现的代码,变成一行行的配置。虽然我们降低了编写代码的复杂度,但是也从某种程度上提升了编写配置的复杂度。因此,总的复杂度大大地降低了。 接下来,让我们来看一个DSL形式的Mock Server示例。笔者将使用Moco来作为mock server。Moco是一个使用Java语言编写的简易Mock Server服务器。该框架使用Java语言,其配置都是通过JSON格式来提供的,而非XML格式。 下面是一个带Token认证的API相关示例: 运行这个服务也同样很简单,只需要一行代码:java -jar moco-runner-0.12.0-standalone.jar http -p 12306 -g config.json。当我们请求这个API时,如果不带Token或者带不正确的Token,那么就会和正常的API一样返回错误。 而当我们带上正确的Token,发起对API的请求时,就会返回对应的配置好的Token。 注:相关的代码位于chapter08/moco-server目录下,并包含相关的搭建指南。 从编写的形式上看,其在普通的Mock Server的基础上,添加了更多自定义的配置,即DSL,通过DSL来提供更为强大的功能。当然,这种DSL也存在一定的局限性,一旦我们绑定了DSL类型的Mock Server,便是绑定了特定的配置,这时要切换Moco Server就不是一件容易的事了。 此外,如果我们想要的某些特性在DSL中无法提供,那么可能需要通过插件的方式来支持,或者可以通过自己编写相应的代码来完成。 3.编程型Mock Server 与上述两种方式相比,编程型Mock Server能提供最大化的定制功能。在适当地配置和编程之后,可以直接使用JSON文件来提供服务。在需要的时候,可以方便地进行扩展,定制出适合自己的DSL。 从维护的角度来看,笔者并不推荐使用编程型Mock Server。一旦涉及编程,便需要选择一门语言,而前端使用的是JavaScript,后端语言则是多种多样的。如果我们选择了一门编程型的DSL,那么就需要选定维护的主力——到底是前端还是后端?如果选择一个非JavaScript的语言,并且这门语言较难上手,那么这对于前端是不友好的。而如果选择JavaScript+Node.js,则会为后端带来一定的学习成本。 但是,当我们使用Swagger来提供API文档时,使用这种类型的Mock Server,便能提供更好的支持。下面是使用Swagger生成的模拟API的示例: Swagger的Mock API是基于Node.js的后端Web框架Express封装而来的。因此,从上面的代码来看,我们仿佛是在使用Express框架编写后端的API。 我们只需要在swagger.yaml文件中指定对应URL的响应,就可以在API文档里测试API: 注:相关的代码位于chapter08/swagger-demo目录下,并包含相关的搭建指南。 两者相结合,我们就拥有了一个“活”的API文档。前端开发人员可以了解每个参数的输入值及示例,然后进行相应的API的测试。结合Swagger的编程API,特别适合于对外提供API,如图8-3所示。 图8-3 此外,这些编程型Mock Server框架又可以提供灵活的数据创造功能。比如Faker.js,它可以在浏览器和Node.js中生成大量的模拟数据,再以API的形式返回前端应用中。而如Spring Cloud,则可以按类型(String、Integer等)来接收相应的输入数据。 总的来说,编程型Mock Server更具灵活性,但上手成本高。 4.Mock Server选型指南 有了上面的对比,想从上述三种Mock Server中,选择适合自己项目的Mock Server,倒也是不难了。 ◎ 如果只是前端开发人员用来简化开发,而后端不能提供支持,那么使用普通的JSON Mock Server就可以了。 ◎ 如果前后端同时维护Mock Server,那么可以尝试使用DSL形式的Mock Server,可以提供更多的特性。 ◎ 需要更多的定制化功能,则可以使用编程型Mock Server。 笔者经历的项目,都是以敏捷类型为主的。前后端开发人员是坐在一起协作开发的,因而往往需要同时修改mock server。采用编程方式的Mock Server,需要选择一门语言。而无论哪种语言,对于其他技术栈的开发人员来说,都是一个新的挑战。因此,往往偏向于采用JSON形式,这样对两端更为友好。 笔者曾经也在一些项目上使用其他Mock Server工具,如Spring Contract,它使用groovy的语法来编写模拟API。对于采用Java语言和Gradle的后端开发人员来说,使用groovy还是相当容易上手的;但是对于前端开发人员来说,groovy的语法和JSON相差甚大,写出模拟API略微麻烦。
8.3.3 Mock Server的测试:契约测试
业务修改会带来一定量的API修改,一旦API修改便需要通知使用方。通知的方式再好,都比不上持续集成的失败,它以直接的状态(红色)显示当前的构建有问题。那么,要怎样做才能在API修改时,影响到持续集成呢?答案:契约测试,即对模拟API或真实的API进行测试。 契约测试,又称为消费者驱动的契约测试(Consumer-Driven Contracts,简称CDC),是指从消费者业务实现的角度出发,驱动出契约,再基于契约对提供者进行验证的一种测试方式。 契约是一个高级版的Mock Server。两者的区别在于,契约是双方或多方共同协议的,而Mock Server则更多是从使用方的角度来考虑的。当我们要往Mock Server里添加一个新的契约(模拟API)时,这个API是需要双方认可和协定的。一旦定下了这个契约,我们就可以验证,API产出是否与契约保持一致。对应的,我们需要在前后端里,各自对契约进行测试,如图8-4所示。 图8-4 各端以自己的方式,验证契约是否符合自身要求。在编写测试的过程中,值得关注的点是,如何以合理的方式来测试契约。其中最常见的关键点是API的一致度。如我们的API返回了20个字段,是否对每个字段进行校验?可能的情况如下: ◎ 校验所有的字段名和返回结果是否一致。 ◎ 只校验所有的字段名是否一致。 ◎ 校验部分字段名和返回结果是否一致。 ◎ 只校验部分字段名是否一致。 ◎ 是否校验所有可能的返回结果?如在A、B、C等条件下,是否都测试? 校验所有的字段,就意味着编写测试和维护代码时的复杂度会提高。校验部分的字段,则可能缺少一些重要的信息。一个折中的方案是:校验所有的字段名、部分的返回结果是否一致。具体在每个项目上实践时,都各有差异,需要慎重考虑。既不要让契约测试成为累赘,又不要让它可有可无。 在笔者经历过的项目里,曾经使用JSON文件来比对API的返回结果。在测试中,仿造出一个与返回API完全一致的数据,代码中有10个字段,将这些字段与JSON文件进行比对。此外,我们还测试了API在不同条件下返回的结果。如果某个字段需要修改,那么所有JSON文件都需要修改。它可以保证代码在前端的健壮性,但是对于后期维护项目的人而言,体验就不是很友好了。 1.后端契约测试 后端作为API的生产者,验证编写出来的API能否和Mock Server上的字段、类型相匹配。其大体的测试步骤如下: (1)先运行Mock Server服务。 (2)发起对Mock Server服务的API请求,获取相应的返回数据。 (3)判断相应的数据、字段与API中的一致。 因此在进行技术选型的时候,会出现消费者驱动的技术选型的情况。后端要进行契约的测试,因而其在选择契约服务器(即Mock Server)时,会偏向于与后端更吻合的契约测试工具,例如Spring Cloud Contract。这样的工具和框架适合于后端进行开发和测试,但是不一定适合前端人员编写。如接下来我们要展示的Spring Cloud Contract,其采用Groovy语言来编写契约,过于复杂的场景不适合前端人员编写模拟API。 下面给大家介绍使用Spring框架官方的契约测试示例,相应的Mock Server API的部分代码如下所示: 这是一个模拟API,在request字段里定义了请求的API的URL和HTTP请求的方法;在response里则定义了对应的返回状态码、header和返回数据。在总体写法上,与我们之前使用JSON的格式是类似的。 接着,我们运行Mock Server,就可以发出API请求,获得相应的响应结果。然后,就可以验证相应的结果是否正确: 后端拥有对应的数据模型,要验证每个字段也比较容易,只需要从body里获取对应的值即可。如果Mock Server中的值被修改了,如上述Mock Server中的name值被修改为bar,那么这里的测试也就失败了。继而影响整个系统的持续集成,以及自动化构建。 这时,我们就遇到一个问题,契约的修改是否影响构建?答案是不确定的。它取决于我们对于契约的重视程度。如果契约和API的修改经常带来各种问题,而这些问题推动起来很困难(如异地开发的沟通问题),那么我们就可以让契约测试来影响构建。而如果契约和API的修改很少带来问题,那么我们还是要利用契约测试来保障应用程序的质量。 不过,如果在契约进行修改时,前后端双方都能做出快速的响应,契约测试的重要性就没有那么大了。正因为团队协调不容易、响应比较慢,所以对于规则的要求会更高。 依靠后端的测试,只能保证后端API与契约是一致的。前端也需要自己的契约测试,以保证前端API和契约是吻合的。 2.前端契约测试 前端作为契约的消费者和使用者,才是更关心契约修改的一方。可是如果后端开发人员没有对API与契约的一致性负责,那么前端的契约测试就会变成一个枷锁。因此,前端契约测试存在必要不充分条件,即存在后端的契约测试。 前端的契约测试与后端的契约测试相差无几:请求API、获取数据、校验数据。下面是一个使用Jest编写的契约测试的示例: 对于不同的项目,我们所需要校验的内容,也有所差异。毕竟完完整整地去测试一个契约,成本太高了。一旦业务修改带来新的返回值的变化,我们就需要修改对应的测试。有了后端的契约做保证,我们只需要做一些简单的校验: ◎ 字段名一致校验。逐一校验每个API的返回值过于烦琐,而且API字段多数时候也不需要修改。我们只需要检验后端返回的API字段是否与我们需求的一致即可。就是说,我们只需要遍历返回数据的key,与我们所需的key列表进行对比,就可以完成测试。对于那些经常修改字段名、采用匈牙利命名法命名字段的项目来说,这种测试是非常适合的。 ◎ 对于使用TypeScript编写应用的前端项目来说,可以尝试使用项目中的interface接口(即面向对象中的类)来进行API测试。笔者编写的mest框架,便是借用interface文件来生成数据,将其与后端的字段名进行对比。 ◎ 校验逻辑字段。即我们并不验证API的所有字段是否正确,往往只验证带逻辑部分的API是否正确。如上所述,逐一校验每个字段,开发和维护成本太高。对于后端来说,仍然有必要验证接口。对于获取API的前端页面来说,它们往往以展示为主。如果展示的字段不带额外的处理逻辑,就没有理由去验证这个字段。只需要关注需要逻辑处理部分的API,再进行绑定和测试即可。 在契约测试里,我们关注的是API本身,这与单元测试、集成测试有所不同。在实现的过程中,我们很容易将契约测试混入单元测试等代码中——因为这样可以方便我们测试从API返回到显示的逻辑。但是,在笔者看来,对于契约测试应该与其他测试代码相分离,形成独立的契约测试代码。
8.3.4 前后端并行开发总结
在有了契约和Mock Server之后,前后端的开发模式变成: ◎ 前后端约定契约API,并完成对应的Mock Server实现。 ◎ 前后端根据各自的逻辑实现对应的业务代码。 ◎ 前后端编写各种契约测试,并确定API的修改能够反映到持续集成。 ◎ 前后端进行API集成。 ◎ 在API修改时,修正对应的API修改。 它能确保前后端以各自独立的方式运行,但是它并不能保证后端能提供前端需要的API。因为“CRUD式的API”往往返回的是所有的字段和未经处理的值。当我们拥有Android、iOS、移动Web、桌面Web时,如果某个API上需要运行一些处理代码,那么就需要在四个客户端上进行各种处理。这个时候需要怎么做呢?
8.4 服务于前端的后端:BFF
前端和后端经常会有各种关于业务处理的讨论。某个业务处理的代码,放在前端还是后端?这个问题需要一方去说服另一方,也不容易讨论出一个结果来。前后端各自都包含一些业务逻辑,逻辑放在前端或后端,并没有太大的区别。 一个简单的共识是,如果多端都需要一个计算如时间转换,那么应该由后端提供。相同的逻辑,不应该在不同的客户端上实现多遍。如一个关于文章、资讯等的时间展示例子,按内容发布时间的不同,有不同的处理逻辑: (1)今天,显示今天+对应的时间,如今天7:30。 (2)昨天,显示昨天+对应的时间,如昨天7:30。 (3)昨天之前的本周的其他时间,则显示星期几+对应的时间,如星期几7:30。 (4)如果时间超出本周,且不是昨天,则显示日期+对应的时间,如2018年11月6号7:30。 如果存在4种客户端(Android、iOS、桌面Web、移动Web),一个功能以0.5天的实现时间来计算,那么总共需要2天。加上对应的测试和bug修复,至少要3天。而如果是服务端实现,那么加上测试,总体上只需要1.5天左右的时间。如果这种情形多,如20个需求 * 1.5=30 天,从时间上说,由后端来实现是相当划算的。如果在这个过程中,业务需求发生了变化,那么就会产生一系列的修改成本。 大量诸如此类的业务逻辑放在后端实现,能在某种程度上降低应用的开发和维护成本。这些与业务相关的处理逻辑,直接放在原有的后端代码中也不是很合适。它们类似于胶水代码,难以整理和维护,并且它们需要经常修改。为了将这些业务代码抽离出来,形成更好的系统架构,便出现了前端、后端之间的中间层,这样的中间层称为BFF。
8.4.1 为什么使用BFF
BFF,即Backends For Frontends(服务于前端的后端),是指在服务器设计API时会考虑客户端的使用情况,在服务端根据不同的设备类型返回不同客户端所需要的结果。BFF模式不会为所有的客户端创建通用的API,而是创建多个BFF服务:一个用于Web前端,另一个用于移动客户端(甚至一个用于iOS,另一个用于Android)。BFF下的API Gateway如图8-5所示。 图8-5 在图8-5的架构中,每种类型的客户端都有自己的BFF服务。每个客户端都可以在自己的BFF里添加所需要的业务逻辑。前端可以针对自己需要的部分,在面向前端的BFF服务里添加自己所需要的逻辑。移动端也是如此,可以自定义自己的API响应。 如果针对每一种类型的设备都有自己的BFF,难免会在维护系统时带来一些额外的成本。倘若这些都是业务独立的应用,就不会带来太多的困扰。只要应用业务逻辑保持一致,维护多个API就会显得多余。这时可以考虑一种折中的方案:一个主要的、完整的API,加上一个或两个针对移动设备的变种,或者针对移动设备做一个BFF层,而针对桌面应用做另外一个BFF层。 如果前端要对接一个已经完备的后端API,而非从头开发,那么我们要考虑开始一个BFF。如果后端API的提供方不提供API封装支持,那么就要由使用方来创建一层BFF;如果对方愿意提供BFF,那么我们什么事都不用做。大都是前者居多,需要由我们来封装API。 1.BFF真的需要吗? 在不同项目下,使用BFF的意图各有不同。我们使用BFF的目的可能是: ◎ 应对多端应用。一方面,对不同的客户端进行特定的业务处理;另一方面,集中处理统一逻辑,降低开发成本。 ◎ 聚合后端微服务。当一个业务的处理和展示,需要多个后端服务时,可以通过APIGateway来聚合后端服务,以加快应用响应的速度。 ◎ 代理第三方API。客户端不直接访问第三方API,而是通过BFF作为中介来访问第三方API。同时,按自方系统的逻辑来实现符号自身需要的API。当更换第三方服务时,可以不更换API。 ◎ 遗留系统的微服务化改造。从单体架构迁移至微服务时,先通过BFF来调用单体应用的功能。再逐一创建微服务,当完成一个服务时,将请求指向新的微服务。慢慢地,就可以从遗留代码转移到新的演进方案上。 问题是,由于每个BFF是独立的服务,它们的独立程度相当高。这意味着,要同时维护这么多个BFF并不是一件容易的事——好在作为一个前端开发人员,我们只需要维护前端的BFF层。但是,我们真的需要BFF吗? 我们要考虑的几个因素是: ◎ 是否需要提供多种接口,来适应不同的客户端? ◎ 是否需要针对某一特定客户端,进行后端接口优化? ◎ 是否需要为第三方提供API? ◎ 是否存在大量的后端服务需要聚合? ◎ 是否需要为客户端进行业务逻辑处理? 不论怎样,对于快速变化的UI和业务来说,BFF是一个比较好的解决方案。它处于两端之间,能更好地响应变化。 2.对比API Gateway 如图8-6所示是一个常见的API Gateway在系统中的架构: 其在架构上与BFF的作用类似。API Gateway是一个位于前端与后台服务之间的代理,也是后台服务的唯一入口。它将请求由客户端路由到对应的服务,并执行身份验证、监控、负载均衡等任务。比如Netflix开源的微服务网关组件Zuul,通过过滤器可以实现身份认证与安全、审查与监控、动态路由、静态响应处理、压力测试等功能。 图8-6 简易的API Gateway可以是一个如Nginx、HAProxy这样的反向代理,统一前端请求的API入口,并分发到不同的服务上。复杂的API Gateway,可以聚合后端的服务,还能根据客户端的需要返回不同的内容。或者通过查询参数来向不同的客户端提供接口,/api/news?type=web用于向Web提供接口,而/api/news?type=mobile用于向移动端提供接口。 API Gateway与BFF最大的区别在于,API Gateway只拥有一个API入口,而BFF则是针对不同客户端,拥有各种API Gateway。此外,BFF会根据业务逻辑进行编码。而API Gateway只做数据的转发,不做额外的数据。因此从某种程度上来说,BFF是一种高级的API Gateway。
8.4.2 前后端如何实现BFF
在实现BFF之前,我们要商榷一下,这层BFF到底是由前端来实现的,还是由后端来实现的,或者是两端一起实现的。多数时候,不会同时由两端来实现,往往由某一方来主导。前端实现BFF和后端实现BFF的主要差别在于其所选的语言。也会有后端开发人员想去尝试前端的技术栈,前端的开发人员想去尝试后端的技术栈。可受限于内部因素,这也只是少数。 前端开发人员擅长编写JavaScript/TypeScript,所以由Node.js来实现BFF层,几乎是前端的第一选择。前端开发人员可以选择合适的Web框架(如Koa、Express、Egg.js)来打造BFF,也可以使用更合适的GraphQL来完成。 后端开发人员在实现BFF时,出于维护或者组织内部规则,会偏向于使用后端现有的技术栈。前端开发人员按照自己习惯的Ajax/Fetch请求写代码,再按一定的逻辑展示到页面上。 1.传统后端技术栈下的BFF 尽管Node.js对于前端开发人员更友好,但是在前端团队经验不够丰富的情况下,采用传统的后端技术栈是一种更稳妥的方案。修改内容主要是字段处理,如果有了一部分BFF代码,那么不需要对后端的数据库等有深入的了解,只需要按业务逻辑 对数据进行一些特殊的处理即可。 对于使用后端技术栈的BFF,前端开发人员几乎很少有话语权,但是这也带来了极大的优势。前端开发人员不需要维护BFF,只需要在合适的时候提出自己对API的需求即可。说到后端的技术栈,偶尔尝试不同类型的语言,会对自己的成长有帮助。长期处在某一特定的计算机领域里,多少会让自己的思维形成一些固化。进入一个新的领域,有时会以第三视角来重新审视原来的技术栈。 由于后端部分不是重点,并且后端部分与其他API编写的差异不大,这里就不再详细地展开讨论了。 2.前端技术栈下的BFF 采用Node.js作为前端的BFF层是前端人员最有可能的技术栈选择。JavaScript的动态语言特性加上Node.js的性能,使得Node.js在快速实现与业务相关的BFF层时有极大的优势。由后端APIGateway获取的数据是JSON格式的,JavaScript可以快速地对JSON进行解析。前端技术栈没有静态类型语言的类型问题,可以相当便捷地修改结果,并返回给前端使用。即: (1)通过Node.js来接收前端发送过来的请求。 (2)根据请求的类型向对应的后台API服务发起请求。 (3)获得返回结果后进行处理,并向前端返回对应的结果。 通过以上步骤,前端开发人员就可以在这层BFF里添加自己所需要的业务逻辑。一旦业务上发生一些变化,也可以直接在BFF上进行修改,而不需要修改后端服务或者前端代码。实现相应的业务逻辑,对于开发人员来说并不算复杂,复杂的部分有如下几个地方: ◎ 难以推进Node.js后端服务的使用。它可能受限于组织内部的审查和安全策略。 ◎ 上线时,需要前端人员配合上线。在传统的上线方式里,前端只是作为静态文件来部署,可以交由后端来完成上线。 此外,尽管Node.js已经越来越稳定,但是与Node.js相关的调试、内存泄漏问题的排查,也需要有相关的能力才能完成。如果组织内部缺乏相关的专家,想要大规模地采用BFF并不是一件容易的事。
8.4.3 使用GraphQL作为BFF
与普通的Node.js+Web框架实现BFF相比,更流行的方式是采用GraphQL。GraphQL既是一种用于API的查询语言,又是一种标准,也相当于一个满足开发者数据查询的运行时。 用过数据库、搜索引擎的人,大都能理解查询语言是什么。开发人员只需要编写查询语句就可以获取数据源中的相关数据。如果在前端编写查询语句,那么前端就可以轻松地获取自己需要的数据,而不需要后台做大量的处理。下面是GraphQL查询一个id为2的记录的代码,功能是获取其中的id、category和featured_image三个字段的值: 然后,将这个请求发送给GraphQL,而GraphQL则会按照定义的RESTDataSource向对应的后台服务请求数据,并返回结果: 下面是获取数据相应的代码: 它与常规的BFF有极大的不同:前端只需要传入自身需要的字段,GraphQL便返回我们所需要的字段。如果使用传统的BFF方式,那么需要对代码进行适度修改,才能返回前端需要的字段。一旦业务发生变化,就要反复修改常规BFF层的代码。 如果采用REST架构,那么上述API请求应该是: 其返回结果如下: 定义数据源类(如上述代码中的RESTDataSource)相当于创建对应的数据源。在数据源中向对应的后端服务发送API请求,相当于一个高度定义 <您已达到本内容的剪贴上限>
第9章 架构设计:微前端架构
大型组织的组织结构、软件架构在不断地发生变化。移动优先(Mobile First)、App平台(One App)、中台战略等,各种口号不断地被提出、修改和演进。同时,其业务也在不断地发展,由线下到线上、从无到有,这些进一步地导致组织的应用不断地膨胀,进一步地映射到软件架构上。 这些让我们联想到“康威定律”:设计系统的组织,其产生的设计和架构等价于组织间的沟通结构,可它并不是在反映康威定律,而是相似的推论:系统的组织在不断变化的同时,其设计和架构也在不断地调整。 而在组织结构变化的同时,架构也随着产生了一系列的变化,毕竟天下大势,分久必合,合久必分。与数据库的分库分表一样,既然一个组织的部门已经过于庞大,就进一步将它细化。同理,软件的不同部分又被拆分到不同的部门之下。随着不同部门的业务发展,技术栈也因此而越来越难以统一,出现了多样化。在走向多样化后,用户越来越厌倦一家公司的应用软件(App)分散在多个不同的应用上。应用的获客成本越来越高,应用又一次走向聚合。
在分离了前后端之后,拆分降低了系统的复杂度,并进一步提高了软件的开发效率。随着业务的不断扩张,需求也不断扩张,应用又开始变得臃肿。既然应用变大了,我们就继续往下拆分,拆分成更小的单位。在本章中,关注于如何采用微前端架构,来解决复杂的前端应用。接下来将要学习的内容有如下几个部分:
◎ 什么是微前端架构?它是如何形成的,以及有什么优缺点。
◎ 如何设计一个微前端架构的系统?
◎ 如何合理地拆分前端应用?s
最后,我们还将引入“微”害架构的概念,即不合理地实施微架构将对系统产生什么影响。
9.1 微前端
微前端是一种类似于微服务的架构,它将微服务的理念应用于浏览器端,即将单页面前端应用由单一的单体应用转变为把多个小型前端应用聚合为一的应用。各个前端应用还可以独立开发、独立部署。同时,它们也可以进行并行开发——这些组件可以通过NPM、Git TagGit或者Submodule来管理。
9.1.1 微前端架构
微前端的实现意味着对前端应用的拆分。拆分应用的目的并不只是为了在架构上好看,它还可以提升开发效率。比如10万行的代码拆解成10个项目,每个项目1万行代码,要独立维护每个项目就会容易得多。而我们只需要实现应用的自治,即实现应用的独立开发和独立部署,就可以在某种程度上实现微前端架构的目的。
1.应用自治
微前端架构,是多个应用组件的统一应用,这些应用可以交由多个团队来开发。要遵循统一的接口规范或者框架,以便于系统集成到一起,因此相互之间是不存在依赖关系的。我们可以在适当的时候,替换其中任意一个前端应用,而整体不受影响。这也意味着,我们可以使用各式各样的前端框架,而不会互相影响。
2.单一职责
与微服务类似的是,微前端架构理应满足单一职责的原则。然而,微前端架构要实现单一职责,并非那么容易。前端面向最终用户,前端需要保证用户体验的连续性。一旦在业务上关联密切,如B页面依赖A页面,A页面又在一定的程度上依赖B页面,拆分开来就没有那么容易。但是如果业务关联少,如一些关于“我们的联系方式”等的页面,使用不多,没有多少难度。因此,一旦面临用户体验的挑战,就要考虑选择其他方式。
3.技术栈无关
在后端微服务的架构中,技术栈无关是一个相当重要的特性。后端可以选用合适的语言和框架来开发最合适的服务,服务之间使用API进行通信即可。但是对于微前端架构来说,虽然拥有一系列的JavaScript语言,但是前端框架是有限的,即使在某个微前端架构里实现了框架无关,也并不是那么重要。框架之间的差距并不大,一个框架能做的事情,另一个框架也能做,这一点便不如后端。使用Java解决不了的人工智能部分,可以交给Python;如果觉得Java烦琐,可以使用Scala,如图9-1所示。
图9-1 对大部分公司和团队来说,技术无关只是一个无关痛痒的话术。如果一家公司的几个创始人使用了Java,那么极有可能在未来的选型上继续使用Java。对于前端框架来说也是相似的,如果我们选定Angular,除非出现新的框架来解决Angular框架的问题,否则大概率继续使用原有的框架——毕竟已经拥有大量成熟的基础设施。 此外,技术栈无关也有一系列的缺点: ◎ 应用的拆分基础依赖于基础设施的构建,如果大量应用依赖于同一基础设施,那么维护就变成了一个挑战。 ◎ 拆分的粒度越小,意味着架构变得越复杂、维护成本越高。 ◎ 技术栈一旦多样化,便意味着技术栈是混乱的。 那么,我们到底应该在什么时候采用微前端架构呢?
9.1.2 为什么需要微前端
虽然微前端不是能在10年内提高10倍生产力的银弹。然而除了上述微前端的优点,仍然有其他理由让我们去采用微前端架构:
◎ 遗留系统迁移。
◎ 聚合前端应用。
◎ 热闹驱动开发。
不同的出发点,在实践方式上都略有差异,值得我们花费时间去尝试。
1.遗留系统迁移 笔者曾在GitHub上开源有微前端框架Mooa及对应的文档《微前端的那些事儿》。自内容发布以来,笔者陆续收到一些微前端架构的咨询。过程中发现了:解决遗留系统,才是人们采用微前端方案最重要的原因。因为在这些咨询里,开发人员所遇到的情况与之前遇到的情形并不相似。笔者的场景是设计一个新的前端架构,而这些开发人员要考虑前端微服务化是因为遗留系统的存在。 过去那些使用Backbone.js、Angular.js、Vue.js等框架所编写的单页面应用,已经在线上稳定地运行了,也没有新的功能。对于这样的应用来说,我们也没有理由浪费时间和精力重写旧的应用。而这些应用是使用旧的、不再使用的技术栈编写的,由于框架本身已经不更新(不增加新功能或者不再维护),因此应用可以称为遗留系统。既然应用可以使用,就不花太多的力气重写,而是直接整合到新的应用中去。 不重写原有系统,同时抽出人力来开发新的业务,这对业务人员来说,是一个相当有吸引力的特性,而且对技术人员来说,也是一件相当不错的事情。人生苦短,请尽量不重写。
2.后端解耦,前端聚合 后台微服务的初期有一个很大的卖点:使用不同的语言、技术栈来开发后台应用。事实上,采用微服务架构的组织和机构,一般都是大中型规模的。相对于中小型组织来说,对于框架和语言的选型要求比较严格,如在内部限定了语言和框架。因此充分使用不同的技术栈,以发挥微服务的优势,几乎是很少出现的。在这些大型组织机构里,采用微服务的原因主要还是,使用微服务架构可以解耦服务间的依赖,如图9-2所示。
图9-2 而在前端微服务化上,则恰恰与之相反,人们更想要的结果是聚合前端应用,尤其是那些To B(to Bussiness,面向企业)的应用。 最近几年,移动应用出现了一种趋势,即用户不想装那么多应用了。而一家大的商业公司,往往会提供一系列的应用。这些应用从某种程度上反映了这家公司的组织架构。然而,在用户的眼里,他们就是一家公司,他们就只应该有一个产品。相似的,这种趋势也在桌面Web出现。聚合成为客户端的一个技术趋势,实现前端聚合的就是微前端架构。 3.热闹驱动开发 所谓热闹驱动开发指的是,软件开发团队所做的软件架构或技术栈的决策,其中很多决策并没有经过踏实的研究和对目标成果的认真思考,而是不准确的意见、社交媒体的信息,或者就是些“热闹”的玩意。 换句话说,“流行”就应该采用某个新的技术和架构,在各种会议、文章中不断被提及,不经过细致研究就直接采用这样的技术。典型的场景有,项目的领导参加了一个会议,或者某一核心成员了解到一个新的技术、架构,便想尝试使用这种技术。多数情况下,往往是经过了一定的考虑,才会使用这样的技术。但是,我们并不排除一些情况,例如只是为了KPI或者是别人觉得好,那便是好。于是在技术社区,常常会看到一些“有意思”的提问:“领导让我来看看xxx技术……”。 因为“热闹”去学习一项新的技术,并没有什么问题。新的技术意味着学习,它可以在一定程度上提升技术水平和技术影响力。可是,对于这样一个“热闹”的技术,没有多加研究,便直接在项目上使用,难免会遇到一些挫折。失败了,不免会得出该微前端不好用的结论。 微服务、微前端便是这样一些“热闹”的玩意,可以预见的是,未来将有越来越多的前端应用采用这样的架构。热闹了,对于整个技术社区来说,起着一定的促进作用——变得更加热闹。但是,采用之前记得先看看别人的失败经验,再想方设法进行一些细致的调查:构建原型、测试应用、寻找合适的人等。 微前端可以实现的方式比较多,当前也没有标准的实现方式。在短期的未来,也不会有可以在不同项目都适用的实践。但是,它们的基本原理都是相似的,也都需要详尽的设计。
9.2 微前端的技术拆分方式
从技术实践上,微前端架构可以采用以下几种方式进行:
(1)路由分发式。通过HTTP服务器的反向代理功能,将请求路由到对应的应用上。
(2)前端微服务化。在不同的框架之上设计通信和加载机制,以在一个页面内加载对应的应用。
(3)微应用。通过软件工程的方式,在部署构建环境中,把多个独立的应用组合成一个单体应用。
(4)微件化。开发一个新的构建系统,将部分业务功能构建成一个独立的chunk 代码,使用时只需要远程加载即可。
(5)前端容器化。将iframe作为容器来容纳其他前端应用。
(6)应用组件化。借助于Web Components技术,来构建跨框架的前端应用。 实施的方式虽然多,但都是依据场景而采用的。在有些场景下,可能没有合适的方式;在有些场景下,则可以同时使用多种方案。
9.2.1 路由分发式
路由分发式微前端,即通过路由将不同的业务分发到不同的独立前端应用上。其通常可以通过HTTP服务器的反向代理来实现,或者通过应用框架自带的路由来解决,如图9-3所示。
图9-3 就当前而言,路由分发式的架构应该是采用得最多、最容易的“微前端”方案。但是这种方式看上去更像是多个前端应用的聚合,即我们只是将这些不同的前端应用拼凑到一起,使他们看起来像一个完整的整体。但它们并非是一个整体,每当用户从A应用转换到B应用的时候,往往需要刷新一下页面、重新加载资源文件。 在这个架构中,我们只需要关注应用间的数据传递方式。通常,我们只需要将当前的用户状态,从A应用传递到B应用即可。如果两个应用在同一个域里运行,就更加方便了,它们可以通过LocalStorage、Cookies、IndexedDB等方式共享数据。值得注意的是,在采用这种应用时,缺少了对应用状态的处理,需要用户重新登录,这种体验对用户来说相当不友好。
9.2.2 前端微服务化
前端微服务化,是微服务架构在前端的实施,每个前端应用都是完全独立(技术栈、开发、部署、构建独立)、自主运行的,最后通过模块化的方式组合出完整的前端应用。其架构如图9-4所示。
图9-4 采用这种方式意味着,一个页面上同时存在两个及以上的前端应用在运行。而路由分发式方案则是,一个页面只有唯一一个应用。 当我们单击指向某个应用的路由时,会加载、运行对应的应用。而原有的一个或多个应用,仍然可以在页面上保持运行的状态。同时,这些应用可以使用不同的技术栈来开发,如页面上可以同时运行React、Angular和Vue框架开发的应用。 我们这样实施的原因是,不论基于Web Components的Angular,还是VirtualDOM的React,都是因为现有的前端框架离不开基本的HTML元素DOM。因此,我们只需要做到如下两点: 第一点,在页面合适的地方引入或者创建DOM。
第二点,用户操作时,加载对应的应用(触发应用的启动),并能卸载应用。 对于第一点,创建DOM是容易解决的。而第二点,则一点儿也不容易,特别是移除DOM和相应应用的监听。当我们拥有一个不同的技术栈时,我们需要有针对性地设计出一套这样的逻辑。 同时,我们还需要保证应用间的第三方依赖不冲突。如应用A中使用了z插件,而应用B中也使用了z插件,如果一个页面多次引入z插件会发生冲突,那么我们应该尝试去解决这样的问题,可以通过向上游开发者提Pull Request来修复这个问题。
9.2.3 组合式集成:微应用化
微应用化是指,在开发时应用都是以单一、微小应用的形式存在的,而在运行时,则通过构建系统合并这些应用,并组合成一个新的应用,其架构如图9-5所示。
图9-5 微应用化大都是以软件工程的方式来完成前端应用的开发的,因此又可以称之为组合式集成。对于一个大型的前端应用来说,采用的架构方式往往是通过业务作为主目录的,然后在业务目录中放置相关的组件,同时拥有一些通用的共享模板,例如: 当我们开发一个这样的应用时,从目录结构上看,业务本身已经被拆分了。我们所要做的是,让每个模块都成为一个单独的项目,如将仪表盘功能提取出来,加上共享部分的代码、应用的基本脚手架,便可以成为一个单独的应用。拆分出每个模块之后,便只需要在构建的时候复制所有的模块到一个项目中,再进行集成构建。 微应用化与前端微服务化类似,在开发时都是独立应用的,在构建时又可以按照需求单独加载。如果以微前端的单独开发、单独部署、运行时聚合的基本思想来看,微应用化就是微前端的一种实践,只是使用微应用化意味着我们只能使用唯一的一种前端框架。大团队通常是不会同时支持多个前端框架的。
9.2.4 微件化
微件(Widget),是一段可以直接嵌入应用上运行的代码,它由开发人员预先编译好,在加载时不需要再做任何修改或编译。而微前端下的微件化则指的是,每个业务团队编写自己的业务代码,并将编译好的代码部署(上传或者放置)到指定的服务器上。在运行时,我们只需要加载相应的业务模块即可。在更新代码的时候,我们只需要更新相应的模块即可。如图9-6所示是微件化的架构示意图。 在非单页面应用时代,要实现微件化方案是一件特别容易的事。从远程加载JavaScript代码并在浏览器上执行,生成对应的组件嵌入页面。对于业务组件也是类似的,提前编写业务组件,当需要对应的组件时再响应和执行。在未来,我们也可以采用WebComponents技术来做这样的事情。 而在单页面应用时代,要实现微件化就没有那么容易了。为了支持微件化,我们需要做下面一些事情。 图9-6
(1)持有一个完整的框架运行时及编译环境。这用于保证微件能正常使用,即可调用框架API等。
(2)性能受影响。应用由提前编译变成运行时才编译,会造成一些性能方面的影响——具体视组件的大小而定。
(3)提前规划依赖。如果一个新的微件想使用新的依赖,需要从上游编译引入。 此外,我们还需要一个支持上述功能的构建系统,它用于构建一个独立的微件模块。这个微件的形式如下:
◎ 分包构建出来的独立代码,如webpack构建出来的chunk文件。
◎ 使用DSL的方式编写出来的组件。 为了实现这种方式,我们需要对前端应用的构建系统进行修改,如webpack,使它可以支持构建出单个的代码段。这种方式的实施成本比微应用化成本高。
9.2.5 前端容器:iframe
iframe作为一个非常“古老”的、人人都觉得普通的技术,却一直很管用。它能有效地将另一个网页/单页面应用嵌入当前页面中,两个页面间的CSS和JavaScript是相互隔离的——除去iframe父子通信部分的代码,它们之间的代码完全不会相互干扰。iframe便相当于创建了一个全新的独立的宿主环境,类似于沙箱隔离,它意味着前端应用之间可以相互独立运行。 当然采用iframe有几个重要的前提:
◎ 网站不需要SEO支持。
◎ 设计相应的应用管理机制。 如果我们做一个应用平台,会在系统中集成第三方系统,或多个不同部门团队下的系统,显然这仍然是一个非常靠谱的方案。此外,在上述几个微前端方案中,难免会存在一些难以解决的依赖问题,那么可以引入iframe来解决。 无论如何当其他方案不是很靠谱时,或者需要一些兼容性支持的时候,只能再度试试iframe。
9.2.6 结合Web Components构建
Web Components是一套不同的技术,允许开发者创建可重用的定制元素(它们的功能封装在代码之外),并且在Web应用中使用它们。 真正在项目上使用Web Components技术,离现在的我们还有些距离,可是结合Web Components来构建前端应用,是一种面向未来演进的架构。或者说在未来,可以采用这种方式来构建应用。比如Angular框架,已经可以将当前应用构建成一个Web Components组件,并在其他支持引入Web Components组件的框架中使用,如React。我们还可以使用Web Components构建出组件,再在其他框架中引入。 为此,我们只需要在页面中通过Web Components引入业务模块即可,其使用方式类似于微件化的方案,如图9-7所示。 目前困扰Web Components技术推广的主要因素在于浏览器的支持程度。在Chrome和Opera浏览器上,对Web Components支持良好,而对Safari、IE、Firefox浏览器的支持程度,并不是很理想。有些不兼容的技术,可以引入polyfill来解决,有些则需要浏览器支持。 图9-7
9.3 微前端的业务划分方式
与微服务类似,要划分不同的前端边界不是一件容易的事。就当前而言,以下几种方式是常见的划分微前端的方式: ◎ 按照业务拆分。 ◎ 按照权限拆分。 ◎ 按照变更的频率拆分。 ◎ 按照组织结构拆分。 ◎ 跟随后端微服务划分。 因为每个项目都有自己特殊的背景,所以切分微前端的方式就不一样。即使项目的类型相似,也存在一些细微的差异。 9.3.1 按照业务拆分 在大型的前端应用里,往往包含了多个业务。这些业务往往在某种程度上存在一定的关联,但并非是强关联。如图9-8所示是一个常见的电商系统的相关业务[1]。 图9-8 在这样的一个系统里,它可能同时存在多个系统:电子商务系统、物流系统、库存系统等。每个系统都代表自己的业务,它们之间的关联可能并不是很紧密——对于前端应用来说,只需要一个系统内对象的ID,加上用户的Token,便能轻松地从一个系统跳转到另外一个系统中。这种业务本身的高度聚合,使得前端应用的划分也变得更加轻松。 如果业务间本身的耦合就比较严重(如一个电子商务的运营人员,可能需要同时操作订单、物流等多个系统),那么要从前端业务上分离它们,就不是很容易。 因此,对于由业务性质决定的应用,往往只能依据业务是否隔离来进行拆分。
9.3.2 按照权限拆分
对于一个同时存在多种角色及多种不同权限的网站来说,最容易采用的方案就是通过权限来划分服务和应用。尤其这些权限在功能上是分开的,也就没有必要集中在一个前端应用中。 在一个带后台管理功能和前台展示页面的网站里,它们的功能有时候是绑定在一起的,如各种CMS、博客应用WordPress;而在一些大型系统中,它们往往又是独立的,有独立的入口来访问后台,有独立的入口来访问前台。多数时候,这取决于大部分用户是否同时拥有两种权限?如果只有管理员拥有后台权限,那么分开是一种更好的选择。因此,多数应用会在项目创建的初期将管理系统划分出去。 但是对于初始时期的管理来说,往往不会有这种划分方式。为了方便管理人员使用,它们需要结合在一起。可是随着后台管理的功能越来越多,应用会变得越来越臃肿。特别在单页面应用中,出于组件复用等目的,往往会将其设计在同一个前端工程中。 是否按照权限来划分应当取决于应用是否臃肿,或者是否正在变得臃肿,导致难以维护。还需要考虑是否为每种角色和权限划分出不同的前端应用。如果只有一种权限的功能比较高,而其他权限业务少,那么是否就只拆分成前台与后台两部分。
9.3.3 按照变更的频率拆分
在一个前端应用中,并非所有模块和业务代码都在不断地修改、添加新的功能。不同的业务模块拥有不同的变更频率。有些功能可能在上线之后,因为用户少而几乎不修改;有些功能则可能为了做而做,即证明有这个技术能力,或者有这个功能。而有一些功能,因为是用户最常用的,所以在不断迭代和优化中。因此,可以依照变更频率来拆分前端应用。 不常用的功能,虽然业务少、变更少导致代码也相对较小,但是因为非核心业务数量多,从应用中拆分出去也更容易维护。比如Word这样的文字处理软件,我们日常使用的功能可能不到10%。而其他一些专业性的需求,则仍然有90%的空间,它们也需要花费大量的开发时间。若是将应用中频繁变更的部分拆分出来,不仅更容易维护其他部分的代码,还可以减少频繁的业务修改给其他部分带来的问题。 经常变更的业务也可以进一步进行拆分——拆分成更多的前端应用或者服务。使用变更的频率进行拆分的前提是,我们使用数据统计来计算各部分的使用情况。对于一个大型的前端应用来说,这部分几乎是不存在问题的。
9.3.4 按照组织结构拆分 如“康威定律”所说,“团队的组织方式必然会对它产生的代码有影响。
”既然如此,就会存在一种合理的微前端划分方式,即根据不同团队来划分不同的微前端应用及服务。 对于后端来说,按照组织结构拆分服务,几乎是一个默认的做法。团队之间使用API文档和契约,就可以轻松地进行协作。对于前端应用来说,同样可以采用这种方式来进行。 这时,作为架构的提出方和主要的核心技术团队,我们需要提供微前端的架构方案。如使用路由分发式微前端,需要提供一个URL入口;使用前端微服务化,需要提供一个API或者接入方式,以集成到系统中。 值得注意的是,它与业务划分方式稍有区别,一个团队可能维护多个业务。如果某些业务是由一个团队来维护的,那么在最开始的阶段,他们可能倾向于将这些业务放在同一应用中。然后,由于业务的增多或者业务变得复杂,则会进一步拆分成多个应用。 对于跨团队协作来说,集成永远都是一个复杂的问题。尤其在团队本身是异地开发的情况下,沟通就变成一个麻烦的问题。技术问题更适合于当面讨论,如指着代码或页面进行讨论。一旦有一方影响了系统构建,就需要优先去解决这个问题。
9.3.5 跟随后端微服务拆分
幸运的是,与微架构相关的实施,并不只有前端才有,往往是后端拥有相应的实施,前端项目才会进行进一步的拆分。而一旦后端拥有相关的服务,前端也可以追随后端的拆分方式。 然而,后端采用的拆分方式,并不都适合于前端应用——可能多数时候都不适合。如后端可能采取聚合关系来划分微服务,这时对于前端应用来说并没有多大的启发,但是有些时候还是可以直接采用一致的拆分模型。毕竟如果在后端服务上是解耦的,那么在前端业务上也存在一定解耦的可能性。
9.3.6 DDD与事件风暴
在后端微服务(Microservices)架构实践中,常常借助于领域驱动设计(Domain Driven Design,DDD)进行服务划分。DDD是一套综合软件系统分析和设计的面向对象建模方法。DDD中的一个限界上下文(Bounded Context),相当于一个微服务。而识别限界上下文的核心是,识别出领域的聚合根,这时便依赖于事件风暴来进行。 事件风暴(Event Storming)是一项团队活动,旨在通过领域事件识别出聚合根,进而划分微服务的限界上下文。事件风暴就是把所有的关键参与者都召集到一个很宽敞的屋子里来开会,并且使用便利贴来描述系统中发生的事情。它们会通过以下步骤来确定各个业务的边界,同时划分出每个服务:
(1)寻找领域事件。
(2)寻找领域命令。
(3)寻找聚合。
(4)划分子域和限界上下文。
由于篇幅所限,这里就不展开详细的介绍了,有兴趣的读者可以寻找相关资料,或者从本章代码中找到README来阅读相关的资料。
9.4 微前端的架构设计
有了微服务之后,遇到一个新的项目,总会考虑是否需要微服务,是否有能力应对微服务带来的技术挑战。在有了微前端之后,也有同样的疑问,我们是否真的需要微前端?我们是否能应对微前端带来的技术挑战?毕竟,没有银弹。在考虑是否采用一种新的架构的时候,除了考虑它带来的好处,还要考量存在的大量风险和技术挑战。微前端,也是这样一个技术架构,和微服务一样,要进行实践,并做好一系列的技术储备。
9.4.1 构建基础设施
在基础设施上,微前端架构与单体应用架构有相当大的差异。在单体应用里,共享层往往只有一个。而在微前端架构里,共享层则往往存在多个,有的是应用间共用的共享层,有的是应用内共用的共享层。在微前端设计初期,构建基础设施要做如下几件事情:
◎ 组件与模式库。在应用之间提供通用的UI组件、共享的业务组件,以及相应的通用函数功能模块,如日期转换等。
◎ 应用通信机制。设计应用间的通信机制,并提供相应的底层库支持。
◎ 数据共享机制。对于通用的数据,采取一定的策略来缓存数据,而不是每个应用单独获取自己的数据。
◎ 专用的构建系统(可选)。
在某些微前端实现里,如微件化,构建系统用于构建出每个单独的应用,又可以构建出最后的整个应用。 这些技术实践,只是一些相对比较通用的内容。对于不同的微前端方案来说,又存在一些细微的差异,具体需求我们将在第10章中讨论。
9.4.2 提取组件与模式库
系统内有多个应用采用同一框架的微前端架构,模式库作为微前端架构的核心基础,可以用于共享代码。通过之前的组件库、设计与系统相关的实践,我们已经有了一个基础的共享模式库。在这个库里,它会包含我们所需要的基础组件,可以在多个前端应用中使用。同时,结合第4章的相关内容为组件库创建版本机制,设计相应的发布周期,以支持整个系统内应用的开发。下面介绍样式、业务组件及共享库。
1.样式 在实施微前端的过程中经常会遇到一个头疼的问题:样式冲突。如果在一个页面里同时有多个前端应用,那么就会存在以下几种形式的样式: ◎ 组件级样式,只能用于某一特定组件的样式。
◎ 应用级样式,在某一个前端应用中使用的样式。
◎ 系统级样式,可在该页面中使用的样式,往往会影响多个应用。 对于组件级样式来说,有些框架可以从底层上直接支持组件模式隔离,如Angular,这是不太需要花费精力考虑的。此外,对于组件库来说,我们也会创建对应的CSS前缀来保证唯一性,只要在开发的过程中多加注意即可。 对于应用级样式而言,则需要制定一个统一的规范,可以根据应用名加前缀,如dashboard-,也可以根据路由来增加相应的前缀,以确保应用本身的样式不会影响到其他应用。此外,我们往往会为这些应用,创建一个统一的样式库,以提供一致的用户体验。 系统级样式,大抵只存在于基座模式设计的微前端架构里。在这种方案里,由基座应用来控制其他应用,也存在部分的样式。在编写这些样式的时候,需要注意对其他应用的影响。此外,它也可以作为统一的样式库承载的应用来使用。
2.业务组件及共享库 对于在多个应用中使用的业务组件和共享函数,我们既可以提供NPM包的方式,又可以提供git submodule的方式,引入其他应用中。 对于通用的组件,它在开发的前期需要频繁地改动,这时可以将其抽取成为子模块(Submodule)的形式在项目中使用。当我们需要的时候,可以轻松地修改,并在其他应用中更新。当这些组件趋于稳定的时候,可以尝试将其作为NPM包发布。如果有这种打算,就需要在这个子模块中使用package.json及NPM的管理方式,方便后期直接扩展。 此外,不得不提及的是,这种类型的修改应当是兼容式修改。在难以兼容的情况下,需要对系统中使用到的部分,逐一进行排查,直到确认已更新下游API。然后,还要进行相应部分的测试,以确保组件修改带来的影响都已经被修复。
9.4.3 应用通信机制
解决了应用间共享代码的问题,我们还需要设计出一个应用间通信的机制。