Тема сетевого программирования является для разработчиков одной из важнейших в современном цифровом мире. Правда, надо признать, что большая часть сетевого программирования сосредоточена в области написания скриптов исполнения для web-серверов на языках PHP, Python и им подобных. Как следствие - по тематике взаимодействия клиент-сервер при работе с web-серверами написаны терабайты текстов в Интернете. Однако когда я решил посмотреть, что же имеется в Интернете по вопросу программирования сетевых приложений с использованием голых сокетов, то обнаружил интересную вещь: да, такие примеры конечно же есть, но подавляющее большинство написано под *nix-системы с использованием стандартных библиотек (что понятно – в области сетевого программирования Microsoft играет роль сильно отстающего и менее надежного «собрата» *nix-ов). Другими словами все эти примеры просто не будут работать под Windows. При определенных танцах с бубнами код сетевого приложения под Linux можно запустить и под Windows, однако это еще более запутает начинающего программиста, на которого и нацелены большинство статей в Интернете с примерами использования сокетов.
Ну а что же с документацией по работе с сетевыми сокетами в Windows от самой Microsoft? Парадоксальность ситуации заключается в том, что непосредственно в самой документации приведено очень беглое описание функций и их использования, а в примерах имеются ошибки и вызовы старых «запрещенных» современными компиляторами функций (к примеру, функция inet_addr() - https://docs.microsoft.com/en-us/windows/win32/api/winsock2/nf-winsock2-listen ) - такие функции конечно же можно вызывать, заглушив бдительность компилятора через #define-директивы, однако такой подход является полным зашкваром для любого даже начинающего программиста и категорически не рекомендуется к использованию. Более того, фрагмент кода в примере от Microsoft по ссылке выше:
service.sin_addr.s_addr = inet_addr("127.0.0.1");
вообще не заработает, т.к. полю Service.sin_addr.s_addr невозможно присвоить значение целого типа, которое возвращает функция inet_addr (возвращает unsigned long). То есть это ни много, ни мало - ошибка! Можно себе представить, сколько пытливых бойцов полегло на этом месте кода.
В общем, посмотрев на всё это, я решил написать базовую статью по созданию простейшего клиент-сервер приложения на С++ под Windows с детальным описанием всех используемых функций. Это приложение будет использовать Win32API и делать незамысловатую вещь, а именно: передавать сообщения от клиента к серверу и обратно, или, иначе говоря – напишем программу по реализации чата для двух пользователей.
Сразу оговорюсь, что статья рассчитана на начинающих программистов, которые только входят в сетевое программирование под Windows. Необходимые навыки – базовое знание С++, а также теоретическая подготовка по теме сетевых сокетов и стека технологии TCP/IP.
Когда мы говорим про работу сетевого приложения, то всегда подразумеваем взаимодействие процессов: процесс 1 (клиент) пытается что-то послать или получить от Процесса 2 (сервер). Наиболее простым и понятным способом организации сетевого взаимодействия процессов является построение канала между этими процессами. Именно таким путём и пошли разработчики первых сетевых протоколов. Получившийся способ взаимодействия сетевых процессов в итоге оказался многоуровневым: основной программный уровень - стек сетевой технологии TCP/IP, который позволяет организовать эффективную доставку пакетов информации между различными машинами в сети, а уже на прикладном уровне тот самый «сокет» позволяет разобраться какой пакет какому процессу доставить на конкретной машине.
Иными словами «сокет» - это «розетка» конкретного процесса, в которую надо подключиться, чтобы этому процессу передать какую-либо информацию. Договорились, что эта «розетка» в Сети описывается двумя параметрами – IP-адресом (для нахождения машины в сети) и Портом подключения (для нахождения процесса-адресата на конкретной машине).
Для того, чтобы сокеты заработали под Windows, необходимо при написании программы пройти следующие Этапы:
#include <iostream>
#include <WinSock2.h>
#include <WS2tcpip.h>
#include <stdio.h>
#include <vector>
#pragma comment(lib, "Ws2_32.lib")
Обратите внимание: имя системной библиотеки ws2_32.libименно такое, как это указано выше. В Сети есть различные варианты написания имени данной библиотеки, что, возможно, связано иным написанием в более ранних версиях ОС Windows. Если вы используете Windows 10, то данная библиотека называется именно ws2_32.libи находится в стандартной папке ОС: C:/Windows/System32 (проверьте наличие библиотеки у себя, заменив расширение с “lib” на “dll”).
Второй шаг – непосредственный вызов функции запуска сокетов с помощью WSAStartup(). Упрощённый прототип данной функции выглядит так:
int WSAStartup (WORD <запрашиваемая версия сокетов>, WSADATA* <указатель на структуру, хранящую текущую версию реализации сокетов>)
Первый аргумент функции – указание диапазона версий реализации сокетов, которые мы хотим использовать и которые должны быть типа WORD. Этот тип данных является внутренним типом Win32API и представляет собой двухбайтовое слово (аналог в С++: unsigned short). Функция WSAStartup() просит вас передать ей именно WORD, а она уже разложит значение переменной внутри по следующему алгоритму: функция считает, что в старшем байте слова указана минимальная версия реализации сокетов, которую хочет использовать пользователь, а в младшем – максимальная. По состоянию на дату написания этой статьи (октябрь 2021 г.) актуальная версия реализации сокетов в Windows – 2. Соответственно, желательно передать и в старшем, и в младшем байте число 2. Для того, чтобы создать такую переменную типа WORD и передать в её старший и младший байты число 2, можно воспользоваться Win32API функцией MAKEWORD(2,2).
Можно немного повыёживаться и вспомнить (или полистать MSDN), что функция MAKEWORD(x,y) строит слово по правилу y << 8 | x.Нетрудно посчитать, что при x=y=2 значение функции MAKEWORD в десятичном виде будет 514. Можешь смело передать в WSAStartup() это значение, и всё будет работать.
Второй аргумент функции – просто указатель на структуру WSADATA, которую мы создали ранее и в которую подгрузилась информация о текущей версии реализации сокетов на данной машине.
WSAStartup() в случае успеха возвращает 0, а в случае каких-то проблем возвращает код ошибки, который можно расшифровать последующим вызовом функции WSAGetLastError().
Важное замечание: поскольку сетевые каналы связи и протоколы в теории считаются ненадежными (это отдельный большой разговор), то критически важно для сетевого приложения анализировать все возможные ошибки, которые возникают в процессе вызовов сокетных функций. По этой причине каждый вызов таких функций мы будем анализировать на ошибки и в случае их обнаружения завершать сетевые сеансы и закрывать открытые сокеты. Используем для этого переменную erStat типа int.
Также важно после работы приложения обязательно закрыть использовавшиеся сокеты с помощью функции closesocket(SOCKET <имя сокета>) и деинициализировать сокеты Win32API через вызов метода WSACleanup().
Итого код Этапа 1 следующий:
WSADATA wsData;
int erStat = WSAStartup(MAKEWORD(2,2), &wsData);
if ( erStat != 0 ) {
cout << "Error WinSock version initializaion #";
cout << WSAGetLastError();
return 1;
}
else
cout << "WinSock initialization is OK" << endl;
Да, кода мало, а описания много. Так обычно и бывает, когда хочешь глубоко в чем-то разобраться. Так что на лабе будешь в первых рядах.
SOCKET socket(int <семейство используемых адресов>, int <тип сокета>, int <тип протокола>)
Функция socket() возвращает дескриптор с номером сокета, под которым он зарегистрирован в ОС. Если же инициализировать сокет по каким-то причинам не удалось – возвращается значение INVALID_SOCKET.
Код Этапа 2 будет выглядеть так:
SOCKET ServSock = socket(AF_INET, SOCK_STREAM, 0);
if (ServSock == INVALID_SOCKET) {
cout << "Error initialization socket # " << WSAGetLastError() << endl;
closesocket(ServSock);
WSACleanup();
return 1;
}
else
cout << "Server socket initialization is OK" << endl;
Такое назначение делается с помощью функции bind(), имеющей следующий прототип:
int bind(SOCKET <имя сокета, к которому необходимо привязать адрес и порт>, sockaddr* <указатель на структуру, содержащую детальную информацию по адресу и порту, к которому надо привязать сокет>, int <размер структуры, содержащей адрес и порт>)
Функция bind() возвращает 0, если удалось успешно привязать сокет к адресу и порту, и код ошибки в ином случае, который можно расшифровать вызовом WSAGetLastError() - см. итоговый код Этапа 3 далее.
Тут надо немножно притормозить и разобраться в том, что за такая структура типа sockaddr передается вторым аргументом в функцию bind(). Она очень важна, но достаточно запутанная.
Итак, если посмотреть в её внутренности, то выглядят они очень просто: в ней всего два поля – (1) первое поле хранит семейство адресов, с которыми мы уже встречались выше при инициализации сокета, а (2) второе поле хранит некие упакованные последовательно и упорядоченные данные в размере 14-ти байт. Бессмысленно разбираться детально как именно эти данные упакованы, достаточно лишь понимать, что в этих 14-ти байтах указан и адрес, и порт, а также дополнительная служебная информация для других системных функций Win32API.
Но как же явно указать адрес и порт для привязки сокета? Для этого нужно воспользоваться другой структурой, родственной sockaddr, которая легко приводится к этому типу - структурой типа sockaddr_in.
В ней уже более понятные пользователю поля, а именно:
Соответственно, ввод данных для структуры типа sockaddr_in выглядит следующим образом:
in_addr ip_to_num;
erStat = inet_pton(AF_INET, “127.0.0.1”, &ip_to_num);
if (erStat <= 0) {
cout << "Error in IP translation to special numeric format" << endl;
return 1;
}
sockaddr_in servInfo;
ZeroMemory(&servInfo, sizeof(servInfo));
servInfo.sin_family = AF_INET;
servInfo.sin_addr = ip_to_num;
servInfo.sin_port = htons(1234);
erStat = bind(ServSock, (sockaddr*)&servInfo, sizeof(servInfo));
if ( erStat != 0 ) {
cout << "Error Socket binding to server info. Error # " << WSAGetLastError() << endl;
closesocket(ServSock);
WSACleanup();
return 1;
}
else
cout << "Binding socket to Server info is OK" << endl;
int listen(SOCKET <«слушающий» сокет, который мы создавали на предыдущих этапах>, int <максимальное количество процессов, разрешенных к подключению>)
Второй аргумент: максимально возможное число подключений устанавливается через передачу параметр SOMAXCONN(рекомендуется). Если нужно установить ограничения на количество подключений – нужно указать SOMAXCONN_HINT(N), где N – кол-во подключений. Если будет подключаться больше пользователей, то они будут сброшены.
После вызова данной функции исполнение программы приостанавливается до тех пор, пока не будет соединения с Клиентом, либо пока не будет возвращена ошибка прослушивания порта. Код Этапа 4 для Сервера:
erStat = listen(ServSock, SOMAXCONN);
if ( erStat != 0 ) {
cout << "Can't start to listen to. Error # " << WSAGetLastError() << endl;
closesocket(ServSock);
WSACleanup();
return 1;
}
else {
cout << "Listening..." << endl;
}
int connect(SOCKET <инициализированный сокет>, sockaddr* <указатель на структуру, содержащую IP-адрес и Порт сервера>, int <размер структуры sockaddr>)
Функция возвращает 0 в случае успешного подключения и код ошибки в ином случае.
Процедура по добавлению данных в структуру sockaddr аналогична тому, как это делалось на Этапе 3 для Сервера при вызове функции bind(). Принципиально важный момент – в эту структуру для клиента должна заноситься информация о сервере, т.е. IPv4-адрес сервера и номер «слушающего» порта на сервере.
sockaddr_in servInfo;
ZeroMemory(&servInfo, sizeof(servInfo));
servInfo.sin_family = AF_INET;
servInfo.sin_addr = ip_to_num; // Server's IPv4 after inet_pton() function
servInfo.sin_port = htons(1234);
erStat = connect(ClientSock, (sockaddr*)&servInfo, sizeof(servInfo));
if (erStat != 0) {
cout << "Connection to Server is FAILED. Error # " << WSAGetLastError() << endl;
closesocket(ClientSock);
WSACleanup();
return 1;
}
else
cout << "Connection established SUCCESSFULLY. Ready to send a message to Server"
<< endl;
SOCKET accept(SOCKET <"слушающий" сокет на стороне Сервера>, sockaddr* <указатель на пустую структуру sockaddr, в которую будет записана информация по подключившемуся Клиенту>, int* <указатель на размер структуры типа sockaddr>)
Функция accept() возвращает номер дескриптора, под которым зарегистрирован сокет в ОС. Если произошла ошибка, то возвращается значение INVALID_SOCKET.
Если подключение подтверждено, то вся информация по текущему соединению передаётся на новый сокет, который будет отвечать со стороны Сервера за конкретное соединение с конкретным Клиентом. Перед вызовом accept() нам надо создать пустую структуру типа sockaddr_in, куда запишутся данные подключившегося Клиента после вызова accept(). Пример кода:
sockaddr_in clientInfo;
ZeroMemory(&clientInfo, sizeof(clientInfo));
int clientInfo_size = sizeof(clientInfo);
SOCKET ClientConn = accept(ServSock, (sockaddr*)&clientInfo, &clientInfo_size);
if (ClientConn == INVALID_SOCKET) {
cout << "Client detected, but can't connect to a client. Error # " << WSAGetLastError() << endl;
closesocket(ServSock);
closesocket(ClientConn);
WSACleanup();
return 1;
}
else
cout << "Connection to a client established successfully" << endl;
Всё, соединение между Клиентом и Сервером установлено! Самое время попробовать передать информацию от Клиента к Серверу и обратно. Как мы в начале и договорились, мы будет реализовывать простейший чат между ними.
Отправлять информацию с любой стороны можно с помощью функции send(). При вызове данной функции обычно никакого ожидания и блокировки не происходит, а переданные в неё данные сразу же отправляются другой стороне.
Рассмотрим прототипы функций recv() и send():
int recv(SOCKET <сокет акцептованного соединения>, char[] <буфер для приёма информации с другой стороны>, int <размер буфера>, <флаги>)
int send(SOCKET <сокет акцептованного соединения>, char[] <буфер хранящий отсылаемую информацию>, int <размер буфера>, <флаги>)
Флаги в большинстве случаев игнорируются – передается значение 0.
Функции возвращают количество переданных/полученных по факту байт.
Как видно из прототипов, по своей структуре и параметрам эти функции совершенно одинаковые. Что важно знать:
Сама по себе упаковка и отправка данных делается элементарным использованием функций чтения всей строки до нажатия кнопки Ввода - fgets() с последующим вызовом функции send(), а на другой стороне - приёмом информации через recv() и выводом буфера на экран через cout <<.
Процесс непрерывного перехода от send() к recv() и обратно реализуется через бесконечный цикл, из которого совершается выход по вводу особой комбинации клавиш. Пример блока кода для Серверной части:
vector <char> servBuff(BUFF_SIZE), clientBuff(BUFF_SIZE);
short packet_size = 0;
while (true) {
packet_size = recv(ClientConn, servBuff.data(), servBuff.size(), 0);
cout << "Client's message: " << servBuff.data() << endl;
cout << "Your (host) message: ";
fgets(clientBuff.data(), clientBuff.size(), stdin);
// Check whether server would like to stop chatting
if (clientBuff[0] == 'x' && clientBuff[1] == 'x' && clientBuff[2] == 'x') {
shutdown(ClientConn, SD_BOTH);
closesocket(ServSock);
closesocket(ClientConn);
WSACleanup();
return 0;
}
packet_size = send(ClientConn, clientBuff.data(), clientBuff.size(), 0);
if (packet_size == SOCKET_ERROR) {
cout << "Can't send message to Client. Error # " << WSAGetLastError() << endl;
closesocket(ServSock);
closesocket(ClientConn);
WSACleanup();
return 1;
}
}
Пришло время показать итоговый рабочий код для Сервера и Клиента. Чтобы не загромождать и так большой текст дополнительным кодом, даю ссылки на код на GitHub:
Исходный код для Сервера
Исходный код для Клиента
Несколько важных финальных замечаний:
Ну а что же с документацией по работе с сетевыми сокетами в Windows от самой Microsoft? Парадоксальность ситуации заключается в том, что непосредственно в самой документации приведено очень беглое описание функций и их использования, а в примерах имеются ошибки и вызовы старых «запрещенных» современными компиляторами функций (к примеру, функция inet_addr() - https://docs.microsoft.com/en-us/windows/win32/api/winsock2/nf-winsock2-listen ) - такие функции конечно же можно вызывать, заглушив бдительность компилятора через #define-директивы, однако такой подход является полным зашкваром для любого даже начинающего программиста и категорически не рекомендуется к использованию. Более того, фрагмент кода в примере от Microsoft по ссылке выше:
service.sin_addr.s_addr = inet_addr("127.0.0.1");
вообще не заработает, т.к. полю Service.sin_addr.s_addr невозможно присвоить значение целого типа, которое возвращает функция inet_addr (возвращает unsigned long). То есть это ни много, ни мало - ошибка! Можно себе представить, сколько пытливых бойцов полегло на этом месте кода.
В общем, посмотрев на всё это, я решил написать базовую статью по созданию простейшего клиент-сервер приложения на С++ под Windows с детальным описанием всех используемых функций. Это приложение будет использовать Win32API и делать незамысловатую вещь, а именно: передавать сообщения от клиента к серверу и обратно, или, иначе говоря – напишем программу по реализации чата для двух пользователей.
Сразу оговорюсь, что статья рассчитана на начинающих программистов, которые только входят в сетевое программирование под Windows. Необходимые навыки – базовое знание С++, а также теоретическая подготовка по теме сетевых сокетов и стека технологии TCP/IP.
Теория сокетов за 30 секунд для "dummies"
Начну всё-таки немного с теории в стиле «for dummies». В любой современной операционной системе, все процессы инкапсулируются, т.е. скрываются друг от друга, и не имеют доступа к ресурсам друг друга. Однако существуют специальные разрешенные способы взаимодействия процессов между собой. Все эти способы взаимодействия процессов можно разделить на 3 группы: (1) сигнальные, (2) канальные и (3) разделяемая память.Когда мы говорим про работу сетевого приложения, то всегда подразумеваем взаимодействие процессов: процесс 1 (клиент) пытается что-то послать или получить от Процесса 2 (сервер). Наиболее простым и понятным способом организации сетевого взаимодействия процессов является построение канала между этими процессами. Именно таким путём и пошли разработчики первых сетевых протоколов. Получившийся способ взаимодействия сетевых процессов в итоге оказался многоуровневым: основной программный уровень - стек сетевой технологии TCP/IP, который позволяет организовать эффективную доставку пакетов информации между различными машинами в сети, а уже на прикладном уровне тот самый «сокет» позволяет разобраться какой пакет какому процессу доставить на конкретной машине.
Иными словами «сокет» - это «розетка» конкретного процесса, в которую надо подключиться, чтобы этому процессу передать какую-либо информацию. Договорились, что эта «розетка» в Сети описывается двумя параметрами – IP-адресом (для нахождения машины в сети) и Портом подключения (для нахождения процесса-адресата на конкретной машине).
Для того, чтобы сокеты заработали под Windows, необходимо при написании программы пройти следующие Этапы:
- Инициализация сокетных интерфейсов Win32API.
- Инициализация сокета, т.е. создание специальной структуры данных и её инициализация вызовом функции.
- «Привязка» созданного сокета к конкретной паре IP-адрес/Порт – с этого момента данный сокет (его имя) будет ассоциироваться с конкретным процессом, который «висит» по указанному адресу и порту.
- Для серверной части приложения: запуск процедуры «прослушки» подключений на привязанный сокет.
Для клиентской части приложения: запуск процедуры подключения к серверному сокету (должны знать его IP-адрес/Порт). - Акцепт / Подтверждение подключения (обычно на стороне сервера).
- Обмен данными между процессами через установленное сокетное соединение.
- Закрытие сокетного соединения.
Этап 0: Подключение всех необходимых библиотек Win32API для работы с сокетами
Сокеты не являются «стандартными» инструментами разработки, поэтому для их активизации необходимо подключить ряд библиотек через заголовочные файлы, а именно:- WinSock2.h – заголовочный файл, содержащий актуальные реализации функций для работы с сокетами.
- WS2tcpip.h – заголовочный файл, который содержит различные программные интерфейсы, связанные с работой протокола TCP/IP (переводы различных данных в формат, понимаемый протоколом и т.д.).
- Также нам потребуется прилинковать к приложению динамическую библиотеку ядра ОС: ws2_32.dll. Делаем это через директиву компилятору: #pragma comment(lib, “ws2_32.lib”)
- Ну и в конце Этапа 0 подключаем стандартные заголовочные файлы iostream и stdio.h
#include <iostream>
#include <WinSock2.h>
#include <WS2tcpip.h>
#include <stdio.h>
#include <vector>
#pragma comment(lib, "Ws2_32.lib")
Обратите внимание: имя системной библиотеки ws2_32.libименно такое, как это указано выше. В Сети есть различные варианты написания имени данной библиотеки, что, возможно, связано иным написанием в более ранних версиях ОС Windows. Если вы используете Windows 10, то данная библиотека называется именно ws2_32.libи находится в стандартной папке ОС: C:/Windows/System32 (проверьте наличие библиотеки у себя, заменив расширение с “lib” на “dll”).
Этап 1: Инициализация сокетных интерфейсов Win32API
Прежде чем непосредственно создать объект сокет, необходимо «запустить» программные интерфейсы для работы с ними. Под Windows это делается в два шага следующим образом:- Нужно определить с какой версией сокетов мы работаем (какую версию понимает наша ОС) и
- Запустить программный интерфейс сокетов в Win32API. Ну либо расстроить пользователя тем, что ему не удастся поработать с сокетами до обновления системных библиотек
Второй шаг – непосредственный вызов функции запуска сокетов с помощью WSAStartup(). Упрощённый прототип данной функции выглядит так:
int WSAStartup (WORD <запрашиваемая версия сокетов>, WSADATA* <указатель на структуру, хранящую текущую версию реализации сокетов>)
Первый аргумент функции – указание диапазона версий реализации сокетов, которые мы хотим использовать и которые должны быть типа WORD. Этот тип данных является внутренним типом Win32API и представляет собой двухбайтовое слово (аналог в С++: unsigned short). Функция WSAStartup() просит вас передать ей именно WORD, а она уже разложит значение переменной внутри по следующему алгоритму: функция считает, что в старшем байте слова указана минимальная версия реализации сокетов, которую хочет использовать пользователь, а в младшем – максимальная. По состоянию на дату написания этой статьи (октябрь 2021 г.) актуальная версия реализации сокетов в Windows – 2. Соответственно, желательно передать и в старшем, и в младшем байте число 2. Для того, чтобы создать такую переменную типа WORD и передать в её старший и младший байты число 2, можно воспользоваться Win32API функцией MAKEWORD(2,2).
Можно немного повыёживаться и вспомнить (или полистать MSDN), что функция MAKEWORD(x,y) строит слово по правилу y << 8 | x.Нетрудно посчитать, что при x=y=2 значение функции MAKEWORD в десятичном виде будет 514. Можешь смело передать в WSAStartup() это значение, и всё будет работать.
Второй аргумент функции – просто указатель на структуру WSADATA, которую мы создали ранее и в которую подгрузилась информация о текущей версии реализации сокетов на данной машине.
WSAStartup() в случае успеха возвращает 0, а в случае каких-то проблем возвращает код ошибки, который можно расшифровать последующим вызовом функции WSAGetLastError().
Важное замечание: поскольку сетевые каналы связи и протоколы в теории считаются ненадежными (это отдельный большой разговор), то критически важно для сетевого приложения анализировать все возможные ошибки, которые возникают в процессе вызовов сокетных функций. По этой причине каждый вызов таких функций мы будем анализировать на ошибки и в случае их обнаружения завершать сетевые сеансы и закрывать открытые сокеты. Используем для этого переменную erStat типа int.
Также важно после работы приложения обязательно закрыть использовавшиеся сокеты с помощью функции closesocket(SOCKET <имя сокета>) и деинициализировать сокеты Win32API через вызов метода WSACleanup().
Итого код Этапа 1 следующий:
WSADATA wsData;
int erStat = WSAStartup(MAKEWORD(2,2), &wsData);
if ( erStat != 0 ) {
cout << "Error WinSock version initializaion #";
cout << WSAGetLastError();
return 1;
}
else
cout << "WinSock initialization is OK" << endl;
Да, кода мало, а описания много. Так обычно и бывает, когда хочешь глубоко в чем-то разобраться. Так что на лабе будешь в первых рядах.
Этап 2: Создание сокета и его инициализация
Сокет в С++ – это структура данных (не класс) типа SOCKET. Её инициализация проводится через вызов функции socket(), которая привязывает созданный сокет к заданной параметрами транспортной инфраструктуре сети. Выглядит прототип данной функции следующим образом:SOCKET socket(int <семейство используемых адресов>, int <тип сокета>, int <тип протокола>)
- Семейство адресов: сокеты могут работать с большим семейством адресов. Наиболее частое семейство – IPv4. Указывается как AF_INET.
- Тип сокета: обычно задается тип транспортного протокола TCP (SOCK_STREAM) или UDP (SOCK_DGRAM). Но бывают и так называемые "сырые" сокеты, функционал которых сам программист определяет в процессе использования. Тип обозначается SOCK_RAW
- Тип протокола: необязательный параметр, если тип сокета указан как TCP или UDP – можно передать значение 0. Тут более детально останавливаться не будем, т.к. в 95% случаев используются типы сокетов TCP/UDP.
Функция socket() возвращает дескриптор с номером сокета, под которым он зарегистрирован в ОС. Если же инициализировать сокет по каким-то причинам не удалось – возвращается значение INVALID_SOCKET.
Код Этапа 2 будет выглядеть так:
SOCKET ServSock = socket(AF_INET, SOCK_STREAM, 0);
if (ServSock == INVALID_SOCKET) {
cout << "Error initialization socket # " << WSAGetLastError() << endl;
closesocket(ServSock);
WSACleanup();
return 1;
}
else
cout << "Server socket initialization is OK" << endl;
Этап 3: Привязка сокета к паре IP-адрес/Порт
Сокет уже существует, но еще неполноценный, т.к. ему не назначен внешний адрес, по которому его будут находить транспортные протоколы по заданию подключающихся процессов, а также не назначен порт, по которому эти подключающиеся процессы будут идентифицировать процесс-получатель.Такое назначение делается с помощью функции bind(), имеющей следующий прототип:
int bind(SOCKET <имя сокета, к которому необходимо привязать адрес и порт>, sockaddr* <указатель на структуру, содержащую детальную информацию по адресу и порту, к которому надо привязать сокет>, int <размер структуры, содержащей адрес и порт>)
Функция bind() возвращает 0, если удалось успешно привязать сокет к адресу и порту, и код ошибки в ином случае, который можно расшифровать вызовом WSAGetLastError() - см. итоговый код Этапа 3 далее.
Тут надо немножно притормозить и разобраться в том, что за такая структура типа sockaddr передается вторым аргументом в функцию bind(). Она очень важна, но достаточно запутанная.
Итак, если посмотреть в её внутренности, то выглядят они очень просто: в ней всего два поля – (1) первое поле хранит семейство адресов, с которыми мы уже встречались выше при инициализации сокета, а (2) второе поле хранит некие упакованные последовательно и упорядоченные данные в размере 14-ти байт. Бессмысленно разбираться детально как именно эти данные упакованы, достаточно лишь понимать, что в этих 14-ти байтах указан и адрес, и порт, а также дополнительная служебная информация для других системных функций Win32API.
Но как же явно указать адрес и порт для привязки сокета? Для этого нужно воспользоваться другой структурой, родственной sockaddr, которая легко приводится к этому типу - структурой типа sockaddr_in.
В ней уже более понятные пользователю поля, а именно:
- Семейство адресов - опять оно (sin_family)
- Порт (sin_port)
- Вложенная структура типа in_addr, в которой будет храниться сам сетевой адрес (sin_addr)
- Технический массив на 8 байт (sin_zero[8])
Соответственно, ввод данных для структуры типа sockaddr_in выглядит следующим образом:
- Создание структуры типа sockaddr_in : sockaddr_in servInfo;
- Заполнение полей созданной структуры servInfo
- servInfo.sin_family = AF_INET;
- servInfo.sin_port = htons(<указать номер порта как unsigned short>); порт всегда указывается через вызов функции htons(), которая переупаковывает привычное цифровое значение порта типа unsigned short в побайтовый порядок понятный для протокола TCP/IP (протоколом установлен порядок указания портов от старшего к младшему байту или «big-endian»).
- Далее нам надо указать сетевой адрес для сокета. Тип этого поля – структура типа in_addr, которая по своей сути представляет просто особый «удобный» системным функциям вид обычного строчного IPv4 адреса. Таким образом, чтобы указать этому полю обычный IPv4 адрес, его нужно сначала преобразовать в особый числовой вид и поместить в структуру типа in_addr .
Благо существует функция, которая переводит обычную строку типа char[], содержащую IPv4 адрес в привычном виде с точками-разделителями в структуру типа in_addr – функция inet_pton(). Прототип функции следующий:
int inet_pton(int <семейство адресов>, char[] <строка, содержащая IP-адрес в обычном виде с точкой-разделителем>, in_addr* <указатель на структуру типа in_addr, в которую нужно поместить результат приведения строчного адреса в численный>).
В случае ошибки функция возвращает значение меньше 0.
Соответственно, если мы хотим привязать сокет к локальному серверу, то наш код по преобразованию IPv4 адреса будет выглядеть так:
in_addr ip_to_num;
erStat = inet_pton(AF_INET, “127.0.0.1”, &ip_to_num);
if (erStat <= 0) {
cout << "Error in IP translation to special numeric format" << endl;
return 1;
}
Результат перевода IP-адреса содержится в структуре ip_to_num. И далее мы передаем уже в нашу переменную типа sockaddr_in значение преобразованного адреса:
servInfo.sin_addr = ip_to_num;
in_addr ip_to_num;
erStat = inet_pton(AF_INET, “127.0.0.1”, &ip_to_num);
if (erStat <= 0) {
cout << "Error in IP translation to special numeric format" << endl;
return 1;
}
sockaddr_in servInfo;
ZeroMemory(&servInfo, sizeof(servInfo));
servInfo.sin_family = AF_INET;
servInfo.sin_addr = ip_to_num;
servInfo.sin_port = htons(1234);
erStat = bind(ServSock, (sockaddr*)&servInfo, sizeof(servInfo));
if ( erStat != 0 ) {
cout << "Error Socket binding to server info. Error # " << WSAGetLastError() << endl;
closesocket(ServSock);
WSACleanup();
return 1;
}
else
cout << "Binding socket to Server info is OK" << endl;
Этап 4 (для сервера): «Прослушивание» привязанного порта для идентификации подключений
Серверная часть готова к прослушке подключающихся «Клиентов». Для того, чтобы реализовать данный этап, нужно вызвать функцию listen(), прототип которой:int listen(SOCKET <«слушающий» сокет, который мы создавали на предыдущих этапах>, int <максимальное количество процессов, разрешенных к подключению>)
Второй аргумент: максимально возможное число подключений устанавливается через передачу параметр SOMAXCONN(рекомендуется). Если нужно установить ограничения на количество подключений – нужно указать SOMAXCONN_HINT(N), где N – кол-во подключений. Если будет подключаться больше пользователей, то они будут сброшены.
После вызова данной функции исполнение программы приостанавливается до тех пор, пока не будет соединения с Клиентом, либо пока не будет возвращена ошибка прослушивания порта. Код Этапа 4 для Сервера:
erStat = listen(ServSock, SOMAXCONN);
if ( erStat != 0 ) {
cout << "Can't start to listen to. Error # " << WSAGetLastError() << endl;
closesocket(ServSock);
WSACleanup();
return 1;
}
else {
cout << "Listening..." << endl;
}
Этап 4 (для Клиента). Организация подключения к серверу
Код для Клиента до текущего этапа выглядит даже проще: необходимо исполнение Этапов 0, 1 и 2. Привязка сокета к конкретному процессу (bind()) не требуется, т.к. сокет будет привязан к серверному Адресу и Порту через вызов функции connect()(по сути аналог bind() для Клиента). Собственно, после создания и инициализации сокета на клиентской стороне, нужно вызвать указанную функциюconnect(). Её прототип:int connect(SOCKET <инициализированный сокет>, sockaddr* <указатель на структуру, содержащую IP-адрес и Порт сервера>, int <размер структуры sockaddr>)
Функция возвращает 0 в случае успешного подключения и код ошибки в ином случае.
Процедура по добавлению данных в структуру sockaddr аналогична тому, как это делалось на Этапе 3 для Сервера при вызове функции bind(). Принципиально важный момент – в эту структуру для клиента должна заноситься информация о сервере, т.е. IPv4-адрес сервера и номер «слушающего» порта на сервере.
sockaddr_in servInfo;
ZeroMemory(&servInfo, sizeof(servInfo));
servInfo.sin_family = AF_INET;
servInfo.sin_addr = ip_to_num; // Server's IPv4 after inet_pton() function
servInfo.sin_port = htons(1234);
erStat = connect(ClientSock, (sockaddr*)&servInfo, sizeof(servInfo));
if (erStat != 0) {
cout << "Connection to Server is FAILED. Error # " << WSAGetLastError() << endl;
closesocket(ClientSock);
WSACleanup();
return 1;
}
else
cout << "Connection established SUCCESSFULLY. Ready to send a message to Server"
<< endl;
Этап 5 (только для Сервера). Подтверждение подключения
После начала прослушивания (вызов функции listen()) следующей функцией должна идти функция accept(), которую будет искать программа после того, как установится соединение с Клиентом. Прототип функции accept():SOCKET accept(SOCKET <"слушающий" сокет на стороне Сервера>, sockaddr* <указатель на пустую структуру sockaddr, в которую будет записана информация по подключившемуся Клиенту>, int* <указатель на размер структуры типа sockaddr>)
Функция accept() возвращает номер дескриптора, под которым зарегистрирован сокет в ОС. Если произошла ошибка, то возвращается значение INVALID_SOCKET.
Если подключение подтверждено, то вся информация по текущему соединению передаётся на новый сокет, который будет отвечать со стороны Сервера за конкретное соединение с конкретным Клиентом. Перед вызовом accept() нам надо создать пустую структуру типа sockaddr_in, куда запишутся данные подключившегося Клиента после вызова accept(). Пример кода:
sockaddr_in clientInfo;
ZeroMemory(&clientInfo, sizeof(clientInfo));
int clientInfo_size = sizeof(clientInfo);
SOCKET ClientConn = accept(ServSock, (sockaddr*)&clientInfo, &clientInfo_size);
if (ClientConn == INVALID_SOCKET) {
cout << "Client detected, but can't connect to a client. Error # " << WSAGetLastError() << endl;
closesocket(ServSock);
closesocket(ClientConn);
WSACleanup();
return 1;
}
else
cout << "Connection to a client established successfully" << endl;
Всё, соединение между Клиентом и Сервером установлено! Самое время попробовать передать информацию от Клиента к Серверу и обратно. Как мы в начале и договорились, мы будет реализовывать простейший чат между ними.
Этап 6: Передача данных между Клиентом и Сервером
Принимать информацию на любой стороне можно с помощью функции recv(), которая при своём вызове блокирует исполнение кода программы до того момента, пока она не получит информацию от другой стороны, либо пока не произойдет ошибка в передаче или соединении.Отправлять информацию с любой стороны можно с помощью функции send(). При вызове данной функции обычно никакого ожидания и блокировки не происходит, а переданные в неё данные сразу же отправляются другой стороне.
Рассмотрим прототипы функций recv() и send():
int recv(SOCKET <сокет акцептованного соединения>, char[] <буфер для приёма информации с другой стороны>, int <размер буфера>, <флаги>)
int send(SOCKET <сокет акцептованного соединения>, char[] <буфер хранящий отсылаемую информацию>, int <размер буфера>, <флаги>)
Флаги в большинстве случаев игнорируются – передается значение 0.
Функции возвращают количество переданных/полученных по факту байт.
Как видно из прототипов, по своей структуре и параметрам эти функции совершенно одинаковые. Что важно знать:
- и та, и другая функции не гарантируют целостности отправленной/полученной информации. Это значит, что при реализации прикладных задач по взаимодействию Клиента и Сервера с их использованием требуется принимать дополнительные меры для контроля того, что все посланные байты действительно посланы и, что еще более важно, получены в том же объеме на другой стороне
- предельно внимательно надо относиться к параметру "размер буфера". Он должен в точности равняться реальному количеству передаваемых байт. Если он будет отличаться, то есть риск потери части информации или «замусориванию» отправляемой порции данных, что ведет к автоматической поломке данных в процессе отправки/приёма. И совсем замечательно будет, если размер буфера по итогу работы функции равен возвращаемому значению функции – размеру принятых/отправленных байт.
Сама по себе упаковка и отправка данных делается элементарным использованием функций чтения всей строки до нажатия кнопки Ввода - fgets() с последующим вызовом функции send(), а на другой стороне - приёмом информации через recv() и выводом буфера на экран через cout <<.
Процесс непрерывного перехода от send() к recv() и обратно реализуется через бесконечный цикл, из которого совершается выход по вводу особой комбинации клавиш. Пример блока кода для Серверной части:
vector <char> servBuff(BUFF_SIZE), clientBuff(BUFF_SIZE);
short packet_size = 0;
while (true) {
packet_size = recv(ClientConn, servBuff.data(), servBuff.size(), 0);
cout << "Client's message: " << servBuff.data() << endl;
cout << "Your (host) message: ";
fgets(clientBuff.data(), clientBuff.size(), stdin);
// Check whether server would like to stop chatting
if (clientBuff[0] == 'x' && clientBuff[1] == 'x' && clientBuff[2] == 'x') {
shutdown(ClientConn, SD_BOTH);
closesocket(ServSock);
closesocket(ClientConn);
WSACleanup();
return 0;
}
packet_size = send(ClientConn, clientBuff.data(), clientBuff.size(), 0);
if (packet_size == SOCKET_ERROR) {
cout << "Can't send message to Client. Error # " << WSAGetLastError() << endl;
closesocket(ServSock);
closesocket(ClientConn);
WSACleanup();
return 1;
}
}
Пришло время показать итоговый рабочий код для Сервера и Клиента. Чтобы не загромождать и так большой текст дополнительным кодом, даю ссылки на код на GitHub:
Исходный код для Сервера
Исходный код для Клиента
Несколько важных финальных замечаний:
- В итоговом коде я не использую проверку на точное получение отосланной информации, т.к. при единичной (не циклической) отсылке небольшого пакета информации накладные расходы на проверку его получения и отправку ответа будут выше, чем выгоды от такой проверки. Иными словами – такие пакеты теряются редко, а проверять их целостность и факт доставки очень долго.
- При тестировании примера также видно, что чат рабочий, но очень уж несовершенный. Наиболее проблемное место – невозможность отправить сообщение пока другая сторона не ответила на твоё предыдущее сообщение. Суть проблемы в том, что после отсылки сообщения сторона-отправитель вызывает функцию recv(), которая, как я писал выше, блокирует исполнение последующего кода, в том числе блокирует вызов прерываний для осуществления ввода. Это приводит к тому, что набирать сообщение и что-то отправлять невозможно до тех пор, пока процесс не получит ответ от другой стороны, и вызов функции recv() не будет завершен. Благо введенная информация с клавиатуры не будет потеряна, а, накапливаясь в системном буфере ввода/вывода, будет выведена на экран как только блокировка со стороны recv() будет снята. Таким образом, мы реализовали так называемый прямой полудуплексный канал связи. Сделать его полностью дуплексным в голой сокетной архитектуре достаточно нетривиальная задача, частично решаемая за счет создания нескольких параллельно работающих потоков или нитей (threads) исполнения. Один поток будет принимать информацию, а второй – отправлять.
Начинающему сетевому программисту
Тема сетевого программирования является для разработчиков одной из важнейших в современном цифровом мире. Правда, надо признать, что большая часть сетевого программирования сосредоточена в области...
habr.com