Руководства по сетевому программированию

Начинающему сетевому программисту

Время на прочтение
17 мин

Количество просмотров 66K

Тема сетевого программирования является для разработчиков одной из важнейших в современном цифровом мире. Правда, надо признать, что большая часть сетевого программирования сосредоточена в области написания скриптов исполнения для 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.

Теория сокетов за 30 секунд для «dummies»

Начну всё-таки немного с теории в стиле «for dummies». В любой современной операционной системе, все процессы инкапсулируются, т.е. скрываются друг от друга, и не имеют доступа к ресурсам друг друга. Однако существуют специальные разрешенные способы взаимодействия процессов между собой. Все эти способы взаимодействия процессов можно разделить на 3 группы: (1) сигнальные, (2) канальные и (3) разделяемая память.

Когда мы говорим про работу сетевого приложения, то всегда подразумеваем взаимодействие процессов: процесс 1 (клиент) пытается что-то послать или получить от Процесса 2 (сервер). Наиболее простым и понятным способом организации сетевого взаимодействия процессов является построение канала между этими процессами. Именно таким путём и пошли разработчики первых сетевых протоколов. Получившийся способ взаимодействия сетевых процессов в итоге оказался многоуровневым: основной программный уровень — стек сетевой технологии TCP/IP, который позволяет организовать эффективную доставку пакетов информации между различными машинами в сети, а уже на прикладном уровне тот самый «сокет» позволяет разобраться какой пакет какому процессу доставить на конкретной машине.

Иными словами «сокет» — это «розетка» конкретного процесса, в которую надо подключиться, чтобы этому процессу передать какую-либо информацию. Договорились, что эта «розетка» в Сети описывается двумя параметрами – IP-адресом (для нахождения машины в сети) и Портом подключения (для нахождения процесса-адресата на конкретной машине).

Для того, чтобы сокеты заработали под Windows, необходимо при написании программы пройти следующие Этапы:

  1. Инициализация сокетных интерфейсов Win32API.

  2. Инициализация сокета, т.е. создание специальной структуры данных и её инициализация вызовом функции.

  3. «Привязка» созданного сокета к конкретной паре IP-адрес/Порт – с этого момента данный сокет (его имя) будет ассоциироваться с конкретным процессом, который «висит» по указанному адресу и порту.

  4. Для серверной части приложения: запуск процедуры «прослушки» подключений на привязанный сокет.

    Для клиентской части приложения: запуск процедуры подключения к серверному сокету (должны знать его IP-адрес/Порт).

  5. Акцепт / Подтверждение подключения (обычно на стороне сервера).

  6. Обмен данными между процессами через установленное сокетное соединение.

  7. Закрытие сокетного соединения.

     Итак, попытаемся реализовать последовательность Этапов, указанных выше, для организации простейшего чата между клиентом и сервером. Запускаем Visual Studio, выбираем создание консольного проекта на С++ и поехали.

Этап 0: Подключение всех необходимых библиотек Win32API для работы с сокетами

Сокеты не являются «стандартными» инструментами разработки, поэтому для их активизации необходимо подключить ряд библиотек через заголовочные файлы, а именно:

  • WinSock2.h – заголовочный файл, содержащий актуальные реализации функций для работы с сокетами.

  • WS2tcpip.h – заголовочный файл, который содержит различные программные интерфейсы, связанные с работой протокола TCP/IP (переводы различных данных в формат, понимаемый протоколом и т.д.).

  • Также нам потребуется прилинковать к приложению динамическую библиотеку ядра ОС: ws2_32.dll. Делаем это через директиву компилятору: #pragma comment(lib, “ws2_32.lib”)

  • Ну и в конце Этапа 0 подключаем стандартные заголовочные файлы iostream и stdio.h   

Итого по завершению Этапа 0 в Серверной и Клиентской частях приложения имеем:

#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. Ну либо расстроить пользователя тем, что ему не удастся поработать с сокетами до обновления системных библиотек

Первый шаг делается с помощью создания структуры типа WSADATA, в которую автоматически в момент создания загружаются данные о версии сокетов, используемых ОС, а также иная связанная системная информация:WSADATA wsData;

Второй шаг – непосредственный вызов функции запуска сокетов с помощью 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() можно здесь.

Функция 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 для использования в функции bind() поля Порт (2 байта), Сетевой адрес (4 байта) и Технический массив (8 байт) как раз в сумме дают нам 14 байт, помещающихся в 14 байт, находящихся во втором поле структуры sockaddr. Первые поля у указанных типов совпадают – это семейство адресов сокетов (указываем AF_INET). Из этого видно, что структуры данных типа sockaddr и sockaddr_in тождественны, содержат одну и ту же информацию, но в разной форме для разных целей.

Соответственно, ввод данных для структуры типа sockaddr_in выглядит следующим образом:

  1. Создание структуры типа sockaddr_in : sockaddr_in servInfo;

  2. Заполнение полей созданной структуры 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;

Вся нужная информация для привязки сокета теперь у нас есть, и она хранится в структуре servInfo. Можно смело вызывать функцию bind(), не забыв при этом привести servInfo из типа sockaddr_in в требуемый функцииsockaddr*. Тогда итоговый код Этапа 3 (слава богу закончили) выглядит так:

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.

Функции возвращают количество переданных/полученных по факту байт.

Как видно из прототипов, по своей структуре и параметрам эти функции совершенно одинаковые. Что важно знать:

  • и та, и другая функции не гарантируют целостности отправленной/полученной информации. Это значит, что при реализации прикладных задач по взаимодействию Клиента и Сервера с их использованием требуется принимать дополнительные меры для контроля того, что все посланные байты действительно посланы и, что еще более важно, получены в том же объеме на другой стороне

  • предельно внимательно надо относиться к параметру «размер буфера». Он должен в точности равняться реальному количеству передаваемых байт. Если он будет отличаться, то есть риск потери части информации или «замусориванию» отправляемой порции данных, что ведет к автоматической поломке данных в процессе отправки/приёма. И совсем замечательно будет, если размер буфера по итогу работы функции равен возвращаемому значению функции – размеру принятых/отправленных байт.

В качестве буфера рекомендую использовать не классические массивы в С-стиле, а стандартный класс С++ <vector> типа char, т.к. он показал себя как более надежный и гибкий механизм при передаче данных, в особенности при передаче текстовых строк, где важен терминальный символ и «чистота» передаваемого массива.

Сама по себе упаковка и отправка данных делается элементарным использованием функций чтения всей строки до нажатия кнопки Ввода — 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) исполнения. Один поток будет принимать информацию, а второй – отправлять.

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

Mr_Dezz

