Хабрахабр

Django under microscope

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

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

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

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

$ 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=,
)

Появляется 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 в сервер.

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

Что внутри 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 в проекте, только централизованно. Не надо ходить и расставлять обертки руками по проекту, всё удобно и логично.

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

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.

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

Дальше мы наконец попадаем во 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 частей.

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

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

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

# 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-полем, а будет сравниваться с value-значением. При обходе дерева, каждый из участков опрашивает свои дочерние ноды, получает вложенные SQL-запросы, и в результате мы сможем построить SQL, как строку. Так же работает и конкатенация, и отрицание запросов — рекурсивным обходом по дереву, у каждой ноды которого вызывается каст к 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 типах собирает инстанс модели. Это не сложная итерация.

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

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

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

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»