6.1. Транспортные службы

В следующих разделах мы познакомимся с транспортными службами. Мы рассмотрим виды служб, предоставляемых прикладному уровню. Чтобы обсуждение было более конкретным, мы разберем два набора базовых операций транспортного уровня. Сначала рассмотрим простой, но условный набор, чтобы показать основные идеи, а затем — реально применяемый в интернете интерфейс.


6.1.1. Службы, предоставляемые верхним уровням

Конечная цель транспортного уровня — предоставить эффективную, надежную и экономичную службу передачи данных своим пользователям (обычно это процессы прикладного уровня). Для этого транспортный уровень применяет службы, предоставленные сетевым уровнем. Программа и/или оборудование, выполняющие работу транспортного уровня, называются транспортной подсистемой, или транспортным объектом (transport entity). Эта подсистема может находиться в ядре операционной системы, в библиотечном модуле, загруженном сетевом приложении, в отдельном пользовательском процессе или даже в сетевой интерфейсной плате. Первые два варианта чаще всего встречаются в интернете. Логическая взаимосвязь сетевого, транспортного и прикладного уровней проиллюстрирована на илл. 6.1.

Илл. 6.1. Сетевой, транспортный и прикладной уровни

На транспортном уровне, как и на сетевом, могут быть службы, ориентированные на установление соединения, и службы без установления соединения. Транспортная служба с установлением соединения во многом похожа на аналогичную сетевую. В обоих случаях соединение проходит три этапа: установление, передача данных и разъединение.

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

Возникает закономерный вопрос: если транспортные и сетевые службы так похожи, то зачем нужны два разных уровня? Почему одного недостаточно? Это довольно тонкий, но ключевой вопрос. Программное обеспечение транспортного уровня запускается целиком на пользовательских устройствах, а сетевой уровень — в основном на маршрутизаторах, управляемых оператором связи (по крайней мере, в WAN). Что произойдет, если сетевой уровень будет предоставлять службу с установлением соединения, но она будет ненадежной? Что, если она часто будет терять пакеты? Что случится, если маршрутизаторы будут время от времени выходить из строя?

Все это приведет к большим трудностям. Пользователи не контролируют сетевой уровень, поэтому не смогут решить проблему плохого обслуживания, используя более совершенные маршрутизаторы или совершенствуя обработку ошибок канального уровня (просто потому, что маршрутизаторы им не принадлежат). Единственная возможность — расположить над сетевым уровнем еще один уровень, который будет улучшать QoS. Если в сети без установления соединения пакеты теряются или передаются с искажениями, транспортная подсистема обнаруживает проблему и выполняет повторную передачу. Если в сети с установлением соединения транспортная подсистема узнает, что ее сетевое соединение было внезапно прервано (без сведений о том, что случилось с передаваемыми в этот момент данными), она может установить новое соединение с удаленной транспортной подсистемой. По нему она может отправить запрос к равноранговому объекту и узнать, какие данные дошли до адресата, а какие нет. Затем транспортная подсистема может продолжить передачу с того момента, где она оборвалась.

По сути, наличие транспортного уровня делает транспортную службу более надежной, чем нижележащая сеть, которая не отличается стабильностью. Более того, транспортные примитивы могут быть реализованы в виде вызовов библиотечных процедур, чтобы не зависеть от сетевых примитивов. Сетевые вызовы варьируются в разных сетях (например, обращения в сети Ethernet без установления соединения могут значительно отличаться от обращений в сети с установлением соединения). Если скрыть сетевую службу за набором транспортных примитивов, то для изменений в сети потребуется простая замена одного набора библиотечных процедур другим. При этом он будет делать то же самое, но c помощью других служб более низкого уровня. Независимость приложений от сетевого уровня несет очевидную пользу.

Благодаря транспортному уровню прикладные программисты могут писать код согласно стандартному набору примитивов и сохранять работоспособность программ в самых разных сетях. Им не приходится учитывать разнообразные сетевые интерфейсы и уровни надежности. Если бы все сети работали идеально, имели одинаковые примитивы служб и никогда не менялись, то транспортный уровень, вероятно, был бы не нужен. Однако в реальности он выполняет ключевую функцию: изолирует верхние уровни от деталей технологии, устройства и несовершенства сети.

