buy the book ribbon

2: Repository Pattern2:仓库模式

It’s time to make good on our promise to use the dependency inversion principle as a way of decoupling our core logic from infrastructural concerns.是时候兑现我们的承诺了:利用依赖倒置原则,将核心逻辑与基础设施关注点解耦。

We’ll introduce the Repository pattern, a simplifying abstraction over data storage, allowing us to decouple our model layer from the data layer. We’ll present a concrete example of how this simplifying abstraction makes our system more testable by hiding the complexities of the database.我们将引入仓库模式(Repository pattern)。这是一种简化数据存储的抽象,能够将领域层与数据层解耦。我们将通过一个具体的例子展示这种抽象如何通过隐藏数据库的复杂性,从而提升系统的可测试性。

Before and after the Repository pattern shows a little preview of what we’re going to build: a Repository object that sits between our domain model and the database.“仓库模式的前后对比”展示了我们将要构建的内容预览:一个位于领域模型和数据库之间的仓库对象。

apwp 0201
Figure 1. Before and after the Repository pattern图 1. 仓库模式的前后对比
Tip

The code for this chapter is in the chapter_02_repository branch on GitHub.

git clone https://github.com/cosmicpython/code.git
cd code
git checkout chapter_02_repository
# or to code along, checkout the previous chapter:
git checkout chapter_01_domain_model

Persisting Our Domain Model持久化领域模型

In [chapter_01_domain_model] we built a simple domain model that can allocate orders to batches of stock. It’s easy for us to write tests against this code because there aren’t any dependencies or infrastructure to set up. If we needed to run a database or an API and create test data, our tests would be harder to write and maintain.在 [chapter_01_domain_model] 中,我们构建了一个简单的领域模型,能够将订单分配给库存批次。由于没有任何依赖项或基础设施需要配置,为这段代码编写测试非常容易。如果我们必须运行数据库或 API 并创建测试数据,测试的编写和维护难度就会增加。

Sadly, at some point we’ll need to put our perfect little model in the hands of users and contend with the real world of spreadsheets and web browsers and race conditions. For the next few chapters we’re going to look at how we can connect our idealized domain model to external state.遗憾的是,在某个阶段,我们必须将这个完美的模型交付给用户,并面对电子表格、网页浏览器和竞态条件等真实世界的问题。在接下来的几章中,我们将探讨如何将理想化的领域模型与外部状态连接起来。

We expect to be working in an agile manner, so our priority is to get to a minimum viable product as quickly as possible. In our case, that’s going to be a web API. In a real project, you might dive straight in with some end-to-end tests and start plugging in a web framework, test-driving things outside-in.我们希望以敏捷的方式工作,因此首要任务是尽快交付最小可行性产品(MVP)。对我们而言,这就是一个 Web API。在实际项目中,你可能会直接通过端到端测试上手,并开始接入 Web 框架,采用由外而内(outside-in)的方式进行驱动开发。

But we know that, no matter what, we’re going to need some form of persistent storage, and this is a textbook, so we can allow ourselves a tiny bit more bottom-up development and start to think about storage and databases.但我们深知,无论如何,最终都需要某种形式的持久化存储。既然这是一本教科书,我们允许自己进行一点自底向上的开发,先从存储和数据库开始思考。

Some Pseudocode: What Are We Going to Need?一些伪代码:我们需要什么?

When we build our first API endpoint, we know we’re going to have some code that looks more or less like the following.当我们构建第一个 API 端点时,我们知道代码大致会是下面这个样子。

What our first API endpoint will look like第一个 API 端点的样子
Note
We’ve used Flask because it’s lightweight, but you don’t need to be a Flask user to understand this book. In fact, we’ll show you how to make your choice of framework a minor detail.

We’ll need a way to retrieve batch info from the database and instantiate our domain model objects from it, and we’ll also need a way of saving them back to the database.我们需要一种从数据库检索批次信息并据此实例化领域模型对象的方法,同时也需要一种将其保存回数据库的方法。

What? Oh, "gubbins" is a British word for "stuff." You can just ignore that. It’s pseudocode, OK?什么?哦,“gubbins”是一个英国俚语,意为“东西”。不用管它。这是伪代码,懂了吗?

Applying the DIP to Data Access将 DIP 应用于数据访问

As mentioned in the introduction, a layered architecture is a common approach to structuring a system that has a UI, some logic, and a database (see Layered architecture).正如引言中所述,分层架构是构建包含 UI、逻辑和数据库的系统的一种常用方法(参见“分层架构”)。

apwp 0202
Figure 2. Layered architecture图 2. 分层架构

Django’s Model-View-Template structure is closely related, as is Model-View-Controller (MVC). In any case, the aim is to keep the layers separate (which is a good thing), and to have each layer depend only on the one below it.Django 的模型-视图-模板(MVT)结构与之密切相关,模型-视图-控制器(MVC)也是如此。无论哪种方式,目的都是保持各层分离(这是好事),并确保每一层仅依赖于其下方的层。

