分享

Python架构模式

 gfergfer 2023-11-10 发布于辽宁

最近看了一本非常不错的 Python 软件架构方面的书:《Architecture Patterns with Python[1]》。主要介绍了领域驱动设计,事件驱动架构在 Python 这门语言中的具体实践,读下来感觉撰文质量很高,有不少收获,大致列举一下:

  • 相比《Clean Architecture》,这本书结合了一个贯穿始终的具体项目来进行讲解,给出了各种具体代码实现,所以理解上会更加清晰直观,新手的友好度 。
  • 叙述方面的思路非常清晰,在项目开发中我们碰到的具体问题是什么,接下来提出一种架构模式来解决,如何实现,最后也给出了设计的trade-off(这点感觉很多书都缺少),任何架构模式都不是银弹,在解决问题的同时也会引入不一样的问题。
  • 以往《Effective Python》,《Fluent Python》之类的书,更专注于Python的语言特性,底层细节用法方面,而在更高的软件架构层面,如何设计可维护可扩展的大型Python项目,之前很少有专著来论述。这本书很好的填补了这一空缺。
  • 除了架构设计的主题,书中其实对各类最佳开发实践都有所覆盖,例如TDD和测试分级,类型注解,甚至配置部署,Docker打包方面也有涉及,非常实战派。
  • 这么好的书,还是免费的!可以在这里[2]在线阅读

如果你对在大型项目上,Python 是个烂语言吗?[3]这个问题感兴趣,那么这本书非常值得一看!以下还是按惯例,简单记录一下我个人的心得笔记。

DDD

大家最早开始接触到的软件系统架构,一般都是下面这个经典的三层结构:

图片

三层结构

这个结构非常符合我们的认知习惯,很多软件项目也都是基于这个结构开发起来形成的“单体应用”。在这个结构下,大体的代码调用逻辑一般也是上层调用下层,尽量减少同一层内的互相调用,绝对杜绝从下层调用上层。

但在看了《Clean Architecture》之后,突然发现业务逻辑调用数据存储层的做法是有问题的。一个比较浅层的痛点就是写测试,在这种结构下,在做业务逻辑测试时,往往需要mock/fake数据存储层,为了简便起见,很多时候就直接搞一个测试数据库来做相应的测试了。这就导致了测试本身的维护成本也很大,而且有了外部依赖之后测试运行速度也会变得很慢。

从更深层次看,采用了这种架构设计之后,我们在做新功能开发的一个习惯性思路就是先设计DB Schema,然后再设计上面的业务逻辑,以及表现层交互等。这个思路其实是有问题的,把设计的重心放在了本来其实是“细节”的数据存储上。如果我们从RDBMS换用了NoSQL,是不是整个系统的设计就变了?这显然逻辑上就不正确。如果思考的重心在存储层,也很容易在与业务方交流时忽略了业务的本质逻辑。

所以这时候引入DDD的思想也就比较自然了。DDD的原著有点难懂,我主要还是看《Clean Architecture》有了一些粗浅的理解。DDD的想法主要就是希望我们在做业务开发时,能以最核心的领域模型为中心来展开。一方面领域问题是我们做软件开发需要解决的核心问题,所以与产品,交互,测试,甚至于销售,客户来做各类交流和沟通时,领域建模及领域语言能让大家处在一个频道上,且专注于解决核心问题。另一方面,从软件架构层面,以领域模型为核心,让其它层面的逻辑去依赖领域模型,能让整体架构的灵活性和可扩展性更好。以《Clean Architecture》的经典示意图为例:

图片

Clean Architecture

作者定义了离核心模型越远的部分,例如UI展现,DB存储这类,应该在越外层。从依赖关系上看要保证外层依赖内层,而不是反过来。这样的话例如我们想支持多种UI展现形式(Web,Mobile),或者切换不同的数据存储(In-memory,File,DB),都可以不需要对内层核心的逻辑做任何修改。

具体要怎样从之前的三层架构改成DDD的这种“洋葱架构”呢?在本书中,作者通过一个实际项目给出了更具体的指导。

1. Domain Modeling

首先一个问题是如何做领域建模。第一章中,作者介绍了其过程和方法,主要的模式就是把业务逻辑抽象成Entity,Value Object和Domain Service三类。这里与传统的面向对象建模也有一些细微的区别,相比传统设计中比较容易出现数据和行为耦合在一起的超复杂class,这里分成三类建模的思路更加清晰简洁。对于具体怎么实现Value Object,作者也给出了dataclass这类最佳实践。另外从第一章开始,就能感受到作者演示的TDD的具体操作方法,类型注解等良好编码习惯,值得留意。设计干净的领域模型几乎没有任何副作用,而且我们可以直接基于这个核心来做单元测试,没有任何外部依赖,执行速度迭代效率会非常高。

