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


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

    Фреймворк на данный момент находится в альфа-тестировании, и я рекомендую экспериментировать с ним, предварительно склонировав с Github репозитория. Установка через 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. Функциональные шаги — на данный момент шаги можно писать только в ООП стиле, в дальнейшем планируется добавить возможность использовать обычные функции

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


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

    Комментарии 15

      –8
      Как видите, я обозначил View как Loader. Позже станет понятно, почему я так поступил.

      MVC это паттерн проектирования и именно поэтому его стараются реализовывать во фреймворках. Чтобы проектировать скрывая реализацию и используя TDD. А так пожалуйста кодьте без mvc, но обозначать View as Loader это менять паттерн. Вопрос зачем? Pipe это хорошо даже без Builder. Но, а где Visitor или Walker. Это в фреймворках 20+ должно быть в коробочном решении.
      Посмотрите в сторону $mol и не изобретайте 3-х колесный велосипед https://habr.com/ru/search/?q=%24mol#h

        0
        «Чтобы проектировать скрывая реализацию и используя TDD» — это откуда такая информация?
        0

        Паттерн MVC это все ж паттерн не только организации кода, но и паттерн разделения логики (логики хранения, логики обработки и логики представления). Именно этот паттерн позволяет искать нужный код в предсказуемых местах. Как предлагается разделять эту логику в вашем паттерне? Или вы сторонник, что логику разделять не надо?

          0

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

          +3
          Поменяйте ETL на что-нибудь другое. Люди из мира хранилищ данных вас не поймут, все таки это совсем совсем другое.
            +2

            А что, если Model в MVC — это не про объект, представляющий таблицу в БД, а про модель бизнес-логики?

              0

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


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

              0
              А где должна находиться бизнес-логика?
                0

                Чем бизнес логика отличается от "просто" логики? Конечно в модели. Вам ничего не мешает использовать паттерн проектирования MVC, MVP,MVCS и т.д. не только в архитектуре движка, но в архитектуре приложения.


                Класс или функция это тоже объект. Их можно убрать под интерфейс. Какие проблемы.


                «Чтобы проектировать скрывая реализацию и используя TDD» — это откуда такая информация?


                dimuska139 tdd начинается с описания и использования интерфейсов. Всё что вы делаете до этого не tdd, а имплементация интерфейса.

                  0
                  tdd начинается с описания и использования интерфейсов

                  Можно, пожалуйста, ссылку на источник?

                    +1
                    Я думал, что TDD начинается с написания тестов. А вообще не очень понял, почему вы именно ко мне обратились. Если вы отвечаете на мой комментарий другому человеку, то я с ним не согласен, в частности, из-за того, что понятие TDD появилось сильно позже, чем шаблон проектирования MVC. То есть утверждение о том, что MVC используется для того, чтобы проектировать с использованием TDD, какое-то странное, учитывая порядок появления этих понятий.
                  0
                  Я ни разу не встретил не MVC веб-фреймворк.

                  Хм, а тот же Angular это разве не MVVM? Ну или как минимум разве он не позволяет использовать MVVM паттерн?

                    0

                    Я говорил скорее про серверные фреймворки.
                    Ну и на самом деле MVVM — это по большей части вариация на тему, чем абсолютно другой подход к проектированию.

                      –1

                      Хм. Во первых что значит "серверный фреймворк"? И что у вас на сервере является View? И зачем оно там нужно?


                      А во вторых нет, MVVM это не "вариация на тему" MVC. Это совсем другой подход.

                    0
                    Не mvc фреймворков полно. Как по мне этот паттерн вообще неудобен для веба. Но большинство разрабов пришло когда php zend уже впарил этот паттерн и других вариантов не представляют.
                    У меня например компонентный событйно ориентироааный фреймворк на php который я в свое время портировал с явовского wicket.
                    По такой же примерно архитектуре cтроился webforms.

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

                    Самое читаемое