Агрегат для node.js

от автора

GitHub и NPM библиотеки.


какой-то неведомый агрегат, никак не связанный с 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 вещи:

  1. регистрирует данный класс для существующего подключения к БД. Говоря проще — в Map внутри подключения засовывается ассоциация "метка" — "класс". При разрешении ассоциаций (о них позже) именно эта мапа используется для превращения объектов БД в объекты JS
  2. запускает внутренние процессы библиотеки (индексация и ограничение на уникальность uuid в целях безопасности)
  3. запускает индексацию пользовательских индексов (если они были заданы для этого класса).

Для абстрактных классов этот метод вызывать не нужно.

В 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *