作者 | Glenn Engstrand
译者 | 屠灵
策划 | Tina
技术公司采用微服务架构已经十多年了,结果好坏参半。微服务之间的依赖关系导致在修改一个服务时也需要修改其他服务,微服务的优势因此打了折扣。这就是所谓的紧密耦合。但组件之间的依赖关系是不可避免的。
如果微服务不能满足用户的需求,那它还有什么用?本文重点讨论如何维护有用的微服务,并能够从微服务的迁移流程中得到好处。关键的是要在各个层保持清晰的关注点分离,并遵循最适合每一个层的设计原则。
1 RESTful API 设计
2000 年,Apache HTTP 服务器联合创始人发表了一篇题为“网络软件架构的架构风格和设计”的博士论文。其中的第 5 章介绍了 REST (Representational State Transfer),作为分布式超媒体系统的一种架构风格。这篇有关 Richardson 成熟度模型的博文是了解 REST 在 API 设计中所起作用的一个很好的资源。
在 API 设计和 HTTP 标准之间存在着紧密的一致性。在 RESTful API 设计中,URI 的路径部分用于标识特定实体 (也称为资源)。HTTP 谓词用于标识要对实体执行的操作类型。实体可以通过其他实体的 URI 路径部分链接到其他实体。对于 HTTP 状态代码含义的解释也存在一致性。
提供在线应用程序的公司将 API 设计成平台,他们之所以这样做有很多原因。或许,他们希望从第三方那里获得额外的收入来源,或者向高级用户追加销售。或许,他们希望让不同的团队更容易调用彼此的 API。或许,他们希望以这样一种方式来组织他们的 API,让它们可以很容易被相同产品族中的类似或相关的产品所重用。最后,他们希望设计出易于进行自动化测试的 API。
就像提供在线应用程序的公司开发便于用户理解的 GUI 应用程序一样,平台公司也应该开发便于开发人员理解的 API。RESTful API 非常适合这一需求。
在 RESTful API 出现之前,API 是基于所谓的远程过程调用 (Remote Procedure Call,RPC) 而设计的。这些 API 的设计不存在一致性,以致于难以看出它们是干什么用的。REST 在 API 设计中引入了一致性。当你将 REST 与 OpenAPI 结合在一起时,开发人员很容易就知道如何使用你的 API。
一个基本的新闻源 RESTful API Swagger 规范。
后端服务可以很容易地调用 RESTful API,但对于前端应用程序来说就不那么容易了。这是因为好的用户体验不那么 RESTful。用户不想要背后满是碎片化实体的 GUI。他们希望一下子看到所有的东西,除非在设计上确实需要渐进显示。例如,我不想在规划旅行行程时打开多个页面,我希望在下订单之前能够在一个页面上看到所有的摘要信息 (包括航班、汽车租赁和酒店预订)。
当用户导航到一个 Web 页面或链接到单页应用程序 (SPA) 或移动应用程序的某个视图时,前端应用程序需要调用后端服务来获取渲染视图所需的数据。如果使用的是 RESTful API,单个调用不太可能获得所有的数据。通常是先执行一个调用,然后前端代码遍历该调用的结果,并对每个结果项进行更多的 API 调用,以获得所需的所有数据。这不仅使前端开发复杂化,而且还增加了应用程序的页面加载时间。稍后再详细介绍。
这里还有另一个问题,即 RESTful API 与前端 GUI 关注点不是很契合。RESTful API 本身不支持推送通知,但支持回调 (通过 WebHook 实现)。WebHook 对推送通知的支持程度不如 WebSocket。WebHook 和 WebSocket 的不同之处在于,Web 浏览器不支持 WebHook,但支持 WebSocket。这是因为 WebSocket 是由前端发起的,并与后端保持连接,后端会向前端发送更新。WebHook 是由后端发起的,但浏览器没有一个固定的 IP 地址来接收这些请求。因为路径在 RESTful API 中被用于标识一个特定的实体,所以请求和响应的格式不应该发生明显的变化。为了节省连接资源,SPA 可能会为所有类型的推送通知打开单个 WebSocket,允许每一条消息的格式之间存在巨大差异。
新的用户需求 (如增加额外的数据字段) 可能需要前端和后端都作出修改,这是导致紧密耦合的根本原因。团队之间的紧密耦合降低了开发速度,这个可以用康威定律来解释。
跨团队的沟通成本要高于单个团队内部的沟通成本。同时拥有前端和后端开发人员的团队也可能缺乏效率。虽然从理论上讲,前后端开发人员处在同一个团队中,但在前端和后端开发人员之间仍然存在分界线。这种“隐式”的子团队隐藏了软件开发的一些复杂性,可以说是康威定律的一个不成文的补充。
当后端服务和前端应用程序发生紧密耦合时,发布管理也会变得复杂。微服务的最大优势之一是你不必一次性发布所有的内容,但紧密耦合的组件通常需要在同一时间发布,如果一个组件需要回滚,其他组件也都需要回滚。
2 GraphQL API 设计
2015 年,Facebook 采用了一种不一样的 API 设计方法,即图查询语言 (GQL) 或 GraphQL。它是一种包含层次结构类型的模式,该模式包含三种特殊类型:查询、变异和订阅。调用者发送一个命令,该命令提供查询条件,并指定在响应中期望得到的数据格式。
最为流行、功能齐全且成熟的 GraphQL 服务器端框架实现是由旧金山的一家名为 Apollo 的小型初创公司开发的。有了他们的框架,在客户端增加新功能就变得非常容易,且无需对服务器作出大量修改。
后端开发人员必须编写 schema 和解析器。框架调用在请求中指定的解析器,然后将每个解析器的响应拼接在一起。
类似的基本新闻提要的 GraphQL schema。
由于解析器位于属性级别,而且获取底层数据的机制可能一次性获取多个属性,因此存在重复获取相同数据的可能性,造成了浪费。这就是所谓的 N 1 问题。后端代码应该用某种类型的请求缓存来缓解这个问题。
基于生存时间值 (TTL)、最近最少使用原则 (LRU) 的缓存在 GQL 中的作用是有限的。因为有效载荷是可以灵活指定的,所以很难实现高命中率和低脏读率的高效缓存。因此,GQL 缓存往往要比 RESTful 缓存大得多。Apollo GraphQL 框架支持在 schema 中使用缓存提示注解或在解析器中动态设置,这可以通过浏览器端缓存或内存缓存或外部缓存 (如 Memcached 或 Redis) 来实现。
变异部分与 RPC 相似,对于它们的处理方式并没有定义好的标准,所以它们都不好理解。
在撰写本文时,GraphQL 的应用程序性能监控 (APM) 还没有 RESTful API 那么成熟。GraphQL 没有提供内建支持,但有一些插件或解决方案可用于 New Relic、Data Dog、Prometheus 和 App Dynamics。我相信,随着时间的推移,Apollo 风格的 GraphQL APM 监控将变得更加主流。
在 RESTful API 中,客户端指定路径,可能是查询字符串参数,可能是身份验证,仅此而已。而在 GQL 中,客户端必须指定有效载荷是什么样子的。这种小程序增加了调用 GQL 服务的复杂性,从而增加了出现错误的可能性。这也提高了自动化测试的成本。通常的方法是在测试自动化中查询所有的内容,这样做应该足够了,除非解析器需要通过上下文对象交换带外数据。
3 关注点分离
API 设计该基于 REST 还是 GQL,关键在于你要理解一个计算机科学概念,也就是关注点分离(SoC)。一个设计良好的软件通常由多个层组成,每个层又分为多个模块。如果每个层和每个模块都有清晰严格的关注点分离,那么软件就容易理解,复杂度也更低。为什么会这样?如果你知道在哪里可以找到某个功能的实现代码,你很快就会知道该如何去阅读它的代码(很可能会跨多个代码库)。就像 REST 和 GQL 在 API 设计方面所提供的一致性一样,清晰的 SoC 提供了一种一致性的方式用于找到每个功能的实现。开发人员很少会在他们了解得很透彻的软件中引入 bug。
SoC 的标准由软件架构师来设定。以下是不同层的分类以及每个层应该关注什么。
通常来说,在现代商业软件中,最主要的层是前端和后端。
- 前端软件的直接交互对象是用户,通常运行在移动设备或笔记本电脑上。前端包括移动应用和 Web 应用,主要是关于渲染、绑定、交互和用户体验。其内部结构类似模型视图控制器(MVC)的变种。
- 后端软件的交互对象是前端软件。在生产环境中,后端软件通常运行在数据中心(如公有云)的服务器上。
后端又被进一步分为数据、边缘和集成服务。
- 数据服务为数据库提供保护、执行业务规则、维护一致性,并专注于可伸缩性、性能和潜在的弹性问题。其内部结构包括资源控制器、服务、模型和数据访问对象(DAO)。
- 边缘服务负责处理推送通知、跨端点聚合和安全问题。
- 集成服务应该作为第三方应用的反应式抗腐蚀层,如电商网站(后端集成)和电子表格(前端集成)。
还有其他一些类型的服务这里没有提及。
一个功能全面的服务网格可用于处理弹性、发现、内部认证、加密和可观察性问题。要实现可观察性,需要与其他类型的服务发生交互,获得监控、告警、日志聚合,甚至是分布式追踪能力。
在架构师看来,当开发人员决定模糊这些 SoC(通常是图一时的方便),就是系统开始陷入麻烦的开始。例如,因为 DevOps 的不完善,你决定让数据服务来处理边缘服务或集成服务应该处理的问题,或者让前端应用做一些本该由后端完成的事情。
4 现代技术栈
大约在六年前,我发现了一种所谓的 BFF 边缘服务。客户端应用程序不会直接调用数据服务,而是通过中间服务来调用,中间服务专门用来满足客户端应用程序的需求。如果设计得当,这种方式可以将数据服务与不断变化需求的 GUI 解耦开来。虽然数据服务仍然与数据库紧密耦合,但数据库 schema 变化速度相对较慢。至少对于相对成熟的项目来说是这样的。
那么 BFF 涉及哪些有用的技术?它们作为 GraphQL 暴露出来,需要基于 RESTful 数据服务提供一个聚合编排层,需要提供 WebSocket 或利用 GQL 的订阅能力,应该由前端团队负责开发维护,并采用前端开发人员比较熟悉的技术栈。
你可能倾向于认为 BFF 会增加延迟,因为多了额外的服务器跳转,但事实却相反。加重延迟的不是服务器跳转,而是数据包的传输距离。为了方便演示,请想象一下以下这种情况。假设一个网页调用了一个 API,这个 API 平均每次返回 10 个数据项,而每个数据项需要调用另外三个 API,这样才能获得渲染页面所需的数据。一位旧金山的用户从亚马逊 us-east-1 区域的服务器加载页面,每个请求来回需要传输 5600 公里。因为总共有 31 个请求,所以数据需要传输 173600 公里,这个距离可以绕地球 7 圈。如果你把这 31 个请求放在一个 BFF 里,并且这个 BFF 与服务器位于同一个数据中心里,那么你总共需要 32 个调用,但数据只需要传输 5600 公里,是不使用 BFF 传输距离的 3%。Web 浏览器可以通过并行的方式调用 API,但相比后端服务,在连接方面具有更强的约束。
另一种边缘服务叫作 API 网关,用于认证、授权、速率限定、单点登录和访问权限管理。如果有必要,它也可以将查询请求路由给 GQL BFF,或者直接将请求路由给 RESTful 服务。对于不同类型的客户端应用程序,需要使用不同的 BFF,但你只需要一个 API 网关就可以满足各种类型的客户端。API 网关有时候也作为第三方的调用代理,让它们可以访问防火墙背后的数据服务。API 网关是一种通用服务,可以由后端开发人员负责开发(OpenResty 就是一种比较流行的方案),也可以是第三方提供的产品(比如 Kong),或者是由公有云供应商提供的 PaaS 服务。
现代 Web 应用程序几乎都是 SPA(单页应用),而在以前,用户用 Web 浏览器加载 HTML 页面,这些页面可能是由服务器端的 Web 应用程序生成的。当用户单击页面上的一个链接,浏览器会渲染一个全新的 HTML 页面,这个页面也是由服务器端的 Web 应用程序生成的。在使用 SPA 时,用户用 Web 浏览器加载一个 Web 页面,这个页面只包含最基本的 HTML 元素,同时也会下载很多 JavaScript 和 CSS 文件。API 调用是通过执行 JavaScript 代码来完成的,然后生成很多 DOM 元素,浏览器再用这些 DOM 来渲染 GUI。当用户单击一个链接,页面上的 JavaScript 会销毁旧的 DOM 元素,并生成一些新的 DOM 元素。页面看起来发生了变化,但浏览器并没有加载全新的页面。
现如今,大多数前端开发人员使用 TypeScript(具备类型检查特性的 JavaScript 变种)编程,以及 Angular 或 React 等框架。TypeScript 被转译为 JavaScript 和 CSS,这个过程成为项目构建的一部分。在进行本地开发时,开发人员将 Node.js 作为 JavaScript 和 CSS 文件的服务器,也用它将请求路由给目标 API。但如果不是在本地开发,我建议使用 Nginx。这样可以将配置了同源策略的文件与应用程序代码放在一起。构建出来的 Docker 镜像包含应用程序编译后的文件以及与 CORS 或缓存控制问题相关的配置文件。如果你采用了这种方式,可能需要调整 CDN 的配置。
现如今大多数移动应用程序都是运行在 iOS 或安卓系统上。这些操作系统都有各自的技术要求,这里就不赘述了。你可以选择为不同的操作系统单独开发应用程序,也可以使用 Ionic 或 React Native 框架来开发同一套应用程序,然后为不同的操作系统分别生成各自的二进制包。你可以为分别为 iOS 和安卓开发单独的 BFF,也可以简单一点,开发一个移动 BFF 来满足两个平台。
5 结论
你不必纠结于是选择 REST 还是 GraphQL。REST 更适合面向平台的数据服务,GraphQL 更适合面向 GUI 的边缘服务。如果你的数据服务和边缘服务位于不同的层,那么完全可以同时保留 REST 和 GQL,把二者的好处尽收囊中。
理清不同类型组件之间的关注点分离,有助于降低意外复杂性。有时候,你会为了达成短期目标而模糊了这些关注点。架构师需要努力满足各方的需求。为了满足紧急需求,考虑应用一些短期的解决方案,允许暂时模糊关注点边界,提高意外复杂性。但后续需要马上跟进(需要各方的参与),进行长期必要的重构工作,让系统重新具备清晰的关注点分离,并从整体上降低意外复杂性。这就是技术负债给我们带来的教训。
如果可能的话,降低跨组件的耦合性。紧密耦合的组件应该由相同的团队负责开发维护,并采用相似或互补的开发技术。你不需要为此大幅改变团队的结构。
作者简介:
Glenn Engstrand 是 Rally Health 的软件架构师。他的工作重点是与工程师合作,交付符合 12 Factor 标准的可扩展服务器端应用架构。在 2017 年和 2018 年的 Adobe 内部广告云开发者大会以及 2012 年在波士顿举行的 Lucene Revolution 大会上,Glenn 进行了突破性的演讲。他擅长将单片应用分解为微服务,并与实时通信基础设施进行深度集成。
原文链接:
https://www.infoq.com/articles/consistency-coupling-complexity/
点击底部【阅读原文】访问InfoQ官网,更多精彩内容等你来看!