Pull to refresh
291.88
Конференции Олега Бунина (Онтико)
Конференции Олега Бунина

Django under microscope

Reading time14 min
Views13K
Если по докладу Артёма Малышева (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.
Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
+34
Comments17

Articles

Information

Website
www.ontico.ru
Registered
Founded
Employees
11–30 employees
Location
Россия