Именно по этой причине часто разграничивают уровни с первого по четвертый и уровни выше четвертого. Нижние уровни можно рассматривать как поставщика транспортных служб (transport service provider), а верхние уровни — как пользователя транспортных служб (transport service user). Разделение на поставщика и пользователя серьезно влияет на устройство уровней и делает транспортный уровень ключевым. Он формирует основную границу между поставщиком и пользователем надежной службы передачи данных. Именно этот уровень виден приложениям.


6.1.2. Примитивы транспортных служб

Чтобы дать пользователям доступ к транспортной службе, транспортный уровень должен совершить некоторые операции над прикладными программами, то есть предоставить транспортный интерфейс. У каждой службы он свой. В этом разделе мы прежде всего рассмотрим простой (но гипотетический) пример транспортной службы и ее интерфейса, чтобы познакомиться с основными принципами и понятиями. Следующий раздел будет посвящен реальному примеру.

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

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

В качестве примера рассмотрим два процесса на одном компьютере, соединенные каналом в системе UNIX (или с помощью любого другого средства межпроцессорного взаимодействия). Эти процессы предполагают, что соединение между ними абсолютно идеально. Они не хотят знать о подтверждениях, потерянных пакетах, перегрузках и т.п. Им требуется стопроцентно надежное соединение. Процесс A помещает данные на одной стороне канала, а процесс B извлекает их на другой. Именно для этого и предназначена транспортная служба, ориентированная на установление соединения, — скрывать несовершенство сетевого обслуживания, чтобы пользовательские процессы считали, что существует безо­шибочный поток битов, даже если они выполняются на разных устройствах.

Кстати, транспортный уровень также может предоставлять ненадежную (дейтаграммную) службу, но о нем сказать почти нечего (разве что «это дейтаграммы»). Поэтому в данной главе мы сконцентрируемся на службе, ориентированной на установление соединения. Тем не менее есть приложения, например клиент-серверные вычислительные системы и потоковое мультимедиа, основанные на транспортных службах без установления соединения, поэтому мы их еще обсудим.

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

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

Для того чтобы научиться использовать эти примитивы, рассмотрим систему, состоящую из сервера и нескольких удаленных клиентов. Вначале сервер выполняет примитив LISTEN — обычно для этого вызывается библиотечная процедура, которая обращается к системе. В результате сервер блокируется, пока клиент не обратится к нему. Когда клиент хочет связаться с сервером, он выполняет примитив CONNECT. Транспортная подсистема выполняет этот примитив, блокируя обратившегося к ней клиента и отсылая пакет серверу. Поле данных пакета содержит сообщение транспортного уровня, адресованное транспортной подсистеме сервера.

Примитив

Отправленный пакет

Значение

LISTEN (ОЖИДАТЬ)

(нет)

Блокировать сервер, пока какой-либо процесс не попытается соединиться

CONNECT (СОЕДИНИТЬ)

CONNECTION REQUEST (ЗАПРОС СОЕДИНЕНИЯ)

Активно пытаться установить соединение

SEND (ОТПРАВИТЬ)

ДАННЫЕ

Отправить информацию

RECEIVE (ПОЛУЧИТЬ)

(нет)

Блокировать сервер, пока не поступят данные

DISCONNECT (РАЗЪЕДИНИТЬ)

DISCONNECTION REQUEST (ЗАПРОС РАЗЪЕДИНЕНИЯ)

Прервать соединение

Илл. 6.2. Примитивы простой транспортной службы

Следует сказать пару слов о терминологии. За неимением лучшего термина, для сообщений, отправляемых одной транспортной подсистемой другой транспортной подсистеме, нам придется использовать понятие сегмент (segment). Оно используется в TCP, UDP и других интернет-протоколах. В более старых протоколах применялось громоздкое название модуль данных транспортного протокола (Transport Protocol Data Unit, TPDU). Сейчас оно практически не используется, однако вы можете встретить его в старых статьях и книгах.

Итак, сегменты, используемые транспортным уровнем, помещаются в пакеты, которыми обменивается сетевой уровень. Эти пакеты, в свою очередь, содержатся во фреймах, которые передает канальный уровень. Получив фрейм, процесс канального уровня обрабатывает его заголовок, и если адрес назначения совпадает с местом доставки, передает содержимое поля пользовательских данных наверх сетевой подсистеме. Cетевая подсистема похожим образом обрабатывает заголовок пакета и передает содержимое поля пользовательских данных пакета наверх транспортной подсистеме. Эта вложенность проиллюстрирована на илл. 6.3.

Илл. 6.3. Вложенность сегментов, пакетов и фреймов

