078 Улучшение архитектуры
Далее мы идём улучшать нашу архитектуру.
Первым делом, запрос должен идти не напрямую в middleware
, а через data transfer object
, который хранит в себе данные в виде определённой структуры.
Внутри middleware
будет находиться валидатор, который будет проверять dto
на корректность переданных данных.
Так же мы введём такое понятие, как entity
- это объект нашей бизнес-единицы, который сфокусирован на работе с самим объектом: создание объекта, преобразование и реализация внутренних методов (например, у пользователя есть метод хеширования его пароля).
Всё, что связано с бизнес-логикой, будет инкапсулировано в services
и entity
079 Data transfer object
dto
будет представлять из себя класс, который описывает, что мы получаем извне и передаём в контроллер
Сразу нужно сказать, что если наши классы очень похожи по принимаемым данным, то в данном случае мы могли бы сделать класс UserCredentional
и от него экстендить эти два представленных класса. Однако объединять два dto
- не нужно!
users > dto > user-register.dto.ts
export class UserRegisterDto {
email: string;
password: string;
name: string;
}
users > dto > user-login.dto.ts
export class UserLoginDto {
email: string;
password: string;
}
Ну и далее нужно упомянуть, что тип Request
из express
принимает в себя дженерик из трёх параметров, где третий представляет из себя body
принимаемых данных
И теперь в методе login
мы видим, что в запросе req.body
сам body
будет иметь тип данных UserLoginDto
- то есть третий дженерик определяет тип body
полученного запроса
users.controller.ts
import { UserLoginDto } from './dto/user-login.dto';
import { UserRegisterDto } from './dto/user-register.dto';
// code ...
login(req: Request<{}, {}, UserLoginDto>, res: Response, next: NextFunction): void {
console.log(req.body);
next(new HTTPError(401, 'ошибка авторизации', 'login'));
}
register(req: Request<{}, {}, UserRegisterDto>, res: Response, next: NextFunction): void {
console.log(req.body);
this.ok(res, 'register');
}
// code ...
И далее, чтобы увидеть ответ от сервера, нужно установить модуль, который будет серилиазовать полученный body
в JSON
npm i body-parser
Ну и уже тут мы реализуем middleware
, который будет принимать в себя все входящие запросы и парсить их в JSON
import { json } from 'body-parser';
@injectable()
export class App {
// code ...
// тут мы подключаем использование прослойки в виде сериализатора принимаемых ответов - теперь экспресс сможет нормально принимать данные
useMiddleware(): void {
this.app.use(json());
}
// code ...
public async init(): Promise<void> {
this.useMiddleware();
this.useRoutes();
this.useExeptionFilters();
this.server = this.app.listen(this.port);
this.logger.log(`Сервер запущен на http://localhost:${this.port}`);
}
// code ...
}
080 User entity
Entity - это бизнес единица, которая описывает какой-то бизнес объект в качестве какого-то класса, у которого могут быть свои методы и свойства и внутри него зашито описание и бизнес-логика.
Так же эта единица должна быть отделена от всей системы и не должна зависеть от остальных компонентов этой системы.
npm i bcryptjs
npm i -D @types/bcryptjs
И вот представление реализации нашей отдельной сущности пользователя. Он максимально абстрагирован от остальных компонентов и не зависит от них.
users > user.entity.ts
import { hash } from 'bcryptjs';
// класс сущности
export class User {
// пароль, который присвоен пользователю
private _password: string;
constructor(private readonly _email: string, private readonly _name: string) {}
// получаем почту пользователя
get email(): string {
return this._email;
}
// получаем имя пользователя
get name(): string {
return this._email;
}
// получаем пароль
get password(): string {
return this._password;
}
// устанавливаем пароль на сущность
public async setPassword(pass: string): Promise<void> {
// паролем будет зашифрованный переданный пароль от пользователя
this._password = await hash(pass, 10);
}
}
Преобразуем функцию регистрации, где сразу проводим деструктуризацию параметра req
, чтобы получить сразу body
. Далее тут создаём нашего нового пользователя и передаём в него все нужные параметры, включая пароль.
users > users.controller.ts
async register(
// сразу деструктурируем ответ и достём одно тело запроса
{ body }: Request<{}, {}, UserRegisterDto>,
res: Response,
next: NextFunction,
): Promise<void> {
// создаём инстанс юзера из данных ответа
const newUser = new User(body.email, body.name);
// задаём пользователю пароль
await newUser.setPassword(body.password);
this.ok(res, newUser);
}
И теперь по роуту регистрации мы можем получить нужные нам данные
081 Сервис users
Теперь нужно отделить роутинг (контроллер) и бизнес-логику (сервисы)
Работаем с сущностью
В сервисе у нас хранится только бизнес-логика: создаём entity
, выставляем пароль, работаем с репозиторием
Создаём интерфейс нашей сущности
users.service.interface.ts
export interface IUserService {
createUser: (dto: UserRegisterDto) => Promise<User | null>;
validateUser: (dto: UserLoginDto) => Promise<boolean>;
}
И тут представлена логика работы самой сущности. Она непосредственно имеет логику создания пользователя и так же работает с репозиторием (базой данных)
users.service.ts
@injectable()
export class UserService implements IUserService {
async createUser({ name, email, password }: UserRegisterDto): Promise<User | null> {
// создание пользователя и занесение данных
const newUser = new User(email, name);
await newUser.setPassword(password);
// проверка, что пользователь существует
// если есть - возвращаем null
// если нет - создаём нового пользователя
// возврат значения
return null;
}
async validateUser(dto: UserLoginDto): Promise<boolean> {
return false;
}
}
Настраиваем DI
Чтобы связать нашу сущность, нужно добавить её в типы
types.ts
export const TYPES = {
Application: Symbol.for('Application'),
ILogger: Symbol.for('ILogger'),
UserController: Symbol.for('UserController'),
// добавляем символ нашего сервиса
UserService: Symbol.for('UserService'),
ExeptionFilter: Symbol.for('ExeptionFilter'),
};
И забиндить в модуле контейнера, чтобы эта сущность хранилась в нём
main.ts
export const appBindings = new ContainerModule((bind: interfaces.Bind) => {
bind<ILogger>(TYPES.ILogger).to(LoggerService);
bind<IExeptionFilter>(TYPES.ExeptionFilter).to(ExeptionFilter);
bind<IUserController>(TYPES.UserController).to(UserController);
// связываем сервис пользователя
bind<IUserService>(TYPES.UserService).to(UserService);
bind<App>(TYPES.Application).to(App);
});
Взаимодейтсвуем с контроллером
Контроллер же у нас будет реализовывать роунтинг и контролировать входных и выходных данных.
Конкретно тут был внедрён UserService
в конструктор. Так же реализована регистрация пользователя (контроль входных и выходных данных)
export class UserController extends BaseController implements IUserController {
constructor(
@inject(TYPES.ILogger) private loggerService: ILogger,
@inject(TYPES.UserService) private userService: IUserService,
) {
super(loggerService);
this.bindRoutes([
{ path: '/register', method: 'post', func: this.register },
{ path: '/login', method: 'post', func: this.login },
]);
}
login(req: Request<{}, {}, UserLoginDto>, res: Response, next: NextFunction): void {
console.log(req.body);
next(new HTTPError(401, 'ошибка авторизации', 'login'));
}
async register(
// сразу деструктурируем ответ и достём одно тело запроса
{ body }: Request<{}, {}, UserRegisterDto>,
res: Response,
next: NextFunction,
): Promise<void> {
// создаём пользователя по данным полученного body
const result = await this.userService.createUser(body);
// проверяем, существует ли такой пользователь
if (!result) return next(new HTTPError(422, 'Такой пользователь уже есть'));
// выводим почту пользователя
this.ok(res, { result: result.email });
}
}
Результат
Как выглядит в действии:
082 Middleware для роутов
Создадим интерфейс самого посредника. Он будет выполнять ровно один метод, который ему предписан.
middleware.interface.ts
import { Request, Response, NextFunction } from 'express';
export interface IMiddleware {
// этот интерфейс будет определять логику работы миддлвэйра, который выполняет ровно одну функцию
execute: (req: Request, res: Response, next: NextFunction) => void;
}
Наш контроллер роута будет принимать в себя (необязательно) массив посредников, которые будут выполняться до основной функции-хэндлера.
common > route.interface.ts
export interface IControllerRoute {
path: string;
func: (req: Request, res: Response, next: NextFunction) => void;
method: keyof Pick<Router, 'get' | 'post' | 'delete' | 'patch' | 'put'>;
// это массив посредников, которые должны отработать перед тем, как мы попадём в сам контроллер
middlewares?: IMiddleware[];
}
И далее в основном контроллере получаем массив посреднических функций, которому меняем контекст вызова на себя самого. В функции pipeline
мы проверяем, если у нас имеются в принципе middlewares
, то передаём в наш роут сначала эти функции-посредники, а уже только потом основную функцию
base.controller.ts
protected bindRoutes(routes: IControllerRoute[]): void {
for (const route of routes) {
this.logger.log(`[${route.method}] ${route.path}`);
// получаем массив обработчиков контроллера и перебинживаем им контекст, чтобы он оставался внутри этих функций
const middleware = route.middlewares?.map((mw) => mw.execute.bind(mw));
const handler = route.func.bind(this);
// если у нас имеются middlewares, то выполняем сначала их, а потом функцию, если нет, то выполняем только функцию
const pipeline = middleware ? [...middleware, handler] : handler;
this.router[route.method](route.path, pipeline);
}
}
083 Валидация данных
Далее нужно реализовать middleware
валидации данных, которые передаются в контроллер
Модуль для валидации данных
Модуль class-validator
позволяет нам производить валидацию поступающих данных, декорируя их и производя валидацию внутри метода validate
.
Тут ниже приведён пример валидации данных:
import { validate, validateOrReject, Contains, IsInt, Length, IsEmail, IsFQDN, IsDate, Min, Max, } from 'class-validator';
export class Post {
@Length(10, 20)
title: string;
@Contains('hello')
text: string;
@IsInt()
@Min(0)
@Max(10)
rating: number;
@IsEmail()
email: string;
@IsFQDN()
site: string;
@IsDate()
createDate: Date;
}
let post = new Post();
post.title = 'Hello'; // should not pass
post.text = 'this is a great post about hell world'; // should not pass
post.rating = 11; // should not pass
post.email = 'google.com'; // should not pass
post.site = 'googlecom'; // should not pass
validate(post).then(errors => {
// errors is an array of validation errors
if (errors.length > 0) {
console.log('validation failed. errors: ', errors);
} else {
console.log('validation succeed');
}
});
validateOrReject(post).catch(errors => {
console.log('Promise rejected (validation failed). Errors: ', errors);
});
// or
async function validateOrRejectExample(input) {
try {
await validateOrReject(input);
} catch (errors) {
console.log('Caught promise rejection (validation failed). Errors: ', errors);
}
}
Модуль class-transformer
позволяет нам перевести JSON
данные в класс, чтобы заменить конструкцию присвоения на конструкцию трансформации
И теперь вместо такого ввода данных
let post = new Post();
post.title = 'Hello'; // should not pass
post.text = 'this is a great post about hell world'; // should not pass
post.rating = 11; // should not pass
post.email = 'google.com'; // should not pass
post.site = 'googlecom'; // should not pass
Мы можем использовать уже заранее определённый класс:
// users.json
[
{
"id": 1,
"firstName": "Johny",
"lastName": "Cage",
"age": 27
},
{
"id": 2,
"firstName": "Ismoil",
"lastName": "Somoni",
"age": 50
},
{
"id": 3,
"firstName": "Luke",
"lastName": "Dacascos",
"age": 12
}
]
// main.ts
export class User {
id: number;
firstName: string;
lastName: string;
age: number;
getName() {
return this.firstName + ' ' + this.lastName;
}
isAdult() {
return this.age > 36 && this.age < 60;
}
}
fetch('users.json').then((users: Object[]) => {
// переводим пользователей в инстансы класса
const realUsers = plainToClass(User, users);
});
Реализация валидации данных
Первым делом, нужно реализовать middleware
, который будет осуществлять саму валидацию данных. Он будет в себя принимать класс для валидации данных. Затем в execute
будет производён перевод полученного JSON
в класс модулем class-transformer
, который будет валидироваться методом validate
из модуля class-validator
validate.middleware.ts
import { Request, Response, NextFunction } from 'express';
import { IMiddleware } from './middleware.interface';
import { ClassConstructor, plainToClass, plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
export class ValidateMiddleware implements IMiddleware {
constructor(private classToValidate: ClassConstructor<object>) {}
execute({ body }: Request, res: Response, next: NextFunction): void {
// и тут уже мы инстанциируем класс, переданный сюда, из данных body
const instance = plainToClass(this.classToValidate, body);
// И тут уже происходит сама валидация данных
validate(instance).then((errors) => {
// если длинна ошибок с массивом больше 0
if (errors.length > 0) {
res.status(422).send(errors);
} else {
// если всё хорошо, то переходим к следующему обработчику
next();
}
});
}
}
Затем в контроллере пользователя добавляем под запросы промежуточные обработчики
users.controller.ts
constructor(
@inject(TYPES.ILogger) private loggerService: ILogger,
@inject(TYPES.UserService) private userService: IUserService,
) {
super(loggerService);
this.bindRoutes([
{
path: '/register',
method: 'post',
func: this.register,
middlewares: [new ValidateMiddleware(UserRegisterDto)],
},
{
path: '/login',
method: 'post',
func: this.login,
middlewares: [new ValidateMiddleware(UserLoginDto)],
},
]);
}
А производить проверку полученных данных будем в самом dto
, где все полученные переменные будут валидироваться
user-login.dto.ts
import { IsEmail, IsString } from 'class-validator';
export class UserRegisterDto {
// это валидация почты + кастомный ответ
@IsEmail({}, { message: 'Неверно введена почта' })
email: string;
// Это валидация почты
@IsString({ message: 'Не указан пароль' })
password: string;
@IsString({ message: 'Не указано имя' })
name: string;
}
Результат
При правильно отправленном запросе у нас будет успешное сообщение без ошибок
Ошибка при ввода пароля (или его не передаём)
Ошибка в вводе почты