Кушаем кактус

Прошло уже несколько недель, как официально вышла 3 версия Django. Я работал с этой версией ещё до публикации официального релиза и, к сожалению, заметил, что развитие Django сильно замедлилось. Версия 1.3 от 1.7 отличается в разы, а вот 3 версия содержит косметические изменения ветки 2 и не более.

Мой проект winePad стартовал с версии Django 1.3, и к текущему моменту в нем переопределено около 12% внутреннего кода Django.

Видя код новой версии, я понимаю, что правки, которые я или мои коллеги сделали при работе с предыдущими версиями поедут и дальше. А глядя на roadmap и вялотекущие изменения официального репозитория ждать, что ошибки будут скорректированы в будущих версиях — не приходится.

Вот о работе над ошибками я и хочу рассказать:

Метод get


Мало кто догадывается о том, что в стандартном методе get django с самого начала была ошибка. Метод get должен вернуть вам либо один объект либо предупредить о том что найдено несколько объектов или же сообщить, что объектов нет.

1  def get(self, *args, **kwargs):
2         clone = self._chain() if self.query.combinator else self.filter(*args, **kwargs)
3         if self.query.can_filter() and not self.query.distinct_fields:
4             clone = clone.order_by()
5         limit = None
6         if not clone.query.select_for_update or connections[clone.db].features.supports_select_for_update_with_limit:
7             limit = MAX_GET_RESULTS
8             clone.query.set_limits(high=limit)
9         num = len(clone)
10        if num == 1:
11            return clone._result_cache[0]
12        if not num:
13            raise self.model.DoesNotExist()
14        raise self.model.MultipleObjectsReturned()

Строка 9 получает данные по ВСЕМ записям которые указаны в queryset и переводит их в набор объектов. Об этом есть предупреждение в документации.

До 3 версии ограничения на количество запрашиваемых объектов не было. Это означает, что get получал абсолютно все данные и превращал их в объекты, прежде, чем дать предупреждение что объектов много.

В итоге вы могли получить несколько миллионов объектов в памяти, только для того, чтобы узнать, что найден более, чем 1 объект. Сейчас появились строки 5,7,8. И теперь вы гордо получите только MAX_GET_RESULTS=21 объект, прежде чем узнать, что объектов больше чем 1.
При тяжёлом "__init__" задержка будет значительной. Как лечить:
Переопределить MAX_GET_RESULTS в django.db.models.query.py
переопределить GET или перед вызовом GET использовать:

vars(queryset.query).update({'high_mark':2, 'low_mark':0})
или
queryset.query.set_limits(0,2)

Метод __init__ моделей Django


Не совсем понятно объявление в коде встроенного метода __init__

_setattr=setattr

вероятно, это для псевдоубыстрения кода переносом ссылки на функцию в локальный словарь, но речь не об этом. Проблем несколько:

1. Если передать дополнительные пары аттрибут=значение в __init__ модели вы получите «got an unexpected keyword argument».

В таком случае я предлагаю не утяжелять метод __init__ а делать добавление атрибутов после инициализации:

obj = MyClass()
vars(obj).update({'attr':val, 'attr2':val2 ...})

2. В новой Django добавили возможность переопределения дескрипторов на любое поле модели (Field.descriptor_class). Но ни один дескриптор не знает, инициализирован объект, или ещё нет. Это надо, например, если дескриптор будет использовать данные из prefetch_related объектов, которые появятся только после инициализации главного объекта.

Использовать сигнал окончания инициализации мне не нравится, поскольку подписчиков может быть очень много.

В таком случае я не придумал ничего умнее, чем переопределить __init__ и добавить аттрибут окончания инициализации.

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._end_init = True

Querysets


Жестко прописанные EmptyQuerySet/DateQuerySet убраны, уже хорошо. Однако ситуация с queryset в роли менеджера мне идеологически не нравится.

Если я хочу переопределить класс QuerySet создаваемых менеджерами, я добавляю атрибут _queryset_class

class MyManager(models.Manager):
    _queryset_class = MyQuerySet

Внимание, для старых версий это не работает, можно сделать, например, так:

class MyManager(models.Manager):
    def get_query_set(self):
        response = super(MyManager, self).get_query_set()
        response.__class__ = MyQuerySet
        return response

inlineFormset панели администратора


Проблем несколько:

1. Нельзя отобразить стандартным inlineFormset записи не имеющие напрямую связь с главным объектом формы. Например: форма правки цен товара. В середине лежит справочная статистическая Tabularinline форма закупочных оптовых цен на «подобный» товар.