Последнее обновление: 11.12.2022

  1. Глава 1. Основы работы с сетями в C# и .NET

    1. Введение в сети и протоколы

    2. Адреса в .NET

    3. Адреса Uri

    4. DNS

    5. Получение информации о сетевой конфигурации и сетевом трафике

    6. Класс Socket

  2. Глава 2. Протокол HTTP. Класс HttpClient и HttpListener

    1. Введение в протокол HTTP

    2. Создание HttpClient

    3. Отправка запросов с помощью HttpClient

    4. Получение данных в формате JSON

    5. Отправка и получение заголовков

    6. Отправка данных в запросе

    7. Отправка json с помощью HttpClient

    8. Взаимодействие HttpClient с Web API

    9. Отправка форм и класс FormUrlEncodedContent

    10. Отправка потоков и массива байтов

    11. Отправка файлов и класс MultipartFormDataContent

    12. Отправка и получение куки с HttpClient

    13. HttpListener. HTTP-сервер

  3. Глава 3. Протокол TCP

    1. TCP-клиент на сокетах

    2. TCP-сервер на сокетах

    3. NetworkStream

    4. TCP-клиент. Класс TcpClient

    5. TCP-сервер. Класс TcpListener

    6. Отправка и получение данных. Однонаправленная связь между сокетами

    7. Отправка и получение данных. Однонаправленная связь между TcpListener и TcpClient

    8. Отправка и получение данных. Двунаправленная связь

    9. Многопоточное клиент-серверное приложение TCP

    10. NetworkStream и текстовые потоки

    11. NetworkStream и бинарные потоки

    12. Консольный TCP-чат

  4. Глава 4. Протокол UDP

    1. Использование сокетов для работы с UDP

    2. UdpClient

    3. Консольный UDP-чат

    4. Широковещательная рассылка

  • Глава 1. Основы работы с сетями в C# и .NET
    • Введение в сети и протоколы
    • Адреса в .NET
    • Адреса Uri
    • DNS
    • Получение информации о сетевой конфигурации и сетевом трафике
    • Класс Socket
  • Глава 2. Протокол HTTP. Класс HttpClient и HttpListener
    • Введение в протокол HTTP
    • Создание HttpClient
    • Отправка запросов с помощью HttpClient
    • Получение данных в формате JSON
    • Отправка и получение заголовков
    • Отправка данных в запросе
    • Отправка json с помощью HttpClient
    • Взаимодействие HttpClient с Web API
    • Отправка форм и класс FormUrlEncodedContent
    • Отправка потоков и массива байтов
    • Отправка файлов и класс MultipartFormDataContent
    • Отправка и получение куки с HttpClient
    • HttpListener. HTTP-сервер
  • Глава 3. Протокол TCP
    • TCP-клиент на сокетах
    • TCP-Сервер на сокетах
    • NetworkStream
    • TCP-клиент. Класс TcpClient
    • TCP-сервер. Класс TcpListener
    • Отправка и получение данных в TCP. Однонаправленная связь между сокетами
    • Отправка и получение данных. Однонаправленная связь между TcpListener и TcpClient
    • Отправка и получение данных. Двунаправленная связь
    • Многопоточное клиент-серверное приложение TCP
    • NetworkStream и текстовые потоки
    • NetworkStream и бинарные потоки
    • Консольный TCP-чат
  • Глава 4. Протокол UDP
    • Использование сокетов для работы с UDP
    • UdpClient
    • Консольный UDP-чат
    • Широковещательная рассылка

YooMoney:

410011174743222

Перевод на карту

Номер карты:

4048415020898850

Brian «Beej Jorgensen» Hall
beej@beej.us
Version 3.0.14
September 8, 2009
Copyright © 2009 Brian «Beej Jorgensen» Hall
Оригинал материала: http://beej.us/guide/bgnet/

1. Введение
1.1 Аудитория
1.2 Платформа и компилятор
1.3 Официальная страница и книги
1.4 Программистам Solaris и SunOS
1.5 Программистам Windows
1.6 E-Mail политика
1.7 Зеркалирование
1.8 Copyright и распространение

2. Что такое сокет?
2.1 Два типа Интернет-Сокетов
2.2 Теория сетей и низкие уровни

3. IP-адреса, структуры, и передача данных
3.1 IP-адреса, версии 4 и 6
3.2 Порядок следования байт
3.3 Структуры
3.4 IP-адреса, часть два

4. Переход от IPv4 к IPv6

5. Системные вызовы
5.1 getaddrinfo() — готовимся к запуску!
5.2 socket() — создаём дескриптор файла!
5.3 bind() — на каком я порту?
5.4 connect() — Эй, ты!
5.5 listen() — Кто-нибуть может мне позвонить?
5.6 accept() — спасибо за звонок на порт 3490.
5.7 send() и recv() — поговори со мной, детка!
5.8 sendto() и recvfrom() Поговрии со мной дейтаграммами!
5.9 close() и shutdown() — Уйди с глаз моих!
5.10 getpeername() — ты кто такой?
5.11 gethostname() — а я кто такой?

6. Взаимодействие Клиент-Сервер
6.1 Простой TCP-сервер
6.2 Простой TCP-клиент
6.3 UDP-сокеты

7. Чуть более продвинутая техника
7.1 Блокирование
7.2 select() — мультиплексирование синхронного I/O
7.3 Обработка частичного send()
7.4 Сериализация — как упаковывать данные
7.5 Сын инкапсуляции данных
7.6 Бродкаст пакеты — Hello, World!

8. Общие вопросы

Введение

Эй! Программирование сокетов тебя достало? Оно слишком заковыристо, чтобы изучить его по манам? Вы хотите писать сетевые программы, но у вас нет времени, чтобы разбираться в дебрях документации, чтобы всего лишь узнать, что перед connect() нужно вызывать bind() и т.д. и т.п.?

Знаете что? Я уже проделал эту грязную работу и горю желанием поделиться со всеми полученными знаниями! Вы сюда удачно зашли! Этот документ даст среднему программисту на C достаточно знаний, чтобы начать писать сетевые программы на C.

И ещё: я наконец нашел время и дополнил гайд информацией о программировании IPv6! Наслаждайтесь!

Аудитория

Этот документ написан в качестве учебника, а не справочного материала. Наверно, лучше всего он будет восприниматься теми, кто только начинает изучать программирование сокетов и ищет точку опоры. Это не полное глобальное руководство по программированию сокетов.
Надеюсь, однако, его будет достаточно, чтобы понять суть и смысл сокетов… 🙂

Платформа и компилятор

Код, приведённый в этом документе, был скомпилирован на компьютере под управлеием GNU/Linux компилятором GNU GCC. Однако, он будет работать и на любой другой платформе, использующей gcc. Естественно, это не касается windows — смотрите раздел Программистам Windows.

Официальный сайт и книги

Официальное и изначальное местонахождение этого документа — http://beej.us/guide/bgnet/. Там вы также найдете примеры кода и переводы руководства на различные языки.

Чтобы купить красиво переплетенные копии этого документа (некоторые называют их «книги»), посетите http://beej.us/guide/url/bgbuy. Я ценю заказы своих книг, поскольку они помогают поддерживать мой документо-писательский образ жизни!

Программистам Solaris и SunOS

При компиляции под Solaris/SunOS вам нужно указать компилятору дополнительные библиотеки для линковки. Для этого просто добавьте в строку компиляции: «-lnsl -lsocket -lresolv» , как-то так:

$ cc o server server.c lnsl lsocket lresolv

Если всё ещё возникают ошибки, добавьте ещё и «-lxnet». Я не знаю, что это такое, но у некоторых людей это решало проблему.

Другое узкое место — вызов setsockopt(). Прототип отличается от такогого в Linux, так что вместо

int yes=1;

используйте:

char yes=‘1’;

Так как у меня нет соляриса, я не тестировал ничего из этого. Всё это мне пришло в отзывах на e-mail.

Программистам Windows

Исторически этот гайд не рассчитан на windows. Просто потому, что windows я не люблю. Но стоит быть действительно честным и признать, что у windows есть огромная база программ и пользователей, и в сущности это прекрасная ОС.

Я всё ещё надеюсь, что вы попробуете Linux, BSD или любой другой Unix.
Но людям нравится то, что им нравится, и ребята под windows имеют право на свою долю информации. Этот документ полезен и им тоже, с небольшими изменениями в коде.

Во-первых, вы можете поставить такую штуку, как Cygwin. Это коллекция инструментов Unix под Windows. Насколько я понимаю, это позволит оставить код без изменений.

