Я очень долго не воспринимал JavaScript, как язык программирования общего пользования. Добавить падающих "снежинок" на корпоративную web-страничку перед Рождеством — вот, для чего на самом деле придумали JS, казалось мне поначалу. Ну что серьёзного можно было сделать на языке, который не имел в своём арсенале функционала по работе с файловой системой, не говоря уже про доступ к БД?


Однако сейчас, по прошествии двух десятков лет, я считаю, что JS развился достаточно, чтобы быть основным языком программирования для создания web-приложений. Сейчас у него есть всё для этого. На самом деле, эта статья не про то, как использовать HTTP/2 сервер в web-приложениях, а про то, как можно писать приложения с современным JS. И про HTTP/2.



Исторический экскурс


Когда я только начинал, в 1999-м году, web-приложения создавались из расчёта, что JS в браузере клиента может быть отключен. SSR рулил, хотя такой аббревиатуры ещё не было (она появилась позднее, когда появился рендеринг на клиенте). Тогда из ЯП в топе у web-разработчиков были Java (её пихали в клиентские апплеты и надеялись, что ещё чуть-чуть и "настоящий" ЯП вытеснит из браузера "это недоразумение"), ASP (от "корпорации добра" того времени) и PHP ("бедненько, но быстренько"). Ну, и ещё Тёма Лебедев со своим parser'ом — это уже для совсем упоротых, в число которых на некоторое время вошёл и я (к счастью, быстро вышел). Даже когда появился AJAX я не видел особых перспектив у JS. Да, на клиенте у JS не было альтернатив, но использовать JS на клиенте — вступать в конфликт с поисковиками. Они JS поначалу игнорили на все 100%.


Потом были jQuery, SPA, ExtJS и GWT. С jQuery я имел дело постольку-поскольку, а с GWT довольно плотно. JS всё ещё не был тем языком, на котором мне бы хотелось писать код. Даже когда появились nodejs, TypeScript и ES2015 (ES6) я не понимал, как JS можно использовать для создания крупных модульных приложений, особенно в браузере.


