buy the book ribbon

3: 短暂的题外话:关于耦合与抽象

尊敬的读者,请允许我们简要地离题讨论一下抽象这个主题。我们已经多次谈到抽象。例如,仓库模式是持久存储的抽象。但是,是什么构成了一个好的抽象?我们对抽象有什么期望?它们与测试有什么关系?

提示

本章的代码位于 chapter_03_abstractions 分支 GitHub

git clone https://github.com/cosmicpython/code.git
git checkout chapter_03_abstractions

本书的一个关键主题,隐藏在各种花哨的模式之下,是我们如何使用简单的抽象来隐藏混乱的细节。当我们为了乐趣或在 kata[1] 中编写代码时,我们可以自由地玩弄想法,快速完成并积极重构。但在大型系统中,我们受到系统中其他地方做出的决策的约束。

当我们因为害怕破坏组件 B 而无法更改组件 A 时,我们说这些组件已经变得耦合。在局部,耦合是一件好事:它表明我们的代码协同工作,每个组件都支持其他组件,所有组件都像手表的齿轮一样各就各位。在行话中,我们说当耦合元素之间具有高内聚时,这种方式是有效的。

在全球范围内,耦合是一种麻烦:它增加了更改代码的风险和成本,有时甚至让我们感到无法进行任何更改。这就是泥球模式的问题:随着应用程序的增长,如果我们无法阻止没有内聚的元素之间的耦合,那么这种耦合会超线性地增长,直到我们再也无法有效地更改我们的系统。

我们可以通过抽象出细节(较低的耦合)来降低系统内的耦合程度(较高的耦合)。

apwp 0301
图 1. 较高的耦合
[ditaa, apwp_0301]
+--------+      +--------+
| System | ---> | System |
|   A    | ---> |   B    |
|        | ---> |        |
|        | ---> |        |
|        | ---> |        |
+--------+      +--------+
apwp 0302
图 2. 较低的耦合
[ditaa, apwp_0302]
+--------+                           +--------+
| System |      /-------------\      | System |
|   A    | ---> |             | ---> |   B    |
|        | ---> | Abstraction | ---> |        |
|        |      |             | ---> |        |
|        |      \-------------/      |        |
+--------+                           +--------+

在两个图中,我们都有一对子系统,其中一个依赖于另一个。在较高的耦合中,两者之间存在高度耦合;箭头的数量表示两者之间存在多种依赖关系。如果我们需要更改系统 B,则更改很可能波及到系统 A。

然而,在较低的耦合中,我们通过插入一个新的、更简单的抽象来降低了耦合程度。因为它更简单,所以系统 A 对抽象的依赖种类更少。抽象通过隐藏系统 B 所做的任何复杂细节来保护我们免受更改的影响——我们可以更改右侧的箭头,而无需更改左侧的箭头。

抽象状态有助于可测试性

让我们看一个例子。假设我们要编写代码来同步两个文件目录,我们将其称为源目录目标目录

  • 如果源目录中存在某个文件,但目标目录中不存在,则将该文件复制过去。

  • 如果源目录中存在某个文件,但其名称与目标目录中的名称不同,则重命名目标文件以匹配。

  • 如果目标目录中存在某个文件,但源目录中不存在,则将其删除。

我们的第一个和第三个要求很简单:我们可以直接比较两个路径列表。但是,我们的第二个要求比较棘手。为了检测重命名,我们必须检查文件的内容。为此,我们可以使用哈希函数,如 MD5 或 SHA-1。从文件生成 SHA-1 哈希的代码非常简单

哈希文件 (sync.py)
BLOCKSIZE = 65536


def hash_file(path):
    hasher = hashlib.sha1()
    with path.open("rb") as file:
        buf = file.read(BLOCKSIZE)
        while buf:
            hasher.update(buf)
            buf = file.read(BLOCKSIZE)
    return hasher.hexdigest()

现在我们需要编写做出决策的部分——业务逻辑,如果你愿意这么称呼它的话。

当我们必须从第一原理开始解决问题时,我们通常会尝试编写一个简单的实现,然后再重构为更好的设计。我们将在本书中始终使用这种方法,因为它正是我们在现实世界中编写代码的方式:从解决问题的最小部分开始,然后迭代地使解决方案更丰富、设计更好。

我们的第一个粗略方法看起来像这样

基本同步算法 (sync.py)
import hashlib
import os
import shutil
from pathlib import Path


