Перехватчик и фильтр: тайминги, форма ответа, лишние поля
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
До обработчика у каркаса есть всё: страж решает, можно ли, труба готовит вход, а доменный код превращает отказ в один формат ответа. Дальше начинается то, чего до обработчика не видно: сколько он выполнялся, что он вернул и какой формы это «что» приедет клиенту. Замер, обёртка ответа и вырезание полей, которым наружу нельзя, живут сейчас внутри методов — и в каждом третьем методе чего-нибудь из этого не хватает. В этой статье у каркаса появляются два последних усилителя: перехватчик обнимает обработчик с обеих сторон и может поменять то, что тот вернул, а фильтр отвечает за отказ рядом с маршрутом, а не одним ловцом на всё приложение. После них порядок в вашем каркасе совпадёт с порядком в настоящем Nest — звено в звено.
Начнём с самой скромной просьбы к серверу: знать, сколько выполнялся каждый обработчик. Работы тут на две строки — засечь время в начале, посчитать разницу в конце, — и ложатся эти строки в сам метод, потому что положить их больше некуда. Звено конвейера обработчика не видит вовсе, страж выполняется до него, труба тоже: всё, что у каркаса есть, заканчивается там, где обработчик начинается.
Время берём у performance.now() — за два свойства, которых нет у Date.now(). Он монотонный: считает от старта программы и не прыгает назад, когда системные часы подводит синхронизация времени. И он точнее: в Node шаг у него меньше микросекунды, а обработчик нередко укладывается в ту единственную целую миллисекунду, которой только и меряет Date.now(). Который час, он не скажет вовсе — и это ровно то, что нужно замеру. В Node он есть такой же глобалью, как в браузере, — но в браузере шаг нарочно огрублён, чтобы по нему не следили за пользователем: в Chrome остаётся 0,1 мс, в Firefox — целая. Поэтому примеры ниже печатают не миллисекунды, а «дольше» и «быстрее» порога.
В контроллере ниже три маршрута, и замер написан в двух: третий добавили позже. Прогноз: сколько записей окажется в журнале после четырёх запросов и каких именно?
Записи две, а запросов, дошедших до обработчика, — четыре. Одна потеря в remove: маршрут написали, когда о журнале уже никто не помнил, и ничто об этом не напомнило — код собрался, ответы верные, просто одного обработчика в отчёте нет. Вторая потеря интереснее: byId на несуществующей заметке зовёт fail, тот бросает исключение, и строка journal.push(…) под ним не выполняется никогда. Замеры пропали ровно там, где они нужнее всего, — на отказах.
Лечится это известным приёмом: try { … } finally { … } в каждом обработчике. И вот тут видна настоящая цена. Три строки, не имеющие к заметкам никакого отношения, обнимают тело каждого метода, и ошибиться в них можно ровно столько раз, сколько у вас методов. Замер — не свойство create. Это свойство всего приложения, и жить оно должно там же, где живут стражи и трубы: рядом с маршрутом, а не внутри него.
Если строка кода повторяется в каждом обработчике и ничего не говорит о предметной области, её место в усилителе. В обработчике её забудут.
Страж отвечает «да» или «нет», труба возвращает значение; оба выполняются до обработчика и ни один не видит, чем дело кончилось. Нужен третий вид усилителя — функция, внутри которой обработчик выполняется. Такую вы уже писали: это звено конвейера со своим next(). Всё, что стоит дальше по цепочке, лежит внутри вызова next(), а строки до и после него — две половины одного звена.
Перехватчик — то же устройство на другом месте и с другим ответом. Место — внутри маршрута, после стражей: он знает, какой контроллер и какой метод выбраны, и знает это ещё до того, как трубы возьмутся за вход. Ответ — значение: что перехватчик вернул, то и поедет клиенту, а next() отдаёт ему то, что вернул обработчик. Отсюда обе его работы — посмотреть (замер, журнал) и подменить (конверт, обрезка полей).
Ниже два перехватчика и два обработчика: долгий и отказавший. Прогноз: сколько строк напечатает trace вокруг каждого и что окажется в журнале замеров после отказа?
На удачном вызове trace напечатал обе половины, и между ними успели отработать measure и обработчик. На отказавшем — только первую: исключение прошло сквозь await next() дальше, и строка под ним выполнена не была. А запись замера на месте в обоих случаях — за это отвечает finally. Это та самая «третья строка», которую в прошлом разделе пришлось бы писать в каждом методе; здесь она написана один раз.
Порядок вложенности задаёт порядок списка: первый перехватчик — снаружи всех, последний — вплотную к обработчику. У «до» он прямой, у «после» — обратный, как у скобок: матрёшка, а не очередь. Отсюда и практическое следствие — замер ставят первым: поставленный последним, он не учтёт работу перехватчиков снаружи себя.
Строка после next() и finally — не одно и то же. Первая выполняется, когда всё прошло хорошо; вторая — всегда.
Теперь поставим перехватчик в каркас. Меняется то же место, где в начале главы появились стражи и трубы: строки, которыми сборка заводит маршрут. Между стражами и трубами появляется матрёшка, а сами трубы и вызов обработчика переезжают внутрь неё, в самую глубину.
Заодно воспользуемся общим уровнем — он у каркаса уже есть: в прошлой статье createApp получил третьим аргументом списки усилителей на всё приложение. Конверт ответа и замер сквозные по определению, и вписывать их в каждый контроллер значило бы вернуться к тому, с чего начали. Сегодня к стражам и трубам в этом аргументе добавляются перехватчики, к концу статьи добавятся фильтры: общий уровень дописывается каждому усилителю одной и той же строкой сборки. Уровней у каждого усилителя три — общий, класса, метода, — и складываются они в одном порядке, от общего к частному.
И про конверт. Сейчас ответ у каркаса двух видов: у отказа это JSON с кодом, у успеха — то, что вернул обработчик, приведённое к строке. Клиенту приходится разбирать тело двумя способами и заранее знать, какой сегодня. Поправим это в двух местах сразу: каркас отдаёт тело всегда как JSON, а какой формы будет сам объект успеха, решает перехватчик — ни каркас, ни обработчик.
Ниже новая сборка целиком и приложение на ней: конверт и перехватчик-метка на общем уровне, вторая метка на классе, третья на методе. Прогноз: в каком порядке напечатаются строки вокруг create и сколько их будет вокруг byId, который отказал?
Порядок «общий, класса, метода» на входе и обратный на выходе — ровно то, что обещал прошлый раздел, только теперь это видно на готовом приложении. У byId строк всего две, и обе «до»: отказ прошёл сквозь оба перехватчика, и ни один к строке под next() не вернулся. Третий запрос до перехватчиков не дошёл вовсе: маршрута GET /notes нет, а POST на тот же путь есть — потому и 405, а не 404. Собрал его маршрутизатор, ещё до того как каркас выбрал обработчика.
Конверт при этом не тронул ни одного обработчика: create как возвращал объект из двух полей, так и возвращает, а {"data": …} появился снаружи. Подчеркнуть это стоит, потому что соблазн велик — завернуть ответ прямо в обработчике кажется проще, чем писать перехватчик. Но тогда о конверте знает каждый метод, и просьба «добавьте в конверт номер запроса» превращается в обход всех контроллеров. А номер запроса понадобится: строки журнала сшивает одно поле, и ставит его туда как раз перехватчик.
Механизмов в каркасе при этом не прибавилось: перехватчик — третий список рядом с двумя собранными, и берётся он из такой же таблицы на слабых ссылках, а декоратор ему делает всё тот же collect, написанный ещё для стражей и труб.
Осталось убедиться, что общий уровень у стража и трубы работает так же, как у перехватчика, — ради него две строки сборки и дописаны. Ниже одно приложение и один маршрут: страж на всех трёх уровнях, труба на всех трёх, перехватчик на общем уровне и на классе. Общая труба при этом не метка, а настоящая работа — срезает пробелы по краям тела. Прогноз: сколько строк выйдет в журнале и каким номером окажется труба всего приложения?
Одиннадцать строк, и общая труба в них шестая. Стражи отработали раньше — доступ решается до разбора входа, — а среди труб она первая, потому что уровни складываются от общего к частному. Главное доказательство при этом не номер, а ответ: в тело попало «молоко» без пробелов, хотя обработчик не срезал ничего. Значит, общий уровень у трубы — не пометка в типе третьего аргумента, а работающее звено.
Смысл у него тот же, что у конверта: разбор входа по одной схеме нужен всем маршрутам, и вешать его на каждый контроллер — значит забыть ровно столько раз, сколько у вас методов. От перехватчика страж и труба отличаются одним: у них нет второй половины, они стоят перед обработчиком, а не обнимают его, поэтому в журнале у каждого по строке, а не по две.
Обработчик не знает ни про конверт, ни про замер. Как только узнал — усилитель написан зря.
В части IV это уже разбиралось: возвращённое из обработчика пересекает сеть целиком, а тип, который «сужает» ответ, ничего не срезает — типы стираются на сборке. Строка из хранилища, отданная как есть, увозит клиенту и passwordHash, и всё прочее, что о человеке знает сервер.
Там же назван и способ — собирать ответ руками, поле за полем. Способ верный, и беда у него та же, что у замера из первого раздела: он живёт в обработчике, а значит, забыть его можно ровно столько раз, сколько у вас методов. Перехватчик даёт этому способу одно место: ответ выходит наружу через него, и что именно выйдет, решает не тот, кто писал обработчик.
Резать можно двумя способами, и разница между ними видна не сразу. Ниже один контроллер и три маршрута: первый отдаёт строку как есть, второй вырезает известное секретное поле, третий собирает ответ из перечисленных. Прогноз: какое поле уедет наружу у второго маршрута?
Чёрный список сработал ровно против того, что в нём написано. passwordHash он убрал, а resetToken — поле, заведённое позже, для писем о восстановлении пароля, — выпустил наружу: в списке его нет. Тот, кто добавлял поле в хранилище, про hide("passwordHash") в другом файле не знал и знать не мог. Чёрный список отстаёт ровно на одно поле — на то, которое заведут завтра, — и узнают об этом отставании не из кода.
Белый список так не ломается: новое поле в строке хранилища ничего не меняет в ответе, потому что ответ собирается из перечисленного. Плата за это — перечислять, и перечислять по месту: у карточки одни поля, у списка другие, а у «себя самого» третьи. Поэтому белый список висит на маршруте, а не на приложении. И это ровно то одно место, которое обещала статья про валидатор: на входе схема защищает вас от чужих данных, на выходе — клиента от ваших собственных.
Две ловушки на этом пути сведены во вторую задачу статьи. Перехватчик, повешенный на метод, защищает только этот метод — соседний маршрут того же контроллера уедет целиком. А обрезка, написанная для одной строки, на списке строк молча не работает: Object.entries от массива даст индексы, а не поля.
Чёрный список отстаёт на одно поле — то, которое заведут завтра. Белый не отстаёт никогда, но писать его приходится по месту.
Остался последний усилитель, и место его уже занято. Отказ в ответ переводит ловец, прибитый к сборке ещё в прошлой главе, а чем именно он ответит, решает одна функция toResponse на всё приложение — та самая, которую завела прошлая статья. Пока отказы двух видов, доменный код и ошибка транспорта, этого хватает. Но в приложение приходят исключения третьего вида — чужие: таймаут почтового клиента, отказ платёжного шлюза, ошибка библиотеки разбора файла. Каркас о них не знает ничего и честно отвечает 500.
Дописать их в toResponse можно, и первые два раза это даже выглядит разумно. Дальше край приложения превращается в единственное место, где перечислены исключения всех чужих библиотек, какие где-либо используются. А знание «таймаут почты у регистрации — это 503, а у приглашения — 202, потому что письмо можно отправить и позже» живёт у одного контроллера и больше никому не нужно.
Значит, ловца надо разобрать на части так же, как раньше разобрали правило доступа. Фильтр — это пара «что ловлю» и «чем отвечаю», которая вешается на метод, на класс или на всё приложение. Четвёртая таблица на слабых ссылках, четвёртый список в сборке — и один новый вопрос, которого не было ни у стражей, ни у труб, ни у перехватчиков.
Вопрос такой: стражи выполняются все, трубы все, перехватчики все — а ответ у запроса один. Значит, из подходящих фильтров сработать должен ровно один, и выбрать его надо по правилу. Правило простое: отвечает тот, что ближе к обработчику. Фильтр метода важнее фильтра класса, фильтр класса важнее общего. Список собирается как обычно, от общего к частному, а перебирается с конца — findLast вместо find.
Правок три. Появляется реестр фильтров и заготовка самих фильтров; у маршрута собирается четвёртый список; и всё, что маршрут делает с запросом, переезжает внутрь try.
Ловец при этом остаётся на своём месте, последним: он отвечает за всё, чего не взял ни один фильтр. В Nest устроено так же, и фильтр по умолчанию там даже называется — BaseExceptionFilter.
Ниже контроллер регистрации: на классе фильтр, переводящий таймаут почты в 503, а на одном методе — свой, для того же исключения. Ещё два маршрута отказывают иначе. Прогноз: какой ответ придёт на /signup/invite и что станет с доменным отказом, для которого фильтра нет?
На /signup ответил фильтр контроллера, на /signup/invite — фильтр метода: оба ловят MailTimeout, оба подходят, сработал ближний. Доменный отказ не подошёл ни одному — catches вернул false, — исключение полетело дальше и досталось ловцу; ответ вышел тот же, каким был до этой статьи. С обычной Error то же самое: наружу код INTERNAL, текст — строкой в журнал.
Теперь порядок в каркасе полный: прослойка, страж, перехватчик «до», труба, обработчик, перехватчик «после», фильтр, ловец. Ровно такой же порядок и у настоящего Nest — с точностью до имён, которые сейчас и разберём.
Фильтров подходит сколько угодно, отвечает один. Поэтому фильтр, который ловит Error и стоит на методе, отменяет собой все остальные.
Названия чужие, устройство то же. Главная разница одна, и стоит она во второй строке таблицы: у Nest перехватчик возвращает не значение, а поток.
| Узел каркаса | Как это зовётся в Nest 12 | Чем настоящий отличается |
|---|---|---|
перехватчик — функция (ctx, next) | класс с методом intercept(context, next), ставится @UseInterceptors | класс приходит из контейнера, как и страж, — перехватчику доступно внедрение зависимостей |
next() возвращает промис со значением | next.handle() возвращает Observable из rxjs | «после» пишется не строкой под await, а оператором: map — подменить ответ, tap — записать, timeout и catchError — уже готовые |
ctx с именами контроллера и обработчика | ExecutionContext — тот же, что у стража | отдаёт не имена, а сам класс и саму функцию; по ним Reflector читает пометки маршрута |
конверт { data: … } перехватчиком | он же перехватчиком | готового конверта в Nest нет: и форму ответа, и имя поля выбирает приложение |
обрезка полей only(…) | ClassSerializerInterceptor и пометки @Exclude из class-transformer | массив ответов разбирает поэлементно; в 12-й версии рядом появился StandardSchemaSerializerInterceptor — ответ проходит через схему из @SerializeOptions({ schema }), ту же самую, что и на входе |
фильтр — пара catches и reply | класс с @Catch(Тип) и методом catch, ставится @UseFilters | @Catch() без аргументов объявляет своим любое исключение; общий уровень — useGlobalFilters |
findLast по списку фильтров | список разворачивается и берётся первый подходящий | тот же выбор с другого конца — ближний к обработчику важнее; у остальных усилителей разворота нет, они выполняются все |
Поток вместо значения — не украшение. Перехватчик Nest может вернуть Observable, который отдаёт значения не один раз, — на этом стоят серверные события; может подменить поток целиком, не дожидаясь обработчика, — так делается кеш; может поставить ограничение по времени одним оператором. Плата — rxjs в зависимостях и его правила: то, что в вашем перехватчике стоит после await next(), там пишется оператором над next.handle().
Порядок в Nest выглядит запутанным ровно в одном месте: трубы выполняются после перехватчиков, хотя обе работают со входом. Объяснение видно в исходнике: router-execution-context.js собирает трубы вместе с вызовом метода в одну функцию и отдаёт её перехватчикам — то есть трубы лежат внутри матрёшки, вплотную к методу, а перехватчики снаружи (проверено по исходнику @nestjs/core 12.0.3).
Практических следствий два. Перехватчик видит запрос до того, как разобран вход, — значит, замер, поставленный перехватчиком, включает и время разбора тела. И отказ трубы приходит к перехватчику как обычное исключение: журнал, поставленный перехватчиком, увидит и отказ валидатора тоже. Ваш каркас устроен так же, и не случайно — матрёшка в нём тоже обнимает трубы.
Дальше порядок, который вы собрали, измеряется на настоящем Nest: тот же журнал, только строки печатает чужой каркас. Первая статья про Nest ставит рядом с вашим контейнером, роутером и конвейером их настоящие соответствия. А глава заканчивается клиентом, выведенным из типа роутера — типы ответов поедут к нему прямо из типов обработчиков, и конверт, заведённый сегодня, окажется частью этого типа.
Перехватчик — единственный усилитель, который видит ответ. Всё, что должно случиться с ответом, случается в нём.
- Сквозной код — замер, журнал, форма ответа — написанный в обработчике, повторяется в каждом методе, забывается в новом и теряется на отказе: строка под тем, что бросило исключение, не выполняется.
- Перехватчик — звено вокруг обработчика: половина до
next(), половина после, аfinallyработает всегда. Возвращает он значение, и это значение едет клиенту, — поэтому перехватчик умеет и смотреть, и подменять. - Перехватчики складываются в матрёшку: первый в списке снаружи всех, последний вплотную к обработчику, на обратном пути порядок обратный. Замер ставят первым — иначе он не считает работу внешних слоёв.
- Уровней у перехватчика три: общий на всё приложение, класса и метода. Конверт ответа — типичный общий: форму успеха выбирает он, а обработчики о ней не знают и остаются при своей предметной области.
- Вырезать лишнее из ответа — работа одного места на выходе, а не каждого обработчика. Чёрный список отстаёт ровно на одно поле, белый собирает ответ из перечисленного и потому не отстаёт; на списке строк обрезку нужно применять поэлементно.
- Фильтр — пара «что ловлю — чем отвечаю» на тех же трёх уровнях. Подходящих бывает несколько, а срабатывает ближний к обработчику; всё, что не взял ни один, достаётся общему ловцу — как
BaseExceptionFilterв Nest.
1/4Почему запись журнала, поставленная строкой после await next(), пропадает на отказе?
2/4Над классом @UseInterceptors(A), над методом @UseInterceptors(B), на общем уровне C. В каком порядке они обнимут обработчик?
3/4Подходящих фильтров у маршрута оказалось два: один на классе, другой на методе. Сколько сработает?
4/4Чем чёрный список полей («вырезать passwordHash») хуже белого («оставить id и name»)?
Отказ, который не доехал до фильтра
Перехватчик audit записывает в журнал каждую неудачу — и заодно отвечает за неё сам. Из-за этого фильтр контроллера не срабатывает ни разу: оба запроса отвечают 200, а клиенту вместо кода уезжает текст исключения вместе с адресом почтового сервера. Почините перехватчик так, чтобы журнал остался, /signup отвечал 503 с кодом MAIL_DOWN, а /signup/confirm — 404 с NOT_FOUND. Каркас, фильтр и обработчики не трогайте.
Перехватчик поймал исключение и не отдал его дальше, а для каркаса «вернул значение» и «отказал» — две разные вещи. Записать и пробросить:
Ответы становятся 503 {"code":"MAIL_DOWN"} и 404 {"code":"NOT_FOUND"}, а строки журнала остаются на месте. Одним finally здесь не обойтись: он выполняется всегда, и в журнал попали бы и удачные вызовы, а до самой ошибки он не добирается вовсе. Поймать её ради текста и не пробросить — вот единственная настоящая ошибка этого кода.
Правило общее: перехватчик, поймавший исключение, обязан либо бросить его дальше, либо честно ответить за него сам. Молча превратить отказ в ответ 200 хуже обоих вариантов: мониторинг видит зелёные ответы, а клиент разбирает текст, которого ему никто не обещал.
Хеш, который уехал в списке
Обрезка only стоит на карточке и работает, а на списке её нет — и GET /users увозит клиенту обе строки целиком, с хешами паролей. Просто повесить only ещё и на all мало: перехватчик написан для одного объекта, а список — это массив. Сделайте так, чтобы обрезка работала в обоих случаях и стояла один раз — на контроллере. Хранилище и обработчики не трогайте.
Перехватчик должен различить два вида ответа: строку и список строк. Сама обрезка при этом остаётся прежней, её просто применяют к каждому элементу.
Декоратор переезжает с метода на класс — @UseInterceptors(only("id", "name")) над @Controller, — и тогда карточка отвечает {"data":{"id":"u1","name":"Рико"}}, а список — массивом из двух таких же объектов. Ни почты, ни хешей.
Проверка на массив выглядит мелочью, но именно она отличает работающую обрезку от той, что режет одиночный ответ и пропускает список. ClassSerializerInterceptor в Nest начинается ровно с этой же строки — и по той же причине.
Фильтр, который ловит слишком много
Фильтр писали ради одного случая — таймаута почты, — а объявили через Error. Теперь он отвечает 503 MAIL_DOWN на всё: и на доменный отказ, и на ошибку в коде шаблона. Сузьте его так, чтобы 503 остался только у таймаута почты, доменный отказ отвечал 404 с кодом NOT_FOUND, а ошибка в коде — 500 с INTERNAL и строкой в журнале.
Первый аргумент filterFor и есть «что ловлю», а err instanceof Error верно для любого исключения, включая ваши собственные.
После правки доменный отказ проходит фильтр насквозь и достаётся ловцу — 404 {"code":"NOT_FOUND"}, — а ошибка шаблона уходит в журнал и отвечает 500 {"code":"INTERNAL"}: наружу код, подробности внутрь. Оба ответа вернулись к тому, какими были до фильтра, и это правильный признак: фильтр добавляет случай, а не подменяет общее правило.
В Nest тот же промах пишется как @Catch() без аргументов: список типов пустой, и фильтр объявляет своим любое исключение. Такой фильтр бывает нужен — но на общем уровне, а не на методе, где он отменяет собой всё остальное.