django

Django under microscope

  • пятница, 15 марта 2019 г. в 00:20:15
https://habr.com/ru/company/oleg-bunin/blog/433482/
  • Блог компании Конференции Олега Бунина (Онтико)
  • Django
  • Python
  • Программирование
  • Разработка веб-сайтов


Если по докладу Артёма Малышева (proofit404) будут снимать фильм, то режиссером выступит Квентин Тарантино — один фильм про Django он уже снял, снимет и второй. Все подробности из жизни внутренних механизмов Django от первого байта HTTP-запроса до последнего байта ответа. Феерия работы парсер-форм, остросюжетная компиляция SQL, спецэффекты реализации шаблонизатора для HTML. Кем и как управляется connection pool? Всё это в хронологическом порядке обработки WSGI-объектов. На всех экранах страны — расшифровка «Django under microscope».



О спикере: Артём Малышев — основатель проекта Dry Python и Core-разработчик Django Channels версии 1.0. Пишет на Python 5 лет, помогал организовывать митапы «Rannts» по Python в Нижнем Новгороде. Артём может быть знаком вам под ником PROOFIT404. Презентация к докладу хранится здесь.

Когда-то давным-давно мы запустили еще старую версию Django. Тогда она выглядела страшно и уныло.



Увидели, что self_check прошел, мы все правильно установили, все заработало и теперь можно писать код. Чтобы всего этого добиться, мы должны были запустить команду django-admin runserver.

$ django-admin runserver 
Performing system checks…

System check identified no issues (0 silenced).

You have unapplied migrations; your app may not work properly until they are applied. Run 'python manage.py migrate1 to apply them.

August 21, 2018 - 15:50:53
Django version 2.1, using settings 'mysite.settings'
Starting development server at http://127.0.0.1:8000/Quit the server with CONTROL-C.

Процесс запускается, обрабатывает HTTP запросы и внутри происходит вся магия и исполняется весь тот код, который мы хотим показать пользователям в виде сайта.

Installation


django-admin появляется в системе, когда мы устанавливаем Django с помощью, например, pip — пакетного менеджера.

$ pip install Django

# setup.py
from setuptools import find_packages, setup

setup(
    name='Django',
    entry_points={
        'console_scripts': [
            'django-admin =
                django.core.management:execute_from_command_line'
        ]
    },
)

Появляется entry_points setuptools, который указывает на функцию execute_from_command_line. Эта функция — точка входа для любой операции с Django, для любого текущего процесса.

Bootstrap


Что происходит внутри функции? Bootstrap, который делится на две итерации.

# django.core.management
django.setup().

Configure settings


Первая — это чтение конфигов:

import django.conf.global_settings
import_module(os.environ["DJANGO_SETTINGS_MODULE"])

Читаются настройки по умолчанию global_settings, потом из переменной среды мы пытаемся найти модуль с DJANGO_SETTINGS_MODULE, который написал сам пользователь. Эти настройки объединяются в один name space.

Кто написал на Django хотя бы «Hello, world», знает, что там есть INSTALLED_APPS — где мы как раз пишем пользовательский код.

Populate apps


Во второй части все эти applications, по сути пакеты, итерируем по одному. Создаем для каждого Config, импортируем модели для работы с базой данных и проверяем модели на целостность. Дальше фреймворк отрабатывает Check, то есть проверяет, что у каждой модели есть primary key, все foreign key указывают на существующие поля и что в BooleanField не написано поле Null, а используется NullBooleanField.

for entry in settings.INSTALLED_APPS:
    cfg = AppConfig.create(entry)
    cfg.import_models()

Это минимальный sanity check для моделей, для админки, для чего угодно — без подключения к базе, без чего-то сверхсложного и специфичного. На этой стадии Django еще не знает, какую команду вы попросили исполнить, то есть не отличает migrate от runserver или shell.

Дальше мы попадаем в модуль, который пытается угадать по аргументам командной строки, какую команду мы хотим исполнить и в каком приложении она лежит.

Management command


# django.core.management
subcommand = sys.argv[1]
app_name = find(pkgutils.iter_modules(settings.INSTALLED_APPS))
module = import_module(
    '%s.management.commands.%s' % (app_name, subcommand)
)
cmd = module.Command()
cmd.run_from_argv(self.argv)