Но некоторые из вас, возможно, захотят писать код под чистый windows. Это очень плохо, и вы должны немедленно поставить Unix!
Нет-нет, шучу. Нужно быть windows-терпеливым ближайшие дни…

Во-первых, вы должны игнорировать почти все хэдер-файлы, которые я использую. Всё, что вам нужно включать, это:

#include <winsock.h>

Стоп! Ещё вы должны вызывать WSAStartup() перед всем остальным кодом, относящимся к сокетам. Выглядит это примерно так:

#include <winsock.h>

{
WSADATA wsaData;   // if this doesn’t work
//WSAData wsaData; // then try this instead

// MAKEWORD(1,1) for Winsock 1.1, MAKEWORD(2,0) for Winsock 2.0:

if (WSAStartup(MAKEWORD(1,1), &wsaData) != 0) {
fprintf(stderr, «WSAStartup failed.n»);
exit(1);
}

Также вам нужно прилинковать к проекту библиотеки: обычно это wsock32.lib или winsock32.lib или ws2_32.lib для winsock 2.0. В VC++ это может быть сделано через меню Проект, в меню Настройки… Выберите вкладку компилятор->линковка или что-то вроде того, и добавьте «wsock32.lib» (или какой-то другой похожый .lib).

Ну, я слышал, что это примерно так делается.

В конце работы с сокетами вы должны вызывать WSACleanup().

Если вы сделаете всё это, остальные примеры из этого учебника должны, по идее, работать, хоть и с некоторыми исключениями.
Во-первых, вместо close() вам нужно использовать closesocket(). select() работает только с дескриптором сокета, с дескриптором файла, как в unix, не работает.

Существует так же класс для работы с сокетами, CSocket, можете поискать его в учебниках.

Чтобы получить больше информации о Winsock, читайте Winsock FAQ.

Наконец, насколько я знаю, в windows, к сожалению, нет системы fork(), использующейся в некоторых примерах. Может быть, вы сможете прилинковать для форка библиотеку POSIX, или использовать вместо него CreateProcess(). Форк не принимает аргументов, а CreateProcess принимает миллиарды. Если вам не хочется в них разбираться, используйте CreateThread, это немного проще… К сожалению, дискуссия о многопоточности выходит за рамки этого документа.

1.6 E-Mail политика

Обычно я доступен по электронной почте, если вам нужна помощь или есть вопросы, так чир не стесняйтесь писать, но я не могу гарантировать свой ответ. Я веду довольно занятой жизни и Есть моменты, когда я просто не могу ответить на ваш вопрос. Когда так случается, я обычно просто удаляю сообщение. Ничего личного, у меня просто никогда не будет времени, чтобы дать подробный ответ, которого вы требуете.

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

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

Теперь, когда я загрузил вас мыслями о том, писать мне или не писать, я просто хочу, чтобы вы знали, что я в полной мере оценил все похвалы руководства, которые получил за эти годы. Знание, что книга используется для создания полезных и хороших вещей — реальная поднимает боевой дух, и радует! 🙂 Спасибо!

1.7 Зеркалирование

Зеркалирование этого материала более чем приветствуется, будь то государственные или частные сайты. Если вы опубликовали зеркало и хотите, чтобы я разместил ссылку на него на главной странице, напишите мне на beej@beej.us.

Авторские права и распространение

Руководство Beej по сетевому программированию © 2009 Brian «Beej Jorgensen» Hall.

За некоторыми исключениями исходного кода и переводов, ниже, эта работа под лицензией Creative Commons Attribution-Noncommercial-No Derivative Works 3.0 License. Чтобы просмотреть копию данной лицензии, посетите http://creativecommons.org/licenses/by-nc-nd/3.0/ или отправьте письмо в Creative Commons, 171 Second Street, Suite 300, San Francisco, California, 94105, USA.

Одним из конкретных исключений «No Derivative Works» части лицензии является следующее: это руководство может быть свободно переведено на любой язык при условии, что перевод является точным, и руководство перепечатано в полном объеме. Такие же ограничения лицензии относятся как переводу, так и к исходному руководству. Перевод может также включать имя и контактную информацию переводчиков.

Исходный код, представленный в данном документе, настоящим объявляется общественным достоянием, и полностью свободен от каких-либо лицензионных ограничений.

Педагогам свободно предлагается рекомендовать или предоставить копии этого руководства своим студентам.

Свяжитесь с beej@beej.us для получения дополнительной информации.

Основы сетевого программирования

44 мин на чтение

(52.704 символов)

Основные понятия сетевого программирования

Чем сетевые приложения отличаются от обычных?

alt_text

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

Сетевые приложения могут обмениваться информацией с другими, сторонними приложениями либо строить взаимодействие по сети между компонентами одного и того же приложения, написанного одним автором или одной командой.

Возможность обмениваться данными по сети открывает перед разработчиком широкий круг возможностей.

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

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

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

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

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

Можно организовывать централизованное хранилище данных. Это удобно, если, например, вам нужно собирать данные от пользователей вашей программы в одном месте, либо предоставить пользователям возможность обмениваться сообщениями.

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

Выводы:

  1. Сетевые приложения — это программы, которые обмениваются данными через сеть.
  2. В подавляющем большинстве случаев обмен идет на основе протоколов TCP/IP.
  3. В настоящее время все более или менее развитые приложения являются сетевыми в той или иной мере.
  4. Приложения обмениваются данными с другими либо между своими компонентами.
  5. Можно обращаться к сторонним сервисам
  6. Создание публичного сервиса — это тоже задача сетевого программирования.
  7. Многопользовательские приложения очень распространены в определенных предметных областях.
  8. Автоматические обновления — это возможность, которая есть почти во всех программных продуктах.
  9. Одна из частных задач сетевого программирования — удаленное хранение данных
  10. Для экономики и бизнеса важна омниканальность взаимодействия с клиентами и пользователями.

В чем сложности создания сетевых приложений?

Разработка сетевых приложений — отдельная дисциплина информатики, требующая от программиста некоторых дополнительных знаний и умений. Естественно, для создания приложений, использующих возможности компьютерной сети необходимо знать основы функционирования таких сетей, понимать главные сетевые протоколы, понятие маршрутизации и адресации. В подавляющем большинстве случаев на практике используются протоколы семейства TCP/IP. Это сейчас универсальный механизм передачи данных. Но сами сетевые приложения могут интенсивно использовать разные конкретные протоколы, находящиеся на разных уровнях модели OSI.

Например, в этой теме мы в основном будем говорить об использовании TCP и UDP сокетов, то есть будем работать на транспортном уровне модели OSI. Но можно работать и на других уровнях с использованием других протоколов. Так веб приложения наиболее активно полагаются на протокол HTTP и его защищенную модификацию — HTTPS. Поэтому так важно знать все многообразие существующих схем и протоколов обмена данными. Ведь при проектировании приложения нужно выбрать тот протокол, уровень, который наилучшим образом подходит для решений стоящей перед разработчиками прикладной задачи.

Кроме того, при проектировании и реализации сетевых приложений на вас, как на разработчике лежит задача продумывания конкретного обмена данными. Используемый протокол регламентирует порядок передачи данных, но конкретную схему, последовательность обмена создает разработчик конкретного приложения. Вам надо определиться, какие данные будут передаваться по сети, в каком формате, в каком порядке. Будете ли вы использовать, например, JSON или XML, может, стоит придумать свой формат данных? После соединения, какой модуль начинает передачу, сколько итераций передачи будет проходить за одно соединение, как обозначить конец передачи, нужно ли регламентировать объем передаваемой информации, нужно ли использовать шифрование данных — это все примеры вопросов, которые необходимо будет решить в ходе разработки. Фактически, каждое сетевое приложение — это по сути еще один, очень конкретный протокол передачи, которые для себя придумывают разработчики специально для этого приложения.

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

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

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

Выводы:

  1. Нужно знать основы организации компьютерной сети.
  2. Для некоторых приложений необходимо знать особенности конкретных сетевых протоколов, например, HTTP.
  3. Необходимо также отдельно заботиться о согласованности обмена информацией между компонентами приложения.
  4. Написание многопоточных приложений требует специальных усилий по обеспечению потокобезопасности.
  5. Также не нужно забывать о вопросах безопасности, конфиденциальности, валидации получаемых данных.
  6. Также существуют вопросы управления нагрузкой ваших сервисов и сетевой инфраструктуры.
  7. Необходимо также помнить о доступности сети и ограниченности полосы пропускания.

Какие основные подходы к их построению?

При создании сетевых приложений первый вопрос, который должен решить для себя разработчик — создание высокоуровневой архитектуры приложений. Из каких частей (модулей) оно будет состоять, как именно они будут обмениваться данными и с кем. Будет ли обмен происходить только между модулями самого приложения или будут предусмотрены обращения к внешним сервисам?

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

alt_text

Самая популярная архитектура сетевых приложений — клиент серверная. Она подразумевает, что приложение состоит из серверной части и клиентской. Сервером мы будем называть именно часть программной системы, модуль, который постоянно (резидентно) выполняется и ждет запросов от клиентов. Когда запрос поступает, сервер его обрабатывает, понимает, что клиент хочет получить и выдает ему ответ.

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

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

Сервер непосредственно с клиентом не взаимодействует. Его задача — выполнять запросы клиентов. Он в такой схеме является центральным элементом. Распределение функционала между клиентом и сервером — другими словами, какие операции вашей программы должны относиться к клиентской части, а какие к серверной — тоже предмет проектирования. Определенно одно — все, что касается пользовательского интерфеса — это прерогатива клиентской части. В зависимости от задачи вы можете делать клиент более “тонким”, то есть оставить только интерфейс и больше ничего (тогда при любых действиях пользователя клиент будет запрашивать сервер и просить его выполнять операции), либо более “толстым” — то есть выносить на клиент часть непосредственного фукнционала приложения.

Достоинством клиент-серверной архитектуры является ее простота и понятность. Приложение явно делиться на четко обозначенные модули, между ними налаживается довольно типичная схема обмена данными. Но у нее есть и недостатки. Если по каким-то причинам сервер становится недоступен, то полноценно пользоваться приложением нельзя. То есть сервер — это потенциальная точка отказа.

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

alt_text

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

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

Несмотря на все достоинства, проектировать, реализовывать и поддерживать распределенные приложения может быть значительно сложнее, чем клиент-серверные. Это происходит потому, что клиент-сервер, более четко определенный протокол, ему нужно лишь следовать и все будет работать так, как предполагается. А в распределенных приложениях все приходится проектировать и продумывать с нуля.

Выводы:

  1. Есть две главные архитектуры построения сетевых приложений — клиент-серверная и распределенная.
  2. Сервер — это компьютер, программа или процесс, обеспечивающий доступ к информационным ресурсам.
  3. Клиент обычно инициирует соединение и делает запрос к ресурсу.
  4. Клиент-серверная архитектура является централизованной со всеми присущими недостатками и преимуществами.
  5. Распределенная архитектура может обойти некоторые ограничения централизованной.
  6. Распределенные приложения сложнее проектировать и управлять ими.

Основы взаимодействия через сокеты

Что такое TCP-сокеты?

alt_text

Со́кет (англ. socket — разъём) — название программного интерфейса для обеспечения обмена данными между процессами. Процессы при таком обмене могут исполняться как на одной ЭВМ, так и на различных ЭВМ, связанных между собой сетью.

Сокеты — это самый базовый механизм сетевого взаимодействия программ, абстрагированный от конкретной реализации сети. Сокеты работают на транспортном уровне модели OSI — там же, где и протокол TCP и UDP.

Каждый сетевой интерфейс IP-сети имеет уникальный в этой сети адрес (IP-адрес). Упрощенно можно считать, что каждый компьютер в сети Интернет имеет собственный IP-адрес. При этом в рамках одного сетевого интерфейса может быть несколько (до 65536) сетевых портов. Для установления сетевого соединения приложение клиента должно выбрать свободный порт и установить соединение с серверным приложением, которое слушает (listen) порт с определенным номером на удаленном сетевом интерфейсе. Пара IP-адрес и порт характеризуют сокет (гнездо) — начальную (конечную) точку сетевой коммуникации.

Сокеты могут применяться для связи процессов как на удаленной машине, так и на локальной. В первом случае, естественно, необходимо, чтобы удаленная машина была доступна по сети. Это можно проверить при помощи команды пинг. Во втором случае сокеты могут выступать как механизм межпроцессного взаимодействия. Или вы можете использовать одну машину для всех процессов-компонентов вашей программной системы, например, для отладки в процессе разработки.

Для создания соединения TCP/IP необходимо два сокета: один на локальной машине, а другой — на удаленной. Таким образом, каждое сетевое соединение имеет IP-адрес и порт на локальной машине, а также IP-адрес и порт на удаленной машине. Как правило, порт локальной машины (исходящий порт) не так важен и его номер не особенно используется в практике. Но порт серверного сокета — это важная информация

Сокеты могут быть клиентские и серверные. Серверный сокет — это функция в программе, которая сидит на определенном порту и “слушает” входящие соединения. Процедура создания серверного сокета аналогична вводу текста из консоли: программа блокируется до тех пор, пока пользователь не ввел что-то. Когда это случилось, программа разблокируется и может продолжать выполнение и обработку полученных данных. Также и серверный сокет: ждет, когда к нему подключится клиент и тогда продолжает выполнение программы и может считывать данные из сокета (которые послал клиент) и отправлять данные в сокет. Клиентский же сокет, наоборот, сразу пытается подключиться к определенном узлу сети (это может быть локальная машина, или, чаще, удаленный компьютер) и на определенный сетевой порт. Если на этой машине на этом порту “сидит” серверный сокет, то подключение происходит успешно. Если же данный сокет никем не прослушивается, то процедура подключения возвращает ошибку.

В языке программирования Python существует стандартный модуль socket, который реализует все необходимые функции для организации обмена сообщениями через сокеты. Для его использования его достаточно импортировать (так как это модуль стандартной библиотеки, устанавливать его не нужно, он идет в поставке с дистрибутивом Python):

Для начала построения сетевого взаимодействия необходимо создать сокет:

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

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

Также среди Интернер сокетов существуют потоковые и датаграммные сокеты.
Датаграммные сокеты называют “сокеты без соединения”, они используют протокол UDP вместо TCP. Потоковые сокеты обеспечивают гарантированную доставку, очередность сообщений, они более надежны. Протокол HTTP использует именно потоковые сокеты для соединения клиента с сервером. UDP обычно используется для передачи потокового медиа, когда скорость критичнее риска потери единичных пакетов.

Выводы:

  1. Сокеты — это базовый механизм сетевого взаимодействия программ.
  2. Для работы сокетов не нужно специальное программное обеспечение, они работают на уровне операционных систем.
  3. Сокет состоит из адреса хоста и номера сетевого порта.
  4. Для соединения необходимо создать два сокета — в двух модуля программы, которые нужно соединить.
  5. Стандартный модуль Python socket позволяет создавать сокеты и работать с ними.
  6. Еще отдельный вид сокетов применяется для организации межпроцессного взаимодействия в *nix системах.
  7. Сокеты могут использовать протокол TCP либо UDP.

Каковы правила использования номеров портов?

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

alt_text

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

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

Так как на номер порта отведено 16 бит, существует всего 65536 возможных портов. Причем, номера портов отдельно считаются по протоколам TCP и UDP. Таким образом, на компьютере одновременно может существовать более 130 тысяч процессов, обменивающихся данными. На практике, свободных портов всегда в избытке и хватает даже для работы множества высоконагруженных серверов.

Но не все номера портов созданы равными. Первые 1024 являются “системными” и используются в основном стандартными приложениями. Существует общепринятое соглашение, какие сетевые службы используют системные порты. Например, служба веб-сервера по умолчанию использует 80 порт для соединений по протоколу HTTP и 443 — для протокола HTTPS. Служба SSH использует порт номер 22. И так далее. Любая стандартная сетевая служба имеет некоторый порт по умолчанию. Кстати, хорошим показателем практикующего администратора является запоминание часто используемых номеров стандартных портов. Специально это учить не нужно, только если вы не хотите блеснуть знаниями на собеседовании.

Для использования системных портов обычно требуются повышенные привилегии. Это сделано для того, чтобы обычные пользователи не “забивали” стандартные порты и не мешали работе системных сетевых служб. Мы вообще не рекомендуем использовать системные порты. Остальные могут использоваться совершенно произвольно и их более чем достаточно для повседневных нужд.

Кстати, хоть сетевые службы используют определенные стандартные порты, они вполне могут быть переназначены на свободные. Служба не “привязана” к номеру порта, это легко регулируется настройками. Например, строго рекомендуется при настройке службы SSH менять стандартный 22 порт на случайный для повышения безопасности.

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

Сетевые администраторы могут в целях безопасности блокировать соединения на некоторые порты. Так же поступают и программы-файерволлы. Это требуется для повышения безопасности сервера. Вообще, по умолчанию, все порты “закрыты”, то есть подключения к ним блокируется файерволлом. При необходимости системный администратор может “открыть” обмен данными по определенному номеру порта в настройках файерволла. Это следует учитывать при попытках подключения к удаленным машинам.

Выводы:

  1. Порт — это всего лишь число в IP-пакете.
  2. Номер порта нужен, чтобы обратиться к определенному процессу на конкретном хосте.
  3. Всего существует 65536 TCP-портов и 65536 UDP-портов.
  4. Первые 1024 порта являются системными — их можно использовать только администраторам.
  5. Распространенные сетевые службы имеют стандартные номера портов, их лучше не занимать.
  6. Порт назначается при открытии серверного сокета. Можно занять только свободный порт.
  7. Системные администраторы, программы-файерволлы могут заблокировать, “закрыть” обмен данными по номерам портов.

Почему стоит начать именно с изучения сокетов?

alt_text

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

Более высокоуровневые механизмы требуют установленного и настроенного специального программного обеспечения. Чтобы написать веб-приложение, нам нужен веб-клиент и веб-сервер, настроенные и готовые к работе. Такая же ситуация с любой другой службой Интернета. Конечно, на практике большинство популярных сетевых приложений используют более высокоуровневые протоколы, например, тот же HTTP.

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

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

Поэтому транспортные сокеты — это компромиссный вариант между прикладными и физическими протоколами.

Выводы:

  1. Сокеты не требуют специального программного обеспечения.
  2. Сокеты не зависят от конкретной физической реализации сети.
  3. Сокеты хороши для понимания основ сетевого взаимодействия.

Как организуется обмен данными через TCP-сокеты?

alt_text

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

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

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

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

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

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

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

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

Следует помнить, что сокеты предоставляют потоковый интерфес передачи данных. Это значит, что через сокет не получится посылать сообщения “пакетами”. По сути, сокет предоставляет два непрерывных потока — один от сервера к клиенту, другой — от клиента к серверу. Мы не можем прочитать отдельное сообщение из этого потока. При чтении мы указываем количество бит, которые хотим прочитать. И процесс будет ждать, пока другой не отправит в сокет необходимое количество (ну либо не закроет сокет). Более понятны особенности работы в потоковом режиме станут на практике.

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

Еще одно замечание. Так как сокет — это пара адрес-порт, и сокета для соединения нужно два, получается что и порта тоже нужно два? Формально, да, у нас есть клиент — его адрес и номер порта, и сервер — его адрес и номер порта. Первые называются исходящими (так как запрос на соединение происходит от них), а вторые — входящие (запрос приходит на них, на сервер). Для того, чтобы соединение прошло успешно, клиент должен знать сокет сервера. А вот сервер не должен знать сокет клиента. При установке соединения, адрес и порт клиента отправляются серверу для информации. И если адрес клиента иногда используется для его идентификации (я тебя по IP вычислю), то исходящий порт обычно не нужен. Но откуда вообще берется этот номер? Он генерируется операционной системой случайно из незанятых несистемных портов. При желании его можно посмотреть, но пользы от этого номера немного.

Выводы:

  1. Серверный сокет назначается на определенный свободный порт и ждет входящих соединений.
  2. Клиентский сокет сразу соединяется с северным. Тот должен уже существовать.
  3. Сокет — это двунаправленное соединение. В него можно читать и писать, как в файл.
  4. Сокет — это битовый потоковый протокол, строки нужно определенным образом кодировать в битовый массив пред отправкой.
  5. После использования сокет нужно закрыть, иначе порт будет считаться занятым.
  6. Существует входящий и исходящий номер порта. Но исходящий номер назначается случайно и редко используется.

Простейшие клиент и сервер

Что мы хотим сделать?

alt_text

Давайте приступим к практическому знакомству с обменом информацией между процессами через сокеты. Для этого мы создадим простейшую пару клиент-сервер. Это будут две программы на Python, которые связываются друг с другом, передают друг другу какую-то информацию и закрываются.

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

Мы будем организовывать один набор сокетов. Так что один из них должен быть клиентским, а другой — серверным. Поэтому один процесс мы будем называть сервером, а другой — клиентом. Однако, такое разделение условно и одна и та же программа может выступать и клиентом для одного взаимодействия и сервером — для другого. Можно сказать, что клиент и сервер — это просто роли в сетевом взаимодействии. Инициирует соединение всегда клиент, это и определяет его роль в сетевом взаимодействии. То есть, то процесс, который первый начинает “стучаться” — тот и клиент.

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

Как создать простой эхо-сервер?

Для начала нам нужно определиться, какой порт будет использовать наш сервер. Мы можем использовать любой несистемный номер порта. Лучше заранее убедиться, что он в данный момент не используется в вашей системе. Например, если вы используете сервер баз данных MS SQL он обычно занимает порт 5432. Мы в учебных примерах будет использовать номер 9090 — это запоминающееся, но ничем не особенное число. Вы можете использовать другой.

Для начала импортируем модуль socket:

Теперь можно создать сокет одноименным конструктором из этого модуля:

Теперь переменная sock хранит ссылку на объект сокета. И через эту переменную с сокетом можно производить все необходимые операции.

После этого свяжем сокет с номером порта при помощи метода bind:

Этот метод принимает один аргумент — кортеж из двух элементов. Поэтому обратите внимание на двойные круглые скобки. Первый элемент кортежа — это сетевой интерфейс. Он задает имя сетевого адаптера, соединения к которому нас интересуют. Вы же знаете, что на компьютере может быть несколько адаптеров, и он может быть подключен одновременно к нескольким сетям. Вот с помощью этого параметра можно выбрать соединения из какой конкретно сети мы будем принимать. Если оставить эту строку пустой, то сервер будет доступен для всех сетевых интерфейсов. Так как мы не будем заниматься программированием межсетевых экранов, мы будем использовать именно это значение.

Второй элемент кортежа, который передается в метод bind — это, собственно, номер порта. Для примера выберем порт 9090.

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

Теперь, мы можем принять входящее подключение с помощью метода accept, который возвращает кортеж с двумя элементами: новый сокет (объект подключения) и адрес клиента. Именно этот новый сокет и будет использоваться для приема и посылке клиенту данных.

1
conn, addr = sock.accept()

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

Объект conn мы будем использовать для общения с этим подключившимся клиентом. Переменная addr — это всего лишь кортеж их двух элементов — адреса хоста клиента и его исходящего порта. При желании можно эту информацию вывести на экран.

Так мы установили с клиентом связь и можем с ним общаться. Чтобы получить данные нужно воспользоваться методом recv, который в качестве аргумента принимает количество байт для чтения. Мы будем читать из сокета 1024 байт (или 1 кб):

1
2
data = conn.recv(1024)
conn.send(data.upper())

Обратите внимание, что прием и отправка сообщений происходит через объект conn, а не sock. Объект conn — это подключение к конкретному клиенту. Это особенность работы именно TCP-сокетов, при использовании протокола UDP все немного по-другому.

Тут есть один неудобный момент. При чтении нам нужно указать объем данных, которые мы хотим прочитать. Это обязательный параметр. Помните мы говорили, что сокеты — это потоковый механизм? Именно поэтому нельзя прочитать сообщение от клиента “целиком”. Ведь клиент может присылать в сокет информацию порциями, в произвольный момент времени, произвольной длины. В сокете, а точнее во входящем потоке, все эти сообщения “склеются” и будут представлены единым байтовым массивом.

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

1
2
3
4
5
while True:
    data = conn.recv(1024)
    if not data:
        break
    conn.send(data)

Это работает потому, что после того, как клиент отослал всю информацию, он закрывает соединение. В таком случае на сервере метод recv возвращает пустое значение. Это сигнал завершения передачи.

Метод recv(), как мы говорили, тоже является блокирующей операцией. Он разблокируется (то есть продолжает выполняться) в двух случаях: когда клиент прислал необходимый объем данных, либо когда клиент закрыл соединение.

Еще, если вы обратили внимание, после получения данных мы их отправляем обратно клиенту методом send(). Это метод посылает данные через сокет. Это уже не блокирующая операция, ведь данные посылаются тут же и не нужно ничего ждать.

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

После получения порции данных и отсылки их обратно клиенту можно и закрыть соединение:

На этом написание сервера закончено. Он принимает соединение, принимает от клиента данные, возвращает их клиенту и закрывает соединение. Вот что у нас получилось в итоге

1
2
3
4
5
6
7
8
9
10
11
12
13
#!/usr/bin/env python
import socket
sock = socket.socket()
sock.bind(('', 9090))
sock.listen(1)
conn, addr = sock.accept()
print 'connected:', addr
while True:
    data = conn.recv(1024)
    if not data:
        break
    conn.send(data)
conn.close()

Как создать простейший клиент?

Клиентское приложение еще короче и проще. Клиент использует точно такой же объект socket.

1
2
import socket
sock = socket.socket()

Вместо привязывания и прослушивания порта мы сразу осуществляем соединение методом connect. Для этого указывается IP-адрес хоста, на котором запущен сервер и номер порта, который он прослушивает.

1
sock.connect(('localhost', 9090))

В качестве адреса можно использовать IP-адрес, доменное имя, либо специальное имя localhost. В нашем случае, так как мы пока подключаемся к другому процессу на той же машине, будем использовать его (либо адрес 127.0.0.1, что абсолютно эквивалентно).

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

Послание данных в сокет осуществляется методом send. Но тут есть один подводный камень. Дело в том, что сокеты — это еще и байтовый протокол. Поэтому в него не получится просто так отправить, например, строку. Ее придется преобразовать в массив байт. Для этого в Python существует специальный строковый метод — encode(). Его параметром является кодировка, которую нужно использовать для кодирования текста. Если кодировку не указывать, то будет использоваться Unicode. Рекомендуем это так и оставить. Вот что получится в итоге:

1
2
msg = 'hello, world!'
sock.send(msg.encode())

Дальше мы читаем 1024 байт данных и закрываем сокет. Для большей надежности чтение данных можно организовать так же как на сервере — в цикле.

1
2
data = sock.recv(1024)
sock.close()

Если мы обмениваемся текстовой информацией, то надо помнить, что данные полученные из сокета — это тоже байтовый массив. Чтобы преобразовать его в строку (например, для вывода на экран), нам нужно воспользоваться методом decode(), который выполняет операцию, обратную encode(). Понятно, что кодировки при этом должны совпадать. Вообще, нет ни одной причины использовать не Unicode.

Здесь важно помнить вот что. Этот клиент хорошо подходит к тому серверу, который мы создавали в предыдущем пункте. Ведь после установки соединения сервер ожидает приема информации (методом recv). А клиент после соединения сразу начинает отдавать информацию (методом send). Если бы же обе стороны начали ждать приема, они бы намертво заблокировали друг друга. О порядке передачи информации нужно определиться заранее. Но в общем случае, обычно именно клиент первым передает запрос, сервер его читает, передает ответ, который читает клиент.

Вот что у нас получилось в итоге:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#!/usr/bin/env python

import socket

sock = socket.socket()
sock.connect(('localhost', 9090))

msg = 'hello, world!'
sock.send(msg.encode())

data = sock.recv(1024)
sock.close()

print(data.decode())

Какие ограничения данного подхода?

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

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

Еще можно заметить, что в работе и клиента и сервера часто встречаются блокирующие операции. Само по себе это неплохо, и даже необходимо. Но представим себе, что клиент по каким-то причинам аварийно завершил свою работу. Или упала сеть. Если в это время сервер ждал сообщения от клиента (то есть заблокировался методом recv), то он останется ждать неопределенно долго. То есть упавший клиент может нарушить работу сервера. Это не очень надежно. Но это тоже легко исправить с помощью установки таймаутов.

Более сложная проблема состоит в том, что сервер способен одновременно работать только с одним клиентом. Вот это исправить уже сложнее, так как потребует многопоточного программирования. Но есть еще один вариант — использовать UDP-сокеты.

Так что давайте познакомимся с некоторыми приемами, которые позволяют обойти или исправить эти недостатки.

Как сделать сервер многоразовым?

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#!/usr/bin/env python
import socket
sock = socket.socket()
sock.bind(('', 9090))
sock.listen(1)
while True:
	conn, addr = sock.accept()
	print 'connected:', addr
	while True:
	    data = conn.recv(1024)
	    if not data:
	        break
	    conn.send(data)
	conn.close()

Теперь наш сервер после завершения соединения с одним клиентом переходит на следующую итерацию цикла и начинает снова слушать входящие соединение. И к нему может подключиться как тот же самый клиент, так и новый.

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

Например, само собой напрашивается создание констант для параментов сокета — интерфейса и номера порта:

1
2
3
4
5
HOST = ""
PORT = 33333

TYPE = socket.AF_INET
PROTOCOL = socket.SOCK_STREAM

Кроме адреса мы еще выделили тип и протокол сокета. Это параметры конструктора socket(), которые мы раньше не использовали. Первый параметр задает тип создаваемого сокета — Интернет-сокет или UNIX-сокет. Мы будем использовать только первый тип, но сейчас мы зададим его явно. Второй параметр — это тип используемого протокола. Потоковые сокеты используют протокол TCP, а датаграммные — UDP. Скоро мы будем создавать и UDP-соединение, так что не лишним будет прописать это тоже в явном виде.

После этих изменений код создания и связывания сокета будет выглядеть следующим образом:

1
2
srv = socket.socket(TYPE, PROTOCOL)
srv.bind((HOST, PORT))

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

1
2
3
4
5
6
7
8
9
10
def do_something(x):
  # ...
  return x

# ...

srv = socket.socket(TYPE, PROTOCOL)
srv.bind((HOST, PORT))

# ...

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
  srv.listen(1)             
  print("Слушаю порт 33333")
  sock, addr = srv.accept()
  print("Подключен клиент", addr)
  while 1:
    pal = sock.recv(1024)
    if not pal: 
      break
    print("Получено от %s:%s:" % addr, pal)
    lap = do_something(pal)
    sock.send(lap)
    print("Отправлено %s:%s:" % addr, lap)
  sock.close()
  print("Соединение закрыто")

Кроме того, такие отладочные сообщения служат заменой комментариям в коде. Сейчас комментарии особенно и не нужны, потому что их названия переменных, отладочных сообщений, имен функций понятно, что происходит в программе. Такой код называется самодокументируемым.

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import socket, string

HOST = ""
PORT = 33333

TYPE = socket.AF_INET
PROTOCOL = socket.SOCK_STREAM
 
def do_something(x):
  return x

srv = socket.socket(TYPE, PROTOCOL)
srv.bind((HOST, PORT))
while 1:
  srv.listen(1)             
  print("Слушаю порт 33333")
  sock, addr = srv.accept()
  print("Подключен клиент", addr)
  while 1:
    pal = sock.recv(1024)
    if not pal: 
      break
    print("Получено от %s:%s:" % addr, pal)
    lap = do_something(pal)
    sock.send(lap)
    print("Отправлено %s:%s:" % addr, lap)
  sock.close()
  print("Соединение закрыто")

Как задать таймаут прослушивания?

Как мы говорили раньше, блокирующие операции в коде программы должны быть объектом пристального внимания программиста. Блокировки программы могут существенно замедлять ее выполнение (об этом мы поговорим потом, когда будем изучать многозадачное программирование), могут быть источником ошибок.

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

Можно использовать другой режим — режим таймаута — путем вызова метода settimeout(t) перед выполнением потенциально блокирующей операции. Тогда программа тоже заблокируется, но будет ждать только определенное время — t секунд. Если по прошествии этого времени нужное событие не произойдет, то метод выбросит исключение.

Таймаут можно “вешать” как на подключение, как и на чтение данных их сокета:

1
2
3
4
5
6
7
8
9
10
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(("",0))
sock.listen(1)
# accept can throw socket.timeout
sock.settimeout(5.0)
conn, addr = sock.accept()
 
# recv can throw socket.timeout
conn.settimeout(5.0)
conn.recv(1024)

В таком случае, лучше обрабатывать возможное исключение, чтобы оно не привело к аварийному завершения программы:

1
2
3
4
5
6
# recv can throw socket.timeout
conn.settimeout(5.0)
try:
	conn.recv(1024)
except socket.timeout:
	print("Клиент не отправил данные")

Как обмениваться объектами по сокетам?

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

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

В Python существует стандартный модуль pickle, который нужен для сериализации объектов. Из этого модуля нам понадобится две операции. Метод dumps(obj) преобразует объект в строковое представление, сериализует его. Метод loads(str) восстанавливает объект из строки — десериализует его.

Давайте рассмотрим пример, когда в сокет отправляется сериализованный словарь:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import socket
import pickle
 
s = socket.socket()
s.bind(("", 9090))
s.listen(1)
 
while True:
    clientsocket, address = s.accept()
 
    d = {1:"hi", 2: "there"}

    msg = pickle.dumps(d).encode()
    clientsocket.send(msg)

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

На другой стороне прочитать и восстановить объект из сокета можно, например, так:

1
2
3
msg = conn.recv(1024)

d = pickle.loads(msg.decode())

В чем особенности UDP-сокетов?

До сих пор мы рассматривали только сокеты, использующие в своей работе протокол TCP. Этот протокол еще называют протоколом с установкой соединения. Но в сокетах можно использовать и протокол UDP. И такие сокеты (их еще называют датаграммными) имеют с воей реализации несколько отличий. Давайте их рассмотрим.

Создание UDP сокета очень похоже, только надо явно указывать агрументы конструктора socket() ведь по умолчанию создаются TCP сокеты:

1
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)

Обратите внимание на второй параметр — именно он задает протокол передачи данных. Раньше мы использовали значение socket.SOCK_STREAM — это TCP-сокет. Для UDP-сокетов нужно значение socket.SOCK_DGRAM.

Связывание серверного сокета с портом происходит точно так же:

Дальше пойдут различия. UDP-сокеты не требуют установки соединения. Они способны принимать данные сразу. Но для этого используется другой системный вызов (и соответствующий ему метод Python) — recvfrom. Этот метод блокирует программу и когда присоединится любой клиент, возвращает сразу и данные и сокет клиента:

1
data, addr = s.recvfrom(1024)

Этот метод также работает в потоковом режиме. То есть нам нужно указывать количество байт, которые мы хотим считать. Но при следующем вызове метода recvfrom() могут придти данные уже от другого клиента. Надо понимать, что UDP-сокеты являются “неразборчивыми”, то есть в него может писать несколько клиентов одновременно, “вперемешку”. Для этого каждый раз и возвращается исходящий сокет, чтобы мы могли понять, от какого клиента пришли эти данные.

Что касается клиентского сокета, то здесь все еще проще:

1
2
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.sendto(msg, (host, port))

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

UDP-сокеты даже проще по своей организации, чем TCP. Меньше шагов нужно сделать, чтобы обмениваться информацией. Но UDP является протоколом без гарантированной доставки. Они ориентирован на скорость и простоту передачи данных, в то время как TCP — на надежность связи.

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

