Реалізація підтримки MongoDB для NestJS Boilerplate з використанням гексагональної архітектури
NestJS Boilerplate — це проєкт, який містить більшість необхідних бібліотек та рішень, як-от аутентифікація, переклади, налаштування бази даних, тести тощо, для швидкого запуску вашого проєкту на NestJS за допомогою класичного підходу REST API. Станом на зараз бойлерплейт налічує 1,8К зірочок на GitHub й отримав визнання і підтримку в ком’юніті розробників.
Ми створили NestJS boilerplate ще у серпні 2020 року, і з тих пір робота над його оптимізацією і покращенням не припиняється.
Чому ми вирішили додати підтримку MongoDB
Відпочатку в бойлерплейті була підтримка PostgreSQL через її надійність, цілісність даних та наявність активної спільноти. Та для проєктів, яким потрібна висока швидкість роботи з великим обсягом даних і висока масштабованість, більше підходить MongoDB.
Через це ми відчували необхідність інтегрувати її в проєкт, до того ж, ми отримували запити на підтримку NoSQL-бази даних як з боку ком’юніті, так і від співробітників, які використовують цей бойлерплейт.
Наразі це зроблено. Тепер у розробників під час використання бойлерплейту є вибір: використовувати документо-орієнтовану базу даних MongoDB або послуговуватися у системою керування реляційними базами даних PostgreSQL.
То ж що ми рекомендуємо використовувати під час інсталяції проєкту — кита чи слона? Звісно, питання не в тому, яка БД краще, адже обидві вони чудові, — все залежить від масштабу та цілей застосунку. Let’s dive into details.
- якщо вам потрібна реляційна база даних, яка виконуватиме складні запити SQL і працюватиме з багатьма наявними програмами на основі табличної реляційної моделі даних, краще скористатися PostgreSQL;
- для сценарію, коли потрібен високий рівень безпеки та висока відповідність ACID, кращим рішенням буде PostgreSQL;
- якщо потрібен надійний інструмент для опрацювання складних транзакцій та аналітики в програмах, які працюють з багатоструктурованими даними, що швидко змінюються, MongoDB буте гарним вибором для вашого проєкту;
- якщо ви підтримуєте програму, яку вам знадобиться масштабувати, і її потрібно розподілити між регіонами для локальності даних або суверенітету даних, архітектура масштабування MongoDB автоматично задовольнить ці потреби.
З метою забезпечення гарного рівня абстракції і для спрощення роботи з MongoDB використовується Mongoose — бібліотека моделювання об’єктних даних (ODM). Вона дозволяє розробникам визначати свої моделі даних, використовуючи підхід на основі схеми, і надає багатий набір функцій, які спрощують процес роботи.
Окрім підтримки основних операцій CRUD і функцій запитів «з коробки», Mongoose надає багатший набір функцій для роботи з MongoDB, наприклад, функції проміжного програмного забезпечення, віртуальні властивості, конструктори запитів і перевірку схем. Це дозволяє розробникам визначати структуру своїх даних, зокрема типи кожного поля, і вказувати правила перевірки для забезпечення послідовності і цілісності даних.
Реалізація підтримки MongoDB з допомогою гексагональної архітектури
Щоб дозволити застосунку однаково керувати сценаріями пакетного виконання, окремо від його кінцевих пристроїв і баз даних, була використана гексагональна архітектура програмного забезпечення (aka ports and adapters architecture), представлена Алістером Кокберном.
У своїй статті він наголошує, що немає великої різниці між тим, як інтерфейс користувача та база даних взаємодіють із програмою, оскільки обидва вони є зовнішніми підключеннями, які є взаємозамінними з подібними компонентами та взаємодіють з програмою еквівалентними способами.
Тому ми використали цей архітектурний підхід в проєкті, і це дозволило інкапсулювати деталі реалізації джерела даних, таким способом здійснивши підтримку двох типів баз даних у бойлерплейті.
Розгляньмо детальніше реалізацію. Перш за все в директорії users/domain
створюємо сутність User
.
export class User { id: number | string; email: string | null; password?: string; firstName: string | null; lastName: string | null; // ...
}
Після цього створюємо порт UserRepository
export abstract class UserRepository { abstract create( data: Omit<User, 'id'>, ): Promise<User>; abstract findOne(fields: EntityCondition<User>): Promise<NullableType<User>>;
}
В users/infrastructure/persistence/relational/repositories
імплементуємо UserRepository для роботи з TypeORM:
@Injectable() export class UsersRelationalRepository implements UserRepository { constructor( @InjectRepository(UserEntity) private readonly usersRepository: Repository<UserEntity>, ) {} async create(data: User): Promise<User> { const persistenceModel = UserMapper.toPersistence(data); const newEntity = await this.usersRepository.save( this.usersRepository.create(persistenceModel), ); return UserMapper.toDomain(newEntity); } async findOne(fields: EntityCondition<User>): Promise<NullableType<User>> { const entity = await this.usersRepository.findOne({ where: fields as FindOptionsWhere<UserEntity>, }); return entity ? UserMapper.toDomain(entity) : null; } }
І створюваємо в users/infrastructure/persistence/relational
модуль для роботи з TypeORM:
@Module({ imports: [TypeOrmModule.forFeature([UserEntity])], providers: [ { provide: UserRepository, useClass: UsersRelationalRepository, }, ], exports: [UserRepository], }) export class RelationalUserPersistenceModule {}
Те саме робимо для Mongoose:
@Injectable() export class UsersDocumentRepository implements UserRepository { constructor( @InjectModel(UserSchemaClass.name) private readonly usersModel: Model<UserSchemaClass>, ) {} async create(data: User): Promise<User> { const persistenceModel = UserMapper.toPersistence(data); const createdUser = new this.usersModel(persistenceModel); const userObject = await createdUser.save(); return UserMapper.toDomain(userObject); } async findOne(fields: EntityCondition<User>): Promise<NullableType<User>> { if (fields.id) { const userObject = await this.usersModel.findById(fields.id); return userObject ? UserMapper.toDomain(userObject) : null; } const userObject = await this.usersModel.findOne(fields); return userObject ? UserMapper.toDomain(userObject) : null; } }
І модуль для роботи з Mongoose:
@Module({ imports: [ MongooseModule.forFeature([ { name: UserSchemaClass.name, schema: UserSchema }, ]), ], providers: [ { provide: UserRepository, useClass: UsersDocumentRepository, }, ], exports: [UserRepository], }) export class DocumentUserPersistenceModule {}
Після цього в users/users.module.ts
базуючись на ENV конфігурації підмикаємо або модуль для роботи з Mongoose (DocumentUserPersistenceModule
) або TypeORM (RelationalUserPersistenceModule
).
const infrastructurePersistenceModule = (databaseConfig() as DatabaseConfig) .isDocumentDatabase ? DocumentUserPersistenceModule : RelationalUserPersistenceModule; @Module({ imports: [infrastructurePersistenceModule, FilesModule], controllers: [UsersController], providers: [UsersService], exports: [UsersService, infrastructurePersistenceModule], }) export class UsersModule {}
І далі в UserService ми можемо отримати доступ до UserRepository, а nestjs буде розуміти яку саме базу данних використовувати базуючись на налаштуваннях ENV конфігурації.
@Injectable() export class UsersService { constructor( private readonly usersRepository: UserRepository, ) {} }
Повну реалізацію можна знайти тут.
Схема бази даних
Проєктування схем для NoSQL баз даних не таке саме як для реляційних! Одна з відмінніостей полягає в тому, що в реляційних ми маємо за можливості приводити вашу схему до нормальних форм для уникнення дублікатів і т. д.
Тоді як для NoSQL ми можемо дублювати дані для уникнення join
, за рахунок чого власне і буде досягатися найкращий показник продуктивності під час вибірок даних. На прикладі бойлерплейту розглянемо, в чому відмінність. Схема бази даних для PostgreSQL виглядає приблизно ось так:
І приклад заповнених даних:
Якщо говорити за MongoDB, ми не можемо перенести сюди досвід проєктування з PostgreSQL, тобто створити чотири колекції users, files (for photos), roles, statuses і зберігати в колекції користувачів посилання на інші колекції. А також під час вибірки даних за допомогою агрегації ($lookup) доєднувати додаткові дані, оскільки це буде впливати на продуктивність (детальніше можна переглянути в цьому дослідженні — порівняння старе (2020 рік), але все ще актуальне).
Як повинна виглядати схема? Усе дуже просто: всі дані треба зберігати в одній колекції:
I тепер, коли ми робитимемо вибірку даних з користувачами, нам не треба буде виконувати додаткові запити на отримання даних про фото, роль та статус користувача, бо всі дані вже і так зберігатимуться в колекції користувача, власне, за рахунок чого і буде підвищуватись продуктивність.
Більш детально про проєктування схем для MongoDB можете прочитати тут.
Якщо говорити про вплив обраної бази даних на застосунок для фронтенду, зокрема Extensive React boilerplate, який ми також підтримуємо up-to-date, і він гарно метчиться з обговорюваним наразі бойлерплейтом, то з впевненістю можна сказати, що це не вплине на їхню взаємодію.
Нижче скриншот фронтенду, що працює:
Як використовувати NestJS Boilerplate з Mongoose
Для комфортної розробки (MongoDB + Mongoose) вам потрібно склонувати репозиторій, перейти у директорію my-app/
та скопіювати env-example-document
у env
.
cd my-app/ cp env-example-document .env
Змінити DATABASE_URL=
mongodb://mongo:27017
на DATABASE_URL=
mongodb://localhost:27017
Запустити додатковий контейнер:
docker compose -f docker-compose.document.yaml up -d mongo mongo-express maildev
Встановити залежності:
npm install
Запустити міграції і залити тестові дані:
npm run migration:run
npm run seed:run:document
Запускаємо застосунок у dev режимі:
npm run start:dev
Яку б базу даних ви не обрали для використання — PostgreSQL чи MongoDB (вони обидві чудові), вибір має залежати від цілей проєкту. Запрошую вас спробувати бойлерплейт і додати нам ще трохи зірочок на GitHub⭐
Дякую за цей матеріал Владу Щепотину і Олені Власенко 🇺🇦
6 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів