攀登“模块化”之山


作为软件开发人员的培训师和教练,我看到模块化软件设计(例如,面向对象或微服务)对人们来说是一种难以理解的技能。

在许多不同的模块化层面(方法和功能、类和模块、包和组件、流程、服务、服务器、系统和系统的系统),用许多不同的方式来解释它,这并没有什么帮助。

仅以对象为例,我们有多套设计概念和原则:SOLID、"tell,Don't Ask"、数据隐藏、封装、抽象、继承、多态性、消息传递等等等等。

而每一级的代码组织似乎都有自己的一套原则。类被分组到包中的方式与成员被分组到类中的方式的解释完全不同。

问题是:模块化设计的原则在每个层次上是否真的不同?

事实上,我们能不能用更普遍的术语来解释模块化?
我发现我们可以,而且是以可以适用于每个层次的方式。

模块化的动机
但首先,我们需要重新审视我们的模块化的动机。

为什么要把代码分解成独立的单元?

虽然有些人可能会谈论重用或可扩展性和其他问题(例如,在证明微服务架构的合理性时),但在过去的30年里,我所看到的是使用我所能想到的几乎所有的模块化机制,它是为了使变化更容易。(banq:但是人是无法预测未来如何变化的)

想一想,我们可能会把一个长的方法分解成多个方法,在原来的基础上进行调用。这不仅有助于使用精心选择的方法名称来解释原始代码的流程,而且意味着开发人员在改变该流程的一部分时可以减少对其他部分的潜在影响。

或者想想我们如何将一个有两个不同职责的类--两个需要改变的原因--分成两个独立的类。以此类推。

我认为模块化程度的提高与我们独立改变系统的一部分而对其他部分影响较小的能力之间有一定的关联。

当然,这也是一种折衷。一个在单个.NET DLL或Java .jar中包含100个类的系统,其开发和维护成本要比在十几个微服务中部署同样的100个类低很多。

这些额外的开销大部分是通过扩大我们代码中的依赖关系产生的,从方法调用方法,到类使用类,到组件引用组件,到服务调用服务等等。

在更高的模块化水平上,这些模块的依赖性会变成组织上的依赖性--团队之间的依赖性,甚至是独立的业务--然后我们的问题才真正开始。

为了澄清我所说的 "依赖关系 "的含义,依赖关系是指一个系统的两个部分之间的关系,其中改变一个部分会破坏另一个部分。当我重构代码以使其更加模块化时,一些开发人员--通常是更多的编程新手--会抗议 "杰森,你引入了一个依赖关系!"许多人认为,这是重用的双刃剑的第二条边。而在更高层次的代码重用中,这当然是真的。

但是在代码层面上...好吧,看一下这个简单的Python代码:

def order_average(orders)
    total_orders = sum(map(lambda order: order.price, orders))

    order_count = len(orders) if len(orders) > 0 else 1

    return total_orders / order_count:

在第一行改变total_orders的名称会破坏第三行。改变order_count的计算方法也会破坏第三行。例如,如果我们允许它为零。

所以,在没有任何模块化的情况下,这段代码中已经存在依赖性。如果我把前两行提取到它们自己的函数中,甚至可能提取到它们自己的源文件中,这只会使依赖关系更容易被看到。

def order_average(orders)
    return total_orders(orders) / order_count(orders)


def total_orders(orders):
    return sum(map(lambda order: order.price, orders))


def order_count(orders):
    return len(orders) if len(orders) > 0 else 1:

现在我可以改变订单总数的计算方式,而不需要改变订单平均化的高层逻辑。把total_orders放在它自己的源文件中,独立的改变就变得更加容易。现在,如果有两个开发者想改变这些关注点中的任何一个,他们就会改变不同的文件,合并冲突的风险就会大大降低。

把它们放在单独的Python包里,它们可以成为各自发布周期的一部分,甚至可以由不同的团队来维护,有自己的目标、实践、节奏和喝咖啡的轮值表。

一些管理者认为这是一种扩大软件开发规模的方法。如果不是因为一个微小的细节,这可能是个好办法:我们在同一个函数中的两行代码之间的依赖现在变成了两个团队之间的依赖。改变total_orders仍然会破坏order_average。

而这正是我们的模块化设计原则的作用所在。我们不能随意打包我们的代码,因为我们有可能在模块之间产生许多依赖关系,当一个模块被改变时,可能产生广泛的 "涟漪效应"。

我们的目标是控制涟漪;将对模块的修改对系统其他部分的影响本地化。这对于我们如何设计,比如说类,以及如何选择在每个组件或包中包含哪些类,都是一样的。每一个层次都是同样的4个原则:

  • 模块应该有一个变化的理由("一起变化的东西属于一起")。
  • 模块应该隐藏其内部工作原理(也就是所谓的 "封装"--客户模块应该尽可能少地知道)。
  • 模块应该是可互换的
  • 模块应该有以客户为中心的接口

1和2是关于内聚力和耦合的。想想一个类的成员之间的关系--方法、字段、依赖关系等等(内聚)--以及与其他类的成员的关系(耦合)。在更高的层次上,想想一个包中的类和其他包中的类之间的关系。乌龟,还记得吗?耦合和内聚是一个硬币的两面。为了减少模块之间的耦合,我们需要增加它们的内聚力。我们应该尽可能地把与模块内的同一关注点有关的关系内部化。

