プログラミング
Djangoの好きなところ
What I love about Django (buttondown.com)
要約
この記事では、著者がButtondownの開発で長年Djangoを愛用してきた理由が語られています。Djangoの「ちょうど良い」意見の強さと構造化された設計により、フレームワーク自体が開発中に「見えなく」なり、Pythonicなコードやビジネスロジックと自然に融合することが、著者が最も高く評価する点です。特に、ミドルウェア、ベースモデルによる機能追加、アクション、シンプルな関数ベースのビュー、そしてパフォーマンスを重視したテスト戦略といった具体的な機能が、コードの保守性と効率性を高める上でいかに役立っているかが解説されています。
全文翻訳
この記事を書くにあたって、Buttondownで長年にわたり非常に便利で永続的だと感じてきたDjangoの側面を称賛することを目標としました。これを実行する上での課題の1つは、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開発者がもっと活用すべきだと私が思うツールが1つあるとすれば、それはミドルウェアです。
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)
# 実際に変更された追跡対象フィールドについては、
# handle_<field>_change フックを呼び出し、トランジション行を永続化します。
...
class Meta:
abstract = True
ordering = ("-creation_date",)
そのベースクラスは、すべてオプトインかつ追加的なもので、多くのことを静かに行っています。
UUIDのプライマリキーとcreation_dateが、すべてのテーブルに無料で追加されます。
ORMがカスタムマネージャーとクエリセットを介して透過的にデコードする、公開されたタイププレフィックス付きID(sub_...、em_...)です。
暗黙的な変更追跡:handle_<field>_changeを定義すると、そのフィールドが変更されるたびに実行されます。シグナルや登録は不要です。
永続的な出所:フィールドをトランジションテーブルにマッピングすると、すべての変更が独自の行として書き込まれます。
フィールドごとの検証フック(validate_<field>)。
オプトインのソフトデリートマネージャー、およびデータ整合性チェッカーシステムへのフックです。
この記事で表面化させるのは奇妙なことのように思えますが、Djangoのオブジェクト指向へのアプローチの便利な点は、これらすべてが断片的であったことです。共通機能の新しいビットを接ぎ木することは、かなりの量の労力、移行、またはリファクタリングを必要としませんでした。そして、新しいフィールドの出所追跡のようなことを非常に、非常に簡単に行えることを意味します。モデルは1つのメソッドでオプトインし、ベースクラスが残りを処理します。
# app/emails/models/email/model.py
class Email(BaseModel):
# 暗黙的な変更追跡:handle_<field>_changeを定義すると、BaseModelが
# そのフィールドが実際に変更されたときにそれを呼び出します。シグナルや配線は不要です。
def handle_body_change(self, **kwargs) -> None:
AsynchronousAction.enqueue(sync_snippet_references, [str(self.id)])
# 永続的な出所:フィールドをトランジションテーブルにマッピングすると、すべての変更が
# 行として永続化されます。1つを追加するのは1行の辞書エントリです。
@classmethod
def tracked_field_to_transition_class(cls) -> dict[str, type[BaseTransition]]:
return {"status": EmailStatusTransition}
これらのどちらも、ベースクラスへの移行や、呼び出しサイトのリファクタリングを必要としませんでした。
3. アクション
モデルクラスに数十個のメソッドが蓄積するのを避けるために、モデルが行えるすべての動作は、そのモデルの隣にあるactions/フォルダの下の独自のファイルに格納されています。モジュールごとに1つの動詞があり、それぞれが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. ビュー
ビューに対する私たちの考え方は非常に教条的で、非常にシンプルです。ビューは次の必要があります。
独自のファイルに配置されること
CBV(クラスベースビュー)ではなく、関数ベースであること
その関数を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")
なぜこれほど退屈で厳格なのか?主にコンテキストスイッチの痛みを避けるためです。私の意見では、保守可能なビューコードを書くことは、成功を見つけることよりも失敗を避けることに関係しており、失敗は不必要な間接参照とコード再利用の欠如という形で現れる傾向があります。これらはどちらも、ビューを可能な限り「純粋」(FPの意味で)にすることで単純化されます。
5. テスト
私は個人のブログでテストについて多く書いてきました。過去1年間の私の個人貢献者としての時間の多くは、バックエンドテストスイートが常に長い時間を要していたCIパイプラインを、可能な限り高速化することに費やされました。私たちはpytestとpytest-django、そして膨大な数のpytestプラグインを使用しています。特に、Factory Boyのような既製のフィクスチャジェネレーターは実際には使用しておらず、代わりにパフォーマンスをさらに引き出すために独自に構築しています。テストは、必要なフィクスチャを受け取り、実際の行に対してアサートするプレーンな関数です。
# app/emails/views/record_lifecycle_email_open--test.py
# `account` は手作業で作成されたフィクスチャで、account/model--mock.py に共置され、
# pytest_plugins を介して登録されています。factory_boy や 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_