Как я сделал веб-фреймворк без MVC — Pipe Framework
- среда, 24 февраля 2021 г. в 00:35:17
Проработав фулстек разработчиком около 10 лет, я заметил одну странность.
Я ни разу не встретил не MVC веб-фреймворк. Да, периодически встречались вариации, однако общая структура всегда сохранялась:
Конечно, с моим мнением можно поспорить, можно продолжить перечислять, однако суть не в этом.
Меня беспокоило то, что за все время существования веб-разработки, MVC является, по сути, монополистом в проектировании приложений. Я не говорю что это плохо,
просто это казалось мне странным.
Другая вещь, которая меня беспокоила — упрощение и однообразие задач, которые стоят перед бэкендом. На данный момент, большинство приложений
включают в себя две части:
Задачи, которые сейчас стоят перед бэкендом (если сильно упростить) — это взять данные из базы, преобразовать в JSON (возможно дополнительно преобразовав структуру) и отправить в браузер.
В ходе этих размышлений, мой взгляд упал на ETL паттерн, и в определенный момент я понял, что он идеально подходит для всех задач, которые на данный момент стоят перед бэкендом.
Осознав это, я решил провести эксперимент, и результатом этого эксперимента стал Pipe Framework.
В Pipe Framework (далее PF) нет понятий модель-представление-контроллер, но я буду использовать их для демонстрации его принципов.
Весь функционал PF строится с помощью "шагов" (далее Step).
Step — это самодостаточная и изолированная единица, призванная выполнять только одну функцию, подчиняясь принципу единственной ответственности (single responsibility principle).
Более детально объясню на примере. Представим, у вас есть простая задача создать API ендпоинт для todo приложения.
При традиционном подходе, вам необходимо создать Todo модель, которая представляет собой таблицу в базе данных.
В контроллере, привязанном к роуту, вы будете использовать экземпляр модели, чтобы извлечь данные о todo тасках, трансформировать их в https ответ, и отправить пользователю.
Я выделил извлечь и трансформировать чтобы вы могли ассоциировать MVC концепты с концептами, которые я использую в PF.
То есть, мы можем провести аналогию между MVC (Модель-Представление-Контроллер) и ETL (Извлечение-Преобразование-Загрузка):
Model — Extractor / Loader
Controller — Transformer
View — Loader
Эта довольно приблизительная аналогия, однако она показывает как части одного и другого подхода связаны друг с другом.
Как видите, я обозначил View как Loader. Позже станет понятно, почему я так поступил.
Давайте выполним поставленную задачу используя PF.
Первое, на что необходимо обратить внимание, это три типа шагов:
Как определиться с тем, какой тип использовать?
Именно поэтому я ассоциирую View с Loader'ом в примере выше. Вы можете воспринимать это как загрузку данных в браузер пользователя.
Любой шаг должен наследоваться от класса Step
, но в зависимости от назначения реализовывать разные методы:
class ESomething(Step):
def extract(self, store):
...
class TSomething(Step):
def transform(self, store):
...
class LSomething(Step):
def load(self, store):
...
Как вы можете заметить, названия шагов начинаются с заглавных E, T, L.
В PF вы работаете с экстракторами, трансформерами, и лоадерами, названия которых слишком длинные, если использовать их как в примере:
class ExtractTodoFromDatabase(Extractor):
pass
Именно поэтому, я сокращаю названия типа операции до первой буквы:
class ETodoFromDatabase(Extractor):
pass
E
значит экстрактор, T
— трансформер, и L
— лоадер.
Однако, это просто договоренность и никаких ограничений со стороны фреймворка нет, так что можете использовать те имена, которые захотите :)
Для того что бы выполнить задачу, прежде всего нам нужно декомпозировать функционал на более мелкие операции:
Итак, нам нужен будет 1 экстратор, 1 трансформер, и 1 лоадер.
К счастью, в PF есть набор предопределенных шагов, и они полностью покрывают описаные выше операции. Но, тем не менее, нам все-таки придется создать экстрактор, потому что нужно будет прописать данные доступа к базе данных.
Так как шаг является независимой частью приложения, которая отвечает лишь за одну задачу и не осведомлена обо всей остальной системе, его легко переносить из одного пайпа в другой, из приложения в приложение и т. д.
Недостаток такого решения: отсутствие центрального хранилища конфигурации. Все конфиги, относящиеся к определенному шагу, должны храниться в свойствах класса шага. Порой, это значит то, что нам необходимо писать один и тот же конфиг каждый раз при работе с шагами с одинаковой конфигурацией.
Для этих целей, в PF предусмотрен @configure
декоратор. То есть, вы просто перечисляете настройки, которые хотите добавить в шаг, следующим образом:
DATABASES = {
'default': {
'driver': 'postgres',
'host': 'localhost',
'database': 'todolist',
'user': 'user',
'password': '',
'prefix': ''
}
}
DB_STEP_CONFIG = {
'connection_config': DATABASES
}
и потом передаете как аргумент декоратору, примененному к классу:
@configure(DB_STEP_CONFIG)
class EDatabase(EDBReadBase):
pass
Итак, давайте создадим корневую папку проекта:
pipe-sample/
Затем папку src
внутри pipe-sample
:
pipe-sample/
src/
Все шаги, связанные с базой данных, будут находится в db пакете, давайте создадим и его тоже:
pipe-sample/
src/
db/
__init__.py
Создайте config.py
файл с настройками для базы данных:
pipe-sample/src/db/config.py
DATABASES = {
'default': {
'driver': 'postgres',
'host': 'localhost',
'database': 'todolist',
'user': 'user',
'password': '',
'prefix': ''
}
}
DB_STEP_CONFIG = {
'connection_config': DATABASES
}
Затем, extract.py
файл для сохранения нашего экстрактора и его конфигурации:
pipe-sample/src/db/extract.py
from src.db.config import DB_STEP_CONFIG # наша конфигурация
"""
PF включает в себя несколько дженериков для базы данных,которые вы можете посмотреть в API документации
"""
from pipe.generics.db.orator_orm.extract import EDBReadBase
@configure(DB_STEP_CONFIG) # применяем конфигурацию к шагу
class EDatabase(EDBReadBase):
pass
# нам не надо ничего добавлять внутри класса
# вся логика уже имплементирована внутри EDBReadBase
Создание целой структуры папок для решения всего одной задачи может быть избыточным, но я сделал это чтобы показать предпочтительную структуру для других проектов.
Теперь мы готовы к созданию первого пайпа.
Добавьте app.py
в корневую папку проекта. Затем скопируйте туда этот код:
pipe-sample/app.py
from pipe.server import HTTPPipe, app
from src.db.extract import EDatabase
from pipe.server.http.load import LJsonResponse
from pipe.server.http.transform import TJsonResponseReady
@app.route('/todo/') # декоратор сообщает WSGI приложению, что этот пайп обслуживает данный маршрут
class TodoResource(HTTPPipe):
"""
мы расширяем HTTPPipe класс, который предоставляет возможность описывать схему пайпа с учетом типа HTTP запроса
"""
"""
pipe_schema это словарь с саб пайпами для каждого HTTP метода.
'in' и 'out' это направление внутри пайпа, когда пайп обрабатывает запрос,
он сначала проходит через 'in' и затем через 'out' пайпа.
В этом случае, нам ничего не надо обрабатывать перед получением ответа,
поэтому опишем только 'out'.
"""
pipe_schema = {
'GET': {
'out': (
# в фреймворке нет каких либо ограничений на порядок шагов
# это может быть ETL, TEL, LLTEETL, как того требует задача
# в этом примере просто так совпало
EDatabase(table_name='todo-items'),
TJsonResponseReady(data_field='todo-items_list'), # при извлечении данных EDatabase всегда кладет результат запроса в поле {TABLE}_item для одного результата и {TABLE}_list для нескольких
LJsonResponse()
)
}
}
"""
Пайп фреймворк использует Werkzeug в качестве WSGI-сервера, так что аргументы должны быть знакомы тем кто работал, например, с Flask. Выделяется только 'use_inspection'.
Inspection - это режим дебаггинга вашего пайпа.
Если установить параметр в True до начала воспроизведения шага, фреймворк будет выводить название текущего шага и содержимое стор на этом этапе.
"""
if __name__ == '__main__':
app.run(host='127.0.0.1', port=8080,
use_debugger=True,
use_reloader=True,
use_inspection=True
)
Теперь можно выполнить $ python app.py
и перейти на http://localhost:8000/todo/
.
Из примера выше довольно сложно понять как выглядит реализация шага, поэтому ниже я приведу пример из исходников:
class EQueryStringData(Step):
"""
Generic extractor for data from query string which you can find after ? sign in URL
"""
required_fields = {'+{request_field}': valideer.Type(PipeRequest)}
request_field = 'request'
def extract(self, store: frozendict):
request = store.get(self.request_field)
store = store.copy(**request.args)
return store
На данный момент, стор в PF — это инстанс frozendict.
Изменить его нельзя, но можно создать новый инстанс используя frozendict().copy()
метод.
Мы помним, что шаги являются самостоятельными единицами функционала, но иногда они могут требовать наличия определенных данных в сторе для выполнения каких-либо операций (например id пользователя из URL). В этом случае, используйте поле required_fields
в конфигурации шага.
PF использует Valideer для валидации. На данный момент, я рассматриваю альтернативы, однако в случае смены библиотеки принцип останется тот же.
Все, что нам надо сделать — это написать dict с необходимыми полями в теле шага (здесь вы найдете больше информации о доступных валидаторах: Valideer).
class PrettyImportantTransformer(Step):
required_fields = {'+some_field': valideer.Type(dict)} # `+` значит обязательное поле
Иногда, в шаге у вас может быть переменная, которая хранит название ключа в сторе, по которому можно найти необходимую информацию.
Вы не можете узнать, как именно называется это поле, но знаете как называется переменная в шаге, которая хранит эти данные.
Если вы хотите валидировать и эти поля, необходимо добавить фигурные скобки с названием переменной класса:
class EUser(Step):
pk_field = 'id' # EUser будет обращаться к полю 'id' в сторе
required_fields = {'+{pk_field}': valideer.Type(dict)} # все остальное так же
Пайп фреймворк заменит это поле на значение pk_field
автоматически, и затем валидирует его.
Вы можете объединить два или более шага в случае, если вам необходимо контролировать порядок выполнения.
В этом примере я использую оператор |
(OR)
pipe_schema = {
'GET': {
'out': (
# В случае если EDatabase бросает любое исключение
# выполнится LNotFound, которому в сторе передастся информация об исключении
EDatabase(table_name='todo-items') | LNotFound(),
TJsonResponseReady(data_field='todo-items_item'),
LJsonResponse()
)
},
Так же есть оператор &
(AND)
pipe_schema = {
'GET': {
'out': (
# В этом случае оба шага должны выполниться успешно, иначе стор без изменений перейдет к следующему шагу
EDatabase(table_name='todo-items') & SomethingImportantAsWell(),
TJsonResponseReady(data_field='todo-items_item'),
LJsonResponse()
)
},
Чтобы выполнить какие-либо операции до начала выполнения пайпа, можно переопределить метод: before_pipe
class PipeIsAFunnyWord(HTTPPipe):
def before_pipe(self, store): # в аргументы передается initial store. В случае HTTPPipe там будет только объект PipeRequest
pass
Также есть хук after_pipe
и я думаю нет смысла объяснять, для чего он нужен.
interrupt
это последний из доступных хуков, должен возвращать bool
. Вызывается после каждого шага, в качестве аргумента получая текущий стор. В случае, если метод возвращает True, выполнение пайпа заканчивается и он возвращает стор в текущем его состоянии.
Пример использования из исходников фреймворка:
class HTTPPipe(BasePipe):
"""Pipe structure for the `server` package."""
def interrupt(self, store) -> bool:
# If some step returned response, we should interrupt `pipe` execution
return issubclass(store.__class__, PipeResponse) or isinstance(store, PipeResponse)
Разрабатывая Pipe Framework, я ничего от него не ожидал, однако в ходе работы я смог выделить довольно большое количество преимуществ такого подхода:
Фреймворк на данный момент находится в альфа-тестировании, и я рекомендую экспериментировать с ним, предварительно склонировав с Github репозитория. Установка через pip
так же доступна
pip install pipe-framework
Планы по развитию:
В целом, планируется двигать фреймворк в сторону упрощения, без потери функциональности. Буду рад вопросам и контрибьюшнам.
Хорошего дня!