Вступление
Каждый разработчик хранит в голове список идей, баг‑фиксов и маленьких задач. Превратить эти «мелочи» в сервис, доступный из браузера и мобильных клиентов — отличная возможность отточить навыки Django и собрать набор полезных лайфхаков. В статье мы построим приложение DailyNotes: CRUD‑API, поиск по дате, автоматическое удаление устаревших записей и несколько оптимизаций, которые часто забывают даже опытные бэкендеры.
Что вы получите:
- готовый код моделей, сериализаторов, view‑setов и роутов;
- примеры management‑команды и сигнала;
- рекомендации по индексации, кешу и async‑вью;
- список типичных подводных камней и проверенных решений.
1. Контекст и предпосылки
| Параметр | Значение |
|---|---|
| Python | 3.11 |
| Django | 4.2 (LTS) |
| Django REST Framework | 3.15 |
| База данных | PostgreSQL 15 |
| СУБД‑фичи | JSONField, GIN‑индекс, generated columns |
Проект небольшого микросервиса: одна таблица Note, публичный read‑only эндпоинт и защищённый CRUD для авторизованных пользователей. Ограничения:
- Записи живут максимум 30 дней, потом удаляются автоматически.
- Нужно поддерживать быстрый поиск по дате без полного сканирования таблицы.
- Приложение будет обслуживать до 200 RPS, поэтому важно избежать N+1 запросов.
2. Пошаговая реализация
2.1 Модель
# notes/models.py
from django.conf import settings
from django.db import models
from django.utils import timezone
class Note(models.Model):
"""Ежедневная заметка пользователя."""
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='notes',
)
title = models.CharField(max_length=200)
body = models.TextField()
created_at = models.DateTimeField(default=timezone.now, db_index=True)
tags = models.JSONField(default=list, blank=True) # требует PostgreSQL
# Генерируемый столбец для ускоренного поиска по дате (available from Django 4.2)
day = models.DateField(
editable=False,
db_index=True,
)
class Meta:
ordering = ['-created_at']
indexes = [
models.Index(fields=['owner', 'day'], name='owner_day_idx'),
]
def save(self, *args, **kwargs):
# Заполняем day автоматически, чтобы можно было группировать по дню
if not self.day:
self.day = self.created_at.date()
super().save(*args, **kwargs)
def __str__(self):
return f'{self.title} ({self.owner})'
JSONFieldхранит список тегов без отдельной ManyToMany‑таблицы.day— дата создания, индексирована совместно сownerдля быстрых запросов вида “записки за конкретный день”.save()гарантирует согласованностьdayдаже при миграции данных.
2.2 Сериализатор
# notes/serializers.py
from rest_framework import serializers
from .models import Note
class NoteSerializer(serializers.ModelSerializer):
owner = serializers.ReadOnlyField(source='owner.username')
age_days = serializers.SerializerMethodField()
class Meta:
model = Note
fields = ('id', 'owner', 'title', 'body', 'tags',
'created_at', 'age_days')
read_only_fields = ('created_at',)
def get_age_days(self, obj):
return (timezone.now().date() - obj.created_at.date()).days
ownerвыводится только как username, запись создаётся черезperform_create.age_daysдемонстрирует вычисляемое поле без дополнительного запроса.
2.3 ViewSet и роуты
# notes/views.py
from rest_framework import viewsets, permissions, filters
from django_filters.rest_framework import DjangoFilterBackend
from .models import Note
from .serializers import NoteSerializer
class NoteViewSet(viewsets.ModelViewSet):
"""
CRUD API для заметок.
Список доступен только аутентифицированным пользователям,
но каждый пользователь видит только свои записи.
"""
serializer_class = NoteSerializer
permission_classes = [permissions.IsAuthenticated]
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
filterset_fields = ['day']
search_fields = ['title', 'body', 'tags']
ordering_fields = ['created_at', 'day']
def get_queryset(self):
# SELECT ... FROM notes_note WHERE owner_id = ?
return Note.objects.filter(owner=self.request.user).select_related('owner')
def perform_create(self, serializer):
serializer.save(owner=self.request.user)
# notes/urls.py
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from .views import NoteViewSet
router = DefaultRouter()
router.register(r'notes', NoteViewSet, basename='note')
urlpatterns = [
path('api/', include(router.urls)),
]
select_related('owner')убирает N+1 при сериализацииowner.username.- Фильтр по полю
dayпозволяет быстро находить записи за конкретный день.
2.4 Миграции
$ python manage.py makemigrations notes
$ python manage.py migrate
Django автоматически создаст GIN‑индекс для JSONField (PostgreSQL ≥ 9.4) и обычный B‑tree‑индекс для day.
2.5 Сигналы: автоматическое логирование
# notes/signals.py
import logging
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from .models import Note
logger = logging.getLogger(__name__)
@receiver(post_save, sender=Note)
def log_note_saved(sender, instance, created, **kwargs):
action = 'Created' if created else 'Updated'
logger.info(f'{action} note {instance.id} by {instance.owner}')
@receiver(post_delete, sender=Note)
def log_note_deleted(sender, instance, **kwargs):
logger.info(f'Deleted note {instance.id} by {instance.owner}')
Не забудьте импортировать сигналы в apps.py:
# notes/apps.py
from django.apps import AppConfig
class NotesConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'notes'
def ready(self):
import notes.signals # noqa
2.6 Management‑команда: очистка старых записей
# notes/management/commands/cleannotes.py
from django.core.management.base import BaseCommand
from django.utils import timezone
from notes.models import Note
from datetime import timedelta
class Command(BaseCommand):
help = "Удаляет заметки старше 30 дней."
def handle(self, *args, **options):
cutoff = timezone.now() - timedelta(days=30)
deleted, _ = Note.objects.filter(created_at__lt=cutoff).delete()
self.stdout.write(self.style.SUCCESS(f'Deleted {deleted} notes.'))
Запуск:
$ python manage.py cleannotes
Команда использует один DELETE запрос, благодаря индексу по created_at.
3. Нюансы, грабли и альтернативы
3.1 N+1 запросы
Проблема: При выводе списка заметок и их тегов часто вызывается отдельный запрос на каждый объект.
Решение:
| Что | Как реализовать |
|---|---|
| Owner | select_related('owner') в get_queryset. |
| Tags | JSONField хранит массив, дополнительный запрос не нужен. |
| Доп. связанные модели | prefetch_related при необходимости. |
3.2 Транзакции и атомарность
Создание и обновление заметки в рамках одной бизнес‑операции (например, запись и отправка email) должно быть атомарным:
from django.db import transaction
def create_note_with_notification(user, data):
with transaction.atomic():
note = Note.objects.create(owner=user, **data)
send_notification_email(user.email, note) # может бросить исключение
return note
Если забыть transaction.atomic(), часть операции может выполниться, а часть – нет.
3.3 Индексация
- Индекс
owner + dayускоряет запросfilter(owner=..., day=...). created_atуже имеет индекс (db_index=True), что делает удаление старых записей быстрым.- Для поиска по тегам (
JSONField) добавьтеGinIndex:
from django.contrib.postgres.indexes import GinIndex
class Meta:
indexes = [
GinIndex(fields=['tags'], name='tags_gin_idx'),
]
3.4 Кеширование
Для публичного списка последних 5 записей можно кешировать результат:
# notes/views.py (добавляем метод)
from django.core.cache import cache
from rest_framework.decorators import action
from rest_framework.response import Response
class NoteViewSet(viewsets.ModelViewSet):
# ...
@action(detail=False, methods=['get'], url_path='recent')
def recent(self, request):
cache_key = f'recent_notes_{request.user_id}'
data = cache.get(cache_key)
if not data:
qs = self.get_queryset().order_by('-created_at')[:5]
data = NoteSerializer(qs, many=True).data
cache.set(cache_key, data, timeout=300) # 5 минут
return Response(data)
Кеш чистится автоматически, когда пользователь создаёт/удаляет заметку (можно добавить сигнал, вызывающий cache.delete).
3.5 Async‑вью (Django 4.2)
Если планируется масштабировать до тысяч одновременных запросов, можно переписать только список в async‑режиме:
# notes/views_async.py
from django.http import JsonResponse
from asgiref.sync import sync_to_async
from .models import Note
async def async_note_list(request):
user = request.user
notes = await sync_to_async(list)(
Note.objects.filter(owner=user).order_by('-created_at')[:20]
)
data = [{'id': n.id, 'title': n.title, 'created_at': n.created_at.isoformat()} for n in notes]
return JsonResponse(data, safe=False)
| Плюсы | Минусы |
|---|---|
| Не блокирует цикл событий, лучше при I/O‑нагрузке | Требует ASGI‑сервер (uvicorn, daphne) и тестов на async. |
Позволяет комбинировать с httpx‑запросами к другим сервисам |
Не ускорит чисто CPU‑операции, такие как JSON‑сериализация. |
4. Вывод
- Модели: используйте
JSONFieldдля гибких тегов, добавляйте «генерируемый»day‑столбец и составные индексы (owner,day) — это решает большинство запросов по дате без сканирования. - API:
ModelViewSet+DjangoFilterBackendпокрывает CRUD и поиск;select_relatedиprefetch_relatedубирают N+1. - Поддержка данных: сигнализируйте о изменениях, а
management‑команда автоматически чистит старые записи, экономя место. - Оптимизации: GIN‑индекс для
tags, кеширование часто запрашиваемых списков, атомарные транзакции и, при необходимости, async‑вью. - Грабли: забытый индекс — медленный
DELETE; отсутствиеtransaction.atomic()— неконсистентные данные; неправильныйordering— плохие планы запросов.
Что можно улучшить
- Перенести бизнес‑логику в слой сервиса (service‑objects) для лучшей тестируемости.
- Добавить Celery‑задачу вместо синхронного management‑команды, если очистка должна происходить регулярно без простоя.
- Ввести versioned API (DRF
DefaultRouter+namespace) для будущего расширения функций.
С этими лайфхаками ваш сервис ежедневных заметок будет надёжен, быстрый и легко расширяемый. Happy coding!