But we want our domain model to have no dependencies whatsoever.[1] We don’t want infrastructure concerns bleeding over into our domain model and slowing our unit tests or our ability to make changes.但我们希望领域模型完全没有任何依赖。[1] 我们不希望基础设施的关注点渗透到领域模型中,从而拖慢单元测试的速度或影响我们进行修改的能力。

Instead, as discussed in the introduction, we’ll think of our model as being on the "inside," and dependencies flowing inward to it; this is what people sometimes call onion architecture (see Onion architecture).相反,正如引言中所讨论的,我们将模型视为处于“内部”,而依赖项则向内流向它;这就是人们有时所说的洋葱架构(参见“洋葱架构”)。

apwp 0203
Figure 3. Onion architecture图 3. 洋葱架构
[ditaa, apwp_0203]
+------------------------+
|   Presentation Layer   |
+------------------------+
           |
           V
+--------------------------------------------------+
|                  Domain Model                    |
+--------------------------------------------------+
                                        ^
                                        |
                             +---------------------+
                             |    Database Layer   |
                             +---------------------+
Is This Ports and Adapters?这就是端口和适配器吗?

If you’ve been reading about architectural patterns, you may be asking yourself questions like this:如果你阅读过架构模式相关资料,可能会产生以下疑问:

Is this ports and adapters? Or is it hexagonal architecture? Is that the same as onion architecture? What about the clean architecture? What’s a port, and what’s an adapter? Why do you people have so many words for the same thing?这是端口和适配器吗?还是六边形架构?这和洋葱架构是一回事吗?整洁架构(Clean Architecture)又是什么?什么是端口,什么是适配器?为什么你们对同一个概念有这么多叫法?

Although some people like to nitpick over the differences, all these are pretty much names for the same thing, and they all boil down to the dependency inversion principle: high-level modules (the domain) should not depend on low-level ones (the infrastructure).[2]虽然有些人喜欢在差异上钻牛角尖,但这些基本上都是同一个概念的名称,归根结底都是依赖倒置原则:高层模块(领域)不应依赖于底层模块(基础设施)。[2]

We’ll get into some of the nitty-gritty around "depending on abstractions," and whether there is a Pythonic equivalent of interfaces, later in the book. See also What Is a Port and What Is an Adapter, in Python?.我们将在本书后续章节中深入探讨“依赖于抽象”的一些细节,以及 Python 中是否有接口的等价物。另请参阅“Python 中的端口和适配器是什么?”。

Reminder: Our Model提醒:我们的模型

Let’s remind ourselves of our domain model (see Our model): an allocation is the concept of linking an OrderLine to a Batch. We’re storing the allocations as a collection on our Batch object.让我们回顾一下我们的领域模型(参见“我们的模型”):分配(allocation)是将订单行(OrderLine)链接到批次(Batch)的概念。我们将分配作为 Batch 对象上的一个集合进行存储。

apwp 0103
Figure 4. Our model

Let’s see how we might translate this to a relational database.让我们看看如何将其转换为关系型数据库。

The "Normal" ORM Way: Model Depends on ORM“常规”ORM 方式:模型依赖于 ORM

These days, it’s unlikely that your team members are hand-rolling their own SQL queries. Instead, you’re almost certainly using some kind of framework to generate SQL for you based on your model objects.如今,团队成员不太可能手动编写 SQL 查询。相反,你几乎肯定是在使用某种框架,根据模型对象为你生成 SQL。

These frameworks are called object-relational mappers (ORMs) because they exist to bridge the conceptual gap between the world of objects and domain modeling and the world of databases and relational algebra.这些框架被称为对象关系映射器(ORM),因为它们的存在是为了弥合对象与领域建模世界与数据库和关系代数世界之间的概念鸿沟。

The most important thing an ORM gives us is persistence ignorance: the idea that our fancy domain model doesn’t need to know anything about how data is loaded or persisted. This helps keep our domain clean of direct dependencies on particular database technologies.[3]ORM 提供给我们最重要的一点是持久化无知(persistence ignorance):即我们的精美领域模型不需要知道数据是如何加载或持久化的。这有助于保持领域逻辑纯净,避免直接依赖特定的数据库技术。[3]

But if you follow the typical SQLAlchemy tutorial, you’ll end up with something like this:但如果你遵循典型的 SQLAlchemy 教程,最终会得到类似这样的代码:

SQLAlchemy "declarative" syntax, model depends on ORM (orm.py)SQLAlchemy “声明式”语法,模型依赖于 ORM (orm.py)

You don’t need to understand SQLAlchemy to see that our pristine model is now full of dependencies on the ORM and is starting to look ugly as hell besides. Can we really say this model is ignorant of the database? How can it be separate from storage concerns when our model properties are directly coupled to database columns?你不需要精通 SQLAlchemy 也能看出,我们原始的模型现在充满了对 ORM 的依赖,而且看起来丑陋不堪。我们真的能说这个模型对数据库一无所知吗?当模型属性直接与数据库列耦合时,它怎么可能与存储关注点分离呢?

