С++. Сетевое программирование. Часть 1
Сетевое программирование... Я думаю, что актуальность этой темы не стоит объяснять, и так понятно, что сегодня сетевые технологии находятся на достаточно высоком уровне развития. А это значит, что программисты в этой области нужны не менее чем в других областях. Вообще, сетевое программирование кажется легче, чем оно есть на самом деле. WinSock функции, которые ты будешь использовать для коммуникации через сеть, довольно просты в использовании, но многие забывают, что просто вызов функции не сделает всю работу. Первые попытки программирования с использованием WinSock часто заканчиваются программами, которые "иногда" не работают или завершаются необъяснимыми ошибками.
Сетевое программирование - это больше чем просто получение или отправление данных. Например, тебе придется столкнуться с синхронизацией: все ли данные отправлены (или получены)? Готова ли программа принять следующую "порцию" данных? Другая проблема - сверка полученных данных, разбор этих данных, предотвращение угроз безопасности и многое другое.
Поэтому ты заметишь, что первые главы не содержат много кода. Я решил начать с основ сетей и принципов работы сетевого программирования. Не пропускай этот материал, очень важно прочесть его прежде, чем начнешь писать программы.
Весь материал сфокусирован на аспектах сетевого программирования. Я предполагаю, что ты достаточно хорошо владеешь языком программирования (С++), что бы успешно продвигаться по материалу. Что ж...приступим.
Сети и протоколы
Любой из вас уже знает что-нибудь про сети, серверы, клиенты и т.д. Но все же я решил начать с введения в организацию и принцип работы компьютерных сетей, чтобы быть уверенным, что ты знаешь достаточно для сетевого программирования. Для сетевого программирования тебе не понадобятся все детали, упомянутые ниже в этом разделе, но лишними эти знания точно не будут.
Ты, наверное, знаешь, что такое «сеть». Это - несколько компьютеров, соединенных между собой таким образом, что они могут обмениваться данными. Существует несколько типов сетей, такие как LAN (Local Area Network), WAN (Wide Area Network) и, конечно же, Internet.
Что бы удостоверится, что весь трафик проходит без искажений, сети полагаются на протоколы. Протокол – это набор правил, согласно которым данные передаются по сети. Из определения понятно, что протокол описывает, как «общаться» через сеть. Протокол подобен человеческому языку: почти каждый человек с рождения может издавать и слышать какие-то звуки (электронные сигналы), но люди не будут понимать друг друга, если они не общаются в соответствии с определенным языком, который понятен им обоим (протокол).
Ethernet
Ethernet — это протокол канального уровня, используемый подавляющим большинством современных локальных сетей. Сеть использует несколько слоев протокола, каждый из которых выполняет свою задачу в коммуникационном процессе. Наиболее используемая конфигурация таких протоколов - ethernet LAN с TCP/IP.
MAC
Низший слой Ethernet интерфейса. Это - аппаратный уровень, называемый уровнем медиа доступа (Media Access Layer, сокращенно MAC). Этим слоем чаще всего является сетевая карта, которая заботится о конвертации данных в электронные сигналы и передаче их в нужное место. Естественно, что пакеты, отправленные по сети, должны преодолеть определенное расстояние до нужного адресата. Разные слои протокола Ethernet имеют разные методы адресации. На нижнем, MAC уровне, адресация осуществляется с помощью MAC-адресов. MAC-адрес – это 48-битовый идентификатор, «вшитый» в каждую сетевую карту. Проще говоря, MAC-адрес – это физический адрес сетевой карты. Обычно MAC-адреса записываются в виде разделенных шестнадцатеричных чисел: 14:74:A0:17:95:D7.
Чтобы отправить данные на другую сетевую карту, данные должны включать в себя MAC-адрес получателя. В локальных сетях (LANs) используется простой метод отправки данных нужному адресату: широковещание (broadcasting). Это значит, что твоя сетевая карта посылает пакет всем другим сетевым адаптерам, которые могут его принять. Каждый принявший адаптер «смотрит» на MAC-адрес конечного адресата и только в случае совпадения этого MAC-адреса со своим собственным сохраняет данные в буфер. По очевидным причинам за пределами LAN (WAN и Internet) этот метод не используется.
IP
Сразу над аппаратным уровнем расположен IP уровень. Так же как и MAC уровень, IP тоже имеет свой способ адресации. Для опознания компьютеров на этом слое используются IP адреса. IP адрес состоит из 4-х чисел от 0 до 225, разделенных точками. В отличии от MAC адресов IP адреса устанавливаются на программном, а не аппаратном, уровне. Подобно предшествующему уровню (забегая вперед, скажу, что и последующим тоже) на IP уровне к отправляемым данным прикрепляется IP адрес получателя (и отправителя).
TCP
Следующий слой – TCP слой (или, в качестве альтернативы, UDP). В дополнении к адресу, TCP добавляет порт, по которому должна быть передана информация. Если IP-адреса используются для определения компьютера, которому надо передать данные, то порт определяет, какой запущенный процесс должен принять эти данные. Номер порта 16 битовый, поэтому ограничен 65536 значениями.
Множество портов связаны с определенными службами. Например, www использует 80 порт, FTP – 21, e-mail использует 25 (SMTP) и 101 (POP). Но ничего не мешает выбрать тебе любой другой порт. Хорошим стилем считается использование в своем приложении портов, начиная от 1024.
В отличии от IP уровня, который не заботится об успешной передаче, TCP уровень делает именно это. TCP слой обеспечивает не только прибытие данных по назначению, но и сохранность этих данных в ходе передачи. Также этот слой позволяет получателю контролировать поток данных, т.е. получатель может решить, когда получать данные. Если данные «потерялись» во время передачи, TCP отправляет эти данные заново. Еще одной особенностью TCP является возможность упорядочивать пакеты, в случае если они пришли не в том порядке, в котором были отправлены. Это делает нашу жизнь легче, т.к. мы уверены, что данные не только придут, но и придут в нужном порядке. UDP, как альтернатива TCP, не обладает такими свойствами и не может гарантировать полную передачу данных. UDP обладает рядом других полезных свойств, которых лишен TCP. Поэтому этот протокол тоже имеет право на жизнь. Я не буду писать про UDP в этом разделе, т.к. это не входит в ряд необходимой, для понимания этого материла, информации.
Программное обеспечение
Последний уровень - это сетевое ПО. В Windows твое приложении взаимодействует с TCP не напрямую, а с помощью WinSock API. Программный уровень обеспечивает очень простой и удобный способ взаимодействия с сетью. Благодаря всем нижележащим слоям, тебе не надо волноваться о пакетах, размерах пакетов, повреждении или потере данных и т.д. За тебя все сделает ОС.
Стек интерфейса Ethernet
Рисунок 1. Процесс передачи данных по сети
На рисунке 1 показана инкапсуляция каждого слоя в протоколе Ethernet. Все начинается с программного уровня, в котором содержатся данные для передачи по сети. Сначала эти данные достигают TCP уровня, на котором к ним (данным) добавляется начальный и конечный номер порта. Потом IP слой добавляет начальный и конечный адреса. В конце концов, данные подключают к себе MAC адреса отправителя и получателя. Т.е. на картинке показан процесс передачи данных по проводам. Как ты мог заметить, этот процесс переполняет передаваемые данные (на входе у нас один «прямоугольник», а на выходе четыре). Это переполнение может быть минимизировано с помощью выделения достаточно большого объема памяти для этих данных. К счастью WinSock сделает это за нас.
Теперь, когда ты знаешь основные уровни сетевого интерфейса, я расскажу про базовые принципы, касающиеся соединений, протоколов программного уровня и имен серверов.
DNS
Аббревиатура DNS расшифровывается как Domain Name System (система доменных имен). Эта система применяется для преобразования имени сервера в его IP адрес. Поскольку не всем и не всегда легко запомнить IP адрес, была создана другая система наименований. Теперь вместо IP адреса определенного компьютера в сети можно использовать его имя (аналогичная ситуация с сайтами в интернете). Когда ты подключаешься к какому-нибудь сайту, тебе требуется его IP адрес. То есть, если в строку браузера ты ввел имя сервера, как например www.google.com , то сначала будет произведен поиск IP адреса, соответствующего имени google. Это как раз тот момент, когда в дело вступает DNS. Твой компьютер отправляет поисковой запрос с именем сервера в DNS твоего провайдера. Если DNS может обнаружить имя сервера, то тебе отправляется соответствующий IP адрес.
Интернет соединения
TCP/IP – протокол, ориентированный на соединении. Соединение осуществляется между двумя устройствами, каждое из которых использует собственный IP адрес и порт. Обычно одно устройство называют клиентом, другое – сервером.
Рисунок 2. Соединение
Клиент – это «сторона» соединения, которая запрашивает определенные данные, а сервер отвечает в соответствии с этими запросами. Например, когда открывается сайт, интернет-обозреватель выступает в роли клиента, а сервер, на котором расположен сайт, - в роли сервера. Обозреватель устанавливает соединение с сервером и запрашивает определенные данные. В ответ на это сервер посылает обратно запрос и запрашиваемую информацию.
Сервер непрерывно ждет поступающие соединения. Это называется прослушиванием (listening), которое всегда происходит по определенному IP адресу и порту. Клиент всего лишь подключается, когда необходимо, т.к. клиент всегда инициатор соединения и отправитель запросов. Чтобы создать соединение, клиент должен знать IP адрес и номер порта, по которым сервер производит прослушивание.
И клиент, и сервер используют определенные IP адрес и номер порта, но обычно у сервера порт фиксированный. Стандартный порт для сайтов – 80. Например, на момент написания статьи, у сайта www.google.ru был IP адрес 74.125.79.147 и порт 80. Каждый подключившийся к сайту клиент устанавливал соединение с этим IP адресом по 80-му порту. Таким образом, сервер может принимать множество клиентов по одному порту. На клиентской «стороне» соединения порт неважен, т.е. может быть использован любой порт. Некоторые думают, что номер порта с обеих «сторон» соединения должен быть одинаков. Это не так. Введи в командной строке «netstat –an» после того как зайдешь на сайт. Ты увидишь примерно следующее: TCP xxx.xxx.xxx.xxx:2672 74.125.79.147:80 ESTABLISHED. xxx.xxx.xxx.xxx - это мой IP адрес, 74.125.79.147 – адрес google, значение после двоеточия – номер порта. Как ты можешь заметить, сервер использует порт 80, в то время как клиент использует случайный номер порта. Каждый подключенный клиент должен иметь разный номер порта, т.к. каждое соединение связано с разными клиентами.
Как итог этого раздела, выделим два определения:
Клиент – программа, устанавливающая соединение и отправляющая запрос.
Сервер – программа, ожидающая входящие соединения, принимающая их и отвечающая в соответствии с запросами клиента. IP адрес и номер порта сервера должны быть известны клиенту, перед тем как тот (клиент) установит соединение с сервером.
Снова протоколы
Ранее я затронул несколько протоколов на разных уровнях сетевого интерфейса. Теперь осталось обсудить протоколы, работающие на программном уровне, например HTTP, FTP, POP3, SMTP. Большинство этих протоколов работают по типу клиент-сервер. Это значит, что клиент отправляет запрос, а сервер на него отвечает. Точный формат запроса и ответа на него описан в этих протоколах. Я не буду сейчас обсуждать эти протоколы, но позже, когда ты будешь знать основы WinSock, мы к ним вернемся.
Сокеты
WinSock (Windows Socket) – это Windows API, который взаимодействует с сетью. Примечание: Socket переводится с английского языка как «гнездо». Но поскольку употребление русского варианта этого слова не совсем вписывается в тему этого материала, то я буду использовать английское значение этого слова.
Как говорилось ранее, тебе придется работать с соединением клиент-сервер. Конечными точками этого соединения являются socket’ы. И у клиента, и у сервера есть socket. Socket связан с определенным IP адресом и номером порта. Почти все WinSock функции оперируют socket’ами. С помощью socket’а ты будешь управлять соединением. Обе «стороны» соединения используют socket’ы, и они (socket’ы) платформенно- независимы. Это значит, что машина с Windows может «общаться» по сети с Unix машиной, используя сокеты. По socket’ам данные могут передаваться и приниматься.
Выделяют два типа socket’ов: потоковый socket (SOCK_STREAM) и, так называемый, дейтаграммный socket (datagram socket, SOCK_DGRAM). Потоковый вариант разработан для приложений, нуждающихся в надежном соединении и часто использующем продолжительные потоки данных. Протокол, использующийся для данного типа socket’ов – TCP. В этом материале будет использоваться только потоковый тип socket’ов, т.к. он чаще всего используется в хорошо известных протоколах, таких как SMTP, POP3, HTTP, TCP.
Дейтаграммные socket’ы используют UDP протокол и имеют низкий сигнал соединения и большой размер буфера данных. Они применяются в приложениях, которые отправляют данные малых размеров и не нуждаются в идеальной надежности. В отличии от потоковых socket’ов, дейтаграммные socket’ы не гарантируют стопроцентной передачи данных получателю, как и не гарантируют передачи данных в нужном порядке. Данный тип socket’ов полезнее для приложений, где надежность не является высоким приоритетом, таким как скорость (например аудио или видео трансляция). В приложениях, которые нуждаются в надежности, целесообразней использовать потоковые сокеты.
Связывание (binding) socket’ов
Связать socket значит «прикрепить» определенный адрес (IP адрес и номер порта) к данному socket’у. Это можно сделать вручную, используя связывающую функцию, но в некоторых случаем WinSock сам автоматически свяжет socket. Более подробно об этом будет рассказано далее в этом разделе.
Соединение
Способ использования socket’ов зависит от того, где ты их используешь: на клиентской или серверной части. Клиентская часть создает соединение путем создания socket’а и вызовом соединяющей функции с определенной адресной информацией. До того как socket не соединится, он не будет связан с адресом. Это связано с тем, что клиент может использовать любой адрес (IP адрес и номер порта) для соединения с сервером.
Когда соединение вызвано, WinSock выберет IP адрес и номер порта для соединения и свяжет с ними socket до того, как клиент фактически соединится с сервером. Номером порта может быть любой номер, который свободен в момент соединения, с выбором IP адреса надо быть аккуратнее. Компьютеры могут иметь более одного IP адреса. Например, компьютер, подключенный к локальной сети и к интернету, имеет три IP адреса: внешний для использования интернета; адрес в локальной сети (192.168.x.x или 10.0.x.x и т.д.); адрес, так называемой «внутренней петли»(loopback), для обозначения «локального хоста» в сети из одного компьютера (127.0.0.1). Здесь выбор IP адреса, с которым связан socket, имеет значение, т.к. также определяет сеть, которую ты используешь для соединения. Если ты хочешь подключиться к локальному компьютеру 192.168.0.4, ты не сможешь сделать это через сеть интернет провайдера. Тебе потребуется связать socket с Вашим IP адресом в такой же сети (192.168.0.1, например).
К счастью, WinSock сам выберет IP адрес на твоем компьютере, который может использоваться для соединения с нужным адресом. Ничего не мешает тебе связать socket самостоятельно, но помни, что ты должен взять ситуацию, описанную выше, во внимание. Так же связывающая функция дает пользователю возможность установить IP адрес или номер порта в нулевое значение. В этом случаем нулевое значение значит «пускай WinSock выберет что-нибудь для меня». Это полезно, когда ты хочешь подключиться, используя определенный IP адрес, но, не указывая значение порта.
Прослушивание
На «стороне» сервера дела обстоят немного иначе. Сервер ждет входящих соединений и клиенту необходимо знать IP адрес и номер порта сервера, чтобы установить соединение. Чтобы упростить дело, на сервере всегда используется фиксированный номер порта (обычно это - порт, предусмотренный протоколом по умолчанию).
Ожидание входящего соединения по определенному адресу называется прослушиванием (listening). Обычно, перед тем как «войти» в режим прослушивания, socket должен быть связан с определенным адресом. Когда номер порта этого адреса установлен и зафиксирован (т.е. не изменится), сервер начинает ждать входящие соединения по этому порту. Например, 80 порт (порт по умолчанию для HTTP) прослушивается большинством серверов.
Когда клиент запрашивает соединение с сервером, сервер разрешит ему (или нет) и породит новый socket, который будет конечной точкой связи. Благодаря этому, socket, по которому происходило прослушивание, не используется для передачи данных и может находиться в режиме прослушивания дальше, «принимая» новых клиентов.
Соединения: примеры
Тут будут приведены графические примеры соединений с сервером, который может обращаться с многократными соединениями.
1) Создание socket’а для сервера
Рисунок 3. Создание socket’а для сервера
Сервер создает новый socket. Вновь созданный socket еще не связан с IP адресом и портом.
2) Связь socket’а
Рисунок 4. Связь socket’а
Так как наш сервер является сервером какого-нибудь сайта, то порт установлен 80 (порт по умолчанию для HTTP). Однако IP адрес установлен «нулевой», указывая на то, что сервер готов получить соединение от любого IP адреса, доступного компьютеру, на котором он запущен. В этом примере мы предполагаем, что у сервера есть три IP адреса: внешний, внутренний и адрес «внутренней петли».
3) Сервер в режиме прослушивания
Рисунок 5. Сервер в режиме прослушивания
После того как socket связан с определенным адресом, он «переходит» в режим прослушивания и ждет входящих соединений по 80ому порту.
4) Создание socket’а для клиента
Рисунок 6. Создание socket’а для клиента
Предположим, что клиент и сервер находятся в одной локальной сети. Клиент хочет запросить страницу с сервера. Чтобы передача данных осуществлялась, клиенту необходим socket, поэтому он и создает его.
5) Подключение клиента к серверу
Рисунок 7. Подключение клиента к серверу
Socket клиента остался несвязанным и пытается запросить соединение с сервером.
6) Сервер принимает соединение
Рисунок 8. Сервер принимает соединение
Прослушивающий socket замечает, что кто-то пытается подключиться. Он разрешает подключение, создавая новый socket (справа снизу на рисунке) , связывая его с одним из адресов, который может «достичь» клиент (в нашем примере клиент и сервер в одной локальной сети, поэтому IP любой в диапазоне 192.168.x.x). Socket клиента и socket сервера для подключения (подключенный socket на рисунке) будут осуществлять передачу данных друг другу, в то время как прослушивающий socket будет ждать новое соединение. Заметь, что socket клиента связан с IP адресом и номером порта клиента пока клиент подключен к серверу. Серая пунктирная линия на рисунке является разделительной линией между серверной и клиентской стороной.
7) Подключение других клиентов
Рисунок 9. Подключение других клиентов
Если другой клиент (из внешней сети) подключается, сервер создает еще один socket для взаимодействия с ним (новым клиентом). Заметь, что IP адрес, с которым связывается только что созданный socket, отличается от того, с которым связался первый socket. Это возможно, потому что прослушивающий socket сервера не связан с определенным IP адресом. Если бы он был связан с адресом 192.168.0.8, то второй клиент не смог бы подключится.
Блокирование
Возможны ситуации, когда при подключении к серверу, используя соединительную функцию, эта функция не завершится, пока соединение не будет установлено (или провалено). Таким образом твоя программа будет «висеть» некоторое время. Это - не проблема, если используется единственное соединение в консольном приложении, но в Windows приложениях это является недопустимой ситуацией. В любом оконном приложении есть процедура, которая всегда должна выполняться. Остановка этой процедуры задержала бы ввод пользовательских данных, прорисовку окна, уведомления программы и другие сообщения. А в то время, в использовании соединительной функции возникли какие-то проблемы, произойдет именно это.
Для решения этой проблемы WinSock позволяет устанавливать socket’ы в блокирующий (blocking) и не блокирующий (non-blocking) режим. Первый режим не позволит переключиться к основной задаче программы, пока не завершится функция соединения (это практически заблокирует твое приложение). Второй режим обычно используется в windows приложениях. При использовании неблокирующей функции, функция будет пытаться завершиться как можно быстрее, даже если она не может завершиться мгновенно. По завершении функции в программу будет послано уведомление, что операция закончена, позволяя программе работать в нормальном режиме, в то время как функция еще не завершилась.
Далее, в продолжении этого материала, мы рассмотрим неблокирующие функции. Пока просто знай об их существовании и отличии их от блокирующих.
Версии WinSock
В большинстве случаев используется версия WinSock 2.х, обычно называемая WinSock 2, т.к. различия небольшие. Последней популярной версией до второй, был WinSock 1.1. Некоторые могут сказать, что надо использовать именно эту версию, т.к. Windows 95 поддерживает только ее, но кто в наши дни пользуется Windows 95? Поэтому я рекомендую тебе использовать WinSock версии 2. Две основных версии WinSock «проживают» в двух .DLL - wsock32.dll и ws2_32.dll. В первой – версия 1.1, а во второй – WinSock2. В С++ достаточно подключить windows.h и winsock2.h для использования функций WinSock в своей программе. Далее в материале будет рассмотрен WinSock второй версии.
Архитектура WinSock
WinSock обеспечивает два интерфейса: API и SPI(Service Provider Interface – Интерфейс Обеспечения Служб). В этом материале будет рассмотрен только API, в нем содержатся все необходимые функции для использования нужных протоколов. SPI – интерфейс, который добавляет «поставщиков передачи данных» (Data Transport Providers) как, например TCP/IP или IPX/SPX. Также SPI добавляет «поставщиков именных служб» (Name Space Service Providers), таких как DNS. Но все эти добавления «прозрачны» для пользователей API и не видны им.
В этом разделе я расскажу про базовые WinSock функции, которые производят различные операции над socket’ами. Важно помнить, что этот раздел всего лишь введение в WinSock функции, чтобы ты мог без каких-либо непонятных моментов перейти к следующему разделу. Основная функциональность каждой функции довольна проста, но такие вещи, как блокирующий режим, делают их (функции) сложнее, чем они кажутся на первый взгляд. В следующих главах эти функции будут детально рассмотрены, но пока что ты должен просто ознакомиться с ними.
Этот раздел будет довольно длинным, и ты можешь не запомнить весь материал, но это не страшно. Просто читай этот раздел внимательно, что бы ты понимал, о чем я буду говорить в следующих главах. Ты всегда можешь вернуться назад и посмотреть информацию о нужной функции.
SAStartup и WSACleanup
int WSAStartup(WORD wVersionRequested, LPWSADATA lpWSAData);
int WSACleanup();
Перед вызовом любой WinSock функции, ты должен инициализировать библиотеку Ws2_32.dll. Это делается с помощью WSAStartup. Функция принимает два параметра:
- wVersionRequested - Максимальная версия Windows Socket, которую может использовать вызывающая программа. Старший байт содержит младшую часть номера версии, младший байт содержит старшую часть номера версии.
- lpWSAData - Указатель на структуру WSADATA, которая, в результате выполнения функции, будет содержать детали реализации Windows Sockets.
Как я говорил ранее, я буду использовать WinSock2. Это значит, что младший байт первого параметра мне надо указать 2, а старший может быть и нулем. Структура WSDATA, определенная параметром lpWSAData, получит информацию о версии WinSock, установленной на твоем компьютере.
В случае успешного выполнения, функция WSAStartup возвращает 0. В противном случае возвращается один из кодов ошибки, приведенных в таблице. Если выполнение функции WSAStartup окончилось неудачей, ты не сможешь определить код ошибки с помощью WSAGetLastError. Это получается потому, что в случае сбоя, библиотека Ws2_32.dll не будет загружена, и область памяти, где сохраняется информация о последней ошибке, недоступна. Но при желании можно попробовать получить код ошибки с помощью API функции GetLastError.
Однако, ты можешь не получить нужную тебе версию в параметр wVersionRequested. Дело в том, что приложение, вызвавшее WSAStartup, и библиотека Ws2_32.dll обмениваются номерами максимальной версии, которую они поддерживают. Ws2_32.dll проверяет версию, требуемую приложением. Если эта версия равна или выше чем низшая версия, поддерживаемая библиотекой, то вызов считается успешным, и библиотека возвращает в wHighVersion номер самой высшей версии, которую она поддерживает и в wVersion номер версии, которая предлагается приложению. Если значение параметра wVersion структуры WSADATA неприемлемо для приложения, то оно должно вызвать функцию WSACleanup и искать другую Ws2_32.dll или вернуть сбой инициализации.
Это справедливо для приложений, написанных с использованием младших версий, для успешной работы со старшими версиями библиотеки. В этом случае приложение гарантирует доступ только к функциональности, совместимой по синтаксису с текущей версией. Для полного доступа к новому синтаксису будущих реализаций приложение должно полностью соответствовать этой реализации - откомпилировано с новыми заголовочными файлами, слинковано с новыми библиотеками.
Выше я упомянул функцию WSACleanup. Каждому вызову функции WSAStartup должен соответствовать вызов WSACleanup, которая освобождает ресурсы, полученные у Windows для работы с WinSock.
Пример использования этих функций:
const int iReqWinsockVer = 2; // Минимальная требуемая версия
WSADATA wsaData;
if (WSAStartup(MAKEWORD(iReqWinsockVer,, &wsaData)==0)
{
// Проверяем если старшая версия больше или равна требуемой
if (LOBYTE(wsaData.wVersion) >= iReqWinsockVer)
{
/* Вызываем тут различные WinSock функции */
}
else
{
// Требуемая версия недоступна.
}
// Освобождаем WinSock
if (WSACleanup()!=0)
{
// Освобождение не удалось
}
}
else
{
// Инициализация не удалась
}
socket()
SOCKET socket(int af, int type, int protocol);
Функция socket() создает новый socket и возвращает его дескриптор. Тип этого дескриптора SOCKET, и он используется во всех функциях, работающих с socket’ами. Единственным недействительным значением дескриптора socket’а является INVALID_SOCKET . Функция принимает три параметра:
af - address family, так называемое, адресное семейство. Этот параметр накладывает определенные ограничения на формат используемых процессом адресов и их интерпретацию. Установи этот параметр в значение AF_INET, чтобы использовать TCP и UDP «семейство».
type – тип создаваемого socket’а. Используй SOCK_STREAM для создания потокового socket’а и SOCK_DGRAM для создания дейтаграммного socket’а.
protocol – протокол, который будет использоваться socket’ом. Этот параметр зависит от «адресного семейства». Чтобы создать TCP socket, Вам нужно указать IPPROTO_TCP.
Функция возвращает дескриптор созданного socket’а, или INVALID_SOCKET, если что то случилось не так. Функцию socket() можно использовать следующим образом:
SOCKET hSocket;
hSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (hSocket==INVALID_SOCKET)
{
// Действия в случае ошибки
}
closesocket
int closesocket(SOCKET s);
Как понятно из названия, эта функция закрывает socket. Функция возвращает ноль, если все прошло успешно, иначе результатом выполнения функции будет SOCKET_ERROR. Каждый созданный тобою socket с помощью функции socket(), должен быть закрыт с помощью функции closesocket().
В функцию передается единственный параметр – дескриптор socket’а, который необходимо закрыть. Не пытайся использовать этот socket после вызова функции closesocket(). В лучшем случае компилятор заметит ошибку.
Использование этой функции довольно простое:
closesocket(hSocket);
Однако, в реальный ситуациях, необходимо немного больше операций, что бы закрыть socket должным образом. Это мы рассмотрим немного позднее.
sockaddr и порядок байт
WinSock был разработан таким образом, что бы он мог взаимодействовать с разными протоколами, включая те, которые должны быть добавлены позднее. Поэтому был разработан общий способ адресации. Например, TCP/IP использует IP адрес и номер порта для определения адреса, но другие протоколы могут делать это по-другому. Если бы WinScok придерживался определенного типа адресации, то добавление других протоколов было бы невозможным.
Первый вариант решения этой проблемы – использование структуры sockaddr:
struct sockaddr
{
u_short sa_family;
char sa_data[14];
};
Первое поле этой структуры определяет «адресное семейство» адреса. Данные, хранящиеся в переменной sa_data, могут меняться в зависимости от «адресного семейства». В этом материале мы будем использовать только интернет-«адресное семейство». В WinSock определена структура sockaddr_in, которая является TCP/IP версией структуры sockaddr.
struct sockaddr_in {
short sin_family;
unsigned short sin_port;
struct in_addr sin_addr;
char sin_zero[8];
};
Последние 8 байт структуры не используются. Они предусмотрены для того, что бы дать структуре нужный размер (такой же как у структуры sockaddr).
Перед тем, как двигаться дальше, необходимо знать про сетевой порядок байт. Под «порядком байт» будем понимать последовательность, в которой хранятся значения, охватывающие несколько байт. Например, 32-битовое целочисленное значение 0x12345678 охватывает 4 8-битовых байта. Некоторые компьютеры используют порядок байт, в котором менее значимые байты сохранены сначала. То есть наше число 0x12345678 будет храниться в такой последовательности: 0x78, 0x56, 0x34, 0x12 (порядок байтов от младшего к старшему, англ. little-endian). Однако большинство машин используют противоположный порядок, т.е. более значимый байт хранится вначале. В таких машинах наше число будет храниться в виде 0x12, 0x34, 0x56, 0x78. Поскольку протоколы, по которым будут переданы данные между двумя компьютерами, могут иметь разные байтовые порядки, то необходим стандарт, чтобы препятствовать передаче данных неправильным образом.
Поскольку такие протоколы, как TCP/IP работают между разными системами с разным порядком байтов, то был разработан стандарт - порядок от старшего к младшему (big-endian). Запись начинается со старшего байта и заканчивается младшим. Например, 16-битовый номер порта 12345 (0x3039) в этом представлении будет выглядеть так: сначала 0x30, потом 0x39, т.е. более значимый байт идет сначала. 32-битовый IP адрес хранится аналогичным образом: каждая часть IP адреса хранится в одном байте, и первая часть хранится в первом байте. Например, IP адрес 216.239.51.100 будет храниться в такой последовательности байтов: 216,239,51,100. Этот порядок является стандартным для протоколов TCP/IP, он используется в заголовках пакетов данных и во многих протоколах более высокого уровня, разработанных для использования поверх TCP/IP. Поэтому, порядок байтов от старшего к младшему часто называют сетевым порядком байтов (network byte order).
Кроме параметров sin_family и sa_family в структурах sockaddr_in и sockaddr, соответственно, которые не являются частью протокола, но говорят WinSock, какое «адресное семейство» использовать, все остальные поля этих структура хранятся в сетевом порядке байтов.
WinSock обеспечивает несколько функций для преобразования порядка байтов локальной машины в сетевой порядок байтов:
// Преобразует u_short из порядка байтов локальной машины в сетевой порядок байтов
u_short htons(u_short hostshort);
// Преобразует u_long порядка байтов локальной машины в сетевой порядок байтов
u_long htonl(u_long hostlong);
// Преобразует u_shorth из сетевого порядка байтов в порядок байтов локальной машины
u_short ntohs(u_short netshort);
// Преобразует u_long из сетевого порядка байтов в порядок байтов локальной машины
u_long ntohl(u_long netlong);
Ты можешь спросить, зачем нам надо четыре API функции для таких простых операций, как замена положения байтов? Ответ прост: эти функции будут работать независимо от того, какой порядок байтов на твоей машине (от младшего к старшему или наоборот).
Вернемся к структуре sockaddr_in. Как писалось выше, все параметры кроме sin_family имеют сетевой порядок байтов. Этот параметр у нас используется в значении AF_INET. sin_port – это 16-битовый номер порта, sin_addr – 32-битовый IP адрес. sin_zero не используется, этот параметр нужен, чтобы придать структуре нужный размер.
Вот пример заполнения полей структуры sockaddr_in:
sockaddr_in sockAddr1, sockAddr2;
// Устанавливаем адресное семейство
sockAddr1.sin_family = AF_INET;
// Преобразуем номер порта 80 в сетевой порядок байтов
sockAddr1.sin_port = htons(80);
/* inet_addr преобразует строку с IP адресом в long значение,
которое является IP адресом в сетевом порядке байтов.
sin_addr.S_un.S_addr определяет long значение в адресном объединении */
sockAddr1.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");
// Устанавливаем адрес sockAddr2, устанавливая значение каждой из 4 байтовой части:
sockAddr2.sin_addr.S_un.S_un_b.s_b1 = 127;
sockAddr2.sin_addr.S_un.S_un_b.s_b2 = 0;
sockAddr2.sin_addr.S_un.S_un_b.s_b3 = 0;
sockAddr2.sin_addr.S_un.S_un_b.s_b4 = 1;
Функция inet_addr, в вышеприведенном примере, преобразует строковое значение IP адреса (записанного в «точечном» формате) в соответствующее 32-битовое значение в сетевом порядке байтов. Также существует функция inet_ntoa, которая делает тоже самое, только наоборот.
connect
int connect(SOCKET s, const struct sockaddr *name, int namelen);
Функция connect соединят socket с удаленным socket’ом. Эта функция используется на клиентской стороне подключения, т.к. именно клиент является инициатором подключения.
Краткое описание параметров этой функции:
s – неподключенный socket, который ты хотел бы подключить.
name - указатель на структуру sockaddr, в которой содержится имя (адрес) удаленного socket’а, к которому необходимо подключится.
namelen – размер структуры, в которой содержится имя.
Первый параметр – это клиентский socket, использующий соединение, например, только что созданный socket с помощью функции socket(). Остальные два параметра, name и namelen, используются для адресации удаленного socket’а (socket сервера, который находится в режиме прослушивания).
Эту функцию применяют для соединения с сервером. Чтобы обратиться к серверу, ты можешь использовать структуру sockaddr_in, заполнив ее IP адресом и номером порта сервера. Ты можешь поинтересоваться, как получить IP адрес сервера, например, www.vr-online.ru. Позже я покажу как это сделать. Пока что просто представь, что ты его знаешь. Представим, что сервер запущен в локальной сети, на компьютере с IP адресом 192.168.0.5, используя HTTP порт по умолчанию (80). Код, позволяющий подключится к этому серверу, выглядит примерно так:
// Этот код подразумевает, что socket был создан, и его дескриптор хранится в hSocket
sockaddr_in sockAddr;
sockAddr.sin_family = AF_INET;
sockAddr.sin_port = htons(80);
sockAddr.sin_addr.S_un.S_addr = inet_addr("192.168.0.5");
// Подключаемся к серверу
if (connect(hSocket, (sockaddr*)(&sockAddr), sizeof(sockAddr))!=0)
{
// Действия в случае неудачного подключения
}
/* Замечение: приведение (sockaddr*) необхадимо, т.к. соединение требует переменную типа
sockaddr, а тип переменной sockAddr sockaddr_in. Преобразование безопасно, т.к. оба типа имеют схожую структуру, но компилятор видит их как два разных типа*/
bind
int bind(SOCKET s, const struct sockaddr *name, int namelen);
Связывание socket’а было рассмотрено в предыдущих главах. Напомню, что эта функция связывает адрес с socket’ом.
Параметры:
s – несвязанный socket, который требуется связать.
name – указатель на структуру sockaddr, в которой содержится адрес необходимого socket’а.
namelen – размер структуры, в которой содержится имя.
Для TCP/IP структура sockaddr_in может быть использована как обычно. Давай сначала взглянем на пример:
sockaddr_in sockAddr;
sockAddr.sin_family = AF_INET;
sockAddr.sin_port = htons(80);
sockAddr.sin_addr.S_un.S_addr = INADDR_ANY; // используем адрес по умолчанию (т.е. любой)
// Связываемся в 80 портом
if (bind(hSocket, (sockaddr*)(&sockAddr), sizeof(sockAddr))!=0)
{
// Действия в случае ошибки
}
Как ты можешь заметить, структура sockaddr_in заполняется необходимой информацией. «Адресное семейство» используется AF_INET для TCP/IP. В этом примере мы связали socket с 80 портом, но не с IP адресом. Указывая INADDR_ANY в качестве значение IP адреса, WinSock выберет адрес за тебя. Это может быть полезно на компьютерах с несколькими сетевыми адаптерами (в нашем примере рассматривается именно такой компьютер) или подключениями.
Если ты сам хочешь указать IP адрес, просто преобразуй его в DWORD с сетевым порядков байтов и присвой это значение соответствующему полю структуры. Подобное возможно проделать и с номером порта; если ты укажешь нулевое значение порта, то WinSock подберет уникальный номер порта, с значением между 1024 и 5000. Однако, в большинстве случаев тебе придется указывать номер порта самостоятельно. Связывание socket’а обычно производится перед тем, как он (socket) перейдет в режим прослушивания, что бы socket прослушивал нужный порт.
listen
int listen(SOCKET s, int backlog);
Эта функция устанавливает socket в режим прослушивания. Она получает два параметра:
s – связанный, неподключенный socket, который ты хочешь установить в режим прослушки
backlog – максимальное количество ожидаемых соединений.
Максимальное количество, передаваемое в параметр backlog, сильно зависит от платформы. В linux оно обрезается до SOMAXCONN. В win32, если передано SOMAXCONN, провайдер сервиса отвечает за установку backlog socket’а в максимальное разумное значение. На этой платформе нет стандарта для установки реального backlog-значения. Так как мы использует Windows, то мы смело можем установить этот параметр в значение SOMAXCONN.
Перед вызовом функции listen, socket должен быть связан с определенным адресом. Например, если Вы связали socket с 80 портом, и поставили его в режим прослушивания, то все, входящие по 80 порту, подключения будут направлены в Ваше приложение. Что бы разрешить соединение, должна быть вызвана функция accept. Она будет рассмотрена далее. Следующий фрагмент кода показывает, как вызвать функцию listen для связанного socket’а.
// Этот код подразумевает, что socket был создан, и его дескриптор хранится в hSocket
if (listen(hSocket, SOMAXCONN)!=0)
{
// Действия в случае ошибки
}
accept
SOCKET accept(SOCKET s, struct sockaddr *addr, int *addrlen);
Если socket находится в режиме прослушивание и получает входящие соединение, он может разрешить его с помощью этой функции.
s – socket, который был установлен в режим просулшивания.
addr – указатель на буфер, который получает адрес удаленного socket’а. Этот параметр – указатель на структуру sockaddr, но он требует структуру, определенную «адресным семейством».
addrlen – указатель на целое число, которое содержит длину addr. Перед вызовом функции этот параметр должен иметь размер буфера, на который указывает addr. По завершении функции, в этом параметре будет храниться размер полученных данных.
Как ты уже знаешь, если соединение было разрешено, на сервере создается новый socket. Этот новый socket соединяется с socket’ом клиента и все операции между сервером и клиентом проводятся именно по этому socket’у. Socket, который находился в режиме прослушивания, продолжает ждать новые соединения.
sockaddr_in remoteAddr;
int iRemoteAddrLen;
SOCKET hRemoteSocket;
iRemoteAddrLen = sizeof(remoteAddr);
hRemoteSocket = accept(hSocket, (sockaddr*)&remoteAddr, &iRemoteAddrLen);
if (hRemoteSocket==INVALID_SOCKET)
{
// Действия в случае ошибки
}
При успешном завершении функции, возвращаемое значение – дескриптор нового socket’а.
send and recv
int send(SOCKET s, const char *buf, int len, int flags);
s – подключенный socket, для передачи данных.
buf – буфер, содержащий данные для отправки.
len – размер данных для передачи.
flags – определяет способ передачи данных.
int recv(SOCKET s, char *buf, int len, int flags);
s – подключенный socket, для получения данных.
buf – буфер, в котором будут храниться полученные данные.
len – размер полученных данных.
flags – определяет способ получения данных.
Для передачи данных ты должен использовать эти две функции. Первая функция отправляет данные из буфера и возвращает количество отправленных байт. Вторая функция получает данные и сохраняет их в буфер. Последний параметр каждой функции может быть установлен в нулевое значение. В блокирующем режиме функция send «заблокирует» твое приложение, пока все данные не будут переданы.
Хотя на первый взгляд эти функции кажутся простыми, они становятся сложнее для работы в неблокирующем режиме. Когда socket находится в неблокирующем режиме, эти функции могут не выполнить операцию полностью. В следующей главе эта проблема будет рассмотрена детальным образом, а пока что пример использования этих функций в блокирующем режиме (в этом примере будут отправлены полученные данные):
//Пример использования функций send и recv
char buffer[128];
while(true)
{
// Получаем данные
int bytesReceived = recv(hRemoteSocket, buffer, sizeof(buffer), 0);
if (bytesReceived==0) // соединение закрыто
{
break;
}
else if (bytesReceived==SOCKET_ERROR)
{
// Действия при ошибке
}
// Отправляем полученные данные обратно
if (send(hRemoteSocket, buffer, bytesReceived, 0)==SOCKET_ERROR)
{
// Действия при ошибке
}
}
Использование
Как отмечалось в начале главы, это был всего лишь обзор основных WinSock функций. Однако знать, как и что делают эти функции не достаточно для корректной работы с ними. В следующих главах мы научимся правильно работать с этими функциями, рассмотрим какие существуют «входные/выходные модели» (I/O Models) и узнаем как работают блокирующий и не блокирующий режимы.
Written by: Сергей Дубовик aka sd
- Добавить комментарий
- 80104 просмотра
Комментарии
12 комментария(ев)Дата: Пнд, 16/05/2011 - 16:55
Надо бы осилить и почитать. Вообще судя по картинкам объясняется все довольно доходчиво. Думаю стоит почитать статью тем, кто не в курсе что такое TCP/IP, UDP/IP, а работает с сокетами и потом жалуется, что ничего не пашет. Такие люди есть...
Добавил в избранное
Дата: Пнд, 16/05/2011 - 17:42
Я очень извиняюсь за то, что вторая часть статьи пока не готова. Она в процессе написания. Просто сейчас не хватает времени. Через 2 недели начинается сессия - допишу статью до конца, а пока что надо сдавать хвосты в универе. Так же извиняюсь за количество ошибок в этой статье, суть статьи все равно понятна, но все же ошибки есть. Во второй части постараюсь, что бы их было меньше.
Дата: Пнд, 16/05/2011 - 18:02
2sd
Статья действительно у тебя хорошая получилась. Материал объемный и подробный. Так держать!
Дата: Втр, 17/05/2011 - 01:27
Так-то оно и неплохо бы, но всё же уровень API уходит в прошлое. Если C++, то сейчас лучше выбирать Qt. И даже у этого фреймворка неясное будущее. По сетям на C++ можно почитать Криса Касперски: ]]>http://www.insidepro.com/kk/006/006r.shtml]]>
Или C++ глазами хакера Фленова.
Дата: Втр, 17/05/2011 - 02:25
Продолжай в том же духе
Андрей Чиков про Qt это правильно ты упомянул.
Дата: Втр, 17/05/2011 - 03:25
познавательно и разложено по полочкам
Дата: Втр, 17/05/2011 - 08:14
Я с QT совсем не знаком
. Что-то я отстал в развитии в этом плане. Надо изучить QT...Но я думаю, что пока существует Windows, API не потеряет свою актуальность.
Дата: Втр, 17/05/2011 - 12:24
Хорошая статья, но, как мне показалось, в теоретической части автор в одну кучу свалил стек TCP/IP и модель OSI, в результате получился некий "стек Ethernet". Ethernet - это лишь технология передачи данных, не привязанная к конкретному стеку протоколов. В кадре Ethernet можно передать и данные стека IPX/SPX, и что тогда?
Дата: Втр, 17/05/2011 - 21:46
У меня на рисунке изображена стандартная модель OSI во взаимодействии с TCP\IP. IPX/SPX я не затрагивал, т.к. далее в статье этот стек протоколов вообще никак не используется, там идет работа с TCP/IP.
Но, возможно, лучше было бы отдельно изобразить стандартную модель OSI, а только потом изобразить то, что изображено у меня.
Дата: СБ, 09/07/2011 - 09:52
Расскажите пожалуйста, кто-нибудь прочитал эту статью полностью? Я сейчас ее пролистал и подумал что "много букв". Вторая часть статьи уже готова, но в ней букв еще больше. Так я вот думаю, может разбить ее на 2 части? Там как раз отлично получится теория и практика.