-
Notifications
You must be signed in to change notification settings - Fork 9
qbit v4 information model, API and sync concept (rus)
Это WIP концепции нового дизайна, не описание готового решения
Кубит это исследовательский проект, в котором я занимаюсь разработкой встраиваемой распределённой высокодоступной объектно-реляционной БД (каждое из прилагательных важно:) ). В этой работе я хочу решить две независимые задачи.
Во-первых, я хочу создать встраиваемую технологию для распределённого хранения данных с автоматической синхронизаций и поддержкой энд-ту-энд шифрованием. Эта технология предназначена для встраивания в десктопные и мобильные приложения и позволит реализовывать синхронизацию данных между устройствами, не сливая их на растерзание датамайнерам гугла и прочих.
Хотя через Kotlin/Multiplatform кубит можно будет встроить в программу практически на любом языке (через С-интероп), первичной целью является встраивание в программы на Котлине.
На данный момент я рассматриваю кубит как "tiny data dbms" - я ориентруюсь на то, что данные будут вноситься в ручном режиме небольшой группой людей. Т.е. размер БД будет 10-100 мегабайт, и кол-во транзакций так же 10-100 в сутки.
Во-вторых, я хочу сделать простую, понятную и гибкую технологию хранения, с которой работать будет приятно, а не мучительно больно как с современными реляционными базами в чистом виде или через ормы, различными документными и графовыми БД.
Проблемы, которые хочется решить в чистом SQL интерфейсе:
- По сути одну: реляционная модель данных не совпадает с моделью данных в памяти современных програм. Из-за этого приходится либо генерять гору шаблонного кода, либо использовать ОРМы, у которых проблем уже куча.
Проблемы, которые хочется решить в ОРМах:
- Первичная проблема: их сложность. Я думаю единицы людей действительно понимают как работает Hibernate, и никто глядя на код, не может сказать, что реально произойдёт в рантайме. Отсюда куча мелких неприятностей. В кубите же я пытаюсь сблизить эти две модели.
- Вторая проблема - переусложнённая модель состояний сущности и ленивая инициализация. В кубите сущности являются истинными POJO (POKO) - это структуры данных, без какого либо поведения и дополнительного неявного состояния. В Хибере все эти навороты нужны для неявной оптимизации дорогих обращений к БД. В кубите же by design, обращение к БД дёшевы (все данные в памяти)
- LazyInitializationException. Одной из главных задачей АПИ кубита является гарантия того, что если программа компилируется, то в рантайме не вылетит исключений при доступе к данным.
- Продолжение следует
Примеры приложений использующих стабы нового АПИ:
Я пологаю, что эта проблемы ОРМов вызваны, тем что они пытаются замапить через чур разные модели данных, и если эти модели приблизить немного друг к другу, то получится намного более простая и надёжная технология.
В основе кубита лежит классическая реляционная модель, которую я считаю лучшей в настоящее время, как для представления данных для человека, так и для программ на современных языках программирования.
В отличие от документной и более примитивных моделей, реляционная модель позволяет естественным образом моделировать отношение многие-ко-многим, которые повсеместно встречаются в мире.
В отличие от объектной модели, реляционная позволяет обходиться в модели без циклических ссылок (т.к. сами связи явлются двунаправленными) между сущностями, и обеспечивает простой и эффективный способ выборки коллекций по необходимости (нет проблем с eager и lazy связями между объектами - первые медленные, вторые и медленные и являются неиссякаемым источником багов и проблем).
В целом графовая модель, пожалуй, является ещё более выразительной, за счёт возможности создания атрибутов связей, но это возможность не имеет прямого аналога в современных ЯП, и всё равно выродится в памяти, в дополнительную структуру-связь, между связываемыми сущностями.
Однако в чистом виде с ней работать из современных языков не удобно, поэтому я вношу в неё ряд небольших изменений, которые приносят в жертву тотальную всеобщность (все отношения, атрибуты, связи и операции над отношениями равны между собой) и в каком-то смысле простоту, в угоду удобства применения в современных ЯП.
Во-первых, я слегка меняю терминологию:
- Таблица/отношение - коллекция сущностей
- Строка/кортеж - сущность
- Внешний ключ - ссылка
В кубите каждая сущность должна имеет особый атрибут id, специального типа Eid. Eid уникально идентифицирует сущность во всей БД (на всех узлах). Ссылки между сущностями возможны только атрибутами типа Eid на атрибут id. Т.е. в отличие от реляционной модели, кубит не допускает произвольные внешние ключи.
Кубит поддерживает традиционные ограничения на уникальность атрибутов. Кроме того, одно из таких ограничений сущности может быть идентифицирующим и обеспечивать семантику апсёрта.
Не бывает сущности без идентификатора, в интервале между созданием объекта представляющего сущность и её сохранением в БД, эта сущность будет иметь специальный временный идентификатор, который будет заменён на постоянный при сохранении сущности.
В отличаи от классической реляционной модели, кубит поддерживает составные атрибуты - списки, множества, мапы и произвольные объекты. Но такие атрибуты не могу быть использованы в запросах, извлечены отдельно от своей сущности и удаляются вместе со своей сущностью.
Кроме того, коллекции поддерживают только примитивные значения, Eid-ы и объекты - вложенные коллекции не поддерживаются.
Традиционная модель допускает циклические ссылки между отношениями, в том числе между конкретными кортежами. Кубит же, допуская циклические ссылки между коллекциями сущностей, запрещает циклические ссылки между сущностями.
В кубите представления используются в первую очередь для реализации связей в обратном объектному направлении (например, есть сущность "запись", которая явно ссылается на сущность "категория", но есть и неявная обратная связь от категории к сущностям, которые на неё ссылаются).
В кубите нельзя создать представление произвольным запросом, но можно составить его из различных коллекций сущностей и других представлений.
В программе, наборы сущностей классами, к которым предъявляется ряд тербований:
- Они должны быть сериализуемыми с помощью kotlinx.serialization. Без этого невозможно реализовать СУБД на платформах без рефлекшена (Js, native).
- Они должны реализовывать интерфейс Ref { val id: Eid }. Плюсы от этой привязки описаны в разделе изменение информации.
typealias CategoryEid = Eid<Category>
@Serializable
data class Category(override val id: CategoryEid, val name: String) : Ref<Category>
@Serializable
data class SlimRecord(override val id: Eid, val text: String, category: Eid) : Ref<LazyRecord>
@Serializable
data class EagerRecord(override val id: Eid, val text: String, category: Category) : Ref<EagerRecord>
@Serializable
data class AgileRecord<R : Ref<Category>>(override val id: Eid, val text: String, category: R) : Ref<EagerRecord>
@Serializable
data class CategoryRecords(val category: Category, records: List<AgileRecord<CategoryEid>)
Ссылки могут быть представлены как через специальный тип Ref, так и непосредственно классом ссылаемой сущности.
Если вас пугает вендор-лок, то напомню, что тот же JPA, который якобы работает с POJO, привязывает вас к аннотациям, заставляет вас создавать пустые конструкторы и вынуждает вас моделировать объектную модель от реляционной, чтобы всё это более-менее быстро работало.
Кроме того, тот же Анкл Боб в чистой архитектуре предлагает объекты завязанные на определённую технологию хранения данные, не выпускать за слой работы с этой технологией, и для кубита, в котором нет потребности в ленивой загрузке этот маппинг не является проблемой.
Возможно будут в каком-то виде поддержаны полиморфные коллекции. Это точно будут только закрытые иерархии (sealed class) и скорее всего буду ещё какие-то ограничения.
Т.к. в основе кубита обычная реляционная модель, возможно реализовать изменение данных через традиционные INSERT, UPDATE, DELETE, но это точно не в первой версии.
Первичным же интерфейсом является метод qbit.persist(refs: Collection). Этот метод похож на одноимённый метод в JPA, но он на много более мощный и гибкий. Это достигается за счёт того, что кубит не пытается делать вид, что он умеет сохранять POJO в произвольную РСУБД.
Так же, как и в JPA вы можете в памяти собрать граф объектов новых сущностей (detached в терминологии JPA) и сохранить их одним вызовом qbit.persistGraph(refs: Collection). Но при этом выбор "каскадности" выполняется по-кейсно, а не одино разово в модели данных. При этом в графе могут быть перемешаны произвольным образом сохранённые и новые сущности и кубит их корректно сохранит, благодаря временным идентификаторам.
Значения полей сохраняемой сущности не обязательно должны быть объектами этих сущностей, этом могут быть их Eid-ы. Т.е. вы можете получить какую-то сущность без её связей отдать наружу, потом получить эту сущность в изменённом виде (даже с изменёнными связями!) и отправить её в БД как есть, без предварительной загрузки и копирования данных между DTO и Attached Entity.
В случае частичного обновления, в кубите нет необходимости сначала выполнить запрос и получить привязанную сущность. Можно сразу отправить в БД специальный объект EntityPatch, который обновит только нужные поля.
Модификации отдельных элементов сложных полей (добавление в список, изменения поля сложного объекта) не поддерживаются.
Если у коллекции есть ограничение уникальности, помеченное как идентифицирующее, и сохраняется сущность со временным идом, для к которой находится сохранённая сущность с таким же идентифицирующим ключём, то вместо выборса исключения, обновится сохранённая сущность.
Возможно будет поддержано внесение данных через представления. Ещё более возможно будет возможность менять состав связи многие-ко-многим через представления (CategoryRecords с изменённым списком records).
Выполняются через SQL API
Ох, запросы... Такое ощущение что их сделать без боли невозможно.
Запросы в кубите делятся на два типа: аналитические и объектные.
Это обычный SQL (с учётом ограничений кубита, на невозможность запросов по сложным значениям), который навыход выдаёт обычный кортеж, со сложными значениями.
На моей практике, 90+% запросов к БД - это получение какого-то подграфа сущностей. И тут можно выбрать одно из: удобство, производительность, рабочая статическая типизация.
JPA попытался выбрать удобство и производительность, но вышло не очень. Статически глядя на сущность JPA невозможно предугадать, что произойдёт при обращении к полю - вернётся значение, выполнится запрос к БД или вылетит LazyInitializationException.
Можно сделать что-то по типу JPQL:
val records = qbit.db().query<AgileRecord<Category>>("SELECT r FROM Records r JOIN FETCH r.category")
Это, пожалуй, самый лаконичный и удобный вариант. Но на платформах без рефлекшена, невозможно проверить, что в запросе зафетчили все ссылки, которые прописали в возвращаемом типе. Это пожалуй можно сделать отдельным модулем, но базовое АПИ я хочу сделать безопасным.
Вместо строкового QQL, можно сделать какой-то ДСЛ по типу CriteriaBuilder/jooq.
WIP: можно описывать сущности не одним классом, с генериками, а одним sealed классом с генериками и пачкой наследников без генериков и разрешить запрашивать только конкретные классы. Но кол-во таких классов (на вскидку) - 2^n, где n - кол-во связей класса.
Этот выбор позволит эффективно рулить производительностью, всегда быть уверенным сколько запросов выполнится к БД и забыть LazyInitializationException как страшный сон. Но придётся пописать гору шаблонного кода. Эту проблему, я возможно удастся решить кодогенерацией на уровне плагина к компилятору, которая в котлине абсолютно прозрачна и полностью поддерживается Идеей. Но как выбирать имена для конкретных классов?
Выбор между удобством и производительностю будет осуществляется на уровне модели данных. Если в модели всем ссылкам указать тип ссылаемой сущности (а не абстрактный Ref - см. EagerRecord), то дополнительно надо будет написать только представления для обратных связей многие-ко-многим. Но при этом на каждый запрос, будет вытаскиваться весь подграф, достижимый из сущностей удовлетворяющих запросу.
Другой способ передать типовую информацию в БД - передть конкретный объект-пример, в котором поля будут иметь конкретные материилизованные типы. В этом случае нужен будет только один генеричный класс, и пачка объектов примеров типа:
val eagerRecord = AgileRecord(tmpEid, "anyText", Category(tmpEid, "anyName"))
val records = qbit.db().query(eagerRecord)
Кажется, технически это наилучший вариант - он и типо безопасный, и не вынуждает генерять гору типов (но возможно придётся заводить typealias-ы, чтобы в сигнатурах методов не писать 3-4 этажные генерики), и позволяет формулировать "запросы" в месте выполнения, в том числе и динамические, кстати. Но интиутивно он как-то не очень...
Можно сделать что-то GraphQL-подобное вроде: https://www.kotlinresources.com/library/kraph/
val query = query<AgileRecord<Category>>() {
fetch(AgileRecord::category) {
// other fetches
}
}
Но тут опять же нет гарантий, что пользователь не ошибся.
Как минимум в первой версии, в объектных запросах будет понятие первичной коллекции - список сущностей из этой коллекции будет результатом выборки. И соотвественно фильтрация возможна только по атрибутам первичной коллекции.
В принципе вроде ничего не мешает, сделать модные сейчас в мобильном мире живие запросы - подписка на асинхронные уведомления об изменениях в данных отвечающих определённым критериям. Но это точно не на первой итерации
Возможно просто запилю клиента к Gun - https://gun.eco/ Но недавно нашёл эту штуку, ещё не разбирал, так что может она и не подойдёт.
-
Сторадж - произвольное хранилище данных, которое поддерживает: 1.1) Сохранение массивов байт по строковым ключам, с возможностью атомарного putIfAbsent 1.2) Пространства имён ключей 1.3) Получение байт по ключу 1.4) Перечисление ключей в прострастве имён 1.5) Удаление значения по ключу 1.6) Проверка существования ключа
-
Инстанс - процесс пользовательского приложения, в который встроен кубит. Инстансы упорядоченных и хранятся в самой БД. Нельзя открыть БД произвольным инстансом - сначала один из существующих инстансов должен его зарегестрировать в БД и передать ссылку на БД и ид инстанса в новый процесс.
-
Локальный сторадж - сторадж, которому имеет доступ только один инстанс
-
Ремоут - сторадж, к которому имеют доступ несколько инстансов
В какой-то мере кубит можно представить как гит для структур данных, в котором каждый инстанс имеет собственную ветку. При распределённой работе журнал транзакций превращается в DAG, каждый инстанс пишет траназкции в локальный сторадж, и дублирует их в ремоуте и туда же пишет свою текущую ветку, так же параллельно подтягивает из ремоута ветки остальных инстансов, где можно делает fast-forward merge, где нельзя - подмёрживает изменения другого инстанса в свою ветку и пушит в ремот.
Синхронизация настраивается и может синхроно или асинхронно выполняться перед или после наступление следующих событий:
- Открытие базы
- Коммит транзакции
- Выполнение запроса (в том числе запроса к определённой коллекции)
- При истечении таймаута
- По команде пользователя/приложения
Для того чтобы исключить датарейсы, транзакции в ремоуте адресуемы контентом и не изменяются, а ветку может писать только инстанс-владелец ветки. Т.е. запись в общий сторадж идемпотента, а запись изменяющихся веток выполняется в эксклюзивном режиме. Понятно, что для произвольной технологии хранения гарантировать это не возможно, поэтому надо будет делать какие-то хелс-чеки.
Инстанс при обнаружении расхождения журнала транзакций смотрит на инстанс с которым у него произошло расхождение. Если у этого инстанса больший порядковый номер, то он разрешает создаёт и пушит в ремоут мёрж-коммит. Если порядковый номер меньше, то инстанс ожидает определённый таймаут и снова выполняет синхронизацию в надежде получить мёрж. Если мёржа нет, то сам выполняет мёрж и пушит, после чего снова выполняет синхронизацию и проверят нет ли второго мёржа от приоритетного инстанса. Если есть - удаляет свой мёрж и переключается на второй мёрж.
Если в расходящихся транзакциях обнаруживаются структурные конфликты (разные изменения одного и того атрибута одной сущности) - то выполняется разрешение конфликта. По началу это будет просто last (по локальным часам инстанса) writer wins плюс возможность позвать пользовательский колбэк для разрешения. Затем добавлю CRDT-типы. Потом возможно придумаю чё-нить новое и таки защищу свой дисер:)
Видимо из-за возможности того, что данные новой версии могут попасть клиенту старой версии придётся на уровне БД принудить разработчиков делать только обратносовместимые миграции. Вместо переименования - добавление алиаса, при удалении атрибута - указание, что писать вместо него.