Django’s ORM Is Essentially the Same, but More RestrictiveDjango 的 ORM 本质上是一样的,但限制更多

If you’re more used to Django, the preceding "declarative" SQLAlchemy snippet translates to something like this:如果你更习惯 Django,前面的 SQLAlchemy “声明式”片段可以转换成类似这样的代码:

Django ORM exampleDjango ORM 示例

The point is the same—​our model classes inherit directly from ORM classes, so our model depends on the ORM. We want it to be the other way around.重点是一样的——我们的模型类直接继承自 ORM 类,因此模型依赖于 ORM。我们希望反过来。

Django doesn’t provide an equivalent for SQLAlchemy’s classical mapper, but see [appendix_django] for examples of how to apply dependency inversion and the Repository pattern to Django.Django 没有提供 SQLAlchemy 经典映射器的等价物,但请参阅 [appendix_django] 获取如何在 Django 中应用依赖倒置和仓库模式的示例。

Inverting the Dependency: ORM Depends on Model反转依赖:ORM 依赖于模型

Well, thankfully, that’s not the only way to use SQLAlchemy. The alternative is to define your schema separately, and to define an explicit mapper for how to convert between the schema and our domain model, what SQLAlchemy calls a classical mapping:谢天谢地,这不是使用 SQLAlchemy 的唯一方式。另一种选择是将模式(schema)单独定义,并定义一个显式映射器来处理模式与领域模型之间的转换,这就是 SQLAlchemy 所称的经典映射(classical mapping):

Explicit ORM mapping with SQLAlchemy Table objects (orm.py)使用 SQLAlchemy Table 对象的显式 ORM 映射 (orm.py)
from sqlalchemy.orm import mapper, relationship

import model  #(1)


metadata = MetaData()

order_lines = Table(  #(2)
    "order_lines",
    metadata,
    Column("id", Integer, primary_key=True, autoincrement=True),
    Column("sku", String(255)),
    Column("qty", Integer, nullable=False),
    Column("orderid", String(255)),
)

...

def start_mappers():
    lines_mapper = mapper(model.OrderLine, order_lines)  #(3)
  1. The ORM imports (or "depends on" or "knows about") the domain model, and not the other way around.ORM 导入(或“依赖于”或“了解”)领域模型,而不是反过来。

  2. We define our database tables and columns by using SQLAlchemy’s abstractions.[4]我们使用 SQLAlchemy 的抽象来定义数据库表和列。[4]

  3. When we call the mapper function, SQLAlchemy does its magic to bind our domain model classes to the various tables we’ve defined.当我们调用映射器函数时,SQLAlchemy 会施展魔法,将我们的领域模型类绑定到我们定义的各个表上。

The end result will be that, if we call start_mappers, we will be able to easily load and save domain model instances from and to the database. But if we never call that function, our domain model classes stay blissfully unaware of the database.最终结果是,如果我们调用 start_mappers,就可以轻松地从数据库加载领域模型实例并保存到数据库。但如果我们从不调用该函数,我们的领域模型类将对数据库一无所知。

This gives us all the benefits of SQLAlchemy, including the ability to use alembic for migrations, and the ability to transparently query using our domain classes, as we’ll see.这赋予了我们 SQLAlchemy 的所有好处,包括使用 alembic 进行迁移的能力,以及正如我们将看到的,使用领域类进行透明查询的能力。

When you’re first trying to build your ORM config, it can be useful to write tests for it, as in the following example:当你初次尝试构建 ORM 配置时,为它编写测试会很有用,如下例所示:

Testing the ORM directly (throwaway tests) (test_orm.py)直接测试 ORM(一次性测试)(test_orm.py)
def test_orderline_mapper_can_load_lines(session):  #(1)
    session.execute(
        "INSERT INTO order_lines (orderid, sku, qty) VALUES "
        '("order1", "RED-CHAIR", 12),'
        '("order1", "RED-TABLE", 13),'
        '("order2", "BLUE-LIPSTICK", 14)'
    )
    expected = [
        model.OrderLine("order1", "RED-CHAIR", 12),
        model.OrderLine("order1", "RED-TABLE", 13),
        model.OrderLine("order2", "BLUE-LIPSTICK", 14),
    ]
    assert session.query(model.OrderLine).all() == expected


def test_orderline_mapper_can_save_lines(session):
    new_line = model.OrderLine("order1", "DECORATIVE-WIDGET", 12)
    session.add(new_line)
    session.commit()

    rows = list(session.execute('SELECT orderid, sku, qty FROM "order_lines"'))
    assert rows == [("order1", "DECORATIVE-WIDGET", 12)]
  1. If you haven’t used pytest, the session argument to this test needs explaining. You don’t need to worry about the details of pytest or its fixtures for the purposes of this book, but the short explanation is that you can define common dependencies for your tests as "fixtures," and pytest will inject them to the tests that need them by looking at their function arguments. In this case, it’s a SQLAlchemy database session. 如果你没用过 pytest,这个测试的 session 参数需要解释一下。本书中你无需担心 pytest 及其 fixture 的细节,简而言之,你可以将测试的通用依赖定义为“fixture”,pytest 会通过查看函数参数,将它们注入到需要的测试中。在本例中,它是一个 SQLAlchemy 数据库会话。

You probably wouldn’t keep these tests around—​as you’ll see shortly, once you’ve taken the step of inverting the dependency of ORM and domain model, it’s only a small additional step to implement another abstraction called the Repository pattern, which will be easier to write tests against and will provide a simple interface for faking out later in tests.你可能不会保留这些测试——正如你很快会看到的,一旦你完成了反转 ORM 和领域模型依赖这一步,再实现另一个名为“仓库模式”的抽象就只需一小步了。它将更容易编写测试,并提供一个简单的接口,以便稍后在测试中进行模拟。

But we’ve already achieved our objective of inverting the traditional dependency: the domain model stays "pure" and free from infrastructure concerns. We could throw away SQLAlchemy and use a different ORM, or a totally different persistence system, and the domain model doesn’t need to change at all.但我们已经实现了反转传统依赖的目标:领域模型保持“纯净”,不受基础设施关注点的影响。我们可以丢弃 SQLAlchemy 并使用不同的 ORM,甚至完全不同的持久化系统,而领域模型完全不需要更改。

Depending on what you’re doing in your domain model, and especially if you stray far from the OO paradigm, you may find it increasingly hard to get the ORM to produce the exact behavior you need, and you may need to modify your domain model.[5] As so often happens with architectural decisions, you’ll need to consider a trade-off. As the Zen of Python says, "Practicality beats purity!"根据你在领域模型中所做的工作,特别是如果你偏离了面向对象范式,你可能会发现让 ORM 产生你需要的精确行为变得越来越困难,你可能需要修改领域模型。[5] 正如架构决策中常发生的那样,你需要权衡取舍。正如 Python 之禅所言:“实用至上,纯粹次之!”

At this point, though, our API endpoint might look something like the following, and we could get it to work just fine:不过此时,我们的 API 端点可能看起来像下面这样,并且可以正常工作:

Using SQLAlchemy directly in our API endpoint在 API 端点中直接使用 SQLAlchemy

Introducing the Repository Pattern引入仓库模式

The Repository pattern is an abstraction over persistent storage. It hides the boring details of data access by pretending that all of our data is in memory.仓库模式是对持久化存储的一种抽象。它通过假装所有数据都在内存中,隐藏了数据访问的繁琐细节。

If we had infinite memory in our laptops, we’d have no need for clumsy databases. Instead, we could just use our objects whenever we liked. What would that look like?如果我们的笔记本电脑有无限内存,我们就根本不需要笨拙的数据库。相反,我们可以随时使用我们的对象。那会是什么样子呢?

You have to get your data from somewhere数据总得从某个地方获取

Even though our objects are in memory, we need to put them somewhere so we can find them again. Our in-memory data would let us add new objects, just like a list or a set. Because the objects are in memory, we never need to call a .save() method; we just fetch the object we care about and modify it in memory.即使我们的对象在内存中,也需要把它们放在某个地方以便再次找到。内存数据允许我们像添加列表或集合一样添加新对象。因为对象在内存中,我们永远不需要调用 .save() 方法;我们只需获取关心的对象并在内存中修改它即可。

The Repository in the Abstract仓库的抽象

The simplest repository has just two methods: add() to put a new item in the repository, and get() to return a previously added item.[6] We stick rigidly to using these methods for data access in our domain and our service layer. This self-imposed simplicity stops us from coupling our domain model to the database.最简单的仓库只有两个方法:add() 用于将新项放入仓库,get() 用于返回之前添加的项。[6] 我们在领域层和服务层中严格坚持使用这些方法进行数据访问。这种自加的简单性阻止了我们将领域模型与数据库耦合。

Here’s what an abstract base class (ABC) for our repository would look like:这是我们仓库的抽象基类(ABC)的样子:

The simplest possible repository (repository.py)
class AbstractRepository(abc.ABC):
    @abc.abstractmethod  #(1)
    def add(self, batch: model.Batch):
        raise NotImplementedError  #(2)

    @abc.abstractmethod
    def get(self, reference) -> model.Batch:
        raise NotImplementedError
  1. Python tip: @abc.abstractmethod is one of the only things that makes ABCs actually "work" in Python. Python will refuse to let you instantiate a class that does not implement all the abstractmethods defined in its parent class.[7] Python 小贴士:@abc.abstractmethod 是让 ABC 在 Python 中真正“起作用”的少数几个要素之一。Python 会拒绝让你实例化一个未实现父类中定义的所有抽象方法的类。[7]

  2. raise NotImplementedError is nice, but it’s neither necessary nor sufficient. In fact, your abstract methods can have real behavior that subclasses can call out to, if you really want.raise NotImplementedError 很好,但它既非必要也不充分。事实上,如果你真的想,你的抽象方法可以包含子类可以调用的真实行为。

