CJSON: внутренний формат Reindexer для обработки данных

В Reindexer используется внутренний формат представления данных CJSON (Compact JSON). С его помощью решаются задачи:

  1. Хранение - -tuple (используется в ядре Reindexer совместно с со структурой PayloadValue).
  2. Передача данных по сети - «транспортный» CJSON.

Структура CJSON для обоих случаев схожая, однако между -tuple и «транспортным» CJSON есть различия в способах хранения значений полей документа.

Структура CJSON

Каждое поле в СJSON представлено в виде ctag, который кодирует тип, имя и двоичное представление данных поля в формате, зависящем от типа.

Формат ctag представлен в таблице:

Поле ctag Размер в битах Описание
TypeTag0 3 Тип поля. Зависит от типа данных в поле. Записывается в виде TAG_XXX. Возможные значения представлены ниже
NameIndex 12 Индекс имени поля в словаре имён. 0 означает пустое имя
FieldIndex 10 Индекс поля данных в PayloadValue. 0 - нет ссылки, значение следует сразу за ctag
Reserved 4 Зарезервировано для дальнейшенго использования
TypeTag1 3 Дополнительные старшие биты для Типа поля. Совместно с TypeTag0 определяют фактический тип данных

Возможные значения поля TypeTag:

Тип поля (TypeTag) Значение Описание
TAG_VARINT 0 Данные в целочисленном формате
TAG_DOUBLE 1 Числа с плавающей точкой (64 бита)
TAG_STRING 2 Данные в формате строки
TAG_ARRAY 3 Данные — массив элементов
TAG_BOOL 4 Данные в формате boolean
TAG_NULL 5 Null
TAG_OBJECT 6 Данные — объект
TAG_END 7 Конец CJSON-объекта
TAG_UUID 8 Данные в формате UUID. Старший бит хранится в TypeTag1
TAG_FLOAT 9 Числа с плавающей точкой (32 бита)

Словарь имён (TagsMatcher)

TagsMatcher представляет из себя словарь имён, в котором каждому уникальному имени поля сопоставлено числовое значение. Этот механизм используется для более компактной сериализации документов. Объект TagsMatcher'a создаётся в момент создания неймспейса и дальше накапливает новые теги/имена по мере их появления в документах (при этом старые теги никогда не удаляются).

Общее максимальное количество уникальных тегов в одном неймспейсе не может превышать 4095, поэтому не рекомендуется использовать в качестве имён полей различного рода хеши. При достижении этого лимита невозможно будет добавить в неймспейс документ с каким-либо новым уникальным именем поля, которое никогда не встречалось ранее. На текущий момент, единственный способ полностью очистить TagsMatcher от старых имён (если они не используются) это полное пересоздание неймспейса из бэкапа, используя reindexer_tool.

Помимо словаря в объекте TagsMatcher также содержатся:

  • StateToken - случайное uint32-значение, идентифицирующее конкретный объект словаря. Используется для синхронизации словарей между узлами;
  • Version - текущая версия словаря. Инкрементально возрастает при добавлении новых полей. Каждая следующая версия словаря целиком содержит в себе предыдущую, а также включает как минимум одну новую пару “тег-имя”.

Для правильного декодирования CJSON объекты TagsMatcher для каждого неймспейса синхронизируются между клиентом и сервером, а также между лидером и всеми фолловерами в кластере, используя StateToken и Version.

Важно отметить то, что при формировании новых тегов для словаря не имеет значения уровень вложенности имён, их тип и сколько раз каждое из них встретится в документе. Например, для следующего JSON-документа:

{
    "field1": 123,
    "arr": [
    {
      "obj": {
        "field1": "string",
        "field2": 321
      },
      "arr": [ 1, 1],
      "flag": false
    },
    {
      "obj": {
        "field1": 10.7,
        "arr": []
      },
      "field1": [1, 1]
    }
  ],
  "flag": true
}

в словаре будет создано всего 5 уникальных тегов:

Тег Имя поля
1 field1
2 arr
3 obj
4 field2
5 flag

Текущий статус TagsMather‘а (Version, StateToken, текущее и максимальное количества тегов в словаре) отображаются в неймспейсе #memstats в поле tags_matcher.

Хранение массивов

Массивы могут храниться двумя различными способами:

  • однородный массив из элементов одного типа,
  • смешанный массив из элементов разных типов.

Оба типа массивов в дополнение к ctag используют atag.

atag — формат тега массива

atag — тег (4 байта, int), с помощью которого кодируется количество и тип элементов массива:

Поле тега Размер в битах Описание
Count 24 Количество элементов в массиве
TypeTag 6 Тип элементов массива. Если TypeTag массива равен — TAG_OBJECT, массив является смешанным, и каждый его элемент содержит свой ctag. TypeTag здесь может принимать те же значения, что и в ctag

Формат пакета CJSON

# Поле Описание
1 Offset to names dict Смещение словаря имен (номер байта в пакете, с которого начинается словарь). Если в пакете нет нового словаря имен, TAG_END в начале отсутствует
2 Records Дерево записей. Начинается с TAG_OBJECT и заканчивается TAG_END
3 Names dictionary Словарь имен полей
names_dictionary :=
  <varint(count)>
  [[<varint(string length)>, <name char array>],
   [<varint(string length)>, <name char array>],
   ...

Offset to names dict не всегда присутствует в пакете. Если пакет начинается с TAG_END, за ним размещается Offset to names dict. Если же пакет начинается не с TAG_END, то в нем вообще не содержится словарь имен.

Формат записи:

record :=
  ctag := (TAG_OBJECT,name)
    [field :=
      ctag := (TAG_VARINT,name) data := <varint> |
      ctag := (TAG_DOUBLE,name) data := <8 byte double> |
      ctag := (TAG_BOOL,name) data := <1 byte: 0 - False, 1 - True> |
      ctag := (TAG_STRING,name) data := <varint(string length)>, <char array> |
      ctag := (TAG_NULL,name) |
      ctag := (TAG_ARRAY,name)
        data := atag := (TAG_OBJECT|TAG_VARINT|TAG_DOUBLE|TAG_BOOL, count)
        array := [ctag := TAG_XXX] <data>,[[ctag := TAG_XXX>]<data>]] ... |
      ctag (TAG_OBJECT,name)>
        [subfield := field]
      ...
      ctag := (TAG_END)
    ]
    ...
  ctag := (TAG_END)

Пример объекта CJSON

{
  "name": "Hello",
  "year": 2010,
  "articles": [
    1,
    2,
    3,
    4,
    5
  ],
  "info": {
    "name": "Info"
  },
  "table": [
    [1,2],
    [3,4]
  ]
}
(TAG_OBJECT)                                    06
  (TAG_STRING,1) 5,"Hello"                      0A 05 48 65 6C 6C 6F
  (TAG_VARINT,2) 2010                           10 B4 1F
  (TAG_ARRAY,3) (TAG_VARINT,5) 1 2 3 4 5        1B 05 00 00 00 01 02 03 04 05
  (TAG_OBJECT,4)                                26
     (TAG_STRING,1) 4,"Info"                    0A 04 49 6E 66 6F
  (TAG_END)                                     07
  (TAG_ARRAY,5) (TAG_OBJECT,2)                  2D 02 00 00 06
    (TAG_ARRAY,0) (TAG_VARINT,2) 1 2            05 02 00 00 02 01 02
    (TAG_ARRAY,0) (TAG_VARINT,2) 3 4            05 02 00 00 02 03 04
(TAG_END)                                       07
1 - "name"
2 - "year"
3 - "articles"
4 - "info"
5 - "table"

-tuple и «транспортный» CJSON

-tuple

-tuple в формате CJSON используется для обработки документов ядром Reindexer. Его применение позволяет решить проблему с отсутствием унифицированной структуры записей в документоориентированных БД, когда каждая запись может иметь уникальные поля и нужно где-то хранить информацию по ее неиндексным полям.

-tuple - всегда самый первый (с индексом 0) элемент записи (PayloadValue -> Item). В нем хранятся либо ссылки на индексные поля записи, либо сами значения — в случае если поле неиндексное.

В CJSON каждое поле записи (документа, строки неймспейса) описывается с помощью ctagcarraytag). В ctag хранится:

  • тип поля (TypeTag),
  • целочисленные номер имени поля (NameIndex),
  • индекс поля в PayloadValue (FieldTag) - если поле индексное .

Если поле индексное, в FieldTag хранится его индекс в PayloadType и IndexesStorage (этот индекс также позволяет вычислить его смещение в PayloadValue). При этом в -tuple сериализуется только ctag, а само значение находится в PayloadValue (доступ к нему можно получить через метод PayloadIface::Get(ctag.field)).

Если поле неиндексное, значение ctag.field = -1. Значение поля сериализуется сразу за ctag.

Все документы неймспейса хранятся в NamespaceImpl (массив items_) в виде PayloadValue. У каждого такого документа в первом поле находится значение -tuple.

«Транспортный» CJSON

«Транспортный» CJSON используется для отправки данных по сети. С ним работают cproto-клиенты, grpc-клиенты, rpc/grpc-серверы. Так же он используется при проксировании в синхронном кластере и шардировании.

В «транспортным» CJSON (в отличие от -tuple, используемого ядром) каждое поле, независимо от того, индексное оно или неиндексное, кодируется без ссылок на его значение в PayloadValue. То есть значение каждого поля хранится непосредственно внутри объекта CJSON. Ссылки на индексные поля в данном случае использоваться не могут, поскольку клиент может не иметь данных PayloadType.

Пример обхода структуры CJSON (код из skipCjsonTag):

void skipCjsonTag(ctag tag, Serializer &rdser) {
	const bool embeddedField = (tag.Field() < 0);
	switch (tag.Type()) {
		case TAG_ARRAY: {
			if (embeddedField) {
				carraytag atag = rdser.GetUInt32();
				for (int i = 0; i < atag.Count(); i++) {
					ctag t = atag.Tag() != TAG_OBJECT ? atag.Tag() : rdser.GetVarUint();
					skipCjsonTag(t, rdser);
				}
			} else {
				rdser.GetVarUint();
			}
		} break;

		case TAG_OBJECT:
			for (ctag otag = rdser.GetVarUint(); otag.Type() != TAG_END; otag = rdser.GetVarUint()) {
				skipCjsonTag(otag, rdser);
			}
			break;
		default:
			if (embeddedField) rdser.GetRawVariant(KeyValueType(tag.Type()));
	}
}

Основные модули работы с CJSON

В Reindexer для работы с CJSON используются следующие основные модули:

Модуль Описание Ссылка
CJsonModifier Класс модификации существующего CJSON. Используется для обновления и удаления существующих, добавления новых (как индексных, так и неиндексных) полей. Поиск поля для модификации осуществляется по его TagsPath (json path с тегом на каждое вложенное поле) через рекурсивный обход json-подобной структуры CJSON. Обновление поля происходит через построение нового CJSON, либо с обновлением текущих полей (или добавлением новых), либо с пропуском (при удалении) некоторых из них. Если при обходе всего CJSON соответствующее поле не было найдено, осуществляется вставка нового поля из корня cjson в соответствие с заданным json path https://github.com/Restream/reindexer/blob/master/cpp_src/core/cjson/cjsonmodifier.cc
ItemModifier Класс модификации записи БД (Item) при выполнении Update запроса. Модифицирует PayloadValue существующей записи на основе UpdateEntry (из объекта Query). Обновление полей-объектов происходит через модификацию CJSON (построение нового CJSON) c использованием класса CJsonModifier и последующим обновлением всех индексных полей (включая композитные индексы). В случае обновления скалярных полей (полей не объектов) происходит либо обновление индексов, либо обновление CJSON через CJsonModifier https://github.com/Restream/reindexer/blob/master/cpp_src/core/itemmodifier.cc
BaseEncoder Класс построения выходных форматов данных (json/msgpack/cjson/protobuf) на основании -tuple существующей записи. Через BaseEncoder происходит конвертация представления данных Item в JSON или транспортный CJSON. Алгоритм построения выходных форматов основан на рекурсивном обходе json-подобной структуры -tuple с записью данных в выходной WrSerializer поток с использованием Builder (JSONBuilder/CJSONBuilder/MsgPackBuilder/ProtobufBuilder/CsvBuilder) объекта, переданного пользователем как аргумент в метод Encode https://github.com/Restream/reindexer/blob/master/cpp_src/core/cjson/baseencoder.cc
CJsonDecoder Класс, декодирующий tuple/cjson с последующей записью выходных данных в WrSerializer поток в формате -tuple. Алгоритм работы осуществляется на основе рекурсивного обхода json-подобной структуры CJSON https://github.com/Restream/reindexer/blob/master/cpp_src/core/cjson/cjsondecoder.cc
CJsonBuilder Класс построения полей CJSON. CJsonBuilder используется BaseEncoder для построения транспортабельного CJSON. Каждое поле кодируется полностью: ctag (поле field = -1) + значение, следующее за ним https://github.com/Restream/reindexer/blob/master/cpp_src/core/cjson/cjsonbuilder.cc
FieldsExtractor Класс извлечения значений неиндексных полей из CJSON объекта. Поиск и получение значений полей основаны на работе класса BaseEncoder, который использует FieldsExtractor как объект Builder при рекурсивном обходе -tuple. Поле считается найденным, когда значение внутренней переменной expectedPathDepth_ (начальное значение равно длине json path) становится равным нулю (означает правильный уровень вложенности поля) https://github.com/Restream/reindexer/blob/master/cpp_src/core/cjson/fieldextractor.h
CJson tools Набор функций для работы с CJSON: генерация -tuple на основании PayloadValue, копирование значения поля в CJSON, создание поля-ссылки (индексное поле в -tuple) и неиндексного поля (как неиндексное поле в -tuple, так и поле в «транспортном» CJSON) и т.д.