2. Repository Pattern

有了领域模型,接下来就是把整体架构转变成clean architecture的形式,让DB这类infra依赖领域模型,而不是反过来,增加领域模型的复杂度。在第二章中,作者通过引入repository pattern来解决这个问题。具体采用的是我们耳熟能详的依赖反转方法,把DB那层的箭头改一下,就成了如下形式:

图片

洋葱架构

依赖反转的具体操作在书中也给出了代码例子,简单来说就是把DB存储方面的操作做了一个抽象层(abstract repository),然后在使用时传入的具体implementation即可。对于抽象/接口方面,书中也给了各种实现方法的建议,例如使用abc,或直接用duck typing等。

引入repository pattern还是增加了一些项目的复杂度的,对于这个pattern的trade-off,书中也进行了讨论。对于复杂度不高的情况下,直接使用ORM会更加简单,而随着业务复杂度的上升,通过抽象层来让domain model更加干净会让整体复杂度更可控:

图片

Cost Trade-off

3. On Coupling and Abstractions

第三章用了一个相对独立的例子来讲解耦合与抽象的问题。一个核心思想是把我们想做什么(领域逻辑),与我们具体怎么做(外层细节)进行分离。这一章里还顺带介绍了TDD的优点,以及TDD中的两个流派,Classic-style TDD与London-school TDD,Fake与Mock的区别,挺长见识。我的直观感受是Fake的做法可以从更高的抽象层级来做总体测试,对于测试代码本身来说更接近于实际的use case,方便维护和理解。

4. Flask API and Service Layer

在前几章的实践中,我们已经可以构建起一个简单的业务架构,例如从web框架接受请求开始,到领域实例的构建,repository的操作等。这里出现的一个问题是单元测试只能覆盖之前的domain层,其它串联逻辑都要在end-to-end测试中覆盖,数量上会显得有点多,不太符合健康的测试数量金字塔模型。End-to-end测试本身的构建也比较复杂,比如需要在每个测试里引入web框架,repository相关的脚手架。万一后续需要更换web框架,则有一堆的测试需要修改。

在第四章中,为了解决这个问题,作者引入了service层(注意跟domain里的service区分),把上述各类编排逻辑整合到这个层里,而在web框架那层只保留其最基本的http会话管理,请求处理,状态返回等最基本的功能。这样编排部分的逻辑,也可以写单独的单元测试来覆盖。涉及到web框架的end-to-end测试数量就可以降下来了。截止目前整体的架构会变成如下状态:

图片

5. TDD in High Gear and Low Gear

第五章中进一步讨论了测试方面的话题。测试金字塔的概念大家都不陌生,整体来说越往下层的单元测试,数量会越多,执行频率越高,且执行速度会越快。越往上层的话会越偏向于真实end-to-end场景的测试,执行频率低,执行耗时也会明显变长。

测试会带来的一个问题是它实际上也是你的逻辑代码的调用,当你需要做代码重构时,如果做了函数签名等方面的改动,可能需要同时修改一系列的测试,造成了额外的负担。这一章里提出的high gear/low gear思想挺有意思,一个指导宗旨就是在新项目,新功能启动初期,可以编写更底层更具体的domain级别单元测试,而在后续项目逐渐成熟后,用service level的单元测试来替代他们,以减轻重构的负担。

这章里有两个令人印象深刻的隐喻,一个就是以自行车来比喻测试的切换,起步时low gear,平稳运行阶段切换到high gear。另外是把测试理解成组合你项目代码的“胶水”,当你在越细粒度的层级去做这个组装时,系统做改变的灵活性就会降低。同样也可以类比到依赖高层次,较稳定的接口相比依赖低层次的细节实现的优势。

图片

High gear vs low gear

6. Unit or Work Pattern

之前我们使用repository pattern对存储层进行了抽象,不过具体在使用时,涉及到session的依赖,以及commit/rollback细节还需要在service层做直接操作,显得有些丑陋。在第六章中,作者又引入了unit of work pattern来解决这个问题。同样也是使用依赖反转的方式增加了一个UoW的抽象层,对于事务性的支持使用context manager来实现。个人感觉这块其实跟repository pattern并在一起讲也okay,作者也提到像sqlalchemy中也实现了这个pattern。另外文中实现的UoW还是比较简单,如果是跨多个系统,例如DB,缓存,消息队列,涉及到多线程,嵌套事务等,可能会有更多需要考虑的点。

7. Aggregates and Consistency Boundaries

DDD的最后一章中,作者提出了domain model里需要解决的另一个问题,就是业务逻辑的一致性问题。文中举了个例子说我们是不是可以把业务直接跑在excel表格上来维护,但有一个问题就是业务中的各种限制无法被自动满足。于是引出了aggregates这个概念来解决这个问题。按我的直观理解就是实际业务中的domain entity, value object等会比较多,但很多其实是可以隐藏为“私有属性”,对最终用户来说暴露的一个统一“公开接口”,就是所谓的aggregates了。

在此基础上,作者还讨论了业务上的并发问题,设计良好的的aggregates的粒度应该尽可能的小,这样能保证更高的并发度,但从逻辑封装上来说又是粒度尽可能粗,只提供对外提供服务的aggregates接口即可。文中还讨论了并发中的乐观锁和悲观锁,也顺带一提。

另外从aggregates还可以继续延伸到bounded contexts,在设计aggregates时也同时限制了领域的context。DDD鼓励在不同的context中去处理不同的任务,例如同样是产品entity,在生产领域只需要知道其sku id和批次信息,而在销售领域可能需要额外的价格,描述,图片等信息。这样做的好处是能把领域模型控制在比较容易管理的复杂度内,提高可维护性和灵活性。现在比较流行的微服务架构也很能天然的与这种设计思路结合。

在第一部分结束的时候,我们的整体系统结构如下图所示:

图片

DDD Architecture

Event-Driven Architecture

在第一部分的领域模型架构基础上,我们会根据不同的领域业务问题来分别构建领域模型与服务。在真实业务场景中往往需要多个领域的模型一起协作来完成一系列的业务需求。所以这里就很自然引出了下一个话题,使用事件驱动型的架构来让各个领域模型服务以较低的耦合方式进行协同工作。

8. Events and the Message Bus

这一章以在应用过程中发送email的需求为例,引入了事件与message bus的概念和方法。总体思路比较直接,希望保证domain service的单一职责,在处理过程中把额外需要处理的信息以事件的形式保存在model中。然后由UoW模块获取到这些事件,发布到message bus中去。最后由具体的handler来处理message bus中的事件,进行其它流程操作。

图片

引入event & message bus

9. Going to Town on the Message Bus

在上一章的基础上,我们可以把message bus作为整个服务层的主入口,也就是把API调用和内部消息两者的处理形式统一了。文中给了非常详细的一步步重构service,message bus,测试用例,API层的操作演示。从这一步开始,整个系统就已经是事件驱动的了。这里主要带来的问题是,之前系统处理流程是在代码里有明确的体现的,改为事件驱动后,这个整体的串联关系就没有实体了,只能通过系统日志等方式来查看。对于系统的一致性,可推断性方面也有了更多的挑战。另外事件对象的开发与领域模型对象的开发中很可能会存在一定的重复性,需要同步维护。

图片

Message bus作为主入口

10. Commands and Command Handler

虽然上一章对API和内部事件做了统一,但这两者很多时候还是有明显区别的。例如API调用一般是明确的指令,需要立刻处理并返回是否成功的信息,而且是针对单一的事件接收方。而内部事件一般会广播发布,相关的订阅者来分别处理,允许fail independently等。所以在这一章里把事件分成了command和event两种类型,采用不同的handler来进行处理。另外本章中还给出了一些错误处理,失败恢复的方法讨论,并再次提到:构建一个可靠的分布式消息系统是非常难的一件事!文中给出的一些实现总体来说还是偏理想化的,要想生产实战还是需要老师傅带路才行……

11. Event-Driven Architecture

前面讲的还都是在单个应用内部构建message bus,但我们一开始说的是让多个领域应用来一起协作。当然我们也可以构建多个微服务,然后以相互API调用的方式来进行协作,但这样就跟之前碰到的问题一样,系统间的耦合度过高,对于不同的业务流程情况,会出现非常复杂的依赖时序图。而且系统也比较脆弱,一旦有任何一个组件不可用,整个调用都会失败,导致整体的不可用。所以解决方法就是把多个系统的协作也通过异步消息来串接。

图片

Event-driven Architecture

个人感觉这里主要的trade-off就是把原先每个应用里要分别维护的复杂调用逻辑,释放给了异步消息层,大家各自只要关心自己的核心逻辑即可。这背后的复杂调用逻辑的组织和维护需要靠消息队列,日志收集,分布式追踪,监控系统,发布系统等等基础架构的组件来保证了,工作量同样也很大。不过好处是每个系统相对都是聚焦于自己的业务,在服务数量大幅增长时,整体的复杂度增长还是O(n)的级别,而不会像每个应用都要维护与所有其它应用的调用关系那样,平方级甚至指数级的复杂度增长。这个架构模式比较适合有专门的中间件组的大型工程团队。

文中使用redis作为消息队列来演示了整体的运作方式。但是对于实际生产来说肯定需要采用更加复杂的消息队列系统,例如EventStore, Kafka, RabbitMQ等。

12. CQRS

前面几章的架构设计案例中,我们处理的大多数操作都是写操作。这一章开始讨论读操作,并提出这两者具有非常大的区别,如下表所示:

图片

read vs write

所以我们可以对读操作,采用完全不同的策略方法来执行,也就是所谓的Command-Query Responsibility Segregation。文中给出了一系列方法来做读方面的单独处理,并建议建立view tables,在event handler中更新view数据,然后使用原始sql来获取读操作的结果。

图片

传说中的CQRS

另外提到最终一致性,如果event handler中更新view失败了,我们还是可以查询写逻辑的当前数据状态,重新回放一系列event来重建view。这方面还有一个很好的例子是像git这类代码版本系统,每一个提交就是一个delta event,回放历史所有的提交就能获取到最新的代码仓库状态。当然如果每次读取都需要回放的话显然性能会比较差,所以git中也会有整体仓库的snapshot,加速读取过程。

这里需要注意的trade-off在于单独的一个读模型会增加额外的复杂度,在系统整体没有复杂到一定程度时,直接用repository来实现也完全OK。

13. Dependency Injection

最后一章主要来看整个应用的准备与启动过程的优化。前面我们做了很多外层的抽象,例如UoW,邮件发送服务,消息发布服务等。具体在使用时,例如测试环境部署与生产环境部署,可能需要使用不同的服务配置,或者执行自动化测试,可能要传入fake的UoW,存储层等。一个比较自然的想法是抽象出一个模块来专门做这些任务,而不用在各个地方都重复一遍环境准备和启动相关的代码,所以就引出了bootstrap模块,以及依赖注入的实现。

图片

引入bootstrap模块

依赖注入方面可以直接用 inspect 模块来实现,也可以使用专业的框架,例如Inject[4]punq[5]dependencies[6]等。

这一章中还介绍了一些Docker打包与环境配置方面的话题。

Epilogue

这本书的结语部分的内容也相当丰富。其中一个主要的话题是对于已有项目的改造如何来进行,如何分析目前的模型结构,识别其中的问题点,如何来拆分设计成多个aggregates等。从上面的介绍中也可以看出,每一种pattern的引入都是有一定trade-off的,所以开始着手改造的一个重要原则是首先识别你要解决什么问题,然后逐渐改造看引入这些pattern时能否解决问题。

全书最终的整体架构如图:

图片

整体架构

具体到这其中的各个pattern,作者建议先从domain modeling开始,事实上哪怕不在架构上有多大改动,能与业务方构建起一套通用的领域语言已经是巨大的进步了!接下来比较推荐的操作是把一系列编排动作抽象到service layer,在此基础上就比较容易把领域逻辑下沉到domain中,而把一些validation,错误处理上移到应用入口处。接下来对于是否使用消息驱动架构,个人感觉对于业务的复杂度应该是比较高的情况下才进行考虑,而且要小心评估对基础架构框架方面的要求是否能得到满足。而CQRS这种,是否采用的灵活度就很高了,不确定在其它公司是否有广泛的应用?

最后还是要强调reliable messaging is hard!作者补充了一些其它方面的话题,例如Outbox pattern,消息处理的幂等性,消息本身可能有多版本等。

Appendix

附录中也有一些挺有意思的内容,例如在Appendix B中介绍了项目目录结构,12-Factor,和docker-compose相关的内容。在Appendix E中介绍了如何做各种validation,分syntax,semantics,pragmatics三种类型来分别讨论。

其它想法

软件系统开发有着悠久的历史,从上个世纪 70 年代开始就有各种大型系统逐渐被开发出来。到了 94 年出现了经典的 GoF 的《设计模式》,02 年 Martin Fowler 写了《Patterns of Enterprise Application Architecture》,03 年 Eric Evans 出版了《Domain-Driven Design》(到了 13 年才有比较具体的讲解 DDD 实践的书),可以看到业界在设计模式,软件架构方面思考逐渐趋于成熟的时间线发展。在机器学习领域,今年也开始有了《Machine Learning Design Patterns[7]》和《Deep Learning Design Patterns[8]》方面的著作出现,后续是否也会有机器学习类项目的架构设计 pattern 呢?值得期待一下。

参考资料

[1]

Architecture Patterns with Python: https://www./library/view/architecture-patterns-with/9781492052197/

[2]

这里: http://www./book/preface.html

[3]

在大型项目上,Python 是个烂语言吗?: https://www.zhihu.com/question/21017354/answer/652602653

[4]

Inject: https:///project/Inject/

[5]

punq: https:///project/punq/

[6]

dependencies: https:///project/dependencies/

[7]

Machine Learning Design Patterns: https://www./library/view/machine-learning-design/9781098115777/

[8]

Deep Learning Design Patterns: https://www./books/deep-learning-design-patterns

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多