我喜爱 Django 之处
摘要
一篇颂扬 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中我一直在享受的更多特性
Julia Evans分享了她在构建后端网站时对Django查询构建器和模板过滤器的正面体验,称赞了它们的可读性和便利性。
放下铅笔,睁大眼睛:Rails 开发者在 Rails World 之后
本文回顾了 DHH 的 Rails World 主题演讲,讨论了 AI 代理对编程的影响,并强调了软件开发中自动化与开发者理解之间的平衡。
Django 将转向年度发布周期
Django 指导委员会已接受 DEP 20,决定从 2028 年 1 月开始转向年度发布周期。每个功能版本将获得三年的支持,取消 LTS 标签,且版本号将包含发布年份(例如 Django 2028)。
@jasonfried: 设计并构建 Campfire、Writebook 和 Fizzy 不仅是一种乐趣,将它们开源并分享出去更是一种享受……
Jason Fried 分享了将 Campfire、Writebook 和 Fizzy 开源的喜悦,而 DHH 则强调了从生产代码中学习 Rails 的价值。
别小看 wrapture
文章强调了 Python 包 'wrapture' 作为猴子补丁、测试和可观测性不可或缺的工具,尽管仍处于 alpha 阶段,但教程和潜力都在增长。