Привет, Хабрахабр! По какой-то причине, последнее время никого не удивляет expressjs под капотом каждого второго фреймворка на node.js, но действительно ли он нужен там? Я не говорю про то, что expressjs — это плохо, нет, он справляется со своими задачами, но когда мне понадобился роутинг сложнее чем может дать этот фреймворк, я задумался, а что есть еще в expressjs чтобы его оставить в проекте? К сожалению, кроме webserver в нем нет ничего, интеграция с шаблонизатарами — это мелочь, да и middleware сводятся к простому набору функций, кучи callback hell.

Если открыть доку по node.js и мельком посмотреть на то количество модулей, которые есть в ядре, — можно открыть много нового для себя. Как вы уже догадались, речь пойдет про очередной велосипед.

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

Зависимости, которые я все же оставил в проекте:

async, hashids, mime-types, sequelize, validator, pug

1) давайте определимся со структурой проекта:

Структура фреймворка
— dashboard — основной модуль проекта
— bin файлы для старта приложения
— config конфиги нашего приложения
— migrations миграции
— modules модули
— views основные view

Структура проекта
— base Базовые классы
— behaviors первичные бихеверы, которые могут понадобиться в 90% проектов
— console классы, которые нужны для старта приложения в консольном режиме
— helpers папка с различными хелперами
— modules модули, которые нужны в 90% проектов (миграции, рендер статики)
— web классы, нужные для работы в режиме web-приложения

2) Как запустить web приложение:

Создадим файл bin/server.js

Файл bin/server.js
import Application from "dok-js/dist/web/Application";
import path from "path";

const app = new Application({
  basePath: path.join(__dirname, ".."),
  id: "server"
});
app.run();

export default app;


После чего наше приложение будет пытаться загрузить конфинг из ./config/server.js

./config/server.js
import path from "path";
export default function () {
  return {
    default: {
      basePath: path.join(__dirname, ".."),
      services: {
        Database: {
          options: {
            instances: {
              db: {
                database: "example",
                username: "example",
                password: "example",
                params: {
                  host: "localhost",
                  dialect: "postgres"
                }
              }
            }
          }
        },
        Server: {
          options: {
            port: 1987
          }
        },
        Router: {
          options: {
            routes: {
              "/": {
                module: "dashboard",
                controller: "index",
                action: "index"
              },
              "/login": {
                module: "identity",
                controller: "identity",
                action: "index"
              },
              "/logout": {
                module: "identity",
                controller: "identity",
                action: "logout"
              },
              "GET /assets/<filePath:.*>": {
                module: "static",
                controller: "static",
                action: "index",
                params: {
                  viewPath: path.join(__dirname, "..", "views", "assets")
                }
              },
              "/<module:\w+>/<controller:\w+>/<action:\w+>": {}
            }
          }
        }
      },
      modules: {
        identity: {
          path: path.join(__dirname, "..", "modules", "identity", "IdentityModule")
        },
        dashboard: {
          path: path.join(__dirname, "..", "modules", "dashboard", "DashboardModule")
        }
      }
    }
  };
}


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

Теперь боль номер два: контроллеры и экшены, которые нам навязывает expressjs и большинство nodejs-фреймворков. Это как правило анонимная функция (я понимаю, что это нужно для производительности), которая на вход получает request и response и делает с ними все что угодно, т.е. если вам нужно будет в середине проекта воткнуть логгер, к примеру, для логирования всех респонзов, будь добр прорефакторить почти все приложение, и не дай бог пропустить вызов колбека который делает next(request, response), это я к тому, что никогда не знаешь в какой момент времени твой экшен закончил свое выполнение.

Решение, которое я предлагаю:

base/Request.js
async run(ctx) {
    this.constructor.parse(ctx);
    try {
      ctx.route = App().getService("Router").getRoute(ctx.method, ctx.url);
    } catch (e) {
      return App().getService("ErrorHandler").handle(404, e.message);
    }

    try {
      return App().getModule(ctx.route.moduleName).runAction(ctx);
    } catch (e) {
      return App().getService("ErrorHandler").handle(500, e.message);
    }
  }


base/Module.js
async runAction(ctx) {
    const {controllerName, actionName} = ctx.route;
    const controller = this.createController(controllerName);
    if (!controller[actionName]) {
      throw new Error(`Action "${actionName}" in controller "${controllerName}" not found`);
    }

    const result = await this.runBehaviors(ctx, controller);
    if (result) {
      return result;
    }
    return controller[actionName](ctx);
  }


Т.е. мы получили единую точку запуска всех контролерров.

Ну и сам контроллер:

modules/dashboard/controllers/IndexController.js
import Controller from "dok-js/dist/web/Controller";
import AccessControl from "dok-js/dist/behaviors/AccessControl";
export default class IndexController extends Controller {

  getBehaviors() {
    return [{
      behavior: AccessControl,
      options: [{
        actions: ["index"],
        roles: ["user"]
      }]
    }];
  }

  indexAction() {
    return this.render("index");
  }
}


modules/identity/controllers/IdentityController.js
import Controller from "dok-js/dist/web/Controller";
import SignInForm from "../data-models/SignInForm";

export default class IdentityController extends Controller {

  async indexAction(ctx) {
    const data = {};
    data.meta = {
      title: "Авторизация"
    };

    if (ctx.method === "POST") {
      const signInForm = new SignInForm();
      signInForm.load(ctx.body);
      const $user = await signInForm.login(ctx);
      if ($user) {
        return this.redirectTo("/", 301);
      }
      data.signInForm = signInForm;
    }

    return this.render("sign-in", data);
  }

  logoutAction(ctx) {
    ctx.session.clearSession();
    return this.redirectTo("/", 302);
  }

}


Так же сразу скажу, что конструктор контроллера вызывается 1 раз и затем складывается в кеш.

Сам фреймворк еще сыроват, но на него можно посмотреть на гитхабе:

github.com/kalyuk/dok-js

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

github.com/kalyuk/dok-js-example
Поделиться с друзьями
-->

Комментарии (28)


  1. webmasterx
    30.04.2017 12:34
    +1

    Попахивает подтверждением фразы «На javascript каждый должен создать свой фреймворк»
    И зачем вы его писали, если уже знакомы с PHP?
    Чем-то похоже на Yii, но кое-что почему-то переименовано


    1. kalyukdo
      30.04.2017 13:03

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

      >> Чем-то похоже на Yii, но кое-что почему-то переименовано
      С этого фреймворка я брал удобные для меня конструкции и подходы


      1. redfs
        30.04.2017 15:08

        >> Чем-то похоже на Yii, но кое-что почему-то переименовано
        С этого фреймворка я брал удобные для меня конструкции и подходы

        Вы точно не первый :) Jii чем-то не подошёл?


        1. kalyukdo
          30.04.2017 15:22

          Да, я видел, но там автор полностью переносит фреймворк, в yii2 у меня был затык в AR, с вложенными атрибутами (JSONB), для решения этой задачи я использую sequelizejs,
          ну и как было уже сказано:

          «На javascript каждый должен создать свой фреймворк»
          не удержался


  1. amokrushin
    30.04.2017 14:16
    +4

    контроллеры и экшены, которые нам навязывает expressjs

    нет в expressjs никаких контроллеров и экшенов, есть только middleware

    Насчет гибкости роутера, как в этом фреймворке можно исключить парсинг кук и работу с сессией например для маршрута «GET /assets/<filePath:.*>»? В expressjs это тривиальная задача.

    т.е. если вам нужно будет в середине проекта воткнуть логгер

    Не понятно в чем проблема:
    router.get('/user/:id',user.getById)
    

    router.get('/user/:id', [loggerBefore, user.getById, loggerAfter])
    


    1. kalyukdo
      30.04.2017 14:28

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

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


      1. amokrushin
        30.04.2017 15:54

        Вопрос не в том зачем отключать, а в гибкости. Не нравится пример с куками, есть аналогичный по сути пример с cors. Допустим возникла необходимость на конкретный маршрут API, либо на определенный неймспейс отдавать заголовок «Access-Control-Allow-Origin: *», в случае с expressjs вставляем middleware перед любым нужным маршрутом. Если нужно что-то по сложнее чем *, то берем готовый middleware из npm, а в вашем случае, на каждую типовую задачу придется писать свой велосипед.


        1. kalyukdo
          30.04.2017 16:01

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

          Ваше замечание справедливо.

          как в этом фреймворке можно исключить парсинг кук и работу с сессией

          и подумать действительно стоит в эту сторону


      1. f0rk
        01.05.2017 08:52

        Когда-то давно писал middleware для express примерно с таким api:


        const group = require('group-middleware');
        
        group(function () {
          router.get('/user/:id',user.getById);
          router.get('...');
        })
          .before(auth);
          .after(resultFormatter);

        Решает проблему копипасты?


        1. kalyukdo
          01.05.2017 11:49

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


  1. jankovsky
    01.05.2017 07:09
    -7

    чем не понравился react?


    1. kalyukdo
      01.05.2017 07:10
      +2

      причем тут реакт? реакт это клиент, а тут речь идет про серверный фреймворк


      1. jankovsky
        01.05.2017 07:12
        -10

        Подучите матчасть господин. React уже и на сервере рендерится.


        1. kalyukdo
          01.05.2017 07:18
          +3

          и еще пади запросы делает в базу? да? и рест апи выдает?


        1. webmasterx
          01.05.2017 07:19
          +2

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


          1. jankovsky
            01.05.2017 07:24
            -10

            Вы хотя бы вникните в суть. На ваш комментарий подсказываю шепотом "Redux".


            1. kalyukdo
              01.05.2017 07:27
              +3

              откуда же вы будете данные получать чтобы в редукс положить?


              1. jankovsky
                01.05.2017 07:29
                -10

                вы даже про nodejs не слышали?


                1. webmasterx
                  01.05.2017 07:54
                  +3

                  Вы похоже ни разу бэкенд не делали


                  1. jankovsky
                    01.05.2017 07:59
                    -10

                    я хотя бы понимаю о чем говорю, любезнейший


                    1. f0rk
                      01.05.2017 08:54
                      +3

                      хм… а мне вот кажется, что нет.


              1. jankovsky
                01.05.2017 07:46
                -10

                "Редукс". Хотя бы правильно произносить научитесь прежде чем вразумлять население. )


  1. zxcabs
    01.05.2017 11:05

    Боже, зачем так делать? https://github.com/kalyuk/dok-js/blob/2d283f8a967f36a569debdf6d2ef4898de632bb5/src/base/Application.js#L31

     constructor(config) {
        super(config);
        global.$App = this;
        this.$cache.modules = {};
      }
    
    


    1. kalyukdo
      01.05.2017 11:32

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


    1. jankovsky
      01.05.2017 23:51
      -2

      Еще и в конструкторе вызывать конструктор через super() вообще не комильфо.


      1. kalyukdo
        02.05.2017 05:28
        +1

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

        я хотя бы понимаю о чем говорю, любезнейший

        сотрясаете воздух без живых примеров и обоснований?


  1. babylon
    03.05.2017 13:02

    @kalyukdo если это серверный фреймворк, то в ES6 есть setPrototypeOf c помошью которого можно "плагинизировать" инстансы, заменяя наследование композицией. Всё остальное немногое убрать в ядро. Ну я бы так сделал. Дело вкуса конечно.


    1. kalyukdo
      03.05.2017 13:27

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