Русский
Русский
English
Статистика
Реклама

Django

Полезности для разработчика на Django

05.04.2021 14:09:20 | Автор: admin

Предисловие

Для написания данной статьи был изучен очень большой пласт материала, разбросанного по всему Интернету, по форумам, чатам, сайтам-блогам, stackoverflow. Я собрал все воедино, так как это пригодится и мне и очень надеюсь, что другие разработчики на Django, также, останутся довольны данным материалом. Если есть что добавить (улучшить) или поправить, пожалуйста, пишите в комментариях или в Диалоги ( личные сообщения ) Хабр.

Тестирование handler 404

Если мы попытаемся тестировать ошибку 404 при заданном debug = True, то будет получать стандартный для Django отчет об ошибке с указанием о причине, но используя следующий метод вы сможете проверить работоспособность отработки 404 ошибки без лишних забот. На работающем сайте настоятельно рекомендую использовать nginx.

  1. Открываем для редактирования файл settings.py, находящийся в каталоге проекта и устанавливаем значение debug = False

  2. В том же каталоге открываем для редактирования файл urls.py и добавляем следующие строки:

from django.urls import re_pathfrom django.views.static import serve #добавляем в заголовкеre_path(r'^media/(?P<path>.*)$', serve, {'document_root': settings.MEDIA_ROOT}),re_path(r'^static/(?P<path>.*)$', serve, {'document_root': settings.STATIC_URL}),

При переключении debug в значение false, мы по умолчанию теряем статику и медиа, но используя данный метод, django продолжить обрабатывать эти данные вместо nginx, к примеру, а также, позволяет проверить отработку 404 или других ошибок в Django при работе на localhost, например при python manage.py runserver .

Формсеты и динамическое добавление форм

Для подготовки этого материала ушло достаточно много времени, сотни незакрытых вкладок в поисках полезной информации, а так множество вопросов в чатах разработчиков на Python/Djangoи даже появился на светсайт для создания резюмес динамическим добавлением полей формы, где представлен и используетсяданный функционал.
(Демо учетная запись:
Логин: habrhabr
Пароль: pp#6JZ2\a7y=

Стояла у меня такая задача: отображать форму, а по нажатию на кнопку добавлять дополнительные экземпляры данной формы.

Для этих целей создал несколько моделей вида, где Worker - это FK для Experience:

class Worker(models.Model):    public_cv = models.BooleanField(default=False, verbose_name='Can everyone see your resume ?')    cv_name = models.CharField(max_length=250, verbose_name='CV name', blank=True)    author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, verbose_name='Author', default=0)# +много других полей    def __str__(self):        return self.name    def publish(self):        self.published_date = timezone.now()        self.save()class Experience(models.Model):    worker = models.ForeignKey(Worker, on_delete=models.CASCADE)    title = models.CharField(max_length=200, verbose_name='Position name')# +много других полей    def __str__(self):        return self.title    def publish(self):        self.published_date = timezone.now()        self.save()# +много других моделей

Следующим шагом, который приближал меня к цели - реализовать желаемое сначала в административной панели django-admin, для этого я использовал StackedInline:

class ExperienceInstance(admin.StackedInline):    model = Experience    extra = 1@admin.register(Worker)class PublishWorkers(admin.ModelAdmin):    inlines = [        ExperienceInstance,]

И получим желаемый вид пока что в Django-admin, создается пустая форма Experience связанная с Worker и кнопка "Добавить форму Experience":

Теперь нужно добавить во views.py код, который позволит выводить форму Experience отдельно и по нажатии кнопки создавать дополнительный экземпляр формы Experience, будем использовать Formset:

from django.forms import inlineformset_factoryfrom django.http import HttpResponseRedirectfrom .forms import ExperienceFormdef expformview(request, worker_uid):    worker = Worker.objects.get(uid=worker_uid)    ExperienceFormset = inlineformset_factory(        Worker, Experience, form=ExperienceForm, extra=1, max_num=15, can_delete=True    )    if request.method == 'POST':        formset = ExperienceFormset(request.POST, instance=worker)        if formset.is_valid():            formset.save()            return HttpResponseRedirect(request.META.get('HTTP_REFERER'))    formset = ExperienceFormset(instance=worker)    return render(request, 'site/expform.html',                  {                      'formset': formset,                      'worker': worker,                  }                  )

Также, создадим Форму в forms.py ExperienceForm:

class ExperienceForm(forms.ModelForm):    started = forms.DateField(        required=False,        label='Start date',        widget=forms.TextInput(attrs={'placeholder': 'YYYY-MM-DD'})    )    ended = forms.DateField(        required=False,        label='End date',        widget=forms.TextInput(attrs={'placeholder': 'YYYY-MM-DD'})    )    class Meta:        model = Experience        fields = ('title',                  'selfedu',                  )

Далее шаблон HTML. Я использую Crispy для лучшего отображения полей форм. {{formset.media}} нужен для вывода WYSIWYG-редактора ckeditor. При нажатии на кнопку с type="submit" данные текущей формы сохраняются в базы и снизу добавляется еще один, но пустой экземпляр формы:

Шаблон HTML
{% extends 'site/base.html' %}{% load crispy_forms_tags %}{% block content %}{% if worker.author == request.user%}<html lang="en">   <head>      <meta charset="UTF-8">      <meta name="viewport" content="width=device-width, initial-scale=1.0">      <meta http-equiv="X-UA-Compatible" content="ie=edge">      <title>Experience | {{worker}}</title>   </head>   <body>      <center>         <div class="col-lg-5" style="margin:1em;">            <nav aria-label="breadcrumb">               <ol class="breadcrumb">                   <li class="breadcrumb-item">Basic information</li>                   <li class="breadcrumb-item active" aria-current="page"><b>Experience</b></li>                   <li class="breadcrumb-item">Education</li>                   <li class="breadcrumb-item">Certification</li>                   <li class="breadcrumb-item">Awards</li>                   <li class="breadcrumb-item">Projects</li>               </ol>            </nav>         </div>      </center>   <h2 align="center" style="margin:1em;">{{worker}}'s Experience form</h2>      <form method="post">         {% csrf_token %}         <div class="row" style="margin:2em 0 2em 0;">            <div class="col-lg-5 mx-auto">               {{formset.media}}               {{formset|crispy}}            </div>             <div class="col-lg-12">                 <center><button type="submit" class="btn btn-outline-warning">Save & Add</button>                 <a href="edu"><button type="button" class="btn btn-outline-success">Next > Education</button></a></center>             </div>         </div>      </form>   </body></html>{%else%}      <div class="row">         <div class="col-lg-12" style="margin-top:6em;">            <center>               <h2>You have not access to this section</h2>            </center>         </div>      </div>      {%endif%}{% endblock %}

Так выглядит это на работающем сайте:

Экспорт данных в PDF с поддержкой кириллицы (русских букв)

Для экспорта данных, в данном случае страницы HTML в PDF мы будем использоватьXHTML2PDF; для его установки необходимо в venv запустить:

pip install xhtml2pdf

Далее добавляем следующий код в views.py:

from xhtml2pdf import pisadef render_pdf_view(request, worker_uid):    template_path = 'site/pdf.html'    worker = Worker.objects.get(uid=worker_uid)    exp = Experience.objects.filter(worker=worker)    context = {        'worker': worker,        'exp': exp,    }    response = HttpResponse(content_type='application/pdf')    response['Content-Disposition'] = 'filename="%s_%s.pdf"' % (worker.name, worker.created_date.strftime('%Y-%m-%d')) # правлю название выходного файла PDF вида: Имя_Год-М-Д    # Найти шаблон и вывести его    template = get_template(template_path)    html = template.render(context)    # Создаем PDF    pisa_status = pisa.CreatePDF(html, dest=response, )    # Вывод ошибок    if pisa_status.err:        return HttpResponse('We had some errors <pre>' + html + '</pre>')    return response

Шаблон HTML заполняем как обычный шаблон, но нужно учитывать, что парсер PDF видит только локальные стили, поэтому их нужно объявить между тегами <style></style> в данном шаблоне.

Чтобы русские символы корректно отображались в экспортируемом PDF необходимо загрузить шрифт с поддержкой кириллических (русских) букв и положить его в static/fonts/ , при этом указать до файла-шрифта полный путь с учетом системных каталогов, например в моем случае путь выглядит так:/var/www/cvmaker/static/fonts/arial.ttf , а между тегами <style/> добавляем следующее:

@font-face {         font-family: 'sans-serif';         src: url("/var/www/cvmaker/static/fonts/arial.ttf");         }         body{         font-family: "sans-serif";         }

Таким образом в экспортируемом PDF-файле мы видим вместо черных квадратиков на месте русских букв нормальные кириллические символы:

Подробнее..

Как я уместил систему управления товарами на сайте Presta Shop в пяти кнопках

04.05.2021 16:09:09 | Автор: admin
Внимание!

Прочитав статью, может сложиться впечатление, что я люблю БДСМ или что-то такое, но это вам только кажется.

Проблемы в работе магазина

Я работаю в обычном велосипедном магазине в центре Варшавы. Торгуем как стационарно, так и в интернете. Среднее количество купленных велосипедов за день ~2 на весь год. При этом пик продаж приходится на лето и тогда в день можем иметь по ~17 интернет-заказов и столько же в магазине, а зимой не продавать вообще ничего.

В 2020г. в связи с пандемией COVID, спрос на велосипеды вырос до невероятных показателей, а мы, как порядочная контора, начали расширение.

Это привело к тому, что в конце прошлого сезона, заказы только несуществующих велосипедов участились в среднем до 4-х раз в неделю на протяжении 4-х самых продуктивных месяцев. А это ~16 несоответствий на сайте в месяц (не считая фейлы в магазине).

Анулированные заказыАнулированные заказы

В текущем году, с появлением еще одного большого склада, ситуация ухудшилась и теперь, кроме несуществующих велосипедов добавились и те, которые находились не там и привезти их клиентам в срок часто бывало невозможным.

В конце концов, чтобы как-нибудь унять этот хаос мне было поручено создать EXСEL, куда впишутся все велосипеды со складов, чтобы примерно знать их расположение. Конечно же, я наплевал на таблицу. Хотя бы потому что при нынешней текучке продуктов она потеряет смысл недели через 2 и станет полностью неактуальной спустя 2-3 больших поставки.

Управление продуктами удаленно

Включение расширенного запаса продуктами не особо помогло. Да, все продукты теперь были на своих складах, но, по сути это та же таблица, которая не будет обновляться без вмешательства. Нужен был сканер.

Портативный сканер, который подключается к интернету, считывает штрих-коды и отправляет их на сервер для обработки. И за все это не придется платить. Если разобраться, то ничего сложного нет. Набросаем примерную схему:

Теперь имеем примерный план действий и можно приступать к разработке, чтению документаций.

Компоненты

Всю систему получилось разделить на три основных части:

  1. Небольшая Python библиотека для взаимодействия с API PrestaShop;

  2. Приложение, сканер штрих-кодов, для отслеживания перемещения товаров между складами. Ну чтоб не вписывать все каждый раз вручную;

  3. Расширение Chrome для автоматического снятия проданного велосипеда во время выставления гарантийной карты. Да, на работе все время использовался только Chrome.

Все запросы проходят через некий middle-сервер, который и связывается с сайтом для взаимодействия. Теперь обо всем вкратце и по порядку.

Главный модуль для работы с API PrestaShop

Способов взаимодействия с API PrestaShop на Python не так много, наверное, потому что PS занимает только 5% рынка (а версия 1.6 и того меньше). Нашлась всего одна полноценная библиотека prestapyt, что само по себе большая редкость для Питона. Возможно, она сэкономила бы мне пару ночей, но попробовать свое решение хотелось не меньше чем быстрее это запустить.

Так как выбора особо и не было, а заняться было нечем, я приступил к анализу API, документациям и изучению работы PS. Спустя пару дней создался скрипт, который имел в себе самые базовые функции типа поиска продуктов и изменение их количества, да и работал умеренно быстро.

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

Код на Git

Алгоритм поиска продукта по комбинациям

Каждый велосипед в нашем интернет-магазине имеет несколько комбинаций, характеризующие размер рамы велосипеда.

Соответственно, для идентификации количеств необходимо получить ссылку на комбинацию, а уже потом манипулировать с ее количеством.

Исходя из этого получился следующий алгоритм:

  1. Получаем ссылку на комбинацию используя фильтр по reference коду;

  2. Из нужной комбинации ссылку на карточку продукта;

  3. Дальше, из карточки продукта, ссылку на stock_availables. Получится некий массив ссылок;

  4. Проходим циклом по всех ссылках в associations и ищем ту же комбинацию.

  5. Получаем ссылку на стоки, проверяем наличие и, если оно > 0 удаляем единицу товара. Если нет выкидываем предупреждение, что товар закончился.

  6. В завершение отправляем отредактированный XML на тот же адрес stock_availables, из которого это все и было получено.

Похоже на такую себе рекурсию от комбинации аж до общего количества.

Плюсы такого подхода в том, что можно попутно захватить еще какие-то данные, скажем модель велосипеда или проверить параметры продукта.

Взаимодействие с API

Доступ к главной странице начинается со ссылки вида https://domain.com/api. Здесь можно посмотреть, какие разделы доступны авторизованному юзеру. А проверяется это попытками найти id раздела например, тег products в теле ответа. Совпадение обозначает, что раздел доступен конкретному ключу.

Общение через API происходит на языке XML, а схемы запросов выглядят примерно так:

<prestashop><api shopName="myshop"><addresses xlink:href="http://personeltest.ru/aways/domain.com/api/addresses" get="true" put="false" post="false" delete="false" head="false"></addresses></api></prestashop>

Следующий сегмент ссылки - это название раздела. Пример получения продуктов https://domain.com/api/products:

<prestashop>  <products>    <product id="22" xlink:href="http://personeltest.ru/aways/domain.com/api/products/22"/>    <product id="24" xlink:href="http://personeltest.ru/aways/domain.com/api/products/24"/>    <product id="265" xlink:href="http://personeltest.ru/aways/domain.com/api/products/265"/>    <product id="294" xlink:href="http://personeltest.ru/aways/domain.com/api/products/294"/>  <products /><prestashop />

Отобразятся ссылки на карточки продуктов.

Для формирования и непосредственной отправки запросов скрипт использует requests. Удобная штука, хоть и работает относительно медленно Requests потому что я работал с ней раньше, она хорошо документирована и с ней просто приятно иметь дело.

Авторизация

API PS использует Basic авторизацию только по ключу (без пароля). Поэтому запрос получается до невозможности прост. Логин средствами requests:

request_url = https://domain.com/apiget_combination_xml = requests.get(request_url, auth=(self.api_secret_key, ''))

Получаем ответ 200, и тело ответа, содержащее всю страницу в XML. Теперь можно распарсить ее на отдельные теги и поискать нужные значения в них.

Парсинг XML ответа

Здесь ситуация выглядела лучше нашлась библиотека xml.etree. Отлично документированный инструмент, который через пару минут после импорта уже выдает все, что нужно (а много и нужно), а работать с целым телом ответа можно так же, как и с обычным словарем Python.

Достанем ссылку на подкатегорию из полученного ответа:

# Импортируем все необходимое в 2 строчкиfrom xml.etree import ElementTree as ETfrom xml.etree.ElementTree import ElementTree# Экземпляр xml.etreedef xml_data_extractor(data, tag):# data  XML данные# tag = products тег для поиска в деревеtry:xml_content = ET.fromstring(data.content) # Экземпляр класса ElementTree. Принимает строку в качестве аргумента. В моем случае тело ответа.general_tag = xml_content[0] # Получаем родительский тег  он всегда первыйtag = general_tag.find(tag) # Ищем заданный тегtag_inner_link = tag.get('{http://www.w3.org/1999/xlink}href') # Ищем ссылку в теге# Так же можно искать подстроку href в каждом ключе и получать значение после совпадения # Возвращаем внутреннюю ссылку в виде словаряproduct_meta = {'product_link': tag_inner_link}  return product_metaexcept:return None

Результат: https://domain.com/api/products. Все просто, но работает.

Большинство функций возвращают None в блоке except и словарь в блоке Try. Не знаю, хорошо ли это, но знаю, что это можно улучшить, чтобы не было такого разброса типов.

Фильтры поиска PS

PS предоставляет фильтры для уточнения поиска. По сути, каждый фильтр это данные для полноценного SQL запроса. Именем фильтра может быть любой тег из XML дерева страницы, а значением его предполагаемое значение, очевидно.

Ссылка для запроса с фильтром для комбинаций выглядит так:

https://domain.com/api/combinations/?filter[reference]=reference, где reference код искомого продукта.

Сам код - это штрих-код, наклеенный на коробке и выглядит примерно так: KRHE1Z26X14M200034.

Дальше идут некоторые специфические функции и приватные методы, которые более подробно описаны в документации. Да! У этого куска кода есть документация на Git:

Структура библиотеки

Весь модуль содержит только один класс (хоть это и не читаемо, изначально я хотел работать только с общим количеством). В конструкторе кодируется в base64 секретный ключ, определяется путь до директории скрипта, создается константа времени и другие переменные, необходимые для работы с логами:

def __init__(self, api_secret_key, request_url=None, **kwargs):try:self.api_secret_key = str(api_secret_key) #!API key is necessary!self.api_secret_key_64 = base64.b64encode((self.api_secret_key + ':').encode())    except:raise TypeError('The secret_key must be a string!')  # Main path to working directoryself.base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))self.request_url = request_urlself.kwargs = kwargs.items()#product meta to returnself.name = Noneself.total_quantity = Noneself.w_from = Noneself.w_to = Noneself.date = str(datetime.datetime.now().strftime("%d-%m-%Y, %H:%M"))

Далее идут приватные методы: _xml_data_extractor(), _wd() и даже кастомный _logging(), который пишет все совершенные операции вне зависимости от результата. Можно задать свое имя лог-файла.

Затем стандартные методы. Всего 11. Каждый отвечает за свою ссылку. Здесь, собственно, и происходит все вышеописанное.

Обработка запросов и middle-сервер

Для взаимодействия приложения и расширения со скриптом из любой точки мира магазина нужен был сервер c поддержкой Python. Сначала это выглядело как проблема, но я кое-что попробовать. Поднять WSGI-сервер на хостинге, конечно же!

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

Однако, нашлось еще более скостылизированное решение. У меня есть блог, запущенный на Django, а если посмотреть под другим углом отличный middle-сервер, способный обрабатывать множество запросов и уже настроен и готов к работе.

Создав еще одно приложение, подключил библиотеку, настроил какой-никакой контроль доступа к странице и вот он тестовый запуск.

Невалидный запросНевалидный запрос

Отлично! Запрос по ссылке https://palachintosh.com/xxx/xxx/? (без параметров) обработался и выдал результат об ошибке, так как нет ни токена, ни номера рамы. Просто пустой запрос.

Пробуем запрос с параметрами /?code=1122334455&token=IUFJ44KPQE342M3M109DNWI (код тестового продукта):

Пример успешного запросаПример успешного запроса

Вернулся словарь success значит, запрос обработался, количество продуктов уменьшилось и на складе, и в доступных для продажи, все записалось в лог-файл и вообще работает. Уже можно слать запросы с реальными номерами рам.

Контроль доступа

За доступ к этому всему отвечает специальный токен, генерирующийся на сервере рандомайзером, и, к сожалению, вручную вписывается в файл и расширение. В будущем я хочу подключить стандартную авторизацию Django или вообще использовать Django REST. Но пока что испытываю проблемы с Java и нехваткой времени для изучения Retrofit, поэтому использую ключи.

Логика проста токен совпадает продолжаем операцию. Токен не совпадает прерываем транзакцию еще на начальном этапе как-то так:

token = Nonewith open(token.txt) as file_t:token = file_t[0]if token == str(request.GET.get(token))://Вызываем обработчикreturn JsonResponse({Error: Invalid token})

В теории это должно защитить страницу от ботов и непрошеных гостей, не более, хоть и сама идея хранения токена в файле на сервере ни к чему доброму не ведет.

Расширение Chrome и первые две кнопки

Расширение содержит набор функций для определения нужной страницы, отправки запроса, стилизации некоторых окон и т.д.

Положение определяет интервал, который слушает изменение ссылки в браузере и если находит совпадение передает управление функции получения данных из формы. После подтверждения в диалоговом окне формируется и отправляется запрос. Запросы шлются методом XMLHttpRequest.

Самым сложным этапом оказалась работа с политикой CORS. На сервере пришлось добавить отдельную функцию, которая добавляет заголовки Access-Control-Allow-Origin. Без него, в случае кроссдоменных запросов срабатывает защита и сразу сбрасывает соединение еще на этапе запроса OPTIONS.

Проблема решилась определением во views.py функции def options(self, request). Она просто проверяет заголовки и отдает допустимые заголовки для совершения полноценного GET запроса.

По итогу получилось вместить отправку одной формы и вывод диалогового окна в менее чем 100 строчках корявого кода, бОльшая часть которых это установка обработчиков на существующие кнопки.

Алгоритм расширения

Сканера штрих-кодов в магазине нет, но есть кое что получше. Это система выставления гарантийных карт производителя. Работает это примерно так:

Процесс выставления гарантийной картыПроцесс выставления гарантийной карты

Если вписать существующий номер рамы, то все поля заполнятся данными из реестра на стороне производителя. В поле Kod roweru появится тот самый код, который характеризует все одинаковые велосипеды.

Ну а дальше все просто получаем значение поля и скармливаем его библиотеке PrestaRequest на сервере. Пускай он разбирается, что и откуда снимать.

Чтобы определить все поля и добраться до формы того самого поля sku пришлось немного подраться с частями страницы, которые подгружаются с помощью ajax после ввода номера рамы. По началу это было проблемой, потому что я не умею перехватывать ajax запросы в расширении. Поэтому заменил перехватчик обычным интервалом.

var interval;functionmain_interval(){clearInterval(interval);  interval=setInterval(function(){href=window.location.hrefif(href.indexOf('https://24.kross.pl/warranty/new')>=0||href.indexOf('id_product=')>=0){if(href.indexOf('id_product=')>=0){prestaCheck();clearInterval(interval);}if(href.indexOf('https://24.kross.pl/warranty/new')>=0){location.reload();get_buttons();}}if(href.indexOf('https://24.kross.pl/bike-overview/new')>=0){clearInterval(interval);check_all();}},1000);}

Конечный код получения данных формы выглядит так:

// onclick or enter events function getFormData() {    var getForm = document.forms[0];    if (getForm != null) {        if (getForm.hasChildNodes("sku") && getForm.sku != null){            var code = String(getForm.sku.value);        }        if (getForm.hasChildNodes("bike_model") && getForm.bike_model != null) {            edit_msg = document.querySelector(".message-container > span > h1");            edit_msg.innerText = "Rower " + String(getForm.bike_model.value) + " zostanie usunity ze stanw!";        }        if (code != null && getForm.serial_number != null) {            sendRequest(code);        }    }}

Поле bike_model нужно исключительно для диалогового окна, в котором напоминается, над каким велосипедом производится операция. Диалоговое окно тоже добавляется на страницу скриптом расширения и выглядит так:

vargetBodyBlock=document.querySelector('body');varalert_div=document.createElement('div');alert_div.innerHTML='<divclass="alert-message"><divclass="message-container">\<span><h1></h1></span>\<divclass="inner-buttons">\<buttonid="btnYes"class="ant-btnant-btn-danger">Potwierdzam!</button>\<buttonid="btnReserve"class="ant-btnant-btn-danger">Zdjrezerwacj</button>\<buttonid="btnNo"class="ant-btnant-btn-success">Nieteraz</button>\</div></div></div>';loader=document.createElement('div');getBodyBlock.appendChild(alert_div);

Ссылка на репозиторий: https://github.com/palachintosh/shop_extension

Самая страшная часть Android-приложение

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

Нужно просто считать или ввести код на коробке и отправить серверу. Запросы делаются на разные адреса, так приложение определяет какие операции с присланным кодом проделать. В целом принцип тот же что и у расширения, но другие обработчики и ссылки.

Работа приложения (ничего интересного)

Приложение имеет 3 страницы. Сначала выбираются названия складов и количество переносимого товара, а дальше:

  • Либо код сканится, если он не поврежден.

  • Либо вписывается вручную, если, скажем, коробка где-то далеко, но есть документ доставки.

Вообще, сам сканнер был написан не мной. Я честно нашел его на каком-то сайте и даже сохранил все комментарии автора. Но его пришлось переделать и завернуть в свое приложение, а это уже что-то добавленное от меня. К тому же переписать часть старого кода на Java и обеспечить совместимость с новыми зависимостями.

Отправкой запросов занимается библиотека Retrofit 2. Простые запросы пишутся в пару строчек, но обработка входных данных намного сложнее чем на Python. Всегда важно помнить какой тип чем обрабатывать, компилировать код, да и в самом проекте много файлов, поддиректорий, зависимостей, легко в этом потеряться с непривычки.

Кнопки в приложении

Main Activity содержит всего 3 метода onCreate, scanCode, enterCode и описывают кнопки, которые запускают другие активити:

protected void onCreate(Bundle savedInstanceState) {    super.onCreate(savedInstanceState);    setContentView(R.layout.activity_main);    Spinner fromSpinner = (Spinner) findViewById(R.id.fromSpinner);    Spinner toSpinner = (Spinner) findViewById(R.id.toSpinner);    ArrayAdapter<String> adapter = new ArrayAdapter<String> (            this, android.R.layout.simple_spinner_item, warehouses);    adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);    fromSpinner.setAdapter(adapter);    toSpinner.setAdapter(adapter);}public void scanCode(View view) {    Intent intent = new Intent(MainActivity.this, Scan.class);    Spinner get_w_from = (Spinner) findViewById(R.id.fromSpinner);    Spinner get_w_to = (Spinner) findViewById(R.id.toSpinner);    EditText editText = (EditText) findViewById(R.id.prodctQuantity);    String quantity_tt = editText.getText().toString();    RequestData requestData = new RequestData(            get_w_from.getSelectedItem().toString(),            get_w_to.getSelectedItem().toString(),            quantity_tt);    intent.putExtra(RequestData.class.getSimpleName(), requestData);    startActivity(intent);}

Закладка Enter Code отвечает за ручной ввод реферального кода. Скажем, если наклейка повреждена. Здесь все просто вписываем и отправляем.

Ручная отправка кодаРучная отправка кода

Scan Code переключается на Activity co сканнером, а обрабатываются изображения библиотекой Barcode Scanner от Google.

Окно сканирования кодаОкно сканирования кода

Send Code отправляющая код на сервер. Обработчик получает данные из текстового поля сканнера или инпута из активити ручного заполнения, валидирует его и передает данные обработчику отправки запроса. А Retrofit делает все остальное. Приложение, пока что, самая недопиленная часть сказывается плохое знание Java и отсутствие опыта разработки под Android в целом, но я пытаюсь исправиться.

Код приложения на github: https://github.com/palachintosh/product_control.git

Как это выглядело раньше

Раньше процесс управления продуктами выглядел так:

  • Когда приезжала большая доставка продукты добавлялись на сайт вручную с накладной;

  • Когда велосипед продавался его нужно было зарегистрировать в базе производителя, записать на бумажный листок модель и дату, снять единицу продукта через админку;

  • Нужно было как-то мониторить склады, с которых коробки приезжают и стараться это обновлять по мере возможности.

Как это выглядит сейчас:

  • Когда приезжает доставка сканируются либо штрих-коды на накладной, либо каждая коробка;

  • Во время продажи подтверждается действие удаления единицы продукта;

  • Когда коробки мигрируют со склада на склад в приложении выбираются склады и отправляется отсканированный код.

В сумме, новый подход экономит около 2-х минут на каждом проданном велосипеде и гораздо больше на всех переносах склад-склад.

Выводы

Если посчитать все обязательные кнопки, то получается, что их реально 5:

  1. Отправка запроса с сайта производителя чтобы снять продукт.

  2. Отмена отправки запроса (если велосипед был продан через интернет, тогда этим управляет PrestaShop).

  3. Кнопка "Enter code" в приложении.

  4. Кнопка "Scan code".

  5. Отправка запроса в приложении "Send Code".

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

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

Из плюсов: облегчает жизнь сотрудникам и экономит время, инвентаризации на складах делаются моментально, ну и респект от шефа, конечно же.

Если статья не улетит в минуса или вообще будет опубликована, то подробно опишу код всего проекта, отрефакторю его, сделаю более безопасным и читаемым.

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

Подробнее..

Удобное логирование на бэкенде. Доклад Яндекса

28.11.2020 12:19:52 | Автор: admin
Что-то всегда идет не по плану. Приходится отвечать на вопросы, Что сломалось?, Почему тормозит? и Почему мы не увидели этого раньше?. На примере простого приложения Даниил Галиев zefirior из Яндекс.Путешествий показал, как отвечать на эти вопросы и какие инструменты в этом помогут. Настроим логирование, прикрутим трассировку, разложим ошибки, и все это в удобном интерфейсе.

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



Что будем делать? Мы построим небольшое приложение, наш стартап. Потом внедрим в него базовое логирование, это маленькая часть доклада, то, что поставляет Python из коробки. И дальше самая большая часть мы разберем типичные проблемы, которые встречаются нам во время отладки, выкатки, и инструменты для их решения.

Небольшой дисклеймер: я буду говорить такие слова, как ручка и локаль. Поясню. Ручка возможно, яндексовый сленг, это обозначает ваши API, http или gRPC API или любые другие комбинации букв перед APU. Локаль это когда я разрабатываю на ноутбуке. Вроде бы я рассказал обо всех словах, которые я не контролирую.

Приложение Книжная лавка


Начнем. Наш стартап это Книжная лавка. Главной фичей этого приложения будет продажа книг, это все, что мы хотим сделать. Дальше немного начинки. Приложение будет написано на Flask. Все сниппеты кода, все инструменты общие и абстрагированы от Python, поэтому их можно будет интегрировать в большинство ваших приложений. Но в нашем докладе это будет Flask.

Действующие лица: я, разработчик, менеджеры и мой любимый коллега Эраст. Любые совпадения случайны.



Давайте немного про структуру. Это приложение с микросервисной архитектурой. Первый сервис Books, хранилище книг с метаданными о книгах. Он использует базу данных PostgreSQL. Второй микросервис микросервис доставки, хранит метаданные о заказах пользователей. Cabinet это бэкенд для кабинета. У нас нет фронтенда, в нашем докладе он не нужен. Cabinet агрегирует запросы, данные из сервиса книг и сервиса доставки.



По-быстрому покажу код ручек этих сервисов, API Books. Это ручка выгребает данные из базы, сериализует их, превращает в JSON и отдает.



Пойдем дальше. Сервис доставки. Ручка точно такая же. Выгребаем данные из базы, сериализуем их и отправляем.



И последняя ручка ручка кабинета. В ней немного другой код. Ручка кабинета запрашивает данные из сервиса доставки и из сервиса книг, агрегирует ответы и отдает пользователю его заказы. Всё. Со структурой приложения мы по-быстрому разобрались.

Базовое логирование в приложении


Теперь давайте про базовое логирование, то, которое мы впилили. Начнем с терминологии.



Что нам дает Python? Четыре базовые, главные сущности:

Logger, входная точка логирования в вашем коде. Вы будете пользоваться каким-то Logger, писать logging.INFO, и все. Ваш код больше ничего не будет знать о том, куда сообщение улетело и что с ним дальше произошло. За это уже отвечает сущность Handler.

Handler обрабатывает ваше сообщение, решает, куда его отправить: в стандартный вывод, в файл или кому-нибудь на почту.

Filter одна из двух вспомогательных сущностей. Удаляет сообщения из лога. Другой стандартный вариант использования это наполнение данными. Например, в вашей записи нужно добавить атрибут. Для этого тоже может помочь Filter.

Formatter приводит ваше сообщение к нужному виду.



На этом с терминологией закончили, дальше к логированию прямо на Python, с базовыми классами, мы возвращаться не будем. Но вот пример конфигурации нашего приложения, которая раскатана на всех трех сервисах. Здесь два главных, важных для нас блока: formatters и handlers. Для formatters, есть пример, который вы можете увидеть здесь, шаблон того, как будет выводиться сообщение.

В handlers вы можете увидеть, что logging.StreamHandler использован. То есть мы выгружаем все наши логи в стандартный вывод. Всё, с этим закончили.

Проблема 1. Логи разбросаны


Переходим к проблемам. Для начала проблема первая: логи разбросаны.

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

Теперь вопрос. Прибегает к нам менеджер и спрашивает: Там сломалось, помоги! Вы бежите. У вас же все логируется, это великолепно. Вы заходите на первую машинку, смотрите там ничего нет по вашему запросу. Заходите на вторую машинку ничего. И так далее. Это плохо, это надо как-то решать.



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

И я не хочу писать. Это Эраст любит писать код. Я не про это, я сразу сделал продукт. То есть хочется меньше дополнительного кода, обойтись одним-двумя файликами, строчками, и всё.



Решение, которое можно использовать, ElasticSearch. Давайте его попробуем поднять. Какие плюсы ElasticSearch нам даст? Это интерфейс с поиском логов. Сразу из коробки есть интерфейс, это вам не консолька, а единственное место хранения. То есть главное требование мы отработали. Нам не нужно будет ходить по серверам.

В нашем случае это будет довольно простая интеграция, и с недавним релизом у ElasticSearch выпустился новый агент, который отвечает за большинство интеграций. Они там сами впилили интеграции. Очень круто. Я составлял доклад чуть раньше и использовал filebeat, так же просто. Для логов все просто.

Немного об ElasticSearch. Не хочу рекламировать, но там много дополнительных фич. Например, крутая штука полнотекстовый поиск по логам из коробки. Очень круто звучит. Нам пока достаточно этих плюсов. Прикручиваем.



В первую очередь нам нужно будет развернуть агент, который будет отправлять наши логи в Elastic. Вы регистрируете аккаунт в Elastic и дальше добавляете в ваш docker-compose. Если у вас не docker-compose, можете поднимать ручками или в вашей системе. В нашем случае добавляется вот такой блок кода, интеграции в docker-compose. Всё, сервис настроен. И вы можете увидеть в блоке volumes файл конфигурации filebeat.yml.



Вот пример filebeat.yml. Здесь настроен автоматический поиск логов контейнеров docker, которые крутятся рядом. Кастомизован выбор этих логов. По условию можно задавать, вешать на ваши контейнеры лейблы, и в зависимости от этого ваши логи будут отправляться только у определенных контейнеров. С блоком processors:add_docker_metadata все просто. В логи добавляем немножечко больше информации о ваших логах в контексте docker. Необязательно, но прикольно.



Что мы получили? Это все, что мы написали, весь код, очень круто. При этом мы получили все логи в одном месте и есть интерфейс. Мы можем поискать наши логи, вот плашечка search. Они доставляются. И можно даже в прямом эфире включить так, чтобы стрим летел к нам в логи в интерфейс, и мы это видели.



Тут я бы и сам спросил: а чего, как грепать-то? Что из себя представляет поиск по логам, что там можно сделать?

Да, из коробки в таком подходе, когда у нас текстовые логи, есть небольшой затык: мы можем по тексту задать запрос, например message:users. Это выведет нам все логи, у которых есть подстрока users. Можно пользоваться звездочками, большинством других юниксовых wild cards. Но кажется, этого недостаточно, хочется сделать сложнее, чтобы можно было нагрепать в Nginx раньше, как мы это умеем.



Давайте немного отступим от ElasticSearch и попытаемся сделать это не с ElasticSearch, а с другим подходом. Рассмотрим структурные логи. Это когда каждая ваша запись лога это не просто текстовая строчка, а сериализованный объект с атрибутами, которые любая ваша сторонняя система может сериализовать, чтобы получить уже готовый объект.

Какие от этого есть плюсы? Это единый формат данных. Да, у объектов могут быть разные атрибуты, но любая внешняя система может прочитать именно JSON и получить какой-то объект.

Какая-никакая типизация. Это упрощает интеграцию с другими системами: не нужно писать десериализаторы. И как раз десериализаторы это другой пункт. Вам не нужно писать в приложении прозаичные тексты. Пример: User пришел с таким-то айдишником, с таким-то заказом. И это все нужно писать каждый раз.

Меня это напрягало. Я хочу написать: Прилетел запрос. Дальше: Такой-то, такой-то, такой-то, очень просто, очень по-айтишному.



Давайте дальше. Договоримся: логировать будем в формате JSON, это простой формат. Сразу ElasticSearch поддерживается, filebeat, которым мы сериализуем и попробуем впилить. Это не очень сложно. Для начала вы добавляете из библиотеки pythonjsonlogger в блок formatters JSONFormatter файлика settings, где у нас хранится конфигурация. У вас в системе это может быть другое место. И дальше в атрибуте format вы передаете, какие атрибуты вы хотите добавлять в ваш объект.

Блок ниже это блок конфигурации, который добавляется в filebeat.yml. Здесь из коробки есть интерфейс у filebeat для парсинга JSON-логов. Очень круто. Это все. Для этогов вам больше ничего писать не придется. И теперь ваши логи похожи на объекты.



Что мы получили в ElasticSearch? В интерфейсе вы сразу видите, что ваш лог превратился в объект с отдельными атрибутами, по которым вы можете искать, создавать фильтрации и делать сложные запросы.



Давайте подведем итог. Теперь наши логи имеют структуру. По ним несложно грепать и можно писать интеллектуальные запросы. ElasticSearch знает об этой структуре, так как он распарсил все эти атрибуты. А в kibana это интерфейс для ElasticSearch можно фильтровать такие логи с помощью специализированного языка запросов, который предоставляет Elastic Stack.

И это проще, чем грепать. Греп имеет довольно сложный и крутой язык. Там очень много можно написать. В kibana можно сделать многие вещи проще. С этим разобрались.

Проблема 2. Тормоза


Следующая проблема тормоза. В микросервисной архитектуре всегда и везде бывают тормоза.



Тут немного контекста, расскажу вам историю. Ко мне прибегает менеджер, главное действующее лицо нашего проекта, и говорит: Эй-эй, кабинет тормозит! Даня, спаси, помоги!

Мы пока ничего не знаем, лезем в Elastic в наши логи. Но давайте я расскажу, что, собственно, произошло.



Эраст добавил фичу. В книгах мы теперь отображаем не айдишник автора, а его имя прямо в интерфейсе. Очень круто. Сделал он это вот таким кодом. Небольшой кусок кода, ничего сложного. Что может пойти не так?

Вы наметанным взглядом можете сказать, что с SQLAlchemy так делать нельзя, с другой ORM тоже. Нужно сделать прекэш или что-нибудь еще, чтобы в цикле не ходить в базу с маленьким подзапросиком. Неприятная проблема. Кажется, что такую ошибку вообще нельзя допустить.

Давайте расскажу. У меня был опыт: мы работали с Django, и у нас в проекте был реализован кастомный прекэш. Много лет все шло хорошо. В какой-то момент мы с Эрастом решили: давай пойдем в ногу со временем, обновим Django. Естественно, Django ничего не знает о нашем кастомном прекэше, и интерфейс поменялся. Прикэш отвалился, молча. На тестировании это не отловили. Та же самая проблема, просто ее сложнее было отловить.

Проблема в чем? Как я помогу вам решить проблему?



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

Первое, что делаю, иду в ElasticSearch, у нас он уже есть, помогает, не нужно бегать по серверам. Я захожу в логи, ищу логи кабинета. Нахожу долгие запросы. Воспроизвожу на ноутбуке и вижу, что тормозит не кабинет. Тормозит Books.

Бегу в логи Books, нахожу проблемные запросы собственно, он у нас уже есть. Точно так же воспроизвожу Books на ноутбуке. Очень сложный код ничего не понимаю. Начинаю дебажить. Тайминги довольно сложно отлавливать. Почему? Внутри SQLAlchemy довольно сложно это определить. Пишу кастомные тайм-логгеры, локализую и исправляю проблему.



Мне было больно. Сложно, неприятно. Я плакал. Хочется, чтобы этот процесс поиска проблемы был быстрее и удобнее.

Формализуем наши проблемы. Сложно по логам искать, что тормозит, потому что наш лог это лог несвязанных событий. Приходится писать кастомные таймеры, которые показывают нам, сколько выполнялись блоки кода. Причем непонятно, как логировать тайминги внешних систем: например, ORM или библиотек requests. Надо наши таймеры внедрять внутрь либо каким-то Wrapper, но мы не узнаем, от чего оно тормозит внутри. Сложно.



Хорошее решение, которое я нашел, Jaeger. Это имплементация протокола opentracing, то есть давайте внедрим трассировку.

Что дает Jaeger? Это удобный интерфейс с поиском запросов. Вы можете отфильтровать долгие запросы или сделать это по тегам. Наглядное представление потока запросов, очень красивая картинка, чуть позже покажу.

Тайминги логируются из коробки. С ними ничего не надо делать. Если вам нужно про какой-то кастомный блок проверить, сколько он выполняется, вы можете его обернуть в таймеры, предоставляемые Jaeger. Очень удобно.



Посмотрим, как вообще можно было найти проблему в интерфейсе и там же ее локализовать. Мы заходим в интерфейс Jaeger. Вот так выглядят наши запросы. Мы можем поискать запросы именно кабинета или другого сервиса. Сразу фильтруем долгие запросы. Нас интересуют именно долгие, их по логам довольно сложно найти. Получаем их список.



Проваливаемся в этот запрос, и видим большую портянку SQL-подзапросов. Мы прямо наглядно видим, как они исполнялись по времени, какой блок кода за что отвечал. Очень круто. Причем в контексте нашей проблемы это не весь лог. Там есть еще большая портянка на два-три слайда вниз. Мы довольно быстро локализовали проблему в Jaeger. В чем после решения проблемы нам может помочь контекст, который предоставляет Jaeger?



Jaeger логирует, например, SQL-запросы: вы можете посмотреть, какие запросы повторяются. Очень быстро и круто.



Проблему мы решили и сразу видим в Jaeger, что все хорошо. Мы проверяем по тому же запросу, что у нас теперь нет подзапросов. Почему? Предположим, мы проверим тот же запрос, узнаем тайминг посмотрим в Elastic, сколько запрос выполнялся. Тогда мы увидим время. Но это не гарантирует, что подзапросов не было. А здесь мы это видим, круто.



Давайте внедрим Jaeger. Кода нужно не очень много. Вы добавляете зависимости для opentracing, для Flask. Теперь о том, какой код мы делаем.

Первый блок кода это настройка клиента Jaeger.

Затем мы настраиваем интеграцию с Flask, Django или с любым другим фреймворком, на который есть интеграция.

install_all_patches самая последняя строчка кода и самая интересная. Мы патчим большинство внешних интеграция, взаимодействуя с MySQL, Postgres, библиотекой requests. Мы все это патчим и именно поэтому в интерфейсе Jaeger сразу видим все запросы с SQL и то, в какой из сервисов ходил наш искомый сервис. Очень круто. И вам не пришлось много писать. Мы просто написали install_all_patches. Магия!

Что мы получили? Теперь не нужно собирать события по логам. Как я сказал, логи это разрозненные события. В Jaeger это одно большое событие, структуру которого вы видите. Jaeger позволяет отловить узкие места в приложении. Вы просто делаете поиск по долгим запросам, и можете проанализировать, что идет не так.

Проблема 3. Ошибки


Последняя проблема ошибки. Да, я лукавлю. Я не помогу вам избавиться от ошибок в приложении, но я расскажу, что с ними можно делать дальше.



Контекст. Вы можете сказать: Даня, мы логируем ошибки, у нас есть алерты на пятисотки, мы настроили. Чего ты хочешь? Логировали, логируем и будем логировать и отлаживать.

По логам вы не знаете важность ошибки. Что такое важность? Вот у вас есть одна крутая ошибка, и ошибка подключения к базе. База просто флапнула. Хочется сразу видеть, что эта ошибка не так важна, и если нет времени, не обращать на нее внимания, а фиксить более важную.

Частота появления ошибок это контекст, который может нам помочь в ее отладке. Как отслеживать появление ошибок? Преподолжим, у нас месяц назад была ошибка, и вот она снова появилась. Хочется сразу найти решение и поправить ее или сопоставить ее появление с одним из релизов.



Вот наглядный пример. Когда я впиливал интеграцию с Jaeger, то немного поменял свою API. У меня изменился формат ответа приложения. Я получил вот такую ошибку. Но в ней непонятно, почему у меня нет ключа, lots в объекте order, и нет ничего, что мне бы помогло. Мол, смотри ошибку здесь, воспроизведи и самостоятельно отлови.



Давайте внедрим sentry. Это баг-трекер, который поможет нам в решении подобных проблем и поиске контекста ошибки. Возьмем стандартную библиотеку, поддерживаемую разработчиками sentry. В четыре строчки кода мы ее добавляем в наше приложение. Всё.



Что мы получили на выходе? Вот такой дашборд с ошибками, которые можно сгруппировать по проектам и за которыми можно следить. Огромная портянка логов с ошибками группируется в одинаковые, похожие. По ним приводится статистика. И вы также можете работать с этими ошибками с помощью интерфейса.



Посмотрим на нашем примере. Проваливаемся в ошибку KeyError. Сразу видим контекст ошибки, что было в объекте order, чего там не было. Я сразу по ошибке вижу, что мне приложение Delivery отдало новую структуру данных. Кабинет просто к этому не готов.



Что дает sentry, помимо того, что я перечислил? Формализуем.

Это хранилище ошибок, в котором можно искать их. Для этого есть удобные инструменты. Есть группировка ошибок по проектам, по похожести. Sentry дает интеграции с разными трекерами. То есть вы можете следить за вашими ошибками, работать с ними. Вы можете просто добавлять задачу в ваш контекст, и все. Это помогает в разработке.

Статистика по ошибкам. Опять же, легко сопоставить с выкаткой релиза. В этом вам поможет sentry. Похожие события, которые произошли рядом с ошибкой, тоже могут вам помочь в поиске и понимании того, что к ней привело.



Давайте подведем итог. Мы с вами написали приложение, простое, но отвечающее потребностям. Оно помогает вам в разработке и поддержке, в его жизненном цикле. Что мы сделали? Мы собрали логи в одно хранилище. Это дало нам возможность не искать их по разным местам. Плюс у нас появился поиск по логам и сторонние фичи, наши tools.

Интегрировали трассировку. Теперь мы наглядно можем следить за потоком данных в нашем приложении.

И добавили удобный инструмент для работы с ошибками. Они в нашем приложении будут, как бы мы ни старались. Но мы будем фиксить их быстрее и качественнее.

Что еще можно добавить? Само приложение готово, оно есть по ссылке, вы можете посмотреть, как оно сделано. Там поднимаются все интеграции. Например, интеграции с Elastic или трассировки. Заходите, смотрите.

Еще одна крутая штука, которую я не успел осветить, requests_id. Почти ничем не отличается от trace_id, который используется в трассировках. Но за requests_id отвечаем мы, это самая главная его фича. Менеджер может прийти к нам сразу с requests_id, нам не потребуется его искать. Мы сразу начнем решать нашу проблему. Очень круто.

И не забывайте, что инструменты, которые мы внедряли, имеют overhead. Это проблемы, которые нужно рассматривать для каждого приложения. Нельзя бездумно внедрить все наши интеграции, облегчить себе жизнь, а потом думать, что делать с тормозящим приложением.

Проверяйте. Если на вас это не сказывается, круто. Вы получили только плюсы и не решаете проблемы с тормозами. Не забывайте об этом. Всем спасибо, что слушали.
Подробнее..

Конвертеры маршрутов в Django 2.0 (path converters)

05.02.2021 18:14:24 | Автор: admin
Всем привет!

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

Меня зовут Александр Иванов, я наставник в Яндекс.Практикуме на факультете бэкенд-разработки и ведущий разработчик в Лаборатории компьютерного моделирования. В этой статье я расскажу о конвертерах маршрутов в Django и покажу преимущества их использования.



Первое, с чего начну, границы применимости:

  1. версия Django 2.0+;
  2. регистрация маршрутов должна выполняться с помощью django.urls.path.

Итак, когда к Django-серверу прилетает запрос, он сперва проходит через цепочку middleware, а затем в работу включается URLResolver (алгоритм). Задача последнего найти в списке зарегистрированных маршрутов подходящий.

Для предметного разбора предлагаю рассмотреть следующую ситуацию: есть несколько эндпоинтов, которые должны формировать разные отчёты за определённую дату. Предположим, что эндпоинты выглядят так:

users/21/reports/2021-01-31/teams/4/reports/2021-01-31/


Как бы могли выглядеть маршруты в urls.py? Например, так:

path('users/<id>/reports/<date>/', user_report, name='user_report'),path('teams/<id>/reports/<date>/', team_report, name='team_report'),

Каждый элемент в < > является параметром запроса и будет передан в обработчик.
Важно: название параметра при регистрации маршрута и название параметра в обработчике обязаны совпадать.

Тогда в каждом обработчике был бы примерно такой код (обращайте внимание на аннотации типов):

def user_report(request, id: str, date: str):   try:       id = int(id)       date = datetime.strptime(date, '%Y-%m-%d')   except ValueError:       raise Http404()     # ...

Но не царское это дело заниматься копипастом такого блока кода для каждого обработчика. Разумно этот код вынести во вспомогательную функцию:

def validate_params(id: str, date: str) -> (int, datetime):   try:       id = int(id)       date = datetime.strptime(date, '%Y-%m-%d')   except ValueError:       raise Http404('Not found')   return id, date

А в каждом обработчике тогда будет просто вызов этой вспомогательной функции:

def user_report(request, id: str, date: str):   id, date = validate_params(id, date)     # ...

В целом это уже удобоваримо. Вспомогательная функция либо вернёт корректные параметры нужных типов, либо прервёт выполнение обработчика. Кажется, что всё хорошо.

Но на самом деле вот что я сделал: я переложил часть ответственности по решению, должен ли этот обработчик запускаться для этого маршрута или нет, с URLResolver на сам обработчик. Выходит, что URLResolver некачественно проделал свою работу, и моим обработчикам приходится не только заниматься полезной работой, но и решать, а должны ли они вообще этим заниматься. Это явное нарушение принципа единственной ответственности из SOLID. Так не пойдёт. Надо исправляться.

Стандартные конвертеры


Django предоставляет стандартные конвертеры маршрутов. Это механизм определения, подходит ли часть маршрута или нет самим URLResolver. Приятный бонус: конвертер может менять тип параметра, а значит, в обработчик может прийти сразу нужный нам тип, а не строка.

Конвертеры указываются перед названием параметра в маршруте через двоеточие. На самом деле конвертер есть у всех параметров, если он не указан явно, то по умолчанию используется конвертер str.

Осторожно: некоторые конвертеры выглядят как типы в Python, поэтому может показаться, что это обычные приведения типов, но это не так например, нет стандартных конвертеров float или bool. Позднее я покажу, что из себя представляет конвертер.


После просмотра стандартных конвертеров, становится очевидно, что для id стоит использовать конвертер int:

path('users/<int:id>/reports/<date>/', user_report, name='user_report'),path('teams/<int:id>/reports/<date>/', team_report, name='team_report'),


Но как быть с датой? Стандартного конвертера для неё нет.

Можно, конечно, извернуться и сделать так:

'users/<int:id>/reports/<int:year>-<int:month>-<int:day>/'

Действительно, часть проблем удалось устранить, ведь теперь гарантируется, что дата будет отображаться тремя числами через дефисы. Однако всё ещё придётся обрабатывать проблемные случаи в обработчике, если клиент передаст некорректную дату, например 2021-02-29 или вообще 100-100-100. Значит, этот вариант не подходит.

Создаём свой конвертер


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

Для этого надо сделать два шага:

  1. Описать класс конвертера.
  2. Зарегистрировать конвертер.

Класс конвертера это класс с определённым набором атрибутов и методов, описанных в документации (на мой взгляд, несколько странно, что разработчики не сделали базовый абстрактный класс). Сами требования:

  1. Должен быть атрибут regex, описывающий регулярное выражение для быстрого поиска требуемой подпоследовательности. Чуть позже покажу, как он используется.
  2. Реализовать метод def to_python(self, value: str) для конвертации из строки (ведь передаваемый маршрут это всегда строка) в объект python, который в итоге будет передаваться в обработчик.
  3. Реализовать метод def to_url(self, value) -> str для обратной конвертации из объекта python в строку (используется, когда вызываем django.urls.reverse или тег url).

Класс для конвертации даты будет выглядеть так:

class DateConverter:   regex = r'[0-9]{4}-[0-9]{2}-[0-9]{2}'   def to_python(self, value: str) -> datetime:       return datetime.strptime(value, '%Y-%m-%d')   def to_url(self, value: datetime) -> str:       return value.strftime('%Y-%m-%d')

Я противник дублирования, поэтому формат даты вынесу в атрибут так и поддерживать конвертер проще, если вдруг захочу (или потребуется) изменить формат даты:

class DateConverter:   regex = r'[0-9]{4}-[0-9]{2}-[0-9]{2}'   format = '%Y-%m-%d'   def to_python(self, value: str) -> datetime:       return datetime.strptime(value, self.format)   def to_url(self, value: datetime) -> str:       return value.strftime(self.format)

Класс описан, значит, пора его зарегистрировать как конвертер. Делается это очень просто: в функции register_converter надо указать описанный класс и название конвертера, чтобы использовать его в маршрутах:

from django.urls import register_converterregister_converter(DateConverter, 'date')

Вот теперь можно описать маршруты в urls.py (я специально сменил название параметра на dt, чтобы не сбивала запись date:date):

path('users/<int:id>/reports/<date:dt>/', user_report, name='user_report'),path('teams/<int:id>/reports/<date:dt>/', team_report, name='team_report'),

Теперь гарантируется, что обработчики вызовутся только в том случае, если конвертер отработает корректно, а это значит, что в обработчик придут параметры нужного типа:

def user_report(request, id: int, dt: datetime):   # больше никакой валидации в обработчиках   # сразу правильные типы и никак иначе

Выглядит потрясающе! И это так, можно проверять.

Под капотом


Если посмотреть внимательно, то возникает интересный вопрос: нигде нет проверки, что дата корректна. Да, есть регулярка, но под неё подходит и некорректная дата, например 2021-01-77, а значит, в to_python должна быть ошибка. Почему же это работает?

Про такое я говорю: Играй по правилам фреймворка, и он будет играть за тебя. Фреймворки берут на себя ряд стандартных задач. Если же фреймворк чего-то не может, то хороший фреймворк предоставляет возможность расширить свой функционал. Поэтому не стоит заниматься велосипедостроением, лучше посмотреть, как фреймворк предлагает улучшить собственные возможности.

У Django есть подсистема маршрутизации с возможностью добавления конвертеров, которая берёт на себя обязанности по вызову метода to_python и отлавливания ошибок ValueError.

Привожу код из подсистемы маршрутизации Django без изменений (версия 3.1, файл django/urls/resolvers.py, класс RoutePattern, метод match):

match = self.regex.search(path)if match:   # RoutePattern doesn't allow non-named groups so args are ignored.   kwargs = match.groupdict()   for key, value in kwargs.items():       converter = self.converters[key]       try:           kwargs[key] = converter.to_python(value)       except ValueError:           return None   return path[match.end():], (), kwargsreturn None

Первым делом производится поиск совпадений в переданном от клиента маршруте с помощью регулярного выражения. Тот самый regex, что определен в классе конвертера, участвует в формировании self.regex, а именно подставляется вместо выражения в угловых скобках <> в маршруте.

Например,
users/<int:id>/reports/<date:dt>/
превратится в
^users/(?P<id>[0-9]+)/reports/(?P<dt>[0-9]{4}-[0-9]{2}-[0-9]{2})/$

В конце как раз та самая регулярка из DateConverter.

Это и есть быстрый поиск, поверхностный. Если совпадение не найдено, то маршрут точно не подходит, но если найдено, то это потенциально подходящий маршрут. Значит, нужно запускать следующий этап проверки.

Для каждого параметра имеется свой конвертер, который и используется для вызова метода to_python. И вот здесь самое интересное: вызов to_python обёрнут в try/except, и отлавливаются ошибки типа ValueError. Именно поэтому и работает конвертер даже в случае некорректной даты: валится ошибка ValueError, и это расценивается так, что маршрут не подходит.

Так что в случае с DateConverter, можно сказать, повезло: в случае некорректной даты валится ошибка нужного типа. Если будет ошибка другого типа, то Django вернёт ответ с кодом 500.

Не стоит останавливаться


Кажется, что всё отлично, конвертеры работают, в обработчики сразу приходят нужные типы Или не сразу?

path('users/<int:id>/reports/<date:dt>/', user_report, name='user_report'),

В обработчике для формирования отчёта наверняка нужен именно User, а не его id (хотя и такое может быть). В моей гипотетической ситуации для создания отчёта нужен как раз именно объект User. Что же тогда получается, опять двадцать пять?

def user_report(request, id: int, dt: datetime):   user = get_object_or_404(User, id=id)     # ...

Снова перекладывание обязанностей на обработчик.

Но теперь понятно, что с этим делать: писать свой конвертер! Он убедится в существовании объекта User и передаст его в обработчик.

class UserConverter:   regex = r'[0-9]+'   def to_python(self, value: str) -> User:       try:           return User.objects.get(id=value)       except Category.DoesNotExist:           raise ValueError('not exists') # именно ValueError   def to_url(self, value: User) -> str:       return str(value.id)

После описания класса регистрирую его:

register_converter(UserConverter, 'user')

И наконец описываю маршрут:

path('users/<user:u>/reports/<date:dt>/', user_report, name='user_report'),

Так-то лучше:

def user_report(request, u: User, dt: datetime):     # ...

Конвертеры для моделей могут использоваться часто, поэтому удобно сделать базовый класс такого конвертера (заодно добавил проверку на существование всех атрибутов):

class ModelConverter:   regex: str = None   queryset: QuerySet = None   model_field: str = None   def __init__(self):       if None in (self.regex, self.queryset, self.model_field):           raise AttributeError('ModelConverter attributes are not set')   def to_python(self, value: str)-> models.Model:       try:           return self.queryset.get(**{self.model_field: value})       except Category.DoesNotExist:           raise ValueError('not exists')   def to_url(self, value) -> str:       return str(getattr(value, self.model_field))

Тогда описание нового конвертера в модель сведётся к декларативному описанию:

class UserConverter(ModelConverter):   regex = r'[0-9]+'   queryset = User.objects.all()   model_field = 'id'

Итоги


Конвертеры маршрутов мощный механизм, который помогает делать код чище. Но появился этот механизм только во второй версии Django до этого приходилось обходиться без него. Отсюда и взялись вспомогательные функции типа get_object_or_404, без этого механизма сделаны крутые библиотеки вроде DRF.

Но это не значит, что конвертеры не надо использовать вовсе. Это значит, что (пока) не везде получится их использовать. Но там, где это возможно, я призываю вас не пренебрегать ими.

Оставлю одно предостережение: тут важно не переусердствовать и не перетянуть одеяло в другую сторону не надо забирать бизнес-логику в конвертер. Надо ответить на вопрос: если такой маршрут в принципе невозможен, тогда это зона ответственности конвертера; если же такой маршрут возможен, но при определённых обстоятельствах он не обрабатывается, тогда это уже зона ответственности обработчика, сериализатора или ещё кого-то, но точно не конвертера.

P.S. На практике я делал и использовал только конвертер для дат, как раз тот самый, который приведён в статье, поскольку почти всегда использую DRF или GraphQL. Расскажите, пользуетесь ли вы конвертерами маршрутов и, если пользуетесь, то какими?
Подробнее..

Domain-driven design, Hexagonal architecture of ports and adapters, Dependency injection и Python

31.05.2021 12:16:05 | Автор: admin

Prologue

- Глянь, статью на Хабр подготовил.
- Эм... а почему заголовок на английском?
- "Предметно-ориентированное проектирование, Гексагональная архитектура портов и адаптеров, Внедрение зависимостей и Пайто..."

С пронзительным хлопком в воздухе материализуется обалдевший Сатана в обличии сине-жёлтого питона.

Intro

Как же летит время! Два года назад я расстался с миром Django и очутился в мире Kotlin, Java и Spring Boot. Я испытал самый настоящий культурный шок. Голова гудела от объёма новых знаний. Хотелось бежать обратно в тёплую, ламповую, знакомую до байтов экосистему Питона. Особенно тяжело на первых порах давалась концепция инверсии управления (Inversion of Control, IoC) при связывании компонентов. После прямолинейного подхода Django, автоматическое внедрение зависимостей (Dependency Injection, DI) казалось чёрной магией. Но именно эта особенность фреймворка Spring Boot позволила проектировать приложения следуя заветам Чистой Архитектуры. Самым же большим вызовом стал отказ от философии "пилим фичи из трекера" в пользу Предметно-ориентированного проектирования (Domain-Driven Design, DDD).

Наш проект стремительно растёт и усложняется. Несмотря на это, его легко поддерживать, тестировать и развивать - всё благодаря качественному фундаменту и архитектуре. Код получается выразительным и легковесным. Компоненты - легко заменяемыми. Во всех отношениях это приложение качественнее всего написанного каждым членом нашей команды в прошлом.

Оглядываясь назад вспоминаю, какие пробелы в моём опыте и знаниях не позволяли писать и решать задачи бизнеса так элегантно. Если вы живёте в экоситеме Питона и на практике хотите познакомиться со всем перечисленным в заголовке, прошу!

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

Dependency Injection

Вы знаете что такое Внедрение зависимостей ака Dependency Injection (DI). Точно знаете, хотя можете и не вспомнить формального определения. Давайте на небольшом примере рассмотрим, в чём плюсы и минусы этого подхода (если вам угодно - шаблона).

Допустим нам понадобилась функция, отправляющая сообщения с пометкой "ТРЕВОГА!" в шину сообщений. После недолгих размышлений напишем:

from my_cool_messaging_library import get_message_bus()def send_alert(message: str):    message_bus = get_message_bus()    message_bus.send(topic='alert', message=message)

В чём главная проблема функции send_alert()? Она зависит от объекта message_bus, но для вызывающего эта зависимость совершенно не очевидна! А если вы хотите отправить сообщение по другой шине? А как насчёт уровня магии, необходимой для тестирования этой функции? Что, что? mock.patch(...) говорите? Коллеги, атака в лоб провалилась, давайте зайдём с флангов.

from my_cool_messaging_library import MessageBusdef send_alert(message_bus: MessageBus, message: str):    message_bus.send(topic='alert', message=message)

Казалось, небольшое изменение, добавили аргумент в функцию. Но одним лишь этим изменением мы убиваем нескольких зайцев: Вызывающему очевидно, что функция send_alert() зависит от объекта message_bus типа MessageBus (да здравствуют аннотации!). А тестирование, из обезьяньих патчей с бубном, превращается в написание краткого и ясного кода. Не верите?

def test_send_alert_sends_message_to_alert_topic()    message_bus_mock = MessageBusMock()    send_alert(message_bus_mock, "A week of astrology at Habrahabr!")    assert message_bus_mock.sent_to_topic == 'alert'    assert message_bus_mock.sent_message == "A week of astrology at Habrahabr!"class MessageBusMock(MessageBus):    def send(self, topic, message):        self.sent_to_topic = topic        self.sent_message = message

Тут искушённый читатель задастся вопросом: неужели придётся передавать экземпляр message_bus в функцию send_alert() при каждом вызове? Но ведь это неудобно! В чём смысл каждый раз писать

send_alert(get_message_bus(), "Stackoverflow is down")

Попытаемся решить эту проблему посредством ООП:

class AlertDispatcher:    _message_bus: MessageBus    def __init__(self, message_bus: MessageBus):        self._message_bus = message_bus    def send(message: str):        self._message_bus.send(topic='alert', message=message)alert_dispatcher = AlertDispatcher(get_message_bus())alert_dispatcher.send("Oh no, yet another dependency!")

Теперь уже класс AlertDispatcher зависит от объекта типа MessageBus. Мы внедряем эту зависимость в момент создания объекта AlertDispatcher посредством передачи зависимости в конструктор. Мы связали (we have wired, не путать с coupling!) объект и его зависимость.

Но теперь акцент смещается с message_bus на alert_dispatcher! Этот компонент может понадобиться в различных местах приложения. Мало ли откуда нужно оправить сигнал тревоги! Значит, необходим некий глобальный контекст из которого можно будет этот объект достать. И прежде чем перейти к построению такого контекста, давайте немного порассуждаем о природе компонентов и их связывании.

Componential architecture

Говоря о внедрении зависимостей мы не сильно заостряли внимание на типах. Но вы наверняка догадались, что MessageBus - это всего лишь абстракция, интерфейс, или как бы сказал PEP-544 - протокол. Где-то в нашем приложении объявленo:

class MessageBus(typing.Protocol):    def send(topic: str, message: str):        pass

В проекте также есть простейшая реализация MessageBus-a, записывающая сообщения в список:

class MemoryMessageBus(MessageBus):    sent_messages = []    def send(topic: str, messagge: str):        self.sent_messages.append((str, message))

Таким же образом можно абстрагировать бизнес-логику, разделив абстрактный сценарий пользования (use case) и его имплементацию:

class DispatchAlertUseCase(typing.Protocol):    def dispatch_alert(message: str):        pass
class AlertDispatcherService(DispatchAlertUseCase):    _message_bus: MessageBus    def __init__(self, message_bus: MessageBus):        self._message_bus = message_bus    def dispatch_alert(message: str):        self._message_bus.send(topic='alert', message=message)

Давайте для наглядности добавим HTTP-контроллер, который принимает сообщения по HTTP-каналу и вызывает DispatchAlertUseCase:

class ChatOpsController:    ...    def __init__(self, dispatch_alert_use_case: DispatchAlertUseCase):        self._dispatch_alert_use_case = dispatch_alert_use_case    @post('/alert)    def alert(self, message: Message):        self._dispatch_alert_use_case.dispatch_alert(message)        return HTTP_ACCEPTED

Наконец, всё это необходимо связать воедино:

from my_favourite_http_framework import http_serverdef main():    message_bus = MemoryMessageBus()    alert_dispatcher_service = AlertDispatcherService(message_bus)    chat_opts_controller = ChatOpsController(alert_dispatcher_service)    http_server.start()

Первой же реакцией здорового программиста будет: "ну нафига громоздить столько кода?". Ваша правда, всё вышенаписанное умещается в одну коротенькую функцию:

@post('/alert)def alert(message: Message):    bus = MemoryMessageBus()    bus.send(topic='alert', message=message)    return HTTP_ACCEPTED

Коротко? Ещё как! Поддерживаемо? Вообще никак. Почему? Из-за сильнейшей связанности (coupling) компонентов в коде. Уместив всё в одну функцию таким образом, мы намертво привязали логику отправки оповещений к конкретной реализации шины сообщений. Но это ещё полбеды. Самое ужасное то, что бизнес-составляющая полностью растворилась в технических деталях. Не поймите меня неправильно, подобный код вполне имеет право на существование. Но простит ли растущее приложение такой сжатый подход?

Вернёмся к нашей компонентной архитектуре. В чём её преимущества?

  • Компоненты изолированы и независимы друг от друга напрямую. Вместо этого они связаны посредством абстракций.

  • Каждый компонент работает в чётких рамках и решает лишь одну задачу.

  • Это значит, что компоненты могут быть протестированы как в полной изоляции, так и в любой произвольной комбинации включающей тестовых двойников (test double). Думаю не стоит объяснять, насколько проще тестировать изолированные части программы. Подход к TDD меняется с невнятного "нуууу, у нас есть тесты" на бодрое "тесты утром, вечером код".

  • С учётом того, что зависимости описываются абстракциями, можно безболезненно заменить один компонент другим. В нашем примере - вместо MemoryMessageBus можно бухнуть DbMessageBus, да хоть в файл на диске писать - тому кто вызывает message_bus.send(...) нет до этого никакого дела.

"Да это же SOLID!" - скажите вы. И будете абсолютно правы. Не удивлюсь, если у вас возникло чувство дежавю, ведь благородный дон @zueve год назад детально описал связь SOLID и Чистой архитектуры в статье "Clean Architecture глазами Python-разработчика". И наша компонентная архитектура находится лишь в шаге от чистой "гексагональной" архитектуры. Кстати, причём тут гексагон?

Architecture is about intent

Одно из замечательных высказываний дядюшки Боба на тему архитектуры приложений - Architecture is about intent (Намерения - в архитектуре).

Что вы видите на этом скриншоте?

Не удивлюсь, если многие ответили "Типичное приложение на Django". Отлично! А что же делает это приложение? Вы вероятно телепат 80го уровня, если смогли ответить на этот вопрос правильно. Лично я не именю ни малейшего понятия - это скриншот первого попавшегося Django-приложения с Гитхаба.

Роберт Мартин развивает идею дальше. Взгляните на архитектурный план этажа и догадайтесь, для чего предназначено это здание?

Разгадка

Это один из этажей библиотеки Oodi в Хельсинки.

Надеюсь вам было несложно отгадать эту маленькую загадку и вы вынесли из неё главное: архитектура должна встречать нас с порога, буквально с момента окончания git clone.... Как здорово, когда код приложения организован таким образом, что предназначение того или иного файла или директории лежит на поверхности!

В "Гексагональной архитектуре", гексагон в частности призван упростить восприятие архитектуры. Мудрено? Пардон, сейчас всё будет продемонстрировано наглядно.

Hexagonal architecture of Ports and Adapters

"У нас Гексагональная архитектура портов и адаптеров" - с этой фразы начинается рассказ об архитектуре приложения новым членам команды. Далее мы показываем нечто Ктулхуподобное:

Изобретатель термина "Гексагональная архитектура" Алистар Кокбёрн (Alistair Cockburn) объясняя выбор названия акцентировал внимание на его графическом представлении:

В гексагоне количество граней не играет роли. Важна сама возможность пририсовать порты и адаптеры по необходимости, выходя за рамки изображения послойных однонаправленных графов. Термин "гексагональная архитектура" вытекает из визуальной составляющей. (источник)

Итак, на изображении мы видим:

Домен (предметная область) - это сердце приложения. Классы, методы, функции, константы и другие объекты домена повторяют язык предметной области. Например, правило Хабра

"Пользователь может голосовать за публикации, комментарии и карму других пользователей если его карма 5"

будет отображено именно здесь. И как вы наверняка поняли, в домене нет места HTTP, SQL, RabbitMQ, AWS и т.д. и т.п.

Зато всему этому празднику технологий есть место в адаптерах подсоединяемых к портам. Команды и запросы поступают в приложение через ведущие (driver) или API порты. Команды и запросы которые отдаёт приложение поступают в ведомые порты (driven port). Их также называют портами интерфейса поставщика услуг (Service Provider Interface, SPI).

Между портами и доменом сидят дирижёры - сервисы приложения (Application services). Они являются связующим звеном между сценариями пользования, доменом и ведомыми портами необходимыми для выполнения сценария. Также стоит упомянуть, что именно сервис приложения определяет, будет ли сценарий выполняться в рамках общей транзакции, или нет.

Всё это - и порты, и адаптеры и сервисы приложения и даже домен - слои архитектуры, состоящие из индивидуальных компонентов. Главной заповедью взаимодействия между слоями является "Зависимости всегда направлены от внешних слоёв к центру приложения". Например, адаптер может ссылаться на домен или другой адаптер, а домен ссылаться на адаптер - не может.

И... ВСЁ. Это - вся суть Гексагональной архитектуры портов и адаптеров. Она замечательно подходит для задач с обширной предметной областью. Для голого CRUDа а-ля HTTP интерфейс для базы данных, такая архитектура избыточна - Active Record вам в руки.

Давайте же засучим рукава и разберём на примере, как спроектировать Django-приложение по канонам гексагональной архитектуры.

Interlude

Дорогой читатель! Спасибо, что дошли до этого места, надеюсь сей опус не утомляет вас, а наоборот захватывает и открывает новые горизонты.

Во второй части вас ждёт реализация гексагональной архитектуры на знакомом нам всем примере. В первой части мы старались абстрагироваться от конкретных решений, будь то фреймворки или библиотеки. Последующий пример построен на основе Django и DRF с целью продемонстрировать, как можно вплести гексагональную архитектуру в фреймворк с устоявшимися традициями и архитектурными решениями. В приведённых примерах вырезаны некоторые необязательные участки и имеются допущения. Это сделано для того, чтобы мы могли сфокусироваться на важном и не отвлекались на второстепенные детали. Полностью исходный код примера доступен в репозитории https://github.com/basicWolf/hexagonal-architecture-django.

Upvote a post at Hubruhubr

Представим, что мы разрабатываем новую платформу коллективных технических блогов "Хубрухубр" и нам нужно реализовать сценарий пользования "Проголосовать за публикацию". Вместе с командой экспертов мы разобрали некоторые нюансы этого сценария:

Рейтинг публикации меняется путём голосования пользователей.

  1. Пользователь может проголосовать "ЗА" или "ПРОТИВ" публикации.

  2. Пользователь может голосовать если его карма 5.

  3. Проголосовать за данную публикацию можно лишь один раз, изменить голос нельзя.

С чего же начать работу? Конечно же с построения модели предметной области!

Domain model

Давайте ещё раз внимательно прочтём требования и подумаем, как описать "пользователя голосующего за публикацию"? Например (source):

# src/myapp/application/domain/model/voting_user.pyclass VotingUser:    id: UUID    voting_for_article_id: UUID    voted: bool    karma: int    def cast_vote(self, vote: Vote) -> CastArticleVoteResult:        ...

На первый взгляд - сомнительного вида творение. Но обратившись к деталям сценария мы убедимся, что этот набор данных - необходим и достаточен для голосования. Vote и CastArticleVoteResult - это также модели домена (source):

# src/myapp/application/domain/model/vote.py# Обозначает голос "За" или "Против"class Vote(Enum):    UP = 'up'    DOWN = 'down'

В свою очередь CastArticleVoteResult - это тип объединяющий оговорённые исходы сценария: ГолосПользователя, НедостаточноКармы, ПользовательУжеПроголосовалЗаПубликацию (source):

# src/myapp/application/domain/model/cast_article_vote_result.py...CastArticleVoteResult = Union[ArticleVote, InsufficientKarma, VoteAlreadyCast]

Как вы думаете, каких данных достаточно для описания результата успешно выполненного сценария?

Ответ

(source)

# src/myapp/application/domain/model/article_vote.py@dataclassclass ArticleVote:    user_id: UUID    article_id: UUID    vote: Vote    id: UUID = field(default_factory=uuid4)

Но самое интересное будет происходить в теле метода cast_article_vote(). И начнём мы конечно же с тестов. Первый же тест нацелен на проверку успешно выполненного сценария (source):

def test_cast_vote_returns_article_vote(user_id: UUID, article_id: UUID):    voting_user = VotingUser(        user_id=user_id,        voting_for_article_id=article_id,        karma=10    )    result = voting_user.cast_vote(Vote.UP)    assert isinstance(result, ArticleVote)    assert result.vote == Vote.UP    assert result.article_id == article_id    assert result.user_id == user_id

Запускаем тест и... ожидаемый фейл. В лучших традициях ТДД мы начнём игру в пинг-понг с тестами и кодом, с каждым тестом дописывая сценарий до полной готовности (source):

MINIMUM_KARMA_REQUIRED_FOR_VOTING = 5...def cast_vote(self, vote: Vote) -> CastArticleVoteResult1:    if self.voted:        return VoteAlreadyCast(            user_id=self.id,            article_id=self.voting_for_article_id        )    if self.karma < MINIMUM_KARMA_REQUIRED_FOR_VOTING:        return InsufficientKarma(user_id=self.id)    self.voted = True    return ArticleVote(        user_id=self.id,        article_id=self.voting_for_article_id,        vote=vote    )

На этом мы закончим моделирование предметной области и приступим к написанию API приложения.

Driver port: Cast article vote use case

Как было сказано ранее, в гексагональной архитектуре, приложение управляется через API-порты.

Чтобы как-то дотянуться до доменной модели, в наше приложение нужно добавить ведущий порт CastArticleVotingtUseCase, который принимает ID пользователя, ID публикации, значение голоса: за или против и возвращает результат выполненного сценария (source):

# src/myapp/application/ports/api/cast_article_vote/cast_aticle_vote_use_case.pyclass CastArticleVoteUseCase(Protocol):    def cast_article_vote(self, command: CastArticleVoteCommand) -> CastArticleVoteResult:        raise NotImplementedError()

Все входные параметры сценария обёрнуты в единую структуру-команду CastArticleVoteCommand (source), а все возможные результаты объединены - это уже знакомая модель домена CastArticleVoteResult (source):

# src/myapp/application/ports/api/cast_article_vote/cast_article_vote_command.py@dataclassclass CastArticleVoteCommand:    user_id: UUID    article_id: UUID    vote: Vote

Работа с гексагональной архитектурой чем-то напоминает прищурившегося Леонардо ди Каприо с фразой "We need to go deeper". Набросав каркас сценария пользования, можно примкнуть к нему с двух сторон. Можно имплементировать сервис, который свяжет доменную модель и ведомые порты для выполнения сценария. Или заняться API адаптерами, которые вызывают этот сценарий. Давайте зайдём со стороны API и напишем HTTP адаптер с помощью Django Rest Framework.

HTTP API Adapter

Наш HTTP адаптер, или на языке Django и DRF - View, до безобразия прост. За исключением преобразований запроса и ответа, он умещается в несколько строк (source):

# src/myapp/application/adapter/api/http/article_vote_view.pyclass ArticleVoteView(APIView):    ...    def __init__(self, cast_article_vote_use_case: CastArticleVoteUseCase):        self.cast_article_vote_use_case = cast_article_vote_use_case        super().__init__()    def post(self, request: Request) -> Response:        cast_article_vote_command = self._read_command(request)        result = self.cast_article_vote_use_case.cast_article_vote(            cast_article_vote_command        )        return self._build_response(result)    ...

И как вы поняли, смысл всего этого сводится к

  1. Принять HTTP запрос, десериализировать и валидировать входные данные.

  2. Запустить сценарий пользования.

  3. Сериализовать и возвратить результат выполненного сценария.

Этот адаптер конечно же строился по кирпичику с применением практик TDD и использованием инструментов Django и DRF для тестирования view-шек. Ведь для теста достаточно построить запрос (request), скормить его адаптеру и проверить ответ (response). При этом мы полностью контролируем основную зависимость cast_article_vote_use_case: CastArticleVoteUseCase и можем внедрить на её место тестового двойника.

Например, давайте напишем тест для сценария, в котором пользователь пытается проголосовать повторно. Ожидаемо, что статус в ответе будет 409 CONFLICT (source):

# tests/test_myapp/application/adapter/api/http/test_article_vote_view.pydef test_post_article_vote_with_same_user_and_article_id_twice_returns_conflict(    arf: APIRequestFactory,    user_id: UUID,    article_id: UUID):    # В роли объекта реализующего сценарий выступает    # специализированный двойник, возвращающий при вызове    # .cast_article_vote() контролируемый результат.    # Можно и MagicMock, но нужно ли?    cast_article_use_case_mock = CastArticleVoteUseCaseMock(        returned_result=VoteAlreadyCast(            user_id=user_id,            article_id=article_id        )    )    article_vote_view = ArticleVoteView.as_view(        cast_article_vote_use_case=cast_article_use_case_mock    )    response: Response = article_vote_view(        arf.post(            f'/article_vote',            {                'user_id': user_id,                'article_id': article_id,                'vote': Vote.UP.value            },            format='json'        )    )    assert response.status_code == HTTPStatus.CONFLICT    assert response.data == {        'status': 409,        'detail': f"User \"{user_id}\" has already cast a vote for article \"{article_id}\"",        'title': "Cannot cast a vote"    }

Адаптер получает на вход валидные данные, собирает из них команду и вызывает сценарий. Oднако, вместо продакшн-кода, этот вызов получает двойник, который тут же возвращает VoteAlreadyCast. Адаптеру же нужно правильно обработать этот результат и сформировать HTTP Response. Остаётся протестировать, соответствует ли сформированный ответ и его статус ожидаемым значениям.

Ещё раз попрошу заметить, насколько облегчённее становится тестирование, когда не нужно загружать всё приложение целиком. Адепты Django вспомнят о легковесном тестировании вьюшек посредством RequestFactory. Но гексагональная архитектура позволяет шагнуть дальше. Мы избавились от обезьяньих патчей и mock-обёрток конкретных классов. Мы легко управляем поведением зависимостей нашего View, ведь взаимодействие с ними происходит через абстрактный интерфейс. Всё это легко модифицировать и отлаживать.

После написания тестов и имплементации для остальных случаев входных и выходных данных, мы получаем отточенный API компонент. Следующим шагом нужно пристыковать этот компонент к рабочей версии сценария.

Application services

Как дирижёр управляет оркестром исполняющим произведение, так и сервис приложения управляет доменом и ведомыми портами при выполнении сценария.

PostRatingService

С места в карьер погрузимся в имплементацию нашего сценария. В первом приближении сервис реализующий сценарий выглядит так (source):

# src/myapp/application/service/post_rating_service.pyclass PostRatingService(    CastArticleVoteUseCase  # имплементируем протокол явным образом):    def cast_article_vote(self, command: CastArticleVoteCommand) -> CastArticleVoteResult:        ...

Отлично, но откуда возьмётся голосующий пользователь? Тут и появляется первая SPI-зависимость GetVotingUserPort задача которой найти голосующего пользователя по его ID. Но как мы помним, доменная модель не занимается записью голоса в какое-либо долговременное хранилище вроде БД. Для этого понадобится ещё одна SPI-зависимость SaveArticleVotePort:

# src/myapp/application/service/post_rating_service.pyclass PostRatingService(    CastArticleVoteUseCase):    _get_voting_user_port: GetVotingUserPort    _save_article_vote_port: SaveArticleVotePort    # def __init__(...) # внедрение зависимостей oпустим, чтобы не раздувать листинг    def cast_article_vote(self, command: CastArticleVoteCommand) -> CastArticleVoteResult:        voting_user = self._get_voting_user_port.get_voting_user(            user_id=command.user_id,            article_id=command.article_id        )        cast_vote_result = voting_user.cast_vote(command.vote)        if isinstance(cast_vote_result, ArticleVote):            self._save_article_vote_port.save_article_vote(cast_vote_result)        return cast_vote_result

Вы наверняка представили как выглядят интерфейсы этих SPI-зависимостей. Приведём один из интерфейсов здесь (source):

# src/myapp/application/ports/spi/save_article_vote_port.pyclass SaveArticleVotePort(Protocol):    def save_article_vote(self, article_vote: ArticleVote) -> ArticleVote:        raise NotImplementedError()

За кадром мы конечно же сначала напишем тесты, а уже потом код :) При написании юнит-тестов роль SPI-адаптеров в тестах сервиса, как и в предыдущих примерах, играют дублёры. Но чтобы удержать сей опус в рамках статьи, позвольте оставить тесты в виде ссылки на исходник (source) и двинуться дальше.

SPI Ports and Adapters

Продолжим рассматривать SPI-порты и адаптеры на примере SaveArticleVotePort. К этому моменту можно было и забыть, что мы всё ещё находимся в рамках Django. Ведь до сих пор не было написано того, с чего обычно начинается любое Django-приложение - модель данных! Начнём с адаптера, который можно подключить в вышеуказанный порт (source):

# src/myapp/application/adapter/spi/persistence/repository/article_vote_repository.pyfrom myapp.application.adapter.spi.persistence.entity.article_vote_entity import (    ArticleVoteEntity)from myapp.application.domain.model.article_vote import ArticleVotefrom myapp.application.ports.spi.save_article_vote_port import SaveArticleVotePortclass ArticleVoteRepository(    SaveArticleVotePort,):    def save_article_vote(self, article_vote: ArticleVote) -> ArticleVote:        article_vote_entity = ArticleVoteEntity.from_domain_model(article_vote)        article_vote_entity.save()        return article_vote_entity.to_domain_model()

Вспомним, что паттерн "Репозиторий" подразумевает скрытие деталей и тонкостей работы с источником данных. "Но позвольте! - скажете Вы, - a где здесь Django?". Чтобы избежать путаницы со словом "Model", модель данных носит гордое название ArticleVoteEntity. Entity также подразумевает, что у неё имеется уникальный идентификатор (source):

# src/myapp/application/adapter/spi/persistence/entity/article_vote_entity.pyclass ArticleVoteEntity(models.Model):    ... # здесь объявлены константы VOTE_UP, VOTE_DOWN и VOTE_CHOICES    id = models.UUIDField(primary_key=True, default=uuid4, editable=False)    user_id = models.UUIDField()    article_id = models.UUIDField()    vote = models.IntegerField(choices=VOTES_CHOICES)    ...    def from_domain_model(cls, article_vote: ArticleVote) -> ArticleVoteEntity:        ...    def to_domain_model(self) -> ArticleVote:        ...

Таким образом, всё что происходит в save_article_vote() - это создание Django-модели из доменной модели, сохранение её в БД, обратная конвертация и возврат доменной модели. Это поведение легко протестировать. Например, юнит тест удачного исхода выглядит так (source):

# tests/test_myapp/application/adapter/spi/persistence/repository/test_article_vote_repository.py@pytest.mark.django_dbdef test_save_article_vote_persists_to_database(    article_vote_id: UUID,    user_id: UUID,    article_id: UUID):    article_vote_repository = ArticleVoteRepository()    article_vote_repository.save_article_vote(        ArticleVote(            id=article_vote_id,            user_id=user_id,            article_id=article_id,            vote=Vote.UP        )    )    assert ArticleVoteEntity.objects.filter(        id=article_vote_id,        user_id=user_id,        article_id=article_id,        vote=ArticleVoteEntity.VOTE_UP    ).exists()

Одним из требований Django является декларация моделей в models.py. Это решается простым импортированием:

# src/myapp/models.pyfrom myapp.application.adapter.spi.persistence.entity.article_vote_entity import ArticleVoteEntityfrom myapp.application.adapter.spi.persistence.entity.voting_user_entity import VotingUserEntity

Exceptions

Приложение почти готово!. Но вам не кажется, что мы кое-что упустили? Подсказка: Что произойдёт при голосовании, если ID пользователя или публикации будет указан неверно? Где-то в недрах Django вылетит исключение VotingUserEntity.DoesNotExist, что на поверхности выльется в неприятный HTTP 500 - Internal Server Error, хотя правильнее было бы вернуть HTTP 400 - Bad Request с телом, содержащим причину ошибки.

Ответ на вопрос, "В какой момент должно быть обработано это исключение?", вовсе не очевиден. С архитектурной точки зрения, ни API, ни домен не волнуют проблемы SPI-адаптеров. Максимум, что может сделать API с таким исключением - обработать его в общем порядке, а-ля except Exception:. С другой стороны SPI-порт может предоставить исключение-обёртку, в которую SPI-адаптер завернёт внутреннюю ошибку. А API может её поймать.

О, я слышу вас, дорогие адепты функционального программирования! "Какие исключения? В топку! Даёшь Either!". В ваших словах много правды и эта тема заслуживает отдельной статьи. В одном я же, я полностью соглашусь с вами - в домене не должно быть исключений!.

Например, в данной ситуации уместным будет исключение VotingUserNotFound (source) в которое оборачивается VotingUserEntity.DoesNotExist (source):

# src/myapp/application/adapter/spi/persistence/exceptions/voting_user_not_found.pyclass VotingUserNotFound(Exception):    def __init__(self, user_id: UUID):        super().__init__(user_id, f"User '{user_id}' not found")# ---# myapp/application/adapter/spi/persistence/repository/voting_user_repository.pyclass VotingUserRepository(GetVotingUserPort):    ...    def get_voting_user(self, user_id: UUID, article_id: UUID) -> VotingUser:        try:            # Код немного упрощён, в оригинале здесь происходит            # аннотация флагом "голосовал ли пользователь за статью".            # см. исходник            entity = VotingUserEntity.objects.get(id=user_id)        except VotingUserEntity.DoesNotExist as e:            raise VotingUserNotFound(user_id) from e        return self._to_domain_model(entity)

А вот теперь действительно, приложение почти готово! Осталось соединить все компоненты и точки входа.

Dependencies and application entry point

Традиционно точки входа и маршрутизация HTTP-запросов в Django-приложениях декларируется в urls.py. Всё что нам нужно сделать - это добавить запись в urlpatterns (source):

urlpatterns = [    path('article_vote', ArticleVoteView(...).as_view())]

Но погодите! Ведь ArticleVoteView требует зависимость имплементирующую CastArticleVoteUseCase. Это конечно же PostRatingService... которому в свою очередь требуются GetVotingUserPort и SaveArticleVotePort. Всю эту цепочку зависимостей удобно хранить и управлять из одного места - контейнера зависимостей (source):

# src/myapp/dependencies_container.py...def build_production_dependencies_container() -> Dict[str, Any]:    save_article_vote_adapter = ArticleVoteRepository()    get_vote_casting_user_adapter = VotingUserRepository()    cast_article_vote_use_case = PostRatingService(        get_vote_casting_user_adapter,        save_article_vote_adapter    )    article_vote_django_view = ArticleVoteView.as_view(        cast_article_vote_use_case=cast_article_vote_use_case    )    return {        'article_vote_django_view': article_vote_django_view    }

Этот контейнер инициализируется на старте приложения в AppConfig.ready() (source):

# myapp/apps.pyclass MyAppConfig(AppConfig):    name = 'myapp'    container: Dict[str, Any]    def ready(self) -> None:        from myapp.dependencies_container import build_production_dependencies_container        self.container = build_production_dependencies_container()

И наконец urls.py:

app_config = django_apps.get_containing_app_config('myapp')article_vote_django_view = app_config.container['article_vote_django_view']urlpatterns = [    path('article_vote', article_vote_django_view)]

Inversion of Control Containers

Для реализации одного небольшого сценария нам понадобилось создать и связать четыре компонента. С каждым новым сценарием, число компонентов будет расти и количество связей будет увеличиваться в арифметической прогрессии. Как управлять этим зоопарком, когда приложение начнёт разрастаться до неприличных размеров? Тут на помощь приходят Контейнеры Инверсии Управления.

IoC-container - это фреймворк управляющий объектами и их зависимостями во время исполнения программы.

Spring был первым универсальным IoC-контейнером / фреймворком с которым я столкнулся на практике (для зануд: Micronaut - да!). Чего уж таить, я не сразу проникся заложенными в него идеями. По-настоящему оценить всю мощь автоматического связывания (autowiring) и сопутствующего функционала я смог лишь выстраивая приложение следуя практикам гексагональной архитектуры.

Представьте, насколько удобнее будет использование условного декоратора @Component, который при загрузке программы внесёт класс в реестр зависимостей и выстроит дерево зависимостей автоматически?

T.e. если зарегистрировать компоненты:

@Componentclass ArticleVoteRepository(    SaveArticleVotePort,):    ...@Componentclass VotingUserRepository(GetVotingUserPort):    ...

То IoC-container сможет инициализировать и внедрить их через конструктор в другой компонент:

```@Componentclass PostRatingService(    CastArticleVoteUseCase):    def __init__(        self,        get_voting_user_port: GetVotingUserPort,        save_article_vote_port: SaveArticleVotePort    ):        ...

К сожалению мне не приходилось иметь дела с подобным инструментарием в экосистеме Питона. Буду благодарен, если вы поделитесь опытом в комментариях!

Directory structure

Помните скриншот "типичного Django-приложения"? Сравните его с тем что получилось у нас:

Чувствуете разницу? Нам больше не нужно лезть в файлы в надежде разобраться, что же там лежит и для чего они предназначены. Более того, теперь даже структура тестов и кода приложения идентичны! Архитектура приложения видна невооружённым глазом и существует "на бумаге", а не только в голове у разработчиков приложения.

Interlude

Давайте дружно выдохнем! Даю честное слово, больше ни одной строчки кода! Новый сценарий пользования готов к испытаниям. И пока коллеги вносят последние штрихи (миграция БД, отписки в трекере задач и т.п.) предлагаю поразмышлять вслух о том, почему гексагональная архитектура и предметно-ориентированное проектирование отлично подходят друг-другу.

Domain-Driven Design

Эрик Эванс (Eric Evans) популяризировал термин "Domain-Driven Design" в "большой синей книге" написанной в 2003м году. И всё заверте... Предметно-ориентированное проектирование - это методология разработки сложных систем, в которой во главу угла ставится понимание разработчиками предметной области путем общение с представителями (экспертами) предметной области и её моделирование в коде.

Мартин Фаулер (Martin Folwer) в своей статье рассуждая о заслугах Эванса подчёркивает, что в этой книге Эванс закрепил терминологию DDD, которой мы пользуемся и по сей день.

В частности, Эванс ввёл понятие об Универсальном Языке (Ubiquitous Language) - языке который разработчики с одной стороны и эксперты предметной области с другой, вырабатывают в процессе общения в течении всей жизни продукта. Невероятно сложно создать систему (а ведь смысл DDD - помочь нам проектировать именно сложные системы!) не понимая, для чего она предназначена и как ею пользуются.

У него и команды программистов, которой он руководил, на это ушло более года. Работать было особенно тяжело, потому что заказчик ни за что не хотел сообщить, для каких целей создаются подсистемы. В техническом задании он находил только параметры требуемой системы, но никаких сведений о ее использовании. Недри работал чуть ли не вслепую. И вот теперь, когда система пришла в действие, он не удивился, что в ней оказались скрытые дефекты.

- Майкл Крайтон, "Парк Юрского периода"

Более того, универсальный язык, со всеми оговорёнными терминами, сущностями, действиями, связями и т.д. используется при написании программы - в названиях модулей, функций, методов, классов, констант и даже переменных!

Другой важный термин - Ограниченный Контекст (Bounded Context) - автономные части предметной области с устоявшимися правилами, терминами и определениями. Простой пример: в онлайн магазине, модель "товар" несёт в себе совершенно разный смысл для отделов маркетинга, бухгалтерии, склада и логистики. Для связи моделей товара в этих контекстах достаточно наличие одинакового идентификатора (например UUID).

Понятие об Агрегатах (Aggregate) - наборе объектов предметной области, с которыми можно обращаться как единым целым, классификации объектов-значений и объектов-сущностей.

О DDD можно рассуждать и рассуждать. Эту тему не то что в одну статью, её и в толстенную книгу-то нелегко уместить. Приведу лишь несколько цитат, которые помогут перекинуть мостик между DDD и гексагональной архитектурой:

Предметная область - это сфера знаний или деятельности.

Модель - это система абстракций, представляющих определённый аспект предметной области.

Модель извлекает знания и предположения о предметной области и не является способом отобразить реальность.

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

Эти цитаты взяты из выступления Эрика Эванса на конференции DDD Europe 2019 года. Приглашаю вас насладиться этим выступлением, прежде чем вы введёте "DDD" в поиск Хабра и начнёте увлекательное падение в бездонную кроличью нору. По пути вас ждёт много открытий и куча набитых шишек. Помню один восхитительный момент: внезапно в голове сложилась мозаика и пришло озарение, что фундаментальные идеи DDD и Agile Manifesto имеют общие корни.

Hexagonal Architecture

Так причём же здесь Гексагональная архитектура? Я очень надеюсь, что внимательный читатель уже ответил на этот вопрос.

На заре Гексагональной архитектуры в 2005м году, Алистар Кокбёрн писал:

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

Гексагональная архитектура позволяет элегантно изолировать части приложения и связать их посредством абстракций.

Становится просто связать модель предметной области в коде и "на бумаге" используя универсальный язык общения с экспертами. Универсальный язык обогащается с обеих сторон. При написании кода находятся и изменяются объекты, связи между ними и всё это перетекает обратно в модель предметной области.

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

Тесты. Тэст-Дривэн Дэвэлопмэнт. В самом соке, когда тест пишется, к пока не существующему функционалу и мы даём возможность нашей IDE (или по-старинке) создать класс/метод/функцию/концепцию которая пока существует лишь в тесте. Интеграционные тесты, для которых необязательно загружать всю программу и инфраструктуру, а лишь адаптеры и необходимые для теста сервисы.

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

Microservices

Подумайте, каким образом в наши дни разбивают приложение на части, работа которых происходит в ограниченных контекстах? Ответ очевиден - Микросервисы! Все вышеописанные плюсы гексагональной архитектуры применимы и в этом случае. Но помимо вышеописанных плюсов, появляется возможность модифицировать микросервисы буквально методом "вырезать-вставить". Можно откреплять целые куски логики и кода из одних и вставлять в другие, отпочковывать микросервисы из монолита, или собирать всё обратно в монолит. И даже не важно, на каких языках написаны эти микросервисы. Ведь домен изолирован от технической составляющей! Поэтому переписывание на другой язык программирования становится куда более тривиальной задачей.

На десерт - короткое видео на тему от Дейва Фарли: The problem with microservices.

Outro

Спасибо вам уважаемый читатель. Спасибо, что не бросили меня в середине статьи и прошли со мной до конца. Надеюсь тема беседы вас заинтриговала и вы дерзнёте внедрить принципы гексагональной архитектуры и DDD в ваши проекты. Успехов и до новых встреч!

P.S.

Хотите проверить, насколько вы прониклись вышеизложенным? Тогда подумайте и ответьте, является ли поле VotingUser.voted оптимальным решением с точки зрения моделирования предметной области? А если нет, что бы вы предложили взамен?

Подробнее..

Архитектура облачного волейбольного сервиса

11.11.2020 08:04:10 | Автор: admin
Не так давно я писал про волейбольный сервис, теперь пришло время описать его с технической точки зрения.

Возможно, общественное сознание найдет изъяны в архитектуре и подтолкнет к лучшим решениям.

Краткое описание функциональности:

  • пользователь загружает видео с записью волейбольной игры
  • один хитрый алгоритм распознает мяч на кадрах
  • другой хитрый алгоритм выделяет розыгрыши
  • розыгрыши компонуются в отдельные видеофайлы
  • файлы с розыгрышами собираются в дайджест всей игры
  • все видео заливается в облако
  • пользователи смотрят/качают/шарят клипы с самыми классными розыгрышами


Например, такими:



Теперь, как это все работает.

Технологии


Все написано на python, веб-сервис Django/Gunicorn.
Интенсивно используются OpenCV и FFMpeg.
База данных Postgres.
Кэш и очередь Redis.

Альфа


В самой первой версии было 3 компонента:
  • Front Веб сервис (Django), с которым взаимодействуют конечные пользователи
  • Videoproc (Vproc) Ядро алгоритма, python + opencv, которое содержит все алгоритмы треккинга мяча и логику нарезки на розыгрыши
  • Clipper Сервис генерации видео на основе выхлопа Vproc, используя ffmpeg




Разработка велась локально, я поставил на домашний десктоп Ubuntu, а на нее microk8s и получил маленький Кубернетес-кластер.

Здесь я столкнулся с тем, что кадры, которые видит opencv не очень совпадают с временем, которое видит ffmpeg из-за особенностей кодеков, поэтому высчитать время в оригинальном файле из номера кадра оказалаось невозможно.
Из-за этого пришлось сохранять кадры, что сильно замедлило процесс и потребовало дальнейших архитектурных изменений.

Параллельная обработка


Как уже упоминалось, время обработки в 3 раза превышает время игры. Профайлер показал, что, большая часть времени тратится при записи кадров на диск, сам же разбор кадров примерно в два раза быстрее.
Логично распараллелить эти две работы.
По начальной задумке пока vproc разбирает кадры через opencv, ffmpeg параллельно записывает все на диск, а clipper собирает из них видео.
Но с ffmpeg нашлись две проблемы:
  • Кадры из ffmpeg не идентичны кадрам из opencv (это не всегда так, зависит от кодека видеофайла)
  • Количество кадров в записи может быть слишком большим например час видео при хорошем fps это порядка 200K файлов, что многовато для одного каталога, даже если это ext4. Городить разбиение на поддиректории и потом склеивать при компоновке видео не хотелось усложнять


В итоге вместо ffmpeg появился пятый элемент компонент Framer. Он запускается из vproc, и листает кадры в том же видеофайле, ожидая пока vproc найдет розыгрыши. Как только они появились framer выкладывает нужные кадры в отдельную директорию.
Из дополнительных плюсов ни одного лишнего кадра не эспортируется.
Мелочь, но все таки.

По производительности (на 10-минутном тестовом видео):
Было:
Completed file id=73, for game=test, frames=36718, fps=50, duration=600 in 1677 sec

Стало:
Completed file id=83, for game=test, frames=36718, fps=50, duration=600 in 523 sec + framer time 303


Выигрыш в два раза, очень хорошо. Если писать в несколько потоков, то наверное можно выжать еще чуть. Если хранить кадры в памяти и генерить из них видео через API, то скорее всего можно ускориться значительно.

Digital Ocean


Дальше я стал выбирать хостинг. Понятно, что основные варианты GKE, AWS, Azure, но многие авторы мелких проектов жалуются на непрозрачное ценообразование и, как следствие, немаленькие счета.
Основная засада здесь цена за исходящий трафик, она составляет порядка $100/Tb, а поскольку речь идет о раздаче видео, вероятность серьезно попасть очень неиллюзорна.

Тогда я решил глянуть второй эшелон Digital Ocean, Linode, Heroku. На самом деле Kubernetes-as-service уже не такая редкая вещь, но многие варианты не выглядят user-friendly.

Больше всего понравился Digital Ocean, потому что:
  • Managed Kubernetes
  • Managed Postgres
  • S3 хранилище с бесплатным(!) CDN + 1 TB/месяц
  • Закрытый docker registry
  • Все операции можно делать через API
  • Датацентры по всему миру


При наличии CDN веб-серверу уже не было надобности раздавать видео, однако кто-то должен был это видео опубликовать.
Так в архитектуре появился четвертый компонент Pusher.



Однако серьезным недостатком оказалась невозможность смонтировать один и тот же диск на несколько машин одновременно.

Сам DO предлагает в таких случаях использовать NFS через выделенный контейнер, но пока решил не заморачиваться, ибо, как упоминалось выше, назревает специальное решение для видео кадров, и если уже городить отдельный контейнер, то для него.

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

В итоге, пришлось привязать все стадии обработки конкретной видео к одной ноде, что конечно уменьшило общую масштабируемость системы.

Логи и метрики


Запустив кластер в облаке, я стал искать решение для сбора логов и метрик. Самостоятельно возиться с хостингом этого добра не хотелось, поэтому целью был free-tier в каком-нибудь облаке.
Такое есть не у всех: Модная Grafana хочет $50 в месяц, выходящий из моды Elastic $16, Splunk даже прямо не говорит.
Зато внезапно оказалось что New Relic, также известный своими негуманными ценами, теперь предоставляет первые 100G в месяц бесплатно.

Видео


Вполне ествественным решением виделось разделить кластер на два node-pool'а:

  1. front на котором крутятся веб-сервера
  2. vproc где обрабатывается видео


Фронтовой пул, понятно, всегда онлайн, а вот процессинговый не так прост.
Во-первых, обработка видео требует серьезных ресурсов (= денег), а во-вторых, обрабатывать приходится нечасто (особенно в стадии зарождения проекта).
Поэтому хочется включать процессинг только, чтобы обработать видео (время обработки 3x от продолжительности действа).

Кубернетес формально поддерживает autoscale 0, но как именно это реализуется я не нашел, зато нашлась такая дискуссия на Stack Overflow.

В итоге пришлось нагородить еще один под во фронтовом пуле, единственной задачей которого является посматривать в постгрес иногда и включать/выключать процессинговый пул в зависимости от того, что в базе оказалось.

У DigitalOcean есть неофициальный клиент для питона, но он уже давно не обновлялся, а Kubernetes API там не присутствует в принципе.

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

В итоге диаграмм разрослась вот так:



DevOps


Несмотря на великое множество CI/CD инструментов, в разработке не нашлось ничего удобнее Jenkins.
А для управления DigitalOcean'ом идеально подошли Github Actions.

Ссылки


Подробнее..

Микросервисы на монолите

15.12.2020 12:20:26 | Автор: admin

Всем привет!

Скажу сразу, эта статья не про очередное переписывание монолита на микросервисы, а о применении микросервисных практик в рамках существующего проекта с использованием интересных, как мне кажется, подходов. Наверное, уже нет смысла объяснять, почему многие проекты активно используют микросервисную архитектуру. Сегодня в IT возможности таких инструментов как Docker, Kubernetes, Service Mesh и прочих сильно меняют наше представление об архитектуре современного приложения, вынуждая пересматривать подходы и переписывать целые проекты на микросервисы. Но так ли это необходимо для всех частей проекта?

В нашем проекте есть несколько систем, которые писались в те времена, когда преимущества микросервисного подхода были не столь очевидны, а инструментов, позволяющих использовать такой подход, было очень мало, и переписывать системы полностью просто нецелесообразно. Для адаптации к новой архитектуре мы решили в части задач использовать асинхронный подход, а также перейти к хранению части данных на общих сервисах. Само приложение при этом осталось на Django (API для SPA). При переходе на k8s деплой приложения был разбит на несколько команд: HTTP-часть (API), Celery-воркеры и RabbitMQ-консьюмеры. Причем было именно три развёртывания, то есть все воркеры, как и консьюмеры, крутились в одном контейнере. Быстрое и простое решение. Но этого оказалось недостаточно, так как это решение не обеспечивало нужный уровень надежности.

Начнем с RabbitMQ-консьюмеров. Основная проблема была в том, что внутри контейнера стартовал воркер, который запускал много потоков на каждый консьюмер, и пока их было пару штук, всё было хорошо, но сейчас их уже десятки. Решение нашли простое: каждый консьюмер вывели в отдельную команду manage.py и деплоим отдельно. Таким образом, у нас несколько десятков k8s-развёртываний на одном образе, с разными параметрами запуска. Ресурсы мы тоже выставляем для каждого консьюмера отдельно, и реальное потребление у консьюмеров достаточно невысокое. В результате для одного репозитория у нас десятки реальных отдельных сервисов в k8s, которые можно масштабировать, и при этом разработчикам намного удобнее работать только с одним репозиторием. То же самое и для развёртываний Сelery.

Если решение с воркерами достаточно простое и очевидное, то с HTTP-частью было немного сложнее. В нашей системе внешний межсервисный API и внутренний для фронтенда были в одном развёртывании с большим лимитом ресурсов и большим количеством инстансов.

Вроде бы рабочее решение? Да, но ДомКлик большой сложный механизм с сотнями сервисов, и иногда выход одного стороннего (вне конкретной системы) сервиса, который используется в одном единственном API-методе, может привести к пробке на сервере uwsgi. К чему это приводит, надеюсь, объяснять не нужно, всё встает или тормозит. В такие моменты должен приходить Кэп и говорить что-то вроде: Нужно было делать отдельные микросервисы и тогда упал бы только тот, что связан с отказавшим внешним сервисом. Но у нас-то монолит.

В Kubernetes есть такой сервис, как Ingress Controller, его основная задача распределять нагрузку между репликами. Но он, по сути, nginx, а значит Ingress Controller можно использовать для того, чтобы роутить запросы на разные сервисы.

Примерно вот так выглядит схема:

Проанализировав наш API, мы разбили его на несколько групп:

  • Внешний API (методы для других сервисов).

  • Внутренний API (методы для фронтенда).

  • Некритичные API-методы, которые зависимы от внешних сервисов, но не влияют на работу системы (статистика, счетчики и т. п.)

Таким образом можно легко создавать новые сервисы. Управлять ресурсами для каждого из них, масштабировать те методы, которые несут большую нагрузку, и получать другие преимущества. Если один из методов перестанет отвечать из-за каких-то внешних причин, то его можно изолировать в отдельное развёртывание и снизить нагрузку на другие части системы.

В конечном итоге наша система, имея один репозиторий и Django под капотом, раздроблена благодаря Kubernetes на 42 сервиса, 5 из которых делят HTTP-трафик, а остальные 37 консьюмеры и Celery-задачи. И в таком виде она может быть актуальна еще пару лет, несмотря на использование относительно старого стека технологий.

Подробнее..

Перевод Почему интернационализация и локализация имеют значение

12.10.2020 16:11:28 | Автор: admin

Хабр, отличного всем времени суток! Скоро в OTUS стартует курс Python Web-Developer: мы приглашаем на бесплатный Demo-урок Паттерны Page Controller и Front Controller: реализация в Django и публикуем перевод статьи Nicolle Cysneiros Full Stack Developer (Labcodes).


Согласно всегда правдивой информации на Википедии, в мире насчитывается около 360 миллионов носителей английского языка. Мы, как разработчики, настолько привыкли писать код и документацию на английском языке, что не осознаем, что это число это всего. 4,67% населения всего мира. Единый язык общения между разработчиками это, конечно, хорошо, но это не значит, что пользователь должен чувствовать дискомфорт при использовании вашего продукта.

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

Локализация или интернационализация

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

Тогда как интернационализация процесс обеспечения возможности локализации продукта. То есть реализация программного обеспечения таким образом чтобы оно знало, когда и как показывать различный контент в зависимости от культурной или языковой принадлежности (локали) пользователя.

Как гласит документация Django: локализацию делают переводчики, а интернационализацию разработчики.

Однако упрощенное определение интернационализации и локализации может создать ложное впечатление, будто речь идет только о переводе. Этот процесс также включает в себя несколько видов адаптации, которые позволяют пользователю чувствовать себя комфортнее при использовании вашего продукта, например:

  • Формат даты и валюты;

  • Конвертация валюты;

  • Преобразование единиц измерения;

  • Символы юникода и двунаправленны текст (см. ниже);

  • Часовые пояса, календарь и особые праздники.

Домашняя страница Википедии на английскомДомашняя страница Википедии на английскомДомашняя страница Википедии на арабскомДомашняя страница Википедии на арабском

С помощью таких адаптаций мы можем улучшить пользовательский опыт нашего приложения.

Как это делается в Python?

GNU gettext

Есть несколько инструментов, которые могут помочь локализовать ваше приложения на Python. Начнем с пакета GNU gettext, который является частью Translation Project. В этом пакете есть:

  • библиотека, которая в рантайме поддерживает извлечение переведенных сообщений;

  • набор соглашений о том, как нужно писать код для поддержки каталогов сообщений;

  • библиотека, поддерживающая синтаксический анализ и создание файлов, содержащих переведенные сообщения.

Следующий фрагмент кода это просто Hello World в файле app.py, где используется модуль gettext в Python для создания объекта перевода (gettext.translation) в домене приложения с указанием директории локали и языка, на который мы хотим перевести строки. Затем мы присваиваем функцию gettext символу нижнего подчеркивания (обычная практика для уменьшения накладных расходов на ввод gettext для каждой переводимой строки), и, наконец, ставим флаг строке Hello World!, чтобы она была переведена.

import gettextgettext.bindtextdomain("app", "/locale")gettext.textdomain("app")t = gettext.translation("app", localedir="locale", languages=['en_US'])t.install()_ = t.gettextgreeting = _("Hello, world!")print(greeting)

После пометки переводимых строк в коде, мы можем собрать их с помощью инструмента командной строки GNU xgettext. Этот инструмент сгенерирует PO-файл, который будет содержать все отмеченные нами строки.

xgettext -d app app.py

PO-файл (или файл Portable Object) содержит список записей, а структура записи выглядит следующим образом:

#  translator-comments#. extracted-comments#: reference#, flag#| msgid previous-untranslated-stringmsgid untranslated-stringmsgstr translated-string

Мы можем добавить для строки комментарий для переводчиков, ссылки и флаги. После этого мы обращаемся к ID записи (msgid), который представляет из себя непереведенную строку, помеченную в коде и строку записи (msgstr) переведенную версию этой строки.

Когда мы запускаем xgettext в командной строке, передавая app.py в качестве входного файла, получается такой PO-файл:

"Project-Id-Version: PACKAGE VERSION\n""Report-Msgid-Bugs-To: \n""POT-Creation-Date: 2019-05-03 13:23-0300\n""PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n""Last-Translator: FULL NAME <EMAIL@ADDRESS>\n""Language-Team: LANGUAGE <LL@li.org>\n""Language: \n""MIME-Version: 1.0\n""Content-Type: text/plain; charset=UTF-8\n""Content-Transfer-Encoding: 8bit\n"#: app.py:7msgid "Hello, world!"msgstr ""

В начале файла у нас есть метаданные о файле, проекте и процессе перевода. Потом стоит непереведенная строка Hello World! в качестве ID записи и пустая строка для строки записи. Если для записи не указан перевод, то при переводе будет использоваться ID записи.

После генерации PO-файла можно начинать переводить термины на разные языки. Важно отметить, что библиотека GNU gettext будет искать переведенные PO-файлы в пути к папке определенного вида (<localedir>/<languagecode>/LCMESSAGES/<domain>.po), то есть для каждого языка, который вы хотите поддерживать, должен быть один PO-файл.

|-- app.py|-- locale   |-- en_US   |   |-- LC_MESSAGES   |       |-- app.po   |-- pt_BR       |-- LC_MESSAGES       |   |-- app.po

Вот пример PO-файла с переводом на португальский:

"Project-Id-Version: PACKAGE VERSION\n""Report-Msgid-Bugs-To: \n""POT-Creation-Date: 2019-05-03 13:23-0300\n""PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n""Last-Translator: FULL NAME <EMAIL@ADDRESS>\n""Language-Team: LANGUAGE <LL@li.org>\n""Language: \n""MIME-Version: 1.0\n""Content-Type: text/plain; charset=UTF-8\n""Content-Transfer-Encoding: 8bit\n"#: app.py:7msgid "Hello, world!"msgstr "Ol, mundo!"

Чтобы использовать переведенные строки в коде, нужно скомпилировать PO-файл в MO-файл с помощью команды msgfmt.

msgfmt -o app.mo app.po

Когда MO-файл готов, можно изменить язык программы на португальский, подав его на вход функции перевода. Если мы запустим следующий код, отмеченная строка будет переведена как Ol, mundo!:

import gettextgettext.bindtextdomain("app", "/locale")gettext.textdomain("app")t = gettext.translation("app", localedir="locale", languages=['pt_BR'])t.install()_ = t.gettextgreeting = _("Hello, world!")print(greeting)

Модуль locale

У этого модуля есть доступ к базе данных локалей (locale) POSIX, и он особенно полезен для обработки форматов дат, чисел и валют. В примере ниже показано как использовать библиотеку locale:

import datetimeimport localelocale.setlocale(locale.LC_ALL, locale='en_US')local_conv = locale.localeconv()now = datetime.datetime.now()some_price = 1234567.89formatted_price = locale.format('%1.2f', some_price, grouping=True)currency_symbol = local_conv['currency_symbol']print(now.strftime('%x'))print(f'{currency_symbol}{formatted_price}')

В данном примере мы импортируем модуль, меняем все настройки локалей на US English и извлекаем соглашения локали. С помощью метода locale.format мы можем отформатировать число и не беспокоиться о разделителях в разрядах десятков и тысяч. С помощью директивы %x для форматирования даты день, месяц и год будут стоять в правильном порядке для текущей локали. Из соглашений локали мы получим и корректный символ для обозначения валюты.

Ниже вы видите выходные данные того кода на Python. Мы видим, что дата соответствует формату Month/Day/Year, десятичный разделитель это точка, а разделитель разряда тысяч запятая, а также есть знак доллара для валюты США.

$ python format_example.py05/03/2019$1,234,567.89

Теперь с тем же кодом, но изменив локаль на Portuguese Brazil, мы получим другой вывод, основанный на бразильских соглашениях форматирования: дата будет отображаться в формате Month/Day/Year, запятая будет разделителем для десятков, а точка для тысяч, символ R$ будет говорить о том, что сумма указана в бразильских реалах.

import datetimeimport localelocale.setlocale(locale.LC_ALL, locale='pt_BR')local_conv = locale.localeconv()now = datetime.datetime.now()some_price = 1234567.89formatted_price = locale.format('%1.2f', some_price, grouping=True)currency_symbol = local_conv['currency_symbol']print(now.strftime('%x'))print(f'{currency_symbol}{formatted_price}')

Легче ли дела обстоят в Django?

Переводы и форматирование

Интернационализация включается по умолчанию при создании проекта на Django. Модуль перевода инкапсулирует библиотеку GNU и предоставляет функционал gettext с настройками перевода на основе языка, полученного из заголовка Accept-Language, который браузер передает в объекте запроса. Итак, весь тот код на Python, который мы видели раньше, оказывается инкапсулирован в модуль перевода из django utils, так что мы можем перепрыгнуть далеко вперед и просто использовать функцию gettext:

from django.http import HttpResponsefrom django.utils.translation import gettext as _def my_view(request):    greetings = _('Hello, World!')    return HttpResponse(greetings)

Для переводов, мы можем помечать переводимые строки в коде Python и в шаблоне (после загрузки тегов интернационализации). Тег trans template переводит одну строку, тогда как тег blocktrans может пометить как переводимый целый блок строк, включая переменный контент.

<p>{% trans "Hello, World!" %}</p><p>{% blocktrans %}This string will have {{ value }} inside.{% endblocktrans %}</p>

Помимо стандартной функции gettext в Django есть ленивые переводы: помеченная строка будет переведена только тогда, когда значение используется в контексте строки, например, при рендеринге шаблона. Особенно полезно это бывает для перевода атрибутов help_text и verbose_name в моделях Django.

Аналогично интерфейсу командной строки GNU, django admin предоставляет команды эквивалентные тем, которые часто используются в процессе разработки. Чтобы собрать все строки, помеченные как переводимые в коде, вам просто нужно выполнить команды django admin makemessages для каждой локали, которую вы хотите поддерживать в своей системе. Как только вы создадите папку locale в рабочей области проекта, эта команда автоматически создаст правильную структуру папок для PO-файла для каждого языка.

Чтобы скомпилировать все PO-файлы, вам просто нужно выполнить django admin compilemessages. Если вам нужно скопировать PO-файл для конкретной локали, вы можете передать его в качестве аргумента django-admin compilemessages --locale=pt_BR. Чтобы получить более полное представление о том, как работают переводы в Django, вы можете ознакомиться с документацией.

Django также использует заголовок Accept-Language для определения локали пользователя и правильного форматирования дат, времени и чисел. В примере ниже мы видим простую форму с DateField и DecimalField. Чтобы указать, что мы хотим получить эти входные данные в формате, согласующимся с локалью пользователя, нам просто нужно передать параметр localize со значением True в экземпляр поля формы.

from django import formsclass DatePriceForm(forms.Form):    date = forms.DateField(localize=True)    price = forms.DecimalField(max_digits=10, decimal_places=2, localize=True)

Как меняется процесс разработки?

После интернационализации приложения процесс развертывания должен быть адаптирован к процессу перевода. В нашем проекте мы отправляем любые новые термины в перевод сразу после развертывания в промежуточной среде. Развертывание на продакшн будет утверждено сразу после перевода всех терминов и компиляции PO-файлов.

Еще одно важное изменение в потоке разработки в нашем случае затронуло добавление интеграционных тестов для различных локалей на этапе QA. Команда QA будет симулировать локали, поддерживаемые нашим приложением, и проверять, все ли тексты были корректно переведены, валюты и единицы измерения преобразованы.

Наш главный вывод из всего этого процесса интернационализации заключается в том, что все эти шаги должны были быть заложены на этапе проектирования в самом начале проекта. Остановить всю разработку, чтобы провести интернационализацию не лучшее решение. Если ваш проект уже не на ранней стадии, то я рекомендую следовать правилу бойскаута: начните помечать строки, которые необходимо перевести, когда внедряете новую функцию или исправляете некритический баг. Таким образом, мы все равно будете поставлять новые функции вместе с постепенным процессом интернационализации.


Интересно развиваться в данном направлении? Узнайте больше о курсе Python Web-Developer и записывайтесь на бесплатные Demo-уроки в OTUS!

Подробнее..

Из песочницы Как поставить Django на сервер heroku в 2020 году. 10 шагов

13.10.2020 18:18:35 | Автор: admin
Решил поделиться с вами тем, как поставить проект написаный на Python/Django на сервер heroku. Heroku это бесплатный хостинг для тестирования своих проектов. Если вам нужно посмотреть как действует проект в боевом режиме вперед!

1. Надо пройти регистрацию на heroku. В этом нет ничего сложного, просто вводите данные, подтверждаете на почте аккаунт, и вперед.

2. Установка командной строки heroku., слева-вверху видим burger меню, клацаем по нему и выбираем Documentation -> Python, нажимаем Get Start With Python. Дальше слева нажимаем Set Up и выбираем установку heroku console на вашу операционную систему, тут нет ничего сложного, просто устанавливаем как вам удобно и все.

3. Закрываем пока что браузер и заходим в командную строку или bash. Переходим в папку с нашим django-проектом и открываем проект в текстовом редакторе (в моем случае Pycharm). Дальше нам придется работать с системой контроля версий git. Если у вас нет данной утилиты, то вы ее можете скачать по адресу git-scm.com/downloads. Пройдите простую установку и возвращайтесь к данной статье.

4. В нашей консоли прописываем команду:

git init

После чего создаем в каталоге проекта файл .gitignore. В нем мы можем записать все файлы которые мы хотим проигнорировать при загрузке на сервер. Допустим на сервере я буду использовать базу данных MySQL, по этому файл db.sqlite3 мне не нужен.

Пишем этот код:

__pychache__/*.pycdb.sqlite3

После пишем в bush 3 команды

git add .git commit -m "GIT init"

1-ая отвечает за добавление всех фалов в git.

2-ая за сохранение данных файлов на компьютере локально с сообщением GIT init.

5. Теперь входим в наш heroku через консоль. Пишем:

heroku login

Дальше вводим сначало E-mail, нажимаем Enter. Потом пароль и опять же Enter.

Вот мы и вошли. Дальше создаем приложение, мы это будем делать через консоль так что вводим в нее следующее:

heroku create

Команда создает приложение. После этой команды через пробел можно написать имя приложения. В противном случае heroku с генерирует его автоматически и выведет в консоль.

6. Дальше создаем несколько файлов, для того что бы heroku понял что мы загружаем и как:

Procfile
runtime.txt

  1. В runtime сразу пишем этот код:

    python-3.8.5
    

    После python-, пишите вашу версию python.
  2. Procfile:

    web: gunicorn appname.wsgi --log-file -
    

    Вместо appname пишите название своего проекта.


Дальше устанавливаем сам gunicorn для обслуживания django через wsgi:

pip install gunicorn

Сразу же устанавливаем whitenoise для работы со статическими файлами:

pip install witenoise

7. Теперь переходим в settings.py и делаем следующие изменения:

ALLOWED_HOSTS = ['*']

Добавляем static_root если у вас его нет:

import osSTATIC_ROOT = os.path.join(BASE_DIR, 'static')

8. Настраиваем работу с базой данных. Устанавливаем утилиту для более удобной работы:

pip install dj-database-url

переходим опять в настройки и пишем:

import dj-database-urldb_from_env = dj-database-url.config()DATABASE['default'].update(db_from_env)

9. Последний файл который нам нужен requirements.txt, в нем будут все установленные библиотеки:

pip freeze -> requirements.txt

У нас создался файл со всеми пакетами. Можете записать в него различные пакеты с их версиями. Обязательно надо записать эту строчку:

psycopg2==2.8.6

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

10. Ну и финал, загружаем на сервер.

Переходим в консоль и пишем такие команды:

git add .git commit -m "Diploy"git push heroku main

Если у вас не получилось с main, попробуйте:

git push heroku master

И пошел процесс загрузки нашего проекта на heroku. Дальше вам в консоль напишет ссылку на наш проект. По ней мы будем переходить потом, а сейчас выполним все миграции:

heroku run python manage.py migrate

И создадим super user-а:

heroku run python manage.py createsuperuser

Переходим по раннее полученной ссылке, и видим наш проект. Вот так за 10 шагов мы загрузили наш проект на heroku и настроили базу данных. Всем спасибо за внимание.
Подробнее..
Категории: Python , Django

Из песочницы Поднимаем Django стек на MS Windows

17.10.2020 06:09:54 | Автор: admin
image

В данной статье будет представлена подробная инструкция по установке и настройке программ Apache, Python и PostgreSQL для обеспечения работы Django проекта в ОС MS Windows. Django уже включает в себя упрощенный сервер разработки для локального тестирования кода, но для задач, связанных с продакшен, требуется более безопасный и мощный веб-сервер. Мы настроим mod_wsgi для взаимодействия с нашим проектом и настроим Apache в качестве шлюза в внешний мир.

Стоит отметить, что установка и настройка будет производиться в ОС MS Windows 10 с 32 разрядностью. Также 32 битная реакция будет универсальна и будет работать на 64 битной архитектуре. Если вам нужна 64 битная установка повторите те же действия для 64 битных дистрибутивов программ, последовательность действий будет идентична.

В качестве Django проекта будем использовать программу Severcart. Она предназначена для управления перемещениями картриджей, учёта печатающего оборудования и договоров поставки и обслуживания. Установка всех программ и модулей будет производиться в каталог C:\severcart. Местоположение не принципиально.

Python


Первым шагом является загрузка и установка Python с веб-сайта Python. Выбираем Windows в качестве операционной системы и 32-битную версию. На момент написания статьи текущей версией является 3.9.0rc2.

Загрузив файл установки, щелкните правой кнопкой мыши файл установки и выберите Запуск от имени администратора. Вы должны увидеть экран ниже



Устанавливаем галочки напротив чекбоксов Install launcher for add user (recomended) и Add Python 3.9 to PATH и нажимаем на Customize installation.



Устанавливаем галочки на против pip, py launcher, for all users (requires elevation) и нажимаем Next.



Выбираем все поля ввода как на картинке выше и нажимаем на Install.



Чтобы убедиться, что установка прошла успешно, откройте cmd и введите python. Если установка прошла успешно, вы должны увидеть приглашение, подобный приведенному ниже



Устанавливаем mod_wsgi


Скачиваем скомпилированный пакет с mod_wsgi c сайта
www.lfd.uci.edu/~gohlke/pythonlibs. Модуль выполняет функции посредника межу сервером Apache и Django проектом. Самый свежий пакет будет с именем mod_wsgi-4.7.1-cp39-cp39-win32.whl. Обратите внимание, что пакет скомпилирован для 32 битной Windows CPython версии 3.9. Также стоит отметить, что очевидная установка модуля pip install mod_wsgi скорее всего завершится ошибкой, т.к. в процессе установки потребуется компилятор Visual Studio C++. Ставить компилятор целиком ради одного Python пакета в Windows считаем нецелесообразным.

Устанавливаем модуль с помощью стандартного пакетного менеджера pip в cmd или powershell:

pip install -U mod_wsgi-4.7.1-cp39-cp39-win32.whl




Apache


Скачиваем дистрибутив с сайта https://www.apachelounge.com/download/.
Самая свежая версия Web-сервера является Apache 2.4.46 win32 VS16. Также для работы программы понадобиться заранее установленный пакет Visual C++ Redistributable for Visual Studio 2019 x86.

Распаковываем дистрибутив Apache в каталог C:\severcart\Apache24, далее меняем строку с номером 37 на свою

Define SRVROOT "C:/severcart/Apache24"


Проверяем работу Apache, выполнив в командной строке

C:/severcart/Apache24/bin> httpd.exe


В результате должны увидеть в браузере по адресу 127.0.0.1 строку It works!.



Устанавливаем службу Apache, для этого выполним в командной строке от имени Администратора инструкцию:

C:\severcart\Apache24\bin>httpd.exe -k install -n "Apache24"


Далее подключим модуль mod_wsgi к Apache. Для этого выполним в командной строке инструкцию
C:\Windows\system32>mod_wsgi-express module-config


В результате в стандартный вывод будет распечатаны строки:
LoadFile "c:/severcart/python/python39.dll"LoadModule wsgi_module "c:/severcart/python/lib/site-packages/mod_wsgi/server/mod_wsgi.cp39-win32.pyd"WSGIPythonHome "c:/severcart/python"


Создаем файл C:\severcart\Apache24\conf\extra\httpd-wsgi.conf и копипастим туда распечатанные строки выше.

Подключаем новую конфигурацию к основному файлу httpd.conf
Include conf/extra/httpd-wsgi.conf

Сохраняем изменения, перезагружаем службы Apache
Net stop Apache24Net start Apache24


PostgreSQL


Устанавливаем PostgreSQL взятый с сайта https://postgrespro.ru/windows. Текущая версия программного продукта 12. Преимущества Российского дистрибутива от канонического представлены на том же сайте.





















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

Создаем БД в postgres, где потом будут храниться структуры данных Django проекта

C:\severcart\postgresql\bin>psql -h 127.0.0.1 -U postgres -WCREATE DATABASE severcart WITH ENCODING='UTF8' OWNER=postgres CONNECTION LIMIT=-1 template=template0;




БД создана. Теперь разворачиваем Django проект.

Устанавливаем web приложение


Для этого скачиваем zip архив с сайта https://www.severcart.ru/downloads/ и распаковываем в каталог C:\severcart\app\



Вносим изменения в главный конфигурационный файл C:\severcart\app\conf\settings_prod.py для указания реквизитов подключения к БД



Python словарь DATABASES содержит в себе реквизиты подключения к БД. Подробности по настройке читайте здесь https://docs.djangoproject.com/en/3.1/ref/databases/#connecting-to-the-database

Устанавливаем Python пакеты значимостей для работы приложений внутри Django проекта

C:\severcart\app\tkinstaller>python install.py




В процессе работы скрипта будет произведена инициализация БД таблицами, констрейтами, индексами и прочим, а будет предложено создать пользователя, от имени которого будет производиться работа в программе.

Подключаем Django приложение к серверу Apache для этого дополняем конфигурационный файл
httpd-wsgi.conf следующим текстом

Alias /static "c:/severcart/app/static"Alias /media "c:/severcart/app/media"<Directory "c:/severcart/app/static">    # for Apache 2.4    Options Indexes FollowSymLinks    AllowOverride None    Require all granted</Directory><Directory "c:/severcart/app/media">    # for Apache 2.4    Options Indexes FollowSymLinks    AllowOverride None    Require all granted</Directory>WSGIScriptAlias / "c:/severcart/app/conf/wsgi_prod.py"WSGIPythonPath "c:/severcart/python/"<Directory "c:/severcart/app/conf/"><Files wsgi_prod.py>    Require all granted</Files>   </Directory>

Перезагружаем службу Apache и проверяем работу приложения



На этом все. Спасибо что дочитали.

В следующей статье будем создавать установочный самораспаковывающийся архив в InnoSetup для быстрого развертывания Django проекта на компьютере заказчика.
Подробнее..
Категории: Python , Postgresql , Apache , Django , Django framework

Перевод Что происходит, когда вы выполняете manage.py test?

25.10.2020 20:05:49 | Автор: admin

Перевод статьи подготовлен специально для студентов курса Python Web-Developer.


Вы запускаете тесты командой manage.py test, но знаете ли вы, что происходит под капотом при этом? Как работает исполнитель тестов (test runner) и как он расставляет точки, E и F на экране?

Когда вы узнаете, как работает Django, то откроете для себя множество вариантов использования, таких как изменение файлов cookie, установка глобальных заголовков и логирование запросов. Аналогично, поняв то, как работают тесты, вы сможете кастомизировать процессы, чтобы, например, загружать тесты в другом порядке, настраивать параметры тестирования без отдельного файла или блокировать исходящие HTTP-запросы.

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

Однако, прежде чем писать код, давайте проведем реконструкцию процесса тестирования.

Выходные данные тестов

Давайте разберемся в результатах выполнения тестов. За основу возьмем проект с пустым тестом:

from django.test import TestCaseclass ExampleTests(TestCase):    def test_one(self):        pass

При выполнении тестов мы получаем знакомые выходные данные:

$ python manage.py testCreating test database for alias 'default'...System check identified no issues (0 silenced)..----------------------------------------------------------------------Ran 1 test in 0.001sOKDestroying test database for alias 'default'...

Чтобы понять, что происходит, попросим программу рассказать об этом подробнее, добавив флаг -v 3:

$ python manage.py test -v 3Creating test database for alias 'default' ('file:memorydb_default?mode=memory&cache=shared')...Operations to perform:  Synchronize unmigrated apps: core  Apply all migrations: (none)Synchronizing apps without migrations:  Creating tables...    Running deferred SQL...Running migrations:  No migrations to apply.System check identified no issues (0 silenced).test_one (example.core.tests.test_example.ExampleTests) ... ok----------------------------------------------------------------------Ran 1 test in 0.004sOKDestroying test database for alias 'default' ('file:memorydb_default?mode=memory&cache=shared')...

Отлично, этого достаточно! Теперь давайте разбираться.

На первой строке мы видим сообщение Creating test database - так Django отчитывается о создании тестовой базы данных. Если в вашем проекте несколько баз данных, вы увидите по одной строке для каждой.

В этом проекте я использую SQLite, поэтому Django автоматически ставит mode=memory в поле адреса базы данных. Так операции с базой данных станут быстрее примерно раз в 10. Другие базы данных, такие как PostgreSQL, не имеют подобных режимов, но для них есть другие методы запуска in-memory.

Вторая строка Operations to perform и несколько последующих строк это выходные данные команды migrate в тестовых базах данных. То есть вывод тут получается идентичным с тем, который мы получаем при выполнении manage.py migrate на пустой базе данных. Сейчас я использую небольшой проект без миграций, но если бы они были, то на каждую миграцию в выводе приходилась бы одна строка.

Дальше идет строка с надписью System check identified no issues. Он взялась из Django, который запускает ряд проверок перед полетом, чтобы убедиться в правильной конфигурации вашего проекта. Вы можете запустить проверку отдельно с помощью команды manage.py check, и также она выполнится автоматически с запуском большинства команд управления. Однако в случае с тестами, она будет отложена до тех пор, пока тестовые базы данных не будут готовы, так как некоторые этапы проверки используют соединения баз данных.

Вы можете написать свои собственные проверки для обнаружения ошибок конфигурации. Поскольку они выполняются раньше, порой имеет смысл прописать их, а не писать отдельный тест. Я бы с удовольствием рассказал и об этом, но эта тема тянет на отдельную статью.

Следующие строки про наши тесты. По умолчанию test runner выводит по одному символу на тест, но с повышением verbosity в Django для каждого теста будет выводиться отдельная строка. Здесь у нас есть всего один тест testone, и когда он закончил выполнение, test runner добавил к строке ok.

Чтобы обозначить конец прогона, ставится разделитель ---. Если бы у нас были какие-то ошибки или сбои, они бы вывелись перед этими черточками. После этого идет краткое описание выполненных тестов и OK, показывающее, что тесты прошли успешно.

Последняя строка сообщает нам, что тестовая база данных была удалена.

В итоге у нас получается следующая последовательность шагов:

  1. Создание тестовых баз данных.

  2. Миграция баз данных.

  3. Запуск проверок системы.

  4. Запуск тестов.

  5. Отчет о количестве тестов и успешном/неуспешном завершении.

  6. Удаление тестовых баз данных.

Давайте разберемся, какие компоненты внутри Django отвечают за эти шаги.

Django и unittest

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

Мы можем найти компоненты каждой стороны взглянув на код.

Команда управления тестами test

Первое, на что нужно посмотреть, это команда управления тестами, которую Django находит и выполняет при запуске manage.py test. Находится она в django.core.management.commands.test.

Что касается команд управления, то они довольно короткие меньше 100 строк. Метод handle() отвечает за обработку в TestRunner. Если упрощать до трех основных строк:

def handle(self, *test_labels, **options):    TestRunner = get_runner(settings, options['testrunner'])    ...    test_runner = TestRunner(**options)    ...    failures = test_runner.run_tests(test_labels)    ...

Полный код.

Так что же представляет из себя класс TestRunner? Это компонент Django, который координирует процесс тестирования. Он настраиваемый, но класс по умолчанию, и единственный в самом Django это django.test.runner.DiscoverRunner. Давайте рассмотрим его следующим.

Класс DiscoverRunner

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

Начинается он как-то так:

class DiscoverRunner:    """A Django test runner that uses unittest2 test discovery."""    test_suite = unittest.TestSuite    parallel_test_suite = ParallelTestSuite    test_runner = unittest.TextTestRunner    test_loader = unittest.defaultTestLoader

(Документация, исходный код)

Эти атрибуты класса указывают на другие классы, которые выполняют различные шаги в процессе тестирования. Как видите, большинство из них компоненты unittest.

Обратите внимание на то, что один из них называется test_runner, таким образом у нас получается два различных понятия, который называются test runner это DiscoverRunner из Django и TextTestRunner из unittest. DiscoverRunner делает гораздо больше, чем TextTestRunner, и у него другой интерфейс. Возможно, в Django можно было бы обозвать DiscoverRunner по-другому, например, TestCoordinator, но сейчас об этом уже поздно думать.

Основной поток в DiscoverRunner находится в методе runtests(). Если убрать кучу деталей, run_tests() будет выглядеть примерно так:

def run_tests(self, test_labels, extra_tests=None, **kwargs):    self.setup_test_environment()    suite = self.build_suite(test_labels, extra_tests)    databases = self.get_databases(suite)    old_config = self.setup_databases(aliases=databases)    self.run_checks(databases)    result = self.run_suite(suite)    self.teardown_databases(old_config)    self.teardown_test_environment()    return self.suite_result(suite, result)

Шагов здесь совсем немного. Многие из методов соответствуют шагам из списка, который мы приводили выше:

  • setup_databases() создает тестовые базы данных. Но этот метод создает только те базы данных, которые необходимы для выбранных тестов, отфильтрованных с помощью get_databases(), поэтому если вы запускаете только SimpleTestCases без баз данных, то Django ничего создавать не будет. Внутри этого метода создаются базы данных и выполняется команда migrate.

  • run_checks() запускает проверки.

  • run_suite() запускает набор тестов, включая все выходные данные.

  • Функция teardown_databases() удаляет тестовые базы данных.

И еще парочка методов, о которых можно рассказать:

  • setup_test_environment() и teardown_test_environment() устанавливают или убирают некоторые настройки, такие как локальный сервер электронной почты.

  • suite_result() возвращает количество ошибок в ответ команде управления тестированием.

Все эти методы полезно рассмотреть, чтобы разобраться с настройками процесса тестирования. Но они все являются частью Django. Другие методы передаются компонентам в unittest - build_suite() и run_suite().

Давайте поговорим и о них.

buildsuite()

buildsuite() ищет тесты для запуска и перемещает их в объект suite. Это длинный метод, но если его упростить, выглядеть он будет примерно так:

def build_suite(self, test_labels=None, extra_tests=None, **kwargs):    suite = self.test_suite()    test_labels = test_labels or ['.']    for label in test_labels:        tests = self.test_loader.loadTestsFromName(label)        suite.addTests(tests)    if self.parallel > 1:        suite = self.parallel_test_suite(suite, self.parallel, self.failfast)    return suite

В этом методе используются три из четырех классов, к которым, как мы видели, обращается DiscoverRunner:

  • test_suite - компонент unittest, который служит контейнером для запуска тестов.

  • parallel_test_suite - оболочка для набора тестов, которая используется с функцией параллельного тестирования в Django.

  • test_loader компонент unittest, который умеет находить тестовые модули на диске и загружать их в набор.

runsuite()

Еще один метод DiscoverRunner, о котором надо поговорить это run_suite(). Его мы упрощать не будем, и просто посмотрим, как он выглядит:

def run_suite(self, suite, **kwargs):    kwargs = self.get_test_runner_kwargs()    runner = self.test_runner(**kwargs)    return runner.run(suite)

Его единственная задача создавать test runner и говорить ему запустить собранный набор тестов. Это последний из компонентов unittest, на который ссылается атрибут класса. Он использует unittest.TextTestRunner - test runner по умолчанию для вывода результатов в виде текста, в отличие, например, от XML-файла для передачи результатов в вашу CI-систему.

Закончим мы наше небольшое расследование, заглянув в класс TextTestRunner.

Класс TextTestRunner

Этот компонент unittest берет тест-кейс или набор и выполняет его. Начинается он вот так:

class TextTestRunner(object):    """A test runner class that displays results in textual form.    It prints out the names of tests as they are run, errors as they    occur, and a summary of the results at the end of the test run.    """    resultclass = TextTestResult    def __init__(self, ..., resultclass=None, ...):

(Исходный код)

По аналогии с DiscoverRunner, он использует атрибут класса для ссылки на другой класс. Класс TextTestResult по умолчанию отвечает за текстовый вывод. В отличие от ссылок класса DiscoverRunner, мы можем переопределить resultclass, передав альтернативу TextTestRunner._init_().

Теперь мы наконец-то можем кастомизировать процесс тестирования. Но сначала вернемся к нашему маленькому исследованию.

Карта

Теперь мы можем расширить карту и показать на ней классы, которые мы нашли:

Конечно, можно было добавить больше деталей, например, содержание нескольких важных методов из DiscoverRunner. Но того, что мы нашли, уже достаточно для реализации многих полезных настроек.

Как кастомизировать

Django предлагает два способа кастомизации процесса выполнения тестов:

Поскольку команда запуска тестов это просто, большую часть времени мы потратим на переписывание DiscoverRunner. Поскольку DiscoverRunner ссылается на компоненты unittest с помощью атрибутов класса, мы можем заменить их, переопределив атрибуты в нашем собственном подклассе.

Супербыстрый Test Runner

В качестве базового примера давайте представим, что нам нужно пропускать тесты и каждый раз уведомлять об успешном прохождении теста. Сделать это мы можем создав подкласс DiscoverRunner с новым методом runtests(), который не будет вызывать метод super():

# example/test.pyfrom django.test.runner import DiscoverRunnerclass SuperFastTestRunner(DiscoverRunner):    def run_tests(self, *args, **kwargs):        print("All tests passed! A+")        failures = 0        return failures

Затем воспользуемся им в файле с настройками следующим образом:

TEST_RUNNER = "example.test.SuperFastTestRunner"

А затем выполним manage.py test, и получим результат за рекордно короткое время!

$ python manage.py testAll tests passed! A+

Отлично, очень полезно!

А теперь давайте сделаем еще практичнее, и перейдем к выводу результатов теста в виде эмодзи!

Вывод в виде эмодзи

Мы уже выяснили, что компонент TextTestResult из unittest отвечает за вывод. Мы можем заменить его в DiscoverRunner, передав значение resultclass в TextTestRunner.

В Django уже есть опции для замены resultclass, например, опция --debug-sql option, которая выводит выполненные запросы для неудачных тестов.

DiscoverRunner.run_suite() создает TextTestRunner с аргументами из метода DiscoverRunner.get_test_runner_kwargs():

<img alt="Изображение выглядит как текст

def get_test_runner_kwargs(self):    return {        'failfast': self.failfast,        'resultclass': self.get_resultclass(),        'verbosity': self.verbosity,        'buffer': self.buffer,    }

Он же в свою очередь вызывает get_resultclass(), который возвращает другой класс, если был использован один из двух параметров тестовой команды (--debug-sql или --pdb):

def get_resultclass(self):    if self.debug_sql:        return DebugSQLTextTestResult    elif self.pdb:        return PDBDebugResult

Если ни один из параметров не задан, метод неявно возвращает None, говоря TextTestResult использовать по умолчанию resultclass. Мы можем увидеть этот None в нашем собственном подклассе и заменить его подклассом TextTestResult:

class EmojiTestRunner(DiscoverRunner):    def get_resultclass(self):        klass = super().get_resultclass()        if klass is None:            return EmojiTestResult        return klass

Наш класс EmojiTestResult расширяет TextTestResult и заменяет вывод точек по умолчанию на эмодзи. В конечном итоге получается довольно длинно, поскольку на каждый тип результата приходится отдельный метод:

class EmojiTestResult(unittest.TextTestResult):    def __init__(self, *args, **kwargs):        super().__init__(*args, **kwargs)        # If the "dots" style was going to be used, show emoji instead        self.emojis = self.dots        self.dots = False    def addSuccess(self, test):        super().addSuccess(test)        if self.emojis:            self.stream.write('')            self.stream.flush()    def addError(self, test, err):        super().addError(test, err)        if self.emojis:            self.stream.write('?')            self.stream.flush()    def addFailure(self, test, err):        super().addFailure(test, err)        if self.emojis:            self.stream.write('')            self.stream.flush()    def addSkip(self, test, reason):        super().addSkip(test, reason)        if self.emojis:            self.stream.write("")            self.stream.flush()    def addExpectedFailure(self, test, err):        super().addExpectedFailure(test, err)        if self.emojis:            self.stream.write("")            self.stream.flush()    def addUnexpectedSuccess(self, test):        super().addUnexpectedSuccess(test)        if self.emojis:            self.stream.write("")            self.stream.flush()    def printErrors(self):        if self.emojis:            self.stream.writeln()        super().printErrors()

После указания TEST_RUNNER в EmojiTestRunner, мы можем запустить тесты и увидеть эмодзи:

$ python manage.py testCreating test database for alias 'default'...System check identified no issues (0 silenced).?...----------------------------------------------------------------------Ran 8 tests in 0.003sFAILED (failures=1, errors=1, skipped=1, expected failures=1, unexpected successes=1)Destroying test database for alias 'default'...

Урааа!

Вместо заключения

После нашего спелеологического исследования мы увидели, что архитектура unittest относительно проста. Мы можем заменить классы на подклассы, чтобы изменить любое поведение в процессе тестирования.

Так работает кастомизация, специфичная для конкретного проекта, но кастомизации других людей использовать непросто. Так происходит потому, что архитектура заточена под наследование, а не под композицию. Мы должны использовать множественное наследование через сеть классов, чтобы объединить кастомизации, да и будет ли это все работать, зависит от качества их реализаций. На самом деле именно из-за этого не существует экосистемы плагинов для unittest.

Я знаком лишь с двумя библиотеками, предоставляющими кастомные подклассы DiscoverRunner:

  • unittest-xml-reporting обеспечивает вывод в формате XML для вашей CI-системы.

  • django-slow-tests обеспечивает измерение времени выполнения тесты для поиска самых медленных тестов.

Сам я не пробовал, но их объединение может не сработать, поскольку обе они переопределяют процесс вывода.

С другой стороны, у pytest есть процветающая экосистема с более чем 700 плагинами. Так происходит из-за того, что в его архитектуре используется композиция с хуками, которые работают по аналогии с сигналами в Django. Плагины регистрируются только для тех хуков, которые им нужны, а pytest вызывает каждую зарегистрированную хук-функцию в соответствующей точке процесса тестирования. Многие из встроенных функций pytest даже реализованы в виде плагинов.

Если вам интересна более детальная настройка процесса тестирования, обратитесь к pytest.

Конец

Спасибо, что отправились со мной в это путешествие. Я надеюсь, что вы узнали что-то новое о том, как Django запускает ваши тесты, и научились кастомизировать их.

Читать ещё:

Подробнее..

Ещё раз о производительности фреймворков Python для веб разработки

23.12.2020 10:20:51 | Автор: admin
Недавно мне пришлось начинать проект нового веб сервиса, и я решил протестировать максимальную нагрузочную способность Django, а заодно сравнить её с Flaskом и AIOHTTP. Результат показался мне неожиданным, поэтому я просто оставлю его тут.

На диаграммах ниже приведены результаты простейшего Apache Benchmarka для фреймворков Django версии 3.1, Flask 1.1 и AIOHTTP 3.7. AIOHTTP работает в штатном однопоточном асинхронном режиме, Django и Flask обслуживаются синхронным WSGI сервером Gunicorn с числом потоков, равным числу доступных ядер процессора * 2. ASGI в тесте не участвовал.

Условия тестирования
Во всех трёх случаях выводится простая страница со списком по результатам выборки из реляционной базы данных PostgreSQL. Запрос я постарался сделать максимально приближенным к реальности:

SELECT r.id, r.auth_user_id, r.status, r.updated, r.label, r.content, u.username,    ARRAY_AGG(t.tag) tag, COUNT(*) OVER() cnt,    (        SELECT COUNT(*) FROM record r2            WHERE                r2.parent_id IS NOT NULL                AND r2.parent_id = r.id                AND r2.status = 'new'    ) AS partsFROM record rJOIN auth_user u ON u.id = r.auth_user_idLEFT JOIN tag t ON t.kind_id = r.id AND t.kind = 'rec'WHERE r.parent_id IS NULL AND r.status = 'new'GROUP BY r.id, u.usernameORDER BY r.updated DESCLIMIT 10 OFFSET 0

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

AIOHTTP использует пулл соединений с БД и драйвер asyncpg, Django и Flask SQLAlchemy без ORM (для чистоты эксперимента) и psycopg2.

Приложение Django создано стандартными средствами фреймворка (django-admin startproject, manage.py startapp и т.д.), вывод тестовой страницы через ListView. Установки Flask и AIOHTTP построены на канонических веб приложениях Hello, world, взятых из документации.

Результаты запуска теста на локальной машине (4 ядра CPU)



и на реальном однопроцессорном VDS (пинг около 45 ms)



Во время теста AIOHTTP использовал 100% одного ядра CPU, Flask и Django 100% всех доступных ядер.

Выводы


На самом деле, сравнение асинхронных и многопоточных приложений не совсем корректно они решают разные задачи. Поэтому, результат выглядит довольно логичным: в локальном тесте у AIOHTTP просто оказалось меньше ресурсов, при равных условиях производительность нивелируется.

А вот скромный результат Flaskа с трудом поддается объяснению, разогнать этот фреймворк у меня не получилось.
Подробнее..

Зачем вам может понадобиться SITE_ID в настройках Django

09.02.2021 20:10:35 | Автор: admin
КДПВ

Если вы не используете все возможности Django, то, очень вероятно, вы не пользуетесь SITE_ID. Этому способствуют как убогая официальная документация Sites framework, так и несогласованное с Sites развитие кода Django.
Предположу, что Sites скоро будет бездумно снесен свежими разработчиками Django, как это уже произошло с модулями Comments (Dj 1.6) или Formtools (Dj 1.8). А, пока этого не произошло, предлагаю вам поразмышлять о возможностях Django Sites framework.

Вспомните, в своем первом проекте 1.xxx версий Django, скорее всего, вы даже не обратили внимания на автоматически созданные строчки, в settings.py:

 'django.contrib.sites',  # включаем framework Sites........SITE_ID = 1  # задаем значение SITE_ID по умолчанию

В новых версиях Django 3.хх упоминание о SITES_ID вы встретите, только когда захотите включить еще одного кандидата на вылет flatpages app. Читайте об этом в разделе установка.
На работу самих Flatpages, Sites особо не влияет, но без миграции из django.contrib.sites не обойтись.

Так зачем вообще может понадобиться настройка SITES_ID?

Если читать документацию по Sites framework, которая не поменялась c Django 1.4, то вам расскажут, как можно настроить одну панель администратора для управления содержимым нескольких сайтов. Скажу больше, разумное использование Sites позволяет ограничивать доступ к данным на уровне запросов к базе данных, когда права доступа на уровне объектов Python/Django неизвестны, т.к. объектов еще не создано.

Вы можете попробовать сделать это самостоятельно:
Для этого запустите несколько раз django server своего проекта для разных портов с указанием атрибута --settings=every_time_another_settings_name, в каждом новом settings.py укажите другой SITE_ID.

image
И вы увидите, что ничего не изменилось. Пока.

Чтобы ощутить разницу, вам предстоит внести изменения в проект.

  • Добавить поле site=ForeignKey(Site) к любой вашей модели.
  • Унаследовать менеджера данных этой модели от CurrentSiteManager
  • Обновить схему модели и выполнить миграцию

from django.db.models import Model, CharField, ForeignKeyfrom django.contrib.sites.managers import CurrentSiteManagerfrom django.contrib.sites.models import Sitefrom django.conf import settingsclass MyModelManager(CurrentSiteManager):    passclass MyModel(Model):    title = CharField(max_length=255)    site = ForeignKey(Site, default=settings.SITE_ID, editable=False, on_delete=CASCADE)    objects = MyModelManager()  # можно сразу тут передать имя поля. 

Опять запустите несколько раз сервер на разных портах, запустите Админ панель, создайте объекты в администраторе измененной модели.

image

Как видите, администратор модели отображает только объекты для своего SITE_ID

image

Обобщим первый опыт:
Менеджер, унаследованный от CurrentSiteManager дает предварительную автоматическую фильтрацию данных своей модели по определенному признаку: obj.site_id=settings.SITE_ID

Если вы добрались до этого момента, то вы встретите первую недоработку SITES framework.

в CurrentSiteManager жестко зашита проверка наличия ForeignKey(Site) в текущей модели. Похоже, что для использования всех удобств Sites в старых версиях Django мы вынуждены иметь ForeignKey(Site) в каждой модели.
Но если подумать, то при доработке CurrentSiteManager напильником можно все сделать как надо:
class MyModelManager(CurrentSiteManager):    site_field_name = 'othermymodel__site'    def __init__(self, *args, **kwargs):        super(MyModelManager, self).__init__(*args, **kwargs)        if self.site_field_name:            self._CurrentSiteManager__is_validated = True  #для старых Django            self._CurrentSiteManager__field_name = self.site_field_name    def _check_field_name(self):  #для новых Django        return []
Mожете сами поискать с какой версии Django этот код останется работосособным, но станет избыточным.
В новых версиях Django код уже пробовали исправить, но так и не доделали. Причина в том, что django.contrib.sites это уже неуловимый Джо для разработчиков Django.


Что должно получиться в итоге:
Вы вставили в ключевые модели поле ссылки на Site, в менеджерах связанных моделей ссылку на это поле в атрибуте site_field_name.
При заходе по адресу одного сайта вы видите и правите данные только этого сайта, при заходе на другой видите и правите данные только другого сайта.
Сервер надо запустить несколько раз. Но база и код в единственном экземпляре.

В проектах моей фирмы Менеджеры сайтовых данных унаследованы от исправленного CurrentSiteManager с правильно прописанными site_field_name и несколько моделей имеют ссылку на ForeignKey(Site). При этом дополнительных JOIN в запросах удалось избежать.


Немногим позже вы заметите, что знания только site_id мало. Например, в шаблонах вы захотите отображать не цифру, как у меня на примере выше, а красивое имя сайта. Этому может поспособствовать CurrentSiteMiddleware из django.contrib.sites.middleware.
Благодаря ей любой request получит вычисленный атрибут site, хранящий объект из модели Sites. CurrentSiteMiddleware позволит не прописывать SITES_ID в settings, и вычисление атрибута site будет выполняться с учетом текущего запроса, и это очень круто. Только эта функция работает не всегда и не так, как ожидается:

  • Атрибут site вычисляется сразу. Не важно, используете вы его в дальнейшем, или нет. Да, в коде предприняты корявые попытки уменьшить количество обращений к базе, но непонятно, что мешало разработчикам Django использовать lazy-объект.
  • Если вы сильно заморочены на типизации, то заметите, что атрибут site может содержать instance двух абсолютно не связанных классов. Причем явно переопределить один из этих классов (RequestSite) вы не можете. Но если подумать, то подмена класса у instance возможна позже.
  • SITES_ID в settings не знает о модели Site и наоборот. Запуск сервера с новым SITES_ID, удаление строки таблицы все это приведет к появлению Server Error. Я считаю это изначальной архитектурной ошибкой Sites framework
  • Хотя это и разрешено, не указывать SITES_ID в settings, но любой CurrentSiteManager и django.contrib.FlatPages перестанут работать.
  • Вычисленный на лету объект в site никак не влияет на работу CurrentSiteManager. Хотя название класса как бы обязывает.

Последний пункт превращает sites framework в тыкву бессмысленный атавизм из ветки Django 1.xxx. Но если подумать то этих если подумать и так уже слишком много.

Разумеется, у вас уже мог появиться вопрос: да кому вообще нужен этот SITES_ID, это старье уже никто не использует! И я с этим не соглашусь.

Я знаю несколько современных проектов, использующих подобную структуру.
Пример из недавнего это проект SHUUP, c доработкой Multivendor Marketplace. Его мы совсем недавно портировали на Python-3.9.1/Django-3.1.6

Ребята сделали copy-paste-find-replace в django.contrib.sites, у них вместо модели Site модель Shop, вместо SITES_ID стоит DEFAULT_SHOP, и вместо CurrentSiteMiddleware ShuupMiddleware, в которой они к request крепят не site а shop.
Как по мне, так все же лучше использовать существующий код, чем повторять его еще раз. Но заново изобретенный разработчиками SHUUP proxy model подсказывает, что:
А в 2019 мы изобрели велосипед!!!


На этой ноте я завершу размышления о том, на что влияет SITES_ID в settings.

Какой же из всего этого можно сделать вывод:

В Django заложена возможность создания мультидоменных платформ, подобных wix.com, ucoz.ru, shopify.com с единым административным интерфейсом и простым и быстрым разграничением доступа к данным. Эта возможность заложена в CurrentSiteManager из django.contrib.sites, остается только правильно её реализовать.

Я готов рассказать в следующей статье, об этой доработке, если это будет интересно читателям.
Подробнее..

Архитектура в Django проектах как выжить

01.03.2021 18:19:29 | Автор: admin

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

Немного теории

Когда мы начинаем изучать Django без опыта из других языков и фреймворков, помимо документации мы читаем туториалы, статьи, книги, и почти во всех видим что-то подобное:

Django это фреймворк, использующий шаблон проектирования Model-View-Controller (MVC).

И дальше куча неточных схем и объяснений о том, что такое MVC. Почему они неточные и что с ними не так, можно посмотреть здесь или здесь.

Обычно в таких схемах MVC описывают подобным образом:

  • Model доступ к хранилищу данных

  • View это интерфейс, с которым взаимодействует пользователь

  • Controller некий связывающий объект между model и view.

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

Стоит обратить внимание на две вещи.

Первое, часто под M в MVC подразумевают модель данных, и говорят, что это некий класс, который отвечает за предоставление доступа к базе данных. Что неверно, и не соответствует классическому MVC и его потомкам MV*. В классическом MVC под M подразумевается domain model объектная модель домена, объединяющая данные и поведение. Если говорить точнее, то M в MVC это интерфейс к доменной модели, так как domain model это некий слой объектов, описывающий различные стороны определенной области бизнеса. Где одни объекты призваны имитировать элементы данных, которыми оперируют в этой области, а другие должны формализовать те или иные бизнес-правила.

Второе, в Django нет выделенного слоя controller, и когда вам говорят, что в Django слой views это контроллер, не верьте этим людям. Обратитесь к официальной документации, а точнее к FAQ, тогда можно увидеть, что этот слой вписывается в принципы слоя View в MVC, особенно, если рассматривать DRF, а как такового слоя Controller в Django нет. Как говорится в FAQ, если вам очень хочется аббревиатур, то можно использовать в контексте Django аббревиатуру MTV (Model, Template, and View). Если очень хочется рассматривать Web MVC и сравнивать Django с другими фреймворками, то для простоты можно считать view контроллером.

Несмотря на то, что Django не соответствует MVC аббревиатуре, в ней реализуется главный смысл MVC отделение бизнес-логики от логики представления данных.Но на практике это не всегда так по нескольким причинам, которые мы рассмотрим ниже.

Перейдем к практике

Выделим в Django приложениях несколько слоев, которые есть в каждом туториале и почти в каждом проекте:

  • front-end/templates

  • serializers/forms

  • views

  • models

Не будем рассматривать каждый слой подробно, это все можно найти в документации. В основном будем рассматривать Django c использованием DRF. Попробуем разобрать на двух простых кейсах, что стоит помещать в каждом из слоев и какая ответственность у каждого слоя.

Первый кейс создание заказа. При создании заказа нам нужно:

  • проверить валидность заказа и доступность товаров

  • создать заказ

  • зарезервировать товар на складе

  • передать заявку менеджеру

  • оповестить пользователя о том, что его заказ принят в работу

Второй кейс просмотр списка моих заказов. Здесь все просто, мы должны показать пользователю список его заказов:

  • получить список заказов пользователя

Слой serializers/forms

У слоя serializers три основные функции (все выводы для serializers справедливы и для forms):

  • валидировать данные

  • преобразовывать данные запроса в типы данных Python

  • преобразовывать сложные Python объекты в простые типы данных Python (например, Django модели в dict)

Дополнительно сериалайзеры имеют два метода, create и update, которые вызываются в методе save() и почти всегда используются во view.

Пример использования из документации:

class CommentSerializer(serializers.Serializer):email = serializers.EmailField()  content = serializers.CharField(max_length=200)  created = serializers.DateTimeField()  def create(self, validated_data):  return Comment.objects.create(**validated_data)  def update(self, instance, validated_data):    instance.email = validated_data.get('email', instance.email)    instance.content = validated_data.get('content', instance.content)    instance.created = validated_data.get('created', instance.created)    instance.save()    return instance

Где-то в нашей view:

# .save() will create a new instance.serializer = CommentSerializer(data=data)# .save() will update the existing `comment` instance.serializer = CommentSerializer(comment, data=data)comment = serializer.save()

В данном подходе за сохранение и обновление сущностей отвечает сериалайзер, точнее он оперирует методами модели.

Можно использовать ModelSerializer и ModelViewSet, что позволяет писать CRUD методы в пару-тройку строк.

# serializers.pyclass OrderSerializer(serializers.ModelSerializer):class Meta:  model = Order    fields = __all__# views.pyclass OrderViewsSet(viewsets.ModelViewSet):queryset = Order.objects.all()  serializer_class = OrderSerializer

Если углубиться в реализацию ModelViewSet и ModelSerializer, то можно заметить, что за сохранение и обновление сущностей также отвечает сериалайзер.

В таком случае, кажется, что переопределение create отличное место для того, чтобы описать там все бизнес-процессы и правила создания заказа.

# serializers.pyclass OrderSerializer(serializers.ModelSerializer):class Meta:model = Orderfields = []def create(self, validated_data):# Проверяем, что все товары есть и заказ валиден...# Создаем запись о заказе в БДinstance = super(OrderSerializer, self).create(validated_data)    # Бронируем товары на складе    ...    # Передаем заявку менеджеру    ...    # Оповещаем пользователя    ...    return instance  # views.pyclass OrderViewsSet(viewsets.ModelViewSet):queryset = Order.objects.all()  serializer_class = OrderSerializer

Если с методом создания мы что-то придумали, то логику получения заказов пользователя придется помещать в view.

Получается такая схема:

Плюсы данного подхода:

Легко делать CRUD

Django и DRF предоставляют очень удобные инструменты с помощью которых можно легко создавать CRUD.

Минусы данного подхода:

Нарушение идей MVC

Мы смешиваем бизнес-логику с задачей сериализации (получения/отображения) данных в одном слое. Ни о каком выделенном слое бизнес-логики у нас нет и речи.

Сериалайзеры стоит относить к слою View в MVC в контексте Django. Когда мы располагаем в сериалайзерах свою бизнес логику, мы нарушаем главный принцип MVC отделение логики представления данных от бизнес-логики.

Нельзя переиспользовать

Не получится переиспользовать логику одного сериалайзера в другом сериалайзере, или в каком-то другом компоненте.

Сложно тестировать

Сложно протестировать бизнес-логику независимо от логики сериализации и валидации данных.

Сложно поддерживать

Остается открытым вопрос куда помещать методы на чтение данных, можно перенести их в слой views.Не все правила бизнес-логики можно поместить в сериалайзеры, иногда это излишне, потому что мы не используем сами сериалайзеры.

В итоге мы получаем разбросанные бизнес-правила по всему проекту, которые невозможно переиспользовать и очень сложно поддерживать.

Высокая зависимость от фреймворка

Высокая зависимость от DRF Serializers или Django Forms. Если мы захотим поменять способ сериализации и отказаться от serializers, то придется переносить или переписывать нашу логику. Также будет сложно переехать с Django Forms на DRF Serializers или наоборот.

Правильные обязанности слоя

Сериализация/десериализация данных

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

Валидация данных

Без написания кастомных валидаторов. Если вам требуется написать кастомный валидатор, то, скорее всего, это бизнес-правило, и данную проверку лучше вынести в слой с бизнес-логикой (где бы он ни был).

Заключение

Сериалайзеры точно не подходят для написания в них бизнес-логики.Если вам нужно что-то большее, чем CRUD, то стоит отказаться от использования метода save у сериалайзеров, так как сохранение данных не должно входить в их обязанности.

Стоит отказаться от ModelSerializer с его магическими методами create и update и заменить их на обычные сериалайзеры (можно использовать ModelSerializer как read only для удобства). Если вы пишете какое-то простое приложение, где кроме CRUD ничего не нужно, то можно не отказываться от удобства DRF и использовать сериалайзеры как предлагается в документации.

Слой Views

Слой View в контексте Django отвечает за представление и обработку пользовательских данных, в нем мы описываем, какие данные нам нужны и как мы хотим их представить. Если вспомнить то, о чем мы говорили в начале, то можно сразу сделать вывод, что во views не нужно писать бизнес-логику, иначе мы смешиваем логику представления данных с бизнес-логикой. Даже если считать, что views в Django это контроллеры, то размещать в них бизнес-логику тоже не стоит, иначе у вас получатся ТТУКи (Толстые, тупые, уродливые контроллеры; Fat Stupid Ugly Controllers).

Но часто можно увидеть что-то подобное:

# views.pyclass OrderViewsSet(viewsets.ModelViewSet):queryset = Order.objects.all()  serializer_class = OrderSerializer                                  def perform_create(self, serializer):  # Проверяем, что все товары есть и заказ валиден    ...    # Создаем запись о заказе в БД    super(OrderViewsSet, self).perform_create(serializer)    # Бронируем товары на складе    ...    # Передаем заявку менеджеру    ...    # Оповещаем пользователя    ...

По дефолту, в ModelViewSet для сохранения и обновления данных используется сериалайзер, что не входит в его обязанности. Это можно исправить, полностью переопределить метод perform_create (не вызывать super, но тогда встает вопрос об объективности наследования от ModelViewSet). Можно написать кастомные методы в ModelViewSetили написать кастомные APIView:

# views.pyclass OrderCreateApi(views.APIView):class InputSerializer(serializers.ModelSerializer):  number = serializers.IntegerField()    ...                     def post(self, request):  serializer = self.InputSerializer(data=request.data)    serializer.is_valid(raise_exception=True)    # Проверяем, что все товары есть и заказ валиден    ...    # Создаем запись о заказе в БД                                             order = Order.objects.create(**serializer.validated_data)    # Бронируем товары на складе    ...    # Передаем заявку менеджеру    ...    # Оповещаем пользователя    ...                       return Response(status=status.HTTP_201_CREATED)

В отличии от слоя serializers, мы теперь легко можем поместить нашу логику получения заказов в слой views.

# views.pyclass OrderViewsSet(viewsets.ModelViewSet):queryset = Order.objects.all()  serializer_class = OrderSerializer                            def get_queryset(self):  queryset = super(OrderViewsSet, self).get_queryset()    queryset = queryset.filter(user=self.request.user)    return queryset

Тем самым, ограничив не только получение списка, но и другие методы CRUD, что, иногда, очень удобно и быстро. Также, можно переопределить каждый метод по отдельности.

Получается такая схема:

Стоит помнить, что мы отказались от использования saveу serializers. В таком случае слой serializers остается чистым и выполняет только правильные обязанности.

Плюсы данного подхода

Легко делать CRUD

Django и DRF предоставляют очень удобные инструменты, с помощью которых можно легко создавать CRUD и views не исключение.

Минусы данного подхода

Нарушение идей MVC

Мы смешиваем в одном слое логику представления данных и бизнес-логику приложения.

Нельзя переиспользовать

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

Высокая зависимость от фреймворка

Высокая зависимость от DRF View или Django View. Если мы захотим поменять способ обработки запроса и отказаться от views, то придется переносить или переписывать нашу логику. Будет сложно переехать с Django View на DRF View или наоборот.

Сложно тестировать

Достаточно сложно протестировать код во views независимо от serializers и остальной инфраструктуры Django + придется использовать http client для тестирования.

Сложно поддерживать

Со временем views разрастаются, часть логики переносится в Celery задачи, часть в модели, код во views дублируется, так как их нельзя переиспользовать все это приводит к тому, что проект сложно поддерживать.

Правильные обязанности слоя

Обработка запроса

Во view мы принимаем и обрабатываем запрос клиента, подготавливаем данные для передачи в бизнес-логику.

Делегирование сериализации данных сериалайзерам

Всю логику сериализации данных должны выполнять сериалайзеры.

Вызов методов бизнес-логики

После подготовки и сериализации данных вызывается интерфейс бизнес-логики.

Логика представления данных

Мы должны обработать ответ от методов бизнес-логики и предоставить нужные данные клиенту.

Заключение

Вывод примерно такой же как и с serializers в views не стоит размещать бизнес-логику.

Стоит отказаться от ModelViewSet и миксинов, так как они используют сериалайзеры для сохранения данных, вместо этого использовать обычные APIView или GenericAPIView.

Если вам нужен только CRUD, то можно использовать подход который предоставляет ModelViewSet и не усложнять себе жизнь.

Слой models

Если опираться на более продвинутые туториалы или вспомнить слой Model в MVC, то кажется, что models отличное место для размещения бизнес-логики.

Это может выглядеть примерно так:

# models.pyclass Order(models.Model):number = serializers.IntegerField()  created = models.DateTimeField(auto_now_add=True)  status = models.CharField(max_length=16)                                               def update_status(self, status: str) -> None:  self.status = status    self.save(update_fields=('status',))  ...    @classmethod  def create(cls, data...):  instance = cls(...)    # Проверяем, что все товары есть и заказ валиден    ...    # Создаем запись о заказе в БД    instance = instance.save()    # Бронируем товары на складе    ...    # Передаем заявку менеджеру (например на почту или создаем какую то запись в БД)    ...    # Оповещаем пользователя    ...# views.pyclass OrderCreateApi(views.APIView):class InputSerializer(serializers.ModelSerializer):  number = serializers.IntegerField()    ...      def post(self, request):  serializer = self.InputSerializer(data=request.data)    serializer.is_valid(raise_exception=True)        Order.create(**serializer.validated_data)                    return Response(status=status.HTTP_201_CREATED)

В view мы сериализуем данные с помощью serializer и вызываем метод создания заказа у класса модели.В данном случае мы реализовали classmethod, что бы не было необходимости создавать экземпляр модели. Иначе нам придется понимать какие данные относятся к полям модели, а какие мы должны передать в метод создания, а это уже некие бизнес- правила.

Стоит заметить, что не нужно писать бизнес-логику в методе save(), так как это базовый метод модели и он может неоднократно использоваться в различных частях кода.

Для методов получения данных в таком случае стоит использовать Managers.

# views.pyclass OrderListApi(views.APIView):class OutputSerializer(serializers.ModelSerializer):  class Meta:    model = Order      fields = __all__                             def get(self, request):  orders = Order.objects.filter(user=request.user)    # если у вас сложные условия фильтрации    # например, Order.objects.filter(user=request.user, is_deleted=False, is_archived=False...)    # то стоит написать кастомные методы в Manager        data = self.OutputSerializer(orders, many=True).data        return Response(data)

Получается такая схема:

В данном случае слои serializers и views становятся чистыми и правила бизнес-логики концентрируются в одном слое.

Плюсы данного подхода

Следование идеям MVC

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

Легко тестировать

Вся бизнес-логика собрана в одном слое, который не зависит от других слоев, например, от views или serializers. Каждый метод модели можно протестировать по отдельности как обычный python код. Остается только замокать метод save и базовые методы managers или использовать базу данных, если требуется.

Можно переиспользовать

Методы модели можно вызывать из любого компонента, DRF Views, Django Views, Celery задачи и т.д.

Минусы данного подхода

Зависимость от фреймворка

У нас все еще есть зависимость от фреймворка, но это не так критично. Так как отказ от Django models и ORM или их замена очень редкий кейс.

Сложно поддерживать большие проекты

В больших проектах много бизнес-правил и если все их описывать в одном классе модели, то модель разрастается и превращается в божественный объект, который сложно читать и поддерживать. Сложно масштабировать и разделять код по файлам, так как мы ограничены требованиями фреймворка. Непонятно, куда помещать методы, которые оперируют несколькими моделями, возможно, из разных модулей.

Усложнение CRUD проектов

Если вам нужен только CRUD, то данный подход увеличивает время разработки и не приносит плюсов.

Заключение

Мы не нарушили главных идей MVC и наш код соответствует им. Такой подход можно использовать в малых проектах, когда бизнес-логики не много и она умещается в классах моделей.

Слой Services

Мы перебрали все дефолтные слои в Django приложении, теперь можем вспомнить о том, что под слоем Model в MVC подразумевается не один объект, а набор объектов.

Выделим отдельный сервисный слой services внутри слоя Model, который будет отвечать за бизнес-правила предметной области и приложения. В models оставить только простые property, в которых нет сложных бизнес-правил, и методы для работы с собственными данными модели, например обновление полей. Тогда наши кейсы можно реализовать так:

# models.pyclass Order(models.Model):number = serializers.IntegerField()  created = models.DateTimeField(auto_now_add=True)  status = models.CharField(max_length=16)                                               def update_status(self, status: str) -> None:  self.status = status    self.save(update_fields=('status',))...# services.py# вместо пречесления всех аргументов можно реализовать DTOdef order_create(name: str, number: int ...) -> bool:# Проверяем, что все товары есть и заказ валиден  ...  # Создаем запись о заказе в БД  order = Order.objects.create(...)  # Бронируем товары на складе  ...  # Передаем заявку менеджеру (например на почту или создаем какую то запись в БД)  ...  # Оповещаем пользователя  ...# views.pyclass OrderCreateApi(views.APIView):class InputSerializer(serializers.ModelSerializer):  number = serializers.IntegerField()    ...                             def post(self, request):  serializer = self.InputSerializer(data=request.data)    serializer.is_valid(raise_exception=True)      services.order_create(**serializer.validated_data)                 return Response(status=status.HTTP_201_CREATED)

Стоит придерживаться следующему подходу:

  • views подготовка данных запроса, вызов бизнес логики, подготовка ответа

  • serializers сериализация данных, простая валидация

  • services простые функции с бизнес правилами или классы (Service Objects)

  • managers содержит в себе правила работы с данными (доступ к данным)

  • models единственный окончательный источник правды о анных

Получение заказов пользователя:

# services.pydef order_get_by_user(user: User) -> Iterable[Order]:return Order.objects.filter(user=user)# views.pyclass OrderListApi(views.APIView):class OutputSerializer(serializers.ModelSerializer):  class Meta:    model = Order      fields = ('id', 'number', ...)                            def get(self, request):  orders = services.order_get_by_user(user=request.user)                                             data = self.OutputSerializer(orders, many=True).data                                return Response(data)

Получается такая схема:

Плюсы данного подхода

Следование идеям MVC

Как и в предыдущем подходе, мы полностью отделили бизнес-логику от логики представления.

Легко тестировать

Сервисы представляют собой простые Python функции, которые легко тестировать.

Можно переиспользовать

Мы можем вызывать наши сервисы из любого компонента + можем повторно использовать какие-то сервисы в других проектах.

Легко поддерживать и расширять

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

Гибкость

Существует множество подходов написания и расширения сервисного слоя.

Минусы данного подхода

Зависимость от фреймворка

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

Усложнение CRUD проектов

Если вам нужен только CRUD, то данный подход увеличивает время разработки и не приносит плюсов.

Заключение

Данный подход удобно использовать в проектах различной сложности и размера. В проекте понятна структура, каждый слой и компонент имеет свою ответственность и не нарушает ее границы. Сервисы легко декомпозировать и отделять друг от друга, в более сложных случаях их можно объединять за фасадами.

На самом деле, каким может быть сервисный слой и как его лучше выделять и разделять это тема отдельной статьи и даже книги, об этом много пишут Мартин Фаулер, Роберт Мартин и другие.

Что касается Django, советую обратить внимание на стайл гайд от HackSoftware у них схожие взгляды, но они разделяют сервисный слой на два компонента (services и selectors) и не используют кастомные методы в managers. Подход написания serializers и включения их во views я взял у них. Также стоит посмотреть на идеи ребят из dry-python.

Общий итог

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

Подробнее..

Перевод Новое тестирование фичей в Django 3.2

01.03.2021 20:22:05 | Автор: admin

Пару недель назад Django 3.2 выпустил свой первый альфа-релиз, а финальный релиз выйдет в апреле. Он содержит микс новых возможностей, о которых вы можете прочитать в примечаниях к релизу. Эта статья посвящена изменениям в тестировании, некоторые из которых можно получить на более ранних версиях Django с пакетами backport.

1. Изоляция setUpTestData()

В примечании к релизу говорится:

Объекты, назначенные для классификации атрибутов в TestCase.setUpTestData() теперь выделяются для каждого тестового метода.

setUpTestData() очень полезный прием для быстрого выполнения тестов, и это изменение делает его использование намного проще.

Прием TestCase.setUp() нередко используется из юнит-теста для создания экземпляров моделей, которые используются в каждом тесте:

from django.test import TestCasefrom example.core.models import Bookclass ExampleTests(TestCase):    def setUp(self):        self.book = Book.objects.create(title="Meditations")

Тест-раннер вызывает setUp() перед каждым тестом. Это дает простую изоляцию тестов, так как берутся свежие данные для каждого теста. Недостатком такого подхода является то, что setUp() запускается много раз, что при большом объеме данных или тестов может происходить довольно медленно.

setUpTestData() позволяет создавать данные на уровне класса один раз на TestCase. Его использование очень похоже на setUp(), только это метод класса:

from django.test import TestCasefrom example.core.models import Bookclass ExampleTests(TestCase):    @classmethod    def setUpTestData(cls):        cls.book = Book.objects.create(title="Meditations")

В промежутках между тестами Django продолжает откатывать (rollback) любые изменения в базе данных, поэтому они остаются там изолированными. К сожалению, до этого изменения в Django 3.2 rollback не происходили в памяти, поэтому любые изменения в экземплярах моделей сохранялись. Это означает, что тесты не были полностью изолированы.

Возьмем, к примеру, эти тесты:

from django.test import TestCasefrom example.core.models import Bookclass SetUpTestDataTests(TestCase):    @classmethod    def setUpTestData(cls):        cls.book = Book.objects.create(title="Meditations")    def test_that_changes_title(self):        self.book.title = "Antifragile"    def test_that_reads_title_from_db(self):        db_title = Book.objects.get().title        assert db_title == "Meditations"    def test_that_reads_in_memory_title(self):        assert self.book.title == "Meditations"

Если мы запустим их на Django 3.1, то финальный тест провалится:

$ ./manage.py test example.core.tests.test_setuptestdataCreating test database for alias 'default'...System check identified no issues (0 silenced)..F.======================================================================FAIL: test_that_reads_in_memory_title (example.core.tests.test_setuptestdata.SetUpTestDataTests)----------------------------------------------------------------------Traceback (most recent call last):  File "/.../example/core/tests/test_setuptestdata.py", line 19, in test_that_reads_in_memory_title    assert self.book.title == "Meditations"AssertionError----------------------------------------------------------------------Ran 3 tests in 0.002sFAILED (failures=1)Destroying test database for alias 'default'...

Это связано с тем, что in-memory изменение из test_that_changes_title() сохраняется между тестами. Это происходит в Django 3.2 за счет копирования объектов доступа в каждом тесте, поэтому в каждом тесте используется отдельная изолированная копия экземпляра модели in-memory. Теперь тесты проходят:

$ ./manage.py test example.core.tests.test_setuptestdataCreating test database for alias 'default'...System check identified no issues (0 silenced)....----------------------------------------------------------------------Ran 3 tests in 0.002sOKDestroying test database for alias 'default'...

Спасибо Simon Charette за изначальное создание этой функциональности в проекте django-testdata, и до его объединения в систему Django. На старых версиях Django вы можете использовать django тест-данные для той же изоляции, добавив декоратор@wrap_testdata в ваши методы setUpTestData(). Он очень удобен, и я добавлял его в каждый проект, над которым работал.

(В более старых версиях Django также документируется обходной путь, включающий повторный запрос базы данных в каждом тесте, но это медленнее).

2. Использование faulthandler по умолчанию

В примечании к релизу говорится:

DiscoverRunner сейчас использует faulthandler по умолчанию.

Это небольшое улучшение, которое может помочь вам дебаггить сбои низкого уровня. Модуль Python faulthandler предоставляет способ сброса экстренной трассировки в ответ на проблемы, которые приводят к сбою в работе интерпретатора Python.Тогда тестовый runner Django использует faulthandler. Подобное решение было скопировано из pytest, который делает то же самое.

Проблемы, которые ловит faulthandler, обычно возникают в библиотеках на языке C, например, в драйверах баз данных. Например, OS сигнал SIGSEGV указывает на ошибку сегментации, что означает попытку чтения памяти, не принадлежащей текущему процессу. Мы можем эмулировать это на Python, напрямую посылая сигнал самим себе:

import osimport signalfrom django.test import SimpleTestCaseclass FaulthandlerTests(SimpleTestCase):    def test_segv(self):        # Directly trigger the segmentation fault        # signal, which normally occurs due to        # unsafe memory access in C        os.kill(os.getpid(), signal.SIGSEGV)

Если мы делаем тест в Django 3.1, мы видим это:

$ ./manage.py test example.core.tests.test_faulthandlerSystem check identified no issues (0 silenced).[1]    31127 segmentation fault  ./manage.py test

Это нам не очень помогает, так как нет никакой зацепки на то, что вызвало ошибку сегментации.

Вместо этого мы видим трассировку на Django 3.2:

$ ./manage.py test example.core.tests.test_faulthandlerSystem check identified no issues (0 silenced).Fatal Python error: Segmentation faultCurrent thread 0x000000010ed1bdc0 (most recent call first):  File "/.../example/core/tests/test_faulthandler.py", line 12 in test_segv  File "/.../python3.9/unittest/case.py", line 550 in _callTestMethod  ...  File "/.../django/test/runner.py", line 668 in run_suite  ...  File "/..././manage.py", line 17 in main  File "/..././manage.py", line 21 in <module>[1]    31509 segmentation fault  ./manage.py test

( Сокращенно )

Faulthandler не может генерировать точно такую же трассировку, как стандартное исключение в Python, но он дает нам много информации для дибаггинга сбоя.

3. Timing (тайминг)

В примечании к релизу говорится:

DiscoverRunner теперь может трекать тайминг, включая настройку базы данных и общее время работы.

Команда manage.py test включает опцию --timing, которая активирует несколько строк вывода в конце пробного тест-запуска для подведения итогов по настройке базы данных и времени:

$ ./manage.py test --timingCreating test database for alias 'default'...System check identified no issues (0 silenced)....----------------------------------------------------------------------Ran 3 tests in 0.002sOKDestroying test database for alias 'default'...Total database setup took 0.019s  Creating 'default' took 0.019sTotal database teardown took 0.000sTotal run took 0.028s

Благодарим Ахмада А. Хуссейна за участие в этом мероприятии в рамках Google Summer of Code 2020.

Если вы используете pytest, опция --durations N работает схоже.

Из-за системы фикстуры pytest время настройки базы данных будет отображаться как время настройки (setup time) только для одного теста, что сделает этот тест более медленным, чем он есть на самом деле.

4. Обратный вызов (callbacks) тестаtransaction.on_commit()

В примечании к релизу говорится:

Новый метод TestCase.captureOnCommitCallbacks() собирает функции обратного вызова (callbacks functions), переданные в transaction.on_commit(). Это позволяет вам тестировать эти callbacks, не используя при этом более медленный TransactionTestCase.

Это вклад, который я ранее сделал и о котором ранее рассказывал.

Итак, представьте, что вы используете опцию ATOMIC_REQUESTSот Django, чтобы перевести каждый вид в транзакцию (а я думаю, что так и должно быть!). Затем вам нужно использовать функцию transaction.on_commit() для выполнения любых действий, которые зависят от того, насколько длительно данные хранятся в базе данных. Например, в этом простом представлении для формы контактов:

from django.db import transactionfrom django.views.decorators.http import require_http_methodsfrom example.core.models import ContactAttempt@require_http_methods(("POST",))def contact(request):    message = request.POST.get('message', '')    attempt = ContactAttempt.objects.create(message=message)    @transaction.on_commit    def send_email():        send_contact_form_email(attempt)    return redirect('/contact/success/')

Мы не посылаем сообщения по электронной почте, если только транзакция не сохраняет изменения, так как в противном случае в базе данных не будет ContactAttempt.

Это правильный способ написания подобного вида, но ранее было сложно протестировать callback (обратный вызов), переданный функции on_commit(). Django не будет запускать callback без сохранения транзакции, а его TestCase избегает сохранения, и вместо этого откатывает (rollback) транзакцию, так что это повторяется при каждом тесте.

Одним из решений является использование TransactionTestCase, которое позволяет сохранять транзакции кода. Но это намного медленнее, чем TestCase , так как он очищает все таблицы между тестами.

(Я ранее говорил об увеличении скорости в три раза благодаря конвертации тестов из TransactionTestCase в TestCase.)

Решением в Django 3.2 является новая функция captureOnCommitCallbacks(), которую мы используем в качестве контекстного менеджера. Она захватывает любые callbacks и позволяет вам добавлять утверждения или проверять их эффект. Мы можем использовать это, чтобы проверить наше мнение таким образом:

from django.core import mailfrom django.test import TestCasefrom example.core.models import ContactAttemptclass ContactTests(TestCase):    def test_post(self):        with self.captureOnCommitCallbacks(execute=True) as callbacks:            response = self.client.post(                "/contact/",                {"message": "I like your site"},            )        assert response.status_code == 302        assert response["location"] == "/contact/success/"        assert ContactAttempt.objects.get().message == "I like your site"        assert len(callbacks) == 1        assert len(mail.outbox) == 1        assert mail.outbox[0].subject == "Contact Form"        assert mail.outbox[0].body == "I like your site"

Итак, мы используем captureOnCommitCallbacks() по запросу тестового клиента на просмотр, передавая execute флаг, чтобы указать, что фальшивое сохранение (коммит) должно запускать все callbacks. Затем мы проверяем HTTP-ответ и состояние базы данных, прежде чем проверить электронную почту, отправленную обратным вызовом (callback). Наш тест затем покрывает все на просмотре, оставаясь быстрым и классным!

Чтобы использовать captureOnCommitCallbacks() в ранних версиях Django, установите django-capture-on-commit-callbacks.

5. Улучшенный assertQuerysetEqual()

В примечании к релизу говорится:

TransactionTestCase.assertQuerysetEqual() в данный момент поддерживает прямое сравнение с другой выборкой элементов запроса в Django

Если вы используете assertQuerysetEqual() в ваших тестах, это изменение точно улучшит вашу жизнь!

В дополнение к Django 3.2, assertQuerysetEqual() требует от вас сравнения с QuerySet после трансформации. Далее происходит переход по умолчанию к repr(). Таким образом, тесты, использующие его, обычно проходят список предварительно вычисленных repr() strings для вышеупомянутого сравнения:

from django.test import TestCasefrom example.core.models import Bookclass AssertQuerySetEqualTests(TestCase):    def test_comparison(self):        Book.objects.create(title="Meditations")        Book.objects.create(title="Antifragile")        self.assertQuerysetEqual(            Book.objects.order_by("title"),            ["<Book: Antifragile>", "<Book: Meditations>"],        )

Проверка требует немного больше размышлений о том, какие экземпляры моделей ожидаются. А также требует отработки тестов в случае изменения метода repr() модели.

Из Django 3.2 можно передать QuerySet или список объектов для сравнения, что позволяет упростить тест:

from django.test import TestCasefrom example.core.models import Bookclass AssertQuerySetEqualTests(TestCase):    def test_comparison(self):        book1 = Book.objects.create(title="Meditations")        book2 = Book.objects.create(title="Antifragile")        self.assertQuerysetEqual(            Book.objects.order_by("title"),            [book2, book1],        )

Спасибо Питеру Инглсби и Хасану Рамезани за то, что они внесли эти изменения. Они помогли улучшить тестовый набор Django.

Финал

Наслаждайтесь этими изменениями, когда Django 3.2 выйдет или сделайте это раньше через пакет backport.


Перевод статьи подготовлен в преддверии старта курса Web-разработчик на Python.

Также приглашаем всех желающих посмотреть открытый вебинар на тему Использование сторонних библиотек в django. На занятии мы рассмотрим общие принципы установки и использования сторонних библиотек вместе с django; а также научимся пользоваться несколькими популярными библиотеками: django-debug-toolbar, django-cms, django-cleanup и т.д.

Подробнее..

Запуск Django сайта на nginx Gunicorn SSL

12.03.2021 20:06:18 | Автор: admin

Предисловие

Для написания этой статьи ушло очень много сил и времени. Я натыкался на множество инструкций, как на английском, так и на русском языках, но как я понял, - они все были клонами оригинальной статьи на Digital Ocean. Спросите вы, почему я так считаю, а все потому, что все ошибки и неточности передаются с одного ресурса на другой без всяких изменений.

Подготовка

У нас есть обычный VPS c ОС Ubuntu, и мы уже написали в PyCharm или в блокноте свой сайт на Django и его осталось всего лишь опубликовать, привязать домен, установить сертификат и в путь.

Первым делом необходимо обновить список репозиториев и установить сразу же пакет nginx:

apt get updateapt get-install nginx

Я решил хранить файлы сайта в каталоге: /var/www/. В данном случае перемещаемся в каталогcd /var/www/и создаем новый каталогmkdir geekheroи получаем такой путь: /var/www/geekhero/

Переходим в новый каталог geekhero:cd geekheroи создаем виртуальное окружение:python3 -m venv geekhero_env

Активируем виртуальное окружение:source geekhero_env/bin/activateи устанавливаем в него Django: pip install Django и сразу же ставим pip install gunicorn

Создаем проект:django-admin startproject ghproj

Далее нужно произвести все первичные миграции; для этого прописываем:python manage.py makemigrations , затемpython manage.py migrate .

После этого создаем административную учетную запись: python manage.py createsuperuser и следуем инструкции.

Далее уже создаем applications, но так как вы читаете данную статью, то вы уже умеете все это делать.

Заходим в Settings.py и прописываем, если отсутствует:
import os в заголовке, остальное в самом низу текстового файла:

STATIC_URL = '/static/'STATIC_ROOT = os.path.join(BASE_DIR, 'static/')

НастройкаGunicorn

Идем в каталог /etc/systemd/system/ и создаем два файла: gunicorn.service и gunicon.socket:

Содержимое файла gunicorn.service:

[Unit]Description=gunicorn daemonRequires=gunicorn.socketAfter=network.target[Service]User=rootWorkingDirectory=/var/www/geekhero #путь до каталога с файлом manage.pyExecStart=/var/www/geekhero/geekhero_env/bin/gunicorn --workers 5 --bind unix:/run/gunicorn.sock ghproj.wsgi:application#путь до файла gunicorn в виртуальном окружении[Install]WantedBy=multi-user.target

Содержимое файла gunicorn.socket:

[Unit]Description=gunicorn socket[Socket]ListenStream=/run/gunicorn.sock[Install]WantedBy=sockets.target

Для проверки файла gunicorn.service на наличие ошибок:

systemd-analyze verify gunicorn.service

Настройка NGINX

Далее идем в каталог: /etc/nginx/sites-available/ и создаем файл geekhero (название вашего сайта) без расширения:

server {    listen 80;    server_name example.com;        location = /favicon.ico { access_log off; log_not_found off; }    location /static/ {        root /var/www/geekhero;           #путь до static каталога    }        location /media/ {        root /var/www/geekhero;           #путь до media каталога    }        location / {        include proxy_params;        proxy_pass http://unix:/run/gunicorn.sock;    }}

Для того, чтобы создать символическую ссылку на файл в каталоге/etc/nginx/site-enabled/необходимо ввести следующую команду:

sudo ln -s /etc/nginx/sites-available/geekhero /etc/nginx/sites-enabled/

При любых изменениях оригинального файла, ярлык из sites-enabled нужно удалять и пересоздавать заново командой выше или выполнять команду:sudo systemctl restart nginx

Финальный этап

Для проверки конфигурации nginx нужно ввести команду:

sudo nginx -t

sudo nginx -tДалее запускаем службу gunicorn и создаем socket:

sudo systemctl enable gunicornsudo systemctl start gunicorn

Для отключения выполняем команды:

sudo systemctl disable gunicorn
sudo systemctl stop gunicorn

Также, эти обе команды пригодятся, если вы будете вносить какие-либо изменения в HTML или python файлы, чтобы обновить свой сайт, но помните, что, если вносите изменения в модели, то обязательно нужно сделать python manage.pymakemigrations <app> и migrate <app> из каталога с проектом.

Для первичного запуска / полной остановки сервиса Gunicorn:
service gunicorn start / service gunicorn stop

Чтобы посмотреть статус запущенного сервиса нужно ввести:

sudo systemctl status gunicornилиsudo journalctl -u gunicorn.socket(с последней командой правильный вывод такой: мар 05 16:40:19 byfe systemd[1]: Listening on gunicorn socket. )

Для проверки создания сокета, необходимо ввести команду:

file /run/gunicorn.sock

Такой вывод считается правильным:/run/gunicorn.sock: socket

Если внес какие-либо изменения в файл gunicorn.service или .socket, необходимо выполнить команду:

systemctl daemon-reload

Если все нормально сработало, то можем запустить nginx:

sudo service nginx start

Получаем сертификат SSL для домена

Установим certbot от Let's Encrypt:sudo apt-get install certbot python-certbot-nginx

Произведем первичную настройку certbot:sudo certbot certonly --nginx

И наконец-то автоматически поправим конфигурацию nginx:sudo certbot install --nginx

Осталось только перезапустить сервис nginx:sudo systemctl restart nginx

Итог

В рамках этой статьи мы разобрали как вывести наш сайт в production, установив Django, Gunicorn, nginx и даже certbot от Let's Encrypt.

Подробнее..

Перевод Быстрый и грязный Django Передача данных в JavaScript без AJAX

18.03.2021 16:05:16 | Автор: admin

Привет, хабровчане. Для будущих студентов курса "Web-разработчик на Python" подготовили перевод материала.


Если мы хотим передать данные из Django в JavaScript, мы обычно говорим об API, сериализаторах, вызовах JSON и AJAX. Обычно дело усложняется наличием React или Angular на фронте.

Но иногда вам нужно сделать что-то быстро и грязное построить диаграмму и не задумываться над инфраструктурой одностраничного приложения.

Обычный подход

Допустим, у нас есть приложение на Django со следующей моделью:

from django.db import modelsclass SomeDataModel(models.Model):    date = models.DateField(db_index=True)    value = models.IntegerField()

И простой TemplateView:

<img alt="Изображение выглядит как текст

from django.views.generic import TemplateViewclass SomeTemplateView(TemplateView):    template_name = 'some_template.html'

Теперь мы можем построить простую линейную диаграмму с помощью Chart.js, и мы не хотим использовать AJAX, создавать новые API и т.д.

Если мы хотим визуализировать простую линейную диаграмму в нашем some_template.html, код будет выглядеть следующим образом (взято из этих примеров):

<canvas id="chart"></canvas><script src="http://personeltest.ru/aways/cdn.jsdelivr.net/npm/chart.js@2.9.4/dist/Chart.min.js"></script><script>window.onload = function () {  var data = [48, -63, 81, 11, 70];  var labels = ['January', 'February', 'March', 'April', 'May'];  var config = {    type: 'line',    data: {      labels: labels,      datasets: [        {          label: 'A random dataset',          backgroundColor: 'black',          borderColor: 'lightblue',          data: data,          fill: false        }      ]    },    options: {      responsive: true    }  };  var ctx = document.getElementById('chart').getContext('2d');  window.myLine = new Chart(ctx, config);};</script>

И получится следующее:

Теперь, если мы захотим построить диаграмму данных, поступающих из SomeDataModel, мы подойдем к этой задаче следующим образом:

from django.views.generic import TemplateViewfrom some_project.some_app.models import SomeDataModelclass SomeTemplateView(TemplateView):    template_name = 'some_template.html'    def get_context_data(self, **kwargs):        context = super().get_context_data(**kwargs)        context['data'] = [            {                'id': obj.id,                'value': obj.value,                'date': obj.date.isoformat()            }            for obj in SomeDataModel.objects.all()        ]        return context

А затем мы визуализируем массив JavaScript с помощью шаблона Django:

<canvas id="chart"></canvas><script src="http://personeltest.ru/aways/cdn.jsdelivr.net/npm/chart.js@2.9.4/dist/Chart.min.js"></script><script>window.onload = function () {  // We render via Django template  var data = [    {% for item in data %}      {{ item.value }},    {% endfor %}  ]  // We render via Django template  var labels = [    {% for item in data %}      "{{ item.date }}",    {% endfor %}  ]  console.log(data);  console.log(labels);  var config = {    type: 'line',    data: {      labels: labels,      datasets: [        {          label: 'A random dataset',          backgroundColor: 'black',          borderColor: 'lightblue',          data: data,          fill: false        }      ]    },    options: {      responsive: true    }  };  var ctx = document.getElementById('chart').getContext('2d');  window.myLine = new Chart(ctx, config);};</script>

Именно так это и работает, но, как по мне, слишком грязно. У нас больше нет JavaScript, но есть JavaScript с шаблоном Django. Мы теряем возможность выделить JavaScript в отдельный файл .js. Также мы не можем красиво работать с этим JavaScript.

Но можем сделать лучше и быстрее.

Добавим остроты

Стратегия следующая:

  1. В нашем случае мы будем сериализовать данные через json.dumps и хранить их в контексте.

  2. Отрендерим скрытый элемент <div> с уникальным id и атрибутом data-json, а именно с сериализованными данными JSON.

  3. Запросите этот <div> из JavaScript, прочитайте атрибут data-json и используйте JSON.parse, чтобы получить необходимые данные.

Преимуществом становится то, что код на JavaScript не содержит шаблонов Django, но имеет переиспользуемый шаблон для получения необходимых данных.

Почти как упрощенный AJAX.

Ниже пример того, как я использую эту стратегию:

import jsonfrom django.views.generic import TemplateViewclass SomeTemplateView(TemplateView):    template_name = 'some_template.html'    def get_context_data(self, **kwargs):        context = super().get_context_data(**kwargs)        context['data'] = json.dumps(            [                {                    'id': obj.id,                    'value': obj.value,                    'date': obj.date.isoformat()                }                for obj in SomeDataModel.objects.all()            ]        )        return context

Теперь извлечем наш код на JavaScript в статичный файл chart.js.

В результате получим some_template.html:

{% load static %}<div style="display: none" id="jsonData" data-json="{{ data }}"></div><canvas id="chart"></canvas><script src="http://personeltest.ru/aways/cdn.jsdelivr.net/npm/chart.js@2.9.4/dist/Chart.min.js"></script><script src="{% static 'chart.js' %}"></script>

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

Атрибут data-json (который необязателен и не является чем-то предопределенным) содержит нужный нам JSON.

Теперь, наконец, мы реализуем следующую простую функцию для получения и анализа необходимых нам данных:

function loadJson(selector) {  return JSON.parse(document.querySelector(selector).getAttribute('data-json'));}

И вот наш chart.js готов:

function loadJson(selector) {  return JSON.parse(document.querySelector(selector).getAttribute('data-json'));}window.onload = function () {  var jsonData = loadJson('#jsonData');  var data = jsonData.map((item) => item.value);  var labels = jsonData.map((item) => item.date);  console.log(data);  console.log(labels);  var config = {    type: 'line',    data: {      labels: labels,      datasets: [        {          label: 'A random dataset',          backgroundColor: 'black',          borderColor: 'lightblue',          data: data,          fill: false        }      ]    },    options: {      responsive: true    }  };  var ctx = document.getElementById('chart').getContext('2d');  window.myLine = new Chart(ctx, config);};

Быстрый и грязный способ для тех, кто просто хочет что-то отправить. А вот и конечный результат:

Дисклеймер

Я не рекомендую вам пользоваться этим подходом для чего-то большего, чем грязное доказательство концепции. Как только ваш код на JavaScript начнет расти, станет сложно его контролировать. Рекомендуется пройти путь SPA с одним из популярных фреймворков (в нашем случае React).


Узнать подробнее о курсе "Web-разработчик на Python".

Посетить Demo day к курсу.

Подробнее..

Развертывание приложений Django

11.04.2021 20:17:45 | Автор: admin

Введение

После того, как мы закончили разработку веб-приложения, оно должно быть размещено на хосте, чтобы общественность могла получить доступ к нему из любого места. Мы посмотрим, как развернуть и разместить приложение на экземпляре AWS EC2, используя Nginx в качестве веб-сервера и Gunicorn в качестве WSGI.

AWS EC2

Amazon Elastic Compute Cloud (Amazon EC2) - это веб-сервис, обеспечивающий масштабируемость вычислительных мощностей в облаке. Мы устанавливаем и размещаем наши веб-приложения на экземпляре EC2 после выбора AMI (OS) по нашему усмотрению. Подробнее об этом мы поговорим в следующих разделах.

NGINX

Nginx - это веб-сервер с открытым исходным кодом. Мы будем использовать Nginx для сервера наших веб-страниц по мере необходимости.

GUNICORN

Gunicorn - это серверная реализация интерфейса шлюза Web Server Gateway Interface (WSGI), который обычно используется для запуска веб-приложений Python.

WSGI - используется для переадресации запроса с веб-сервера на Python бэкэнд.

Мы не будем использовать сервер, который поставляется с django по умолчанию в производстве.

Развертывание приложения

Мы запустим EC2 экземпляр на AWS, для этого войдите в консоль aws.

  • Выберите EC2 из всех сервисов

  • Выберите запуск New instance и выберите Ubuntu из списка.

  • Выберите любой из экземпляров, каждый из них имеет различные конфигурации, мы выберем тот, который имеет свободный уровень.

  • Теперь настройте группы безопасности и откройте порты 8000 и 9000, так как мы будем использовать эти порты . Просмотрите и запустите ваш экземпляр, может потребоваться некоторое время, чтобы он запустился.

Подключение к Экземпляру

Мы можем подключиться к экземпляру, используя опцию 'connect' в консоли (или с помощью putty или любого другого подобного инструмента ). После подключения запустите следующие команды

sudo apt-get update

Установите python , pip и django

sudo apt install pythonsudo apt install python3-pippip3 install django

Теперь, когда мы установили наши зависимости, мы можем создать папку, в которую мы скопируем наше приложение django.

cd  /home/ubuntu/  mkdir Projectcd Projectmkdir ProjectNamecd ProjectName

Теперь мы поместим наш код по следующему пути.
/home/ubuntu/Project/ProjectName

GitHub

Убедитесь, что ваш код находится в репозитории, чтобы мы могли легко втянуть его в наш экземпляр ec2.

  • Перейдите в только что созданную папку (/home/ubuntu/Project/ProjectName/)

  • git clone <repository-url>

Это клонирует репозиторий в папку, и в следующий раз мы сможем просто вытащить изменения с помощью git pull.

Settings.py Файл.

Мы должны внести некоторые изменения в settings.py в нашем проекте.

  • Вставьте свои секретные ключи и пароли в переменные окружения

  • Установить Debug = False

  • Добавте Ваш домейн в ALLOWED_HOSTS

BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))STATIC_ROOT = os.path.join(BASE_DIR, static)

Выполните следующие действия, чтобы миграция модели произошла и все статические файлы были собраны в общую папку (путь указан в STATIC_ROOT).

manage.py makemigrationsmanage.py migratemanage.py collectstatic

Установка Nginx

Для установки Nginx выполните команду

 sudo apt install nginx

Есть конфигурационный файл с именем по умолчанию в /etc/nginx/sites-enabled/, который имеет базовую настройку для NGINX, мы отредактируем этот файл.

sudo vi default

Файл будет выглядеть так после добавления необходимой конфигурации, а остальная часть файла останется такой же.

мы добавим proxy_pass http://0.0.0.0:9000 и укажем путь к нашей статической папке, добавив путь внутри каталога /static/, как указано выше. Убедитесь, что вы собрали все статические файлы в общую папку, запустив команду

manage.py collectstatic

Теперь запустите сервер nginx

sudo service nginx start             #to start nginxsudo service nginx stop              #to stop nginxsudo service nginx restart           #to restart nginx

Установка Gunicorn

pip install gunicorn

Убедитесь, что Вы находитесь в папке проекта, например: /home/ubuntu/Project, и запустите следующую команду, чтобы запустить gunicorn

gunicorn ProjectName.wsgi:application- -bind 0.0.0.0:9000

Теперь, когда мы установили и настроили nginx и gunicorn, к нашему приложению можно получить доступ через DNS экземпляра ec2.

Подробнее..
Категории: Python , Nginx , Python3 , Django , Aws , Gunicorn

Конечные автоматы и django

01.06.2021 00:14:15 | Автор: admin

При работе над django-проектом, есть ряд must-have сторонних библиотек, если не хочется бесконечно изобретать велосипед. Средстав отладки sql запросов(debug-toolbar, silk, --print-sql из django-extensions), что-нибудь для хранения древовидных структур, переодических/отложенных задач(кстати, cron-like интерфейс есть у uswgi. EAV всё ещё бывает нужен, хотя часто его можно заменить jsonfield. И одна из таких крайне полезных вещей, но почему-то реже обсуждаемая в сети - FSM. Не так часто почему-то сталкиваюсь с ними в чужом коде.

Практически у каждой записи в БД есть некоторое состояние. Например, для комментария это может быть - опубликован/удален/удален модератором. Для заказа в магазине - оформлен/оплачен/доставлен/возврат и т.п. Причем переход из одного состояния в другое часто бывает размазан по коду и в нем присутствует бизнес-логика, которую надо обильно покрывать тестами(всё равно придется, но можно избежать тестирования элементарных вещей, например, что заказ может перейти в состояние "возврат денег" только после того, как он побывал в состоянии "оплачен".

Вполне логично было бы описывать такие переходы более декларативно и в одном месте. Вместе с необходимой логикой и проверкой доступа.

Вот пример кода из тестов библиотеки django-fsm

class BlogPost(models.Model):    """    Test workflow    """    state = FSMField(default='new', protected=True)    def can_restore(self, user):        return user.is_superuser or user.is_staff    @transition(field=state, source='new', target='published',                on_error='failed', permission='testapp.can_publish_post')    def publish(self):        pass    @transition(field=state, source='published')    def notify_all(self):        pass    @transition(field=state, source='published', target='hidden', on_error='failed',)    def hide(self):        pass    @transition(        field=state,        source='new',        target='removed',        on_error='failed',        permission=lambda self, u: u.has_perm('testapp.can_remove_post'))    def remove(self):        raise Exception('No rights to delete %s' % self)    @transition(field=state, source='new', target='restored',                on_error='failed', permission=can_restore)    def restore(self):        pass    @transition(field=state, source=['published', 'hidden'], target='stolen')    def steal(self):        pass    @transition(field=state, source='*', target='moderated')    def moderate(self):        pass    class Meta:        permissions = [            ('can_publish_post', 'Can publish post'),            ('can_remove_post', 'Can remove post'),        ]

Помимо прочего это прекрасно подходит для rest api. Мы может создавать эндпоинты для переходов между состояниями автоматически. Например, запрос /orders/id/cancel выглядит вполне логичным action`ом для viewset. И у нас уже есть необходимая информация для проверки доступа! А также для кнопочек в админке, и возможность рисовать красивые чарты с workflow :) Есть даже визуальных редакторы workflow т.е. не-программисты могут описывать бизнесс-процессы