1
2
3
4
5
6
7
8
import socket
port = 5000
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.bind(("", port))
while 1:
    data, addr = s.recvfrom(1024)
    print(data)
    s.sendto(data, addr)

Код клиента приведем в более подробном виде. Механику работы сокетов мы уже рассмотрели, попробуйте проанализировать в этом коде обработку исключений и вывод отладочных сообщений:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import socket
import sys
try:
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
except socket.error:
    print('Failed to create socket')
    sys.exit()
 
host = 'localhost';
port = 8888;
 
while(1) :
    msg = raw_input('Enter message to send: ')
    
    try :
        s.sendto(msg, (host, port))
        
        reply, addr = s.recvfrom(1024)        
        print('Server reply : ' + reply)
    
    except socket.error, msg:
        print('Error Code : ' + str(msg[0]) + ' Message ' + msg[1])
        sys.exit()

This article is for programmers with the following requirements:

network5

Before you start learning socket programming, make sure you already have a certain basic knowledge of network such as understanding what is IP address, TCP, UDP.

Before we start our tutorial, keep in mind that the following tutorial only works for Linux OS environment. If you are using Windows, I have to apologize to you because Windows has its own socket programming and it is different from Linux even though the connection concept is the same. Well, first copy and paste the following code and run it on server and client, respectively.

