«1. Обзор

В этом руководстве мы дадим обзор того, что такое SirixDB, и наиболее важные цели его разработки.

Далее мы рассмотрим низкоуровневый транзакционный API на основе курсора.

2. Возможности SirixDB

SirixDB — это структурированное журналом временное хранилище документов NoSQL, в котором хранятся эволюционные данные. Он никогда не перезаписывает данные на диске. Таким образом, мы можем эффективно восстанавливать и запрашивать полную историю изменений ресурса в базе данных. SirixDB гарантирует, что для каждой новой версии создается минимум накладных расходов на хранение.

В настоящее время SirixDB предлагает две встроенные собственные модели данных, а именно двоичное хранилище XML и хранилище JSON.

2.1. Цели разработки

Некоторые из наиболее важных основных принципов и целей разработки:

    Параллелизм — SirixDB содержит очень мало блокировок и стремится максимально подходить для многопоточных систем. Асинхронный REST API — операции могут выполняться независимо. ; каждая транзакция привязана к определенной ревизии, и только одна транзакция чтения-записи на ресурсе разрешена одновременно с N транзакциями только для чтения до минимума. Производительность чтения и записи настраивается. Это зависит от типа управления версиями, который мы можем указать для создания ресурса Целостность данных — SirixDB, как и ZFS, хранит полные контрольные суммы страниц на родительских страницах. Это означает, что почти все повреждения данных могут быть обнаружены при чтении в будущем, поскольку разработчики SirixDB стремятся в будущем разделять и реплицировать базы данных Семантика копирования при записи — аналогично файловым системам Btrfs и ZFS, SirixDB использует CoW семантики, что означает, что SirixDB никогда не перезаписывает данные. Вместо этого фрагменты страниц базы данных копируются и записываются в новое место. Версии для каждой версии и для каждой записи — SirixDB создает версии не только для каждой страницы, но и для каждой записи. Таким образом, всякий раз, когда мы меняем потенциально небольшую часть записей на странице данных, не нужно копировать всю страницу и записывать ее в новое место на диске или флэш-накопителе. Вместо этого мы можем указать одну из нескольких стратегий управления версиями, известных из систем резервного копирования, или алгоритм скользящего моментального снимка во время создания ресурса базы данных. Указанный нами тип управления версиями используется SirixDB для управления версиями страниц данных. Гарантированная атомарность (без WAL) — система никогда не перейдет в несогласованное состояние (за исключением аппаратного сбоя), а это означает, что неожиданное отключение питания никогда не повредит система. Это достигается без накладных расходов, связанных с журналом упреждающей записи (WAL). Структурированный журнал и удобный для SSD — SirixDB выполняет пакетную запись и синхронизацию всего последовательно на флэш-накопитель во время фиксации. Он никогда не перезаписывает зафиксированные данные

Сначала мы хотим представить низкоуровневый API на примере данных JSON, прежде чем в будущих статьях переключить внимание на более высокие уровни. Например, XQuery-API для запросов к базам данных XML и JSON или асинхронный временный RESTful API. По сути, мы можем использовать один и тот же низкоуровневый API с небольшими отличиями для хранения, просмотра и сравнения ресурсов XML.

Чтобы использовать SirixDB, нам как минимум нужно использовать Java 11.

3. Зависимость Maven для встраивания SirixDB

Чтобы следовать примерам, мы сначала должны включить зависимость sirix-core, например, через Maven:

<dependency>
    <groupId>io.sirix</groupId>
    <artifactId>sirix-core</artifactId>
    <version>0.9.3</version>
</dependency>

Или через Gradle:

dependencies {
    compile 'io.sirix:sirix-core:0.9.3'
}

4. Древовидное кодирование в SirixDB

Узел в SirixDB ссылается на другие узлы с помощью кодировки firstChild/leftSibling/rightSibling/parentNodeKey/nodeKey: ~ ~~ Числа на рисунке представляют собой автоматически сгенерированные уникальные, стабильные идентификаторы узлов, сгенерированные с помощью простого генератора последовательных чисел.

У каждого узла может быть первый дочерний узел, левый одноуровневый узел, правый одноуровневый узел и родительский узел. Кроме того, SirixDB может хранить количество дочерних узлов, количество потомков и хэши каждого узла.

В следующих разделах мы представим основной низкоуровневый JSON API SirixDB.

5. Создайте базу данных с одним ресурсом

«Во-первых, мы хотим показать, как создать базу данных с одним ресурсом. Ресурс будет импортирован из файла JSON и сохранен во внутреннем двоичном формате SirixDB:

Сначала мы создадим базу данных. Затем мы открываем базу данных и создаем первый ресурс. Существуют различные варианты создания ресурса (см. официальную документацию).

var pathToJsonFile = Paths.get("jsonFile");
var databaseFile = Paths.get("database");

Databases.createJsonDatabase(new DatabaseConfiguration(databaseFile));

try (var database = Databases.openJsonDatabase(databaseFile)) {
    database.createResource(ResourceConfiguration.newBuilder("resource").build());

    try (var manager = database.openResourceManager("resource");
         var wtx = manager.beginNodeTrx()) {
        wtx.insertSubtreeAsFirstChild(JsonShredder.createFileReader(pathToJsonFile));
        wtx.commit();
    }
}

Затем мы открываем одну транзакцию чтения-записи для ресурса, чтобы импортировать файл JSON. Транзакция предоставляет курсор для навигации по методам moveToX. Кроме того, транзакция предоставляет методы для вставки, удаления или изменения узлов. Обратите внимание, что API XML даже предоставляет методы для перемещения узлов в ресурсе и копирования узлов из других ресурсов XML.

Чтобы правильно закрыть открытую транзакцию чтения-записи, менеджер ресурсов и базу данных, мы используем оператор Java try-with-resources.

Мы привели пример создания базы данных и ресурса на основе данных JSON, но создание базы данных и ресурса XML практически идентично.

В следующем разделе мы откроем ресурс в базе данных и покажем навигационные оси и методы.

6. Открытие ресурса в базе данных и навигация

6.1. Навигация по предварительному заказу в ресурсе JSON

Для навигации по древовидной структуре мы можем повторно использовать транзакцию чтения-записи после фиксации. Однако в следующем коде мы снова откроем ресурс и начнем транзакцию только для чтения с самой последней ревизией:

Мы используем ось потомков для перебора всех узлов в предварительном порядке (сначала в глубину) . Хэши узлов строятся снизу вверх для всех узлов по умолчанию в зависимости от конфигурации ресурса.

try (var database = Databases.openJsonDatabase(databaseFile);
     var manager = database.openResourceManager("resource");
     var rtx = manager.beginNodeReadOnlyTrx()) {
    
    new DescendantAxis(rtx, IncludeSelf.YES).forEach((unused) -> {
        switch (rtx.getKind()) {
            case OBJECT:
            case ARRAY:
                LOG.info(rtx.getDescendantCount());
                LOG.info(rtx.getChildCount());
                LOG.info(rtx.getHash());
                break;
            case OBJECT_KEY:
                LOG.info(rtx.getName());
                break;
            case STRING_VALUE:
            case BOOLEAN_VALUE:
            case NUMBER_VALUE:
            case NULL_VALUE:
                LOG.info(rtx.getValue());
                break;
            default:
        }
    });
}

Узлы массива и узлы объекта не имеют ни имени, ни значения. Мы можем использовать одну и ту же ось для перебора XML-ресурсов, различаются только типы узлов.

SirixDB предлагает набор осей, таких как, например, все оси XPath для навигации по ресурсам XML и JSON. Кроме того, он предоставляет LevelOrderAxis, PostOrderAxis, NestedAxis для оси цепочки и несколько вариантов ConcurrentAxis для одновременной и параллельной выборки узлов.

В следующем разделе мы покажем, как использовать VisitorDescendantAxis, который выполняет итерацию в предварительном порядке, руководствуясь типами возвращаемых данных посетителя узла.

6.2. Ось потомков посетителей

Поскольку очень часто поведение определяется на основе различных типов узлов, SirixDB использует шаблон посетителей.

Мы можем указать посетителя в качестве аргумента построителя для специальной оси с именем VisitorDescendantAxis. Для каждого типа узла существует эквивалентный метод посещения. Например, для ключевых узлов объекта это посещение метода VisitResult (узел ImmutableObjectKeyNode).

Каждый метод возвращает значение типа VisitResult. Единственной реализацией интерфейса VisitResult является следующее перечисление:

VisitorDescendantAxis выполняет итерацию по древовидной структуре в предварительном порядке. Он использует VisitResultTypes для направления обхода:

public enum VisitResultType implements VisitResult {
    SKIPSIBLINGS,
    SKIPSUBTREE,
    CONTINUE,
    TERMINATE
}

SKIPSIBLINGS означает, что обход должен продолжаться без посещения правых братьев и сестер текущего узла курсор указывает на SKIPSUBTREE означает продолжение без посещения потомков этого узла Мы используем CONTINUE, если обход должен продолжить в предварительном порядке Мы также можем использовать TERMINATE для немедленного прекращения обхода

    Реализация по умолчанию каждого метода в интерфейсе посетителя возвращает VisitResultType.CONTINUE для каждого типа узла. Таким образом, нам нужно только реализовать методы для узлов, которые нас интересуют. Если мы реализовали класс, который реализует интерфейс посетителя с именем MyVisitor, мы можем использовать VisitorDescendantAxis следующим образом:

~ ~~ Методы MyVisitor вызываются для каждого узла обхода. Параметр rtx является транзакцией только для чтения. Обход начинается с узла, на который в данный момент указывает курсор.

var axis = VisitorDescendantAxis.newBuilder(rtx)
  .includeSelf()
  .visitor(new MyVisitor())
  .build();

while (axis.hasNext()) axis.next();

6.3. Ось путешествий во времени

«

One of the most distinctive features of SirixDB is thorough versioning. Thus, SirixDB not only offers all kinds of axes to iterate through the tree structure within one revision. We’re also able to use one of the following axes to navigate in time:

  • FirstAxis
  • LastAxis
  • PreviousAxis
  • NextAxis
  • AllTimeAxis
  • FutureAxis
  • PastAxis

The constructors take a resource manager as well as a transactional cursor as parameters.  The cursor navigates to the same node in each revision.

If another revision in the axis – as well as the node in the respective revision – exists, then the axis returns a new transaction. The return values are read-only transactions opened on the respective revisions, whereas the cursor points to the same node in the different revisions.

We’ll show a simple example for the PastAxis:

var axis = new PastAxis(resourceManager, rtx);
if (axis.hasNext()) {
    var trx = axis.next();
    // Do something with the transactional cursor.
}

6.4. Filtering

SirixDB provides several filters, which we’re able to use in conjunction with a FilterAxis. The following code, for instance, traverses all children of an object node and filters for object key nodes with the key “a” as in {“a”:1, “b”: “foo”}.

new FilterAxis<JsonNodeReadOnlyTrx>(new ChildAxis(rtx), new JsonNameFilter(rtx, "a"))

The FilterAxis optionally takes more than one filter as its argument. The filter either is a JsonNameFilter, to filter for names in object keys or one of the node type filters: ObjectFilter, ObjectRecordFilter, ArrayFilter, StringValueFilter, NumberValueFilter, BooleanValueFilter and NullValueFilter.

The axis can be used as follows for JSON resources to filter by object key names with the name “foobar”:

var axis = new VisitorDescendantAxis.Builder(rtx).includeSelf().visitor(myVisitor).build();
var filter = new JsonNameFilter(rtx, "foobar");
for (var filterAxis = new FilterAxis<JsonNodeReadOnlyTrx>(axis, filter); filterAxis.hasNext();) {
    filterAxis.next();
}

Alternatively, we could simply stream over the axis (without using the FilterAxis at all) and then filter by a predicate.

rtx is of type NodeReadOnlyTrx in the following example:

var axis = new PostOrderAxis(rtx);
var axisStream = StreamSupport.stream(axis.spliterator(), false);

axisStream.filter((unusedNodeKey) -> new JsonNameFilter(rtx, "a"))
  .forEach((unused) -> /* Do something with the transactional cursor */);

7. Modify a Resource in a Database

Obviously, we want to be able to modify a resource. SirixDB stores a new compact snapshot during each commit.

After opening a resource we have to start the single read-write transaction as we’ve seen before.

7.1. Simple Update Operations

Once we navigated to the node we want to modify, we’re able to update for instance the name or the value, depending on the node type:

if (wtx.isObjectKey()) wtx.setObjectKeyName("foo");
if (wtx.isStringValue()) wtx.setStringValue("foo");

We can insert new object records via insertObjectRecordAsFirstChild and insertObjectRecordAsRightSibling. Similar methods exist for all node types. Object records are composed of two nodes: An object key node and an object value node.

SirixDB checks for consistency and as such it throws an unchecked SirixUsageException if a method call is not permitted on a specific node type.

Object records, that is key/value pairs, for instance, can only be inserted as a first child if the cursor is located on an object node. We insert both an object key node as well as one of the other node types as the value with the insertObjectRecordAsX methods.

We can also chain the update methods – for this example, wtx is located on an object node:

wtx.insertObjectRecordAsFirstChild("foo", new StringValue("bar"))
   .moveToParent().trx()
   .insertObjectRecordAsRightSibling("baz", new NullValue());

First, we insert an object key node with the name “foo” as the first child of an object node. Then, a StringValueNode is created as the first child of the newly created object record node.

The cursor is moved to the value node after the method call. Thus we first have to move the cursor to the object key node, the parent again. Then, we’re able to insert the next object key node and its child, a NullValueNode as a right sibling.

7.2. Bulk Insertions

More sophisticated bulk insertion methods exist, too, as we’ve already seen when we imported JSON data. SirixDB provides a method to insert JSON data as a first child (insertSubtreeAsFirstChild) and as a right sibling (insertSubtreeAsRightSibling).

To insert a new subtree based on a String we can use:

var json = "{\"foo\": \"bar\",\"baz\": [0, \"bla\", true, null]}";
wtx.insertSubtreeAsFirstChild(JsonShredder.createStringReader(json));

The JSON API currently doesn’t offer the possibility to copy subtrees. However, the XML API does. We’re able to copy a subtree from another XML resource in SirixDB:

wtx.copySubtreeAsRightSibling(rtx);

Here, the node the read-only transaction (rtx) currently points to is copied with its subtree as a new right sibling of the node that the read-write transaction (wtx) points to.

SirixDB always applies changes in-memory and then flushes them to a disk or the flash drive during a transaction commit. The only exception is if the in-memory cache has to evict some entries into a temporary file due to memory constraints.

We can either commit() or rollback() the transaction. Note that we can reuse the transaction after one of the two method calls.

SirixDB also applies some optimizations under the hood when invoking bulk insertions.

In the next section, we’ll see other possibilities on how to start a read-write transaction.

7.3. Start a Read-Write Transaction

As we’ve seen we can begin a read-write transaction and create a new snapshot by calling the commit method. However, we can also start an auto-committing transactional cursor:

resourceManager.beginNodeTrx(TimeUnit.SECONDS, 30);
resourceManager.beginNodeTrx(1000);
resourceManager.beginNodeTrx(1000, TimeUnit.SECONDS, 30);

Either we auto-commit every 30 seconds, after every 1000th modification or every 30 seconds and every 1000th modification.

We’re also able to start a read-write transaction and then revert to a former revision, which we can commit as a new revision:

resourceManager.beginNodeTrx().revertTo(2).commit();

All revisions in between are still available. Once we have committed more than one revision we can open a specific revision either by specifying the exact revision number or by a timestamp:

var rtxOpenedByRevisionNumber = resourceManager.beginNodeReadOnlyTrx(2);

var dateTime = LocalDateTime.of(2019, Month.JUNE, 15, 13, 39);
var instant = dateTime.atZone(ZoneId.of("Europe/Berlin")).toInstant();
var rtxOpenedByTimestamp = resourceManager.beginNodeReadOnlyTrx(instant);

8. Compare Revisions

To compute the differences between any two revisions of a resource, once stored in SirixDB, we can invoke a diff-algorithm:

DiffFactory.invokeJsonDiff(
  new DiffFactory.Builder(
    resourceManager,
    2,
    1,
    DiffOptimized.HASHED,
    ImmutableSet.of(observer)));

The first argument to the builder is the resource manager, which we already used several times. The next two parameters are the revisions to compare. The fourth parameter is an enum, which we use to determine if SirixDB should take hashes into account to speed up the diff-computation or not.

If a node changes due to update operations in SirixDB, all ancestor nodes adapt their hash values, too. If the hashes and the node keys in the two revisions are identical, SirixDB skips the subtree during the traversal of the two revisions, because there are no changes in the subtree when we specify DiffOptimized.HASHED.

An immutable set of observers is the last argument. An observer has to implement the following interface:

public interface DiffObserver {
    void diffListener(DiffType diffType, long newNodeKey, long oldNodeKey, DiffDepth depth);
    void diffDone();
}

The diffListener method as the first parameter specifies the type of diff encountered between two nodes in each revision. The next two arguments are the stable unique node identifiers of the compared nodes in the two revisions. The last argument depth specifies the depth of the two nodes, which SirixDB just compared.

9. Serialize to JSON

At some point in time we want to serialize a JSON resource in SirixDBs binary encoding back to JSON:

var writer = new StringWriter();
var serializer = new JsonSerializer.Builder(resourceManager, writer).build();
serializer.call();

To serialize revision 1 and 2:

var serializer = new
JsonSerializer.Builder(resourceManager, writer, 1, 2).build();
serializer.call();

And all stored revisions:

var serializer = new
JsonSerializer.Builder(resourceManager, writer, -1).build();
serializer.call();

10. Conclusion

We’ve seen how to use the low-level transactional cursor API to manage JSON databases and resources in SirixDB. Higher level-APIs hide some of the complexity.

The complete source code is available over on GitHub.