Простой UDP-чат на Delphi (WinSock)

Эта статья из серии сетевого программирования.

Содержание:

  • Основные понятия протокола UDP;
  • Написание UDP-чата в Delphi используя Windows Sockets API (WinSock).

О протоколе UDP

В целом UDP можно назвать ненадёжным протоколом без постоянного соединения. Он не имеет никаких дополнительных средств по управлению передаваемых пакетов. Т.е. пакеты могут теряться и менять порядок следования. Это не значит что на UDP нельзя реализовать чего-то надёжного в плане соединения, просто все необходимые функции по организации и охране передачи пакетов должен реализовать  сам программист в самом приложении(если это шибко требуется).

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

Теперь о том, что передаёт UDP и как эти данные для передачи в нём представлены.

UserDatagramProtocol – протокол пользовательских дейтаграмм.

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

При отправке данных, указывается IP адрес и порт принимающего сокета получателя. Сама дейтаграмма состоит из заголовка и тела. Заголовок всегда занимает 8 байт, таким образом максимальный размер передаваемых данных равен 65507 байт.

Достоинства протокола UDP – это простота установления связи, возможность посылки данных на несколько адресов через один сокет и нет необходимости устанавливать связь при разрыве канала связи. Так же через UDP можно отправлять широковещательные дейтаграммы.  Для этого указываем широковещательный IP адрес, например для локальной сети: 192.168.255.255 и вперёд. Такую дейтаграмму получат все сокеты которые привязаны к порту, указанному отправителем.

В общем получается, что UDP обычно можно юзать(а порой даже удобнее) там, где незначительная потеря пакетов не критична. А так же очень удобна широковещательная рассылка, когда заранее программа не знает адресатов, но знает порт и область применения, например сетевой чат, написанием которого мы и займёмся.

Теперь о некоторых деталях реализации:

Например, из-за отсутствия постоянного соединения, на сервере одной сетевой игры, я держал экземпляры клиентов как связку IP:Port + присвоенный сервером id, т.е. в менеджере посылки пакетов (на сервере) использовалась эта связка. А для проверки на отключение клиента проверял время задержки запросов пинга от клиента, например 45 секунд, если запросы пинга от клиента не приходили, то значит он отключился, при этом 45 секунд его присутствие ещё сохранялось на сервере.

Более подробную техническую информацию по протоколу UDP не сложно найти в интернете: UDP(wiki).

Простой чат на UDP

Перейдём к практике — напишем простенький сетевой чат на WinSock и UDP используя Delphi.

Далее следует пошаговое рассуждение затрагивающее основные элементы при написании программы.

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

Для работы с сокетами в Delphi нам потребуется лишь одна библиотека, которая входит в стандартный набор: uses WinSock.

Наладим систему оповещения об ошибках сокетов функцией GetErrorString, которая будет возвращать текстовое сообщение об ошибки по коду ошибки с помощью системной функции FormatMessage.

// Функция GetErrorString возвращает сообщение об ошибке, сформированное
// системой на основе значения, которое вернула функция WSAGetLastError.
// Для получения сообщения используется системная функция FormatMessage.
function GetErrorString: string;
var
  Buffer: array[0..2047] of Char;
begin
  FormatMessage(FORMAT_MESSAGE_FROM_SYSTEM, nil, WSAGetLastError, $400,
  @Buffer, SizeOf(Buffer), nil);
  Result := Buffer;
end;

Нам понадобиться принимать и передавать данные. А т.к. функция приёма recvfrom не возвращает управление нити пока не получит данные от сокета, то приём сообщений организуем в отдельной нити и будем передавать ей сокет, читающий сообщения. А отправлять данные будем из основной нити, т.к. функция sendto практически никогда не будет блокировать нить.

И так, получается, что нам надо создать 2 сокета: читающий и посылающий.

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

Создание данного сокета будет находиться в событии создания формы.

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

Таким образом полный код функции создания формы выглядит так:

procedure TChatForm.FormCreate(Sender: TObject);
var
  WSAData : TWSAData;
  Addr : TSockAddr; // Адрес, к которому привязывается сокет для отправки сообщений
  AddrLen : Integer;