3是我们的老朋友多态性。这通常被认为是一个面向对象的概念。但实际上,其他的编程范式也有这种现象。在函数式编程中,我们可以看到一个函数被另一个函数使用而不知道其实现的例子。Python中的map函数接受一个函数的实现(在这里是一个lambda表达式)作为参数值。只要该函数具有 map 所要求的签名,它就可以将其应用于列表中的每个元素。lambda 实际上是做什么的,我喜欢称之为 "别人的问题";map 不需要知道。

在OOP中,我们可以通过依赖注入来实现类似于传递函数实现的事情。

class Totaller
    def total(self, orders):
        return sum(map(lambda order: order.price, orders))

class Counter:
    def count(self, orders):
        return len(orders) if len(orders) > 0 else 1

class Customer:
    def __init__(self, orders):
        self.orders = orders

    def order_average(self, totaller, counter):
        return totaller.total(self.orders) / counter.count(self.orders):

请注意,Customer对Totaller和Counter的实现没有任何依赖性。与将lambda传入map函数一样,这里客户代码决定Customer应该使用哪些实现。

这意味着我们可以从外部交换实现而不对Customer做任何改变。我们称之为控制反转(Inversion of Control)。(就像所有这些概念一样,你可能知道它有其他的名字,比如 "协调"(orchestration)。我最喜欢的是 "软布线")简而言之,关于哪些模块与其他模块对话的决定是在堆栈的较高位置做出的,并且可以动态地改变--甚至在运行时--而不需要改变许多单独的模块,这些模块的依赖关系是硬连接的。

在更高层次上,有许多例子表明,组件或包的依赖关系同样是软连接的。在微软的开放数据库连接标准(ODBC)时代,有一组接口(抽象),所有的数据库驱动程序都需要实现,打包在他们自己的库中,我们的代码依赖于此。我们的代码不需要直接绑定到,比如说,Oracle的ODBC驱动库。使用哪个实际的物理库(DLL)的决定是在我们的代码之外做出的--控制反转。

或者想想Web服务;在我们的代码中硬编码他们的URL被认为是一个坏主意。许多团队有集中的服务,用于将抽象的网络服务调用映射到网上的物理地址。只要我们的请求与服务所期望的相匹配,并且它的响应与我们所期望的相匹配,我们就不需要担心它的实现会改变。

那么,在模块化的每个层面上,我们都要关注模块间的契约。如果我们不改变契约,调用的代码也不需要改变。因此,涟漪被包含在该模块中。

最后,4是关于模块如何向外部世界展示自己。许多开发者从错误的角度来进行界面设计,问 "这个模块是做什么的?"这可能会导致更大的界面,也可能会导致集成不匹配。想象一下,设计一个拼图的碎片,然后在最后试图把它们做成一幅画。(在我的职业生涯中,我见过一些令人大开眼界的例子,开发人员被派去做各个模块的工作,但在最后一刻才发现,当我们试图把它们放在一起时,没有一个模块适合。)

模块的客户端
更有用的问题是这个模块的调用客户端需要告诉它做什么?在我们设计适合它的作品之前,先定义孔的形状。

在Steve Freeman和Nat Pryce的《以测试为指导的面向对象的软件的成长》一书中描述了这种方法的一个伟大的例子。他们从最终用户的结果开始,然后从外到内(从作为逻辑入口的模块开始),为该模块需要做的事情编写代码--从失败的测试开始,并使用接口作为任何重要的依赖关系的占位符--我们可能还不想打开的罐子(这种方法有时被称为 "假的,直到你成功")来驱动他们的端到端设计。

当最外层的模块开始工作时,他们就向内移动,为其依赖的实现编写代码(反过来,这些依赖也可能有自己的依赖,而接口则作为占位符--就像我说的,一路走来都是乌龟。)

想象一下,我们想专注于如何计算平均订单价值,而不考虑如何处理总订单价值和订单数量。

def order_average(orders, total_orders, order_count)
    return total_orders(orders) / order_count(orders):

如果我们使用依赖注入来提供这些函数,我们的客户代码--在这种情况下,我们的单元测试--可以交换他们自己的total_orders和order_count的实现,这将只是提供我们的测试数字。

class OrdersTest(unittest.TestCase)
    def test_order_average(self):
        self.assertEqual(7.5, order_average([], lambda: 15.0, lambda: 2))

    def test_order_average_no_orders(self):
        self.assertEqual(0.0, order_average([], lambda: 0.0, lambda: 1)):


在这一点上,测试代码之外不存在任何实现。但函数的签名是在客户端代码中定义的--如果你愿意,由用户定义。任何符合这里定义的契约的函数都可以工作。重要的是,契约是从客户的角度来定义的。它描述了他们需要从该函数中得到什么。这是一个微妙但重要的区别。

在这个意义上,模块之间的交互--以及约束这些交互的契约--才是在最终用户的目标是否实现之后最重要的事情。在OOP中,消息是第一顺序的设计关注点。我想进一步指出,在软件模块化的每一个层次上,它们都是第一顺序的关注。乌龟,是吗?

总结
我们将软件模块化的主要目的是使代码能够独立变化;当我们进行修改时,要将涟漪限制在本地化。(banq:限界上下文

我教授的模块化软件设计有四个原则,精心设计的模块:

  1. 有一个改变的理由
  2. 隐藏其内部工作原理
  3. 可以互换(例如,通过依赖性注入)。
  4. 拥有以客户端为中心的接口

例如,类的模块化和微服务的模块化之间的细节可能有所不同,但基本原则是相同的。