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

    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). Шутка, конечно же. Если нашли ошибку, пишите в личные сообщения или в комментарии — постараюсь исправить.

    Комментарии 10

      –4
      Если не хотите Express — используйте Connect

      image
        +2
        На мой взгляд мидлвары появились как раз потому что их было вот так вот просто реализовать. Ну а потом, как говорится, понеслось, и вот — все как в тумане, а потом вдруг оказывается, что мы потеряли контроль на реквестом из респонсом и любой из 100500 разработчиков, которые приложили руку к созданию мидлваров, взятых в наш проект из сомнительных репозиториев, которые не обновлялись с 2017 года, может сделать, пусть ненароком, практически все что угодно с данным до того, как они доберутся к тому месту в программе, которое мы контролируем. И словив проблему, мы будем дебажить до рези в глазах эти цепочки вызовов мидлвар в попытке понять, что происходит.
          0
          Короче лучше свой велосипед? Я лично в таких делах воздержусь изобретать велосипед. Просто потому, что не считаю себя специалистом в этой области. Зачем мне наступать на грабли, на которые наступили впереди идущие. Для образовательных целей может быть ок, что бы понять как оно устроено, но реальном проекте вряд-ли, дороже выйдет.
          +1
          Стоит выбирать транспорт (если уж брать аналогию с транспортом), который довезет тебя до пункта назначения оптимальным образом и без особых проблем в пути. И да — иногда это может быть велосипед.
            +1

            Есть, например, fastify с prehandler и другие нормальные фреймворки.

              –4
              Шёл 2021 год, а люди всё искали альтернативу давно умершему Экспрессу. Ещё лет через 5, возможно, они откроют для себя Коа, а потом и нормальные фреймворки.
                +1

                Следующий этап понимания — а зачем мне middleware (в понимании express, где каждый норовит сунуть что нибудь в request)? )

                  0

                  Также middleware может и error первым параметром принять, и обработать его. Если вызвать next(err), то обработка не прекратится, поправьте меня если это не так(в контексте express).

                    0
                    В ExpressJS после ошибки вызывается middleware, которое задано в качестве обработчика ошибки, а следующие за упавшим игнорируются.

                    app.use((request, response, next) => {
                        console.log('Step 1');
                    
                        next(new Error('General error'));
                    });
                    
                    app.use((request, response, next) => {
                        console.log('Step 2');
                    
                        next();
                    });
                    
                    app.use((error, request, response, next) => {
                        console.log('Got error');
                    
                        response.status(500).send('Error!');
                    });
                    

                    В коде выше в консоли будет «Step 1» и «Got error».
                      +2

                      И обработчиков может быть несколько, если дальше next(err) вызывать.

                  Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                  Самое читаемое