После прочтения заголовка у многих наверняка возникает вопрос — зачем ещё один велосипед при наличии уже обкатанных Mongoose, Mongorito, TypeORM и т. д.? Для ответа нужно разобраться в чём отличие ORM от ODM. Смотрим википедию:
ORM (англ. Object-Relational Mapping, рус. объектно-реляционное отображение, или преобразование) — технология программирования, которая связывает базы данных с концепциями объектно-ориентированных языков программирования, создавая «виртуальную объектную базу данных».
То есть ORM — это именно про реляционное представление данных. Напомню, в реляционных БД нет возможности просто взять и встроить документ в поле другого документа (в этой статье записи таблиц тоже называются документами, хоть это и некорректно), можно конечно хранить в поле JSON в виде строки, но индекс по данным в нём сделать не выйдет. Вместо этого используются "ссылки" — в поле, где должен быть вложенный документ, вместо него записывается его идентификатор, а сам документ с этим идентификатором сохраняется в соседней таблице. ORM умеет работать с такими ссылками — записи по ним автоматически сразу или лениво забираются из БД, а при сохранении не нужно сперва сохранять дочерний документ, брать назначенный ему идентификатор, записывать его в поле родительского документа и только после этого сохранять родительский документ. Нужно просто попросить ORM сохранить родительский документ и всё что с ним связано, а он (object-relational mapper) уже сам разберётся как это правильно сделать. ODM же наоборот, не умеет работать с такими ссылками, зато знает про встроенные документы.
Думаю отличия примерно понятны, так вот всё перечисленное выше является именно ODM. Даже TypeORM при работе с MongoDB имеет некоторые ограничения (https://github.com/typeorm/typeorm/issues/655) которые делают его опять же обычным ODM.
И тут вы спросите — а зачем? Зачем работая с документоориентированной БД мне понадобились какие-то ссылки? Есть минимум одна простая, но часто встречающаяся ситуация когда они всё же необходимы: на дочерний документ указывают несколько родительских, здесь можно каждому родительскому записать по копии дочернего и потом страдать обеспечивая консистентность данных в этих копиях, а можно просто сохранить дочерний документ в отдельной коллекции, а всем родителям дать ссылку на него (можно ещё в дочерний встраивать родительский, но это не всегда возможно, во-первых, отношение может быть many-to-many, во-вторых, дочерний тип может быть слишком второстепенен в системе и завтра может вообще исчезнуть из БД, встраивать в него что-то ключевое совсем не хочется).
Долгое время я работал с RethinkDB для которого есть несколько неплохих ORM (thinky, requelize, ...), но последнее время активность разработки этой БД совсем уж вызывает уныние. Я решил посмотреть в сторону MongoDB и первое чего я не обнаружил, это подобных пакетов. Почему бы не написать самому, это будет довольно интересный опыт, подумал я и, встречайте — Maraquia.
Установка
npm i -S maraquiaПри использовании с typescript необходимо также добавить "experimentalDecorators": true в tsconfig.json.
Настройка соединения
Есть два способа, здесь рассмотрим более простой: в папке проекта создаём файл config/maraquia.json в который добавляем следующее:
{
"databaseUrl": "mongodb://localhost:27017/",
"databaseName": "Test"
}Использование
Сохранение в БД
Простой пример отношения one-to-many со ссылкой только в одну сторону (примеры будут на typescript, в конце пример на javascript):
import { BaseModel, Field, Model } from 'maraquia';
@Model({
collectionName: 'Pet'
})
class Pet extends BaseModel {
@Field() name: string | null;
}
@Model({
collectionName: 'Owner'
})
class Owner extends BaseModel {
@Field() name: string | null;
@Field(() => Pet) pets: Promise<Array<Pet> | null>;
}
(async () => { // в следующих примерах я буду опускать эту строчку
let pet = new Pet({
name: 'Tatoshka'
});
let owner = new Owner({
name: 'Dmitry',
pets: [pet]
});
await owner.save();
})();В БД появятся две коллекции Pet и Owner с записями:
{
"_id": "5a...1f44",
"name": "Tatoshka"
}и
{
"_id": "5a...1f43",
"name": "Dmitry",
"pets": ["5a...1f44"]
}Метод save был вызван только на модели owner, Maraquia как и положено сама позаботилась о сохранении второго документа.
Усложним пример, теперь отношение many-to-many и ссылки в обе стороны:
@Model({
collectionName: 'User'
})
class User extends BaseModel {
@Field() name: string | null;
@Field(() => Group) groups: Promise<Array<Group> | null>;
}
@Model({
collectionName: 'Group'
})
class Group extends BaseModel {
@Field() name: string | null;
@Field(() => User) users: Promise<Array<User> | null>;
}
let user1 = new User({
name: 'Dmitry'
});
let user2 = new User({
name: 'Tatoshka'
});
let group1 = new Group({
name: 'Admins',
users: [user1]
});
let group2 = new Group({
name: 'Moderators',
users: [user1, user2]
});
user1.groups = [group1, group2] as any;
user2.groups = [group2] as any;
await group1.save();В БД появится коллекция User с записями:
{
"_id": "5a...c56f",
"name": "Dmitry",
"groups": ["5a...c56e", "5a...c570"]
}
{
"_id": "5a...c571",
"name": "Tatoshka",
"groups": ["5a...c570"]
}и коллекция Group с записями:
{
"_id": "5a...c56e",
"name": "Admins",
"users": ["5a...c56f"]
}
{
"_id": "5a...c570",
"name": "Moderators",
"users": ["5a...c56f", "5a...c571"]
}Вы, наверное, уже заметили отсутствие декораторов с именами вроде hasOne, hasMany, belongsTo как это обычно принято для ORM. Maraquia справляется без этой дополнительной информации, hasOne или hasMany определяется значением, массив — значит hasMany. А встроенный документ или внешний (сохраняется в отдельной коллекции) определяется наличием в его схеме заполненного collectionName. Например, если в первом примере закомментировать строку collectionName: 'Pet' и вновь запустить его, то запись появится только в коллекции Owner и будет выглядеть так:
{
"_id": "5b...ec43",
"name": "Dmitry",
"pets": [{
"name":"Tatoshka"
}]
}Кроме того тип поля pets перестаёт быть промисом.
То есть с помощью Maraquia можно также удобно работать и со встраиваемыми документами.
Чтение из БД
Попробуем прочитать из базы что-то из ранее сохранённого:
let user = User.find<User>({ name: 'Dmitry' });
console.log(user instanceof User); // true
console.log(user.name); // 'Dmitry'
console.log(await user.groups); // [Group { name: 'Admins', ... }, Group { name: 'Moderators', ... }]При чтении поля groups было использовано ключевое слово await — внешние документы достаются из базы лениво при первом чтении соответствующего поля.
Но что если необходимо иметь доступ к идентификаторам хранящимся в поле без вытаскивания соответствующих им документов из БД, но при этом опционально может понадобиться и вытащить их? Имя поля в модели соответствует имени поля в документе, но используя опцию dbFieldName можно изменить это соответствие. То есть определив два поля в модели ссылающихся на одно поле в документе и не указав тип для одного из них можно решить эту проблему:
@Model({
collectionName: 'Group'
})
class Group extends BaseModel {
@Field({ dbFieldName: 'users' })
readonly userIds: Array<ObjectId> | null; // здесь будут идентификаторы
@Field(() => User)
users: Promise<Array<User> | null>; // а здесь инстансы пользователей по идентификаторам
}Удаление документа
Метод remove удаляет соответствующий документ из БД. Maraquia не знает где есть ссылки на него и здесь программисту необходимо поработать самому:
@Model({
collectionName: 'User'
})
class User extends BaseModel {
@Field() name: string | null;
@Field({ dbFieldName: 'groups' })
groupIds: Array<ObjectId> | null;
@Field(() => Group)
groups: Promise<Array<Group> | null>;
}
@Model({
collectionName: 'Group'
})
class Group extends BaseModel {
@Field() name: string | null;
@Field({ dbFieldName: 'users' })
userIds: Array<ObjectId> | null;
@Field(() => User)
users: Promise<Array<User> | null>;
}
let user = (await User.find<User>({ name: 'Tatoshka' }))!;
// удаляем ссылки на документ
for (let group of await Group.findAll<Group>({ _id: { $in: user.groupIds } })) {
group.userIds = group.userIds!.filter(
userId => userId.toHexString() != user._id!.toHexString()
);
await group.save();
}
// удаляем сам документ
await user.remove();В данном примере массив userIds был заменён на новый, созданный методом Array#filter, но можно менять существующий массив, Maraquia находит и такие изменения. То есть можно было так:
group.userIds!.splice(
group.userIds!.findIndex(userId => userId.toHexString() == user._id!.toHexString()),
1
);Валидация
Для валидации поля необходимо добавить свойство validate в его опции:
@Model({
collectionName: 'User'
})
class User extends BaseModel {
@Field({
validate: value => typeof value == 'string' && value.trim().length >= 2
})
name: string | null;
@Field({
validate: value => {
// При false сообщение об ошибке сформируется автоматически:
return typeof value == 'number' && value >= 0;
// Или можно задать его самостоятельно:
if (typeof value != 'number' || value < 0) {
return 'Что-то пошло не так';
// или так:
return new TypeError('Что-то пошло не так');
// или так:
throw new TypeError('Что-то пошло не так');
}
}
})
age: number | null;
@Field(() => Account, {
validate: value => !!value
})
/*
тоже самое что и:
@Field({
type: () => Account,
validate: value => !!value
})
*/
account: Promise<Account | null>;
}Так же можно передавать объекты создаваемые библиотекой joi:
import * as joi from 'joi';
@Model({
collectionName: 'User'
})
class User extends BaseModel {
@Field({
validate: joi.string().min(2)
})
name: string | null;
@Field({
validate: joi.number().min(0)
})
age: number | null;
}Хуки
Следующие методы срабатывают согласно их названию: beforeSave, afterSave, beforeRemove, afterRemove.
Использование с javascript
Typescript — это здорово, но иногда надо без него. Для этого вместо объекта передаваемого декоратору Model необходимо определить статическое поле $schema, в котором есть также поле fields:
const { BaseModel } = require('maraquia');
class Pet extends BaseModel {
}
Pet.$schema = {
collectionName: 'Pet',
fields: {
name: {}
}
};
class Owner extends BaseModel {
}
Owner.$schema = {
collectionName: 'Owner',
fields: {
name: {},
pets: { type: () => Pet }
}
};
let pet = new Pet({
name: 'Tatoshka'
});
let owner = new Owner({
name: 'Dmitry',
pets: [pet]
});
await owner.save();Запись в поля делается через метод setField:
pet.setField('name', 'Tosha');А чтение полей с внешними документами через метод fetchField:
await owner.fetchField('pets');Остальные поля читаются как обычно.
Производительность
Я написал парочку простых бенчмарков для сравнения производительности с Mongoose и Mongorito. В первом просто создаются экземпляры модели. Для всех троих это выглядит одинаково:
let cat = new Cat({
name: 'Tatoshka',
age: 1,
gender: '1',
email: 'tatoshka@email.ru',
phone: '+79991234567'
});Результат (больше — лучше):
Mongoose x 41,382 ops/sec ±7.38% (78 runs sampled)
Mongorito x 28,649 ops/sec ±3.20% (85 runs sampled)
Maraquia x 1,312,816 ops/sec ±1.70% (87 runs sampled)Во втором тоже самое, но с сохранением в БД. Результат:
Mongoose x 1,125 ops/sec ±4.59% (69 runs sampled)
Mongorito x 1,596 ops/sec ±4.08% (69 runs sampled)
Maraquia x 1,143 ops/sec ±3.39% (73 runs sampled)Исходники в папке perf.
Футер
Надеюсь кому-то библиотека окажется полезна, она пока ещё не применялась на реальных проектах, так что используйте на свой страх и риск. Если что-то работает не так как ожидается, создавайте issue на github.
В основном я занимаюсь front-end разработкой и вряд ли хорошо разбираюсь в базах данных, так что если где-то написал чушь, то прошу понять и простить :).
Благодарю за внимание.
Комментарии (17)

