Свой маршрутизатор: сегменты, параметры, строка запроса, 404 и 405
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Запрос теперь приезжает собранным: метод, цель, заголовки, тело. И попадает в одну-единственную функцию, которая сама разбирается, что ей прислали. Дальше так нельзя: POST /notes и GET /notes/17 — разные работы, и расходиться они обязаны раньше, чем начнётся их код. Соберём маршрутизатор: он режет путь на сегменты, достаёт из них :id, читает page и limit с умолчаниями и потолком — и различает «нет такого адреса» и «адрес есть, а метод не тот». Половину этой работы вы уже делали в части IV, для файлового маршрутизатора: там она доставалась дереву папок, здесь — строке из сокета. В ней прячется ловушка, которую видно только на запуске.
Из разборщика запрос выходит с полем target — куском текста из стартовой строки; node:http кладёт его же в req.url. Маршрутизатору нужно из него две вещи: путь, по которому ищут обработчик, и строка запроса, которая поедет к обработчику отдельно. Делит их первый ? — и только первый: в пути вопросительный знак невозможен, там он обязан быть записан как %3F, а значит, все следующие принадлежат уже строке запроса.
Сегменты пути даёт split("/"). Прогноз: сколько сегментов окажется у /notes/ и у / — и что выйдет из цели *?
Слэш в начале пути есть всегда, поэтому первый кусок после split — пустая строка; её и снимает slice(1). Слэш в конце такой заботы не получает: у /notes/ сегментов два, и второй пустой — для маршрутизатора /notes и /notes/ разные пути, и это решение придётся принять сознательно (задача в конце статьи как раз о нём). У корня / сегмент один, и он тоже пустой. А у цели * сегментов нет вовсе: это не путь, а особая форма записи — так спрашивают OPTIONS * HTTP/1.1, «что вообще умеет сервер». Отсюда простое правило перед всяким сопоставлением: цель, не начинающаяся со слэша, — не адрес вашего приложения.
Вторая половина цели в выборе обработчика не участвует вовсе: /notes, /notes?page=2 и /notes?page=3 — один адрес и один обработчик. Это не упрощение, а договор: путь отвечает на вопрос «что», строка запроса — «какое именно», и как только выбор обработчика начнёт зависеть от параметра, ваш сервер перестанут понимать и кеши, и журналы, и вы сами через полгода.
Соблазн велик: одна строка — и готовы pathname и searchParams. Так и правда делают, но знать надо три вещи (проверено на node 26.5.0). Первая: pathname остаётся закодированным — /files/a%2Fb он так и отдаёт, декодировать всё равно вам. Вторая: цель не обязана начинаться со слэша. Клиент вправе прислать GET http://other.example/notes HTTP/1.1 — это абсолютная форма записи, и RFC 9112 велит серверу её принимать; node:http пропускает такой запрос до обработчика как есть, а new URL возьмёт хост из цели, а не ваш. Третья и худшая: цель //evil.example/notes node тоже пропускает, а new URL прочитает её как адрес без схемы, и url.host станет evil.example. Путь в обоих случаях получается правильный, /notes, так что маршрут найдётся и ничего не сломается на вид — а вот всё, что вы построите из url.host или url.origin (ссылка в письме, ключ кеша, адрес переадресации), окажется чужим.
Имя req.url историческое: настоящего URL — со схемой и хостом — в нём нет и не было, там лежит ровно то, что клиент написал в стартовой строке.
Маршрут — это метод, шаблон пути и обработчик. Шаблон пишут той же строкой, что и адрес, только переменные места отмечают двоеточием: /notes/:id. Сравнивать шаблон с путём целиком бессмысленно — на этом и сломался браузерный маршрутизатор части IV, где строки сверялись как есть и /tasks/42 в списке не находился. А посегментно — легко: длины должны совпасть, буквальные сегменты — совпасть точно, а сегмент с двоеточием забирает что угодно и кладёт добычу в params. Переменный сегмент вы уже собирали — там он был папкой со скобками в имени, а спор «буква или скобки» решался порядком двух проверок. Дерева здесь нет — есть плоский список маршрутов, и спор за сегмент он разрешает иначе; к этому мы вернёмся, когда список появится.
Прогноз: какие из пяти путей подойдут шаблону /notes/:id/comments?
Двоих отсекает длина: у /notes/17 сегментов меньше, у /notes/17/comments/3 больше. Третий, /notes//comments, по длине подошёл — и всё-таки отвергнут: сегмент пустой, а пустой id не идентификатор, а забытая переменная в коде клиента. Пропустить его — значит отдать обработчику params.id === "" и узнать об этом уже в запросе к базе. Четвёртый случай важнее всех: /Notes/17/comments не подошёл, потому что путь в HTTP чувствителен к регистру — в отличие от схемы и хоста, которые сравниваются без его учёта. Если вы хотите отвечать и на /Notes, это отдельное решение и отдельный код.
Стоит заметить, где именно шаблон превращается в массив сегментов. Разбирать его на каждом запросе незачем: маршрутов в приложении десятки, а запросов — тысячи, и шаблоны между ними не меняются. Поэтому строку режут один раз, при объявлении маршрута, а на запросе остаётся сравнение двух готовых массивов. Ровно так устроены и взрослые маршрутизаторы, только вместо массива у них дерево или скомпилированное регулярное выражение — механика поиска от этого не меняется, меняется цена одного сравнения.
Имя параметра становится ключом в params, и двух одинаковых имён в одном шаблоне быть не должно: второй :id молча затрёт первый.
Пока в адресах была одна латиница, всё сходилось. Но в путях бывают кириллица, пробелы и слэши внутри имён, а по проводу такое не ездит: RFC 3986 держит в адресе ограниченный набор символов, всё остальное записывается процентами. Кодирует клиент — fetch делает это молча и сам, — а декодировать вам. Прогноз: что окажется в req.url у адреса с кириллицей и куда денется #top?
// Что на самом деле доезжает до сервера в req.url: адрес с кириллицей, «/» внутри // значения и фрагмент. Клиент — настоящий fetch, адреса он собирает сам. import { createServer } from "node:http"; const server = createServer((req, res) => { const mark = req.url.indexOf("?"); const pathname = mark === -1 ? req.url : req.url.slice(0, mark); const search = mark === -1 ? "" : req.url.slice(mark + 1); console.log(` req.url: ${req.url}`); const afterSplit = pathname.split("/").slice(1).map((part) => decodeURIComponent(part)); const beforeSplit = decodeURIComponent(pathname).split("/").slice(1); console.log(` split, потом decodeURIComponent — сегментов ${afterSplit.length}: ${afterSplit.join(" | ")}`); console.log(` decodeURIComponent, потом split — сегментов ${beforeSplit.length}: ${beforeSplit.join(" | ")}`); if (search !== "") { console.log(` строка запроса: ${search}`); console.log(` URLSearchParams: search = ${new URLSearchParams(search).get("search")}`); } res.end(); }); server.listen(0, "127.0.0.1", async () => { const { port } = server.address(); const base = `http://127.0.0.1:${port}`; console.log(`сервер слушает ${base}`); for (const target of ["/время/Москва", "/files/a%2Fb", "/notes?search=кот и пёс#top"]) { console.log(`fetch просили о: ${target}`); await fetch(base + target); } server.close(); });
сервер слушает http://127.0.0.1:<порт> fetch просили о: /время/Москва req.url: /%D0%B2%D1%80%D0%B5%D0%BC%D1%8F/%D0%9C%D0%BE%D1%81%D0%BA%D0%B2%D0%B0 split, потом decodeURIComponent — сегментов 2: время | Москва decodeURIComponent, потом split — сегментов 2: время | Москва fetch просили о: /files/a%2Fb req.url: /files/a%2Fb split, потом decodeURIComponent — сегментов 2: files | a/b decodeURIComponent, потом split — сегментов 3: files | a | b fetch просили о: /notes?search=кот и пёс#top req.url: /notes?search=%D0%BA%D0%BE%D1%82%20%D0%B8%20%D0%BF%D1%91%D1%81 split, потом decodeURIComponent — сегментов 1: notes decodeURIComponent, потом split — сегментов 1: notes строка запроса: search=%D0%BA%D0%BE%D1%82%20%D0%B8%20%D0%BF%D1%91%D1%81 URLSearchParams: search = кот и пёс
Кириллица приезжает процентами — по три знака на байт, то есть по шесть на букву, — и оба порядка действий дают одно и то же: два сегмента, «время» и «Москва». А вот на втором адресе они расходятся. %2F — это код слэша, и если декодировать целый путь до split, слэш из значения станет настоящим разделителем: было имя a/b, стало два сегмента a и b. Клиент просил один файл со слэшем в имени, сервер услышал папку и файл в ней. Третий адрес показывает ещё две вещи: фрагмент #top до сервера не доехал вовсе — его отрезает клиент, и серверу он не отправляется никогда, — а строку запроса URLSearchParams разобрал сам, вернув пробелы пробелами.
Повторим оба порядка в песочнице и добавим то, что бывает, когда процент испорчен. Прогноз: что вернёт decodeURIComponent("%2") и что выйдет из пути /notes/%D0?
Порядок «сначала разрезать, потом декодировать» — не стилистическая мелочь, а единственный правильный: после split разделители уже расставлены, и декодирование не может их добавить. Оба испорченных процента дают URIError: у %2 не хватает цифры, у %D0 цифр достаточно, но один этот байт не складывается в букву UTF-8. Ловить исключение обязательно, и отвечать на него надо 400, а не 404: «адрес не разобрать» — это не «такого адреса нет». Сравните с /notes/a+b: в пути плюс — обычный символ, декодирование его не трогает. В строке запроса тот же плюс значит пробел — это различие покажет следующий раздел.
Та же ловушка живёт и с другой стороны провода. Собирая адрес, кодировать надо каждое значение отдельно — encodeURIComponent для сегмента, а не encodeURI для целого пути: второй нарочно не трогает разделители, и слэш из имени файла уехал бы к серверу настоящим слэшем. Правило одно и то же в обе стороны: кодируют и декодируют значения, а не адреса.
Декодировать сегмент надо через decodeURIComponent, а не decodeURI: второй нарочно оставляет %2F и %3F как есть — он умеет разбирать целый адрес, а не его кусок.
Путь отвечает на вопрос «что», строка запроса — «какое именно»: страница, размер страницы, поиск, фильтры. Разбирать её руками не нужно: URLSearchParams есть и в браузере, и в Node, и проценты, плюсы вместо пробелов и повторы он уже умеет. С другой стороны провода вы собирали такую строку тем же классом — теперь разбираете. Прогноз: что вернёт get для повторённого tag и для параметра draft, записанного без знака равенства?
Три вещи стоит запомнить сразу. Повтор не ошибка и не замена: get отдаёт первое значение, getAll — все, и выбор между ними делаете вы (у фильтра «тег» честнее getAll, у страницы — get). Параметр без значения — это пустая строка, а не отсутствие: has говорит true, get отдаёт "". И только у параметра, которого нет, get возвращает null. Разница между "" и null кажется схоластикой ровно до следующего примера.
Теперь то, ради чего строка запроса и нужна серверу: страница и её размер. Обязанностей здесь три — умолчание, проверка и потолок. Прогноз: во что превратятся limit= без значения, limit=12 котов и page=-3?
Функция toInt длиннее, чем хочется, и каждая её строка — про отдельную ловушку. Number("") — это ноль, а не NaN, поэтому ?limit= без проверки на пустоту дал бы предел в ноль строк и пустую страницу вместо умолчания. Number("12 котов") — NaN, а parseInt того же вернул бы 12, то есть сервер сделал бы вид, что понял написанное; на границе такое поведение дороже, чем кажется. Number.isInteger отсекает разом и NaN, и дробное 10.5. А потолок — не про аккуратность: без него ?limit=1000000 означает «отдай всю таблицу», и выполнит эту просьбу ваша база. Умолчание, проверка и потолок в одном месте — это и есть разбор границы; отдельная статья главы 6 соберёт из них общий механизм, который делает то же самое со всем входом сразу.
limit из строки запроса — то, что попросил клиент, а не то, что вы обязаны отдать. Ответ честнее, когда рядом с данными едет применённый предел: клиент попросил тысячу, получил сто и знает об этом.
Три куска готовы: цель делится на путь и строку запроса, путь — на сегменты, сегменты сопоставляются с шаблоном. Осталось сложить их в объект, который принимает маршруты и отвечает на запрос. И тут появляется различие, ради которого написана статья. Если путь не подошёл ни одному маршруту — это 404, «нет такого адреса». Если путь подошёл, а метод не тот — это 405, и вместе с ним обязателен заголовок Allow со списком методов, которые этот адрес умеет: RFC 9110 требует его именно у 405. Разница не косметическая. 404 говорит «такого не существует», 405 — «существует, но не так», и для клиента это разные новости: по первой он ищет опечатку в адресе, по второй — в своём коде.
Прогноз: сколько ответов из восьми будут 405 и что встанет в Allow у PUT /notes/17?
Маршрут ищется одним проходом по списку, и заодно в этом проходе копятся методы, которые подошли бы по пути: если ни одного обработчика не нашлось, а множество не пусто — это и есть 405. PUT /notes/17 собрал GET, DELETE, DELETE /notes — GET, POST: в обоих случаях адрес существует, и сервер сказал об этом прямо. /files/a%2Fb дошёл до обработчика с params.name === "a/b" — ровно потому, что декодирование стоит после split. А /notes/ и /заметки получили честный 404: первого адреса у нас нет из-за хвостового слэша, второго — нет вовсе.
Обратите внимание, чего find не делает: он не пишет в сокет, не зовёт обработчик и вообще ничего не отвечает. Он возвращает находку — обработчик с разобранным адресом или код отказа, — а отвечать будет тот, кто его позвал. Разделение не ради красоты: маршрутизатор в таком виде проверяется без сервера, сокета и портов, обычным примером в песочнице, — чем мы весь раздел и занимаемся.
Осталось одно свойство, которое стоит увидеть глазами, потому что оно портит людям вечера. Маршрут ищется первым совпадением, а значит, порядок объявления — часть поведения. Прогноз: что ответит /notes/new в двух одинаковых наборах маршрутов, объявленных в разном порядке?
Один и тот же адрес попал в разные обработчики — вся разница в том, какая строка объявлена раньше. В части IV этот спор решало само дерево: буквальная папка проверялась раньше переменной. Плоскую таблицу маршрутов вы тоже видели — в части V, когда собирали адреса для Expo: там этот спор решал не порядок объявления, а сортировка: таблица сортировалась один раз, при сборке, и маршруты с меньшим числом переменных сегментов вставали выше. Наш список не делает ни того, ни другого — он читается сверху вниз, как написан. Express ведёт себя так же (проверено на 5.2.1): объявите /notes/:id раньше /notes/new — и форма новой заметки станет заметкой с номером «new». Значит, выбор из двух: либо держать точные маршруты выше переменных и помнить об этом — так делает большинство, — либо один раз отсортировать при добавлении и о порядке забыть.
Проверено 22.09.2026 на тех версиях, что стоят в носителе прогонов. Express 5.2.1: объявлены GET /notes и POST /notes, приходит DELETE /notes — ответ 404 и никакого Allow; зато на OPTIONS /notes Express отвечает сам, 200 и Allow: GET, HEAD, POST. Fastify 5.12.4 отвечает 404 и на DELETE, и на OPTIONS. Зато HEAD оба обслуживают обработчиком GET: ответ 200 с правильным content-length и пустым телом. Вывод для своего сервера: 405 — ваше решение и ваш код, каркас его за вас не примет, а HEAD без особой заботы попадёт у нас в 405, чего быть не должно (задача 2).
Множество вместо массива у списка методов — не ради скорости: один и тот же метод может подойти по нескольким маршрутам сразу, и в Allow он должен встретиться один раз.
Соберём теперь всё, что написала глава: разборщик превращает байты в запросы, маршрутизатор раздаёт их обработчикам, обработчик отвечает. Прогноз: какой код вернёт PATCH /notes/17 и что окажется в теле ответа у POST?
Пять запросов — пять разных ответов, и ни одной проверки request.target внутри обработчиков: адрес разобран до них. Обработчик POST получил третьим аргументом весь запрос и достал заголовок заметки из тела, GET /notes — страницу из строки запроса, GET /notes/:id — номер из пути. Это Express в зародыше: таблица маршрутов, сопоставление по сегментам, params и query у обработчика на входе.
Маршрутизатор решил, КУДА идёт запрос. Он ничего не решает о том, что должно случиться с каждым запросом по дороге, — а таких дел набирается много, и сейчас они лежали бы копией в каждом обработчике.
| Чего нет | Как это выглядит | Где закрывается |
|---|---|---|
| Общего для всех маршрутов | журнал, разбор JSON, проверка токена, время ответа — по копии в каждой функции | конвейер и свой next() |
| Строгости к самой цели | мусор вместо метода и цель без слэша мы примем и честно поищем | настоящий node:http и восемь дыр |
| Связи с объявлением | маршруты перечислены руками, а хочется писать их рядом с самим обработчиком | каркас целиком: декоратор вместо строки |
Последняя строка таблицы — самая интересная. Когда в главе 7 на месте вашего сервера окажется Nest, @Get("/notes/:id") над методом контроллера будет делать ровно то, что делал router.add("GET", "/notes/:id", …): класть в таблицу маршрутов метод, разобранный шаблон и функцию. Разница только в том, откуда таблица берётся — из ваших вызовов или из метаданных декоратора. Механика поиска при этом не меняется совсем, и узнать её в чужих внутренностях будет легко.
- Цель запроса делится первым
?на путь и строку запроса; всё, что после первого знака, принадлежит строке запроса, потому что в пути его быть не может. - Сегменты даёт
split("/")с отброшенным первым пустым куском. Хвостовой слэш даёт пустой сегмент в конце, поэтому/notesи/notes/— разные пути, пока вы не решите иначе. - Декодировать надо каждый сегмент отдельно и после
split:%2Fвнутри значения — часть значения, а декодирование целого пути превратило бы его в разделитель. - Испорченный процент — это
URIErrorи ответ 400: «адрес не разобрать» и «такого адреса нет» — разные новости. - Строку запроса разбирает
URLSearchParams: повтор даётgetAll, параметр без значения — пустую строку, отсутствующий —null. Числа с границы проверяются явно, а у размера страницы есть потолок. - Путь не подошёл — 404; подошёл, а метод не тот — 405 с обязательным заголовком
Allow. Ни Express, ни Fastify этого за вас не сделают.
1/4Путь /notes/ разложен как pathname.split("/").slice(1). Что получится?
2/4Клиент просит /files/a%2Fb. Почему декодировать надо после split, а не до?
3/4Объявлены GET /notes и POST /notes. Приходит DELETE /notes. Что отвечать?
4/4В строке запроса пришло limit= без значения. Что вернёт get("limit") и что сделает из этого Number?
Хвостовой слэш
Читатель скопировал адрес из письма и получил /notes/ вместо /notes — а сервер ответил 404. Отвечать так — законное решение: у страницы остаётся ровно один адрес. Но чаще хвостовой слэш всё-таки не считают значащим, и это тоже надо уметь. Научите маршрутизатор снимать его перед поиском — не сломав при этом /notes/17, где слэш стоит между сегментами, и не превратив корень / в пустой путь. Строка запроса должна доехать до обработчика в целости.
Снимать надо в find, до всякого сопоставления, и только у пути: строку запроса приходится отцепить и приставить обратно. Условие pathname.length > 1 бережёт корень — там слэш не хвост, а весь путь.
Теперь /notes/ и /notes/?page=2 попадают в список, /notes/17/ — в заметку, а / по-прежнему отвечает 404: маршрута для корня в примере нет, и это другая история. Взрослые каркасы решают этот вопрос по-разному, и свой стоит проверить — ответы ниже сняты 22.09.2026 на этих же двух маршрутах. Express 5.2.1 хвостовой слэш значащим не считает: /notes/ уходит тому же обработчику, что и /notes. Fastify 5.12.4 считает, и получается хуже обоих решений: /notes/ достаётся маршруту /notes/:id с пустым id — ровно та пустая переменная, от которой бережёт проверка в matchRoute, — а /notes/17/ получает 404. «Правильного» ответа тут нет, важно другое: у страницы должен быть ровно один адрес — второй либо ведёт туда же, либо не существует.
Метод, о котором никто не просил
Два метода наш маршрутизатор обижает незаслуженно. HEAD — это GET, которому не отправят тело: обслуживать его должен тот же обработчик, а не 405. OPTIONS на существующем пути — вопрос «что этот адрес умеет», и отвечать на него надо не отказом, а списком: 204 и заголовок Allow, в котором, кроме объявленных методов, есть и HEAD с самим OPTIONS. Допишите оба случая, не трогая Router. Подсказка: 405 от родителя уже содержит нужный список.
HEAD решается подменой метода при поиске: обработчик у него тот же, а тела ему просто не отправят этажом выше. Для OPTIONS пригодится то, что родитель уже сделал всю работу: его 405 значит «путь есть, метода нет», а список методов лежит в allow.
OPTIONS /health по-прежнему отвечает 404, и это правильно: у несуществующего адреса нет и списка методов. А DELETE /notes остался при своём 405 — подмена метода касается только HEAD.
Сегмент, который хочет наверх
Маршрут /files/:name отдаёт файл по имени, а имя приходит из адреса. После декодирования в одном сегменте может оказаться что угодно — в том числе слэши и .., то есть путь наверх по файловой системе. Ключ здесь именно в процентах: .. и /, написанные как есть, разбор адреса схлопывает ещё до отправки — /files/../../etc/passwd уезжает как /etc/passwd (проверено на node 26.5.0), — а ..%2F..%2F остаётся одним сегментом и доезжает до вас целым. Проверьте params.name прежде, чем ему поверить.
Проверка короткая, потому что требование к значению короткое: это имя файла, а не путь.
Последний адрес в примере — /files/%2E%2E: точки тоже записываются процентами. Браузер и его схлопнул бы — new URL приводит такой путь к /, — но полагаться на это нельзя: запрос пишется одной строкой в сокет, и разбор адреса на стороне клиента вам ничего не обещает. Общее правило здесь такое же, как со строкой запроса: сегмент пути — это ввод пользователя, и после decodeURIComponent он становится ровно тем, что пользователь захотел. Складывать его с корнем каталога без проверки нельзя — ни этим маршрутом, ни любым другим.