技术博客教程文档【免费下载链接】one-python-craftsman来自一位 Pythonista 的编程经验分享内容涵盖编码技巧、最佳实践与思维模式等方面。项目地址https://gitcode.com/gh_mirrors/on/one-python-craftsman点击查看免费下载本篇是“Python 工匠”系列的第 14 篇文章对应仓库文档 zh_CN/14-write-solid-python-codes-part-3.md。系列前两篇原则上篇、原则中篇分别讲解了 S单一职责与 O开放-关闭、L里氏替换。本篇聚焦 SOLID 的最后两位成员D依赖倒置原则与I接口隔离原则。在这篇文章中我们将继续介绍 SOLID 原则剩下的两位成员I接口隔离原则和D依赖倒置原则。为了方便这篇文章将会使用先 D 后 I 的顺序。D依赖倒置原则软件是由一个个模块组合而成的。当你跟别人说“我在写一个很复杂的软件”其实你并不是直接在写那个软件你只是在编写它的一个个模块最后把它们放在一起组合成你的软件。有了模块模块间自然就有了依赖关系。比如你的个人博客可能依赖着 Flask 框架而 Flask 又依赖了 WerkzeugWerkzeug 又由更多个低层模块组成。依赖倒置原则Dependency Inversion Principle就是一条和依赖关系相关的原则。它认为“高层模块不应该依赖于低层模块二者都应该依赖于抽象。”High-level modules should not depend on low-level modules. Both should depend on abstractions.这个原则看上去有点反直觉。毕竟在我们的第一堂编程课上老师就是这么教我们写代码的“高层模块要依赖低层模块hello world 程序依赖 printf()。”那为什么这条原则又说不要这样做呢而依赖倒置原则里的“倒置”又是指什么让我们先把这些问题放在一边看看下面这个小需求。上面这些问题的答案都藏在这个需求中。需求按域名分组统计 HN 新闻数量这次出场的还是我们的老朋友新闻站点 Hacker News。在 HN 上每个用户提交的条目标题后面都跟着这条内容的来源域名。我想要按照来源域名来分组统计条目数量这样就能知道哪个站在 HN 上最受欢迎。这个需求非常简单使用requests、lxml模块可以很快完成任务# file: hn_site_grouper.py import requests from lxml import etree from typing import Dict from collections import Counter class SiteSourceGrouper: 对 HN 页面的新闻来源站点进行分组统计 def __init__(self, url: str): self.url url def get_groups(self) - Dict[str, int]: 获取 (域名, 个数) 分组 resp requests.get(self.url) html etree.HTML(resp.text) # 通过 xpath 语法筛选新闻域名标签 elems html.xpath(//table[classitemlist]//span[classsitestr]) groups Counter() for elem in elems: groups.update([elem.text]) return groups def main(): groups SiteSourceGrouper(https://news.ycombinator.com/).get_groups() # 打印最常见的 3 个域名 for key, value in groups.most_common(3): print(fSite: {key} | Count: {value}) if __name__ __main__: main()代码执行结果❯ python hn_sitestr_grouper.py Site: github.com | Count: 2 Site: howonlee.github.io | Count: 1 Site: latimes.com | Count: 1这段代码很短核心代码总共不到 20 行。现在让我们来理一理它里面的依赖关系。SiteSourceGrouper是我们的核心类。为了完成任务它需要使用requests模块获取首页内容、lxml模块解析标题。所以现在的依赖关系是“正向”的高层模块SiteSourceGrouper依赖低层模块requests、lxml。也许现在这张图在你眼里看起来特别合理。正常的依赖关系不就应该是这样的吗别着急我们还没给代码写单元测试呢。为 SiteSourceGrouper 编写单元测试现在让我来为这段代码加上单元测试。首先让最普通的情况开始from hn_site_grouper import SiteSourceGrouper from collections import Counter def test_grouper_returning_valid_types(): 测试 get_groups 是否返回了正确类型 grouper SiteSourceGrouper(https://news.ycombinator.com/) result grouper.get_groups() assert isinstance(result, Counter), groups should be Counter instance这是一个再简单不过的单元测试我调用了SiteSourceGrouper.get_groups()方法然后简单校验了一下返回结果类型是否正常。这个测试在本地电脑上执行时没有一点问题可以正常通过。但当我在服务器上执行这段单元测试代码时却发现它根本没办法成功。因为我的服务器不能访问外网。# 运行单元测试时提示网络错误 requests.exceptions.ConnectionError: HTTPSConnectionPool(hostnews.ycombinator.com, port443): ... ... [Errno 8] nodename nor servname provided, or not known))到这里单元测试暴露了SiteSourceGrouper类的一个问题它的核心逻辑依赖 requests 模块和网络连接严格限制了单元测试的执行条件。既然如此那要如何解决这个问题呢如果你去问一个有经验的 Python 的开发者十有八九他会甩给你一句话“用 mock 啊”使用 mock 模块mock 是 unittest 里的一个模块同时也是一类测试手法的统称。假如你需要测试的模块里有一部分依赖很难被满足比如代码需要访问一整套 Kubernetes 集群或者你想在测试时故意替换掉某些依赖那么 mock 就能派上用场。在这个例子里使用 unittest.mock 模块需要做下面这些事情把一份正确的 HN 页面内容保存为本地文件static_hn.html在测试文件中导入unittest.mock模块在测试函数中通过mock.patch(requests.get)替换网络请求部分将其修改为直接返回文件static_hn.html的内容使用 mock 后的代码看起来是这样的from unittest import mock def test_grouper_returning_valid_types(): 测试 get_groups 是否返回了正确类型 resp mock.Mock() # Mock 掉 requests.get 函数 with mock.patch(hn_site_grouper.requests.get) as mocked_get: mocked_get.return_value resp with open(static_hn.html, r) as fp: # Mock 掉响应的 text 字段 resp.text fp.read() grouper SiteSourceGrouper(https://news.ycombinator.com/) result grouper.get_groups() assert isinstance(result, Counter), groups should be Counter instance上面的代码并不算复杂。对于 Python 这类动态语言来说使用 mock 有着一种得天独厚的优势。因为在 Python 里运行时的一切对象几乎都可以被替换掉。不过虽然 mock 用起来很方便但它不是解决我们问题的最佳做法。因为 mock 在带来方便的同时也让测试代码变得更复杂和难以理解。而且给测试加上 mock 也仅仅只是让我的单元测试能够跑起来糟糕设计仍然是糟糕设计。它无法体现出单元测试最重要的价值之一“通过编写测试反向推动设计改进”。所以我们需要做的是改进依赖关系而不只是简单的在测试时把依赖模块替换掉。如何改进依赖关系让我们看看“依赖倒置”是如何做的。实现依赖倒置原则首先让我们重温一下“依赖倒置原则”后简称 D 原则的内容“高层模块不应该依赖于低层模块二者都应该依赖于抽象。”在上面的代码里高层模块SiteSourceGrouper就直接依赖了低层模块requests。为了让代码符合 D 原则我们首先需要创造一个处于二者中间的抽象然后让两个模块都可以依赖这个新的抽象层。创建抽象的第一步可能也是最重要的一步就是确定这个抽象层的职责。在例子中高层模块主要依赖requests做了这些事通过requests.get()获取 response通过response.text获取响应文本所以这个抽象层的主要职责就是产生 HN 站点的页面文本。我们可以给它起个名字HNWebPage。确定了抽象层的职责和名字后接下来应该怎么实现它呢在 Java 或 Go 语言里标准答案是定义 Interface接口。因为对于这些编程语言来说“接口”这两个字基本就可以等同于“抽象”。拿 Go 来说“Hacker News 站点页面”这层抽象就可以被定义成这样的 Interfacetype HNWebPage interface { // GetText 获取页面文本 GetText() (string, error) }不过Python 根本没有接口这种东西。那该怎么办呢虽然 Python 没有接口但是有一个非常类似的东西“抽象类Abstrace Class”。使用abc模块就可以轻松定义出一个抽象类from abc import ABCMeta, abstractmethod class HNWebPage(metaclassABCMeta): 抽象类Hacker New 站点页面 abstractmethod def get_text(self) - str: raise NotImplementedError抽象类和普通类的区别之一就是你不能将它实例化。如果你尝试实例化一个抽象类解释器会报出下面的错误TypeError: Cant instantiate abstract class HNWebPage with abstract methods get_text所以光有抽象类还不能算完事我们还得定义几个依赖这个抽象类的实体。首先定义的是RemoteHNWebPage类。它的作用就是通过 requests 模块请求 HN 页面返回页面内容。class RemoteHNWebPage(HNWebPage): 远程页面通过请求 HN 站点返回内容 def __init__(self, url: str): self.url url def get_text(self) - str: resp requests.get(self.url) return resp.text定义了RemoteHNWebPage类后SiteSourceGrouper类的初始化方法和get_groups也需要做对应的调整class SiteSourceGrouper: 对 HN 页面的新闻来源站点进行分组统计 def __init__(self, page: HNWebPage): self.page page def get_groups(self) - Dict[str, int]: 获取 (域名, 个数) 分组 html etree.HTML(self.page.get_text()) # 通过 xpath 语法筛选新闻域名标签 elems html.xpath(//table[classitemlist]//span[classsitestr]) groups Counter() for elem in elems: groups.update([elem.text]) return groups def main(): # 实例化 page传入 SiteSourceGrouper page RemoteHNWebPage(urlhttps://news.ycombinator.com/) grouper SiteSourceGrouper(page).get_groups()做完这些修改后让我们再看看现在的模块依赖关系高层模块SiteSourceGrouper不再直接调用requests而是依赖抽象层HNWebPage低层实现RemoteHNWebPage同样依赖抽象层HNWebPage高层模块和低层模块同时依赖于抽象概念HNWebPage低层模块的依赖箭头和之前相比倒过来了所以我们称其为依赖倒置。依赖倒置后的单元测试再回到之前的单元测试上来。通过引入了新的抽象层HNWebPage我们可以实现一个不依赖外部网络的新类型LocalHNWebPage。class LocalHNWebPage(HNWebPage): 本地页面根据本地文件返回页面内容 def __init__(self, path: str): self.path path def get_text(self) - str: with open(self.path, r) as fp: return fp.read()所以单元测试也可以改为使用LocalHNWebPagedef test_grouper_from_local(): page LocalHNWebPage(path./static_hn.html) grouper SiteSourceGrouper(page) result grouper.get_groups() assert isinstance(result, Counter), groups should be Counter instance这样就可以在没有外网的服务器上测试SiteSourceGrouper类的核心逻辑了。Hint其实上面的测试函数test_grouper_from_local远远算不上一个合格的测试用例。如果真要测试SiteSourceGrouper的核心逻辑我们应该准备一个虚构的 Hacker News 页面比如刚好包含 5 个来源自 github.com 的条目然后判断结果是否包含assert result[github.com] 5。问题一定要使用抽象类 abc 吗为了实现依赖倒置我们在上面定义了抽象类HNWebPage。那是不是只有定义了抽象类才能实现依赖倒置只有用了抽象类才算是依赖倒置呢答案是否定的。如果你愿意你可以把代码里的抽象类HNWebPage以及所有的相关引用都删掉你会发现没有它们代码仍然可以正常运行。这是因为 Python 是一门“鸭子类型”语言。这意味着只要RemoteHNWebPage和LocalHNWebPage类型保持着统一的接口协议提供.get_text()公开方法并且它们的协议符合我们定义的抽象。那么那个中间层就存在依赖倒置就是成立的。至于这份协议是通过抽象类还是普通父类甚至可以是普通函数定义的就没那么重要了。所以虽然在某些编程语言中实现依赖倒置必须得定义新的接口类型但在 Python 里依赖倒置并不是抽象类 abc 的特权。问题抽象一定是好东西吗前面的所有内容都是在说新增一个抽象层然后让依赖关系倒过来的种种好处。所以多抽象的代码一定就是好的吗缺少抽象的代码就一定不够灵活和所有这类问题的标准回答一样答案是视情况而定。当你习惯了依赖倒置原则以后你会发现抽象Abstract其实是一种思维方式而不仅仅是一种编程手法。如果你愿意你可以在代码里的所有地方都硬挤一层额外抽象出来比如代码依赖了 lxml 模块的 xpath 具体实现我是不是得定义一层“HNTitleDigester”把它抽象进去比如代码里的字符串字面量也是具体实现我是不是得定义一个 StringLike 类型把它抽象进去... ...事实上抽象的好处显而易见它解耦了高层模块和低层模块间的依赖关系让代码变得更灵活。但抽象同时也带来了额外的编码与理解成本。所以了解何时不抽象与何时抽象同样重要。只有对代码中那些现在或未来会发生变化的东西进行抽象才能获得最大的收益。I接口隔离原则接口隔离原则后简称 I 原则全称为 “Interface Segregation Principles”。顾名思义它是一条和“接口Interface”有关的原则。我在前面解释过何为“接口Interface”。接口是模块间相互交流的抽象协议它在不同的编程语言里有着不同的表现形态。比如在 Go 里它是type ... interface而在 Python 中它可以是抽象类、普通类或者函数甚至某个只在你大脑里存在的一套协议。I 原则认为“客户client应该不依赖于它不使用的方法”The interface-segregation principle (ISP) states that no client should be forced to depend on methods it does not use.这里说的“客户Client”指的是接口的使用方客户程序也就是调用接口方法的高层模块。拿上一个统计 HN 页面条目的例子来说使用方客户程序SiteSourceGrouper接口其实是抽象类HNWebPage依赖关系调用接口方法get_text()获取页面文本在 I 原则看来**一个接口所提供的方法应该就是使用方所需要的方法不多不少刚刚好。**所以在上个例子里我们设计的接口HNWebPage是符合接口隔离原则的。因为它没有向使用方提供任何后者不需要的方法。你需要 get_text()我提供 get_text()刚刚好所以这条原则看上去似乎很容易遵守。既然如此让我们试试来违反它吧例子开发页面归档功能让我们接着上一个例子开始。在实现了上个需求后我现在有一个代表 Hacker News 站点页面的抽象类HNWebPage它只提供了一种行为就是获取当前页面的文本内容。class HNWebPage(metaclassABCMeta): abstractmethod def get_text(self) - str: 获取页面文本内容现在假设我要开发一个和 HN 页面有关的新功能**我想在不同时间点对 HN 首页内容进行归档观察热点新闻在不同时间点发生的变化。**所以除了页面文本内容外我还需要拿到页面的大小、生成时间这些额外信息然后将它们都保存到数据库中。为了做到这一点现在的HNWebPage类需要被扩展一下class HNWebPage(metaclassABCMeta): abstractmethod def get_text(self) - str: 获取页面文本内容 # 新增 get_size 与 get_generated_at abstractmethod def get_size(self) - int: 获取页面大小 abstractmethod def get_generated_at(self) - datetime.datetime: 获取页面生成时间我在原来的类上增加了两个新的抽象方法get_size和get_generated_at。这样归档程序就能通过它们拿到页面大小和生成时间了。改完抽象类后紧接着的任务就是修改依赖它的实体类。问题实体类不符合 HNWebPage 接口规范在修改抽象类前我们有两个实现了它协议的实体类RemoteHNWebPage和LocalHNWebPage。如今HNWebPage增加了两个新方法get_size和get_generated_at。我们自然需要把这两个实体类也加上这两个方法。RemoteHNWebPage类的修改很好做我们只要让get_size放回页面长度让get_generated_at返回当前时间就行了。# class RemoteHNWebPage: # def get_generated_at(self) - datetime.datetime: # 页面生成时间等同于通过 requests 请求的时间 return datetime.datetime.now()但是在给LocalHNWebPage添加get_generated_at方法时我碰到了一个问题。LocalHNWebPage是一个完全基于本地页面文件作为数据来源的类仅仅通过 “static_hn.html” 这么一个本地文件我根本就没法知道它的内容是什么时候生成的。这时我只能选择让它的get_generated_at方法返回一个错误的结果比如文件的修改时间或者直接抛出异常。无论是哪种做法我都可能违反里氏替换原则。Hint里氏替换原则认为子类派生类对象应该可以在程序中替代父类基类对象使用而不破坏程序原本的功能。让方法抛出异常显然破坏了这一点。# class LocalHNWebPage: # def get_generated_at(self) - datetime.datetime: raise NotImplementedError(local web page can not provide generate_at info)所以对现有接口的盲目扩展暴露出来一个问题更多的接口方法意味着更高的实现成本给实现方带来麻烦的概率也变高了。不过现在让我们暂且把这个问题放到一边继续写一个SiteAchiever类完成归档任务class SiteAchiever: 将不同时间点的 HN 页面归档 def save_page(self, page: HNWebPage): 将页面保存到后端数据库 data { content: page.get_text(), generated_at: page.get_generated_at(), size: page.get_size(), } # 将 data 保存到数据库中成功违反 I 协议代码写到这让我们回头看看上个例子里的条目来源分组类SiteSourceGrouper。当我修改完抽象类后虽然SiteSourceGrouper仍然依赖着HNWebPage但它其实只使用了get_text这一个方法而已其他get_size、get_generated_at这些它不使用的方法也成为了它的依赖。很明显现在的设计违反了接口隔离原则。为了修复这一点我们需要将HNWebPage拆成更小的接口。如何分拆接口设计接口有一个技巧让客户调用方来驱动协议设计。让我们来看看HNWebPage到底有哪些客户SiteSourceGrouper域名来源统计依赖get_text()SiteAchieverHN 页面归档程序依赖get_text()、get_size()、get_generated_at()按照上面的方式我们可以把HNWebPage分离成两个独立的抽象类class ContentOnlyHNWebPage(metaclassABCMeta): 抽象类Hacker New 站点页面仅提供内容 abstractmethod def get_text(self) - str: raise NotImplementedError class HNWebPage(ContentOnlyHNWebPage): 抽象类Hacker New 站点页面含元数据 abstractmethod def get_size(self) - int: 获取页面大小 abstractmethod def get_generated_at(self) - datetime.datetime: 获取页面生成时间将旧类拆分成两个不同的抽象类后SiteSourceGrouper和SiteAchiever就可以分别依赖不同的抽象类了。同时对于LocalHNWebPage类来说它也只需要实现那个只返回文本的ContentOnlyHNWebPage就行无需再为无法提供的元数据方法而烦恼。这里的分拆方式很有讲究我们并没有让两个新接口完全彼此独立而是让HNWebPage继承ContentOnlyHNWebPage。这样既保证了“只含内容”的窄接口可以被单独依赖又保留了“含元数据”的完整能力归档程序SiteAchiever依然可以一次拿到文本、大小与生成时间三个字段。一些不容易发现的违反情况虽然我花了很长的篇幅用了好几个抽象类才把接口隔离原则讲明白但其实在我们的日常编码中对这条原则的违反经常会出现在一些更容易被忽视的地方。举个例子当我们在 web 站点里判断用户请求的 Cookies 或头信息是否包含某个标记值时我们经常直接写一个依赖整个request对象的函数def is_new_visitor(request: HttpRequest) - bool: 从 Cookies 判断是否新访客 return request.COOKIES.get(is_new_visitor) y但事实上除了.COOKIES以外is_new_visitor根本就不需要request对象里面的任何其他内容。“用户请求对象request”是一个比“Cookie 字典request.COOKIES”复杂得多的抽象。我们完全可以把函数改成只接收 cookies 字典。def is_new_visitor(cookies: Dict) - bool: 从 Cookies 判断是否新访客 return cookies.get(is_new_visitor) y类似的情况还有很多比如一个发短信的函数本身只需要两个参数电话号码和用户姓名但是函数却依赖了整个用户对象User里面包含着几十个用不上的其他字段和方法。对于这类函数我们都可以重新考虑一下它们的抽象是否合理是否需要应用接口隔离原则。与 D 原则下的“依赖注入”思路一致——只把真正需要的东西注入进来才能让函数的依赖面最小化。现实世界中的接口隔离当你知道了接口隔离原则的种种好处后你很自然就会养成写小类、小接口的习惯。在现实世界里其实已经有很多小而精的接口设计可以供你参考。比如Python 的collections.abc模块里面有非常多的小接口Go 里面的Reader和Writer也是非常好的例子以collections.abc为例它把容器行为拆成了Iterable、Iterator、Sized、Container、Sequence、Mapping等一个个极小的协议。你在写代码时完全可以只依赖其中的某一个比如只依赖Iterable判断“能不能迭代”而不必把整套容器协议都背在身上。这正是接口隔离原则在标准库中的自然体现。结合系列前文SOLID 的完整拼图将本篇的两条原则与系列前两篇放在一起可以更清楚地看到 SOLID 的全貌S单一职责一个类只应该有一种被修改的原因O开放-关闭类应该对扩展开放、对修改关闭可以通过继承、依赖注入或数据驱动实现L里氏替换子类对象应能在程序中替换父类对象而不破坏程序功能D依赖倒置高层模块与低层模块都应依赖抽象I接口隔离客户不应该依赖任何它不使用的方法其中D 与 I 的共性在于它们都和“抽象”紧密相关D 告诉我们要面向抽象而非实现编程I 则教导我们在设计抽象时要做到精准、克制。同时它们与前面的原则也相互牵连——本篇的例子就展示了盲目扩展接口不仅违反 I 原则还可能连带破坏 L里氏替换原则而接口的职责过于宽泛往往也是 S单一职责原则失守的征兆。如果你对前两篇的内容感兴趣可以在仓库中继续阅读写好面向对象代码的原则上S 与 O 原则以及使用继承、依赖注入、数据驱动改造代码的具体手法写好面向对象代码的原则中L 原则以及不当继承如何破坏程序功能总结在这篇文章里我向你介绍了 SOLID 原则的最后两位成员“依赖倒置原则”与“接口隔离原则”。这两条原则之间有一个共同点那就是它们都和**“抽象”**有着紧密的联系。前者告诉我们要面向抽象而非实现编程后者则教导我们在设计抽象时应该做到精准。最后再总结一下“D依赖倒置原则”认为高层模块和低层模块都应该依赖于抽象依赖抽象意味着我们可以完全修改低层实现而不影响高层代码在 Python 中你可以使用 abc 模块来定义抽象类除 abc 外你也可以使用其他技术来完成依赖倒置“I接口隔离原则”认为客户不应该依赖任何它不使用的方法设计接口就是设计抽象违反接口隔离原则也可能会导致违反单一职责与里氏替换原则写更小的类、写更小的接口在大多数情况下是个好主意赞分享技术博客教程文档【免费下载链接】one-python-craftsman来自一位 Pythonista 的编程经验分享内容涵盖编码技巧、最佳实践与思维模式等方面。项目地址https://gitcode.com/gh_mirrors/on/one-python-craftsman点击查看免费下载相关推荐如何快速看懂 Open Mercato 查询引擎QueryOptions 与结果模型完整指南如何快速看懂 Open Mercato 查询引擎QueryOptions 与结果模型完整指南 Open Mercato 是一个面向 CRM/ERP 与电商场景Python依赖倒置原则实战clean-code-python解耦技巧Python依赖倒置原则实战clean code python解耦技巧 依赖倒置原则Dependency Inversion Principle, DIP教程代码质量Element接口与接口隔离原则ReflectionCommon中的SOLID实践Element接口与接口隔离原则ReflectionCommon中的SOLID实践 接口隔离原则ISP的核心价值 你是否遇到过这样的困境一个接口包含了太静态分析上一篇告别API过载1Panel环境下的访问控制与限流方案下一篇LEDE系统存储之战ext4与Btrfs深度对比及选型指南创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考