С 2012 я плотно сидел на Magento (написана на PHP, но в enterprise-стиле — пакетно-модульно, с namespace'ами, IoC и прочими архитектурными паттернами), но попытки работать с JS из Magento оборачивались головной болью (кто сталкивался с ui-компонентами Magento, тот в курсе).


Кратковременное переключение на python принесло разочарование — тот же PHP, только ещё и без namespace'ов и с проблемами совместимости между версиями 2 и 3. Первый angular — слишком сложно, несмотря на прекрасную документацию. React я даже не трогал — идея добавлять JS в HTML мне казалась приемлемой, а вот HTML в JS (JSX) — слишком экстравагантной. PHP, ASP, JSP — везде программный код добавлялся в код разметки, а не наоборот. Я смотрел в сторону более традиционных Ember и Vue.


Поворотным моментом была Magento-конференция в Риге в 2019-м году, на которой я увидел демонстрацию Vue Storefront. Это уже было нечто похожее на то, каким должны были быть "красивые" web-приложения с моей точки зрения. Я занырнул в PWA и увидел, что новые возможности браузеров очень сильно меняют правила игры, смещая функциональный акцент с бэка на фронт — web-приложения "устанавливаются" в браузер клиента, работают в режиме "offline" и архитектурно приблизились к десктопным приложениям (появилась даже своя IndexedDB). А на фронте у JS нет альтернативы. Вернее, все альтернативы (включая Java Applets, Macromedia Flash, Microsoft Silverlight, Google Dart) так и не смогли потеснить JS.


Три шага до счастья


Тем не менее, процесс программирования на JS был всё ещё далёк от того, к какому я привык за время работы с Java и PHP. Для начала мне очень сильно не хватало возможности адресации любого элемента кода (класс, функция, константа). В Java/PHP package'ы и namespace'ы позволяли однозначно идентифицировать элементы ("Copy Reference" в IDEA), а JS либо выдавал просто имя метода/класса без привязки к файлу, либо имя файла и номер строки в нём. Для сопровождения разработки в той же JIRA этого явно было мало. Проблему разрешили аннотации JSDoc и имена элементов кода в стиле PHP Zend 1 (Vnd_Prj_Mod_Name).


Следующим камнем преткновения был DI. К хорошему быстро привыкаешь, а DI — очень хорошая вещь. Я использовал Spring Framework в Java, а затем DI в Magento 2. В Magento 2 менеджер объектов не просто создавал объекты вместе с зависимостями, а позволял подменять одни объекты другими (plugins). Нечто похожее я хотел найти и для JS, но оказалось, что просто DI, который бы работал и в браузере, и в nodejs-приложениях, найти было проблематично. Пришлось разбираться с функцией import() и делать DI самому.


Самое простое, но самое последнее — интерфейсы. В JS интерфейсов нет. Зато они есть в JSDoc. IDEA очень хорошо разбирает аннотации JSDoc и "видит" связь между интерфейсом:


/** @interface */
export default class TeqFw_Web_Front_Api_Gate_IAjaxLed {
    on() {}
    off() {}
    reset() {}
}

и имплементацией:


/** @implements TeqFw_Web_Front_Api_Gate_IAjaxLed */
export default class Fl32_Ap_Front_Rewrite_Web_Gate_AjaxLed {
    on() {
        console.log('AP app: Ajax LED On');
    }
    off() {
        console.log('AP app: Ajax LED Off');
    }
    reset() {
        console.log('AP app: Ajax LED Reset');
    }
}

С учётом того, что у меня есть свой собственный DI-контейнер, добавить в него инструкции по подмене интерфейсов их имплементациями — дело техники:


{
  "di": {
    "replace": [
      {
        "orig": "TeqFw_Web_Front_Api_Gate_IAjaxLed",
        "alter": "Fl32_Ap_Front_Rewrite_Web_Gate_AjaxLed",
        "area": "front"
      }
    ]
  }
}

Да, это настройка для всего контейнера, но если мне понадобится, то я могу добавить настройки и для отдельного es-модуля — ведь у меня есть namespace'ы.


Итого: на данный момент я вполне удовлетворён тем, что из себя представляет JavaScript, и могу нарабатывать инструментарий для решения типовых задач в области прогрессивных web-приложений.


Дескриптор плагина


Теперь собственно, о том, как я всё это применяю. Начну с дескриптора ./teqfw.json плагина @teqfw/http2 (про дескриптор — "Плагины"). Он не слишком велик, поэтому привожу его целиком:


{
  "di": {
    "autoload": {
      "ns": "TeqFw_Http2",
      "path": "./src"
    }
  },
  "core": {
    "commands": [
      "TeqFw_Http2_Back_Cli_Server_Start",
      "TeqFw_Http2_Back_Cli_Server_Stop"
    ]
  }
}

В дескрипторе определяется пространство имён для es-модулей плагина (TeqFw_Http2), который использует DI-контейнер. Можно по разному настраивать DI-контейнер, я взял за основу PSR-4. Привязываешь префикс пространства имён к каталогу и по логическому имени es-модуля вычисляешь путь к файлу на диске (nodejs) или сервере (браузер).


Второй момент — логические имена позволяют адресовать важные с точки зрения приложения элементы кода. В дескрипторе плагина указаны модули, которые содержат код консольных команд приложения (см. @teqfw/core).


Конструктор объекта


Теперь о том, как инжектятся зависимости. Вот код для HTTP/2-сервера:


export default class TeqFw_Http2_Back_Server {
    constructor(spec) {
        // EXTRACT DEPS
        /** @type {Function|TeqFw_Http2_Back_Server_Stream.action} */
        const process = spec['TeqFw_Http2_Back_Server_Stream$'];
        /** @type {TeqFw_Web_Back_Handler_Registry} */
        const registryHndl = spec['TeqFw_Web_Back_Handler_Registry$'];

        // MAIN FUNCTIONALITY
        ...
    }
}

Имея информацию из дескриптора (/di/autoload), можно по имени класса определить путь к файлу, в котором находятся исходники.


Я выработал для себя именно такую (расширенную) форму записи конструктора, хотя начинал вот с такой:


export default class TeqFw_Http2_Back_Server {
    constructor({TeqFw_Http2_Back_Server_Stream$, TeqFw_Web_Back_Handler_Registry$}) {}
}

Вторая форма более похожа на классическую форму инъекции зависимостей через конструктор в Java:


@Component
public class Car {
    @Autowired
    public Car(Engine engine, Transmission transmission) {}
}

Первый, более развёрнутый, вариант позволяет добавить JSDoc-аннотации к переменным (указания для IDE) и делает более понятным, какие, собственно, данные инжектятся. Мой DI-контейнер устроен так, что один и тот же код может инжектиться в конструктор по-разному:


  • как es-модуль (практически не пользуюсь, но возможно);
  • как класс/функция;
  • как singleton-объект (представитель класса или результат работы функции-конструктора);
  • как новый экземпляр класса или новый результат работы функции-конструктора (использую гораздо реже, чем singleton'ы);

Со временем начинаешь различать на глаз эти варианты:


const module = spec['Vnd_Prj_Mod'];
const klass = spec['Vnd_Prj_Mod#'];
const singleton = spec['Vnd_Prj_Mod$'];
const instance = spec['Vnd_Prj_Mod$$'];
const fn = spec['Vnd_Prj_Mod#fn'];

но такая запись помогает IDE, которая затем помогает тебе:


/** @type {typeof Vnd_Prj_Mod} */
const klass = spec['Vnd_Prj_Mod#'];
/** @type {Vnd_Prj_Mod} */
const singleton = spec['Vnd_Prj_Mod$'];
/** @type {Function|Vnd_Prj_Mod.fn} */
const fn = spec['Vnd_Prj_Mod#fn'];

В общем, такой подход позволяет отойти от импортов (завязанных на файловой структуре) и перейти к получению зависимостей не только на уровне элементов кода, но и на уровне экземпляров объектов приложения. Так идентификатор singleton-зависимости Vnd_Prj_Mod$ в любом конструкторе соответствует одному и тому же объекту, и без разницы, для какого конструктора этот объект создался впервые.


Следствие такого подхода: все объекты должны создаваться через DI-контейнер или через отдельный тип элементов кода — фабрики объектов (рассматривались в статье про DTO). Я очень редко применяю оператор new вне фабрик в своих приложениях — никогда не знаешь, как изменятся зависимости в конструкторах.


Команды


http2-плагин добавляет две команды для запуска и останова HTTP/2-сервера в добавок к командам запуска/останова HTTP/1-сервера из web-плагина:


$ node ./bin/tequila.mjs help
...
Commands:
  http2-server-start [options]  Start the HTTP/2 server.
  http2-server-stop             Stop the HTTP/2 server.
  web-server-start [options]    Start the HTTP/1 server.
  web-server-stop               Stop the HTTP/1 server.

Интеграция плагинов


HTTP/1-сервер из web-плагина использует для обработки запросов обработчики, для которых определяет интерфейс TeqFw_Web_Back_Api_Request_IHandler, и контекст запроса (с интерфейсом TeqFw_Web_Back_Api_Request_IContext). HTTP/2-сервер просто должен быть совместим с этими интерфейсами. Основной момент, что http и http2 сервера из библиотек nodejs по разному предоставляют доступ к заголовкам и телу запроса.


Вот иерархия es-модулей для HTTP/1-сервера и его фабрика для создания контекстов запросов:



/** @type {TeqFw_Web_Back_Api_Request_IContext.Factory} */
const fContext = spec['TeqFw_Web_Back_Server_Request_Context#Factory$'];

Для HTTP/2-сервера вот такая иерархия модулей и инъекция фабрики:



/** @type {TeqFw_Web_Back_Api_Request_IContext.Factory} */
const fContext = spec['TeqFw_Http2_Back_Server_Request_Context#Factory$'];

Вот и всё — на этом уровне в приложении происходит подмена одного сервера другим совершенно незаметно для кода остальных плагинов в приложении — начиная от обработчиков (handler'ов) и вдаль.


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


Интерфейсы очень широко используются в статически типизированных языках, но в языках с "утиной" типизацией они пока что применяются значительно реже. Но это пока.


Использование серверов


HTTP/1-сервер доступен напрямую из браузера. Просто запускаем сервер:


$ node ./bin/tequila.mjs web-server-start
...
2021/07/21 08:52:44 (info): HTTP/1 server is listening on port 3020. PID: 236742.

и открываем его в браузере: http://localhost:3000/


С HTTP/2-сервером несколько сложнее — не все браузеры напрямую используют HTTP/2, в основном — поверх TLS. Я в своих приложениях использую проксирующий сервер (apache — я давно им пользуюсь, привык), настраиваю на нём HTTPS, а затем делаю редирект на HTTP/2-сервер самого приложения:


RewriteEngine  on
RewriteRule    "^/(.*)$"  "h2c://localhost:3000/$1"  [P]

В общем, HTTP/1-сервер можно использовать локально для разработки, а HTTP/2 — для production'а.


Demo


Я изменил демо-проект из предыдущей статьи (habr_teqfw_web) так, чтобы приложение можно было запускать как на HTTP/1-сервере, так и на HTTP/2.


Вторая версия приложения, развёрнутая по адресу http://ws.habr.demo.teqfw.com/, работает по протоколу HTTP/1:



А третья версия, развёрнутая по адресу https://http2.habr.demo.teqfw.com/, уже по HTTP/2:



Оба nodejs-сервера стоят за проксирующим apache'м и доступны напрямую по портам 3001 (HTTP/1) и 3002 (HTTP/2) соответственно. Картинки приведены для красоты, т.к., если зацепиться на первое приложение по HTTPS, то от браузера до прокси также будет использоваться HTTP/2, а HTTP/1 будет уже от прокси к nodejs-серверу.


Демо-приложение содержит всего 8 зависимостей в ./node_modules/ и тем не менее, способно поддерживать оба протокола:



Да, express делает больше, но у него и зависимостей больше (+48 к моим), а мне пока хватает и того, что есть. Это ж мой персональный инструмент, а не "универсальная таблетка для всего в интернете".


Резюме


JavaScript развился достаточно сильно (как и "среда обитания" web-приложений, да и вообще всё IT) и продолжает развиваться (HTTP/3 на горизонте). Используя новые возможности, можно меньшими затратами выйти на те же результаты. Или теми же затратами достичь результатов гораздо больших. Так было, так есть и так будет ещё какое-то время. Нельзя объять необъятное, как говорил Козьма Прутков, но можно копать вглубь. Выбрать себе тему — и копать, копать, копать…


Я выбрал PWA и копаю. Интересно, что там есть ещё...

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


  1. Silverthorne
    21.07.2021 23:40
    +1

    export default class TeqFw_Http2_Back_Server {
        constructor(spec) {
            // EXTRACT DEPS
            /** @type {Function|TeqFw_Http2_Back_Server_Stream.action} */
            const process = spec['TeqFw_Http2_Back_Server_Stream$'];
            /** @type {TeqFw_Web_Back_Handler_Registry} */
            const registryHndl = spec['TeqFw_Web_Back_Handler_Registry$'];

    зачем все это, когда есть TypeScript?


    1. flancer Автор
      22.07.2021 08:31
      +1

      А как на TS будет выглядеть аналогичный фрагмент с учётом того, что:

      • функция-конструктор для функции-обработчика action находится в файле ./src/Back/Server/Stream.ts в npm-пакете @teqfw/http2

      • класс реестра обработчиков находится в файле ./src/Back/Handler/Registry.ts в npm-пакете @teqfw/web

      • зависимости (функция-обработчик и реестр) в конструктор сервера инжектятся в виде singleton'ов?

      Как будет выглядеть код конструктора для сервера?


      1. Zoolander
        24.07.2021 12:46
        +1

        Вот так бы я написал ваш фрагмент на TS - три строчки
        типы не нужно указывать - они автоматически определятся по типам полей в интерфейсе входящих параметров

        константы можно сразу пакетом получить за счет деструктуризации (3 строчка)

        export default class TeqFw_Http2_Back_Server {
            constructor(spec: IServerConfig) {
                const {process, registryHndl} = spec;

        PS: это без учета импортов, работу по которым из-за хоткеев в IDE я вообще не вижу

        нет разницы, где именно в папках лежат импортируемые пакеты - если они в src, IDE их найдет. Если в node_modules - то может быть понадобится вводить вручную


        1. Zoolander
          24.07.2021 13:06
          +1

          если захочется, то можно еще строчку сэкономить за счет той же деструктуризации

          export default class TeqFw_Http2_Back_Server {
          constructor({spec, registryHndl}:IServerConfig)


        1. flancer Автор
          24.07.2021 13:25

          В том-то и дело, что без import'а никак. То, что вы их не видите, роли не играет. Особенно при рефакторинге. А межпакетный импорт несовместим для nodejs и браузеров (браузеры вообще не понимают межпакетный импорт - только с явным указанием пути). Да, за вас всю механическую работу делают IDE и сборщики, а в моём им этого делать не надо. Я не призываю вас использовать моё "кун-фу", я просто сравниваю стили.

          Спасибо, что подтвердили мои догадки.


          1. Zoolander
            25.07.2021 09:07

            я не учу вас жить, просто призываю подумать - зачем тратить время на то, что могут делать машины лучше?


            1. flancer Автор
              25.07.2021 09:30

              Машины не могут править мой код вместо меня. С этим нужно управляться мне. Я ищу способ кодировать на JS, который бы меня устраивал. Когда Google анонсировал dart, я начал погружаться в dart, но когда я увидел, что dart нативно не будет поддерживаться всеми браузерами, я перестал погружаться. Как только TS начнёт поддерживаться всеми браузерами, я сразу же переключусь на TS, а пока же я вижу, как JS и TS плавно мигрируют друг к другу. И я вместе с ними.

              Кстати, я не трачу время, я изучаю возможности языка :)


              1. Zoolander
                25.07.2021 10:21

                я так понял, что вас парит лишняя работа по созданию import

                но одновременно вас не парит обильное многословие, к которому вынуждает типизация через JSDoc - при этом крайне ненадежная типизация, кстати. TS при ошибке типов остановит вас дважды - в IDE и при сборке. JS Doc остановит вас только в IDE, сборщик проекта отлично проглотит все ошибки и несовместимости типов.

                Важно то, что мне кажется, что у вас гораздо больше работы по вводу символов, чем у меня. Я могу писать двухстрочники, однострочники, не указывать типы (при этом они будут соблюдаться). Работы стало на порядок меньше.

                Мне кажется, это уже не вопрос программированния, а вопрос управления личным настроением, личным отношением.

                Тут никто помочь не может, но вы можете попробовать увидеть выигрыш, который важен для вас. Я так понял, что вам важен выигрыш в наборе символов. Так вот, TypeScript даже с import - значительно мог бы уменьшить объемы символов, которые нужно набирать для вашего кода.


                1. flancer Автор
                  25.07.2021 12:06

                  Меня не парит набор симовлов, я искал возможность писать JS-код, который бы без изменений запускался в браузере и nodejs. Оказалось, что межпакетный импорт этому мешает. И я нашёл для себя способ, который позволяет писать код, который бы без изменений запускался в браузере и nodejs.

                  В каком-то роде вам проще, вы пишите на TS, который затем транспилируется под среду выполнения. Я так уже делал на GWT, мне не понравилось.


              1. Zoolander
                25.07.2021 10:26

                // который бы меня устраивал

                У вас есть измеримые численные параметры для этого критерия, или вы опираетесь исключительно на внутреннее ощущение "от сердца"?

                может быть, составим таблица Pro et Contra с числовыми параметрами и еще проставим коэффициенты, какой из них имеет наибольший вес?

                по набору символов я привел примеры и уже сказал - что по численному параметру TypeScript тут намного лучше и по строчкам, и по символам.


                1. flancer Автор
                  25.07.2021 12:07

                  Ответил выше, мне нужен был DI для фронта и бэка.


    1. flancer Автор
      22.07.2021 14:53
      +1

      Похоже, на TypeScript аналогичный фрагмент отобразить - это не дело 5 минут. Я TS не знаю, поэтому помочь не могу. Может кто-то ещё может соорудить аналогичный код на TS?

      @teqfw/diбыл создан для загрузки исходных кодов es-модулей, создания и инъекции зависимостей в "мультимодульных монолитах" (единая кодовая база, состоящая из npm-пакетов) с возможностью одновременного использования es-модулей в браузерах и nodejs и без применения транспиляции к модулям.

      Зачем? Сначала из любопытства, а потом пошло-поехало. Так получилось, в общем.


  1. khegay
    22.07.2021 15:15

    Ванильный JS, Apache, сурово :)

    Но, для изучения однозначно круто


  1. Zoolander
    24.07.2021 12:59
    +1

    Для меня веским аргументом для перехода на TypeScript стало как раз сокращение работы по указанию типов через JSDoc - во многих случаях компилятор прекрасно стал определять их сам, а там, где их надо указывать - все равно получалась более компактная запись.


    1. flancer Автор
      24.07.2021 13:29

      Я указываю типы в конструкторе и в большинстве случаев IDE далее в коде способна разобрать что-куда и помочь autocomplete'ом. Ну и в сигнатурах функций/методов, само собой, нужно ставить. В общем-то, как и в TS, только в JSDoc.

      Если бы в JS были аналоги PHP'шного namespace, то аннотации можно было сделать ещё короче. Наверное :)


      1. Zoolander
        25.07.2021 09:09
        +1

        я 2 года сидел на JSDoc, потом на TS, по ощущениям - лишней работы стало меньше. Конечно, это субъективно. TS надо просто вбить в пальцы, первое время я смотрел на конструкции некоторых опытных кодеров как на мумбо-юмбо, но в итоге признал, что это крайне гибкий и мощный язык, который ограничивает там, где надо ограничивать, и значительно сокращает объем кода, когда выучиваешь его фокусы )