Сокеты в C++ под Unix: простой сервер
В этом гайде мы займемся созданием простого сервера и клиента с нуля, на сокетах на языке программирования C++.
Компьютерная сеть — это множество вычислительных устройств, взаимодействующих между собой и совместно использующих ресурсы. Понятие сеть близко по смыслу к понятию графа. Cеть также состоит из множества узлов (nodes) и множества звеньев (links). Отличие сети от графа в том, что узлы являются чем-то осмысленным, в данном случае — это вычислительные устройства, а звенья представляют связи этих устройств. В русскоязычной литературе компьютерную сеть иногда называют вычислительной сетью.
В зависимости от охвата территории компьютерные сети бывают:
Персональные — Personal Area Network (PAN).
Локальные — Local Area Network (LAN).
Городские — Metropolitan Area Network (MAN).
Глобальные — Wide Area Network (WAN).
Различные датчики, подключённые к смартфону, образуют сеть PAN. Компьютерная сеть из устройств, подключённых к вашему домашнему роутеру, является LAN-сетью, сеть из абонентов провайдера в городе — это MAN-сеть, а весь интернет, который вам предоставляет провайдер — WAN-сеть.
Что такое протоколы TCP/IP?
Большинство действующих стандартов интернета и протоколов TCP/IP регламентируются документами Request For Comments (RFC). Учебники по компьютерным сетям ставят целью объяснить модель TCP/IP, но за точной трактовкой понятий лучше обращаться к RFC.
Детально сетевая модель TCP/IP рассмотрена в RFC 1122 (Requirements for Internet Hosts — Communication Layers) и RFC-1123 (Requirements for Internet Hosts — Application and Support). Модель объясняется и расширяется другими RFC, но для понимания основ, достаточно этих двух.
Выделим базовые понятия из модели TCP/IP:
Чтобы не утомлять вас скучными определениями, я не буду приводить их, а просто расскажу принципы работы сети TCP/IP, используя вышеприведённые термины.
IP-сеть представляет собой множество связанных между собой хостов. Хосты связаны непосредственно или косвенно при помощи ретранслирующих устройств (маршрутизаторов и коммутаторов).
Для приёма сообщений из сети и отправку их в сеть хост использует интерфейсы. Физический интерфейс отправляет и принимает фреймы, а логический интерфейс отправляет и принимает IP-пакеты. Физический интерфейс идентифицируется MAC-адресом, логический интерфейс — IP-адресом.
Передаваемое сообщение представляет собой UDP-датаграмму или TCP-сегмент. Сообщение содержит заголовок и полезные данные. Чтобы передать сообщение внутри IP-сети оно помещается в IP-датаграмму. Конкретный физический интерфейс позволяет передавать данные порциями, которые имеют определённый максимально допустимый размер (MTU). Если размер IP-датаграммы превышает MTU, выполняется её фрагментация и создаётся несколько IP-пакетов, иначе создаётся только один IP-пакет для всей IP-датаграммы.
IP-пакет в соответствии с таблицей маршрутизации хоста передаётся на выбранный логический интерфейс.
Логический интерфейс сам непосредственно не может передать IP-пакет, он использует физический интерфейс. Физический интерфейс передаёт данные фреймами. Фрейм имеет заголовок и полезные данные (payload). В заголовке фрейма указывается MAC-адрес получателя, MAC-адрес отправителя и какому протоколу принадлежат данные в payload (Ethertype). Адрес отправителя известен, это МАС-адрес интерфейса отправляющего хоста. Для протокола IPv4 Ethertype=0×0800.
Адрес физического интерфейса определяется путём посылки ARP-сообщения в широковещательный домен. ARP-сообщение инкапсулируется во фрейм, у которого EtherType = 0×0806 (ARP). В сообщении указывается MAC-адрес отправителя, широковещательный MAC-адрес получателя и интересующий IP-адрес. Хост с физическим интерфейсом, которому назначен этот IP-адрес в ответном сообщении, указывает MAC-адрес этого физического интерфейса. Чтобы не отсылать ARP-сообщение каждый раз, соответствие IP-адреса MAC-адресу сохраняется в кеше хоста.
После передачи фрейма на другой сетевой интерфейс из него извлекается содержимое IP-пакета, и, если IP-адрес логического интерфейса хоста соответствует IP-адресу получателя, он собирается в IP-датаграмму. Из IP-датаграммы извлекается TCP-сегмент или UDP-датаграмма. Из них извлекаются сами данные и передаются процессу операционной системы, который уже понимает, что с ними делать дальше.
Иначе IP-пакет или отвергается или пересылается далее в соответствии с таблицей маршрутизации хоста. При отсылке он опять передаётся на логический интерфейс. Там упаковывается во фрейм и отсылается.
Это упрощённое описание, так как я не углублялся в виртуальные сетевые интерфейсы, виртуальные частные сети, PPP-соединения, как работают сетевые транспортные протоколы TCP и UDP.
Что такое сокеты?
Программирование сокетов — это способ соединения двух узлов в сети для связи друг с другом. Один сокет (узел) прослушивает определенный порт по IP-адресу, а другой сокет обращается к другому для формирования соединения. Сервер формирует сокет слушателя, в то время как клиент обращается к серверу.
Сокет же, представляет собой программную абстракцию, механизм для организации взаимодействия программ между собой. Эти программы могут располагаться на одном компьютере, могут выполняться на разных машинах в локальной сети, а могут быть вообще разнесены по разным континентам и взаимодействовать через всемирную сеть.
Программа использует сокеты, записывая в них и считывая из них информацию.
Процесс использования сокетов в значительной мере стандартизирован и основан на нескольких библиотечных функциях.
Диаграмма состояний для модели сервера и клиента
Создание сокета на С++ на nix системах
Сокеты на C++ создаются функцией socket
:
#include
#include
int socket(int domain, int type, int protocol);
В качестве параметров функции выступают три значения:
domain
— определяет адреса и протоколы, используемые при взаимодействии сокетов. Чаще всего используются значения AF_UNIX, когда необходимо связать процессы на локальной машине и можно задействовать юниксовую систему ввода/вывода и AF_INET, — когда необходимо обеспечить связь через Интернет.type
— определяет способ передачи данных по сети. Чаще других применяются:SOCK_STREAM. Передача потока данных с предварительной установкой соединения. Обеспечивается надёжный канал передачи данных, при котором фрагменты отправленного блока не теряются, не переупорядочиваются и не дублируются. Этот тип сокетов является самым распространённым.
SOCK_DGRAM. Передача данных в виде отдельных сообщений (датаграмм). Предварительная установка соединения не требуется. Обмен данными происходит быстрее, но является ненадёжным: сообщения могут теряться в пути, дублироваться и переупорядочиваться. Допускается передача сообщения нескольким получателям (multicasting) и широковещательная передача (broadcasting).
SOCK_RAW. Этот тип присваивается низкоуровневым (т. н. «сырым») сокетам. Их отличие от обычных сокетов состоит в том, что с их помощью программа может взять на себя формирование некоторых заголовков, добавляемых к сообщению.
protocol
определяет протокол, используемый для передачи данных. Как мы только что видели, часто протокол однозначно определяется по домену и типу сокета. В этом случае в качестве третьего параметра функции socket можно передать 0, что соответствует протоколу по умолчанию. Тем не менее, иногда (например, при работе с низкоуровневыми сокетами) требуется задать протокол явно. Числовые идентификаторы протоколов зависят от выбранного домена; их можно найти в документации.
Результатом работы функции является целое число, которое носит название дескриптор сокета. Это значение должно быть положительным. Если функция возвращает -1, то создать сокет не удалось.
После создания сокета функцией socket
, необходимо произвести его связывание с адресом в выбранном домене (именование сокета). Для этой цели используют функцию bind
.
#include
#include
int bind(int sockfd, struct sockaddr *addr, int addrlen);
Вид адреса зависит от выбранного вами домена. В Unix-домене это текстовая строка — имя файла, через который происходит обмен данными. В Internet-домене адрес задаётся комбинацией IP-адреса и 16-битного номера порта. IP-адрес определяет хост в сети, а порт — конкретный сокет на этом хосте. Протоколы TCP и UDP используют различные пространства портов.
Первый параметр функции — дескриптор сокета.Второй — адрес структуры, задающей параметры для связи сокета:
struct sockaddr_in {
short int sin_family;
unsigned short int sin_port;
struct in_addr sin_addr;
unsigned char sin_zero[8];
};
Описание полей структуры:
sin_family — Семейство адресов.
sin_port — Номер порта
sin_addr — IP-адрес
sin_zero — «Дополнение» до размера структуры
sockaddr
Третий параметр — размер структуры с параметрами.
Существует два порядка хранения байтов в слове и двойном слове. Один из них называется порядком хоста (host byte order), другой — сетевым порядком (network byte order) хранения байтов. При указании IP-адреса и номера порта необходимо преобразовать число из порядка хоста в сетевой. Для этого используются функции htons
(Host TO Network Short) и htonl
(Host TO Network Long). Обратное преобразование выполняют функции \textsf{ntohs} и ntohl
.
Установка соединения на стороне сервера
Установка соединения на стороне сервера состоит из четырёх этапов, ни один из которых не может быть опущен. Сначала сокет создаётся и привязывается к локальному адресу. Если компьютер имеет несколько сетевых интерфейсов с различными IP-адресами, вы можете принимать соединения только с одного из них, передав его адрес функции bind
. Если же вы готовы соединяться с клиентами через любой интерфейс, задайте в качестве адреса константу INADDR_ANY. Что касается номера порта, вы можете задать конкретный номер или 0 (в этом случае система сама выберет произвольный неиспользуемый в данный момент номер порта).
На следующем шаге создаётся очередь запросов на соединение. При этом сокет переводится в режим ожидания запросов со стороны клиентов. Всё это выполняет функция listen
.
int listen(int sockfd, int backlog);
Первый параметр — дескриптор сокета, а второй задаёт размер очереди запросов. Каждый раз, когда очередной клиент пытается соединиться с сервером, его запрос ставится в очередь, так как сервер может быть занят обработкой других запросов. Если очередь заполнена, все последующие запросы будут игнорироваться. Когда сервер готов обслужить очередной запрос, он использует функцию accept
.
#include
int accept(int sockfd, void *addr, int *addrlen);
Функция accept
создаёт для общения с клиентом новый сокет и возвращает его дескриптор. Параметр sockfd
задаёт слушающий сокет. После вызова он остаётся в слушающем состоянии и может принимать другие соединения. В структуру, на которую ссылается addr
, записывается адрес сокета клиента, который установил соединение с сервером. В переменную, адресуемую указателем addrlen
, изначально записывается размер структуры; функция accept
записывает туда длину, которая реально была использована. Если вас не интересует адрес клиента, вы можете просто передать NULL в качестве второго и третьего параметров.
Полученный от accept
новый сокет связан с тем же самым адресом, что и слушающий сокет. Сначала это может показаться странным. Но дело в том, что адрес TCP-сокета не обязан быть уникальным в Internet-домене. Уникальными должны быть только соединения, для идентификации которых используются два адреса сокетов, между которыми происходит обмен данными.
Установка соединения (клиент)
На стороне клиента для установления соединения используется функция connect
, которая имеет следующий прототип.
#include
#include
int connect(int sockfd, struct sockaddr *serv_addr, int addrlen);
Здесь sockfd
— сокет, который будет использоваться для обмена данными с сервером, serv_addr
содержит указатель на структуру с адресом сервера, а addrlen
— длину этой структуры. Обычно сокет не требуется предварительно привязывать к локальному адресу, так как функция connect
сделает это за вас, подобрав подходящий свободный порт. Вы можете принудительно назначить клиентскому сокету некоторый номер порта, используя bind
перед вызовом connect
. Делать это следует в случае, когда сервер соединяется с только с клиентами, использующими определённый порт (примерами таких серверов являются rlogind
и rshd
). В остальных случаях проще и надёжнее предоставить системе выбрать порт за вас.
Пример: подготовка к передаче данных
Теперь мы можем собрать воедино все подготовительные действия по установлению соединения на стороне сервера и на стороне клиента. Сначала рассмотрим сервер:
int sock, listener;
struct sockaddr_in addr;
int bytes_read;
listener = socket(AF_INET, SOCK_STREAM, 0);
if(listener < 0)
{
perror("socket");
exit(1);
}
addr.sin_family = AF_INET;
addr.sin_port = htons(3425);
addr.sin_addr.s_addr = htonl(INADDR_ANY);
if(bind(listener, (struct sockaddr *)&addr, sizeof(addr)) < 0)
{
perror("bind");
exit(2);
}
listen(listener, 1);
После вызова listen
, сервер готов к приему и обработке запросов от клиентов.
Код на стороне клиента:
int sock;
struct sockaddr_in addr;
sock = socket(AF_INET, SOCK_STREAM, 0);
if(sock < 0)
{
perror("socket");
exit(1);
}
addr.sin_family = AF_INET;
addr.sin_port = htons(3425);
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
if(connect(sock, (struct sockaddr *)&addr, sizeof(addr)) < 0)
{
perror("connect");
exit(2);
}
Обмен данными
После того как соединение установлено, можно начинать обмен данными. Для этого используются функции send
и recv
.
Функция send
используется для отправки данных и имеет следующий прототип.
int send(int sockfd, const void *msg, int len, int flags);
Здесь sockfd
— это, как всегда, дескриптор сокета, через который мы отправляем данные, msg
— указатель на буфер с данными, len
— длина буфера в байтах, а flags
— набор битовых флагов, управляющих работой функции (если флаги не используются, передайте функции 0).
MSG_OOB
. Предписывает отправить данные как срочные (out of band data, OOB). Концепция срочных данных позволяет иметь два параллельных канала данных в одном соединении. Иногда это бывает удобно. Например, Telnet использует срочные данные для передачи команд типа Ctrl+C. В настоящее время использовать их не рекомендуется из-за проблем с совместимостью (существует два разных стандарта их использования, описанные в RFC793 и RFC1122). Безопаснее просто создать для срочных данных отдельное соединение.
MSG_DONTROUTE
. Запрещает маршрутизацию пакетов. Нижележащие транспортные слои могут проигнорировать этот флаг. Функция send возвращает число байтов, которое на самом деле было отправлено (или -1 в случае ошибки). Это число может быть меньше указанного размера буфера. Если вы хотите отправить весь буфер целиком, вам придётся написать свою функцию и вызывать в ней send, пока все данные не будут отправлены.
Для чтения данных из сокета используется функция recv
.
int recv(int sockfd, void *buf, int len, int flags);
В целом её использование аналогично send
. Она точно так же принимает дескриптор сокета, указатель на буфер и набор флагов. Флаг MSG_OOB
используется для приёма срочных данных, а MSG_PEEK
позволяет «подсмотреть» данные, полученные от удалённого хоста, не удаляя их из системного буфера (это означает, что при следующем обращении к recv
вы получите те же самые данные). Полный список флагов можно найти в документации. По аналогии с send
функция recv
возвращает количество прочитанных байтов, которое может быть меньше размера буфера. Вы без труда сможете написать собственную функцию recvall
, заполняющую буфер целиком. Существует ещё один особый случай, при котором recv возвращает 0. Это означает, что соединение было разорвано.
Значение, возвращаемое send
может отличаться от размера буфера. В этом случае необходимо написать функцию, отправляющую все данные из буфера:
int sendall(int s, char *buf, int len, int flags)
{
int total = 0;
int n;
while(total < len)
{
n = send(s, buf+total, len-total, flags);
if(n == -1) { break; }
total += n;
}
return (n==-1 ? -1 : total);
}
Закрытие сокета
Закончив обмен данными, закройте сокет с помощью функции close
. Это приведёт к разрыву соединения.
#include
int close(int fd);
Вы также можете запретить передачу данных в каком-то одном направлении, используя shutdown
.
int shutdown(int sockfd, int how);
Параметр `how может принимать одно из следующих значений:
0 — запретить чтение из сокета
1 — запретить запись в сокет
2 — запретить и то и другое
Хотя после вызова shutdown
с параметром how
, равным 2, вы больше не сможете использовать сокет для обмена данными, вам всё равно потребуется вызвать close
, чтобы освободить связанные с ним системные ресурсы.
Пример простого приложения без выхода в сеть
В следующем примере мы создаем простой сервер, который принимает от клиентов строку и передает ее им обратно. Получив строку от сервера, клиент выводит ее на терминал. В качестве адреса сервера в клиенте используется константа INADDR_LOOPBACK
.
Код сервера:
/* Простое сервер-клиентское приложение на C++
Простой сервер */
#include
#include
#include
#include
#include
#include
int main()
{
int sock, listener;
struct sockaddr_in addr;
char buf[1024];
int bytes_read;
listener = socket(AF_INET, SOCK_STREAM, 0);
if(listener < 0)
{
perror("socket");
exit(1);
}
addr.sin_family = AF_INET;
addr.sin_port = htons(3425);
addr.sin_addr.s_addr = htonl(INADDR_ANY);
if(bind(listener, (struct sockaddr *)&addr, sizeof(addr)) < 0)
{
perror("bind");
exit(2);
}
listen(listener, 1);
while(1)
{
sock = accept(listener, NULL, NULL);
if(sock < 0)
{
perror("accept");
exit(3);
}
while(1)
{
bytes_read = recv(sock, buf, 1024, 0);
if(bytes_read <= 0) break;
send(sock, buf, bytes_read, 0);
}
close(sock);
}
return 0;
}
Код клиента:
/* Простое сервер-клиентское приложение на C++
Простой клиент */
#include
#include
#include
#include
#include
#include
char message[] = "Hello there!\n";
char buf[sizeof(message)];
int main()
{
int sock;
struct sockaddr_in addr;
sock = socket(AF_INET, SOCK_STREAM, 0);
if(sock < 0)
{
perror("socket");
exit(1);
}
addr.sin_family = AF_INET;
addr.sin_port = htons(3425);
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
if(connect(sock, (struct sockaddr *)&addr, sizeof(addr)) < 0)
{
perror("connect");
exit(2);
}
send(sock, message, sizeof(message), 0);
recv(sock, buf, sizeof(message), 0);
printf(buf);
close(sock);
return 0;
}
Пример простого приложения с выходом в сеть
В качестве примера с выходом в сеть мы возьмем за основу предыдущую программу и внесем в нее изменения. Во-первых, мы должны добавить новый заголовочный файл
#include
для поддержки функции inet_addr
. Эта функция нам будет необходима для преобразования строки, содержащей IP-адрес сервера в число, помещаемое в поле sin_addr
структуры sockadr_in
.
Во-вторых, определим адрес сервера и порт через define:
#define SERVER_ADRESS "192.168.254.34"
#define SERVER_PORT 3425
В-третьих, добавим возможность для сервера вести журнал регистрации сообщений.
Код сервера:
/* Простое сервер-клиентское приложение на C++
Простой сервер с поддержкой интернета */
#include
#include
#include
#include
#include
#include
#include
#define SERVER_ADRESS "127.0.0.1"
#define SERVER_PORT 4000
int main()
{
int sock, listener;
struct sockaddr_in addr;
char buf[1024];
int bytes_read;
FILE *fp;
listener = socket(AF_INET, SOCK_STREAM, 0);
if(listener < 0)
{
perror("socket");
exit(1);
}
addr.sin_family = AF_INET;
addr.sin_port = htons(SERVER_PORT);
addr.sin_addr.s_addr = inet_addr(SERVER_ADRESS);
if(bind(listener, (struct sockaddr *)&addr, sizeof(addr)) < 0)
{
perror("bind");
exit(2);
}
listen(listener, 1);
while(1)
{
sock = accept(listener, NULL, NULL);
if(sock < 0)
{
perror("accept");
exit(3);
}
while(1)
{
bytes_read = recv(sock, buf, 1024, 0);
if(bytes_read <= 0) break;
fp=fopen("server.log","a");
fprintf(fp,"%s",buf);
fclose(fp);
send(sock, buf, bytes_read, 0);
}
close(sock);
}
return 0;
}
Код клиента:
/* Простое сервер-клиентское приложение на C++
Простой клиент с поддержкой интернета */
#include
#include
#include
#include
#include
#include
#include
#define SERVER_ADRESS "127.0.0.1"
#define SERVER_PORT 4000
char message[] = "Hello there!\n";
char buf[sizeof(message)];
int main()
{
int sock;
struct sockaddr_in addr;
sock = socket(AF_INET, SOCK_STREAM, 0);
if(sock < 0)
{
perror("socket");
exit(1);
}
addr.sin_family = AF_INET;
addr.sin_port = htons(SERVER_PORT);
addr.sin_addr.s_addr = inet_addr(SERVER_ADRESS);
if(connect(sock, (struct sockaddr *)&addr, sizeof(addr)) < 0)
{
perror("connect");
exit(2);
}
send(sock, message, sizeof(message), 0);
recv(sock, buf, sizeof(message), 0);
printf(buf);
close(sock);
return 0;
}
Компиляция и запуск
Компилируем с помощью gcc или clang:
gcc <файл сервера>.cpp -o server
gcc <файл клиента>.cpp -o client
И если мы все правильно сделали, то запускаем сервер, а после в отдельной сессии запускаем клиент:
./client
>>> Hello there!
Заключение
Мы написали простое серверное-клиентское приложение. Зная эту базу, вы сможете даже создать свой клиентский чат.
С вами был Аргентум, все удачи, всем пока!