Django under microscope
Если по докладу Артёма Малышева будут снимать фильм, то режиссером выступит Квентин Тарантино — один фильм про 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
(?P\d{1,3}(?:\.\d{1,3}){3}) | # IPv4 address
(?P\[[a-fA-F0-9:]+\]) | # IPv6 address
(?P[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*) # FQDN
):)?(?P\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 нужен, чтобы прочитать у запроса. Также сам сервер дает 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//', views.year_archive),
path('articles///', views.month_archive)
]
Resolver
Так выглядит резольвер:
# django.urls.resolvers
_PATH_RE = re.compile(
r'<(?:(?P[^>:]+):)?(?P\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
{% for entry in entries %}
- {{ entry.name }}
{% endfor %}
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.