def sync(source, dest):
    # Walk the source folder and build a dict of filenames and their hashes
    source_hashes = {}
    for folder, _, files in os.walk(source):
        for fn in files:
            source_hashes[hash_file(Path(folder) / fn)] = fn

    seen = set()  # Keep track of the files we've found in the target

    # Walk the target folder and get the filenames and hashes
    for folder, _, files in os.walk(dest):
        for fn in files:
            dest_path = Path(folder) / fn
            dest_hash = hash_file(dest_path)
            seen.add(dest_hash)

            # if there's a file in target that's not in source, delete it
            if dest_hash not in source_hashes:
                dest_path.remove()

            # if there's a file in target that has a different path in source,
            # move it to the correct path
            elif dest_hash in source_hashes and fn != source_hashes[dest_hash]:
                shutil.move(dest_path, Path(folder) / source_hashes[dest_hash])

    # for every file that appears in source but not target, copy the file to
    # the target
    for source_hash, fn in source_hashes.items():
        if source_hash not in seen:
            shutil.copy(Path(source) / fn, Path(dest) / fn)

太棒了!我们有一些代码,它看起来还可以,但在我们的硬盘上运行它之前,也许我们应该对其进行测试。我们如何进行这种测试呢?

一些端到端测试 (test_sync.py)
def test_when_a_file_exists_in_the_source_but_not_the_destination():
    try:
        source = tempfile.mkdtemp()
        dest = tempfile.mkdtemp()

        content = "I am a very useful file"
        (Path(source) / "my-file").write_text(content)

        sync(source, dest)

        expected_path = Path(dest) / "my-file"
        assert expected_path.exists()
        assert expected_path.read_text() == content

    finally:
        shutil.rmtree(source)
        shutil.rmtree(dest)


def test_when_a_file_has_been_renamed_in_the_source():
    try:
        source = tempfile.mkdtemp()
        dest = tempfile.mkdtemp()

        content = "I am a file that was renamed"
        source_path = Path(source) / "source-filename"
        old_dest_path = Path(dest) / "dest-filename"
        expected_dest_path = Path(dest) / "source-filename"
        source_path.write_text(content)
        old_dest_path.write_text(content)

        sync(source, dest)

        assert old_dest_path.exists() is False
        assert expected_dest_path.read_text() == content

    finally:
        shutil.rmtree(source)
        shutil.rmtree(dest)

哇哦,这两个简单的案例需要大量的设置!问题在于,我们的领域逻辑“找出两个目录之间的差异”与 I/O 代码紧密耦合。如果不调用 pathlibshutilhashlib 模块,我们就无法运行我们的差异算法。

麻烦的是,即使对于我们当前的要求,我们也没有编写足够的测试:当前的实现存在多个错误(例如,shutil.move() 是错误的)。获得良好的覆盖率并揭示这些错误意味着编写更多的测试,但如果它们都像前面的那些一样笨拙,那将会变得非常痛苦。

最重要的是,我们的代码的可扩展性不高。想象一下尝试实现一个 --dry-run 标志,让我们的代码只打印出它将要做的事情,而不是实际执行它。或者,如果我们想同步到远程服务器或云存储怎么办?

我们的高层代码与底层细节耦合在一起,这让生活变得艰难。随着我们考虑的场景变得更加复杂,我们的测试将变得更加笨拙。我们当然可以重构这些测试(例如,一些清理工作可以放入 pytest fixtures 中),但只要我们进行文件系统操作,它们就会保持缓慢,并且难以阅读和编写。

选择正确的抽象

我们应该怎么做才能重写我们的代码,使其更具可测试性?

首先,我们需要考虑我们的代码需要文件系统的哪些内容。通读代码,我们可以看到正在发生三件不同的事情。我们可以将这些视为代码具有的三个不同的职责

  1. 我们使用 os.walk 查询文件系统,并确定一系列路径的哈希值。这在源目录和目标目录的情况下都是相似的。

  2. 我们决定文件是新的、已重命名的还是冗余的。

  3. 我们复制、移动或删除文件以匹配源目录。

请记住,我们希望为每个职责找到简化的抽象。这将使我们能够隐藏混乱的细节,以便我们可以专注于有趣的逻辑。[2]

注意
在本章中,我们将一些棘手的代码重构为更具可测试性的结构,方法是识别需要完成的独立任务,并将每个任务分配给明确定义的参与者,这与 duckduckgo 示例类似。

对于步骤 1 和 2,我们已经直观地开始使用抽象,即哈希到路径的字典。您可能已经想过,“为什么不为目标文件夹以及源文件夹构建一个字典,然后我们只需比较两个字典?” 这似乎是抽象文件系统当前状态的一种好方法

