使用 *attr* DTO 为我们的 API 提供支持

帮助我们为全公司范围内的新 API 奠定基础是令人兴奋的 。2022 年初,我所在的团队为我们的新 API 开发了概念验证并建立了标准 。快进到今天,您会发现跨越 12 个产品领域的 150 多个端点每天处理数百万个请求 。在这篇文章中,我将深入探讨 API 的一个内部方面:数据传输对象 (DTO) 。我将讨论为什么我们选择 attrs 以及我们如何使用它 。我还将展示我们如何为开发人员标准化 API 实现流程,包括端点的版本控制 。
API 工作非常艰巨,涉及多个工程和产品团队 。这需要讨论、技术设计文件,当然还有一些自行车脱落! 早期设计文档中的这句话捕捉到了这一愿景:

“Klaviyo 值得拥有一个长期、一致且灵活的 API,它可以在未来几年为 Klaviyo 内部和外部的开发人员提供服务,同时最大限度地减少我们内部开发人员的运营开销,并最大限度地提高我们外部开发人员的一致性和可用性 。”
设置场景我们的 API 符合 JSON:API 规范 。API 团队的 Chad Furman 写了一篇很棒的文章,介绍了我们为什么选择 JSON:API 以及我们如何使用它 。我们的实现是在 Python/ target=_blank class=infotextkey>Python 中使用 Django Rest Framework (DRF) 。我们利用 DRF 的可组合性和灵活性来定制 API 中的各种组件 。
大致来说,实现如下:
API 路由注册在 Router 对象上,该对象将传入请求(正文、查询参数、标头等)分派到相应的 ViewSet 类 。
通过在 ViewSet 类上配置自定义身份验证、许可和速率限制逻辑,将它们插入到 DRF 中 。这些由 DRF 在传入请求时调用 。
ViewSet 类上实现的不同 HTTP 方法(GET、POST、PATCH 等)通过调用内部服务来处理传入请求 。这通常会通过适配器层来处理各个方向的有效负载,最终返回 HTTP 响应 。
使用数据传输对象 (DTO)在 Python 中,使用普通的旧字典很容易表示键值数据(例如 JSON 有效负载),但这种便利性可能会代价高昂:
缺乏结构:词典松散 。它们的外观没有界限 。很容易犯错别字、数值多余、数值不足等错误 。
可变性:Python 中的字典是可变的,如果您使用该语言一段时间,您已经知道这可能会导致各种令人讨厌的错误 。
从根本上讲,DTO 是仅封装数据的对象——它们内部几乎没有行为(最多是序列化逻辑) 。这些也称为纯数据类或数据类 。此类(或一般类)在实例化期间强制执行严格的模式 。也可以轻松地实现这些来实例化不可变对象 。另外,正如我们稍后将看到的,DTO 允许向属性添加类型提示,这极大地提高了代码的可读性 。
我们使用 DTO 来代表我们的 API 合约 。每个端点(HTTP 方法)都有一个关联的入口 DTO(表示传入请求的 JSON 正文以及查询参数)和一个相关的响应 DTO(表示返回到客户端的 JSON 正文) 。例如,当使用我们的 API 创建目录项时,请求和响应数据字典将被建模为 DTO 。
我们不鼓励使用可以跨多个端点重用的通用、稀疏实例化的 DTO 。尽管这增加了一些冗余,但它提供了清晰、严格的模式实施,并且还产生了模块化设计,可以轻松独立地对不同端点的合约进行版本控制 。此外,这种独特的 DTO 端点绑定有助于简化公共 API 文档的自动生成 。
当时,一些库已经提供了创建纯数据类的出色解决方案,而开发人员无需编写标准 Python 类通常所需的样板代码 。最流行的是:dataclasses、pydantic 和 attrs 。我不会详细比较这三者,因为有很多文章(请参阅 Attrs、Dataclasses 和 Pydantic 以及为什么我使用 attrs 而不是 pydantic) 。
在较高的层面上,第一个决定是在 attrs/dataclasses 和 pydantic 之间 。前两个与 pydantic 相似但又截然不同 。Pydantic 主要是一个验证库而不是数据容器 。尽管在这里使用 pydantic 很诱人,因为它适合我们的用例,但我们主要出于性能原因决定不使用它 。我们的 DTO 需要在每个 API 请求的 API Web 层上同步实例化,因此每个潜在的性能瓶颈都很重要 。这篇博文对这些库的性能进行了一些有趣的研究和基准测试 。
我们选择了 attrs,因为它高性能、功能丰富(与数据类相比)、灵活且易于使用 。另外,由于 attrs 不是标准库的一部分(与数据类不同),合并新功能不需要 Python 版本升级 。就我个人而言,我真的很喜欢他们的装饰器风格模式,而不是 pydantic 使用的继承 。他们在哲学上更倾向于组合而不是继承,这使得组合更加透明并且易于针对我们的用例进行定制 。attrs 将方法附加到类上,一旦类生成装饰器执行,它就是一个普通的旧 Python 类 。


推荐阅读