Вступление
Каждый день программист пишет небольшие «заметки»: TODO‑списки, прототипы API, идеи по рефакторингу. Обычное приложение «Заметки» в Django позволяет быстро фиксировать мысли и одновременно отточить навыки работы с ORM, DRF и современными возможностями фреймворка. В статье мы построим минимальное, но полностью рабочее CRUD‑приложение для заметок, а затем разберём набор проверенных лайфхаков, которые делают код чище, быстрее и более надёжным.
Что получит читатель: готовое приложение, набор практических приёмов (select_related, bulk_create, transaction.atomic, index_together, async‑views) и список типичных «граблей», которых стоит избегать.
1. Контекст и предпосылки
| Параметр | Значение |
|---|---|
| Python | 3.11 |
| Django | 4.2 (LTS) |
| Django REST Framework | 3.14 |
| PostgreSQL | 15 |
| Цель проекта | Минимальный сервис для личных заметок, доступный через веб‑интерфейс и JSON‑API. |
| Ограничения | Один пользователь (без аутентификации), но код готов к масштабированию. |
Почему именно такие версии?
Django 4.2 впервые поддерживает полностью async‑ORM‑операции (например, aqueryset), а DRF 3.14 уже совместим с async‑views. PostgreSQL 15 даёт возможности GIN‑индексов для полнотекстового поиска, если в дальнейшем понадобится поиск по содержимому заметки.
2. Пошаговая реализация
2.1 Модели
# notes/models.py
from django.db import models
from django.utils import timezone
class Note(models.Model):
"""Простая заметка с поддержкой мягкого удаления."""
title = models.CharField(max_length=200, db_index=True)
body = models.TextField()
created_at = models.DateTimeField(default=timezone.now, db_index=True)
updated_at = models.DateTimeField(auto_now=True)
is_deleted = models.BooleanField(default=False, db_index=True)
class Meta:
ordering = ["-created_at"]
indexes = [
models.Index(fields=["title"], name="idx_note_title"),
models.Index(fields=["created_at"], name="idx_note_created"),
]
def soft_delete(self):
"""Не удаляем запись из БД, а только помечаем её."""
self.is_deleted = True
self.save(update_fields=["is_deleted", "updated_at"])
def __str__(self):
return self.title
Лайфхак: добавляем db_index=True сразу к полям, по которым будем фильтровать (title, created_at, is_deleted). Это избавит от последующего пере‑индексирования.
2.2 Сериализаторы
# notes/serializers.py
from rest_framework import serializers
from .models import Note
class NoteSerializer(serializers.ModelSerializer):
class Meta:
model = Note
fields = ("id", "title", "body", "created_at", "updated_at")
read_only_fields = ("id", "created_at", "updated_at")
2.3 Вьюхи
2.3.1 Синхронные API‑вьюхи (DRF Generic)
# notes/views.py
from rest_framework import generics, status
from rest_framework.response import Response
from .models import Note
from .serializers import NoteSerializer
from django.db import transaction
from django.db.models import Q
class NoteListCreateView(generics.ListCreateAPIView):
"""GET – список активных заметок, POST – создание новой."""
serializer_class = NoteSerializer
def get_queryset(self):
# Исключаем мягко удалённые записи
return Note.objects.filter(is_deleted=False)
@transaction.atomic
def perform_create(self, serializer):
# Пример использования transaction.atomic для гарантии целостности
serializer.save()
class NoteRetrieveUpdateDeleteView(generics.RetrieveUpdateDestroyAPIView):
"""GET/PUT/PATCH/DELETE конкретной заметки."""
serializer_class = NoteSerializer
lookup_url_kwarg = "pk"
def get_queryset(self):
return Note.objects.filter(is_deleted=False)
def perform_destroy(self, instance):
# Мягкое удаление вместо hard‑delete
instance.soft_delete()
2.3.2 Async‑вьюха для массового импорта
# notes/views.py (async часть)
from django.http import JsonResponse
from django.views import View
from asgiref.sync import sync_to_async
import json
class BulkImportView(View):
"""
Асинхронный эндпоинт, получающий список заметок в JSON и создающий их
пакетно через bulk_create. Работает только в ASGI‑режиме.
"""
async def post(self, request):
data = json.loads(request.body)
notes = [
Note(
title=item["title"],
body=item.get("body", ""),
created_at=item.get("created_at", timezone.now()),
)
for item in data
]
# bulk_create внутри sync‑обёртки, т.к. ORM пока sync‑внутри
await sync_to_async(Note.objects.bulk_create)(notes, batch_size=500)
return JsonResponse({"created": len(notes)}, status=201)
Почему
sync_to_async? Django 4.2 пока не реализует полностью async‑ORM;bulk_createостаётся синхронным, но мы можем вызвать его в отдельном потоке без блокировки event‑loop.
2.4 URL‑маршрутизация
# notes/urls.py
from django.urls import path
from . import views
urlpatterns = [
path("api/notes/", views.NoteListCreateView.as_view(), name="note-list"),
path("api/notes/<int:pk>/", views.NoteRetrieveUpdateDeleteView.as_view(), name="note-detail"),
path("api/notes/bulk-import/", views.BulkImportView.as_view(), name="note-bulk-import"),
]
2.5 Миграции
$ python manage.py makemigrations notes
$ python manage.py migrate
Грабль: если после изменения
Meta.indexesдобавили новый индекс, миграция может занять несколько минут на больших таблицах. Планируйте время простоя или используйтеCONCURRENTLYв PostgreSQL (требует отдельного SQL‑скрипта).
3. Примеры кода и практические лайфхаки
3.1 Оптимизация запросов: select_related vs prefetch_related
Для заметок у нас нет внешних ключей, но в реальном проекте часто добавляют author = ForeignKey(User). В этом случае:
| Ситуация | Выбор |
|---|---|
| Получаем список заметок с автором | Note.objects.select_related('author') |
| Получаем список заметок + их теги (ManyToMany) | Note.objects.prefetch_related('tags') |
select_related использует JOIN, а prefetch_related делает отдельный запрос и «привязывает» результаты в Python. Ошибка «N+1 queries» появляется, когда забывают оба метода.
3.2 Транзакционный контроль
from django.db import transaction
def update_multiple_notes(note_ids, new_title):
with transaction.atomic():
notes = Note.objects.select_for_update().filter(id__in=note_ids, is_deleted=False)
notes.update(title=new_title)
Лайфхак: select_for_update() блокирует строки до коммита, избегая гонок при параллельных обновлениях.
3.3 Индексация и ограничения
class Meta:
constraints = [
models.UniqueConstraint(fields=["title"], condition=Q(is_deleted=False), name="unique_active_title")
]
Это гарантирует, что две активные заметки не могут иметь одинаковый заголовок, но мягко удалённые могут.
3.4 Кеширование часто запрашиваемого списка
# notes/utils.py
from django.core.cache import cache
from .models import Note
def get_recent_notes(limit=10):
key = f"recent_notes:{limit}"
notes = cache.get(key)
if notes is None:
notes = list(Note.objects.filter(is_deleted=False).order_by("-created_at")[:limit])
cache.set(key, notes, timeout=60) # 1 минута
return notes
Грабль: кэшировать ORM‑объекты безопасно, только если они не изменятся в течение TTL. Иначе используйте сериализацию (например, serializer.data).
3.5 Асинхронные сигналы (Django 4.2)
# notes/signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import Note
import asyncio
@receiver(post_save, sender=Note)
async def notify_on_new_note(instance, created, **kwargs):
if created and not instance.is_deleted:
await asyncio.sleep(0) # имитация асинхронной отправки
# например, отправка в Slack через aiohttp (не показано)
Чтобы активировать асинхронный обработчик, добавьте в apps.py:
class NotesConfig(AppConfig):
name = "notes"
def ready(self):
import notes.signals # noqa
4. Нюансы, грабли и альтернативы
| Проблема | Как избежать | Альтернативный подход |
|---|---|---|
| N+1 запросов в списках | select_related/prefetch_related |
Использовать raw SQL (raw()) только в критических участках |
| Большие миграции индексов | CONCURRENTLY (ручной SQL) |
Планировать в окно обслуживания |
| Блокировки при массовом обновлении | select_for_update + transaction.atomic |
Пакетировать операции (bulk_update) |
| Смотрим только активные записи | Всегда фильтровать is_deleted=False |
Реализовать менеджер objects = NoteManager() |
| Асинхронные ORM‑операции | Пока большинство остаются sync, используем sync_to_async |
Переход на полностью async‑ORM (например, tortoise-orm) только при необходимости |
4.1 Менеджер для «только активные» записи
# notes/managers.py
from django.db import models
class ActiveNoteManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(is_deleted=False)
И в модели:
class Note(models.Model):
# ... поля ...
objects = ActiveNoteManager() # default manager
all_objects = models.Manager() # для админки, если нужен доступ к удалённым
4.2 Полнотекстовый поиск (PostgreSQL)
Если понадобится поиск по body, добавляем GIN‑индекс:
class Meta:
indexes = [
models.Index(
name="idx_note_body_gin",
fields=["body"],
opclasses=["gin_trgm_ops"], # require pg_trgm extension
),
]
Тогда запрос:
Note.objects.filter(body__search="django")
5. Вывод
- Ключевые тейкэвы
- Добавляйте индексы сразу, а не «потом».
- Мягкое удаление + кастомный менеджер упрощают фильтрацию без изменения бизнес‑логики.
transaction.atomic+select_for_updateзащищают от гонок при массовом изменении.- Для больших импортов используйте
bulk_createв сочетании сsync_to_async. -
select_related/prefetch_relatedизбавляют от типичной ошибки N+1. -
Ограничения
- Полностью асинхронный ORM пока не доступен в официальном Django 4.2.
- Индексация GIN требует расширения
pg_trgmв PostgreSQL. -
Мягкое удаление усложняет уникальные ограничения, их нужно задавать через
UniqueConstraintс условием. -
Что можно улучшить
- Перевести менеджеры на
QuerySet.as_manager()для более гибкой цепочки вызовов. - Добавить Celery‑задачу для асинхронной отправки уведомлений вместо простого
asyncio.sleep. - Ввести DRF‑throttling и
django-axesдля защиты публичного API, если приложение переедет из «одного пользователя» в «мульти‑user».
Эти лайфхаки делают простое приложение «Заметки» пригодным для реального проекта, а также показывают, как извлечь максимум из современных возможностей Django. Happy coding!