Итак, вернемся к нашему примеру общения клиента и сервера. В результате запроса клиента CONNECT серверу отравляется сегмент, содержащий CONNECTION REQUEST (запрос соединения). Когда он прибывает, транспортная подсистема проверяет, заблокирован ли сервер примитивом LISTEN (то есть готов ли он к обработке запросов). Затем она снимает блокировку сервера и отсылает обратно клиенту сегмент CONNECTION ACCEPTED (соединение принято). Получив этот сегмент, клиент разблокируется, после чего соединение считается установленным.

Теперь клиент и сервер могут обмениваться данными с помощью примитивов SEND и RECEIVE. В простейшем случае каждая из сторон использует блокирующий примитив RECEIVE для перехода в режим ожидания сегмента, который передается другой стороной с помощью SEND. Когда сегмент прибывает, получатель разблокируется. Затем он может обработать полученный сегмент и отправить ответ. Такая схема прекрасно работает, пока обе стороны помнят, чья очередь передавать, а чья — принимать данные.

Обратите внимание, что на транспортном уровне даже простая однонаправленная пересылка данных сложнее, чем на сетевом. Каждый отправленный пакет будет в конце концов подтвержден. Пакеты с управляющими сегментами также подтверждаются, явно или неявно. Эти подтверждения управляются транспортными подсистемами при помощи протокола сетевого уровня и не видны пользователям транспортного уровня. Аналогично транспортные объекты занимаются проблемами таймеров и повторных передач. Все эти механизмы не видны пользователям транспортного уровня, для которых соединение представляется надежным битовым каналом. Биты поступают с одного конца канала и волшебным образом появляются на другом его конце в том же порядке. Эта способность скрывать сложность от пользователей свидетельствует о том, что многоуровневые протоколы являются довольно мощным инструментом.

Когда соединение больше не требуется, оно должно быть разорвано, чтобы освободить место в таблицах двух транспортных подсистем. Разъединение бывает симметричным и асимметричным. В асимметричном варианте одна из сторон может вызвать примитив DISCONNECT, в результате чего другая сторона получает управляющий сегмент DISCONNECTION REQUEST (запрос разъединения) и соединение разрывается.

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

Диаграмма состояний для установления и разрыва соединения показана на илл. 6.4. Каждый переход вызывается каким-то событием — операцией, выполненной локальным пользователем транспортной службы, или входящим пакетом. Для простоты мы будем считать, что каждый сегмент подтверждается отдельно. Мы также предполагаем, что используется модель симметричного разъединения, в которой клиент делает первый ход. Обратите внимание на простоту этого примера. Позднее, когда мы будем говорить о TCP, мы рассмотрим более реалистичные модели.


6.1.3. Сокеты Беркли

Теперь рассмотрим другой набор примитивов транспортного уровня — примитивы сокетов, используемые для протокола TCP. Впервые сокеты стали применяться в 1983 году в операционной системе Berkeley UNIX 4.2BSD. Очень скоро они приобрели популярность и сейчас широко используются для интернет-программирования в большинстве операционных систем, особенно UNIX; кроме того, существует специальный API, предназначенный для программирования сокетов в системе Windows — «winsock».

Илл. 6.4. Диаграмма состояний для простой схемы управления соединениями. Переходы, обозначенные курсивом, вызываются поступлением пакетов. Сплошными линиями показана последовательность состояний клиента. Пунктирными линиями показана последовательность состояний сервера

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

Первые четыре примитива из списка выполняются серверами в указанной последовательности. Примитив SOCKET создает новый сокет и выделяет для него место в таблице транспортной подсистемы. Параметры вызова указывают используемый формат адресов, тип требуемой службы (например, надежный поток байтов) и протокол. В случае успеха SOCKET возвращает обычный файловый дескриптор, используемый при вызове следующих операций, подобно тому, как процедура OPEN работает для файла.

Примитив

Значение

SOCKET (СОКЕТ)

Создать новый сокет (гнездо связи)

BIND (СВЯЗАТЬ)

Связать локальный адрес с сокетом

LISTEN (ОЖИДАТЬ)

Объявить о желании принять соединение; указать размер очереди

ACCEPT (ПРИНЯТЬ)

Пассивно установить входящее соединение

CONNECT (СОЕДИНИТЬ)

Активно пытаться установить соединение

SEND (ОТПРАВИТЬ)

Отправить данные по соединению

RECEIVE (ПОЛУЧИТЬ)

Получить данные у соединения

CLOSE (ЗАКРЫТЬ)

Разорвать соединение

Илл. 6.5. Примитивы сокетов для TCP