В данном случае в модуле runserver будет встроенный модуль django.core.management.commands.runserver. После импорта модуля, по convention внутри вызывается глобальный класс Command, инстанцируется, и мы говорим: " Я тебя нашел, вот тебе аргументы командной строки, которые передал пользователь, сделай с ними что-нибудь".

Дальше идем в модуль runserver и видим, что Django сделан из «regexp’ов и палок», про которые я буду сегодня подробно рассказывать:

# django.core.management.commands.runserver
naiveip_re = re.compile(r"""^(?:
(?P<addr>
    (?P<ipv4>\d{1,3}(?:\.\d{1,3}){3}) |                 # IPv4 address
    (?P<ipv6>\[[a-fA-F0-9:]+\]) |                       # IPv6 address
    (?P<fqdn>[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*) # FQDN
):)?(?P<port>\d+)$""", re.X)

Commands


Скроллим вниз на полтора экрана — наконец попадаем в определение нашей команды, которая запускает сервер.

# django.core.management.commands.runserver
class Command(BaseCommand):

    def handle(self, *args, **options):

        httpd = WSGIServer(*args, **options)
        handler = WSGIHandler()
        httpd.set_app(handler)
        httpd.serve_forever()

BaseCommand проводит минимальный набор операций, чтобы аргументы командной строки привести к аргументам вызова функции *args и **options. Мы видим, что здесь создается инстанс WSGI-сервера, в этот WSGI-сервер устанавливается глобальный WSGIHandler — это как раз и есть God Object Django. Можно сказать, что это единственный инстанс фреймворка. На сервер инстанс устанавливается глобально — через set application и говорит: «Крутись в Event Loop, исполняй запросы».

Всегда где-то есть Event Loop и программист, который ему дает задачи.

WSGI server


Что же такое WSGIHandler? WSGI — это интерфейс, который позволяет обрабатывать HTTP-запросы с минимальным уровнем абстракции, и выглядит, как нечто в виде функции.

WSGI handler


# django.core.handlers.wsgi
class WSGIHandler:
    def __call__(self, environ, start_response):
        signals.request_started.send()
        request = WSGIRequest(environ)
        response = self.get_response(request)
        start_response(response.status, response.headers)
        return response

Например, здесь это экземпляр класса, у которого определен call. Он ждет к себе на вход dictionary, в котором уже в виде байтов и файл-handler будут представлены headers. Handler нужен, чтобы прочитать <body> у запроса. Также сам сервер дает callback start_response, чтобы мы могли одной пачкой отослать response.headers и его заголовок, например, status.

Дальше мы можем через объект response передавать тело response в сервер. Response — это генератор, по которому можно итерироваться.

Все сервера, которые написаны для WSGI — Gunicorn, uWSGI, Waitress, работают по этому интерфейсу и взаимозаменяемы. Мы сейчас рассматриваем сервер для девелопмента, но любой сервер приходит к тому, что в Django он стучится через environ и callback.

Что внутри God Object?


Что происходит внутри этой глобальной функции God Object внутри Django?

  • REQUEST.
  • MIDDLEWARES.
  • ROUTING запроса на view.
  • VIEW — обработка пользовательского кода внутри view.
  • FORM — работа с формами.
  • ORM.
  • TEMPLATE.
  • RESPONSE.

Вся машинерия, которую мы хотим от Django, происходит внутри одной функции, которая размазана на весь фреймворк.

Request


Оборачиваем environment WSGI, который есть простой dictionary, в какой-то специальный объект, для удобства работы с environment. Например, узнать длину пользовательского запроса удобнее через работу с чем-то похожим на dictionary, чем с байт-строкой, которую нужно парсить и искать в ней вхождения ключ-значение. При работе с куками, тоже не хочется вычислять вручную — истек срок хранения или нет, и как-то это интерпретировать.

# django.core.handlers.wsgi
class WSGIRequest(HttpRequest):
    @cached_property
    def GET(self):
        return QueryDict(self.environ['QUERY_STRING'])

    @property
    def POST(self):
        self._load_post_and_files()
        return self._post

    @cached_property
    def COOKIES(self):
        return parse_cookie(self.environ['HTTP_COOKIE'])

Request содержит парсеры, а также набор handlers для управления обработкой тела POST-запроса: будет ли это файл в памяти или временный в хранилище на диске. Все решается внутри Request. Также Request в Django — это объект-агрегатор, в который все middlewares могут поместить необходимую нам информацию про сессию, аутентификацию и авторизацию пользователя. Можно сказать, что это тоже God Object, но поменьше.

