Конвейер: собираем next() и понимаем, откуда взялся Express

HTTP≈ 16 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Маршрутизатор решил, куда идёт запрос, и на этом его работа кончилась. А у каждого запроса, кроме его собственного дела, есть ещё общие: записаться в журнал, показать токен, отдать разобранное тело, засечь время ответа, а если что-то упало — превратить это в честный 500, а не в оборванное соединение. Сейчас все они лежат копиями внутри обработчиков, и стоит одной копии отстать от остальных, как в сервере появляется дыра, которую не видно глазами. Соберём конвейер: цепочку независимых звеньев, у каждого из которых на входе запрос, ответ и функция next(). Вызвал next() — пустил запрос дальше; не вызвал — ответил сам; написал строку после next() — она выполнится, когда ответ уже сложен. Из этих трёх правил вырастает Express, и к концу статьи вы увидите, чем ваш next() отличается от его.

Разделы темы:·

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

Прогноз: какой из трёх запросов сервер выполнит вообще без токена?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Первые два ответа такие, как задумано: гостю 401, читателю с токеном — заметка. А POST /notes создал заметку неизвестно кому: в третьем обработчике проверки просто нет. Дыра при этом не в маршрутизаторе и не в разборщике — оба отработали честно, маршрут найден, ответ отдан, код 201. Дыра в том, что общее дело размножено копиями: копий три, а забыть достаточно в одной.

И дел этих больше, чем два. Журнал, токен, разбор тела, время ответа, заголовки ответа, перевод исключения в 500 — шесть. Обработчиков в небольшом API — десятка полтора. Девяносто мест, которые надо не забыть, а потом одинаково поменять. Так не выходит ни у кого, и лечится это не внимательностью, а устройством: общее дело должно лежать в одном месте, через которое проходит каждый запрос.

Забытая строка не падает и не светится в тестах маршрутизатора: маршрут найден, ответ отдан, код 201. Такую дыру находят по журналу, в котором её как раз и нет.

Присмотримся к общим делам внимательнее. У каждого есть часть до обработчика и часть после: журнал пишет строку о запросе до и строку об ответе после, таймер засекает до и считает после, проверка токена — только до, а заголовки ответа — только после. Значит, звену мало «выполниться перед обработчиком»: ему нужно место с обеих сторон.

Договор будет такой. Звено — функция трёх аргументов: запрос, ответ и next. Вызов next() передаёт управление следующему звену и возвращается сюда же, когда всё, что стоит дальше, отработало. Последнее звено next() не зовёт: за ним никого нет. Сам конвейер — восемь строк, которые не знают ни про HTTP, ни про маршруты.

Прогноз: в каком порядке напечатаются шесть строк и какой код ответа увидит журнал?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Вышла матрёшка, а не очередь. Звено, которое стоит первым, начинает раньше всех и заканчивает позже всех: его «после» выполняется последним, когда ответ уже сложен, — потому и код в нём 200, а не 404, с которым конвейер начинался. Обработчик оказался в середине конструкции, хотя в массиве он последний.

Стоит заметить, чего в runPipeline нет. Он не знает, что такое запрос: для него это объект, который он передаёт дальше. Не знает, что такое ответ. Не знает, сколько звеньев и что они делают. Всё его устройство — «позвать следующего и дать ему способ позвать следующего за ним», и этого хватает, чтобы из отдельных функций собралась одна обработка запроса.

Звено не знает ни своих соседей, ни своего номера в цепочке. Поэтому цепочку можно пересобрать в другом порядке, и ни одно звено об этом не узнает — а поведение сервера изменится.

Синхронный конвейер годится ровно до первого настоящего обработчика. Настоящий идёт в базу, дочитывает тело из сокета, стучится в почту — и возвращается не сразу. Значит, next() обязан вернуть промис, а звено — его дождаться. Переделка мелкая: step становится async, а вызов звена — с await. И на этой мелочи появляется ловушка: await перед самим next() внутри звена легко не написать, а код при этом не падает и даже что-то печатает.

Прогноз: какой код ответа напечатает журнал и когда напечатается строка «обработчик: ответил»?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Пять строк, и три из них не на своём месте. Звено напечатало «после» с кодом 404, хотя обработчик в этот момент только пошёл в базу, и весь конвейер закончился раньше, чем ответ был собран. Причина знакомая: вызов асинхронной функции без await просто возвращает промис и мгновенно идёт дальше. Промис, который вернул next(), никто не забрал, и звено продолжило свой код так, будто внизу уже всё сделано.

