Ма­гия вне Хо­гварт­са: NJS

Редактура Андрей Мелихов Никита Дубко Вадим Макеев

Наверняка вы слышали про веб-сервер Nginx Игоря Сысоева. Архитектура Nginx построена таким образом, чтобы выдерживать колоссальное количество запросов от несметного количества клиентов. Словом, настоящая магия программирования на чистом Си! Если попробовать разобраться, как это работает, у многих может возникнуть синдром самозванца. И действительно, чтобы написать свой модуль для расширения функциональности Nginx, надо пройти школу чародейства и мастерства. А что делать JS-волшебникам и JS-волшебницам? Можно пройти курс защиты от тёмных сил или просто узнать больше об NJS.

Алохомора: откроем замочек Скопировать ссылку

Nginx поддерживал внедрение скриптов в рантайм практически с самого начала, но это были скрипты на популярном тогда языке Perl. Сегодня мало кто умеет писать на Perl: сначала ему на смену пришёл PHP, а потом был период, когда практически ничего не менялось в самом Nginx. В то же время в разработке набирали популярность другие языки и всё меньше программистов могли поддерживать скрипты на сервере. Именно поэтому возникла необходимость использовать что-то другое.

И вот примерно с 2017 года одна из команд Nginx начала разрабатывать новое решение — использование JavaScript в качестве языка для скриптов. Было разработано два модуля для бесплатной версии Nginx, которые вместе составляют основу инструмента NJS, и много чего ещё для платной версии Nginx Plus.

NJS — очень интересный инструмент. У команды была возможность использовать готовые решения, например, движки V8 или SpiderMonkey, но они пошли другим путём. Эти решения нужны для решения широкого круга задач. О сложностях использования существующих движков JavaScript для работы скриптов в Nginx подробно рассказывает один из авторов NJS в докладах на русском и английском языке и приводит сравнение производительности.

Вместо этого NJS использует прекомпиляцию в байт-код при старте Nginx: виртуальная машина клонируется для каждого запроса, а ещё нет JIT-компиляции и сборщки мусора. Скрипты под NJS иногда вполне могут заменить модули Nginx на языке Си, хотя и с небольшой просадкой по производительности. При внедрении NJS было важно, чтобы Nginx не потерял своих достоинств. И команда разработчиков из Nginx сделала это!

Портус: пора в новый мир Скопировать ссылку

Установка NJS для большинства операционных систем описана в официальной документации. Возможны как установка в качестве готового пакета, так и сборка модулей из исходных файлов. Для работы с NJS используются два основных модуля: ngx_http_js_module и ngx_stream_js_module. Первый используется для обработки данных по протоколу HTTP, второй — по протоколам TCP и UDP.

После установки необходимо подключить модули NJS к Nginx. Модули являются динамическими, поэтому в файле конфигурации необходимо воспользоваться директивой load_module:

load_module modules/ngx_http_js_module.so;
# или
load_module modules/ngx_stream_js_module.so;

Люмос: что-то темно Скопировать ссылку

Для того чтобы реализовать скрипт для Nginx, необходимо прописать определённые директивы в файле конфигурации. Здесь и дальше конфигурацию будем описывать в файле nginx.conf, а скрипт на языке NJS — в файле script.js.

Начнём с классического «Hello world!». Логика работы скрипта заключается в том, чтобы прослушивать заранее заданный порт, например 8080, и при обращении к корневой папке сайта отослать клиенту ответ с кодом 200 и нашим сообщением.

Файл nginx.conf:

http {
    js_import script.js;

    server {
        listen 8080;

        location / {
            js_content script.hello;
        }
    }
}

Файл script.js:

function hello(r) {
    r.return(200, 'Hello world!');
}

export default {
    hello
};

NJS — это подмножество JavaScript. Что можно, а что нельзя, определено дорожной картой. Язык поддерживает стандарт ECMA-262 и имеет ряд специальных методов и свойств.

Среди специальных методов, которые обрабатывают сущности Nginx — HTTP-запрос, Stream-сессия. Можно узнать метод и аргументы запроса, обработать его заголовки и тело HTTP-сообщения, отправить коды ответа, заголовки, тело HTTP-ответа от сервера. Возможно проксирование, авторизация, логирование, доступна обработка ошибок. Создать и организовать поток данных тоже возможно с помощью NJS. Таймеры, кодирование-декодирование строк, информация о процессе, шифрование являются частью ядра, а синтаксический разбор и форматирование URL, доступ к файловой системе через реализацию библиотеки fs — встроенными модулями.

Разработчики привели довольно много примеров кода и вариантов использования NJS. Обязательно зайдите на эту страницу — вдруг ваш вариант уже реализован.

Список всех актуальных директив NJS для версии 0.4.0 и старше, которые могут быть использованы в файлах конфигурации:

  • js_body_filter — устанавливает функцию из модуля в качестве фильтра тела HTTP-сообщения ответа;
  • js_content — устанавливает функцию из модуля в качестве обработчика содержимого location;
  • js_header_filter — устанавливает функцию из модуля в качестве фильтра заголовков HTTP-сообщения ответа;
  • js_import — импортирует модуль с функциями;
  • js_path — устанавливает дополнительный путь до модулей;
  • js_set — устанавливает функцию из модуля, которая вызывается в момент первого обращения к переменной для данного запроса;
  • js_var — объявляет перезаписываемую переменную.

Вингардиум Левиоса: полетели Скопировать ссылку

Теперь напишем наш собственный модуль, который будет шифровать данные 🙃. Возьмём самый простой способ шифрования и попробуем передавать и принимать данные с помощью него. Будем использовать очень простой шифр Цезаря. Его заключается в том, чтобы каждый символ в сообщении заменяется на другой, сдвинутый циклично на фиксированное число позиций в алфавите. Например, слово JavaScript будет преобразовано в слово OfafXhwnuy при смещении на пять символов.

Напишем сначала код, используя NJS. Шифрование и дешифрование реализуем в функции processMessage() (для примера учтём только символы английского алфавита, для шифрования будем использовать положительное значение сдвига, а для дешифрования — отрицательное).

Попробуем сделать так, чтобы при приёме HTTP-запроса скрипт возвращал расшифрованное-зашифрованное сообщение в зависимости от пути на сервере /encode/ или /decode/. Если мы обратимся к /shift/, то получим сдвиг, который заложен в нашу программу. Для обработки каждого пути напишем отдельные функции в модуле: encode, decode и shift. С помощью функции getMessage() можно будет получить сообщения из переменных, переданных GET- или POST-запросом от клиента.

Модули NJS перед исполнением компилируются и запускаются на сервере как отдельный процесс, а затем уничтожаются после выполнения. В платной версии Nginx Plus есть вариант хранения переменных в общем хранилище. В нашем случае мы могли бы так настраивать величину сдвига. Но у нас версия бесплатная, поэтому мы храним величину сдвига в константе CAESAR_SHIFT. В платной версии Nginx Plus есть даже кэширование результатов работы скриптов, нам бы вряд ли это пригодилось, но интересно.

Основной модуль script.js будет выглядеть так:

const CAESAR_SHIFT = 5;

// Преобразуем сообщение (шифр Цезаря)
function processMessage(inputString, shift) {
    let outputString = '';
    // Бежим по всем символам строки
    for (let i = 0; i < inputString.length; i++) {
        // Получаем символ строки
        let c = inputString[i];
        if (c.match(/[a-z]/i)) {
            // Получаем код символа
            const code = inputString.charCodeAt(i);
            // Обрабатываем прописные буквы
            if (code >= 65 && code <= 90) {
                c = String.fromCharCode(((code - 65 + shift) % 26) + 65);
            }
            // Обрабатываем строчные буквы
            else if (code >= 97 && code <= 122) {
                c = String.fromCharCode(((code - 97 + shift) % 26) + 97);
            }
        }
        // Добавляем символ в конец строки
        outputString += c;
    }
    return outputString;
}

// Получаем данные из переменной
function getMessage(r) {
    return r.args.msg;
}

// Шифруем
function encode(r) {
    r.return(200, processMessage(getMessage(r), CAESAR_SHIFT));
}

// Дешифруем
function decode(r) {
    r.return(200, processMessage(getMessage(r), -CAESAR_SHIFT));
}

// Возвращаем сдвиг
function shift(r) {
    r.return(200, CAESAR_SHIFT);
}

export default {
    encode,
    decode,
    shift
};

Полный список доступных в NJS конструкций языка JavaScript описан на странице «Совместимость».

Команда r.return() — отправляет тело HTTP-сообщения клиенту и статус ответа веб-сервера. r.variables хранит переменные, переданные в запросе клиента. Перед использованием нужно экспортировать необходимые функции в интерфейс, как это делается при работе с модулями в JavaScript.

Первая версия кода готова, теперь настроим веб-сервер.

У Nginx есть официальный образ Docker. Для того чтобы проверить ваш код, вы можете выполнить команду (если у вас установлен и настроен Docker):

docker run -i -t nginx:mainline /usr/bin/njs