source_files = {'hash1': 'path1', 'hash2': 'path2'}
dest_files = {'hash1': 'path1', 'hash2': 'pathX'}

从步骤 2 到步骤 3 呢?我们如何抽象出实际的移动/复制/删除文件系统交互?

我们将在这里应用一个技巧,我们将在本书的后面大规模地使用它。我们将把我们想做什么如何做它分开。我们将使我们的程序输出一个命令列表,如下所示

("COPY", "sourcepath", "destpath"),
("MOVE", "old", "new"),

现在我们可以编写仅使用两个文件系统字典作为输入的测试,并且我们期望输出表示操作的字符串元组列表。

我们不是说“给定这个实际的文件系统,当我运行我的函数时,检查发生了什么操作”,而是说“给定文件系统的这个抽象,将发生文件系统操作的什么抽象?”

测试中简化的输入和输出 (test_sync.py)

实现我们选择的抽象

这一切都很好,但是我们实际上如何编写这些新测试,以及我们如何更改我们的实现以使其全部工作?

我们的目标是隔离我们系统的聪明部分,并能够在不需要设置真实文件系统的情况下彻底测试它。我们将创建一个没有外部状态依赖项的代码“核心”,然后查看当我们给它来自外部世界的输入时,它会如何响应(这种方法被 Gary Bernhardt 描述为 Functional Core, Imperative Shell,或 FCIS)。

让我们首先拆分代码,将有状态的部分与逻辑部分分开。

我们的顶层函数几乎不包含任何逻辑;它只是一个命令式步骤序列:收集输入,调用我们的逻辑,应用输出

将我们的代码分成三部分 (sync.py)
def sync(source, dest):
    # imperative shell step 1, gather inputs
    source_hashes = read_paths_and_hashes(source)  #(1)
    dest_hashes = read_paths_and_hashes(dest)  #(1)

    # step 2: call functional core
    actions = determine_actions(source_hashes, dest_hashes, source, dest)  #(2)

    # imperative shell step 3, apply outputs
    for action, *paths in actions:
        if action == "COPY":
            shutil.copyfile(*paths)
        if action == "MOVE":
            shutil.move(*paths)
        if action == "DELETE":
            os.remove(paths[0])
  1. 这是我们分解出的第一个函数 read_paths_and_hashes(),它隔离了我们应用程序的 I/O 部分。

  2. 这是我们雕刻出功能核心、业务逻辑的地方。

现在,构建路径和哈希字典的代码非常容易编写

一个只做 I/O 的函数 (sync.py)
def read_paths_and_hashes(root):
    hashes = {}
    for folder, _, files in os.walk(root):
        for fn in files:
            hashes[hash_file(Path(folder) / fn)] = fn
    return hashes

determine_actions() 函数将是我们业务逻辑的核心,它说:“给定这两组哈希和文件名,我们应该复制/移动/删除什么?” 它接受简单的数据结构并返回简单的数据结构

一个只做业务逻辑的函数 (sync.py)
def determine_actions(source_hashes, dest_hashes, source_folder, dest_folder):
    for sha, filename in source_hashes.items():
        if sha not in dest_hashes:
            sourcepath = Path(source_folder) / filename
            destpath = Path(dest_folder) / filename
            yield "COPY", sourcepath, destpath

        elif dest_hashes[sha] != filename:
            olddestpath = Path(dest_folder) / dest_hashes[sha]
            newdestpath = Path(dest_folder) / filename
            yield "MOVE", olddestpath, newdestpath

    for sha, filename in dest_hashes.items():
        if sha not in source_hashes:
            yield "DELETE", dest_folder / filename

我们的测试现在直接作用于 determine_actions() 函数

外观更好的测试 (test_sync.py)
def test_when_a_file_exists_in_the_source_but_not_the_destination():
    source_hashes = {"hash1": "fn1"}
    dest_hashes = {}
    actions = determine_actions(source_hashes, dest_hashes, Path("/src"), Path("/dst"))
    assert list(actions) == [("COPY", Path("/src/fn1"), Path("/dst/fn1"))]


def test_when_a_file_has_been_renamed_in_the_source():
    source_hashes = {"hash1": "fn1"}
    dest_hashes = {"hash1": "fn2"}
    actions = determine_actions(source_hashes, dest_hashes, Path("/src"), Path("/dst"))
    assert list(actions) == [("MOVE", Path("/dst/fn2"), Path("/dst/fn1"))]

