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