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