Abstract Base Classes, Duck Typing, and Protocols抽象基类、鸭子类型和协议

We’re using abstract base classes in this book for didactic reasons: we hope they help explain what the interface of the repository abstraction is.我们在本书中使用抽象基类是出于教学目的:我们希望它们有助于解释仓库抽象的接口是什么。

In real life, we’ve sometimes found ourselves deleting ABCs from our production code, because Python makes it too easy to ignore them, and they end up unmaintained and, at worst, misleading. In practice we often just rely on Python’s duck typing to enable abstractions. To a Pythonista, a repository is any object that has add(thing) and get(id) methods.在现实生活中,我们有时会从生产代码中删除 ABC,因为 Python 让忽略它们变得太容易了,导致它们最终无人维护,甚至在最坏的情况下产生误导。在实践中,我们通常只依赖 Python 的鸭子类型来实现抽象。对于 Python 开发者来说,任何拥有 add(thing) 和 get(id) 方法的对象都是一个仓库。

An alternative to look into is PEP 544 protocols. These give you typing without the possibility of inheritance, which "prefer composition over inheritance" fans will particularly like.可以研究的另一种选择是 PEP 544 协议(Protocols)。它们为你提供了无需继承的类型检查,这对于“优先使用组合而非继承”的拥护者来说特别受欢迎。

What Is the Trade-Off?权衡是什么?

You know they say economists know the price of everything and the value of nothing? Well, programmers know the benefits of everything and the trade-offs of nothing.你知道人们常说经济学家知道每样东西的价格却不知道其价值吗?程序员也是,知道每样东西的好处,却不知道其权衡。

— Rich Hickey — Rich Hickey

Whenever we introduce an architectural pattern in this book, we’ll always ask, "What do we get for this? And what does it cost us?"每当我们在本书中引入架构模式时,我们总是会问:“我们从中得到了什么?它又付出了什么代价?”

Usually, at the very least, we’ll be introducing an extra layer of abstraction, and although we may hope it will reduce complexity overall, it does add complexity locally, and it has a cost in terms of the raw numbers of moving parts and ongoing maintenance.通常,至少我们会引入一个额外的抽象层,虽然我们希望它能降低整体复杂性,但它确实增加了局部的复杂性,并且在活动部件的数量和持续维护方面付出了代价。

The Repository pattern is probably one of the easiest choices in the book, though, if you’re already heading down the DDD and dependency inversion route. As far as our code is concerned, we’re really just swapping the SQLAlchemy abstraction (session.query(Batch)) for a different one (batches_repo.get) that we designed.不过,如果你已经走上了 DDD 和依赖倒置的道路,仓库模式可能是本书中最容易做出的选择。就我们的代码而言,我们实际上只是将 SQLAlchemy 的抽象 (session.query(Batch)) 换成了我们设计的另一个抽象 (batches_repo.get)。

We will have to write a few lines of code in our repository class each time we add a new domain object that we want to retrieve, but in return we get a simple abstraction over our storage layer, which we control. The Repository pattern would make it easy to make fundamental changes to the way we store things (see [appendix_csvs]), and as we’ll see, it is easy to fake out for unit tests.每当我们添加一个想要检索的新领域对象时,都必须在仓库类中编写几行代码,但作为回报,我们获得了一个由我们控制的、针对存储层的简单抽象。仓库模式使得对存储方式进行根本性更改变得容易(参见 [appendix_csvs]),而且正如我们将看到的,它非常易于在单元测试中进行模拟。

In addition, the Repository pattern is so common in the DDD world that, if you do collaborate with programmers who have come to Python from the Java and C# worlds, they’re likely to recognize it. Repository pattern illustrates the pattern.此外,仓库模式在 DDD 世界中非常普遍,因此如果你与从 Java 和 C# 世界转到 Python 的程序员合作,他们很可能会认出它。“仓库模式”说明了这个模式。

apwp 0205
Figure 5. Repository pattern图 5. 仓库模式
[ditaa, apwp_0205]
  +-----------------------------+
  |      Application Layer      |
  +-----------------------------+
                 |^
                 ||          /------------------\
                 ||----------|   Domain Model   |
                 ||          |      Objects     |
                 ||          \------------------/
                 V|
  +------------------------------+
  |          Repository          |
  +------------------------------+
                 |
                 V
  +------------------------------+
  |        Database Layer        |
  +------------------------------+