Дальше Request попадает в middleware.

Middlewares


Middleware — это обертка, которая оборачивает другие функции как декоратор. Перед тем как отдать контроль middleware, в методе call мы отдаем response или вызываем уже оборачиваемую middleware.

Так выглядит middleware с точки зрения программиста.

Settings


# settings.py
MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
]

Define


class Middleware:

    def __init__(self, get_response=None):
        self.get_response = get_response

    def __call__(self, request):
        return self.get_response(request)

С точки зрения Django, middlewares выглядят как своеобразный стек:

# django.core.handlers.base
def load_middleware(self):
    handler = convert_exception_to_response(self._get_response)
    for middleware_path in reversed(settings.MIDDLEWARE):
        middleware = import_string(middleware_path)
        instance = middleware(handler)
        handler = convert_exception_to_response(instance)
    self._middleware_chain = handler

Apply


def get_response(self, request):
    set_urlconf(settings.ROOT_URLCONF)
    response = self._middleware_chain(request)
    return response

Берем изначальную функцию get_response, оборачиваем ее handler, который будет переводить, например, permission error и not found error в корректный HTTP-код. Всё оборачиваем в саму middleware из списка. Стек middlewares растет, и каждая следующая оборачивает предыдущую. Это очень похоже на применение одного и того же стека декораторов ко всем view в проекте, только централизованно. Не надо ходить и расставлять обертки руками по проекту, всё удобно и логично.

Мы прошли 7 кругов middlewares, наш request выжил и решил обрабатывать это во view. Дальше мы попадаем в модуль routing.

Routing


Это то, где мы решаем, какой handler вызвать для какого-то конкретного запроса. А решается это:

  • на основании url;
  • в спецификации WSGI, где называется request.path_info.

# django.core.handlers.base
def _get_response(self, request):
    resolver = get_resolver()
    view, args, kwargs = resolver.resolve(request.path_info)
    response = view(request, *args, **kwargs)
    return response

Urls


Берем резольвер, скармливаем ему текущий url запроса и ожидаем, что он вернет саму функцию view, и из этого же url достанет аргументы, с которыми надо вызвать view. Дальше get_response вызывает view, обрабатывает исключения и что-то с этим делает.

# urls.py
urlpatterns = [
    path('articles/2003/', views.special_case_2003),
    path('articles/<int:year>/', views.year_archive),
    path('articles/<int:year>/<int:month>/', views.month_archive)
]

Resolver


Так выглядит резольвер:

# django.urls.resolvers
_PATH_RE = re.compile(
    r'<(?:(?P<converter>[^>:]+):)?(?P<parameter>\w+)>'
)
def resolve(self, path):
    for pattern in self.url_patterns:
        match = pattern.search(path)
        if match:
            return ResolverMatch(
                self.resolve(match[0])
            )
      raise Resolver404({'path': path})

Это тоже regexp, но рекурсивный. Он идет по частям url, ищет то, что хочет пользователь: других пользователей, посты, блоги, либо это какой-то конвертер, например, конкретный год, который нужно вычленить, положить в аргументы, привести к int.

Характерно, что глубина рекурсии метода resolve всегда равна количеству аргументов, с которым вызывается view. Если что-то пошло не так и мы не нашли конкретный url, возникает not found error.

Дальше мы наконец попадаем во view — в код, который написал программист.

View


В самом простом представлении — это функция, которая возвращает request от response, но внутри у нее мы выполняем логические задачи: «за, если, когда-нибудь» — много повторяющихся задач. Django нам предоставляет class based view, где можно указать конкретные детали, и все поведение будет интерпретировано в правильном формате уже самим классом.

# django.views.generic.edit
class ContactView(FormView):
    template_name = 'contact.html'
    form_class = ContactForm
    success_url = '/thanks/'

Method flowchart


self.dispatch()
self.post()
self.get_form()
self.form_valid()
self.render_to_response()

Метод dispatch этого инстанса лежит уже в url mapping вместо функции. Dispatch на основании HTTP verb понимает, какой метод вызвать: к нам пришел POST и мы, скорее всего, хотим инстанцировать объект form, если form валиден, сохранить его в базу и показать шаблон. Это все делается через большое количество миксин, из которых состоит этот класс.

