如何通过80%抽象建模防止单体走向混乱


熵是一个普遍法则:如果不重新投入能量,一切都会趋于无序。软件也不例外。

当进化发展受到时间和/或预算的限制时,系统就会变得“单体”。

单体架构通常是对不一致抽象的意大利面条的委婉说法。

Gusto已经建立了超过十年的薪资软件。我们现在为全国超过30万家企业提供服务。我们开始时的抽象概念已经随着我们客户的需求而增长。更不用说不断变化的政府和合规要求了。

开发人们花费数年时间成为薪资管理专家。

理想的工程是平衡要求和约束:解决必要的复杂性,但要简单、快速、有效和准确。
由此产生的权衡在软件发展中是普遍存在的,甚至在生活中也是普遍存在的:对未来的关注太多,或者不够。
这两个极端都是有代价的。完美可以保护未来,但要花费时间和金钱,而且有风险:未来是难以捉摸和不断变化的。
速度可以保护现在,但有可能造成不必要的复杂性,甚至在我们增加功能时造成返工。

让我们来探讨一下这种权衡,以及我们首先是如何得到单体软件的。然后我们将讨论一种在复杂系统中寻找简化的方法。我们将使用Ruby on Rails的ActiveRecord抽象作为一个例子。

不必要的复杂性的权衡
不必要的复杂性在规模上是相当昂贵的。它使系统在各方面都更加困难:开发、调试、运行、规划。然而,它却非常顽固!

为什么呢?

桑迪-梅茨在《错误的抽象》(2016)中写到了这一点:、
我们无意识的偏见促使我们以不适当的尊重来对待大型系统:

现有的代码产生了强大的影响。它的存在证明了它的正确性和必要性。[......]越是复杂和难以理解的代码,我们就越有压力保留它("沉没成本谬论")。

但是没有特别的理由把代码放在一个标准基座上。
原型有办法留在生产中;它很容易近似,甚至误解了复杂的领域。

坦率地说,软件架构就是这么难。

运行工资单也是如此。每个人都会犯错误。

当我们连问题和解决方案,也就是成本和收益都难以说清楚的时候,就很难证明投资于重构的合理性。

在系统中建立新的功能,而不是改变系统,是有明确和无意识的压力的。

但是,还是按照Sandi Metz的说法:

然而,试图[在系统内构建:重构],是很残酷的。代码不再代表一个单一的、通用的抽象概念,而是变成了一个充满条件的程序,其中交织着一些模糊的相关想法。它很难理解,也很容易被破坏

这种情况越多,软件就越偏离理想。让我们假设我们可以将质量从0-100%进行量化。要清楚的是:我们实际上不能像这样量化质量,但这是一个有用的比喻。

由于预算限制等原因,我们可能会接受不那么完美的结果--比如,97%的完美(根据我们虚构的指标)。主要的要求是为客户提供正确的服务,同时保障工程和商业效率。这样做一次或两次,听起来很简单。但如果我们重复这个决定45次,我们最终会达到理想的25%。如果我们得到99%的理想,则需要137次重复:更多,但在十年的软件开发中并没有那么多。

这些痛苦是正常的,甚至是必要的:驾驭不完美是软件开发的一部分,还有,生活的一部分。
但作为工程师,我们需要控制这种复合效应。

怎么做呢?

Sandi Metz提议用重复来减少耦合:

将抽象内联到调用者中,然后再派生新的抽象。将抽象完全内联到所有的调用站点中,可以有效地消除耦合。

一旦你完全去除旧的抽象,你就可以重新开始,重新隔离重复,重新提取抽象。[......]一旦一个抽象被证明是错误的,最好的策略就是重新引入重复,让它告诉你什么是正确的。

这是个非常有价值的建议:让代码本身揭示抽象
也许Marie Kondo会这样重构软件:把所有的东西从抽象的柜子里拿出来,铺在地上。然后保留能激发快乐的东西,丢弃其他的。