У только что созданного сокета нет сетевых адресов. Они назначаются с помощью примитива BIND. После того как сервер привязывает адрес к сокету, с ним могут связаться удаленные клиенты. Вызов SOCKET не создает адрес напрямую, так как некоторые процессы придают своим адресам большое значение (например, они использовали один и тот же адрес годами, и он известен всем).

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

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

Теперь посмотрим на этот процесс со стороны клиента. И здесь прежде всего должен быть создан сокет с помощью примитива SOCKET, но BIND в этом случае не требуется, так как используемый адрес не имеет значения для сервера. CONNECT блокирует вызывающего и инициирует активный процесс соединения. Когда этот процесс завершается (то есть когда соответствующий сегмент, отправленный сервером, получен), процесс клиента разблокируется, и соединение считается установленным. После этого обе стороны могут использовать SEND и RECIEVE для передачи и получения данных по полнодуплексному соединению. Могут также применяться стандартные UNIX-вызовы READ и WRITE, если нет нужды в использовании специальных свойств SEND и RECIEVE.

В модели сокетов используется симметричный разрыв соединения. Это происходит, когда обе стороны выполняют примитив CLOSE.

Получив широкое распространение, сокеты де-факто стали стандартом абстрагирования транспортных служб для приложений. Часто сокет-API используется вместе с протоколом TCP для предоставления службы, ориентированной на установление соединения, — надежного потока байтов (reliable byte stream); на деле это надежный битовый канал, о котором мы говорили выше. Этот API может сочетаться и с другими протоколами, но в любом случае результат должен быть одинаковым для пользователя.

Преимущество сокет-API состоит в том, что приложение может использовать его и для других транспортных служб. К примеру, с помощью сокетов можно реализовать службу без установления соединения. В этом случае CONNECT задает адрес удаленного узла, а SEND и RECEIVE отправляют и получают дейтаграммы. (Иногда используется расширенный набор вызовов, например примитивы SENDTO и RECEIVEFROM, позволяющие приложению не ограничиваться одним транспортным узлом.) Иногда сокеты используются с транспортными протоколами, в которых вместо байтового потока применяется поток сообщений и которые могут включать (или не включать) контроль перегрузок. К примеру, дейтаграммный протокол с контролем перегрузок (Datagram Congestion Control Protocol, DCCP) является вариантом UDP с управлением перегрузкой (Колер и др.; Kohler et al., 2006). Необходимую службу выбирают сами пользователи.

Тем не менее последнее слово в вопросе транспортных интерфейсов, скорее всего, останется не за сокетами. Довольно часто приложениям приходится работать с группой связанных потоков, например браузер может одновременно запрашивать у сервера несколько объектов. В таком случае применение сокетов обычно означает, что для каждого объекта будет использоваться один поток. В результате управление перегрузкой будет выполняться отдельно для каждого потока (а не для всей группы). Безусловно, это далеко не оптимальный вариант, поскольку управление набором потоков становится задачей приложения. Чтобы более эффективно обрабатывать группы связанных потоков и уменьшить роль приложения в этом процессе, был создан ряд дополнительных протоколов и интерфейсов. В частности, протокол передачи с управлением потоками (Stream Control Transmission Protocol, SCTP), описанный в RFC 4960 (Форд; Ford, 2007), и протокол QUIC (он будет рассмотрен ниже). Эти протоколы слегка изменяют сокет-API для удобства работы с группами потоков, обеспечивая новые возможности, например работу со смешанным трафиком (с установлением соединения и без) и даже поддержку множественных сетевых путей.


6.1.4. Пример программирования сокета: файл-сервер для интернета

Чтобы узнать, как выполняются вызовы для сокета на практике, рассмотрим клиентский и серверный код на илл. 6.6. Имеется примитивный файл-сервер, работающий в интернете, и использующий его клиент. У программы много ограничений (о которых еще будет сказано), но теоретически данный код, описывающий сервер, может быть скомпилирован и запущен на любой UNIX-системе, подключенной к интернету. Код, описывающий клиента, может быть запущен с определенными параметрами. Это позволит ему получить любой файл, к которому у сервера есть доступ. Файл отображается на стандартном устройстве вывода, но, разумеется, может быть перенаправлен на диск или какому-либо процессу.

/* На этой странице содержится клиентская программа, запрашивающая файл у серверной программы, расположенной на следующей странице. Сервер в ответ на запрос высылает файл. */

#include

#include

#include

#include

#include

#include

