вторник, 23 марта 2010 г.

[prog] Забавный баг при реализации TLV unpacker-а

Есть такой удобный формат для организации бинарных данных – TLV (Tag-Length-Value или Type-Length-Value). Его смысл в том, что каждая порция данных предваряется двумя обязательными полями – Tag и Length. Поле Tag содержит целочисленный идентификатор, который указывает, какой смысл несут данные. Поле Length содержит длину данных в байтах. Ну а дальше идут сами данные.

Удобство этого формата в том, что парсер может ничего не знать про содержимое некоторых блоков данных. Парсер считывает Tag и Length. Если значение Tag ему известно, то он обрабатывает данные. Если не известно, то следующие Length байт просто пропускаются. Благодаря этой схеме протоколы на основе TLV очень легко расширяются, сохраняя при этом совместимость с предыдущими версиями. Например, в какой-то PDU (Protocol Data Unit, элемент прикладного протокола, например, сообщение протокола) можно добавить новое поле, и старые клиенты будут его просто игнорировать.

Так же TLV удобен при передаче по потоковым коммуникационным каналам, например, по потоковым TCP/IP сокетам. Ведь в TCP, например, если отправитель отсылает 32K одной операцией write, то получатель может вычитать их не за одно обращение к read, а за два – первый раз, скажем, придет 28K, затем оставшиеся 4K. Вот с TLV-протоколами такая передача данных очень удобна – получатель всегда может определить, получил ли он весь пакет или нужно еще подождать. (С другой стороны, при отправке нужно сначала длину подсчитывать и в начало блока данных записывать Tag+Length, что может негативно сказываться на производительности, но это уже другой вопрос).

TLV-представление имеет очень широкое распространение. Так, ASN.1 BER – это TLV. Да и в Google Protobuf, AFAIK, так же используется принцип TLV.

Ну а теперь к сути повествования. Последние дни делал библиотеку для поддержки простого протокола для взаимодействия с клиентом. Упаковка сообщений выполняется в TLV. При распаковке обнаруживается интересный эффект – упаковывался объект сообщения A, а распаковывается объект сообщения B. При этом ошибок распаковки не возникает – тишь да гладь.

Ларчик, как водится, открывался просто – в парсере для нескольких Tag-ов по ошибке была зарегистрирована одна и та же фабрика (т.е. вместо фабрики объектов A регистрировалась фабрика объектов B). Ну это ладно, до этого я быстро додумался.

Озадачивало меня другое – почему при чтении объектом B полей объекта A не происходит никаких ошибок? Сообщения-то разные, и имеют только одно общее поле. Все остальные поля совершенно разные. Но объект B как-то умудряется их проглатывать, даже не поперхнувшись…

А дело как раз в расширяемости основанных на TLV протоколов :) Объект B, видя во входном потоке чужие поля (поля объекта A) просто игнорировал их, считая, что это новые опциональные поля о которых он не знает и знать не хочет.

Вот так вот – за что боролись, на то и напоролись :)

PS. А unit-тесты рулят!

5 комментариев:

Alexander P. комментирует...

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

eao197 комментирует...

Если бы я писал эту библиотеку на C++, я бы попробовал сделать так:

- в класс фабрики для конкретного PDU включил бы статический атрибут -- идентификатор тега;

- в парсере PDU фабрики бы регистрировал через какой-нибудь шаблонный метод/функцию. Что-то типа:

template<PDU>
void register() { registry.add(PDU::tag_id, new PDU()); }
...
parser_t::parser_t() {
register<PduA>();
register<PduB>();
register<PduC>();... }

Получилось бы, что идентификатор тега можно было бы указать только в одном месте.

Но, т.к. библиотеку нужно было написать на Java, где такие фокусы не проходят, я был вынужден пойти простым путем:

static PduInfo[] topLevelPdu = {
new PduInfo(TAG_A, PduA.class),
new PduInfo(TAG_B, PduB.class),... }

И, поскольку здесь много копипаста, то я неизбежно ошибся и поменял имена констант TAG_A, но не поменял имя класса PduB.

В принципе, в Java так же можно было в классах PDU создавать public static final поля TAG_ID, а потом по Class-объекту через Reflection доставать его значение. Но что-то мне религия запретила так делать (и сложнее код получает, и нет compile-time контроля за наличием этого TAG_ID в классе фабрики).

Alexander P. комментирует...

Расписал как сделал сам: http://aptakhin.blogspot.com/2010/03/blog-post.html . Там, может, не всё точно, но идея вроде понятна :).

eao197 комментирует...

2Alexander P.: ага, идея понятна. Но у вас, насколько я могу судить, основная цель -- это именно обработка пакета. У меня же задача была, скажем так, предшествующая -- из потока байт сформировать полностью десериализованный объект-сообщение. Поскольку десериализатор универсальный, которому все равно, какой именно объект десериализовать, то нужно было как-то связать идентификатор и фабрику объектов.

А уж дальше десериализованные объекты можно обрабатывать как, как вы это у себя сделали.

На C++ных шаблонах я обработку по типу сообщений стараюсь делать вот так: http://eao197.narod.ru/desc/cpp_tricks/try_call_handler.htm

Alexander P. комментирует...

Ну да, у меня шаг сделать "десериализованный объект-сообщение" вообще был пропущен, в силу неширокого-несложного протокола :).