Photo by 山姆理发师 on 不飞溅 | Image height altered
在你职业生涯的最初几年,你可能和我一样陷入了同样的错误。您认为数据库是系统中最重要的部分。在我了解了软件架构之后,发现数据库并不重要。这对我来说也有点令人震惊,我花了一段时间才被说服。让我解释一下原因,然后你自己决定。
在软件架构中,Robert C. Martin 提出了一些称为 SOLID 设计原则的原则。它们是面向对象软件开发中一些最著名的设计原则。 SOLID 是以下五个原则的助记词缩写:
最后一个是我们对这篇文章感兴趣的。在设计软件系统时,最重要的是域。域是您要解决的问题的一个花哨的词。域规则是现实世界的规则。它们与任何 Web 服务器、Web 框架或数据库无关。如果您不熟悉这些概念,请查看 这个 Robert C. Martin 的文章。
让我们想一想我们通常如何设计一个 Web 项目。我们觉得有义务做的第一件事是决定 Web 框架、Web 服务器和数据库。然后我们将域转移到已经做出这些决定的代码中。现在,每当我们必须考虑业务需求时,我们的决策就会受到限制。如果您经常搜索如何在 Django 中执行此操作,如何在 Postgres 中实现此操作等。这意味着您可能做出的决定太早了。
恕我直言,首先选择网络框架或数据库与设计汽车并处理内饰(如果它是织物或皮革)而不是首先处理发动机或设计等最重要的部件是相同的。一个好的软件架构可以使未做出的决策数量最大化。
好吧,让我们推迟对数据库的决定,但是如何呢?当您安装包含电池样式的 Web 框架时,它需要一个数据库来运行 Web 服务器。有时它还会为您配置一个类似 SQLite 的东西。在这里,您应该了解一些概念,例如高级模块、低级模块和抽象。
高级模块通常是您自己编写的用于解决问题的代码。它们包含应用程序的重要策略决策和业务模型——应用程序的身份。
另一方面,低级模块与数据库等外部依赖项进行通信。它们包含实现业务模型所需的各个机制的详细实现。
抽象用于隐藏背景细节或任何不必要的数据实现,以便用户只能看到所需的信息。
您的域不应取决于您的数据是存储在 SQL 数据库中还是 NoSQL 数据库中。它也不应该依赖于数据存储在数据库中而不是存储在数据库中的事实。 文件系统
.域只对这些数据在持久存储中并且在需要时可以从外部访问感兴趣。
高级模块不应依赖于低级模块。两者都应该依赖于抽象。
数据库只是一个 IO 设备。它恰好提供了一些用于排序、查询和报告的有用工具,但这些工具是系统架构的辅助工具。 罗伯特·C·马丁
为了实现这一切,聪明的软件工程师创建了存储库模式和依赖注入等模式和技术。让我们看看这两个在行动。
依赖注入是给一个对象它的实例变量。我们不是自己构建它们,而是将这些变量提供给实例,并让实例通过抽象(接口)与其依赖关系进行通信。
让我们看一些没有依赖注入的代码:
检查上面的简单代码,因为它向远程服务器发出 HTTP 请求以通过提供 文件网址
保存所有数据的地方。如果我们在这里应用依赖倒置原则,我们的函数不应该知道文件已上传到 S3。毕竟,对我们的函数来说唯一有趣的部分是文件在我们发出请求之前上传到某个地方的事实,我们有 文件网址
背部。
让我们在这里应用依赖倒置原则。
def create_new_job(数据,blob_storage_client:BlobStorageClient): file_url = blob_storage_client.save_data(数据) 返回请求.post( url='some_url', json={'file_url': file_url} )
我们没有直接导入 S3Client 并在函数内部对其进行初始化,而是将其作为参数。注意打字 blob_storage_client
创建一个接口:
类 BlobStorageClient(ABC): @抽象方法 def save_data(self, data) -> str: 引发 NotImplementedError()
简单地说,这是 Python 中的一个抽象类,带有一个名为的抽象方法 保存数据
.现在我们的 S3Client 将实现这个接口。
我们已经实施了 BlobStorageClient
对于 S3 服务。需要记住的是,在接口的实现中,可以让代码依赖于第三方(如 boto3)。
现在当你运行 create_new_job
函数,您创建一个 s3client 并将其作为参数提供给函数。
s3client = S3Client() create_new_job(数据='一些数据',blob_storage_client=s3client)
显而易见的好处是可测试性、单一职责、模块化等。让我向您展示这如何有助于更轻松的可测试性。通常,当您测试 create_new_job
,您不想访问 S3 服务。您可以做的第一件事是模拟 boto3 方法。这很容易做到,但我认为模拟出每个依赖项都会在您的测试中产生代码气味。
通过实施我们的模式和原则,我们可以在测试时利用不同的技术与第三方打交道。我们可以注入虚假的实现。
我们可以实现另一个 BlobStorageClient
如下:
类TestBlobStorageClient(BlobStorageClient): is_call = 假 def save_data(自我,数据): self.is_call = True
当我们测试时,我们可以注入这个测试客户端,而不是注入 s3 客户端:
def test_create_new_job(): test_client = TestBlobStorageClient() create_new_job('一些数据',test_client) 断言 test_client.is_call
在这里,我想向您展示如果正确应用这一原则最重要的好处。如果你想更深入地进行测试,有一篇我详细讲过的文章 这里 .
现在,让我们对数据库做同样的事情。不是领域模型依赖于数据库,而是数据库必须依赖于我们的领域模型。我们将反转依赖关系,为了实现这一点,我们将引入存储库模式。
存储库是封装访问数据源所需的逻辑的类或组件。简而言之,存储库负责与数据库或您拥有的任何持久性存储解决方案进行通信。您不是直接与数据库通信,而是通过模式存储库对其进行抽象。
假设您经常觉得需要通过用户名获取一些用户。现在您要做的第一件事可能是安装和配置 ORM 并查询用户。 SQLAlchemy 的一个例子:
def get_user_by_username(username: str) -> 用户 |没有任何: stmt = select(User).where(User.username == username) 用户 = session.execute(stmt).scalars().first() 返回用户
该代码的问题在于,可能在某些业务逻辑的中间,您将调用该函数,该函数会创建从域到数据库的直接依赖关系。我们不希望这种情况发生。我们希望我们的软件系统独立于基础设施问题。软件系统是我们的业务,我们不希望我们的业务受到外部依赖的影响。
存储库模式之前和之后
在经典或框架驱动的方法中,您经常会遇到前图那样的情况。在这种情况下,您可以直接从您的域访问数据库(对于域驱动设计,请阅读 这个 部分)。
在这种情况下,您的领域层必须知道数据库的存在、该数据库的类型以及与之通信的方式。领域层将导入数据库表、ORM 和/或数据库客户端以执行原始查询。这会产生很多依赖和很多问题。
为了避免这种情况,软件工程中有一条简单的规则。软件工程中的大多数问题都可以通过添加另一层抽象来解决。
存储库模式只不过是对数据库的抽象。这种模式使您拥有的数据库对于您的领域层来说微不足道。毕竟,领域层不应该担心数据库。如果现在这对您没有意义,请这样想:
域是您要解决的问题。这是纯粹的业务逻辑。如果您正在创建一个加密交换应用程序,那么您的领域就是交易、跟踪价格、下订单等。它不会将交易保存在 Postgres 或 MongoDB 中。您应该将您的领域视为软件工程的局外人。
把它想象成领域专家会做的事情。领域专家并不关心您的数据库偏好是关系数据库。他们只关心每笔交易都保存在安全的地方,以后可以检索。您的域层应该是相同的方式。它应该只知道它可以用来检索保存的数据或保存新数据的某个地方存在持久性存储。
好吧,如何实现这种间接性?应用存储库模式。
最初,我们应该创建一个类,其中包含我们需要在数据库上为特定域实体执行的所有操作。最好为每个实体都有一个存储库。这样,您可以使它们保持小而简单。 用户
使用 SQLAlchemy 编写 Python 中的实体。 (这个概念适用于所有语言和框架。)
类用户回购: def __init__(self, session): self.session = 会话
我们在构造函数中创建了一个接受 SQLAlchemy Session 的类。现在我们将使用该会话来访问数据库。
类用户回购: def __init__(self, session): self.session = 会话 def get_by_username(self, username: str) -> 用户 |没有任何: return self.session.query(User).filter_by( 用户名=用户名).first()
我们添加了 get_by_username
方法。很简单。现在,每当我需要通过用户名获取用户时,我都需要导入该存储库,通过提供会话创建一个实例并使用该方法。
回购 = 用户回购(会话) 用户 = repo.get_by_username('klement')
现在完成这个难题的是领域模型。你可以注意到 get_by_username
方法返回一个 用户
实例或 没有任何
.现在这个领域模型只是我们特定问题中那个实体的表示。让我们看一些不同框架如何定义域模型的示例。
在 SQLAlchemy 中,如果您遵循通常的教程,域模型可以编写如下:
类用户(基础): id = 列(UUID(as_uuid=True),primary_key=True) 用户名=列(字符串(50),唯一=真,可空=假) first_name = 列(字符串(150)) 姓氏 = 列(字符串(150)) 电子邮件 = 列(字符串(255)) is_active = 列(布尔值,可为空=假) date_joined = Column(DateTime, nullable=False) last_login = 列(日期时间)
这个领域模型充满了 ORM 依赖;你甚至不需要了解 SQLAlchemy 就可以理解这一点。
如果我们检查 Django 中的示例,这就是它的样子:
那一个更糟糕。在 Django 中,模型也是从模板中使用的,并且里面有一些配置 字段
对于模板,如空白选项或 帮助文本
等。在输入下决定帮助文本的域模型太多了。这应该是表示层的责任,而不是域的责任。
如果我们通过 SQLAlchemy 的帮助来应用 DIP,结果如下:
这样,ORM就依赖于我们的领域模型,它是一个纯Python类,没有任何外部依赖。当我们调用 start_mappers
SQLAlchemy 会将一些私有属性附加到 用户
类 __桌子__
因此,当您在带有 SQLAlchemy 会话的查询中使用它时,它知道该类表示数据库中的用户表。
所有相关的业务逻辑 用户
实体将进入 用户
班级。将使用多个实体的业务逻辑将进入 服务
或用例取决于您决定使用什么。重要的是,现在我们已经反转了依赖关系。数据库现在依赖于我们的域模型,而不是相反。
当项目随着时间的推移变得更大、更复杂时,依赖倒置原则的缺失会导致严重的架构问题。对于中小型项目,像 Django 这样的框架或任何其他框架都可以很好地完成这项工作。你甚至不需要担心这些话题。
如果您发现自己的业务团队告诉您对模型进行一些重大更改,并且您发现自己受到数据库的限制,那么您的架构可能会出错。域应该用纯 Python、Java、C# 或您喜欢的任何语言编写,它应该是一切都依赖的,而不是其他方式。您应该尽可能地将现实世界的问题反映到代码中,而不必担心外部依赖关系。
“一个好的软件架构可以最大限度地增加未做出的决定。” — 罗伯特·C·马丁
尽管您可以严格遵循许多架构,但没有完美的架构。你无法知道什么是完美的。您可以尝试、迭代并在每次迭代中使您的软件变得更好。重要的是要遵循我们谈到的原则,不要违反规则,除非你有充分的理由这样做。
更多关于 SOLID 原则的信息: Robert C. Martin 的 Clean Coder 博客
Python中应用的模式和技术: 宇宙蟒
微软关于存储库模式的优秀文章
版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明
本文链接:https://www.qanswer.top/7786/51230109