|
||||||||||||||||||||||||
|
Глава 2Использование сокетов Delphi Так как большинство современных компьютеров объединены в сети, то и задачи программирования передачи и получения данных по сети возникают часто. Существует множество высокоуровневых средств обмена, но иногда их бывает недостаточно, и тогда приходится использовать самые низкоуровневные средства сетевого программирования — сокеты. Однако существует целый ряд причин, по которым овладеть этой технологией непросто. Во-первых, она сложна сама по себе из-за большого количества возможностей. Во-вторых, существующие библиотеки отягощены совместимостью со старыми версиями, которые, с современной точки зрения, не всегда развивались по правильному пути. В-третьих, у программистов на Delphi возникают дополнительные трудности, связанные как с тем, что сама реализация библиотеки сокетов ориентирована на язык С, так и с тем, что стандартный модуль WinSockпочему-то "застрял" на первой версии этой библиотеки, в то время как начиная с Windows NT 4 существует более удобная вторая. Несмотря на традиционно высокий интерес к сокетам, литературы, в которой бы эта библиотека была детально описана, очень мало. Если не считать книг, где сокеты упоминаются обзорно, автору известна только одна книга, посвященная сокетам в Windows [3]. Но она не имеет никакого отношения к Delphi и не учитывает специфику этого средства разработки. Естественно, что из-за недостатка информации у начинающих программистов часто возникают вопросы, связанные с использованием сокетов в Delphi. В данной главе мы попытаемся ответить на эти вопросы. Изложение материала ориентировано на человека, который не только не сталкивался с сокетами раньше, но и вообще имеет смутные представления об устройстве и функционировании компьютерных сетей. Поэтому мы будем обзорно рассматривать и общетеоретические темы, которые необходимо знать, чтобы эффективно программировать сетевое взаимодействие, так что никакая предварительная подготовка в этих вопросах от читателя не требуется. Глава разбита на две части. Первая посвящена стандартным сокетам, а вторая — сокетам Windows. Термины достаточно условны и нуждаются в дополнительном пояснении. Строго говоря, стандартными называются сокеты Беркли (Berkley sockets), разработанные в университете Беркли для системы Unix. Как это ни парадоксально звучит, но сокеты Беркли появились до появления компьютерных сетей. Изначально они предназначались для взаимодействия между процессами в системе и только позже были приспособлены для TCP/IP. Работа с сокетами Беркли сделана максимально похожей на работу с файлами в Unix. В частности, для отправки и получения данных используются те же функции, что и для файлового ввода-вывода. Сокеты в Windows не полностью совместимы с сокетами Беркли (например, для них предусмотрены специальные функции отправки и получения данных, переопределены некоторые типы данных и т.п.). Но возможности работы с сокетами в Windows можно разделить на две части: то, что укладывается в идеологию сокетов Беркли, хотя и реализовано несколько иначе, и то, что является специфичным для Windows. Ту часть реализации сокетов Windows, которая по функциональности соответствует сокетам Беркли, мы будем называть стандартными сокетами, а сокетами Windows (Windows sockets) — специфичные для Windows расширения. Ориентироваться мы будем только на два протокола передачи данных — TCP и UDP. Хотя библиотека сокетов поддерживает и другие протоколы, но эти два, во-первых, применяются наиболее часто, а во-вторых, именно на них во многом ориентированы стандартные сокеты. Поэтому здесь мы не будем касаться особенностей работы функций библиотеки сокетов, которые проявляются только для протоколов, отличных от TCP и UDP. Объем информации, с которой придется познакомиться, и без того большой, а с другими протоколами легче ознакомиться потом, изучив TCP и UDP. 2.1. Стандартные сокетыСначала рассмотрим классические методы работы с сокетами, которые не учитывают ни существования окон и оконных сообщений, ни возможности распараллеливания работы программы на несколько нитей. Это. впрочем, не означает, что программа, использующая эти методы, должна быть безоконной и однонитевой, оконные и многонитевые программы есть среди примеров этого раздела. Просто приспосабливать стандартные сокеты к окнам и распараллеливанию приходится вручную, а не за счет средств самой библиотеки. Тем не менее из-за своей простоты стандартные сокеты нередко оказываются более удобными, чем сокеты Windows, даже в оконных приложениях. 2.1.1 Соглашения об именахПервые библиотеки сокетов писались на языке С. В этом языке идентификаторы чувствительны к регистру символов, т.е., например, SOCKET, Socketи socket— разные идентификаторы. Исторически сложилось, что имена встроенных в C типов данных пишутся в нижнем регистре, имена определенных в программе типов, макроопределений и констант — в верхнем, а имена функций — в смешанном (прописные буквы выделяют начала слов, например, GetWindowText). Разработчики библиотеки сокетов несколько отошли от этих правил: имена всех стандартных сокетных функций пишутся в нижнем регистре. И хотя мы и программируем на Паскале, нечувствительном к регистру символов, все же будем придерживаться этой традиции, пусть это не удобно, зато не будет расхождений с другими источниками. Чувствительность С к регистру символов создаст некоторые проблемы при переноce библиотек, написанных на этом языке, в Delphi. Это связано с тем, что разные объекты могут иметь имена, различающиеся только регистром символов, в частности, есть тип SOCKETи функция socket. Сохранить эти имена в Delphi невозможно. Чтобы избежать этой проблемы, разработчики Delphi при переносе библиотек к имени типа добавляют букву " Т", причем независимо от того, существуют ли у этого типа одноименные функции или нет. Так, типу SOCKETиз С в Delphi соответствует TSocket. Имена функций остаются без изменений. Ранее был упомянут термин "макроопределение". Он может быть непонятен тем, кто не работал с языками С и C++, потому что в Delphi макроопределения отсутствуют. Нормальная последовательность трансляции программы в Delphi следующая: сначала компилятор создает объектный код, в котором вместо реальных адресов функций, переменных и т.п. стоят ссылки на них (на этапе компиляции эти адреса еще не известны). Затем компоновщик размещает объекты в памяти и заменяет ссылки реальными адресами. Так получается готовая к исполнению программа. В С/C++ трансляция включает в себя еще один этап: перед компиляцией текст программы модифицируется препроцессором, и компилятор получает уже несколько видоизмененный текст. Макроопределения, или просто макросы, — это директивы препроцессору, предписывающие ему, как именно нужно менять текст программы. Макрос задаст подмену: везде, где в программе встречается имя макроса, препроцессор изменяет его на тот текст, который задан при определении этого макроса. Определяются макросы с помощью директивы препроцессору #define. В простейшем случае макросы используются для определения констант. Например, директива #define SOMECONST 10вынуждает препроцессор заменять SOMECONSTна 10. Для компилятора эта директива ничего не значит, идентификатора SOMECONSTдля него не существует. Он получит уже измененный препроцессором текст, в котором вместо SOMECONSTбудет 10. Допускается также создавать параметризованные макросы, которые изменяют текст программы по более сложным правилам. Макросы позволяют в некоторых случаях существенно сократить программу и повысить ее читабельность. Тем не менее они считаются устаревшим средством. т.к. их использование может привести к существенным проблемам (обсуждение этого выходит за рамки данной книги). В современных языках от макросов отказываются. В частности, в C++ макросы поддерживаются в полном объеме, но использовать их не рекомендуется, т.к. есть более безопасные инструменты, решающие типичные для макросов задачи. В С# и Java макросы отсутствуют. Тем не менее в заголовочных файлах для системных библиотек Windows (в том числе и библиотеки сокетов) макросы широко применяются, т.к. требуется обеспечить совместимость с языком С. При портировании таких файлов в Delphi макросы без параметров заменяются константами, а макросы с параметрами — функциями (иногда один макрос приходится заменять несколькими функциями для разных типов данных). 2.1.2. Общие сведения о сокетахСокетом (от англ. socket — гнездо, розетка) называется специальный объект, создаваемый для отправки и получения данных через сеть. Отметим, что под термином "объект" в данном случае подразумевается не объект в терминах объектно-ориентированного программирования, а некоторая сущность, внутренняя структура которой скрыта от нас, и мы можем оперировать с ней только как с единым и неделимым (атомарным) объектом. Этот объект создается внутри библиотеки сокетов, а программист, работающий с данной библиотекой, получает уникальный номер (дескриптор) этого сокета. Конкретное значение этого дескриптора не несет для программиста никакой полезной информации и может быть использовано только для того, чтобы при вызове функции из библиотеки сокетов указать, с каким сокетом требуется выполнить операцию. В этом смысле тип TSocketполностью аналогичен дескрипторам окон, графических объектов и т.п., с которыми мы встречались в предыдущей главе. Чтобы две программы могли общаться друг с другом через сеть, каждая из них должна создать сокет. Каждый сокет обладает двумя основными характеристиками: протоколом и адресом, к которым он привязан. Протокол задается при создании сокета и не может быть изменен впоследствии. Адрес сокета задаётся позже, но обязательно до того, как через сокет пойдут данные. В некоторых случаях привязка сокета к адресу может быть неявной. Формат адреса сокета определяется конкретным протоколом. В частности, для протоколов TCP и UDP адрес состоит из IP-адреса сетевого интерфейса и номера порта. Каждый сокет имеет два буфера: для входящих и для исходящих данных. При отправке данных они сначала помещаются в буфер исходящих, и лишь затем отправляются в фоновом режиме. Программа в это время продолжает свою работу. При получении данных сокет помещает их в буфер для входящих, откуда они затем могут извлекаться программой. Сеть может связывать разные аппаратные платформы, поэтому требуется согласование форматов передаваемых данных, в частности форматов целых чисел. Двухбайтные целые числа хранятся в памяти в двух последовательно расположенных байтах. При этом возможны два варианта: в первом байте хранится младший байт числа, а во втором — старший, и наоборот. Способ хранения определяется аппаратной частью платформы. Процессоры Intel используют первый вариант, т.е. первым хранится младший байт, а другие процессоры (например, Motorola) — второй вариант. То же касается и четырёхбайтных чисел: процессоры Intel хранят их, начиная с младшего байта, а некоторые другие процессоры — начиная со старшего. Сетевой формат представления таких чисел совпадает с форматом процессора Motorola, т.е. на платформах с процессором Intel необходимо переставлять байты при преобразовании чисел в сетевой формат. Библиотека сокетов разрабатывалась для ОС Unix в которой традиционно высоко ценилась переносимость между платформами, поэтому она содержит функции, позволяющие не задумываться о порядке байтов в числах: ntohs, ntohl, htonsи htonl. Первая буква в названии этих функций показывает, в каком формате дано исходное число (n — Network — сетевом формат, h — Host — формат платформы), четвертая буква — формат результата, последняя буква — разрядность (s — Short — двухбайтное число, l — Long — четырёхбайтное число). Например, функция htonsпринимает в качестве параметра число типа u_short(Word) в формате платформы и возвращает то же число в сетевом формате. Реализация этих функций для каждой платформы своя: где-то они переставляют байты, где-то они возвращают в точности то число, которое было им передано. Благодаря этим функциям программы становятся переносимыми. Хотя для программиста на Delphi вопросы переносимости не столь актуальны, приходится прибегать к этим функциям хотя бы потому, что байты переставлять нужно, а никакого более удобного способа для этого не существует. 2.1.3. Сетевые протоколы. Семиуровневая модель OSIСетевым протоколом называется набор соглашений, следование которым позволяет обеим сторонам одинаково интерпретировать принимаемые и отправляемые данные. Сетевой протокол можно сравнить с языком: два человека понимают друг друга тогда, когда говорят на одном языке. Причем если люди, говорящие на похожих, но немного разных языках, все же могут понимать друг друга, то компьютеры для нормального обмена данными должны поддерживать в точности одинаковый протокол. Для установления взаимодействия между компьютерами должен быть согласован целый ряд вопросов, начиная от напряжения в проводах и заканчивая форматом пакетов. Реализуются эти соглашения на разных уровнях, поэтому логичнее иметь не один протокол, описывающий все и вся, а набор протоколов, каждый из которых охватывает только вопросы одного уровня. Организация Open Software Interconnection (OSI) предложила разделить все вопросы, требующие согласования, на семь уровней. Это разделение известно как семиуровневая модель OSI. Семейство протоколов, реализующих различные уровни, называется стеком протоколов. Стеки протоколов не всегда точно следуют модели OSI, некоторые протоколы решают вопросы, связанные сразу с несколькими уровнями. Первый уровень в модели OSI называется физическим. На нем согласовываются физические, электрические и оптические параметры сети: напряжение и форма импульсов, кодирующих 0 и 1, какой разъем используется и т.п. Второй уровень носит название канального. На этом уровне решаются вопросы конфигурации сети (шина, звезда, кольцо и т.п.), приема и передачи кадров, допустимости и методов разрешения коллизий (ситуаций, когда сразу два компьютера пытаются передать данные). Третий уровень — сетевой. Здесь определяется, как адресуются компьютеры. Большинство сетей используют широковещательный способ передачи: пакет, переданный одним компьютером, получают все остальные. Протокол сетевого уровня описывает критерии, на основании которых каждый компьютер может выбирать из сети только те пакеты, которые предназначены ему, и игнорировать все остальные. На этом же уровне определяется, как пакеты проходят через маршрутизатор. Четвертый уровень называется транспортным. На этом уровне единый физический поток данных разбивается на независимые логические потоки. Это позволяет нескольким программам независимо друг от друга использовать сеть, не опасаясь, что их данные смешаются. Кроме того, на транспортном уровне решаются вопросы, связанные с подтверждением доставки пакета и упорядочиванием пакетов. Пятый уровень известен как уровень сессии. Он определяет процедуру установления, завершения связи и ее восстановления после разрыва. Расписывается последовательность действий каждой стороны и пакеты, которые они должны друг другу отправить для инициализации и завершения связи. Определяются соглашения о том, как единый поток разбивается на логические пакеты. Шестой уровень называется уровнем представлений. На этом уровне определяется то, в каком формате данные передаются по сети. Под этим подразумевается, в первую очередь, внутренняя структура пакета, а также способ представления данных разных типов. Например, для двух- и четырёхбайтных целых чисел должен быть согласован порядок байтов, для логических величин — какие значения соответствуют True, какие — False, для строк — кодировка и способ задания конца строки и т.п. Седьмой уровень называется уровнем приложений. Соглашения этого уровня позволяют работать с ресурсами (файлами, принтерами и т.д.) удаленного компьютера как с локальными, осуществлять удаленный вызов процедур и т.п. Чтобы получить данные через сеть, должны быть реализованы все уровни, за исключением, может быть, седьмого. Для каждого уровня должен быть определён свой протокол. В идеале механизмы взаимодействия между протоколами разных уровней должны иметь столь высокую степень абстракции, чтобы один протокол на любом из уровней можно было заменить любым другим протоколом того же уровня, не внося каких-либо изменений в выше- и нижележащие уровни. 2.1.4. Стек TCP/IPФизический и канальный уровни полностью реализуются сетевой картой или модемом (или другим устройством, выполняющим ту же функцию) и ее драйвером. Здесь действительно достигнута настолько полная абстракция, что программист обычно не задумывается о том, какая используется сеть. Поэтому мы также не будем останавливаться на этих двух уровнях. В реальной жизни не все протоколы, особенно старые, соответствуют модели OSI. Существует такое понятие, как стек протоколов — набор протоколов разных уровней, которые совместимы друг с другом. Эти уровни не всегда точно соответствуют тем, которые предлагает модель OSI, но определенное разделение задач на уровни в них присутствует. Здесь мы сосредоточимся на стеке протоколов, который называется TCP/IP (нередко можно услышать словосочетание "протокол TCP/IP", что не совсем корректно: TCP/IP не протокол, а стек протоколов). Название этот стек получил по наименованию двух самых известных своих протоколов: TCP и IP. Протокол сетевого уровня IP расшифровывается как Internet Protocol. Это название иногда ошибочно переводят как "протокол Интернета" или "протокол для Интернета". На самом деле, когда разрабатывался этот протокол, никакого Интернета еще и в помине не было, поэтому правильный перевод — межсетевой протокол. История появления этого протокола связана с особенностями работы сети Ethernet. Эта сеть строится по принципу шины, когда все компьютеры подключены, грубо говоря, к одному проводу. Если хотя бы два компьютера попытаются одновременно передавать данные по общей шине, возникнет неразбериха, поэтому все шинные сети строятся по принципу "один говорит — все слушают". Очевидно, что требуется какая-то защита от так называемых коллизий (ситуаций, когда два узла одновременно пытаются передавать данные). Разные сети решают проблему коллизий по-разному. В промышленных сетях, например, обычно имеется маркер — специальный индикатор, который показывает, какому узлу разрешено сейчас передавать данные. Узел, называемый мастером, следит за тем, чтобы маркер вовремя передавался от одного узла к другому. Маркер исключает возможность возникновения коллизий. Ethernet же является одноранговой сетью, в которой нет мастера, поэтому в ней реализован другой подход: коллизии допускаются, но существует механизм их разрешения, заключающийся в том, что, во-первых, узел не начинает передачу данных, если видит, что другой узел уже что-то передает, а во-вторых, если два узла одновременно пытаются начать передачу, то оба прекращают попытку и повторяют ее через случайный промежуток времени. У кого этот промежуток окажется меньше, тот и захватит сеть (или за этот промежуток времени сеть будет захвачена кем-то еще). При большом числе компьютеров, сидящих на одной шине, коллизии становятся слишком частыми, и производительность сети резко падает. Для борьбы с этим служат специальные устройства — маршрутизаторы специализированные узлы, подключенные одновременно к нескольким сетям. Пока остальные узлы каждой из этих сетей взаимодействуют только между собой, маршрутизатор никак себя не проявляет, и эти сети существуют независимо друг от друга. Но если компьютер из одной сети посылает пакет компьютеру другой сети, этот пакет получает маршрутизатор и переправляет его в ту сеть, в которой находится адресат, или в которой находится другой маршрутизатор, способный передать этот пакет адресату. На канальном уровне существует адресация узлов, основанная на так называемом MAC-адресе сетевой карты (MAC — это сокращение Media Access Control). Этот адрес является уникальным номером карты, присвоенной ей производителем. Очевидно неудобство такого способа адресации, т.к. по MAC-адресу невозможно определить положение компьютера в сети, т.е. выяснить, куда направлять пакет. Кроме того, при замене сетевой карты меняется адрес компьютера, что также не всегда удобно. Поэтому на сетевом уровне определяется собственный способ адресации, не связанный с аппаратными особенностями узла. Отсюда следует, что маршрутизатор должен понимать протокол сетевого уровня, чтобы принимать решение о передаче пакета из одной сети в другую, а протокол, в свою очередь, должен учитывать наличие маршрутизаторов в сети и предоставлять им необходимую информацию. IP был одним из первых протоколов сетевого уровня, который решал такую задачу и с его помощью стала возможной передача пакетов между сетями. Поэтому он и получил название межсетевого протокола. Впрочем, название прижилось: в некоторых статьях MSDN сетевой уровень (network layer) называется межсетевым уровнем (internet layer). В протоколе IP. в частности, вводится важный параметр для каждого пакета: максимальное число маршрутизаторов, которое он может пройти, прежде чем попадет к адресату (этот параметр носит не совсем удачное название TTL — Time То Live, время жизни). Это позволяет защититься от бесконечного блуждания пакетов по сети. Примечание Для адресации компьютера протокол IP использует уникальное четырёхбайтное число, называемое IP-адресом. Впрочем, более распространена форма записи этого числа в виде четырех однобайтных значений. Система назначения этих адресов довольно сложна и призвана оптимизировать работу маршрутизаторов, обеспечив прохождение широковещательных пакетов только внутри определенной части сети и т.п. Мы здесь не будем подробно останавливаться на этом, потому что в правильно настроенной сети программисту не нужно знать всех этих тонкостей: достаточно помнить, что каждый узел имеет уникальный IP-адрес, для которого принята запись в виде четырех цифровых полей, разделенных точками, например, 192.168.200.217. Также следует знать, что адреса из диапазона 127.0.0.1—127.255.255.255 задают так называемый локальный узел: через эти адреса могут связываться программы, работающие на одном компьютере. Таким образом, обеспечивается прозрачность местонахождения адресата. Кроме того, один компьютер может иметь несколько IP-адресов, которые могут использоваться для одного и того же или разных сетевых интерфейсов. Кроме IP, в стеке TCP/IP существует еще несколько протоколов — ICMP, IGMP и ARP, — решающих задачи сетевого уровня. Эти протоколы не являются полноценными и не могут заменить IP. Они служат только для решения некоторых частных задач. Протокол ICMP (Internet Control Message Protocol — протокол межсетевых управляющих сообщений) обеспечивает диагностику связи на сетевом уровне. Многим знакома утилита ping, позволяющая проверить связь с удаленным узлом. В основе ее работы лежат специальные запросы и ответы, определяемые в рамках протокола ICMP. Кроме того, этот же протокол определяет сообщения, которые получает узел, отправивший IP-пакет, если этот пакет по каким-то причинам не доставлен. Протокол называется надежным (reliable), если он гарантирует, что пакет будет либо доставлен, либо отправивший его узел получит уведомление о том что доставка невозможна. Кроме того, надежный протокол должен гарантировать, что пакеты доставляются в том же порядке, в каком они отправлены и дублирования сообщений не происходит. Протокол IP в чистом виде не является надежным протоколом, т.к. в нем вообще не предусмотрены средства уведомления узла о проблемах с доставкой пакета. Добавление ICMP также не делает IP надежным, т.к. ICMP-пакет является частным случаем IP-пакета, и также может не дойти до адресата, поэтому возможны ситуации, когда пакет не доставлен, а отправитель об этом не подозревает. Протокол IGMP (Internet Group Management Protocol — протокол управления межсетевыми группами) предназначен для управления группами узлов, которые имеют один групповой IP-адрес. Отправку пакета по такому адресу можно рассматривать как нечто среднее между адресной и широковещательной рассылкой, т. к. такой пакет будет получен сразу всеми узлами, входящими в группу. Протокол ARP (Address Resolution Protocol — протокол разрешения адресов) необходим для установления соответствия между IP- и MAC-адресами. Каждый узел имеет таблицу соответствия. Исходящий пакет содержит два адреса узла: MAC-адрес для канального уровня и IP-адрес для сетевого. Отправляя пакет, узел находит в своей таблице MAC-адрес, соответствующий IP-адресу получателя, и добавляет его к пакету. Если в таблице такой адрес не найден, отправляется широковещательное сообщение, формат которого определяется протоколом ARP. Получив такое сообщение, узел, чей IP-адрес соответствует искомому, отправляет ответ, в котором указывает свой MAC-адрес. Этот ответ также широковещательный, поэтому его получают все узлы, а не только отправивший запрос, и все узлы обновляют свои таблицы соответствия. Строго говоря, обеспечение правильного функционирования протоколов сетевого уровня — задача администратора системы, а не программиста. В своей работе программист чаще всего сталкивается с более высокоуровневыми протоколами и не интересуется деталями реализации сетевого уровня. Протоколами транспортного уровня в стеке TCP/IP являются протоколы TCP и UDP. Строго говоря, они решают не только задачи транспортного уровня, но и небольшую часть задач уровня сессии. Тем не менее они традиционно называются транспортными. Эти протоколы мы рассмотрим детально в следующих разделах. Уровни сессии, представлений и приложений в стеке TCP/IP не разделены: протоколы HTTP, FTP, SMTP и т.д., входящие в этот стек, решают задачи всех трех уровней. Мы здесь не будем рассматривать эти протоколы, потому что при использовании сокетов они в общем случае не нужны: программист сам определяет формат пакетов, отправляемых с помощью TCP или UDP. Новички нередко думают, что фраза "программа поддерживает соединение через TCP/IP" полностью описывает то, как можно связаться с программой и получить данные. На самом деле необходимо знать формат пакетов, которые эта программа может принимать и отправлять, т.е. должны быть согласованы протоколы уровня сессии и представлений. Гибкость сокетов дает программисту возможность самостоятельно определить этот формат, т.е., по сути дела, придумать и реализовать собственный протокол поверх TCP или UDP. И без описания этого протокола организовать обмен данными с программой невозможно. 2.1.5. Протокол UDPПротокол UDP (User Datagram Protocol — протокол пользовательских дейтаграмм) встречается реже, чем его "одноклассник" TCP, но он проще для понимания, поэтому мы начнем изучение транспортных протоколов с него. Коротко UDP можно описать как ненадежный протокол без соединения, основанный на дейтаграммах. Теперь рассмотрим каждую из этих характеристик подробнее. UDP не имеет никаких исполнительных средств управления пакетами по сравнению с IP. Это значит, что пакеты, отправленные с помощью UDP, могут теряться, дублироваться и менять порядок следования. В сети без маршрутизаторов ничего этого с пакетами почти никогда не происходит, и UDP может условно считаться надежным протоколом. Сети с маршрутизаторами строятся, конечно же, таким образом, чтобы подобные случаи происходили как можно реже, но полностью исключить их, тем не менее, нельзя. Происходит это из-за того, что передача данных может идти несколькими путями через разные маршрутизаторы. Например, пакет может пропасть, если короткий путь к удаленному узлу временно недоступен, а в длинном приходится пройти больше маршрутизаторов, чем это разрешено. Дублироваться пакеты могут, если они ошибочно передаются двумя путями, а порядок следования может изменяться, если пакет, посланный первым, идет по более длинному пути, чем пакет, посланный вторым. Все сказанное отнюдь не означает, что на основе UDP нельзя построить надежный обмен данными, просто заботу об этом должно взять на себя само приложение. Каждый исходящий пакет должен содержать порядковый номер, и в ответ на него должен приходить специальный пакет — квитанция, которая уведомляет отправителя, что пакет доставлен. При отсутствии квитанции пакет высылается повторно (для этого необходимо ввести тайм-ауты на получение квитанции). Принимающая сторона по номерам пакетов восстанавливает их исходный порядок. UDP не поддерживает соединение. Это означает, что при использовании этого протокола можно в любой момент отправить данные по любому адресу без необходимости каких-либо предварительных действий, направленных на установление связи с адресатом. Это напоминает процесс отправки обычного письма: на нем пишется адрес, и оно опускается в почтовый ящик без каких-либо предварительных действий. Такой подход обеспечивает большую гибкость, но лишает систему возможности автоматической проверки исправности канала связи. Дейтаграммами называются пакеты, которые передаются как единое целое. Каждый пакет, отправленный с помощью UDP, составляет одну дейтаграмму. Принятые дейтаграммы складываются в буфер принимающего сокета и могут быть получены только раздельно: за одну операцию чтения из буфера программа, использующая сокет, может получить только одну дейтаграмму. Если в буфере лежит несколько дейтаграмм, потребуется несколько операций чтения, чтобы прочитать все. Кроме того, одну дейтаграмму нельзя получить из буфера по частям: она должна быть прочитана целиком за одну операцию. Чтобы данные, передаваемые разным сокетам, не перемешивались, каждый сокет должен получить уникальный в пределах узла номер от 0 до 65 535, называемый номером порта. При отправке дейтаграммы отправитель указывает IP-адрес и порт получателя, и система принимающей стороны находит сокет, привязанный к указанному порту, и помещает данные в его буфер. По сути дела, UDP является очень простой надстройкой над IP, все функции которой заключаются в том, что физический поток разделяется на несколько логических с помощью портов, и добавляется проверка целостности данных с помощью контрольной суммы (сам по себе протокол IP не гарантирует отсутствия искажений данных при передаче). Максимальный размер одной дейтаграммы IP равен 65 535 байтам. Из них не менее 20 байтов занимает заголовок IP. Заголовок UDP имеет размер 8 байтов. Таким образом, максимальный размер одной дейтаграммы UDP составляет 65 507 байтов. Типичная область применения UDP — программы, для которых потеря пакетов некритична. Например, некоторые сетевые 3D-игры в локальной сети используют UDP, т.к. очень часто посылают пакеты, информирующие о действиях игрока, и потеря одного пакета не приведет к существенным проблемам: следующий пакет доставит необходимые данные. Достоинства UDP — простота установления связи, возможность обмена данными с несколькими адресами через один сокет и отсутствие необходимости возобновлять соединение после разрыва связи. В некоторых задачах также очень удобно то, что дейтаграммы не смешиваются, и получатель всегда знает, какие данные были отправлены одной дейтаграммой, а какие — разными. Еще одно преимущество UDP — возможность отправки широковещательных дейтаграмм. Для этого нужно указать широковещательный IP-адрес (обычно 255.255.255.255, но в некоторых случаях допустимы адреса типа 192.168.100.225 для вещания в пределах сети 192.168.100.XX и т.п.), и такую дейтаграмму получат все сокеты в локальной сети, привязанные к заданному порту. Эту возможность нередко используют программы, которые заранее не знают, с какими компьютерами они должны связываться. Они посылают широковещательное сообщение и связываются со всеми узлами, которые распознали это сообщение и прислали на него соответствующий ответ. По умолчанию для широковещательных пакетов число маршрутизаторов, через которые они могут пройти (TTL), устанавливается равным нулю, поэтому такие пакеты не выходят за пределы подсети. 2.1.6. Протокол TCPПротокол TCP (Transmission Control Protocol — протокол управления передачей) является надежным потоковым протоколом с соединением, т.е. полной противоположностью UDP. Единственное, что у этих протоколов общее, — это способ адресации: в TCР каждому сокету также назначается уникальный номер порта. Уникальность номера порта требуется только в пределах протокола: два сокета могут иметь одинаковые номера портов, если один из них работает через TCP, а другой через UDP. В TCP предусмотрены так называемые хорошо известные (well-known) порты, которые зарезервированы для нужд системы и не должны использоваться программами. Стандарт TCP определяет диапазон хорошо известных портов от 0 до 255, в Windows и в некоторых других системах этот диапазон расширен до 0–1023. Часть портов UDP тоже выделена для системных нужд, но зарезервированного диапазона в UDP нет. Кроме того, некоторые системные утилиты используют порты за пределами диапазона 0–1023. Полный список системных портов для TCP и UDP содержится в MSDN, в разделе Resource Kits/Windows 2000 Server Resource Kit/TCP/IP Core Networking Appendixes/TCP and UDP Port Assignment. Для отправки пакета с помощью TCP отправителю необходимо сначала установить соединение с получателем. После выполнения этого действия соединенные таким образом сокеты могут использоваться только для отправки сообщений друг другу. Если соединение разрывается (самой программой из-за проблем в сети), эти сокеты уже непригодны для установления нового соединения: они должны быть уничтожены, а вместо них созданы новые сокеты. Механизм соединения, принятый в TCP, подразумевает разделение ролей соединяемых сторон: одна из них пассивно ждет, когда кто-то установит с ней соединение, и называется сервером, другая самостоятельно устанавливает соединение и называется клиентом. Действия клиента по установке связи заключаются в следующем: создать сокет, привязать его к адресу и порту вызвать функцию для установки соединения, передав ей адрес сервера. Если все эти операции выполнены успешно, то связь установлена, и можно начинать обмен данными. Действия сервера выглядят следующим образом: создать сокет, привязать его к адресу и порту, перевести в режим ожидания соединения и дождаться соединения. При соединении система создаст на стороне сервера специальный сокет, который будет связан с соединившимся клиентом, и обмениваться данными с подключившимся клиентом сервер будет через этот новый сокет. Старый сокет останется в режиме ожидания соединения. и другой клиент сможет к нему подключиться. Для каждого нового подключения будет создаваться новый сокет, обслуживающий только данное соединение, а исходный сокет будет по-прежнему ожидать соединения. Это позволяет нескольким клиентам одновременно соединяться с одним сервером, а серверу — не путаться в своих клиентах. Точное число клиентов, которые могут одновременно работать с сервером, в документации не приводится, но оно достаточно велико. Установление такого соединения позволяет осуществлять дополнительный контроль прохождения пакетов. В рамках протокола TCP выполняется проверка доставки пакета, соблюдения очередности и отсутствия дублей. Механизмы обеспечения надежности достаточно сложны, и мы их здесь рассматривать не будем. Программисту для начала достаточно знать, что данные, переданные с помощью TCP, не теряются, не дублируются и доставляются в том порядке, в каком были отправлены. В противном случае отправитель получает сообщение об ошибке. Соединенные сокеты время от времени обмениваются между собой специальными пакетами, чтобы убедиться в наличии соединения. Если из-за неполадок в сети произошел разрыв связи, при попытке отправить данные или прочитать их клиент получит отказ, а соединение будет разорвано. После этого клиент должен уничтожить сокет, создать новый и повторить подключение. Сервер также получает ошибку на сокете, обслуживающем данное соединение, но существенно позже (эта задержка может достигать часа). При обнаружении ошибки сервер просто уничтожает сокет и ждет нового подключения от клиента. Возможна ситуация, когда клиент уже подключился заново и для него создан новый сокет, а старый сокет еще не закрыт. Это не является существенной проблемой — на старом сокете рано или поздно будет получена ошибка, и он будет закрыт. Тем не менее сервер может учитывать такую ситуацию и уничтожать старый сокет, не дожидаясь, пока на нем будет получена ошибка, если новое соединение с тем же клиентом уже установлено. На исходный сокет, находящийся в режиме ожидания подключения, физические разрывы связи никак не влияют, после восстановления связи никаких действий с ним проводить не нужно. Если на клиентской стороне не удалось для новою сокета установить соединение с сервером с первого раза (из-за отсутствия связи или неработоспособности сервера), этот сокет не обязательно уничтожать: он может использоваться при последующих попытках установления связи неограниченное число раз, пока связь не будет установлена. Протокол TCP называется потоковым потому, что он собирает входящие пакеты в один поток. В частности, если в буфере сокета лежат 30 байтов, принятые по сети, не существует возможности определить, были ли эти 30 байтов отправлены одним пакетом, 30 пакетами по 1 байт, или еще как-либо. Гарантируется только то. что порядок байтов в буфере совпадает с тем порядком, в котором они были отправлены. Принимающая сторона также не ограничена в том, как она будет читать информацию из буфера: все сразу или по частям. Это существенно отличает TCP от UDP, в котором дейтаграммы не объединяются и не разбиваются на части. Склеивание пакетов осуществляется не только принимающей, но и отправляющей стороной. Библиотека сокетов может придержать в выходном буфере то, что кладет программа, и потом отправить одним пакетом данные, которые программа складывала в буфер постепенно. И наоборот, данные большого объема могут быть отправлены по частям, поэтому возможна ситуация, когда принимающая сторона находит в своем буфере только часть сообщения, посланного своим визави. Это значит, что оставшаяся часть сообщения придет позже. Будут ли пакеты сливаться или разбиваться на части, зависит от пропускной способности и текущей загрузке сети, определяется это алгоритмом, который называется алгоритмом Нагла. TCP применяется там, где программа не хочет заботиться о проверке целостности данных. За отсутствие этой проверки приходится растачиваться более сложной процедурой установления и восстановления связи. Если при использовании UDP сообщение не будет отправлено из-за проблем в сети или на удаленной стороне, никаких действий перед отправкой следующего сообщения выполнять не нужно и можно использовать тот же сокет. В случае же TCP, как уже было сказано, необходимо сначала уничтожать старый сокет, затем создать новый и подключить его к серверу, и только потом можно будет снова отправлять сообщения. Другим недостатком TCP по сравнению с UDP является то, что через один TCP-сокет все пакеты отправляются только по одному адресу, в то время как через UDP-сокет разные пакеты могут быть отправлены по разным адресам. И наконец, TCP не позволяет рассылать широковещательные сообщения. Но, несмотря на эти неудобства, TCP применяется существенно чаще UDP, потому что автоматическая проверка целостности данных и гарантия их доставки является очень важным преимуществом. Кроме того, TCP гарантирует более высокую безопасность в Интернете (это связано с тем, что обеспечить безопасность при передаче данных легче при наличии соединения, а не в ситуации, когда пакеты могут передаваться от кого угодно кому угодно). То, что TCP склеивает данные в один поток, не всегда удобно. Во многих случаях пакеты, приходящие по сети, обрабатываются отдельно, поэтому читать их из буфера желательно тоже по одному. Это просто сделать, если все пакеты имеют одинаковую длину. Но при различной длине пакетов принимающая сторона заранее не знает, сколько байтов нужно прочитать из буфера, чтобы получить ровно один пакет и ни байта больше. Чтобы обойти эту ситуацию, в пакете можно предусмотреть обязательный заголовок фиксированной длины, одно из полей которого хранит длину пакета. В этом случае принимающая сторона может читать пакет по частям: сначала заголовок известной длины, а потом тело пакета, размер которого стал известен благодаря заголовку. Другой способ разделения пакетов — вставка между ними заранее оговоренной последовательности байтов, которая не может появиться внутри пакета. Но самое неудобное то, что пакеты не только склеиваются, но и разбиваются на части. Принимающая сторона может получить пакет меньшего размера, чем отправленный, если этот пакет был послан по частям, и на момент его чтения принимающей стороной еще не все части были получены. Тогда приходится повторять операцию чтения данных, пока не будет получено все, что нужно. В отличие от UDP, в TCP данные, которые программа отправляет одной командой, могут разбиваться на части и отправляться несколькими IP-пакетами. Поэтому ограничение на длину данных, отправляемых за один раз, в TCP отсутствует (точнее, определяется доступными ресурсами системы). Количество данных, получаемое отправителем за одну операцию чтения, ограничено размером низкоуровневого буфера сокета и может быть разным в различных реализациях. Следует иметь в виду, что при переполнении буфера принимающей стороны протокол TCP предусматривает передачу отправляющей стороне сигнала, по которому она приостанавливает отправку, причём этот сигнал прерывает всю передачу данных между этими двумя компьютерами с помощью TCP, т.е. это может повлиять и на другие программы. Поэтому желательно не допускать таких ситуаций, когда у принимающей стороны в буфере накапливается много данных. 2.1.7. Сетевые экраныСеть не только позволяет пересылать полезные данные, но и может служить путем проникновения вредоносных программ, несанкционированного доступа к данным и т.п. С этим, естественно, борются, и один из способов борьбы — сетевые экраны (они же брандмауэры, иди firewalls). Мы здесь не будем детально знакомиться с ними, но затронем эту тему, потому что сетевые экраны могут повлиять на работоспособность наших примеров. Сетевые экраны бывают аппаратными и программными. Их общий принцип действия заключается в проверке пакетов, идущих по сети, и блокировании тех из них, которые не удовлетворяют заданным критериям. Критерии могут быть различными и зависят от настройки конкретного сетевого экрана. Все пакеты делятся на входящие и исходящие. Для входящих UDP-сообщений обычно оставляют открытыми некоторые порты, а все сообщения, присланные на другие порты, отсекаются. Для исходящих сообщений тоже может быть задан набор портов, но обычно сетевые экраны осуществляют проверку по-другому: у них есть список приложений, которым разрешено отправлять исходящие UDP-сообщения, а пакеты, отправляемые другими приложениями, сетевой экран не пропускает. Для протокола TCP настройки обычно задаются на уровне соединения, а не отдельного пакета. Составляется список портов, открытых для внешнего подключения. Если сервер использует порт не из этого набора, клиент не сможет к нему подключиться. Для исходящих подключений тоже составляется список программ, которым разрешено это к делать, и, если клиент, отсутствующий в "белом" списке сетевого экрана, пытается подключиться к удаленному серверу, сетевой экран не допускает этого. Примечание При тестировании своих примеров или примеров из этой книги вы можете столкнуться с тем, что программы по непонятным причинам не могут обмениваться данными по сети, и это может объясняться работой сетевых экранов. Проконсультируйтесь у администратора сети насчет настроек сетевых экранов в вашей сети и согласуйте с ним возможность работы с теми или иными портами и запуска ваших приложений. 2.1.8. Создание сокетаДо сих пор мы обсуждали только теоретические аспекты работы с сокетами. Далее будут рассматриваться конкретные функции, позволяющие осуществлять те или иные операции с сокетами. Эти функции экспортируются системной библиотекой wsock32.dll (а также библиотекой ws2_32.dll; взаимоотношение этих библиотек будет обсуждаться во втором разделе данной главы), для их использования в Delphi в раздел uses нужно добавить стандартный модуль WinSock. Полное формальное описание функций этого модуля здесь приводиться не будет (для этого есть MSDN), но для каждой функции будет дано описание, достаточно полное для понимания ее предназначения. Кроме того, мы будем также обращать внимание на некоторые моменты, которые в MSDN найти трудно. Тем не менее после знакомства с этим текстом настоятельно рекомендуется самостоятельно прочитать в MSDN описания всех упомянутых в нем функций. Хотя ранее мы договорились, что будем обсуждать только стандартные сокеты, тем не менее, есть три функции, относящиеся к сокетам Windows, не познакомившись с которыми мы не сможем двигаться дальше. Это функции WSAStartup, WSACleanupи WSAGetLastError(префикс WSA означает Windows Sockets API и служит для именования большинства функций, относящихся к Windows-расширению библиотеки сокетов). Функция WSAStartupпредназначена для инициализации библиотеки сокетов. Эту функцию необходимо вызвать до вызова любой другой функции из этой библиотеки. Ее прототип имеет вид: function WSAStartup(wVersionRequired: Word; var WSData: TWSAData): Integer; Параметр wVersionRequiredзадает требуемую версию библиотеки сокетов. Младший байт задает основную версию, старший — дополнительную. Допустимы версии 1.0 ($0001), 1.1 ($0101), 2.0 ($0002) и 2.2 ($0202). Пока мы работаем со стандартными сокетами, принципиальной разницы между этими версиями нет, но версии 2.0 и выше пока лучше не использовать, т.к. модуль WinSockне рассчитан на их поддержку. Вопросы взаимоотношения библиотек и версий будут рассматриваться во втором разделе этой главы, а пока ограничимся версией 1.1. Параметр WSDataвыходной, т.е. значение, которое имела переменная до вызова функции, игнорируется, а имеет смысл только то значение, которое эта переменная получит после вызова функции. Через этот параметр передается дополнительная информация о библиотеке сокетов. В большинстве случаев эти сведения не представляют никакого интереса, поэтому их можно игнорировать. Нулевое значение, возвращаемое функцией, говорит об успешном завершении, в противном случае возвращается код ошибки. Обычно функция, завершившаяся с ошибкой, возвращает значение SOCKET_ERROR. Функция WSACleanupзавершает работу с библиотекой сокетов. Эта функция не имеет параметров и возвращает ноль в случае успешного завершения или код ошибки в противном случае. Функцию WSAStartupдостаточно вызвать один раз, даже в многонитевом приложении, в этом ее отличие от таких функций, как, например, CoInitialize, которая должна быть вызвана в каждой нити, использующей COM. Функцию можно вызывать повторно — в этом случае ее вызов не дает никакого эффекта, но для завершения работы с библиотекой сокетов функция WSACleanupдолжна быть вызвана столько же раз, сколько была вызвана WSAStartup. Большинство функций библиотеки сокетов возвращают значение, позволяющее судить только об успешном или неуспешном завершении операции, но не дающее информации о том, какая именно ошибка произошла (если она произошла). Для получения сведений об ошибке служит функция WSAGetLastError, не имеющая параметров и возвращающая целочисленный код последней ошибки, произошедшей в библиотеке сокетов в данной нити. После неудачного завершения функции из библиотеки сокетов следует вызывать функцию WSAGetLastError, чтобы выяснить причину неудачи. Забегая чуть вперед, отметим, что библиотека сокетов содержит стандартную функцию getsockopt, которая, кроме всего прочего, также позволяет получить информацию об ошибке. Однако она менее удобна, поэтому в тех случаях, когда не требуется совместимость с другими платформами, лучше прибегнуть к WSAGetLastError. К тому же, getsockoptвозвращает ошибку, связанную с указанным сокетом, поэтому с её помощью нельзя получить код ошибки, не связанной с конкретным сокетом. Для создания сокета предусмотрена стандартная функция socketсо следующим прототипом: function socket(af, struct, protocol: Integer): TSocket; Параметр аfзадаёт семейство адресов (address family). Этот параметр определяет, какой способ адресации (т.е. по сути дела, какой стек протоколов) будет использоваться для данного сокета. Для TCP/IP этот параметр должен быть равен AF_INET, для других стеков также есть соответствующие константы, которые можно посмотреть в файле WinSock.pas. Параметр structуказывает тип сокета и может принимать одно из двух значений: SOCK_STREAM(для потоковых протоколов) и SOCK_DGRAM(для дейтаграммных протоколов). Параметр protocolпозволяет указать, какой именно протокол будет использоваться сокетом. Этот параметр можно оставить равным нулю — тогда будет выбран протокол по умолчанию, отвечающий условиям, заданным первыми двумя параметрами. Для стека TCP/IP потоковый протокол по умолчанию — TCP, дейтаграммный — UDP. В некоторых примерах можно увидеть значение третьего параметра равно IPPROTO_IP. Значение этой константы равно 0, и ее использование только повышает читабельность кода, но приводит к тому же результату: будет выбран протокол по умолчанию. Если требуется протокол, отличный от протокола по умолчанию (например, в некоторых реализациях стека TCP/IP существует протокол RDP — Reliable Datagram Protocol, надежный дейтаграммный протокол), следует указать здесь соответствующую константу (для RDP это будет IPPROTO_RDP). Можно также явно задать TCP или UDP с помощью констант IPPROTO_TCPи IPPROTO_UDPсоответственно. Тип TSocketпредназначен для хранения дескриптора сокета. Формально он совпадает с 32-битным беззнаковым целым типом, но об этом лучше не вспоминать, т.к. любые операции над значениями типа TSocketбессмысленны. Значение, возвращаемое функцией socket, следует сохранить в переменной соответствующего типа и затем использовать для идентификации сокета при вызове других функций. Если по каким-то причинам создание сокета невозможно, функция вернет значение INVALID_SOCKET. Причину ошибки можно узнать с помощью функции WSAGetLastError. Сокет, созданный с помощью функции socket, не привязан ни к какому адресу. Привязка осуществляется с помощью функции bind, имеющей следующий прототип: function bind(s: TSocket; var addr: TSockAddr; namelen: Integer): Integer; Первый параметр этой функции — дескриптор сокета. который привязывается к адресу. Здесь, как и в остальных подобных случаях, требуется передать значение, которое вернула функция socket. Второй параметр содержит адрес, к которому требуется привязать сокет, а третий — длину структуры, содержащей адрес. Функция bindпредназначена для сокетов, реализующих разные протоколы из разных стеков, поэтому кодирование адреса в ней сделано достаточно универсальным. Впрочем, следует отметить, что разработчики модуля WinSockдля Delphi выбрали не лучший способ перевода прототипа этой функции на Паскаль, поэтому универсальность в значительной мере утрачена. В оригинале прототип функции bindимеет следующий вид: int bind(SOCKET s, const struct sockaddr FAR* name, int namelen); Видно, что второй параметр — это указатель на структуру sockaddr. Однако C/C++ позволяет при вызове функции в качестве параметра передать указатель на любую другую структуру, если будет выполнено явное приведение типов. Для каждого семейства адресов предусмотрена своя структура, и в качестве фактического параметра передастся указатель на эту структурy. Если бы авторы модуля WinSock описали второй параметр как параметр-значение типа указатель, можно было бы поступать точно так же. Однако они описали этот параметр как параметр-переменную. В результате на двоичном уровне ничего не изменилось: и там, и там в стек помещается указатель. Однако компилятор при вызове функции bindне допустит использования никакой другой структуры, кроме TSockAddr, а эта структура не универсальна и удобна, по сути дела, только при использовании стека TCP/IP. В других случаях наилучшим решением будет самостоятельно импортировать функцию bindиз wsock32.dll с нужным прототипом. При этом придется импортировать и некоторые другие функции, работающие с адресами. Впрочем мы здесь ограничиваемся только протоколами TCP и UDP, поэтому больше останавливаться на этом вопросе не будем. Примечание В стандартной библиотеке сокетов (т.е. в заголовочных файлах для этой библиотеки) полагается, что адрес кодируется структурой sockaddrдлиной 16 байтов, причем первые два байта этой структуры кодируют семейство протоколов, а смысл остальных зависит от этого семейства. В частности, для стека TCP/IP семейство протоколов задается константой PF_INET. (Ранее мы уже встречались с термином "семейство адресов" и константой AF_INET. В ранних версиях библиотеки сокетов семейства протоколов и семейства адресов были разными понятиями, но затем эти понятия слились в одно, и константы AF_XXXи PF_XXXстали взаимозаменяемыми). Остальные 14 байтов структуры sockaddrзанимает массив типа char(напомним, что тип charв C/C++ соответствует одновременно двум типам Delphi: Charи ShortInt). В принципе, в стандартной библиотеке сокетов предполагается, что структура, задающая адрес, всегда имеет длину 16 байтов, но на всякий случай предусмотрен третий параметр функции bind, который хранит длину структуры. В сокетах Windows длина структуры может быть любой (это зависит от протокола), так что этот параметр, в принципе, может пригодиться. Ранее уже упоминалось, что неструктурированное представление адреса в виде массива из 14 байтов бывает неудобно, и поэтому для каждого семейства протоколов предусмотрена своя структура, учитывающая особенности адреса. В частности, для протоколов стека TCP/IP используется структура sockaddr_in, размер которой также составляет 16 байтов. Из них задействовано только восемь: два для кодирования семейства протоколов, четыре для IP-адреса и два — для порта. Оставшиеся 8 байтов должны содержать нули. Можно было бы предположить, что типы TSockAddrи TSockAddrIn, описанные в модуле WinSock, соответствуют структурам sockaddrи sockaddr_in, однако это не так. На самом деле эти типы описаны следующим образом (листинг 2.1). Листинг 2.1. Типы TSockAddrи TSockAddrIn SunB = packed record s_b1, s_b2, s_b3, s_b4: u_char; end; SunW = packed record s_w1, s_w2: u_short; end; in_addr = record case Integer of 0: (S_un_b: SunB); 1: (S_un_w: SunW); 2: (S_addr: u_long); end; TInAddr = in_addr; sockaddr_in = record case Integer of 0: ( sin_family: u_short; sin_port: u_short; sin_addr: TInAddr; sin_zero: array[0..7] of Char); 1: ( sa_family: u_short; sa_data: array[0..13] of Char); end; TSockAddrIn = sockaddr_in; TSockAddr = sockaddr_in; Таким образом, типы TSockAddrи TSockAddrIn— это синонимы типа sockaddr_in(но не того sockaddr_in, который имеется в стандартной библиотеке сокетов, а типа sockaddr_in, описанного в модуле WinSock). А тип sockaddr_inиз WinSockявляется вариантной записью, и в случае 0 соответствует типу sockaddr_inиз стандартной библиотеки сокетов, а в случае 1 — sockaddrиз этой же библиотеки. Вот такая несколько запутанная ситуация, хотя на практике все выглядит не так страшно. Примечание Перейдем, наконец, к более практически важному вопросу: какими значениями следует заполнять переменную типа TSockAddr, чтобы при передаче ее в функцию bindсокет был привязан к нужному адресу. Так как мы ограничиваемся рассмотрением протоколов TCP и UDP, нас не интересует та часть вариантной записи sockaddr_in, которая соответствует случаю 1, т.е. мы будем рассматривать только те поля этой структуры, которые имеют префикс sin. Поле sin_zero, очевидно, должно содержать массив нулей. Это то самое поле, которое не несет никакой смысловой нагрузки и служит только для увеличения размера структуры до стандартных 16 байтов. Поле sin_family, должно иметь значение PF_INET. В поле sin_portзаписывается номер порта, к которому привязывается сокет. Номер порта должен быть записан в сетевом формате, т.е. здесь необходимо прибегать к функции htons, чтобы из привычной нам записи номера порта получить число в требуемом формате. Номер порта можно оставить нулевым, тогда система выберет для сокета свободный порт с номером от 1024 до 5000. IP-адрес для привязки сокета задается полем sin_addr, которое имеет тип TInAddr. Этот тип сам является вариантной записью, которая отражает три способа задания IP-адреса: в виде 32-битного числа, в виде двух 16-битных чисел или в виде четырех 8-битных чисел. На практике чаще всего встречается формат в виде четырех 8-битных чисел, реже — в виде 32-битного числа. Задание адресов в виде двух 16-битных чисел или двух 8-битных и одного 16-битного числа относится к очень редко встречающейся экзотике. Пусть у нас есть переменная Addrтипа TSockAddr, и нам требуется ее поле sin_addrзаписать адрес 192.168.200.217. Это можно сделать так, как показано в листинге 2.2. Листинг 2.2. Прямое присваивание IP-адреса Addr.sin_addr.S_un_b.s_b1 := 192; Addr.sin_addr.S_un_b.s_b2 := 168; Addr.sin_addr.S_un_b.s_b3 := 200; Addr.sin_addr.S_un_b.s_b4 := 217; Существует альтернатива такому присвоению четырех полей по отдельности — функция inet_addr. Эта функция в качестве входного параметра принимает строку, в которой записан IP-адрес, и возвращает этот IP-адрес в формате 32-битного числа. С использованием функции inet_addrприведенный в листинге 2.2 код можно переписать так: Addr.sin_addr.S_addr := inet_addr('192.168.200.217'); Функция inet_addrвыполняет простой парсинг строки и не проверяет, существует ли такой адрес на самом деле. Поля адреса можно задавать в десятичном, восьмеричном и шестнадцатеричном форматах. Восьмеричное поле должно начинаться с нуля, шестнадцатеричное — с "0x". Приведенный адрес можно записать в виде "0300.0250.0310.0331" (восьмеричный) или "0xC0.0xA8.0xC8.0xD9" (шестнадцатеричный). Допускается также смешанный формат записи, в котором разные поля заданы в разных системах исчисления. Функция inet_addrподдерживает также менее распространенные форматы записи IP-адреса в виде трех полей. Подробнее об этом можно прочитать в MSDN. Примечание В библиотеке сокетов предусмотрена константа INADDR_ANY, позволяющая не указывать явно адрес в программе, а оставить его выбор на усмотрение системы. Для этого полю sin_addr.S_addrследует присвоить значение INADDR_ANY. Если IP-адрес компьютеру не назначен, то при использовании этой константы сокет будет привязан к локальному адресу 127.0.0.1. Если компьютеру назначен один IP-адрес, сокет будет привязан к этому адресу. Если компьютеру назначено несколько IP-адресов, то будет выбран один из них, причем сама привязка при этом отложится до установления соединения (в случае TCP) или до первой отправки данных через сокет (в случае UDP). Выбор конкретного адреса при этом зависит от того, какой адрес имеет удалённая сторона. Итак, резюмируем все сказанное. Пусть у нас есть сокет S, который нужно привязать, например, к адресу 192.168.200.217и порту 3320. Для этого следует выполнить код листинга 2.3. Листинг 2.3. Привязка сокета к конкретному адресу Addr.sin_family := PF_INET; Addr.sin_addr.S_addr := inet_addr('192.168.200.217'); Addr.sin_port := htons(3320); FillChar(Addr.sin_zero, SizeOf(Addr.sin_zero), 0); if bind(S, Addr, SizeOf(Addr)) = SOCKET_ERROR then begin // какая-то ошибка, анализируем с помощью WSAGetLastError end; FillChar— это стандартная процедура Паскаля, заполняющая некоторую область памяти заданным значением. В данном случае мы применяем ее для заполнения нулями поля sin_zero. Для этой же цели пригодна функция Windows API ZeroMemory. В примерах на С/C++ обычно используется функция memset. Теперь рассмотрим другой случай: пусть выбор адреса и порта можно оставить на усмотрение системы. Тогда код будет выглядеть так, как показано в листинге 2.4. Листинг 2.4. Привязка сокета к адресу, выбираемому системойAddr.sin_family := PF_INET; Addr.sin_addr.S_addr := INADDR_ANY; Addr.sin_port := 0; FillChar(Addr.sin_zero, SizeOf(Addr.sin_zero), 0); if bind(S, Addr, SizeOf(Addr)) = SOCKET_ERROR then begin // какая-то ошибка, анализируем с помощью WSAGetLastError end; В случае TCP сервер сам не является инициатором подключения, но может работать с любым подключившимся клиентом, какой бы у него ни был адрес. Для сервера принципиально, какой порт он будет использовать — если порт не определен заранее, клиент не будет знать, куда подключаться. Поэтому номер порта является важным признаком для сервера. (Иногда, впрочем встречаются серверы, порт которых заранее неизвестен, но в таких случаях всегда существует другой канал передачи данных, позволяющий клиенту до подключения узнать, какой порт задействован в данный момент сервером. С другой стороны, клиенту обычно непринципиально, какой порт будет у его сокета, поэтому чаще всего серверу назначается фиксированный порт, а клиент оставляет выбор системе. Протокол UDP не поддерживает соединение, но при его применении часто одно приложение тоже можно условно назвать сервером, а другое — клиентом. Сервер создает сокет и ждет, когда кто-нибудь что-нибудь пришлет и высылает что-то в ответ, а клиент сам отправляет что-то куда-то. Поэтому, как и в случае TCP, сервер должен использовать фиксированный порт, а клиент может выбирать любой свободный. Если у компьютера только один IP-адрес, то выбор адреса для сокета и клиент, и сервер могут доверить системе. Если компьютер имеет несколько интерфейсов к одной сети, каждый со своим IP-адресом, то выбор конкретного адреса в большинстве случаев также непринципиален и может быть оставлен на усмотрение системы. Проблемы возникают, когда у компьютера несколько сетевых интерфейсов, каждый из которых включен в свою сеть. В этом случае выбор того или иного IP-адреса для сокета привязывает его к одной из сетей, и только к одной. Поэтому нужно принять меры для того, чтобы сокет оказался привязан к той сети, в которой находится его адресат. Ранее мы уже говорили, что в системах с несколькими сетевыми картами привязка сокета к адресу в том случае, когда его выбор доверен системе, может осуществляться не во время выполнения функции bind, а позже, когда системе станет понятно, зачем используется этот сокет. Например, когда TCP-клиент осуществляет подключение к серверу, система по адресу этого сервера определяет, через какую карту должен идти обмен, и выбирает соответствующий адрес. То же самое происходит с UDP-клиентом: когда он отправляет первую дейтаграмму, система по адресу получателя определяет, к какой карте следует привязать сокет. Поэтому клиент и в данном случае может оставить выбор адреса на усмотрение системы. С серверами все несколько сложнее. Система привязывает сокет UDP-сервера к адресу, он ожидает получения пакета. В этот момент система не имеет никакой информации о том, с какими узлами будет вестись обмен через данный сокет, и может выбрать не тот адрес, который нужен. Поэтому сокеты UDP-серверов, работающих в подобных системах, должны явно привязываться к требуемому адресу. Сокеты TCP-серверов, находящиеся в режиме ожидания и имеющие адрес INADDR_ANY, допускают подключение к ним по любому сетевому интерфейсу, который имеется в системе. Сокет, который создается таким сервером при подключении клиента, будет автоматически привязан к IP-адресу того сетевого интерфейса, через который осуществляется взаимодействие с подключившимся клиентом. Таким образом, сокеты, созданные для взаимодействия с разными клиентами, могут оказаться привязанными к разным адресам. После успешного завершения функций socketи bindсокет создан и готов к работе. Дальнейшие действия с ним зависят от того, какой протокол он реализует и для какой роли предназначен. Мы разберем эти операции в разделах, посвященных соответствующим протоколам. Там же мы увидим, что в некоторых случаях можно обойтись без вызова функции bind, поскольку она будет неявно вызвана при вызове других функций библиотеки сокетов. Когда сокет больше не нужен, следует освободить связанные с ним ресурсы. Это выполняется в два этапа: сначала сокет "выключается", а потом закрывается. Для выключения сокета предусмотрена функция shutdown, имеющая следующий прототип: function shutdown(s: TSocket; how: Integer): Integer; Параметр sопределяет сокет, который необходимо выключить, параметр howможет принимать значения SD_RECEIVE, SD_SENDили SD_BOTH. Функция возвращает ноль при успешном выполнении и SOCKET_ERROR— в случае ошибки. Вызов функции с параметром SD_RECEIVEзапрещает чтение данных из входного буфера сокета. Однако на у ровне протокола вызов этой функции игнорируется: дейтаграммы UDP и пакеты TCP, посланные данному сокету, продолжают помещаться в буфер, хотя программа уже не может их оттуда забрать. При указании значения SD_SENDфункция запрещает отправку данных через сокет. В случае протокола TCP при этом удаленный сокет получает специальный сигнал, предусмотренный данным протоколом, уведомляющий о том, что больше данные посылаться не будут. Если на момент вызова shutdownв буфере для исходящих остаются данные, сначала посылаются они. а потом только сигнал о завершении. Поскольку протокол UDP подобных сигналов не предусматривает, то в этом случае shutdownпросто запрещает библиотеке сокетов использовать указанный сокет для отправки данных. Параметр SD_BOTHпозволяет одновременно запретить и прием, и передачу данных через сокет. ПримечаниеЛистинг 2.5. Объявление констант SD_XXX для Delphi 5 и более ранних версий const SD_RECEIVE = 0; SD_SEND = 1; SD_BOTH = 2; Для освобождения ресурсов, связанных с сокетом, служит функция closesocket, которая освобождает память, выделенную для буферов, и порт. Ее единственный параметр задает сокет, который требуется закрыть, а возвращаемое значение — ноль или SOCKET_ERROR. После вызова этой функции соответствующий дескриптор сокета перестает иметь смысл, и использовать его больше нельзя. По умолчанию функция closesocketнемедленно возвращает управление вызвавшей ее программе, а процесс закрытия сокета начинает выполняться в фоновом режиме. Под закрытием подразумевается не только освобождение ресурсов, но и отправка данных, которые остались в выходном буфере сокета. Вопрос о том, как изменить поведение функции closesocket, будет обсуждаться в разд. 2.1.17. Если сокет закрывается одной нитью в тот момент, когда другая нить пытается выполнить какую-либо операцию с этим сокетом, то эта операция завершается с ошибкой. Функция shutdownнужна в первую очередь для того, чтобы заранее сообщить партнеру по связи о намерении завершить связь, причем это имеет смысл только для протоколов, поддерживающих соединение. В случае UDP функцию shutdown вызывать практически бессмысленно, можно сразу вызывать closesocket. При использовании TCP удаленная сторона получает сигнал о выключении партнера, но стандартная библиотека сокетов не позволяет программе обнаружить его получение (такие функции есть в сокетах Windows, о чем мы будем говорить далее). Но этот сигнал может быть важен для внутрисистемных функций, реализующих сокеты. Windows-версия библиотеки сокетов относится к отсутствию данного сигнала достаточно либерально, поэтому вызов shutdown в том случае, когда и клиент, и сервер работают под управлением Windows, не обязателен. Но реализации TCP в других системах не всегда столь же снисходительно относятся к подобной небрежности. Результатом может стать долгое (до двух часов) "подвешенное" состояние сокета в той системе, когда с ним и работать уже нельзя, и информации об ошибке программа не получает. Поэтому в случае TCP лучше не пренебрегать вызовом shutdown, чтобы сокет на другой стороне не имел проблем. MSDN рекомендует следующий порядок закрытия TCP-сокета. Во-первых, сервер не должен закрывать свой сокет по собственной инициативе, он может это делать только после того, как был закрыт связанный с ним клиентский сокет. Клиент начинает закрытие сокета с вызова shutdownс параметром SD_SEND. Сервер после этого сначала получает все данные, которые оставались в буфере сокета клиента, а затем получает от клиента сигнал о завершении передачи. Тем не менее сокет клиента продолжает работать на прием, поэтому сервер при необходимости может на этом этапе послать клиенту какие-либо данные, если это необходимо. Затем сервер вызывает shutdownс параметром SD_SEND, и сразу после этого — closesocket. Клиент продолжает читать данные из входящего буфера сокета до тех пор, пока не будет получен сигнал о завершении передачи сервером. После этого клиент также вызывает closesocket. Такая последовательность гарантирует, что данные не будут потеряны, но, как мы уже обсуждали ранее, она не может быть реализована в рамках стандартных сокетов из-за невозможности получить сигнал о завершении передачи, посланный удаленной стороной. Поэтому на практике следует реализовывать упрощенный способ завершения связи: клиент вызывает shutdownс параметром SD_SENDили SD_BOTHи сразу после этого — closesocket. Сервер при попытке выполнить операцию с сокетом получает ошибку, после которой также вызывает closesocket. Вызов shutdownна стороне сервера при этом не нужен, т.к. в этот момент соединение уже потеряно, и высылать данные из буфера вместе с сигналом завершения уже некуда. 2.1.9. Передача данных при использовании UDPМы наконец-то добрались до изучения того, ради чего сокеты и создавались: как передавать и получать с их помощью данные. По традиции начнем рассмотрение с более простого протокола UDP. Функции, которые рассматриваются в этом разделе, могут работать и с другими протоколами, и от этого их поведение может меняться. Мы здесь описываем только их поведение при использовании UDP. Для передачи данных удалённому сокету предусмотрена функция sendto, описанная следующим образом: function sendto(s: TSocket; var Buf; len, flags: Integer; var addrto: TSockAddr; tolen: Integer): Integer; Первый параметр данной функции задаёт сокет, который служит для передачи данных. Здесь нужно указать значение, полученное ранее от функции socket. Параметр Bufзадаёт буфер, в котором хранятся данные для отправки, а параметр len— размер этих данных в байтах. Параметр flagsпозволяет указать некоторые дополнительные опции, которых мы здесь касаться не будем, т.к. в большинстве случаев они не нужны. Пока следует запомнить, что параметр flagsв функции sendto, а также в других функциях, где он встречается, должен быть равен нулю. Параметр addrtoзадает адрес (состоящий из IP-адреса и порта) удаленного сокета, который должен получить эти данные. Значение параметра addrtoдолжно формироваться по тем же правилам, что значение аналогичного параметра функции bind, за исключением того, что IP-адрес и порт должны быть заданы явно (т.е. не допускаются значения INADDR_ANYи нулевой номера порта). Параметр tolenзадает длину буфера, отведенного для адреса, и должен быть равен SizeOf(TSockAddr). Один вызов функции sendtoприводит к отправке одной дейтаграммы. Данные, переданные в sendto, никогда не разбиваются на несколько дейтаграмм, и данные, переданные последовательными вызовами sendto, никогда не объединяются в одну дейтаграмму. Функцию sendtoможно использовать с сокетами, не привязанными к адресу. В этом случае внутри библиотеки сокетов будет неявно вызвана функция bindдля привязки сокета к адресу INADDR_ANYи нулевому порту (т.е. адрес и порт будут выбраны системой). Если выходной буфер сокета имеет ненулевой размер, sendtoпомещает данные в этот буфер и сразу возвращает управление программе, а собственно отправка данных осуществляется библиотекой сокетов в фоновом режиме. Поэтому успешное завершение sendtoгарантирует только то, что данные скопированы в буфер и что на момент их копирования не обнаружено никаких проблем, которые делали бы невозможной их отправку. Но такие проблемы могут возникнуть позже, поэтому даже в случае успешного завершения sendtoотправитель не получает гарантии, что данные посланы. Если в выходном буфере сокета не хватает места для новой порции данных, sendtoне возвращает управление программе (т.е. блокирует ее) до тех пор, пока в буфере за счет фоновой отправки не появится достаточно места или не будет обнаружена ошибка. Если размер выходного буфера сокета равен нулю, функция sendtoкопирует данные сразу в сеть, без промежуточной буферизации. Когда функция вернет управление программе, программа может быть уверена, что информация уже успешно передана в сеть. Однако даже в этом случае успешное завершение sendtoне гарантирует доставку информации: дейтаграмма может потеряться по дороге. В случае успешного завершения функция sendtoвозвращает количество байтов, скопированных в буфер (или переданных напрямую в сеть, если буфера нет). Для протокола UDP это значение может быть равно только значению параметра len, хотя для некоторых других протоколов (например, TCP) возможны ситуации, когда в буфер сокета копируется только часть данных, переданных программой, и тогда sendtoвозвращает значение в диапазоне от 1 до len. Если при выполнении sendtoвозникает ошибка, она возвращает значение SOCKET_ERROR(эта константа имеет отрицательное значение). Для получения данных, присланных сокету, предназначена функция recvfrom, имеющая следующий прототип: function recvfrom(s: TSocket; var Buf; len, flags: Integer; var from: TSockAddr; var fromlen: Integer): Integer; Параметр sзадает сокет, из входного буфера которого будут извлекаться данные, Buf— буфер, в который эти данные будут копироваться, а len — размер этого буфера. Параметр flagsзадает дополнительные опции и в большинстве случаев должен быть равен нулю. Параметр fromвыходной: в него помещается адрес, с которого была послана дейтаграмма. Параметр fromlenзадает размер в байтах буфера для адреса отправителя. При вызове функции значение переменной, подставляемой в качестве фактического параметра, должно быть равно SizeOf(TSockAddr). Функция меняет это значение на ту длину, которая реально потребовалась для хранения адреса отправителя (в случае UDP это значение также будет равно SizeOf(TSockAddr). В оригинале параметры fromи fromlenпередаются как указатели, и программа может использовать вместо них нулевые указатели, если ее не интересует адрес отправителя. Разработчики модуля WinSockзаменили указатели параметрами-переменными, что в большинстве случаев удобнее. Но для передачи нулевых указателей приходится в качестве фактических параметров подставлять неуклюжие конструкции PSockAddr(nil)^и PInteger(nil)^. Функция reсvfromвсегда читает только одну дейтаграмму, даже если размер переданного ей буфера достаточен для чтения нескольких дейтаграмм. Если на момент вызова recvfromдейтаграммы во входном буфере сокета отсутствуют, функция будет ждать, пока они там появятся, и до этого момента не вернет управление вызвавшей её программе. Если в буфере находится несколько дейтаграмм, то они читаются в порядке очередности поступления в буфер. Напомним, что дейтаграммы могут поступать в буфер не в том порядке, в котором они были отправлены. Кроме того, в очень редких случаях буфер может содержать несколько копий одной дейтаграммы, каждую из которых нужно извлекать отдельно. Значение, возвращаемое функцией recvfrom, равно длине прочитанной дейтаграммы. Это значение может быть равно нулю, т.к. UDP позволяет отправлять дейтаграммы нулевой длины (для этого при вызове sendtoнужно задать параметр lenравным нулю). Если обнаружена какая-то ошибка, возвращается значение SOCKET_ERROR. Если размер буфера, определяемого параметром Buf, меньше, чем первая находящаяся во входном буфере сокета дейтаграмма, то копируется только часть дейтаграммы, помещающаяся в буфере, a recvfromзавершается с ошибкой ( WSAGetLastErrorпри этом вернет ошибку WSAEMSGSSIZE). Оставшаяся часть дейтаграммы при этом безвозвратно теряется, при следующем вызове recvfromбудет прочитана следующая дейтаграмма. Этой проблемы легко избежать, т.к. длина дейтаграммы в UDP не может превышать 65 507 байтов. Достаточно подготовить буфер соответствующей длины, и и в него гарантированно поместится любая дейтаграмма. Другой способ избежать подобной проблемы — использовать флаг MSG_PEEK. В этом случае дейтаграмма не удаляется из входного буфера сокета, а значение, возвращаемое функцией recvfrom, равно длине дейтаграммы. При этом в буфер, заданный параметром Buf, копируется та часть дейтаграммы, которая в нем помещается. Программа может действовать следующим образом: вызвать recvfromс флагом MSG_PEEK, выделить память, требуемую для хранения дейтаграммы, вызвать recvfromбез флага MSG_PEEK, чтобы прочитать дейтаграмму целиком и удалить ее из входного буфера сокета. Этот метод сложнее, а 65 507 байтов — не очень большая по нынешним меркам память, поэтому легче все-таки заранее приготовить буфер фиксированной длины. Функция recvfromнепригодна для тех сокетов, которые еще не привязаны к адресу, поэтому перед вызовом этой функции должна быть вызвана либо функция bind, либо функция, которая осуществляет неявную привязку сокета к адресу (например, sendto). Протокол UDP не поддерживает соединения в том смысле, в котором их поддерживает TCP, но библиотека сокетов позволяет частично имитировать такие соединения, Для этого служит функция connect, имеющая следующий прототип: function connect(s: TSocket; var name: TSockAddr; namelen: Integer): Integer; Параметр sзадает сокет, который должен быть "соединен" с удаленным адресом. Адрес задается параметром name аналогично тому, как он задаётся в параметре addrфункции sendto. Параметр namelenсодержит длину структуры, описывающей адрес, и должен быть равен SizeOf(TSockAddr). Функция возвращает ноль при успешном завершении и SOCKET_ERROR— в случае ошибки. Вызов функции connectв случае UDP устанавливает фильтр для входящих дейтаграмм. Дейтаграммы, адрес отправителя которых не совпадает с адресом, заданным в функции connect, игнорируются: новые дейтаграммы не помещаются во входной буфер сокета, а те, которые находились там на момент вызова connect, удаляются из него. Функция connectне проверяет, существует ли адрес, с которым сокет "соединяется", и может успешно завершиться, даже если узла с таким IP-адресом нет. Программа может вызывать connect неограниченное число раз с разными адресами. Если параметр name задает IP-адрес INADDR_ANYи нулевой порт, то сокет "отсоединяется", т.е. все фильтры для него снимаются, и он ведет себя так же, как сокет, для которого не была вызвана функция connect. Для сокетов, не привязанных к адресу, connectнеявно вызывает bind. После вызова connectдля отправки данных можно использовать функцию sendсо следующим прототипом: function send(s: TSocket; var Buf; len, flags: Integer): Integer; От функции sendtoона отличается отсутствием параметров addrtoи tolen. При использовании sendдейтаграмма отправляется по адресу, заданному при вызове connect. В остальном эти функции ведут себя одинаково, функция sendtoпри работе с "соединенным" сокетом ведет себя так же, как с несоединенным, т.е. отправляет дейтаграмму по адресу, определяемому параметром addrlen, а не по адресу, заданному при вызове connect. Получение данных через "соединенные" сокеты может также осуществляться с помощью функции reсv, имеющей следующий прототип: function recv(s: TSocket; var Buf; len, flags: Integer): Integer; От своего аналога recvfromона отличается только отсутствием параметров fromи fromlen, через которые передается адрес отправителя дейтаграммы. Рис. 2.1. Последовательность действий программы при обмене данными с помощью UDP Строго говоря, функцию recvможно использовать и для несоединенных сокетов, но при этом программе остается неизвестным адрес отправителя. В случае же "соединенных" сокетов адрес отправителя заранее известен — это адрес, заданный в функции connect, а дейтаграммы всех других отправителей будут отбрасываться. Функция recvfromтакже пригодна для "соединенных" сокетов, но адрес отправителя, который она возвращает, в данном случае может быть только тот, который определен в функции connect. Таким образом, функция connectв случае протокола UDP позволяет, во-первых, выполнить фильтрацию входящих дейтаграмм по адресу средствами самой библиотеки сокетов, а во-вторых, использовать более лаконичные альтернативы recvfromи sendto— recvи send. Возможные последовательности действий программы для протокола UDP показаны на рис. 2.1. 2.1.10. Пример программы: простейший чат на UDPПопробуем применить свои знания на практике и напишем простейший чат на основе протокола UDP. Пример этой программы находится на прилагаемом к книге компакт-диске и называется UDPChat, окно приложения показано на рис. 2.2. Прежде чем писать программу, необходимо определиться с форматом передаваемых данных (т.е. договориться о протоколе уровня представлений). Так как мы пишем простейший пример, то и протокол у нас будет простейшим: дейтаграмма содержит текстовое сообщение, введенное пользователем, без завершающего нуля (он не нужен, т.к. размер строки определяется размером дейтаграммы) и без дополнительной служебной информации. Для начала нам потребуется научиться сообщать пользователю об ошибках. Номер ошибки мало что дает даже опытному пользователю, поэтому сообщения должны быть дружественными, с внятным объяснением того, какая именно ошибка произошла. К счастью, мы избавлены от необходимости вручную писать текстовое сообщение для каждой из возможных ошибок, т.к. в системе уже есть функция FormatMessage, которая возвращает текстовое сообщение по коду ошибки (эта функция работает со всеми ошибками, а не только с ошибками сокетов). На основе FormatMessageмы создадим функцию GetErrorString(листинг 2.6), которая возвращает сообщение, соответствующее коду ошибки, возвращаемому функцией WSAGetLastError. Эта функция будет встречаться во всех наших примерах. Рис. 2.2. Главное окно UDP-чата Листинг 2.6. Функция GetErrorString, возвращающая описание ошибки // функция 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не возвращает управление вызвавшей ее нити до тех пор, пока не будут получены данные. Таким образом, если мы будем вызывать recvfromв главной нити, то при отсутствии входящих дейтаграмм программа просто повиснет, т.к. не сможет обрабатывать оконные сообщения. Поэтому все действия по приему сообщений мы должны вынести в отдельную нить. Задача этой нити очень проста: она в бесконечном цикле вызывает recvfrom и все полученные дейтаграммы передает в главное окно для отображения на экране. Нить, читающая данные, создается обычным образом — порождением наследника от класса TThread. Мы не будем возлагать на эту нить задачу создания сокета, — пусть он создается в главной нити, а затем его дескриптор передаётся в дополнительную, которая сохраняет его в своем внутреннем поле FSocket. Код нити, читающей сообщения, показан в листинге 2.7. Листинг 2.7. Код "читающей" нити unit ReceiveThread; { В этом модуле реализуется дополнительная нить UDP-чата, отвечающая за прием сообщений. } interface uses SysUtils, Classes, WinSock; type TReceiveThread = class(TThread) private // Сообщение, которое нужно добавить в лог, // хранится в отдельном поле, т.к. метод, вызывающийся через // Synchronize, не может иметь параметров. FMessage: string; // Сокет, получающий сообщения FSocket: TSocket; // Вспомогательный метод для вызова через Synchronize procedure DoLogMessage; protected procedure Execute; override; // Вывод сообщения в лог главной формы procedure LogMessage(const Msg: string); public constructor Create(ServerSocket: TSocket); end; implementation uses ChatMainUnit; {TReceiveThread} // Сокет, получающий сообщения, создается в главной нити, // а сюда передаётся через параметр конструктора constructor TReceiveThread.Create(ServerSocket: TSocket); begin FSocket := ServerSocket; inherited Create(False); end; procedure TReceiveThread.Execute; var // Буфер для получения сообщения. // Размер равен максимальному размеру UDP-дейтаграммы Buffer: array[0..65506] of Byte; // Адрес, с которого пришло сообщение RecvAddr: TSockAddr; RecvLen, AddrLen: Integer; Msg: string; begin // Начинаем бесконечный цикл, на каждой итерации которого // читается одна дейтаграмма repeat AddrLen := SizeOf(RecvAddr); // Получаем дейтаграмму RecvLen := recvfrom(FSocket, Buffer, SizeOf(Buffer), 0, RecvAddr, AddrLen); // Так как UDP не поддерживает соединение, ошибку при вызове recvfrom // мы можем получить, только если случилось что-то совсем // экстраординарное. В этом случае завершаем работу нити. if RecvLen < 0 then begin LogMessage('Ошибка при получении сообщения: ' + GetErrorString); // Перевод элементов управления главной формы // в состояние "Сервер не работает" Synchronizе(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 FMessage := Msg; Synchronize(DoLogMessage); end; procedure TReceiveThread.DoLogMessage; begin ChatForm.AddMessageToLog(FMessage); end; end. Отправлять данные можно и из основной нити, поскольку функция sendtoпри наших объемах данных практически никогда не будет блокировать вызывающую ее нить (да и при больших объемах данных, как мы увидим в дальнейшем, этого практически никогда не бывает). Соответственно, нам нужно создать два сокета: один для отправки сообщений, другой для приема. Сокет для отправки сообщений создаем сразу же при запуске приложения, при обработке события OnCreateглавной (и единственной) формы. Дескриптор сокета хранится в поле FSendSocket. Пользователю не принципиально, какой порт займет этот сокет, поэтому мы доверяем его выбор системе (листинг 2.8). Листинг 2.8. Инициализация программы UDPChat 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_DGPAM, IPROTO_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; // Пусть система сама выбирает для него IP-адрес и порт 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; // Не забываем, что номер порта возвращается в сетевом формате, // и его нужно преобразовать к обычному функцией htons. LabelSendPort.Caption := 'Порт отправки: ' + IntToStr(ntohs(Addr.sin_port)); end; Сокет для получения сообщений создается при нажатии кнопки Запустить и привязывается к тому порту, который указал пользователь. В случае его успешного создания запускается нить, которой передается этот сокет, и все дальнейшие операции с ним выполняет эта нить. Нить вместе с этим сокетом мы будем условно называть сервером. Код обработчика нажатия кнопки Запустить показан в листинге 2.9. Листинг 2.9. Обработчик нажатия кнопки Запустить// Реакция на кнопку "Запустить" procedure TChatForm.BtnStartServerClick(Sender: TObject); var // Сокет для приема сообщений ServerSocket: TSocket; // Адрес, к которому привязывается сокет для приема сообщений ServerAddr: TSockAddr; begin // Формирование адреса сокета для приема сообщений FillChar(ServerAddr.sin_zero, SizeOf(ServerAddr.sin_zero), 0); ServerAddr.sin_family := AF_INET; // IP-адрес может выбрать система, а порт назначаем тот, // который задан пользователем ServerAddr.sin_addr.S_addr := INADDR_ANY; try // He забываем преобразовать номер порта к сетевому формату // с помощью функции htons ServerAddr.sin_port := htons(StrToInt(EditServerPort.Text)); if ServerAddr.sin_port = 0 then begin MessageDlg('Номер порта должен находиться в диапазоне 1-65535', mtError, [mbOK], 0); Exit; end; // Создание сокета для получения сообщений ServerSocket := socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); if ServerSocket = INVALID_SOCKET then begin MessageDlg('Ошибка при создании сокета: '#13#10 + GetErrorString, mtError, [mbOK], 0); Exit; end; // привязка сокета к адресу if bind(ServerSocket, ServerAddr, SizeOf(ServerAddr)) = SOCKET_ERROR then begin MessageDlg('Ошибка при привязке сокета к адресу: '#13#10 + GetErrorString, mtError, [mbOK], 0); closesocket(ServerSocket); Exit; end; // Создание нити, которая будет получать сообщения. // Сокет передается ей, и дальше она отвечает за него. TReceiveThread.Create(ServerSocket); // Перевод элементов управления в состояние "Сервер работает" LabelServerPort.Enabled := False; EditServerPort.Enabled := False; BtnStartServer.Enabled := False; LabelServerState.Caption:= 'Сервер работает'; except on EConvertError do // Это исключение может возникнуть только в одном месте - // при вызове StrToInt(ЕditServerPort.Text) MessageDlg('"' + EditServerPort.Text + '" не является целым числом', mtError, [mbOK], 0); on ERangeError do // Это исключение может возникнуть только в одном месте - // при присваивании значения номеру порта MessageDlg('Номер порта должен находиться в диапазоне 1-65535", mtError, [mbOK], 0); end; end; Для отправки сообщения пользователь должен нажать кнопку Отправить. При этом формируется адрес на основании введённых пользователем данных и вызывается функция sendto(листинг 2.10). Пользователь должен каким-то образом узнать, какой порт назначения выбран у адресата. Его IP-адрес тоже, разумеется, необходимо знать. Листинг 2.10. Обработчик нажатия кнопки Отправить // Реакция на кнопку "Отправить" procedure TChatFormBtnSendClick(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)); // Для совместимости со старыми версиями Delphi приводим // константу INADDR_NONE к типу u_long if SendAddr.sin_addr.S_addr = u_long(INADDR_NONE) then begin MessageDlg('"' +EditSendAddr.Text + '"не является IP-адресом', mtError, [mbOK], 0); Exit; end; try SendAddr.sin_port := htons(StrToInt(EditSendPort.Text)); // Получаем сообщение, которое ввел пользователь. // Дополнительная переменная понадобилась потому, // что нам потребуется ее передавать в качестве var-параметра, // а делать это со свойством EditMessage.Техt нельзя. 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); except on EConvertError do // Это исключение может возникнуть только в одном месте - // при вызове IntToStr(EditSendPort.Text) MessageDlg('"' + EditSendPort.Text + не является целым числом', mtError, [mbOK], 0); on ERangeError do // Это исключение может возникнуть только в одном месте - // при присваивании значения номеру порта MessageDlg('Номер порта должен находиться в диапазоне 1-65535', mtError, [mbOK], 0); end; end; Заметим, что в нашем сервере есть один очень неудобный момент. Предположим, получено сообщение, и программа высветила следующую надпись: "Сообщение с адреса 192.168.200.211:2231. Привет!". Порт, который указан в этом сообщении — это порт того сокета, который используется на удаленной стороне для отправки сообщений. Для их получения там предназначен другой сокет и другой порт, поэтому цифра 2231 не несет никакой информации о том, на какой порт нужно отправлять ответ. В нашем примитивном чате соответствие между номерами портов для отправки и для получения сообщений пользователю приходится держать в голове. По сути дела, более-менее нормальная работа такого чата возможна только тогда, когда все пользователи используют один и тот же порт для сокета, принимающего сообщения (или когда компьютеры стоят рядом, и пользователи могут сообщить друг другу номера своих портов). Не будем слишком строги к нашему первому примеру — его ценность в том, что он учит основам использования сокетов и протокола UDP. Проблему можно было бы решить, передавая в дейтаграмме не только сообщения, но и номер порта для ответа и реализовав в программе таблицу соответствия портов для отправки и приема сообщений известных адресатов. Однако это уже не относится к работе с сокетами, и потому мы не стали загромождать этим пример. Чуть позже мы научимся делать так, что функция recvfromне будет блокировать нить, и тогда переделаем чат так, чтобы отправка и прием данных осуществлялись через один и тот же сокет. Здесь возникает вопрос: нельзя ли с помощью sendtoпередавать данные через тот же сокет, который в другой нити используется в функции recvfrom? Документация по этому поводу упорно молчит. Если в нашем чате оставить только один сокет и задействовать его в обеих нитях, то всё вроде как работает. Однако это тот случай, когда эксперимент не может служить доказательством, потому что у ошибок, связанных с неправильной синхронизацией нитей, есть очень неприятная особенность: программа может миллион раз отработать правильно, а на миллион первый дать сбой. Поэтому сколько бы раз такой эксперимент ни завершился удачно, полной гарантии он все же не даёт, так что приходится действовать осторожно и не использовать один сокет в разных нитях. В заключение отметим, что наш чат допускает одновременное общение любого количества человек с любого числа адресов, но сообщения всегда передаются от одного человека к другому. Широковещательных и групповых сообщений у нас нет. Отметим также, что отправлять сообщения можно и не запуская "сервер". Примечание 2.1.11. Передача данных при использовании TCPПри программировании TCP и UDP применяются одни и те же функции, но их поведение при этом различно. Для передачи данных с помощью TCP необходимо сначала установить соединение, и после этого возможен обмен данными только с тем адресом, с которым это соединение установлено. Функция sendtoможет использоваться для TCP-сокетов, но ее параметры, задающие адрес получателя, игнорируются, а данные отправляются на тот адрес, с которым соединен сокет. Поэтому при отправке данных через TCP обычно прибегают к функции send, которая дает тот же результат. По тем же причинам обычно используется recv, а не recvfrom. В TCP существует разделение ролей взаимодействующих сторон на клиент и сервер. Мы начнем изучение передачи данных в TCP с изучения действий клиента. Для начала взаимодействия клиент должен соединиться с сервером с помощью функции connect. Мы уже знакомы с этой функцией, но в случае TCP она выполняет несколько иные действия. В данном случае она устанавливает реальное соединение, поэтому ее действия начинаются с проверки того, существует ли по указанному адресу серверный сокет, находящийся в режиме ожидания подключения. Функция connectзавершается успешно только тогда, когда соединение установлено, и серверная сторона выполнила все необходимые для этого действия. При вызове connectв TCP предварительный явный вызов функции bindтакже не обязателен. В отличие от UDP, сокет в TCP нельзя отсоединить или соединить с другим адресом, если он уже соединен. Для нового соединения необходим новый сокет. Мы уже говорили, что TCP является надежным протоколом, т.е. в том случае, если пакет не доставлен, отправляющая сторона уведомляется об этом. Тем не менее успешное завершение send, как и в случае UDP, не является гарантией того, что пакет был отослан и дошел до получателя, а говорит только о том, что данные скопированы в выходной буфер сокета, и на момент копирования сокет был соединён. Если в дальнейшем библиотека сокетов не сможет отправить эти данные или не получит подтверждения об их доставке, соединение будет закрыто, и следующая операция с этим сокетом завершится с ошибкой. Если выходной буфер сокета равен нулю, данные сразу копируются в сеть, но успешное завершение функции и в этом случае не гарантирует успешную доставку. Использовать нулевой выходной буфер для TCP-сокетов не рекомендуется, т.к. это снижает производительность при последовательной отправке данных небольшими порциями. При буферизации эти порции накапливаются в буфере, а потом отправляются одним большим пакетом, требующим одного подтверждения от клиента. Если же буферизация не осуществляется, то будет отправлено несколько мелких пакетов, каждый со своим заголовком и своим подтверждением от клиента, что приведет к снижению производительности. Функция recvкопирует пришедшие данные из входного буфера сокета в буфер, заданный параметром Buf, но не более lenбайтов. Скопированные данные удаляются из буфера сокета. При этом все полученные данные сливаются в один поток, поэтому получатель может самостоятельно выбирать, какой объем данных считывать за один раз. Если за один раз была скопирована только часть пришедшего пакета, оставшаяся часть не пропадает, а будет скопирована при следующем вызове recv. Функция recvвозвращает число байтов, скопированных в буфер. Если на момент ее вызова входной буфер сокета пуст, она ждет, когда там что-то появится, затем копирует полученные данные и лишь после этого возвращает управление вызвавшей ее программе. Если recvвозвращает 0, это значит, что удаленный сокет корректно завершил соединение. Если соединение завершено некорректно (например, из-за обрыва кабеля или сбоя удаленного компьютера), функция завершается с ошибкой (т.е. возвращает SOCKET_ERROR). Теперь рассмотрим, какие действия при использовании TCP должен выполнить сервер. Как мы уже говорили, сервер должен перевести сокет в режим ожидания соединения. Это делается с помощью функции listen, имеющей следующий прототип: function listen(s: TSocket; backlog: Integer): Integer; Параметр s задает сокет, который переводится в режим ожидания подключения. Этот сокет должен быть привязан к адресу, т.е. функция bindдолжна быть вызвана для него явно. Для сокета, находящегося в режиме ожидания, создается очередь подключений. Размер этой очереди определяется параметром backlog, если он равен SOMAXCONN, очередь будет иметь максимально возможный размер. В MSDN отмечается, что узнать максимально допустимый размер очереди стандартными средствами нельзя. Функция возвращает ноль при успешном завершении и SOCKET_ERROR— в случае ошибки. Когда клиент вызывает функцию connect, и по указанному в ней адресу имеется сокет, находящийся в режиме ожидания подключения, то информация о клиенте помещается в очередь подключений этого сокета. Успешное завершение connect говорит о том, что на стороне сервера подключение добавлено в очередь. Однако для того, чтобы соединение было действительно установлено, сервер должен выполнить еще некоторые действия: извлечь из очереди соединений информацию о соединении и создать сокет для его обслуживания. Эти операции выполняются с помощью функции accept, имеющей следующий прототип: function accept(s: TSocket; addr: PSockAddr; addrlen: PInteger) : TSocket; Параметр sзадает сокет, который находится в режиме ожидания соединения и из очереди которого извлекается информация о соединении. Выходной параметр addrпозволяет получить адрес клиента, установившего соединение. Здесь должен быть передан указатель на буфер, в который этот адрес будет помещен. Параметр addrlenсодержит указатель на переменную, в которой хранится длина этого буфера: до вызова функции эта переменная должна содержать фактическую длину буфера, задаваемого параметром addr, после вызова — количество байтов буфера, реально понадобившихся для хранения адреса клиента. Очевидно, что в случае TCP и входное, и выходное значение этой переменной должно быть равно SizeOf(TSockAddr). Эти параметры передаются как указатели, а не как параметры-переменные, что было бы более естественно для Delphi, потому что библиотека сокетов допускает для этих указателей нулевые значения, если сервер не интересует адрес клиента. В данном случае разработчики модуля WinSock сохранили полную функциональность, предоставляемую библиотекой. В случае ошибки функция acceptвозвращает значение INVALID_SOCKET. При успешном завершении возвращается дескриптор сокета. созданного библиотекой сокетов и предназначенного для обслуживания данного соединения. Этот сокет уже привязан к адресу и соединен с сокетом клиента, установившего соединение, и его можно использовать в функциях recvи sendбез предварительного вызова каких-либо других функций. Уничтожается этот сокет обычным образом, с помощью closesocket. Исходный сокет, определяемый параметром s, остается в режиме прослушивания. Если сервер поддерживает одновременное соединение с несколькими клиентами, то функция acceptможет быть вызвана многократно. Каждый раз при этом будет создаваться новый сокет, обслуживающий одно конкретное соединение: протокол TCP и библиотека сокетов гарантируют, что данные, посланные клиентами, попадут в буферы соответствующих сокетов и не будут перемешаны. Для получения целостной картины кратко повторим все сказанное. Для установления соединения сервер должен, во-первых, создать сокет с помощью функции socket, а во-вторых, привязать его к адресу с помощью функции bind. Далее сокет должен быть переведен в режим ожидания с помощью listen, а потом с помощью функции acceptсоздается новый сокет, обслуживающий соединение, установленное клиентом. После этого сервер может обмениваться данными с клиентом. Клиент же должен создать сокет, при необходимости привязки к конкретному порту вызвать bind, и затем вызвать connectдля установления соединения. После успешного завершения этой функции клиент может обмениваться данными с сервером. Это иллюстрируют листинги 2.11 и 2.12. Листинг 2.11. Код сервера var S, AcceptedSock: TSocket; Addr: TSockAddr; Data: TWSAData; Len: Integer; begin WSAStartup($101, Data); S := socket(AF_INET, SOCK_SТREAМ, 0); Addr.sin_family := FF_INET; Addr.sin_port := htons(3030); Addr.sin_addr.S_addr := INADDR_ANY; FillChar(Addr.sin_zero, SizeOf(Addr.sin_zero), 0); bind(S, Addr, SizeOf(TSockAddr)); listen(S, SOMAXCONN); Len := SizeOf(TSockAddr); AcceptedSock := accept(S, @Addr, @Len); { Теперь Addr содержит адрес клиента, с которым установлено соединение, а AcceptedSock - дескриптор, обслуживающий это соединение. Допустимы следующие действия: send(AcceptedSock, ...) - отправить данные клиенту recv(AcceptedSock, ...) - получить данные от клиента accept(...) - установить соединение с новым клиентом } Здесь сокет сервера привязывается к порту с номером 3030. В общем случае разработчик сервера сам должен выбрать порт из диапазона 1024–65 535. Листинг 2.12. Код клиентаvar S: TSocket; Addr: TSockAddr; Data: TWSAData; begin WSAStartup($101, Data); S := socket(AF_INET, SOCK_STREAM, 0); Addr.sin_family := AF_INET; Addr.sin_port := htons(3030); Addr.sin_addr.S_addr := inet_addr(...); FillChar(Addr.sin_zero, SizeOf(Addr.sin_zero), 0); connect(S, Addr, SizeOf(TSockAddr)); { Теперь соединение установлено. Допустимы следующие действия: send(S, ...) - отправить данные серверу recv(S, ...) — получить данные от сервера } В приведенном коде для краткости опущены проверки результатов функций с целью обнаружения ошибок. При написании серьезных программ этим пренебрегать нельзя. Блок-схема действии клиента и сервера приведена на рис. 2.3. Если на момент вызова функции acceptочередь соединений пуста, то нить, вызвавшая ее, блокируется до тех пор, пока какой-либо клиент не подключится к серверу. С одной стороны, это удобно: сервер может не вызывать функцию acceptв цикле до тех пор, пока она не завершится успехом, а вызвать ее один раз и ждать, когда подключится клиент. С другой стороны, это создает проблемы тем серверам, которые должны взаимодействовать с несколькими клиентами. Действительно, пусть функция acceptуспешно завершилась и в распоряжении программы оказались два сокета: находящийся в режиме ожидания новых подключений и созданный для обслуживания уже существующего подключения. Если вызвать accept, то программа не сможет продолжить работу до тех пор, пока не подключится еще один клиент, а это может произойти через очень длительный промежуток времени или вообще никогда не случится. Из-за этого программа не сможет обрабатывать вызовы уже подключившегося клиента. С другой стороны, если функцию acсeptне вызывать, сервер не сможет обнаружить подключение новых клиентов. Средства для решения этой проблемы есть как у стандартных сокетов, так и у сокетов Windows, и далее мы их рассмотрим. Но существует довольно популярный способ ее решения средствами не библиотеки сокетов, а операционной системы. Он заключается в использовании отдельной нити для обслуживания каждого из клиентов. Каждый раз, когда клиент подключается, функция acceptпередает управление программе, возвращая новый сокет. Здесь сервер может породить новую нить, которая предназначена исключительно для обмена данными с новым клиентом. Старая нить после этого снова вызывает accept для старого сокета, а новая — функции recvи sendдля нового сокета. Такой метод решает заодно и проблемы, связанные с тем, что функции sendи recvтакже могут блокировать работу программы и помешать обмену данными с другими клиентами. В данном случае будет блокирована только одна нить, обменивающаяся данными с одним из клиентов, а остальные нити продолжат свою работу. Далее мы рассмотрим пример сервера, работающего по такой схеме. Рис. 2.3. Последовательность действий клиента и сервера при использовании TCP То, что функция recvможет возвратить только часть ожидаемого пакета, обычно вызывает трудности, поэтому здесь мы рассмотрим один из вариантов написания функции (назовем ее ReadFromSocket), которая эти проблемы решает (листинг 2.13). Суть этой функции в том, что она вызывает recv до тех пор, пока не будет получено требуемое количество байтов или пока не возникнет ошибка. Тот код, который получает и анализирует приходящие данные, использует уже не recv, a ReadFromSocket, которая гарантирует, что будет возвращено столько байтов, сколько требуется. Листинг 2.13. Функция ReadFromSocket, читающая из буфера сокета заданное количество байтов // Функция читает Cnt байтов в буфер Buffer из сокета S // Учитывается, что может потребоваться несколько операций чтения, // прежде чем будет прочитано нужное число байтов. // Возвращает: // 1 — в случае успешного чтения // 0 - в случае корректного закрытия соединения удаленной стороной // -1 — в случае ошибки чтения function ReadFromSocket(S: TSocket; var Buffer; Cnt: Integer): Integer; var Res, Total: Integer; begin // Total содержит количество принятых байтов Total := 0; // Читаем байты в цикле до тех пор, пока не будет прочитано Cnt байтов repeat // На каждой итерации цикла нам нужно прочитать // не более чем Cnt - Total байтов, т.е. не более // чем нужное количество минус то, что уже прочитано // на предыдущих итерациях. Очередную порцию данных // помещаем в буфер со смещением Total. Res := recv(S, (PChar(@Buffer) + Total)^, Cnt - Total, 0); if Res = 0 then begin // Соединение закрыто удаленной стороной Result := 0; Exit; end; if Res < 0 then begin // Произошла ошибка при чтении Result := -1; Exit; end; Inc(Total, Res); until Total >= Cnt; Result:= 1; end; Эта функция будет использоваться в дальнейшем в нескольких наших примерах. 2.1.12. Примеры передачи данных с помощью TCPТеперь у нас достаточно знаний, чтобы написать TCP-клиент и TCP-сервер. Как и в случае с UDP, сначала нужно договориться о том, какими данными и в каком формате будут обмениваться наши программы. С протоколом, описанным здесь, нам предстоит работать на протяжении всей главы. По мере изучения новых возможностей библиотеки сокетов мы будем реализовывать новые варианты серверов и клиентов, но почти все они будут поддерживать один и тот же протокол, поэтому любой клиент сможет работать с любым сервером. Наши сервер и клиент будут обмениваться строковыми сообщениями: клиент пошлет строку, сервер отправит ответ. Мы уже не можем, как в случае UDP, просто отправить строку, потому что при использовании TCP несколько строк могут быть отправлены одним пакетом, или наоборот, одна строка разбита на несколько пакетов. Соответственно, наш протокол должен позволять определить, где заканчивается одна строка и начинается другая. Ранее мы уже упоминали три основных способа определения границ логического пакета в TCP: все пакеты могут иметь одинаковую длину, пакет может предваряться фиксированным заголовком, содержащим длину, между пакетами может вставляться заранее оговоренная последовательность байт. Первый способ самый легкий в реализации, но он накладывает существенные ограничения на передаваемые данные. В частности, нам он не подходит, потому что мы будем передавать строки произвольной длины. Второй и третий способы приемлемы для передачи строк, и чтобы проиллюстрировать как можно больше различных вариантов в наших примерах, мы будем использовать их оба. При передаче данных от клиента серверу, мы будем перед строгой передавать четырёхбайтное значение — длину строки, а при передаче данных от сервера клиенту длину строки мы передавать не будем, но к каждой строке будет добавляться символ #0, указывающий на завершение строки. Таким образом, получается, что строки, передаваемые клиентом, могут содержать символ #0в середине, а передаваемые сервером — нет. Все серверы, которые мы напишем, будут возвращать клиенту присланную строку, но слегка преобразованную. Во-первых, все символы #0будут в ней заменены на подстроку " #0", во-вторых, все буквы превращены в заглавные, а в-третьих, добавлено имя сервера, который ответил. Практическое знакомство с TCP мы начнем с написания простейшего сервера. На компакт-диске этот сервер находится в папке SimplestServer. Сразу отметим, что это чисто учебный пример, и брать его за основу при создании реальных серверов ни в коем случае нельзя. Чуть позже мы напишем другой сервер, который уже может служить образцом для подражания. Наш простейший сервер будет использовать только одну нить. Как мы помним, сервер должен вызывать две функции, которые блокируют работу нити: accept и recv. Очевидно, что задействовать их обе сразу в одной нити не получится, именно поэтому наш сервер сможет работать только с одним клиентом одновременно. И чтобы не блокировать пользовательский интерфейс, наш сервер будет консольным приложением. В командной строке ему передается номер порта, к которому привязывается слушающий сокет. Первое, что должен сделать сервер, — это создать сокет. привязать его к требуемому адресу и перевести в режим прослушивания. Этот код мало чем отличается от приведенного ранее примера создания сокета для UDP (см. листинг 2.8). Вся разница только в том, что вместо сокета типа SOCK_DGRAMсоздается сокет типа SOCK_STREAM, а в конце еще вызывается функция listen(листинг 2.14). Листинг 2.14. Создание сокета в программе SimplestServer var // Порт, который будет "слушать" сервер Port: Word; // "Слушающей" сокет MainSocket: TSocket; // Сокет, создающийся для обслуживания клиента ClientSocket: TSocket; // Адрес "слушающего" сокета MainSockAddr: TSockAddr; // Адрес подключившегося клиента ClientSockAddr: TSockAddr; // Размер адреса подключившегося клиента ClientSockAddrLen: Integer; //Без этой переменной не удастся инициализировать библиотеку сокетов WSAData: TWSAData; StrLen: Integer; Str: string; begin try if ParamCount = 0 then // Если в командной строке порт не задан, назначаем его Port := 12345; else // В противном случае анализируем командную строку и назначаем порт try Port := StrToInt(ParamStr(1)); if Port = 0 then raise ESocketException.Create( 'Номер порта должен находиться в диапазоне 1-65535'); except on EConvertError do raise ESocketException.Create( 'Параметр "' + ParamStr(1) + '" не является целым числом'); on ERangeError do raise ESocketException.Create( 'Номер порта должен находиться в диапазоне 1-65535'); end; // инициализация библиотеки сокетов if WSAStartup($101, WSAData) <> 0 then raise ESocketException.Create( 'Ошибка при инициализации библиотеки WinSock'); // Создание сокета, который затем будет "слушать" порт MainSocket := socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if MainSocket = INVALID_SOCKET then raise ESocketException.Create( 'Невозможно создать слушающий сокет: ' + GetErrorString'); // Формирование адреса для "слушающего" сокета FillChar(MainSockAddr.sin_zero, SizeOf (MainSockAddr.sin_zero, 0); MainSockAddr.sin_family := AF_INET; // Выбор IP-адреса доверяем системе MainSockAddr.sin_addr.S_addr := INADDR_ANY; // Порт назначаем, не забывая перевести его номер в сетевой формат MainSockAddr.sin_port := htons(Port); // Привязка сокета к адресу if bind(MainSocket, MainSockAddr, SizeOf(MainSockAddr)) = SOCKET_ERROR then raise ESocketException.Create( 'Невозможно привязать слушающий сокет к адресу: ' + GetErrorString); // Установка сокета в режим прослушивания if listen(MainSocket, SOMAXCONN) = SOCKET_ERROR then raise ESocketException.Create( 'Невозможно установить сокет в режим прослушивания: ' + GetErrorString); WriteLn(OemString('Сервер успешно начал прослушивание порта '), Port); ... // Основная часть сервера приведена в листинге 2.15 ... except on Е: ESocketException do WriteLn(OemString(E.Message)); on E: Exception do WriteLn(OemString('Неожиданное исключение ' + E.ClassName + ' с сообщением ' + E.Message)); end; end. Основная часть кода сервера — это два цикла, один из которых вложен в другой (листинг 2.15). Перед внешним циклом сервер создает сокет и переводит его в режим прослушивания, и внешний цикл начинается с вызова функции accept. Завершение acceptуказывает на подключение клиента. После этого начинается внутренний цикл, который состоит из получения сообщений от клиента, преобразования строки и отправки ответа. Внутренний цикл завершается, когда соединение разрывается либо самим клиентом, либо из-за ошибки в сети. После этого управление вновь передается на начало внешнего цикла, т.е. на accept, и сервер может принять подключение другого клиента (или повторное подключение того же клиента). Листинг 2.15. Основная часть сервера SimplestServer // Начало цикла подключения и общения с клиентом repeat ClientSockAddrLen := SizeOf(ClientSockAddr); // Принимаем подключившегося клиента. Для общения с ним создается новый // сокет, дескриптор которого помещается в ClientSocket. ClientSocket := accept(MainSocket, @ClientSockAddr, @ClientSockAddrLen); if ClientSocket = INVALID_SOCKET then raise ESocketException.Create( 'Ошибка при ожидании подключения клиента: ' + GetErrorString); // При выводе сообщения не забываем, // что номер порта имеет сетевой формат WriteLn(OemString(' Зафиксировано подключение с адреса '), Ord(ClientSockAddr.sin_addr.S_un_b.s_b1), '.', Ord(ClientSockAddr.sin_addr.S_un_b.s_b2), '.', Ord(ClientSockAddr.sin_addr.S_un_b.s_b3), '.', Ord(ClientSockAddr.sin_addr.S_un_b.s_b4), ':', ntohs(ClientSockAddr.sin_port)); // Цикл общения с клиентом. Продолжается до тех пор, // пока клиент не закроет соединение или пока // не возникнет ошибка repeat // Читаем длину присланной клиентом строки и помещаем ее в StrLen case ReadFromSocket(ClientSocket, StrLen, SizeOf(StrLen)) of 0: begin WriteLn(OemString('Клиент закрыл соединение'); Break; end; -1: begin WriteLn(OemString('Ошибка при получении данных от клиента: ', GetErrorString)); Break; end; end; // Протокол не допускает строк нулевой длины if StrLen <= 0 then begin WriteLn(OemString('Неверная длина строки от клиента: '), StrLen); Break; end; // Установка длины строки в соответствии с полученным значением SetLength(Str, StrLen); // Чтение строки нужной длины case ReadFromSocket(ClientSocket, Str[1], StrLen) of 0: begin WriteLn(OemString('Клиент закрыл соединение')); Break; end; -1: begin WriteLn(OemString( 'Ошибка при получении данных от клиента: ' + GetErrorString)); Break; end; end; WriteLn(OemString('Получена строка: ' + Str)); // Преобразование строки Str := AnsiUpperCase(StringReplace(Str, #0, '#0', [rfReplaceAll])) + ' (Simplest server)'; // Отправка строки. Отправляется на один байт больше, чем // длина строки, чтобы завершающий символ #0 тоже попал в пакет if send(ClientSocket, Str[1], Length(Str) + 1, 0) < 0 then begin WriteLn(OemString('Ошибка при отправке данных клиенту: ' + GetErrorString)); Break; end; WriteLn(OemString('Клиенту отправлен ответ: ' + Str)); // Завершение цикла обмена с клиентом until False; // Сокет для связи с клиентом больше не нужен closesocket(ClientSocket); until False; Теперь перейдем к написанию клиента. Пример этого клиента находится на компакт-диске в папке SimpleClient, главное окно показано на рис. 2.4. Клиент должен вызывать только одну функцию, которая реально может блокировать вызвавшую ее нить, — функцию recv. Но по нашему протоколу сервер не посылает клиенту ничего по собственной инициативе, он только отвечает на сообщения клиента. Следовательно, клиент не должен быть всегда готов принять сообщение, он его принимает только после отправки своего. В простых случаях, когда сообщение имеет небольшой размер, а формирование ответа на сервере не требует длительной работы, мы можем считать, что попытка получения ответа от сервера сразу же после отправки ему сообщения в подавляющем большинстве случаев не будет блокировать работу клиента, а оставшееся незначительное количество случаев считаем форс-мажором и допускаем, что в такой ситуации блокирование будет допустимо. На практика заметить это блокирование можно будет только тогда, когда сервер не будет должным образом отвечать на сообщения или связь с ним будет потеряна. Для простого клиента с невысокими требованиями к надежности такое упрощение вполне допустимо и вполне может быть использовано на практике. А в дальнейшем мы познакомимся со средствами библиотеки сокетов, позволяющими писать программы, в которых работа с сокетами никогда не приводит к блокировке. Рис. 2.4. Главное окно программы SimpleClient Таким образом, наш клиент будет очень простым: по кнопке Соединиться он будет соединяться с сервером, по кнопке Отправить — отправлять серверу сообщение и дожидаться ответа. Третья кнопка, Отсоединиться, служит для корректного завершения работы с сервером. Рассмотрим эти действия подробнее. При соединении с сервером клиент должен создать сокет и вызвать функцию connect. Здесь мы не можем создать сокет один раз и потом пользоваться им на протяжении всего времени работы клиента, т.к. после закрытия соединения (неважно, корректного или из-за ошибки) сокет больше нельзя использовать. Поэтому при установлении соединения каждый раз приходится создавать новый сокет. Обработчик нажатия кнопки Соединиться приведен в листинге 2.16. Листинг 2.16. Обработчик нажатия кнопки Соединиться procedure TSimpleClientForm.BtnConnectClick(Sender: TObject); var // Адрес сервера ServerAddr: TSockAddr; begin // Формируем адрес сервера, к которому нужно подключиться FillChar(ServerAddr.sin_zero, SizeOf(ServerAddr.sin_zero), 0); ServerAddr.sin_family := AF_INET; ServerAddr.sin_addr.S_addr := inet_addr(PChar(EditIPAddress.Text)); // Для совместимости со старыми версиями Delphi приводим // константу INADDR_ANY к типу u_long if ServerAddr.sin_addr.S_addr := u_long(INADDR_NONE)then begin MessageDlg('Синтаксическая ошибка в IР-адресе', mtError, [mbOK], 0); Exit; end; try ServerAddr.sin_port := htons(StrToInt(EditPort.Text)); // Создание сокета FSocket := socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if FSocket = INVALID_SOCKET then begin MessageDlg('Ошибка при создании сокета: '#13#10 + GetErrorString, mtError, [mbOK], 0); Exit; end; // Подключение к серверу if connect(FSocket, ServerAddr, SizeOf(ServerAddr)) < 0 then begin MessageDlg('Ошибка при установлении подключения: '#13#10 + GetErrorString, mtError, [mbOK], 0); // Так как сокет был успешно создан, // в случае ошибки его нужно удалить closesocket(FSocket); FSocket := 0; Exit; end; // Включаем режим "Соединение установлено" OnConnect; except on EConvertError do // Это исключение может возникнуть только в одном месте - // при вызове StrToInt(EditPort.Text) MessageDlg('"' + EditPort.Text + '"не является целым числом', mtError, [mbOK], 0); on ERangeError do // Это исключение может возникнуть только в одном месте - // при присваивании значения номеру порта MessageDlg('Номер порта должен находиться в диапазоне 1-65535', mtError, [mbOK], 0); end; end; Теперь посмотрим, как клиент реагирует на нажатие кнопки Отправить (листинг 2.17). Сама по себе отправка — вещь очень простая: нужно сформировать адрес получателя и вызвать функцию send. Несколько сложнее выполняется чтение данных, потому что, согласно нашему протоколу, клиент не знает, сколько байтов он должен прочитать, и читает до тех пор, пока не встретит символ #0. Листинг 2.17. Обработчик нажатия кнопки Отправить procedure TSimpleClientForm.BtnSendClick(Sender: TObject); const // Данные из буфера сокета мы будем читать порциями. // константа BufStep определяет размер порции BufStep = 10; var Str: string StrLen, BufStart, Portion: Integer; Buf: array of Char; begin Str := EditStringToSend.Text; StrLen := Length(Str); if StrLen = 0 then begin MessageDlg('Протокол не допускает отправки пустых строк', mtError, [mbOK], 0); Exit; end; // отправляем серверу длину строки if send(FSocket, StrLen, SizeOf(StrLen), 0) < 0 then begin MessageDlg('Ошибка при отправке данных серверу '#13#10 + GetErrorString, mtError, [mbOK], 0); OnDisconnect; Exit; end; // Отправляем серверу строку if send(FSocket, Str[1], StrLen, 0) < 0 then begin MessageDlg('Ошибка при отправке данных серверу: '#13#10 + GetErrorString, mtError, [mbOK], 0); OnDisconnect; Exit; end; BufStart := 0; // Цикл получения ответа от сервера // завершается, когда получаем посылку, оканчивающуюся на #0 repeat SetLength(Buf, Length(Buf) + BufStep); // Читаем очередную порцию ответа от сервера Portion := recv(FSocket, Buf(BufStart), BufStep, 0); if Portion <= 0 then begin MessageDlg('Ошибка при получении ответа от сервера: '#13#10 + GetErrorString, mtError, [mbOK], 0); OnDisconnect; Exit; end; // Если порция кончается на #0, ответ прочитан полностью, выходим из // цикла. Здесь мы использовали особенность нашего протокола, который // запрещает серверу присылать несколько строк подряд, следующая // строка будет выслана сервером только после нового запроса от // клиента. Если бы протокол допускал отправку сервером нескольких // ответов подряд, при чтении очередной порции данных могло бы // оказаться, что начало порции принадлежит одной строке, конец - // следующей, а признак конца строки нужно искать где-то в середине if Buf[BufStart + Portion - 1] = #0 then begin EditReply.Text := PChar(@Buf[0]); Break; end; Inc(BufStart, BufStep); until False; end; Реакция на кнопку Отсоединиться совсем простая: нужно разорвать соединение и закрыть сокет (листинг 2.18). Листинг 2.18. Реакция на нажатие кнопки Отсоединитьсяprocedure TSimpleClientForm.BtnDisconnectClick(Sender: TObject); begin shutdown(FSocket, SD_BOTH); closesocket(FSocket); OnDisconnect; end; Откомпилируем наши примеры и посмотрим, что получилось. Пока у нас один клиент работает с одним сервером, все вполне предсказуемо: клиент передает сообщения, сервер на них отвечает. Попытаемся подключиться вторым клиентом, не отключая первый, и посмотрим, что будет. Само подключение с точки зрения клиента проходит нормально, хотя сервер находится в своем внутреннем цикле и не вызывает accept, для второго клиента. Впрочем, как мы знаем, для успешного выполнения функции connect на стороне клиента достаточно, чтобы сокет сервера находился в режиме прослушивания. Теперь попытаемся отправить что-то серверу со второго клиента. Сама отправка проходит успешно, но при попытке получить ответ клиент "зависает", т.к. функция recvблокирует нить до прихода данных, а данные не приходят, потому что сервер не обрабатывает сообщения от этого клиента. Отсоединим первый клиент от сервера, чтобы сервер вернулся к выполнению функции accept. Мы видим, что сервер немедленно обнаружил подключение второго клиента, а также то, что клиент прислал ему сообщение. Соответственно, сервер отвечает на это сообщение, и второй клиент "отвисает" — теперь с ним можно нормально работать. Простейший сервер и эксперименты с ним, конечно, очень познавательны, но на практике хотелось бы иметь такой сервер, который может работать одновременно с несколькими клиентами. Чтобы добиться этого, сделаем так же, как при написании UDP-чата: вынесем в отдельные нити работу с блокирующими функциями (пример MultithreadedServerна компакт-диске). Нам понадобится одна нить для выполнения функции accept и по одной нити на работу с каждым подключившимся клиентом. Инициализация выполняется при нажатии кнопки Запустить (листинг 2.19). После инициализации библиотеки сокетов, создания сокета и перевода его в режим прослушивания она создает нить типа TListenThread, передает ей дескриптор сокета и больше с сокетами не работает — дальнейшая роль главной нити заключается только в обработке сообщений. Благодаря этому сервер может иметь нормальный пользовательский интерфейс. Листинг 2.19. Инициализация многонитевого сервера // Реакция на кнопку Запустить procedure TServerForm.BtnStartServerClick(Sender: TObject); var // Сокет, который будет "слушать" ServerSocket: TSocket; // Адрес, к которому привязывается слушающий сокет ServerAddr: TSockAddr; begin // Формирyем адрес для привязки. FillChar(ServerAddr.sin_zero, SizeOf(ServerAddr.sin_zero), 0); ServerAddr.sin_family := AF_INET; ServerAddr.sin_addr.S_addr := ADDR_ANY; try ServerAddr.sin_port := htons(StrToInt(EditPortNumber.Text)); if ServerAddr.sin_port = 0 then begin MessageDlg('Номер порта должен находиться в диапазоне 1-65535', mtError, [mbOK], 0); Exit; end; // Создание сокета ServerSocket := socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if ServerSocket = INVALID_SOCKET then begin MessageDlg('Ошибка при создании сокета: '#13#10 + GetErrorString, mtError, [mbOK], 0); Exit; end; // Привязка сокета к адресу if bind(ServerSocket, ServerAddr, SizeOf(ServerAddr)) = SOCKET_ERROR then begin MessageDlg('Ошибка при привязке сокета к адресу: '#13#10 + GetErrorString, mtError, [mbOK], 0); closesocket(ServerSocket); Exit; end; // Перевод сокета в режим прослушивания if listen(ServerSocket, SOMAXCONN) = SOCKET_ERROR then begin MessageDlg('Ошибка при переводе сокета в режим просушивания:'#13#10 + GetErrorString, mtError, [mbOK], 0); closesocket(ServerSocket); Exit; end; // Запуск нити, обслуживающей слушающий сокет TListenThread.Create(ServerSocket); // Перевод элементов управления в состояние "Сервер работает" LabelPortNumber.Enabled := False; EditРоrtNumber.Enabled := False; BtnStartServer.Enabled := False; LabelServerState.Caption := 'Сервер работает'; except on EConvertError do // Это исключение может возникнуть только в одном месте // при вызове StrToInt(EditPortNumber.Text) MessageDlg('"' + EditPortNumber.Text + '"не является целым числом', mtError, [mbOK], 0); on ERangeError do // это исключение может возникнуть только в одном месте - // при присваивании значения номеру порта MessageDlg('Номер порта должен находиться в диапазоне 1-65535', mtError, [mbOK], 0); end; end; Слушающая" нить TListenThreadсостоит из бесконечного ожидания подключения клиента. Каждый раз при подключении клиента библиотека сокетов создаёт новый сокет, и для работы с ним создается новая нить типа TClientThread(листинг 2.20). Листинг 2.20. Код "слушающей" нити procedure TListenThread.Execute; // Сокет, созданный для общения с подключившимся клиентом ClientSocket: TSocket; // Адрес подключившегося клиента ClientAddr: TSockAddr; ClientAddrLen: Integer; begin // Начинаем бесконечный цикл repeat ClientAddrLen := SizeOf(ClientAddr); // Ожидаем подключения клиента ClientSocket := accept(FServerSocket, @ClientAddr, @ClientAddrLen); if ClientSocket = INVALID_SOCKET then begin // Ошибка в функции accept возникает только тогда, когда // происходит нечто экстраординарное. Продолжать работу // в этом случае бессмысленно. LogMessage('Ошибка при подключении клиента: ' + GetErrorString); Break; end; // Создаем новую нить для обслуживания подключившегося клиента // и передаём ей сокет, созданный для взаимодействия с ним. TClientThread.Create(ClientSocket, ClientAddr); until False; closesocket(FServerSocket); LogMessage('Сервер завершил работу'); Synchronize(ServerForm.OnStopServer); end; Метод LogMessage, существующий у "слушающей" нити, эквивалентен тому, который приведен в листинге 2.7. Код нити типа TClientThread, которая отвечает за взаимодействие с одним клиентом, приведен в листинге 2.21. Листинг 2.21. Код нити, реализующей взаимодействие с клиентом // Сокет для взаимодействия с клиентом создается в главной нити, // а сюда передается через параметр конструктора. Для формирования // заголовка сюда же передается адрес подключившегося клиента constructor TClientThread.Create(ClientSocket: TSocket; const ClientAddr:TSockAddr); begin FSocket := ClientSocket; // Заголовок содержит адрес и номер порта клиента. // Этот заголовок будет добавляться ко всем сообщениям в лог // от данного клиента. FHeader := 'Сообщение от клиента ' + inet_ntoa(ClientAddr.sin_addr) + ':' + IntToStr(ntohs(ClientAddr.sin_port)) + ': '; inherited Create(False); end; procedure TClientThread.Execute; var Str: string; StrLen: Integer; begin LogMessage('Соединение установлено'); // Начинаем цикл, из которого выходим только при закрытии // соединения клиентом или из-за ошибки в сети. repeat // Читаем длину присланной клиентом строки и помещаем ее в StrLen case ReadFromSocket(FSocket, StrLen, SizeOf(StrLen)) of 0: begin LogMessage('Клиент закрыл соединение'); Break; end; -1: begin LogMessage('Ошибка при получении данных от клиента: ' + GetErrorString); Break; end; end; // Протокол не допускает строк нулевой длины if StrLen <= 0 then begin LogMessage('Неверная длина строки от клиента: ' + IntToStr(StrLen)); Break; end; // Установка длины строки в соответствии с полученным значением SetLength(Str, StrLen); // Чтение строки нужной длины case ReadFromSocket(FSocket, Str[1], StrLen) of 0: begin LogMessage('Клиент закрыл соединение'); Break; end; -1: begin LogMessage('Ошибка при получении данных от клиента: ' + GetErrorString); Break; end; end; LogMessage('Получена строка: ' + Str); // Преобразование строки Str := AnsiUpperCase(StringReplace(Str, #0, '#0', [rfReplaceAll]), ' (Multithreaded server)'; // Отправка строки. Отправляется на один байт больше, чем // длина строки, чтобы завершающий символ #0 тоже попал в пакет if send(FSocket, Str[1], Length(Str) + 1, 0) < 0 then begin LogMessage('Ошибка при отправке данных клиенту: ' + GetErrorString); Break; end; LogMessage('Клиенту отправлен ответ: ' + Str); until False; closesocket(FSocket); end; procedure TClientThread.LogMessage(const Msg: string); begin FMessage := FHeader + Msg; Synchronize(DoLogMessage); end; Метод LogMessageздесь несколько видоизменен по сравнению с предыдущими примерами: к каждому сообщению он добавляет адрес клиента, чтобы пользователь мог видеть, с каким именно из одновременно подключившихся клиентов связано сообщение. Что же касается кода Execute, то видно, что он практически не отличается от кода внутреннего цикла простейшего сервера (см. листинг 2.15). Это неудивительно — сообщение здесь читается и обрабатывается единым образом. Вся разница только в том, что теперь у нас одновременно могут работать несколько таких нитей, обеспечивая одновременную работу сервера с несколькими клиентами. Этот сервер уже можно использовать как образец для подражания. Нужно только помнить, что он тратит на каждого клиента относительно много ресурсов, и поэтому не подходит там, где могут подключаться сотни и более клиентов одновременно. Кроме того, этот сервер очень уязвим по отношению к DoS-атакам, поэтому подобный сервер целесообразен там. где число клиентов относительно невелико, а вероятность DoS-атак низка. Примечание 2.1.13. Определение готовности сокетаТак как многие функции библиотеки сокетов блокируют вызвавшую их нить, если соответствующая операция не может быть выполнена немедленно, часто бывает полезно заранее знать, готов ли сокет к немедленному (без блокирования) выполнению той или иной операции. Основным средством определения этого в библиотеке сокетов служит функция select: function select(nfds: Integer; readfds, writefds, exceptfds: PFDSet; timeout: PTimeVal): LongInt; Первый параметр этой функции оставлен только для совместимости со старыми версиями библиотеки сокетов: в существующих версиях он игнорируется. Три следующих параметра содержат указатели на множества сокетов (эти множества описываются типом TFDSet), состояние которых должно проверяться. В данном случае понятие множества не имеет ничего общего с типом множество в Delphi. В оригинальной версии библиотеки сокетов, написанной на C, определены макросы, позволяющие очищать такие множества, добавлять и удалять сокеты и определять, входит ли тот или иной сокет в множество. В модуле WinSock эти макросы заменены одноименными процедурами и функциями (листинг 2.22). Листинг 2.22. Функции для работы с типом TFDSet // Удаляет сокет Socket из множества FDSet. procedure FD_CLR(Socket: TSocket; var FDSet: TFDSet); // Определяет, входит ли сокет Socket в множество FDSet. function FD_ISSET(Socket: TSocket; var FDSet: TFDSet): Boolean; // Добавляет сокет Socket в множество FDSet. procedure FD_SET(Socket: TSocket; var FDSet: TFDSet); // Инициализирует множество FDSet. procedure FD_ZERO(var FDSet: TFDSet); При создании переменной типа TFDSetв той области памяти, которую она занимает, могут находиться произвольные данные, являющиеся, по сути дела, "мусором". Из-за этого мусора функции FD_CLR, FD_ISSET, и FD_SETне смогут работать корректно. Процедура FD_ZEROочищает мусор, создавая пустое множество. Вызов остальных функций FD_XXXдо вызова FD_ZEROприведёт к непредсказуемым результатам. Мы намеренно не приводим здесь описание внутренней структуры типа TFDSet. С помощью функций FD_XXXможно выполнить все необходимые операции с множеством, не зная этой структуры. Отметим, что в Windows и в Unix внутреннее устройство этого типа существенно различается, но благодаря использованию этих функций код остается переносимым. В Windows максимальное количество сокетов, которое может содержать в себе множество TFDSet, определяется значением константы FD_SETSIZE. По умолчанию ее значение равно 64. В C/C++ отсутствует раздельная компиляция модулей в том смысле, в котором она существует в Delphi, поэтому модуль в этих языках может поменять значение константы FD_SETSIZEперед включением заголовочного файла библиотеки сокетов, и это изменение приведёт к изменению внутренней структуры типа TFDSet(точнее, типа FDSet— в C/C++ он называется так). К счастью, в Delphi модули надежно защищены от подобного влияния друг на друга, поэтому как бы мы ни переопределяли константу FD _SETSIZEв своем модуле, на модуле WinSock это никак не отразится. В Delphi приходится прибегать к другому способу изменения количества сокетов в множестве: для этого следует определить свой тип, эквивалентный по структуре TFDSet, но резервирующий иное количество памяти для хранения сокетов (структуру TFDSetможно узнать из исходного кода модуля WinSock). В функцию selectможно передавать указатели на структуры нового типа, необходимо только приведение типов указателей. А вот существующие функции FD_XXX, к сожалению, не смогут работать с новой структурой, потому что компилятор требует строгого соответствия типов для параметров-переменных. Но, опять же, при необходимости очень легко создать аналоги этих функций для своей структуры. Примечание Последний параметр функции selectсодержит указатель на структуру TTimeVal, которая описывается следующим образом: TTimeVal = record tv_sec: LongInt; tv_usec: LongInt; end; Эта структура служит для задания времени ожидания. Поле tv_secсодержит число полных секунд в этом интервале, поле tv_usec— число микросекунд. Так, чтобы задать интервал ожидания, равный 1,5 с, нужно присвоить полю tv_secзначение 1, а полю tv_usec— значение 500 000. Параметр timeoutфункции selectдолжен содержать указатель на заполненную подобным образом структуру, определяющую, сколько времени функция будет ожидать, пока хотя бы один из сокетов не будет готов к требуемой операции. Если этот указатель равен nil, ожидание будет бесконечным. Мы потратили достаточно много времени, выясняя структуру параметров функции select. Теперь, наконец-то, можно перейти к описанию того, зачем она нужна и какой смысл несет каждый из ее параметров. Функция selectпозволяет дождаться, когда хотя бы один из сокетов, переданный в одном из множеств, будет готов к выполнению той или иной операции. Какой именно операции, определяется тем, в какое из трех множеств входит сокет. Для сокетов, входящих в множество readfds, готовность означает, что функции recvили recvfromбудут выполнены без блокирования. В случае UDP это означает, что во входном буфере сокета есть данные, которые можно прочитать. При использовании TCP функции recvи recvfromмогут быть выполнены без задержки еще в двух случаях: когда партнер закрыл соединение (в этом случае функции вернут 0), а также когда соединение некорректно разорвано (в этом случае функции вернут SOCKET_ERROR). Кроме того, если сокет, включенный в множество readfds, находится в состоянии ожидания соединения (в которое он переведен с помощью функции listen), то для него состояние готовности означает, что очередь соединений не пуста и функция acceptбудет выполнена без задержек. Для сокетов, входящих в множество writefds, готовность означает, что сокет соединен, а в его выходном буфере есть свободное место. (До сих пор мы обсуждали только блокирующие сокеты, для которых успешное завершение функции connect автоматически означает, что сокет соединен. Далее мы познакомимся с неблокирующими сокетами, для которых нужно вызвать функцию select, чтобы понять, установлено ли соединение.) Наличие свободного места в буфере не гарантирует того, что функции sendили sendtoне будут блокировать вызвавшую их нить, т.к. программа может попытаться передать больший объем информации, чем размер свободного места в буфере на момент вызова функции. В этом случае функции sendи sendtoвернут управление вызвавшей их нити только после того, как часть данных будет отправлена, и в буфере сокета освободится достаточно места. Следует отметить, что большинство протоколов обмена устроено таким образом, что при их реализации проблема переполнения выходного буфера практически никогда не возникает. Чаще всего клиент и сервер обмениваются небольшими пакетами, причем сервер посылает клиенту только ответы на его запросы, а клиент не посылает новый запрос до тех пор. пока не получит ответ на предыдущий. В этом случае гарантируется, что пакеты будут уходить о выходного буфера быстрее (или, по крайней мере, не медленнее), чем программа будет их туда помещать. Поэтому заботиться о том, чтобы в выходном буфере было место, приходится достаточно редко. И наконец, последнее множество exceptfds. Для сокетов, входящих в это множество, состояние готовности означает либо неудачу попытки соединения для неблокирующего сокета, либо получение высокоприоритетных данных (out-of-band data). В этой книге мы не будем детально рассматривать отправку и получение высокоприоритетных данных. Те, кому это понадобится, легко разберутся с этим вопросом по MSDN. Функция selectвозвращает общее количество сокетов, находящихся в состоянии готовности. Если функция завершила работу по тайм-ауту, возвращается 0. Множества readfds, writefdsи exceptfdsмодифицируются функцией: в них остаются только те сокеты, которые находятся в состоянии готовности. При вызове функции любые два из этих трех указателей могут быть равны nil, если программу не интересует готовность сокетов по соответствующим критериям. Один и тот же сокет может входить в несколько множеств. В листинге 2.23 приведен пример кода TCP-сервера, взаимодействующего с несколькими клиентами в рамках одной нити и работающего по простой схеме "запрос-ответ". Листинг 2.23. Пример сервера, использующегоselect var Sockets: array of TSocket; Addr: TSockAddr; Data: TWSAData; Len, I, J: Integer; FDSet: TFDSet; begin WSAStartup($101, Data); SetLength(Sockets, 1); Sockets[0] := socket(AF_INET, SOCK_STREAM, 0); Addr.sin_family := AF_INET; Addr.sin_port := htons(5514); Addr.sin_addr.S_addr := INADDR_ANY; FillChar(Addr.sin_zero, SizeOf(Addr.sin_zero), 0); bind(Sockets[0], Addr, SizeOf(TSockAddr)); listen(Sockets[0], SCMAXCONN); while True do begin // 1. Формирование множества сокетов FD_ZERO(FDSet); for I := 0 to High(Sockets) do FDSET(Sockets[1], FDSet); // 2. Проверка готовности сокетов select(0, @FDSet, nil, nil, nil); // 3. Чтение запросов клиентов тех сокетов, которые готовы к этому I := 1; while I <= High(Sockets) do begin if FD_ISSET(Sockets[I], FDSet) then if recv(Sockets[I], ...) <= 0 then begin // Связь разорвана, нужно закрыть сокет // и удалить его из массива closesocket(Sockets[I]); for J := I to High(Sockets) - 1 do Sockets[J] := Sockets[J + 1]; Dec(I); SetLength(Sockets, Length(Sockets) -1); end else begin // Получены данные от клиента, нужно ответить send(Sockets[I], ...); end; Inc(I); end; // 4. Проверка подключения нового клиента if FD_ISSET(Sockets[0], FDSet) then begin // Подключился новый клиент SetLength(Sockets, Length(Sockets) + 1); Len := SizeOf(TSockAddr); Sockets[High(Sockets)] := accept(Sockets[0], @Addr, @Len) end; end; end; Как и в предыдущих примерах, код для краткости не содержит проверок успешности завершения функций. Еще раз напоминаем, что в реальном коде такие проверки необходимы. Теперь разберем программу по шагам. Создание сокета, привязка к адресу и перевод в режим ожидания подключений вам уже знакомы, поэтому мы на них останавливаться не будем. Отметим только, что вместо переменной типа TSocketмы формируем динамический массив этого типа, длина которого сначала устанавливается равной одному элементу, и этот единственный элемент и содержит дескриптор созданного сокета. В дальнейшем мы будем добавлять в этот массив сокеты, создающиеся в результате выполнения функции accept. После перевода сокета в режим ожидания подключения начинается бесконечный цикл, состоящий из четырех шагов. На первом шаге цикла создаётся множество сокетов, в которое добавляются все сокеты, содержащиеся в массиве. В этом месте в примере пропущена важная проверка того, что сокетов в массиве не больше 64-х. Если их будет больше, то попытки добавить лишние сокеты в множество будут проигнорированы функцией FD_SETи, соответственно, эти сокеты выпадут из дальнейшего рассмотрения, т.е. даже если клиент что-то пришлет, сервер этого не увидит. Решить проблему можно тремя способами. Самый простой — это отказывать в подключении лишним клиентам. Для этого сразу после вызова acceptнужно вызывать для нового сокета closesocket. Второй способ — это увеличение количества сокетов в множестве, как это было описано ранее. В этом случае все равно остается та же проблема, хотя если сделать число сокетов в множестве достаточно большим, она практически исчезает. И наконец, можно разделить сокеты на несколько порций, для каждой из которых вызывать select отдельно. Это потребует усложнения примера, потому что сейчас в функции selectмы используем бесконечное ожидание. При разбиении сокетов на порции это может привести к тому, что из-за отсутствия готовых сокетов в первой порции программа не сможет перейти к проверке второй порции, в которой готовые сокеты, может быть, есть. Пример разделения сокетов на порции будет рассмотрен в следующем разделе. При создании множества оно сначала очищается, а потом в него в цикле добавляются сокеты. Для любителей кратких решений есть существенно более быстрый способ формирования множества, при котором не потребуются ни циклы, ни FD_ZERO, ни FD_SET: Move((PChar(Sockets) - 4)^, FDSet, Length(Sockets) * SizeOf(TSocket) + SizeOf(Integer)); Почему такая конструкция будет работать, предлагаем разобраться самостоятельно, изучив по справке Delphi, как хранятся в памяти динамические массивы, а по MSDN — структуру типа FDSET. Тем же, кто по каким-то причинам не захочет разбираться, настоятельно рекомендуем никогда и ни при каких обстоятельствах не использовать такую конструкцию, потому что в неумелых руках она превращается в мину замедленного действия, из-за которой ошибки могут появиться в самых неожиданных местах программы. Второй шаг — это собственно выполнение ожидания готовности сокетов с помощью функции select. Готовность к записи и к чтению высокоприоритетной информации нас в данном случае не интересует, поэтому мы ограничиваемся заданием множества readfds. В нашем простом примере не должно выполняться никаких действий, если ни один сокет не готов, поэтому последний параметр тоже равен nil, что означает ожидание, не ограниченное тайм-аутом. Третий шаг выполняется только после функции select, т.е. тогда, когда хотя бы один из сокетов находится в состоянии готовности. На этом шаге мы проверяем сокеты, созданные для взаимодействия с клиентами на предыдущих итерациях цикла с помощью функции accept. Эти сокеты располагаются в массиве сокетов, начиная с элемента с индексом 1. Программа в цикле просматривает все сокеты и, если они находятся в состоянии готовности, выполняет операцию чтения. На первый взгляд может показаться странным, почему для перебора элементов массива выбран цикл while, а не for. Но в дальнейшем мы увидим, что размер массива во время выполнения цикла может изменяться. Особенность же цикла forзаключается в том, что его границы вычисляются один раз и запоминаются в отдельных ячейках памяти, и дальнейшее изменение значений выражений, задающих эти границы, не изменяет эти границы. В нашем примере это приведет к тому, что в случае уменьшения массива цикл forне остановится на реальной уменьшившейся длине, а продолжит выполнение по уже не существующим элементам, что приведет к трудно предсказуемым последствиям. Поэтому в данном случае предпочтительнее цикл while, в котором условие продолжения цикла заново вычисляется при каждой его итерации. Напомним, что функция selectмодифицирует переданные ей множества таким образом, что в них остаются лишь сокеты, находящиеся в состоянии готовности. Поэтому чтобы проверить, готов ли конкретный сокет, достаточно с помощью функции FD_ISSETпроверить, входит ли он в множество FDSet. Если входит, то вызываем для него функцию recv. Если эта функция возвращает положительное значение, значит, данные в буфере есть, программа их читает и отвечает. Если функция возвращает 0 или -1 ( SOCKET_ERROR) значит, соединение закрыто или разорвано, и данный сокет больше не может быть использован. Поэтому мы должны освободить связанные с ним ресурсы ( closesocket) и убрать его из массива сокетов (как раз на этом шаге размер массива уменьшается). При удалении оставшиеся сокеты смещаются на одну позицию влево, поэтому переменную цикла необходимо уменьшить на единицу, иначе следующий сокет будет пропущен. И наконец, на четвертом шаге мы проверяем состояние готовности исходного сокета, который хранится в нулевом элементе массива. Так как этот сокет находится в режиме ожидания соединения, для него состояние готовности означает, что в очереди соединений появились клиенты, и необходимо вызвать функцию accept, чтобы создать сокеты для взаимодействия с этими клиентами. Хотя приведенный пример вполне работоспособен, следует отметить, что это только один из возможных вариантов организации сервера. Так что лучше не относиться к нему как к догме, потому что именно в вашем случае может оказаться предпочтительнее какой-либо другой вариант. Ценность этого примера заключается в том, что он иллюстрирует работу функции select, а не в том, что он дает готовое решение на все случаи жизни. 2.1.14. Примеры использования функции selectРассмотрим два практических примера использования функции selectдля получения информации о готовности сокета. Оба примера станут развитием рассмотренных ранее. Сначала модифицируем UDP-чат (см. разд. 2.1.10) таким образом, чтобы он использовал один сокет и для отправки, и для получения сообщений (пример SelectChat на компакт-диске). Вторая нить нам теперь не понадобится, всё будет делать главная форма. Процедуры создания сокета и отправки сообщений изменений не претерпели, главное дополнение — это то, что на форме появился таймер, в обработчике события OnTimerкоторого мы будем проверять с помощью select, пришло ли сообщение для сокета (листинг 2.24). С помощью таких простейших модификаций мы получили чат, который работает без распараллеливания и использует всего один сокет. Работать с таким чатом стало намного проще, потому что теперь ответ нужно посылать на тот же порт, с которого пришло сообщение, а не запоминать, какой порт для отправки какому из экземпляров чата соответствует. ПримечаниеЛистинг 2.24. Проверка готовности сокетов при обработке сообщения от таймера // Реакция на таймер. С периодичностью, заданной таймером, // проверяем, не пришли ли сообщения, и если пришли, // получаем их. procedure TChatForm.TimerChatTimer(Sender: TObject); var // Множество сокетов для функции select. // Будет содержать только один сокет FSocket. SocketSet: TFDSet; // Тайм-аут для функции select Timeout: TTimeVal; // Буфер для получения сообщения. // Размер равен максимальному размеру UDP-дейтаграммы Buffer: array[0..65506] of Byte; Msg: string; // Адрес, с которого пришло сообщение RecvAddr: TSockAddr; RecvLen, AddrLen: Integer; begin // Инициализируем множество сокетов, // т.е. очищаем его от случайного мусора FD_ZERO(SocketSet); // Добавляем в это множество сокет FSocket FD_SET(FSocket, SocketSet); // Устанавливаем тайм-аут равным нулю, чтобы // функция select ничего не ждала, а возвращала // готовность сокетов на момент вызова. Timeout.tv_sec := 0; Timeout.tv_usec := 0; // Проверяем готовность сокета для чтения if select(0, @SocketSet, nil, nil, @Timout) = SOCKET_ERROR then begin AddMessageToLog('Ошибка при проверке готовности сокета: ' + GetErrorString); Exit; end; // Проверяем, оставила ли функция select сокет в множестве. //Если оставила, значит, во входном буфере сокета есть данные. if FD_ISSET(FSocket, SocketSet) then begin AddrLen := SizeOf(RecvAddr); // Получаем дейтаграмму RecvLen := recvfrom(FSocket, Buffer, SizeOf(Buffer), 0, RecvAddr, AddrLen); // Так как UDP не поддерживает соединение, ошибку при вызове recvfrom // мы можем получить, только если случилось что-то совсем // экстраординарное. if RecvLen < 0 then begin AddMessageToLog('Ошибка при получении сообщения: ' + GetErrorString); Exit; end; // Устанавливаем нужный размер строки SetLength(Msg, RecvLen); // и копируем в неё дейтаграммы из буфера if RecvLen > 0 then Move(Buffer, Msg[1], RecvLen); AddMessageToLog('Сообщение с адреса ' + inet_ntoa(RecvAddr.sin_port) + ':' + IntToStr(ntohs(RecvAddr.sin_port)) + ': ' + Msg); end; end; Обратите внимание, что в обработчике события от таймера читается только одно сообщение, хотя за время, прошедшее с предыдущего вызова этого обработчика, в принципе, могло прийти несколько сообщений. Если запустить два экземпляра чата на одном компьютере, и с одного из них послать несколько сообщений подряд другому (добиться этого можно, несколько раз быстро нажав на кнопку Отправить), то адресат получит сообщения последовательно, с полусекундной задержкой между ними. Было бы достаточно просто организовать в обработчике сообщения таймера цикл до тех пор, пока функция selectне покажет, что сокет не готов к чтению, и извлечь за один раз сразу все сообщения, которые накопились в буфере сокета. Этого не сделано, чтобы уменьшить уязвимость чата по отношению к действиям потенциального злоумышленника. Имеется в виду та разновидность DoS-атаки, когда злоумышленник посылает большой поток сообщений, чтобы парализовать работу чата. Работа в этом случае, конечно же, будет парализована независимо от того, будет ли в обработчике события таймера извлекаться одно сообщение или все сразу — все равно чат будет замусорен бессмысленными сообщениями. Но в первом случае между показом сообщений будут интервалы, и пользователь хотя бы сможет корректно закрыть программу. Во втором же случае, если злоумышленник посылает сообщения достаточно быстро, цикл может оказаться бесконечным, обработка других оконных сообщений прекратится, и пользователь вынужден будет снять задачу средствами системы. Таким образом, извлечение только одного сообщения за один раз снижает ущерб от атаки. (Разумеется, вряд ли кто-то всерьез захочет атаковать наш учебный пример, но эту возможность следует учитывать при разработке более серьезных приложений.) Перейдем к следующему примеру использования select— TCP-серверу, который может работать одновременно с неограниченным числом клиентов (пример находится на компакт-диске в папке SelectServer). Этот сервер будет усовершенствованной версией нашего простейшего сервера (см. разд. 2.1.12) и тоже будет консольным приложением (функция select, как мы видели на примере UDP-чата, позволяет создавать приложения с графическим интерфейсом пользователя, так что реализация сервера в качестве консольного приложения — это не необходимость, а свободный выбор для иллюстрации различных способов применения функции select). Примечание Инициализация сокета и установка его в режим прослушивания в новом сервере ничем не отличается от простейшего, изменения начинаются только с цикла. Теперь цикл только один (вложенные циклы в нем есть, но они выполняют чисто техническую роль). Начинается цикл с того, что с помощью функции selectопределяется готовность к чтению слушающего сокета. Если слушающий сокет готов к чтению, то в данном случае это означает, что есть клиенты, которые уже подключились к серверу, но еще не были обработаны функцией accept. Если такие клиенты есть, то сервер принимает подключение, причем только одно за одну итерацию цикла. Для каждого подключившегося клиента сервер создает экземпляр записи TConnection, которая описана в листинге 2.25. Листинг 2.25. Описание типа TConnection // запись TConnection хранит информацию о подключившемся клиенте. // поле ClientAddr содержит строковое представление адреса клиента. // Поле ClientSocket содержит сокет, созданный функцией accept // для взаимодействия с данным клиентом. // Поле Deleted - служебное. Если оно равно False, значит, // соединение с данным клиентом по каким-то причинам потеряно, // и сервер должен освободить ресурсы, выделенные для этого клиента. PConnection = ^Connection; TConnection = record ClientAddr: string; ClientSocket: TSocket; Deleted: Boolean; end; Поле ClientAddrхранит строковое представление адреса клиента в виде "X.X.X.X:Port" — это поле используется только при выводе сообщений, связанных с данным клиентом. Поле ClientSocketсодержит сокет, созданный для связи с данным клиентом. Поле Deletedнеобходимо для того, чтобы упростить удаление записей для тех клиентов, соединение с которыми уже потеряно. Список соединений хранится в глобальной переменной FConnectionsтипа TList. Потеря соединения обнаруживается при попытке чтения или отправки данных через сокет. Если в одном цикле делать и попытки чтения, и удаление ненужных записей, этот цикл усложняется, и в нем легко сделать ошибку в индексах. Чтобы избежать этого, в "читающем" цикле те записи, для которых потеряно соединение, просто помечаются как удаленные с помощью свойства Deleted. Затем другой цикл удаляет все записи, помеченные для удаления. После проверки новых подключений начинается проверка получения сообщений от тех клиентов, которые уже подключены. Для этого перебираются сокеты из списка подключений и для каждого вызывается select. Чтобы повысить производительность, сокеты проверяются не по одному, а группами. Как уже было сказано, множество типа TFDSetможет содержать не более FD_SETSIZEсокетов, а в нашем списке их может оказаться больше. Приходится разбивать сокеты на группы размером по FD_SETSIZEи для каждой группы вызывать selectотдельно. Для тех сокетов, которые готовы к чтению, вызывается процедура ProcessSocketMessage. Ее код практически полностью совпадает с кодом одной итерации внутреннего цикла примера SimplestServer (см. листинг 2.15), т.е. процедура сначала читает размер строки, затем — саму строку, после этого формирует ответ и отправляет его клиенту. Реализуя эту функцию таким образом, мы пошли на некоторый риск блокировки, потому что функция select информирует только о том, что во входном буфере сокета есть хоть что-то, но вовсе не гарантирует, что там лежит уже все сообщение целиком. Наша же функция реализована таким образом, что она завершается либо после прочтения сообщения целиком, либо после обнаружения ошибки. Тем не менее в простых случаях можно пойти на такой риск, потому что, во-первых, короткие сообщения редко разбиваются на части, а во-вторых, если даже такое произойдет, оставшаяся часть сообщения, скорее всего, догонит первую достаточно быстро, и блокировка долгой не будет, так что риск при нормальной работе сети и клиента не очень велик. Примечание Завершается основной цикл сервера удалением всех ресурсов, связанных с закрытыми соединениями. После небольшой паузы, сделанной для того, чтобы сервер не нагружал процессор непрерывно, управление передается на начало цикла (листинг 2.26). Листинг 2.26. Основная часть сервера SelectServer// Тайм-аут для функции select, хотя и передается через указатель, // является для нее входным параметром, который не изменяется. // Так как у нас везде будет использоваться один и тот же нулевой // тайм-аут, можем один раз задать значение переменной Timeout // и в дальнейшем всегда им пользоваться. Timeout.tv_sec := 0; Timeout.tv_usec := 0; // Начало цикла подключения и общения с клиентами repeat // Сначала проверяем, готов ли слушающий сокет. // Если он готов, это означает, что есть подключившийся, // но не обработанный функцией accept клиент FD_ZERO(SockSet); FD_SET(MainSocket, SockSet); if select(0, @SockSet, nil, nil, @Timeout) = SOCKET_ERROR then raise ESocketException.Create('Ошибка при проверке готовности слушающего сокета: ' + GetErrorString); // Если функция select оставила MainSocket в множестве, значит, // зафиксировано подключение клиента, и функция accept не приведет // к блокированию нити. if FD_ISSET(MainSocket, SockSet) then begin ClientSockAddrLen := SizeOf(ClientSockAddr); // Принимаем подключившегося клиента. Для общения с ним создается // новый сокет, дескриптор которого помещается в ClientSocket. ClientSocket := accept(MainSocket, @ClientSockAddr, @ClientSockAddrLen); if ClientSocket = INVALID_SOCKET then raise ESocketException.Create( 'Ошибка при ожидании подключения клиента: ' + GetErrorString); // Создаем в динамической памяти новый экземпляр TConnection // и заполняем его данными, соответствующими подключившемуся клиенту New(NewConnection); NewConnection.ClientSocket := ClientSocket; NewConnection.ClientAddr := Format('%u.%u.%u.%u:%u', Ord(ClientSockAddr.sin_addr.S_un_b.s_bl), Ord(ClientSockAddr.sin_addr.S_un_b.s_b2), Ord(ClientSockAddr.sin_addr.S_un_b.s_b3), Ord(ClientSockAddr.sin_addr.S_un_b.s_b4), ntohs(ClientSockAddr.sin_port)); NewConnection.Deleted := False; // Добавляем соединение в список Connections.Add(NewConnection); WriteLn(OemString('Зафиксировано подключение с адреса ' + NewConnection.ClientAddr)); end; // Теперь проверяем готовность всех сокетов подключившихся клиентов. // Так как множество SockSet не может содержать более чем FT_SETSIZE // элементов, а размер списка Connections мы нигде не ограничиваем, // приходится разбивать Connections на "куски" размером не более // FD_SETSIZE и обрабатывать этот список по частям. // Поэтому у нас появляется два цикла - внешний, который повторяется // столько раз, сколько у нас будет кусков, и внутренний, который // повторяется столько раз, сколько элементов в одном куске. for J := 0 to Ceil(Connections.Count, FD_SETSIZE) - 1 do begin FD_ZERO(SockSet); for I := FD_SETSIZE * J to Min(FD_SETSIZE * (J + 1) - 1, Connections.Count - 1) do FD_SET(PConnection(Connections[I])^.ClientSocket, SockSet); if select(0, @SockSet, nil, nil, @Timeout) = SOCKET_ERROR then raise ESocketException.Create( 'Ошибка при проверке готовности сокетов: ' + GetErrorString); // Проверяем, какие сокеты функция select оставила в множестве, // и вызываем для них ProcessSocketMessage. В этом есть некоторый // риск, т.к. для того, чтобы select оставила сокет в множестве, // достаточно, чтобы он получил хотя бы один байт от клиента, // а не все сообщение. Поэтому может возникнуть такая ситуация, // когда сервер получил только часть сообщения, но уже пытается // прочитать сообщение целиком. Это приведет к блокированию нити, // но вероятность блокирования на долгое время мы оцениваем как // крайне низкую, т.к. оставшаяся часть сообщения, скорее всего, // придет достаточно быстро, и поэтому идем на такой риск. for I := FD_SETSIZE * J to Min(FD_SETSIZE * (J + 1) - 1, Connections.Count - 1) do if FD_ISSET(PConnection(Connections[I])^.ClientSocket, SockSet) then ProcessSocketMessage(PConnection(Connections[I])^); end; // Проверяем поле Deleted у всех соединений. Те, у которых // оно равно True, закрываем: закрываем сокет, освобождаем память, // удаляем указатель из списка. Цикл идет с конца списка к началу, // потому что в ходе работы цикла верхняя граница списка // может меняться, и цикл for снизу вверх мог бы привести // к появлению индексов вне диапазона. for I := Connections.Count - 1 downto 0 do if PConnection(Connections[I])^.Deleted then begin closesocket(PConnection(Connections[I])^.ClientSocket); Dispose(PConnection(Connections[I])); Connections.Delete(I); end; Sleep(100); until False; Функции Ceilи Min, которые встречаются здесь, можно было бы заменить одноимёнными функциями из модуля Math. Но этот модуль входит не во все варианты поставки Delphi, и чтобы пример можно было откомпилировать в любом варианте поставки Delphi, мы описали их здесь самостоятельно (листинг 2.27). Листинг 2.27. Функции Ceilи Min // Функция Ceil возвращает наименьшее целое число X, удовлетворяющее // неравенству X >= А / В function Ceil(A, B: Integer): Integer; begin Result := A div B; if A mod В <> 0 then Inc(Result); end; // Функция Min возвращает меньшее из двух чисел function Min(А, В: Integer): Integer; begin if A < В then Result := A else Result := B; end; Получившийся сервер более устойчив к DoS-атакам, чем написанный ранее многонитевой сервер. Так как он обходится одной нитью, планировщик задач не перегружается при большом числе подключившихся клиентов. DoS-атака заставляет расходовать только ресурсы библиотеки сокетов и процессорное время, причем вредный эффект последнего легко уменьшить, установив процессу сервера низкий приоритет. Однако сервер имеет другую уязвимость, связанную с возможным отступлением от протокола обмена клиентом (случайным или злонамеренным). Если клиент, например, пришлет всего один байт и на этом остановится, не разрывая связь с сервером, то при попытке получить сообщение от такого клиента сервер окажется заблокированным, т.к. будет ожидать как минимум четырех байтов (длина строки). Это полностью парализует работу сервера, потому что его единственная нить окажется заблокированной, и обрабатывать сообщения от других клиентов он не сможет. Примечание Сделать сервер более устойчивым к некорректным действиям клиента можно, если каждый раз читать ровно столько байтов, сколько пришло. Это усложнит сервер, т.к. придется между "сеансами связи с клиентом" помнить сколько байтов было прочитано в прошлый раз. Однако это поможет полностью избежать блокировок при операциях чтения, что существенно повысит надежность сервера. В этом разделе мы не будем рассматривать соответствующий пример, а реализуем эту возможность в следующем сервере, использующем неблокирующие сокеты. В сервере на основе selectэто делается совершенно аналогично. 2.1.15. Неблокирующий режимРанее мы столкнулись с функциями, которые могут надолго приостановить работу вызвавшей их нити, если действие не может быть выполнено немедленно. Это функции accept, recv, recvfrom, send, sendtoи connect(в дальнейшем в этом разделе мы не будем упоминать функции recvfromи sendto, потому что они в смысле блокирования эквивалентны функциям recvи sendсоответственно, и все, что будет здесь сказано о recvи send, применимо к recvfromи sendto). Такое поведение не всегда удобно вызывающей программе, поэтому в библиотеке сокетов предусмотрен особый режим работы сокетов — неблокирующий. Этот режим может быть установлен или отменен дм каждого сокета индивидуально с помощью функции ioctlsocket, имеющей следующий прототип: function ioctlsocket(s: TSocket; cmd: DWORD; var arg: u_long): Integer; Данная функция предназначена для выполнения нескольких логически мало связанных между собой действий. Возможно, у разработчиков первых версий библиотеки сокетов были причины экономить на количестве функций, потому что мы и дальше увидим, что иногда непохожие операции выполняются одной функцией. Но вернемся к ioctlsocket. Ее параметр cmdопределяет действие, которое выполняет функция, а также смысл параметра arg. Допустимы три значения параметра cmd: SIOCATMARK, FIONREADи FIONBIO. При задании SIOCATMARKпараметр argрассматривается как выходной: в нем возвращается ноль, если во входном буфере сокета имеются высокоприоритетные данные, и ненулевое значение, если таких данных нет (как уже было оговорено, мы в этой книге не будем касаться передачи высокоприоритетных данных). При cmd, равном FIONREAD, в параметре argвозвращается размер данных, находящихся во входном буфере сокета, в байтах. При использовании TCP это число равно максимальному количеству информации, которое можно получить на данный момент за один вызов recv. Для UDP это значение равно суммарному размеру всех находящихся в буфере дейтаграмм (напомним, что прочитать несколько дейтаграмм за один вызов recvнельзя). Функция ioctlsocketс параметром FIONREADможет использоваться для проверки наличия данных с целью избежать вызова recv тогда, когда это может привести к блокированию, или для организации вызова recv в цикле до тех пор, пока из буфера не будет извлечена вся информация. При задании аргумента FIONBIOпараметр argрассматривается как входной. Если его значение равно нулю, сокет будет переведен в блокирующий режим, если не равно нулю — в неблокирующий. Таким образом, чтобы перевести который сокет sв неблокирующий режим, нужно выполнить следующие действия (листинг 2.28). Листинг 2.28. Перевод сокета в неблокирующий режим var S: TSocket; Arg: u_long; begin ... Arg := 1; ioctlsocket(S, FIONBIO, Arg); Пока программа использует только стандартные сокеты (а не сокеты Windows), сокет может быть переведен в неблокирующий или обратно в блокирующий режим в любой момент. Неблокирующим может быть сделан любой сокет (серверный или клиентский) независимо от протокола. Функция ioctlsocketвозвращает нулевое значение в случае успеха и ненулевое — при ошибке. В примере, как всегда, проверка результата для краткости опущена. Итак, по умолчанию сокет работает в блокирующем режиме. С особенностями работы функций accept, connect, recvи sendв этом режиме мы уже познакомились. Теперь рассмотрим то, как они ведут себя в неблокирующем режиме. Для этого сначала вспомним, когда эти функции блокируют вызвавшую их нить. □ accept— блокирует нить, если на момент ее вызова очередь подключений пуста. □ connect— в случае TCP блокирует сокет практически всегда, потому что требуется время на установление связи с удаленным сокетом. Без блокирования вызов connectвыполняется только в том случае, если какая-либо ошибка не дает возможности приступить к операции установления связи. Также без блокирования функция connect выполняется при использовании UDP, потому что в данном случае она только устанавливает фильтр для адресов. □ recv— блокирует нить, если на момент вызова входной буфер сокета пуст. □ send— блокирует нить, если в выходном буфере сокета недостаточно места, чтобы скопировать туда переданную информацию. Если условия, при которых эти функции выполняются без блокирования, выполнены, то их поведение в блокирующем и неблокирующем режимах идентично. Если же выполнение операции без блокирования невозможно, функции возвращают результат, указывающий на ошибку . Чтобы понять, произошла ли ошибка из-за необходимости блокирования или из-за чего-либо еще. программа должна вызвать функцию WSAGetLastError. Если она вернет WSAEWOULDBLOCK, значит, никакой ошибки не было, но выполнение операции без блокирования невозможно. Закрывать сокет и создавать новый после WSAEWOULDBLOCK, разумеется, не нужно, т.к. ошибки не было, и связь (в случае TCP) осталась неразорванной. Следует отметить, что при нулевом выходном буфере сокета (т.е. когда функция sendпередаст данные напрямую в сеть) и большом объеме информации функция sendможет выполняться достаточно долго, т.к. эти данные отправляются по частям, и на каждую часть в рамках протокола TCP получаются подтверждения. Но эта задержка не считается блокированием, и в данном случае sendбудет одинаково вести себя с блокирующими и неблокирующими сокетами, т.е. вернет управление программе лишь после того, как все данные окажутся в сети. Для функций accept, recvи send WSAEWOULDBLOCKозначает, что операцию следует повторить через некоторое время, и, может быть, в следующий раз она не потребует блокирования и будет выполнена. Функция connectв этом случае начинает фоновую работу по установлению соединения. О завершении этой работы можно судить по готовности сокета, которая проверяется с помощью функции select. Листинг 2.29 иллюстрирует это. Листинг 2.29. Установление связи при использовании неблокирующего сокета var S: TSocket; Block: u_long; SetW, SetE: TFDSet; begin S :=socket(AF_INET, SOCK_STREAM, 0); ... Block := 1; ioctlsocket(S, FIONBIO, Block); connect(S, ...); if WSAGetLastError <> WSAEWOULDBLOCK then begin // Произошла ошибка raise ... end; FD_ZERO(SetW); FD_SET(S, SetW); FD_ZERO(SetE); FD_SET(S, SetE); select(0, nil, @SetW, @SetE, nil); if FD_ISSET(S, SetW) then // Connect выполнен успешно else if FD_ISSET(S, SetE) then // Соединиться не удалось else // Произошла еще какая-то ошибка Напомним, что сокет, входящий в множество SetW, будет считаться готовым, если он соединен, а в его выходном буфере есть место. Сокет, входящий в множество SetE, будет считаться готовым, если попытка соединения не удалась. До тех пор, пока попытка соединения не завершилась (успехом или неудачей), ни одно из этих условий готовности не будет выполнено. Таким образом, в данном случае selectзавершит работу только после того, как будет выполнена попытка соединения, и о результатах этой попытки можно будет судить по тому, в какое из множеств входит сокет. Из приведенного примера не видно, какие преимущества дает неблокирующий сокет по сравнению с блокирующим. Казалось бы, проще вызвать connect в блокирующем режиме, дождаться результата и лишь потом переводить сокет в неблокирующий режим. Во многих случаях это действительно может оказаться удобнее. Преимущества соединения в неблокирующем режиме связаны с тем, что между вызовами connectи selectпрограмма может выполнить какую-либо полезную работу, а в случае блокирующего сокета программа будет вынуждена сначала дождаться завершения работы функции connect и лишь потом сделать что-то еще. Функция sendдля неблокирующего сокета также имеет некоторые специфические черты поведения. Они проявляются, когда свободное место в выходном буфере есть, но его недостаточно для хранения данных, которые программа пытается отправить с помощью этой функции. В этом случае функция send, согласно документации, может скопировать в выходной буфер такой объем данных, для которого хватает места. При этом она вернет значение, равное этому объему (оно будет меньше, чем значение параметра len, заданного программой). Оставшиеся данные программа должна отправить позже, вызвав еще раз функцию send. Такое поведение функции send возможно только при использовании TCP. В случае UDP дейтаграмма никогда не разделяется на части, и если в выходном буфере не хватает места для всей дейтаграммы, то функция sendвозвращает ошибку, a WSAGetLastError— WSAEWOULDBLOCK. Сразу отметим, что, хотя спецификация допускает частичное копирование функцией sendданных в буфер сокета, на практике такое поведение наблюдать пока не удалось: все эксперименты показали, что функция sendвсегда либо копирует данные целиком, расширяя при необходимости буфер, либо дает ошибку WSAEWOULDBLOCK. Далее этот вопрос будет обсуждаться подробнее. Тем не менее при написании программ следует учитывать возможность частичного копирования, т.к. оно может появиться в тех условиях или в тех реализациях библиотеки сокетов, которые в наших экспериментах не были проверены. 2.1.16. Сервер на неблокирующих сокетахВ этом разделе мы создадим сервер, основанный на неблокирующих сокетах. Это будет наш первый сервер, не использующий функцию ReadFromSocket(см. листинг 2.13). Этот сервер (пример NonBlockingServerна компакт-диске) состоит из одной нити, которая никогда не будет блокироваться сокетными операциями, т.к. все сокеты используют неблокирующий режим. На форме находится таймер, по сигналам которого сервер выполняет попытки чтения данных с сокетов всех подключившихся клиентов. Если данных нет, функция recv немедленно завершается с ошибкой WSAEWOULDBLOCK, и сервер переходит к попытке чтения из следующего сокета. Запуск сервера (листинг 2.30) мало чем отличается от запуска многонитевого сервера (см. листинг 2.19). Практически вся разница заключается в том, что вместо запуска "слушающей" нити сокет переводится в неблокирующий режим и включается таймер. Листинг 2.30. Инициализация сервера на неблокирующих сокетах// Реакция на кнопку "Запустить" - запуск сервера procedure TServerForm.BtnStartServerClick(Sender: TObject); var // Адрес, к которому привязывается слушающий сокет ServerAddr: TSockAddr; NonBlockingArg: u_long; begin // Формируем адрес для привязки. FillChar(ServerAddr.sin_zero, SizeOf(ServerAddr.sin_zero), 0); ServerAddr.sin_family := AF_INET; ServerAddr.sin_addr.S_addr := INADDR_ANY; try ServerAddr.sin_port := htons(StrToInt(EditPortNumber.Text)); if ServerAddr.sin_port = 0 then begin MessageDlg('Номер порта должен находиться в диапазоне 1-65535', mtError, [mbOK], 0); Exit; end; // Создание сокета FServerSocket := socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if FServerSocket = INVALID_SOCKET then begin MessageDlg('Ошибка при создании сокета: '#13#10 + GetErrorString, mtError, [mbOK], 0); Exit; end; // Привязка сокета к адресу if bind(FServerSocket, ServerAddr, SizeOf(ServerAddr)) = SOCKET_ERROR then begin MessageDlg('Ошибка при привязке сокета к адреcу: '#13#10 + GetErrorString, mtError, [mbOK], 0); closesocket(FServerSocket); Exit; end; // Перевод сокета в режим прослушивания if listen(FServerSocket, SOMAXCONN) = SOCKET_ERROR then begin MessageDlg('Ошибка при переводе сокета в режим прослушивания:'#13#10 + GetErrorString, mtError, [mbOK], 0); closesocket(FServerSocket); Exit; end; // Перевод сокета в неблокирующий режим NonBlockingArg := 1; if ioctlsocket(FServerSocket, FIONBIO, NonBlockingArg) = SOCKET_ERROR then begin MessageDlg('Ошибка при переводе сокета в неблокирующий режим:'#13#10 + GetErrorString, mtError, [mbOK], 0); closesocket(FServerSocket); Exit; end; // Перевод элементов управления в состояние "Сервер работает" LabelPortNumber.Enabled := False; EditРоrtNumber.Enabled := False; BtnStartServer.Enabled := False; TimerRead.Interval := TimerInterval; LabelServerState.Caption := 'Сервер работает'; except on EConvertError do // Это исключение может возникнуть только в одном месте - // при вызове StrToInt(EditPortNumber.Text) MessageDlg('"' + EditPortNumber.Text + '" не является целым числом', mtError, [mbOK], 0); on ERangeError do // Это исключение может возникнуть только в одном месте - // при присваивании значения номеру порта MessageDlg('Номер порта должен находиться в диапазоне 1-65535', mtError, [mbOK], 0); end; end; Так как протокол TCP допускает разбиение посылки на произвольное число пакетов, возможна ситуация, когда на момент срабатывания таймера в буфере сокета будет только часть того, что отправил клиент. Так как мы договорились не блокировать нить, то ждать, пока придет остальное, мы не будем. Вместо этого будем запоминать то, что пришло, а при следующем срабатывании таймера, если пришло еще что-то. добавлять это к предыдущим данным, и так до тех пор, пока не придет все, что мы ожидаем получить от клиента. Так как посылка может разорваться в любом месте, наш код должен быть к этому готов. Взаимодействие сервера с клиентом состоит из трех этапов. На первом этапе сервер получает от клиента четырёхбайтное значение — длину строки. На втором этапе сервер получает от клиента саму строку, размер которой уже известен из величины, полученной на первом этапе. На третьем этапе сервер отправляет ответ клиенту, состоящий из строки, завершающейся нулем. Чтобы при очередном "тике" таймера сервер мог продолжить общение с клиентом, прерванное в произвольном месте, необходимо запоминать, на каком этапе было прервано взаимодействие в предыдущий раз, сколько байтов на данном этапе уже прочитано или отправлено и сколько еще осталось прочитать или отправить. Для хранения этих данных мы будем использовать типы TTransportPhaseи TConnection(листинг 2.31). Листинг 2.31. Типы TTransportPhaseи TConnection type // Этап взаимодействия с клиентом: // tpReceiveLength - сервер ожидает от клиента длину строки // tpReceiveString - сервер ожидает от клиента строку // tpSendString - сервер посылает клиенту строку TTransportPhase = (tpReceiveLength, tpReceiveString, tpSendString); // Информация о соединении с клиентом: // СlientSocket - сокет, созданный для взаимодействия с клиентом // ClientAddr - строковое представление адреса клиента // MsgSize - длина строки, получаемая от клиента // Msg - строка, получаемая от клиента или отправляемая ему, // Phase - этап взаимодействия с данным клиентом // Offset - количество байтов, уже полученных от клиента // или отправленных ему на данном этапе // BytesLeft - сколько байтов осталось получить от клиента // или отправить ему на данном этапе PConnection = ^TConnection; TConnection = record ClientSocket: TSocket; ClientAddr: string; MsgSize: Integer; Msg: string; Phase: TTransportPhase; Offset: Integer; BytesLeft: Integer; end; Для каждого подключившегося клиента создается отдельный экземпляр записи TConnection, в котором хранится информация как о самом подключении, так и о том, на каком этапе находится взаимодействие с данным клиентом. Проверка подключения клиентов и взаимодействие с подключившимися ранее реализуется, как уже было сказано, при обработке события таймера. Код обработчика приведен в листинге 2.32. Листинг 2.32. Обработчик события таймера// Обработка сообщения от таймера // В ходе обработки проверяется наличие вновь подключившихся клиентов // а также осуществляется обмен данными с клиентами procedure TServerForm.TimerReadTimer(Sender: TObject); var // Сокет, который создается для вновь подключившегося клиента ClientSocket: TSocket; // Адрес подключившегося клиента ClientAddr: TSockAddr; // Длина адреса AddrLen: Integer; // Вспомогательная переменная для создания нового подключения NewConnection: PConnection; I: Integer; begin AddrLen := SizeOf(TSockAddr); // Проверяем наличие подключении. Так как сокет неблокирующий, // accept не будет блокировать нить даже в случае отсутствия // подключений. ClientSocket := accept(FServerSocket, @ClientAddr, @AddrLen); if ClientSocket = INVALID_SOCKET then begin // Если произошедшая ошибка - WSAEWOULDBLOCK, это просто означает, // что на данный момент подключений нет, а вообще все в порядке, // поэтому ошибку WSAEWOULDBLOCK мы просто игнорируем. Прочие же // ошибки могут произойти только в случае серьезных проблем, // которые требуют остановки сервера. if WSAGetLastError <> WSAEWOULDBLOCK then begin MessageDlg('Ошибка при подключении клиента:'#13#10 + GetErrorString + #13#10'Сервер будет остановлен', mtError, [mbOK], 0); ClearConnections; closesocket(FServerSocket); OnStopServer; end; end else begin // Создаем запись для нового подключения и заполняем ее New(NewConnection); NewConnection.ClientSocket := ClientSocket; NewConnection.СlientAddr := Format('%u.%u.%u.%u:%u', [ Ord(ClientAddr.sin_addr.S_un_b.s_b1), Ord(ClientAddr.sin_addr.S_un_b.s_b2), Ord(ClientAddr.sin_addr.S_un_b.s_b3), Ord(ClientAddr.sin_addr.S_un_b.s_b4), ntohs(ClientAddr.sin_port)]); NewConnection.Phase := tpReceiveLength; NewConnection.Offset := 0; NewConnection.BytesLeft := SizeOf(Integer); // Добавляем запись нового соединения в список FConnections.Add(NewConnection); AddMessageToLog('Зафиксировано подключение с адреса ' + NewConnection.ClientAddr); end; // Обрабатываем все существующие подключения. // Цикл идет от конца списка к началу потому, что в ходе // обработки соединение может быть удалено из списка. for I := FConnections.Count - 1 downto 0 do processConnection(I); end; Обратите внимание, что сокет, созданный функцией accept, нигде не переводится в неблокирующий режим. Это связано с тем, что такой сокет наследует свойства слушающего сокета, поэтому он в данном случае сразу создается неблокирующим. Собственно взаимодействие сервера с клиентом вынесено в метод ProcessConnection(листинг 2.33). который осуществляет чтение данных от клиента и отправку данных в соответствии с этапом, на котором остановилось взаимодействие. При реализации этого метода необходимо просто аккуратно следить за тем, куда и сколько данных нужно передать. Листинг 2.33. Метод ProcessConnection // Обработка клиента. Index задает индекс записи в списке procedure TServerForm.ProcessConnection(Index: Integer); var // Вспомогательная переменная, чтобы не приводить каждый раз // FConnections[Index] к PConnection Connection: PConnection; // Результат вызова recv и send Res: Integer; // Вспомогательная процедура, освобождающая ресурсы, связанные // с клиентом и удаляющая запись подключения из списка procedure RemoveConnection; begin closesocket(Connection.ClientSocket); Dispose(Connection); FConnections.Delete(Index); end; begin Connection := PConnection(PConnections[Index]); // Проверяем, на каком этапе находится взаимодействие с клиентом. // Используется оператор if, а не case, потому, что в случае case // выполняется только одна альтернатива, а в нашем случае в ходе // выполнения этапа он может завершиться, и взаимодействие // перейдет к следующему этапу. Использование if позволяет выполнить // все три этапа, если это возможно, а не один из них. if Connection.Phase = tpReceiveLength then begin // Этап получения от клиента длины строки. При выполнении этого // этапа сервер получает от клиента длину строки и размещает ее // в поле Connection.MsgSize. Здесь приходится учитывать, что // теоретически даже такая маленькая (4 байта) посылка может // быть разбита на несколько пакетов, поэтому за один раз этот // этап не будет завершен, и второй раз его придется продолжать, // загружая оставшиеся байты. Connection.Offset — количество // уже прочитанных на данном этапе байтов - одновременно является // смещением, начиная с которого заполняется буфер. Res := recv(Connection.ClientSocket, (PChar(@Connection.MsgSize) + Connection.Offset)^, Connection.BytesLeft, 0); if Res > 0 then begin // Если Res > 0, это означает, что получено Res байтов. // Соответственно, увеличиваем на Res количество прочитанных // на данном этапе байтов и на такую же величину уменьшаем // количество оставшихся. Inc(Connection.Offset, Res); Dec(Connection.BytesLeft, Res); // Если количество оставшихся байтов равно нулю, можно переходить // к следующему этапу. if Connection.BytesLeft = 0 then begin // Проверяем корректность принятой длины строки if Connection.MsgSize <= 0 then begin AddMessageToLog('Неверная длина строки от клиента ' + Connection.ClientAddr + ': ' + IntToStr(Connection.MsgSize)); RemoveConnection; Exit; end; // Следующий этап - это чтение самой строки Connection.Phase := tpReceiveString; // Пока на этом этапе не прочитано ни одного байта Connection.Offset := 0; // Осталось прочитать Connection.MsgSize байтов Connection.BytesLeft := Connection.MsgSize; // Сразу выделяем память под строку SetLength(Connection.Msg, Connection.MsgSize); end; end else if Res = 0 then begin AddMessageToLog('Клиент ' + Connection.ClientAddr + ' закрыл соединение'); RemoveConnection; Exit; end else // Ошибку WSAEWOULDBLOCK игнорируем, т.к. она говорит // только о том, что входной буфер сокета пуст, но в целом // все в порядке if WSAGetLastError <> WSAEWOULDBLOCK then begin AddMessageToLog('Ошибка при получении данных от клиента ' + Connection.ClientAddr + ': ' + GetErrorString); RemoveConnection; Exit; end; end; if Connection. Phase := tpReceiveString then begin // Следующий этап - чтение строки. Он практически не отличается // по реализации от этапа чтения длины строки, за исключением // того, что теперь буфером, куда помещаются полученные от клиента // данные, служит не Connection.MsgSize, a Connection.Msg. Res := recv(Connection.ClientSocket, Connection.Msg[Connection.Offset + 1], Connection.BytesLeft, 0); if Res > 0 then begin Inc(Connection.Offset, Res); Dec(Connection.BytesLeft, Res); // Если количество оставшихся байтов равно нулю, можно переходить // к следующему этапу. if Connection.BytesLeft = 0 then begin AddMessageToLog('От клиента ' + Connection.ClientAddr + ' получена строка: ' + Connection.Msg); // Преобразуем строку. В отличие от предыдущих примеров, здесь // мы явно добавляем к строке #0. Это связано с тем, что при // отправке, которая тоже может быть выполнена не за один раз, // мы указываем индекс того символа строки, начиная с которого // нужно отправлять данные. И (хотя теоретически вероятность // этого очень мала) может возникнуть ситуация, когда за // один раз будут отправлены все символы строки, кроме // завершающего #0, и тогда при следующей отправке начинать // придется с него. Если мы будем использовать тот #0, который // добавляется к концу строки автоматически, то в этом случае // индекс выйдет за пределы диапазона. Поэтому мы вручную // добавляем еще один #0 к строке, чтобы он стал законной // ее частью. Connection.Msg := AnsiUpperCase(StringReplace(Connection.Msg, #0, '#0', [rfReplaceAll])) + ' (Non-blocking server)'#0; // Следующий этап - отправка строки клиенту Connection.Phase := tpSendString; // Отправлено на этом этапе 0 байт Connection.Offset := 0; // Осталось отправить Length(Connection.Msg) байт. // Единицу к длине строки, в отличие от предыдущих примеров, // не добавляем, т.к. там эта единица нужна была для того, // чтобы учесть добавляемый к строке автоматически символ #0. // Здесь мы еще один #0 добавили к строке явно, поэтому // он уже учтен в функции Length. Connection.BytesLeft := Length(Connection.Msg); end; end else if Res = 0 then begin AddMessageToLog('Клиент ' + Connection.ClientAddr + ' закрыл соединение'); RemoveConnection; Exit; end else // Как обычно, "ошибку" WSAEWOULDBLOCK просто игнорируем if WSAGetLastError <> WSAEWOULDBLOCK then begin AddMessageToLog('Ошибка при получении данных от клиента ' + Connection.ClientAddr + ': ' + GetErrorString); RemoveConnection; Exit; end; end; if Connection.Phase = tpSendString then begin // Следующий этап — отправка строки. Код примерно такой же, // как и в предыдущем этапе, но вместо recv используется send. // Кроме того, отсутствует проверка на Res = 0, т.к. при // использовании TCP send никогда не возвращает 0. Res := send(Connection.ClientSocket, Connection.Msg[Connection.Offset + 1], Connection.BytesLeft, 0); if Res > 0 then begin Inc(Connection.Offset, Res); Dec(Connection.BytesLeft, Res); // Если Connection.BytesLeft = 0, значит, строка отправлена // полностью. if Connection.BytesLeft = 0 then begin AddMessageToLog('Клиенту ' + Connection.ClientAddr + ' отправлена строка: ' + Connection.Msg); // Очищаем строку, престо сэкономить память Connection.Msg := ''; // Следующий этап - снова получение длины строки от клиента Connection.Phase := tpReceiveLength; // Получено - 0 байт Connection.Offset := 0; // Осталось прочитать столько, сколько занимает целое число Connection.BytesLeft := SizeOf(Integer); end; end else if WSAGetLastError <> WSAEWOULDBLOCK then begin AddMessageToLog('Ошибка при отправке данных клиенту ' + Connection.ClientAddr + ': ' + GetErrorString); RemoveConnection; Exit; end; end; end; В итоге мы получили сервер, достаточно устойчивый как к подключению множества клиентов, так и к нарушению протокола со стороны клиента. Для самостоятельной работы рекомендуем подумать о том, как можно сделать UDP-чат на неблокирующих сокетах. На самом деле он мало чем будет отличаться от рассмотренного чата на основе select. Просто при использовании selectпроверка возможности неблокирующего чтения из сокета проверяется предварительным вызовом этой функции, а в случае неблокирующих сокетов сначала вызывается recvfrom, а потом проверяется, было что-то прочитано, или же операция не может быть выполнена потому, что блокировки запрещены. Во всем остальном использование selectи неблокирующих сокетов очень похоже, причем не только в данном случае, но и вообще. 2.1.17. Параметры сокетаКаждый сокет обладает рядом параметров (опций), которые влияют на его работу. Существуют параметры уровня сокета, которые относятся к сокету как к объекту безотносительно используемого протокола и его уровня. Впрочем, некоторые параметры уровня сокета применимы не ко всем протоколам. Здесь мы не будем рассматривать все параметры сокета, а ограничимся лишь изложением методов доступа к ним и познакомимся с некоторыми самыми интересными параметрами. Для получения текущего значения параметров сокета предусмотрена функция getsockopt, для изменения — setsockopt. Прототипы этих функций выглядят следующим образом: function getsockopt(s: TSocket; level, optname: Integer; optval: PChar; var optlen: Integer): Integer; function setsockopt(s: TSocket; level, optname: Integer; optval: PChar; optlen: Integer): Integer; Параметры у функций почти одинаковы. Первый задает сокет, параметры которого следует узнать или изменить. Второй указывает, параметр какого уровня следует узнать или изменить. Третий задает сам параметр сокета. Параметр optvalсодержит указатель на буфер, в котором хранится значение параметра, a optlen— размер этого буфера (разные параметры имеют различные типы и поэтому размер буфера может быть разным). Функция getsockoptсохраняет значение параметра в буфере, заданном указателем optval. Длина буфера передается через параметр optlen, и через него же возвращается размер, реально понадобившийся для хранения параметра. У функции setsockoptпараметр optvalсодержит указатель на буфер, хранящий новое значение параметра сокета, a optlen— размер этого буфера. Чаще всего параметры сокета имеют целый или логический тип. В обоих случаях параметр optvalдолжен содержать указатель на значение типа Integer. Для логического типа любое ненулевое значение интерпретируется True, нулевое — как False. Два достаточно важных параметра сокета — размеры входного и выходного буфера. Это параметры уровня сокета ( SOL_SOCKET), их номера задаются константами SO_RCVBUFи SO_SNDBUF. Например, чтобы получить размер входного буфера сокета, нужно выполнить код листинга 2.34. Листинг 2.34. Получение размера входного буфера сокета var Val, Len: Integer; S: TSocket; begin ... Len := SizeOf(Integer); getsockopt(S, SOL_SOCKET, SO_RCBUF, @Val, Len); После выполнения этого кода размер буфера будет содержаться в переменной Val. Немного поэкспериментировав, можно обнаружить, что размер входного и выходного буфера равен 8192 байтам как для TCP, так и для UDP. Тем не менее это не мешает отправлять и получать дейтаграммы большего размера (для UDP), а также накапливать в буфере больший объем информации (для TCP). При получении данных это достигается за счет использования более низкоуровневых буферов, чем буфер самого сокета. Можно даже установить входной буфер сокета равным нулю — тогда все поступившие данные будут храниться в низкоуровневых буферах. Однако делать так не рекомендуется, т.к. при этом снижается производительность. Как уже говорилось, если буфер для исходящих имеет нулевой размер, то функции sendи sendtoнезависимо от режима работы сокета отправляют данные непосредственно в сеть. Если же размер этого буфера не равен нулю, при необходимости он может увеличиваться. В MSDN описаны следующие правила роста буфера: 1. Если объем данных в буфере меньше, чем это задано параметром SO_SNDBUF, то новые данные копируются в буфер полностью. Буфер при необходимости увеличивается. 2. Если объем данных в буфере достиг или превысил SO_SNDBUF, но в буфере находятся данные, переданные в результате только одного вызова send, последующий вызов приводит к увеличению буфера до размера, необходимого, чтобы принять эти данные целиком. 3. Если объем данных в буфере достиг или превысил SO_SENDBUF, и эти данные оказались в буфере в результате нескольких вызовов send, то буфер не расширяется. Блокирующий сокет при этом ждет, когда за счет отправки данных в буфере появится место, неблокирующий завершает операцию с ошибкой WSAEWOULDBLOCK. Следует отметить, что увеличение размера буфера носит временный характер. Заметим также, что в ходе наших экспериментов второе правило воспроизвести не удалось. Если предел, заданный параметром SO_SNDBUF, был достигнут, не удавалось поместить новые данные в буфер независимо от того, были ли имеющиеся данные положены туда одним вызовом sendили несколькими. Впрочем, это могут быть детали реализации, которые различны в разных версиях системы. Ранее мы упоминали, что UDP допускает широковещательную рассылку (рассылку по адресу 255.255.255.255 и т.п.). Но по умолчанию такая рассылка запрещена. Чтобы разрешить широковещательную рассылку, нужно установить в Trueпараметр SO_BROADCAST, относящийся к уровню сокета ( SOL_SOCKET). Таким образом, вызов функции setsockoptдля разрешения широковещательной рассылки будет выглядеть так, как показано в листинге 2.35. Листинг 2.35. Включение возможности широковещательной рассылки var EnBroad: Integer; begin EnBroad := 1; setsockopt(S, SOL_SOCKET, SO_BROADCAST, PChar(@EnBroad), SizeOf(Integer)); Для запрета широковещательной рассылки через сокет используется тот же код, за исключением того, что переменной EnBroadследует присвоить ноль. Последний параметр сокета, который мы рассмотрим, называется SO_LINGER. Он управляет поведением функции closesocket. Напомним, что по умолчанию эта функция не блокирует вызвавшую ее нить, а закрывает сокет в фоновом режиме. Параметр SO_LINGERимеет тип TLinger, представляющий собой следующую структуру: TLinger = record l_onoff: u_short; l_linger: u_short; end; Поле l_onoffэтой структуры показывает, будет ли использоваться фоновый режим закрытия сокета. Нулевое значение показывает, что закрытие выполняется в фоновом режиме, как это установлено по умолчанию (в этом случае поле l_lingerигнорируется). Ненулевое значение показывает, что функция closesocketне вернет управление вызвавшей ее нити, пока сокет не будет закрыт. В этом случае возможны два варианта: мягкое и грубое закрытие. Мягкое закрытие предусматривает, что перед закрытием сокета все данные, находящиеся в его выходном буфере, будут переданы партнеру. При грубом закрытии данные партнеру не передаются. Поле l_lingerзадает время (в секундах), которое дается на передачу данных партнеру. Если за отведенное время данные, находящиеся в выходном буфере сокета, не были отправлены, сокет будет закрыт грубо. Если поле l_lingerбудет равно нулю (при ненулевом l_onoff), сокет всегда будет закрываться грубо. Неблокирующие сокеты рекомендуется закрывать с нулевым временем ожидания или в фоновом режиме, При мягком закрытии неблокирующего сокета не в фоновом режиме, если остались непереданные данные, вызов closesocketзавершится с ошибкой WSAEWOULDBLOCK, и сокет не будет закрыт. Придется вызывать функцию closesocketнесколько раз до тех пор. пока она не завершится успешно. Остальные параметры сокета детально описаны в MSDN. 2.1.18. Итоги первого разделаМы рассмотрели основные принципы работы со стандартными сокетами. Хотя многое осталось за кадром, того, что здесь было написано, достаточно, чтобы начать создавать разнообразные приложения с использованием сокетов. Для самостоятельного изучения рекомендуется сделать следующее: □ Для каждой из упоминавшихся здесь функций выяснить, какие ошибки может возвращать WSAGetLastErrorв случае неуспешного завершения и что каждая из этих ошибок означает. □ посмотреть, какие еще параметры (опции) есть у сокета; □ самостоятельно разобраться с не упомянутыми здесь функциями getsockname, gethostbyaddrи getaddrbyhost. Из приведенных примеров видно, что стандартные сокеты достаточно интегрируются с пользовательским интерфейсом, однако приложение, использующее их, вынуждено самостоятельно опрашивать сокеты с определённой периодичностью (например, по таймеру). Это не совпадает с принятой в Windows схемой событийного управления программой, основанной на принципе "пусть мне скажут, когда что-то произойдет, и я отреагирую". Именно поэтому стандартные сокеты были расширены и появились сокеты Windows, с которыми мы познакомимся далее. 2.2. Сокеты WindowsВ предыдущих разделах мы рассмотрели те методы работы с сокетами, которые восходят еще к сокетам Беркли. Разработчики библиотеки сокетов для Windows добавили в нее также поддержку новых методов, упрощающих работу с сокетами для приложений, имеющих традиционную для Windows событийно-ориентированную модель. В Windows можно использовать асинхронные сокеты и перекрытый ввод-вывод. Далее мы рассмотрим эти расширения, а также ряд новых функций, пришедших на смену "морально устаревшим" функциям из стандартных сокетов. Материал здесь, как и ранее, не претендует на полноту, а предназначен лишь для знакомства с наиболее часто употребляемыми возможностями библиотеки сокетов. По-прежнему рассматриваются только протоколы TCP и UDP. Не будут затронуты такие вопросы, как поддержка качества обслуживания, пространства имен, простые сокеты ( RAW_SOCK) и SPI (Service Provider Interface); Тем, кто захочет самостоятельно разобраться с данными вопросами, рекомендуем книгу [3]. 2.2.1. Версии Windows SocketsПри рассмотрении функции WSAStartup уже упоминалось, что существуют разные версии библиотеки сокетов, которые заметно различаются по функциональности. К сожалению, полный перечень существующих на сегодняшний день версий Windows Sockets и их особенностей в документации в явном виде не приводится, но, изучая разрозненную информацию, можно сделать некоторые выводы, которые приведены в табл. 2.1. В дальнейшем, если не оговорено иное, под WinSock 1 мы будем подразумевать версию 1.1, под WinSock 2 — версию 2.2. Таблица 2.1. Версии Windows Sockets
WinSock 1 в 16-разрядных версиях Windows реализуется библиотекой WinSock.dll, в 32-разрядных — WSock32.dll. WinSock 2 реализуется библиотекой WS2_32.dll, и. кроме того, часть функций вынесена в отдельную библиотеку MSWSock.dll. При этом для сохранения совместимости WS2_32.dll содержит даже те устаревшие функции, которые формально исключены из спецификации WinSock 2. В тех системах, в которых установлена библиотека WinSock 2, WSock32.dll не реализует самостоятельно практически ни одной функции, а просто импортирует их из WS2_32.dll и MSWSock.dll. WSock32.dll требуется только для обратной совместимости, в новых программах необходимости в этой библиотек нет. Как это ни удивительно, но в Delphi даже 2007-й версии (не говоря уже о более ранних) отсутствует поддержка WinSock 2. Стандартный модуль WinSock импортирует функции только из WSock32.dll, поэтому программисту доступны только функции WinSock 1. Разумеется, импортировать функции WinSock 2 самостоятельно не составит труда. Более того, в Интернете можно найти уже готовые модули, импортирующие их (например, на сайте Алекса Коншина http://home.carthlink.net/~akonshin/delphi_ru.htm). Тем не менее, чтобы избежать разночтений, мы не будем использовать какой-либо готовый модуль для импорта и примем следующее соглашение: если прототип функции приведен только на Паскале, значит, эта функция есть в модуле WinSock. Если же прототип приведен и на C/C++ и на Паскале, значит, функция в WinSockне описана. В этом случае прототип функции на C/C++ берется из MSDN, а перевод на Паскаль — импровизация автора книги. В некоторых случаях возможны несколько вариантов перевода, поэтому не стоит рассматривать приведенный здесь перевод как истину в последней инстанции. Тем, кто будет самостоятельно импортировать функции из WS2_32.dll, следует помнить, что они имеют модель вызова stdcall(при описании прототипов функций мы для краткости будем опускать эту директиву). Примечание WinSock 2 предлагает разработчику Service Provider Interface (SPI), с помощью которого можно добавлять в систему поддержку своих протоколов. Устаревшими объявлены функции, имеющие привязку к конкретным протоколам (например, уже знакомая нам функция inet_addr, которая имеет смысл только при использовании протокола IP). Добавлены новые функции, которые призваны унифицировать операции с разными протоколами. Фактически если работать с WinSock 2, то программа может быть написана так, что сможет использовать даже те протоколы, которые не существовали на момент её разработки. Кроме того, добавлена возможность связи асинхронных сокетов с событиями вместо оконных сообщений, а также поддержка перекрытого ввода-вывода (в WinSock 1 он поддерживался только в линии NT и не в полном объеме). Добавлена поддержка качества обслуживания (Quality of Service, QoS — резервирование части пропускной способности сети для нужд конкретного соединения), поддержка портов завершения, многоадресной рассылки и регистрации имен. Большинство этих нововведений требуются для пользовательских программ относительно редко (или вообще не нужны), поэтому мы не будем заострять на них внимание. Далее будут рассмотрены асинхронные сокеты (связанные как с сообщениями, так и с событиями), перекрытый ввод-вывод, методы универсализации работы с протоколами и многоадресная рассылка. 2.2.2. Устаревшие функции WinSock 1В этом разделе мы познакомимся с теми устаревшими функциями, которые не стоит применять в 32-разрядных программах. Рассмотрим мы их, разумеется, очень обзорно, только для того, чтобы после прочтения книги вас не смущали упоминания этих функций и связанных с ними ошибок, которые иногда встречаются в MSDN. В 16-разрядных версиях Windows реализована так называемая корпоративная многозадачность: каждая программа время от времени должна добровольно возвращать управление операционной системе, чтобы та могла передать управление другой программе. Если какая-то программа при этом поведет себя некорректно и не вернет управление системе, то все остальные приложения не смогут продолжать работу. Другой недостаток такой модели — в ней невозможно распараллеливание работы в рамках одного процесса, т.е. создание нитей. При такой модели многозадачности использование блокирующих сокетов может привести к остановке всей системы, если не будут приняты дополнительные меры. В Windows проблема решается следующим образом: библиотека сокетов во время ожидания периодически вызывает заранее указанную функцию. В 16-разрядных версиях Windows эта функция по умолчанию извлекает сообщение из системной очереди и передает его соответствующему приложению. Таким образом, остальные приложения не прекращают работу во время блокирующего вызова. В очереди могут находиться сообщения и для того приложения, которое выполняет блокирующий вызов. В этом случае будет снова вызвана оконная процедура, инициировавшая блокирующую операцию. Это напоминает рекурсию, при которой процедура вызывает сама себя: в памяти компьютера будут одновременно две активации этой процедуры. Упрощенно это выглядит так: оконная процедура вызывает блокирующую функцию (например, accept), а та, в свою очередь, снова вызывает ту же самую оконную процедуру. При этом вторая активация не может выполнять никаких операций с сокетами: они будут завершены с ошибкой WSAEINPROGRESS. Эта ошибка не фатальная, она указывает, что в данный момент выполняется блокирующая операция, и программа должна подождать ее завершения и лишь потом пытаться работать с сокетами (т.е. не раньше, чем первая активация оконной процедуры вновь получит управление). Существует специальная функция WSAIsBlocking, которая возвращает True, если в данный момент выполняется блокирующая операция и работа с сокетами невозможна. Вторая активация процедуры может прервать блокирующий вызов с помощью функции WSACancelBlockingСаll. При этом первая активация получит ошибку WSAECANCELLED. Программа может устанавливать свою процедуру, которая будет вызываться во время выполнения блокирующей операции. Для этого предусмотрены функции WSASetBlockingHookи WSAUnhookBlockingHook. Данная модель неудобна, поэтому разработчики WinSock 1 рекомендуют модель асинхронных сокетов, более приспособленную к особенностям Windows. В 32-разрядных версиях WinSock такая модель работы поддерживается в полном объеме, за исключением того, что по умолчанию при блокирующем вызове не вызывается никакая функция. Поэтому если не вызове не вызывается никакая функция. Поэтому если не использовать WSASetBlockingHook, то в 32-разрядном приложении невозможно получить ситуацию, когда операция с сокетом не будет выполнена из-за того, что в этот момент уже выполняется другая операция, и второй активации оконной процедуры из-за блокирующего вызова тоже не будет создано. Отметим, что разные нити могут одновременно выполнять блокирующие операции с сокетами, и это не приведет к появлению ошибки WSAEINPROGRESS. Все перечисленные функции формально исключены из спецификации WinSock 2, хотя фактически они присутствуют в библиотеке WS2_32.dll и при необходимости могут быть задействованы (это, правда, осложняется тем, что в новых версиях MSDN отсутствует их описание). Тем не менее причин ориентироваться на эту неудобную модель в 32-разрядных версиях Windows, видимо, нет. Описание этих функций мы здесь привели только для того, чтобы упоминания об ошибках WSAEINPROGRESSи WSAECANCELLED, которые иногда встречаются в MSDN, не смущали вас. 2.2.3. Информация о протоколеРанее мы уже видели, что передача данных через сокет осуществляется одними и теми же функциями независимо от протокола. Но при этом программа должна учитывать, является ли протокол потоковым, дейтаграммным или иным. Кроме того, информация о протоколе требуется для создания сокета и для распределения ролей между клиентом и сервером при установлении соединения. Чтобы работать с любым протоколом, программа должна иметь возможность получить всю эту информацию и выполнить на основе ее те или иные действия. Могут также понадобиться такие сведения, как максимальное число сокетов, поддерживаемых провайдером протокола, допустимый диапазон адресов, максимальный размер сообщений для дейтаграммных протоколов и т.д. Для хранения полного описания протокола и его провайдера в WinSock 2 предусмотрена структура WSAPROTOCOL_INFO. Она не описана в модуле WinSock, т.к. в WinSock 1 ее нет. Тем, кто захочет использовать эту структуру, придется самостоятельно добавлять ее описание в программу. Листинг 2.36 показывает, как выглядит эта структура. Листинг 2.36. Тип WSAPROTOCOL_INFO // ***** Описание на C++ ***** typedef struct _WSAPROTOCOLCHAIN { int ChainLen; DWORD ChainEntries[MAX_PROTOCOL_CHAIN]; } WSAPROTOCOLCHAIN, *LPWSAPROTOCOLCHAIN; typedef struct _WSAPROTOCOL_INFO { DWORD dwServiceFlags1; DWORD dwServiceFlags2; DWORD dwServiceFlags3; DWORD dwServiceFlgs4; DWORD dwProviderFlags; GUID ProviderId; DWORD dwCatalogEntryId; WSAPROTOCOLCHAIN ProtocolChain; int iVersion; int iAddressFamily; int iMaxSockAddr; int iMinSockAddr; int iSocketType; int iProtocol; int iProtocolMaxOffset; int iNetworkByteOrder; int iSecurityScheme; DWORD dwMessageSize; DWORD dwProviderReserved; TCHAR szProtocol[WSAPROTOCOL_LEN - 1]; } WSAPROTOCOL_INFO, *LPWSAPROTOCOL_INFO; // ***** Описание на Delphi ***** TWSAProtocolChain = packed record ChainLen: Integer; ChainEntries: array[0..MAX_PROTOCOL_CHAIN - 1] of DWORD; end; //Структура на C++ содержит тип TCHAR, который, как мы // говорили в главе 1, может означать как Char, // так и WideChar, т.е. структура должна иметь // два варианта описания: TWSAProtocolInfoA для // однобайтной кодировки и TWSAProtocolInfo для // двухбайтной. Соответственно, все функции // использующие эту структуру, реализованы // в системных библиотеках в двух вариантах. // Здесь мы приводим только ANSI-вариант. PWSAProtocolInfo = ^TWSAProtocolInfo; TWSAProtocolInfo = packed record dwServiceFlags1: DWORD; dwServiceFlags2: DWORD; dwServicsFlags3: DWORD; dwServiceFlags4: DWORD; dwProviderFlags: DWORD; ProviderId: GUID; dwCatalogEntryId: DWORD; ProtocolChain: TWSAProtocolChain; iVersion: Integer; iAddressFamily: Integer; iMaxSockAddr: Integer; iMinSockAddr: Integer; iSocketType: Integer; iProtocol: Integer; iProtocolMaxOffset: Integer; iNetworkByteOrder: Integer; iSecurityScheme: Integer; dwMessageSize: DWORD; dwProviderReserved: DWORD; szProtocol: array [0..WSAPROTOCOL_LEN] of Char; end; Расшифровка полей типа TWSAProtocolInfoесть в MSDN, мы здесь не будем ее приводить. Сама функция WSAEnumProtocols, которая позволяет получить список всех протоколов, провайдеры которых установлены на компьютере, приведена в листинге 2.37. Листинг 2.37. Функция WSAEnumProtocols // ***** описание на C++ ***** int WSAEnumProtocols(LPINT lpiProtocols, LPWSAPROTOCOL_INFO lpProtocolBuffer, LPDWORD lpdwBufferLength); // ***** Описание на Delphi ***** function WSAEnumProtocols(lpiProtocols: PInteger; lpProtocolBuffer: PWSAProtocolInfo; var BufferLength: DWORD): Integer; Примечание Параметр lpiProtocolsуказывает на первый элемент массива, содержащего список протоколов, информацию о которых нужно получить. Если этот указатель равен nil, то возвращается информация обо всех доступных протоколах. Параметр lpProtocolBufferсодержит указатель на начало массива структур типа TWSAProtocolInfo. Программа должна заранее выделить память под этот массив. Параметр BufferLengthпри вызове должен содержать размер буфера lpProtocolBufferв байтах (именно размер в байтах, а не количество элементов). После завершения функции сюда помешается минимальный размер буфера, необходимый для размещения информации обо всех запрошенных протоколах. Если это значение больше переданного, функция завершается с ошибкой. Если параметр lpiProtocolsне равен нулю, он должен содержать указатель на массив, завершающийся нулем. Следовательно, если количество протоколов, запрашиваемых программой, равно N, этот массив должен состоять из N+1 элементов, и первые N элементов должны содержать номера протоколов, а последний элемент — ноль. В системе может быть установлено несколько провайдеров для одного протокола. В этом случае информация о каждом провайдере будет помещена в отдельный элемент массива. Из-за этого число задействованных элементов в массиве lpProtocolBufferможет превышать количество протоколов, определяемых параметром lpiProtocols. К сожалению, полную информацию о том, каким протоколам какие номера соответствуют, в документации найти не удалось. Можно только сказать, что для получения информации о протоколе TCP в массив lpiProtocolsнеобходимо поместить константу IPPROTO_TCP, о протоколе UDP — константу IPPROTO_UDP. Возвращаемое функцией значение равно числу протоколов, информация о которых помещена в массив, если функция выполнена успешно, и SOCKET_ERROR, если при ее выполнении возникла ошибка. Конкретная ошибка определяется стандартным методом, с помощью WSAGetLastError. Если массив lpProtocolBufferслишком мал для хранения всей требуемой информации, функция завершается с ошибкой WSAENOBUFS. WinSock 1 содержит аналогичную по возможности функцию EnumProtocols, возвращающую массив структур PROTOCOL_INFO. Эта структура содержит меньше информации о протоколе, чем WSAPROTOCOL_INFOи, в отличие от последней, не используется никакими другими функциями WinSock. Несмотря на то, что функция EnumProtocolsи структура PROTOCOL_INFOописаны в первой версии WinSock, модуль WinSock их не импортирует, при необходимости их нужно импортировать самостоятельно. Но функция EnumProtocolsсчитается устаревшей, использовать ее в новых приложениях не рекомендуется, поэтому практически всегда, за исключением редких случаев, требующих совместимости с WinSock 1, лучше выбрать более современную функцию WSAEnumProtocols. 2.2.4. Новые функцииВ этом разделе мы рассмотрим некоторые новые функции, появившиеся в WinSock 2. Большинство из них позволяет выполнять действия, уже знакомые нам из предыдущих разделов, но предоставляет большие возможности, чем стандартные сокетные функции. Для создания сокета предназначена функция WSASocketсо следующим прототипом (листинг 2.38). Листинг 2.38. Функция WSASocket // ***** Описание на C++ ***** SOCKET WSASocket(int af, int SockType, int protocol, LPWSAPROTOCOL_INFO lpProtocolInfo, GROUP g, DWORD dwFlags); // ***** Описание на Delphi ***** function WSASocket(AF, SockType, Protocol: Integer; lpProtocolInfo: PWSAProtocolInfo; g: TGroup; dwFlags: DWORD): TSocket; Первые три параметра совпадают с тремя параметрами функции socket. Параметр lpProtocolInfoуказывает на структуру TWSAProtocolInfo, содержащую информацию о протоколе, для которого создается сокет. Если этот указатель равен nil, функция создает сокет на основании первых трёх параметров так же, как это делает функция socket. С другой стороны, если этот параметр не равен nil, то структура, на которую он указывает, содержит всю информацию, необходимую для создания сокета, поэтому первые три параметра должны быть равны константе FROM_PROTOCOL_INFO(-1). Параметр gзарезервирован для использования в будущем и должен быть равен нулю (тип TGroupсовпадает с DWORD). Последний параметр dwFlagsопределяет, какие дополнительные возможности имеет создаваемый сокет. Вызов функции socketэквивалентен вызову функции WSASocketс флагом WSA_FLAG_OVERLAPPED, который показывает, что данный сокет можно использовать для перекрытого ввода-вывода (см. разд. 2.2.9). Остальные флаги нужны при многоадресной рассылке (не все из них допустимы для протоколов TCP и UDP). Эти флаги мы рассмотрим в разд. 2.2.11. В случае TCP и UDP функция WSASocketдает следующие преимущества по сравнению с функцией socket. Во-первых, через параметр lpProtocolInfoпоявляется возможность явно указать провайдера, который будет выбран программой. Во-вторых, если программа не использует перекрытый ввод-вывод, можно создавать сокеты без флага WSA_FLAG_OVERLAPPED, экономя при этом некоторое незначительное количество ресурсов. Кроме того, как это будет обсуждаться далее, с помощью WSASocketдве разных программы могут работать с одним и тем же сокетом. Функция WSAConnect— это более мощный аналог connect. Ее прототип приведен в листинге 2.39. Листинг 2.39. Функция WSAConnectи связанные с ней типы // ***** Описание на C++ ***** int WSAConnect(SOCKET s, const struct sockaddr FAR* name, int name len, LPWSABUF lpCallerData, LPWSABUF lpCalleeData, LPQOS lpSQOS, LPQOS lpGQOS); typedef struct __WSABUF { u_long len; char FAR *buf; } WSABUF, FAR* LPWSABUF; // ***** Описание на Delphi ****** function WSAConnect(S: TSocket; var Name: TSockAddr; NameLen: Integer; lpCollerData, lpCalleeData: PWSABuf; lpSQOS, lpGQOS: PQOS): Integer; PWSABuf = ^TWSABuf; TWSABuf = packed record Len: Cardinal; Buf: PChar; end; Функция WSAConnectустанавливает соединение со стороны клиента. Ее первые три параметра совпадают с параметрами функции connect. Параметр lpCallerDataи lpCalleeDataслужат для передачи данных от клиента серверу и от сервера клиенту при установлении соединения. Они оба являются указателями на структуру TWSABufтип TWSABuf, которая содержит размер буфера Lenи указатель на буфер Buf. Протоколы стека TCP/IP не поддерживают передачу данных при соединении, поэтому для TCP и UDP lpCallerDataи lpCalleeDataдолжны быть равны nil. Параметры lpSQOSи lpGQOS— это указатели на структуры, с помощью которых программа передает свои требования к качеству обслуживания, причем параметр lpGQOSсвязан с не поддерживаемым в настоящий момент групповым качеством и всегда должен быть равен nil. Параметр lpSQOSтакже должен быть равен nil, если программа не предъявляет требований к качеству обслуживания. Так как рассмотрение качества обслуживания выходит за рамки данной книги, мы не приводим здесь определение структуры SQOS, которое при необходимости легко найти в MSDN. Между функциями connectи WSAConnectсуществует небольшое различие при работе с сокетами, не поддерживающими соединение. Как вы знаете из разд. 2.1.9, функция connectможет использоваться с такими сокетами для задания адреса отправки по умолчанию и автоматической фильтрации входящих пакетов. Для того чтобы отменить такое "соединение", нужно при вызове функции connectуказать адрес INADDR_ANYи нулевой порт. В случае WSAConnectдля отмены "соединения" требуется, чтобы все без исключения поля структуры Name, включая sin_family, были нулевыми. Это сделано для того, чтобы обеспечить независимость от протокола: при любом протоколе для разрыва "соединения" должно устанавливаться одно и то же значение Name. Если программа не предъявляет требований к качеству обслуживания, то для протоколов TCP и UDP функция WSAConnectне предоставляет никаких преимуществ по сравнению с connect. Функция acceptиз стандартной библиотеки сокетов позволяет серверу извлечь из очереди соединений информацию о подключившемся клиенте и создать сокет для его обслуживания. Эти действия выполняются безусловно, для любых подключившихся клиентов. Если сервер допускает подключение не любых клиентов, а только тех, которые отвечают некоторым условиям (для протокола TCP эти условия могут заключаться в том, какие IP-адреса и какие порты допустимо использовать клиентам), сразу после установления соединения его приходится разрывать, если клиент не удовлетворяет этим условиям. Для упрощения этой операции в WinSock 2 предусмотрена функция WSAAccept, прототип которой приведен в листинге 2.40. Листинг 2.40. Функция WSAAccept // ***** Описание на C++ ***** SOCKET WSAAccept(SOCKET S, struct sockaddr FAR* addr, LPINT addrlen, LPCONDITIONPROC lpfnCondition, dwCallbackData); // ***** описание на Delphi ***** function WSAAccept( S: TSocket; Addr: PSockAddr; AddrLen: PInteger; lpfnCondition: TConditionProc; dwCallbackData: DWORD): TSocket; По сравнению с уже известной нам функцией acceptфункция WSAAcceptимеет два новых параметра: lpfnConditionи dwCallbackData. lpfnConditionявляется указателем на функцию обратного вызова. Эта функция объявляется и реализуется программой. WSAAcceptвызывает ее внутри себя и в зависимости от ее результата принимает или отклоняет соединение. Параметр dwCallbackDataне имеет смысла для самой функции WSAAcceptи передается без изменений в функцию обратного вызова. Тип TConditionProcдолжен быть объявлен следующим образом (листинг 2.41). Листинг 2.41. Тип TConditionProc // ***** Описание на C++ ***** typedef (int*)(LPWSABUF lpCallerId, LPWSABUF lpCallerData, LPQOS lpSQOS, LPQOS lpGQOS, LPWSABUF lpCalleeId, LPWSABUF lpCalleeData, GROUP FAR* g, DWORD dwCallbackData) LPCONDITIONPROC; // ***** Описание на Delphi ***** TConditionProc = function(lpCallerId, lpCallerData: PWSABuf; lpSQOS, lpGQOS: PQOS; lpCalleeID, lpCalleeData: PWSABuf; g: PGroup; dwCallbackData: DWORD): Integer; stdcall; Параметр lpCallerIdуказывает на буфер, в котором хранится адрес подключившегося клиента. При работе со стеком TCP/IP lpCallerId^.Lenбудет равен SizeOf(TSockAddr), a lpCallerId^.Bufбудет указывать на структуру TSockAddr, содержащую адрес клиента. Параметр lpCallerDataопределяет буфер, в котором хранятся данные, переданные клиентом при соединении. Как уже отмечалось, протоколы стека TCP/IP не поддерживают передачу данных при соединении, поэтому для них этот параметр будет равен nil. Параметры lpSQOSи lpGQOSзадают требуемое клиентом качество обслуживания для сокета и для группы соответственно. Так как группы сокетов в текущей реализации WinSock не поддерживаются, параметр lpGQOSбудет равен nil. Параметр lpSQOSтоже будет равен nil, если клиент не задал качество обслуживания при соединении. Параметр lpCalleeIdсодержит адрес интерфейса, принявшего соединение (поля структуры при этом используются так же, как у параметра lpCallerId). Ранее уже обсуждалось, что сокет, привязанный к адресу INADDR_ANY, прослушивает все сетевые интерфейсы, имеющиеся на компьютере, но каждое подключение, созданное с его помощью, использует конкретный интерфейс. Параметр lpCalleeIdсодержит адрес, привязанный к конкретному соединению. Параметр lpCalleeDataуказывает на буфер, в который сервер может поместить данные для отправки клиенту. Этот параметр также не имеет смысла для протокола TCP, не поддерживающего отправку данных при соединении. Параметр gвыходной, он позволяет управлять присоединением создаваемого функцией WSAAcceptсокета к группе. Параметр, как и все, связанное с группами, зарезервирован для использования в будущем. Примечание И наконец, через параметр dwCallbackDataв функцию обратного вызова передается значение параметра dwCallbackData, переданное в функцию WSAAccept. Программист должен сам решить, как ему интерпретировать это значение. Функция должна вернуть CF_ACCEPT(0), если соединение принимается, CF_REJECT(1), если оно отклоняется, и CF_DEFER(2), если решение о разрешении или запрете соединения откладывается. Если функция обратного вызова вернула CF_REJECT, to WSAAcceptзавершается с ошибкой WSAECONNREFUSED, если CF_DEFER— то с ошибкой WSATRY_AGAIN(в последнем случае соединение остаётся в очереди, и информация о нем вновь будет передана в функцию обратного вызова при следующем вызове WSAAccept). Обе эти ошибки не фатальные, сокет остается в режиме ожидания соединения и может принимать подключения от новых клиентов. Ранее уже обсуждалось, что функция connectна стороне клиента считается успешно завершенной тогда, когда соединение встало в очередь, а не тогда, когда оно реально принято сервером через функцию accept. По умолчанию для клиента, соединение с которым сервер отклонил, нет разницы, вызвал ли сервер функцию WSAAcceptи сразу отклонил соединение, или установил его с помощью accept, а потом разорвал. В обоих случаях клиент сначала получит информацию об успешном соединении с сервером, а потом это соединение будет разорвано. Но при использовании WSAAcceptможно установить такой режим работы, когда сначала выполняется функция. заданная параметром lpCondition, и лишь потом клиенту отправляется разрешение или запрет на подключение. Включается этот режим установкой параметра слушающего сокета SO_CONDITIONAL_ACCEPT, что иллюстрирует листинг 2.42. Листинг 2.42. Включение режима ожидания реального подключения var Cond: BOOL; begin Cond := True; setsockopt(S, SOL_SOCKET, SO_CONDITIONAL_ACCEPT, PChar(@Cond), SizeOf(Cond)); Этот режим снижает нагрузку на сеть и повышает устойчивость сервера против DoS-атак, заключающихся в многократном подключении-отключении посторонних клиентов, поэтому в серьезных серверах рекомендуется использовать эту возможность. Из сказанного следует, что при использовании протокола TCP функция WSAAcceptпо сравнению с accept даёт два принципиальных преимущества: позволяет управлять качеством обслуживания и запрещать подключение нежелательных клиентов. Некоторые протоколы поддерживают передачу информации не только при установлении связи, но и при её завершении. Для таких протоколов в WinSock2 предусмотрены функции WSASendDisconnectи WSARecvDisconnect. Так как протокол TCP не поддерживает передачу данных при закрытии соединения, для него эти функции не дают никаких преимуществ по сравнению с вызовом функции shutdown, поэтому мы не будем их здесь рассматривать. Далее мы рассмотрим несколько новых функций, унифицирующих работу с различными протоколами. Функция inet_addr, как это уже упоминалось, жестко связана с протоколом IP и не имеет смысла для других протоколов. WinSock 2 предлагает вместо нее функцию WSAStringToAddress, имеющую следующий прототип (листинг 2.43). Листинг 2.43. Функция WSAStringToAddress // ***** Описание на C++ ***** INT WSAStringToAddress(LPTSTR AddressString, INT AddressFamily, LPWSAPROTOCOL_INFO lpProtocolInfo, LPSOCKADDR lpAddress, LPINT lpAddressLength); // ***** Описание на Delphi ***** function WSAStringToAddress(AddresString: PChar; AddressFamily: Integer; lpProtocolInfo: PWSAProtocolInfo; var Address: TSockAddr; var AddressLength: Integer): Integer; Данная функция преобразует строку, задающую адрес сокета, в адрес, хранящийся в структуре TSockAddr. Параметр AddressStringуказывает на строку, хранящую адрес, параметр AddressFamily— на семейство адресов, для которого осуществляется трансляция. Если есть необходимость выбрать конкретный провайдер для протокола, в функцию может быть передан параметр lpProtocolInfo, в котором указан идентификатор провайдера. Если же программу устраивает провайдер по умолчанию, параметр lpProtocolInfoдолжен быть равен nil. Адрес возвращается через параметр Address. Параметр AddressLengthпри вызове функции должен содержать размер буфера, переданного через Address, а на выходе содержит реально использованное число байтов в буфере. Функция возвращает 0 в случае успешного выполнения и SOCKET_ERROR— при ошибке. Допустимый формат строки определяется протоколом (некоторые протоколы вообще не поддерживают текстовую запись адреса, и для них функция WSAStringToAddressнеприменима). Для семейства AF_INET, к которому относятся TCP и UDP, адрес может задаваться в виде "IP1.IP2.IP3.IР4:Port" или "IP1.IP2.IP3.IP4", где IРn — n-й компонент IP-адреса, записанною в виде 4-байтных полей, Port— номер порта. Если порт явно не указан, устанавливается нулевой номер порта. Таким образом, чтобы в структуре TSockAddrоказался, например, адрес 192.168.100.217 и порт с номером 5000, необходимо выполнить следующий код (листинг 2.44). Листинг 2.44. Пример использования функции WSAStringToAddress var Addr: TSockAddr; AddrLen: Integer; begin AddrLen := SizeOf(Addr); WSAStringToAddress('192.168.100.217:5000', AF_INET, nil, Addr, AddrLen); Существует также функция WSAAddressToString, обратная к WSAStringToAddrеss. Ее прототип приведен в листинге 2.45. Листинг 2.45. Функция WSAAddressToString // ***** Описание на C++ ***** INT WSAAddressToString(LPSOCKADDR lpsaAddress, DWORD dwAddressLength, LWSAPROTOCOL_INFO lpProtocolInfo, LPTSTR lpszAddressString, LPDWORD lpdwAddressStringLength); // ***** Описание на Delphi ***** function WSAAddressToString(var Address: TSockAddr; dwAddressLength: DWORD; lpProtocolInfo: PWSAProtocolInfo; lpszAddressString: PChar; var AddressStringLength: DWORD): Integer; Как нетрудно догадаться по названию функции, она преобразует адрес, заданный структурой TSockAddr, в строку. Адрес задаётся параметром Address, параметр dwAddressLengthопределяет длину буфера Address. Необязательный параметр lpProtocolInfoсодержит указатель на структуру TWSAProtocolInfo, с помощью которой можно определить, какой именно провайдер должен выполнить преобразование. Параметр lpszAddressStringсодержит указатель на буфер, заранее выделенный программой, в который будет помещена строка. Параметр AddressStringLengthна входе должен содержать размер буфера, заданного параметром lpszAddressString, а на выходе содержит длину получившейся строки. Функция возвращает ноль в случае успеха и SOCKET_ERROR— при ошибке. Ранее мы уже обсуждали различные форматы представления целых чисел, а также то, что формат, предусмотренный сетевым протоколом, может не совпадать с форматом, используемым узлом. Напомним, что для преобразования из сетевого формата в формат узла предназначены функции htons, ntohs, htonlи ntohl, привязанные к протоколам стека TCP/IP (другие протоколы могут иметь другой формат представления чисел). WinSock 2 предлагает аналоги этих функций WSAHtons, WSANtohs, WSAHtonlи WSANtohl, которые учитывают особенности конкретного протокола. Мы здесь рассмотрим только функцию WSANtohl, преобразующую 32-битное целое из сетевого формата в формат узла. Остальные три функции работают аналогично. Листинг 2.46 содержит прототип функции WSANtohl. Листинг 2.46. Функция WSANtohl // ***** Описание на C++ ***** int WSANtohl(SOCKET s, u_long netlong, u_long FAR *lphostlong); // ***** Описание на Delphi ***** function WSANtohl(S: TSocket; NetLong: Cardinal; var HostLong: Cardinal): Integer; Параметр Sзадает сокет, для которого осуществляется преобразование. Так как сокет всегда связан с конкретным протоколом, этого параметра достаточно, чтобы библиотека могла определить, по какому закону преобразовывать число из сетевого формата в формат хоста. Число в сетевом формате задаётся параметром NetLong, результат преобразования помещается в параметр HostLong. Функция возвращает ноль в случае успешного выполнения операции и SOCKET_ERROR— при ошибке. Если программа работает только с протоколами стека TCP/IP, старые варианты функций удобнее новых, потому что возвращают непосредственно результат преобразования, который можно использовать в выражениях. При работе с новыми функциями для получения результата следует заводить отдельную переменную, поэтому эти функции целесообразны тогда, когда программа должна единым образом работать с разными протоколами. Последняя функция, которую мы здесь рассмотрим, не имеет прямых аналогов среди старых функций. Называется она WSADuplicateSocketи служит для копирования дескриптора сокета в другой процесс. Прототип функции WSADuplicateSocketприведен в листинге 2.47. Листинг 2.47. Функция WSADuplicateSocket // ***** Описание на C++ ***** int WSADuplicateSocket(SOCKET s, DWORD dwProcessId, LPWSAPROTOCOL_INFO lpProtocolInfo); // ***** Описание на Delphi ***** function WSADuplicateSocket(S: TSocket; dwProcessID: DWORD; var ProtocolInfo: TWSAProtocolInfo): Integer; Параметр Sзадает сокет, дескриптор которого нужно скопировать, параметр dwProcessID— идентификатор процесса, для которого предназначена копия, функция помещает в структуру ProtocolInfoинформацию, необходимую для создания копии дескриптора другим процессом. Затем эта структура должна быть каким-то образом передана другому процессу, который передаст ее в функцию WSASocketи получит свою копию дескриптора для работы с данным сокетом. Функция WSADuplicateSocketвозвращает ноль при успешном завершении и SOCKET_ERROR— в случае ошибки. Как мы помним, сокет является объектом, внутренняя структура которого остается скрытой от использующей его программы. Программа манипулирует только дескриптором сокета — некоторым уникальным идентификатором этого объекта. Функция WSADuplicateSocketпозволяет другой программе получить новый дескриптор для уже существующего сокета. Старый и новый дескриптор становятся равноправными. Чтобы освободить сокет, нужно закрыть все его дескрипторы с помощью функции closesocket. Если во входной буфер сокета поступают данные, их получит та программа, которая первой вызовет соответствующую функцию чтения, поэтому совместное использование одного сокета разными программами требует синхронизации их работы. MSDN рекомендует такую схему работы, при которой одна программа только создаёт сокет и устанавливает соединение, а затем передает сокет другой программе, которая реализует через него ввод-вывод. Первая программа при этом закрывает свой дескриптор. Такой алгоритм работы позволяет полностью исключить проблемы, возникающие при совместном доступе разных программ к одному сокету. Отметим, что функция WSADuplicateSocketможет быть полезна только для копирования дескрипторов между разными процессами. Разные нити одного процесса не нуждаются в этой функции, т.к., находясь в одном адресном пространстве, они могут работать с одним и тем же дескриптором. 2.2.5. Асинхронный режим, основанный на сообщенияхВсе операции с сокетами, которые мы рассматривали раньше, являлись синхронными. Программа, использующая такие сокеты, должна сама время от времени проверять тем или иным способом, пришли ли данные, установлена ли связь и т.п. Асинхронные сокеты позволяют программе получать уведомления о событиях, происходящих с сокетом: поступлении данных, освобождении места в буфере, закрытии и т.п. Такой способ работы лучше подходит для событийно-ориентированных программ, типичных для Windows. Поддержка асинхронных сокетов впервые появилась в WinSock 1 и была основана на сообщениях, которые обрабатывались оконными процедурами. В WinSock 2 этот асинхронный режим остался без изменений. Программист указывает, какое сообщение какому окну должно приходить при возникновении события на интересующем его сокете. Асинхронный режим с уведомлением через сообщения устанавливается функцией WSAAsyncSelect, имеющей следующий прототип: function WSAAsyncSelect(S: TSocket; HWindow: HWND; wMsg: u_int; lEvent: LongInt): Integer; Параметр Sопределяет сокет, для которого устанавливается асинхронный режим работы. Параметр HWindow— дескриптор окна, которому будут приходить сообщения, wMsg— сообщение, a lEventзадает события, которые вызывают отправку сообщения. Для этого параметра определены константы, комбинация которых задает интересующие программу события. Мы не будем рассматривать здесь все возможные события, остановимся только на самых главных (табл. 2.2). Таблица 2.2. Асинхронные события сокета
Каждый последующий вызов WSAAsyncSelectдля одного и того же сокета отменяет предыдущий вызов. Таким образом, в результате выполнения следующего кода форма будет получать только сообщения, показывающие готовность сокета к чтению, а готовность к записи не приведет к отправке сообщения (листинг 2.48). Листинг 2.48. Последовательный вызов функции WSAAsyncSelect WSAAsyncSelect(S, Form1.Handle, WM_USER, FD_WRITE); // Второй вызов отменит результаты первого WSAAsyncSelect(S, Form1.Handle, WM_USER, FD_READ); // Теперь окно не будет получать уведомления о возможности записи WSAAsyncSelectсвязывает с сообщением именно сокет, а не его дескриптор. Это означает, что если две программы используют один сокет (копия дескриптора которого была создана с помощью функции WSADuplicateSocket), и первая программа вызывает WSAAsyncSelectсо своим дескриптором, а затем вторая — со своим, то вызов WSAAsyncSelect, сделанный во второй программе, отменит вызов, сделанный в первой. Для того, чтобы получать сообщения при готовности сокета как к чтению, так и к записи, нужно выполнить следующий код. WSAAsyncSelect(S, Form1.Handle, WM_USER, FD_READ or FD_WRITE); При необходимости с помощью orможно комбинировать и большее число констант. Из сказанного следует, что нельзя связать с разными событиями одного и того же сокета разные сообщения (или отправлять сообщения разным окнам), т.к. при одном вызове WSAAsyncSelectможно передать только один дескриптор окна и один номер сообщения, а следующий вызов этой функции, с другим дескриптором и/или номером, отменит предыдущий. Функция WSAAsyncSelectпереводит сокет в неблокирующий режим. Если необходимо использовать асинхронный сокет в блокирующем режиме, после вызова WSAAsyncSelectтребуется перевести его в этот режим вручную. Сообщение, которое связывается с асинхронным сокетом, может быть любым. Обычно его номер выбирают от WM_USERи выше, чтобы исключить путаницу со стандартными сообщениями. При получении сообщения его параметр wParamсодержит дескриптор сокета, на котором произошло событие. Младшее слово lParamсодержит произошедшее событие (одну из констант FD_XXX), а старшее слово — код ошибки если она произошла. Для выделения кода события и кода ошибки из lParamв библиотеке WinSock предусмотрены макросы WSAGETSELECTEVENTи WSAGETSELECTERRORсоответственно. В модуле WinSock они заменены функциями WSAGetSelectEventи WSAGetSelectError. Одно сообщение может информировать только об одном событии на сокете. Если произошло несколько событий, в очередь окна будет добавлено несколько сообщений. Сокет, созданный при вызове функции accept, наследует режим того сокета, который принял соединения. Таким образом, если сокет, находящийся в режиме ожидания подключения, является асинхронным, то и сокет, порожденный функцией accept, будет асинхронным, и тот же набор его событий будет связан с тем же сообщением, что и у исходного сокета. Рассмотрим подробнее каждое из перечисленных событий. Событие FD_READвозникает, когда во входной буфер сокета поступают данные (если на момент вызова WSAAsyncSelect, разрешающего такие события, в буфере сокета уже есть данные, то событие также возникает). Как только соответствующее сообщение помещается в очередь окна, дальнейшая генерация таких сообщений для этого сокета блокируется, т.е. получение новых данных не будет приводить к появлению новых сообщений (при этом сообщения, связанные с другими событиями этого сокета или с событием FD_READдругих сокетов, будут по-прежнему помещаться при необходимости в очередь окна). Генерация сообщений снова разрешается после того, как будет вызвана функция для чтения данных из буфера сокета (это может быть функция recv, recvfrom, WSARecvили WSARecvFrom, мы в дальнейшем будем говорить только о функции recv, потому что остальные ведут себя в этом отношении аналогично). Если после вызова recvв буфере асинхронного сокета остались данные, в очередь окна снова помещается это же сообщение. Благодаря этому программа может обрабатывать большие массивы по частям. Действительно, пусть в буфер сокета приходят данные, которые программа хочет забирать оттуда по частям. Приход этих данных вызывает событие FD_READ, сообщение о котором помещается в очередь. Когда программа начинает обрабатывать это сообщение, она вызывает recvи читает часть данных из буфера. Так как данные в буфере еще есть, снова генерируется сообщение о событии FD_READ, которое ставится в конец очереди. Через некоторое время программа снова начинает обрабатывать это сообщение. Если и на этот раз данные будут прочитаны не полностью, в очередь снова будет добавлено такое же сообщение. И так будет продолжаться до тех пор, пока не будут прочитаны все полученные данные. Описанная схема, в принципе, достаточно удобна, но следует учитывать, что в некоторых случаях она может давать ложные срабатывания, т.е. при обработке сообщения о событии FD_READфункция recvзавершится с ошибкой WSAEWOULDBLOCK, показывающей, что входной буфер сокета пуст. Если программа читает данные из буфера не только при обработке FD_READ, может возникнуть следующая ситуация: в буфер сокета поступают данные. Сообщение о событии FD_READпомещается в очередь. Программа в это время отрабатывает какое-то другое сообщение, при обработке которого также читаются данные. В результате все данные извлекаются из буфера, и он остается пустым. Когда очередь доходит до обработки FD_READ, читать из буфера уже нечего. Другой вариант ложного срабатывания возможен, если программа при обработке FD_READчитает данные из буфера по частям, вызывая recv несколько раз. Каждый вызов recv, за исключением последнего, приводит к тому, что в очередь ставится новое сообщение о событии FD_READ. Чтобы избежать появления пустых сообщении в подобных случаях, MSDN рекомендует перед началом чтения отключить для данного сокета реакцию на поступление данных, вызвав для него WSAAsyncSelectбез FD_READ, а перед последним вызовом recv — снова включить. И наконец, следует помнить, что сообщение о событии FD_READможно получить и после того, как с помощью WSAAsyncSelectсокет будет переведен в синхронный режим. Это может случиться в том случае, когда на момент вызова WSAAsyncSelectв очереди еще остались необработанные сообщения о событиях на данном сокете. Впрочем, это касается не только FD_READ, а вообще любого события. Событие FD_WRITEинформирует программу о том, что в выходном буфере сокета есть место для данных. Вообще говоря, оно там есть практически всегда, если только программа не отправляет постоянно большие объемы данных. Следовательно, механизм генерации этого сообщения должен быть таким, чтобы не забивать очередь программы постоянными сообщениями о том, что в буфере есть место, а посылать эти сообщения только тогда, когда программа действительно нуждается в такой информации. При использовании TCP первый раз сообщение, уведомляющее о событии FD_WRITE, присылается сразу после успешного завершения операции подключения к серверу с помощью connect, если речь идет о клиенте, или сразу после создания сокета функцией acceptили ее аналогом в случае сервера. В случае UDP это событие возникает после привязки сокета к адресу явным или неявным вызовом функции bind. Если на момент вызова WSAAsyncSelectописанные действия уже выполнены, событие FD_WRITEтакже генерируется. В следующий раз событие может возникнуть только в том случае, если функция send(или sendto) не смогла положить данные в буфер из-за нехватки места в нем (в этом случае функция вернет значение, меньшее, чем размер переданных данных, или завершится с ошибкой WSAEWOULBBLOCK). Как только в выходном буфере сокета снова появится свободное место, возникнет событие FD_WRITE, показывающее, что программа может продолжить отправку данных. Если же программа отправляет данные не очень большими порциями и относительно редко, не переполняя буфер, то второй раз событие FD_WRITEне возникнет никогда. Событие FD_ACCEPTво многом похоже на FD_READ, за исключением того, что оно возникает не при получении данных, а при подключении клиента. После постановки сообщения о событии FD_ACCEPTв очередь новые сообщения о FD_ACCEPTдля данного сокета в очередь не ставятся, пока не будет вызвана функция acceptили WSAAccept. При вызове одной из этих функций сообщение о событии вновь помещается в очередь окна, если в очереди подключений после вызова функции остаются подключения. Событие FD_CONNECTвозникает при установлении соединения для сокетов, поддерживающих соединение. Для клиентских сокетов оно возникает после завершения процедуры установления связи, начатой с помощью функции connect, для серверных — после создания нового сокета с помощью функции accept(событие возникает именно на новом сокете, а не на том, который находится в режиме ожидания подключения). В MSDN написано, что оно должно возникать также и после выполнения connectдля сокетов, не поддерживающих соединение, однако для UDP практика это не подтверждает. Событие FD_CONNECTтакже возникает, если при попытке установить соединение произошла ошибка (например, оказался недоступен указанный сетевой адрес). Поэтому при получении этого события необходимо анализировать старшее слово параметра lParam, чтобы понять, удалось ли установить соединение. Событие FD_CLOSEвозникает только для сокетов, поддерживающих соединение, при разрыве такого соединения нормальным образом или в результате ошибки связи. Если удаленная сторона дня завершения соединения использует функцию shutdown, то FD_CLOSEвозникает после вызова этой функции с параметром SD_SEND. При этом соединение закрыто еще не полностью, удаленная сторона еще может получать данные, поэтому при обработке FD_CLOSEможно попытаться отправить те данные, которые в этом нуждаются. Однако гарантии, что вызов функции отправки не завершится неудачей, нет, т.к. удаленная сторона может закрывать сокет сразу, не прибегая к shutdown. Рекомендуемая последовательность действий при завершении связи такова. Сначала клиент завершает отправку данных через сокет, вызывая функцию shutdownс параметром SD_SEND. Сервер при этом получает событие FD_CLOSE. Сервер отсылает данные клиенту (при этом клиент получает одно или несколько событий FD_READ), а затем также завершает отправку данных с помощью shutdownс параметром SD_SEND. Клиент при этом получает событие FD_CLOSE, в ответ на которое закрывает сокет с помощью closesocket. Сервер, в свою очередь, сразу после вызова shutdownтакже вызывает closesocket. В листинге 2.49 приведен пример кода сервера, использующего асинхронные сокеты. Сервер работает в режиме запрос-ответ, т.е. посылает какие-то данные клиенту только в ответ на его запросы. Константа WM_SOCKETEVENT, определенная в коде для сообщений, связанных с сокетом, может, в принципе, иметь и другие значения. Листинг 2.49. Пример простого сервера на асинхронных сокетах unit Unit1; interface uses Windows, Messages, SysUtils, Classes, Graphics, Controls, Forms, Dialogs, WinSock; const WM_SOCKETEVENT = WM_USER + 1; type TForm1 = class(TForm) procedure FormCreate(Sender: TObject); procedure FormDestroy(Sender: TObjеct); private ServSock: TSocket; procedure WMSocketEvent(var Msg: TMessage); message WM_SOCKETEVENT; end; var Form1: TForm1; implementation {$R *.DFM} procedure TForm1.FormCreate(Sender: TObject); var Data: TWSAData; Addr: TSockAddr; begin WSAStartup($101, Data); // Обычная последовательность действий по созданию сокета, // привязке его к адресу и установлению на прослушивание ServSock := socket(AF_INET, SOCK_STREAM, 0); Addr.sin_family := AF_INET; Addr.sin_addr.S_addr := INADDR_ANY; Addr.sin_port := htons(3320); FillChar(Addr.sin_zero, SizeOf(Addr.sin_zero), 0); bind(ServSock, Addr, SizeOf(Addr)); listen(ServSock, SOMAXCONN); // Перевод сокета в асинхронный режим. Кроме события FD_ACCEPT // указаны также события FD_READ и FD_CLOSE, которые никогда не // возникают на сокете, установленном в режим прослушивания. // Это сделано потому, что сокеты, созданные с помощью функции // accept, наследуют асинхронный режим, установленный для // слушающего сокета. Таким образом, не придется вызывать // функцию WSAAsyncSelect для этих сокетов - для них сразу // будет назначен обработчик событий FD_READ и FD_CLOSE. WSAAsyncSelect(ServSock, Handle, WM_SOCKETEVENT, FD_READ or FD_ACCEPT or FD_CLOSE); end; procedure TForm1.FormDestroy(Sender: TObject); begin closesocket(ServSock); WSACleanup; end; procedure TForm1.WMSocketEvent(var Msg: TMessage); var Sock: TSocket; SockError: Integer; begin Sock := TSocket(Msg.WParam); SockError := WSAGetSelectError(Msg.lParam); if SockError <> 0 then begin // Здесь должен быть анализ ошибки closesocket(Sock); Exit; end; case WSAGetSelectEvent(Msg.lParam) of FD_READ: begin // Пришел запрос от клиента. Необходимо прочитать данные, // сформировать ответ и отправить его. end; FD_АССЕРТ: begin // Просто вызываем функция accept. Ее результат нигде не // сохраняется, потому что вновь созданный сокет автоматически // начинает работать в асинхронном режиме, и его дескриптор // при необходимости будет передан через Msg.wParam при // возникновение события accept(Sock, nil, nil); end; FD_CLOSE: begin // Получив от клиента сигнал завершения, сервер, в принципе, // может попытаться отправить ему данные. После этого сервер // также должен закрыть соединение со своей стороны shutdown(Sock, SD_SEND); closesocket(Sock); end; end; end; end. Преимущество такого сервера по сравнению с сервером, основанным на функции select, заключается в том, что он не должен постоянно проверять наличие полученных данных — когда данные поступят, он без дополнительных усилий получит уведомление об этом. Кроме того, этот сервер не имеет проблем, связанных с количеством сокетов в множестве типа TFDSet. Впрочем, последнее несущественно, т.к. при таком количестве клиентов сервер обычно реализует другие, более производительные способы взаимодействия с клиентами. 2.2.6. Пример сервера, основанного на сообщенияхВ этом разделе мы напишем сервер, использующий асинхронные сокеты и их сообщения (пример AsyncSelectServer на компакт-диске). Этот сервер будет во многом похож на сервер на основе неблокирующих сокетов (см. разд. 2.1.16), только он не станет проверять по таймеру наличие данных в буфере и возможность отправки данных, а будет выполнять это тогда, когда поступят соответствующие сообщения. Такая схема работы требует более осторожного подхода. По сигналу от таймера мы сами проверяем, на каком этапе в данный момент находится обмен данными с клиентом. Если, например, идет этап отправки данных, то проверять входной буфер сокета не нужно, можно оставить это до тех пор, пока не наступит этап чтения данных. При использовании сообщений приходится учитывать, что сообщение о поступлении данных в буфер сокета может прийти в любой момент, в том числе и тогда, когда обмен с клиентом находится на этапе отправки строки. По протоколу сервер не должен читать сообщение в этот момент, необходимо сначала закончить отправку, поэтому приходится данное уведомление игнорировать. Но второго уведомления система не пришлет, соответственно, после окончания отправки данных сервер должен сам вспомнить, что было уведомление, и перейти к операции чтения. Примечание Как обычно, работа сервера начинается с инициализации слушающего сокета, выполняющейся при нажатии кнопки Запустить (листинг 2.50). Листинг 2.50. Инициализация сервера, основанного на сообщенияхprocedure TServerForm.BtnStartServerClick(Sender: TObject); var // Адрес, к которому привязывается слушающий сокет ServerAddr: TSockAddr; begin // Формируем адрес для привязки. FillChar(ServerAddr.sin_zero, SizeOf(ServerAddr.sin_zero), 0); ServerAddr.sin_family := AF_INET; ServerAddr.sin_addr.S_addr := INADDR_ANY; try ServerAddr.sin_port := htons(StrToInt(EditPortNumber.Text)); if ServerAddr.sin_port = 0 then begin MessageDlg('Номер порта должен находиться в диапазоне 1-65535', mtError, [mbOK], 0); Exit; end; // Создание сокета FServerSocket := socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if FServerSocket = INVALID_SOCKET then begin MessageDlg('Ошибка при создании сокета:'#13#10 + GetErrorString, mtError, [mbOK], 0); Exit; end; // Привязка сокета к адресу if bind(FServerSocket, ServerAddr, SizeOf(ServerAddr)) = SOCKET_ERROR then begin MessageDlg('Ошибка при привязке сокета к адресу:'#13#10 + GetErrorString, mtError, [mbOK], 0); closesocket(FServerSocket); Exit; end; // Перевод сокета в режим прослушивания if listen(FServerSocket, SOMAXCONN) = SOCKET_ERROR then begin MessageDlg('Ошибка при переводе сокета в режим прослушивания:'#13#10 + GetErrorString, mtError, [mbOK], 0); closesocket(FServerSocket); Exit; end; // Связь слушающего сокета с событием FD_ACCEPT if WSAAsyncSelect(FServerSocket, Handle, WM_ACCEPTMESSAGE, FD_ACCEPT) = SOCKET_ERROR then begin MessageDlg('Ошибка при установке асинхронного режима ' + 'cлушающего сокета:'#13#10 + GetErrorString, mtError, [mbOK], 0); closesocket(FServerSocket); Exit; end; // Перевод элементов управления в состояние "Сервер работает" LabelPortNumber.Enabled := False; EditPortNumber.Enabled := False; BtnStartServer.Enabled := False; LabelServerState.Caption := 'Сервер работает'; except on EConvertError do // Это исключение может возникнуть только в одном месте - // при вызове StrToInt(EditPortNumber.Text) MessageDlg('"' + EditPortNumber.Text + '" не является целый числом', mtError, [mbOK], 0); on ERangeError do // Это исключение может возникнуть только в одном месте - // при присваивании значения номеру порта MessageDlg('Номер порта должен находиться в диапазоне 1-65535', mtError, [mbOK], 0); end; end; Этот код мало чем отличается от того, что мы уже видели (сравните, например, с листингами 2.19 и 2.30). Единственное существенное отличие здесь — вызов функции WSAAsyncSelectпосле перевода сокета в режим прослушивания. Этот вызов связывает событие FD_ACCEPTс сообщением WM_ACCEPTMESSAGE. Сообщение WM_ACCEPTMESSAGEнестандартное, мы должны сами определить его. Использовать это сообщение сервер будет только для определения момента подключения нового клиента, определять момент прихода данных мы будем с помощью другого сообщения — WM_SOCKETMESSAGE, которое тоже нужно определить. И, чтобы легче было писать обработчики для этих сообщений, объявим тип TWMSocketMessage, "совместимый" с типом TMessage(листинг 2.51). Листинг 2.51. Сообщения, связанные с сокетами, и тип TWMSocketMessage const WM_ACCEPTMESSAGE = WM_USER + 1; WM_SOCKETMESSAGE = WM_USER + 2; type TWMSocketMessage = packed record Msg: Cardinal; Socket: TSocket; SockEvent: Word; SockError: Word; end; Прежде чем реализовывать реакцию на эти сообщения, нужно позаботиться об обработке ошибок. Функция GetErrorString(см. листинг 2.6), столько времени служившая нам верой и правдой, нуждается в некоторых изменениях. Это связано с тем, что теперь код ошибки может быть получен не только в результате вызова функции WSAGetLastError, но и через параметр SockErrorсообщения. Новый вариант функции GetErrorStringиллюстрирует листинг 2.52. Листинг 2.52. Новый вариант функции GetErrorString // функция GetErrorString возвращает сообщение об ошибке, // сформированное системой на основе значения, которое // передано в качестве параметра. Если это значение // равно нулю (по умолчанию), функция сама определяет // код ошибки, используя функцию WSAGetLastError. // Для получения сообщения используется системная функция // FormatMessage. function GetErrorString(Error: Integer = 0): string; var Buffer: array[0..2047] of Char; begin if Error = 0 then Error := WSAGetLastError; FormatMessage(FORMAT_MESSAGE_FROM_SYSTEM, nil, Error, $400, @Buffer, SizeOf(Buffer), nil); Result := Buffer; end; Сам обработчик сообщения WM_ACCEPTMESSAGEприведен в листинге 2.53. Листинг 2.53. Обработчик сообщения WM_ACCEPTMESSAGE procedure TServerForm.WMAcceptMessage(var Msg: TWMSocketMessage); var NewConnection: PConnection; // Сокет, который создаётся для вновь подключившегося клиента ClientSocket: TSocket; // Адрес подключившегося клиента ClientAddr: TSockAddr; // Длина адреса AddrLen: Integer; begin // Страхуемся от "тупой" ошибки if Msg.Socket <> FServerSocket then raise ESocketError.Create( 'Внутренняя ошибка сервера - неверный серверный сокeт'); // Обрабатываем ошибку на сокете, если она есть. if Msg.SockError <> 0 then begin MessageDlg('Ошибка при подключении клиента:'#13#10 + GetErrorString(Msg.SockError) + #13#10'Сервер будет остановлен', mtError, [mbOK], 0); ClearConnections; closesocket(FServerSocket); OnStopServer; Exit; end; // Страхуемся от еще одной "тупой" ошибки if Msg.SockEvent <> FD_ACCEPT then raise ESocketError.Create( 'Внутренняя ошибка сервера — неверное событие на сокете'); AddrLen := SizeOf(TSockAddr); ClientSocket := accept(FServerSocket, @ClientAddr, @AddrLen); if ClientSocket = INVALID_SOCKET then begin // Если произошедшая ошибка - WSAEWOULDBLOCK, это просто означает, // что на данный момент подключений нет, а вообще все в порядке, // поэтому ошибку WSAEWOULDBLOCK мы просто игнорируем. Прочие же // ошибки могут произойти только в случае серьезных проблем, // которые требуют остановки сервера. if WSAGetLastError <> WSAEWOULDBLOCK then begin MessageDlg('Ошибка при подключении клиента:'#13#10 + GetErrorString + #13#10'Сервер будет остановлен', mtError, [mbOK], 0); ClearConnections; closesocket(FServerSocket); OnStopServer; end; end else begin // связываем сообщение с новым сокетом if WSAAsyncSelect(ClientSocket, Handle, WM_SOCKETMESSAGE, FD_READ or FD_WRITE or FD_CLOSE) = SOCKET_ERROR then begin MessageDlg('Ошибка при установке асинхронного режима ' + 'подключившегося сокета:'#13#10 + GetErrorString, mtError, [mbOK], 0); closesocket(ClientSocket); Exit; end; // Создаем запись для нового подключения и заполняем ее New(NewConnection); NewConnection.ClientSocket := ClientSocket; NewConnection.ClientAddr := Format('%u.%u.%u.%u.%u', [ Ord(ClientAddr.sin_addr.S_un_b.s_b1), Ord(ClientAddr.sin_addr.S_un_b.s_b2), Ord(ClientAddr.sin_addr.S_un_b.s_b3), Ord(ClientAddr.sin_addr.S_un_b.s_b4), ntohs(ClientAddr.sin_port)]); NewConnection.Phase := tpReceiveLength; NewConnection.Offset := 0; NewConnection.BytesLeft := SizeOf(Integer); NewConnection.SendRead := False; // Добавляем запись нового соединения в список FConnections.Add(NewConnection); AddMessageToLog('Зафиксировано подключение с адреса ' + NewConnection.ClientAddr); end; end; Для каждого подключившегося клиента создается запись типа TConnection, указатель на которую добавляется в список FConnections— здесь полная аналогия с сервером на неблокирующих сокетах. Отличие заключается в том, что в типе TConnectionпо сравнению с тем сервером (см. листинг 2.31) добавилось поле SendReadлогического типа. Оно равно True, если возникло событие FD_READв то время, как сервер находится на этапе отправки данных. Каждый сокет, созданный функцией accept, связывается с сообщением WM_SOCKETMESSAGE. Обработчик этого сообщения приведен в листинге 2.54. Листинг 2.54. Обработчик сообщения WM_SOCKETMESSAGE // Метод GetConnectionBySocket находит в списке FConnections // запись, соответствующую данному сокету function TServerForm.GetConnectionBySocket(S: TSocket): PConnection; var I: Integer; begin for I := 0 to FConnections.Count - 1 do if PConnection(FConnections[I]).ClientSocket = S then begin Result := FConnections[I]; Exit; end; Result := nil; end; procedure TServerForm.WMSocketMessage(var Msg: TWMSocketMessage); var Connection: PConnection; Res: Integer; // Вспомогательная процедура, освобождающая ресурсы, связанные // с клиентом и удаляющая запись подключения из списка procedure RemoveConnection; begin closesocket(Connection.ClientSocket); FConnections.Remove(Connection); Dispose(Connection); end; begin // Ищем соединение по сокету Connection := GetConnectionBySocket(Msg.Socket); if Connection = nil then begin AddMessageToLog( 'Внутренняя ошибка сервера — не найдено соединение для сокета'); Exit; end; // Проверяем, были ли ошибки при взаимодействии if Msg.SockError <> 0 then begin AddMessageToLog('Ошибка при взаимодействии с клиентом ' + Connection.ClientAddr + ': ' + GetErrorString(Msg.SockError)); RemoveConnection; Exit; end; // Анализируем, какое событие произошло case Msg.SockEvent of FD_READ: begin // Проверяем, на каком этапе находится взаимодействие с клиентом. if Connection.Phase = tpReceiveLength then begin // Этап получения от клиента длины строки. При выполнении этого // этапа сервер получает от клиента длину строки и размещает ее // в поле Connection.MsgSize. Здесь приходится учитывать, что // теоретически даже такая маленькая (4 байта) посылка может // быть разбита на несколько пакетов, поэтому за один раз этот // этап не будет завершен, и второй раз его придется // продолжать, загружая оставшиеся байты. Connection.Offset - // количество уже прочитанных на данном этапе байтов - // одновременно является смещением, начиная с которого // заполняется буфер. Res := recv(Connection.ClientSocket, (PChar((PConnection.MsgSize + Connection.Offset)^, Connection.BytesLeft, 0); if Res > 0 then begin // Если Res > 0, это означает, что получено Res байтов. // Соответственно, увеличиваем на Res количество прочитанных // на данном этапе байтов и на такую же величину уменьшаем // количество оставшихся. Inc(Connection.Offset, Res); Dec(Connection.BytesLeft, Res); // Если количество оставшихся байтов равно нулю, нужно // переходить к следующему этапу. if Connection.BytesLeft = 0 then begin // Проверяем корректность принятой длины строки if Connection.MsgSize <= 0 then begin AddMessageToLog('Неверная длина строки, от клиента ' + Connection.ClientAddr + ': ' + IntToStr(Connection.MsgSize)); RemoveConnection; Exit; end; // Следующий этап - это чтение самой строки Connection.Phase := tpReceiveString; // Пока на этом этапе не прочитано ни одного байта Connection.Offset := 0; // Осталось прочитать Connection.MsgSize байтов Connection.BytesLeft := Connection.MsgSize; // Сразу выделяем память под строку SetLength(Connection.Msg, Connection.MsgSize); end; end elsе if Res = 0 then begin AddMessageToLog('Клиент ' + Connection.ClientAddr + ' закрыл соединение'); RemoveConnection; Exit; end else // Ошибку WSAEWOULDBLOCK игнорируем, т.к. она говорит // только о том, что входной буфер сокета пуст, но в целом // все в порядке - такое вполне возможно при ложных // срабатываниях сообщения if WSAGetLastError <> WSAEWOULDBLOCK then begin AddMessageToLog('Ошибка при получении данных от клиента ' + Connection.ClientAddr + ': ' + GetErrorString); RemoveConnection; Exit; end; end else if Connection.Phase = tpReceiveString then begin // Следующий этап - чтение строки. Он практически не отличается // по реализации от этапа чтения длины строки, за исключением // того, что теперь буфером, куда помещаются полученные от // клиента данные, служит не Connection.MsgSize, // a Connection.Msg. Res := recv(Connection.ClientSocket, Connection.Msg(Connection.Offset + 1), Connection.BytesLeft, 0); if Res > 0 then begin Inc(Connection.Offset, Res); Dec(Connection.BytesLeft, Res); // Если количество оставшихся байтов равно нулю, можно // переходить к следующему этапу. if Connection.BytesLeft = 0 then begin AddMessageToLog('От клиента ' + Connection.ClientAddr + ' получена строка: ' + Connection.Msg); // Преобразуем строку. В отличие от предыдущих примеров, // здесь мы явно добавляем к строке #0. Это связано с тем, // что при отправке, которая тоже может быть выполнена не // за один раз, мы указываем индекс того символа строки, // начиная с которого нужно отправлять данные. И (хотя // теоретически вероятность этого очень мала) может // возникнуть ситуация, когда за один раз будут отправлены // все символы строки, кроме завершающего #0, и тогда при // следующей отправке начинать придется с него. Если мы // будем использовать тот #0, который добавляется к концу // строки автоматически, то в этом случае индекс выйдет за // пределы диапазона. Поэтому мы вручную добавляем ещё один // #0 к строке, чтобы он стал законной ее частью. Connection.Msg := AnsiUpperCase(StringReplace(Connection.Msg, #0, '#0', [rfReplaceAll])) + '(AsyncSelect server)'#0; // Следующий этап - отправка строки клиенту Connection.Phase := tpSendString; // Отправлено на этом этапе 0 байт Connection.Offset := 0; // Осталось отправить Length(Connection.Msg) байтов. // Единицу к длине строки, в отличие от предыдущих // примеров, не добавляем, т.к. там эта единица нужна была // для того, чтобы учесть добавляемый к строке // автоматически символ #0. Здесь мы еще один #0 добавили // к строке явно, поэтому он уже учтен в функции Length. Connection.BytesLeft := Length(Connection.Msg); // Ставим в очередь сообщение с событием FW_WRITE. // Его получение заставит сервер отправить данные PostMessage(Handle, WM_SOCKETMESSAGE, Msg.Socket, FD_WRITE); end; end else if Res = 0 then begin AddMessageToLog('Клиент ' + Connection.ClientAddr + ' закрыл соединение'); RemoveConnection; Exit; end elsе // Как обычно, "ошибку" WSAEWOULDBLOCK просто игнорируем if WSAGetLastError <> WSAEWOULDBLOCK then begin AddMessageToLog('Ошибка при получении данных от клиента ', + Connection.ClientAddr + ': ' + GetErrorString); RemoveConnection; Exit; end; end else if Connection.Phase = tpSendString then // Если сервер находится на этапе отправки данных, // а событие FD_READ все же произошло, отмечаем это Connection.SendRead := True; end; FD_WRITE: begin if Connection.Phase = tpSendString then begin // При наступлении события FD_WRITE проверяем, находится ли // сервер на этапе отправки данных, и если да, отправляем их Res := send(Connection.ClientSocket, Connection.Msg[Connection.Offset + 1], Connection.BytesLeft, 0); if Res > 0 then begin Inc(Connection.Offset, Res); Dec(Connection.BytesLeft, Res); // Если Connections. BytesLeft = 0, значит, строка отправлена // полностью. if Connection.BytesLeft = 0 then begin AddMessageToLog('Клиенту ' + Connection.ClientAddr + ' отправлена строка: ' + Connection.Msg); // Очищаем строку, просто чтобы сэкономить память Connection.Msg := ''; // Следующий этап - снова получение длины строки от клиента Connection.Phase := tpReceiveLength; // Получено - 0 байт Connection.Offset := 0; // Осталось прочитать столько, сколько занимает целое число Connection.BytesLeft := SizeOf(Integer); // Если были промежуточные события FD_READ, вызываем их // снова искусственно it Connection.SendRead then begin PostMessage(Handle, WM_SOCKETMESSAGE, Msg.Socket, FD_READ); Connection.SendRead := False; end; end; end else if WSAGetLastError <> WSAEWOULDBLOCK then begin AddMessageToLog('Ошибка при отправке данных клиенту ' + Connection.ClientAddr + ': ' + GetErrorString); RemoveConnection; Exit; end; end; end; FD_CLOSE: begin // Клиент вызвал функцию shutdown. Закрываем соединение. AddMessageToLog('Клиент ' + Connection.ClientAddr + ' закрыл соединение'); shutdown(Connection.ClientSocket, SD_BOTH); RemoveConnection; end else begin AddMessageToLog('Неверное событие при обмене с клиентом ' + Connection.ClientAddr); RemoveConnection; end; end; end; В этом примере можно найти много общего с кодом из листинга 2.32 — получение и отправка данных в зависимости от этапа выполняется практически одинаково, различаются только условия, при которых эти участки кода выполняются. Обратите внимание, что теперь проверка того, какой этап чтения выполняется, сделана взаимоисключающей, т.е. при обработке одного сообщения не может быть прочитана и длина строки, и сама строка. Это сделано, чтобы убрать ложные срабатывания. Рассмотрим два возможных варианта. Первый вариант — когда во входном буфере сокета оказывается сразу длина и строка (или ее часть). После того как будет прочитана длина, сообщение WM_SOCKETMESSAGEс параметром FD_READвновь будет помещено в очередь, поскольку функция recvпомещает это сообщение в очередь, если после ее вызова во входном буфере сокета остались данные. Если мы немедленно перейдем ко второму этапу, то прочитаем из буфера сокета все оставшиеся там данные, но сообщение в очереди все равно останется, что даст нам ложное срабатывание, когда петля сообщений извлечет и диспетчеризует это сообщение. Таким образом, выполнение сразу двух этапов при обработке одного сообщения не даст выигрыша в производительности, т.к. все равно придется извлекать и обрабатывать два сообщения. Второй вариант — когда на момент обработки события FD_READво входном буфере находится только длина строки. В этом случае функция recvне будет помещать в очередь второе сообщение WM_SOCKETMESSAGE, т.к. данных в буфере после ее выполнения не останется, но и попытка выполнить этап чтения строки окажется бесполезной работой, т.к. строка еще не получена. В любом случае этап чтения строки будет выполнен только при обработке следующего сообщения WM_SOCKETMESSAGE, когда от клиента будут получены новые данные. Получается, что при обоих вариантах попытка выполнить за один раз сразу два этапа не дает никаких преимуществ в быстродействии, но зато повышает вероятность ложных срабатываний события FD_READв то время, когда сервер находится на этапе отправки данных клиенту. А ложные срабатывания на этом этапе вредны тем, что сервер принимает их за поступление данных, которое нужно запомнить, чтобы обработать после того, как ответ будет отправлен клиенту. В принципе, эти ложные срабатывания в итоге не приводят ни к чему плохому, кроме незначительного увеличения нагрузки на процессор, но раз от них нет пользы, и мы можем избавиться от них совсем небольшой ценой, лучше это сделать. Отправка данных клиенту выполняется при обработке события FD_WRITE. Это событие генерируется библиотекой сокета в двух случаях: при начале работы сокета и когда возможность отправки данных восстановлена после отказа из-за нехватки места в буфере. Пока речь не идет об обмене сообщениями размером в десятки мегабайтов, ситуация с нехваткой места в выходном буфере крайне маловероятна, т.е. библиотека сокетов будет генерировать это событие лишь один раз для каждого клиента. Но никто не мешает нам помещать соответствующее сообщение в очередь вручную, что мы и делаем при обработке события FD_READпосле завершения этапа получения строки, т.е. когда сервер согласно протоколу должен отправить ответ. Таким образом. один и тот же участок кода используется для отправки данных как тогда, когда сервер видит в этом необходимость, так и тогда, когда их вновь можно отправлять после переполнения буфера. При обработке события FD_WRITEв очередь сообщений также помещается сообщение WM_SOCKETMESSAGE, если было зафиксировано получение события FD_READна этапе отправки данных. В принципе, это может дать ложное срабатывание FD_READв двух случаях: когда исходное событие FD_READбыло ложным и когда событие FD_READуже присутствует в очереди на момент вызова PostMessage. Но, как мы уже отметили ранее, никаких неприятных последствий, кроме незначительного увеличения нагрузки на процессор, ложные срабатывания не приносят, так что с ними можно смириться. В итоге у нас получился сервер, который, как и сервер на неблокирующих сокетах, никогда не блокируется и устойчив к нарушению клиентом протокола. По сравнению с сервером на неблокирующих сокетах сервер на асинхронных событиях имеет два преимущества. Во-первых, немного снижена нагрузка на процессор, т.к. попытка чтения данных из сокета выполняется не периодически, а только когда это необходимо. Во-вторых, сообщения клиента обрабатываются несколько быстрее, т.к. сообщение помещается в очередь сразу при получении данных, и, если сервер не занят ничем другим, он сразу приступает к его обработке, а не ждет, пока истечет период опроса. 2.2.7. Асинхронный режим, основанный на событияхАсинхронный режим, основанный на событиях, появился во второй версии Windows Sockets. В его основе лежат события — специальные объекты, служащие для синхронизации работы нитей. Существуют события, поддерживаемые на уровне системы. Они создаются с помощью функции CreateEvent. Каждое событие может находиться в сброшенном или взведенном состоянии. Нить с помощью функций WaitForSingleObjectи WaitForMultipleObjectsможет дожидаться, пока одно или несколько событий не окажутся во взведенном состоянии. В режиме ожидания нить не требует процессорного времени. Другая нить может установить событие с помощью функции SetEvent, в результате чего первая нить выйдет из состояния ожидания и продолжит свою работу. Подробно о системных событиях и прочих объектах синхронизации написано в [2]. Аналогичные объекты определены и в Windows Sockets. Сокетные события отличаются от стандартных системных событий прежде всего тем, что они могут быть связаны с событиями FD_XXX, происходящими на сокете, и взводиться при наступлении этих событий. Так как сокетные события поддерживаются только в WinSock 2, модуль WinSock не содержит объявлений типов и функций, требуемых для их поддержки. Поэтому их придется объявлять самостоятельно. Прежде всего, должен быть объявлен тип дескриптора событий, который в MSDN называется WSAEVENT. В Delphi он может быть объявлен следующим образом: PWSAEvent = ^TWSAEvent; TWSAEvent = THandle; Событие создается с помощью функции WSACreateEvent, прототип которой приведен в листинге 2.55. Листинг 2.55. Функция WSACreateEvent // ***** Описание на C++ ***** WSAEVENT WSACreateEvent(void); // ***** Описание на Delphi ***** function WSACreateEvent: TWSAEvent; Событие, созданное этой функцией, находится в сброшенном состоянии, при ожидании автоматически не сбрасывается, не имеет имени и обладает стандартными атрибутами безопасности. В MSDN отмечено, что сокетное событие на самом деле является простым системным событием, и его можно создавать с помощью стандартной функции CreateEvent, управляя значениями всех перечисленных параметров. Функция создает событие и возвращает его дескриптор. Если произошла ошибка, функция возвращает значение WSA_INVALID_EVENT(0). Для ручного взведения и сброса события предназначены функции WSASetEventи WSAResetEventсоответственно, прототипы которых приведены в листинге 2.56. Листинг 2.56. Функции для управления событиями // ***** Описание на C++ ***** BOOL WSASetEvent(WSAEVENT hEvent); BOOL WSAResetEvent(WSAEVENT hEvent); // ***** Описание на Delphi ***** function WSASetEvent(hEvent: TWSAEvent): BOOL; function WSAResetEvent(hEvent: TWSAEvent): BOOL; Функции возвращают True, если операция прошла успешно, и False— в противном случае. После завершения работы с событием оно уничтожается с помощью функции WSACloseEvent(листинг 2.57). Листинг 2.57. Функция WSACloseEvent // ***** Описание на C++ ***** BOOL WSACloseEvent(WSAEVENT nEvent); // ***** Описание на Delphi ***** function WSACloseEvent(hEvent: TWSAEvent): BOOL; Функция уничтожает событие и освобождает связанные с ним ресурсы. Дескриптор, переданный в качестве параметра, становится недействительным. Для ожидания взведения событий служит функция WSAWaitForMultiрleEvents(листинг 2.58). Листинг 2.58. Функция WSAWaitForMultipleEvents // ***** Описание на C++ ***** DWORD WSAWaitForMultipleEvents(DWORD cEvents, const WSAEVENT FAR *lphEvents, BOOL fWaitAll, WORD dwTimeout, BOOL fAlertable); // ***** Описание на Delphi ***** function WSAWaitForMultipleEvents(cEvents: DWORD; lphEvents: PWSAEvent; fWaitAll: BOOL; dwTimeout: DWORD; fAlertable: BOOL): DWORD; Дескрипторы событий, взведения которых ожидает нить, должны храниться в массиве, размер которого передаётся через параметр cEvents, а указатель — через параметр lphEvents. Параметр fWaitAllопределяет, что является условием окончания ожидания: если он равен True, ожидание завершается, когда все события из переданного массива оказываются во взведенном состоянии, если False— когда оказывается взведенным хотя бы одно из них. Параметр dwTimeoutопределяет тайм-аут ожидания в миллисекундах. В WinSock 2 определена константа WSA_INFINITE(совпадающая по значению со стандартно константой INFINITE), которая задает бесконечное ожидание. Параметр fAlertableнужен при перекрытом вводе-выводе: мы рассмотрим его позже в разд. 2.2.9. Если перекрытый ввод-вывод не используется, fAlertableдолжен быть равен False. Существует ограничение на число событий, которое можно ожидать с помощью данной функции. Максимальное число событий определяется константой WSA_MAXIMUM_WAIT_EVENTS, которая в данной реализации равна 64. Результат, возвращаемый функцией, позволяет определить, по каким причинам закончилось ожидание. Если ожидалось взведение всех событий ( fWaitAll = True), и оно произошло, функция возвращает WSA_WAIT_EVENT_0(0). Если ожидалось взведение хотя бы одного из событий, возвращается WSA_WAIT_EVENT_0 + Index, где Index— индекс взведенного события в массиве lphEvents(отсчет индексов начинается с нуля). Если ожидание завершилось по тайм-ауту, возвращается значение WSA_WAIT_TIMEOUT(258). И наконец, если произошла какая-либо ошибка, функция возвращает WSA_WAIT_FAILED( $FFFFFFFF). Существует еще одно значение, которое может возвратить функция WSAWaitForMultipleEvents: WAIT_IO_COMPLETION(это константа из стандартной части Windows API, она объявлена в модуле Windows). Смысл этого результата и условия, при которых он может быть возвращен, мы рассмотрим в разд. 2.2.9. Функции, которые мы рассматривали до сих пор, являются аналогами системных функций для стандартных событий. Теперь мы переходим к рассмотрению тех функций, которые отличают сокетные события от стандартных. Главная из них — WSAEventSelect, позволяющая привязать события, создаваемые с помощью WSACreateEvent, к тем событиям, которые происходят на сокете. Прототип этой функции приведен в листинге 2.59. Листинг 2.59. Функция WSAEventSelect // ***** Описание на C++ ***** int WSAEventSelect(SOCKET s, WSAEVENT hEventObject, long lNetworkEvents); // ***** описание на Delphi ***** function WSAEventSelect(S: TSocket; hEventObject: TWSAEvent; lNetworkEvents: LongInt): Integer; Эта функция очень похожа на функцию WSAAsyncSelect, за исключением того, что события FD_XXXпривязываются не к оконным сообщениям, а к сокетным событиям. Параметр Sопределяет сокет, события которого отслеживаются, параметр hEventObject— событие, которое должно взводиться при наступлении отслеживаемых событий, lNetworkEvents— комбинация констант FD_XXX, определяющая, с какими событиями на сокете связывается событие hSocketEvent. Функция WSAEventSelectвозвращает ноль, если операция прошла успешно, и SOCKET_ERRORпри возникновении ошибки. Событие, связанное с сокетом функцией WSAEventSelect, взводится при тех же условиях, при которых в очередь окна помещается сообщение при использовании WSAAsyncSelect. Так, например, функция recvвзводит событие, если после ее вызова в буфере сокета еще остаются данные. Но, с другой стороны, функция recvне сбрасывает событие, если данных в буфере сокета нет. А поскольку сокетные события не сбрасываются автоматически функцией WSAWaitForMultipleEvents, программа всегда должна сбрасывать события сама. Так, при обработке FD_READнаиболее типична ситуация, когда сначала сбрасывается событие, а потом вызывается функция recv, которая при необходимости снова взводит событие. Здесь мы снова имеем проблему ложных срабатываний в тех случаях, когда данные извлекаются из буфера по частям с помощью нескольких вызовов recv, но в данном случае проблему решить легче: не нужно отменять регистрацию событий, достаточно просто сбросить событие непосредственно перед последним вызовом recv. В принципе, события FD_XXXразных сокетов можно привязать к одному сокетному событию, но этой возможностью обычно не пользуются, т.к. в WinSock2 отсутствуют средства, позволяющие определить, событие на каком из сокетов привело к взведению сокетного события. Поэтому приходится для каждого сокета создавать отдельное событие. Как и в случае с WSAAsyncSelectпри вызове WSAEventSelectсокет переводится в неблокирующий режим. Повторный вызов WSAEventSelectдля данного сокета отменяет результаты предыдущего вызова (т.е. невозможно связать разные события FD_XXXодного сокета с разными сокетными событиями). Сокет, созданный в результате вызова accept или WSAAcceptнаследует связь с сокетными событиями, установленную для слушающего сокета. Существует весьма важное различие между использованием оконных сообщений и сокетных событий для оповещения о том, что происходит на сокете. Предположим, с помощью функции WSAAsyncSelectсобытия FD_READ, FD_WRITEи FD_CONNECTсвязаны с некоторым оконным сообщением. Пусть происходит событие FD_CONNECT. В очередь окна помещается соответствующее сообщение. Затем, до того, как предыдущее сообщение будет обработано, происходит FD_WRITE. В очередь окна помещается еще одно сообщение, которое информирует об этом. И наконец, при возникновении FD_READв очередь будет помещено третье сообщение. Затем оконная процедура получит их по очереди и обработает. Теперь рассмотрим ситуацию, когда те же события связаны с сокетным событием. Когда происходит FD_CONNECT, сокетное событие взводится. Теперь если FD_WRITEи FD_READпроизойдут до того, как сокетное событие будет сброшено, оно уже не изменит своего состояния. Таким образом, программа, работающая с асинхронными сокетами, основанными на событиях, должна, во-первых, учитывать, что взведенное событие может означать несколько событий FD_XXX, а во-вторых, иметь возможность узнать, какие именно события произошли с момента последней проверки. Для получения этой информации предусмотрена функция WSAEnumNetworkEvents, прототип которой приведен в листинге 2.60. Листинг 2.60. Функция WSAEnumNetworkEvents // ***** Описание на C++ ***** int WSAEnumNetworkEvents(SOCKET s, WSAEVENT hEventObject, LPWSANETWORKEVENTS lpNetworkEvents); // ***** Описание на Delphi ***** function WSAEnumNetworkEvents(S: TSocket; hEventObject: TWSAEvent; var NetworkEvents: TWSANetworkEvents): Integer; Функция WSAEnumNetworkEventsчерез параметр NetworkEventsвозвращает информацию о том, какие события произошли на сокете S с момента последнего вызова этой функции для данного сокета (или с момента запуска программы, если функция вызывается в первый раз). Параметр hEventObjectнеобязательный, он определяет сокетное событие, которое нужно сбросить. Использование этого параметра позволяет обойтись без явного вызова функции WSAResetEventдля сброса события. Как и большинство функций WinSock, функция WSAEnumNetworkEventsвозвращает ноль в случае успеха и ненулевое значение при возникновении ошибки. Запись TWSANetworkEventsсодержит информацию о произошедших событиях об ошибках (листинг 2.61). Листинг 2.61. Тип TWSANetworkEvents // ***** Описание на C++ ***** typedef struct _WSANETWORKEVENTS { long lNetworkEvents; int iErrorCode[FD_MAX_EVENTS]; } WSANETWORKEVENTS, *LPWSANETWORKEVENTS; // ***** Описание на Delphi ***** TWSANetworkEvents = packed record lNetworkEvents: LongInt; iErrorCode: array[0..FD_MAX_EVENTS - 1] of Integer; end; Константа FD_MAX_EVENTSопределяет количество разных типов событий и в данной реализации равна 10. Значения констант FD_XXXпредставляют собой степени двойки, поэтому их можно объединять операцией арифметического ИЛИ без потери информации. Поле lNetworkEventsявляется таким объединением всех констант, задающих события, которые происходили на сокете. Другими словами, если результат операции ( lNetworkEvents and FD_XXX) не равен нулю, значит, событие FD_XXXпроисходило на сокете. Массив iErrorCodeсодержит информацию об ошибках, которыми сопровождались события FD_XXX. Для каждого события FD_XXXопределена соответствующая константа FD_XXX_BIT(т.е. константы FD_READ_BIT, FD_WRITE_BITи т.д.). Элемент массива с индексом FD_XXX_BITсодержит информацию об ошибке, связанной с событием FD_XXX. Если операция прошла успешно, этот элемент содержит ноль, в противном случае — код ошибки, которую в аналогичной ситуации вернула бы функция WSAGetLastErrorпосле выполнения соответствующей операции на синхронном сокете. Таким образом, программа, использующая асинхронный режим, основанный на событиях, должна выполнить следующие действия. Во-первых, создать сокет и установить соединение. Во-вторых, привязать события FD_XXXк сокетному событию. В-третьих, организовать цикл, начинающийся с вызова WSAWaitForMultipleEvents, в котором с помощью WSAEnumNetworkEventsопределять, какое событие произошло, и обрабатывать его. При возникновении ошибки на сокете цикл должен завершаться. Сокетные события могут взводиться не только в результате событий на сокете, но и вручную, с помощью функции WSASetEvent. Это дает нити, вызвавшей функцию WSAWaitForMultipleEvents, возможность выходить из состояния ожидания не только при возникновении событий на сокете, но и по сигналам от других нитей. Типичная область применения этой возможности — для тех случаев, когда программа может как отвечать на запросы от удаленного партнера, так и отправлять ему что-то по собственной инициативе. В этом случае могут использоваться два сокетных события: одно связывается с событием FD_READдля оповещения о поступлении данных, а второе не связывается ни с одним из событий FD_XXX, а устанавливается другой нитью тогда, когда необходимо отправить сообщение. Нить, работающая с сокетом, ожидает взведения одного из этих событий и в зависимости от того, какое из них взведено, читает или отправляет данные. В листинге 2.62 приведен пример кода такой нити. Она задействует три сокетных события: одно для уведомления о событиях на сокете, второе — для уведомления о необходимости отправить данные, третье — для уведомления о необходимости завершиться. В данном примере мы предполагаем, что, во-первых, сокет создан и подключен до создания нити и передается ей в качестве параметра, а во-вторых, три сокетных события хранятся в глобальном массиве SockEvents: array[0..2] of TWSAEvent, причем нулевой элемент этого массива содержит событие, связываемое с событиями FD_XXX, первый элемент — событие отправки данных, второй — событие завершения нити. Прототип функции, образующей нить, совместим с функцией BeginThreadиз модуля SysUtils. Листинг 2.62. Схема нити, использующей события асинхронного сокета function ProcessSockEvents(Parameter: Pointer): Integer; var S: TSocket; NetworkEvents: TWSANetworkEvents; begin // Так как типы TSocket и Pointer занимают по 4 байта, такое // приведение типов вполне возможно, хотя и некрасиво S := TSocket(Parameter); // Связываем событие SockEvents[0] с FD_READ и FD_CLOSE WSAEventSelect(S, SockEvents[0], FD_READ or FD_CLOSE); while True do begin case WSAWaitForMultipleEvents(3, @SockEvents[0], True, WSA_INFINITE, False) of WSA_WAIT_EVENT_0: begin WSAEnumNetworkEvents(S, SockEvents[0], NetworkEvents); if NetworkEvents.lNetworkEvents and FD_READ > 0 then if NetworkEvents.iErrorCode[FD_READ_BIT] = 0 then begin // Пришли данные, которые нужно прочитать end else begin // произошла ошибка. Нужно сообщить о ней и завершить нить closesocket(3); Exit; end; if NetworkEvents.lNetworkEvents and FD_CLOSE > 0 then begin // Связь разорвана if NetworkEvents.iErrorCode[FD_CLOSE_BIT] = 0 then begin // Связь закрыта корректно end else begin // Связь разорвана в результате сбоя сети end; // В любом случае нужно закрыть сокет и завершить нить closesocket(S); Exit; end; end; WSA_WAIT_EVENT_0 + 1: begin // Получен сигнал о необходимости отправить данные // Здесь должен быть код отправки данных // После отправки событие нужно сбросить вручную ResetEvent(SockEvents[1]); end; WSA_WAIT_EVENT_0 + 2: begin // Получен сигнал о необходимости завершения работы нити closesocket; ResetEvents(SockEvents[2]); Exit; end end; end; end; Как и во всех предыдущих примерах, здесь для краткости не проверяются результаты, возвращаемые функциями и не отлавливаются возникающие ошибки. Кроме того, отсутствует процедура завершения связи с вызовом shutdown. Данный пример может рассматриваться как фрагмент кода простого сервера. В отдельной нити такого сервера выполняется цикл, состоящий из вызова acceptи создания новой нити для обслуживания полученного таким образом сокета. Затем другие нити при необходимости могут давать таким нитям команды (необходимо только предусмотреть для каждой нити, обслуживающей сокет, свою копию массива SockEvents). Благодаря этому каждый клиент будет обслуживаться независимо. К недостаткам такого сервера следует отнести его низкую устойчивость против DoS-атак, при которых к серверу подключается очень большое число клиентов. Если сервер будет создавать отдельную нить для обслуживания каждого подключения, количество нитей очень быстро станет слишком большим, и вся система окажется неработоспособной, т.к. большая часть процессорного времени будет тратиться на переключение между нитями. Более защищенным является вариант, при котором сервер заранее создает некоторое разумное количество нитей (пул нитей) и обработку запроса или выполнение команды поручает любой свободной нити из этого пула. Если ни одной свободной нити в пуле нет, задание ставится в очередь. По мере освобождения нитей задания извлекаются из очереди и выполняются. При DoS-атаках такой сервер также не справляется с поступающими заданиями, но это не приводит к краху всей системы. Но сервер с пулом нитей реализуется сложнее (обычно — через порты завершения, которые мы здесь не рассматриваем). Тем не менее простой для реализации сервер без пула нитей тоже может оказаться полезным, если вероятность DoS-атак низка (например, в изолированных технологических подсетях). Приведенный пример может рассматриваться также как заготовка для клиента. В этом случае целесообразнее передавать в функцию ProcessSockEventsне готовый сокет, а только адрес сервера, к которому необходимо подключиться. Создание сокета и установление связи с сервером при этом выполняет сама нить перед началом цикла ожидания событий. Такой подход очень удобен для независимой работы с несколькими однотипными серверами. 2.2.8. Пример использования сокетов с событиямиК достоинствам асинхронного режима, основанного на сообщениях, относится то, что нить, обслуживающая сокет, может выходить из состояния ожидания не только при получении данных сокетом, но и по иным сигналам. Протокол обмена который мы до сих пор использовали, не позволяет продемонстрировать это достоинство в полном объеме. Поэтому, прежде чем создавать пример, мы несколько изменим протокол. Формат пакетов оставим прежним, изменим условия, при которых эти пакеты могут быть посланы. Теперь клиент имеет право посылать пакеты, не дожидаясь от сервера ответа на предыдущий пакет, а сервер имеет право посылать пакеты клиенту не только в ответ на его запросы, но и по собственной инициативе. В нашей реализации он будет посылать клиентам строку с текущим временем. Сервер на асинхронных событиях (пример EventSelectServer на компакт-диске) имеет много общего с рассмотренным ранее многонитевым сервером (пример MultithreadedServer, см. разд. 2.1.12). В нем также есть нить для обработки подключений клиентов и по одной нити на каждого клиента, а главная нить только создает слушающий сокет и запускает обслуживающую его нить. Еще одним важным отличием нашего сервера от всех предыдущих примеров серверов станет то, что пользователь сможет его остановить в любой момент. Подобную функциональность было бы несложно добавить и к таким серверам, как SelectServer, NonBlockingServer и AsyncSelectServer, которые работают в одной нити. Но остановить нити в многонитевом сервере можно было только одним способом: уничтожив сокеты из главной нити — в этом случае все работающие с этими сокетами нити завершились бы с ошибками. Очевидно, что это порочный подход, не позволяющий корректно завершить работу с клиентами. Режим с использованием событий позволяет предусмотреть реакцию нити на внешний сигнал об отключении. Отключаться сервер будет по нажатию кнопки Остановить. В листинге 2.63 приведен код нити, взаимодействующей с клиентом (код методов LogMessageи DoLogMessageопущен, т.к. он идентичен приведенному в листингах 2.20 и 2.7 соответственно). Листинг 2.63. Нить, взаимодействующая с клиентами unit ClientThread; { Нить, обслуживающая одного клиента. Выполняет цикл, выход из которого возможен по внешнему сигналу или при возникновении ошибки на сокете. Умеет отправлять клиенту сообщения по внешнему сигналу. } interface uses Windows, Classes, WinSock, Winsock2_Events, ShutdownConst, SysUtils, SyncObjs; type TClientThread = class(TThread) private // Сообщение, которое нужно добавить в лог, // хранится в отдельном поле, т.к. метод, вызывающийся через // Synchronize, не может иметь параметров. FMessage: string; // Префикс для всех сообщений лога, связанных с данным клиентом FHeader: string; // Сокет для взаимодействия с клиентом FSocket: TSocket; // События нити // FEvents[0] используется для остановки нити // FEvents[1] используется для отправки сообщения // FEvents[2] связывается с событиями FD_READ, FD_WRITE и FD_CLOSE FEvents; array[0..2] of TWSAEvent; // Критическая секция для доступа к буферу исходящих FSendBufSection: TCriticalSection; // Буфер исходящих FSendBuf: string; // Вспомогательный метод для вызова через Synchronize procedure DoLogMessage; // Функция, проверяющая, завершила ли нить работу function GetFinished: Boolean; protected procedure Execute; override; // Вывод сообщения в лог главной формы procedure LogMessage(сonst Msg: string); // Отправка клиенту данных из буфера исходящих function DoSendBuf: Boolean; public constructor Create(ClientSocket: TSocket; const ClientAddr: TSockAddr); destructor Destroy; override; // Добавление строки в буфер исходящих procedure SendString(const S: string); // Остановка нити извне procedure StopThread; property Finished: Boolean read GetFinished; end; ESocketError = class(Exception); implementation uses MainServerUnit; { TClientThread } // Сокет для взаимодействия с клиентом создается в главной нити, // а сюда передается через параметр конструктора. Для формирования // заголовка сюда же передается адрес подключившегося клиента constructor TClientThread.Create(ClientSocket: TSocket; const ClientAddr: TSockAddr); begin FSocket := ClientSocket; // заголовок содержит адрес и номер порта клиента. // Этот заголовок будет добавляться ко всем сообщениям в лог // от данного клиента. FHeader := 'Сообщение от клиента ' + inet_ntoa(ClientAddr.sin_addr) + ': ' + IntToStr(ntohs(ClientAddr.sin_port)) + ': '; // Создаем события и привязываем первое из них к сокету FEvents[0] := WSACreateEvent; if FEvents[0] = WSA_INVALID_EVENT then raise ESocketError.Create( FHeader + 'Ошибка при создании события: ' + GetErrorString); FEvents[1] := WSACreateEvent; if FEvents[1] = WSA_INVALID_EVENT then raise ESocketError.Create( FHeader + 'Ошибка при создании события: ' + GetErrorString); FEvents[2] := WSACreateEvent; if FEvents[2] = WSA_INVALID_EVENT then raise ESocketError.Create( FHeader + 'Ошибка при создании события: ' + GetErrorString); if WSAEventSelect(FSocket, FEvents[2], FD_READ or FD_WRITE or FD_CLOSE) = SOCKET_ERROR then raise ESocketError.Create( FHeader + 'Ошибка при привязывании сокета к событию: ' + GetErrorString); FSendBufSection := TCriticalSection.Create; // Объект этой нити не должен удаляться сам FreeOnTerminate := False; inherited Create(False); end; destructor TClientThread.Destroy; begin FSendBufSection.Free; WSACloseEvent(FEvents[0]); WSACloseEvent(FEvents[1]); WSACloseEvent(FEvents[2]); inherited; end; // Функция добавляет строку в буфер для отправки procedure TClientThread.SendString(const S: string); begin FSendBufSection.Enter; try FSendBuf := FSendBuf + S + #0; finally FSendBufSection.Leave; end; LogMessage('Сообщение "' + S + '" поставлено в очередь для отправки'); // Взводим событие, которое говорит, что нужно отправлять данные WSASetEvent(FEvents[1]); end; // Отправка всех данных, накопленных в буфере // Функция возвращает False, если произошла ошибка, // и True, если все в порядке function TClientThread.DoSendBuf: Boolean; var SendRes: Integer; begin FSendBufSection.Enter; try // Если отправлять нечего, выходим if FSendBuf = '' then begin Result := True; Exit; end; // Пытаемся отправить все, что есть в буфере SendRes := send(FSocket, FSendBuf[1], Length(FSendBuf), 0); if SendRes > 0 then begin // Удаляем из буфера ту часть, которая отправилась клиенту Delete(FSendBuf, 1, SendRes); Result := True; end else begin Result := WSAGetLastError = WSAEWOULDBLOCK; if not Result then LogMessage('Ошибка при отправке данных: ' + GetErrorString); end; finally FSendBufSection.Leave; end; end; procedure TClientThread.Execute; const // размер буфера для приема сообщении RecvBufSize = 4096; var // Буфер для приема сообщений RecvBuf: array[0..RecvBufSize - 1] of Byte; RecvRes: Integer; NetEvents: TWSANetworkEvents; // Полученная строка Str: string; // Длина полученной строки StrLen: Integer; // Если ReadLength = True, идет чтение длины строки, // если False - самой строки ReadLength: Boolean; // Смещение от начала приемника Offset: Integer; // Число байтов, оставшихся при получении длины строки или самой строки BytesLeft: Integer; Р: Integer; I: Integer; LoopExit: Boolean; WaitRes: Cardinal; begin LogMessage('Соединение установлено'); ReadLength := True; Offset := 0; BytesLeft := SizeOf(Integer); repeat WaitRes := WSAWaitForMultipleEvents(3, @FEvents, False, WSA_INFINITE, False); case WaitRes of WSA_WAIT_EVENT_0: begin // Закрываем соединение с клиентом и останавливаем нить LogMessage('Получен сигнал об остановке нити'); shutdown(FSocket, SD_BOTH); Break; end; WSA_WAIT_EVENT_0 + 1: begin // Сбрасываем событие и отправляем данные WSAResetEvent(FEvents[1]); if not DoSendBuf then Break; end; WSA_WAIT_EVENT_0 + 2: begin // Произошло событие, связанное с сокетом. // Проверяем, какое именно, и заодно сбрасываем его if WSAEnumNetworkEvents(FSocket, FEvents[2], NetEvents) = SOCKET_ERROR then begin LogMessage('Ошибка при получении списка событий: ' + GetErrorString); Break; end; if NetEvents.lNetworkEvents and FD_READ <> 0 then begin if NetEvents.iErrorCode[FD_READ_BIT] <> 0 then begin LogMessage('Ошибка в событии FD_READ: ' + GetErrorString(NetEvents.iErrorCode[FD_READ_BIT])); Break; end; // В буфере сокета есть данные. // Копируем данные из буфера сокета в свой буфер RecvBuf RecvRes := recv(FSocket, RecvBuf, SizeOf(RecvBuf), 0); if RecvRes > 0 then begin P := 0; // Эта переменная нужна потому, что здесь появляется // вложенный цикл, при возникновении ошибки в котором нужно // выйти и из внешнего цикла тоже. Так как в Delphi нет // конструкции типа Break(2) в Аде, приходится прибегать // к таким способам: если нужен выход из внешнего цикла, // во внутреннем цикле выполняется LoopExit := True, // а после выполнения внутреннего цикла проверяется // значение этой переменной и при необходимости выполняется // выход и из главного цикла. LoopExit := False; // В этом цикле мы извлекаем данные из буфера // и раскидываем их по приёмникам - Str и StrLen. while Р < RecvRes do begin // Определяем, сколько байтов нам хотелось бы скопировать L := BytesLeft; // Если в буфере нет такого количества, // довольствуемся тем, что есть if Р + L > RecvRes then L := RecvRes - P; // Копируем в соответствующий приемник if ReadLength then Move(RecvBuf[P], (PChar(@StrLen) + Offset)^, L) else Move(RecvBuf[P], Str(Offset + 1), L); Dec(BytesLeft, L); // Если прочитали все, что хотели, // переходим к следующему if BytesLeft = 0 then begin ReadLength := not ReadLength; Offset := 0; // Если закончено чтение строки, нужно вывести ее if ReadLength then begin LogMessage('Получена строка: ' + Str); BytesLeft := SizeOf(Integer); // Формируем ответ и записываем его в буфер Str := AnsiUpperCase(StringReplace(Str, #0, '#0', [rfReplaceAll])) + '(AsyncEvent server)'; SendString(Str); Str := ''; end else begin if StrLen <= 0 then begin LogMessage('Неверная длина строки от клиента: ' + IntToStr(StrLen)); LoopExit := True; Break; end; BytesLeft := StrLen; SetLength(Str, StrLen); end; end else Inc(Offset, L); Inc(P, L); end; // Проверяем, был ли аварийный выход из внутреннего цикла, // и если был, выходим и из внешнего, завершая работу // с клиентом if LoopExit then Break; end else if RecvRes = 0 then begin LogMessage('Клиент закрыл соединение '); Break; end else begin if WSAGetLastError <> WSAEWOULDBLOCK then begin LogMessage('Ошибка при получении данных от клиента: ' + GetErrorString); end; end; end; // Сокет готов к передаче данных if NetEvents.lNetworkEvents and FD_WRITE <> 0 then begin if NetEvents.iErrorCode[FD_WRITE_BIT] <> 0 then begin LogMessage('Ошибка в событии FD_WRITE: ' + GetErrorString(NetEvents.iErrorCode[FD_WRITE_BIT))); Break; end; // Отправляем то, что лежит в буфере if not DoSendBuf then Break; end; if NetEvents.lNetworkEvents and FD_CLOSE <> 0 then begin // Клиент закрыл соединение if NetEvents.iErrorCode[FD_CLOSE_BIT] <> 0 then begin LogMessage('Ошибка в событии FD_CLOSE: ' + GetErrorString(NetEvents.iErrorCode[FD_CLOSE_BIT])); Break; end; LogMessage('Клиент закрыл соединение'); shutdown(FSocket, SD_BOTH); Break; end; end; WSA_WAIT_FAILED: begin LogMessage('Ошибка при ожидании сообщения: ' + GetErrorString); Break; end; else begin LogMessage( 'Внутренняя ошибка сервера — неверный результат ожидания ' + IntToStr(WaitRes)); Break; end; end; until False; closesocket(FSocket); LogMessage('Нить остановлена'); end; // Функция возвращает True, если нить завершилась function TClientThread.GetFinished: Boolean; begin // Ждем окончания работы нити с нулевым тайм-аутом. // Если нить завершена, вернется WAIT_OBJECT_0. // Если еще работает, вернется WAIT_TIMEOUT. Result := WaitForSingleObject(Handle, 0) = WAIT_OBJECT_0; end; // Метод для остановки нити извне. // Взводим соответствующее событие, а остальное сделаем // при обработке события procedure TClientThread.StopThread; WSASetEvent(FEvents[0]); end; Модуль WinSock2_Events, появившийся в списке uses, содержит объявления констант, типов и функций из WinSock 2, которые понадобятся в программе. Модуль ShutdownConstсодержит объявления констант для функции shutdown, которые отсутствуют в модуле WinSock Delphi 5 и более ранних версиях — этот модуль нам понадобился, чтобы программу можно было откомпилировать в Delphi 5. Нить использует три события, дескрипторы которых хранятся в массиве FEvents. Событие FEvents[0]служит для уведомления нити о том, что необходимо завершиться, FEvents[1]— для уведомления о том, что нужно оправить данные, FEvents[2]связывается с событиями на сокете. Такой порядок выбран не случайно. Если взведено несколько событий, функция WSAWaitForMultipleEventsвернет результат, соответствующий событию с самым младшим из взведенных событий индексом. Соответственно, чем ближе к началу массива, тем более высокий приоритет у события. Событие, связанное с сокетом, имеет наинизший приоритет для того, чтобы повысить устойчивость сервера к DoS-атакам. Если приоритет этого события был бы выше, чем события остановки нити, то в случае закидывания сервера огромным количеством сообщений от клиента, событие FD_READбыло бы всегда взведено, и сервер все время тратил бы на обработку этого события, игнорируя сигнал об остановке нити. Соответственно, сигнал об остановке должен иметь самый высокий приоритет, чтобы остановке нити ничего не могло помешать. Тем, как отправляются сообщения, сервер управляет сам. поэтому не приходится ожидать проблем, связанных с тратой излишних ресурсов на обработку сигнала отправки. Соответственно, этому событию присваивается приоритет, промежуточный между событием остановки нити и событием сокета. Так как клиент по новому протоколу перед отправкой сообщения не обязан ждать, пока сервер ответит на предыдущее, возможны ситуации, когда ответ на следующее сообщение сервер должен готовить уже тогда, когда предыдущее еще не отправлено. Кроме того, сервер может отправить сообщение по собственной инициативе, и этот момент тоже может наступить тогда, когда предыдущее сообщение еще не отправлено. Таким образом, мы вынуждены формировать очередь сообщений в том или ином виде. Так как протокол TCP, с одной стороны, может объединять несколько пакетов в один, а с другой, не обязан отправлять отдельную строку за один раз, проще всего не делать очередь из отдельных строк, а заранее объединять их в одном буфере и затем пытаться отправить все содержимое буфера. Таким буфером в нашем случае является поле FSendBuf, метод SendStringдобавляет строку в этот буфер, a DoSendBufотправляет данные из этого буфера. Если все данные отправить за один раз не удалось, отправленные данные удаляются из буфера, а оставшиеся будут отправлены при следующем вызове SendBuf. Все операции с буфером FSendBuf выполняются внутри критической секции, т.к. функция SendStringможет вызываться из других нитей. К каждой строке добавляется символ #0, который, согласно протоколу, является для клиента разделителем строк в потоке. Сигналом к отправке данных является событие FEvents[1]. Метод SendString, помещая данные в буфер, взводит это событие. Если все содержимое буфера за один раз отправить не удастся, то через некоторое время возникнет событие FD_WRITE, означающее готовность сокета к приему новых данных. Это событие привязано у нас к FEvents[2], поэтому при наступлении FEvents[2]тоже возможна отправка данных. Для приема данных здесь также используется буфер. Прямой необходимости в этом нет — можно было, как и раньше, помещать данные непосредственно в переменную, хранящую длину строки, а затем и в саму строку. Сделано это в учебных целях, чтобы показать, как можно работать с подобным буфером. Буфер имеет фиксированный размер. Сначала мы читаем из сокета в этот буфер столько, сколько сможем, а потом начинаем разбирать полученное точно так же, как и раньше, копируя данные то в целочисленную, то в строковую переменную. Когда строковая переменная полностью заполняется, строка считается принятой, для пользователя выводится ответ на нее, а в буфер для отправки добавляется ответная строка. Достоинством такого способа является то, что, с одной стороны, за время обработки одного события сервер может прочитать несколько запросов от клиента (если буфер достаточно велик), но, с другой стороны, это не приводит к зацикливанию, если сообщения поступают непрерывно. Другими словами, разработчик здесь сам определяет, какой максимальный объем данных можно получить от сокета за один раз. Иногда это бывает полезно. Теперь рассмотрим нить, обслуживающую слушающий сокет. Код этой нити приведен в листинге 2.64. Листинг 2.64. Код нити, обслуживающей слушающий сокетunit ListenThread; { Нить, следящая за подключением клиента к слушающему сокету. При обнаружении подключения она создает новую нить для работы с подключившимся клиентом, а сама продолжает обслуживать "слушающий" сокет. } interface uses SysUtils, Classes, WinSock, WinSock2_Events; type TListenThread = class(TThread) private // Сообщение, которое нужно добавить в лог. // Хранится в отдельном поле, т.к. метод, вызывающийся // через Synchronize, не может иметь параметров. FMessage: string; // Сокет, находящийся в режиме прослушивания FServerSocket: TSocket; // События нити // FEvents[0] используется для остановки нити // FEvents[1] связывается с событием FD_ACCEPT FEvents: array[0..1] of TWSAEvent; // Список нитей клиентов FClientThreads: TList; // Если True, сервер посылает клиенту сообщения // по собственной инициативе FServerMsg: Boolean; // Вспомогательный метод для вызова через Synchronize procedure DoLogMessage; protected procedure Execute; override; // Вывод сообщения в лог главной формы procedure LogMessage(const Msg: string); public constructor Create(ServerSocket: TSocket; ServerMsg: Boolean); destructor Destroy; override; // Вызывается извне для остановки сервера procedure StopServer; end; implementation uses MainServerUnit, ClientThread; { TListenThread } // "Слушающий" сокет создается в главной нити, // а сюда передается через параметр конструктора constructor TListenThread.Create(ServerSocket: TSocket; ServerMsg: Boolean); begin FServerSocket := ServerSocket; FServerMsg := ServerMsg; // Создаем события FEvents[0] := WSACreateEvent; if FEvents[0] = WSA_INVALID_EVENT then raise ESocketError.Create( 'Ошибка при создании события для сервера:' + GetErrorString); FEvents[1] := WSACreateEvent; if FEvents[1] = WSA_INVALID_EVENT then raise ESocketError.Create( 'Ошибка при создании события для сервера: ' + GetErrorString); if WSAEventSelect(FServerSocket, FEvents[1], FD_ACCEPT) = SOCKET_ERROR then raise ESocketError.Create( 'Ошибка при привязывании серверного сокета к событию: ' + GetErrorString); FClientThreads := TList.Create; inherited Create(False); end; destructor TListenThread.Destroy; begin // Убираем за собой FClientThreads.Free; WSACloseEvent(FEvents[0]); WSACloseEvent(FEvents[1]); inherited; end; procedure TListenThread.Execute; var // Сокет, созданный для общения с подключившимся клиентом ClientSocket: TSocket; // Адрес подключившегося клиента ClientAddr: TSockAddr; ClientAddrLen: Integer; NetEvents: TWSANetworkEvents; I: Integer; WaitRes: Cardinal; begin LogMessage('Сервер начал работу'); // Начинаем бесконечный цикл repeat // Ожидание события с 15-секундным тайм-аутом WaitRes := WSAWaitForMultipleEvents(2, @FEvents, False, 15000, False); case WaitRes of WSA_WAIT_EVENT_0: // Событие FEvents[0] взведено - это означает, что // сервер должен остановиться. begin LogMessage('Сервер получил сигнал завершения работы'); // Просто выходим из цикла, остальное сделает код после цикла Break; end; WSA_WAIT_EVENT_0 + 1: // Событие FEvents[1] взведено. // Это должно означать наступление события FD_ACCEPT. begin // Проверяем, почему событие взведено, // и заодно сбрасываем его if WSAEnumNetworkEvents(FServerSocket, FEvents[1], NetEvents) = SOCKET_ERROR then begin LogMessage('Ошибка при получении списка событий: ' + GetErrorString); Break; end; // Защита от "тупой" ошибки - проверка того, // что наступило нужное событие if NetEvents.lNetworkEvents and FD_ACCEPT = 0 then begin LogMessage( 'Внутренняя ошибка сервера - неизвестное событие'); Break; end; // Проверка, не было ли ошибок if NetEvents.iErrorCode[FD_ACCEPT_BIT] <> 0 then begin LogMessage('Ошибка при подключении клиента: ' + GetErrorString(NetEvents.iErrorCode[FD_ACCEPT_BIT])); Break; end; ClientAddrLen := SizeOf(ClientAddr); // Проверяем наличие подключения ClientSocket := accept(FServerSocket, @ClientAddr, @ClientAddrLen); if ClientSocket = INVALID_SOCKET then begin // Ошибка в функции accept возникает только тогда, когда // происходит нечто экстраординарное. Продолжать работу // в этом случае бессмысленно. Единственное возможное // в нашем случае исключение - ошибка WSAEWOULDBLOCK, // которая может возникнуть, если срабатывание события // было ложным, и подключение от клиента отсутствует if WSAGetLastError <> WSAEWOULDBLOCK then begin LogMessage('Ошибка при подключении клиента: ' + GetErrorString); Break; end; end; // Создаем новую нить для обслуживания подключившегося клиента // и передаем ей сокет, созданный для взаимодействия с ним. // Указатель на нить сохраняем в списке FClientThreads.Add( TClientThread.Create(ClientSocket, ClientAddr)); end; WSA_WAIT_TIMEOUT: // Ожидание завершено по тайм-ауту begin // Проверяем, есть ли клиентские нити, завершившие работу. // Если есть такие нити, удаляем их из списка // и освобождаем объекты for I := FClientThreads.Count -1 downto 0 do if TClientThread(FClientThreads[I]).Finished then begin TClientThread(FClientThreads[I]).Free; FClientThreads.Delete(I); end; // Если разрешены сообщения от сервера, отправляем // всем клиентам сообщение с текущим временем if FServerMsg then for I := 0 to FClientThreads.Count - 1 do TClientThread(FClientThreads[I]).SendString( 'Время на сервере ' + TimeToStr(Now)); end; WSA_WAIT_FAILED: // При ожидании возникла ошибка. Это может означать // только какой-то серьезный сбой в библиотеке сокетов. begin LogMessage('Ошибка при ожидании события сервера: ' + GetErrorString); Break; end; else // Неожиданный результат при ожидании begin LogMessage( 'Внутренняя ошибка сервера — неожиданный результат ожидания ' + IntToStr(WaitRes)); Break; end; end; until False; // Останавливаем и уничтожаем все нити клиентов for I := 0 to FClientThreads.Count - 1 do begin TClientThread(FClientThreads[I]).StopThread; TClientThread(FClientThreads[I]).WaitFor; TClientThread(FClientThreads[I]).Free; end; closesocket(FServerSocket); LogMessage('Сервер завершил работу'); Synchronize(ServerForm.OnStopServer); end; // Завершение работы сервера. Просто взводим соответствующее // событие, а остальное делает код в методе Execute. procedure TListenThread.StopServer; begin WSASetEvent(FEvents[0)); end; end. Нить TListenThreadреализует сразу несколько функций. Во-первых, она обслуживает подключение клиентов и создает нити для их обслуживания. Во-вторых, уничтожает объекты завершившихся нитей. В-третьих, она с определённой периодичностью ставит в очередь на отправку всем клиентам сообщение с текущим временем сервера. И в-четвертых, управляет уничтожением клиентских нитей при завершении работы сервера. Здесь следует пояснить, почему выбран такой способ управления временем жизни объектов клиентских нитей. Очевидно, что нужно иметь список всех нитей, чтобы обеспечить возможность останавливать их и ставить в очередь сообщения для отправки клиентам (этот список реализован переменной FClientThreads). Если бы объект TClientThreadавтоматически удалялся при завершении работы нити, в его деструкторе пришлось бы предусмотреть и удаление ссылки на объект из списка, а это значит, что к списку пришлось бы обращаться из разных нитей. Соответственно, потребовалось бы синхронизировать обращение к списку, и здесь мы бы столкнулись с одной неприятной проблемой. Когда нить TListenThreadполучает команду завершиться, она должна завершить все клиентские нити. Для этого она должна использовать их список для отправки сигнала и ожидания их завершения. И получилась бы взаимная блокировка, потому что нить TListenThreadждала бы завершения клиентских нитей, а они не могли бы завершиться, потому что им требовался бы список, захваченный нитью TListenThread. Избежать этого можно с помощью асинхронных сообщений, но в нашем случае реализация этого механизма затруднительна (хотя и возможна). Для простоты был выбран другой вариант: клиентские нити сами свои объекты не удаляют, а к списку имеет доступ только нить TListenThread, которая время от времени проходит по по списку и удаляет объекты всех завершившихся нитей. В этом случае клиентские нити не используют синхронизацию при завершении, и нить TListenThread может дожидаться их. Нить TListenThreadиспользует два события: FEvents[0]для получения сигнала о необходимости закрытия и FEvents[1]для получения уведомлений о возникновении события FD_ACCEPTна слушающем сокете (т.е. о подключении клиента). Порядок следования событий к массиве определяется теми же соображениями, что и в случае клиентской нити: сигнал остановки нити должен иметь более высокий приоритет. чтобы в случае DoS-атаки нить могла быть остановлена. И поиск завершившихся нитей, и отправка сообщений с текущим временем клиентам осуществляется в том случае, если при ожидании события произошёл тайм-аут (который в нашем случае равен 15 c). Подключение клиента — событие достаточно редкое, поэтому такое решение выгладит вполне оправданным. Для тех экзотических случаев, когда клиенты часто подключаются и отключаются, можно предусмотреть еще одно событие у нити TListenThread, при наступлении которого она будет проверять список клиентов. Клиентская нить при своем завершении будет взводить это событие. Что же касается отправки сообщений клиентам, то в обработчик тайм-аута этот код помещён в демонстрационных целях. В реальной программе инициировать отправку сообщений клиентам будет, скорее всего, другой код, например, код главной нити по команде пользователя. Несмотря на изменение протокола, новый сервер был бы вполне совместим со старым клиентом SimpleClient (см. разд. 2.1.11), если бы не отправлял сообщения по своей инициативе. Действительно, прочие изменения в протоколе разрешают клиенту отправлять новые сообщения до получения ответа сервера, но не обязывают его делать это. В класс TClientThreadдобавлено логическое поле FServerMsg. Если оно равно False, то сервер не посылает клиентам сообщений по собственной инициативе, т.е. работает в режиме совместимости со старым клиентом. Поле FServerMsgинициализируется в соответствии с параметром, переданным в конструктор, т.е. в соответствии с состоянием галочки Сообщения от сервера, расположенной на главной форме. Если перед запуском сервера она снята, сервер не будет сам посылать сообщения, и старый клиент сможет обмениваться данными с ним. Запуск сервера практически не отличается от запуска сервера MultithreadedServer (см. листинг 2.19), только теперь объект, созданный конструктором, запоминается классом главной формы, чтобы потом можно было сервер остановить. Остановка осуществляется методом StopServer(листинг 2.65). Листинг 2.65. Метод StopServer // Остановка сервера procedure TServerForm.StopServer; begin // Запрещаем кнопку, чтобы пользователь не мог нажать ее // еще раз, пока сервер не остановится. BtnStopServer.Enabled := False; // Ожидаем завершения слушавшей нити. Так как вывод сообщений // эта нить осуществляет через Synchronize, выполняемый главной // нитью в петле сообщений, вызов метода WaitFor мог бы привести // к взаимной блокировке: главная нить ждала бы, когда завершится // нить TListenThread, а та, в свою очередь - когда главная нить // выполнит Synchronize. Чтобы этого не происходило, организуется // ожидание с локальной петлей сообщений. if Assigned(FListenThread) then begin FListenThread.StopServer; while Assigned(FListenThread) do begin Application.ProcessMessages; Sleep(10); end; end; end; Данный метод вызывается в обработчике нажатия кнопки Остановить и при завершении приложения. Сервер можно многократно останавливать и запуска вновь, не завершая приложение. Чтобы увидеть все возможности сервера, потребуется новый клиент. На компакт-диске он называется EventSelectClient, но "EventSelect" в данном случае означает только то, что клиент является парным к серверу EventSelectServer. Сам клиент функцию WSAEventSelectне использует, поскольку она неудобна, когда нужно работать только с одним сокетом. Поэтому клиент работает в асинхронном режиме, основанном на сообщениях, т.е. посредством функции WSAAsyncSelect. Клиент может получать от сервера сообщения двух видов: те. которые сервер посылает в ответ на запросы клиента, и те, которые он посылает по собственной инициативе. Но различить эти сообщения клиент не может: например, если клиент отправляет запрос серверу, а потом получает от него сообщение, он не может определить, то ли это сервер ответил на его запрос, то ли именно в этот момент сервер сам отправил клиенту свое сообщение. Соответственно, сообщения обоих типов читает один и тот же код. Примечание Подключение клиента к серверу выполняется точно так же, как в листинге 2.16, за исключением того, что после выполнения функции connectсокет переводится в асинхронный режим, и его события FD_READи FD_CLOSEсвязываются с сообщением WM_SOCKETMESSAGE. Обработчик этого сообщения приведен в листинге 2.66. Листинг 2.66. Получение данных клиентом procedure TESClientForm.WMSocketMessage(var Msg: TWMSocketMessage); const // Размер буфера для получения данных RecvBufSize = 4096; var // Буфер для получения данных RecvBuf: array[0..RecvBufSize - 1] of Byte; RecvRes: Integer; P: Integer; begin // Защита от "тупой" ошибки if Msg.Socket <> FSocket then begin MessageDlg('Внутренняя ошибка программы — неверный сокет', mtError, [mbOK], 0); Exit; end; if Msg.SockError <> 0 then begin MessageDlg('Ошибка при взаимодействии с сервером'#13#10 + GetErrorString(Msg.SockError), mtError, [mbOK], 0); OnDisconnect; Exit; end; case Msg.SockEvent of FD_READ: // Получено сообщение от сервера begin // Читаем столько, сколько можем RecvRes := recv(FSocket, RecvBuf, RecvBufSize, 0); if RecvRes > 0 then begin // Увеличиваем строку на размер прочитанных данных P := Length(FRecvStr); SetLength(FRecvStr, P + RecvRes); // Копируем в строку полученные данные Move(RecvBuf, FRecvStr[Р + 1], RecvRes); // В строке может оказаться несколько строк от сервера, // причем последняя может прийти не целиком. // Ищем в строке символы #0, которые, согласно протоколу, // являются разделителями строк. P := Pos(#0, FRecvStr)); while P > 0 do begin AddMessageToRecvMemo('Сообщение от сервера: ' + Copy(FRecvStr, 1, P - 1)); // Удаляем из строкового буфера выведенную строку Delete(FRecvStr, 1, P); P := Pos(#0, FRecvStr); end; end else if RecvRes = 0 then begin MessageDlg('Сервер закрыл соединение'#13#10 + GetErrorString, mtError, [mbOK], 0); OnDisconnect; end else begin if WSAGetLastError <> WSAEWOULDBLOCK then begin MessageDlg('Ошибка при получении данных от клиента'#13#10 + GetErrorString, mtError, [mbOK], 0); OnDisconnect; end; end; end; FD_CLOSE: begin MessageDlg('Сервер закрыл соединение', mtError, [mbOK], 0); shutdown(FSocket, SD_BOTH); OnDisconnect; end; else begin MessageDlg('Внутренняя ошибка программы — неизвестное событие ' + IntToStr(Msg.SockEvent), mtError, [mbOK], 0); OnDisconnect; end; end; end; Здесь мы используем новый способ чтения данных. Он во многом похож на тот, который применен в сервере. Функция recvвызывается один раз за один вызов обработчика значений и передаст данные в буфер фиксированного размера RecvBuf. Затем в буфере ищутся границы отдельных строк (символы #0), строки, полученные целиком, выводятся. Если строка получена частично (а такое может случиться не только из-за того, что она передана по частям, но и из-за того, что в буфере просто не хватило место для приема ее целиком), её начало следует сохранить в отдельном буфере, чтобы добавить к тому, что будет прочитано при следующем событии FD_READ. Этот буфер реализуется полем FRecvStrтипа string. После чтения к содержимому этой строки добавляется содержимое буфера RecvBuf, а затем из строки выделяются все подстроки, заканчивающиеся на #0. То, что остается в строке FRecvStrпосле этого, — это начало строки, прочитанной частично. Оно будет учтено при обработке следующего события FD_READ. Примечание При отправке данных вероятность того, что функция send не сможет быть выполнена сразу, достаточно мала. Кроме того, как мы уже говорили, блокировка клиента при отправке данных часто бывает вполне приемлема из-за редкости и непродолжительности. Таким образом, блокирующий режим из-за своей простоты наиболее удобен при отправке данных серверу клиентом. Но мы не можем перевести сокет, работающий в асинхронном режиме, в блокирующий режим на время отправки, зато можем этот режим имитировать. Занимается этим метод SendString(листинг 2.67). Листинг 2.67. Метод SendString, имитирующий блокирующим режим отправки // Отправка строки серверу. Функция имитирует блокирующий // режим работы сокета: если за один раз не удается отправить // данные, попытка отправить их продолжается до тех пор,. // пока все данные не будут отправлены или пока не возникнет ошибка. procedure TESClientForm.SendString(const S: string); var SendRes: Integer; // Буфер, куда помещается отправляемое сообщение SendBuf: array of Byte; // Сколько байтов уже отправлено BytesSent: Integer; begin if Length(S) > 0 then begin // Отправляемое сообщение состоит из длины строки и самой строки. // Выделяем для буфера память, достаточную для хранения // и того и другого. SetLength(SendBuf, SizeOf(Integer) + Length(S)); // Копируем в буфер длину строки PInteger(@SendBuf[0])^ := Length(S); // А затем - саму строку Move(S[1], SendBuf[SizeOf(Integer)], Length(S)); BytesSent := 0; // повторяем попытку отправить до тех пор, пока все содержимое // буфера не будет отправлено серверу. while BytesSent < Length(SendBuf) do begin SendRes := send(FSocket, SendBuf[BytesSent], Length(SendBuf) - BytesSent, 0); if SendRes > 0 then Inc(BytesSent, SendRes) else if WSAGetLastError = WSAEWOULDBLOCK then Sleep(10) else begin MessageDlg('Ошибка при отправке данных серверу'#13#10 + GetErrorString, mtError, [mbOK], 0); OnDisconnect; Exit; end; end; end; end; Имитация блокирующего режима осуществляется очень просто: если сообщение не удалось отправить сразу, после небольшой паузы производится попытка отправить то, что ещё не отправлено, и так до тех пор, пока не будет отправлено все или пока не возникнет ошибка. В программе SimpleClient мы отправляли длину строки и саму строку разными вызовами send. Теперь, из-за того, что функция sendможет отправить только часть переданных ей данных, это становится неудобным из-за громоздкости многочисленных проверок. Поэтому мы создаем один буфер, куда заносим и длину строки, и саму строку, и затем передаем его как единое целое. Примечание Чтобы продемонстрировать возможности сервера по приему нескольких слившихся запросов, клиент должен отправлять ему несколько строк сразу, поэтому на главной форме клиента мы заменяем однострочное поле ввода на многострочное (т.е. TEditна TMemo). При нажатии кнопки Отправить клиент отправляет серверу все непустые строки из этого поля ввода. Других существенных отличий от SimpleClient программа EventSelectClient не имеет. Получившийся пример работает не только с сервером EventSelectServer, но и с любым сервером, написанным нами ранее. Действительно, ни один из этих серверов не требует, чтобы на момент получения запроса от клиента в буфере сокета ничего не было, кроме этого запроса. Поэтому то, что EventSelectClient может отправлять несколько сообщений сразу, не помешает им работать: просто, в отличие от EventSelectServer, они будут обрабатывать эти запросы строго по одному, а не получать из сокета сразу несколько штук. 2.2.9. Перекрытый ввод-выводПрежде чем переходить к рассмотрению перекрытого ввода-вывода, вспомним, какие модели ввода-вывода нам уже известны. Появление разных моделей связано с тем, что операции ввода-вывода не всегда могут быть выполнены немедленно. Самая простая модель ввода-вывода — блокирующая. В блокирующем режиме, если операция не может быть выполнена немедленно, работа нити приостанавливается до тех пор, пока не возникнут условия для выполнения операции. В неблокирующей модели ввода-вывода операция, которая не может быть выполнена немедленно, завершается с ошибкой. И наконец, в асинхронной модели ввода-вывода предусмотрена система уведомлений о том что операция может быть выполнена немедленно. При использовании перекрытого ввода-вывода операция, которая не может быть выполнена немедленно, формально завершается ошибкой — в этом заключается сходство перекрытого ввода-вывода и неблокирующего режима. Однако, в отличие от неблокирующего режима, при перекрытом вводе-выводе библиотека сокетов начинает выполнять операцию в фоновом режиме, после ее завершения начавшая операцию программа получает уведомление об успешно выполненной операции или о возникшей при ее выполнении фатальной ошибке. Несколько операций ввода-вывода могут одновременно выполняться в фоновом режиме, как бы перекрывая работу инициировавшей их нити и друг друга. Именно поэтому данная модель получила название модели перекрытого ввода-вывода. Перекрытый ввод-вывод существовал и в спецификации WinSock 1, но реализовывался только для линии NT. Специальных функций для перекрытого ввода-вывода в WinSock 1 не было, требовались функции ReadFileи WriteFile, в которые вместо дескриптора файла подставлялся дескриптор сокета. В WinSock 2 появилась полноценная поддержка перекрытого ввода-вывода для всех версий Windows, а в спецификацию добавились новые функции для его реализации, избавившие от необходимости использования функций файлового ввода-вывода. Здесь мы будем рассматривать перекрытый ввод-вывод только в спецификации WinSock 2, т.к. старый вариант из-за своих ограничений уже не имеет практического смысла. Существуют два варианта уведомления о завершении операции перекрытого ввода-вывода: через событие и через процедуру завершения. Кроме того, программа может не дожидаться уведомления, а проверять состояние запроса перекрытого ввода-вывода с помощью функции WSAGetOverlappedResult(ее мы рассмотрим позже). Чтобы сокет мог использоваться в операциях перекрытого ввода-вывода, при его создании должен быть установлен флаг WSA_FLAG_OVERLAPPED(функция socketнеявно устанавливает этот флаг). Для выполнения операций перекрытого ввода-вывода сокет не нужно переводить в какой-либо особый режим, достаточно обычные функции sendи recvзаменить на WSARecvи WSASend. Сначала мы рассмотрим функцию WSARecv, прототип которой приведен в листинге 2.68. Листинг 2.68. Функция WSARecv // ***** Описание на C++ ***** int WSARecv(SOCKET s, LPWSABUF lpBuffers, DWORD dwBufferCount, LPDWORD lpNumberOfBytesRecvd, LPDWORD lpFlags, LPWSAOVERLAPPED lpOverlapped, LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine); // ***** Описание на Delphi ***** function WSARecv(S: TSocket; lpBuffers: PWSABuf; dwBufferCount: DWORD; var NumberOfBytesRecvd: DWORD; var Flags: DWORD; lpOverlapped: PWSAOverlapped; lpCompletionRoutine: TWSAOverlappedCompletionRoutine): Integer; Перекрытым вводом-выводом управляют два последних параметра функции, но WSARecvобладает и другими дополнительными по сравнению с функцией recvвозможностями, не связанными с перекрытым вводом-выводом. Если оба этих параметра равны nil, или сокет создан без указания флага WSA_FLAG_OVERLAPPED, функция работает в обычном блокирующем или неблокирующем режиме, который установлен для сокета. При этом ее поведение отличается от поведения функции recvтолько тремя незначительными аспектами: во-первых, вместо одного буфера ей можно передать несколько, заполняемых последовательно. Во-вторых, флаги передаются ей не как значение, а как параметр-переменная, и при некоторых условиях функция WSARecvможет их изменять (при использовании TCP и UDP флаги никогда не меняются, поэтому мы не будем рассматривать здесь эту возможность). В-третьих, при успешном завершении функция WSARecvвозвращает ноль, а не число прочитанных байтов (последнее возвращается через параметр lpNumberOfBytesRecvd). Буферы, в которые нужно поместить данные, передаются функции WSARecvчерез параметр lpBuffers. Он содержит указатель на начало массива структур TWSABuf, а параметр dwBufferCount— число элементов в этом массиве. Ранее мы знакомились со структурой TWSABuf(см. листинг 2.39): она содержит указатель на начало буфера и его размер. Соответственно, массив таких структур определяет набор буферов. При чтении данных заполнение буферов начинается с первого буфера в массиве lpBuffers, затем, если в нем не хватает места, заполняется второй буфер и т.д. Функция не переходит к следующему буферу, пока не заполнит предыдущий до последнего байта. Таким образом, данные, получаемые с помощью функции WSARecv, могут быть помещены в несколько несвязных областей памяти, что иногда бывает удобно, если принимаемые сообщения имеют строго определенный формат с фиксированными размерами компонентов пакета: в этом случае можно каждый компонент поместить в свой независимый буфер. Теперь переходим непосредственно к рассмотрению перекрытого ввода-вывода на основе событий. Для реализации этого режима при вызове функции WSARecvпараметр lpCompletionRoutineдолжен быть равен nil, а через параметр lpOverlappedпередается указатель на запись TWSAOverlapped, которая определена следующим образом (листинг 2.69). Листинг 2 69. Тип TWSAOverlapped //***** Описание на C++ ***** struct _WSAOVERLAPPED { DWORD Internal; DWORD InternalHigh; DWORD Offset; DWORD OffsetHigh; WSAEVENT hEvent; } WSAOVERLAPPED, *LPWSAOVEPLAPPED; // ***** Описание на Delphi ***** PWSAOverlapped = ^TWSAOverlapped; TWSAOverlapped = packed record Internal, InternalHigh, Offer, OffsetHigh: DWORD; hEvent: TWSAEvent; end; Поля Internal, InternalHigh, Offsetи OffsetHighпредназначены для внутреннего использования системой, программа не должна выполнять никаких действий с ними. Поле hEventзадает событие, которое будет взведено при завершении операции перекрытого ввода-вывода. Если на момент вызова функции WSARecvданные в буфере сокета отсутствуют, она вернет значение SOCKET_ERROR, а функция WSAGetLastError— WSA_IO_PENDING(997). Это значит, что операция начала выполняться в фоновом режиме. В этом случае функция WSARecvне изменяет значения параметров NumberOfBytesRecvdи Flag. Поля структуры TWSAOverlappedпри этом также модифицируются, и эта структура должна быть сохранена программой в неприкосновенности до окончания операции перекрытого ввода-вывода. После окончания операции будет взведено событие, указанное в поле hEventпараметра lpOverlapped. При необходимости программа может дождаться этого взведения с помощью функции WSAWaitForMultipleEvents. Как только запрос будет выполнен, в буферах, переданных через параметр lpBuffers, оказываются принятые данные. Но знания одного только факта, что запрос выполнен, недостаточно, чтобы этими данными воспользоваться, потому что, во-первых, неизвестен размер этих данных, а во-вторых, неизвестно, успешно ли завершена операция перекрытого ввода-вывода. Для получения недостающей информации служит функция WSAGetOverlappedResult, прототип которой приведен в листинге 2.70. Листинг 2.70. Функция WSAGetOverlappedResult // ***** Описание на C++ ***** BOOL WSAGetOverlappedResult(SOCKET s, LPWSAOVERLAPРED lpOverlapped, LPDWORD lpcbTransfer, BOOL fWait, LPDWORD lpdwFlags); // ***** Описание на Delphi ***** function WSAGetOverlappedResult(S: TSocket; lpOverlapped: PWSAOverlapped; var cbTransfer: DWORD; fWait: BOOL; var Flags: DWORD): BOOL; Параметры Sи lpOverlappedфункции WSAGetOverlappedResultопределяют coкет и операцию перекрытого ввода-вывода, информацию о которой требуется получить. Их значения должны совпадать со значениями соответствующих параметров, переданных функции WSARecv. Через параметр cbTransferвозвращается число полученных байтов, а через параметр Flags— флаги (напомним, что в случае TCP и UDP флаги не модифицируются, и выходное значение параметра Flagsбудет равно входному значению параметра Flagsфункции WSARecv). Допускается вызов функции WSAGetOverlappedResultдо того, как операция перекрытого ввода-вывода будет завершена. В этом случае поведение функции зависит от параметра fWait. Если он равен True, функция переводит нить в состояние ожидания до тех пор, пока операция не будет завершена. Если он равен False, функция завершается немедленно с ошибкой WSA_IO_INCOMPLETE(996). Функция WSAGetOverlappedResultвозвращает True, если операция перекрытого ввода-вывода успешно завершена, и False, если произошли какие-то ошибки. Ошибка может возникнуть в одном из трех случаев: 1. Операция перекрытого ввода-вывода еще не завершена, а параметр fWaitравен False. 2. Операция перекрытого ввода-вывода завершилась с ошибкой (например, из-за разрыва связи). 3. Параметры, переданные функции WSAGetOverlappedResult, имеют некорректные значения. Точную причину, по которой функция вернула False, можно установить стандартным образом — по коду ошибки, возвращаемому функцией WSAGetLastError. В принципе, программа может вообще не использовать события для отслеживания завершения операции ввода-вывода, а вызывать вместо этого время от времени функцию WSAGetOverlappedResultв удобные для себя моменты. Тогда при вызове функции WSARecvможно указать нулевое значение события hEvent. Но следует иметь в виду, что при вызове функции WSAGetOverlappedResultс параметром fWait, равным True, указанное событие служит для ожидания завершения операции, и если событие не задано, возникнет ошибка. Таким образом, если событие не используется, функция WSAGetOverlappedResultне может вызываться в режиме ожидания. Отдельно рассмотрим ситуацию, когда на момент вызова функции WSARecvс ненулевым параметром lpOverlappedво входном буфере сокета есть данные. В этом случае функция отработает так же, как и в неперекрытом режиме, т.е. изменит значения параметров NumberOfBytesRecvdи Flagsи вернет ноль, свидетельствующий об успешном выполнении функции. Но при этом событие будет взведено, а в структуру lpOverlappedбудет внесена вся необходимая информация. Благодаря этому последующие вызовы функций WSAWaitForMultipleEventsи WSAGetOverlappedResultбудут выполняться корректно, т.е. таким образом, как если бы функция WSARecvзавершилась с ошибкой WSA_IO_PENDING, и сразу после этого в буфер сокета поступили данные. Это позволяет выполнить обработку результатов операций перекрытого ввода-вывода с помощью одного и того же кода независимо от того, были ли в буфере сокета данные на момент начала операции или нет. Новая операция перекрытого ввода-вывода может быть начата до того, как закончится предыдущая. Это удобно при работе с несколькими сокетами: можно выполнять операции с ними параллельно в фоновом режиме, получая уведомления о завершении каждой из операций. В MSDN не написано явно, что будет, если вызвать для сокета функцию WSARecvповторно, до того как будет завершена предыдущая операция перекрытого чтения (но запрета на такие действия тоже нет). Эксперименты показывают, что в этом случае операции перекрытого чтения встают в очередь, т.е. первый полученный сокетом пакет приводит к завершению операции, начатой первой, второй пакет — к завершению операции, начатой второй, и т.д. Но поскольку это явно не документировано, лучше не полагаться на то, что такой порядок будет всегда соблюдаться. В качестве примера реализации перекрытого ввода-вывода рассмотрим, ситуацию, когда программа начинает операцию чтения данных из сокета, время от времени проверяя статус операции (листинг 2.71). События в этом примере не используются, проверка осуществляется с помощью функции WSAGetOverlappedResult. Листинг 2.71. Перекрытый ввод-вывод с использованием функции WSAGetOverlappedResult var S: TSocket; Overlapped: TWSAOverlapped; BufPtr: TWSABuf; RecvBuf: array[1..100] of Char; Cnt, Flags: Cardinal; begin // Инициализация WinSock, создание сокета S, привязка его к адресу ...... // Подготовка структуры, задавшей буфер BufPtr.Buf := @RBuf; BufPtr.Len := SizeOf(RBuf); // Подготовка структуры TWSAOverlapped // Поля Internal, InternalHigh, Offset, OffsetHigh программа // не устанавливает Overlapped.hEvent := 0; Flags := 0; // Начало операции перекрытого получения данных WSARecv(S, @BufPtr, 1, Cnt, Flags, @Overlapped, nil); while True do begin if WSAGetOverlappedResult(S, @Overlapped, Cnt, False, Flags) then begin // Данные получены, находятся в RecvBuf, обрабатываем ...... // Выходим из цикла Break; end else if WSAGetLastError <> WSA_IO_INCOMPLETE then begin // Произошла ошибка, анализируем ее ...... // Выходим из цикла Break; end else begin // Операция чтения не завершена // Занимаемся другими действиями end; end; Теперь перейдем к рассмотрению перекрытого ввода-вывода на основе процедур завершения. Для этого при вызове функции WSARecvнужно задать указатель на процедуру завершения, описанную в программе. Процедура завершения должна иметь прототип, приведенный в листинге 2.72. Листинг 2.72. Прототип процедуры завершения // ***** Описание на C++ ***** void CALLBACK CompletionROUTINE(DWORD dwError, DWORD cbTransferred, LPWSAOVERLAPPED lpOverlapped, DWORD dwFlags); // ***** Описание на Delphi ***** TWSAOverlappedCompletionRoutine = procedure(dwError: DWORD; cbTransferred: DWORD; lpOverlapped: PWSAOverlapped; dwFlags: DWORD); stdcall; При использовании процедур завершения в функцию WSARecvтакже нужно передавать указатель на запись TWSAOverlappedчерез параметр lpOverlapped, но значение поля hEventэтой структуры игнорируется. Вместо взведения события при завершении операции будет вызвана процедура, указанная в качестве параметра функции WSARecv. Указатель на структуру, заданный при вызове WSARecv, передается в процедуру завершения через параметр lpOverlapped. Смысл остальных параметров очевиден: dwError— это код ошибки (или ноль, если операция завершена успешно), cbTransferred— число полученных байтов (само полученное сообщение копируется в буферы, указанные при вызове функции WSARecv), a dwFlags— флаги. Процедура завершения всегда выполняется в той нити, которая инициировала начало операции перекрытого ввода-вывода. Но система не может прерывать нить для выполнения процедуры завершения в любой удобный ей момент — нить должна перейти в состояние ожидания. В это состояние ее можно перевести, например, с помощью функции SleepEx, имеющей следующий прототип: function SleepEx(dwMilliseconds: DWORD; bAlertable: BOOL); DWORD; Функция SleepExявляется частью стандартного API системы и импортируется модулем Windows. Она переводит нить в состояние ожидания. Параметр dwMillisecondsзадает время ожидания в миллисекундах (или значение INFINITEдля бесконечного ожидания). Параметр bAlertable указывает, допустимо ли прерывание состояния ожидания для выполнения процедуры завершения. Если bAlertableравен False, функция SleepExведет себя так же как функция Sleep, т.е. просто приостанавливает работу нити на заданное время. Если bAlertableравен True, нить может быть выведена системой из состояния ожидания раньше, чем истечет заданное время, если возникнет необходимость выполнить процедуру завершения. О причине завершения ожидания программа может судить по результату, возвращаемому функцией SleepEx: ноль в случае завершения по тайм-ауту и WAIT_IO_COMPLETIONв случае завершения из-за выполнения процедуры завершения (в последнем случае сначала выполняется процедура завершения, а потом только происходит возврат из функции SleepEx). Если завершились несколько операций перекрытого ввода-вывода, в результате выполнения SleepExбудут вызваны процедуры завершения для всех этих операций. Существует также возможность ожидать выполнения процедуры завершения одновременно с ожиданием взведения событий с помощью функции WSAWaitForMultipleEvents. Напомним, что у этой функции также есть параметр fAlertable. Если задать его равным True, то при необходимости выполнения процедуры завершения функция WSAWaitForMultipleEvents, подобно функции SleepEx, выполняет эту процедуру и возвращает WAIT_IO_COMPLETION. Если программа выполняет одновременно несколько операций перекрытого ввода-вывода, возникает вопрос, как при вызове процедуры завершения определить, какая из них завершилась. Для каждой такой операции должен быть создан уникальный экземпляр записи TWSAOverlapped. Процедура завершения получает указатель на тот экземпляр, который использовался для начала завершившейся операции. Можно сравнил, указатель с теми, которые были заданы при запуске операций перекрытого ввода-вывода, и определить, какая из них завершилась. Это не всегда бывает удобно из-за необходимости где-то хранить список указателей, заданных при начале операций перекрытого ввода-вывода. Существуют еще два варианта решения этой проблемы. Первый заключается в создании своей процедуры завершения для каждой из выполняющихся параллельно операций. Этот способ приводит к получению громоздкого кода и может быть неудобен, если число одновременно выполняющихся операций заранее неизвестно. Он целесообразен только при одновременном выполнении разнородных операций, требующих разных алгоритмов при обработке их завершения. Другой вариант предлагается в MSDN. Так как при работе через процедуры завершения значение поля hEventструктуры TWSAOverlappedигнорируется системой, программа может записать туда любое 32-битное значение и с его помощью определить, какая из операций завершена. В строго типизированном языке, каким является Delphi, подобное смещение типа дескриптора и целого выглядит весьма непривлекательно, но, к сожалению, это лучшее из того, что нам предлагают разработчики WinSock API. Механизм процедур завершения допускает определение статуса операции с с помощью функции WSAGetOverlappedResult, но ее параметр fWaitобязательно должен быть равен False, потому что события, необходимые для выполнения ожидания, не взводятся, и попытка дождаться окончания операции может привести к блокировке работы нити. В процедуре завершения допускается вызывать функции, начинающие новую операцию перекрытого ввода-вывода, в том числе и такую же операцию, которая только что завершена. Эта возможность используется в примере, приведенном в листинге 2.73. Пример иллюстрирует работу клиента, который подключается к серверу и получает от него данные в режиме перекрытого ввода-вывода, выполняя параллельно какие-то другие действия. Листинг 2.73. Перекрытый ввод-вывод с использованием процедуры завершенияvar S: TSocket; Overlapped: TWSAOverlapped; BufPtr: TWSABuf; RecvBuf: array[1..100] of Char; Cnt, Flags: Cardinal; Connected: Boolean; procedure GetData(Err, Cnt:DWORD; OvPtr: PWSAOverlapped; Flags: DWORD): stdcall; begin if Err <> 0 then begin // Произошла ошибка. Соединение нужно устанавливать заново closesocket(S); Connected := False; end; else begin // Получены данные, обрабатываем ...... // Запускаем новую операцию перекрытого чтения Flags := 0; WSARecv(S, @BufPtr, 1, Cnt, Flags, OvPtr, GetData); end; end; procedure ProcessConnection; begin // Устанавливаем начальное состояние - сокет не соединен Connected := False; // Задаем буфер BufPtr.Buf := @RecvBuf; BufPtr.Len := SizeOf(RecvBuf); while True do begin if not Connected then begin Connected := True; // Создаем и подключаем сокет S := socket(AF_INET, SOCK_STREAM, 0); connect(S, ...); // Запускаем первую для данного сокета операцию чтения Flags := 0; WSARecv(S, @BufPtr, 1, Cnt, Flags, @Overlapped, GetData); end; // Позволяем системе выполнить процедуру завершения, // если это необходимо SleepEx(0, True); // Выполняем какие-либо дополнительные действия ...... end; end; Основная процедура здесь — ProcessConnection. Эта процедура в бесконечном цикле устанавливает соединение, если оно не установлено, дает системе выполнить процедуру завершения, если это требуется, и выполняет какие-либо иные действия, не связанные с получением данных от сокета. Процедура завершения GetDataполучает и обрабатывает данные, а если произошла ошибка, закрывает сокет и сбрасывает флаг Connected, что служит для процедуры ProcessConnectionсигналом о необходимости установить соединение заново. Из этого примера хорошо видны достоинства и недостатки процедур заверения. Получение и обработка данных выносится в отдельную процедуру, и с одной стороны, позволяет разгрузить основную процедуру, но, с другой стороны, заставляет прибегнуть к глобальным переменным для буфера и сокета. Для протоколов, не поддерживающих соединение, существует другая функция для перекрытого получения данных — WSARecvFrom. Из названия очевидно, что она позволяет узнать адрес отправителя. Прототип функции WSARecvFromприведен в листинге 2.74. Листинг 2.74. Функция WSARecvFrom // ***** Описание на C++ ***** int WSARecvFrom(SOCKET s, LPWSABUF lpBuffers, DWORD dwBufferCount, LPDWORD lpNumberOfBytesRecvd, LPDWORD lpFlags, struct sockaddr FAR *lpFrom, LPINT lpFromlen, LPWSAOVERLAPPED lpOverlapped, LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine; // ***** Описание на Delphi ***** function WSARecvFrom(S: TSocket; lpBuffers: PWSABuf; dwBufferCount: DWORD; var NumberOfBytesRecvd: DWORD; var Flags: DWORD; lpFrom: PSockAddr; lpFromLen: PInteger; lpOverlapped: FWSAOverlapped; lpCompletionRoutine: TWSAOverlappedCompletionRoutine): Integer; Параметры lpFromи lpFromLenэтой функции, служащие для получения адреса отправителя, эквивалентны соответствующим параметрам функции recvfrom, с которой мы уже хорошо знакомы. В остальном WSARecvFromведет себя так же, как WSARecv, поэтому мы не будем останавливаться на ней. Для отправки данных в режиме перекрытого ввода-вывода существуют функции WSASendи WSASendTo, имеющие следующие прототипы (листинг 2.75). Листинг 2.75. Функции WSASendи WSASendTo // ***** Описание на C++ ***** int WSASend(SOCKET s, LPWSABUF lpBuffers, DWORD dwBufferCount, LPDWORD lpNumberOfBytesSent, DWORD dwFlags, LPWSAOVERLAPPED lpOverlapped, LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine); int WSASendTo(SOCKET s, LPWSABUF lpBuffers, DWORD dwBufferCount, LPDWORD lpNumberOfBytesSent, DWORD dwFlags, const struct sockaddr FAR *lpTo, int iToLen, LPWSAOVERLAPPED lpOverlapped, LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine); // ***** Описание на Delphi ***** function WSASend(S: TSocket; lpBuffers: PWSABuf; dwBufferCount: DWORD; var NumberOfBytesRecvd: DWORD; Flags: DWORD; lpOverlapped: PWSAOverlapped; lpCompletionRoutine: TWSAOverlappedCompletionRoutine): Integer; function WSASendTo(S: TSocket; lpBuffers: PWSABuf; dwBufferCount: DWORD; var NumberOfBytesRecvd: DWORD; Flags: DWORD; var AddrTo: TSockAddr; ToLen: Integer; lpOverlapped: PWSAOverlapped; lpCompletionRoutine: TWSAOverlappedCompletionRoutine): Integer; Если вы разобрались с функциями WSARecv, sendи sendto, то смысл параметров функций WSASendи WSASendToдолжен быть вам очевиден, поэтому подробно разбирать мы их не будем. Но отметим, что флаги передаются по значению, и функции не могут изменять их. Потребность в перекрытом вводе-выводе при отправке данных возникает достаточно редко. Но функции WSASend/WSASendToмогут оказаться удобными при подготовке многокомпонентных пакетов, которые, например, имеют фиксированный заголовок и финальную часть. Для таких пакетов можно один раз подготовить буферы с заголовком и с финальной частью и, пользуясь возможностью отправки данных из несвязных буферов, при отправке каждого пакета менять только его среднюю часть. 2.2.10. Сервер, использующий перекрытый ввод-выводВ этом разделе мы рассмотрим создание сервера на основе перекрытого ввода-вывода на основе процедур завершения (пример кода с использованием событий есть в MSDN в описании функций WSARecv— и WSASend). Перекрытый ввод-вывод лучше подходит для обмена в режиме "запрос-ответ", поэтому мы вновь вернемся к первоначальному протоколу, который не предусматривает отправку сервером сообщений по собственному усмотрению. На компакт-диске этот пример называется OverlappedServеr. Как обычно, для каждого соединения создается экземпляр записи TConnection, которая на этот раз выглядит так, как показано в листинге 2.76. Листинг 2.76. Тип TConnection // Информация о соединении с клиентом: // ClientSocket - сокет, созданный для взаимодействия с клиентом // ClientAddr - строковое представление адреса клиента // MsgSite - длина строки, получаемая от клиента // Msg - строка, получаемая от клиента или отправляемая ему // Offset - количество байтов, уже полученных от клиента // или отправляемых ему на данном этапе // BytesLeft - сколько байтов осталось получить от клиента // или отправить ему на данном этапе // Overlapped - структура для выполнения перекрытой операции PConnection = ^TConnection; TConnection = record ClientSocket: TSocket; ClientAddr: string; MsgSize: Integer; Msg: string; Offset: Integer; BytesLeft: Integer; Overlapped: TWSAOverlapped; end; Основное отличие этого варианта типа TConnectionот того, что применялся ранее в примерах NonBlockingServerи AsyncSelectServer(см. разд. 2.1.16 и 2.2.6, а также листинг 2.31) — это отсутствие поля Phase, которое хранит этап взаимодействия с клиентом. Разумеется, в программе OverlappedServerвзаимодействие с клиентом также разбивается на три этапа, но реализуется другой способ для того, чтобы различать этапы — для каждого этапа создается своя процедура завершения. Примечание Поле Overlappedсодержит структуру TWSAOverlapped, которой программа непосредственно не пользуется, она только передает указатель на эту структуру в функции WSARecvи WSASend. Напомним, что одновременно может выполняться несколько операций перекрытого ввода-вывода, но у каждой из этих операций должен быть свой экземпляр TWSAOverlapped. Гак как в нашем случае с одним клиентом в каждый момент времени может выполняться не более одной операции, мы создаем по одному экземпляру TWSAOverlappedна каждого клиента. Функция для перекрытого подключения клиентов существует — это AcceptEx, с которой мы познакомимся в разд. 2.2.12. Но она неудобна при работе совместно с WSARecvи WSASend, особенно в таком строго типизированном языке, как Delphi. Поэтому подключение клиентов мы будем отслеживать с помощью уже опробованной технологии асинхронных сокетов на сообщениях. Код запуска сервера OverlappedServer выглядит идентично коду запуска AsyncSelectServer (см. листинг 2.30): точно так же создается сокет, ставится в режим прослушивания, а затем его событие FD_ACCEPTпривязывается к сообщению WM_ACCEPTMESSAGE. Сам обработчик WM_ACCEPTMESSAGEвыглядит теперь следующим образом (листинг 2.77). Листинг 2.77. Обработчик сообщения WM_ ACCEPTMESSAGE procedure TServerForm.WMAcceptMessage(var Msg: TWMSocketMessage); var NewConnection: PConnection; // Сокет, который создается для вновь подключившегося клиента ClientSocket: TSocket; // Адрес подключившегося клиента ClientAddr: TSockAddr; // Длина адреса AddrLen: Integer; // Аргумент для перевода сокета в неблокирующий режим Arg: u_long; // Буфер для операции перекрытого чтения Buf: TWSABuf; NumBytes, Flags: DWORD; begin // Страхуемся от "тупой" ошибки if Msg.Socket <> FServerSocket then raise ESocketError.Create( 'Внутренняя ошибка сервера - неверный серверный сокет'); // Обрабатываем ошибку на сокете, если она есть if Msg.SockError <> 0 then begin MessageDlg('Ошибка при подключении клиента:'#13#10 + GetErrorString(Msg.SockError) + #13#10'Сервер будет ocтановлен', mtError, [mbOK], 0); ClearConnections; closesocket(FServerSocket); OnStopServer; Exit; end; // Страхуемся от ещё одной "тупой" ошибки if Msg.SockEvent <> FD_ACCEPT then raise ESocketError.Create( 'Внутренняя ошибка сервера - неверное событие на сокете'); AddrLen := SizeOf(TSockAddr); ClientSocket := accept(FServerSocket, @ClientAddr, @AddrLen); if ClientSocket = INVALID_SOCKET then begin // Если произошедшая ошибка - WSAEWOULDBLOCK, это просто означает // что на данный момент подключений нет, а вообще все а порядке, // поэтому ошибку WSAEWOULDBLOCK мы просто игнорируем. Прочие же // ошибки могут произойти только в случае серьезных проблем, // которые требуют остановки сервера. if WSAGetLastError <> WSAEWOULDBLOCK then begin MessageDlg('Ошибка при подключении клиента:'#13#10 + GetErrorString + #13#10'Сервер будет остановлен', mtError, [mbOK], 0); ClearConnections; closesocket(FServerSocket); OnStopServer; end; end else begin // Новый сокет наследует свойства слушающего сокета. // В частности, он работает в асинхронном режиме, // и его событие FD_ACCEPT связано с сообщением WM_ACCEPTMESSAGE. // Так как нам это совершенно не нужно, отменяем асинхронный // режим и делаем сокет блокирующим. if WSAAsyncSelect(ClientSocket, Handle, 0, 0) = SOCKET_ERROR then begin MessageDlg('Ошибка при отмене асинхронного режима ' + 'подключившегося сокета:'#13#10 + GetErrorString, mtError, [mbOK], 0); closesocket(ClientSocket); Exit; end; Arg := 0; if ioctlsocket(ClientSocket, FIONBIO, Arg) = SOCKET_ERROR then begin MessageDlg('Ошибка при переводе подключившегося сокета ' + 'в блокирующий режим:'#13#10 + GetErrorString, mtError, [mbOK], 0); closesocket(ClientSocket); Exit; end; // Создаем запись для нового подключения и заполняем ее New(NewConnection); NewConnection.ClientSocket := ClientSocket; NewConnection.ClientAddr := Format('%u.%u.%u.%u:%u, [ Ord(ClientAddr.sin_addr.S_un_b.s_b1), Ord(ClientAddr.sin_addr.S_un_b.s_b2), Ord(ClientAddr.sin_addr.S_un_b.s_b3), Ord(ClientAddr.sin_addr.S_un_b.s_b4), ntohs(ClientAddr.sin_port)]); NewConnection.Offset := 0; NewConnection.BytesLeft := SizeOf(Integer); NewConnection.Overlapped.hEvent := 0; // Добавляем запись нового соединения в список FConnections.Add(NewConnection); AddMessageToLog('Зафиксировано подключение с адреса ' + NewConnection.ClientAddr); // Начинаем перекрытый обмен с сокетом. // Начинаем, естественно, с чтения длины строки, // в качестве принимающего буфера используем NewConnection.MsgSize Buf.Len := NewConnection.BytesLeft; Buf.Buf := @NewConnection.MsgSize; Flags := 0; if WSARecv(NewConnection.ClientSocket, @Buf, 1, NumBytes, Flags, @NewConnection.Overlapped, ReadLenCompleted) = SOCKET_ERROR then begin if WSAGetLastError <> WSA_IO_PENDING then begin AddMessageToLog('Клиент ' + NewConnection.ClientAddr + ' - ошибка при чтении длины строки: ' + GetErrorString); RemoveConnection(NewConnection); end; end; end; end; После того как сокет для взаимодействия с подключившимся клиентом создан, следует отменить для него асинхронный режим, унаследованный от слушающего сокета, т.к. при перекрытом вводе-выводе этот режим не нужен. Затем, после создания экземпляра TConnectionи добавления его в список, запускается первая операция перекрытого чтения с помощью функции WSARecv. Об окончании этой операции будет сигнализировать вызов функции ReadLenCompleted, которая передана в WSARecvв качестве параметра. Как мы уже говорили ранее, в программе OverlappedServerесть три разных функции завершения: ReadLenCompleted, ReadMsgCompletedи SendMsgCompleted. Последовательность работы с ними такая: сначала для чтения длины строки вызывается WSARecv, в качестве буфера передастся Connection.MsgSize, в качестве функции завершения — ReadLenCompleted(это мы уже видели в листинге 2.77). Когда вызывается ReadLenCompleted, это значит, что операция чтения уже завершена и прочитанная длина находится в Connection.MsgSize. Поэтому в функции ReadLenCompletedвыделяем нужный размер для строки Connection.Msgи запускаем следующую операцию перекрытого чтения — с буфером Connection.Msgи функцией завершения ReadMsgCompleted. В этой функции полученная строка показывается пользователю, формируется ответ, и запускается следующая операция перекрытого ввода-вывода — отправка строки клиенту. В качестве буфера в функцию WSASendпередаётся Connection.Msg, а в качестве функции завершения — SendMsgCompleted. В функции SendMsgCompletedвновь вызывается WSARecvс буфером Connection.MsgSizeи функцией завершения ReadLenCompleted, и таким образом сервер возвращается к первому этапу взаимодействия с клиентом. Описанную простую последовательность действий портит то, что из-за возможной отправки данных по частям можно столкнуться с ситуацией, когда функция завершения вызвана для уведомления о том, что получена или отправлена часть данных. Чтобы получить остальную их часть, необходимо вновь вызвать функцию чтения или записи с той же функцией завершения, а указатель на буфер должен при этом указывать на оставшуюся незаполненной часть переменной, в которую помещаются данные. С учетом этого, а также необходимости обработки ошибок, функции завершения выглядят так, как показано в листинге 2.78. Листинг 2.78. Функции завершения// Функция ReadLenCompleted используется в качестве функции завершения // для перекрытого чтения длины строки procedure ReadLenCompleted(dwError: DWORD; cdTransferred: DWORD; lpOverlapped: PWSAOverlapped; dwFlags: DWORD); stdcall; var // Указатель на соединение Connection: PConnection; // Указатель на буфер Buf: TWSABuf; // Параметры для WSARecv NumBytes, Flags: DWORD; begin // Для идентификации операции в функцию передается указатель // на запись TWSAOverlapped. Ищем по этому указателю // подходящее соединение в списке FConnections. Connection := ServerForm.GetConnectionByOverlapped(lpOverlapped); if Connection = nil then begin ServerForm.AddMessageToLog( 'Внутренняя ошибка программы - не найдено соединение'); Exit; end; // Проверяем, что не было ошибки if dwError <> 0 then begin ServerForm.AddMessageToLog('Клиент ' + Connection.ClientAddr + ' - ошибка при чтении длины строки: ' + GetErrorString(dwError)); ServerForm.RemoveConnection(Connection); Exit; end; // Уменьшаем число оставшихся к чтению байтов // на размер полученных данных Dec(Connection.BytesLeft, cdTransferred); if Connection.BytesLeft < 0 then // Страховка от "тупой" ошибки ServerForm.AddMessageToLog('Клиент ' + Connection.ClientAddr + ' - внутренняя ошибка программы: получено больше байтов, ' + 'чем ожидалось'); ServerForm.RemoveConnection(Connection); end else if Connection.BytesLeft = 0 then begin // Длина строки прочитана целиком if Connection.MsgSize <= 0 then begin ServerForm.AddMessageToLog('Клиент ' + Connection.ClientAddr + ' — получена неверная длина строки ' + IntToStr(Conneсtion.MsgSizе)); ServerForm.RemoveConnection(Connection); Exit; end; // Делаем строку нужной длины SetLength(Connection.Msg, Connection.MsgSize); // Данные пока не прочитаны, поэтому смещение - ноль, // осталось прочитать полную длину. Connection.Offset := 0; Connection.BytesLeft := Connection.MsgSize; // Заносим размер буфера и указатель на него в Buf. // Данные будут складываться в строку, // на которую ссылается Connection.Msg. Buf.Len := Connection.MsgSize; Buf.Buf := Pointer(Connection.Msg); // Вызываем WSARecv для чтения самой строки Flags := 0; if WSARecv(Connect ion.ClientSocket, @Buf, 1, NumBytes, Flags, @Connection.Overlapped, ReadMsgCompleted) = SOCKET_ERROR then begin if WSAGetLastError <> WSA_IO_PENDING then begin ServerForm.AddMessageToLog('Клиент ' + Connection.ClientAddr + ' - ошибка при чтении строки: ' + GetErrorString(dwError)); ServerForm.RemoveConnection(Connection); end; end; end else begin // Connection.BytesLeft < 0 - длина строки // прочитана не до конца. // Увеличиваем смещение на число прочитанных байтов Inc(Connection.Offset, cdTransferred); // Формируем буфер для чтения оставшейся части длины Buf.Len := Connection.BytesLeft; Buf.Buf := PChar(@Connection.MsgSize) + Connection.Offset; // вызываем WSARecv для чтения оставшейся части длины строки Flags := 0; if WSARecv(Connection.ClientSocket, @Buf, 1, NumBytes, Flags, @Connection.Overlapped, ReadMsgCompleted) = SOCKET_ERROR then begin if WSAGetLastError <> WSA_IO_PENDING then begin ServerForm.AddMessageToLog('Клиент ' + Connection.ClientAddr + ' - ошибка при чтении длины строки: ' + GetErrorString(dwError)); ServerForm.RemoveConnection(Connection); end; end; end; end; // Функция ReadMsgCompleted используется в качестве функции завершения // для перекрытого чтения строки. // Она во многом аналогична функции ReadLenCompleted procedure ReadMsgCompleted(dwError: DWORD; cdTransferred: DWORD; lpOverlapped: PWSAOverlapped; dwFlags: DWORD); stdcall; var Connection: PConnection; Buf: TWSABuf; NumBytes, Flags: DWORD; begin Connection := ServerForm.GetConnectionByOverlapped(lpOverlapped); if Connection = nil then begin ServerForm.AddMessageToLog( 'Внутренняя ошибка программы - не найдено соединение'); Exit; end; if dwError <> 0 then begin ServerForm.AddMessageToLog('Клиент ' + Connection.ClientAddr + ' ошибка при чтении строки: ' + GetErrorString(dwError)); ServerForm.RemoveConnection(Connection); Exit; end; Dec(Connection.BytesLeft, cdTransferred); if Connection.BytesLeft < 0 then begin ServerForm.AddMessageToLog('Клиент ' + Connection.ClientAddr + ' - внутренняя ошибка программы: получено больше байтов, ' + 'чем ожидалось'); ServerForm.RemoveConnection(Connection); end else if Connection.BytesLeft = 0 then begin // Строка получена целиком. Выводим ее на экран. ServerForm.AddMessageToLog('От клиента ' + Connection.ClientAddr + ' получена строка: ' + Connection.Msg); // Формируем ответ Connection.Msg := AnsiUpperCase(StringReplace(Connection.Msg, #0, '#0', [rfReplaceAll])) + ' (Overlapped server)'#0; // Смещение - ноль, осталось отправить полную длину Connection.Offset := 0; Connection.BytesLeft := Length(Connection.Msg); // Формируем буфер из строки Connection.Msg Buf.Len := Connection.BytesLeft; Buf.Buf := Point(Connection.Msg); // Отправляем строку if WSASend(Connection.ClientSocket, @Buf, 1, NumBytes, 0, @Connection.Overlapped, SendMsgCompleted) = SOCKET_ERROR then begin it WSAGetLastError <> WSA_IO_PENDING then begin ServerForm.AddMessageToLog('Клиент ' + Connection.ClientAddr + ' - ошибка при отправке строки: ' + GetErrorString); ServerForm.RemoveConnection(Connection); end; end; end else begin // Connection.BytesLeft < 0 - строка прочитана частично Inc(Connection.Offset, cdTransferred); // Формируем буфер из непрочитанного остатка строки Buf.Len := Connection.BytesLeft; Buf.Buf := PChar(Connection.Msg) + Connection.Offset; // Читаем остаток строки Flags := 0; if WSARecv(Connection.ClientSocket, @Buf, 1, NumBytes, Flags, @Connection.Overlapped, ReadMsgCompleted) = SOCKET_ERROR then begin if WSAGetLastError <> WSA_IO_PENDING then begin ServerForm.AddMessageToLog('Клиент ' + Connection.ClientAddr + ' - ошибка при чтении строки: ' + GetErrorString); ServerForm.RemoveConnection(Connection); end; end; end; end; // Функция SendMsgCompleted используется в качестве функции завершения // для перекрытой отправки строки. // Во многом она аналогична функции ReadLenCompleted procedure SendMsgCompleted(dwError: DWORD; cdTransferred: DWORD; lpOverlapped: PWSAOverlapped; dwFlags: DWORD); stdcall; var Connection: PConnection; Buf: TWSABuf; NumBytes, Flags: DWORD; begin Connection := ServerForm.GetConnectionByOverlapped(lpOverlapped); if Connection = nil then begin ServerForm.AddMessageToLog( 'Внутренняя ошибка программы - не найдено соединение'); Exit; end; if dwError <> 0 then begin ServerForm.AddMessageToLog('Клиент ' + Connection.ClientAddr + ' - ошибка при отправке строки: ' + GetErrorString(dwError)); ServerForm.RemoveConnection(Connection); Exit; end; Dec(Connection.BytesLeft, cdTransferred); if Connection.BytesLeft < 0 then begin ServerForm.AddMessageToLog('Клиент ' + Connection.ClientAddr + ' — внутренняя ошибка программы: отправлено больше байтов, ' + 'чем ожидалось'); ServerForm.RemoveConnection(Connection); end else if Connection.BytesLeft = 0 then begin // Строка отправлена целиком. Выводим сообщение об этом. ServerForm.AddMessageToLog('Клиенту ' + Connection.ClientAddr + ' отправлена строка: ' + Connection.Msg); // Очищаем строку, чтобы зря не занимала память Connection.Msg := ''; // Теперь будем снова читать длину строки Connection.Offset := 0; Connection.BytesLeft := SizeOf(Integer); // Читать будем в Connection.MsgSize Buf.Len := Connection.BytesLeft; Buf.Buf := @Connection.MsgSize; Flags := 0; if WSARecv(Connection.ClientSocket, @Buf, 1, NumBytes, Flags, @Connection.Overlapped, ReadLenCompleted) = SOCKET_ERROR then begin if WSAGetLastError <> WSA_IO_PENDING then begin ServerForm.AddMessageToLog('Клиент ' + Connection.ClientAddr + ' - ошибка при чтении длины строки: ' + GetErrorString); ServerForm.RemoveConnection(Connection); end; end; end else begin // Строка отправлена не полностью Inc(Connection.Offset, cdTransferred); // Формируем буфер из остатка строки Buf.Len := Connection.BytesLeft; Buf.Buf := PChar(Connection.Msg) + Connection.Offset; if WSASend(Connection.ClientSocket, @Buf, 1, NumBytes, 0, @Connection.Overlapped, SendMsgCompleted) = SOCKET_ERROR then begin if WSAGetLastError <> WSA_IO_PENDING then begin ServerForm.AddMessageToLog('Клиент ' + Connection.СlientAddr + ' - ошибка при отправке строки: ' + GetErrorString); ServerForm.RemoveConnection(Connection); end; end; end; end; Чтобы это все заработало, остался последний штрих: нить нужно время от времени переводить в состояние ожидания. Мы будем это делать, вызывая SleepExс нулевым тайм-аутом по сигналам от таймера. В получившемся сервере трудно увидеть все преимущества перекрытого ввода-вывода. Это и неудивительно, потому что его главное достоинство — высокая производительность при большом количестве подключений. Перекрытый ввод-вывод ориентирован на создание серверов, интенсивно взаимодействующих с многими клиентами, а на таком маленьком сервере, как OverlappedServer, он выглядит несколько тяжеловесно, хотя и позволяет получить вполне работоспособный вариант. 2.2.11. Многоадресная рассылкаПри описании стека протоколов TCP/IP мы упоминали протокол IGMP - дополнение к протоколу IP, позволяющее назначать нескольким узлам групповые адреса. С помощью этого протокола можно группе сокетов назначить один IP-адрес, и тогда все пакеты, отправленные на этот адрес, будут получать все сокеты, входящие в группу. Заметим, что не следует путать группы сокетов в терминах IGMP, и группы сокетов в терминах WinSock (поддержка групп сокетов в WinSock пока отсутствует, существуют только зарезервированные для этого параметры в некоторых функциях). Мы уже говорили, что сетевая карта получает все IP-пакеты, которые проходят через ее подсеть, но выбирает из них только те, которые соответствуют назначенному ей MAC- и IP-адресу. Существуют два режима работы сетевых карт. В первом выборка пакетов осуществляется аппаратными средствами карты, во втором — программными средствами драйвера. Аппаратная выборка осуществляется быстрее и не загружает центральный процессор, но ее возможности ограничены. В частности, у некоторых старых карт отсутствует аппаратная поддержка IGMP, поэтому они не могут получать пакеты, отправленные на групповой адрес, без переключения в режим программной выборки. Более современные сетевые карты способны запоминать несколько (обычно 16 или 32) групповых адресов, и, пока количество групповых адресов не превышает этот предел, могут осуществлять аппаратную выборку пакетов с учетом групповых адресов. Windows 95 и NT 4 используют сетевые карты в режиме программной выборки пакетов. Windows 98 и 2000 и выше по умолчанию устанавливают сетевые карты в режим аппаратной выборки пакетов. При этом Windows 2000 может переключать карту в режим программной выборки, если число групповых адресов, с которых компьютер должен принимать пакеты, превышает ее аппаратные возможности. Windows 98 такой возможностью не обладает, поэтому программа, выполняемая в этой среде, может столкнуться с ситуацией, когда сокет не сможет присоединиться к групповому адресу из-за нехватки аппаратных ресурсов сетевой карты (программа при этом получит ошибку WSAENOBUFS). WinSock предоставляет достаточно широкие возможности по управлению многоадресной рассылкой, но для их использования необходимо, чтобы выбранный сетевой протокол поддерживал все эти возможности. Поддержка многоадресной рассылки протоколом IP достаточно скудна по сравнению, например, с протоколами, применяющимися в сетях ATM. Здесь мы будем рассматривать только те возможности WinSock по поддержке многоадресной рассылки, которые совместимы с протоколом IP. Протокол TCP не поддерживает многоадресную рассылку, поэтому все, что далее будет сказано, относится только к протоколу UDP. Отметим также, что при многоадресной рассылке через границы подсетей маршрутизаторы должны поддерживать передачу многоадресных пакетов. Глава "Многоадресная рассылка" в [3], к сожалению, содержит множество неточностей. Далее мы будем обращать внимание на эти неточности, чтобы облегчить чтение этой книги. Многоадресная рассылка в IP является одноранговой и в плоскости управления, и в плоскости данных (в [3] вместо "одноранговая" употребляется слово "немаршрутизируемая" — видимо, переводчик просто перепутал слова non-rooted и non-routed). Это значит, что все сокеты, участвующие в ней, paвноправны. Каждый сокет без каких-либо ограничений может подключиться к многоадресной группе и получать все сообщения, отправленные на групповой адрес. При этом послать сообщение на групповой адрес может любой сокет, в том числе и не входящий в группу. Для групповых адресов протокол IP задействует диапазон от 224.0.0.0 до 239.255.255.255. Часть из этих адресов зарезервирована для стандартных служб, поэтому своим группам лучше назначать адреса, начиная с 225.0.0.0. Кроме того, весь диапазон от 224.0.0.0 до 224.0.0.255 зарезервирован для групповых сообщений, управляющих маршрутизаторами, поэтому сообщения, отправленные на эти адреса, никогда не передаются в соседние подсети. Есть два варианта осуществления многоадресной рассылки с использованием IP средствами WinSock. Первый реализуется средствами WinSock 1 и жестко привязан к протоколу IP. Второй вариант подразумевает работу с WinSock 2 и осуществляется универсальными, не привязанными к конкретному протоколу средствами. Если рассылка будет осуществляться средствами WinSock 1, то сокет, участвующий в ней, создается обычным образом — с помощью функции WSASocketсо стандартным набором флагов или с помощью функции socketс обычными параметрами, задаваемыми при создании UDP-сокета. Если же используется WinSock 2, то сокет должен быть создан с указанием его роли в плоскостях управления и данных. Так как многоадресная рассылка в IP является одноранговой, все сокеты, участвующие в ней, могут быть только "листьями", поэтому сокет для рассылки должен создаваться функцией WSASocketс указанием флагов WSA_FLAG_MULTIPONT_C_LEAF(4) и WSA_FLAG_MULTIPOINT_D_LEAF(16). В [3] на странице 313 написано, что для рассылки средствами WinSock 2 можно создавать сокет функцией socket— это неверно. Впрочем, на странице 328 все-таки сказано, что указанные флаги задавать обязательно. Далее сокет, который планируется добавить в группу, привязывается к любому локальному порту обычным способом — с помощью функции bind. Этот шаг ничем не отличается от привязки к адресу обычного сокета, не использующего групповой адрес. Затем выполняется собственно добавление сокета в группу. В WinSock 12 для этого потребуется функция setsockoptс параметром IP_ADD_MEMBERSHIP, в качестве уровня следует указать IPPROTO_IP. При этом через параметр optvalпередается указатель на запись ip_mreq, описанную так, как показано в листинге 2.79. Листинг 2.79. Тип TIPMreq // ***** Описание на C++ ***** struct ip_mreq { struct in_addr imr_multiaddr; struct in_addr imr_interface; } // ***** Описание на Delphi ***** TIPMreq = packed record IMR_MultiAddr: TSockAddr; IMR_Interface: TSockAddr end; Поле IMR_MultiAddrзадает групповой адрес, к которому присоединяется сокет. У этой структуры должны быть заполнены поля sin_family(значением AF_INET) и sin_addr. Номер порта здесь указывать не нужно, значение этого поля игнорируется. Поле IMR_Interfaceопределяет адрес сетевого интерфейса, через который будет вестись прием многоадресной рассылки. Если программу устраивает интерфейс, выбираемый системой по умолчанию, значение поля IMR_Interface.sin_addrдолжно быть INADDR_ANY(на компьютерах с одним сетевым интерфейсом обычно используется именно это значение). Но если у компьютера несколько сетевых интерфейсов, которые связывают его с разными сетями, интерфейс для получения групповых пакетов, выбираемый системой по умолчанию, может быть связан не с той сетью, из которой они реально ожидаются. В этом случае программа может явно указать IP-адрес того интерфейса, через который данный сокет должен принимать групповые пакеты. Как и в поле IMR_MultiAddr, в поле IMR_Interfaceзадействованы только поля sin_familуи sin_addr, а остальные поля игнорируются. Для прекращения членства сокета в группе служит та же функция setsockopt, но с параметром IP_DROP_MEMBERSHIP. Через параметр optvalпри этом также передается структура ip_mreq, значимые поля которой должны быть заполнены так же, как и при добавлении данного сокета в данную группу. Несмотря на то, что структура ip_mreqотносится к WinSock 1, в модуле WinSock ее описание отсутствует. Константы IP_ADD_MEMBERSHIPи IP_DROP_MEMBERSHIPв этом модуле объявлены, но работать с ними следует с осторожностью, потому что они должны иметь разные значения в WinSock 1 и WinSock 2. В WinSock 1 они должны иметь значения 5 и 6 соответственно, а в WinSock 2 — 12 и 13. Из-за этого нужно внимательно следить, чтобы значения соответствовали той библиотеке, из которой импортируется функция setsockopt: 5 и 6 — для WSock32.dll и 12 и 13 — для WS2_32.dll. В WinSock 2 для присоединения сокета к группе объявлена функция WSAJoinLeaf, прототип которой приведен в листинге 2.80. Листинг 2.80. Функция WSAJoinLeaf // ***** описание на C++ ***** SOCKET WSAJoinLeaf(SOCKET s, const struct sockaddr FAR *name, int namelen, LPWSABUF lpCallerData, LPWSABUF lpCalleeData, LPQOS lpSQOS, LPQOS lpGQOS, DWORD dwFlags); // ***** описание на Delphi ***** function WSAJoinLeaf(S: TSocket; var Name: TSockAddr; NameLen: Integer; lpCallerData, lpCalleeData: PWSABuf; lpSQOS, lpGQOS: PQOS; dwFlags: DWORD): TSocket; Параметры lpCallerDataи lpCalleeDataзадают буферы, в которые помещаются данные, передаваемые и получаемые при присоединении к группе. Протокол IP не поддерживает передачу таких данных, поэтому при его использовании эти параметры должны быть равны nil. Параметры lpSQOSи lpGQOSотносятся к качеству обслуживания, которое мы здесь не рассматриваем, поэтому их мы тоже полагаем равными nil. Параметр Sопределяет сокет, который присоединяется к группе, Name— адрес группы, NameLen— размер буфера с адресом. Параметр dfFlagsопределяет, будет ли сокет служить для отправки данных ( JL_SENDER_ONLY, 1), для получения данных ( JL_RECEIVER_ONLY, 2) или и для отправки, и для получения ( JL_BOTH, 4). Функция возвращает сокет, который создан для взаимодействия с группой. В протоколах типа ATM подключение к группе похоже на установление связи в TCP, и функция WSAJoinLeaf, подобно функции accept, создаёт новый сокет, подключенный к группе. В случае UDP новый сокет не создается, и функция WSAJoinLeafвозвращает значение переданного ей параметра S. Номер порта в параметре Nameигнорируется. Для получения групповых сообщений используется тот интерфейс, который система назначает для этого по умолчанию. Чтобы прекратить членство сокета в группе, в которую он был добавлен с помощью WSAJoinLeaf, нужно закрыть его посредством функции closesocket. Если сокет, для которого вызывается функция WSAJoinLeaf, находится в асинхронном режиме, то при успешном присоединении сокета к группе возникнет событие FD_CONNECT(в [3] написано, что в одноранговых плоскостях управления FD_CONNECTне возникает — это не соответствует действительности). Но в случае ненадежного протокола UDP возникновение этого события говорит лишь о том, что было отправлено IGMP-сообщение, извещающее о включении сокета в группу (это сообщение должны получить все маршрутизаторы сети, чтобы потом правильно передавать групповые сообщения в другие подсети). Однако FD_CONNECTне гарантирует, что это сообщение успешно принято всеми маршрутизаторами. UDP-сокет, присоединившийся к многоадресной группе, не должен "подключаться" к какому-либо адресу с помощью функции connectили WSAConnect. Соответственно, для отправки данных такой сокет может использовать только sendtoи WSASendTo. Сокет, присоединившийся к группе, может отправлять данные на любой адрес, но если используется поддержка качества обслуживания, она работает только при отправке данных на групповой адрес сокета. Отправка данных на групповой адрес не требует присоединения к группе, причем для сокета, отправляющего данные, нет никакой разницы между отправкой данных на обычный адрес и на групповой. И в том и в другом случае используется функция sendtoили WSASendto(или sendWSASendс предварительным вызовом connect). Никаких дополнительных действий для отправки данных на групповой адрес выполнять не требуется. Порт при этом также указывается. Как мы уже видели, номер порта при добавлении сокета в группу не указывается, но сам сокет перед этим должен быть привязан к какому-либо порту. При отправке группового сообщения его получат только те сокеты, входящие в группу, чей порт привязки совпадает с портом, указанным в адресе назначения сообщения. Если сокет, отправляющий сообщение на групповой адрес, сам является членом этой группы, он, в зависимости от настроек, может получать или не получать свое сообщение. Это определяется его параметром IP_MULTICAST_LOOP, имеющим тип BOOL. По умолчанию этот параметр равен True— это значит, что сокет будет получать свои собственные сообщения. С помощью функции setsockoptможно изменить значение этого параметра на False, и тогда сокет не будет принимать свои сообщения. Параметром IP_MULTICAST_LOOPследует пользоваться осторожно, т.к. он не поддерживается в Windows NT 4 и требует Windows 2000 или выше. В Windows 9x/МЕ он тоже не поддерживается (хотя упоминания об этом в MSDN нет). В разд. 2.1.4 мы говорили, что каждый IP-пакет в своем заголовке имеет целочисленный параметр TTL (Time То Live). Его значение определяет, сколько маршрутизаторов может пройти данный пакет. По умолчанию групповые пакеты имеют TTL, равный 1, т.е. могут распространяться только в пределах непосредственно примыкающих подсетей. Целочисленный параметр сокета IP_MULTICAST_TTLпозволяет программе изменить это значение. У функции WSAJoinLeafне предусмотрены параметры для задания адреса сетевого интерфейса, через который следует получать групповые сообщения, поэтому всегда используется интерфейс, выбираемый системой для этих целей по умолчанию. Выбрать интерфейс, который система будет назначать по умолчанию, можно с помощью параметра сокета IP_MULTICAST_IF. Этот параметр имеет тип TSockAddr, причем значимыми полями структуры в данном случае являются sin_familyи sin_addr, а значение поля sin_portигнорируется. Значения констант IP_MULTICAST_IF, IP_MULTICAST_TTLи IP_MULTICAST_LOOPтакже зависят от версии WinSock. В WinSock 1 они должны быть равны 2, и 4, а в WinSock 2 — 9, 10 и 11 соответственно. 2.2.12. Дополнительные функцииВ этом разделе мы рассмотрим некоторые функции, относящиеся в WinSock к дополнительным. В WinSock 1 эти функции вместе со всеми остальными экспортируются библиотекой WSock32.dll, а в WinSock 2 они вынесены в отдельную библиотеку MSWSock.dll (в эту же библиотеку вынесены некоторые устаревшие функции типа EnumProtocols). Начнем мы знакомство с этими функциями с функции WSARecvEx(которая, кстати, является расширенной версией функции recv, а отнюдь не WSARecv, как это можно заключить из ее названия), имеющей следующий прототип: function WSARecvEx(s: TSocket; var buf; len: Integer; var flags: Integer): Integer; Видно, что она отличается от обычной функции recvтолько тем, что флаги передаются через параметр-переменную вместо значения. В функции WSARecvExэтот параметр не только входной, но и выходной; функция может модифицировать его. Ранее мы познакомились с функцией WSARecv, которая также может модифицировать переданные ей флаги, но условия, при которых эти две функции модифицируют флаги, различаются. При использовании TCP (а также любого другого потокового протокола) флаги не изменяются функцией, и результат работы WSARecvExэквивалентен результату работы recv. Как мы уже не раз говорили, дейтаграмма UDP должна быть прочитана из буфера сокета целиком. Если в буфере, переданном функции recvили recvfrom, недостаточно места для получения дейтаграммы, эти функции завершаются с ошибкой. При этом в буфер помещается та часть дейтаграммы, которая может в нем поместиться, а оставшаяся часть дейтаграммы теряется. Функция WSARecvExотличается от recvтолько тем, что в случае, когда размер буфера меньше размера дейтаграммы, она завершается без ошибки (возвращая при этом размер прочитанной части дейтаграммы, т.е. размер буфера) и добавляет флаг MSG_PARTIALк параметру flags. Остаток дейтаграммы при этом также теряется. Таким образом, WSARecvExдает альтернативный способ проверки того, что дейтаграмма не поместилась в буфер, и в некоторых случаях этот способ может оказаться удобным. Если при вызове функции WSARecvExфлаг MSG_PARTIALустановлен программой, но дейтаграмма поместилась в буфер целиком, функция сбрасывает этот флаг. В описании функции WSARecvExв MSDN можно прочитать, что если дейтаграмма прочитана частично, то следующий вызов функции позволит прочитать оставшуюся часть дейтаграммы. Это не относится к протоколу UDP и справедливо только по отношению к протоколам типа SPX, в которых одна дейтаграмма может разбиваться на несколько сетевых пакетов и потому возможна ситуация, когда в буфере сокета окажется только часть дейтаграммы. В UDP, напомним, дейтаграмма всегда посылается одним IP-пакетом и помещается в буфер сразу целиком. Функция WSARecvExне позволяет программе определить, с какого адреса прислана дейтаграмма, а аналога функции recvfromс такими же возможностями в WinSock нет. Мы уже упоминали о том, что в WinSock 1 существует перекрытый ввод-вывод, но только для систем линии NT. Также в WinSock 1 определена функция AcceptEx, которая является более мощным эквивалентом функции accept, и позволяет принимать входящие соединения в режиме перекрытого ввода-вывода. В WinSock 1 эта функция не поддерживается в Windows 95, в WinSock 2 она доступна во всех системах. Листинг 2.81 содержит ее прототип. Листинг 2.81. Функция AcceptEx function AcceptEx(sListenSocket, sAcceptSocket: TSocket; lpOutputBuffer: Pointer; dwReceiveDataLength: DWORD; dwLocalAddressLength: DWORD; dwRemoteAddressLength: DWORD; var lpdwBytesReceived: DWORD; lpOverlapped: POverlapped): BOOL; Функция AcceptExпозволяет принять новое подключение со стороны клиента и сразу же получить от него первую порцию данных. Функция работает только в режиме перекрытого ввода-вывода. Параметр sListenSocketопределяет сокет, который должен находиться в режиме ожидания подключения. Параметр sAcceptSocket— сокет, через который будет осуществляться связь с подключившимся клиентом. Напомним, что функции acceptи WSAAcceptсами создают новый сокет. При использовании же AcceptExпрограмма должна заранее создать сокет и, не привязывая его к адресу, передать в качестве параметра sAcceptSocket. Параметр lpOutputBuferзадает указатель на буфер, в который будут помещены, во-первых, данные, присланные клиентом, а во-вторых, адреса подключившегося клиента и адрес, к которому привязывается сокет sAcceptSocket. Параметр dwReceiveDataLengthзадает число байтов в буфере, зарезервированных для данных, присланных клиентом, dwLocalAddressLength— для адреса привязки сокета sAcceptSocket, dwRemoteAddressLength— адреса подключившегося клиента. Если параметр dwReceiveDataLengthравен нулю, функция не ждет, пока клиент пришлет данные, и считает операцию завершившейся сразу после подключения клиента, как функция accept. Для адресов нужно резервировать как минимум на 16 байтов больше места, чем реально требуется. Так как размер структуры TSockAddrсоставляет 16 байтов, на каждый из адресов требуется зарезервировать как минимум 32 байта. Параметр lpdwBytesReceivedиспользуется функцией, чтобы вернуть количество байтов, присланных клиентом. Параметр lpOverlappedуказывает на запись TOverlapped, определенную в модуле Windows следующим образом (листинг 2.82). Листинг 2.82. Тип TOverlapped POverlapped = TOverlapped; _OVERLAPPED = record Internal: DWORD; InternalHigh: DWORD; Offset: DWORD; OffsetHigh: DWORD; hEvent: THandle; end; TOverlapped = _OVERLAPPED; Структура TOverlappedиспользуется, в основном, для перекрытого ввода-вывода в файловых операциях. Видно, что она отличается от уже знакомой нам структуры TWSAOverlapped(см. листинг 2.69) только типом параметра hEvent— THandleвместо TWSAEvent. Впрочем, ранее мы уже обсуждали, что TWSAEvent— это синоним THandle, так что можно сказать, что эти структуры идентичны (но компилятор подходит к этому вопросу формально и считает их разными). Параметр lpOverlappedфункции AcceptExне может быть равным -1, а его поле hEventдолжно указывать на корректное событие. Процедуры завершения не предусмотрены. Если на момент вызова функции клиент уже подключился и прислал первую порцию данных (или место для данных в буфере не зарезервировано), AcceptExвозвращает True. Если же клиент еще не подключился, или подключился, но не прислал данные, функция AcceptExвозвращает False, а WSAGetLastError— ERROR_IO_PENDING. Параметр lpBytesReceivedв этом случае остается без изменений. Проконтролировать состояние операции можно с помощью функции GetOverlappedResult, которая является аналогом известной нам функции WSAGetOverlappedResult, за исключением того, что использует запись TOverlappedвместо TWSAOverlappedи не предусматривает передачу флагов. С ее помощью можно узнать, завершилась ли операция, а также дождаться ее завершения и узнать, сколько байтов прислано клиентом (функция AcceptExне ждет, пока клиент заполнит весь буфер, предназначенный для него — для завершения операции подключения достаточно первого пакета). Если к серверу подключаются некорректно работающие клиенты, которые не присылают данные после подключения, операция может не завершаться очень долго, что будет мешать подключению новых клиентов. MSDN рекомендует при ожидании время от времени с помощью функции getsockoptдля сокета sAcceptSocketузнавать значение целочисленного параметра SO_CONNECT_TIMEуровня SOL_SOCKET. Этот параметр показывает время в секундах, прошедшее с момента подключения клиента к данному сокету (или -1, если подключения не было). Если подключившийся клиент слишком долго не присылает данных, сокет sAcceptSocketследует закрыть, что приведет к завершению операции, начатой AcceptEx, с ошибкой. После этого можно снова вызывать AcceptExдля приема новых клиентов. Функция AcceptExреализована таким образом, чтобы обеспечивать максимальную скорость подключения. Ранее мы говорили, что сокеты, созданные функциями acceptи WSAAccept, наследуют параметры слушающего сокета (например, свойства асинхронного режима). Для повышения производительности сокет sAcceptSocketпо умолчанию не получает свойств сокета sListenSocket. Но он может унаследовать их после завершения операции с помощью следующей установки параметра сокета SO_UPDATE_ACCEPT_CONTEXT: setsockopt(sAcceptSocket, SOL_SOCKET, SO_UPDATE_ACCEPT_CONTEXT, PChar(@sListenSocket), SizeOf(sListenSocket)); Ha сокет sAcceptedSocketпосле его подключения к клиенту накладываются ограничения: он может использоваться не во всех функциях WinSock, а только в следующих: send, WSASend, recv, WSARecv, ReadFile, WriteFile, TransmitFile, closesocketи setsockopt, причем в последней — только для установки параметра SO_UPDATE_ACCEPT_CONTEXT. В WinSock не документируется, в какую именно часть буфера помещаются адрес клиента и принявшего его сокета. Вместо этого предоставляется функция GetAcceptExSockAddrs, прототип которой приведен в листинге 2.83. Листинг 2.83. Функция GetAcceptExSockAddrs procedure GetAcceptExSockAddrs(lpOutputBuffer: Pointer; dwReceiveDataLength: DWORD; dwLocalAddressLength: DWORD; dwRemoteAddressLength: DWORD; var LocalSockaddr: PSockAddr; var LocalSockaddrLength: Integer; var RemoteSockaddr: PSockAddr; var RemoteSockaddrLength: Integer); Примечание Первые четыре параметра функции GetAcceptExSockAddrsопределяют буфер, в котором в результате вызова AcceptExоказались данные от клиента и адреса, и размеры частей буфера, зарезервированных для данных и для адресов. Значения этих параметров должны совпадать со значениями аналогичных параметров в соответствующем вызове AcceptEx. Через параметр LocalSockaddrsвозвращается указатель на то место в буфере, в котором хранится адрес привязки сокета sAcceptSocket, а через параметр LocalSockaddrsLength— длина адреса (16 в случае TCP). Адрес клиента и его длина возвращаются через параметры RemoteSockaddrsи RemoteSockaddrsLength. Следует особенно подчеркнуть, что указатели LocalSockaddrsи RemoteSockaddrsуказывают именно на соответствующие части буфера: память для них специально не выделяется и, следовательно, не должна освобождаться, а свою актуальность они теряют при освобождении буфера. Последняя из дополнительных функций, TransmitFile, служит для передачи файлов по сети. Ее прототип приведен в листинге 2.84. Листинг 2.84. Функция TransmitFile function TransmitFile(hSocket: TSocket; hFile: THandle; nNumberOfBytesToWrite, nNumberOfBytesPerSend: DWORD; lpOverlapped: POverlapped; lpTransmitBuffers: PTransmitFileBuffers; dwReserved: DWORD): BOOL; Функция TransmitFileотправляет содержимое указанного файла через указанный сокет. При этом допускаются только протоколы, поддерживающие соединение, т.е. использовать данную функцию с UDP-сокетом нельзя. Сокет задается параметром hSocket, файл — параметром hFile. Дескриптор файла обычно получается с помощью функции стандартного API CreateFile. Файл рекомендуется открывать с флагом FILE_FLAG_SEQUENTIAL_SCAN, т.к. это повышает производительность. Параметр nNumberOfBytesToWriteопределяет, сколько байтов должно быть передано (позволяя, тем самым, передавать не весь файл, а только его часть). Если этот параметр равен нулю, передается весь файл. Функция TransmitFileкладет данные из файла в буфер сокета по частям. Параметр nNumberOfBytesPerSendопределяет размер одной порции данных. Он может быть равен нулю — в этом случае система сама определяет размер порции. Этот параметр критичен только в случае дейтаграммных протоколов, потому что при этом размер порции определяет размер дейтаграммы. Для TCP данные, хранящиеся в буфере, передаются в сеть целиком или по частям в зависимости от загрузки сети, готовности принимающей стороны и т.п., а какими порциями они попали в буфер, на размер пакета почти не влияет. Поэтому для TCP-сокета параметр nNumberOfBytesPerSendлучше установить равным нулю. Параметр lpOverlappedуказывает на запись TOverlapped, использующуюся для перекрытого ввода-вывода. Эту структуру мы обсуждали при описании функции AcceptEx. В отличие от AcceptEx, в TransmitFileэтот параметр добыть равным nil, и тогда операция передачи файла не будет перекрытой. Если параметр lpOverlappedравен nil, передача файла начинается с той позиции, на которую указывает файловый указатель (для только что открытого файла этот указатель указывает на его начало, а переместить его можно, например, с помощью функции SetFilePointer; также он перемещается при чтении файла с помощью ReadFile). Если же параметр lpOverlappedзадан, то передача файла начинается с позиции, заданной значениями полей Offsetи OffsetHigh, которые должны содержать соответственно младшую и старшую часть 64-битного смещения стартовой позиции от начала файла. Параметр lpTransmitBuffersявляется указателем на запись TTransmitFileBuffers, объявленную так, как показано в листинге 2.85. Листинг 2.85. Тип TTransmitFileBuffers PTransmitFileBuffers = ^TTransmitFileBuffers; _TRANSMIT_FILE_BUFFERS = record Head: Pointer; HeadLength: DWORD; Tail: Pointer; TailLength: DWORD; end; TTransmitFileBuffers = _TRANSMIT_FILE_BUFFERS; С ее помощью можно указывать буферы, содержащие данные, которые должны быть отправлены перед передачей самого файла и после него. Поле Headсодержит указатель на буфер, содержащий данные, предназначенные для отправки перед файлом, HeadLength— размер этих данных. Аналогично Tailи TailLengthопределяют начало и длину буфера с данными, которые передаются после передачи файла. Если передача дополнительных данных не нужна, параметр lpTransmitBufferможет быть равен nil. Допускается и обратная ситуация: параметр hFileможет быть равен нулю, тогда передаются только данные, определяемые параметром lpTransmitBuffer. Последний параметр функции TransmitFileв модуле WinSockимеет имя Reserved. В WinSock 1 он и в самом деле был зарезервирован и не имел смысла, но в WinSock 2 через него передаются флаги, управляющие операцией передачи файла. Мы не будем приводить здесь полный список возможных флагов (он есть в MSDN), а ограничимся лишь самыми важными. Указание флага TF_USE_DEFAULT_WORKERили TF_USE_SYSTEM_THREADпозволяет повысить производительность при передаче больших файлов, a TF_USE_KERNEL_APC— при передаче маленьких файлов. Вообще, при работе с функцией TransmitFileчтение файла и передачу данных в сеть осуществляет ядро операционной системы, что приводит к повышению быстродействия по сравнению с использованием ReadFileи sendсамой программой. Функция TransmitFileреализована по-разному в серверных версиях Windows NT/2000 и в остальных системах: в серверных версиях она оптимизирована по быстродействию, а в остальных — по количеству необходимых ресурсов. Данные, переданные функцией TransmitFile, удаленная сторона должна принимать обычным образом, с помощью функций recv/WSARecv. 2.3. Итоги главыНа этом мы заканчиваем рассмотрение WinSock. Многие возможности этого стандарта остались не рассмотренными и даже не упомянутыми. Но для этого существуют книги, подобные [3]. Нашей же основной задачей было последовательное знакомство с базовыми возможностями WinSock API и способам их применения в Delphi. Следует отметить, что в Delphi не обязательно напрямую использовать WinSock API, чтобы работать с сокетами, т.к. VCL содержит компоненты для этого. Прежде всего это TServerSocketи TClientSocket, использующие асинхронные сокеты, основанные на оконных сообщениях. Начиная с Delphi 7, к ним добавились компоненты TTCPServer, TTCPClientи TUDPSocket, использующие блокирующие или неблокирующие сокеты. Кроме того, с Delphi поставляется библиотека Indy, которая тоже содержит компоненты для работы с сокетами. Но практика показывает, что освоить эти компоненты без знания особенностей WinSock API очень сложно, так что даже если вы никогда не будете вызывать функции WinSock API явно, а ограничитесь компонентами. информация, изложенная в этой главе, вам все равно пригодится. Примечание Настоятельно рекомендуем прочитать книгу [3]. Несмотря на незначительные недостатки, она является наиболее полным из изданных на данный момент на русском языке руководством по использованию сокетов в Windows. В крайнем случае рекомендуем хотя бы посмотреть ее содержание в Интернете, чтобы представлять себе, сколько различных возможностей WinSock API остались здесь не упомянутыми. |
|
||||||||||||||||||||||
Главная | В избранное | Наш E-MAIL | Прислать материал | Нашёл ошибку | Наверх |
||||||||||||||||||||||||
|