MongoDB

001 Работа с переменными окружения

Установим средство NestJS для работы с конфигом

npm i @nestjs/config

Далее добавим зависимость ConfigModule.forRoot() в корневой модуль, которая позволит нам во всём проекте использовать один и тот же файл конфигурации (глобализирует модуль forRoot). Она позволит нам работать с переменными окружения.

src / app.module.ts

@Module({
	imports: [ConfigModule.forRoot(), AuthModule, TopPageModule, ProductModule, ReviewModule],
	controllers: [AppController],
	providers: [AppService],
})
export class AppModule {}

Записываем какое-либо значение в переменные окружения.

.env

TEST=1

И уже в любом другом нужном нам модуле мы можем заинжектить этот сервис по работе с переменными окружения и воспользоваться его функционалом: получить значение переменной можно с помощью функции get(), которая принимает в себя наименование переменной.

src / top-page / top-page.controller.ts

@Controller('top-page')
export class TopPageController {
	constructor(private readonly configService: ConfigService) {}
 
	@Get(':id')
	async get(@Param('id') id: string) {
		return this.configService.get('TEST');
	}
 
	/// CODE ...
}

002 Подготовка окружения

Первым делом нужно установить на ПК Docker

После установки докера, нужно настроить его окружение под наш проект:

  • version - минимальная версия докера
  • services - сервисы, которые используются в данном контейнере
  • image - образ, который запускает контейнер
  • container_name - имя контейнера
  • restart - перезапуск каждый раз, когда у нас перезагрузился сервер
  • environment - переменные окружения (логин и пароль администратора)
  • ports - позволяет прокинуть порт изнутри контейнера наружу (без них не получится подключиться к БД)
  • volumes - позволяет подключить часть дискового пространства внутри контейнера к диску на сервере (компьютере)
  • command - команды (ограничение кеша для БД)

docker-compose.yml

version: '3'  
services:  
  mongo:  
    image: mongo:4.4.4  
    container_name: mongo  
    restart: always  
    environment:  
      - MONGO_INITDB_ROOT_USERNAME=admin  
      - MONGO_INITDB_ROOT_PASSWORD=admin  
    ports:  
      - 27017:27017  
    volumes:  
      - ./mongo-data-4.4:/data/db  
    command: --wiredTigerCacheSizeGB 1.5

Позволяет поднять контейнер по заданным параметрам (по файлу docker-compose.yml в папке, где запускается команда):

docker-compose up -d

Позволяет посмотреть контейнеры | выбираем отдельный контейнер монги:

docker ps | grep mongo

Примечание: grep работает только на unix-системах

Так же посмотреть на статус контейнера можно через десктопное приложение докера

Остановить и запустить контейнер мы можем следующими комадами:

docker stop <имя_контейнера>
docker start <имя_контейнера>

003 Подключение Mongo

Сейчас нам нужно установить данные модули:

  • mongoose - удобная ORM для монги
  • typegoose - позволяет проще описать модели для mongoose
  • nestjs-typegoose - позволяет использовать typegoose в несте более нативно (приближенно к основным подходам фреймворка)
// тут может потребоваться --legacy-peer-deps
npm i @typegoose/typegoose mongoose nestjs-typegoose 
 
npm i -D @types/mongoose 

Записываем переменные для подключения к монге в окружение

.env

MONGO_LOGIN=admin  
MONGO_PASSWORD=admin  
MONGO_HOST=localhost  
MONGO_PORT=27017  
MONGO_AUTHDATABASE=admin

Далее, в основном модуле приложения, подключим модуль провайдера монги TypegooseModule

Тут мы используем вместо forRoot метод forRootAsync, который позволит асинхронно инициализировать модуль вместе с его зависимостями. Это делается для того, чтобы иметь возможность использовать ConfigModule в зависимостях модуля тайпгуза

Для того, чтобы использовать любой провайдер, нужно использовать модуль, который содержит этот провайдер, поэтому вставляем в import модуль ConfigModule

В inject мы вставляем зависимость из ConfigModule, а именно тут это представляет ConfigService, который позволит нам получить данные из .env

Внутрь useFactory будет помещать функцию, которая сгенерирует строку подключения к БД монги getMongoConfig.

src / app.module.ts

@Module({  
   imports: [  
      ConfigModule.forRoot(),  
      // асинхронно подключаем конфигурацию  
      TypegooseModule.forRootAsync({  
         // тут мы импортируем модули провайдеров  
         imports: [ConfigModule],  
         // тут мы вставляем зависимость в фэктори из модуля, который в него импортировали  
         inject: [ConfigService],  
         // сюда мы передаём конфиг подключения к монге  
         useFactory: getMongoConfig,  
      }),  
      AuthModule,  
      TopPageModule,  
      ProductModule,  
      ReviewModule,  
   ],  
   controllers: [AppController],  
   providers: [AppService],  
})  
export class AppModule {}

Сейчас нам нужно написать саму функцию getMongoConfig, которая сгенерирует параметры подключения.

Таким образом должен выглядеть объект подключения по типам:

Все конфиги, которые мы передаём в модули, лучше складировать в папку configs.

  • getMongoConfig - эта функция возвращает объект, который содержит в себе строку подключения к монге и деструктурированный объект, возвращаемый из функции, которая возвращает опции монги
  • getMongoString - возвращает строку подключения к монге через обращение к configService
  • getMongoOptions - возвращает опции для подключения к монге

src / configs / mongo.config.ts

import { ConfigService } from '@nestjs/config';  
import { TypegooseModuleOptions } from 'nestjs-typegoose';  
  
// получаем строку подключения к монге  
const getMongoString = (configService: ConfigService) =>  
   'mongodb://' +  
   configService.get('MONGO_LOGIN') +  
   ':' +  
   configService.get('MONGO_PASSWORD') +  
   '@' +  
   configService.get('MONGO_HOST') + // хост
   ':' +  
   configService.get('MONGO_PORT') + // порт
   '/' +  
   configService.get('MONGO_AUTHDATABASE'); // база, к которой подключаемся
  
// получение опций для подключения к монге  
const getMongoOptions = () => ({ });  
  
// это функция получения конфига для подключения к монге  
export const getMongoConfig = async (  
   configService: ConfigService,  
): Promise<TypegooseModuleOptions> => {  
   // возвращаем объект, который вызывает две функции, которые вернут нам - строку и опции для подключения  
   return {  
      uri: getMongoString(configService),  
      ...getMongoOptions(),  
   };  
};

И теперь npm start запустит наш сервер с подключением к монге

004 Подключение моделей

Далее нам нужно подготовить наши модели данных и навесить на них декораторы, чтобы typegoose понял, как работать с этими данными

В модуль нужно импортировать TypegooseModule, из которого буде использовать локальную функциональность (forFeature), в которой опишем модель данных, которую будет иметь данный модуль

src > auth > auth.module.ts

import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { TypegooseModule } from 'nestjs-typegoose';
import { AuthModel } from './auth.model';
 
@Module({
	controllers: [AuthController],
	// добавляем импорт модуля
	imports: [
		// подключаем локально для модуля модели
		TypegooseModule.forFeature([
			{
				typegooseClass: AuthModel, // класс модели
				// опции схемы данных
				schemaOptions: {
					collection: 'Auth', // имя коллекции
				},
			},
		]),
	],
})
export class AuthModule {}

Далее нам нужно описать свойства (а именно, их характеристики), которые будут попадать в монгу через декораторы

@prop - описывает данные как отдельные свойства, которые будут класться в базу. Данный декоратор стоит добавлять на все свойства, которые мы отправляем в монгу.

Ну и так же далее нам нужно сделать TimeStamps, который будет помечать время создания объекта. Можно создать его отдельным свойством (createdAt), а можно сделать правильно и расширить ДТОшку от тайпгуза через extends TimeStamp

