Сокеты в C++ под Unix: простой сервер

В этом гайде мы займемся созданием простого сервера и клиента с нуля, на сокетах на языке программирования C++.

Компьютерная сеть — это множество вычислительных устройств, взаимодействующих между собой и совместно использующих ресурсы. Понятие сеть близко по смыслу к понятию графа. Cеть также состоит из множества узлов (nodes) и множества звеньев (links). Отличие сети от графа в том, что узлы являются чем-то осмысленным, в данном случае — это вычислительные устройства, а звенья представляют связи этих устройств. В русскоязычной литературе компьютерную сеть иногда называют вычислительной сетью.

В зависимости от охвата территории компьютерные сети бывают:

  1. Персональные — Personal Area Network (PAN).

  2. Локальные — Local Area Network (LAN).

  3. Городские — Metropolitan Area Network (MAN).

  4. Глобальные — 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!

Заключение

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

С вами был Аргентум, все удачи, всем пока!

© Habrahabr.ru