В консоли это выглядит как перепутанные строки. На сервере это ответ, который ушёл клиенту раньше, чем обработчик его собрал: код 404 вместо 200, пустое тело — и попытка обработчика ответить второй раз, когда он наконец закончит. Поэтому в конвейере не бывает «почти синхронных» звеньев: звено объявляется async, а next() пишется с await. Годится и return next() — но только там, где после него в звене ничего нет: промис тогда забирает тот, кто звено позвал. Даже если сегодня за звеном стоит синхронный обработчик — завтра за ним будет база.

Строку «журнал: после» в жизни печатают не в консоль, а в журнал сервера, и код ответа в ней — тот, по которому потом разбирают инциденты. Забытый await превращает такой журнал в выдумку.

Третье правило: звено не обязано звать next(). Проверка токена, ограничитель частоты, кеш, нашедший готовый ответ, — все они иногда обрывают путь запроса и отвечают сами. Обрыв не ошибка, а обычный исход: до обработчика запрос просто не доходит.

Заодно доведём конвейер до конца — в нём появится защёлка в три строки, и о ней сразу после примера. Прогноз: напечатает ли журнал своё «после», если страж не пустил гостя дальше?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Два запроса — два пути. У гостя цепочка оборвалась на страже: обработчик не выполнился и о запросе не узнал. Но журнал, который стоит перед стражем, свою вторую строку напечатал, и код в ней — 401, поставленный стражем. «Не пустил дальше» не значит «прервал всё»: конвейер сворачивается назад ровно так же, как разворачивался, и каждое звено получает управление обратно. Поэтому журнал, таймер и заголовки ответа работают одинаково и на 200, и на 401 — они их не различают.

Страж на входе — это экономия, а не охрана: он не пускает гостя к базе. Но привратник, которого можно обойти, охраной не считается, а обойти конвейер нетрудно: обработчик могут позвать мимо него — из фоновой задачи, из соседнего модуля, из другого транспорта. Настоящая проверка прав живёт там, где лежат данные. В главе про свои усилители страж станет отдельным видом звена, у которого вместо ответа — «пускать» или «нет».

Теперь про защёлку. Индекс звена в step только растёт: конвейер помнит, до какого звена он уже дошёл, и на попытку вернуться назад бросает исключение. Три строки написаны против одной-единственной ошибки — двух вызовов next() в одном звене.

Второй next() — это второй проход по всему хвосту конвейера: обработчик выполнится дважды, ответ соберётся дважды. Без проверки это выглядело бы как загадка «почему заметка создалась два раза»; с проверкой — как исключение, которое ловец переведёт в 500.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

В настоящем сервере второй ответ упирается в сокет, в который первый уже записан. Проверено 22.09.2026 на Express 5.2.1 и node 26.5.0: если за обработчиком в цепочке есть ещё один отвечающий слой, второй next() доводит запрос и до него, и на попытке ответить второй раз Express получает ERR_HTTP_HEADERS_SENT — «Cannot set headers after they are sent to the client». Клиент при этом получает первый ответ и ни о чём не подозревает, а исключение уезжает в обработчик ошибок. Голый node:http на втором ответе даёт ту же ошибку своими словами: «Cannot write headers after they are sent to the client».

Звено, которое ответило само, обязано вернуться, а не просто «не звать next()»: без return выполнение пойдёт по звену дальше — то есть как раз в next(), и гость проедет внутрь с готовым 401 в ответе.

Ещё одно дело, которое лежало копиями в обработчиках, — try..catch. Обработчик падает не потому, что программист плох, а потому, что база отвалилась, в теле оказалось не то, а в коде — опечатка, которую не поймал ни один тест. Клиенту в этот момент нужен честный 500, а не оборванное соединение и не зависший запрос.

В конвейере для этого хватает одного звена, и стоять оно должно первым: try вокруг await next() накрывает всё, что конвейер позовёт после. Прогноз: какие из двух строк «после» напечатаются, если обработчик бросит исключение?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Напечаталась одна. Исключение из обработчика поднимается сквозь await next() вверх по конвейеру, как по обычному стеку вызовов, — и код, стоящий после next(), при этом не выполняется вовсе: строка «журнал: после» пропала. Таймер свою вторую строку напечатал, потому что написал её в finally. Разница ровно та же, что у обычного try..catch: «после» — это продолжение звена, а не обещание конвейера.

Отсюда правило. Всё, что должно случиться при любом исходе — закрыть транзакцию, снять блокировку, вернуть соединение в пул, дописать строку в журнал, — пишется в finally. Всё, что имеет смысл только для удавшегося ответа, — после next(). Путать эти два места дорого: журнал, который ведётся только для удачных запросов, молчит ровно тогда, когда он нужнее всего.