#include

#include

#define SERVER_PORT 8080 /* По договоренности между клиентом и сервером */

#define BUF_SIZE 4096 /* Размер передаваемых блоков */

int main(int argc, char **argv)

{

int c, s, bytes;

char buf[BUF_SIZE]; /* буфер для входящего файла */

struct hostent *h; /* информация о сервере */

struct sockaddr_in channel; /* содержит IP-адрес */

if (argc != 3) {printf("Для запуска введите: client имя_сервера имя_файла"); exit(-1);}

h = gethostbyname(argv[1]); /* поиск IP-адреса хоста */

if (!h) {printf("gethostbyname не удалось найти %s", argv[1]); exit(-1;}

s = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP);

if (s <0) {printf("сбой вызова сокета"); exit(-1);}

memset(&channel, 0, sizeof(channel));

channel.sin_family= AF_INET;

memcpy(&channel.sin_addr.s_addr, h->h_addr, h->h_length);

channel.sin_port= htons(SERVER_PORT);

c = connect(s, (struct sockaddr *) &channel, sizeof(channel));

if (c < 0) {printf("сбой соединения"); exit(-1);}

/* Соединение установлено. Отправляется имя файла с нулевым байтом на конце */

write(s, argv[2], strlen(argv[2])+1);

/* Получить файл, записать на стандартное устройство вывода */

while (1) {

bytes = read(s, buf, BUF_SIZE); /* Читать из сокета */

if (bytes <= 0) exit(0); /* Проверка конца файла */

write(1, buf, bytes); /* Записать на стандартное устройство вывода */

}

}

Илл. 6.6. Клиентская программа для использования сокетов. Серверная программа представлена на следующей странице

#include /* Серверная программа */

#include

#include

#include

#include

#include

#include

#include

#define SERVER_PORT 8080 /* По договоренности между клиентом и сервером */

#define BUF_SIZE 4096 /* Размер передаваемых блоков */

#define QUEUE_SIZE 10

int main(int argc, char *argv[])

{ int s, b, l, fd, sa, bytes, on = 1;

char buf[BUF_SIZE]; /* буфер для исходящего файла */

struct sockaddr_in channel; /* содержит IP-адрес */

/* Создать структуру адреса для привязки к сокету */

memset(&channel, 0, sizeof(channel)); /* Обнуление channel */

channel.sin_family = AF_INET;

channel.sin_addr.s_addr = htonl(INADDR_ANY);

channel.sin_port = htons(SERVER_PORT);

/* Пассивный режим. Ожидание соединения */

s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); /* Создать сокет */

if (s <0) {printf("сбой вызова сокета"); exit(-1);}

setsockopt(s, SOL_SOCKET, SO_REUSEADDR, (char *) &on, sizeof(on));

b = bind(s, (struct sockaddr *) &channel, sizeof(channel));

if (b < 0) {printf("сбой связывания"); exit(-1);}

l = listen(s, QUEUE_SIZE); /* Определение размера очереди */

if (l < 0) {printf("сбой ожидания"); exit(-1);}

/* Теперь сокет установлен и привязан. Ожидание и обработка соединения. */

while (1) {

sa = accept(s, 0, 0); /* Блокировка в ожидании запроса соединения */

if (sa < 0) {printf("сбой доступа"); exit(-1);}

read(sa, buf, BUF_SIZE); /* Считать имя файла из сокета */

/* Получить и возвратить файл. */

fd = open(buf, O_RDONLY); /* Открыть файл для обратной отправки */

if (fd < 0) {printf("сбой открытия файла");

while (1) {

bytes = read(fd, buf, BUF_SIZE); /* Читать из файла */

if (bytes <= 0) break; /* Проверка конца файла */

write(sa, buf, bytes); /* Записать байты в сокет */

}

close(fd); /* Закрыть файл */

close(sa); /* Разорвать соединение */

}

}

Сначала рассмотрим ту часть программы, которая описывает сервер. Она начинается с включения некоторых стандартных заголовков, последние три из которых содержат основные структуры данных и определения, относящиеся к интернету. Затем SERVER_PORT определяется как 8080. Значение выбрано случайным образом. Любое число от 1024 до 65 535 также подойдет, если только оно не используется другим процессом; порты с номерами 1023 и ниже зарезервированы для привилегированных пользователей.

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