Чем более декларативный и обобщенный код мы пишем, тем он надежней. Меньше объем кода, меньше его дублирование, меньше ошибок. Тестирование частично перекладывается на автора библоитеки, и можно сосредоточиться на бизнес-логике уникальной для проекта.

Подробнее..
Категории: Python , Fsm , Django , Django rest framework

Django Rest Framework для начинающих создаём API для чтения данных (часть 2)

15.06.2021 12:07:46 | Автор: admin

В прошлой части мы в общих чертах рассмотрели, как устроен REST API на DRF при работе на чтение. Едва ли не самый сложный для понимания этап сериализация. Вооружившись исходным кодом, полностью разберем этот этап от приема набора записей из модели до их преобразования в список словарей.


Важный момент: мы говорим о работе сериалайзера только на чтение, то есть когда он отдаёт пользователю информацию из базы данных (БД) сайта. О работе на запись, когда данные поступают извне и их надо сохранить в БД, расскажем в следующей статье.


Код учебного проекта, который используется в этой статье, доступен в репозитории на Гитхабе.



Как создаётся сериалайзер, работающий на чтение


Создание экземпляра сериалайзера мы описывали следующим образом:


# capitals/views.py        serializer_for_queryset = CapitalSerializer(            instance=queryset,  # Передаём набор записей            many=True # Указываем, что на вход подаётся набор записей        )

