Mongoose с Node.js - моделирование объектных данных
NoSQL привнес гибкость в табличный мир баз данных. В частности, MongoDB стал отличным вариантом для хранения неструктурированных документов JSON. Данные начинаются как JSON в пользовательском интерфейсе и претерпевают очень мало преобразований для сохранения, поэтому мы получаем преимущества от повышения производительности и сокращения времени обработки.
Но NoSQL не означает полного отсутствия структуры. Нам все еще нужно проверить и преобразовать наши данные перед их сохранением, и нам все еще может потребоваться применить к ним некоторую бизнес-логику. Это место, которое заполняет Mongoose.
В этой статье мы узнаем на примере приложения, как мы можем использовать Mongoose для моделирования наших данных и проверки их перед сохранением в MongoDB.
Мы напишем модель для приложения «Генеалогия» - человека с несколькими личными свойствами, включая их родителей. Мы также увидим, как мы можем использовать эту модель для создания и изменения лиц и сохранения их в MongoDB.
Что такое Mongoose?
Как работает MongoDB
Чтобы понять, что такое Mongoose, нам сначала нужно понять в общих чертах, как работает MongoDB. Базовая единица данных, которую мы можем сохранить в MongoDB, - это документ. Несмотря на то, что они хранятся как двоичные, когда мы запрашиваем базу данных, мы получаем ее представление в виде объекта JSON.
Связанные документы могут храниться в коллекциях, подобных таблицам в реляционных базах данных. На этом аналогия заканчивается, поскольку мы определяем, что считать «связанными документами».
MongoDB не навязывает структуру документов. Например, мы можем сохранить этот документ в коллекцию Person
:
{
"name": "Alice"
}
А затем в той же коллекции мы могли бы сохранить, казалось бы, несвязанный документ без общих свойств или структуры:
{
"latitude": 53.3498,
"longitude": 6.2603
}
В этом заключается новизна баз данных NoSQL. Мы придаем смысл нашим данным и храним их так, как нам кажется лучше. База данных не налагает никаких ограничений.
Цель Mongoose
Хотя MongoDB не навязывает структуру, приложения обычно управляют данными с ее помощью. Мы получаем данные, и нам необходимо их проверить, чтобы убедиться, что мы получили именно то, что нам нужно. Нам также может потребоваться обработать данные каким-либо образом перед их сохранением. Здесь и вступает в игру Mongoose.
Mongoose - это NPM пакет для приложений NodeJS. Он позволяет определять схемы для наших данных, а также абстрагироваться от доступа к MongoDB. Таким образом мы можем гарантировать, что все сохраненные документы имеют общую структуру и содержат необходимые свойства.
Давайте теперь посмотрим, как определить схему.
Установка Mongoose и создание схемы Person
Давайте запустим проект Node со свойствами по умолчанию и схемой Person:
npm init -y
После инициализации проекта давайте продолжим и установим mongoose
, используя npm
:
npm install --save mongoose
mongoose
также автоматически установит NPM модуль mongodb
. Вы не будете использовать его напрямую. Этим займется Mongoose.
Чтобы работать с Mongoose, нам нужно импортировать его в наши скрипты:
let mongoose = require('mongoose');
А затем подключитесь к базе данных с помощью:
mongoose.connect('mongodb://localhost:27017/genealogy', {useNewUrlParser: true, useUnifiedTopology: true});
Поскольку база данных еще не существует, она будет создана. Мы будем использовать последний инструмент для анализа строки подключения, установив значение useNewUrlParser
как true
а также будем использовать последнюю версию драйвера MongoDB с useUnifiedTopology
равным true
.
mongoose.connect()
предполагает, что сервер MongoDB работает локально на порту по умолчанию и без учетных данных. Один из простых способов заставить MongoDB работать таким образом - это Docker:
docker run -p 27017:27017 mongo
Созданного контейнера нам будет достаточно, чтобы попробовать Mongoose, хотя данные, сохраненные в MongoDB, не будут постоянными.
Схема и модель Person
После предыдущих необходимых объяснений мы можем сосредоточиться на написании нашей схемы человека и компиляции из нее модели.
Схема в Mongoose сопоставляется с коллекцией MongoDB и определяет формат для всех документов в этой коллекции. Всем свойствам внутри схемы должен быть назначен SchemaType
. Например, имя нашего Person
можно определить так:
const PersonSchema = new mongoose.Schema({
name: { type: String},
});
Или еще проще, вот так:
const PersonSchema = new mongoose.Schema({
name: String,
});
String
является одним из нескольких SchemaTypes
, определенных Mongoose. Остальное можно найти в документации Mongoose.
Ссылка на другие схемы
Мы можем ожидать, что все приложения среднего размера будут иметь более одной схемы, и, возможно, эти схемы будут каким-то образом связаны.
В нашем примере, чтобы представить генеалогическое древо, нам нужно добавить в нашу схему два атрибута:
const PersonSchema = new mongoose.Schema({
// ...
mother: { type: mongoose.Schema.Types.ObjectId, ref: 'Person' },
father: { type: mongoose.Schema.Types.ObjectId, ref: 'Person' },
});
Человек может иметь mother
и father
. В Mongoose это можно представить, сохранив идентификатор документа, на который есть ссылка mongoose.Schema.Types.ObjectId
, а не самого объекта.
ref
должно быть название модели, на которую мы ссылаемся. Позже мы узнаем больше о моделях, но пока достаточно знать, что схема относится только к одной модели и Person
является моделью PersonSchema
.
Наш случай немного особенный, потому что оба mother
и father
также будут содержать людей, но способ определения этих отношений одинаков во всех случаях.
Встроенная проверка
Все SchemaType
идут со встроенной проверкой по умолчанию. В зависимости от выбранного мы можем определить лимиты и другие требования SchemaType
. Для того, чтобы увидеть некоторые примеры, давайте добавим surname
, yearBorn
и notes
к нашему Person
:
const PersonSchema = new mongoose.Schema({
name: { type: String, index: true, required: true },
surname: { type: String, index: true },
yearBorn: { type: Number, min: -5000, max: (new Date).getFullYear() },
notes: { type: String, minlength: 5 },
});
Все встроенные SchemaType
могут быть required
. В нашем случае мы хотим, чтобы у всех людей было хотя бы имя. Тип Number
позволяет устанавливать мин и максимальными значениями.
Свойство index
заставит Mongoose создать индекс в базе данных. Это облегчает эффективное выполнение запросов. Выше мы определили индексы name
и surname
. Мы всегда будем искать людей по именам.
Пользовательская проверка
Встроенные SchemaType
позволяют настраивать собственные проверки. Это особенно полезно, когда у нас есть свойство, которое может содержать только определенные значения. Добавим свойство photosURLs
в наш массив URL-адресов их фотографий Person
:
const PersonSchema = new mongoose.Schema({
// ...
photosURLs: [
{
type: String,
validate: {
validator: function(value) {
const urlPattern = /(http|https):\/\/(\w+:{0,1}\w*#)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%#!\-/]))?/;
const urlRegExp = new RegExp(urlPattern);
return value.match(urlRegExp);
},
message: props => `${props.value} is not a valid URL`
}
}
],
});
photosURLs
это просто массив строк photosURLs: [String]
. Что делает это свойство особенным, так это то, что нам нужна настраиваемая проверка, чтобы подтвердить, что добавленные значения имеют формат URL-адреса.
В приведенной выше функции validator()
используется регулярное выражение, которое соответствует типичным URL-адресам в Интернете, которые должны начинаться с http(s)://
.
Если нам нужен более сложный SchemaType
, мы можем создать свой собственный, но нам стоит поискать, возможно он уже существует.
Например, пакет mongoose-type-url, который мы могли бы использовать вместо нашей валидации.
Виртуальные свойства
Виртуальные объекты - это свойства документа, которые не сохраняются в базе данных. Они результат расчета. В нашем примере было бы полезно задать полное имя человека в одной строке, а не разделять name
и surname
.
Посмотрим, как этого добиться после определения нашей первоначальной схемы:
PersonSchema.virtual('fullName').
get(function() {
if(this.surname)
return this.name + ' ' + this.surname;
return this.name;
}).
set(function(fullName) {
fullName = fullName.split(' ');
this.name = fullName[0];
this.surname = fullName[1];
});
Приведенное выше виртуальное свойство fullName
делает некоторые предположения для простоты: у каждого человека есть как минимум имя и фамилия. Мы столкнемся с проблемами, если у человека есть отчество, составное имя или фамилия. Все эти ограничения могут быть закреплены внутри get()
и set()
функции, определенные выше.
Поскольку виртуальные данные не сохраняются в базе данных, мы не можем использовать их в качестве фильтра при поиске людей в базе данных. В нашем случае нам нужно будет использовать name
и surname
.
ПО промежуточного слоя
Промежуточное ПО - это функции или перехватчики, которые могут выполняться до или после стандартных методов Mongoose, например, таких как save()
или find()
.
Человек может иметь mother
и father
. Как мы уже говорили ранее, мы сохраняем эти отношения, сохраняя идентификатор объекта как свойства человека, а не сами объекты. Было бы неплохо заполнить оба свойства самими объектами, а не только идентификаторами.
Это может быть достигнуто как функция pre()
, связанная с методом Mongoose findOne()
:
PersonSchema.pre('findOne', function(next) {
this.populate('mother').populate('father');
next();
});
Вышеупомянутой функции необходимо вызвать функцию next()
, полученную в качестве параметра, чтобы продолжить обработку других хуков.
populate()
- это метод Mongoose для замены идентификаторов объектами, которые они представляют, и мы используем его для получения родителей при поиске только одного человека.
Мы могли бы добавить этот хук к другим функциям поиска, например find()
. Мы могли бы даже найти родителей рекурсивно, если бы захотели. Но мы должны обращаться с populate()
осторожно, так как каждый вызов - это выборка из базы данных.
Создайте модель для схемы
Чтобы начать создавать документы на основе нашей схемы Person, последний шаг - это скомпилировать модель на основе схемы:
const Person = mongoose.model('Person', PersonSchema);
Первым аргументом будет единственное имя коллекции, о которой мы говорим. Это значение ref
, которое мы придали свойствам mother
и father
. Второй аргумент - это Schema, то что мы определили ранее.
Метод model()
делает копию всего что мы определили в схеме. Он также содержит все методы Mongoose, которые мы будем использовать для взаимодействия с базой данных.
Модель - это единственное, что нам теперь нужно. Мы могли бы даже использовать module.exports
, чтобы сделать Person доступным в других модулях нашего приложения:
module.exports.Person = mongoose.model('Person', PersonSchema);
module.exports.db = mongoose;
Мы также экспортировали модуль mongoose
. Он понадобится нам для отключения от базы данных до завершения работы приложения.
Мы можем импортировать модуль таким образом:
const {db, Person} = require('./persistence');
Как использовать модель
Модель, которую мы скомпилировали в последнем разделе, содержит все, что нам нужно для взаимодействия с коллекцией в базе данных.
Давайте теперь посмотрим, как мы будем использовать нашу модель для всех операций CRUD.
Создание Person
Мы можем создать Person, просто выполнив:
let alice = new Person({name: 'Alice'});
name
это единственное обязательное свойство. Давайте создадим другого человека, но на этот раз с использованием виртуального свойства:
let bob = new Person({fullName: 'Bob Brown'});
Теперь, когда у нас есть первые два человека, мы можем создать нового со всеми заполненными свойствами, включая родителей:
let charles = new Person({
fullName: 'Charles Brown',
photosURLs: ['https://bit.ly/34Kvbsh'],
yearBorn: 1922,
notes: 'Famous blues singer and pianist. Parents not real.',
mother: alice._id,
father: bob._id,
});
Все значения для этого последнего человека установлены на допустимые, так как проверка выдаст ошибку, как только эта строка будет выполнена. Например, если бы мы установили URL-адрес первой фотографии не на ссылку, мы бы получили ошибку:
ValidationError: Person validation failed: photosURLs.0: wrong_url is not a valid URL
Как объяснялось ранее, родители были заполнены идентификаторами первых двух человек, а не объектами.
Мы создали трех человек, но они еще не сохранены в базе данных. Сделаем это дальше:
alice.save();
bob.save();
Операции с базой данных асинхронны. Если мы хотим дождаться завершения, мы можем использовать async / await:
await charles.save();
Теперь, когда все люди будут сохранены в базе данных, мы можем получить их обратно с методами find()
и findOne()
.
Получить одно или несколько Person
Все методы поиска в Mongoose требуют аргумента для фильтрации поиска. Вернемся к последнему созданному нами человеку:
let dbCharles = await Person.findOne({name: 'Charles', surname: 'Brown'}).exec();
findOne()
возвращает запрос, поэтому для получения результата нам нужно выполнить его с помощью exec()
, а затем дождаться результата с помощью await
.
Поскольку мы прикрепили к методу findOne()
ловушку для заполнения родителей человека, теперь мы можем обращаться к ним напрямую:
console.log(dbCharles.mother.fullName);
В нашем случае мы знаем, что запрос вернет только один результат, но даже если более одного человека соответствует фильтру, будет возвращен только первый результат.
Мы можем получить более одного результата, если воспользуемся методом find()
:
let all = await Person.find({}).exec();
Мы вернем массив, который сможем перебрать.
Обновление Person
Если у нас уже есть Person, так как мы только что его создали или извлекли, мы можем обновить и сохранить изменения, выполнив следующие действия:
alice.surname = 'Adams';
charles.photosURLs.push('https://bit.ly/2QJCnMV');
await alice.save();
await charles.save();
Поскольку оба человека уже существуют в базе данных, Mongoose отправит команду обновления только с измененными полями, а не со всем документом.
Удалить Person
Как и поиск, удаление может быть выполнено для одного или нескольких Person:
await Person.deleteOne({name: 'Alice'});
await Person.deleteMany({}).exec();
После выполнения этих двух команд коллекция будет пустой.
Вывод
В этой статье мы увидели, как Mongoose может быть очень полезен в наших проектах NodeJS и MongoDB.
В большинстве проектов с MongoDB нам необходимо хранить данные в определенном формате. Приятно знать, что Mongoose предоставляет простой способ моделирования и проверки этих данных.