Почему нельзя трогать 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

Получите крутое код-ревью от практикующих программистов с разбором ошибок и рекомендациями, на что обратить внимание — бесплатно.

Переходите на страницу учебных модулей «Девмана» и выбирайте тему.