apapacy
20.05.2018 20:39Добавлю еще:
Что с организацией пула? Могу дать рекомендацию дать доступ к нативным параметрам драйвера mongodb. Наиболее важные связаны с параметрами пула соединений а так же с реконнектами (соединение может на время исчезнуть например при рестарте базы данных)
Riim Автор
20.05.2018 21:39Способ соединения описанный в статье скорее для тестов, более продвинутый выглядит так:
import { BaseModel, Field, Maraquia, Model } from '@riim/maraquia'; import { MongoClient } from 'mongodb'; const db = (await MongoClient.connect('mongodb://localhost:27017/Test')).db('Test'); const m = new Maraquia(db); let user = (await User.find<User>({}, m))!; user.printData();
класс Maraquia — что-то вроде адаптера, его инстанс передаётся в методы
find,findAll,saveиremoveпоследним дополнительным параметром. Передать можно один раз, он будет запомнен, то есть если он был передан вfind, то позже при сохранении вsaveуже можно не передавать. Так же он автоматически копируется в порождаемые модели (при чтении полей соответствующих встроенным документам).
При таком способе, во-первых, можно настроить соединение как угодно работая напрямую с драйвером mongodb, во-вторых, можно работать сразу с несколькими базами создав несколько инстансов Maraquia.