Both codes can be run on the same computer.

It is always easy to understand after getting the code to work.

Socket-server.c

#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <sys/types.h>
 
int main(void)
{
  int listenfd = 0,connfd = 0;
  
  struct sockaddr_in serv_addr;
 
  char sendBuff[1025];  
  int numrv;  
 
  listenfd = socket(AF_INET, SOCK_STREAM, 0);
  printf("socket retrieve successn");
  
  memset(&serv_addr, '0', sizeof(serv_addr));
  memset(sendBuff, '0', sizeof(sendBuff));
      
  serv_addr.sin_family = AF_INET;    
  serv_addr.sin_addr.s_addr = htonl(INADDR_ANY); 
  serv_addr.sin_port = htons(5000);    
 
  bind(listenfd, (struct sockaddr*)&serv_addr,sizeof(serv_addr));
  
  if(listen(listenfd, 10) == -1){
      printf("Failed to listenn");
      return -1;
  }     
  
  while(1)
    {      
      connfd = accept(listenfd, (struct sockaddr*)NULL ,NULL); 
  
      strcpy(sendBuff, "Message from server");
      write(connfd, sendBuff, strlen(sendBuff));
 
      close(connfd);    
      sleep(1);
    } 
 
  return 0;
}

Socket-client.c

#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <netdb.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <arpa/inet.h>
 
