Pull to refresh

Хочу middleware, но не хочу ExpressJS

Reading time5 min
Views6.6K
Middleware в случае с HTTP-сервером в Node.JS — это промежуточный код, который выполняется до того, как начнёт выполняться ваш основной код. Это, чаще всего, нужно для того, чтобы сделать какой-то дополнительный тюнинг или проверку входящего запроса. Например, чтобы превратить данные из POST-запроса в формате JSON-строки в обычный объект, или получить доступ к кукам в виде объекта, и т.п.

Стандартный модуль http из Node.JS не поддерживает такие вещи. Самый очевидный путь: установить ExpressJS и не париться. Но, на мой взгляд, если есть возможность самому написать немного кода и не добавлять ещё 50 пакетов-зависимостей в проект, архитектура станет проще, скорость работы будет выше, будет меньше точек отказа, и ещё не будет нужно постоянно пастись на гитхабе и уговаривать разработчиков обновить версии зависимостей в package.json (или просто принять пулл-реквест, где другой человек за него это сделал), чтобы код был постоянно свежим и актуальным. Я пару раз так делал, и мне не очень нравится тратить время на такие вещи. Очень часто, если ты самостоятельно воспроизводишь какую-то технологию, времени на поддержку тратится меньше, чем если ты устанавливаешь сторонний модуль с такой технологией — как раз из-за таких моментов, когда ты тратишь время на то, чтобы напоминать другим разработчикам, что нужно следить за обновлениями зависимостей и реагировать на них своевременно.

Суть middleware довольно-таки проста: это функция, которая принимает три параметра: request, response и next:

  • request — инстанс http.IncomingMessage для текущего запроса
  • response — инстанс http.ServerResponse для текущего запроса
  • next — функция.

Middleware делает все нужные телодвижения с request и response, после чего вызывает функцию next — это сигнал, что оно закончило работу и можно работать дальше (например, запустить в обработку следующее middleware, или просто перейти к основному коду). Если next вызывается без параметров, то всё нормально. Если в вызов передать ошибку, то обработка списка middleware останавливается.

Пример простейшего middleware:

function myMiddleware(request, response, next) {
    if (typeof next !== 'function') {
        next = () => {};
    }

    console.log('Incoming request');

    next();
}

Если честно, я даже не смотрел, как это реализовано в ExpressJS, но, навскидку, я понимаю этот процесс так: когда вызывается server.use(myMiddleware), моя функция myMiddleware добавляется в какой-то массив, а при каждом входящем запросе вызываются все функции из этого массиа в порядке очерёдности их добавления, после чего начинает работать остальной код. Очевидно, раз используется функция next, то подразумевается асинхронность кода: middleware-функции не просто выполняются одна за другой — перед тем как выполнить следующую функцию из списка, нужно дождаться окончания работы предыдущей.

Получается, вначале мне нужно создать функцию server.use, которая будет регистрировать все middleware.

MyHttpServer.js:

const http = require('http');
const middlewares = [];

/**
 * Основной обработчик HTTP-запросов
 * 
 * Пока что тут только заглушка
 * 
 * @param {IncomingMessage} request
 * @param {ServerResponse} response
 * @return {Promise<void>}
 */
async function requestListener(request, response) {
    throw new Error('Not implemented');
}

/*
 * Функция-регистратор middleware-кода
 */
function registerMiddleware(callback) {
    if (typeof callback !== 'function') {
        return;
    }

    middlewares.push(callback);
}

// Создаётся сервер и регистрируется осноной обработчик
const server = http.createServer(requestListener);

// К серверу добавляется регистратор middleware-функций
server.use = registerMiddleware;

Осталась самая малость: нужно каким-то образом выполнять все эти middleware в асинхронном режиме. Лично я, если мне нужно обойти массив в асинхронном режиме, пользуюсь функцией Array.prototype.reduce(). Она, в определённых условиях, может делать как раз то, что мне нужно. Самое время доработать функцию requestListener.

/*
 * Это просто служебная функция — вставлена здесь для примера 
 * и сама по себе обычно находится в другом модуле
 */
function isError(error) {
    switch (Object.prototype.toString.call(error)) {
        case '[object Error]':
            return true;
        case '[object Object]':
            return (
                error.message
                && typeof error.message === 'string'
                && error.stack
                && typeof error.stack === 'string'
            );
    }

    return false;
}

/**
 * Основной обработчик HTTP-запросов
 * 
 * @param {IncomingMessage} request
 * @param {ServerResponse} response
 * @return {Promise<void>}
 */
async function requestListener(request, response) {
    response.isFinished = false;
    response.on('finish', () => response.isFinished = true);
    response.on('close', () => response.isFinished = true);

    let result;
    try {
        result = await middlewares.reduce(
            // Редусер. Первый параметр — предыдущее значение, 
            // второй — текущее. Чтобы обеспечить асинхронность, 
            // всё оборачивается в Promise.
            (/**Promise*/promise, middleware) => promise.then(result => {
                // Если в предыдущем middleware был вызов 
                // next(new Error('Some message')), текущий middleware 
                // игнорируется и сразу возвращается ошибка 
                // из предыдущего кода
                if (isError(result)) {
                    return Promise.reject(result);
                }

                // Возвращается новый Promise, который, кроме прочего, 
                // реагирует на какую-то ошибку в рамках не только 
                // вызова next, но и в рамках всего кода.
                // То есть, если middleware вызывает внутри JSON.parse 
                // без try-catch, то, в случае ошибки парсинга, реакция 
                // будет такая же, как и при вызове next с передачей 
                // ошибки в качестве параметра
                return new Promise((next, reject) => {
                    Promise.resolve(middleware(request, response, next)).catch(reject);
                });
            }),
            Promise.resolve()
        );

        if (isError(result)) {
            throw result;
        }
    } catch (error) {
        response.statusCode = 500;

        result = 'Error';
    }

    if (response.isFinished) {
        return;
    }

    response.end(result);
}

Теперь я могу без установки ExpressJS использовать любые middleware, которые были написаны для него. И вообще, используя этот механизм, я могу представить мой основной обработчик запросов в виде обычной middleware-функции.

const cookieParser = require('cookie-parser');

server.use(cookieParser());

// Мой основной код
server.use((request, response, next) => {
    if (request.cookies['SSID']) {
        response.end('Your session id is ' + request.cookies['SSID']);
    } else {
        response.end('No session detected');
    }

    next();
});

Под спойлером — простейший пример стандартного HTTP-сервера Node.JS с поддержкой экспрессовких middleware для тех, кто предпочитает copy/paste.

MyHttpServer.js
const http = require('http');
const middlewares = [];

function isError(error) {
    switch (Object.prototype.toString.call(error)) {
        case '[object Error]':
            return true;
        case '[object Object]':
            return (
                error.message
                && typeof error.message === 'string'
                && error.stack
                && typeof error.stack === 'string'
            );
    }

    return false;
}

/**
 * Основной обработчик HTTP-запросов
 * 
 * @param {IncomingMessage} request
 * @param {ServerResponse} response
 * @return {Promise<void>}
 */
async function requestListener(request, response) {
    response.isFinished = false;
    response.on('finish', () => response.isFinished = true);
    response.on('close', () => response.isFinished = true);

    let result;
    try {
        result = await middlewares.reduce(
            (/**Promise*/promise, middleware) => promise.then(result => {
                if (isError(result)) {
                    return Promise.reject(result);
                }

                return new Promise((next, reject) => {
                    Promise.resolve(middleware(request, response, next)).catch(reject);
                });
            }),
            Promise.resolve()
        );

        if (isError(result)) {
            throw result;
        }
    } catch (e) {
        response.statusCode = 500;

        result = 'Error';
    }

    if (response.isFinished) {
        return;
    }

    response.end(result);
}

/*
 * Функция-регистратор middleware-кода
 */
function registerMiddleware(callback) {
    if (typeof callback !== 'function') {
        return;
    }

    middlewares.push(callback);
}

const server = http.createServer(requestListener);

server.use = registerMiddleware;

const cookieParser = require('cookie-parser');
server.use(cookieParser());

server.use((request, response) => {
    if (request.cookies['SSID']) {
        return 'Your session id is ' + request.cookies['SSID'];
    }

    return 'No session detected';
});

server.listen(12345, 'localhost', () => {
    console.log('Started http');
});


Нашли ошибку в тексте? Выделите текст, содержащий ошибку, и нажмите Alt-F4 (если у вас мак, то ⌘-Q). Шутка, конечно же. Если нашли ошибку, пишите в личные сообщения или в комментарии — постараюсь исправить.
Tags:
Hubs:
Total votes 9: ↑7 and ↓2+6
Comments10

Articles