Grox
21.05.2018 04:41Напомню, в реляционных БД нет возможности просто взять и встроить документ в поле другого документа (в этой статье записи таблиц тоже называются документами, хоть это и некорректно), можно конечно хранить в поле JSON в виде строки, но индекс по данным в нём сделать не выйдет.
Я немного дополню. В PostgreSQL поддерживается индексация JSONB.
Riim Автор
21.05.2018 07:05Есть такое, причём, насколько я понимаю, единственная причина по которой PostgreSQL нельзя назвать полноценной документоориентированной БД — это полная физическая перезапись JSONB при любых изменениях в нём.

Rsa97
21.05.2018 09:04В MySQL 8.0 добавлены функции для изменения отдельных полей JSON. Индексация тоже есть, делается через генерируемые колонки.

Riim Автор
21.05.2018 09:07Функции такие и в PostgreSQL есть, вопрос в том, как это на диск при изменениях пишется. Монга перезаписывает весь документ только если какое-то поле выросло в размерах. Как с этим в MySQL?

Rsa97
21.05.2018 09:43Частичная перезапись JSON в MySQL 8.0 возможна при удалении поля или при установке нового значения существующего поля, если новое значение не длиннее места, занятого полем при предыдущем полном изменении/добавлении.

kwolfy
21.05.2018 08:22Замечательная вещь, await user.groups просто гениально, надеюсь решение доростет до продакшена

Riim Автор
21.05.2018 09:16Спасибо. Как я написал в статье, я фронтендер, у меня есть парочка мелких проектов где использую эту библиотеку, но говорить о production ready по ним никак нельзя. Тут мне остаётся лишь надеятся, что кто-то попробует использовать в более крупном проекте и расскажет о том, что всё хорошо или о возникших проблемах. Так что если рискнёте, то обязательно отпишитесь, даже если всё будет хорошо работать.
apapacy
Тема эта очень интересная и меня давно интересует. Хорошо что модно напрямую пообщаться с разработчиком. Такой ORM если он упрощает JOIN между коллекциями и позволяет делать выборку одним запросом из нескольких коллекций именно одним (а не N+1) запросом к базе данных при этом с возможностью задавать фильтр по значению из любой коллекции. В Mongoose это кстати можно делать см. mongoosejs.com/docs/populate.html. В этом смысле Mongoose это тоже ODM+ORM. Аналогичный функционал есть и у справедливо критикуемой watreline которая вообще организует одинаковый интерфейс к релязионным базам и к mongodb.
Как у Вас с этими вопросами обстоит дело (JOIN, N+1, WEHRE на полях из связанных коллекций)?
Вот эта структура кажется не очень перспективной:
Пока мы связываем коллекции Owner и Pets, то хранить массивом связанные значения вроде бы не сильно накладно. Но если это Country и Citizen — то это уже не так приятно.
Riim Автор
Да, похоже забирать данные по ссылкам Mongoose действительно умеет, про сохранение правда ничего не сказано.
делается дополнительный отложенный запрос при первом обращении к полю.
в реляционных БД сохранить массив в поле не особо просто, там для хранения связи делается промежуточная таблица. Здесь тоже можно было так сделать, плюс будет в более быстром редактировании связи при большом количестве записей в ней, но будет и минус — более медленное чтение, две выборки вместо одной. Так что да, если отношение один/многие к очень-очень многим и записи в связи часто меняются, то реализованный механизм не очень подойдёт.
apapacy
То есть тот самый N+1-й?
Riim Автор
Да, но +1 только для реально используемых данных. Я рассматривал ещё вариант с указанием методам find и findAll полей которые необходимо выкачать, примерно так:
тут плюс был бы в отсутствии необходимости использовать
awaitпри дальнейшем чтении таких полей, но минус в том, что не всегда заранее знаешь какие поля понадобятся и иногда приходилось бы выкачивать лишнее, да и указыватьgroups: trueтоже не очень то хочется. В результате остановился на том, что есть.kwolfy
Почему бы не совместить оба варианта? Когда точно знаешь что понадобятся некоторые поля указываешь как в mongoose populate и подгружаешь все что нужно одним запросом, но и фишка с await user.groups тоже остается
Riim Автор
А что если оставлять await даже если данные сразу выкачиваются? По идее он почти не мешает, а вот если пытаться от него избавиться, то сразу возникают проблемы с типизацией, так как тип будет зависеть от аргументов в find/findAll. Прийдётся указывать что-то типа
Promise<Array<User> | null> | User | null, что, конечно, будет совсем не удобно. То есть данные будут забираться сразу и оборачиваться в уже resolved промис.Riim Автор
Реализовал не убирая await, пример:
Поля передаются пока только в виде массива, позже попробую ещё в виде объектов доделать, чтобы можно было подподдокументы сразу вытаскивать.
kwolfy
Да, отлично, это я и имел ввиду