Form


Форма перед тем, как попадет в представление Django, должна быть прочитана из сокета — через тот самый файловый handler, который лежит в WSGI-environment. form-data представляет из себя byte stream, в котором описаны разделители — эти блоки мы можем прочитать и что-то из них сделать. Это может быть соответствие ключ-значение, если это поле, часть файла, потом снова какое-то поле — всё смешано.

Content-Type: multipart/form-data;boundary="boundary"
--boundary
name="field1"
value1
--boundary
name="field2";
value2

Parser


Парсер состоит из 3 частей.

Chunk-итератор, который из byte stream создает ожидаемые чтения — превращает в итератор, который может выдавать boundaries. Он гарантирует, что если что-то и вернет, то это будет boundary. Это нужно, чтобы внутри парсера не надо было хранить состояние коннекта, читать из сокета или не читать, чтобы минимизировать логику обработки данных.

Дальше генератор оборачивается в LazyStream, который создает из него снова файл object, но с ожидаемым чтением. Так парсер уже может ходить по кусочкам байтов и строить из них ключ-значение.

field и data здесь всегда будет являться строками. Если к нам пришла datatime в ISO-формате, уже Django-форма (которая написана программистом) с помощью определенных полей получит, например, timestamp.

# django.http.multipartparser
self._post = QueryDict(mutable=True)
stream = LazyStream(ChunkIter(self._input_data))
for field, data in Parser(stream):
    self._post.append(field, force_text(data))

Дальше форма, скорее всего, захочет сохранить себя в базу данных, и здесь начинается Django ORM.

ORM


Примерно через такой DSL выполняются запросы на ORM:

# models.py
Entry.objects.exclude(
    pub_date__gt=date(2005, 1, 3),
    headline='Hello',
)

С помощью ключей можно собирать подобные SQL-выражения:

SELECT * WHERE NOT (pub_date > '2005-1-3' AND headline = 'Hello')

Как это происходит?

Queryset


У метода exclude под капотом есть объект Query. Объекту в функцию передают аргументы, и он создает иерархию объектов, каждый из которых может превратить себя в отдельный кусочек SQL-запроса в виде строки.

При обходе дерева, каждый из участков опрашивает свои дочерние ноды, получает вложенные SQL-запросы, и в результате мы сможем построить SQL, как строку. Например, ключ-значение будет не отдельным SQL-полем, а будет сравниваться с value-значением. Так же работает и конкатенация, и отрицание запросов — рекурсивным обходом по дереву, у каждой ноды которого вызывается каст к SQL.

# django.db.models.query
sql.Query(Entry).where.add(
    ~Q(
        Q(F('pub_date') > date(2005, 1, 3)) &
        Q(headline='Hello')
    )
)

Compiler


# django.db.models.expressions
class Q(tree.Node):
    AND = 'AND'
        OR = 'OR'
        def as_sql(self, compiler, connection):
            return self.template % self.field.get_lookup('gt')

Output


>>> Q(headline='Hello')
# headline = 'Hello'
>>> F('pub_date')
# pub_date
>>> F('pub_date') > date(2005, 1, 3)
# pub_date > '2005-1-3'
>>> Q(...) & Q(...)
# ... AND ...
>>> ~Q(...)
# NOT …

В этот метод передается небольшой helper-compiler, который может отличить диалект MySQL от PostgreSQL и правильно расставить синтаксический сахар, который используется в диалекте конкретной базы данных.

DB routing


Когда мы получили SQL-запрос, модель стучится в DB routing и спрашивает, в какой базе данных она лежит. В 99% случаев это будет база данных default, в оставшемся 1% — какая-то своя.

# django.db.utils
class ConnectionRouter:
    def db_for_read(self, model, **hints):
        if model._meta.app_label == 'auth':
            return 'auth_db'

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

Connecting pool


# django.db.backends.base.base
class BaseDatabaseWrapper:
    def commit(self):
        self.validate_thread_sharing()
        self.validate_no_atomic_block()
        with self.wrap_database_errors:
            return self.connection.commit()

В этом конкретном connection мы отправляем запросы в сокет, который стучится в БД, и ждем выполнения. Обертка над библиотекой будет читать уже человеческий ответ от БД в виде записи, и Django из этих данных в Python типах собирает инстанс модели. Это не сложная итерация.

