Сегодня уже никому не надо рассказывать, что такое компьютерные сети, — сети прочно вошли в нашу жизнь. Сейчас многие программисты занимаются разработкой приложений, которые физически расположены на разных компьютерах и взаимодействуют друг с другом посредством сетей.
В Delphi существуют встроенные классы для работы с сетью — это компоненты Delphi 6 на закладке Internet (TServerSocket и TСlientCocket) и компоненты FastNet, либо компоненты Indy в Delphi 7. Но в этой статье рассматривается программирование сетевых приложений на низком уровне — с использованием WinSock API.
Зачем это нужно, если существует большой набор встроенных компонентов и большое количество бесплатно распространяемых классов?
1. В случае, если для приложения критичен размер (это больше всего относится к серверным приложениям, которые чаще всего не имеют никакой визуальной части), то использование любых VCL-компонентов крайне нежелательно.
2. Программирование на более низком уровне традиционно требует больших затрат, но предоставляет более полный контроль за работой приложения.
3. И, наконец, знание основ работы Windows Socket необходимо всем желающим разрабатывать сетевые приложения и будет являться хорошей практикой для более полного понимания всех происходящих процессов.
Для поддержки сетевых приложений существует технология, названная «сокеты». Сокет — это модель одного конца соединения, со всеми присущими ему свойствами и методами. По сути, это прикладной программный интерфейс, входящий в состав многих операционных систем (ОС) и призванный для поддержки сетевых возможностей ОС. В стандарте структуры протоколов семиуровневой модели OSI-сокеты лежат на так называемом транспортном уровне, ниже находится сетевой протокол IP, а выше — протоколы сеансового уровня, такие как FTP, POP3, SMTP и т.д.
В Windows поддержка сокетов включена начиная с версии 3.11 и названа WinSock. Для написания приложений с сетевой поддержкой существует специальный WinSock API.
Сокеты могут базироваться на TCP/IP, IPX/SPX но в дальнейшем мы будем говорить только про соединения TCP/IP.
Все сетевые приложения построены на технологии клиент-сервер; это значит, что в сети существует по крайней мере одно приложение, являющее сервером, типичная задача которого — это ожидание запроса на подключение от приложений-клиентов, которых может быть теоретически сколько угодно, и выполнение всевозможных процедур в ответ на запросы клиентов. Для клиент-серверной технологии абсолютно неважно, где расположены клиент и сервер — на одной машине или на разных. Конечно, для успешного соединения клиента с сервером клиенту необходимо иметь минимальный набор данных о расположении сервера — для сетей TCP/IP это IP-адрес компьютера, где расположен сервер, и адрес порта, на котором сервер ожидает запросы от клиентов.
Каждый из компьютеров в сети TCP/IP имеет свой уникальный IP-адрес, который используется для обмена данными с другими компьютерами. Каждый посылаемый пакет от одного компьютера другому имеет адрес отправителя и получателя, что позволяет его однозначно идентифицировать. Однако в случае, если на компьютере работает множество приложений, одновременно использующих сеть, то такого набора атрибутов явно недостаточно.
Для разрешения неоднозначности кроме адреса каждое соединение на каждом конце имеет идентификатор под названием «порт», этот идентификатор представляет число от 0 до 65535. Таким образом, пара адрес+порт представляет собой сокет-канал, по которому два компьютера обмениваются данными друг с другом. Только одно приложение на одном компьютере в одно и то же время может использовать конкретный порт, однако для серверных частей возможно создание нескольких сокетов на одном порту для работы с несколькими клиентами.
Значение порта не обязательно должно совпадать на сервере и клиенте — клиенту для соединения важно только знать порт сервера, порт клиента может выбираться клиентом произвольно и становится известен серверу в момент запроса клиента на соединение. Когда соединение будет установлено, ОС создаст для серверного приложения соответствующий сокет, с которым и будет работать приложение, так что порт клиента для сервера совершенно не важен.
Механизм работы сокетов таков: на серверной стороне запускается серверный сокет, который после запуска сразу переходит в режим прослушивания (т.е. ожидания соединения клиентов). На стороне клиента создается сокет, для которого указывается IP-адрес и порт сервера и дается команда на соединение. Когда сервер получает запрос на соединение, ОС создает новый экземпляр сокета, с помощью которого сервер может обмениваться данными с клиентом. При этом сокет, который создан для прослушивания, продолжает находиться в режиме приема соединений, таким образом программист может создать сервер, работающий с несколькими подключениями от клиентов.
Работа с сокетами, по существу, это операции ввода-вывода, которые бывают синхронные и асинхронные. В терминологии сокетов работа в асинхронном режиме называется блокирующими сокетами, а в синхронном — неблокирующие сокеты. Попытка соединения или приема данных в блокирующем режиме (отправка всегда синхронна, так как фактически является постановкой в очередь) означает, что пока программа не соединится или не примет данные, передачи управления на следующий оператор не произойдет.
Давайте теперь рассмотрим минимальный набор функций из WinSock API, необходимых для написания элементарного клиента и сервера. Сами функции находятся в файле winsock.dll. Файл winsock.pas содержит необходимые объявления импортируемых функций WinSock API и базовые структуры данных. К сожалению, этот файл импортирует не все необходимые нам функции, и позже мы напишем свой файл импорта.
function WSAStartup(wVersionRequired: word; var WSData: TWSAData): Integer; stdcall;
Функция сообщает ОС, что в любом процессе приложения могут быть использованы функции WinSock. Функция должна быть вызвана один раз при запуске приложения перед использованием любой функции WinSock.
function WSACleanup: Integer; stdcall;
Функция сообщает ОС, что приложение более не использует WinSock. Должна быть вызвана перед завершением приложения.
function socket(af, Struct, protocol: Integer): TSocket; stdcall;
Функция создает сокет. Порт и адрес задается в функции bind (сервер) или connect (клиент). Входящий параметр af — спецификация семейства сокетов (AF_INET, AF_IPX и др.), Struct — спецификация типа нового сокета (принимает значение SOCK_STREAM или SOCK_DGRAM), protocol — специфический протокол, который будет использоваться сокетом. Если функция выполнена без ошибок, она возвращает дескриптор на новый сокет, если ошибки есть, возвращается INVALID_SOCKET.
function connect(s: TSocket; var name: TSockAddr; namelen: Integer): Integer; stdcall;
Функция соединения для клиента. Структура адреса содержит порт (необходимо привести функцией htons) и адрес (для клиента необходимо привести из имени или спецификации ip4 — xxx.xxx.xxx.xxx).
function bind(s: TSocket; var addr: TSockAddr; namelen: Integer): Integer; stdcall;
Функция ассоциирует адрес с сокетом. Структура адреса содержит порт (необходимо привести функцией htons) и адрес (для сервера обычно указывается INADDR_ANY — любой).
function send(s: TSocket; var Buf; len, flags: Integer): Integer; stdcall;
Функция отправки данных. Помещает в очередь сокета s кусок данных из buf, длиной len. Последний параметр отвечает за вид передачи сообщения. Может быть проигнорирован (0).
function recv(s: TSocket; var Buf; len, flags: Integer): Integer; stdcall;
Функция получение данных.
Итак, рассмотрим примеры элементарного сервера и клиента. Договоримся, что сервер будет работать в асинхронном (блокирующем режиме). Единственная функциональность сервера — это получение строк данных от клиента и вывод их на экран. Связь клиента с сервером разрывается после получения строки, состоящей из единственного символа ‘q’.
Для обеспечения возможности подключения к серверу множества клиентов сервер на каждое соединение запускает отдельный поток.
program WinSock_Server;
//Простейшее приложение-сервер.
//Сокеты работают в блокирующем режиме.
//На каждое соединение создается отдельный поток.
{$APPTYPE CONSOLE}
uses
SysUtils,
Winsock,
Windows;
var
vWSAData : TWSAData;
vListenSocket,vSocket : TSocket;
vSockAddr : TSockAddr;
trId : THandle;
const
cPort = word(33);
cSigExit = 'q';
//Процедура отдельного потока для каждого клиента.
procedure SocketThread;
var SockName : TSockAddr;
aBuf : array of char;
vBuf : string;
vSize : integer;
s :TSocket;
BufSize : integer;
begin
s := vSocket;
if s = INVALID_SOCKET then exit;
vSize := SizeOf(TSockAddr);
getpeername(s, SockName, vSize);
Writeln(format('Client accepted, remote address [%s].',[inet_ntoa (SockName.sin_addr)]));
//Определяем размер буфера чтения для сокета
vSize := sizeOf(BufSize);
getsockopt(s,SOL_SOCKET,SO_RCVBUF,PChar(@
BufSize),vSize);
writeln(format('Receive buffer size [%d]',[BufSize]));
SetLength(aBuf,BufSize);
repeat
//Получаем данные. Процедура работает в блокирующем режиме,
//таким образом следующая строка кода не получит управление,
//пока не поступят данные от клиента.
vSize := recv(s,aBuf[0],BufSize,0);
if vSize<=0 then Break;
SetLength(vBuf,vSize);
lstrcpyn(@vBuf[1],@aBuf[0],vSize);
writeln(format('Received from cleint: %s',[vBuf]));
until vBuf = 'q';
Writeln(format('Client disconnected, remote address [%s].',[inet_ntoa(SockName.sin_addr)]));
SetLength(aBuf,0);
closesocket(s);
end;
begin
Writeln('Starting application...');
//Объявляем, что программа будет использовать Windows Sockets.
if WSAStartup($101,vWSAData)<>0 then Halt(1);
Writeln('Using Windows Sockets.');
//Создаем прослушивающий сокет.
vListenSocket := socket(AF_INET,SOCK_STREAM,IPPROTO_IP);
Writeln(format('Creating socket on port [%d].',[cPort]));
if vListenSocket = INVALID_SOCKET then Halt(1);
FillChar(vSockAddr,SizeOf(TSockAddr),0);
vSockAddr.sin_family := AF_INET;
vSockAddr.sin_port := htons(cPort);
vSockAddr.sin_addr.S_addr := INADDR_ANY;
Writeln('Binding socket...');
//Привязываем адрес и порт к сокету.
if bind(vListenSocket,vSockAddr,SizeOf(TSockAddr)) <> 0
then Halt(1);
//Начинаем прослушивать.
if listen(vListenSocket,SOMAXCONN) <> 0
then Halt(1);
Writeln('Socket status: listening.');
repeat
//Ожидаем подключения.
vSocket := accept(vListenSocket,nil,nil);
//Клиент подключился, запускаем новый процесс на соединение.
CreateThread(nil,0,@SocketThread,0,0,trId);
until false;
closesocket(vListenSocket);
WSACleanup;
end.
Исходный текст снабжен подробными комментариями и, думаю, не требует никаких дальнейших пояснений. Единственное, что отмечу, — тут использовалась не описанная ранее функция getpeername(), которая возвращает информацию о канале, ассоциированном с сокетом. Я использовал ее для получения информации о IP-адресе подключившегося клиента. Подробно об этой функции, как и о всех других функциях WinSock, вы можете прочитать в Windows Sockets 2 Application Program Interface, входящем в состав Win32 Programmer’s Reference.
Хочу обратить ваше внимание, что приведенный выше код можно использовать только как учебное пособие. Для того что использовать его в качестве основы для настоящего приложения, необходимо некоторое количество доработок, т.к. многие вещи, которые могут привести в будущем к серьезным ошибкам, были сознательно опущены для уменьшения размера кода и акцентирования внимания именно на аспектах использования WinSock. Если вы недостаточно хорошо знакомы с понятием потоков (Threads) в Windows, то вам лучше использовать класс Tthread, существующий в Delphi специально для поддержки многопоточных приложений.
Исходный код клиента не представляет из себя ничего интересного и не требует никаких комментариев:
program WinSock_Client;
{$APPTYPE CONSOLE}
uses
SysUtils,
winsock;
const
cPort = 33;
cSigExit = 'q';
var
vWSAData : TWSAData;
vSocket : TSocket;
vSockAddr : TSockAddr;
buf : string;
begin
if WSAStartup($101,vWSAData)<>0 then Halt(1);
vSocket := socket(AF_INET,SOCK_STREAM,IPPROTO_IP);
if vSocket = INVALID_SOCKET then Halt(1);
FillChar(vSockAddr,SizeOf(TSockAddr),0);
vSockAddr.sin_family := AF_INET;
vSockAddr.sin_port := htons(cPort);
vSockAddr.sin_addr.S_addr := inet_addr('127.0.0.1');
if connect(vSocket,vSockAddr,SizeOf(TSockAddr)) = SOCKET_ERROR then Halt(1);
repeat
Readln(buf);
if send(vSocket,buf[1],Length(buf),0) = SOCKET_ERROR then Break;
until buf = cSigExit;
closesocket(vSocket);
WSACleanup;
end.
Итак, мы рассмотрели, как работают сокеты в асинхронном режиме, давайте посмотрим теперь, какие возможности WinSock нам предоставляет для работы с неблокирующими сокетами.
Для того чтобы перевести сокет в неблокирующий режим, используется функция ioctlsocket(…), позволяющая контролировать режимы работа сокета.
Т.к. теперь функция recv сервера будет возвращать управление сразу, независимо от наличия данных в буфере сокета, то теперь нам нужен механизм, позволяющий определять какие-либо события, происходящие с сокетом. Для этого существует несколько механизмов. Первый, который мы рассмотрим, это использование функции select(…).
function select(nfds: Integer; readfds, writefds, exceptfds: PFDSet; timeout: PTimeVal): Longint; stdcall;
Эта функция позволяет контролировать состояние набора сокетов.
Аргумент nfds игнорируется и оставлен только для совместимости. Должен быть равен 0. readfs,writefds,exceptfds — указатели на наборы сокетов, для которых нужно контролировать состояние чтения, отправки данных и ошибок соответственно. Наборы хранятся в структуре PFDSet, управление которой осуществляется специальными макросами, описанными в winsock.pas:
procedure FS_ZERO(var FDSet: TFDSet) — обнуляет структуру, устанавливает количество контролируемых сокетов в 0;
procedure FD_SET(Socket: TSocket; var FDSet: TFDSet) — добавляет указанный сокет в структуру;
procedure FD_CLR(Socket: TSocket; var FDSet: TFDSet) — удаляет указанный сокет из структуры;
function FD_ISSET(Socket: TSocket; var FDSet: TFDSet): Boolean — возвращает true, если указанный сокет является членом указанной структуры.
Аргумент timeout является ссылкой на структуру типа PTimeVal, в которой можно указать время ожидания срабатывания функции select. В случае указания в качестве значения времени задержки 0 или nil в качестве аргумента timeout функция select будет ждать бесконечно, как при выполнении операции в блокирующем режиме.
Как видно из описания, функция может следить сразу за несколькими сокетами, таким образом, теперь мы можем либо также запускать отдельный процесс на каждый открытый сокет, который будет следить за конкретным сокетом, либо создать один процесс, который будет следить за всеми открытыми сокетами. В первом случае нам потребуются лишь незначительные изменения процедуры SocketThread, во втором случае потребуются довольно значительные изменения.
Давайте рассмотрим логику работы во втором случае, так как первый случай банален.
Для начала в основной части программы необходимо перевести вновь созданный сокет в неблокирующий режим:
arg := 1;
ioctlsocket(Socket,FIONBIO,arg);
Внимание, перед закрытием сокета его необходимо будет вернуть в блокирующий режим:
arg := 0;
ioctlsocket(Socket,FIONBIO,arg);
Затем необходимо реализовать возможность сохранения каждого сокета в некоторый массив SockArray. Далее, нужно обеспечить, чтобы поток, который будет заниматься обработкой клиентов, запускался только один раз. Это сделать несложно, зная, что процедура CreateThread возвращает в переменную ссылку на вновь созданный поток, которую и нужно использовать для проверки, был ли создан поток. В потоке обработки нужно внести следующие изменения для использования функции select:
новые используемые переменные wfds :TFDSet ; i : integer; tv : Ttimeval;
Затем определим основной цикл, в котором будем обрабатывать данные:
Repeat
…
until connum>0;
В этом цикле первым делом необходимо сформировать структуру wfds, содержащую набор контролируемых сокетов. Для этого мы переносим туда сокеты из массива SockArray:
FD_ZERO(wfds);
for i:=1 to connum do
begin
FD_SET(sock[i],wfds);
end;
Далее, указываем в структуре tv время задержки для функции select:
tv.tv_sec := 5;
tv.tv_usec := 0;
Теперь можно вызывать функцию select (т.к. мы следим только за приемом данных, то в качестве writefds, exceptfds мы указываем nil):
select(0,@wfds,nil,nil,@tv);
Теперь, когда функция select возвратит управление в переменной wfds, мы будем иметь набор сокетов, для которых необходимо произвести чтение, и можем обработать поступившие данные:
if wfds.fd_count=0 then continue;
for i:=0 to wfds.fd_count-1 do
begin
vSocket := wfds.fd_array[i];
//Обработка поступивших данных с сокета vSocket.
end;
Условие выхода из основного цикла — отсутствие открытых сокетов в массиве SockArray. Условия закрытия сокета я оставляю на совести читателя, добавлю только лишь, что сокет попадет в обработку select и при наступлении события разрыва связи (для обработки можно использовать то, что количество принятых байт функцией recv будет равно нулю, а также функцию WSAGetLastError).
Давайте теперь разберемся, для чего мы указали время ожидания функции send, а не сделали его бесконечным? Дело в том, что в текущей реализации, при создания нового сокета, он не попадет в обработку select, пока не будет установлен функцией FS_SET, что, естественно, не пройдет в блокирующем режиме, пока select не возвратит управление по событию с одним из отслеживаемых клиентов. Установка значения timeout гарантирует, что сокет попадет в обработку независимо от состояния других сокетов.
Какой вариант использовать — поток на каждый сокет, либо один поток обрабатывает все соединения — вопрос, к которому нужно подходить по-разному в каждом конкретном случае. Если в процессе общения с клиентом серверу требуется значительное время на подготовку и обработку данных, то, конечно, следует создать разные потоки, чтобы другим клиентам не пришлось ожидать завершения обработки, а работать паралелльно. В противном случае, если обработка минимальна, то создание большого количества процессов окажет влияние в худшую сторону на общую производительность системы.
Помимо функции select существует еще два метода работы с асинхронными сокетами:
function WSAAsyncSelect(s: TSocket; HWindow: HWND; wMsg: u_int; lEvent: Longint): Integer; stdcall;
Эта функция связывает сокет с получением сообщений окна. При вызове этой функции, сообщения о соединении, чтении/записи данных в сокет и закрытии сокета можно обрабатывать в функции обработки сообщений от окна.
const
WM_MYSOCKET = WM_USER + 1;
...
type
TForm1 = class(TForm)
...
private
procedure Socket_Proc(var Msg:TMessage);message WM_MYSOCKET;
...
WSAAsyncSelect(vSocket,Form1.Handle,WM_
MYSOCKET,FD_ACCEPT+FD_READ);
....
procedure TForm1.Socket_Proc(var Msg: TMessage);
begin
if ((Msg.Msg = WM_MYSOCKET)
and (Msg.lParam = FD_ACCEPT))
then ShowMessage('Connected');
end;
Не будем задерживаться на обработке сообщений окна, а рассмотрим еще один способ, который пригодится, если у вас нет окна приложения; этот способ основан на системных событиях (Events). К сожалению, файл winsock.pas не импортирует соответствующие функции, в результате чего многие программисты пренебрегают возможностями событий. Но для нас это не беда — напишем собственный импорт необходимых процедур:
function WSAEventSelect(s: TSocket; Event: THandle; lEvent: Longint):integer;stdcall;
external 'ws2_32.dll' name 'WSAEventSelect';
function WSAWaitForMultipleEvents(nCount: DWORD; lpHandles: PWOHandleArray;
bWaitAll: BOOL; dwMilliseconds: DWORD; fAlertable:BOOL):integer;stdcall;
external 'ws2_32.dll' name 'WSAWaitForMultipleEvents';
function WSACreateEvent:THandle;stdcall;
external 'ws2_32.dll' name 'WSACreateEvent';
function WSAResetEvent(Event : THandle):BOOL;stdcall;
external 'ws2_32.dll' name 'WSAResetEvent';
function WSAEnumNetworkEvents(const s : TSocket;
const Event : THandle; lpNetworkEvents : LPWSANetworkEvents): longint ;
stdcall;far;
external 'ws2_32.dll' name 'WSAEnumNetworkEvents';
function WSACloseEvent(Event : THandle):integer;
stdcall; external 'ws2_32.dll' name 'WSACloseEvent';
Также нам потребуется описание структуры WSANetworkEvents
const
fd_max_eventS = 10;
type
TWSANetworkEvents = record
lNetworkEvents: LongInt;
iErrorCode: Array[0..fd_max_eventS-1] of Integer;
end;
PWSANetworkEvents = ^TWSANetworkEvents;
LPWSANetworkEvents = PWSANetworkEvents;
Принцип работы с этим набором функций состоит в создании специального объекта типа Event, затем связывание этого события с сокетом с помощью функции WSAEventSelect, в которой также указывается набор отслеживаемых состояний сокета. Один сокет может быть связан только с одним объектом типа Event.
Затем, в цикле обработки мы организуем ожидание поступления события от сокета; это реализуется с помощью API функций WaitForSingleObject — для ожидания одного события, либо WaitForMultipleObjects — для ожидания набора событий. При наступлении события функция возвращает управление. Для однозначной идентификации, от какого сокета пришло уведомление, в связи с чем используется функция WSAEnumNetworkEvents, возвращающая структуру типа TWSANetworkEvents.
var
FEvent : THandle;
//Создаем серверный сокет
...
FEventClose := WSACreateEvent;
WSAEventSelect(Socket,FEvent, FD_CLOSE + FD_READ );
repeat
WaitForSingleObject(FEvent,INFINITE);
WSAEnumNetworkEvents(FSocket,FEvent,@NI);
case NI.lNetworkEvents of
FD_Close:break;
FD_Read: begin
ReceiveData;
end;
end;
WSAResetEvent(FEventClose);
Until false;
WSACloseEvent(FEventClose);
Вне зависимости от того, используем ли мы синхронные или асинхронные сокеты и какие методы выбраны для обработки событий, приходящих от сокетов, при отправке и приеме данных есть один подводный камень, на который попадают все начинающие программисты сетевых приложений.
Рассмотрим отправку данных — дело в том, что, как мы уже говорили, отправка данных есть, по сути, постановка порции данных в очередь. Мы не может управлять отдельными пакетами, более того, данные, попавшие в буфер, отправляются не сразу, а могут накапливаться для отправки в дальнейшем одним пакетом. Таким образом, последовательный вызов
send(vSocket,@buf1,Length(buf1),0);
send(vSocket,@buf2,Length(buf2),0);
фактически будет идентичен одному вызову send с объединенным буфером buf1+buf2.
Таким образом, при приеме данных, хотя мы послали две порции данных, мы получим одну. Совсем другой случай, когда мы посылаем порцию данных, большую, чем буфер сокета, тогда функция send отправит только часть данных из указанного буфера, ровно столько, сколько влезло в буфер сокета. Для того чтобы отследить такую ситуацию и отправить необработанную часть буфера, нужно воспользоваться тем, что функция send возвращает количество фактически отосланных данных. Функцию send нужно запускать в цикле, условием завершения которого будет полная отправка всего буфера.
Таким образом, при чтении из сокета данных, мы можем наблюдать как бы «склейку» порций данных, либо, наоборот, фрагментацию (не путать с фрагментацией пакетов на уровне TCP/IP). Такие ситуации должна обрабатывать наша программа. Решить проблему можно добавлением сигнатуры признака конца блока данных. Это имеет смысл, если приложения часто обмениваются небольшими блоками данных, где чаще всего возникает эффект «склеивания», но неэффективно при больших объемах, так как сканирование большого буфера на предмет сигнатуры отнимает много времени. Обычно все же это решается таким способом — в начале каждого пакета добавляется 32-битное число, определяющее длину порции данных в байтах. Таким образом, принимающая часть, зная размер каждого блока, может распознать «склейку» и фрагментацию.
Изложенной информации достаточно для понимания идеологии сетевых приложений. Об остальных аспектах сетевого программирования с использованием библиотеки WinSock вы можете узнать из справки «Windows SDK» в разделе «Windows Sockets 2 Application Program Interface».