Решается переопределением метода get_formset inline модели и созданием собственного MyFormSet унаследованного от BaseInlineFormSet

def get_formset(self, request, obj=None, **kwargs):
        kwargs['formset'] = MyFormSet
        super().get_formset(request, obj, **kwargs)

class MyFormSet(BaseInlineFormSet):
    pass

2. Если вы правите обьект с inlineformset в админ панели, а в это время кто-то удалит одну из записей обьектов внутри inlineformset через другой механизм, то вы получите ошибку и сохранить обьект не удастся. Только через kopy paste в новом окне браузера.

Я нашел пока только одно Решение — не использовать inlineformset.

Панель администратора


«Киллер-фича» Django, является самым большим кактусом проекта:

1. Действие «Удаление объектов» в администраторах моделей видны по умолчанию, не важно, есть ли права у пользователя на удаление, или нет.

Решается отключением этого действия по умолчанию:

admin.site.disable_action('delete_selected')

2. Создание дополнительных прав пользователя из админ панели будет невозможно пока вы не включите администратор модели Permissions:

from django.contrib.auth.models import Permission
class PermissionsAdmin(admin.ModelAdmin):
	search_fields = ('name', 'codename','content_type__app_label', 'content_type__model')
	list_display = ('name', 'codename',)
	actions = None
admin.site.register(Permission, PermissionsAdmin)  

3. Увы, прав на доступ только к определенным объектам в Django не существует.

Это возможно решить через прописывание записи в djangoAdminLog со специальным флагом.
А после проверять наличие флага:

user.logentry_set.filter(action_flag=ENABLED, .....).exists()

4. Если вы создаете действия администратора, так, как стоит в документации, то помните, что они не протоколируются в djangoAdminLog автоматически.

5. Еще недостаток этой части документации — все примеры только на функциях. А как же GCBV? В моих проектах все действия администраторов моделей переведены на GCBV. Репозиторий.

Подключение действия стандартно:

class MyAdmin(admin.ModelAdmin):
    actions = (MyActionBasedOnActionView.as_view(),)

ContentType — реестр моделей django


50% гениальность / 50% тупость.
Ни у одной модели нет доступа к реестру моделей по умолчанию.
У нас в проектах решается добавлением миксина во все классы:

from django.contrib.contenttypes.models import ContentType
class ExportMixin(object):
    @classmethod
    def ct(cls):
        if not hasattr(cls, '_ct'):
            cls._ct, create = ContentType.objects.get_or_create(**cls.get_app_model_dict())
            if create:
                cls._ct.name = cls._ct.model._meta.verbose_name
                cls._ct.save()
        return cls._ct

    @classmethod
    def get_model_name(cls):
        if not hasattr(cls, '_model_name'):
            cls._model_name = cls.__name__.lower()
        return cls._model_name

    @classmethod
    def get_app_name(cls):
        if not hasattr(cls, '_app_name'):
            cls._app_name = cls._meta.app_label.lower()
        return cls._app_name

    @classmethod
    def get_app_model_dict(cls):
        if not hasattr(cls, '_format_kwargs'):
            cls._format_kwargs = {'app_label': cls.get_app_name(), 'model': cls.get_model_name()}
        return cls._format_kwargs

теперь мы можем вызывать obj.ct() при необходимости.

UserModel


Возможность переопределения модели пользователя появилась в версии 1.5.

Но к 3 версии так и не исправили model=User в стандартных UserCreationForm/UserChangeForm.
Решается:

from django.contrib.auth.forms import UserCreationForm
from django.contrib.auth import get_user_model
class MyUserCreationForm(UserCreationForm):
    class Meta:
        model= get_user_model()

Система переводов


Разметка текстов, видимых пользователю, выполняется тегами

{% trans %}

или в коде через

gettext_lazy

Однако, управление этими ресурсами в админпанели отсутствует. Вообще.

Есть внешние решения, все они работают кое-как.

Например, Rosetta систематически теряет тексты, и глючно работает интерфейс. Нигде нет проверки прав доступа к переводам. Для работы необходимы систематические makemessages / compilemessages…

В winePad теги trans, blocktrans и gettext_lazy переопределены и мы стали получать тексты из кеша. Если нет кеша, то кешированный запрос из базы get_or_create избавил нас и от makemessages.

Тема мультиязычности — вообще сложная. Встроенное в Django решение работает только для статических текстов. А ведь есть еще необходимость перевода данных моделей. Я попробовал по-своему решить вопрос перевода динамических текстов в проекте django-TOF, где я соединил возможности model-translation и Parler/Hvad. Вероятно, кому-то будет интересно заглянуть.

Пока я остановлю повествование, поскольку статья по исправлению недостатков Django легко может превратится в longread.

Прошу вас рассказать, как вы улучшаете свою Django. Если будет продолжение, я систематизирую появившиеся идеи.

p.s. Некоторые коды написаны в старой нотации, надеюсь на понимание, не всегда находится время или сотрудники на рефакторинг.

Комментарии (21)


  1. Hellpain
    19.12.2019 22:05

    Метод get, возвращающий более 2х записей, да еще и миллионы, это такой редкий кейс ошибки, вы реально ради этого джангу поманчипатчили?)


    1. danilovmy Автор
      19.12.2019 22:56

      Если судить по тому, что ограничение на количество получаемых объектов методом get уже попало в официальный релиз Django 3, то получается, что я не один такой сказочный долбо§б.

      Я уже в статье отметил, что считаю, что в этой функции ошибка. Ошибка в логике: функция должна получать только один объект, если он есть. Не два, не 25, и не два миллиона. Жаль только, что мой манкипатчинг (смешное слово) не исправляет эту ошибку а только уменьшает количество получаемых объектов.


      1. Hellpain
        20.12.2019 12:58
        +2

        метод get действительно должен возвращать один объект, но это не значит что в sql должен быть limit 1. То, что можно получить несколько записей подстраховывает от ошибок, когда метод get выбирает по неуникальному кортежу. Если вы не хотите гарантировать эту уникальность, то берите .first()/.last(). Вы не будете получать ошибок в случае неправильно сформированных параметров запроса.


        1. danilovmy Автор
          20.12.2019 13:35
          +1

          По логике, данная функция должна инициировать и возвращать только один объект, и делать это только в том случае, если объект один.

          В реальной Django это невозможно сделать за один запрос. Потому текущая логика метода такова:

          • Проинициализировать объекты данными из запроса, будет проинициализировано столько объектов, сколько возвращено строк,
          • Объекты сохраняются в "_result_cache".
          • Если обьект один — Get вернет ссылку на первый и единственный объект в "_result_cache"
          • GET выдаст ошибку «DoesNotExist» если _result_cache пустой
          • GET вернет MultipleObjectsReturned, при этом "_result_cache" будет заполнен несколькими объектами и их количество не учитывается.


          В Django 3 появилось ограничение в 25 строк, это значит, что в sql запроса есть «LIMIT». Во всех предыдущих версиях этого ограничения не было вообще, и мы поставили у себя в проекте ограничение на количество возвращенных строк до 2х.
          В этом случае будут проинициированы максимум два обьекта. После чего Get выдаст ошибки, если получен иной результат, чем один объект.

          Ограничение на 2 объекта вместо 25 я предлагаю и для новой Django.


          1. Hellpain
            20.12.2019 14:47

            еще раз — если у вас .get() возвращает более 2х элементов, то это ошибка в коде.
            то что лимит в джанге добавили — это хорошо. но между 2 и 25 разницы особой нет, а вот миллионы да, могут стрельнуть


            1. danilovmy Автор
              20.12.2019 15:00

              метод get возвращал и возвращает только один объект или выдает ошибки. Ни в коде, ни в моем тексте я не вижу упоминаний что get по окончанию возвращает что-то другое.

              прошу указать: где в коде или тексте статьи ошибка, подразумевающая, что результатом работы метода GET будет возврат двух и более объектов.


              1. Hellpain
                20.12.2019 15:13

                В итоге вы могли получить несколько миллионов объектов в памяти, только для того, чтобы узнать, что найден более, чем 1 объект.

                вот же.
                возвращает .get() один объект, но ему еще надо рейзить ошибку, когда из базы более 1 объекта прилетело, что свидетельствует об ошибке в логике программы.


                1. danilovmy Автор
                  20.12.2019 15:24

                  Речь идет о методе GET в родном коде django (django/db/models/query.py). Я описал как он работает:
                  Объекты сохраняются в "_result_cache".
                  Если обьект один — Get вернет ссылку на первый и единственный объект в "_result_cache"
                  GET выдаст ошибку «DoesNotExist» если _result_cache пустой
                  GET вернет MultipleObjectsReturned, при этом "_result_cache" будет заполнен несколькими объектами и их количество не учитывается.
                  _result_cache хранится в памяти, методом GET не возвращается.


                  1. Hellpain
                    20.12.2019 15:33

                    все верно. а чем противоречие?


                    1. danilovmy Автор
                      20.12.2019 15:46

                      Противоречие в том, что моя фраза «В итоге вы могли получить несколько миллионов объектов в памяти, только для того, чтобы узнать, что найден более, чем 1 объект.» не говорит, что результатом работы метода GET будет возврат двух и более объектов.

                      однако именно она была приведена с комментарием «вот же», как пример того, что результатом работы метода GET будет возврат более одного объекта.


                      1. swelf
                        20.12.2019 16:16

                        Мне кажется изначально вам о другом говорилось, если у вас постоянно вылетает MultipleObjectsReturned, то надо не заниматься оптимизацией обработки этой ошибки, а правильно построить запросы или брать элемент при помощи .first().
                        Если get возвращает миллион записей а вы ожидаете одну(иначе почему get используется), то наверно не в get проблема


                        1. danilovmy Автор
                          20.12.2019 17:01

                          «Если get возвращает миллион записей...» — get возвращает один объект или ошибки. В процессе работы GET может создать много объектов на основе записей, возвращенных из базы.

                          Эта логическая ошибка в стандартном методе get заключается в том, что на 9 строке метода создаются объекты, которые, если их можно было создать больше одного — создаваться не должны были в принципе. А они создаются, и только потом проверяется их количество.

                          Кстати, когда выпал MultipleObjectsReturned, на обработке ошибки воспользоваться этими УЖЕ созданными «MultipleObjects» нельзя.


                          1. swelf
                            20.12.2019 17:16

                            Я если честно ниче не понял, вы то пишите get, то GET. для меня это разные вещи, так как «GET» относится к методу get в CBV, а «get» это метод выборки в query. Приведите пример?

                            Вы делаете Human.objects.get(age__gt=20) и ругаетесь что django перед тем как выдать ошибку создает несколько миллиардов Human инстансев?
                            Да это плохо, да можно уже на втором бросить исключение, но так ваша цель получить какого-то человека а не ошибку, то наверно надо переписать либо условие выборки по уникальному id, либо брать случайного человека типа order_by('?').first()


                            1. mayorovp
                              22.12.2019 11:18

                              Если бы этого исключения и правда никогда не должно было происходить ("наша цель получить какого-то человека а не ошибку"), то можно было бы просто писать всегда first. Для корректного кода это равноценная замена.


                              Но если мы пишем get — значит, иногда всё-таки исключение происходить должно. И видеть в этом "иногда" серьёзную деградацию производительности очень не хочется.


                              1. danilovmy Автор
                                23.12.2019 11:10

                                Спасибо.


  1. serwiz
    20.12.2019 00:23
    +2

    50% гениальность / 50% тупость.

    К сожалению вся джанга такая. Админка подходит только для стандартных задач, кастомизируется через жуткие костыли. Метод get вообще перестали использовать, только first. Да много чего хотелось бы поменять, поэтому вопрос, не пробовали пропихнуть свои изменения в саму джангу вместо очередной батарейки?


    1. ZaEzzz
      20.12.2019 08:12

      Админка подходит только для стандартных задач, кастомизируется через жуткие костыли.

      Будь моя воля, я бы вообще запретил использовать админку джанги для чего-то нестандартного. А то накидают костылей, а потом сиди и разгребай все это в попытках обновится на следующую версию.
      Так и сидим на 1.5 в одном проекте — фичи нужно завозить, а на перепиливание админки времени нет (там используются неподдерживаемые более модули на JS).


    1. danilovmy Автор
      20.12.2019 13:41

      Пробовал. Десять открытых тикетов на сайте Джанго проекта и один несостоявшийся пулл реквест про конвертацию флоат/децимал- писал в предыдущей статье. В итоге плюнул, правим в нашем проекте под себя.


  1. danilovmy Автор
    20.12.2019 00:31

    Пробовал. Десять открытых тикетов на сайте Джанго проекта и один несостоявшийся пулл реквест про конвертацию флоат/децимал- писал в предыдущей статье. В итоге плюнул, правим в нашем проекте под себя.


  1. kalombo
    20.12.2019 09:04

    к сожалению, заметил, что развитие Django сильно замедлилось

    Хм, в Django 3.0 же добавили поддержку асинхронности, разве это не большой шаг вперед? Я, правда, еще не смотрел как это выглядит, может быть это просто маркетинговый ход и работать с этим невозможно?


    1. rSedoy
      20.12.2019 09:25

      Правильней, сделали первый маленький шажок для добавления асинхронности, работы там дофига, есть целая статья рассказывающая что и как