Разработка клиент-серверных приложений с использованием API сокетов Беркли

Базовые понятия клиент-серверной архитектуры ПО

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

Чаще всего, и клиент и сервер являются отдельными программами, однако иногда они разделены только логически, внутри кода одного приложения. Взаимодействие между клиентом и сервером, в преобладающем числе случаев, осуществляется с помощью протокола Интернета (IP), но нередко можно увидеть применение таких технологий как разделяемая память (shared memory) и удалённый вызов процедур (RPC).

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

Сокеты Беркли

Центральным понятием, используемым при разработке клиент-серверного ПО, является сокет (socket). Под этим словом может подразумеваться как программный интерфейс (application programming interface, API) в широком смысле, так и специальная структура данных, описывающая одну из сторон в процессе обмена информацией.

Сокеты, как программный интерфейс, были разработаны в университете Беркли, и являлись частью операционной системы BSD Unix 4.1. Этот интерфейс представлял собой набор функций и структур данных, доступных для работы из языка программирования C. Другие языки программирования следуют за этим интерфейсом, зачастую повторяя сигнатуры функций один в один.

В рамках интерфейса, термин “сокет” используется применительно к структуре данных, а точнее - к её дескриптору. Дескриптором (на сленге - fd) называется целое число, которое ОС назначает тому или иному ресурсу. Файлы и сокеты - одни из самых часто используемых ресурсов, имеющих дескриптор.

Классификация сокетов

Существует несколько способов классификации сокетов. Сокеты можно разделить на два вида следующим образом:

  1. Сокеты, ориентированные на соединение. Прежде чем передавать данные через такие сокеты, требуется сперва установить соединение. Что понимается под этим выражением зависит от конкретного сетевого протокола.
  2. Сокеты, не ориентированные на соединение. Через такие сокеты можно отправлять данные сразу после их создания.

Другая классификация сокетов основывается на способе передачи данных:

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

Потоковые сокеты применяются для надёжной передачи информации потенциально большого объёма. Протоколы передачи файлов (FTP, SMB, HTTP и др.) и различные Интернет-мессенджеры используют потоковые сокеты.

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

Сетевые протоколы

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

Протокол TCP (Transmission Control Protocol) является наиболее распространённым потоковым протоколом, ориентированным на соединение. Он имеет механизмы контроля целостности и обнаружения потерянных пакетов, что делает его надежным протоколом для передачи больших объёмов данных.

Протокол UDP (User Datagram Protocol) представляет собой датаграммный сетевой протокол, не ориентированный на соединение.

Оба этих протокола используют протокол более низкого уровня, называемый IP (Internet Protocol), для адресации. Таким образом, адресом в протоколах TCP и UDP является пара из IP-адреса (четыре числа в пределах [0-255]) и номер порта (число в пределах [1-65535]).

Функции для работы с сокетами

Рассмотрим наиболее важные функции, определённые API сокетов Беркли, и приведём их сигнатуры в псевдокоде:

Приведённые выше функции используются при написании программ-клиентов. Изобразим типовую структуру клиентского ПО в виде псевдокода:

// создаём сокет нужного типа
int socket = socket(...);

// подключаемся к удаленному узлу
int result = connect(socket, "1.2.3.4", ...);

if (result == -1)
{
    // обработка ошибки при попытке подключения
}

// общаемся с удалённой стороной
while(true)
{
    // отправляем сообщение
    send(socket, "Hello", ...);
    byte[] reply;
    // принимаем сообщение
    recv(socket, reply, ...);
}

// закрываем сокет
shutdown(socket);

Теперь рассмотрим функции, которые применяются при написании серверов:

Псевдокод простейшего серверного приложения имеет следующий вид:

// создаём сокет нужного типа
int listenSocket = socket(...);

// привязываем к сокету локальный адрес
int result = bind(listenSocket, "0.0.0.0", ...);

if (result == -1)
{
    // обработка ошибки при попытке привязки адреса
}

// начинаем прослушивать сокет
listen (listenSocket, 10);

