вторник, 9 июля 2013 г.

[prog.thoughts] О эволюции схем данных при двоичной сериализации/десериализации

С паузой в два с половиной месяца продолжу разговор, начатый заметкой "Презентация Thrift vs Protobuf vs Avro". Там я упомянул проблему эволюции схемы данных. Пришло время поговорить об этом подробнее.

Для начала disclaimer-ы. В свое время, учась в аспирантуре, я разработал объектную СУБД Dss. Затем, многие вещи оттуда реинкарнировали в виде проекта ObjESSty: первоначально он задумывался как более простая, чем Dss, объектная БД, но намного чаще ObjESSty 1.* использовалась как система сериализации данных. Поэтому из ObjESSty 2.0 было удалено все, что связано с СУБД, и оставлены только средства сериализации. Уже после того, как первые реализации ObjESSty были задействованы в промышленных разработках компании Интервэйл, я узнал про существование ASN.1 и потратил тогда некоторое время на изучение этого зверя, хотя на практике применять ASN.1 не приходилось. Поэтому все, о чем я буду говорить ниже, базируется на моем собственном опыте работы с двоичными данными при их длительном хранении в БД и при использовании двоичных данных для коммуникации приложений. К каким-то из описанных ниже вещей я пришел сам, набивая собственные шишки, какие-то были подсказаны знакомством с ASN.1 и другими аналогичными разработками.

Два подхода к представлению двоичных данных

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

Подробнее о TLV-представлении

Самый простой в работе способ представления двоичных данных строится на специальном оформлении сериализуемых полей объекта. Каждому полю предшествует идентификатор, который указывает, что это за поле. Данный подход имеет название TLV -- Tag, Length, Value. Т.е. перед каждым сериализованным полем есть его идентификатор/метка (Tag) и длина значения поля (Length).

Pазмерности Tag и Length могут быть как фиксированными (например, Tag строго 1 байт, Length строго 2 байта), так и "плавающими" (т.е. в зависимости от значения Tag и/или Length могут занимать 1, 2 или более байт). Выбор между фиксированными или плавающими представлениями Tag/Length делается, обычно, на основе компромисса между размером двоичного представления и простотой (а значит и скоростью) обработки данных.

С TLV очень удобно работать в плане простоты ручного разбора двоичной информации. Как правило, даже при наличии лишь части двоичного дампа не сложно выявить в нем TLV-структуру и разобрать дамп на составляющие. Иногда это помогает обнаружить причины проблем в работе приложения. Но, с другой стороны, TLV может привнести изрядные накладные расходы.

Во-первых, по объему сериализованных данных, особенно при фиксированных размерах Tag/Length. Особенно в ситуациях, когда в сериализуемых структурах большое количество мелких полей. Допустим, в структуре 20 полей, Tag зафиксирован в 1 байт, Length в 2 байта. Накладные расходы на TLV будут составлять 60 байт. Если же сами поля имеют длину в 1-2 байта, накладные расходы в процентном отношении окажутся весьма заметными.

Во-вторых, по скорости сериализации данных. При записи Length нужно точно знать размер двоичного представления поля. Значит либо эту длину нужно предварительно вычислить (дополнительных проход по структуре), либо нужно уметь оставлять пустые места для Length и затем, после однократного прохода по структуре данных, возвращаться к ним и фиксировать в них актуальные значения. Что может и не получится в случаях, когда:

  • сериализация идет в поток, без возможности возврата на какую-то из его позиций. Например, запись идет не в байтовый массив в памяти, а в транспортный канал, скажем, в TCP сокет;
  • для представления Length используется "плавающий" формат и актуальный размер Length будет зависеть от значения Length.

Подробнее о не-TLV представлении

Второй подход к разметке содержимого двоичных данных основывается на строгом порядке следования полей и не предполагает каких-либо специальных идентификаторов/маркеров вокруг значения поля. Данный подход характеризуется очень высокой эффективностью как по объему двоичных данных (он меньше, чем в случае TLV), так и по скорости сериализации/десериализации данных. Но и сложностей при работе с этим подходом больше.

Во-первых, имея двоичный дамп, даже полный, крайне сложно вручную разбить его на составные части и выделять значение конкретного поля. Нужно обязательно иметь перед глазами схему данных. Но даже с ее использованием расчленение двоичного потока на поля может быть тяжелой задачей, если в дампе находится множество сложных объектов (объектов с большим количеством полей и/или полей со сложной структурой).

Во-вторых, сериализованные таким образом объекты нуждаются в дополнительном "обрамлении" какой-то служебной информацией. Особенно когда они передаются по транспортным каналам между приложениями. Действительно, когда приложение A получает блок двоичных данных от приложения B, как оно поймет, что:

  • блок данных получен полностью?
  • каково содержимое полученного блока данных?

Обычно для решения этих проблем в коммуникационных протоколах применяются верхнеуровневые элементы -- PDU (Protocol Data Unit), которые оформляются по принципам TLV. А уже внутри PDU информация представляется в виде жестко зафиксированной последовательности полей.

В отношении БД эта проблема не так актуальна, хотя там она решается приблизительно так же. Но вместо PDU в БД используется штатный для конкретной БД способ разметки сущностей в файлах данных БД. Это может быть как аналог TLV, так и что-то другое.

Эволюция схемы данных

Для разговора об эволюции схемы данных я бы выделил два крайних случая: эволюция схема данных, хранящихся в БД, и эволюция схемы данных, использующихся в каком-то коммуникационном протоколе. Деление это несколько условное. Но его лучше обозначить, т.к. все-таки особенности есть. Существует еще один промежуточный случай, который сочетает в себе специфику БД и коммуникаций -- это двоичные файлы данных, в которые приложения сохраняют результаты своей работы. Например, бинарные файлы .doc или .tiff могут служить примерами этого случая.

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

  • содержимое БД или файлов данных, созданное на старой схеме данных;
  • прикладное ПО, работающее с этим содержимым БД/файлов данных;
  • взаимодействующие по какому-то протоколу программные компоненты в случае, когда меняется схема данных этого протокола.

Если у нас есть возможность поменять схему данных и сразу же перестроить все файлы данных (все БД) или же перекомпилировать и заменить все взаимодействующие друг с другом компоненты, то у нас нет проблем с эволюцией схемы данных :)

Специфика эволюции схемы данных в БД

Выше я сказал, что эволюция схемы данных в БД -- это крайний случай, имеющий свою специфику. Она заключается в том, что нужно рассматривать две схемы данных -- ту, что зафиксирована в БД, и ту, что зафиксирована в приложении.

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

Самый простой подход -- это полная модификация содержимого БД. Т.е. БД переводится в оффлан-режим (отключается от всех приложений), в БД фиксируется новая схема и все хранящиеся в БД данные "перелопачиваются" и приводятся к новому виду. После чего новые версии приложения извлекают из БД объекты в уже их новом виде. Очевидно, что не всегда возможно такое "переформатирование" содержимого БД. Ведь это может потребовать больших затрат времени (зависит от объема БД). И на это время БД должна быть переведена в оффлайн. Зато такая модификация данных обеспечивает очень высокую скорость работы с БД после модификации.

Более сложный подход состоит в преобразовании "старых" значений объектов в "новые" значения "на лету". Т.е. когда новая версия приложения извлекает из БД объект X, БД понимает, что объект X должен быть преобразован к другой схеме, выполняет это преобразование и отдает соответствующее значение программе. Такой подход не требует перевода БД в режим оффлайн. Но может замедлять работу с БД за счет операций преобразования объектов. Ну и реализовать его в СУБД значительно сложнее.

Для того, чтобы БД поддерживала эволюцию схемы данных, необходимо, чтобы БД хранила в себе описание структуры объектов. Для этих целей СУБД сохраняет для каждого объекта идентификатор его типа, а так же, в отдельной служебной области данных, описание каждого типа. Если же БД поддерживает преобразование объектов "на лету", то в этом служебном описании типов так же хранятся описания различных версий этого типа, а для объекта в БД фиксируется не только идентификатор типа, но и идентификатор версии типа.

То, что в БД может быть сохранено описание схемы данных сохраненных в БД объектов, является еще одним большим отличием задачи поддержки эволюции схемы данных в БД от задачи поддержки эволюции схемы данных в коммуникационных протоколах (где схема данных обычно не передается внутри PDU).

Речь шла не о реляционных СУБД

На данный момент самым распространенным и общеупотребительным типом СУБД являются реляционные СУБД (РСУБД), в которых схема данных описывается таблицами и взаимосвязями между таблицами. Поэтому для тех, кто привык работать с РСУБД и никогда не сталкивался с объектными СУБД (ООСУБД) или другими типами нереляционных БД (иерархическими/сетевыми, документно-ориентированными, key-value хранилищами и пр. NoSQL), может быть не очень понятно, почему я уделяю этому пункту столько внимания.

Дело в том, что в случае с РСУБД реально существуют две совершенно разных схемы данных: одна схема данных в приложении (например, объектная схема данных); вторая, реляционная схема, в БД. И приложение вручную или посредством какого-то фреймворка выполняет преобразование данных из одной схемы в другую. Один объект в программе может отображаться на целую группу таблиц. И изменение структуры объекта может требовать как изменения одной таблицы из этой группы, так и изменения всей группы таблиц целиком. А может и не требовать никакого изменения схемы данных БД вовсе. Точно так же изменение схемы данных в БД (добавление индекса) может вообще не требовать изменения схемы данных в приложении. Поэтому вопрос модификации схемы данных для случая РСУБД -- это вообще "отдельная пестня", в которой я не очень силен.

В случае же с нереляционными СУБД (в частности, с объектными СУБД) схема данных, как правило, одна -- и в приложении, и в БД. Специфику этой задачи пользователи реляционных СУБД смогут себе представить, если подумают вот о каком примере: допустим у вас есть большая таблица, одно из полей которой имеет тип BLOB. В этот BLOB упакован какой-то сложный объект: векторное графическое изображение. И вот структура этого объекта поменялась. Скажем, раньше координаты точек в изображении задавались целыми числами, теперь же они должны задаваться числами с плавающей запятой. И вам нужно после этого работать со всеми BLOB-ами из этой таблицы.

Принципиальное отличие между РСУБД и прочими типами СУБД в том, что при работе с РСУБД есть некий слой, который преобразует данные из представления БД в необходимый приложению вид. Поэтому вопрос эволюции схемы БД решается на двух уровнях: на уровне БД (например, добавление еще одной таблицы или столбца в таблицу) и на уровне слоя преобразования представлений. В случае же объектной СУБД такого промежуточного слоя нет, там задача самой объектной СУБД в том, чтобы отдать приложению именно то представление, которое нужно приложению. Поэтому вопрос эволюции в ООСУБД решается только на уровне СУБД.

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

Специфика файлов данных

Отличие файлов данных от БД при эволюции схемы данных в том, что в отличии от БД, у разработчика нет возможности каким-то образом выполнить оффлайновую модификацию всех уже созданных файлов данных. А так же нет возможности заменить все приложения со старыми версиями схемы данных на приложения с новыми версиями.

Самый яркий пример: эволюция MS Word-а. MS Word 6 записывал свои версии .doc-файлов, MS Word 97 -- свои, MS Word 2010 -- свои. Старые версии Word-а не могут читать .doc-файлы от новых версий (если только они не созданы в старом формате специально). Но вот новые версии Word-а вынуждены поддерживать старые версии .doc-файлов, т.к. никто не может заставить всех пользователей Word-а перезаписать все свои файлы в новый формат при выходе очередной версии приложения.

Поэтому при работе с файлами данных нужно иметь возможность поддержки старых схем данных наряду с новыми схемами. И, при этом, крайне желательно выполнять преобразование данных "на лету", при чтении/записи файла данных. Это очень похоже на то, что происходит в коммуникационных протоколах. Но, в отличии от коммуникационных протоколов в файлах данных можно сохранить полное описание версии схемы данных.

Принципы и детали поддержки различных случаев эволюции схемы данных

Далее под эволюцией будет пониматься модификация схемы данных, которая может выражаться в:

  • добавлении новых полей для объекта;
  • удаления старых полей объекта;
  • изменения типа существующих полей объекта.

В эволюции схемы данных могут быть и более сложные случаи. Например, если схема данных поддерживает такие вещи, как объектно-ориентированное наследование, то в эволюции должны обрабатываться случаи изменения иерархии наследования (добавление новых родительских типов, изъятие родительских типов, изменение порядка наследования). Но такие специфические ситуации следует рассматривать отдельно и они выходят за рамки данного поста.

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

Добавление новых полей

Добавление новых полей в структуру объекта -- это самый простой и распространенный случай эволюции схемы данных :)

В TLV-подходе он имеет и самую тривиальную реализацию. Для новых полей регистрируются новые значения Tag. Значения новых полей записываются с этими новыми идентификаторами. Когда новый сериализованный объект приходит в старую версию приложения (т.е. в приложение, которое работает со старой схемой данных), приложение просто игнорирует неизвестные ему Tag-и и новые поля для старого приложения просто не существуют.

Игнорирование новых полей приложениями, использующими старую схему данных, -- это общепринятый и разумный подход. Если вам нужен объект, все новые поля которого являются обязательными, без значения которых с объектом нельзя работать, то не следует расширять старый тип объекта. Просто создайте новый тип, у которого "новые" поля будут присутствовать изначально как обязательные. И старые приложения не смогут работать с объектами этого типа. Это вполне нормальная и понятная практика. Впрочем, возможен и другой подход к этой теме (см.ниже).

Если для сериализации используется представление на основе порядка следования полей, то добавление новых полей в объект не так тривиально, как в случае TLV-представления. Для того, чтобы тип объекта мог быть расширен новыми полями, в самом типе объекта должны быть описаны специальные места, в которые новые поля могут быть добавлены. Такие места далее будут называться точками расширения./p>

В ASN.1, если не ошибаюсь, в типе объекта можно указать несколько точек расширения типа. В процессе эволюции схемы данных разработчик может модифицировать каждую из них отдельно. Но количество и расположение точек расширения в типе объекта менять нельзя. У себя в ObjESSty я поступил проще: есть либо нерасширяемые типы (т.е. типы с жестко зафиксированным набором полей, который не может быть расширен), либо расширяемые типы. У расширяемых типов есть всего одна точка расширения и разработчик может добавлять новые поля только в нее.

Точки расширений в схеме данных имеют двойную нагрузку. Во-первых, если описание схемы данных делается на специализированном языке (Data Definition Language или Interface Definition Language), то точки расширения синтаксически ограничивают места, в которых разработчик может описывать расширение типа объекта. Во-вторых, и это более важно, точки расширения дают возможность вставлять в двоичное представление специальные маркеры, которые будут указывать, что очередная последовательность байт в данном месте является расширением объекта.

В двоичном виде расширение объекта в не-TLV представлении оформляется в виде LV-блока: сначала следует Length, за ним сериализованное представление расширения (Value). Когда старая версия приложения доходит при парсинге данных до расширения, она считывает Length, а потом просто пропускает Length-байт, т.е. происходит то же самое, что и при парсинге TLV. Новая же версия приложения считывает Length, а потом обычным образом продолжает парсить содержимое расширения, т.к. она знает, что именно находится в расширении.

При этом в случае не-TLV представления возникает вопрос о том, а сколько раз может расширятся тип объекта в этих точках расширения? У себя в ObjESSty я заложил возможность бесконечного расширения объектов. Для этого всего лишь нужно, чтобы каждое новое расширение описывалось как секция в предыдущем расширении. На уровне DDL это выглядит так:


{type MyType {extensible} || Тег {extensible} разрешает расширять тип со временем.
  ...
  {extension || Начинается первое расширение объекта.
    {attr ...}
    ...
    {extension  || Начинается второе расширение объекта.
      {attr ...}
      ...
      {extension || Начинается третье расширение объекта.
        ...
      } || Третье расширение завершено.
    } || Второе расширение завершено.
  } || Первое расширение завершено.
}

Изъятие старых полей

Как и в случае с добавлением новых полей, изъятие старых полей в TLV представлении происходит элементарно. Из описания типа объекта эти поля изымаются, в сериализации они не участвуют, соответственно, старые Tag-и в двоичном потоке отсутствуют и не считываются при десериализации.

Единственной сложностью здесь может стать явная поддержка системой сериализации/десериализации такого понятия как опциональные/обязательные поля. Если это так, то при десериализации обычно проверяется наличие прочитанных из двоичного потока обязательных полей. В случае их отсутствия порождается ошибка парсинга. Так вот, если удаленные поля в свое время были помечены как обязательные, что старая версия приложения не сможет работать с новым двоичным представлением объекта. Впрочем, тут речь нужно вести не столько об ограничениях системы сериализации, сколько о здравом смысле разработчиков, которые сначала отмечают поля как обязательные, а потом удаляют их из типа объекта :)

В случае не-TLV представления удаление полей намного серьезнее. Если двоичная сериализация не предусматривает никаких специальных маркеров наличия/отсутствия поля, то удаление поля из типа объекта просто невозможно. Например, такая ситуация в ObjESSty: если поле было добавлено, то изъять его нельзя.

Но, если в не-TLV представлении есть поддержка опциональности полей, то удаление поля в этом случае имитируется через объявление его опциональным.

Поддержка опциональности полей в не-TLV представлении может быть реализована разными способами. Самый простой из них -- сохранение перед полем булевого признака наличия поля. При парсинге сначала считывается это булевое поле. Если оно содержит false, значит значения поля нет и парсинг переходит к следующему полю. Если поле содержит true, значит значение поля есть и оно парсится как обычно.

Более сложный, но и более эффективный способ (в плане объема сериализуемых данных) состоит в объединении всех булевых полей для опциональных полей объекта в одну битовую маску. Эта битовая маска сохраняется в самом начале двоичного образа объекта. При десериализации объекта сначала считывается битовая маска, потом выполняется чтение полей объекта. Перед чтением очередного опционального поля проверяется, выставлен ли соответствующий бит в битовой маске. Если выставлен, то поле присутствует и должно быть десериализовано.

Если в двоичном образе объекта присутствует битовая маска его опциональных полей, то в этой же маске можно указывать наличие расширений объекта. Если расширение есть, то в битовой маске выставляется соответствующий бит, а перед расширением записывается Length. При десериализации проверяется этот бит и считывается Length (само расширение может быть считано или пропущено в зависимости от версии схемы данных приложения). Если же расширения нет, то бит не выставляется и нулевой Length не записывается. Что приводит к некоторой экономии на размере двоичного представления объекта.

Изменение типов существующих полей

Этот случай эволюции схемы данных характерен для БД. Он не часто встречался мне в отношении файлов данных. И совсем не встречался по отношению к коммуникационным протоколам. Действительно, в коммуникационном протоколе намного проще удалить старые атрибуты и заменить их новыми, чем обучить десериализатор автоматически заменять, скажем, int-овые поля на float-поля.

Если говорить про БД, то в случае с одноразовой оффлайновой модификацией содержимого БД, нет особой разницы между TLV и не-TLV представлением объекта. Если мы меняем тип поля с int на float, то не важно, оформлено ли это поле посредством TLV или же за ним зафиксирована 10-я позиция в двоичном представлении объекта.

А вот для случая модификацией схемы данных на лету обязательно нужно знать, какой версии схемы данных принадлежит тип объект. В случае не-TLV объекта выход один: рядом с объектом нужно хранить идентификатор версии. Или, если не жалко места, вообще само описание структуры объекта.

В случае TLV-представления можно поступать либо точно так же, как для не-TLV представления -- т.е. хранить версию схемы данных. Либо же можно специальным образом формировать значение Tag: оно должно хранить не только уникальный идентификатор поля, но так же и тип поля. Поскольку атомарных типов полей может быть не так уж и много (несколько типов int-ов, float-ов, символов + какие-то элементарные типы самой БД, вроде ссылок между объектами), то для такой идентификации в Tag могут быть задействованы 3-4 старших бита.

Еще немного специфики БД

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

Переименование типов объектов и полей внутри типа

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

Очень важны такие изменения для СУБД. Нормальная СУБД должна предоставлять возможность инспекции своего содержимого. Пользователи РСУБД привыкли к тому, что посредством продвинутых GUI-инструментов можно просматривать структуру и содержимое таблиц БД. В NoSQL СУБД с этим не так хорошо. Но если СУБД обладает инструментами для инспекции содержимого объекта, то после переименования, например, типа Graphics в VectorImage, тип соответствующих объектов в БД должен отображаться именно как VectorImage. Аналогично, если поле меняет свое имя с VectorImage::cx на VectorImage::width, то в инспекторе содержимого объекта это поле должно отображаться как VectorImage::width.

Поддержка таких изменений схемы данных практически никак не сказывается на двоичном представлении самих объектов в БД (если, конечно, рядом с объектом не хранится полное описание его схемы). Зато ставит вопросы перед разработчиком СУБД. Не всегда разрешимые ;)

Нужно отметить, что я согрешил против истины, сказав, что переименования типов практически не влияют на коммуникационные протоколы. Например, в ObjESSty могут влиять. Там это связано с поддержкой сериализации объектов по указателю на базовый тип. Для того, чтобы сохранить в бинарном представлении настоящий тип объекта, ObjESSty 1.* использует текстовое имя объекта. Соответственно, такой тип, будучи однажды зафиксированным в схеме данных, уже не может быть переименован. В ObjESSty 2.* для устранения этой проблемы была добавлена возможность идентификации типов через цифровой идентификатор (OID).

Версионность объектов

Еще одна особенность, которая присуща NoSQL базам данных, в частности, некоторым объектным СУБД. Это хранение в БД сразу нескольких версий одного и того же объекта. Т.е., когда из БД извлекается объект X, затем модифицируется и сохраняется в БД, то в БД оказывается не одна (самая последняя), а две версии объекта: последняя и оригинальная. При этом СУБД предоставляет разработчику возможность, при необходимости, извлекать из БД любую из версий объекта.

Если такая СУБД поддерживает модификацию содержимого объекта "на лету", то при модификациях схемы данных в БД может оказаться несколько версий объекта, каждая их которых соответствует своей версии схемы данных. Соответственно, при извлечении конкретной версии объекта СУБД должна суметь "на лету" преобразовать его к той версии схемы данных, с которой работает приложение.

Эта особенность, опять же, касается не столько двоичного представления объектов в БД, сколько указывает на круг проблем, с которыми приходится сталкиваться разработчикам СУБД с поддержкой версионности объектов.

Еще немного о разном

В завершение рассказа затрону еще несколько вещей, которые всплыли по ходу разговора, но на которых не хотелось останавливаться, дабы не уводить повествование в сторону от обсуждавшихся тогда вещей.

Опциональные поля и значения по умолчанию для новых полей

Опциональные поля -- это хорошая штука. Фактически, наличие опциональных полей делает ненужной поддержку такого случая эволюции схемы данных, как удаление старых полей. Особенно просто поддерживать опциональные поля в TLV-представлении, где сам факт отсутствия конкретного Tag-а является признаком отсутствия значения поля. В не-TLV представлении опциональность связана с некоторыми накладными расходами, которые относительно невелики при использовании битовых масок.

Но главная проблема с опциональностью полей -- это злоупотребление признаком обязательности наличия значения поля. Действительно, если вы будете в своем типе практически все поля помечать как обязательные, то вы не сможете задействовать достоинства опциональности полей. В первую очередь вы лишитесь возможности "изъять" ставшее ненужным поле. Но так же вы лишитесь возможности сэкономить на двоичном представлении объекта. Представьте, что у вас в объекте есть несколько полей, которые в 90% случаев содержат одинаковое значение, например, 42. Зачем их сериализовать? Не лучше ли объявить их опциональными и сконфиругировать их так, чтобы поля сериализовались только, если их значение отличается от 42?

