-
Notifications
You must be signed in to change notification settings - Fork 3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Модуль 6. «Exress.js» #6
Merged
Merged
Conversation
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Для обработки запросов клиентов нам потребуется сервер. Самый простой способ создать сервер — воспользоваться встроенным в Node.js модулем — `http`. Однако, он предоставляет более низкоуровневый API и чтобы комфортно им пользоваться придётся написать дополнительные обёртки. Альтернативный вариант: воспользоваться фреймворком `express`. Под капотом он использует встроенный модуль `http`, но предоставляет прикладное API. Им пользоваться удобней и он избавляет от написания дополнительного кода. После установки Express.js потребуется установить типы. Их ставим в зависимости для разработки.
Добавим создание экземпляра сервера с помощью Express в класс `RestApplication`. Экземпляр сохраним в приватном поле `server`. После этого в методе `init` воспользуемся экземпляром `express` и запустим сервер на прослушивание входящих подключений. Для этого вызовем метод `listen`. Ему нужно передать порт, на который будем принимать подключения. Номер порта возьмём из конфигурации (`PORT`).
Подготовив Express-сервер, мы можем сразу приступать к обработке входящих запросов. Для этого требуется добавить обработчики нужным методам. Например, если требуется обрабатывать GET-запрос к маршруту `/` (то есть `http://localhost:4000/`), то достаточно задать обработчик `get`. Технически это можно сделать прямо сейчас где-нибудь внутри класса `RestApplication`. В свойстве `server` сохранён экземпляр express-приложения. Чтобы добавить обработчик для GET-запроса, необходимо вызвать одноимённый метод `get` (для POST-запроса соответственно метод `post` и так далее). Методу `get` следует передать два аргумента: маршрут (например, `/`) и функцию-обработчик. Эта функция вызывается при поступлении запроса от клиента. Сигнатура функции определена в документации к Express. В самом типичном случае она определяет два параметра: `req` (тип `Request`) и `res` (тип `Response`). Первый содержит объект запроса, а второй — объект ответа. Объекты предоставляют доступ к данным, которые отправил клиент и методы для подготовки и отправки ответа клиенту. Например, через объект `Request` можно получить доступ к телу запроса и извлечь из него данные. А с помощью метода `send` объекта `Response` можно отправить ответ клиенту. Простой пример, в качестве теста его можно определить прямо внутри метода `init` класса `RestApplication`: ``` this.server.get('/', (_req, res) => { res.send('Hello'); }); ``` Для GET-запросов к корневому ресурсу будет вызвана функция обработчик. Внутри функции пишем код для отправки клиенту строки `Hello`. Попробуйте запустить код с этим примером и отправить запрос с помощью расширения RestClient. В ответ на запрос придёт строка `Hello`. Однако объявлять такие обработчики маршрутов внутри приложения неудобно. Их может быть много и `RestApplication` точно не должно содержать детали обработки запросов. Этот класс может их регистрировать, но детали должны быть описаны где-то в другом месте. Попробуем спроектировать интерфейс для отдельного маршрута в приложении. Нам известно, что для установки обработчика нам требуется несколько элементов: маршрут (точный путь ресурса), метод (http-метод) и функция-обработчик. Всем этим мы воспользовались в примере выше, а теперь зафиксируем в интерфейсе `Route`: `path`, `method` и `handler`. Все возможные методы, которые будем использовать в приложении, зафиксируем в отдельном перечислении `HttpMethod`. Обратите внимание на последний параметр функции `handler` — `next`. Это отдельная функция, которая позволяет передать управление следующему обработчику. С ней мы познакомимся позже (когда дойдёт до middleware), а пока просто предусмотрим в интерфейсе.
В прошлом шаге мы определили интерфейс для описания отдельного маршрута. Поднимемся на уровень выше и подумаем над вопросом: а кто должен регистрировать сами маршруты? На уровне приложения этой задачей может заниматься класс `RestApplication`. Он вполне может знать о всех группах маршрутах. Под группой подразумеваются определённые ресурсы. Например, ресурс `users` предоставляет возможность добавлять новых пользователей, возвращать информацию по пользователю с определённым идентификатором и так далее. Все ресурсы отдельной сущности могут регистрироваться в контроллере (паттерн MVC). Например, сущность `Category` обладает контроллером `CategoryController` и в нём определены все необходимые обработчики маршрутов. Получается, что тогда потребуется всего лишь определить контроллеры для всех необходимых сущностей. Попробуем спроектировать интерфейс контроллера. Создадим отдельный модуль и в нём опишем `ControllerInterface`. Контроллеры будут использовать `Router` из фреймворка `Express` и с его помощью определять все необходимые обработчики маршрутов. Поэтому предусмотрим поле `router`. Снаружи оно не должно изменяться, поэтому поставим модификатор `readonly`. Любой контроллер должен уметь добавлять маршруты. Напомним, маршрут соответствует интерфейсу `RouteInterface`, который мы определили в предыдущем шаге. Добавлять новые маршруты контроллер будет с помощью метода `addRoute`. Аргументом он будет ожидать новый маршрут. И последнее. Неплохо, чтобы контроллер предоставлял какую-то абстракцию для отправки сообщений клиенту. Обёртка над методом `send` у объекта `Response`. Предусмотрим для этого метод `send`. Обратите внимание, поскольку метод `send` должен быть универсальным (он умеет отправлять разные данные) мы используем дженерик. Аргументами метод будет получать объект ответа (`Response`), статус-код и данные. При формировании ответов клиенту нам придётся часто прибегать к отправке кода 200 (OK), 204 (NO Content) и 201 (Created). Для этого придётся постоянно передавать соответствующий элемент перечисления `StatusCode` в метод `send`. Предусмотрим для таких ситуаций методы хелперы: `ok`, `created`, `noContent`.
Пакет предоставляет удобные именованные константы для всех стандартных кодов состояния HTTP (HTTP status codes). Напомним, коды состояния HTTP — это числовые значения, которые сервер отправляет в ответ на запросы клиента для указания состояния выполнения запроса.
До сих пор в проекте мы не использовали абстрактные классы, а сразу делали полную имплементацию интерфейса. Однако TypeScript предоставляет модификатор `abstract`, позволяющий пометить класс как абстрактный. Напомним, абстрактные классы нельзя инстанцировать (создать экземпляр) напрямую, но его можно применять в качестве родительского класса. Давайте подумаем, как будет выглядеть метод `send` для любого контроллера? Скорей всего одинаково. Он должен отправлять клиенту ответ с определённым кодом состояния и данными. Метод не привязан к определённому контроллеру. А что насчёт метода `addRoute`, который мы предусмотрели в интерфейсе? Он будет отличаться? Тоже вряд ли. Метод добавляет маршрут и никак не связан с определёнными маршрутами. Получается, нет смысла реализовывать эти методы в каждом отдельном контроллере. Здорово их реализовать один раз, а потом использовать во всех новых контроллерах. Вот здесь нам и пригодится возможность определять абстрактные классы. Класс `BaseController` станет абстрактным. Мы пометим его соответствующим модификатором `abstract`. Он имплементирует интерфейсе `Controller` и реализует функциональность, которая не будет меняться от контроллера к контроллеру. Геттер `router`, методы `addRoute` и `send`. Остальные контроллеры будут потомками класса `BaseController`. Разбираться с классом `BaseController` начнём с определения. Первое, на что стоит обратить внимание — модификатор `abstract`. Мы явно указали, что класс является абстрактным и экземпляры не должны создаваться напрямую. Обратите внимание на декоратор `@injectable`. Напрямую этот класс не будет использоваться для внедрения зависимостей, но его всё равно нужно отметить `@injectable`. Так как `inversify` требуется получить метаданные класса, который будет использоваться в качестве родительского класса. Переходим к телу класса. В начале определяем приватное поле `router`. Через него будет происходить взаимодействие с `Router` из Express. Для него предусмотрим геттер. Обратите внимание на определение конструктора. Конструктор принимает экземпляр логера, соответствующий интерфейсу `Logger`. Внедрять логер в абстрактном классе нет смысла, будем передавать из наследников. Поскольку логировать потребуется в потомках, поле `logger` мы определяем с модификатором `protected`. Оно не будет доступно снаружи, но им смогут воспользоваться потомки `BaseController`. Метод `addRoute` должен добавлять маршрут. Для добавления маршрута мы должны воспользоваться одноимённым методом объекта `Router`. Аргументами передаём `path` и функцию-обработчик. Обратите внимание на вызов `bind`. Мы принудительно устанавливаем в качестве контекста текущий экземпляр. Метод `send` работает как обёртка. Для отправки любых ответов клиенту будет использоваться именно он. Поскольку все ответы клиенту будут передаваться в виде `json`, мы сразу устанавливаем тип-контента `application/json`. Статус-код возьмём из параметра `statusCode`. Это не финальная реализация класса `BaseController`. По мере разработки приложения мы будем добавлять в него полезные методы, которые потребуются в контроллерах.
В первую очередь создадим контроллер для `Category`. Назовём его `CategoryController`. Этот контроллер является наследником `BaseController`. В конструктор класса внедрим `Logger` с помощью декоратора `@inject`. Затем в конструкторе вызовем родительский конструктор. Для этого воспользуемся `super`. Обратите внимание, аргументом для `super` станет `logger`. Далее мы должны добавить маршруты. Удобней всего это сделать в конструкторе. С помощью логера отобразим информацию о регистрации маршрутов, а затем воспользуемся методом `addRoute` родительского класса и добавим два маршрута. На первых порах в `CategoryController` потребуется два маршрута: один для метода POST, другой — для `GET`. В качестве пути укажем `/`. Полный путь станет: `/categories`, его мы укажем при регистрации роутера определённого контроллера. Сами функции-обработчики (методы `index` и `create`) пока оставим пустыми. Код для них напишем чуть позже. WIP: Линтер ругается на неиспользуемые переменные.
Добавим `CategoryController` в компоненты приложения. Компонент потребуется для добавления привязки в контейнер с зависимостями, чтобы потом получить к нему доступ в виде зависимостей.
Добавим привязку для `CategoryController` в контейнер. Это позволит внедрять `CategoryController` в виде зависимости.
Следующим шагом доработаем класс `RestApplication`. Ему необходимо научить регистрировать маршруты, которые мы определяем с помощью контроллеров. Для решения этой задачи создадим метод `_initControllers`. В этом методе зарегистрируем все экземпляры Express-роутеров с помощью метода `use` экземпляра `express`. Для `CategoryController` первым аргументом передадим название общий путь к ресурсу (`/categories`), а вторым — экземпляр нужного `Router`. Его получим через геттер `router` нужного контроллера. Сам контроллер `CategoryController` внедрим в виде зависимости. Получается, что маршруту, которые мы добавили в `CategoryController` будут доступны через сегмент `/categories`. Например, `GET http://localhost:4000/categories`. Вызов метода `_initControllers` напишем в методе `init`. WIP. Линтер ругается на неиспользуемые переменные.
Вернёмся к `CategoryController`. Расширим конструктор и подключим `categoryService`. Его внедрим в виде зависимости. Затем опишем метод `index`. Вместо комментария добавим получение списка категорий, а затем их отправку клиенту. Обратите внимание, в `statusCode` мы передаём код, полученный из перечисления `http-status-codes`. Это удобней, чем помнить в голове соответствие всех кодов. В этом обработчике нет необходимости использовать параметр `req`, поэтому добавим ему подчёркивание, чтобы линтер не ругался. Чтобы проверить работу обработчика, в список запросов (`category.http`) добавим GET-запрос для получения списка категорий.
Пакет class-transformer позволяет создавать классы на основании объектов, а также выполнять обратную процедуру. Он пригодится для подготовки DTO.
Контроллер работает, но отдаёт данных больше, чем нужно. Клиенту не нужны `createdAt`, `updatedAt`, `_id` и `__v`. Для ресурса `category` достаточно `name` и `id`. Эту задачу можно решить несколькими способами. Первый: модифицировать обработчик `toJSON` для модели. В `mongoose` реализована эта возможность (смотри документацию). Второй: определить RDO с нужными полями. Этот способ более гибкий. Воспользуемся им. RDO объявим в виде класса, а создадим его с помощью `plainToInstance` из пакета `class-transformer`. Эту функцию придётся использовать часто, поэтому подготовим обёртку в виде `fillDTO`. Обратите внимание, в `CategoryRdo` мы применяем декоратор `@Expose`. Им мы отмечаем, что свойство должно быть включено для заполнения.
Обработчик создания новой категории реализуется похожим образом. Главное отличие: нужно извлечь данные из запроса клиента и вызвать соответствующие методы `CategoryService`. И вот здесь появляется первая сложность. Вы помните, что любой http-запрос — это обычный текст. Чтобы воспользоваться данными из запроса, необходимо разобрать тело запроса. В теле запроса могут находиться данные в разных форматах. Наш сервис ожидает данные в формате JSON, поэтому нужно научиться корректно извлекать JSON. Для решения этой задачи следует воспользоваться middleware. Это промежуточные обработчики, в которых может быть реализована дополнительная логика. Мы воспользуемся встроенным middleware — `express.json()`. Сделаем отдельный метод `_initMiddleware` в классе `RestApplication`. В нём будем регистрировать все необходимые middleware.
Перед созданием категории выполним проверку на существование категории. Если категория существует, то вернём соответствующий код состояния — `422`. В противном случае добавим новую категорию и вернём её клиенту. Данные для создания категория возьмём из тела запроса. Для этого обратимся к полю `body` объекта `Request`. Мы воспользовались middleware `express.json()`, поэтому в `body` будет обычный объект. Для проверки обработчика создадим тестовый запрос.
`CategoryController` полностью готов, теперь пришло время подумать про обработку ошибок. От ошибок никуда не деться и они рано или поздно появится. Поэтому важно научиться их обрабатывать. Вы знаете, что для обработки ошибок достаточно научиться пользоваться исключениями. Но как быть с ошибками, которые могут возникнуть в обработчиках Express? Есть хорошая новость: Express из коробки умеет обрабатывать исключения, которые возникают в обработчиках. Если бросить ошибку в синхронном обработчике, то Express её автоматом поймает и отправит клиенту код `500` (Internal Server Error) и включит в ответ весь стек-трейс. Это неплохой вариант для начала, но клиенту обычно не нужен стек-трейс вызова функций, ему достаточно кода ошибки и понятного описания. Исправить стандартный обработчик ошибок легко, достаточно зарегистрировать собственный. В нём можно сделать более продвинутую обработку ошибок. Такие обработчики обычно называют `ExceptionFilter`. При переходе на другую технологию (например, ASP .NET Core) вы столкнётесь с аналогичным наименованием. Попробуем реализовать такой обработчик исключений в нашем проекте. Начнём с проектирования интерфейса `ExceptionFilter`. Предусмотрим в нём единственный метод `catch`. Сигнатура метода полностью соответствует сигнатуре обработчика ошибок в Express. Первым аргументом он принимает объект ошибки, а в остальных традиционный набор: объект запроса, объект ответа и функцию для передачи следующему обработчику.
Теперь добавим имплементацию `ExceptionFilter`. Для этого создадим класс `AppExceptionFilter`. В классе нам потребуется логер. Поэтому внедрим его сразу в виде зависимости через конструктор. В конструкторе добавим вывод информации о регистрации `AppExceptionFilter`. Затем имплементируем метод `catch`. Пока сделаем самый простой вариант. Сделаем запись в лог сообщения ошибки (`error.message`) и отправим клиенту корректный ответ. Помните, даже если в сервисе произошла ошибка, следует отправить клиенту корректный код. В случае неизвестной ошибки таким кодом станет `500`. Помимо кода с ошибкой отправим объект с ошибкой. Здесь можно пойти дальше и внедрить отправку дополнительных констант для автоматической обработки ошибок клиентом, но пока не будем переусложнять.
Первая версия `AppExceptionFilter` готова. Подключим её в `RestApplication`. Подключать будем в виде зависимости, поэтому сразу обновим `Component` и добавим привязку в контейнер (`main.rest.ts`). Затем немного обновим реализацию `RestApplication`. Добавим внедрение `ExceptionFilter` в конструктор и метод `_initExceptionFilters`. В методе добавим обработчики фильтров в экземпляр express-приложения. Обработчиков ошибок может быть несколько, поэтому сразу предусмотрим для этого отдельный метод. Вызов метода выполним в методе `init`, после регистрации маршрутов.
Протестируем `ExceptionFilter` в новом контроллере. Создадим контроллер `UserController` и реализуем в нём обработчики для ресурса `users`. Помните, каждый контроллер является потомком абстрактного класса `BaseController`. В `UserController` у нас будет несколько обработчиков: `/register` (для регистрации новых пользователей) и `/login` (для авторизации). Пока добавим один обработчик `/register` и создадим метод `create`. В нём бросим ошибку для проверки `AppExceptionFilter`. Добавим `UserController` в компоненты (`Components`) и создадим привязку в контейнере. Затем в `RestApplication` зарегистрируем новый контроллер. Затем добавим в `user.http` новый запрос для проверки обработчика регистрации. Попробуйте выполнить запрос к `/users/register`. Поскольку в обработчике мы бросили ошибку, её перехватит `AppExceptionFilter` и зафиксирует информацию об этом в логах, а также вернёт клиенту корректный код ответа. WIP: Добавит основу для контроллера `UserController`.
Немного изменим заготовку обработчика `create`. Сделаем его асинхронным. Нам это всё равно придётся сделать, так как мы планируем работать с сервисом `userController`. После внесения этих небольших изменений, попробуйте вновь отправить запрос. На этот раз что-то пошло не так. `AppExceptionFilter` не сработал и приложение попросту упало. Обратите внимание, клиент не получил сообщение об ошибке. Для него наш сервер фактически стал недоступен, упал. WIP: Контроллер `UserController` в разработке.
Express не умеет обрабатывать ошибки в асинхронных функциях. Это поведение изменят в 5-й версии Express, но она пока в бета-версии. Что делать сейчас? Можно самостоятельно обрабатывать ошибки и пробрасывать через `next`. Таким образом, ошибка дойдёт до обработчика ошибок и вы получите нужный результат.
Рассмотренный в прошлом шаге вариант обработки ошибок в асинхронных функциях вполне рабочий, но он многословен. Передавать ошибку с помощью next несложно, но в обработчике используется несколько асинхронных действий, то это добавит в код множество try/catch. Можно конечно отказаться от громоздкой конструкции в пользу метода .catch (не забывайте, что результат асинхронной функции всегда промис), но в корне проблему не исправит. В текущей версии Express для решения такой задачи можно сделать обёртку над обработчиком. Фактически эта обёртка заворачивает обработчик в промис и за счёт этого упрощается обработка и передача ошибок (внутри вызывается next). Самостоятельно делать такую обёртку необязательно. Есть готовая в виде пакета `express-async-handler`.
Чтобы воспользоваться `express-async-handler` нужно просто обернуть обработчик маршрута функцией `asyncHandler`. Это несложно. Вопрос, где это делать применительно к нашему проекту? Первый вариант: внутри каждого контроллера. При добавлении маршрута при помощи `addRoute` можно обернуть функцию-обработчик. Второй вариант: сделать это один раз в абстрактном классе `Controller`. Поскольку все наши обработчики подразумевают работу с асинхронными функциями, будет неплохо переложить эту задачу на `Controller`.
Вернёмся в контроллер `UserController` и уберём блоки `try/catch`. Вместо них просто кинем ошибку и увидим, что она корректно перехвачена обработчиком ошибок.
В приложении ошибки могут быть разных типов. Пока мы используем только один — базовый класс `Error`. С его помощью мы можем бросить любую ошибку. Однако, не очень удобно использовать его повсеместно. Некоторые ошибки должны содержать больше информации. Например, если у нас существует пользователь или категория, то было бы здорово просто кинуть ошибку со всей необходимой информацией. Её сможет поймать `AppExceptionFilter` и выполнить все необходимые действия. Таким образом, код обработчиков станет немного чище — повторяющиеся действия. Создадим для этого класс `HttpError` и отнаследуемся от стандартного `Error`. Этому типу ошибок добавим передачу статус-кода и дополнительное поле `detail`. В него можно записывать информацию, которая поможет установить контекст проблемы.
В `AppExceptionFilter` мы вручную формируем объект с ошибкой, который будет отправлен пользователю. Это не очень удобно, когда есть разные типы ошибок. В таких случаях придётся дублировать код и если в будущем захотим расширить информацию в объекте, который отправляем пользователю, придётся пробежаться по всем участкам `AppExceptionFilter`, где происходило формирование ошибок. Упростить эту задачу поможет функция `createErrorObject`. Пока она будет максимально простой. Аргументом она принимает текст ошибки и возвращает объект с одним свойством `error`. Этот объект будем отправлять клиенту.
Теперь добавим обработку ошибок тип `HttpError` в `AppExceptionFilter`. Для этого создадим два приватных метода. Один отвечает за обработку ошибок типа `HttpError`, другой — всех остальных. Вызывать эти методы будем в методе `catch` в зависимости от типа `error`.
Последним шагом закончим с обработчиком `create` в `UserController`. Он отвечает за добавление новых пользователей в базу данных. Проведём похожие действия, как и при создании `CategoryController`. Сначала проверим существование пользователя. Если он существует, то вернём код `409`. Обратите внимание, мы напрямую не вызываем `send`. Вместо этого бросаем ошибку типа `HttpError`. Если всё хорошо и пользователь создан, то вернём объект пользователя без поля «пароль». Структуру объекта для клиента предварительно описали в `UserDto`.
Обновим метод `create` в контроллере `CategoryController`. Вместо подготовки ошибки вручную, подготовим `HttpError`.
Добавим в контроллер `UserController` метод `login`. Он будет использоваться для авторизации пользователя в системе. Авторизацию мы реализуем позже, а пока опишем одну проверку (существует пользователь или нет) и вернём в качестве результата «Not implemented». При реализации нам потребуется DTO — `LoginUserDto`. Поэтому создадим новый класс для DTO по аналогии с ранее созданными. Для тестирования обработчика сразу добавим запрос в `user.http`.
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
No description provided.