但是,这有两个问题:

  • 首先,大多数生产系统不能随便丢弃那些不能 "激发快乐 "的代码。
  • 第二,把东西从柜子里拉出来而不对它们进行重组是一种熵:所有的东西都散落一地。其结果是一堆彼此相连的乐高碎片,而不是重复的模式。

这就像用原子来讨论苹果,或者更糟的是:量子夸克......

但如何组织一堆乐高积木呢?也许按颜色或形状。但乐高积木也有专门的套件,以城堡或星舰为主题。也许先按主题整理,再按颜色整理?等等......为什么我们又在谈论乐高积木?

组织乐高积木实际上是一个数据索引的抽象概念。先按颜色组织,就很难找到特定套件的碎片。但先按套件组织,就更难找到不寻常的形状,除非我们也追踪哪个形状来自哪个套件。

对于我们的工资发放软件,我们是否按领域(如工资或税收)来组织?按技术栈,如Ruby或Javascript?
Rails的自然组织方式是将其分组:API控制器;视图布局;实用服务;ActiveRecord模型;等等。
但功能的改变通常会影响到几个层次。

理想的组织是对今天的工作进行优化,而不是对昨天的工作进行优化。只要重组成本高或风险大,明天的工作就很重要。

在一个大型的软件系统中,今天的工作是一个很大的工作。必要的领域复杂性总是伴随着不必要的、偶然的复杂性。如果创建一个 "大统一抽象 "很容易,我们就已经做到了(可能)。

为什么创建这些大统一的、100%的抽象这么难?
嗯,据说马克-吐温写了一封长信,因为他没有时间写一封短信。
的确:要传达大量的信息,同时结合准确、简洁和清晰,这是一个相当大的成就。

吐温的俏皮话可以追溯到1657年的法国哲学家布莱斯-帕斯卡尔:

我只把这个做长了,因为我没有时间把它做短。

我的翻译:我只把这封[信]弄长了,因为我没有闲心把它弄短。

因此,混合这些比喻:我们需要整理乐高堆,而要整理好它需要时间。
错误是不可避免的。
这就是为什么我们说:不要让完美成为伟大的敌人。但我们也说,没有愿景的行动是一场噩梦。
只是 "做一些事情 "并不一定有价值:就像潦草地写了几个字可能并不能传达那么多。

比方说,我们的 "愿景 "是先前的那个抽象的100%。
我们知道,以100%为目标并不务实。
作为Gusto的工程师,我们需要为我们的客户快速构建,同时也要安全。我们还需要在长期内保持我们业务的灵活性:今天的进展不应该阻碍明天的进展。作为一名工程师,帮助实现这种平衡是我的首要职责之一。

我们也知道,组织1000个乐高积木要从第1块开始分类。
但是,某一块乐高并不是一个有代表性的样本。甚至少数几块也不是。
想象一下,我们的系统将1x1和4x2的积木编成索引,并将其余的归入 "其他 "篮子?这听起来像是一场噩梦......除非1x1和4x2是我们需要的全部。

在软件方面,你可能见过局部重构,但不知为何没有改变整个系统的复杂性
这就像在不改变API设计的情况下简化API实现。
或者像,让局部值更加局部最大。
抽象的说,这些重构是 "无悔的":更好就是更好,对吗?
是的,但要考虑到机会成本
在这里付出更多的努力意味着在那里付出更少的努力。
(banq:与其花时间重构,不如通读代码以后,酝酿重写,如果你时间花在小改小修,你就没有时间推倒重来)


随着系统的发展和复杂性的增加,这个问题变得很紧迫。我的薪资管理团队问自己,哪些架构变化实际上值得重构成本。毕竟,这些重构是很难估计的。复杂性的整个问题是,它很难被理解、沟通,因此也很难预测。

理想的重构是平衡的:

  • 有选择的用例,不要演变成 "修复所有的东西"
  • 足够的用例,以避免演变成局部的过度优化

针对所有的用例--修复所有的东西--意味着除非是全局性的,否则就无法取得进展。

但是,修复太少并不能捕捉到抽象的东西。

按照80/20帕累托原则,我们决定80%是一个很好的目标平衡。

我们找到了适用于80%用例的模式,并在此基础上重建了我们的工资单抽象。

在这80%中发生的事情一定是这个领域的核心,因为它出现在很多地方。其他的都是......其他的东西,我们可以在其他时间担心。
但是,组织80%而不是100%的工作仍然是非常有影响的。

今天,针对我们系统的具体问题进行的研究架构加强了我们的薪资管理平台。它解除了我们预期的能力,并继续提供意想不到的好处。这就是好的抽象的全部意义:更多的人可以快速而安全地创建软件。

例子:ActiveRecord又称数据建模
Gusto的服务器技术栈几乎完全是Ruby on Rails,所以我们当然会使用ActiveRecord。关于Rails的一些东西似乎不可避免地会产生 "单体应用"。

为什么呢?

嗯,ActiveRecord是一个强大的抽象概念。

太强大了:随着时间的推移,ActiveRecord模型成了进入数据的 "API"。

所有的应用程序用例都通过相同的模型。所有的用例都暴露在对方的关注中。

安全的改变需要了解数据之间的隐性和显性联系。当很难安全地使用数据模型时,混乱就会随之而来!

这里有一个经典的例子。

假设我们正在建立一个全新的工资系统,我们的数据模型有三列:

日期,小时,和工资

我们很快意识到我们需要处理税收,但是,我们只有在计算了工资单后才知道税收,或者说是运行了工资单。

我们增加了两列:

  • 一个布尔标志run?,如果为真,则是一个已填好的税收栏。
  • 如果run?是假的,那么税收栏就没有意义。

新模型:

日期、小时、工资pay、run、Taxes税

现在,这一条Payroll行(又称ActiveRecord对象)增加了两列新的:run和Taxes税收。

当系统小到足以容纳在内存中时,这是相当无害的,
也就是说。这有两个问题:

  • 一个新的成员,开始没有关系。
  • 系统内存很快就会变得比人的记忆要大。

我将留给大家想象力,去寻找我们可能需要的额外标志、新列或枚举状态,以代表一个完整的工资系统。

我们通常也不存储像工资这样的整数......我们在另一个表中把它分成若干项。工资单是非常复杂的:它很快就会变得非常有趣。

但为了本博客的目的,我们将坚持使用抽象的行与列。

抽象是伟大的
记得桑迪-梅兹说过,"糟糕的抽象 "代表:

一个充满条件的程序,其中交织着一些模糊的相关想法

因此,一个 "更好的 "抽象:

  • 有较少的条件
  • 分组相似性,分离差异性

由于我们关注的是数据模型层,让我们从简单的开始:
什么数据被使用以及如何使用?

在这里,我们将考虑只读的用例,如通过一些条件过滤行和读取列值。顺便说一下,这并不是一个昂贵的简化。
在许多领域,大多数软件都是为了显示或报告数据,而不是改变数据。

我们将从工资软件的五个不那么假设的用例开始:

  1. 审查一个月内的总工作时间。
  2. 显示某个支票日期的工资单。
  3. 提交季度税单。
  4. 审计员工福利。
  5. 在起草工资单时总结最终工资。

首先,我们需要弄清楚这些用例是如何选择行的,以及他们用这些行做什么。不是那么容易:我们在这里是因为 "糟糕的抽象"。所以这一步实际上是在浏览单体的奥秘。有时,我们可以看到模式,而不需要实际复制出抽象;有时,复制确实有利于看到它的全部布局。

在我们的案例中,用例可能归结为这些步骤:

  1. 总工作时间:选择运行当月的工资单,然后将这些工资单上的工作时间相加。
  2. 工资单:选择在检查日期运行工资单,然后显示日期、小时和工资。
  3. 税收:选择运行该季度的工资单,然后汇总工资和税收领域。
  4. 审核:选择运行福利薪酬大于0的薪资单,然后返回日期。
  5. 草稿工资:选择工资日期的工资单,然后汇总草稿和处理组的工资。

英文的重复说明了这些模式。但没有什么能胜过视觉化的东西!

我将使用以下颜色我将使用以下颜色;蓝色表示阅读一列,黄色表示过滤,紫色表示阅读和过滤。

下面是这个可视化的一些观察:

  • 所有的用例都关心日期。在大多数情况下,我们要选择一个时间段的数据。
  • 所有的用例都关心运行状态。但是⅘案例只是在运行字段上进行过滤,然后就忘了它。对于这些用例来说,草稿数据并不是 "真实的":就他们而言,根本就没有草稿数据这回事。
  • 只有五分之一的用例读取税收。税收是例外吗?这是一个检查样本偏差的好时机。不过,本着将相似性和差异性进行分组的精神,这表明时间和税收可能从单独的抽象中受益。
  • 大多数用例关心工资,但只有一些关心时间。如上所述,这表明工资是这些用例中的主要概念。事实上,对于雇员来说,时间通常是薪酬的主要输入。一旦我们完成了对时间的跟踪,工资就是现实世界的结果。

就像变魔术一样,80%的用例只关心 "真实 "的、经过处理的数据。在一个大型的工资系统中,实际数字会高得多:当然我们只关心发生的事实。另一方面,处理草稿数据是工资单核心功能的一部分:运行工资单以获得雇员的工资。然而,对于工资单草案来说,这些数据实际上是真实的。如果它对用例不真实,我们就不会去管它。

抽象的全部意义在于将实施者和消费者的关切分开
抽象的全部意义在于将实施者和消费者的关切分开,对吗?

在这种观点下,这种对工资单草案和运行工资单的近乎完全的分割表明:

  1. 运行工资单是消费者关心的问题;
  2. 工资单草案是产生新的处理工资单的机制。

我们能用这个观点做什么?我们不能从数据库中删除运行工资单,除非我们把草稿和运行工资单表分开(但这有其他影响)。但我们可以确保人们不需要关心它,除非他们必须这样做。

请记住,我们的单体是如此之大,而我们的团队又是如此之多变,这不是简单地 "记住 "它是否重要。
一栏运行吗变成了标志的组合;然后这些标志变成了分布在几个连接行中的枚举的组合,也就是相关的ActiveRecord对象图。

在任何情况下,理想的抽象都能提供这种保证:
看见 就意味 我关心它


什么是导致不断增长的抽象概念的原因
有时,你必须使问题复杂化,而不是停滞不前。但不必要的复杂性会让人不知所措。

因此,我们需要那个甜蜜点:必要时尽可能多的复杂,但尽可能多的幸福无知

实现这一目标的方法之一是将复杂性隔离到获取数据的那一点。
从这一点开始,所有的下游消费者都应该是幸福的无知的,并假定数据是被适当选择的。

就大多数意图和目的而言,运行工资单的命名章程是真正的章程,而草案是例外。
但在这两种情况下,我们只是有工资单。

总结
在一个大型的软件系统中,在几个层次上迭代这个过程会发现 "80%的抽象":
这些抽象足以对系统产生有意义的影响,但并不试图做到完美。

在Gusto,我们将这种方法付诸实践,引入了工资税的数据抽象。

从Dillon Tiner对税务用例的分析开始,我们开发了一个新的税务责任API。这个API成为Tori Huang工作的基础,以提高我们的税收纠正系统的精度和颗粒度。

在这个过程中,这种抽象在很大程度上使我们的申报系统与我们的工资税数据脱钩。这是另一个故事了。

通过复制来解开不良的抽象概念是一个强大的增长工具。只要确保减少它,认识到完成的80%远比失败的100%更有价值。