C, C++
July 22, 2021

Пишем компилируемый сервер на JS

Ситуация прямо сейчас

Согласитесь, то, что JS подходит не только для браузера - уже не новость. С появлением Node.js мы все поняли, что JS, как и любой другой язык, всего лишь инструмент, а как его использовать - дело фантазии, ну и, пожалуй, API.

Спустя десять с лишним лет ситуация на поле серверного JS изменилась несущественно. Основные пути использования - Node и Deno, для любителей особых ощущений - fpm-решения.

Болячки Node известны всем. Deno частично решает их, но остаётся всё тем же наследником, перенося некоторые непонятные решения. К примеру, когда я смотрел последний релиз Deno, в котором появилась команда deno compile, я был удивлён тем, что на выходе мы получаем бинарник размером ~36 мегабайт. В Node же есть только костыльные сторонние решения, которые чаще всего приводят к тому, что ваше приложение либо не собирается, либо не работает после сборки, из-за того, что какие-то модули не найдены.

Почему так происходит? Потому что все решения для ноды сейчас работают по принципу упаковки всего необходимого в одну “корзину”. В итоговый файл включается рантайм ноды, нужные модули и ваши исходные коды. Естественно, на этапе этой, с позволения сказать, “компиляции”, резолвится далеко не всё, поэтому если у вас работает собранное таким образом приложение - это большая удача.

Какие есть решения? Пожалуй, первое и самое логичное - писать не на JS. Но… как? Давайте признаем, что большинство из тех, кто пишут на клиентском или серверном JS хорошо знают только этот язык. По крайней мере, я буду говорить за себя.

И вот, в 2019 году появился человек, который решил помочь таким, как я. Уж не знаю, из побуждений сочувствия или саморазвития, но получилось у него суперски.

Фабрис Беллар и QuickJS

Вы можете не знать это имя, но вы знаете его дела. Фабрис Беллар, французский математик и программист, прежде всего известен четырьмя вещами:

  • Эмулятор QEMU;
  • C-компилятор Tiny C Compiler;
  • Конечно же, FFmpeg;
  • Конечно же, Git.

В 2019 году к этому списку прибавилась ещё одна интересная вещица: рантайм для JavaScript, написанный на C, не использующий AST и обеспечивающий практически бесшовную интеграцию со всякими POSIX-штуками — QuickJS.

На своем сайте Беллар называет QJS “маленьким встраиваемым движком JavaScript”. Это действительно так: полный размер движка всего 620 килобайт, при этом он поддерживает практически все стандарты ES2020.

Всё это здорово, но давайте трезвым взглядом посмотрим на то, чем QJS является прямо сейчас:

  • Это довольно быстрый маленький JS-движок, способный составить конкуренцию даже V8 (но без JIT);
  • Это полная поддержка ES2020, но не без странностей. К примеру, вывод объектов в консоль покажет вам только [object Object];
  • Это очень скудная стандартная библиотека. Фактически, всё, что вы можете - взаимодействовать с файлами;
  • Это полное отсутствие методов для взаимодействия с сетью.

Последний пункт расстроил меня больше всего. Мне очень понравились идеи легковесности и компилируемости, которые несёт в себе QJS, но серверный JS без самого сервера — штука довольно-таки бесполезная.

Исходные коды QJS доступны на гитхабе всем желающим. Я решил взглянуть на них и понял, что даже несмотря на то, что на C я никогда всерьёз не писал, понять, что там происходит, оказалось не так сложно. И именно тогда во мне зародилась идея добавить в этот движок чуть-чуть сервера.

А как работать-то будет

QuickJS предполагает следующий процесс:

  1. Сам функционал какого-либо модуля пишется на C;
  2. На C же пишется “прослойка”, использующая QJS-api, которая говорит интерпретатору или компилятору, что вот тут-то мы и вызываем C-функции;
  3. Внутри JS-скриптов мы импортируем JS-переменные из скомпилированной shared-библиотеки.

В итоге мы получаем простое решение: если мы хотим добавить что-то в QJS, нужно найти или написать новый функционал на C, а затем просто написать API-прослойку.

Если честно, в этой работе я не сделал ничего особо важного. Я нашёл сервер на C, который показался мне идеально подходящим для моей задумки — библиотеку httpserver.h. Взяв его за основу, я написал немного связующего кода, и получился QuickWebServer — динамическая библиотека, привносящая в QJS функционал веб-сервера с Express-подобным API. Давайте же посмотрим, как оно работает!

Первый сервер на QJS

Прежде всего, рекомендую вам скачать, собрать и установить QuickJS. Лишним точно не будет. Скачать можно из этого репозитория. Далее, для пользователей Mac и Linux проблем никаких - сперва запускаете make и ждёте, пока проект соберётся, а затем make install, чтобы все файлы поместились в нужные папки. Для пользователей Windows тоже можно собрать, но я, к сожалению, не проверял.

Также давайте убедимся, что у вас установлен Node.js и NPM. Это выглядит нелогичным, ведь мы не будем использовать Node, но установка пакетов удобнее с помощью NPM.

Давайте создадим тестовый проект, к примеру, в папке first-server.

# ~/first-server
npm init -y

Теперь нам нужно скачать и собрать QuickWebServer (далее - QWS). Он опубликован в NPM:

npm i -S @lyohaplotinka/quickwebserver

Появилась папка node_modules, а также началась сборка shared-библиотеки. Это может занять определённое время, так что можем отвлечься.

Пока что начнём писать код нашего сервера. Первым делом импортируем библиотеку:

import QuickWebServer from './node_modules/@lyohaplotinka/quickwebserver/src/QuickWebServer.js';

Важное замечание: QuickJS не Node.js, поэтому он:

  • Не ищет модули автоматически в node_modules;
  • не умеет загружать index.js из папки;
  • требует всегда указывать расширение файла после точки.

Двигаемся дальше. Создадим экземпляр:

import QuickWebServer from './node_modules/@lyohaplotinka/quickwebserver/src/QuickWebServer.js';

const server = new QuickWebServer();

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

import QuickWebServer from './node_modules/@lyohaplotinka/quickwebserver/src/QuickWebServer.js';

const server = new QuickWebServer();

server.get('/', async (request, response) => {
  response.type('text/html');
  response.send('Hello from QuickJS!');
});

Осталось лишь начать слушать соединения:

import QuickWebServer from './node_modules/@lyohaplotinka/quickwebserver/src/QuickWebServer.js';

const server = new QuickWebServer();

server.get('/', async (request, response) => {
  response.type('text/html');
  response.send('Hello from QuickJS!');
});

server.listen(3000);

Код выше - минимальный пример работы. О, кажется, у нас завершилась сборка. Запустим!

# ~/first-server
qjs ./server.js

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

Hello from QuickJS!

В целом, уже победа: у нас сервер на JS, но не Node.js. Круто!

В целом, всё можно так и оставить, и использовать QJS как рантайм. Но если мы хотим скомпилировать приложение, чтобы использовать всего лишь один бинарник, нам нужно сделать ещё кое-что.

Компилируем сервер

Сейчас QWS не часть QuickJS, а динамическая библиотека. Чтобы не таскать её за собой, нам нужно собрать программу со статической линковкой. Признаюсь честно, мне эта операция показалась не самым лёгким занятием, поэтому я решил это дело немного автоматизировать.

В составе пакета quickwebserver распространяется скрипт create-build-makefile.sh. Он, собственно, создаёт Makefile, в котором прописаны все необходимые шаги для сборки. Вызовем его так:

# ~/first-server
./node_modules/@lyohaplotinka/quickwebserver/create-build-makefile.sh ~/first-server/server.js

Как видите единственный аргумент - абсолютный путь к главному файлу нашего приложения. После выполнения команды рядом с файлом server.js появится Makefile.

Остался один шаг до сборки. Откроем server.js и поправим импорт QWS так, чтобы подключалась версия для статической сборки:

import QuickWebServer from './node_modules/@lyohaplotinka/quickwebserver/src/QuickWebServer.build.js';

Время выполнить самый простой шаг - просто вызвать команду make. Совсем скоро рядом появится исполняемый файл server. Если его запустить и перейти на http://localhost:3000, мы увидим всё ту же приятную фразу:

Hello from QuickJS!

Отдельно радует размер исполняемого файла - чуть больше мегабайта. Кстати, при компиляции из QuickJS можно “выпилить” какие-либо фичи языка, если они не используются. Так вы сократите размер файла.

Очень важно, что это никакой не “хак”, типо упаковки рантайма и кода в один пакет чисто “для виду”. При желании вы можете использовать QuickJS не для компиляции в бинарник, а для превращения вашего кода в C.

А зачем это вообще нужно?

Это извечный вопрос. Однако, у меня есть пара ответов.

Во-первых, затем, зачем я вообще что-либо делаю всегда: это просто прикольно. Ну, круто же иметь сервер в одном бинарнике!

Во-вторых, это быстро. Почти настолько же, как Node.js, но куда более транспортабельно и без необходимости в рантайме. И гораздо меньше по размеру.

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

В-четвертых, мне уже давно хотелось бы иметь какую-нибудь маленькую альтернативу Node.js, созданную для каких-нибудь простецких целей. Поэтому я лично рад, что у меня получилось это реализовать вот так.

Резюме

В итоге, конечно, всё это пока что несерьёзно. Нет HTTP-клиента, нет библиотек для работы с БД. Да и сама версия сервера ещё нестабильная, к примеру, нет возможности принимать от клиента файлы.

Во всём Интернете сейчас всего лишь один сайт работает на QWS - и это сайт его документации (https://qws.lyoha.info). Он, кстати, загружает доки из маркдауна и рендерит их на стороне JS. Уже что-то!

Тем не менее, эта работа меня сильно вдохновила. Поэтому я завёл на гитхабе организацию QuickJS Web Project, в которой хочу попытаться привнести в QJS всё то, что нужно для работы хорошего серверного движка. Если вы чувствуете силы и желание - я буду только рад работать вместе :)

Вот так вот можно написать свой сервер на JS, скомпилировать его и почувствовать себя немного серьёзнее, пусть даже всё это появилось из-за несерьёзного порыва любопытства.