int main(void)
{
  int sockfd = 0,n = 0;
  char recvBuff[1024];
  struct sockaddr_in serv_addr;
 
  memset(recvBuff, '0' ,sizeof(recvBuff));
  if((sockfd = socket(AF_INET, SOCK_STREAM, 0))< 0)
    {
      printf("n Error : Could not create socket n");
      return 1;
    }
 
  serv_addr.sin_family = AF_INET;
  serv_addr.sin_port = htons(5000);
  serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
 
  if(connect(sockfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr))<0)
    {
      printf("n Error : Connect Failed n");
      return 1;
    }
 
  while((n = read(sockfd, recvBuff, sizeof(recvBuff)-1)) > 0)
    {
      recvBuff[n] = 0;
      if(fputs(recvBuff, stdout) == EOF)
    {
      printf("n Error : Fputs error");
    }
      printf("n");
    }
 
  if( n < 0)
    {
      printf("n Read Error n");
    }
 
  return 0;
}

After debugging both source files, run Socket-server.out, then run Socket-client. Attention here, never mess up with the order of executing Socket-server.out and Socket-client. Socket-server must be executed first, then execute Socket-client.out and never try to break Socket-server forever loop. It means, you need to open two terminals to run each of the outputs.

When you execute Socket-cli, I guess you will get the following result:

network2 network1

If you see the message above, congratulations, you have success with your first step to networking programming. Otherwise, do some checking on your development environment or try to run some simple code for instance hello world.

Why Both Server and Client on the Same Computer?

The answer is the server and client both are software but not hardware. It means what is happening on the top is there are two different software executed. To be more precise, the server and client are two different processes with different jobs. If you are experienced with constructing a server, you might find out that a server can be built on a home computer by installing a server OS. It is because server is a kind of software.

Understand Sockets

Imagine a socket as a seaport that allows a ship to unload and gather shipping, whereas socket is the place where a computer gathers and puts data into the internet.

network3

Configure Socket

Things that need to be initialized are listed as follows:

  1. Using TCP or UDP
  2. Additional protocol
  3. Permit the incoming IP address
  4. Assign the port used

At the beginning, a socket function needs to be declared to get the socket descriptor.

int socket(int domain, int type, int protocol)
Domain AF_UNIX — connect inside same machine AF_INET – connect with different machine
Type SOCK_STREAM – TCP connection SOCK_DGRAM – UDP connection
Protocol Define here when there is any additional protocol. Otherwise, define it as 0

Next, decide which struct needs to be used based on what domain is used above.

AF_UNIX AF_INET

C#

struct sockaddr_un
  {
    sa_family_t sun_family ;
    char sun_path[];
  };

C#

struct sockaddr_in
  {
    short int  sin_family ;
    int        sin_port;
    struct in_addr sin_addr;
  };
Use struct sockaddr_un if you are using AF_UNIX on your domain. It is required to include <sys/un.h> Use struct sockaddr_in if you are using AF_INT on your domain.

In this article, I will explain sockadd_in that showed in the code above.

serv_addr.sin_family = AF_INET;
Define the domain used
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
Permit any incoming IP address by declaring INADDR_ANY
serv_addr.sin_port = htons(5000);
Declare port 5000 to be used.

Based on the example above, server is using port 5000. You can check it by the following command:

sudo netstat -ntlp

Then, you will see the following list:

network6

Inside red bracket, you will find 0.0.0.0:5000 and Socket-server, it means port 5000 is used and listen to any valid incoming address.

On client side, serv_addr.sin_port = htons(127.0.0.1) is declared in order to listen to the internal network.

The flow chart below shows the interaction between client and server. The flow chart might look complicated but make sure you don’t lose your patience due to the following flow chart. Because every process on the flow chart is needed and it acts as a very important role on network connection.

network4

After all setup on struct sockaddr_in is done, declare bind function. As flow chart, bind function must be declared on both server and client.

bind function

server_socket & client_socket Put socket description retrieved on the top
address Put struct sockaddr_in into it as domain is AF_INET. If your domain is AF_UNIX, try and put struct sockaddr_un here.
address_len Put the length of the address

Server and client will start interacting with each other after the bind function and it is the most important session. From what flow chart shows, listen, accept, connect, three functions play very important roles.

Imagine that server looks like an ATM, and only one person can be used the ATM. So, what happens if there are 2 or more people that come at one time? The answer is simple, lining up and wait for the front people to finish using with ATM. It is exactly the same as what is happening in the server.

Listen function acts as a waiting room, asking the traffic wait on the waiting room. Accept function acts as the person who is asking the traffic waiting inside the waiting room to be ready for the meeting between server. Last, connect function acts as the person who wants to carry out some work with the server.

listen function

server_socket Put socket description retrieved on the top
backlog Define the maximum of awaiting request

accept function

server_socket Put socket description retrieved on the top
client_address Put null here if there is no special request to specify address.
address_len Put null here if second parameter is null
return Return information of client socket description. Use it for interaction between client and server.

connect function

client_socket Put socket description retrieved on the top
address Put the struct sockaddr defined on the top
address_len Put the length of the address

Finally, after the request is accepted, what should server and client do is send and read data. It is the most simple part in this entire article. read function is used to read the buffer data and write function is used to send the data. That’s all.

read function

socket_description Put server or client socket description depending on reading data from server or client
read buffer Content of the data retrieved
read buffer length Length of the output string

write function

socket_description Put server or client socket description depending on sending data to server or client
write buffer Data to be sent
write buffer length Length of the output string

Personal Comment

This article was published on 2013/5/1 and I was still new to networking programming at that time. Maybe there is some point that I am not making clear enough. I have tried my best to present all my knowledge to this article. Hope you can get the good basic beginning over here. Thank you!

Hi! Thank you everyone who reading my article. My major is electronic and programming. Right now I am doing foreign study at Japan. I will like sharing to everyone with my works and if you do interesting with my works, please leave you comment on my blog. Any comments is welcoming.

Понравилась статья? Поделить с друзьями:

А вот и еще наши интересные статьи:

  • Мануалы по ремонту акпп скачать бесплатно
  • Руководство по ремонту актион спорт бесплатно
  • Руководство по прибыльности
  • Препарат титул дуо инструкция по применению
  • Огневые работы инструкция по охране труда газпром

  • 0 0 голоса
    Рейтинг статьи
    Подписаться
    Уведомить о
    guest

    0 комментариев
    Старые
    Новые Популярные
    Межтекстовые Отзывы
    Посмотреть все комментарии