As always, we start with a test. This would probably be classified as an integration test, since we’re checking that our code (the repository) is correctly integrated with the database; hence, the tests tend to mix raw SQL with calls and assertions on our own code.像往常一样,我们从测试开始。这通常会被归类为集成测试,因为我们正在检查我们的代码(仓库)是否与数据库正确集成;因此,测试往往会将原始 SQL 与我们自己代码的调用和断言混合在一起。

Tip
Unlike the ORM tests from earlier, these tests are good candidates for staying part of your codebase longer term, particularly if any parts of your domain model mean the object-relational map is nontrivial.
Repository test for saving an object (test_repository.py)保存对象的仓库测试 (test_repository.py)
def test_repository_can_save_a_batch(session):
    batch = model.Batch("batch1", "RUSTY-SOAPDISH", 100, eta=None)

    repo = repository.SqlAlchemyRepository(session)
    repo.add(batch)  #(1)
    session.commit()  #(2)

    rows = session.execute(  #(3)
        'SELECT reference, sku, _purchased_quantity, eta FROM "batches"'
    )
    assert list(rows) == [("batch1", "RUSTY-SOAPDISH", 100, None)]
  1. repo.add() is the method under test here.repo.add() 是此处被测试的方法。

  2. We keep the .commit() outside of the repository and make it the responsibility of the caller. There are pros and cons for this; some of our reasons will become clearer when we get to [chapter_06_uow].我们将 .commit() 放在仓库之外,并将其作为调用者的责任。这样做有利有弊;当我们到达 [chapter_06_uow] 时,我们的一些理由会变得更加清晰。

  3. We use the raw SQL to verify that the right data has been saved.我们使用原始 SQL 来验证正确的数据已被保存。

The next test involves retrieving batches and allocations, so it’s more complex:下一个测试涉及检索批次和分配,因此更为复杂:

Repository test for retrieving a complex object (test_repository.py)检索复杂对象的仓库测试 (test_repository.py)
def insert_order_line(session):
    session.execute(  #(1)
        "INSERT INTO order_lines (orderid, sku, qty)"
        ' VALUES ("order1", "GENERIC-SOFA", 12)'
    )
    [[orderline_id]] = session.execute(
        "SELECT id FROM order_lines WHERE orderid=:orderid AND sku=:sku",
        dict(orderid="order1", sku="GENERIC-SOFA"),
    )
    return orderline_id


def insert_batch(session, batch_id):  #(2)
    ...

def test_repository_can_retrieve_a_batch_with_allocations(session):
    orderline_id = insert_order_line(session)
    batch1_id = insert_batch(session, "batch1")
    insert_batch(session, "batch2")
    insert_allocation(session, orderline_id, batch1_id)  #(2)

    repo = repository.SqlAlchemyRepository(session)
    retrieved = repo.get("batch1")

    expected = model.Batch("batch1", "GENERIC-SOFA", 100, eta=None)
    assert retrieved == expected  # Batch.__eq__ only compares reference  #(3)
    assert retrieved.sku == expected.sku  #(4)
    assert retrieved._purchased_quantity == expected._purchased_quantity
    assert retrieved._allocations == {  #(4)
        model.OrderLine("order1", "GENERIC-SOFA", 12),
    }
  1. This tests the read side, so the raw SQL is preparing data to be read by the repo.get().这测试的是读取端,因此原始 SQL 正在准备数据供 repo.get() 读取。

  2. We’ll spare you the details of insert_batch and insert_allocation; the point is to create a couple of batches, and, for the batch we’re interested in, to have one existing order line allocated to it.我们就不详细介绍 insert_batch 和 insert_allocation 了;重点是创建几个批次,并确保对于我们感兴趣的批次,有一个现有的订单行分配给它。

  3. And that’s what we verify here. The first assert == checks that the types match, and that the reference is the same (because, as you remember, Batch is an entity, and we have a custom __eq__ for it).这就是我们在此验证的内容。第一个 assert == 检查类型是否匹配,以及引用是否相同(因为你还记得,Batch 是一个实体,我们为它自定义了 __eq__)。

  4. So we also explicitly check on its major attributes, including ._allocations, which is a Python set of OrderLine value objects.因此,我们也显式检查了它的主要属性,包括 ._allocations,这是一个 OrderLine 值对象的 Python 集合。

Whether or not you painstakingly write tests for every model is a judgment call. Once you have one class tested for create/modify/save, you might be happy to go on and do the others with a minimal round-trip test, or even nothing at all, if they all follow a similar pattern. In our case, the ORM config that sets up the ._allocations set is a little complex, so it merited a specific test.是否为每个模型都一丝不苟地编写测试是一个判断问题。一旦你测试了一个类的创建/修改/保存,如果它们都遵循类似的模式,你可能很乐意继续用最少的往返测试来完成其他类,甚至根本不做测试。在我们的例子中,配置 ._allocations 集合的 ORM 配置有点复杂,因此值得进行专门测试。

You end up with something like this:你最终会得到类似这样的代码:

A typical repository (repository.py)典型的仓库 (repository.py)
class SqlAlchemyRepository(AbstractRepository):
    def __init__(self, session):
        self.session = session

    def add(self, batch):
        self.session.add(batch)

    def get(self, reference):
        return self.session.query(model.Batch).filter_by(reference=reference).one()

    def list(self):
        return self.session.query(model.Batch).all()

And now our Flask endpoint might look something like the following:现在我们的 Flask 端点可能看起来像下面这样:

Using our repository directly in our API endpoint在 API 端点中直接使用我们的仓库
Exercise for the Reader读者练习

We bumped into a friend at a DDD conference the other day who said, "I haven’t used an ORM in 10 years." The Repository pattern and an ORM both act as abstractions in front of raw SQL, so using one behind the other isn’t really necessary. Why not have a go at implementing our repository without using the ORM? You’ll find the code on GitHub.我们前几天在 DDD 会议上遇到一位朋友,他说:“我 10 年没用过 ORM 了。”仓库模式和 ORM 都是原始 SQL 前面的抽象,因此在一个之上使用另一个并非绝对必要。为什么不试着在不使用 ORM 的情况下实现我们的仓库呢?你可以在 GitHub 上找到代码。

We’ve left the repository tests, but figuring out what SQL to write is up to you. Perhaps it’ll be harder than you think; perhaps it’ll be easier. But the nice thing is, the rest of your application just doesn’t care.我们保留了仓库测试,但编写什么 SQL 取决于你。也许它比你想象的要难;也许要简单得多。但好消息是,你的应用程序其余部分根本不在乎。

Building a Fake Repository for Tests Is Now Trivial!为测试构建伪造仓库现在变得轻而易举!

Here’s one of the biggest benefits of the Repository pattern:这是仓库模式最大的好处之一:

A simple fake repository using a set (repository.py)使用集合的简单伪造仓库 (repository.py)

Because it’s a simple wrapper around a set, all the methods are one-liners.因为它只是对集合的一个简单包装,所以所有方法都是单行代码。

Using a fake repo in tests is really easy, and we have a simple abstraction that’s easy to use and reason about:在测试中使用伪造仓库真的非常容易,而且我们拥有一个简单、易于使用和推理的抽象:

Example usage of fake repository (test_api.py)

You’ll see this fake in action in the next chapter.你将在下一章中看到这个伪造仓库的实际应用。

Tip
Building fakes for your abstractions is an excellent way to get design feedback: if it’s hard to fake, the abstraction is probably too complicated.

What Is a Port and What Is an Adapter, in Python?Python 中的端口和适配器是什么?

We don’t want to dwell on the terminology too much here because the main thing we want to focus on is dependency inversion, and the specifics of the technique you use don’t matter too much. Also, we’re aware that different people use slightly different definitions.我们不想在这里过多纠结于术语,因为我们要关注的核心是依赖倒置,而你所使用的具体技术并不重要。此外,我们也知道不同的人使用略有不同的定义。

Ports and adapters came out of the OO world, and the definition we hold onto is that the port is the interface between our application and whatever it is we wish to abstract away, and the adapter is the implementation behind that interface or abstraction.端口和适配器源于面向对象世界,我们坚持的定义是:端口是应用程序与我们希望抽象掉的任何事物之间的接口,而适配器是该接口或抽象背后的实现。

Now Python doesn’t have interfaces per se, so although it’s usually easy to identify an adapter, defining the port can be harder. If you’re using an abstract base class, that’s the port. If not, the port is just the duck type that your adapters conform to and that your core application expects—the function and method names in use, and their argument names and types.现在 Python 本身并没有接口,所以虽然识别适配器通常很容易,但定义端口可能更难。如果你使用的是抽象基类,那就是端口。如果没有,端口就是你的适配器所遵循的、核心应用程序所期望的鸭子类型——即所使用的函数和方法名称,以及它们的参数名称和类型。

Concretely, in this chapter, AbstractRepository is the port, and SqlAlchemyRepository and FakeRepository are the adapters.具体来说,在这一章中,AbstractRepository 是端口,而 SqlAlchemyRepository 和 FakeRepository 是适配器。

Wrap-Up总结

Bearing the Rich Hickey quote in mind, in each chapter we summarize the costs and benefits of each architectural pattern we introduce. We want to be clear that we’re not saying every single application needs to be built this way; only sometimes does the complexity of the app and domain make it worth investing the time and effort in adding these extra layers of indirection.牢记 Rich Hickey 的名言,我们在每一章中都会总结我们引入的每种架构模式的成本和收益。我们想明确一点:我们并不是说每个应用程序都需要这样构建;只有当应用程序和领域的复杂性使得投入时间和精力来增加这些额外的间接层变得值得时,才应该这样做。

With that in mind, Repository pattern and persistence ignorance: the trade-offs shows some of the pros and cons of the Repository pattern and our persistence-ignorant model.考虑到这一点,“仓库模式和持久化无知:权衡”展示了仓库模式和我们持久化无知模型的一些利弊。