Благодаря many=True запускается метод many_init класса BaseSerializer.


class BaseSerializer(Field):           def __new__(cls, *args, **kwargs):        if kwargs.pop('many', False):            return cls.many_init(*args, **kwargs)        return super().__new__(cls, *args, **kwargs)

Подробнее о методе many_init:


  • При создании экземпляра сериалайзера он меняет родительский класс. Теперь родителем выступает не CapitalSerializer, а класс DRF для обработки наборов записей restframework.serializers.ListSerializer.
  • Созданный экземпляр сериалайзера наделяется атрибутом child. В него включается дочерний сериалайзер экземпляр класса CapitalSerializer.

 @classmethod    def many_init(cls, *args, **kwargs):        ...        child_serializer = cls(*args, **kwargs)        list_kwargs = {            'child': child_serializer,        }      ...        meta = getattr(cls, 'Meta', None)        list_serializer_class = getattr(meta, 'list_serializer_class',                           ListSerializer)        return list_serializer_class(*args, **list_kwargs)

Экземпляр сериалайзера Описание К какому классу относится
serializer_for_queryset Обрабатывает набор табличных записей ListSerializer класс из модуля restframework.serializers
serializer_for_queryset.child Обрабатывает каждую отдельную запись в наборе CapitalSerializer наш собственный класс, наследует от класса Serializer модуля restframework.serializers

Помимо many=True мы передали значение для атрибута instance (инстанс). В нём набор записей из модели.


Важное замечание: чтобы не запутаться и понимать, когда речь идёт о сериалайзере в целом, а когда о дочернем сериалайзере, далее по тексту мы будем говорить основной сериалайзер (в коде контроллера это serializer_for_queryset) и дочерний сериалайзер (атрибут child основного сериалайзера).


После создания основного сериалайзера мы обращаемся к его атрибуту data:


return Response(serializer_for_queryset.data)

Запускается целый набор операций, каждую из которых подробно рассмотрим далее.


Что под капотом атрибута data основного сериалайзера


Важное замечание: атрибут data есть и у основного, и у дочернего сериалайзеров. Поэтому, чтобы найти подходящий исходный код, нужно помнить: экземпляр основного (serializer_for_queryset) относится к классу ListSerializer.


Исходный код атрибута data:


class ListSerializer(BaseSerializer):    ...    @property    def data(self):        ret = super().data        return ReturnList(ret, serializer=self)

Задействован атрибут data родительского BaseSerializer. Исходный код:


class BaseSerializer(Field):            @property    def data(self):    ...        if not hasattr(self, '_data'):            if self.instance is not None and not getattr(self, '_errors', None):                self._data = self.to_representation(self.instance)            ...        return self._data

Поскольку никакие данные ещё не сгенерированы (нет атрибута _data), ничего не валидируется (нет _errors), но есть инстанс (набор записей для сериализации), запускается метод to_representation, который и обрабатывает набор записей из модели.


Как работает метод to_represantation основного сериалайзера


Возвращаемся в класс ListSerializer.


class ListSerializer(BaseSerializer):           def to_representation(self, data):        """        List of object instances -> List of dicts of primitive datatypes.        """        iterable = data.all() if isinstance(data, models.Manager) else data        return [            self.child.to_representation(item) for item in iterable        ]

Исходный код


Код нехитрый:


  • набор записей из модели (его передавали при создании сериалайзера в аргументе instance) помещается в цикл в качестве единственного аргумента data;
  • в ходе работы цикла каждая запись из набора обрабатывается методом to_representation дочернего сериалайзера (self.child.to_representation(item)). Теперь понятно, зачем нужна конструкция основной дочерний сериалайзер.

Сделаем небольшую остановку:


  • Чтобы обрабатывать не одну запись из БД, а набор, при создании сериалайзера нужно указать many=True.
  • В этом случае мы получим матрёшку основной сериалайзер с дочерним внутри.
  • Задача основного сериалайзера (он относится к классу ListSerializer) запустить цикл, в ходе которого дочерний обработает каждую запись и превратит ее в словарь.

Как работает метод to_representation дочернего сериалайзера


Дочерний сериалайзер экземпляр класса CapitalSerializer наследует от restframework.serializers.Serializer.


class Serializer(BaseSerializer, metaclass=SerializerMetaclass):    def to_representation(self, instance):        """        Object instance -> Dict of primitive datatypes.        """        ret = OrderedDict()        fields = self._readable_fields        for field in fields:            try:                attribute = field.get_attribute(instance)            except SkipField:                continue            check_for_none = attribute.pk if isinstance(attribute, PKOnlyObject)      else attribute            if check_for_none is None:                ret[field.field_name] = None            else:                ret[field.field_name] = field.to_representation(attribute)        return ret

Исходный код


Пойдём по порядку: сначала создаётся пустой OrderedDict, далее идёт обращение к атрибуту _readable_fields.


Откуда берётся _readable_fields? Смотрим исходный код:


class Serializer(BaseSerializer, metaclass=SerializerMetaclass):           @property    def _readable_fields(self):        for field in self.fields.values():            if not field.write_only:                yield field

То есть _readable_fields это генератор, включающий поля дочернего сериалайзера, у которых нет атрибутa write_only со значением True. По умолчанию он False. Если объявить True, поле будет работать только на создание или обновление записи, но будет игнорироваться при её представлении.


В дочернем сериалайзере все поля могут работать на чтение (представление) ограничений write only не установлено. Это значит, что генератор _readable_fields будет включать три поля capital_city, capital_population, author.


Читаем код to_representation далее: генератор _readable_fields помещается в цикл, и у каждого поля вызывается метод get_attribute.


Если посмотреть код to_representation дальше, видно, что у поля вызывается и другой метод to_representation. Это не опечатка: метод to_representation под одним и тем же названием, но с разной логикой:


  • есть у основного сериалайзера в классе ListSerializer;
  • у дочернего сериалайзера в классе Serializer;
  • у каждого поля дочернего сериалайзера в классе соответствующего поля.

Итак, когда конкретная запись из модели попадает в сериалайзер, у каждого его поля включаются методы get_attribute и to_representation, чтобы наконец извлечь искомые данные.


Как запись из модели обрабатывается методами полей сериалайзера


Метод get_attribute работает с инстансом (instance). Важно не путать этот инстанс с инстансом основного сериалайзера. Инстанс основного сериалайзера это набор записей из модели. Инстанс дочернего сериалайзера каждая конкретная запись.


Вспомним строку из кода to_representation основного сериалайзера:


[self.child.to_representation(item) for item in iterable]

Этот item (отдельная запись из набора) и есть инстанс, с которым работает метод get_attribute конкретного поля.


class Field:       ...    def get_attribute(self, instance):        try:            return get_attribute(instance, self.source_attrs)      ...

Исходный код


Вызывается функция get_attribute, описанная на уровне всего модуля rest_framework.fields. Функция получает на вход запись из модели и значение атрибута поля source_attrs. Это список, который возникает в результате применения метода split (разделитель точка) к строке, которая передавалась в аргументе source при создании поля. Если такой аргумент не передавали, то в качестве source будет взято имя поля.


Если вспомнить, как работает строковый метод split, станет понятно, что если при указании source не применялась точечная нотация, то список всегда будет из одного элемента.


У нас есть такие поля:


class CapitalSerializer(serializers.Serializer):    capital_city = serializers.CharField(max_length=200)    capital_population = serializers.IntegerField()    author = serializers.CharField(source='author.username', max_length=200)

Получается следующая картина:


Поле сериалайзера Значение атрибута source поля Значение source_attrs
capital_city 'capital_city' ['capital_city']
capital_population 'capital_population' ['capital_population']
author 'author.username' ['author', 'username']

Как мы уже указывали, список source_attrs в качестве аргумента attrs передаётся в метод get_attribute rest_framework.fields:


def get_attribute(instance, attrs):    for attr in attrs:        try:            if isinstance(instance, Mapping):                instance = instance[attr]            else:                instance = getattr(instance, attr)        ...    return instance

Для полей capital_city и capital_population цикл for attr in attrs отработает однократно и выполнит инструкцию instance = getattr(instance, attr). Встроенная Python-функция getattr извлекает из объекта записи (instance) значение, присвоенное конкретному атрибуту (attr) этого объекта.
При обработке записей из нашей таблицы рассматриваемую строку исходного кода можно представить примерно так:


instance = getattr(запись_о_конкретной_столице, 'capital_city')

С author.username ситуация интереснее. До значения атрибута username DRF будет добираться так:


  • На первой итерации инстанс это объект записи из модели Capital. Из source_attrs берётся первый элемент author, и значение одноимённого атрибута становится новым инстансом. author объект из модели User, с которой Capital связана через внешний ключ.
  • На следующей итерации из source_attrs берётся второй элемент username. Значение атрибута username будет взято уже от нового инстанса объекта author. Так мы и получаем имя автора.

Извлечённые из объекта табличной записи данные помещаются в упорядоченный словарь ret, но перед этим с ними работает метод to_representation поля сериалайзера:


ret[field.field_name] = field.to_representation(attribute)

Задача метода to_representation представить извлечённые из записи данные в определённом виде. Например, если поле сериалайзера относится к классу CharField, то извлечённые данные будут приведены к строке, а если IntegerField к целому числу.


В нашем случае применение to_representation по сути ничего не даст. Например, из поля табличной записи capital_city будет извлечена строка. Метод to_representation поля CharField к извлечённой строке применит метод str. Очевидно, что строка останется строкой, то есть какого-то реального преобразования не произойдёт. Но если бы из поля табличной записи IntegerField извлекались целые числа и передавались полю класса CharField, то в итоге они превращались бы в строки.


При необходимости создать собственный класс поля сериалайзера, описать специфичную логику и для метода get_attribute, и для метода to_representation, чтобы как угодно преобразовывать поступившие на сериализацию данные. Примеры есть в документации кастомные классы ColorField и ClassNameField.


Суммируем всё, что узнали


Преобразованный набор записей из Django-модели доступен в атрибуте data основного сериалайзера. При обращении к этому атрибуту задействуются следующие методы и атрибуты из-под капота DRF (разумеется, эти методы можно переопределить):


Метод, атрибут, функция Класс, модуль Действие
data serializes.BaseSerializer Запускает метод to_representation основного сериалайзера.
to_representation serializers.ListSerializer Запускает цикл, в ходе которого к каждой записи из набора применяется метод to_representation дочернего сериалайзера.
to_representation serializers.Serializer Сначала создаётся экземпляр упорядоченного словаря, пока он пустой. Далее запускается цикл по всем полям сериалайзера, у которых не выставлено write_only=True.
get_attribute fields (вызывается методом get_attribute класса fields.Field) Функция стыкует поле сериалайзера с полем записи из БД. По умолчанию идет поиск поля, чьё название совпадает с названием поля сериалайзера. Если передавался аргумент source, сопоставление будет идти со значением этого аргумента. Из найденного поля табличной записи извлекается значение текст, числа и т.д.
to_representation fields.КлассПоляКонкретногоТипа Извлечённое значение преобразуется согласно логике рассматриваемого метода. У каждого поля restframework она своя. Можно создать собственный класс поля и наделить его метод to_representation любой нужной логикой.

В словарь заносится пара ключ-значение:


  • ключ название поля сериалайзера;
  • значение данные, возвращённые методом to_representation поля сериалайзера.

Итог: список из OrderedDict в количестве, равном числу переданных и сериализованных записей из модели.




Надеюсь, статья оказалась полезной и позволила дать картину того, как под капотом DRF происходит сериализация данных из БД. Если у вас остались вопросы, задавайте их в комментариях разберёмся вместе.

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru