Почему нельзя трогать Model.save и остальные методы записи в БД
В статьях и документации к Django авторы часто советуют помещать код прямо внутрь метода save
модели данных — мол, так удобнее и надёжнее. На деле это не так. Ниже разберёмся почему.
Пример #1. Считаем координаты
У вас есть модель заказа к БД, Order
:
class Order(models.Model):
address = models.TextField(...)
status = models.CharFiled(...)
...
Заказ надо доставить клиенту, и для курьера вы решили добавить ещё два поля с координатами. Называться они будут lon
и lat
:
class Order(models.Model):
address = models.TextField(...)
status = models.CharFiled(...)
lon = models.FloatField(null=True)
lat = models.FloatField(null=True)
...
Координаты можно вычислить автоматически с помощью геокодера Яндекса. Он просит адрес места, а возвращает координаты на карте.
Куда положить этот код? Вы решили сделать это через переопределение save
:
def save(self, *args, **kwargs):
if self.address:
self.lon, self.lat = fetch_coordinates(YANDEX_API_KEY, self.address)
super().save(*args, **kwargs)
Получилось круто: добавили всего 4 строчки, а координаты теперь рассчитываются сами при любом вызове save
: вьюхи, формы, админка, что угодно.
Затем пришёл заказ на новую “фичу”. Теперь в заказе нужно поле с “примерным временем доставки”. Называться оно будет estimated_dilivery_at
. Чтобы рассчитать время доставки для всех имеющихся заказов вам понадобилась дата-миграция. В цикле вы пройдёте по всем заказам, вычислите estimated_dilivery_at
и сохраните Order
с новыми данными – вызовете save
.
Метод save
делает всего один запрос к геокодеру, в интернет. Сайту уже 2 года, заказов доставлено аж 100 тысяч. Один запрос по интернету займёт около 0.2 секунд, а это 5 часов на одну дата-миграцию. Аж 5 часов на ненужные запросы к геокодеру?! Ведь вы даже не трогаете ни адрес, ни координаты!
Хорошо, попробуем решить проблему: добавим условие в save
. Если координаты уже рассчитаны, то снова рассчитывать их не будем:
def save(self, *args, **kwargs):
if self.address and not (self.lon and self.lat):
self.lon, self.lat = fetch_coordinates(YANDEX_API_KEY, self.address)
super().save(*args, **kwargs)
Если координаты уже вычислены, то и запроса к геокодеру не будет. Но теперь вы создали новую проблему: координаты не пересчитаются при изменении адреса.
Если убрать эту логику, то снова упрёмся в проблему с дата-миграцией.
И как быть?
Чтобы не попадать в такие ситуации просто не переопределяйте save
. Вместо этого вызовите функцию fetch_coordinates(...)
вручную прямо перед save()
. Да, придётся вызывать этот метод везде: во views
, в админке и так далее.
Это кажется нарушением принципа DRY, копипастой. Но на самом деле всё это разные кейсы: редактирование заказа в админке, оформление заказа клиентом, дата-миграции обновляющие поля заказов.
Все эти ситуации имеют разную логику на случай проблем с сетью. Если у клиента не получается сделать заказ, то это фиаско… Уж лучше остаться без координат, но с оформленным заказом: менеджер и курьер дальше сами залезут на Яндекс.Карты. Если сломалась дата-миграция, то ничего страшного вообще не случилось, можно накатить ещё одну. Если сопротивляется админка – просто отправляем уведомление в Rollbar или другую систему мониторинга.
Иногда копипаста – это лучшее решение
Пример #2. Сгенерировать slug
Проблема предыдущего примера сама разрешится, если вам не нужны запросы в интернет. Рассмотрим другой пример. Вы хотите для каждого объекта создать slug
: уникальный и человекочитаемый id. Снова переопределили метод save
:
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.title)
super().save(*args, **kwargs)
Сразу оговоримся, что для русскоязычных сайтов
slug
всё же лучше заставлять вбивать менеджеров сайта, а то получите трешовый slug с транслитом по типуkak_pereopredelyat_metod_save
.
Вы не хотите заставлять пользователей вбивать slug самостоятельно, и это желание понятно. Будем генерить слаги автоматически. Что в этом плохого?
Метод save
делает не то, что обещает
Метод save
обещал сохранить объект в БД, а сам придумывает slug
. Нехорошо. У save()
одно предназначение: положить данные в БД. Больше он ничего делать не должен. См. статью Больше функций.
Код внутри save
не сработает в bulk
-операциях
Сломается часть стандартных методов QuerySet
, таких как bulk_create
и bulk_update
. Будут проблемы с написанием дата-миграций и массовым созданием/обновлением чего-либо.
Код внутри save
опасно редактировать
Если вы меняете код внутри save()
даже в средненьком проекте на 20к строк, то вы охренеете очень долго будете этот код тестировать. Метод save()
используется просто везде: в админке, во вьюхах, в формах, при вызове других методов: update
, create
, get_or_create
… Каждый случай использования сначала придётся найти в коде, а потом протестировать. Если у вас ещё и нет автотестов в проекте, то лучше просто никогда этот код больше не трогать. Работает – не трогай.
Поломка в save()
полностью парализует сайт
Это не какая-то одна вьюха, отдающая 500. Вся база теперь недоступна. И эту бомбу вы подкладываете себе сами, ведь можно было просто не переопределять save()
.
И как быть?
Решений много. Конкретно для slug
есть нативное решение prepopulated_fields. Это такая настройка админки, она генерирует slug
на основе других полей модели. Технически это реализовано с помощью JS-кода. Это решение очень удобно для менеджера сайт, он увидит сгенерированный слаг сразу уже при заполнении полей в админке, а не после сохранения.
Если вас не устраивает поведение prepopulated_fields, то можете подключить другую JS-библиотеку. Это совсем не сложно. Начните поиски с npmjs.com.
Есть решение с атрибутом поля default, но оно сработает только если вам не нужен инстанс с которым вы работаете, т.е. self
.
В остальных случаях можно сделать отдельную функцию, которая делает то, что вы хотите. Если без self
эта функция теряет смысл, то пусть это будет метод модели. Но отдельный, не приколоченный гвоздями к save
. Тогда его можно будет вызывать там, где он нужен. Не переопределяйте стандартное, а добавьте своё, новое.
Если подобных функций нужна целая пачка, то сделайте функцию-обёртку. В случае чего как вы их объединили, так же сможете и разобрать, использовать по-отдельности. Ещё один плюс – все изменения в коде становятся предсказуемыми. Изменили код функции? Не беда, пройдитесь поиском по проекту, и вы легко найдите все места где функцию вызывали.
Если свой код вам нужен для проверки данных и очистки, то присмотритесь к методу Model.clean()
. Подробнее в документации.
Переопределить save
просто, но и другие варианты не сложнее
А какие аргументы в пользу save()
?
Основной аргумент – хочу писать весь код в одном месте и соблюдать DRY. Хочу разом добавить весь код, и больше об этом не думать. Вызвать эти функции/методы-обёртки я могу просто забыть. Придётся постоянно вспоминать об этом и проставлять недостающие вызовы в каждом месте.
В реальности же думать всё равно придётся: не забыть про save()
при bulk
-операциях, вспомнить какие кастомные ошибки он выкидывает, какие поля заполняет сам, а какие – нет. А если на проекте вы не один, код регулярно правят другие программисты, то перечитывать код save
вам придётся буквально перед каждым использованием. Мало ли, что там спрятано…
Можно переопределить save()
и он будет неявно запускаться где-то под капотом у Django, а можно явно вызвать функцию, которая сделает то, что написано у неё в названии. Второй путь экономит вам мыслетопливо.
Попробуйте бесплатные уроки по Python
Получите крутое код-ревью от практикующих программистов с разбором ошибок и рекомендациями, на что обратить внимание — бесплатно.
Переходите на страницу учебных модулей «Девмана» и выбирайте тему.