Наш ловец переводит в 500 что угодно, и пока это честно: ошибка, о которой мы ничего не знаем, — наша. Но большая часть ошибок настоящего сервера знает о себе больше: «нет такой заметки» — это 404, «чужая заметка» — 403, «в теле мусор» — 400. Как перевести доменную ошибку в код ответа одним местом, разберём в главе про свой конвейер; сейчас важно, что место для такого перевода в сервере уже есть и оно ровно одно.

Ловец не всесилен: если ответ уже ушёл в сокет, а исключение случилось после этого, поменять код ответа нельзя — остаётся закрыть соединение и записать строку в журнал.

Соберём главу целиком. Разборщик превращает байты в запросы. Конвейер проводит запрос через журнал и стража. Маршрутизатор становится последним звеном: он выбирает обработчик, зовёт его и кладёт результат в ответ. Никакого главного кода, который всем этим руководит, в сервере больше нет — есть список звеньев.

Одна тонкость на стыке. Разборщик отдаёт запросы синхронно, прямо из feed: получил кусок байтов — тут же позвал onRequest. А конвейер асинхронный. Поэтому запросы складываются в очередь и проходят конвейер по одному: на настоящем сервере у каждого соединения свой поток запросов и одновременность получается сама, а здесь хватает цикла.

Прогноз: какой из пяти запросов не дойдёт до маршрутизатора и что ответит DELETE /notes?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Пять запросов — четыре разных исхода, и ни один обработчик не знает ни о журнале, ни о токене. POST без токена до маршрутизатора не дошёл: страж ответил сам. POST с токеном дошёл и создал заметку. DELETE /notes прошёл весь конвейер, но маршрута не нашёл — нашёл только путь, и вернул 405 со списком методов. GET /health не нашёл ничего. Журнал при этом одинаково написал по две строки на каждый запрос, включая те, что оборвались на полпути.

А теперь посмотрите на первые строки любого проекта на Express. app.use(звено) кладёт функцию в массив; app.get(путь, обработчик) кладёт в тот же массив звено, которое сначала сверяет метод и путь, а потом зовёт ваш обработчик. Когда приходит запрос, Express идёт по массиву по индексу и передаёт каждому звену третьим аргументом функцию next. Это ваш runPipeline, написанный чуть аккуратнее и обросший сахаром вокруг ответа.

У васВ ExpressВ Nest
массив звеньев и runPipeline, который по нему идётapp.use(звено) — тот же массив внутри appпрослойка (middleware), список задаёт модуль
звено не позвало next() и ответило самото же самоестраж: вместо ответа возвращает «пускать» или «нет»
ловец первым звеномобработчик ошибок — звено с четырьмя аргументами (err, req, res, next), и ставят его последнимфильтр исключений
код после await next()next() ничего не возвращает: «после» пишут в событии finish у ответаперехватчик: «до» и «после» в одном месте

Последняя строка таблицы — единственное место, где ваш конвейер и Express расходятся по существу. В Express next() ничего не возвращает: звено передаёт управление дальше и тут же продолжает свой код, не дожидаясь обработчика. Проверено 22.09.2026 на Express 5.2.1 и node 26.5.0: строка после next() печатается ещё до того, как асинхронный обработчик дошёл до ответа, и res.headersSent в ней false. Поэтому «после» в Express пишут не после next(), а в обработчике события finish. Ваш конвейер в этом месте устроен как koa: next() возвращает промис, и «после» — просто следующая строка. У перехватчика Nest место то же, но next.handle() возвращает не промис, а Observable из rxjs.

Отклонённый промис обработчика Express 5 подхватывает сам: проверено 22.09.2026 на 5.2.1 — async-обработчик бросил TypeError, и звено с четырьмя аргументами получило его без единого next(err). В Express 4 это не работало, и try..catch с next(err) в каждом асинхронном обработчике был обязательным обрядом — он до сих пор попадается в чужом коде и в ответах на форумах.

Место у ловца при этом другое. Ваш стоит первым, потому что его try обнимает весь хвост конвейера; в Express звено с четырьмя аргументами ставят последним — ошибка приходит к нему не возвратом вверх по цепочке, а передачей вперёд, как обычный next(err). Проверено 22.09.2026 на 5.2.1: тот же обработчик ошибок, поставленный первым, не получает ничего, а поставленный после маршрутов — получает.

Всё остальное в Express — тоже звенья. express.json() — звено разбора тела. express.static() — звено, которое отвечает файлом, а если файла нет, зовёт next() и пускает запрос дальше. cors() — звено, которое дописывает заголовки. Ни одного нового понятия сверх тех четырёх, что вы написали сами: цепочка, next(), обрыв и ловец.

