微服务架构中如何避免抽象变幻觉? - Gregor


像我这样的架构师(被亲切地称为“脾气暴躁的老人”)已经见证了几代编程抽象的来来去去:

  • 也许最成功的抽象是编译器,
  • CASE 工具在 1990 年代受到广泛关注
  • 其次是2000 年代初期的模型驱动架构和可执行 UML 。
  • 几年后并延伸到 2010 年代初出现了诸如 J2EE、Spring Framework 或 Rails 之类的框架,它们不提供语言抽象,而是负责处理应用程序的许多常规编码的库。
  • 最近低代码/无代码是旨在吸引公民程序员的更高级别抽象的新竞争者。

低代码工具可能会带来令人期待的春天,但这种情况会在多大程度上发生还有待观察。

现代云平台为我们提供了全新且非常强大的运行时环境。
构建云应用程序也可以从更高级别的抽象中获益。事实上,“云编译器”是一个不时出现的术语。

任何此类努力的成功都取决于使编译器有效的关键特性:堆栈跟踪Stack Trace 。

更高层次的编程
将编程提升到更高的水平,无论是在云端还是在其他地方,似乎都很自然。

好的抽象并不容易找到,许多所谓的云抽象落入了两个陷阱之一:

  • Compositions只有组合:组合较低级别元素但未能引入新语言的构造,主要示例是 SQS-Lambda-SQS 等云元素。它们很方便,但不提供任何抽象,因为没有任何东西真正被抽象掉。
  • Illusions幻觉 : 隐藏太多的抽象概念,包括相关概念。它们导致最初的开发人员欣喜若狂——事情看起来简单多了——直到现实破灭和幻觉泡沫破灭

因此,一个好的云抽象必须通过的第一个测试是在不产生危险幻想的情况下实际抽象事物。
这样的云编译器将使我们能够在更高级别描述我们的解决方案并代表我们部署较低级别的元素以使该解决方案有效。
许多旨在创建云提供商或服务独立层的框架,部分是为了实现可移植性,同时也是为了实现更高级别的抽象。
DAPR,即分布式应用程序运行时

抽象的失败
失败往往发生在被我们抽象掉的实现元素中,经典的例子是一种NullPointerException语言,它让我们相信我们不需要处理指针。

这可能会使故障原因的诊断变得具有挑战性,原因有两个:

  1. 该错误可能发生在您甚至不知道其存在的元素中。
  2. 修复必须回到抽象层,而不是它发生的地方。

因此,需要从实现层转换回抽象层。

飞机(和汽车)类比:
波音 737 MAX 新引入的MACS系统的存在是从飞行员那里抽象出来的,他们被留下来争先恐后地稳定一架无缘无故想要俯冲的飞机。
幸运的是,软件工程师不必在 10,000 英尺高处进行调试。

不受欢迎的“演示软件”版本——它演示得很好,但实际上在现实世界中不起作用。
建议对演示更高级别开发系统的供应商进行以下测试:

  1. 要求他们在开发人员希望输入某些逻辑的字段之一中输入错字。
  2. 让他们离开房间两分钟,同时我们更改他们演示配置的一些随机元素。返回后,他们将不得不调试并弄清楚发生了什么变化。

不用说,没有供应商接受过这样的挑战。


堆栈跟踪Stack Trace 
很少有开发人员希望自己回到汇编编程或在没有任何框架或库的情况下使用 Java 这样的语言进行编码,这表明这些抽象确实有效。

而且他们中的任何一个人都不太可能需要调试任何东西。
那是因为两个超级有用的工具将问题转换回抽象级别、堆栈跟踪和符号表。

堆栈跟踪显示程序到达错误位置的路径(“跟踪”)的快照。没有显示整个路径,而是显示了方法的嵌套.
如这个简单的 Java 示例所示:

java.lang.IllegalStateException: No onEvent(class com.eaipatterns.event.common.EventChannelTest$TestEvent) in class com.eaipatterns.event.common.EventChannelTest$EventRecipientWithoutOnEvent
    at com.eaipatterns.event.common.EventChannel.send(EventChannel.java:44)
    at com.eaipatterns.event.common.EventChannelTest.testSubscribeWithoutMatchingOnEventMethodThrows(EventChannelTest.java:78)
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.base/java.lang.reflect.Method.invoke(Method.java:564)
    at junit.framework.TestCase.runTest(TestCase.java:154)