因为我们已经将程序的逻辑(用于识别更改的代码)与低级 I/O 细节解耦,所以我们可以轻松地测试我们代码的核心。

通过这种方法,我们已经从测试我们的主入口点函数 sync() 切换到测试较低级别的函数 determine_actions()。您可能会认为这很好,因为 sync() 现在非常简单。或者您可能会决定保留一些集成/验收测试来测试 sync()。但还有另一种选择,即修改 sync() 函数,使其可以进行单元测试端到端测试;Bob 称之为边缘到边缘测试的方法。

使用伪造对象和依赖注入进行边缘到边缘测试

当我们开始编写新系统时,我们通常首先关注核心逻辑,并使用直接单元测试来驱动它。但是,在某个时候,我们希望一起测试系统中更大的块。

我们可以回到我们的端到端测试,但这些测试仍然像以前一样难以编写和维护。相反,我们经常编写调用整个系统但伪造 I/O 的测试,有点像边缘到边缘

显式依赖项 (sync.py)
  1. 我们的顶层函数现在公开了一个新的依赖项,即 FileSystem

  2. 我们调用 filesystem.read() 来生成我们的文件字典。

  3. 我们调用 FileSystem 的 .copy().move().delete() 方法来应用我们检测到的更改。

提示
虽然我们正在使用依赖注入,但没有必要定义抽象基类或任何类型的显式接口。在本书中,我们经常展示 ABC,因为我们希望它们帮助您理解抽象是什么,但它们不是必需的。Python 的动态特性意味着我们始终可以依赖鸭子类型。

我们的 FileSystem 抽象的真实(默认)实现执行真实的 I/O

真实的依赖项 (sync.py)

但伪造的实现是我们选择的抽象的包装器,而不是执行真实的 I/O

使用 DI 的测试
  1. 我们使用我们选择的抽象来表示文件系统状态来初始化我们的伪造文件系统:哈希到路径的字典。

  2. 我们 FakeFileSystem 中的操作方法只是将记录附加到 .actions 列表中,以便我们稍后可以检查它。这意味着我们的测试替身既是“伪造对象”又是“间谍”。

所以现在我们的测试可以作用于真实的顶层 sync() 入口点,但它们使用 FakeFilesystem() 来做到这一点。就它们的设置和断言而言,它们最终看起来与我们在直接针对功能核心 determine_actions() 函数进行测试时编写的测试非常相似

使用 DI 的测试

这种方法的优点是我们的测试作用于与我们的生产代码使用的完全相同的函数。缺点是我们必须使我们的有状态组件显式化并将它们传递出去。Ruby on Rails 的创建者 David Heinemeier Hansson 曾将此著名地描述为“测试诱导的设计损坏”。

无论哪种情况,我们现在都可以着手修复我们实现中的所有错误;现在枚举所有边缘案例的测试要容易得多。

为什么不直接修补它?

此时,您可能会挠头并思考,“为什么你不直接使用 mock.patch 并节省自己的精力?”

我们在本书和我们的生产代码中都避免使用模拟。我们不打算发动圣战,但我们的直觉是,模拟框架,特别是猴子补丁,是一种代码异味。

相反,我们喜欢清楚地识别我们代码库中的职责,并将这些职责分离到小的、专注的对象中,这些对象很容易用测试替身替换。

注意
您可以在 [chapter_08_events_and_message_bus] 中看到一个示例,我们在其中 mock.patch() 掉了一个电子邮件发送模块,但最终我们在 [chapter_13_dependency_injection] 中将其替换为显式的依赖注入。

我们有三个密切相关的原因来支持我们的偏好

  • 修补掉您正在使用的依赖项可以对代码进行单元测试,但它无助于改进设计。使用 mock.patch 不会让您的代码与 --dry-run 标志一起工作,也不会帮助您针对 FTP 服务器运行。为此,您需要引入抽象。

  • 使用模拟的测试往往与代码库的实现细节更紧密地耦合。这是因为模拟测试验证事物之间的交互:我们是否使用正确的参数调用了 shutil.copy?根据我们的经验,代码和测试之间的这种耦合往往会使测试更加脆弱。

  • 过度使用模拟会导致复杂的测试套件,而这些测试套件无法解释代码。

注意
为可测试性而设计实际上意味着为可扩展性而设计。我们为了更清晰的设计而牺牲了一点复杂性,这种设计允许新的用例。
模拟与伪造对象;经典风格与伦敦学派 TDD