Вместе с этим нужно добавить ещё и _id в нашу модель. Это можно сделать ещё более красивым способом - заэкстендить интерфейс с именем класса модели данных от класса Base. И тут нужно будет сказать, что в наши модели данных теперь нельзя добавлять поле _id, так как оно присутствует в Base. Сам интерфейс обязательно нужно экспортировать, чтобы TS смог смёрджить класс и интерфейс

src > auth > auth.model.ts

import { prop } from '@typegoose/typegoose';
import { Base, TimeStamps } from '@typegoose/typegoose/lib/defaultClasses';
 
export interface AuthModel extends Base {}
 
export class AuthModel extends TimeStamps {
	@prop({
		// index: true, // это поле будет являться индексом в БД
		unique: true, // сделает поле уникальным и так же индексом записи
	})
	email: string;
 
	@prop()
	passwordHash: string;
}

Чтобы пустые интерфейсы не подсвечивались, нужно их оффнуть в еслинте

Добавляем ровно то же самое подключение Typegoose модуля для подключение модели к модулю продукта

src > product > product.module.ts

import { Module } from '@nestjs/common';
import { ProductController } from './product.controller';
import { TypegooseModule } from 'nestjs-typegoose';
import { AuthModel } from '../auth/auth.model';
 
@Module({
	controllers: [ProductController],
	imports: [
		TypegooseModule.forFeature([
			{
				typegooseClass: ProductModel, 
				schemaOptions: {
					collection: 'Product', 
				},
			},
		]),
	],
})
export class ProductModule {}

Если мы хотим в @prop указать тип, то нам придётся указать такую конструкцию:

Между [] попадает не тип TypeScript, а конструктор типа Typegoose

@prop({ type: () => [String] })  
tags: string[];

Типизация модели продукта:

src > product > product.model.ts

import { Base, TimeStamps } from '@typegoose/typegoose/lib/defaultClasses';
import { prop } from '@typegoose/typegoose';
 
class ProductCharacteristic {
	@prop()
	name: string;
 
	@prop()
	value: string;
}
 
export interface ProductModel extends Base {}
 
export class ProductModel extends TimeStamps {
	@prop()
	image: string;
 
	@prop()
	title: string;
 
	@prop()
	price: number;
 
	@prop()
	oldPrice: number;
 
	@prop()
	credit: number;
 
	@prop()
	calculatedRating: number;
 
	@prop()
	description: string;
 
	@prop()
	advantages: string;
 
	@prop()
	disAdvantages: string;
 
	@prop({ type: () => [String] })
	categories: string[];
 
	@prop({ type: () => [String] })
	tags: string[];
 
	@prop({
		type: () => [ProductCharacteristic], // типизируем запрос
		_id: false, // отключаем автоматическую генерацию id в массиве
	})
	characteristics: ProductCharacteristic[];
}

Подключение модуля тайпгуза к обзору:

src > review > review.module.ts

import { Module } from '@nestjs/common';
import { ReviewController } from './review.controller';
import { ReviewModel } from './review.model';
import { TypegooseModule } from 'nestjs-typegoose';
 
@Module({
	controllers: [ReviewController],
	imports: [
		TypegooseModule.forFeature([
			{
				typegooseClass: ReviewModel,
				schemaOptions: {
					collection: 'Review',
				},
			},
		]),
	],
})
export class ReviewModule {}

Типизация модели обзора:

src > review > review.model.ts

import { Base, TimeStamps } from '@typegoose/typegoose/lib/defaultClasses';
import { prop } from '@typegoose/typegoose';
 
export interface ReviewModel extends Base {}
 
export class ReviewModel extends TimeStamps {
	@prop()
	name: string;
 
	@prop()
	title: string;
 
	@prop()
	description: string;
 
	@prop()
	rating: number;
 
	// удаляем, так как экстендим от TimeStamps
	// @prop()
	// createdAt: Date;
}