此堆栈跟踪包含许多与来自 JUnit 测试框架的反射相关的不是很有用的信息。
在类EventChannel的第44行遇到了IllegalStateException(我删除了捕捉这个预期异常的部分以获得堆栈跟踪)。

在任何情况下,堆栈跟踪都是了解错误发生上下文的宝贵工具。

对于像 C 这样不提供堆栈跟踪的语言,下一个最好的选择是符号表,它将错误发生时的指令指针与一行源代码相关联。您将看不到发生错误的上下文。但至少你可以看到触发它的源代码行。

上下文为王
没有收到任何上下文是最糟糕的,因为它让你对发生的事情一无所知,除了反复试验之外别无他法。

一位客户分享了一个特别令人沮丧的版本:
在一家大型企业中,基础架构团队编写了工具来检查部署并阻止无效部署。
他们没有提供任何错误消息,让开发人员只能尝试所有变体,直到最终部署。

这样的设置不仅浪费时间,而且会导致不良行为。人们将不再以最好的设计为目标,而只是试图碰运气运行通过一些功能,然后再也不会碰它,因为它可能会再次失败。

你想要的不是悬崖(或走钢丝),而是边缘有缓坡的漂亮高原,万一滑倒,你可以轻松爬上去。

我使用无服务器的第一步比我想象的更像走悬崖:

  • 消息没有通过我部署的元素传递。
  • 我的匹配模式无效吗?
  • 消息格式与我想象的不同吗?
  • 是否缺少 IAM 权限(每个人的第一个猜测)?

我的变量太多而得到的反馈信号太少。

对于编程工具或云服务,良好的错误消息可能与主要功能一样重要。

不要把复杂的事情复杂化
根据Cynefin Framework: 缺少堆栈跟踪等信号似乎类似于从复杂complicated 域转移到复杂域complex 。

  • 一个复杂的complicated 系统有很多部分,但你可以推理它,并通过分析找到一个正确的答案。
  • 复杂complex 系统不断涌现,您所能做的就是探测更多信号。如果这些信号很弱,你甚至可能会陷入混乱的境地,在那里你所能做的就是行动,也许是随机的,希望大海捞针。

工程师是分析性的思考者。将它们降级为纯粹的探索者,或者更糟糕的是,随机尝试者,是最令人沮丧的经历之一。

可悲的是,这就是反馈信号不足的系统所做的。

没有堆栈跟踪的编程
面向消息的系统似乎更易于调试,因为它们具有简单的交互模型。然而,事实往往恰恰相反。这些系统是分布式的、异步的,可以乱序传递消息(或根本不传递消息——见上文),具有从轮询到事件流的不同控制流,并且通常使用软模式。在此基础上添加细粒度的访问控制,不难想象您需要充足的信号才能不落入混乱的土地。

可观察性
鉴于调试现代分布式系统(如微服务或无服务器应用程序)的难度,因此在开发期间或运行期间调试这些系统已成为软件交付的一个主要方面也就不足为奇了。

可观察性是衡量一个系统的内部状态可以从其外部输出的知识中推断出来的程度。

如果我们了解(或推断)内部状态,那么找出问题出在哪里就变得简单多了。

众所周知,现代分布式系统架构以新颖的方式失败,没有人能够预测,也没有人以前经历过。

在没有特定警报的情况下,我们需要根据信号进行调试。
我对此的心智模型是用于测向的三角测量。两个可以告诉他们看到物体的角度的定向天线可以组合确定物体的位置
根据两个信号,您可以推断出系统的状态(船或飞机的位置)。
如果你有更多的天线(接收到的 GPS 需要看到 3 颗卫星加一颗用于校正)并且分辨率越高,它的效果就越好。例如,拥有精确的角度将比仅仅拥有一个象限要好得多。

两个因素是可观察性的主要输入:维度和基数。
例如,知道在特定机架位置具有特定软件版本的特定型号的服务器在特定时间可以运行,这为我们提供了很多维度但基数较低(一位 - 可运行或不可运行)。
它是时间序列数据库 Prometheus 的灵感来源。

堆栈跟踪:一条从任何故障点返回到确定在抽象层更改什么以纠正它的路径。