以下是模拟和伪造对象之间差异的简短且有些简单的定义

  • 模拟用于验证某物如何被使用;它们具有诸如 assert_called_once_with() 之类的方法。它们与伦敦学派 TDD 相关联。

  • 伪造对象是它们替换的事物的工作实现,但它们仅设计用于测试。它们在“现实生活中”不起作用;我们的内存仓库就是一个很好的例子。但是您可以使用它们来断言系统的最终状态,而不是沿途的行为,因此它们与经典风格的 TDD 相关联。

我们在这里稍微将模拟与间谍混淆,将伪造对象与存根混淆,您可以在 Martin Fowler 关于这个主题的经典文章 "Mocks Aren’t Stubs" 中阅读更长、更正确的答案。

unittest.mock 提供的 MagicMock 对象严格来说不是模拟,这可能也没有帮助;如果有什么区别的话,它们是间谍。但它们也经常用作存根或哑对象。好了,我们保证我们现在完成了测试替身术语的吹毛求疵。

伦敦学派与经典风格的 TDD 呢?您可以在我们刚刚引用的 Martin Fowler 的文章以及 Software Engineering Stack Exchange 站点 上阅读有关这两者的更多信息,但在本书中,我们非常坚定地站在经典主义阵营中。我们喜欢围绕状态构建我们的测试,无论是在设置还是在断言中,并且我们喜欢在尽可能高的抽象级别上工作,而不是对中间协作者的行为进行检查。[3]

[kinds_of_tests] 中阅读更多相关内容。

我们将 TDD 视为首先是一种设计实践,其次是一种测试实践。测试充当我们设计选择的记录,并在我们长期缺席后返回代码时帮助我们解释系统。

使用过多模拟的测试会被设置代码淹没,而这些设置代码隐藏了我们关心的故事。

Steve Freeman 在他的演讲 "Test-Driven Development" 中有一个过度模拟测试的精彩示例。您还应该查看我们的尊敬的技术审阅员 Ed Jung 的 PyCon 演讲 "Mocking and Patching Pitfalls",该演讲也讨论了模拟及其替代方案。

在我们推荐演讲的同时,请查看 Brandon Rhodes 在 "Hoisting Your I/O" 中的精彩演讲。它实际上不是关于模拟,而是关于将业务逻辑与 I/O 解耦的总体问题,在其中他使用了一个非常简单的说明性示例。

提示
在本章中,我们花费了大量时间用单元测试替换端到端测试。这并不意味着我们认为您永远不应该使用 E2E 测试!在本书中,我们将展示一些技术,使您能够获得一个体面的测试金字塔,其中包含尽可能多的单元测试,以及让您感到自信所需的最小数量的 E2E 测试。继续阅读 [types_of_test_rules_of_thumb] 以了解更多详细信息。
那么我们在本书中使用哪种方法?函数式组合还是面向对象组合?

两者都有。我们的领域模型完全没有依赖项和副作用,所以那是我们的功能核心。我们在其周围构建的服务层(在 [chapter_04_service_layer] 中)允许我们边缘到边缘地驱动系统,并且我们使用依赖注入为这些服务提供有状态的组件,因此我们仍然可以对它们进行单元测试。

有关使我们的依赖注入更显式和集中的更多探索,请参阅 [chapter_13_dependency_injection]

总结

我们将在本书中一遍又一遍地看到这个想法:我们可以通过简化业务逻辑和混乱的 I/O 之间的接口,使我们的系统更易于测试和维护。找到正确的抽象是棘手的,但这里有一些启发式方法和要问自己的问题

  • 我可以选择一个熟悉的 Python 数据结构来表示混乱系统的状态,然后尝试想象一个可以返回该状态的单一函数吗?

  • 什么如何分开:我可以使用数据结构或 DSL 来表示我希望发生的外部效果,而与我计划如何实现它们无关吗?

  • 我可以在哪里在我的系统之间划一条线,我可以在哪里雕刻出一个 接缝 来插入该抽象?

  • 将事物划分为具有不同职责的组件的明智方法是什么?我可以使哪些隐式概念显式化?

  • 依赖项是什么,核心业务逻辑是什么?

熟能生巧!现在回到我们的常规编程……


1. 代码 kata 是一个小的、受限的编程挑战,通常用于练习 TDD。请参阅 Peter Provost 的 "Kata—学习 TDD 的唯一方法"
2. 如果您习惯于从接口的角度思考,那么这就是我们在这里尝试定义的。
3. 这并不是说我们认为伦敦学派的人是错误的。一些非常聪明的人就是那样工作的。只是我们不习惯而已。