本文要点
如果做得好,模型驱动的软件开发(MDSD)可以局部地屏蔽一些复杂性,但是必须要将源码输出作为构建构件,并获取模板的所有权。维护代码生成模板是一种大多数开发人员都不太习惯的元编程。
12 要素应用程序确实可以实现更低的复杂性,但也只有与成熟稳定(即枯燥)的数据存储集成时才能实现这一点。
可以通过在连接器中构建一些有限的交叉智能来降低微服务编排的复杂性,但是必须要小心,否则太多的智能会产生反效果。
使用重量级框架,可以编写较小的应用程序,但是要注意,效率可能会受到影响,这种类型的服务很难进行性能调优,并且调试某些类型的问题时可能需要更长的时间。
使用反应式编程,最终是将后端复杂性转换成了前端复杂性。
软件架构师工作的一个重点是管理他们系统的复杂性,以便在保证功能开发速度足够快的同时能减少发布中断。当我们不能降低系统复杂性时,我们可以试图隐藏或转移它。
软件架构师倾向于使用如下久经考验的策略来管理系统复杂性:
通过复用通用框架或使用编程代码生成器来减小应用程序的大小。
通过密切关注应用程序的有状态性可以更容易地扩展系统。
设计的系统在负荷升高或局部宕机时能平稳地降级。
最后,通过迁移到最终一致性系统,规范化工作负载。
现在让我们更详细地了解下这些不同的策略及每种策略形成的历史背景,以便更好地了解它们的优缺点。
对于每种策略,我提到了针对 Java、JavaScript、Python 和 .NET 开发人员的相关技术示例。但这些并不完整,所以如果我没有提到您所喜爱的技术,请接受我的道歉。
它只是一个模型
模型驱动的软件开发(model-driven software development,MDSD)提高了功能开发的速度,因为开发人员可以通过编写更少的样板代码来节省时间。为了编写更少的样板代码,需要指定一个模型,然后通过代码生成器将模型与样板代码的模板关联起来,生成以前开发人员必须手工编写的代码。
我第一次接触 MDSD 是在 CASE(计算机辅助软件工程)时代。当 UML(统一建模语言)达到顶峰时,它又重新出现了。当时 MDSD 的问题是,用它来生成所有的代码,这意味着需要捕获所有需求可能需要的模型类型,这是非常复杂的,因此编写代码更容易些。
MDSD 的复兴得益于一项名为 Swagger 的技术(现在由 OpenAPI Initiative管理的新版本建模规范)而复苏,这项技术详细规定了描述 API 地模型。然后将模型和一组模板作为入参传入代码生成器,后者会输出 API 地样本代码。如果需要生成和使用建模好地 API,有单独地模版,而且用于任何相关技术栈的入门模板均可在线获取。
比如,检查 Spring Boot 的 Swagger 模板会发现,它能生成 REST 控制器、Jackson 注解的请求和响应 POJO 以及各种应用程序的样板代码。开发人员在此基础上需要添加逻辑和实现细节(如数据库访问),来满足每个 API 需求。并且可以使用提供继承或依赖注入,来避免工程师不得不修改 Swagger 生成的文件。
MDSD 如何屏蔽应用程序代码的复杂性呢?这很棘手,但也可以做到。代码生成器输出实现 API 资源的代码,因此开发人员不必担心编码问题。但是,如果将代码生成器用作一次性代码导入使用并将输出结果提交到有版本控制的源码仓库(如 Git),那么所做的这些只是节省了一些初始编码时间。其实没有真正隐藏任何东西,因为开发人员仍需研究和维护这些生成的代码。
要真正屏蔽这些代码的复杂性,必须将模型提交到有版本控制的源码仓库,而不是提交生成的代码。每次构建代码时,都需要从模型生成该输出源码。需要将生成器的步骤添加到所有的构建通道中。Maven 用户可以在他们的 POM 文件中配置 swagger-codegen-maven-plugin 插件。该插件是 swagger-codegen 项目中的一个模块。
如果必须要对生成的源代码进行变更怎么办呢?这就是为什么必须要设定模板的所有权,并将它们提交到有版本控制的源码仓库中的原因。这些是 mustache 模板,它们看起来像是用大括号分隔的替换参数和散布在其中的决策分支逻辑生成的代码。模板编程是一种非常先进的元编程形式。
最后,对 MDSD 期望的最好结果是,它可以为初级开发人员屏蔽复杂性,但代价是让高级开发人员支持这些模板。
论计算机系统的起源
2011 年,Heroku 的工作人员发布了一系列编写现代、云原生、面向服务软件的最佳实践。它们称之为12 要素应用程序。为了更好地理解 12 要素为什么能真正降低复杂性,我们简要地回顾下计算机系统是如何从简单的单机设置发展到在软件定义网络中互联的复杂虚拟机集群的发展历史。
在很长一段的时间中,应用程序被设计成在单台计算机上运行。如果想让这个应用程序处理更多的请求,那么就必须把它安装在一台更大的计算机上来向上扩大它的规模。系统逐渐演变成两层应用程序架构(客户端/服务端),数百名用户可以在桌面上运行一个专门的客户端程序,该客户端程序与运行在单个服务器上的数据库程序相连。
计算发展的下一阶段是三层系统架构,其中客户端程序与能访问数据库服务器的应用服务器相连。Web 应用程序取代了客户端/服务端应用程序,因为它更容易部署客户端部分(假设每个人的计算机上都安装了现代 Web 浏览器),并且可以容纳更多连接到系统的用户。水平扩展(从一台计算机扩展到多台计算机)比向上扩展(用更大的一台计算机替换现用的一台计算机)更有吸引力。为了处理更多的用户负载,将单个应用服务器扩展为具有负载均衡能力的计算机集群。数据库服务器可以通过称为分片(用于写操作)和复制(用于读操作)的技术进行扩展。当时,所有这些服务器要么部署在使用它们的公司的办公场所,要么部署在租用的数据中心。
在大约三十年的时间里,数据库软件的最佳选择是关系型数据库,也称为 SQL 数据库,因为应用程序代码通过用结构化查询语言编写的命令与它们通信。有许多伟大的关系型数据库可供选择。MySQL、MariaDB 和 PostgreSQL 都是十分流行的开源数据库。Oracle 和 MS SQL Server 是十分流行的专有数据库。
在过去十年左右的时间里,出现了越来越多的其他选择。现在有一种 NoSQL 数据库,它包括宽列数据库(如 Cassandra)、键值数据库(如 Aerospike)、文档数据库(如 MongoDB)、图形数据库(如 Neo4j)以及 ElasticSearch 风格的反向索引。甚至最近,多模态和分布式数据库也开始流行起来了。使用多模态数据库,可以在安装单个数据库的情况下能同时调用 SQL 和 NoSQL API。分布式数据库可以在不增加应用程序代码的复杂性的情况下,处理分片和复制。YugaByte 和 Cosmos DB 都是多模态分布式的数据库。
随着云计算的出现,企业不再需要雇佣懂如何在计算机机架上组装和布线的工程师,也不再需要与计算机制造商和托管服务提供商签订五年租赁协议。为了真正实现规模经济,计算机被虚拟化,变得更加短暂。软件必须重新设计,才能更容易地适应所有这些部署方式的变化。
水平扩展两个微服务和一个数据库的典型部署
准确遵循 12 要素的应用程序可以以最小的复杂性轻松地处理硬件的这种扩展。让我们看下 12 要素的第 6 个(流程)、第 8 个(并发)和 第 9 个(易处理)要素。
如果应用程序是被设计成在许多无状态的进程上运行,那么可以更容易地水平扩展这些应用程序。否则,你只能采用向上扩展的方式。这就是要素 6 的意义所在。可以在外部集群中缓存数据,以加快平均延迟或保护底层数据库不被淹没,但缓存不应包含数据库中不存在的任何数据。可以随时失效缓存,且不会丢失任何实际数据。
要素 8 是关于并发的。这些进程在集群中被分组,以便每个进程集群能处理相同类型的请求。如果这些进程除了数据库之外不共享任何状态,那么软件将会简单很多。如果这些进程共享内部状态,那么它们必须相互了解,采用向集群中添加更多进程的方式来水平扩展应用程序,将会变得更加困难和复杂。
如果每个进程都能够快速初始化并优雅地终止,那么应用程序将能快速响应负载变化,并对波动具有更强的鲁棒性。这是要素 9,易处理。动态扩展能够快速、自动地添加更多的进程来处理增加的负载,但只有当每个进程在准备接受请求之前不需要花费很长的时间来启动时,动态扩展才有效。有时系统会不稳定,解决宕机的最快方法是重新启动所有进程,但也只有在每个进程都可以快速终止且不丢失任何数据的情况下,这种方法才有效。
如果采用使用许多并发运行的进程处理入站请求流的方式构建系统,那么将会避免许多缺陷和脆弱性。这些进程可以(但不必)是多线程的。这些进程应该能够快速启动并能优雅地终止。最重要的是,这些进程应该是无状态的,且不共享任何内容。
显然,没有太多的需求需要一个无法记住任何内容的应用程序,那么状态将去何处呢?答案是在数据库中,但数据库应用程序也是软件。为什么数据库可以有状态,而应用程序不适合有状态呢?我们已经讨论过,应用程序需要能够以相当快的功能开发速度进行交付。而数据库软件则不一样。要在高负载下正确完成有状态的任务,需要花费大量的工程时间、思想和努力。一旦达到了这个目标,就不想做太多大的变更,因为有状态的软件非常复杂,而且很容易被破坏。
如前所述,数据库技术正在大量涌现,其中很多是新技术,且还未经过测试。工程师经过几个月的努力实践,就可以在一定程度上掌握无状态的应用程序。这些工程师中的一部分或大部分是刚毕业的,没有什么专业经验。有状态应用程序则完全不同。我敢打赌,任何一种数据库技术都至少经过了 20 年的工程实践(这是工程学的几十年,而不是历法的几十年)。从事该工作的工程师必须是经验丰富的专业人员,他们非常聪明,并且拥有大量的分布式计算经验和强大的计算机科学能力。如果使用未经测试或不成熟的数据库引擎,那么最终将在应用程序中引入额外的复杂性,以解决不成熟数据库的缺陷和限制。一旦数据库中的这些缺陷得到修复,那么必须重新构建应用程序,以消除现在不必要的复杂性。
它毕竟是一系列管道
随着系统从单一的应用程序发展到应用程序和数据库相互连接的集群,为了能对这些应用程序相互交互的最有效方式提出建议,人们对这个知识体系进行了编目。在 21 世纪初,一本关于企业集成模式(enterprise integration patterns,简称 EIP)的书出版了,该书更正式地记录了这一知识体系。
当时,一种称为面向服务的体系结构的服务交互风格开始流行起来。在 SOA 中,应用程序通过企业服务总线(Enterprise Service Bus,ESB)进行通信,该总线也被编程为严格遵循 EIP 的配置规则来操纵并路由消息。
工作流引擎是一种基于 Petri Nets 的类似技术,它更侧重于业务。它是基于非工程师也可以编写规则的前提进行推销的,但却从未真正兑现过这一承诺。
这些方法引入了许多不必要且不被认可的复杂性,导致了它们的失宠。配置发展成一个复杂的相互关联的规则,随着时间的推移,变更这些规则变得非常困难。这是为什么呢?这与让 MDSD 为所有需求建模是相同的问题。编程语言可能比建模语言需要更多的工程知识,但它们也更具表现力。与编写大型且复杂的 BPMN 模型规范相比,编写或理解以前编写的用于处理 EIP 需求的小代码片段要容易得多。Camel(一个 Apache 项目)和 Mulesoft(2018 年被 Salesforce 收购)都是试图简化各自技术的 ESB 。希望它们能够成功。
对 ESB 或工作流风格的 SOA 的反应被称为微服务体系结构(MSA)。2014 年,James Lewis 和 Martin Fowler 总结了 MSA 和 SOA 之间的差异。使用 SOA,将拥有哑端点和智能管道。使用 MSA,则拥有智能端点和哑管道。复杂性降低了,但可能降低得太多了。在发生局部宕机或性能下降时,这类系统是非常脆弱而无弹性的(即容易失稳)。在单独的微服务中也存在很多重复,每个微服务都必须实现相同的横切关注点,比如安全性。即使每个实现只是嵌入同一个共享库,也是如此(尽管程度较低)。
接下来介绍 API 网关和服务网格,它们都是第 7 层负载均衡的增强版本。术语“第7层”是指 80 年代引入的开放系统互连模型( OSI )。
当通过 Internet 或 Intranet 调用后端的微服务时,调用将通过一个 API 网关,该网关将处理身份验证、速率限制和请求日志记录、从每个微服务中删除这些请求等功能。
从任一微服务到其他微服务的调用都会经过一个服务网格,该服务网格处理诸如拦截和断路之类的问题。当服务请求频繁超时时,服务网格会立即(在一段时间内)舍弃后来的调用,而不是尝试进行实际的调用。这可以防止无响应服务导致依赖的服务也变得无响应,这是因为后者的所有线程都在等待原始无响应的服务。这种行为类似于船上的舱壁,防止洪水扩散到另外一个的舱室。使用断路,服务网格会立即(在一段时间内)中断对一个服务的调用,因为在最近的一段时间里,该服务以前的大多数调用都失败了。这种策略的基本原理是,失败的服务已经不堪重负,阻止对该服务的调用将会给它一个恢复的机会。
部署 API 网关和服务网格。
API 网关和服务网格使微服务变得更具弹性,而无需在微服务代码本身中引入任何额外的复杂性。但是,由于需要额外负责维护 API 网关或服务网格的健康状况,增加了操作成本。
框架的进展
另一种减少开发人员编码量的方法是使用应用程序框架。框架只是实现所有应用程序共有功能的通用程序库。框架的某些部分首先加载,然后再调用代码。
正如我前面提到的,关系型数据库最初是在 70 年代中期开发的,它非常有用,以至于在前面描述的技术趋势中仍然很受欢迎。它们现在仍然很流行,但是在 Web 应用程序中使用它们会引入很多复杂性。到关系型数据库的连接是有状态的长连接,然而典型的 Web 请求是无状态的短连接。最终的结果是,多线程服务必须使用一种称为连接池的技术来处理这种复杂性。单线程应用程序在这种方式下效率较低,因此它们必须依赖更多地分片和复制。
面向对象编程在客户端/服务端时期非常流行,并且一直保持着到现在。关系型数据很难适应面向对象的结构,因此开发了对象关系映射框架,来屏蔽这种复杂性。比较流行的 ORM 框架包括 Hibernate、SQLAlchemy、 LoopBack 和 实体框架。
在 Web 应用程序开发的早期,一切都是在后来被称为“单体”的东西中构建的。图形用户界面或 GUI(基本上是浏览器渲染的 HTML、CSS 和 JavaScript)是在服务端生成的。MVC(Model View Controller)等模式用于协调 GUI 渲染与数据访问、业务规则等的关系。实际上,MVC 有许多变体,但就本文来说,我将它们都归入到与 MVC 相同的类别中。MVC 目前仍然存在,流行的现代 MVC 框架包括 Play、 Meteor、Django 和 ASP.NET。
随着时间的推移,这类应用程序变得庞大而笨重;它们庞大到使人很难以理解或预测它们的行为。这会给应用程序变更带来很大的风险,并且由于很难测试和验证这些过于复杂的系统的正确性,发布新版本容易造成混乱。大量的工程时间都被花费在快速修复那些没有经过适当审查就被部署上线的缺陷代码上了。当被迫要快速修复某个问题时,就没有时间来想最佳解决方案,从而导致低质量的代码混入。打算是稍后用高质量代码替换这些低质量的代码。
解决这个问题的答案是将单体分割成多个组件或微服务,这些组件或微服务可以单独发布。GUI 代码全部迁移到了 SPA(单页应用程序)以及本地移动应用程序中。数据访问和业务规则仍保留在服务端,并被分割成多个服务。流行的微服务框架包括 Flask 和 Express。Spring Boot 和 Dropwizard 是 Java 开发人员最常用的基于 Jersey 的 Servlet 容器。
微服务框架最初很简单易学,并且表现出易于理解和预测的行为。由于上述复杂性因素,随着时间的推移,基于这些轻量级框架构建的应用程序也变得越来越大。应用程序越大,就越像是一个整体。当架构师没有将大型微服务拆分为较小的微服务时,他们开始寻找通过在框架中隐藏相关的复杂性来减小应用程序大小的方法。采用固执己见的软件、基于注解的设计模式,并用配置替换代码,减少了应用程序中的代码行数,但使框架变得更为沉重了。
使用重量级框架的应用程序的代码行数会更少,并且具有更快的功能开发速度,但是这种遮掩复杂性的形式也有缺点。从本质上讲,框架比应用程序更通用,这意味着框架做同样的工作需要更多的代码。虽然自定义的应用程序代码较少,但实际的可执行文件(包括相关的框架代码)要大得多。这意味着当所有这些额外的代码加载到内存中时,应用程序启动需要更长的时间。所有这些额外的不可见的代码也意味着堆栈跟踪(每当抛出意外的异常时,这些跟踪信息会被写入应用程序日志)将会更长。在调试时,更大的堆栈跟踪需要工程师花费更多的时间来阅读和理解。
在最好的情况下,性能调优可能有点像黑色艺术。需要大量的试错才能得到连接池大小、缓存过期时间及连接超时时间的最佳组合值。当看不到想要调优的代码时,这会变得更加困难。这些框架都是开源的,所以可以研究源代码,但大多数开发人员没有这样做。
最终一致性
反应式系统不是立即同步处理每个 API 请求,而是将消息异步传递给它的内部子系统,以便最终处理每个 API 请求。
很难说清是什么时候开始引入反应式编程的。《反应式宣言》发表于 2013 年 7 月,但这之前就有很多先驱。发布订阅(PubSub)模式最早出现在 80 年代中期。复杂事件处理(CEP)在 90 年代也曾短暂流行过。我看到的第一篇关于分阶段的事件驱动架构(SEDA)的文章,是在 2001 年底发表的。事件源是反应式编程主题的最新变体。反应式系统可以采用 PubSub 风格编码,也可以采用类似于函数式编程的领域脚本语言编写消息流。
当一个反应式编程系统分布在多台计算机上时,通常(但不总是)会涉及到一个消息代理。一些比较流行的代理有 Kafka、RabbitMQ 和 ActiveMQ。最近,Kafka 团队发布了一个名为 Kafka Streams 的客户端库。
典型的分布式全反应性系统部署
ReactiveX是一个非常流行的反应式框架,它为许多不同编程语言都提供了库。对于 Java 程序员来说,反应式框架有Spring Integration 或 Spring Cloud Data Flow、Vert.x 及 Akka。
下面是架构师如何使用反应式编程来屏蔽复杂性。对微服务进行异步调用,意味着当调用返回时,不必执行 API 请求的任何操作。这也被称为最终一致性。它使得这些微服务在不引入太多额外复杂性的情况下,对局部宕机或数据库性能下降更有弹性。我们不必担心调用方超时和在原始事务仍在运行的情况下重新提交。如果某些资源不可用,那么就等待它再次可用。我承认,对初级开发人员来说,调试反应式程序(尤其是用 PubSub 风格编码的程序)是一个挑战,但这主要是因为他们不熟悉这种模式。
那么,复杂性到哪里去了呢?现代消息代理是复杂多样的,但是你很可能只需使用其中之一,而不必自己编写代理。和任何技术一样,它们也有自己的注意事项,不过它们有非常合理的限制。
对于应用程序开发,复杂性被移到了前端。最终一致性对于后端系统来说可能很好,但对于人类来说却很糟糕。您可能不关心您的假期照片何时到达您社交网络中的所有朋友,但如果您是一个企业客户,并正在协商一个相互关联的多阶段订单,那么您将希望准确知道订单的每个部分何时提交、验证、批准、计划以及最终完成。
为了让 GUI 适应这种非常人性化的心理需求,它需要在后端请求处理完成时通知用户。由于 API 调用不是同步的,前端将不得不寻找其他方法。通过轮询 API 更新状态的方式伸缩性不好。这意味着 Web 浏览器或移动设备需要使用一个有状态的长连接,通过该连接,它可以在没有任何提示的情况下,接收到后端的更新。在过去,可以通过扩展 XMPP 服务来实现这一点。对于现代 Web 浏览器,已经很好地支持了 WebSocket 和服务器的发送事件。Spring WebFlux、socket.io 和 SignalR 是三个比较流行的库,它们都允许服务端服务以这种方式与客户端 JavaScript 进行通信。
Web 浏览器对这类连接施加了限制,因此客户端应用程序需要共享相同的连接来接收所有的通知。由于大多数的负载均衡都会关闭空闲连接,因此应用程序必须通过偶尔发送心跳消息的方式来解决这一问题。移动设备因间歇性连接故障而臭名昭著,因此需要在客户端软件中编写重新连接逻辑。此外,必须有某种机制,客户端应用程序通过该机制可以将每个通知(可能有多个)与原始 API 调用相关联。当用户离开后再返回应用程序时,也需要有某种机制来确定以前的 API 调用状态。
结论
从最初的大型机到现在的云计算,系统的复杂性一直在增长,软件架构师已经找到了管理这种复杂性的新方法。在可能且不牺牲性能的情况下降低复杂性是最佳的行动方案。关于如何做到这一点,12 要素应用程序给出了很好的建议。通过使用 EIP、反应式系统和最终一致性,我们可能会认为自己正在降低复杂性,但实际上只是将复杂性转移到系统的另一部分上。有时,我们只需要隐藏复杂性,并且有很多基于模型的代码生成器、框架和连接器可以帮助我们做到这一点,但是这种方法既有优点也有缺点。正如我们从 12 要素应用程序和反应式系统中了解到的,没有什么东西会像有状态性那样更能增加复杂性,所以在应用程序中添加或扩大状态性时要非常谨慎和保守。
无论复杂性如何减少、隐藏或重新分配,软件架构师都将继续管理复杂性,以便在一个对功能、能力、容量和效率的需求不断增长的世界中更快速地交付高质量的软件。
作者介绍
Glenn Engstrand 是 Adobe 公司的软件架构师。他的工作重点是与工程师合作,提供可伸缩且符合 12 要素标准的服务器端应用程序架构。Engstrand 还是 2018 年和 2017 年 Adobe 内部广告云开发者大会以及 2012 年波士顿 Lucene Revolution 大会的最受关注演讲者。他专注于将单体应用程序分解为微服务以及与实时通信基础设施的深度集成。
原文链接:
评论