После объявления локальных переменных начинается сама программа сервера. Вначале она инициирует структуру данных, которая будет содержать IP-адрес сервера. Эта структура вскоре будет привязана к серверному сокету. Вызов memset полностью обнуляет структуру данных. Последующие три присваивания заполняют три поля этой структуры. Последнее содержит порт сервера. Функции htonl и htons преобразуют значения в стандартный формат, что позволяет программе нормально выполняться на устройствах с представлением числовых разрядов little-endian (например, Intel x86) и big-endian (например, SPARC).

После этого сервер создает и проверяет сокет на ошибки (определяется по s < 0). В окончательной версии программы сообщение об ошибке может быть чуть более понятным. Вызов setsockopt нужен для того, чтобы порт мог использоваться несколько раз, а сервер — бесконечно, обрабатывая запрос за запросом. Теперь IP-адрес привязывается к сокету и выполняется проверка успешного завершения вызова bind. Конечным этапом инициализации является вызов listen. Он свидетельствует о готовности сервера к приему входящих вызовов и сообщает системе о том, что нужно ставить в очередь до QUEUE_SIZE вызовов, пока сервер обрабатывает текущий вызов. При заполнении очереди прибытие новых запросов спокойно игнорируется.

В этот момент сервер входит в основной цикл программы и уже из него не выходит. Этот цикл можно остановить только извне. Вызов accept блокирует сервер на то время, пока клиент пытается установить соединение. В случае успеха accept возвращает дескриптор сокета, который можно использовать для чтения и записи, аналогично тому, как файловые дескрипторы применяются для чтения и записи в каналы. Однако, в отличие от однонаправленных каналов, сокеты двунаправлены, поэтому для чтения (и записи) данных из соединения можно использовать sa (принятый сокет). Файловые дескрипторы канала могут применяться для чтения или записи, но не одновременно.

После установления соединения сервер считывает имя файла. Если оно еще недоступно, сервер блокируется, ожидая его. Получив имя файла, сервер открывает файл и входит в цикл, который читает блоки данных из файла и записывает их в сокет. Это продолжается до тех пор, пока не будут скопированы все запрошенные данные. Затем файл закрывается, соединение разрывается и начинается ожидание нового вызова. Данный цикл повторяется бесконечно.

Теперь рассмотрим часть кода, описывающую клиента. Чтобы понять, как работает программа, сначала необходимо разобраться, как она запускается. Если она называется client, ее типичный вызов будет выглядеть так:

client flits.cs.vu.nl /usr/tom/filename >f

Этот вызов сработает, только если сервер расположен по адресу flits.cs.vu.nl, файл /usr/tom/filename существует и у сервера есть доступ по чтению для этого файла. Если вызов произведен успешно, файл передается по интернету и записывается в f, после чего клиентская программа заканчивает свою работу. Поскольку серверная программа продолжает работать, клиент может быть запущен снова с новыми запросами на получение файлов.

Клиентская программа начинается с подключения файлов и объявлений. Прежде всего проверяется корректность числа аргументов (где argc = 3 означает, что была вызвана программа с указанием ее имени и двух аргументов). Обратите внимание на то, что argv[1] содержит имя сервера (например, flits.cs.vu.nl) и переводится в IP-адрес с помощью функции gethostbyname. Для поиска имени эта функция использует DNS. Межсетевые экраны мы изучим отдельно в главе 7.

Затем создается и инициализируется сокет, после чего клиент пытается установить TCP-соединение с сервером посредством connect. Если сервер включен, работает на указанном компьютере, соединен с SERVER_PORT и либо простаивает, либо имеет достаточно места в очереди listen (очереди ожидания), то соединение с клиентом будет рано или поздно установлено. По данному соединению клиент передает имя файла, записывая его в сокет.

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

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

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

Стоит заметить, что такой сервер построен далеко не по последнему слову техники. Осуществляемая проверка ошибок минимальна, а сообщения об ошибках реализованы весьма посредственно. Система будет обладать низкой производительностью, поскольку все запросы обрабатываются только последовательно (используется один поток запросов). Понятно, что ни о какой защите информации здесь говорить не приходится, а применение аскетичных системных вызовов UNIX не лучшее решение для достижения независимости от платформы. При этом делаются некоторые некорректные с технической точки зрения допущения. Например, что имя файла всегда поместится в буфер и будет передано без ошибок. Несмотря на эти недостатки, с помощью данной программы можно организовать полноценный работающий файл-сервер для интернета. Более подробную информацию вы найдете в работах Донаху и Калверта (Donahoo and Calvert; 2008, 2009), а также Стивенса и др. (Stevens et al., 2004).

Загрузка...