Подключение модуля главной страницы:

src > top-page > top-page.module.ts

import { Module } from '@nestjs/common';
import { TopPageController } from './top-page.controller';
import { TypegooseModule } from 'nestjs-typegoose';
import { TopPageModel } from './top-page.model';
 
@Module({
	controllers: [TopPageController],
	imports: [
		TypegooseModule.forFeature([
			{
				typegooseClass: TopPageModel,
				schemaOptions: {
					collection: 'TopPage',
				},
			},
		]),
	],
})
export class TopPageModule {}

Типизация модели главной страницы:

src > top-page > top-page.model.ts

import { Base, TimeStamps } from '@typegoose/typegoose/lib/defaultClasses';
import { prop } from '@typegoose/typegoose';
 
export enum TopLevelCategory {
	Courses,
	Services,
	Books,
	Products,
}
 
export class HhData {
	@prop()
	count: number; // количество вакансий
 
	@prop()
	juniorSalary: number;
 
	@prop()
	middleSalary: number;
 
	@prop()
	seniorSalary: number;
}
 
export class TopPageAdvantages {
	@prop()
	title: string;
 
	@prop()
	description: string;
}
 
export interface TopPageModel extends Base {}
 
export class TopPageModel extends TimeStamps {
	@prop({ enum: TopLevelCategory })
	firstCategory: TopLevelCategory;
 
	@prop()
	secondCategory: string;
 
	@prop({ unique: true })
	alias: string;
 
	@prop()
	title: string;
 
	@prop()
	category: string;
 
	@prop({ type: () => HhData }) // возвращается тип модели HhData
	hh?: HhData;
 
	@prop({ type: () => [TopPageAdvantages] })
	advantages: TopPageAdvantages[];
 
	@prop()
	seoText: string;
 
	@prop()
	tagsTitle: string;
 
	@prop({ type: () => [String] })
	tags: string[];
}

Если бы мы присвоили значения для свойства енама, то можно было бы указать и возвращаемый тип

И теперь тут отображены после кор-модуля все 4 модуля forFeature, которые мы подключили к модулям приложения

005 Сервис отзывов

Добавляем новый сервис через CLI неста

nest g service review

Далее добавляется в модуль зависимость от данного сервиса в провайдерах

Далее переходим в сервис и первым делом в конструкторе инжектим ReviewModel, который предоставит доступ к редактированию данных внутри данной модели (предоставит доступ к методам изменения, создания и так далее)

src > review > review.service.ts

import { Inject, Injectable } from '@nestjs/common';  
import { ReviewModel } from './review.model';  
import { ModelType } from '@typegoose/typegoose/lib/types';  
  
@Injectable()  
export class ReviewService {  
   // инжектим модель в сервис  
   constructor(@InjectModel(ReviewModel) private readonly reviewModel: ModelType<ReviewModel>) {}  
}

Так же нам нужно добавить в модель обзора id продукта, к которому будет присвоен комментарий

src > review > review.model.ts

Далее нам нужно описать ДТОшку модели данных, которую нужно соблюдать для создании нового обзора на продукт

src > review > dto > create-review.dto.ts

export class CreateReviewDto {
	name: string;
	title: string;
	description: string;
	rating: number;
	productId: string;
}

И далее подставим для контроллера создания новую ДТОшку, которая будет отвечать за модель принимаемых данных для добавления обзора

src > review > review.controller.ts

Приступаем к описанию сервиса, который будет напрямую взаимодействовать с провайдером (хранить методы для модификации и изменения данных):

  • create - метод создания нового обзора
  • findByProductId - метод поиска обзора по продукту (для вывода обзоров по продукту)
  • delete - удаление обзора
  • deleteByProductId - удаление обзоров по id продукта (например, если удалится продукт, то вместе с ним и обзор)

Конкретно reviewModel предоставляет нам методы для работы с данными внутри Mongo:

  • find - найти запись по query, которая принимает в себя объект с полями {}
  • create - создание нового объекта по модели данных
  • findByIdAndDelete - поиск и удаление записи по id
  • deleteMany - удаление нескольких записей по query

В некоторых операциях используется метод exec, который запускает операцию

Когда мы пишем в query productId: new Types.ObjectId(productId), то тут мы вызываем поиск по новому созданному типу, который является уникальным идентификатором для записи

src > review > review.service.ts

import { Injectable } from '@nestjs/common';
import { ReviewModel } from './review.model';
import { ModelType, DocumentType } from '@typegoose/typegoose/lib/types';
import { CreateReviewDto } from './dto/create-review.dto';
import { Types } from 'mongoose';
import { InjectModel } from 'nestjs-typegoose';
 
@Injectable()
export class ReviewService {
	// инжектим модель, которая содержит методы mongoose
	constructor(
		@InjectModel(ReviewModel) 
		private readonly reviewModel: ModelType<ReviewModel>
	) {}
 
	// метод создания нового обзора по продукту
	async create(dto: CreateReviewDto): Promise<DocumentType<ReviewModel>> {
		// возвращает созданный обзор
		return this.reviewModel.create(dto);
	}
 
	// метод удаления обзора
	async delete(id: string): Promise<DocumentType<ReviewModel> | null> {
		// findByIdAndDelete - найти по id и удалить
		// exec - запрашивает выполнение данной операции
		return this.reviewModel.findByIdAndDelete(id).exec();
	}
 
	// метод удаления всех обзоров по продукту (нужно, если удаляем продукт)
	async deleteByProductId(productId: string) {
		return this.reviewModel.deleteMany({ 
			productId: new Types.ObjectId(productId) 
		}).exec();
	}
 
	// метод поиска обзора по продукту (если переходим на просмотр продукта)
	async findByProduct(
		productId: string
	): Promise<DocumentType<ReviewModel>[]> {
		return this.reviewModel.find({ 
			productId: new Types.ObjectId(productId) 
		}).exec();
	}
}

И далее в контроллере (который принимает в себя запросы с фронта) вызываем методы сервиса, который уже и производит изменения в базе данных

Сюда в конструктор вставляем ReviewService из review.service.ts. Далее нам остаётся только добавить методы из сервиса ReviewService. Наименования методов ровно такие же, как и в сервисе.

Сам контроллер работает только с http-запросами. Ответы на ошибки приходят тоже из него (тот же нестовский HttpException, который отправляет на фронт ответ со сгенерированной ошибкой)

src > review > review.controller.ts

import { Controller, Delete, Get, HttpException, HttpStatus, Param, Post } from '@nestjs/common';
import { Body } from '@nestjs/common/decorators';
import { CreateReviewDto } from './dto/create-review.dto';
import { ReviewService } from './review.service';
import { REVIEW_NOT_FOUND } from './review.constants';
 
@Controller('review')
export class ReviewController {
	// тут мы принимаем инстанс сервиса обзоров
	constructor(private readonly reviewService: ReviewService) {}
 
	@Post('create')
	async create(@Body() dto: CreateReviewDto) {
		return this.reviewService.create(dto); // создаём новый DTO
	}
 
	@Delete(':id')
	async delete(@Param('id') id: string) {
		// удаляем нужную запись получаем удалённый документ
		const deletedDoc = await this.reviewService.delete(id);
 
		// если мы ничего не удалили, то
		if (!deletedDoc) {
			// выкенем нестовскую http-ошибку
			// 1арг - строка с сообщением ошибки, 2арг - статус ошибки
			throw new HttpException(REVIEW_NOT_FOUND, HttpStatus.NOT_FOUND);
		}
	}
 
	@Get('getByProduct/:productId')
	async getByProduct(@Param('productId') productId: string) {
		return this.reviewService.findByProductId(productId);
	}
}

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

src > review > review.constants.ts

export const REVIEW_NOT_FOUND = 'Отзыв по такому id не найден';