ЛАБОРАТОРНАЯ РАБОТА №3 Знакомство с библиотекой сокетов. Разработка простейших сетевых приложений типа клиент-сервер с применением протокола TCP ЦЕЛЬ РАБОТЫ 1. Изучить понятие сокетов. 2. Изучить механизм межсетевого взаимодействия на основе сокетов. 3. Ознакомиться с базовой функциональностью библиотеки winsocks. 4. Научиться разрабатывать сетевые приложения типа клиент-сервер с использованием протокола TCP. ПОНЯТИЕ СОКЕТОВ КАК СРЕДСТВА МЕЖПРОЦЕССОРНОГО И МЕЖСЕТЕВОГО ВЗАИМОДЕЙСТВИЯ Какие бы замечательные идеи в области телекоммуникаций, распределенных баз знаний или поисковых систем вам не пришли в голову, реализовать их на практике можно, лишь написав соответствующую программу. Основные операционные среды (*nix и Windows 2000+) содержат базовый механизм межсетевого взаимодействия на идеологии соединителей (socket). Эта технология была разработана в университете г. Беркли (США) для системы Unix, поэтому соединители иногда называют соединителями Беркли (Berkeley Sockets). Соединители реализуют механизм взаимодействия не только партнеров по телекоммуникациям, но и процессов в ЭВМ вообще. Socket API был впервые реализован в операционной системе Berkeley UNIX. Сейчас этот программный интерфейс доступен практически в любой модификации Unix, в том числе в Linux. Хотя все реализации чем-то отличаются друг от друга, основной набор функций в них совпадает. Изначально интерфейс сокетов был доступен, в основном, для программ на языках C, C++ и т.п., но в 1 настоящее время средства для работы с ними предоставляют многие языки (Perl, PHP, Python, Java и др.). Сокеты предоставляют весьма мощный и гибкий механизм межпроцессного взаимодействия (IPC – Inter-Process communication). Они могут использоваться для организации взаимодействия программ (в дальнейшем процессов) в рамках локального компьютера, локальной сети или посредством Internet. Это позволяет вам создавать распределённые приложения различной сложности. Кроме того, одним из самых главных достоинств данного интерфейса заключается в том, что он кроссплатформенный и позволяет организовывать взаимодействие между процессами, запущенными на различных ОС. Например, для ОС семейства Windows интерфейс носит название Window Sockets. Базовый интерфейс Berkeley Sockects позволяет без особых затруднений портировать программы с одной ОС на другую. В общем случае сокеты поддерживают многие стандартные сетевые протоколы (конкретный их список зависит от реализации) и предоставляют унифицированный интерфейс для работы с ними. Но наиболее широко распространены сокеты для работы в IP-сетях. Как видим, сокеты – весьма мощное и удобное средство для сетевого программирования. Понимание принципа работы с сокетами позволяет легко реализовывать всевозможные сетевые приложения, поддерживающие как стандартные протоколы, так и специализированные. ИСПОЛЬЗОВАНИЕ СОКЕТОВ Сокет (Socket) – это конечная точка сетевых коммуникаций. Он является чем-то вроде «портала», через которое можно отправлять байты во внешний мир. Приложение просто пишет поток данных в сокет; их дальнейшая буферизация, отправка и транспортировка осуществляется используемым стеком протоколов и сетевой 2 аппаратурой. Чтение данных из сокета происходит аналогичным образом. В программе сокет идентифицируется дескриптором. Программа получает дескриптор от операционной системы при создании сокета, а затем передаёт его сервисам Socket API для указания сокета, над которым необходимо выполнить то или иное действие. Программирование сокетов: базовые сведения В классическом варианте ядро сетевого приложения состоит из 2-х программ: клиента и сервера. Когда эти программы запускаются, создаются клиентский и серверный процессы, которые взаимодействуют друг с другом, обмениваясь сообщениями через сокеты. Клиент-сервер (англ. Сlient/Server) – сетевая архитектура, в которой устройства являются либо клиентами, либо серверами. Клиентом (front end) является запрашивающая машина (обычно ПК), сервером (back end) – машина, которая отвечает на запрос. Оба термина (клиент и сервер) могут применяться как к физическим устройствам, так и к программному обеспечению. Существует два вида приложений с архитектурой клиент/сервер. К первому виду относятся приложения, поддерживающие стандартный протокол, описанный в документах RFC. В этом случае как серверная, так и клиентская части приложения должны соответствовать всем описаниями, приведенным в RFC. Например, если приложение использует протокол FTP (его будем рассматривать на следующей лабораторной работе), то клиентская и серверная программы должны быть построены в соответствии с требованиями RFC 959. Следование стандартам позволяет независимым разработчикам создавать различные части одного и того же приложения. Например, web-браузер Mozilla способен успешно взаимодействовать с web-сервером IIS, а встроенный 3 Internet Explorer ftp-клиент успешно взаимодействует с FTP-сервером, работающем на платформе ОС Linux и т.д. Второй вид приложений с архитектурой клиент/сервер – нестандартные приложения. В таких приложениях поддержка клиентом и сервером каких-либо согласованных с RFC стандартов не требуется. При этом для разработки клиента и сервера, способного работать друг с другом разработчикам в любом случае требуется знать (или разработать) протокол взаимодействия. Наиболее распространенными и удобными для применения транспортными протоколами для прикладных сетевых приложений являются уже упоминавшиеся на предыдущих лабораторных работах протоколы TCP и UDP. Выбор того или иного протокола зависит напрямую от решаемой задачи. Основные области применения протоколов таковы: TCP используется для передачи потоковых данных, где достоверность получения корректных данных имеет высокое значение; UDP применим при организации взаимодействия посредством отдельных, зачастую не связанных друг с другом датаграмм. На данной лабораторной работе мы познакомимся с принципом создания клиент/серверных приложений с использованием TCPсокетов. Программирование TCP-сокетов Как уже отмечалось ранее, сетевые процессы взаимодействуют друг с другом при помощи сообщений, посылаемых через сокеты. Рассмотрим процесс взаимодействия клиента и сервера более подробно. В функции клиента входит инициирование соединения с сервером, а сервер должен быть готовым к установлению соединения. Это означает, что, во-первых, программа-сервер должна быть запущена раньше, чем клиент сделает попытку установить 4 соединения, и, во-вторых, что сервер должен располагать сокетом, с помощью которого устанавливается соединение. Первым действие клиентской программы является создание сокета, при этом программа указывает адрес серверного процесса, состоящий из IP-адреса, протокола (TCP) и порта процесса. После создания сокета клиентская сторона протокола TCP осуществляет процедуру т.н. «тройного рукопожатия» с сервером, оканчивающуюся установление соединения. Стоит отметить, что процедура рукопожатия никак не сказывается на работе приложения, т.к. выполняется прозрачно для него. В ходе процесса установления соединения на серверной стороне создается новый сокет, сопоставленный с конкретным клиентским процессом. С точки зрения приложения TCP-соединение является прямым дуплексным виртуальным каналом между сокетами соединения клиента и сервера. Например, клиент может осуществлять передачу любых байт через свой сокет, при этом протокол TCP гарантирует, что сервер получит эти байты через свой сокет без искажений и в том же порядке, в каком они были переданы. И что немаловажно в случае возникновения ошибок в среде передачи данных или разрыве соединения оба процесса будут уведомлены, что произошла ошибка при передаче данных. Поскольку сокеты играют центральную роль в работе приложений клиент/серверных, разработку таких приложений часто называют программированием сокетов. На рис. 1 приводится схема установления TCP-соединения и взаимодействия клиента и сервера. 5 С каждым сокет связываются три атрибута: домен, тип и протокол. Эти атрибуты задаются при создании сокета и остаются неизменными на протяжении всего времени его существования. Для создания сокета используется функция socket, имеющая следующий прототип. int socket(int domain, int type, int protocol); 6 Домен определяет пространство адресов, в котором располагается сокет, и множество протоколов, которые используются для передачи данных. Чаще других используются домены Unix и Internet, задаваемые константами AF_UNIX и AF_INET соответственно (префикс AF означает «address family» – «семейство адресов»). При задании AF_UNIX для передачи данных используется файловая система ввода/вывода Unix. В этом случае сокеты используются для межпроцессного взаимодействия на одном компьютере и не годятся для работы по сети. Константа AF_INET соответствует Internet-домену. Сокеты, размещённые в этом домене, могут использоваться для работы в любой IP-сети. Существуют и другие домены (например, AF_IPX для протоколов Novell, AF_INET6 для новой модификации протокола IP - IPv6 и т. д.). Тип сокета определяет способ передачи данных по сети. Чаще других применяются: SOCK_STREAM. Передача потока данных с предварительной установкой соединения. Обеспечивается надёжный канал передачи данных, при котором фрагменты отправленного блока не теряются, не переупорядочиваются и не дублируются. Этот тип сокетов является самым распространённым. SOCK_DGRAM. Передача данных в виде отдельных сообщений (датаграмм). Предварительная установка соединения не требуется. Обмен данными происходит быстрее, но является ненадёжным: сообщения могут теряться в пути, дублироваться и переупорядочиваться. Допускается передача сообщения нескольким получателям (multicasting) и широковещательная передача (broadcasting). SOCK_RAW. Этот тип присваивается низкоуровневым (т. н. «сырым») сокетам. Их отличие от обычных сокетов состоит в том, что с их помощью программа может взять на себя формирование некоторых заголовков, добавляемых к сообщению. 7 Не все домены допускают задание произвольного типа сокета. Например, совместно с доменом Unix используется только тип SOCK_STREAM. В тоже время, для Internet-домена можно задавать любой из перечисленных типов. В этом случае для реализации SOCK_STREAM используется протокол TCP, для реализации SOCK_DGRAM – протокол UDP, а тип SOCK_RAW используется для низкоуровневой работы с протоколами IP, ICMP и т. д. Наконец, последний атрибут определяет протокол, используемый для передачи данных. Как мы только что видели, часто протокол однозначно определяется по домену и типу сокета. В этом случае в качестве третьего параметра функции socket можно передать 0, что соответствует протоколу по умолчанию. Тем не менее, иногда (например, при работе с низкоуровневыми сокетами) требуется задать протокол явно. Числовые идентификаторы протоколов зависят от выбранного домена; их можно найти в документации. Адреса Прежде чем передавать данные через сокет, его необходимо связать с адресом в выбранном домене (эту процедуру называют именованием сокета). Иногда связывание осуществляется неявно (внутри функций connect и accept), но выполнять его необходимо во всех случаях. Вид адреса зависит от выбранного вами домена. В Internet-домене адрес задаётся комбинацией IP-адреса и 16-битного номера порта. IP-адрес определяет хост в сети, а порт - конкретный сокет на этом хосте. Протоколы TCP и UDP используют различные пространства портов. Для явного связывания сокета с некоторым адресом используется функция bind. Её прототип имеет вид: int bind(int sockfd, struct sockaddr *addr, int addrlen); В качестве первого параметра передаётся дескриптор сокета, который мы хотим привязать к заданному адресу. Второй параметр, 8 addr, содержит указатель на структуру с адресом, а третий – длину этой структуры. Структура выглядит следующим образом: struct sockaddr{ unsigned short sa_family; // Семейство адресов, AF_xxx char sa_data[14]; // 14 байт для хранения адреса }; Поле sa_family содержит идентификатор домена, тот же, что и первый параметр функции socket. В зависимости от значения этого поля по-разному интерпретируется содержимое массива sa_data. Разумеется, работать с этим массивом напрямую не очень удобно, поэтому вы можете использовать вместо sockaddr одну из альтернативных структур. Для Internet домена это sockaddr_in. При передаче в функцию bind указатель на эту структуру нужно приводить к указателю на sockaddr. Рассмотрим для примера структуру sockaddr_in. struct sockaddr_in { short int sin_family; // Семейство адресов unsigned short int sin_port; // Номер порта struct in_addr sin_addr; // IP-адрес unsigned char sin_zero[8]; // "Дополнение" до размера // структуры sockaddr }; Здесь поле sin_family соответствует полю sa_family в sockaddr, в sin_port записывается номер порта, а в sin_addr – IP-адрес хоста. Поле sin_addr само является структурой, которая имеет вид: struct in_addr { unsigned long s_addr; }; Для обратной совместимости (на уровне исходных кодов) со старыми приложениями единственное поле заключено в структуру, до этого же in_addr представляла собой объединение (union), содержащее гораздо большее число полей. 9 Сетевой порядок байт Передача от одного вычислительного комплекса к другому символьной информации, как правило (когда один символ занимает один байт), не вызывает проблем. Однако для числовой информации ситуация усложняется. Как известно, порядок байт в целых числах, представление которых занимает более одного байта, может быть для различных компьютеров неодинаковым. Есть вычислительные системы, в которых старший байт числа имеет меньший адрес, чем младший байт (big-endian byte order), а есть вычислительные системы, в которых старший байт числа имеет больший адрес, чем младший байт (little-endian byte order). Различие представлено в табл. 1. Таблица 1. Хранение целых чисел в памяти Число little-endian big-endian 0xAABBCCDD 0xAABBCCDD Последовательность байт в памяти DD CC BB AA AA BB CC DD При передаче целой числовой информации от машины, имеющей один порядок байт, к машине с другим порядком байт мы можем неправильно истолковать принятую информацию. Для того чтобы этого не произошло, было введено понятие сетевого порядка байт, т.е. порядка байт, в котором должна представляться целая числовая информация в процессе передачи ее по сети (на самом деле – это big-endian byte order). Целые числовые данные из представления, принятого на компьютере-отправителе, переводятся пользовательским процессом в сетевой порядок байт, в таком виде путешествуют по сети и переводятся в нужный порядок байт на машине-получателе процессом, которому они предназначены. Для перевода целых чисел из машинного представления в сетевое и 10 обратно используется четыре функции: htons(Host TO Network Short), htonl(Host TO Network Long), ntohs(Network TO Host Short), ntohl(Network TO Host Long). При указании IP-адреса и номера порта необходимо преобразовать число из порядка хоста в сетевой (функции htonl и htons соответственно) Установка соединения (сервер) Установка соединения на стороне сервера состоит из четырёх этапов, все они являются обязательными. Сначала сокет создаётся и привязывается к локальному адресу. Если компьютер имеет несколько сетевых интерфейсов с различными IP-адресами, вы можете принимать соединения только с одного из них, передав его адрес функции bind. Если же вы готовы соединяться с клиентами через любой интерфейс, задайте в качестве адреса константу INADDR_ANY. Что касается номера порта, вы можете задать конкретный номер или 0 (в этом случае система сама выберет произвольный неиспользуемый в данный момент номер порта). На следующем шаге создаётся очередь запросов на соединение. При этом сокет переводится в режим ожидания запросов со стороны клиентов. Всё это выполняет функция listen. int listen(int sockfd, int backlog); Первый параметр – дескриптор сокета, а второй задаёт размер очереди запросов. Каждый раз, когда очередной клиент пытается соединиться с сервером, его запрос ставится в очередь, так как сервер может быть занят обработкой других запросов. Если очередь заполнена, все последующие запросы будут игнорироваться. Когда сервер готов обслужить очередной запрос, он использует функцию accept. int accept(int sockfd, void *addr, int *addrlen); Функция accept создаёт для общения с клиентом новый сокет и возвращает его дескриптор. Параметр sockfd задаёт слушающий 11 сокет. После вызова он остаётся в слушающем состоянии и может принимать другие соединения. В структуру, на которую ссылается addr, записывается адрес сокета клиента, который установил соединение с сервером. В переменную, адресуемую указателем addrlen, изначально записывается размер структуры; функция accept записывает туда длину, которая реально была использована. Если вас не интересует адрес клиента, вы можете просто передать NULL в качестве второго и третьего параметров. Установка соединения (клиент) На стороне клиента для установления соединения используется функция connect, которая имеет следующий прототип. int connect(int sockfd, struct sockaddr *serv_addr, int addrlen); Здесь sockfd – сокет, который будет использоваться для обмена данными с сервером, serv_addr содержит указатель на структуру с адресом сервера, а addrlen – длину этой структуры. Обычно сокет не требуется предварительно привязывать к локальному адресу, так как функция connect сделает это за вас, подобрав подходящий свободный порт. Вы можете принудительно назначить клиентскому сокету некоторый номер порта, используя bind перед вызовом connect. Делать это следует в случае, когда сервер соединяется с только с клиентами, использующими определённый порт (однако такая ситуация чрезвычайно редка). В остальных случаях проще и надёжнее предоставить системе выбрать порт за вас. Обмен данными После того как соединение установлено, можно начинать обмен данными. Для этого используются функции send и recv. Функция send используется для отправки данных и имеет следующий прототип. int send(int sockfd, const void *msg, int len, int flags); 12 Здесь sockfd – это, как всегда, дескриптор сокета, через который мы отправляем данные, msg – указатель на буфер с данными, len – длина буфера в байтах, а flags – набор битовых флагов, управляющих работой функции (если флаги не используются, передайте функции 0). Вот некоторые из них (полный список можно найти в документации): MSG_OOB. Предписывает отправить данные как срочные (out of band data, OOB). Концепция срочных данных позволяет иметь два параллельных канала данных в одном соединении. Иногда это бывает удобно. Например, Telnet использует срочные данные для передачи команд типа Ctrl+C. В настоящее время использовать их не рекомендуется из-за проблем с совместимостью (существует два разных стандарта их использования, описанные в RFC793 и RFC1122). Безопаснее просто создать для срочных данных отдельное соединение. MSG_DONTROUTE. Запрещает маршрутизацию пакетов. Нижележащие транспортные слои могут проигнорировать этот флаг. Функция send возвращает число байтов, которое на самом деле было отправлено (или -1 в случае ошибки). Это число может быть меньше указанного размера буфера. Если вы хотите отправить весь буфер целиком, вам придётся написать свою функцию и вызывать в ней send, пока все данные не будут отправлены. Она может выглядеть примерно так: int sendall(int s, char *buf, int len, int flags) { int total = 0; int n; while(total < len) { n = send(s, buf+total, len-total, flags); if(n == -1) break; total += n; } return (n==-1 ? -1 : total); 13 } Использование sendall ничем не отличается от использования send, но она отправляет весь буфер с данными целиком. Для чтения данных из сокета используется функция recv. int recv(int sockfd, void *buf, int len, int flags); В целом её использование аналогично send. Она точно так же принимает дескриптор сокета, указатель на буфер и набор флагов. Флаг MSG_OOB используется для приёма срочных данных, а MSG_PEEK позволяет «подсмотреть» данные, полученные от удалённого хоста, не удаляя их из системного буфера (это означает, что при следующем обращении к recv вы получите те же самые данные). Полный список флагов можно найти в документации. По аналогии с send функция recv возвращает количество прочитанных байтов, которое может быть меньше размера буфера. Вы без труда сможете написать собственную функцию recvall, заполняющую буфер целиком. Существует ещё один особый случай, при котором recv возвращает 0. Это означает, что соединение было разорвано. Закрытие сокета Закончив обмен данными, необходимо закрыть соединение и сам сокет. Это делается при помощи функции closesocket. Это приведёт к разрыву соединения. int closesocket (int fd); Вы также можете запретить передачу данных в каком-то одном направлении, используя shutdown. int shutdown(int sockfd, int how); Параметр how может принимать одно из следующих значений: 0 - запретить чтение из сокета 1 - запретить запись в сокет 2 - запретить и то и другое Хотя после вызова shutdown с параметром how, равным 2, вы больше не сможете использовать сокет для обмена данными, вам 14 всё равно потребуется вызвать closesocket, чтобы освободить связанные с ним системные ресурсы. Принцип установления TCP-соединения на серверной стороне Пара сокетов (sockets pair) для соединения TCP – это кортеж (группа взаимосвязанных элементов данных или записей) из 4-х элементов, определяющих две конечные точки соединения: локальный IP-адрес, локальный порт, удаленный IP-адрес и удаленный порт. Два значения, идентифицирующих конечную точку, – IP-адрес и номер порта – часто называют TCP-сокетом (на самом деле понятие пары сокетов применимо и для протокола UDP). Рассмотрим подробнее, что на самом деле происходит на стороне клиента и сервера при установлении TCP-соединения. Для наглядности рассмотрим поэтапно ситуацию установления соединения на простом примере. Пускай у нас на машине с адресом 192.168.1.2 запущен Webсервер. Он ожидает подключение клиента на предопределенном 80м порту. Эта ситуация изображена на рис. 2. Рис. 2. Сервер с пассивным открытием на порте 80 Обозначение { * : 80 , * : * } используется для указания пары сокетов сервера. Сервер ожидает запроса соединения на всех локальных интерфейсах (первая звездочка) на порт 80. Удаленный IPадрес и порт пока не известны, поэтому они обозначаются как «* : *». Такая структура называются прослушивающим сокетом. 15 После запуска на узле 192.168.1.11 клиента и выполнения активного соединения с IP-адресом сервера 192.168.1.2 клиентскому сокету в данном примере назначается динамический порт 1800. Данная ситуация отображена на рис. 3. Рис. 3. Запрос на установление соединения от клиента 192.168.1.11 После принятия соединения на стороне сервера функция accept возвращает новую структуру сокета. Вновь созданный сокет носит название присоединенного. Обратите внимание, что как прослушивающий, так и присоединенный сокеты используют один и тот же локальный порт (80), также обратите внимание, что структура сокета теперь полностью заполнена – {192.168.1.2:80, 192.168.1.11:1800}. В дальнейшем вся работа по потоковой передачи байт осуществляется с использованием только присоединенного сокета (см. рис. 4). Рис. 4. Создание присоединенного сокета 16 После принятия соединения сервер может вновь перейти в режим прослушивания сокета (например, если программа многопоточная или используются возможности асинхронных сокетов), ожидая подключения новых клиентов. Ситуация работы сервера с 2-мя клиентами приведена на рис. 5. Рис. 5. Работа с двумя клиентами одновременно Berkeley Sockets API Ниже приведены прототипы соответствующих функций Berkeley Sockets API на языке C для ОС Windows. Более подробную информацию можно найти в справочной системе (например, MSDN). 17 #include <Winsock2.h> int gethostname (char * name, int namelen); SOCKET socket (int af, int type, int protocol); int bind (SOCKET s, const struct sockaddr *addr, int namelen); int listen (SOCKET s, int backlog); SOCKET accept(SOCKET s, struct sockaddr *addr, int *addrlen); int connect (SOCKET s, const struct sockaddr *name, int namelen); int recv (SOCKET s, char * buf, int len, int flags); int recvfrom (SOCKET s, char * buf, int len, int flags, struct sockaddr *from, int * fromlen); int send (SOCKET s, const char * buf, int len, int flags); int sendto (SOCKET s, const char * buf, int len, int flags, const struct sockaddr *to, int tolen); int setsockopt (SOCKET s, int level, int optname, const char * optval, int optlen); int shutdown (SOCKET s, int how); int closesocket (SOCKET s); unsigned long inet_addr (const char * cp); char * inet_ntoa (struct in_addr in); u_long htonl (u_long hostlong); u_short htons (u_short hostshort); u_long ntohl (u_long netlong); u_short ntohs (u_short netshort); int select (int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, const struct timeval *timeout); ПРИМЕР КЛИЕНТ-СЕРВЕРНОГО ПРИЛОЖЕНИЯ Для изучения техники создания простых сетевых приложений Вам предлагается рассмотреть пример простого сетевого сервиса (сервера), работающего под ОС Windows. В функции данного сервера входит прослушивание TCP порта под номером 1500 (может быть 18 легко изменен) в ожидании подключений некоторого TCP-клиента (в простейшем случае netcat, putty и др.) После подключения клиента сервер может получать строки от него, преобразовывать их к верхнему регистру и отсылать обратно. Также поддерживается ряд служебных команд (команды отправляются без кавычек): «info» – возвращает информацию о сервере; «exit» – завершить текущую сессию с сервером; «shutdown» – завершить текущую сессию и остановить сервер. Сервер написан на языке С++. Исходный код сервера приведен ниже. Для компиляции можно (рекомендуется) использовать консольный компилятор Borland C++ 5.5, который можно взять на сервере (ftp://iipo.tu-bryansk.ru/pub/Drozdov/Net/Bin/Bcc55/). Однако, без всяких проблем можно использовать любую среду разработки, например: Borland CBuilder, Microsoft Visual Studio и т.д., предварительно создав соответствующий проект. В любом случае для успешной линковки программы с функциями работы с сокетами необходимо подключить библиотеку Ws2_32.lib. С использование упомянутого выше компилятора Borland C++ 5.5 программа может быть собрана следующим образом: C:> bcc32 -oserver.exe server.cpp Ws2_32.lib Ниже приводится детально прокомментированный исходный код TCP-сервера. /* Простой TCP-сервер Поучает текстовую строку, преобразует ее в верхний регистр и возвращает клиенту результат. Файл: server.cpp Пример компиляции: bcc32 -oserver.exe server.cpp Ws2_32.lib */ #include <Winsock2.h> #include <stdio.h> 19 #include <stdlib.h> // TCP-порт сервера #define SERVICE_PORT 1500 int send_string(SOCKET s, const char * sString); // int main(void) { SOCKET S; //дескриптор прослушивающего сокета SOCKET NS; //дескриптор присоединенного сокета sockaddr_in serv_addr; WSADATA wsadata; char sName[128]; bool bTerminate = false; // Инициализируем библиотеку сокетов WSAStartup(MAKEWORD(2,2), &wsadata); // Пытаемся получить имя текущей машины gethostname(sName, sizeof(sName)); printf("\nServer host: %s\n", sName); // Создаем сокет // Для TCP-сокета указываем параметр SOCK_STREAM // Для UDP - SOCK_DGRAM if ((S = socket(AF_INET, SOCK_STREAM, 0)) == INVALID_SOCKET) { fprintf(stderr, "Can't create socket\n"); exit(1); } // Заполняем структуру адресов memset(&serv_addr, 0, sizeof(serv_addr)); serv_addr.sin_family = AF_INET; // Разрешаем работу на всех доступных сетевых интерфейсах, // в частности на localhost serv_addr.sin_addr.s_addr = INADDR_ANY; // Обратите внимание на преобразование порядка байт serv_addr.sin_port = htons((u_short)SERVICE_PORT); // Связываем сокет с заданным сетевым интерфесом и портом if (bind(S, (sockaddr*)&serv_addr, sizeof(serv_addr)) == INVALID_SOCKET) { fprintf(stderr, "Can't bind\n"); exit(1); } // Переводим сокет в режим прослушивания заданного порта // с максимальным количеством ожидания запросов на соединение 5 if (listen(S, 5) == INVALID_SOCKET) 20 { fprintf(stderr, "Can't listen\n"); exit(1); } printf("Server listen on %s:%d\n", inet_ntoa(serv_addr.sin_addr), ntohs(serv_addr.sin_port)); // Основной цикл обработки подключения клиентов while (!bTerminate) { printf("Wait for connections.....\n"); sockaddr_in clnt_addr; int addrlen = sizeof(clnt_addr); memset(&clnt_addr, 0, sizeof(clnt_addr)); // Переводим сервис в режим ожидания запроса на соединение. // Вызов синхронный, т.е. возвращает управление только при // подключении клиента или ошибке NS = accept(S, (sockaddr*)&clnt_addr, &addrlen); if (NS == INVALID_SOCKET) { fprintf(stderr, "Can't accept connection\n"); break; } // Получаем параметры присоединенного сокета NS и // информацию о клиенте addrlen = sizeof(serv_addr); getsockname(NS, (sockaddr*)&serv_addr, &addrlen); // Функция inet_ntoa возвращает указатель на глобальный буффер, // поэтому использовать ее в одном вызове printf не получится printf("Accepted connection on %s:%d ", inet_ntoa(serv_addr.sin_addr), ntohs(serv_addr.sin_port)); printf("from client %s:%d\n", inet_ntoa(clnt_addr.sin_addr), ntohs(clnt_addr.sin_port)); // Отсылаем вводную информацию о сервере send_string(NS, "* * * Welcome to simple UPCASE TCP-server * * *\r\n"); // char sReceiveBuffer[1024] = {0}; // Получаем и обрабатываем данные от клиента while (true) { int nReaded = recv(NS, sReceiveBuffer, sizeof(sReceiveBuffer)-1, 0); // В случае ошибки (например, отсоединения клиента) выходим if (nReaded <= 0) break; // Мы получаем поток байт, поэтому нужно самостоятельно // добавить завержающий 0 для ASCII строки sReceiveBuffer[nReaded] = 0; 21 // Отбрасываем символы превода строк for (char* pPtr = sReceiveBuffer; *pPtr != 0; pPtr++) { if (*pPtr == '\n' || *pPtr == '\r') { *pPtr = 0; break; } } // Пропускаем пустые строки if (sReceiveBuffer[0] == 0) continue; printf("Received data: %s\n", sReceiveBuffer); // Анализируем полученные команды или преобразуем текст в верхний регистр if (strcmp(sReceiveBuffer, "info") == 0) { send_string(NS, "Test TCP-server.\r\n"); } else if (strcmp(sReceiveBuffer, "exit") == 0) { send_string(NS, "Bye...\r\n"); printf("Client initialize disconnection.\r\n"); break; } else if (strcmp(sReceiveBuffer, "shutdown") == 0) { send_string(NS, "Server go to shutdown.\r\n"); Sleep(200); bTerminate = true; break; } else { // Преобразовываем строку в верхний регистр char sSendBuffer[1024]; _snprintf(sSendBuffer, sizeof(sSendBuffer), "Server reply: %s\r\n", strupr(sReceiveBuffer)); send_string(NS, sSendBuffer); } } // закрываем присоединенный сокет closesocket(NS); printf("Client disconnected.\n"); } // Закрываем серверный сокет closesocket(S); // освобождаем ресурсы библиотеки сокетов WSACleanup(); return 0; } 22 // Функция отсылки текстовой ascii строки клиенту int send_string(SOCKET s, const char * sString) { return send(s, sString, strlen(sString), 0); } TCP-клиенты В качестве готового TCP-клиента рекомендуется использовать удобную программу PuTTY в режиме работы raw (данные не модифицируются, а передаются и получаются «как есть» – чистый TCP-клиент).. Также возможно применение специализированной утилиты netcat. Использование стандартного клиента telnet в windows возможно, но некоторые команды могут некорректно обрабатываться по причине того, что данный клиент подразумевает работу только с протоколом telnet. Универсальный TCP-клиент PuTTY. PuTTY – свободно распространяемый клиент для протоколов SSH, Telnet, rlogin и чистого TCP. Изначально разрабатывался для Windows, однако позднее портирован на Unix. В разработке находятся порты для Mac OS и Mac OS X. Сторонние разработчики выпустили неофициальные порты на другие платформы, такие как мобильные телефоны под управлением Symbian OS и коммуникаторы с Windows Mobile. Программа выпускается под лицензией MIT. Программу можно свободно скачать с официальной странички http://www.chiark.greenend.org.uk/~sgtatham/putty/. А также она доступна на кафедральном сервере. Запуск и тестирование сервера Сервер запускается и работает в консольном режиме. На рис. 6 приведено состояние сервера сразу после запуска. 23 Рис. 6. Запуск сервера Для тестирования работы сервера к нему необходимо подключиться TCP-клиентом. В рассматриваемом примере будем использовать PuTTY и netcat. На рис. 7 представлены основные параметры настроек PuTTY для подключения к серверу. В самом простом случае клиент и сервер запускаются на одной машине, поэтому в качестве адреса сервера указываем localhost (127.0.0.1). Указываем порт (1500) и выбираем тип протокола (RAW – чистый TCP). Подключившись к серверу (Open) мы можем вводить поддерживаемые команды или произвольные текстовые строки. Рис. 7. Подключение к серверу при помощи PuTTY 24 На рис. 8 представлена короткая сессия работы с сервером. В данном примере мы послали команду «info», на которую получили ответ «Text TCP-server.». Послав произвольную строку «text string», получили ответ в виде этой же строки, но сконвертированной в верхний регистр. На команду «exit» сервер произвел отключение клиента. На рис. 9 показан подобный пример работы, но с использованием утилиты netcat (nc.exe). Рис. 8. Пример работы с сервером с использованием утилиты PuTYY Рис. 9. Пример работы с сервером с использованием утилиты netcat 25 На рис. 10 представлена сессия работы сервера поочередно с двумя указанными выше клиентами (с локальной машины и с удаленной – 192.168.1.8). Рис. 10. Сессия работы сервера Работа по стандартным протоколам и параллельные серверы Мы рассмотрели использование сокетов для реализации собственного простейшего протокола. В тоже время сокеты могут использоваться при написании приложений, работающих по стандартным протоколам прикладного уровня Internet. При этом взаимодействие клиента и сервера происходит по той же самой схеме, что и взаимодействие эхо-сервера с клиентом в нашем примере. Разница в том, что данные, которыми обмениваются клиент и сервер, интерпретируются в соответствии с предписаниями соответствующего протокола (RFC). 26 Изучению самых распространенных протоколов в Internet – FTP, HTTP, SMTP – будет посвящен рад последующих лабораторных работ. Рассмотренный с вами пример очень прост. В частности с ним одновременно может работать только один клиент. Только после его отключения соединение принимается от другого и т.д. В дальнейшем на лабораторных работах мы рассмотрим, каким образом можно реализовывать параллельные сетевые сервисы. КОНТРОЛЬНЫЕ ВОПРОСЫ 1. Что такое сокет? 2. Что такое Sockets API? 3. Почему при использовании сетевых протоколов необходимо учитывать сетевой порядок байт? 4. Какими параметрами определяются взаимодействующие сокеты? 5. Перечислите основные этапы установления TCP-соединения. 6. Что означает термин «протокол рукопожатия»? 7. В чем отличие прослушивающего и присоединенного сокетов? 8. Зачем за стандартными сетевыми сервисами закрепляют строго определенные порты? 9. Какими данными позволяют обмениваться TCP-сокеты? 10. Каким образом при взаимодействии двух хостов вы определяете, какая из сторон является серверная, а какая клиентская? 11. Рассмотренный вариант Berkeley Sockets API является синхронным или асинхронным? 27 ЗАДАНИЕ НА ЛАБОРАТОРНУЮ РАБОТУ 1. Изучить исходный код рассмотренного TCP-сервера. Скомпилировать и протестировать сетевой сервис. Утилиты PuTTY и Netcat 2. Добавить в сервис поддержку дополнительной команды time, ответом на которую будет строка со временем на машине с запущенным сервисом (например, «System time: 8/04/2007 22:30»). 3. Разработать простое приложение-клиент, для работы с предложенным сервером (вместо netcat и PuTTY). Протестировать приложение. В качестве руководства следует использовать схему, приведенную на рис 1. 4. Определить параметры соединения – номера портов с обеих сторон. 5. С использованием сниффера Wireshark изучить базовый механизм работы протокола TCP. Зарисовать диаграмму взаимодействия клиента и сервера, в частности, указав служебные команды протокола TCP. 28