Кракс! Миллениалы изобрели Python фреймворк
Пролог
Здравствуй, Хабр! Данная статья посвящена разбору плюсов и минусов очередного Python фреймворка, который увидел свет около недели назад.
Итак, небольшое лирическое отступление. Во время всем известных событий, когда нас чуть-чуть самоизолировали, у нас появилось немножко больше свободного времени. Кто-то добрался до списка литературы, отложенной к прочтению, кто-то начал изучать ещё один иностранный язык, кто-то продолжал жать в дотан и не обратил внимание на перемены. Я же (простите, в этой статье будет очень много «Я», и мне немного стыдно) решился и попробовал сделать что-то полезное. Впрочем, о полезности можно поспорить. Очевидные вопросы, которые скорее всего возникнут у читателя в первую очередь: «Эм, Python framework? Ещё один? Простите, но зачем? Мы же не JavaScript, в конце концов!»
Собственно, именно об этом и пойдёт речь в данной статье: Нужно ли это? Если нужно, то кому? В чём отличие от того, что уже есть? Чем это может быть привлекательно и почему, например, это можно похоронить, не дожидаясь первого дня рождения. В статье не планируется много кода — примеры написания приложения и использования отдельных частей можно найти в документации (там кода значительно больше;)). Данная статья носит скорее обзорный характер.
Кому это нужно?
Несколько эгоистичный ответ на этот вопрос — в первую очередь, разумеется, мне самому. Я имею определённый опыт в построении веб приложений с использованием существующих фреймворков и регулярно ловлю себя на мысли: «Да, всё классно, но вот если бы тут было вот так…. А тут вот эдак… .». Большинство из нас так или иначе рано или поздно сталкиваются с тем, что некоторые вещи не нравятся и хотелось бы (а то и придётся) их изменить. Я попробовал собрать вместе то, что мне нравится из инструментов, которые я использовал. Я надеюсь, что я не одинок в своих предпочтениях, и что найдутся люди, которым эти идеи покажутся близкими. Главная идея Crax — это то, что он максимально не навязывает какой то определённый стиль разработки. Например, нам не нужны неймспейсы, мы не хотим делить логику на приложения, мы хотим быстро развернуть два роута и погонять реквесты и респонсы. Ок, в этом случае мы можем просто создать single file application и получить желаемое. Но возможна и обратная ситуация, и это тоже не будет проблемой. Второе, что пропагандирует Crax — это простота. Минимум кода и минимум чтения документации для старта. Если с фреймворком планирует работать человек, который только начинает изучать Python, он должен быть в состоянии безболезненно преодолеть порог вхождения.
Если посмотреть на количество строк кода, необходимых для прохождения всех тестов
TechEmpower (об этом ниже), то Crax в приложении, состоящем из одного файла, компактнее всех прочих участников, причём не было цели «ужать» этот файл. Просто больше действительно нечего писать. Резюмируя написанное выше, можно сказать, что Crax подойдёт для очень разного спектра задач и очень широкого спектра программистов разной степени подготовки.
Почему не использовать уже существующие инструменты?
А почему бы и нет? Если Вы точно знаете какой инструмент использовать, что наиболее подходит для Вашей текущей задачи, да ещё и Вы работали с этим инструментом и знаете все нюансы. Разумеется, Вы выберете то, что Вам известно и подходит. Нет (и не будет) цели позиционировать Crax как «Убийца %framework_name%». Не будет агитации типа: «Выкиньте срочно %framework_name%, перепишите всё на Crax и сразу же заметно увеличится член количество продаж». Ничего подобного. Просто можно отметить для себя, что у Вас в наборе инструментов неделю назад появился ещё один. Использовать его, или нет — Ваше дело. Однако, почему стоит попробовать.
Во-первых, он достаточно быстрый. Он написан с использованием интерфейса ASGI (читаем спецификацию тут) и он гораздо быстрее Flask или Django 1.*, 2.*. Но Crax, разумеется, не единственный Python framework, использующий ASGI, и предварительные тесты показывают, что он уверенно соревнуется с прочими фреймворками, использующими данную технологию. Для сравнения использовались тесты TechEmpower Performance Rating К сожалению, Crax как и прочие фреймворки, добавленные в середине текущего раунда, попадут только в следующий, и тогда можно будет наблюдать результаты в графической выдаче. Однако, после каждого пулл реквеста Travis прогоняет тесты и можно посмотреть сравнительную характеристику фреймворков в логе Travis. Ниже по ссылке длинная портянка лога Travis для Python фреймворков с названиями в алфавитном порядке от A до F Вот тут. Можно попробовать почитать лог и сравнить Crax, например, с apidaora, получится достаточно неплохо. Ниже на графике текущее положение вещей в Раунде 19 тестов.
Разумеется, реальные результаты и реальную выдачу мы сможем посмотреть только в следующем раунде, но тем не менее.
Однако, у нас, как говорилось выше, есть не менее быстрые и уже проверенные инструменты.
Такие же асинхронные, с нативной поддержкой вебсокетов и прочими радостями.
Допустим, Starlette или FastApi. Это совершенно потрясающие фреймворки с большим коммьюнити, которое заинтересованно в развитии этих продуктов. Стоит отметить, что Crax наиболее похож на Starlette или FastAPI по своей идеологии, а некоторые идеи были украдены подсмотрены в Starlette (например Response Middleware). Тем не менее, есть ряд моментов, которые могут понравиться в Crax, и заставить задуматься: «Может попробовать его для следующего проекта». Например файл конфигурации. Разумеется, у Starlette тоже есть возможность создания конфигурационного файла, но он несколько сложноват для начинающего, и в итоге его суть сводится к тому, что все переменные конфигурации в итоге всё равно передаются в инициализатор класса приложения. В случае, если собрать ВСЕ возможные переменные, например, настройку логгера, миддлвари, CORS и прочее, то получится многовато. В Crax все переменные объявляны в главном (конфигурационном) файле (на манер Django), и передавать их никуда не нужно. Более того, ко всем переменным, объявленным в конфигурационном файле, всегда есть возможность получить доступ в рантайме (как у работающего приложения, так и со стороны, привет Django).
from crax.utils import get_settings_variable
base_url = get_settings_variable('BASE_URL')
Казалось бы, сомнительное преимущество, однако, когда файл конфигурации начинает обрастать переменными и настройками, а к ним хотелось бы иметь доступ, это становится важным.
Следующая важная деталь, о которой хотелось бы поговорить — это организация структуры приложения. Когда у Вас маленький проект, всю логику которого можно поместить в один файл — это одно. Но когда Вы пишете что то более глобальное, Вы, возможно, хотели бы разделить представления, модели, описания роутов и прочее, согласно их логики. В этом контексте нам приходят в голову прекрасные Flask blueprints или Django applications. Crax рассуждает в этом смысле о неймспейсах. Изначально задумано, что Ваше приложение — это
набор python packages, которые подключены в основном файле проекта. Кстати, неймспейсы (ваши части приложения) могут быть рекурсивно вложенные (привет, Flask), а наименования файлов в них не имеют значения. Зачем так делать? И что нам это даёт?
Во-первых роутинг. Неймспейсы будут создавать uri, исходя из расположения неймспейса автоматически (но этим, разумеется, можно управлять). Например:
from crax.urls import Route, Url, include
url_list = [
Route(Url('/'), Home),
Route(Url('/guest_book'), guest_view_coroutine),
include('second_app.urls'),
include('second_app.nested.urls'),
include('third_app.urls')
]
Замените точки на слэши и Вы получите uri до Вашего неймспейса (естественно, добавив конечный хендлер). Раз уж мы упомянули роутинг, то остановимся на нём чуть подробнее.
Crax предлагает пару любопытных возможностей, помимо привычной работы с регулярными выражениями или работы via Django path.
# URL defined as regex with one floating (optional) parameter
Url(r"/cabinet/(?P\w{0,30})/(?:(?P\w+))?", type="re_path")
# General way to define URL
Url("/v1/customer///")
Однако, существует возможность привязать к одному хендлеру несколько Url.
from crax.urls import Route, Url
class APIView(TemplateView):
template = "index.html"
urls = [
Route(
urls=(
Url("/"),
Url("/v1/customers"),
Url("/v1/discounts"),
Url("/v1/cart"),
Url("/v1/customer/"),
Url("/v1/discount///"),
),
handler=APIView)
]
Вы сами можете придумать, где Вам это может быть полезно. А так же, есть режим работы резолвера в режиме «маскарадинга». Например, Вы желаете просто раздавать какую-то директорию с шаблонами, и не желаете ничего более. Возможно, это документация Sphinx, или что-то подобное. Вы всегда можете сделать так:
import os
from crax.urls import Url, Route
class Docs(TemplateView):
template = 'index.html'
scope = os.listdir('docs/templates')
URL_PATTERNS = [
Route(urls=(
Url('/documentation', masquerade=True),
handler=Docs),
]
Отлично, теперь все шаблоны, которые находятся в каталоге docs/templates, будут успешно отрендерены с использованием одного хендлера. Пытливый читатель скажет, что тут вообще не нужен никакой питон, и можно это всё сделать только силами условного Nginx. Абсолютно согласен, ровно до той поры пока не придётся, например, раздавать эти шаблоны по ролям или где то сбоку не потребуется дополнительная логика.
Однако, вернёмся к нашим баранам неймспейсам. Было бы очень грустно, если бы неймспейсы (пусть и вложенные) были бы нужны только для того чтобы организовать url resolving. Разумеется, назначение неймспейсов чуть шире. Например работа с моделями баз данных и миграциями.
В Crax нет ORM. И не предполагается. Во всяком случае до тех пор пока SQLAlchemy не предложит асинхронных решений. Тем не менее, работа с базами данных (Postgres, MySQL и SQLite) заявлена. Это значит, что есть возможность писать свои модели на основе Crax BaseTable. Под капотом — это очень тоненькая обёртка над SQLAlchemy Core Table, и умеет всё, что умеет Core Table. Для чего она может быть нужна. Возможно, чтобы делать что то похожее.
from crax.database.model import BaseTable
import sqlalchemy as sa
class BaseModelOne(BaseTable):
# This model just passes it's fields to the child
# Will not be created in database because the abstract is defined
parent_one = sa.Column(sa.String(length=50), nullable=False)
class Meta:
abstract = True
class BaseModelTwo(BaseTable):
# Also passes it's fields to the child
# Will be created in database
parent_two = sa.Column(sa.String(length=50), nullable=False)
class MyModel(BaseModelOne, BaseModelTwo):
name = sa.Column(sa.String(length=50), nullable=False)
print([y.name for x in MyModel.metadata.sorted_tables for y in x._columns])
# Let's check our fields ['name', 'id', 'parent_one', 'parent_two']
И для того чтобы иметь возможность работать с миграциями. Миграции Crax — это немного кода поверх SQLAlchemy Alembic. Раз уж мы говорим о неймспейсах и разделении логики, то,
очевидно, хотелось бы хранить миграции в том же пакете, что и прочая логика данного нэймспейса. Именно так и работают миграции Crax. Все миграции будут распределены согласно их неймспейса, а если в данном неймспейсе подразумевается работа с различными базами данных, то внутри каталога миграций будет разделение на каталоги соответствующих баз. Это же касается и миграций в оффлайн режиме — все *.sql файлы будут разделены согласно неймспейса и базы данных модели. Не буду здесь расписывать про составление запросов — это есть в документации, скажу только, что Вы по прежнему продолжаете работать с SQLAlchemy Core.
Опять же, неймспейсы подразумевают удобное хранение шаблонов (наследование и прочие Jinja2 возможности поддерживаются + пара приятностей в виде уже готовых CSRF токенов или генерации url). То есть, все Ваши шаблоны структурированы. Ну, конечно же, я не застрял в славном 2007 году, я понимаю, что шаблоны (пусть даже которые рендерятся асинхронно) будут мало востребованы в 2020-м. И что скорее всего Вы изволите разделить логику frontend и backend. Crax отлично справляется с этой задачей, результаты можно посмотреть на Github.
Здесь в качестве фронтенда использован VueJs. А раз у нас есть какой то API, вероятно мы захотим сделать интерактивную документацию. Crax умеет из коробки строить OpenAPI (Swagger) документацию, основанную на Ваших списках роутов и докстрингах Ваших хендлеров. Все примеры, разумеется, есть в документации.
Перед тем, как мы перейдём к наиболее интересной части нашего краткого обзора, стоит чуть-чуть рассказать о том, какие полезные батарейки уже поставляются вместе с Crax.
Естественно, режим отладки — это когда ошибку и полный трейс можно читать прямо в браузере, на странице, где случилось несчастье. Режим отладки можно отключить, и кастомизировать нескучными обоями своими хендлерами. Вывод уникального представления для каждого http status code. Делается это очень просто, впрочем, как и всё в Crax.
Встроенный логгер с возможностью одновременной записи в указанный файл и отправлением логов в консоль (или делать что-то одно). Возможность назначения собственного логгера вместо дефолтного. Поддержка Sentry при помощи добавления двух строк в конфиг (и, если нужно, настройка).
Два типа предустановленных миддлварей. Первый отрабатывается ДО того, как реквест будет обработан приложением, а второй ПОСЛЕ.
Встроенная поддержка CORS headers. Нужно только в конфиге объявить правила CORS.
Возможность определять доступные для каждого хендлера методы непосредственно на месте. Каждый хендлер будет работать со списком HTTP методов, который задан (+ HEAD и OPTIONS), либо только с GET, HEAD и OPTIONS.
Возможность указания, что данный хендлер доступен только для авторизованных пользователей либо только пользователей из группы администраторы, либо только для членов роли superuser.
Есть авторизация на HMAC подписанных сессиях, за которыми не нужно лезть в базу данных и ряд инструментов создания и управления пользователями. Можно включить поддержку бэкенда авторизации и получить предустановленного пользователя и ряд инструментов для работы. Впрочем, как и большинство инструментов Crax, это можно не включать, не использовать и писать своё. Можно не использовать авторизацию, базы данных, модели, миграции, представления и полностью писать свои кастомные решения. Для этого не нужно прилагать никаких усилий, Вы это не включили — этого нет.
Есть несколько типов Response и несколько типов Class Based хендлеров, которые помогут писать приложения быстрее и лаконичнее. При этом будут работать и Ваши собственные, которые не наследуются от встроенных.
from crax.views import BaseView
# Written your own stuff
class CustomView:
methods = ['GET', 'POST']
def __init__(self, request):
self.request = request
async def __call__(self, scope, receive, send):
if self.request.method == 'GET':
response = TextResponse(self.request, "Hello world")
await response(scope, receive, send)
elif self.request.method == 'POST':
response = JSONResponse(self.request, {"Hello": "world"})
await response(scope, receive, send)
# Crax based stuff
class CustomView(BaseView):
methods = ['GET', 'POST']
async def get(self):
response = TextResponse(self.request, "Hello world")
return response
async def post(self):
response = JSONResponse(self.request, {"Hello": "world"})
return response
class CustomersList(TemplateView):
template = 'second.html'
# No need return anything in case if it is TemplateView.
# Template will be rendered with params
async def get(self):
self.context['params'] = self.request.params
Поддержка CSRF protection. Генерация токенов, проверка наличия токена в теле запроса,
отключение проверки для конкретных хендлеров.
Поддержка ClickJacking protection (Политики отрисовки frame, iframe, embed…)
Поддержка проверки максимально допустимого размера body запроса ДО того, как приложение его начнёт обрабатывать.
Нативная поддержка вебсокетов. Давайте возьмём пример из документации, и напишем простенькое приложение, которое может отправлять вебсокет сообщения бродкастом, по группам пользователей или сообщения конкретному пользователю. Предположим, у нас есть гуппы «мальчишки» и «девочки» (есть возможность добавить группу «родители»). Мы можем написать для примера (разумеется, это не продуктовый код) что то похожее.
#app.py
import asyncio
import json
import os
from base64 import b64decode
from functools import reduce
from crax.auth import login
from crax.auth.authentication import create_session_signer
from crax.auth.models import Group, UserGroup
from crax.response_types import JSONResponse
from crax.urls import Route, Url
from crax.views import TemplateView, WsView
from sqlalchemy import and_, select
from websockets import ConnectionClosedOK
BASE_URL = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SECRET_KEY = "SuperSecret"
MIDDLEWARE = [
"crax.auth.middleware.AuthMiddleware",
"crax.auth.middleware.SessionMiddleware",
]
APPLICATIONS = ["ws_app"]
CLIENTS = {'boys': [], 'girls': []}
class Home(TemplateView):
template = "index.html"
login_required = True
class Login(TemplateView):
template = "login.html"
methods = ["GET", "POST"]
async def post(self):
credentials = json.loads(self.request.post)
try:
await login(self.request, **credentials)
if hasattr(self.request.user, "first_name"):
context = {'success': f"Welcome back, {self.request.user.username}"}
status_code = 200
else:
context = {'error': f"User or password wrong"}
status_code = 401
except Exception as e:
context = {'error': str(e)}
status_code = 500
response = JSONResponse(self.request, context)
response.status_code = status_code
return response
class WebSocketsHome(WsView):
def __init__(self, request):
super(WebSocketsHome, self).__init__(request)
self.group_name = None
async def on_connect(self, scope, receive, send):
# This coroutine will be called every time a client connects.
# So at this point we can do some useful things when we find a new connection.
await super(WebSocketsHome, self).on_connect(scope, receive, send)
if self.request.user.username:
cookies = self.request.cookies
# In our example, we want to check a group and store the user in the desired location.
query = select([Group.c.name]).where(
and_(UserGroup.c.user_id == self.request.user.pk, Group.c.id == UserGroup.c.group_id)
)
group = await Group.query.fetch_one(query=query)
self.group_name = group['name']
# We also want to get the username from the user's session key for future access via direct messaging
exists = any(x for x in CLIENTS[self.group_name] if cookies['session_id'] in list(x)[0])
signer, max_age, _, _ = create_session_signer()
session_cookie = b64decode(cookies['session_id'])
user = signer.unsign(session_cookie, max_age=max_age)
user = user.decode("utf-8")
username = user.split(":")[0]
val = {f"{cookies['session_id']}:{cookies['ws_secret']}:{username}": receive.__self__}
# Since we have all the information we need, we can save the user
# The key will be session: ws_cookie: username and the value will be an instance of uvicorn.WebSocketProtocol
if not exists:
CLIENTS[self.group_name].append(val)
else:
# We should clean up our storage to prevent existence of the same clients.
# For example due to page reloading
[
CLIENTS[self.group_name].remove(x) for x in
CLIENTS[self.group_name] if cookies['session_id'] in list(x)[0]
]
CLIENTS[self.group_name].append(val)
async def on_disconnect(self, scope, receive, send):
# This coroutine will be called every time a client disconnects.
# So at this point we can do some useful things when we find a client disconnects.
# We remove the client from the storage
cookies = self.request.cookies
if self.group_name:
try:
[
CLIENTS[self.group_name].remove(x) for x in
CLIENTS[self.group_name] if cookies['session_id'] in list(x)[0]
]
except ValueError:
pass
async def on_receive(self, scope, receive, send):
# This coroutine will be called every time we receive a new incoming websocket message.
# Check the type of message received and send a response according to the message type.
if "text" in self.kwargs:
message = json.loads(self.kwargs["text"])
message_text = message["text"]
clients = []
if message["type"] == 'BroadCast':
clients = reduce(lambda x, y: x + y, CLIENTS.values())
elif message["type"] == 'Group':
clients = CLIENTS[message['group']]
elif message["type"] == 'Direct':
username = message["user_name"]
client_list = reduce(lambda x, y: x + y, CLIENTS.values())
clients = [client for client in client_list if username.lower() in list(client)[0]]
for client in clients:
if isinstance(client, dict):
client = list(client.values())[0]
try:
await client.send(message_text)
except (ConnectionClosedOK, asyncio.streams.IncompleteReadError):
await client.close()
clients.remove(client)
URL_PATTERNS = [Route(Url("/"), Home), Route(Url("/", scheme="websocket"), WebSocketsHome), Route(Url("/login"), Login)]
DATABASES = {
"default": {
"driver": "sqlite",
"name": f"/{BASE_URL}/ws_crax.sqlite",
},
}
app = Crax('ws_app.app')
if __name__ == "__main__":
if sys.argv:
from_shell(sys.argv, app.settings)
Crax Websockets
Send Message
Crax Websockets
Login
Полный код можно посмотреть в документации Crax.
Ну и настало время самого интересного в этой статье.
Почему это не нужно?
Во-первых, как уже говорилось выше, существует несколько фреймворков, делающих то же самое, и обладающих уже сформировавшимся коммьюнити. В то время как Crax — это младенец, которому неделя от роду. Single man army — это почти гарантия того, что рано или поздно проект будет заброшен. Печально, но факт, что работать в стол, выпуская релизы и обновления только для себя и Василия из Сыктывкара — это значительно дольше, чем когда над проектом работает коммьюнити. Тем временем, в проекте нет ряда фич, которые маст хэв в 2020 году. Например: нет поддержки JWT (JOSE). Нет поддержки из коробки инструментов для работы с OAuth2. Нет поддержки GraphQL. Понятно, что это можно написать самому для своего проекта, но в Starlette или FastAPI это уже есть. Мне же только предстоит это писать (да, это есть в планах). О планах будет немного в заключении.
О FastAPI пишут разработчики Netflix и Microsoft. О Crax пишет нонейм, не известно откуда появившийся, и неизвестно куда способный в аккурат послезавтра пропасть.
Моим именем идиотским не назовут парохода
Моя мама ночами плачет, ведь она родила урода…
©
Это важно. Это называется — репутация и экосистема. У Crax нет ни того ни другого. Без этих важных вещей проект гарантировано отправится на свалку, так и не родившись.
Стоит понимать. То, что написано выше — это не попытка набрать классы и не текст бездомного в электричке. Это трезвая оценка и предостережение, что «продакшн реди солюшн» — это не только результаты покрытия тестами исходного кода, это общая оценка зрелости технологий, подхода и решений, использованных в проекте.
Если Вы только начинаете знакомство с Python и пробуете фреймворки, Вас подстерегает опасность: Скорее всего, ответов на вопрос Вы не найдёте на SO, возможно, Вам помогут более опытные товарищи, которых, к несчастью, может не оказаться.
The Goals
Первое, что я планирую сделать — это, конечно же, дописать маст хэв вещи, такие как поддержку JWT (JOSE), OAuth2 и GraphQL. Это то, что позволит работать мне и заинтересованным людям проще. А это, собственно, главная цель Crax — сделать кому-то работу чуть проще. Возможно, к тому времени начнётся новый раунд на TechEmpower и бенчмарки станут более очевидными. Возможно даже, что после этого появится определённый интерес у сообщества.
Существует идея на базе Crax написать CMS. Если я не ошибаюсь (если ошибаюсь — поправьте),
у нас пока нет в инструментарии ни одной асинхронной CMS на питоне. Я могу передумать и решить написать какое то e-commerce решение. Но, очевидно, что для того чтобы Crax не утонул, не доплыв до буйков, нужно сделать на его базе что-то интересное. Возможно, этим заинтересуются энтузиасты. Энтузиасты — это когда бесплатно. Потому что денег тут нет и скорее всего не будет. Crax — это совершенно бесплатно для всех и я за эту работу не получил ни цента. Таким образом, разработка планируется «долгими зимними вечерами» и, возможно, в грядущем году что-то интересное появится на свет.
Заключение
Я размышлял о том, в какую группу отнести данную статью (это, кстати, моя первая публикация на ресурс). Может даже стоило это разместить под тегом «Я пиарюсь». Что заставило передумать: в первую очередь то, что это не имеет рекламного характера чего бы то ни было.
Здесь нет призыва «Мальчики, срочно записываемся на пулл реквестики». Здесь нет идеи найти спонсора. Здесь даже нет идеи о том, что я принёс Вам то, чего Вы никогда не видели (естественно, видели). Можно абстрагироваться от мысли, что я автор и статьи и этого инструмента, и воспринимать написанное как обзор. И, да, так будет лучше всего. Для меня будет прекрасным результатом если Вы просто будете иметь в виду, что это есть.
На этом, пожалуй, всё.
— Так… пора сматывать удочки.
— Почему?
— Красная кепка Харриса распугала всю рыбу.
©
Код на GitHub
Документация