Проектирование сетевых протоколов
Поискал по хабру статьи о проектировании протоколов и к своему удивлению ничего не нашел. Пожалуй, стоит тогда поделиться своими соображениями по сабжу. Сразу скажу, что деление на типы сугубо мое и может не совпадать с тем, что вы найдете в справочниках. Также заранее условимся, что используется язык С/C++.
Введение
Вопрос-ответ
Эти протоколы основаны на общении небольшими порциями данных. Протокол общения обычно сильно типизирован. В качестве примера можно привести всем известные AT-команды для модемов.
Данный тип протоколов является наиболее простым для обработки (требуется элементарный разбор строки с вычленением данных). Но общаться таким протоколом на более-менее серьезных задачах не очень легко. Таки протоколы хорошо подходят для пересылки небольших порций данных скалярных типов (строки, числа).
Тем не менее проектировать подобные протоколы тоже необходимо.
Пример
Нам необходимо передавать с клиента на сервер по 10 чисел, и получать в ответ строку либо число (варьируется от входных данных). Для этого нам необходимо: стадия «рукопожатия», стадия пересылки данных, стадия получения ответа.
«Рукопажатие»: вполне достаточно пересылки слова «HELLO» туда и обратно (если мы знаем что есть вероятность вместо сервера попасть на другого клиента, то можно разделить клиентское и серверное приветствие, например «HELLOCL» (от клиента) и «HELLOSRV» (от сервера)). Обработка элементарная и сводится к строковому сравнению.
Пересылка данных: возьмем команду «SEND x1 x2 x3 x4 x5 x6 x7 x8 x9 x10» как команду посылки от клиента и «OK SEND» как ответ сервера. Обработка опять же элементарная и сводится к строковому сравнению и вызову sscanf().
Получение ответа: условимся, что сервер посылает «ANSWER STR строка» (если ответ — строка) или «ANSWER NUM число» (если ответ — число). Клиент отвечает командой «OK ANSWER».
Казалось бы все просто и понятно. НО. Ведь таким образом мы не сможем понять к какому набору чисел относится присланный ответ. Решить эту проблему просто. При пересылке данных будем использовать команды: «SEND id NUMS x1 x2 x3 x4 x5 x6 x7 x8 x9 x10» и «OK SEND id», где id — это уникальный идентификатор этого набора чисел. При ответе соответственно команды будут следующими: «ANSWER id STR строка», «ANSWER id NUM число», «OK ANSWER id». Такой протокол уже будет исчерпывающим в большинстве случаев.
Структуры
Данный тип протокола является наиболее распространенным. Его основой является жесткая типизация порции отправляемых данных. То есть мы заранее уславливаемся, что во всех пакетах по такому-то смещению и такой-то длины будут лежать такие-то данные (смещение и длина некоторых полей могут также задаваться в структуре, но в основном используются изначально заданные смещения). Такими протоколами являются практически все низкоуровневые протоколы.
Несомненным плюсом таких протоколов (особенно при условии жестко заданных смещений и длин всех полей) является крайняя простота обработки. Нам необходимо всего лишь проверить размер и выполнить команду memcpy() в экземпляр структуры, соответствующей нашему пакету.
При проектировании подобных протоколов необходимо помнить об особенностях хранения структур в C. Я имею в виду то, что называется packing. Дело в том, что любой экземпляр любой структуры должен быть выравнен в памяти с некоторой кратностью (кратность задается для всей программы одна). По умолчанию используется кратность 4 (менять не советую, так как это значение сильно влияет например на неймспейс std). Это означает, что размер любой структуры всегда будет кратен 4 (если размер структуры был 14, то в конец допишутся 2 ничего не значащих байта и размер станет равен 16). Следовательно нам необходимо позаботиться об одинаковом значении этого параметра на сервере и клиенте.
Так же основной ошибкой при проектировании подобных протоколов является невнимательность к хранению многобайтных типов в памяти. Необходимо помнить что x86 хранит их в виде little-endian (от младшего к старшему), а по стандартам сетевых протоколов и например в компьютерах SPARC необходимо хранить их в виде big-endian (от старшего к младшему). Таким образом нам необходимо знать в каком порядке к нам придут многобайтные типы и при необходимости их переворачивать. Причем, если нам необходима высокая скорость, у нас большой поток обмениваемыми данными и мы не можем уйти от вращений (критерии например акутальны при разработке кросс-архитектурной системы распределенных вычислений), то необходимо уделить функции поворота лишних полчаса, но написать ее максимально быстрой. В таких случаях стандартные htonl(), ntohl() могут не успеть.
Теги
К теговым протоколам я отношу ныне модные XML-подобные протоколы. Такие протоколы хоть и являются крайне избыточными, но тем не менее легко обрабатываются и являются абсолютно гибкими. Основными их проблемами являются избыточность и не очень высокая скорость обработки.
Основной ошибкой проектирования подобных протоколов является желание впихнуть все и сразу. Необходимо же как можно четче сформулировать требования к протоколу и вычленить то подмножество функционала, которое действительно необходимо. Такой подход к проектированию возможно и является не слишком расширяемым, но зато позволит нам сэкономить на времени обработки. Тем более, при грамотном проектировании модуля разборщика мы можем свести проблему расширяемости к минимуму (добавить пару функций и проверок в общий код).
Лично мне (в силу специализации на требовательных к скорости и жестко структурированных сетевых приложениях) подобный подход кажется излишним и расточительным.
Теги+структуры
Пожалуй самый интересный тип протоколов. Позволяет объединить высокую скорость разбора и гибкость.
Пакеты данного протокола разделяются по типам. Также можно спроектировать дерево подчинения типов (например пакет А может входить только в пакет В, а отдельно идти не может). Основой таких пакетов является жестко заданная заголовочная структурная часть (для каждого типа своя)+нежесткая часть данных (такой же подход бывает используется и в структурных данных при переменной длине последнего параметра в структуре).
Разбор таких протоколов хоть и сложнее, чем разбор структурных протоколов, но легче и быстрее чем разбор чисто теговых протоколов.
Обычно тип пакета пишется первым полем в заголовочной части и нам достаточно считать его и вызвать необходимую функцию, которая скопирует заголовок в структуру, а данные в кусок памяти (обычно достаточно char*, но в некоторых случаях удобнее копировать сразу в массив неких структур).
Основной ошибкой при проектировании является желание сделать «как в теговом протоколе» и создать кучу разных типов с мелкими заголовками. Это приводит к потере высокой скорости разбора и сводит на нет все преимущества этого типа. Таким образом необходимо балансировать между неповоротливостью и низкой скоростью. Как показала практика, в большинстве случаев является идеальным разбитие на теги такое же как разбитие на классы, если бы этот протокол был бы обычной структурой данных.
P.S. Данный пост является скорее обзорным, нежели направленным на конкретные реализации. Если у кого есть желание почитать про какие-то конкретные реализации (как общеизвестных, так и проектирование каких-либо других), то пишите в комментах, постараюсь написать.