- PVSM.RU - https://www.pvsm.ru -

Как я сделал веб-фреймворк без MVC — Pipe Framework

Проработав фулстек разработчиком около 10 лет, я заметил одну странность.
Я ни разу не встретил не MVC веб-фреймворк. Да, периодически встречались вариации, однако общая структура всегда сохранялась:

  • Codeigniter — мой первый фреймворк, MVC
  • Kohana — MVC
  • Laravel — MVC
  • Django — создатели слегка подменили термины, назвав контроллер View, а View Template'ом, но суть не изменилась
  • Flask — микрофреймворк, по итогу все равно приходящий к MVC паттерну

Конечно, с моим мнением можно поспорить, можно продолжить перечислять, однако суть не в этом.

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

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

  1. REST (порой GraphQL или другие варианты) бэкенд, выполняющий роль провайдера данных.
  2. Frontend, написаный на каком-либо из фреймворков большой тройки.

Задачи, которые сейчас стоят перед бэкендом (если сильно упростить) — это взять данные из базы, преобразовать в 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.

Первое, на что необходимо обратить внимание, это три типа шагов:

  • Extractor
  • Transformer
  • Loader

Как определиться с тем, какой тип использовать?

  1. Если вам надо извлечь данные из внешнего ресурса: extractor.
  2. Если вам надо передать данные за пределы фреймворка: loader.
  3. Если вам надо внести изменения в данные: transformer.

Именно поэтому я ассоциирую 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. Извлекаем данные из базы
  2. Преобразовываем данные в JSON
  3. Отправляем данные в браузер посредством HTTP.

Итак, нам нужен будет 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 [1].
Изменить его нельзя, но можно создать новый инстанс используя frozendict().copy() метод.

Валидация

Мы помним, что шаги являются самостоятельными единицами функционала, но иногда они могут требовать наличия определенных данных в сторе для выполнения каких-либо операций (например id пользователя из URL). В этом случае, используйте поле required_fields в конфигурации шага.

PF использует Valideer [2] для валидации. На данный момент, я рассматриваю альтернативы, однако в случае смены библиотеки принцип останется тот же.

Пример

Все, что нам надо сделать — это написать dict с необходимыми полями в теле шага (здесь вы найдете больше информации о доступных валидаторах: Valideer [2]).

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, я ничего от него не ожидал, однако в ходе работы я смог выделить довольно большое количество преимуществ такого подхода:

  1. Принудительная декомпозиция: разработчик вынужден разделять задачу на атомарные шаги. Это приводит к тому, что сначала надо подумать, а потом делать, что всегда лучше, чем наоборот.
  2. Абстрактность: фреймворк подразумевает написание шагов, которые можно применить в нескольких местах, что позволяет уменьшить количество кода.
  3. Прозрачность: любая, пусть даже и сложная логика, спрятанная в шагах, призвана выполнять понятные для любого человека задачи. Таким образом, гораздо проще объяснить даже нетехническому персоналу о том, что происходит внутри через преобразование данных.
  4. Самотестируемость: даже без написаных юнит тестов, фреймворк подскажет вам что именно и в каком месте сломалось за счет валидации шагов.
  5. Юнит-тестирование осуществляется гораздо проще, нужно только задать начальные данные для шага или пайпа и проверить, что получается на выходе.
  6. Разработка в команде тоже становится более гибкой. Декомпозировав задачу, можно легко распределить различные шаги между разработчиками, что практически невозможно сделать при традиционном подходе.
  7. Постановка задачи сводится к предоставлению начального набора данных и демонстрации необходимого набора данных на выходе.

Фреймворк на данный момент находится в альфа-тестировании, и я рекомендую экспериментировать с ним, предварительно склонировав с Github репозитория [3]. Установка через pip так же доступна

pip install pipe-framework


Планы по развитию:

  1. Django Pipe: специальный тип Pipe, который можно использовать как Django View.
  2. Смена Orator ORM на SQL Alchemy для Database Generics (Orator ORM — библиотека с приятным синтаксисом, но слабой поддержкой, парой багов, и недостаточным функционалом в стабильной версии).
  3. Асинхронность.
  4. Улучшеный Inspection Mode.
  5. Pipe Builder — специальный веб-дашбоард, в котором можно составлять пайпы посредством визуальных инструментов.
  6. Функциональные шаги — на данный момент шаги можно писать только в ООП стиле, в дальнейшем планируется добавить возможность использовать обычные функции

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

Хорошего дня!

Автор: Рома Латышенко

Источник [4]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/python/361871

Ссылки в тексте:

[1] frozendict: https://pypi.org/project/frozendict/

[2] Valideer: https://github.com/podio/valideer

[3] Github репозитория: https://github.com/jellyfish-tech/pipe-framework

[4] Источник: https://habr.com/ru/post/543670/?utm_source=habrahabr&utm_medium=rss&utm_campaign=543670