• 2.1. Стандартные сокеты
  • 2.1.1 Соглашения об именах
  • 2.1.2. Общие сведения о сокетах
  • 2.1.3. Сетевые протоколы. Семиуровневая модель OSI
  • 2.1.4. Стек TCP/IP
  • 2.1.5. Протокол UDP
  • 2.1.6. Протокол TCP
  • 2.1.7. Сетевые экраны
  • 2.1.8. Создание сокета
  • 2.1.9. Передача данных при использовании UDP
  • 2.1.10. Пример программы: простейший чат на UDP
  • 2.1.11. Передача данных при использовании TCP
  • 2.1.12. Примеры передачи данных с помощью TCP
  • 2.1.13. Определение готовности сокета
  • 2.1.14. Примеры использования функции select
  • 2.1.15. Неблокирующий режим
  • 2.1.16. Сервер на неблокирующих сокетах
  • 2.1.17. Параметры сокета
  • 2.1.18. Итоги первого раздела
  • 2.2. Сокеты Windows
  • 2.2.1. Версии Windows Sockets
  • 2.2.2. Устаревшие функции WinSock 1
  • 2.2.3. Информация о протоколе
  • 2.2.4. Новые функции
  • 2.2.5. Асинхронный режим, основанный на сообщениях
  • 2.2.6. Пример сервера, основанного на сообщениях
  • 2.2.7. Асинхронный режим, основанный на событиях
  • 2.2.8. Пример использования сокетов с событиями
  • 2.2.9. Перекрытый ввод-вывод
  • 2.2.10. Сервер, использующий перекрытый ввод-вывод
  • 2.2.11. Многоадресная рассылка
  • 2.2.12. Дополнительные функции
  • 2.3. Итоги главы
  • Глава 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, время жизни). Это позволяет защититься от бесконечного блуждания пакетов по сети.

    Примечание

    Здесь следует заметить, что сеть Ethernet ушла далеко вперёд по сравнению с моментом создания протокола IP и теперь организована сложнее, поэтому не следует думать, что в предыдущих абзацах изложены все принципы работы этой сети (это выходит за рамки данной книги). Тем не менее протокол IР по-прежнему используется, а компьютеры по-прежнему видят в сети не только свои, но и чужие пакеты. На этом основана работа так называемых снифферов — программ, позволяющих одному компьютеру читать пакеты пересылаемые между двумя другими компьютерами.

    Для адресации компьютера протокол 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, поэтому больше останавливаться на этом вопросе не будем.

    Примечание

    На самом деле существует способ передать в функцию

    bind
    с таким прототипом параметр
    addr
    любого типа, совместимого с этой функцией. Если
    A
    — некая переменная типа, отличающегося от
    TSockAddr
    , то передать в качестве параметра-переменной ее можно так:
    PSockAddr(@А)^
    . Однако подобные низкоуровневые операции программу не украшают.

    В стандартной библиотеке сокетов (т.е. в заголовочных файлах для этой библиотеки) полагается, что адрес кодируется структурой

    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
    из этой же библиотеки. Вот такая несколько запутанная ситуация, хотя на практике все выглядит не так страшно.

    Примечание

    Из названия типов можно сделать вывод, что тип

    u_short
    — это
    Word
    , а
    u_long
    Cardinal
    . На самом деле
    u_short
    — это действительно
    Word
    , а вот
    u_long
    — это
    LongInt
    . Сложно сказать почему выбран знаковый тип там, где предполагается беззнаковый. Видимо, это осталось в наследство от старых версий Delphi, которые не поддерживали тип
    Cardinal
    в полном объеме. Кстати, тип
    u_char
    — это
    Char
    , а не
    Byte
    .

    Перейдем, наконец, к более практически важному вопросу: какими значениями следует заполнять переменную типа

    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.

    Примечание

    Если строка, переданная функции

    inet_addr
    , не распознается как допустимый адрес, то функция возвращает значение
    INADDR_NONE
    . До пятой версии Delphi включительно эта константа имеет значение
    $FFFFFFFF
    , начиная с шестой версии — значение -1. Поле
    S_addr
    имеет тип
    u_long
    , который, как отмечалось, соответствует типу
    LongInt
    , т.е. является знаковым. Сравнение знакового числа с беззнаковым обрабатывается компилятором особым образом (подробнее об этом написано в разд. 3.1.4), поэтому, если просто сравнить
    S_addr
    и
    INADDR_NONE
    в старых версиях Delphi, получится неправильный результат. Перед сравнением константу
    INADDR_NONE
    следует привести к типу
    u_long
    , тогда операция выполнится правильно. В шестой и более поздних версиях Delphi приведение не обязательно, но оно не мешает, поэтому в целях совместимости со старыми версиями его тоже лучше выполнять.

    В библиотеке сокетов предусмотрена константа

    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
    позволяет одновременно запретить и прием, и передачу данных через сокет.

    Примечание

    Модуль

    WinSock
    до пятой версии Delphi включительно содержит ошибку: в нем не определены константы
    SD_XXX
    . Чтобы использовать их в своей программе, нужно объявить их так, как показано в листинге 2.5.

    Листинг 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
    ? Документация по этому поводу упорно молчит. Если в нашем чате оставить только один сокет и задействовать его в обеих нитях, то всё вроде как работает. Однако это тот случай, когда эксперимент не может служить доказательством, потому что у ошибок, связанных с неправильной синхронизацией нитей, есть очень неприятная особенность: программа может миллион раз отработать правильно, а на миллион первый дать сбой. Поэтому сколько бы раз такой эксперимент ни завершился удачно, полной гарантии он все же не даёт, так что приходится действовать осторожно и не использовать один сокет в разных нитях.

    В заключение отметим, что наш чат допускает одновременное общение любого количества человек с любого числа адресов, но сообщения всегда передаются от одного человека к другому. Широковещательных и групповых сообщений у нас нет. Отметим также, что отправлять сообщения можно и не запуская "сервер".

    Примечание

    Для того чтобы протестировать работу чата, не обязательно иметь два компьютера, соединенных в сеть. Два или более экземпляра чата можно запустить и на одном компьютере, главное, чтобы у них у всех были разные порты для принимающего сокета. В качестве IP-адреса для отправки сообщений можно задавать адрес локального компьютера вида 127.0.0.N. Это же верно и для всех остальных примеров работы с сокетами.

    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-атак низка.

    Примечание

    DoS-атака (Denied of Service) — способ помешать функционированию сервера, заключающийся в загрузке его бесполезной работой. В простейшем случае — это просто одновременное подключение большого числа клиентов. У нас даже простое подключение большого числа клиентов приводит к большому расходу системных ресурсов, поэтому 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
    , к сожалению, не смогут работать с новой структурой, потому что компилятор требует строгого соответствия типов для параметров-переменных. Но, опять же, при необходимости очень легко создать аналоги этих функций для своей структуры.

    Примечание

    На первый взгляд может показаться, что Delphi в данном случае хуже, чем C/C++. Но достаточно хотя бы раз столкнуться с ошибкой, вызванной взаимным влиянием макроопределений в модулях C/C++, чтобы понять, что уж лучше написать несколько лишних строк кода, лишь бы никогда больше не иметь таких проблем.

    Последний параметр функции

    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). С помощью таких простейших модификаций мы получили чат, который работает без распараллеливания и использует всего один сокет. Работать с таким чатом стало намного проще, потому что теперь ответ нужно посылать на тот же порт, с которого пришло сообщение, а не запоминать, какой порт для отправки какому из экземпляров чата соответствует.

    Примечание

    Несмотря на эти изменения, новая версия UDP-чата может обмениваться сообщениями со старой, т.к. протокол обмена остался неизменным.

    Листинг 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 информирует только о том, что во входном буфере сокета есть хоть что-то, но вовсе не гарантирует, что там лежит уже все сообщение целиком. Наша же функция реализована таким образом, что она завершается либо после прочтения сообщения целиком, либо после обнаружения ошибки. Тем не менее в простых случаях можно пойти на такой риск, потому что, во-первых, короткие сообщения редко разбиваются на части, а во-вторых, если даже такое произойдет, оставшаяся часть сообщения, скорее всего, догонит первую достаточно быстро, и блокировка долгой не будет, так что риск при нормальной работе сети и клиента не очень велик.

    Примечание

    Эта ситуация отличается от использования

    select
    для UDP-сокетов. С ними такой проблемы не возникает, т.к. дейтаграмма никогда не приходит по частям, и если функция
    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

    Версия Комментарий
    1.0 Упоминается только вскользь. Видимо, настолько старая версия, что ее поддержка в чистом виде в современных системах отсутствует
    1.1 Основная подверсия первой версии библиотеки. По умолчанию входила во все версии Windows до Windows 95 включительно. Ориентирована на 16-разрядные системы с корпоративной многозадачностью
    2.0 В чистом виде никуда не ставилась. Ориентирована на 32-разрядные системы с вытесняющей многозадачностью. Исключены некоторые устаревшие функции
    2.2 Основная подверсия второй версии библиотеки. По умолчанию входит в состав Windows 98/NT 4/2000 а также видимо, и более поздних версий. Для Windows 95 существует возможность обновления 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
    (при описании прототипов функций мы для краткости будем опускать эту директиву).

    Примечание

    С Delphi поставляется библиотека Indy (Internet Direct), в состав которой входит модуль

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

    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;

    Примечание

    В старых версиях MSDN в описании этой функции есть небольшая опечатка: тип параметра

    lpdwBufferLength
    назван
    LLPDWORD
    вместо
    LPDWORD
    .

    Библиотека WS2_32.dll придерживается тех же правил насчет ANSI- и Unicode-вариантов функций, что и другие системные библиотеки (см. разд. 1.1.12), поэтому в ней нет функции с именем

    WSAEnumProtocols
    , а есть
    WSAEnumProtocolsA
    и
    WSAEnumProtocolsW
    . Эти функции работают с разными вариантами структуры
    WSAPROTOCOL_INFO
    , которые различаются типом элементов в последнем массиве —
    CHAR
    или
    WCHAR
    .

    Параметр

    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
    сокета к группе. Параметр, как и все, связанное с группами, зарезервирован для использования в будущем.

    Примечание

    Если вы пользуетесь старой версией MSDN, то можете не обнаружить там описания параметра

    g
    — оно там отсутствует. Видимо, просто по ошибке.

    И наконец, через параметр

    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. Асинхронные события сокета

    Событие Комментарий
    FD_READ
    Сокет готов к чтению
    FD_WRITE
    Сокет готов к записи
    FD_ACCEPT
    В очереди сокета есть подключения (применимо только для сокетов, находящихся в режиме ожидания подключения)
    FD_CONNECT
    Соединение установлено (применимо только для сокетов, для которых вызвана функция
    connect
    или аналогичная ей)
    FD_CLOSE
    Соединение закрыто

    Каждый последующий вызов

    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
    .

    Примечание

    Описанный алгоритм разбора буфера прост, но неэффективен с точки зрения нагрузки на процессор и использования динамической памяти, особенно в тех случаях, когда в буфере

    RecvBuf
    оказывается сразу несколько строк. Это связано с тем, что при добавлении содержимого
    RecvBuf
    к
    FRecvStr
    и последующем поочередном удалении строк из
    FRecvStr
    происходит многократное перераспределение памяти, выделенной для строки. Алгоритм можно оптимизировать: все строки, которые поместились в
    RecvBuf
    целиком, выделять непосредственно из этого буфера, не помещая в
    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
    может отправить только часть переданных ей данных, это становится неудобным из-за громоздкости многочисленных проверок. Поэтому мы создаем один буфер, куда заносим и длину строки, и саму строку, и затем передаем его как единое целое.

    Примечание

    Далее мы познакомимся с функцией

    WSASend
    , которая позволяет отправлять данные, находящиеся не в одном, а в нескольких разных местах. Если бы мы использовали ее, можно было бы не объединять самостоятельно длину строки и саму строку в специальном буфере, а просто передать два указателя на длину и на строку.

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

    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
    взаимодействие с клиентом также разбивается на три этапа, но реализуется другой способ для того, чтобы различать этапы — для каждого этапа создается своя процедура завершения.

    Примечание

    Использование одной процедуры завершения для всех трех этапов и распознавание в ней этапов с помощью поля

    Phase
    в случае перекрытого ввода-вывода также возможно. Рекомендуем написать такой вариант сервера в качестве самостоятельного упражнения.

    Поле

    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);

    Примечание

    В Delphi до 7-й версии включительно модуль WinSock содержит ошибку — параметры

    LocalSockaddr
    и
    RemoteSockaddr
    функции
    GetAcceptExSockAddrs
    имеют в нем тип
    TSockAddr
    вместо
    PSockAddr
    . Из-за этой ошибки функцию
    GetAcceptExSockAddrs
    в этих версиях Delphi необходимо самостоятельно импортировать. Следует заметить, что во многих модулях для WinSock 2 от независимых разработчиков объявление этой функции скопировано из стандартного модуля вместе с ошибкой.

    Первые четыре параметра функции

    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 явно, а ограничитесь компонентами. информация, изложенная в этой главе, вам все равно пригодится.

    Примечание

    Начиная с Delphi 7, компоненты

    TClientSocket
    и
    TServerSocket
    в поставке присутствуют, но в палитру компонентов по умолчанию не устанавливаются. Чтобы работать с этими компонентами, их нужно установить самостоятельно. Для этого в меню Component следует выбрать пункт Install Packages, в открывшемся диалоговом окне нажать кнопку Add и добавить нужный пакет. Этот пакет находится в папке $(DELPHI)/Bin, а название его зависит от версии Delphi. Для Delphi 7 это будет dclsockets70.bpl, для BDS 2005 — dclsockets90.bpl, для BDS 2006, Turbo Delphi и Delphi 2007 — dclsockets100.bpl.

    Настоятельно рекомендуем прочитать книгу [3]. Несмотря на незначительные недостатки, она является наиболее полным из изданных на данный момент на русском языке руководством по использованию сокетов в Windows. В крайнем случае рекомендуем хотя бы посмотреть ее содержание в Интернете, чтобы представлять себе, сколько различных возможностей WinSock API остались здесь не упомянутыми. 








    Главная | В избранное | Наш E-MAIL | Прислать материал | Нашёл ошибку | Наверх