Pull to refresh

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

Reading time 8 min
Views 11K
GitHub и NPM библиотеки.


какой-то неведомый агрегат, никак не связанный с node.js. Но на хабре считается хорошим тоном приложить картинку

Некоторое время назад я задумался, почему же в node.js работа с реляционными БД, такими как *SQL, и некоторыми noSQL типа Mongo, сложна, и сделал альтернативное решение, заточенное под скорость работы программиста (в сравнении с классическими решениями, заточенных под скорость работы с БД) и прямолинейность и компактность API для минимального порога вхождения. Первым источником вдохновления стал доклад "минимальная поверхность API", вторым — знаменитая цитата Дональда Крута:

Программисты тратят ненормальное количество времени, волнуясь о скорости некритичных частей приложений, и эти попытки повысить эффективность серьезно отрицательно влияют на отладку и поддержку этих приложений.  Преждевременная оптимизация есть корень всех зол.

Дикслеймер 1: описанная тут библиотека находится в стадии ранней беты. Пока что не стоит использовать ее для коммерческих или критичных для вашей жизни проектов.

Дисклеймер 2: в тексте могут встречаться сложные термины, а в примерах кода — множество фич ES6. Если что-то кажется непонятным — пожалуйста, пишите в комментариях, я постараюсь упростить текст и добавить комментарии к коду

Когда я пользовался 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 {
    author = ::new Relation(this, 'created', {direction: -1}).only //тут мы биндим метод only, который является одновременно геттером и сеттером. Он используется для работы с отношениями, где нужна единственная запись.

    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 строк (сам в шоке) довольно неплохо организованного кода, и вносить правки, дополнения, обновления, делать дополнительные тесты — очень просто. Я бы хотел видеть его однозначно пригодным к использованию в большом продакшне, и если вам тоже он понравился — подключайтесь к разработке!
Tags:
Hubs:
+6
Comments 15
Comments Comments 15

Articles