какой-то неведомый агрегат, никак не связанный с node.js. Но на хабре считается хорошим тоном приложить картинку
Некоторое время назад я задумался, почему же в node.js работа с реляционными БД, такими как *SQL и Mongo, сложна, и сделал альтернативное решение, заточенное под скорость работы программиста (в сравнении с классическими решениями, заточенных под скорость работы с БД) и прямолинейность и компактность API для минимального порога вхождения. Первым источником вдохновления стал доклад "минимальная поверхность API", вторым — знаменитая цитата Дональда Крута:
Программисты тратят ненормальное количество времени, волнуясь о скорости некритичных частей приложений, и эти попытки повысить эффективность серьезно отрицательно влияют на отладку и поддержку этих приложений. Преждевременная оптимизация есть корень всех зол.
Дикслеймер: описанная тут библиотека находится в стадии ранней беты. Пока что не стоит использовать ее для коммерческих или критичных для вашей жизни проектов.
Когда я пользовался sequelize — не надейтесь, другие библиотеки не лучше — я невольно задумывался, почему работа с ней так сложна. Не в плане понимания, как она работает изнутри, нет — я зарывался в интерфейс библиотеки. То ли руки не оттуда растут, то ли разработчики — прожженные DBA, не то что я.
Теперь я знаю, что они пытались внести в инструментарий, что могли. На выходе — 15й стандарт, объединяющий предыдущие 14. Показателен в этом плане адский комбайн juggling, который умеет в крайне разнообразный список БД — MySQL, SQlite3, Postgres, CouchDB, Mongo, Redis, Neo4j.
Но мне для маленьких проектов — всяких телеграм-ботов, дев-серверов и SPA — не нужно было сложной части функционала, что есть под капотом сложных ORM-ок. Базовый требуемый функцоинал — сохранение и поиск записей, выборки по условиям и отношениям. Мне не нужно преждевременных оптимизаций: выборки части полей из базы, хитрых оптимизаций запросов, хранимых функций. Выборку по отношению (получить объект и все связи) можно оформить транзакцией. За счет потери в быстродействии мы получаем отсутствие кучи дополнительных сущностей декларативного синтаксиса. Бритва Оккама в чистом виде.
Лирическое отступление: если посмотреть на историю развития проектов с десятками и сотнями тысяч пользователей — через определенное время разработчики упираются в быстродействие. Они изменяют запросы, БД, языки, платформы, чтобы бы оно работало. Если проект выстреливает — ему предстоит замена деталей, и первой под нож идет работа с базой — если изначально не было потрачено достаточное количество усилий “на будущее”. При этом тяжеловесный, комплексный синтаксис ORM усложняет замену. Вывод напрашивается очевидный — если оценивать выбор ORM как безальтернативный будущий технический долг, корректным выбором может оказаться решение с меньшей эффективностью, но обеспечивающее скорость работы разработчика и предоставляющее минимальный API, что упрощает переход на другое решение.
Я сделал Агрегат
Не БД-, но JS-центричный ActiveRecord — правда, я местами отошел от классического паттерна.
Важно понимать, что раз он не БД-центричен — БД выбиралась под запросы решения, а не решение делалось под конкретную БД. Хранилищем было выбрано Neo4j. Это решение обладает плюсами и минусами, но пока плюсов больше.
Если вы не знакомы с neo4j — это популярная графовая база данных с гораздо более понятным для непосвященного человека, нежели SQL, языком, удобным веб-клиентом и полнотекстовыми индексами из коробки (используется lucene), и немного меньшим (линейно) быстродействием в сравнении с Postgres/MySQL. Все инструкции по установке есть тут: http://neo4j.com/download-thanks/?edition=community. На mac он ставится через brew install neo4j
Начнем с простого — подключения и записей:
const {Connection, Record} = require('agregate') const dbPath = 'http://neo4j:password@localhost:7474'; class ConnectedRecord extends Record { static connection = new Connection(dbPath); } class User extends ConnectedRecord {} User.register() //подготовительная часть завершена, пробуем работать с бд const user = new User({name: 'foo'}) user.surname = 'bar' user.save() .then(() => User.where({name: 'foo'})) .then(([user]) => console.log(user)) //=> User {name: 'foo', surname: 'bar'}
Единственное, что выбивается из понятности кода — вызов User.register(). В JS на создание класса нельзя повесить обработчик (и слава разработчикам языка за это), так что приходится делать это за язык.
Метод Record.register делает 3 вещи:
- регистрирует данный класс для существующего подключения к БД. Говоря проще — в Map внутри подключения засовывается ассоциация "метка" — "класс". При разрешении ассоциаций (о них позже) именно эта мапа используется для превращения объектов БД в объекты JS
- запускает внутренние процессы библиотеки (индексация и ограничение на уникальность uuid в целях безопасности)
- запускает индексацию пользовательских индексов (если они были заданы для этого класса).
Для абстрактных классов этот метод вызывать не нужно.
В ES2015 статические свойства наследуются так же, как и свойства сущности — connecton объявляется однажды, в родительском классе, как и было показано. Если у вас одна БД — можно и Record.connection присвоить подключение, хоть это и некорректно с точки зрения разработки.
Отношения и связи
Давайте усложним пример. Представим, что мы делаем ACL, и нам нужны отношения:
const {Connection, Record, Relation} = require('agregate'); // классы Role и Permission выглядят не сильно сложнее const Role = require('./role'); const Permission = require('./permission'); export default class User extends ConnectedRecord { roles = new Relation(this, 'has_role', {target: Role}); permissions = new Relation(this.roles, 'has_permission', {target: Permission}); hasPermission = ::this.permissions.has }
Если не присматриваться — не сразу видишь, что по факту this.permissions — many-to-many through отношение. Синтаксис такого рода дает строить длинные цепочки отношений, для которых доступны полноценные запросы — поиск, удаление, проверка наличия, всё, кроме по понятным соображениям не работающему Relation#add.
Relation подражает встроенному в ES6 объекту Set. API отличается, но он знаком и понятен сразу же. Разница — в том, что методы возвращают Promise, который уже возвращает данные, а size() — метод, а не свойство. Дополнительно появились методы #intersect, который возвращает пересечение передаваемого массива элементов с входящими в отношение элементами, и #where, который делает очевидное, но о нем ниже.
Поиск по БД
Для этого доступны методы с идентичным API: метод класса Record.where() и метод экземпляра класса Relation#where(). Доступны offset, limit, order by, поиск по значению, содержимому массива и вхождению в массив (да, типизированный массив — один из примитивов в neo4j) и подстроке. Возможностей для поиска много. Они покрывают все основные задачи. Перечислять все опции довольно трудно, поэтому проще посмотреть на формальное описание на typescript-подобном синтаксисе:
var dbPrimitiveType = bool | string | number | Array<bool> | Array<string> | Array<number> async function where( params?: { [string: queryKey]: dbPrimitiveType | { $gt?: number //greater than - больше $gte?: number //greater than or equal - больше или равно $lt?: number //less than - меньше $lte?: number //less than or equal - меньше или равно $exists?: bool //существует ли свойство $startsWith?: Array<string> | string //начинается с $endsWith?: Array<string> | string //заканчивается на $contains?: Array<string> | string //содержит подстроку $has?: Array<dbPrimitiveType> | dbPrimitiveType //содержит элемент массива $in?: Array<dbPrimitiveType> | dbPrimitiveType //входит в массив } }, opts?: { order?: string | Array<string>; // строка - формата key или key DESC или key ASC, например ['created_at', 'friends DESC'] offset?: number; limit?: number; }, transaction?: Queryable): Array<Record>
Транзакции
Описанный выше API уже позволяет работать. Остается только вопрос атомарности, который классически решается при помощи транзакций.
В агрегате работать с транзакциями можно двумя способами — простым или понятным.
Понятный способ — использовать транзакции "в лоб". Для этого нужно передать ее последним аргументом (помимо остальных). Все стандартные методы, работающие с БД, поддерживают эту нотацию.
class Post extends Record { async static createAndAssign(text, user) { const transaction = this.connection.transaction() const post = await new this({text}).save(transaction) await post.author(user, transaction) await transaction.commit() return post } //или учитывая то, что что-то может пойти не так async static createAndAssign(text, user) { const transaction = this.connection.transaction() try { const post = await new this({text}).save(transaction) await post.author(user, transaction) await transaction.commit() return post } catch (e) { await transaction.rollback() throw e } } }
Объект connection (который доступен и для класса, так и для экземпляра класса) может быть подключением, транзакцией или суб-транзакцией. Для использования в жизни разницы нет, потому что все три сущности предоставляют один и тот же интерфейс с небольшими внутренними отличиями. Если вызвать connection.transaction(), подключение вернет транзакцию, транзакция — суб-транзакцию, суб-транзакция — другую суб-транзакцию.
Внутреннее отличие заключается в следующем — методы commit и rollback для подключения пробросят ошибку, для транзакции — отработают ожидаемо, для суб-транзакции — commit сделает ничего, а rollback откатит родительскую транзакцию.
Это сделано из-за того, что некоторые методы о генерируют для себя транзакцию и закрывают в конце — например, Record#save(). Чтобы в рамках транзакции корректно работали такие методы — реализована бесконечная вложенность суб-транзакций.
Для второго способа — простого — используется декоратор:
import {Record, acceptsTransaction} from 'agregate' class Post extends Record { @acceptsTransaction async static create(text) { return await new this({text}).save(this.connection) } }
Он превращает код в примерно такой:
import {Record, acceptsTransaction} from 'agregate' class Post extends Record { async static create(text, transaction) { //Queryable - внутренний класс, от него наследуются Connection, Transaction, SubTransaction if (transaction instanceof Queryable) this.connection = transaction try { const result = await new this({text}).save(this.connection) if (transaction) await transaction.commit() return result } catch (e) { if (transaction) await transaction.rollback() throw e } } }
Декоратор можно использовать и прямо, как в примере выше, и конфигурируя. Для конфигурации пока доступен только один флаг — force, который принудительно создает транзакцию — если не передана транзакция, он сам ее создаст. Использовать нужно так: @acceptsTransaction({force: true}) ...
.
Обратите внимание — теперь this.connection стала транзакцией. Когда отработает функция, свойство вернется в прежнее состояние, но теперь это позволяет вызывать другие методы класса, не заботясь о том, чтобы передавать транзакцию. Работает эта магия только в пределах this (что предсказуемо).
Поскольку транзакции обрабатываются по очереди, т.е. пока не завершится одна, не начнется другая, клонирования объекта не производится, поэтому учтите: если обернуть статический метод в этот декоратор, можно случайно "пошарить" транзакцию. Для экземпляров класса это не страшно в силу того, что если вы правильно работаете с JS — они находятся в своей области видимости, и из других потоков выполнения (таких как промисы, async-и и так далее) нельзя одновременно получить к ним доступ в силу недоступности объекта.
Вот и весь агрегат
Описание API и причин, почему сделано так, а не иначе, завершено.
Наверное, единственное, что стоит добавить — что я уже использую его в небольших проектах для себя и друзей. Я давно не испытывал такого удовольствия при работе с БД — такое ощущение "прозрачности" и понятности механизмов работы я испытывал только при работе в Ruby/Rails, и даже там приходилось местами мучиться с CLI.
В агрегате может не хватать каких-то возможностей или быстроты, но если вы хотите этого — подключайтесь к проекту. Сейчас агрегат это всего 608 строк (сам в шоке) довольно неплохо организованного кода, и вносить правки, дополнения, обновления, делать дополнительные тесты — очень просто. Я бы хотел видеть его однозначно пригодным к использованию в большом продакшне. Даже не потому что это моя библиотека, а потому что мне правда нравится тот интерфейс, который она предоставляет.
ссылка на оригинал статьи https://habrahabr.ru/post/278871/
Добавить комментарий