Вот и ответ на вопрос из названия. Фреймворк — это ваш же код, доведённый до конца и упакованный: тот же массив, тот же обход по индексу, та же функция третьим аргументом. Не хватает вашему серверу не абстракций, а строгости — к разбору заголовков, к размеру тела, к удержанию соединения. Следующая статья ставит рядом настоящий node:http и называет эти дыры поимённо, а в главе про каркас конвейер, маршрутизатор и контейнер соберутся в каркас целиком — миниатюру Nest, которая сама отвечает 200 и 404.

Express и koa разошлись ровно на этой строке: у koa next() возвращает промис, и «до» и «после» пишутся в одном звене. Из этого выбора выросли два разных мира middleware.

  • Звено конвейера — функция трёх аргументов: запрос, ответ и next. Позвал next() — пустил запрос дальше по цепочке, не позвал — ответил сам.
  • Конвейер — матрёшка, а не очередь: первое звено начинает раньше всех и заканчивает позже всех, и его «после» видит уже собранный ответ.
  • Любое звено объявляется async, а next() зовётся с await: без него звено не ждёт обработчика и записывает в журнал — или отправляет клиенту — недособранный ответ.
  • Обрыв цепочки не отменяет «после» у звеньев, которые стоят раньше: конвейер сворачивается назад целиком, поэтому журнал и таймер одинаково работают и на 200, и на 401.
  • Исключение поднимается сквозь await next() и выносит остаток звена. То, что обязано случиться при любом исходе, пишется в finally; ловец стоит первым звеном, потому что видит только то, что конвейер позовёт после него.
  • Express — тот же массив звеньев и тот же next третьим аргументом; расходится он с вашим конвейером в одном: его next() ничего не возвращает, и «после» в нём пишут через событие finish у ответа.

1/4В цепочке из трёх звеньев каждое печатает «до», зовёт next() и печатает «после»; обработчик стоит последним. Чья строка «после» напечатается первой?

2/4Звено написало next() без await, а обработчик асинхронный. Что будет в ответе на следующей строке звена?

3/4Второе звено цепочки не позвало next(), а ответило само. Что станет с кодом, который стоит после next() в первом звене?

4/4Обработчик бросил исключение, первым звеном стоит ловец с try вокруг await next(). Почему строка после await next() в среднем звене не выполнилась?

Звено под адресом

Журнал пишет строку на каждый запрос, включая проверки живости от балансировщика, — а их десятки в минуту, и журнал скоро состоит из них одних. В Express для этого есть app.use("/api", звено): звено выполняется только для запросов под этим адресом, остальные идут дальше нетронутыми. Напишите обёртку only(prefix, link). Следите за границей: /apiary/notes адресом под /api считаться не должен.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Граница проходит по сегменту, а не по началу строки: одного startsWith("/api") мало — он пропустит /apiary. Путь берётся у splitTarget: строка запроса к выбору звена отношения не имеет.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Обёртка ничего не знает о том, что она обернула, и потому годится любому звену. Ровно так же устроены в Express и app.use(путь, звено), и сам маршрут: app.get — это тоже звено с условием.

Разбор тела звеном

Каждый обработчик сам зовёт JSON.parse(request.body), и на кривом теле сервер отвечает 500: исключение из JSON.parse ничем не отличается от нашей собственной ошибки, и ловец честно переводит его во «внутреннюю ошибку». Но виноват тут клиент, и правильный ответ — 400. Напишите звено parseBody: пустое тело пропустить, разобранный JSON положить в request.json, на мусор ответить 400 и дальше не идти. Обработчики после этого возьмут готовое.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Звено делает три вещи, и каждая — про границу ответственности: нет тела — разбирать нечего, разобралось — кладём в запрос, не разобралось — это 400, и конвейер дальше не идёт.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

А обработчики становятся короче ровно на разбор:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Настоящее такое звено смотрит ещё и на content-type — разбирать как JSON стоит только то, что объявлено JSON, — и на размер: предел ставится там же, где тело читают, а не после разбора.

Ловец, который ничего не ловит

Ловец в этой цепочке стоит последним — и 500 не получается: обработчик падает, а клиент получает 404, который лежал в ответе с самого начала. Объясните, почему так, и почините цепочку, не меняя ни одного звена.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ловец видит только то, что зовёт после себя: try стоит вокруг await next(), а next() — это весь хвост цепочки. Стоя последним, он не оборачивает ничего: обработчик выполнился раньше и бросил исключение мимо него. Лечится это порядком звеньев, а не их кодом.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

В цепочке промисов действует то же правило: catch видит только то, что стоит выше него. А исключение, вылетевшее из конвейера наружу, на настоящем сервере кончается необработанным отклонением: запрос висит без ответа, и node 26 по умолчанию валит процесс (проверено 22.09.2026: код выхода 1).