// цикл обработки клиентских запросов
while (true)
{
    // принимаем соединение с очередным клиентом
    int clientSocket = accept(listenSocket, ...);
    // отправляем сообщение
    send(clientSocket, "Hello", ...);
    byte[] reply;
    // принимаем сообщение
    recv(clientSocket, reply, ...);
    // закрываем клиентский сокет
    shutdown(clientSocket);
}

Многопоточные серверные приложения

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

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

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

void handleClient(int clientSocket)
{
    // отправляем сообщение
    send(clientSocket, "Hello", ...);
    byte[] reply;
    // принимаем сообщение
    recv(clientSocket, reply, ...);
    // закрываем клиентский сокет
    shutdown(clientSocket);
}

void Main()
{
    int listenSocket = socket(...);
    bind(listenSocket, "0.0.0.0", ...);
    listen (listenSocket, 10);

    while (true)
    {
        // принимаем соединение с очередным клиентом
        int clientSocket = accept(listenSocket, ...);
        // запускаем поток, в котором начинается обработка
        start_thread(handleClient, clientSocket);
    }
}

Интерфейс сокетов Беркли в C#

C# являясь, с одной стороны, языком более высокого уровня, чем C, а с другой - языком объекто-ориентированным, имеет несколько отличный интерфейс сокетов, чем тот, который был приведён выше. Прежде чем использовать сокеты, необходимо подключить пространства имён:

using System.Net;
using System.Net.Sockets;

Сокет в языке C# описывается классом, объект которого создаётся следующим образом:

Socket tcpSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
Socket udpSocket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);

Здесь перечисление AddressFamily содержит возможные семейства адресов, SocketType - типы сокетов, а ProtocolType - поддерживаемые протоколы.

Дальнейшие операции над сокетом производятся с помощью соответствующих методов:

// соединение с узлом
tcpSocket.Connect("onlyfans.com", 80);

// получение данных
byte[] reply = new byte[1024];
int bytesReceived = tcpSocket.Receive(reply);

// отправка данных
int bytesSent = tcpSocket.Send(reply);

Метод .Connect() имеет несколько перегрузок:

Класс EndPoint является базовым классом для классов, описывающих удалённый узел в терминах конкретного протокола. По этой причине, объекты класса EndPoint не создаются напрямую, а вместо этого необходимо работать с классами-наследниками:

IPAddress ipAddress = IPAddress.Parse("127.0.0.1");
IPEndPoint endPoint = new IPEndPoint(ipAddress, 11000);

Чтобы сконвертировать доменное имя в IP-адрес, нужно использовать статичный метод Dns.GetHostAddresses(). С доменным именем может быть ассоциировано несколько IP-адресов, и по этой причине метод возвращает массив:

IPAddress[] addrs = Dns.GetHostAddresses("onlyfans.com");

При работе с сокетами, не ориентированными на соединение, необходимо использовать методы .SendTo() и .ReceiveFrom():

Socket s = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
IPAddress addr = IPAddress.Parse("1.2.3.4");
IPEndPoint ep = new IPEndPoint(addr, 800);

byte[] buffer = new byte[1024];
s.ReceiveFrom(buffer, ep);
s.SendTo(buffer, ep);

Наконец, серверные функции в сокетах C# применяются следующим образом:

Socket listenSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
IPEndPoint ep = new IPEndPoint(IPAddress.Any, 800);

listenSocket.Bind(ep);
listenSocket.Listen(10);

while (true) {
    Socket clientSocket = listenSocket.Accept();
    byte[] msg = new byte[1024];
    int bytesRec = clientSocket.Receive(msg);
    clientSocket.Send(msg);
}

Сериализация данных

Можно заметить, что методы .Receive() и .Send() оперируют только массивами байт. Это означает, что если нам требуется отправить данные другого типа (например, int или string), мы должны сперва сконвертировать их в byte[]. Аналогично, на принимающей стороне код должен разобрать полученный массив байт назад в исходный тип данных. Эти операции называются сериализацией и десериализацией, соответственно.

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

Сериализация целочисленных типов

Для сериализации/десериализации целочисленных типов данных, типов с плавающей точкой и типа bool используются статические методы класса BitConverter:

bool a = false;
float b = 1.5f;
double c = 2.6;
short d = 123;
int e = 456;

// Сериализуем переменные "a"-"e".
//
// Каждый вызов BitConverter.GetBytes() производит небольшой массив,
// содержащий байты, соответствующие каждой переменной.
//
// Метод Array.Concat() используется для того, чтобы "склеить" маленькие
// массивы в один большой.
byte[] serialized = BitConverter.GetBytes(a);
serialized = serialized.Concat(BitConverter.GetBytes(b)).ToArray();
serialized = serialized.Concat(BitConverter.GetBytes(c)).ToArray();
serialized = serialized.Concat(BitConverter.GetBytes(d)).ToArray();
serialized = serialized.Concat(BitConverter.GetBytes(e)).ToArray();

// десериализуем их обратно
a = BitConverter.ToBoolean(serialized, 0);
b = BitConverter.ToSingle(serialized, 1);
c = BitConverter.ToDouble(serialized, 5);
d = BitConverter.ToInt16(serialized, 13);
e = BitConverter.ToInt32(serialized, 15);

Методы десериализации BitConverter в качестве второго аргумента принимают смещение внутри массива, с которого нужно начинать чтение байтов. Чтобы правильно вычислить смещения, нужно представлять сколько занимает тот или иной тип данных в сериализованном виде. В примере выше видно, что тип bool занимает 1 байт, типы float и int - 4 байта, short - 2 байта, и double - 8 байт.

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

byte[] serialized = BitConverter.GetBytes((short)1);
serialized = serialized.Concat(BitConverter.GetBytes((int)2).ToArray();

// Переменная serialized в памяти представляется последовательностью в 6 байт:
// 01 00 02 00 00 00
// /\ /\ /\ /\ /\ /\
// || || || || || ||
// || ||     int
// short

Если нарушить порядок сериализации и десериализовать int первым, то его байтами станут байты исходного значения short и 2 байта исходного значения int, что приведёт к тому, что после десериализации мы получим 16777728 и 0, вместо 1 и 2.

Сериализация строк

Сериализация и десериализация строк производится путём кодирования/декодирования символов строки в соответствии с какой-либо кодировкой:

byte[] serialized = Encoding.Unicode.GetBytes("Hello!");
string deserialized = Encoding.Unicode.GetString(serialized);

Здесь вместо .Unicode могут использоваться иные кодировки, такие как .ASCII, UTF8 и др.

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

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

// сериализуем строку как пару <длина, текст>
byte[] serializedString = Encoding.Unicode.GetBytes("Hello!");
byte[] serializedLength = BitConverter.GetBytes(serializedString.Length);
byte[] serialized = serializedLength.Concat(serializedString).ToArray();

// десериализуем сначала длину
int length = BitConverter.ToInt32(serialized, 0);
// затем саму строку
string s = Encoding.Unicode.GetString(serialized, 4, length);

Контрольные вопросы

  1. Понятие сокета, виды сокетов.
  2. Функции bind и connect.
  3. Функции listen и accept.
  4. Синтаксис создания сокета в C#.
  5. Понятие сериализации и десериализации данных.

Задание на лабораторную работу

Разработайте клиент-серверное приложение, соответствующее своему варианту. Приложение должно удовлетворять следующим критериям:

Варианты заданий

Вариант 1

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

Вариант 2

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

Вариант 3

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

Вариант 4

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

Вариант 5

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

Вариант 6

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

Вариант 7

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

Вариант 8

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

Вариант 9

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

Вариант 10

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

Вариант 11

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

Вариант 12

Сетевая игра-файтинг. Игрок ставит в очередь 3 действия из 6 возможных - верхний/средний/нижний удар, и такие же блоки. Бой между двумя игроками состоит из 5 ходов. На каждом ходе рассчитывается урон, полученный игроками. В конце боя нанесший больший урон объявляется победителем. Сервер ведет статистику боев, рейтинг игроков и случайным образом организует бои между подключенными клиентами.

Вариант 13

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