Низкоуровневое сетевое программирование. Пишем клиент/серверное приложение на сокетах Беркли
Вступление.
Итак. Свою новую статью я бы хотял посвятить низкоуровневому сетевому программированию. Постараюсь наиболее полно и одновременно сжато изложить основные принципы сетевого программирования, а так же будет рассмотрен пример построение рабочего многопоточного сервера и клиента. Все примеры разрабатывались и комплировались на Unix-подобной операционной системе и все ниже сказанное будет справедливо для любой Unix. Но, т.к. описываемое является стандартом, — данным материалом смогут воспользоваться и программисты, работающие в среде Windows (я на рассматривал конкретно сетевое программирование в этой ОС, т.к. не использую её), изменения коснуться, разве что, заголовочных файлов.
Как было сказано выше — будет рассмотрено низкоуровневое сетевое программирование. Справедливости ради, следует сказать, что оно, на самом деле, не такое уж низкоуровневое, т.к. существуют гораздо более низкие уровни, но все это, как правило, прерогатива ядра ОС/драйверов/железа. Для облегчения работы с сетью, операционной системой предоставляются особые объекты — сокеты (в некоторых книгах их называют «гнезда»), представляющие собой разновидность программных интерфейсов. Они позволяют представить сетевой интерфейс как простое устройство ввода/вывода и работать с ним, почти как с обычным файлом (что истинно, ибо в Unix все устройства представлены как файлы). Для работы с сокетами используются API, разработанные в Калифорнийском университете в городе Беркли (для BSD Unix) в 1983 году. Эти API являются сегодня стандартном де-факто и поддерживаются практически всеми современными операционными системами. Данный программный интерфейс, так же называют сокетами Беркли. В основе сокетов лежат протоколы TCP/IP и UDP. Рассмотрение особенностей каждого из них выходит за пределы данной статьи. Скажу только самое главное: TCP — это протокол, обеспечивающий надежное соединение и гарантированную доставку пакетов. UDP — протокол без установления соединения и без каких либо гарантий доставки пакета. IP — протокол сетевого уровня, служит транспортом для протоколов TCP и UDP.
От теории к действию.
Для работы с функциями сокетов необходимо подключить ряд заголовочных файлов, рассмотрим их:
<sys/socket.h>
Самый главный файл, в нем находятся базовые функции сокетов и структуры данных.
<netdb.h>
Функции для преобразования протокольных имен и имен хостов в числовые адреса.
<arpa/inet.h>
Функции для работы с числовыми IP-адресами.
<netinet/in.h>
Семейства адресов/протоколов PF_INET (для IPv4) и (PF_INET6 для IPv6). Включают в себя IP-адреса, а также номера портов TCP и UDP.
<netdb.h>
Функции для преобразования протокольных имен и имен хостов в числовые адреса.
Как было написано выше — сокеты схожи с файлами, их (сокеты) аналогично можно представить в виде числового дескриптора, а затем использовать этот дескриптор в стандартных функциях read и write. Для получения нового дескриптора сокета используется функция:
int socket(int domain, int type, int protocol);
Рассмотрим параметры:
int domain — этот параметр задает правила использования именования и формат адреса. Следует указывать PF_INET, если планируется работать с IPv4, либо PF_INET6 для IPv6.
int type — этот параметр задает тип сокета. Следует указывать SOCK_STREAM, если планируется использование протокола TCP, либо SOCK_DGRAM — в случае использования UDP.
int protocol — этот параметр указывает конкретный протокол, который следует использовать с данным сокетом. В качестве параметра следует использовать экземпляр структуры struct protoent. Ниже будет рассмотрено, как с помощью этой структуры и строк «tcp» или «udp» задать необходим протокол. Так же параметр может быть просто равен 0, тогда ядро само выберет соответствующий протокол.
Теперь посмотрим как это выглядит все вместе, написав небольшую функцию sock, которая, в дальнейшем, упростит нашу жизнь
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>
int sock(const char *transport)
{
struct protoent *ppe; //указатель на запись с информацией о протоколе
int s, type; //дескриптор и тип сокета
//преобразовываем имя транспортного протокола в корректный номер протокола
ppe = getprotobyname(transport);
//определяем тип сокета, согласно имени транспортного протокола
if(strcmp(transport, "udp") == 0)
type = SOCK_DGRAM; //если udp - указываем SOCK_DGRAM
else
type = SOCK_STREAM; //в ином (tcp) случае - указываем SOCK_STREAM
//создаем новый сокет, передав необходимые параметры
s = socket(PF_INET, type, ppe->p_proto);
//возвращаем результат - дескриптор сокета.
return s;
}
Данная функция может быть полезна как для разработки клиента, так и сервера, данный подход позволяет существенно сократить количество исходного кода и избежать дублирования кода. Здесь были использованы новая структура и функция: protoent — является удобным способом передачи параметров для функции сокета и работы с интернет-протоколами.
Функция sock возвращает дескриптор созданного сокета, либо же отрицательное значение в случае неудачи.
Сервер.
Теперь рассмотрим построение полноценного рабочего сервера, на основе этой функции, отвечающего на запросы клиентов. Почему сервера, а не клиента ? Всегда следует начинать с разработки сервера, это удобнее, т.к. последний всегда можно проверить с помощью готового клиента, имеющегося в ОС — telnet, а клиента уже создавать на готовый сервер. Итак. Выше мы получили дескриптор сокета, что мы с ним должны сделать, что бы получился сервер ? Необходимо связать созданный сокет с определенным сетевым интерфейсом, на котором сервер будет «слушать» входящие подключения. Связывание выполняется с помощью функции bind, рассмотрим ее.
int bind(int sid, struct sockaddr* addr_p, int len);
Аргументы:
int sid — собственно сам дескриптор сокета.
struct sockaddr* addr_p — указатель на структуру адреса, с которым связывается сокет.
int len — длина структуры sockaddr
При успешном выполнении функция возвращает 0, при неудаче возвращает -1.
Перед тем, как рассматривать пример использования функции bind, познакомимся с еще одной важной функций — listen, а затем напишем полноценный код. Функция listen предназначена для «прослушивания» сетевого интерфейса, с которым связан серверный сокет. Т.е. она переводит сокет в режим ожидания входящих подключений.
Рассмотрим детально функцию listen.
int listen(int sid, int size);
Аргументы функции:
int sid — дескриптор сокета.
int size — максимальное число клиентов в очереди. Т.к. сокет не может обработать одновременно сразу все подключения — все запросы выстраиваются в очередь и ожидают своей обработки.
При успешном выполнении возвращается 0, при неуспешном возвращается -1.
Теперь рассмотрим применение функций bind и listen на небольшом примере.
#include <sys/types>
#include <sys/socket>
int listener(int sock, const char *host, const char *port)
{
struct hostent *phe; //указатель на запись с информацией о хосте
struct sockaddr_in sin; //структура IP-адреса
//обнуляем структуру адреса
memset(&sin, 0, sizeof(sin));
//указываем тип адреса - IPv4, для IPv6 необходимо указать AF_INET6
sin.sin_family = AF_INET;
//задаем порт
sin.sin_port = htons((unsigned short)atoi(port));
//задаем адрес
//преобразовываем строку адреса
phe = gethostbyname(host);
//копируем значение в поле структуры адреса
memcpy(&sin.sin_addr, phe->h_addr, phe->h_length);
//связываем сокет с указанным адресом, проверяя результат вызова функции
if(bind(sock, (struct sockaddr *)&sin, sizeof(sin)) < 0)
return -1; в случае неудачи возвращаем -1
//включаем режим прослушивания для 5 клиентов, возвращая результат
return listen(sock, 5);
}
Итак, данная функция выполняет связывания сокета sock, с адресом host и портом port. Для преобразования host и port из строковых значений (например «192.168.1.0» и «21») в корректные бинарные значения используются функции gethostbyname и htons. Значения адреса и порта инициализируют поля структуры sockaddr_in, которая является параметром функции bind. Функция listener возвращает результат вызова функции listen.
На данно этапе мы научились создавать сокет, связывать его с сетевым интерфейсом и переключать сокет в режим прослушивания. Теперь осталось научится обрабатывать входящие подключения. В этом на поможет функция accept. Принцип работы очень прост: когда выполнение кода доходит до этой функции — выполнение останавливается. При входящем подключении выполнение кода продолжается и начинается процесс обмена данным с клиентом. Тут есть один важный момент — после успешного входящего подключения, функция accept возвращает новый дескриптор сокета. Над этим дескриптором и производятся операции чтения/записи, после чего этот дескритор закрывается с помощью функции close (по завершении работы сервера следует закрывать и сокет, созданный в функции sock, как и всякий файловый дескриптор). Для удобства цепочку «accept -> read/write -> close» заключают в бесконечный цикл. Для записи и чтения в сокет используются обычные функции write и read.
Рассмотрим функцию accept.
int accept(int sid, struct sockaddr* addr_p, int len_p);
Аргументы:
int sid — дескриптор сокета.
struct sockaddr — структура адреса, она инициализируется адресом подключившегося клиента
int len_p — размер структуры адреса
В случае удачи функция возвращает дескриптор нового сокета, в противном случае вовзвращает -1.
Подытожим, мы рассмотрели все функции, необходимые для построения полноценного сервера. Для большего понимания серверной архитектуры я нарисовал эту небольшую диаграмму.
Теперь самое время объеденить все вышеизученное и написать полный исходный код сервера, и протестировать его! Наш сервер будет принимать входящие подключения на всех доступных сетевых интерфейсах и при получении строки «hello» — будет отвечать строкой вида «hello, %computeradress% !!!», где %computeradress% — адрес удаленной машины.
Следует отметить, что в Unix, сервер, запущенный из под обычного пользователя, имеет право прослушивать на портах не менее 1024, для прослушивания на портах 0-1024 необходимы root права.
Код сервера.
/************************************************/ /* server.c - простой TCP/IP сервер */ /************************************************/ //подключаем необходимые заголовчные файлы #include <sys/types.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include <string.h> #include <stdlib.h> #include <errno.h> #include <stdio.h> #include <netdb.h> extern errno; //глобальная переменная, которая хранит код последней ошибки //проверка, задан ли шаблон INADDR_NONE, который обозначает сразу все доступные сетевые интерфейсы //на некоторых платформах, он может быть не задан. #ifndef INADDR_NONE #define INADDR_NONE 0xfffffffff #endif //функция создания и связывания сокета. объявление //аргументы: //port - порт, с которым связывается сервер //transport - протокол, по которому будет работать сервер (tcp или udp) //qlen - длина очереди int sock(const char *port, const char *transport, int qlen); //главная функция int main() { int msock, csock; //дескрипторы сокетов struct sockaddr_in remaddr; //структура IP-адреса клиента unsigned int remaddrs = sizeof(remaddr); //размер структуры адреса char msg[21]; //буфер сообщения msock = sock("1231", "tcp", 5); //создаем tcp сокет и привязываем его к порту 3123, задав очередь 5 if(msock < 0) //проверяем значение дескриптора сокета return -1; //завершаем программу while(1) //бесконечный цикл { csock = accept(msock, (struct sockaddr*) &remaddr, &remaddrs); //принимаем входящее подключение, адрес клиента в remaddr if(csock < 0) //проверяем результат printf("Ошибка принятия подключения: %s\n", strerror(errno)); //сообщение об ошибке else //если все нормально - начинаем обмен данными с клиентом { if(read(csock, &msg, sizeof(msg)) >0 ) //пробуем читать данные от клиента { if(strstr(msg, "hello")) //если получено "hello" { memset(&msg, 0, sizeof(msg)); //обнуляем буфер strcpy(msg, "hello, "); //формируем строку ответа strcat(msg, inet_ntoa(remaddr.sin_addr)); //преобразовываем адрес клиента в строку strcat(msg, " !!!\n"); //завершаем строку ответа write(csock, msg, sizeof(msg)); //отсылаем ответ } } close(csock); //закрываем сокет клиента } } close(msock); //закрываем сокет сервера return 0; }
//функция создания и связывания сокета. реализация int sock(const char *port, const char *transport, int qlen) { struct protoent *ppe; struct sockaddr_in sin; int s, type; //обнуляем структуру адреса memset(&sin, 0, sizeof(sin)); //указываем тип адреса - IPv4, для IPv6 необходимо указать AF_INET6 sin.sin_family = AF_INET; //указываем, в качестве адреса, шаблон INADDR_ANY - все сетевые интерфейсы sin.sin_addr.s_addr = INADDR_ANY; //задаем порт sin.sin_port = htons((unsigned short)atoi(port)); //преобразовываем имя транспортного протокола в номер протокола if((ppe = getprotobyname(transport)) == 0) { printf("Ошибка преобразования имени транспортного протокола: %s\n", strerror(errno)); //в случае неудачи выводим сообщение ошибки return -1; } //используем имя протокола для определения типа сокета if(strcmp(transport, "udp") == 0) type = SOCK_DGRAM; else type = SOCK_STREAM; //создаем сокет s = socket(PF_INET, type, ppe->p_proto); if(s < 0) { printf("Ошибка создания сокета: %s\n", strerror(errno)); //в случае неудачи выводим сообщение ошибки return -1; } //привязка сокета с проверкой результата if(bind(s, (struct sockaddr *)&sin, sizeof(sin)) < 0) { printf("Ошибка связывания сокета: %s\n", strerror(errno)); //в случае неудачи выводим сообщение ошибки return -1; } //запуск прослушивания с проверкой результата if(type == SOCK_STREAM && listen(s, qlen) <0) { printf("Ошибка прослушивания сокета: %s\n", strerror(errno)); //в случае неудачи выводим сообщение ошибки return -1; } return s; //возвращаем дескриптор сокета }
Думаю, что еще какие либо пояснения для кода излишни. Комментарии подробно описывают все происходящее и для человека, внимательно прочитавшего всю информацию выше, нет здесь ничего непонятного. В данном коде отсутсвует функция listener — она объеденена с функцией sock, все остальное возложено на функцию main.
После компиляции (gcc server.c -o server) и запуска сервера (./server) можно пробовать подключаться к нему по telnet:
Как прекрасно видно — сервер отвечает на подключение к localhost:1231 и при получении строки «hello» — отвечает «hello, 127.0.0.1 !!!», а затем закрывает соединение.
Теперь пришла очеред разработать клиента для нашего сервера.
Клиент.
Как было сказано выше — клиент имеет общую часть с сервером, а именно создание сокета. Но, в отличие от сервера, клиенту не нужно производить связывания сокета с адресами и переходить в режим прослушивания. Клиенту достаточно вызвать функцию connect, которая свяжет его сокет с удаленным сокетом сервера. Дальнейший процесс чтения/записи и закрытия соедиения сходен с таковыми у сервера.
Рассмотрим функцию connect подробно.
int connect(int sid, struct sockaddr* addr_p, int len);
Аргументы функции:
int sid — дескриптор сокета клиента.
struct sockaddr — структура адреса сервера, с которым необходимо соединится
int len — размер структур адреса.
Для наглядности рассмотрим, так же, диаграму работы клиента.
Теперь мы можем объеденить все вышеизученное и функцию connect для создания нашего клиента. Рассмотрим сразу полный исходный код.
Код клиента.
/************************************************/ /* client.c - простой TCP/IP клиент */ /************************************************/ #include <sys/types.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include <netdb.h> #include <errno.h> #include <string.h> #include <stdlib.h> #include <stdio.h> extern errno; //глобальная переменная, которая хранит код последней ошибки //функция подключения к серверу. объявление //аргументы: //host - адрес (имя) сервера //port - порт сервера //transport - протокол tcp или udp int connectsock(const char *host, const char *port, const char *transport); //главная функция int main(int argc, char **argv) { int sock; //сокет char msg[22]; //буфер сообщения if(argc == 3) //проверяем количество переданных аргументов. { //подключаем сокет, в качестве хоста - первый аргумент программы, в качестве порта - второй аргумент программы //напомню, что в argv[0] хранится имя самого исполняемого файла программы, поэтому его опускаем. sock = connectsock(argv[1], argv[2], "tcp"); if(sock < 0) //проверяем дескриптор сокета return -1; else //подключились { printf("Установлено соединение с %s:%s\n", argv[1], argv[2]); strcpy(msg, "hello"); //подготавливаем строку сообщения if(write(sock, msg, sizeof(msg)) < 0) //отсылаем серверу { printf("Не удалось отправить данные серверу: %s\n", strerror(errno)); return -1; } printf("Серверу отправлен \"hello\"\n"); //читаем ответ сервера memset(&msg, 0, sizeof(msg)); if(read(sock, msg, sizeof(msg)) < 0) { printf("Не удалось отправить данные серверу: %s\n", strerror(errno)); return -1; } else //выводим ответ сервера printf("От сервера получено: %s\n", msg); close(sock); //закрываем сокет } } else //иначе printf("Использование: server \"server\" \"port\"\n"); //выводим подсказку по использованию. return 0; }
//функция подключения к серверу. реализация int connectsock(const char *host, const char *port, const char *transport) { struct hostent *phe; //указатель на запись с информацией о хосте struct servent *pse; //указатель на запись с информацией о службе struct protoent *ppe; //указатель на запись с информацией о протоколе struct sockaddr_in sin; //структура IP-адреса оконечной точки int s, type; //дескриптор сокета и тип сокета //обнуляем структуру адреса memset(&sin, 0, sizeof(sin)); //указываем тип адреса (IPv4) sin.sin_family = AF_INET; //задаем порт sin.sin_port = htons((unsigned short)atoi(port)); //преобразовываем имя хоста в IP-адрес, предусмотрев возможность представить его //в точечном десятичном формате if(phe = gethostbyname(host)) memcpy(&sin.sin_addr, phe->h_addr, phe->h_length); //преобразовываем имя транспортного протокола в номер протокола if((ppe = getprotobyname(transport)) == 0) { printf("Ошибка преобразования имени транспортного протокола: %s\n", strerror(errno)); //в случае неудачи выводим сообщение ошибки return -1; } //используем имя протокола для определения типа сокета if(strcmp(transport, "udp") == 0) type = SOCK_DGRAM; else type = SOCK_STREAM; //создание сокета s = socket(PF_INET, type, ppe->p_proto); if(s < 0) { printf("Ошибка создания сокета: %s\n", strerror(errno)); //в случае неудачи выводим сообщение ошибки return -1; } //попытка подключить сокет if(connect(s, (struct sockaddr *)&sin, sizeof(sin)) < 0) { printf("Не удалось подключится к серверу: %s\n", strerror(errno)); //в случае неудачи выводим сообщение ошибки return -1; } //возвращаем дескриптор подключенного сокета return s; }
Клиент принимает два аргумента командной строки — адрес и порт сервера, если количество аргументов отличается — выводится подсказка по использованию. Далее происходит попытка подключения, с помощью совмещнной функции создания сокета и connect. В случае успешного подключения — выполняется процедура обмена сообщениями, почти как на сервере. После чего происходит закрытие сокета и завершение программы клиента.
Рабочий клиент:
Заключение.
В этой статье были рассмотрены основы клиент/сервных приложений с использованием сокетов Беркли. Приведенный код лежит в основе практически любого подобного приложения. Все различие сводится в реализации протокола, а именно процедурах обмена сообщениями/пакетами данных с помощью read/write. Так же, для одновременной обработки множества запросов — сервера выполняют многопоточными. Принцип прост, после того как произошло входящее подключение — происходит запуск нового потока, с передачей ему сокета. В новом потоке происходит процесс обмена данными с клиентом, а основной поток в это время снова ожидает входящих подключений и процесс повторяется для нового клиента, но все это уже тема отдельной статьи.
Разумеется своей статьей я донес лишь основы сетевого программирования, т.к. это очень сложная и интересная тема. Если у Вас возникли какие либо вопросы или трудности — спрашивайте.
Очень рекомендую прочесть: «Стивенс Р. Unix. Разработка сетевых приложений.» и «Камер Д. Разработка приложений типа клиент/сервер».
Эти книги являются бестселлерами в своей области.
more — разграничивает текст публикуемый на главной странице и весь текст.
в статье не рекомендую использовать div. например в этой был не закрытый div тег и весь дизайн перекосило.
Дизайн надо расширять. Так как код не умещается в статью. Но пределы расширения тоже есть. Так что надо бы переносить строки в коде.
просто напросто, нужна горизонтальная полоса прокрутки и проблема с кодом решена.
надо хорошо разобраться 😕
в функции sock память, выделенная в getprotobyname под структуру ppe должна осовобождаться.
Огромное спасибо за статью)))) Нигде немог найти настьлько простого примера) 🙂
Выложите пожалуйста все используемые библиотеки в этой статье, заранее спасибо!
это же всё libc. ничего не нужно выкладывать
Скажите, пожалуйста, где взять эти файлики…
У меня Visual Studio 2010 Ultimate не может их найти
sys/socket.h
netdb.h
arpa/inet.h
netinet/in.h
netdb.h
За этим следует обратиться к MSDN.
скажем тебе нужна функция gethostbyname() — ищешь её на MSDN, там есть секция «Требования», в ней написано что заинклудить.
в принципе тебе должно хватить того что лежит в WinSock2.h
Я разрабатывал нечто подобное на C#, спасибо, статья полезная)
Спасибо огромное за ценную информацию. Всегда хотел разобраться с сетевыми приложениями, методиками их создания и сопровождения! Но до сих пор времени не было:)
Спасибо за инфу, но что-то так сложно все в первый раз для понимания((
Пишет ‘Undefined first referenced symbol in file’
> Спасибо за инфу, но что-то так сложно все в первый раз для понимания((
Попробуйте python, на нем гораздо проще.
Интересная статья. Очень полезная. Спасибо.
Огромное спасибо!
попробовал делать отладку на программу клиент но не работал думаю кто то поможет мне можно мне писать на мой ligaban@yahoo.fr спс
тестовый пример не скомпилился:
client-tcp.cpp:16:8: error: ISO C++ forbids declaration of ‘__errno_location’ with no type [-fpermissive]
client-tcp.cpp: In function ‘int main(int, char**)’:
client-tcp.cpp:42:36: error: ‘write’ was not declared in this scope
client-tcp.cpp:49:35: error: ‘read’ was not declared in this scope
client-tcp.cpp:56:14: error: ‘close’ was not declared in this scope
компилил на g++
из глубин инета нашел совет добавить
#include
#include
и изменить «extern errno» на «extern int errno»
в такой версии работает
unistd.h
fcntl.h