Так вы можете проверять корректность вашего кода, вставляя функции отдельно. Если без Docker, то вы можете сделать это напрямую, установив CLI-утилиту на компьютер или сервер. Это по сути тот же интерпретатор, который используется при обработке скриптов в модуле Nginx.

Установим Nginx и NJS. Для того чтобы ставить эксперименты было удобнее, установим окружение внутри Docker-контейнера. Так можно будет работать с контейнером, как с удалённым сервером, а клиентом будет выступать наш компьютер. Вы можете сделать иначе, просто установив всё, что требуется, к себе на компьютер или на сервер. Подробнее о Docker можно почитать в цикле статей «Распаковка Docker».

Создадим папку с проектом и перейдем в неё:

mkdir caesar
cd caesar

Создадим папку для кода и добавим наш скрипт с помощью редактора или командой cat:

mkdir src
cat > ./src/script.js << __EOF__
// Cодержимое файла скрипта
__EOF__

Добавим ещё простейший интерфейс пользователя в файле index.html, который поместим в папку проекта:

<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>NJS тест</title>
</head>
<body>
    <form action="/encode/">
        <fieldset>
            <legend>Зашифровать сообщение</legend>
            <label for="encode-message">Сообщение</label><br>
            <textarea id="encode-message" name="message" cols="30" rows="10"></textarea><br>
            <button type="submit">Отправить сообщение</button>
        </fieldset>
    </form>
    <form action="/decode/">
        <fieldset>
            <legend>Расшифровать сообщение</legend>
            <label for="decode-message">Сообщение</legend><br>
            <textarea id="decode-message" name="message" cols="30" rows="10"></textarea><br>
            <button type="submit">Отправить сообщение</button>
        </fieldset>
    </form>
    <form action="/shift/">
        <fieldset>
            <legend>Получить сдвиг</legend>
            <button type="submit">Отправить запрос</button>
        </fieldset>
    </form>
</body>
</html>

Создадим папку, в которой будем хранить настройки образа для Nginx (ссылка на готовый Dockerfile для сборки берём из примера в официальном репозитории):

mkdir nginx
curl -o nginx/Dockerfile https://raw.githubusercontent.com/nginxinc/docker-nginx/master/modules/Dockerfile

Создадим файл конфигурации nginx.conf:

touch ./nginx/nginx.conf

Поместим в файл конфигурацию для Nginx:

load_module modules/ngx_http_js_module.so;

events {}

http {
    js_path /etc/nginx/njs/;
    js_import script.js;

    server {
        listen 8080;

        root /var/www;

        location /encode/ {
            js_content script.encode;
        }

        location /decode/ {
            js_content script.decode;
        }

        location /shift/ {
            js_content script.shift;
        }
    }
}

Создадим файл конфигурации docker-compose.yml:

touch docker-compose.yml

Наполним его:

version: "3.9"
services:
    web:
        build:
            context: ./nginx/
            args:
                ENABLED_MODULES: njs
        image: nginx-with-njs:v1
        volumes:
            - ./nginx/nginx.conf:/etc/nginx/nginx.conf
            - ./src/:/etc/nginx/njs/
            - ./index.html:/var/www/index.html
        ports:
            - "80:8080"

Обратите внимание на пути томов volumes. В конфигурации используются связанные тома binded volumes, то есть прямая ссылка на файловую систему компьютера, на котором запущен Docker. Однако ссылки внутри контейнера очень важны. Конфигурацию и скрипт нужно поместить в /etc/nginx/ и /etc/nginx/njs/, соотвественно. В конфигурации дополнительно используется директива js_path, чтобы точно ничего не потерялось.

Осталось всё запустить:

docker-compose up

Теперь можно посмотреть в браузере результат нашей работы по адресу http://localhost. Для того чтобы остановить веб-сервер, можно использовать сочетание клавиш Ctrl C или соответствующий сигнал в Unix-подобных системах.

Приори Инкантатем: что это было Скопировать ссылку

Для написания кода можно использовать TypeScript, как это описано в документации. В NJS можно использовать модули Node.js. Например, не составит большого труда сделать сервис для работы с протоколом gRPC с помощью библиотеки protobufjs. Чтобы двигаться дальше, посмотрите примеры использования скриптов NJS.

Разработчику не нужно писать код на Си для реализации модуля Nginx, а потом код на JavaScript для клиентского веб-приложения. Можно переиспользовать один и тот же модуль. Вы, конечно, можете писать скрипты на Perl или на PHP, но JavaScript иногда и правда является лучшим решением. А скорость исполнения в случае NJS сравнима с кодом на Си, помните об этом!