Если при десериализации объекта обнаруживается, что каких-то полей нет, то в программе эти поля все равно должны получить какие-то значения по умолчанию. Это касается как опциональных полей, так и полей, которые были добавлены как расширения объекта. Последний случай очень важен, если два приложения общаются посредством коммуникационного протокола и приложение с новой версией протокола получает пакет от приложения со старой версией. После парсинга пакета и получения в приложении соответствующего объекта, новые поля объекта, про которые не знает старое приложение, должны получить корректные значения по умолчанию. Для этого нужно:

  • чтобы используемая вами система сериализации/десериализации данных поддерживала возможность задания значений по умолчанию на новых полей объекта;
  • чтобы вы обязательно указывали эти значения для новых полей, которые расширяют тип объекта.

Эти два правила просты и очевидны, но как обычно, простые и очевидные правила необычайно часто забываются ;)

Новые поля, которые нельзя игнорировать.

Как я говорил выше, нет большого смысла в том, чтобы расширять старый тип объекта новыми полями, без знания которых использовать объект нельзя. В этом случае лучше ввести новый тип объекта.

Однако, в теории могут быть случаи, когда эти новые поля могут присутствовать лишь изредка. Например, приложение A передает приложению B запрос StartPayment. В большинстве случаев это обычный запрос, поля которого известны приложению B. Но, иногда, в него добавляется одно или несколько специфических полей, например, Geolocation и MSISDN (расположение и мобильный телефон плательщика), если платеж инициируется с мобильного устройства. Эти параметры должны использоваться как дополнительная информация для проверки безопасности платежа. И если приложение B не умеет обрабатывать эти поля, то оно не должно обрабатывать StartPayment. (Пример не самый удачный, но это лучшее, что пришло мне в голову)

Никогда не видел таких специфических ситуаций в двоичных протоколах. Видел в XML-ном, где дополнительные поля описывались специальными тегами с атрибутом mustUnderstand. Если принимающая сторона находила неизвестный для себя XML тег выставленным в true атрибутом mustUnderstand, то она понимала, что полученное сообщение она обрабатывать не имеет права.

В принципе, в TLV-представлении что-нибудь похожее можно получить за счет резервирования бита в Tag. Если бит установлен, значит принимающая сторона должна знать про Tag и уметь с ним работать. Если не установлен, значит Tag может быть проигнорирован при парсинге.

Однако, я лично считаю, что такие вещи должны решаться не на уровне двоичного представления, а на уровне прикладной схемы данных. Если в сообщении могут передаваться дополнительные поля с некоторой мета-информацией (а признак mustUnderstand является такой метаинформацией), то лучше выделить специальные типы для описания таких полей. Например, в ObjESSty это можно было бы сделать так:

{type PaymentTrait || Дополнительная информация для платежа.
  {extensible}
  {subclassing_by_extension}
  {attr mustUnderstand {of oess_1::char_t}} || В ObjESSty нет Bool.
}

{type StartPayment || Начало платежа.
  {extensible}
  ... || Какие-то общеизвестные параметры платежа.
  || Список дополнительных параметров.
  {attr paymentTraits
    {stl-list}               || это действительно список...
    {of {extension_of} PaymentPrait}  || наследников PaymentTrait.
  }
}
...
{type GeolocationTrait || Тип для параметра Geolocation.
  {subclassing_by_extension {extension_of PaymentTrait}}
  ...
}
{type MsisdnTrait || Тип для параметра MSISDN.
  {subclassing_by_extension {extension_of PaymentTrait}}
  ...
}

Т.е. вместо того, чтобы расширять объект StartPayment новыми полями Geolocation и MSISDN, в нем просто создается поле-список paymentTraits, которое может содержать любых наследников PaymentTrait.

Сохранение неизвестных расширений

В тривиальных случаях, когда приложение A шлет сообщение приложению B, задача десериализации данных выглядит очень просто: читается все, что пришло; то, что не известно, выбрасывается. Никаких неизвестных приложению B полей сохранять не нужно.

Ситуация усложняется, если между A и B появляется промежуточное звено C, которое десериализует сообщение, как-то его обрабатывает, потом вновь сериализует и отсылает приложению B. Если приложение C не знает про какие-то новые поля, сериализованные приложением A, то при простейшей логики парсинга данных, приложение C не сможет передать B исходное сообщение от A, т.к. значение этих полей будет потеряно при десериализации. Иногда это нежелательно. Например, если C является фильтром, статистическим анализатором или балансировщиком, о котором не должны знать ни A, ни B.

В таких случаях может быть удобным, если система десериализации данных может отдельно сохранить все расширения объекта, которые не описаны в схеме данных. А система сериализации затем может объединить значение объекта и эти неизвестные расширения в один двоичный блок. В этом случае реализация "прозрачных" фильтров/анализаторов/балансировщиков, вроде описанного выше приложения C, упрощается.

Но это уже камень в огород систем сериализации/десериализации данных. Прямого отношения к формату двоичных данных он не имеет.

TLV-представление и объекты в программе

Как я уже говорил, TLV-представление очень удобно для расширения объекта новыми полями. Если говорить о PDU коммуникационных протоколов, то там PDU может быть со временем расширен очень большим количеством новых полей. Счет может идти в прямом смысле на десятки, а то и на сотни. Поэтому говоря о TLV-представлении имеет смысл задумываться о том, во что TLV-представление будет трансформироваться в программе.

Не думаю, что во всех случаях будет удобно каждое новое поле PDU представлять в виде поля C++/Java/С#/Ruby/Python/etc объекта. Даже если это поле будет указателем/ссылкой, в большинстве случаев имеющим null-значение. Подозреваю, что C++ класс с сотней полей-указателей с 0-значением, это вообще очень плохое решение. Череватое как расходом памяти, так и ошибками времени исполнения из-за обращений по нулевому указателю.

Поэтому при работе с PDU с большим количеством опциональных полей (что бывает в TLV-представлении) может быть лучше, если в программе все эти опциональные поля будут представлены ассоциативным массивом (std::map или std::unordered_map в C++), ключом в котором будет Tag опционального поля.

Независимое расширение пространства идентификаторов

Данный пункт касается не столько технологических аспектов идентификации сущностей в двоичном представлении, сколько заостряет внимание на одном организационном аспекте. При использовании TLV-представления необходимо выделить уровни, на которых Tag уникальны и не могут принадлежать разным сущностям. Например, если речь идет о коммуникационных протоколах, то таким уровнем будет уровень PDU: вряд ли кто-то захочет, чтобы разные PDU могли иметь одинаковый Tag. (Хотя я предпочитаю, чтобы все Tag-и были уникальными вне зависимости от уровня видимости. Благодаря этому по частичным фрагментам двоичных дампов удавалось разобраться в сложных ситуациях.)

В случае, если над протоколом работает несколько коллективов, нужно позаботится, чтобы при добавлении новых PDU/объектов/полей не произошло пересечение Tag-ов. Но, повторюсь, это организационный момент и каждый для себя выбирает удобную ему политику (единый координатор, распределенные заранее диапазоны для Tag и пр.).

Отдельную актуальность этот вопрос приобретает, если в двоичном представлении нужно кодировать тип сериализованного объекта. Выше я показал пример с GeolocationTrait и MsisdnTrait. Фокус в том, что это наследники класса PaymentTrait, но при их сериализации нужно указать какой именно наследник был сериализован. Сделать это можно либо посредством сохранения текстового длинного имени (скажем, в привычной для Java-разработчиков нотации com.intervale.acquiring.protocol.GeolocationTrait), либо в виде какого-то длинного цифрового идентификатора, вроде OID.

Заключение

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

Комментариев нет: