Прошло уже несколько недель, как официально вышла 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)
serwiz
20.12.2019 00:23+250% гениальность / 50% тупость.
К сожалению вся джанга такая. Админка подходит только для стандартных задач, кастомизируется через жуткие костыли. Методget
вообще перестали использовать, толькоfirst
. Да много чего хотелось бы поменять, поэтому вопрос, не пробовали пропихнуть свои изменения в саму джангу вместо очередной батарейки?ZaEzzz
20.12.2019 08:12Админка подходит только для стандартных задач, кастомизируется через жуткие костыли.
Будь моя воля, я бы вообще запретил использовать админку джанги для чего-то нестандартного. А то накидают костылей, а потом сиди и разгребай все это в попытках обновится на следующую версию.
Так и сидим на 1.5 в одном проекте — фичи нужно завозить, а на перепиливание админки времени нет (там используются неподдерживаемые более модули на JS).
danilovmy Автор
20.12.2019 13:41Пробовал. Десять открытых тикетов на сайте Джанго проекта и один несостоявшийся пулл реквест про конвертацию флоат/децимал- писал в предыдущей статье. В итоге плюнул, правим в нашем проекте под себя.
danilovmy Автор
20.12.2019 00:31Пробовал. Десять открытых тикетов на сайте Джанго проекта и один несостоявшийся пулл реквест про конвертацию флоат/децимал- писал в предыдущей статье. В итоге плюнул, правим в нашем проекте под себя.
kalombo
20.12.2019 09:04к сожалению, заметил, что развитие Django сильно замедлилось
Хм, в Django 3.0 же добавили поддержку асинхронности, разве это не большой шаг вперед? Я, правда, еще не смотрел как это выглядит, может быть это просто маркетинговый ход и работать с этим невозможно?rSedoy
20.12.2019 09:25Правильней, сделали первый маленький шажок для добавления асинхронности, работы там дофига, есть целая статья рассказывающая что и как
Hellpain
Метод get, возвращающий более 2х записей, да еще и миллионы, это такой редкий кейс ошибки, вы реально ради этого джангу поманчипатчили?)
danilovmy Автор
Если судить по тому, что ограничение на количество получаемых объектов методом get уже попало в официальный релиз Django 3, то получается, что я не один такой
сказочный долбо§б.Я уже в статье отметил, что считаю, что в этой функции ошибка. Ошибка в логике: функция должна получать только один объект, если он есть. Не два, не 25, и не два миллиона. Жаль только, что мой манкипатчинг (смешное слово) не исправляет эту ошибку а только уменьшает количество получаемых объектов.
Hellpain
метод get действительно должен возвращать один объект, но это не значит что в sql должен быть limit 1. То, что можно получить несколько записей подстраховывает от ошибок, когда метод get выбирает по неуникальному кортежу. Если вы не хотите гарантировать эту уникальность, то берите .first()/.last(). Вы не будете получать ошибок в случае неправильно сформированных параметров запроса.
danilovmy Автор
По логике, данная функция должна инициировать и возвращать только один объект, и делать это только в том случае, если объект один.
В реальной Django это невозможно сделать за один запрос. Потому текущая логика метода такова:
В Django 3 появилось ограничение в 25 строк, это значит, что в sql запроса есть «LIMIT». Во всех предыдущих версиях этого ограничения не было вообще, и мы поставили у себя в проекте ограничение на количество возвращенных строк до 2х.
В этом случае будут проинициированы максимум два обьекта. После чего Get выдаст ошибки, если получен иной результат, чем один объект.
Ограничение на 2 объекта вместо 25 я предлагаю и для новой Django.
Hellpain
еще раз — если у вас .get() возвращает более 2х элементов, то это ошибка в коде.
то что лимит в джанге добавили — это хорошо. но между 2 и 25 разницы особой нет, а вот миллионы да, могут стрельнуть
danilovmy Автор
метод get возвращал и возвращает только один объект или выдает ошибки. Ни в коде, ни в моем тексте я не вижу упоминаний что get по окончанию возвращает что-то другое.
прошу указать: где в коде или тексте статьи ошибка, подразумевающая, что результатом работы метода GET будет возврат двух и более объектов.
Hellpain
вот же.
возвращает .get() один объект, но ему еще надо рейзить ошибку, когда из базы более 1 объекта прилетело, что свидетельствует об ошибке в логике программы.
danilovmy Автор
Речь идет о методе GET в родном коде django (django/db/models/query.py). Я описал как он работает:
Объекты сохраняются в "_result_cache".
Если обьект один — Get вернет ссылку на первый и единственный объект в "_result_cache"
GET выдаст ошибку «DoesNotExist» если _result_cache пустой
GET вернет MultipleObjectsReturned, при этом "_result_cache" будет заполнен несколькими объектами и их количество не учитывается.
_result_cache хранится в памяти, методом GET не возвращается.
Hellpain
все верно. а чем противоречие?
danilovmy Автор
Противоречие в том, что моя фраза «В итоге вы могли получить несколько миллионов объектов в памяти, только для того, чтобы узнать, что найден более, чем 1 объект.» не говорит, что результатом работы метода GET будет возврат двух и более объектов.
однако именно она была приведена с комментарием «вот же», как пример того, что результатом работы метода GET будет возврат более одного объекта.
swelf
Мне кажется изначально вам о другом говорилось, если у вас постоянно вылетает MultipleObjectsReturned, то надо не заниматься оптимизацией обработки этой ошибки, а правильно построить запросы или брать элемент при помощи .first().
Если get возвращает миллион записей а вы ожидаете одну(иначе почему get используется), то наверно не в get проблема
danilovmy Автор
«Если get возвращает миллион записей...» — get возвращает один объект или ошибки. В процессе работы GET может создать много объектов на основе записей, возвращенных из базы.
Эта логическая ошибка в стандартном методе get заключается в том, что на 9 строке метода создаются объекты, которые, если их можно было создать больше одного — создаваться не должны были в принципе. А они создаются, и только потом проверяется их количество.
Кстати, когда выпал MultipleObjectsReturned, на обработке ошибки воспользоваться этими УЖЕ созданными «MultipleObjects» нельзя.
swelf
Я если честно ниче не понял, вы то пишите get, то GET. для меня это разные вещи, так как «GET» относится к методу get в CBV, а «get» это метод выборки в query. Приведите пример?
Вы делаете Human.objects.get(age__gt=20) и ругаетесь что django перед тем как выдать ошибку создает несколько миллиардов Human инстансев?
Да это плохо, да можно уже на втором бросить исключение, но так ваша цель получить какого-то человека а не ошибку, то наверно надо переписать либо условие выборки по уникальному id, либо брать случайного человека типа order_by('?').first()
mayorovp
Если бы этого исключения и правда никогда не должно было происходить ("наша цель получить какого-то человека а не ошибку"), то можно было бы просто писать всегда first. Для корректного кода это равноценная замена.
Но если мы пишем get — значит, иногда всё-таки исключение происходить должно. И видеть в этом "иногда" серьёзную деградацию производительности очень не хочется.
danilovmy Автор
Спасибо.