Мы что-то записали в базу, что-то прочитали и решили сказать об этом пользователю с помощью HTML-странички. Для этого у Django есть нелюбимый сообществом язык шаблонов, который выглядит, как что-то похожее на язык программирования, только в HTML-файле.

Template


from django.template.loader import render_to_string
render_to_string('my_template.html', {'entries': ...})

Code


<ul>
{% for entry in entries %}
    <li>{{ entry.name }}</li>
{% endfor %}
</ul>

Parser


# django.template.base
BLOCK_TAG_START = '{%'
BLOCK_TAG_END = '%}'
VARIABLE_TAG_START = '{{'
VARIABLE_TAG_END = '}}'
COMMENT_TAG_START = '{#'
COMMENT_TAG_END = '#}'
tag_re = (re.compile('(%s.*?%s|%s.*?%s|%s.*?%s)' %
          (re.escape(BLOCK_TAG_START),
           re.escape(BLOCK_TAG_END),
           re.escape(VARIABLE_TAG_START),
           re.escape(VARIABLE_TAG_END),
           re.escape(COMMENT_TAG_START),
           re.escape(COMMENT_TAG_END))))

Сюрприз — опять regexp. Только в конце должна быть запятая, и список продолжится далеко вниз. Наверное, это самый сложный regexp, который я видел в этом проекте.

Lexer


Обработчик шаблона и интерпретатор устроен довольно просто. Есть lexer, который с помощью regexp переводит текст в список маленьких токенов.

# django.template.base
def tokenize(self):
    for bit in tag_re.split(template_string):
        lineno += bit.count('\n')
        yield bit

Итерируемся по списку токенов, смотрим: «Ты кто? Обернем тебя в тэг-ноду». Например, если это старт какого-то if или for или for, тэг-нода возьмет соответствующий обработчик. Сам же обработчик for опять говорит парсеру: «Прочитай мне список токенов вплоть до закрывающего тэга».

Операция опять идет в парсер.

Нода, тэг и парсер — это взаимно рекурсивные вещи, и глубина рекурсии обычно равна вложенности самого шаблона по тэгам.

Parser


def parse():
    while tokens:
        token = tokens.pop()
        if token.startswith(BLOCK_TAG_START):
            yield TagNode(token)
        elif token.startswith(VARIABLE_TAG_START):
            ...

Обработчик тэга дает нам конкретную ноду, например, с циклом for, у которой появляется метод render.

For loop


# django.template.defaulttags
@register.tag('for')
def do_for(parser, token):
    args = token.split_contents()
    body = parser.parse(until=['endfor'])
    return ForNode(args, body)

For node


class ForNode(Node):
    def render(self, context):
         with context.push():
             for i in self.args:
                 yield self.body.render(context)

Метод render представляет из себя render-дерево. Каждая верхняя нода может пойти в дочернюю, попросить ее отрендериться. Программисты привыкли, что показываются какие-то переменные в этом шаблоне. Это делается через context — он представлен в виде обычного словарика. Это стек словарей для эмулирования области видимости, когда мы входим внутрь тэга. Например, если внутри цикла for сам context поменяет какой-то другой тэг, то, когда мы выйдем из цикла — изменения откатятся. Это удобно, потому что когда все глобально, работать тяжело.

Response


Наконец-то мы получили нашу строку с HTTP-response:

Hello, World!

Мы можем отдавать строку пользователю.

  • Возвращаем этот response из view.
  • View отдает в список middlewares.
  • Middlewares этот response модифицируют, дополняют и улучшают.
  • Response начинает итерироваться внутри WSGIHandler, частично записывается в сокет, и браузер получает ответ нашего сервера.

Все известные стартапы, которые были написаны на Django, например, Bitbucket или Instagram, начинались с такого небольшого цикла, который проходил каждый программист.

Все это, и выступление на Moscow Python Conf++ нужно, чтобы вы лучше понимали, что находится у вас в руках и как этим пользоваться. В любой магии есть большая часть regexp, которые надо уметь готовить.

Артём Малышев и еще 23 отличных спикера 5 апреля снова дадут нам много пищи для размышления и дискуссий на тему Python на конференции Moscow Python Conf ++. Изучайте расписание и присоединяйтесь к обмену опытом решения самых разных задач с использованием Python.