begin
  // Инициализация библиотеки сокетов
  if WSAStartup($101, WSAData) <> 0 then
  begin
    MessageDlg('Ошибка при инициализации библиотеки WinSock', mtError, [mbOK], 0);
    Application.Terminate;
  end;
  // Перевод элементов управления в состояние "Сервер не работает"
  OnStopServer;
  // Создание сокета
  fSendSocket := socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
  if fSendSocket = INVALID_SOCKET then
  begin
    MessageDlg('Ошибка при создании отправляющего сокета:'#13#10 + GetErrorString, mtError, [mbOK], 0);
    Exit;
  end;
  // Формирование адреса, к которому будет привязан сокет для отправки сообщений
  FillChar(Addr.sin_zero, SizeOf(Addr.sin_zero), 0);
  Addr.sin_family := AF_INET;
  Addr.sin_addr.S_addr := INADDR_ANY;
  Addr.sin_port := 0;
  // Привязка сокета к адресу
  if bind(fSendSocket, Addr, SizeOf(Addr)) = SOCKET_ERROR then
  begin
    MessageDlg('Ошибка при привязке отправляющего сокета к адресу:'#13#10 + GetErrorString, mtError, [mbOK], 0);
    Exit;
  end;
  // Узнаём, какой адрес система назначила сокету.
  // Это нужно для вывода информации для пользователя
  AddrLen := SizeOf(Addr);
  if getsockname(fSendSocket, Addr, AddrLen) = SOCKET_ERROR then
  begin
    MessageDlg('Ошибка при получении адреса отправляющего сокета:'#13#10 + GetErrorString, mtError, [mbOK], 0);
    Exit;
  end;
  LabelSendPort.Caption := 'Порт отправки: ' + IntToStr(ntohs(Addr.sin_port));
end;

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

Код читающей (можно сказать слушающей) нити:

unit uReceiveThread;
{ В этом модуле реализуется дополнительная нить UDP-чата,
  отвечающая за приём сообщений.
}
 
interface
 
uses
  SysUtils, Classes, WinSock;
 
type
  TReceiveThread = class(TThread)
  private
    fLogMsg : string;
    fSocket : TSocket; // Сокет, получающий сообщения
    procedure DoLogMessage; // Вспомогательный метод для вызова через Synchronize
  protected
    procedure Execute; override;
    procedure LogMessage(const Msg: string); // Вывод сообщения в лог главной формы
  public
    constructor Create(aServerSocket: TSocket);
  end;
 
implementation
 
uses
  uFormMain;
 
{ TReceiveThread }
 
// Сокет, получающий сообщения, создаётся в главной нити, и передаётся сюда
constructor TReceiveThread.Create(aServerSocket: TSocket);
begin
  fSocket := aServerSocket;
  inherited Create(False);
end;
 
procedure TReceiveThread.Execute;
var
  Buffer : array[0..65506] of Byte; // Буфер для получения сообщения. Размер равен максимальному размеру UDP-дейтаграммы.
  RecvAddr : TSockAddr; // Адрес, с которого пришло сообщение
  RecvLen, AddrLen : integer;
  Msg : string;
begin
  // на каждой итерации цикла читается одна дейтаграмма
  repeat
    AddrLen := SizeOf(RecvAddr);
    // Получаем дейтаграмму
    RecvLen := recvfrom(FSocket, Buffer, SizeOf(Buffer), 0, RecvAddr, AddrLen);
    // если получаем ошибку при вызове recvfrom - завершаем работу нити.
    if RecvLen < 0 then
    begin
      LogMessage('Ошибка при получении сообщения: ' + GetErrorString);
      // Перевод элементов управления главной формы в состояние "Сервер не работает"
      Synchronize(ChatForm.OnStopServer);
      Break;
    end;
    SetLength(Msg, RecvLen); // Устанавливаем нужный размер строки
    // и копируем в неё дейтаграмму из буфера
    if RecvLen > 0 then
      move(Buffer, Msg[1], RecvLen);
    LogMessage('Сообщение с адреса ' + inet_ntoa(RecvAddr.sin_addr) + ':' +
    IntToStr(ntohs(RecvAddr.sin_port)) + ': ' + Msg);
  until False;
  closesocket(fSocket);
end;
 
procedure TReceiveThread.LogMessage(const Msg: string);
begin
  fLogMsg := Msg;
  Synchronize(DoLogMessage);
end;
 
procedure TReceiveThread.DoLogMessage;
begin
  ChatForm.AddMessageToLog(fLogMsg);
end;
 
end.

Далее. По кнопке отправить формируется адрес относительно введённого IP и вызывается sendto(). При этом пользователь должен знать IP и Port адресата.

Код функции, которая посылает сообщение:

procedure TChatForm.BtnSendClick(Sender: TObject);
var
  SendAddr: TSockAddr; // Адрес назначения
  Msg : string; // Сообщение для отправки
  SendRes : Integer; // Результат отправки
begin
  // Формируем адрес назначения на основе того, что ввёл пользователь
  FillChar(SendAddr.sin_zero, SizeOf(SendAddr.sin_zero), 0);
  SendAddr.sin_family := AF_INET;
  SendAddr.sin_addr.S_addr := inet_addr(PChar(EditSendAddr.Text));
  if SendAddr.sin_addr.S_addr = INADDR_NONE then
  begin
    MessageDlg('"' +EditSendAddr.Text + '" не является IP-адресом', mtError, [mbOK], 0);
    Exit;
  end;
  SendAddr.sin_port := htons(StrToInt(EditSendPort.Text));
  Msg := EditMessage.Text;
  if Length(Msg) = 0 then
    // Отправляем дейтаграмму нулевой длины - UDP такое разрешает
    SendRes := sendto(fSendSocket, Msg, 0, 0, SendAddr, SizeOf(SendAddr))
  else // Отправляем сообщение, содержащее строку
    SendRes := sendto(fSendSocket, Msg[1], Length(Msg), 0, SendAddr, SizeOf(SendAddr));
  if SendRes < 0 then
    MessageDlg('Ошибка при отправке сообщения:'#13#10 + GetErrorString, mtError, [mbOK], 0)
  else
    AddMessageToLog('Для ' + EditSendAddr.Text + ':' + EditSendPort.Text +
      ' отправлено сообщение: ' + Msg);
end;

Вот в принципе и все ключевые моменты. В остальном помогут комментарии.

По поводу минусов:

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

Конечно раз я заикнулся о проблеме, то должен рассказать, как её решить) Решить её можно передачей в дейтаграмме вместе с сообщением номер порта для ответов.

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

Но не стоит быть шибко критичным к данному примеру, его ценность в том, что он учит основам использования сокетов и протокола UDP в Delphi.

Архив с исходниками данного мини-урока: UDPChat.

Программа писалась на Delphi 2007. Поэтому можно адаптировать под D7, а вот на версиях новее D2007 (версии с юникодом) возможна проблема при формировании адреса, да и сокеты там организуются чуток по другому. Об этом я ещё буду писать.

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