我喜爱 Django 之处

Hacker News Top 工具

摘要

一篇颂扬 Django 中间件与模型抽象的文章,阐述它们如何在 Buttondown 代码库中提供长期杠杆效应。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/08/06 11:01

# 我喜爱 Django 的原因 来源:https://buttondown.com/blog/what-i-love-about-django 写下这篇文章之初,我的目标是赞颂 Django 中那些让我——在 Buttondown 这个整体中——多年来受益良多且经久耐用的部分。这样做的一个挑战在于,这恰恰可以说是 Django 本身的一项基本资产:它的固执己见和结构化程度恰到好处,以至于随着时间推移,它会变得隐形。起初,我发现很难眯起眼睛审视我们的代码库,然后指向“所谓的”Django 事物,因为应用中的 Django 部分与那些仅仅是 Pythonic 或纯粹业务逻辑的部分融合得如此顺畅。我看 Buttondown 时,看到的并不是一个 Django 应用;我看到的是一个结构良好的代码库,其中许多问题已被比我更聪明的人解决。这,比任何其他东西都更能代表我喜爱 Django 的原因。不过,无论这多么富有诗意,它都会让博客文章变得又短又无趣。于是,我戴上思考帽和 X 射线护目镜,认真地审视了一番:过去几年里,Django 的哪些部分为我们带来了最大的长期杠杆? ## 1. 中间件 Django 的中间件抽象极其简单,也因此极其强大。我认为,像我这样在中间件从函数式向类式迁移期间真正成熟起来的人,往往会理所当然地认为——无论实际的 Python 原语是什么——Django 中间件就是一些作用于请求/响应生命周期的简单函数。它们只需采纳该协议,就可以在其中为所欲为。 事实证明,这种请求钩子对很多事情都极其有用:根据子域将请求路由到正确的通讯稿,捕获 UTM 和来源归因,设置 Content-Security-Policy 头,记录页面浏览量,将请求上下文绑定到我们的结构化日志中,以及——如下所示——标记已部署的构建版本。 下面这个中间件会为每个响应打上已部署的 git SHA,让过期的浏览器标签页能够注意到新构建已发布。它的完整代码是: ``` # app/emails/middlewares/build_version.py class Middleware: def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None: self.get_response = get_response def __call__(self, request: HttpRequest) -> HttpResponse: response = self.get_response(request) if settings.HEROKU_SLUG_COMMIT and not flag_is_active(CIRCUIT_BREAKER_FLAG): response[BUILD_VERSION_HEADER] = settings.HEROKU_SLUG_COMMIT return response ``` 如果有一个工具是我认为普通 Django 开发者应该更充分利用的,那就是中间件。 ## 2. 模型(以及轻量继承) 我们回避多态模型,部分原因是我们认为它们有点容易踩坑,但更现实的原因是,我们并没有多少适合它们的用例。不过,Buttondown 中的每个模型都继承自一个基类模型。其精简版本如下: ``` # app/utils/models.py class BaseModel(models.Model): creation_date = models.DateTimeField(auto_now_add=True) id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) objects = TypeIDAwareManager() def save(self, *args, **kwargs): super().save(*args, **kwargs) # For any tracked field that actually changed, fire its # handle__change hook and persist a transition row. ... class Meta: abstract = True ordering = ("-creation_date",) ``` 这个基类悄然做了很多事,而且全部是可选的、叠加式的: - 每个表都免费获得一个 UUID 主键和 `creation_date`。 - 公开的、带类型前缀的 ID(`sub_...`、`em_...`),ORM 通过自定义 manager 和 queryset 透明地解码它们。 - 隐式变更跟踪:定义 `handle__change`,只要该字段发生变化它就会运行——无需信号,无需注册。 - 持久化溯源:将字段映射到转换表,每次变更都会写为独立的一行。 - 逐字段校验钩子(`validate_`)。 - 一个可选的软删除 manager,以及接入我们数据完整性检查系统的钩子。 在这篇文章中提及这一点似乎有些奇怪,但 Django 面向对象方式之所以如此方便,在于这一切都是零敲碎打的。加入新的通用功能并不需要大量的工作、迁移或重构。这意味着,为字段添加溯源跟踪等功能变得非常非常简单——模型只需通过一个方法选择启用,剩下的由基类完成: ``` # app/emails/models/email/model.py class Email(BaseModel): # Implicit change tracking: define handle__change and BaseModel # invokes it whenever that field actually changes. No signal, no wiring. def handle_body_change(self, **kwargs) -> None: AsynchronousAction.enqueue(sync_snippet_references, [str(self.id)]) # Durable provenance: map a field to a transition table and every change # is persisted as a row. Adding one is a one-line dict entry. @classmethod def tracked_field_to_transition_class(cls) -> dict[str, type[BaseTransition]]: return {"status": EmailStatusTransition} ``` 这两者都不需要对基类进行迁移,也不需要重构任何调用点。 ## 3. 动作 我们不让模型类不断积累数十个方法,而是让模型可能经历的每个行为都位于该模型旁边的 `actions/` 文件夹下的独立文件中——每个模块对应一个动词,每个模块都暴露一个 `call()`。下面是“封禁订阅者”的完整实现,它本身又组合了另一个动作: ``` # app/emails/models/subscriber/actions/ban.py from emails.models.subscriber.actions import end_premium_subscription from emails.models.subscriber.model import Subscriber def call(subscriber: Subscriber) -> None: if subscriber.subscriber_type == Subscriber.Type.PREMIUM.value: end_premium_subscription.call(str(subscriber.id)) subscriber.subscriber_type = Subscriber.Type.REMOVED.value subscriber.save(update_fields=["subscriber_type", "modification_date"]) ``` ## 4. 视图 我们对视图的处理方式极其教条,也极其简单。一个视图必须: 1. 放在自己的文件中 2. 基于函数而非 CBV(类视图) 3. 以 `view` 为名称暴露该函数 ``` # app/emails/views/record_lifecycle_email_open.py def view(request: HttpRequest, compressed_id: str) -> HttpResponse: try: account_id, email_type = _decode_open_payload(compressed_id) except (UnicodeDecodeError, Base64Error, ValueError): return HttpResponse(TRANSPARENT_GIF, content_type="image/gif") with transaction.atomic(): LifecycleEmailEvent.objects.create( account_id=account_id, email_type=email_type, event_type=LifecycleEmailEvent.EventType.OPENED, timestamp=timezone.now(), metadata=_build_metadata(request), ) return HttpResponse(TRANSPARENT_GIF, content_type="image/gif") ``` 为什么要这么无聊且严格?很大程度上是因为上下文切换带来的痛苦。在我看来,编写可维护的视图代码更多在于避免失败,而非追求成功;而失败往往以不必要的间接性和缺乏代码复用的形式出现——通过让视图尽可能“纯粹”(在函数式编程意义上),两者都变得更简单了。 ## 5. 测试 我在个人博客上写了很多关于测试的内容,过去一年里,我把大量个人贡献者时间花在了让 CI 流水线——其中后端测试套件长期以来一直是瓶颈——尽可能快上。我们使用 pytest、pytest-django 以及一大堆 pytest 插件。值得注意的是,我们并没有真正使用像 Factory Boy 这样的现成 fixture 生成器,而是自己构建 fixture,以便榨取更多性能。 测试就是一个普通函数,它接收所需的 fixtures,并针对真实的数据库行进行断言: ``` # app/emails/views/record_lifecycle_email_open--test.py # `account` is a hand-rolled fixture, colocated in account/model--mock.py and # registered via pytest_plugins — no factory_boy, no mock.patch. def test_records_open_event(account): encoded = encode_open_payload(str(account.pk), "unconfirmed") request = RequestFactory().get(f"/lo/{encoded}/") response = view(request, encoded) assert response.status_code == 200 event = LifecycleEmailEvent.objects.get(account=account) assert event.event_type == LifecycleEmailEvent.EventType.OPENED ``` ## 我们略去不用的东西 如果说 Rails 以 omakase(主厨定制)闻名,那么我最喜爱 Django 的一点,恰恰是上面没有提到的那些东西——出于种种原因,我们认定它们并不适合我们。举几个例子: **Signals(信号)。** 我甚至不确定你能说我们不用它们,倒不如说我们不滥用它们。我们只使用一个信号,作为进入 django-allauth 的轻量级连接。我们发现,在内部使用信号——将我们自己拥有的两段代码连接起来——是一种反模式,它让推理发生的事情变得更困难,也让未来出于性能考虑进行优化变得更困难。 **类视图。** 我认为类视图在某些情境下有其优点,但在代码库中穿梭时最难处理的事情之一,就是在函数视图和类视图之间进行上下文切换。而且,无论 CBV 在某种用例中可能带来多少边际收益,与能够非常教条且标准化地统一每个视图的工作方式相比,这些都相形见绌。 **Apps(应用)。** 我们不按照 Django 建议的传统模块化意义来使用应用,主要有两个原因。其一,处理跨应用的迁移非常困难,尤其是压缩(squash)它们。其二,与其他组织方式——Django 在很大程度上对此不予关心——相比,它并没有提供明显的好处,比如简单地将相关模型分组到一个文件夹里。我们有两个例外。第一个是我们的核心 API 基础设施,其骨架位于独立的应用中,仅仅是因为我在持有更成熟观点之前就是这样构建的。第二个是任何我们认为可能想要抽象成第三方包或开源的东西,这时,应用有助于预先设定好它与我们代码库其余部分之间的边界。 **Checks(检查)。** 我认为 checks 其实非常酷,我的一部分内心很遗憾没有更多地使用它们。我发现,采用奇怪的测试是强制系统内各种约束的一种更简单的方式。这会损失一点性能——你可以说 REPL 在技术上更慢——但话说回来,可动部件又少了一个。 **表单和前端相关。** 我们完全不使用 Django 的表单抽象。事实上,我们构建应用前端的方法有点意思:我们更倾向基于水合(hydration)的模式,在这种模式下,对于大多数已认证视图,Django 的工作是渲染一个薄壳并用数据填充它,而不是生成 HTML。几乎应用中的每个页面都由同一个视图支撑——它解析会话,将账户(以及少数路由中相关资源的第一页)序列化到 `json_script` 标签中,然后交给 Vue;Vue 启动并从该 payload 中水合,而不是在加载时付出一次 API 往返的代价。Django 渲染骨架;SPA 负责其余部分。 ## 我当初为什么使用 Django? Buttondown 使用 Django 编写,原因无聊却颇具揭示性:这是我当时唯一熟悉的技术栈。早在 2018 年,我在一家使用 Django 和 Vue 技术栈的公司工作,并且我是以拥有大量 Django 经验的身份被聘用的(意思是:我知道 South 是什么——各位老前辈应该懂)。我长期以来的理念之一是限制创新代币(https://mcfunley.com/choose-boring-technology)。我不想在从白天的工作切换到业余项目时,还要花费时间在不同框架之间进行上下文切换。在随后的八年里,我发现自己对选择 Vue 作为前端框架感到懊悔——但我可以诚实地说,即使那不是一个经过深思熟虑的选择,我也从未有一秒钟后悔使用 Django。

相似文章

Django中我一直在享受的更多特性

Lobsters Hottest

Julia Evans分享了她在构建后端网站时对Django查询构建器和模板过滤器的正面体验,称赞了它们的可读性和便利性。

Django 将转向年度发布周期

Lobsters Hottest

Django 指导委员会已接受 DEP 20,决定从 2028 年 1 月开始转向年度发布周期。每个功能版本将获得三年的支持,取消 LTS 标签,且版本号将包含发布年份(例如 Django 2028)。

别小看 wrapture

Simon Willison's Blog

文章强调了 Python 包 'wrapture' 作为猴子补丁、测试和可观测性不可或缺的工具,尽管仍处于 alpha 阶段,但教程和潜力都在增长。