Table 1. Repository pattern and persistence ignorance: the trade-offs
Pros Cons
  • We have a simple interface between persistent storage and our domain model.

  • It’s easy to make a fake version of the repository for unit testing, or to swap out different storage solutions, because we’ve fully decoupled the model from infrastructure concerns.

  • Writing the domain model before thinking about persistence helps us focus on the business problem at hand. If we ever want to radically change our approach, we can do that in our model, without needing to worry about foreign keys or migrations until later.

  • Our database schema is really simple because we have complete control over how we map our objects to tables.

  • An ORM already buys you some decoupling. Changing foreign keys might be hard, but it should be pretty easy to swap between MySQL and Postgres if you ever need to.

  • Maintaining ORM mappings by hand requires extra work and extra code.

  • Any extra layer of indirection always increases maintenance costs and adds a "WTF factor" for Python programmers who’ve never seen the Repository pattern before.

Domain model trade-offs as a diagram shows the basic thesis: yes, for simple cases, a decoupled domain model is harder work than a simple ORM/ActiveRecord pattern.[8]“领域模型权衡图”展示了基本论点:是的,对于简单的情况,解耦的领域模型比简单的 ORM/ActiveRecord 模式更费力。[8]

Tip
If your app is just a simple CRUD (create-read-update-delete) wrapper around a database, then you don’t need a domain model or a repository.

But the more complex the domain, the more an investment in freeing yourself from infrastructure concerns will pay off in terms of the ease of making changes.但领域越复杂,投入精力将自己从基础设施关注点中解放出来,在变更的便捷性方面就越能获得回报。

apwp 0206
Figure 6. Domain model trade-offs as a diagram图 6. 领域模型权衡图

Our example code isn’t complex enough to give more than a hint of what the right-hand side of the graph looks like, but the hints are there. Imagine, for example, if we decide one day that we want to change allocations to live on the OrderLine instead of on the Batch object: if we were using Django, say, we’d have to define and think through the database migration before we could run any tests. As it is, because our model is just plain old Python objects, we can change a set() to being a new attribute, without needing to think about the database until later.我们的示例代码还不够复杂,无法充分展示图表右侧的情况,但线索已经存在。例如,想象一下,如果我们有一天决定将分配逻辑从 Batch 对象转移到 OrderLine 上:如果是使用 Django,我们必须在运行任何测试之前定义并思考数据库迁移。而现在,由于我们的模型只是普通的 Python 对象,我们可以将 set() 更改为一个新属性,而无需考虑数据库,直到以后再说。

Repository Pattern Recap仓库模式回顾
Apply dependency inversion to your ORM

Our domain model should be free of infrastructure concerns, so your ORM should import your model, and not the other way around. 领域模型应不受基础设施关注点的影响,因此你的 ORM 应该导入你的模型,而不是反过来。

The Repository pattern is a simple abstraction around permanent storage

The repository gives you the illusion of a collection of in-memory objects. It makes it easy to create a FakeRepository for testing and to swap fundamental details of your infrastructure without disrupting your core application. See [appendix_csvs] for an example.仓库为你提供了内存对象集合的错觉。它使得创建用于测试的 FakeRepository 变得容易,并且可以在不破坏核心应用程序的情况下交换基础设施的基本细节。示例请参见 [appendix_csvs]。

You’ll be wondering, how do we instantiate these repositories, fake or real? What will our Flask app actually look like? You’ll find out in the next exciting installment, the Service Layer pattern.你可能在想,我们如何实例化这些仓库,无论是伪造的还是真实的?我们的 Flask 应用到底会是什么样子?你将在下一个激动人心的章节“服务层模式”中找到答案。

But first, a brief digression. 但首先,让我们进行一段简短的题外话。


1. I suppose we mean "no stateful dependencies." Depending on a helper library is fine; depending on an ORM or a web framework is not.
2. Mark Seemann has an excellent blog post on the topic.
3. In this sense, using an ORM is already an example of the DIP. Instead of depending on hardcoded SQL, we depend on an abstraction, the ORM. But that’s not enough for us—not in this book!
4. Even in projects where we don’t use an ORM, we often use SQLAlchemy alongside Alembic to declaratively create schemas in Python and to manage migrations, connections, and sessions.
5. Shout-out to the amazingly helpful SQLAlchemy maintainers, and to Mike Bayer in particular.
6. You may be thinking, "What about list or delete or update?" However, in an ideal world, we modify our model objects one at a time, and delete is usually handled as a soft-delete—i.e., batch.cancel(). Finally, update is taken care of by the Unit of Work pattern, as you’ll see in [chapter_06_uow].
7. To really reap the benefits of ABCs (such as they may be), be running helpers like pylint and mypy.
8. Diagram inspired by a post called "Global Complexity, Local Simplicity" by Rob Vens.