Delphi: UDP эхо-сервер и клиент на блокирующих сокетах

В последнее время я получил уже несколько однотипных жалоб со стороны знакомых разработчиков, заключающиеся в том, что в инете нормальных примеров по сетям, реализованных на Sockets API, почти не найти, а то что есть, то это в основном простенькие, сделанные на тяп-ляп при помощи компонентов, UDP чаты и не более того (конечно мой чатик к этому не относится :] )

Поэтому я уделил время новой статейке из серии сетевого программирования, в которой решил описать структуру пусть даже и простого эхо-сервера, но зато уже с ориентацией на учёт и обработку подключений. Поэтому думаю что многие заметят в этом полезное деяние)

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

Логически предшествующие статьи:
Введение в протокол UDP и пример с исходниками простого чата.

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

И так, цель данного урока или статьи(как кому удобнее):

  1. создать приложение-сервер на протоколе UDP. Организовать структуру для обработки клиентов и механизм моментального зеркального ответа принятых сообщений;
  2. создать приложение-клиент на протоколе UDP. С возможностью цикличных посылок пакетов установленной длины(в байтах) и с установленной периодичностью. Возможность считать кол-во пакетов(для статистики потери) и замерять отклик сервера;
  3. Работа сокетов будет реализована в блокирующем режиме с использованием функции select().

Вот этого и будем придерживаться.

Начнём с сервера

Весь код, который касается сетевого взаимодействия, можно разделить на несколько частей:
  1. инициация использования процессом библиотеки WinSock;
  2. создание серверного сокета;
  3. формирование сетевого адреса с учётом установленного порта и привязка сокета к этому адресу;
  4. обработка пакетов от клиентов;
  5. закрытие серверного сокета.

Далее приведён листинг основного модуля сервера:

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

  • uSockUtils. В данном модуле содержатся некоторые однообразные функции для сокетов(в основном создание), а в данной программе из него используется только suWSAInit, которая проводит попытку инициации библиотеки сокетов для данного приложения;
  • uArrayUtils. Имеет ряд типов и утилит для работы с массивами, в данном случае в нём заключон тип TArrOfByte = array of byte;
  • uSimpleUtils. Содержит всякие мини удобства, чаще всего использую из неё функции its и sti, что есть аббревиатуры функций inttostr и strtoint.

Вот вроде бы и всё, остальные интуитивно понятны или их использование не влияет на суть демки.
Ах да, чуть не забыл показать функцию suWSAInit:

Теперь по порядку.

При создании формы инициируется библа сокетов, выставляются флаги, создаются объекты и запрашивается частота генератора импульсов, вобщем как обычно.
Далее идёт активация формы, фиксируем это флагом, чтобы второй такой активации не произошло уже никогда. Тут выставляем всякие статусы и всё что связано с интерфейсом.
Наконец, запускаем сервак по кнопке(или как у вас будет) и выполняется проца StartServer, в которой создаётся серверный сокет, формируется серверный адрес, затем привязывается сокетов к адресу с помощью bind. Это всё вдобавок вроде боле-менее прокомментировано. Далее создаётся высокоточный системный таймер и выставляется статус рабочего сервера.
Выполняется тик таймера. Первым делом начинаем транзакцию(чтоб очередь не нарастала, а то сервак при многочисленных подключениях рано или поздно может крякнуть). Затем обрабатываем серверный сокет, который принимает сообщения и фиксирует новых клиентов. А вторым делом обрабатываем всех клиентов.
Далее можно поставить обработку игрового мира, если такой имеется и т.п.

Стоит упомянуть(а то в реализации этого нет), что если по сети посылается переменная или record, то сначало эти данные можно конвертировать в массив байт, например функцией move(), потом переслать по сети, а на стороне приёмника проделать обратную операцию.
Например (пример передачи переменных):

В общем случае можно передать любую структуру данных, если выполняются следующие условия:

  1. имеется указатель на данную структуру;
  2. известна длина данных;
  3. данные распологаются в памяти последовательно.

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

Теперь займёмся клиентом

Весь сетевой код клиента состоит из следующих частей:

  1. инициация использования процессом библиотеки WinSock;
  2. создание сокета;
  3. обработка пакетов от клиентов;
  4. закрытие сокета.

А вот и сам листинг основного модуля клиента:

Здесь уже, в принципе, знакомый код, ничего иного не наблюдается. На форме 2 таймера и 2-е кнопки. По таймеру PingTimer раз в минуту посылается однобайтовый пакет пинга, байт которого = 0. А по таймеру PackTimer посылаются пакеты указанной на форме длины и с указанным интервалом времени, первый байт которых = 1. Различное значение первых байт нам позволяет различать пакет пинга, по возвращению которого мы замеряем задержку отклика сервера, а т.е. lag. Так же при отправке любого пакета идёт инкремент переменной fSentPackCount, а при приёме любого пакета inc(fRecvPackCount), таким образом я прикрутил статистику потерь пакетов.

Вот как всё это хозяйство выглядит в действии:

Не стоит обращать внимание, что во время начала теста до удалённого сервака был большой пинг — это просто торрент по полной качал)

По результатам некоторых тестов с сервером, лаг с которым был равен в среднем 0.09 секунды, были получены следующие данные:

  • размер = 5000 б, интервал = 100 мс, sent = 9639, recv = 9322, потери = 317;
  • размер = 40000 б, интервал = 900 мс, sent = 348, recv = 268, потери = 80;
  • размер = 10 б, интервал = 50 мс, sent = 3174, recv = 3017, потери = 157.

Стоит отметить, что потери зависят от размера пакета и частоты посылки. Т.е. пакет размером в 40000 байт и интервалом посылки в 50 мс вообще почти не доходит обратно. Обратим внимание на тест с пакетом в 40Кб (рекомендуемый максимум): стоит учесть, что раз в секунду идёт пакеты пинга размеров всего лишь в 1 байт, которые в 99% вернутся, поэтому почти половина из посланных пакетов — это пинг пакеты. А т.е. среди жирных пакетов потерялось примерно 60%!

И так, подводя итоги, можно сказать, что получился вполне юзабильный UDP клиент-сервер на блокирующих сокетах.

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