Содержание статьи
- Создаем базовую структуру протокола
- Пишем клиент и сервер
- Учимся записывать и считывать данные
- Установка значения
- Проверка на работоспособность
- Вводим типы пакетов
- Создаем структуру пакетов для их сериализации и десериализации
- Создаем сериализатор
- Создаем десериализатор
- Первое рукопожатие
- Имплементируем простую защиту протокола
- Заключение
Итак, протокол передачи данных — это соглашение между приложениями о том, как должны выглядеть передаваемые данные. Например, сервер и клиент могут использовать WebSocket в связке с JSON. Вот так приложение на Android могло бы запросить погоду с сервера:
{
"request": "getWeather",
"city": "cityname"
}
И сервер мог бы ответить:
{
"success": true,
"weatherHumanReadable": "Warm",
"degrees": 18
}
Пропарсив ответ по известной модели, приложение предоставит информацию пользователю. Выполнить парсинг такого пакета можно, только располагая информацией о его строении. Если ее нет, протокол придется реверсить.
Создаем базовую структуру протокола
Этот протокол будет базовым для простоты. Но мы будем вести его разработку с расчетом на то, что впоследствии его расширим и усложним.
Первое, что необходимо ввести, — это наш собственный заголовок, чтобы приложения могли отличать пакеты нашего протокола. У нас это будет набор байтов 0xAF
, 0xAA
, 0xAF
. Именно они и будут стоять в начале каждого сообщения.
INFO
Почти каждый бинарный протокол имеет свое «магическое число» (также «заголовок» и «сигнатура») — набор байтов в начале пакета. Оно используется для идентификации пакетов своего протокола. Остальные пакеты будут игнорироваться.
Каждый пакет будет иметь тип и подтип и будет размером в байт. Так мы сможем создать 65 025 (255 * 255) разных типов пакетов. Пакет будет содержать в себе поля, каждое со своим уникальным номером, тоже размером в один байт. Это предоставит возможность иметь 255 полей в одном пакете. Чтобы удостовериться в том, что пакет дошел до приложения полностью (и для удобства парсинга), добавим байты, которые будут сигнализировать о конце пакета.
Завершенная структура пакета:
XPROTOCOL PACKET STRUCTURE
(offset: 0) HEADER (3 bytes) [ 0xAF, 0xAA, 0xAF ]
(offset: 3) PACKET ID
(offset: 3) PACKET TYPE (1 byte)
(offset: 4) PACKET SUBTYPE (1 byte)
(offset: 5) FIELDS (FIELD[])
(offset: END) PACKET ENDING (2 bytes) [ 0xFF, 0x00 ]
FIELD STRUCTURE
(offset: 0) FIELD ID (1 byte)
(offset: 1) FIELD SIZE (1 byte)
(offset: 2) FIELD CONTENTS
Назовем наш протокол, как ты мог заметить, XProtocol. На третьем сдвиге начинается информация о типе пакета. На пятом начинается массив из полей. Завершающим звеном будут байты 0xFF
и 0x00
, закрывающие пакет.
Пишем клиент и сервер
Для начала нужно ввести основные свойства, которые будет иметь пакет:
- тип пакета;
- подтип;
- набор полей.
public class XPacket
{
public byte PacketType { get; private set; }
public byte PacketSubtype { get; private set; }
public List<XPacketField> Fields { get; set; } = new List<XPacketField>();
}
Добавим класс для описания поля пакета, в котором будут его данные, ID и размер.
public class XPacketField
{
public byte FieldID { get; set; }
public byte FieldSize { get; set; }
public byte[] Contents { get; set; }
}
Сделаем обычный конструктор приватным и создадим статический метод для получения нового экземпляра объекта.
private XPacket() {}
public static XPacket Create(byte type, byte subtype)
{
return new XPacket
{
PacketType = type,
PacketSubtype = subtype
};
}
Теперь можно задать тип пакета и поля, которые будут внутри него. Создадим функцию для этого. Записывать будем в поток MemoryStream
. Первым делом запишем байты заголовка, типа и подтипа пакета, а потом отсортируем поля по возрастанию FieldID
.
public byte[] ToPacket()
{
var packet = new MemoryStream();
packet.Write(
new byte[] {0xAF, 0xAA, 0xAF, PacketType, PacketSubtype}, 0, 5);
var fields = Fields.OrderBy(field => field.FieldID);
foreach (var field in fields)
{
packet.Write(new[] {field.FieldID, field.FieldSize}, 0, 2);
packet.Write(field.Contents, 0, field.Contents.Length);
}
packet.Write(new byte[] {0xFF, 0x00}, 0, 2);
return packet.ToArray();
}
Теперь запишем все поля. Сначала пойдет ID поля, его размер и данные. И только потом конец пакета — 0xFF
, 0x00
.
Теперь пора научиться парсить пакеты.
INFO
Минимальный размер пакета — 7 байт: HEADER
(3) + TYPE
(1) + SUBTYPE
(1) + PACKET ENDING
(2)
Проверяем размер входного пакета, его заголовок и два последних байта. После валидации пакета получим его тип и подтип.
public static XPacket Parse(byte[] packet)
{
if (packet.Length < 7)
{
return null;
}
if (packet[0] != 0xAF ||
packet[1] != 0xAA ||
packet[2] != 0xAF)
{
return null;
}
var mIndex = packet.Length - 1;
if (packet[mIndex - 1] != 0xFF ||
packet[mIndex] != 0x00)
{
return null;
}
var type = packet[3];
var subtype = packet[4];
var xpacket = Create(type, subtype);
/* <---> */
Пора перейти к парсингу полей. Так как наш пакет заканчивается двумя байтами, мы можем узнать, когда закончились данные для парсинга. Получим ID поля и его размер, добавим к списку. Если пакет будет поврежден и будет существовать поле с ID
, равным нулю, и SIZE
, равным нулю, то необходимости его парсить нет.
/* <---> */
var fields = packet.Skip(5).ToArray();
while (true)
{
if (fields.Length == 2)
{
return xpacket;
}
var id = fields[0];
var size = fields[1];
var contents = size != 0 ?
fields.Skip(2).Take(size).ToArray() : null;
xpacket.Fields.Add(new XPacketField
{
FieldID = id,
FieldSize = size,
Contents = contents
});
fields = fields.Skip(2 + size).ToArray();
}
}
У кода выше есть проблема: если подменить размер одного из полей, парсинг завершится с необработанным исключением или пропарсит пакет неверно. Необходимо обеспечить безопасность пакетов. Но об этом речь пойдет чуть позже.
Учимся записывать и считывать данные
Из-за строения класса XPacket
необходимо хранить бинарные данные для полей. Чтобы установить значение поля, нам потребуется конвертировать имеющиеся данные в массив байтов. Язык C# не предоставляет идеальных способов сделать это, поэтому внутри пакетов будут передаваться только базовые типы: int
, double
, float
и так далее. Так как они имеют фиксированный размер, можно считать его напрямую из памяти.
Продолжение доступно только участникам
Вариант 1. Присоединись к сообществу «Xakep.ru», чтобы читать все материалы на сайте
Членство в сообществе в течение указанного срока откроет тебе доступ ко ВСЕМ материалам «Хакера», позволит скачивать выпуски в PDF, отключит рекламу на сайте и увеличит личную накопительную скидку! Подробнее
Вариант 2. Открой один материал
Заинтересовала статья, но нет возможности стать членом клуба «Xakep.ru»? Тогда этот вариант для тебя! Обрати внимание: этот способ подходит только для статей, опубликованных более двух месяцев назад.
Я уже участник «Xakep.ru»