Использование маршрутов Express.js для обработки ошибок на основе обещаний

Опубликовано: 2022-03-11

Слоган Express.js звучит правдоподобно: это «быстрый, бескомпромиссный, минималистичный веб-фреймворк для Node.js». Это настолько беспристрастно, что, несмотря на современные передовые методы JavaScript, предписывающие использование промисов, Express.js по умолчанию не поддерживает обработчики маршрутов на основе промисов.

Поскольку многие руководства по Express.js не учитывают эту деталь, разработчики часто имеют привычку копировать и вставлять код отправки результатов и обработки ошибок для каждого маршрута, создавая технический долг по ходу работы. Мы можем избежать этого антишаблона (и его последствий) с помощью техники, которую мы рассмотрим сегодня — той, которую я успешно использовал в приложениях с сотнями маршрутов.

Типичная архитектура для маршрутов Express.js

Начнем с учебного приложения Express.js с несколькими маршрутами для пользовательской модели.

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

В целом Yeoman может давать гораздо лучшие скелеты проекта, но для того, что нам нужно, мы просто создадим скелет проекта с помощью экспресс-генератора и удалим ненужные части, пока не получим вот это:

 bin start.js node_modules routes users.js services userService.js app.js package-lock.json package.json

Мы сократили строки оставшихся файлов, которые не связаны с нашими целями.

Вот основной файл приложения Express.js, ./app.js :

 const createError = require('http-errors'); const express = require('express'); const cookieParser = require('cookie-parser'); const usersRouter = require('./routes/users'); const app = express(); app.use(express.json()); app.use(express.urlencoded({ extended: false })); app.use(cookieParser()); app.use('/users', usersRouter); app.use(function(req, res, next) { next(createError(404)); }); app.use(function(err, req, res, next) { res.status(err.status || 500); res.send(err); }); module.exports = app;

Здесь мы создаем приложение Express.js и добавляем базовое промежуточное ПО для поддержки использования JSON, кодирования URL-адресов и разбора файлов cookie. Затем мы добавляем usersRouter для /users . Наконец, мы указываем, что делать, если маршрут не найден, и как обрабатывать ошибки, которые мы изменим позже.

Скрипт для запуска самого сервера — /bin/start.js :

 const app = require('../app'); const http = require('http'); const port = process.env.PORT || '3000'; const server = http.createServer(app); server.listen(port);

Наш /package.json также является базовым:

 { "name": "express-promises-example", "version": "0.0.0", "private": true, "scripts": { "start": "node ./bin/start.js" }, "dependencies": { "cookie-parser": "~1.4.4", "express": "~4.16.1", "http-errors": "~1.6.3" } }

Давайте используем типичную реализацию пользовательского маршрутизатора в /routes/users.js :

 const express = require('express'); const router = express.Router(); const userService = require('../services/userService'); router.get('/', function(req, res) { userService.getAll() .then(result => res.status(200).send(result)) .catch(err => res.status(500).send(err)); }); router.get('/:id', function(req, res) { userService.getById(req.params.id) .then(result => res.status(200).send(result)) .catch(err => res.status(500).send(err)); }); module.exports = router;

У него есть два маршрута: / для получения всех пользователей и /:id для получения одного пользователя по идентификатору. Он также использует /services/userService.js , который имеет методы на основе обещаний для получения этих данных:

 const users = [ {id: '1', fullName: 'User The First'}, {id: '2', fullName: 'User The Second'} ]; const getAll = () => Promise.resolve(users); const getById = (id) => Promise.resolve(users.find(u => u.id == id)); module.exports = { getById, getAll };

Здесь мы избегали использования фактического коннектора БД или ORM (например, Mongoose или Sequelize), просто имитируя выборку данных с помощью Promise.resolve(...) .

Проблемы с маршрутизацией Express.js

Глядя на наши обработчики маршрутов, мы видим, что каждый вызов службы использует повторяющиеся обратные вызовы .then(...) и .catch(...) для отправки данных или ошибок обратно клиенту.

На первый взгляд это может показаться несерьезным. Давайте добавим некоторые основные требования реального мира: нам нужно отображать только определенные ошибки и опускать общие ошибки уровня 500; кроме того, применяем ли мы эту логику или нет, должно быть основано на окружающей среде. При этом, как это будет выглядеть, когда наш примерный проект вырастет из двух маршрутов в реальный проект с 200 маршрутами?

Подход 1: служебные функции

Возможно, мы бы создали отдельные служебные функции для обработки resolve и reject и применяли их везде в наших маршрутах Express.js:

 // some response handlers in /utils const handleResponse = (res, data) => res.status(200).send(data); const handleError = (res, err) => res.status(500).send(err); // routes/users.js router.get('/', function(req, res) { userService.getAll() .then(data => handleResponse(res, data)) .catch(err => handleError(res, err)); }); router.get('/:id', function(req, res) { userService.getById(req.params.id) .then(data => handleResponse(res, data)) .catch(err => handleError(res, err)); });

Выглядит лучше: мы не повторяем нашу реализацию отправки данных и ошибок. Но нам по-прежнему нужно импортировать эти обработчики в каждый маршрут и добавлять их к каждому промису, переданному в then() и catch() .

Подход 2: ПО промежуточного слоя

Другим решением может быть использование лучших практик Express.js в отношении промисов: переместите логику отправки ошибок в промежуточное программное обеспечение ошибок Express.js (добавлено в app.js ) и передавайте ему асинхронные ошибки с помощью next обратного вызова. Наша базовая установка промежуточного программного обеспечения ошибок будет использовать простую анонимную функцию:

 app.use(function(err, req, res, next) { res.status(err.status || 500); res.send(err); });

Express.js понимает, что это для ошибок, потому что сигнатура функции имеет четыре входных аргумента. (Он использует тот факт, что каждый объект функции имеет свойство .length , описывающее, сколько параметров ожидает функция.)

Передача ошибок через next будет выглядеть так:

 // some response handlers in /utils const handleResponse = (res, data) => res.status(200).send(data); // routes/users.js router.get('/', function(req, res, next) { userService.getAll() .then(data => handleResponse(res, data)) .catch(next); }); router.get('/:id', function(req, res, next) { userService.getById(req.params.id) .then(data => handleResponse(res, data)) .catch(next); });

Даже используя официальное руководство по передовой практике, нам по-прежнему нужны наши JS-промисы в каждом обработчике маршрута, чтобы разрешать их с помощью handleResponse() и отклонять, передавая next функцию.

Давайте попробуем упростить это с помощью лучшего подхода.

Подход 3: ПО промежуточного слоя на основе обещаний

Одной из величайших особенностей JavaScript является его динамическая природа. Мы можем добавить любое поле к любому объекту во время выполнения. Мы будем использовать это для расширения объектов результатов Express.js; Функции промежуточного программного обеспечения Express.js — удобное место для этого.

Наша promiseMiddleware()

Давайте создадим наше промежуточное ПО обещаний, которое даст нам возможность более элегантно структурировать наши маршруты Express.js. Нам понадобится новый файл /middleware/promise.js :

 const handleResponse = (res, data) => res.status(200).send(data); const handleError = (res, err = {}) => res.status(err.status || 500).send({error: err.message}); module.exports = function promiseMiddleware() { return (req,res,next) => { res.promise = (p) => { let promiseToResolve; if (p.then && p.catch) { promiseToResolve = p; } else if (typeof p === 'function') { promiseToResolve = Promise.resolve().then(() => p()); } else { promiseToResolve = Promise.resolve(p); } return promiseToResolve .then((data) => handleResponse(res, data)) .catch((e) => handleError(res, e)); }; return next(); }; }

В app.js давайте применим наше промежуточное ПО ко всему объекту app Express.js и обновим поведение ошибки по умолчанию:

 const promiseMiddleware = require('./middlewares/promise'); //... app.use(promiseMiddleware()); //... app.use(function(req, res, next) { res.promise(Promise.reject(createError(404))); }); app.use(function(err, req, res, next) { res.promise(Promise.reject(err)); });

Обратите внимание, что мы не опускаем промежуточное ПО для обработки ошибок . Это по-прежнему важный обработчик всех синхронных ошибок, которые могут существовать в нашем коде. Но вместо того, чтобы повторять логику отправки ошибок, промежуточное ПО ошибок теперь передает любые синхронные ошибки той же самой центральной функции handleError() через Promise.reject() , отправленный в res.promise() .

Это помогает нам обрабатывать синхронные ошибки, подобные этой:

 router.get('/someRoute', function(req, res){ throw new Error('This is synchronous error!'); });

Наконец, давайте воспользуемся нашим новым res.promise() в /routes/users.js :

 const express = require('express'); const router = express.Router(); const userService = require('../services/userService'); router.get('/', function(req, res) { res.promise(userService.getAll()); }); router.get('/:id', function(req, res) { res.promise(() => userService.getById(req.params.id)); }); module.exports = router;

Обратите внимание на различные варианты использования .promise() : мы можем передать ему функцию или обещание. Передача функций может помочь вам с методами, у которых нет промисов; .promise() видит, что это функция, и заключает ее в обещание.

Куда лучше собственно отправлять ошибки клиенту? Это хороший вопрос по организации кода. Мы могли бы сделать это в нашем промежуточном программном обеспечении ошибок (потому что оно должно работать с ошибками) или в нашем промежуточном программном обеспечении обещаний (потому что оно уже взаимодействует с нашим объектом ответа). Я решил хранить все операции ответа в одном месте в промежуточном программном обеспечении обещания, но каждый разработчик должен организовать свой собственный код.

Технически res.promise() является необязательным

Мы добавили res.promise() , но мы не ограничены в его использовании: мы можем работать с объектом ответа напрямую, когда нам это нужно. Давайте рассмотрим два случая, когда это было бы полезно: перенаправление и конвейерная обработка потоков.

Особый случай 1: перенаправление

Предположим, мы хотим перенаправить пользователей на другой URL. Давайте добавим функцию getUserProfilePicUrl() в userService.js :

 const getUserProfilePicUrl = (id) => Promise.resolve(`/img/${id}`);

А теперь давайте используем его в роутере наших пользователей в стиле async / await с прямым манипулированием ответами:

 router.get('/:id/profilePic', async function (req, res) { try { const url = await userService.getUserProfilePicUrl(req.params.id); res.redirect(url); } catch (e) { res.promise(Promise.reject(e)); } });

Обратите внимание, как мы используем async / await , выполняем перенаправление и (что наиболее важно) по-прежнему имеем одно центральное место для передачи любой ошибки, потому что мы использовали res.promise() для обработки ошибок.

Особый случай 2: потоковые трубопроводы

Как и в случае с нашим маршрутом изображения профиля, передача потока по конвейеру — это еще одна ситуация, когда нам нужно напрямую манипулировать объектом ответа.

Чтобы обрабатывать запросы к URL-адресу, на который мы сейчас перенаправляемся, давайте добавим маршрут, который возвращает некоторую общую картинку.

Сначала мы должны добавить profilePic.jpg в новую подпапку /assets/img . (В реальном проекте мы бы использовали облачное хранилище, такое как AWS S3, но механизм конвейерной обработки был бы таким же.)

Давайте отправим это изображение в ответ на запросы /img/profilePic/:id . Для этого нам нужно создать новый маршрутизатор в /routes/img.js :

 const express = require('express'); const router = express.Router(); const fs = require('fs'); const path = require('path'); router.get('/:id', function(req, res) { /* Note that we create a path to the file based on the current working * directory, not the router file location. */ const fileStream = fs.createReadStream( path.join(process.cwd(), './assets/img/profilePic.png') ); fileStream.pipe(res); }); module.exports = router;

Затем мы добавляем наш новый маршрутизатор /img в app.js :

 app.use('/users', require('./routes/users')); app.use('/img', require('./routes/img'));

Одно отличие, вероятно, выделяется по сравнению со случаем перенаправления: мы не использовали res.promise() в маршрутизаторе /img ! Это связано с тем, что поведение уже переданного объекта ответа с ошибкой будет отличаться от поведения ошибки в середине потока.

Разработчики Express.js должны быть внимательны при работе с потоками в приложениях Express.js, обрабатывая ошибки по-разному в зависимости от того, когда они возникают. Нам нужно обрабатывать ошибки перед конвейерной передачей ( res.promise() может помочь нам в этом ), а также в середине потока (на основе .on('error') ), но дальнейшие подробности выходят за рамки этой статьи.

Улучшение res.promise()

Как и в случае с вызовом res.promise() , мы также не привязаны к его реализации . promiseMiddleware.js можно расширить, чтобы принимать некоторые параметры в res.promise() , чтобы позволить вызывающим сторонам указывать коды состояния ответа, тип контента или что-либо еще, что может потребоваться проекту. Разработчики должны формировать свои инструменты и организовывать свой код так, чтобы он наилучшим образом соответствовал их потребностям.

Обработка ошибок Express.js соответствует современному кодированию на основе обещаний

Представленный здесь подход позволяет использовать более элегантные обработчики маршрутов , чем мы начали, и единую точку обработки результатов и ошибок — даже тех, которые запускаются вне res.promise(...) — благодаря обработке ошибок в app.js Тем не менее, мы не вынуждены использовать его и можем обрабатывать крайние случаи по своему усмотрению.

Полный код из этих примеров доступен на GitHub. Оттуда разработчики могут добавить собственную логику в handleResponse() , например, изменить статус ответа на 204 вместо 200, если данные недоступны.

Однако добавленный контроль над ошибками гораздо полезнее. Такой подход помог мне лаконично реализовать эти функции в продакшене:

  • Отформатируйте все ошибки последовательно как {error: {message}}
  • Отправить общее сообщение, если статус не указан, или передать данное сообщение в противном случае
  • Если среда dev (или test и т. д.), заполните поле error.stack
  • Обрабатывать ошибки индекса базы данных (т. е. некоторый объект с полем с уникальным индексом уже существует) и корректно реагировать на значимые пользовательские ошибки.

Вся эта логика маршрутов Express.js была собрана в одном месте, не касаясь какой-либо службы — такое разъединение значительно упростило поддержку и расширение кода. Вот как простые, но элегантные решения могут радикально улучшить структуру проекта.


Дальнейшее чтение в блоге Toptal Engineering:

  • Как создать систему обработки ошибок Node.js