Pull to refresh

@teqfw/http2

Reading time9 min
Views3.5K

Я очень долго не воспринимал 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 и копаю. Интересно, что там есть ещё...

Tags:
Hubs:
Total votes 4: ↑3 and ↓1+2
Comments16

Articles