![]() |
Разработка клиент-серверных приложений с использованием API сокетов Беркли |
![]() |
Базовые понятия клиент-серверной архитектуры ПО
Клиент-серверной архитектурой при разработке программного обеспечения называется подход, при котором часть функционала выносится в отдельный программный компонент, называемый сервером, а другая часть - в компонент, называемый клиентом. В процессе работы клиент обращается к серверу с запросами, которые тот обрабатывает согласно логике приложения и генерирует ответ клиенту. Иными словами, сервер реализует бизнес-логику приложения, а клиент предоставляет интерфейс для взаимодействия пользователю.
Чаще всего, и клиент и сервер являются отдельными программами, однако иногда они разделены только логически, внутри кода одного приложения. Взаимодействие между клиентом и сервером, в преобладающем числе случаев, осуществляется с помощью протокола Интернета (IP), но нередко можно увидеть применение таких технологий как разделяемая память (shared memory) и удалённый вызов процедур (RPC).
Программа-сервер обычно запускается в единичном экземпляре и продолжает свою работу в течение длительного промежутка времени. Клиентские программы подключаются к серверу в произвольные моменты времени, причем распространённой является ситуация, при которой сервер одновременно обрабатывает запросы множества клиентов.
Сокеты Беркли
Центральным понятием, используемым при разработке клиент-серверного ПО, является сокет (socket). Под этим словом может подразумеваться как программный интерфейс (application programming interface, API) в широком смысле, так и специальная структура данных, описывающая одну из сторон в процессе обмена информацией.
Сокеты, как программный интерфейс, были разработаны в университете Беркли, и являлись частью операционной системы BSD Unix 4.1. Этот интерфейс представлял собой набор функций и структур данных, доступных для работы из языка программирования C. Другие языки программирования следуют за этим интерфейсом, зачастую повторяя сигнатуры функций один в один.
В рамках интерфейса, термин “сокет” используется применительно к структуре данных, а точнее - к её дескриптору. Дескриптором (на сленге - fd) называется целое число, которое ОС назначает тому или иному ресурсу. Файлы и сокеты - одни из самых часто используемых ресурсов, имеющих дескриптор.
Классификация сокетов
Существует несколько способов классификации сокетов. Сокеты можно разделить на два вида следующим образом:
- Сокеты, ориентированные на соединение. Прежде чем передавать данные через такие сокеты, требуется сперва установить соединение. Что понимается под этим выражением зависит от конкретного сетевого протокола.
- Сокеты, не ориентированные на соединение. Через такие сокеты можно отправлять данные сразу после их создания.
Другая классификация сокетов основывается на способе передачи данных:
- Потоковые (stream) сокеты представляют передаваемые данные как непрерывный поток байт. Большие порции данных, переданные через потоковые сокеты, могут быть разбиты на несколько кусочков в произвольных местах, поэтому приложения, использующие такие сокеты должны корректно обрабатывать подобные ситуации. С другой стороны, потоковые сокеты гарантируют, что отдельные сообщения прибудут в место назначения в том же порядке, в котором они были отправлены.
- Датаграммные (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(int domain, int type, int protocol)
. Создаёт новый сокет и возвращает его дескриптор. При создании сокета указываются 3 параметра: семейство протоколов (domain
), тип сокета (type
) и используемый протокол (protocol
). Существует большое число комбинаций значений этих трёх параметров.int connect(int socket, sockaddr addr, int namelen)
. Осуществляет попытку подключения сокетаsocket
по сетевому адресу, описанному в структуре данныхsockaddr addr
. Т.к. в разных сетевых протоколах адреса имеют различный вид, эта функция так же принимает длину адреса в качестве параметраnamelen
. Подключаемый сокет должен быть создан с помощью функцииsocket
и быть сокетом, ориентированным на соединение.int send(int socket, byte[] data, int len, int flags)
. Производит отправку байт из массиваdata
через сокетsocket
на удаленный узел. Разумеется, сокет должен быть переведён в состояние “подключен” путем предварительного вызоваconnect
. Параметрlen
задаёт количество байт, которые нужно передать, а параметрflags
позволяет изменить некоторые аспекты передачи. Функция возвращает количество отправленных байт в случае успеха, либо значение-1
, чтобы сигнализировать об ошибке.int recv(int socket, byte[] data, int len, int flags)
. Производит приём информации с удалённого узла. Функция во многом аналогичнаsend
.int shutdown(int socket, int how)
. Позволяет отключить возможность отправки или передачи данных через сокет. В зависимости от используемого протокола, может приводить к отправке служебных пакетов на удалённый узел.int sendto(int socket, byte[] data, int len, int flags, sockaddr addr, int namelen)
. Вариант функцииsend
для сокетов, не ориентированных на подключение. Можно заметить, чтоsendto
дополнительно принимает адрес узла, на который необходимо отправить сетевой пакет.int recv(int socket, byte[] data, int len, int flags, sockaddr addr, int namelen)
. Аналог функцииrecv
для сокетов, не ориентированных на подключение. Параметрыaddr
иnamelen
заполняются операционной системой перед возвратом из функции.
Приведённые выше функции используются при написании программ-клиентов. Изобразим типовую структуру клиентского ПО в виде псевдокода:
// создаём сокет нужного типа
int socket = socket(...);
// подключаемся к удаленному узлу
int result = connect(socket, "1.2.3.4", ...);
if (result == -1)
{
// обработка ошибки при попытке подключения
}
// общаемся с удалённой стороной
while(true)
{
// отправляем сообщение
(socket, "Hello", ...);
send[] reply;
byte// принимаем сообщение
(socket, reply, ...);
recv}
// закрываем сокет
(socket); shutdown
Теперь рассмотрим функции, которые применяются при написании серверов:
int bind(int socket, sockaddr addr, int addrlen)
. Эта функция в определённом смысле аналогична функцииconnect
. При успешном вызовеconnect
на клиенте, с сокетом ассоциируется некоторый сетевой адрес. Однако, сервер не инициирует сам сетевые подключения, а ожидает их. Поэтому, для того чтобы назначить сетевой адрес серверному сокету, применяется функцияbind
;int listen(int socket, int backlog)
. Переводит сокет в режим прослушки, который необходим для возможности принимать соединения от клиентов. Параметрsocket
должен содержать дескриптор потокового сокета, к которому был привязан локальный сетевой адрес с помощью функцииbind
. Второй параметр,backlog
, определяет размер очереди клиентов, ожидающих подтверждение подключения;int accept(int socket, sockaddr addr, int addrlen)
. Принимает запрос на соединение от очередного клиента из очереди ожидания. В качествеsocket
должен передаваться сокет, находящийся в режиме прослушки. При успешном соединении заполняет структуруsockaddr addr
информацией об адресе клиента и возвращает ещё один сокет - клиентский. Дальнейшие операцииsend
иrecv
должны производиться именно над клиентским сокетом.
Псевдокод простейшего серверного приложения имеет следующий вид:
// создаём сокет нужного типа
int listenSocket = socket(...);
// привязываем к сокету локальный адрес
int result = bind(listenSocket, "0.0.0.0", ...);
if (result == -1)
{
// обработка ошибки при попытке привязки адреса
}
// начинаем прослушивать сокет
(listenSocket, 10);
listen
// цикл обработки клиентских запросов
while (true)
{
// принимаем соединение с очередным клиентом
int clientSocket = accept(listenSocket, ...);
// отправляем сообщение
(clientSocket, "Hello", ...);
send[] reply;
byte// принимаем сообщение
(clientSocket, reply, ...);
recv// закрываем клиентский сокет
(clientSocket);
shutdown}
Многопоточные серверные приложения
В приведённом выше коде сервера имеется существенный недостаток - в каждый момент времени этот сервер может взаимодействовать только лишь с одним клиентом. Это не будет проблемой, если обработка каждого пользователя занимает очень короткое время, однако на практике многие сетевые приложения поддерживают соединение часами.
Для того, чтобы эффективно обрабатывать множество одновременных соединений, существуют различные подходы к организации серверного кода. Рассмотрение этих подходов с их достоинствами и недостатками выходит за рамки этой работы, поэтому рассмотрим лишь один способ.
Быстрым, но неэффективным решением проблемы является вынесение обработки запросов пользователей в отдельный программный поток (thread). Это позволяет продолжать принимать соединения, не дожидаясь обработки запросов уже подключенных клиентов. Псевдокод такого многопоточного сервера имеет следующий вид:
void handleClient(int clientSocket)
{
// отправляем сообщение
(clientSocket, "Hello", ...);
send[] reply;
byte// принимаем сообщение
(clientSocket, reply, ...);
recv// закрываем клиентский сокет
(clientSocket);
shutdown}
void Main()
{
int listenSocket = socket(...);
(listenSocket, "0.0.0.0", ...);
bind(listenSocket, 10);
listen
while (true)
{
// принимаем соединение с очередным клиентом
int clientSocket = accept(listenSocket, ...);
// запускаем поток, в котором начинается обработка
(handleClient, clientSocket);
start_thread}
}
Интерфейс сокетов Беркли в C#
C# являясь, с одной стороны, языком более высокого уровня, чем C, а с другой - языком объекто-ориентированным, имеет несколько отличный интерфейс сокетов, чем тот, который был приведён выше. Прежде чем использовать сокеты, необходимо подключить пространства имён:
using System.Net;
using System.Net.Sockets;
Сокет в языке C# описывается классом, объект которого создаётся следующим образом:
= new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
Socket tcpSocket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); Socket udpSocket
Здесь перечисление AddressFamily
содержит возможные семейства адресов, SocketType
-
типы сокетов, а ProtocolType
- поддерживаемые протоколы.
Дальнейшие операции над сокетом производятся с помощью соответствующих методов:
// соединение с узлом
.Connect("onlyfans.com", 80);
tcpSocket
// получение данных
byte[] reply = new byte[1024];
int bytesReceived = tcpSocket.Receive(reply);
// отправка данных
int bytesSent = tcpSocket.Send(reply);
Метод .Connect()
имеет несколько перегрузок:
Connect(string, int)
подключается к узлу, адрес которого задаётся в строковом виде. Строка может содержать как IP-адрес, так и доменное имя. Целочисленный параметр задаёт номер порта подключения.Connect(EndPoint)
принимает объект классаEndPoint
, инкапсулирующий в себе информацию об удалённом узле.Connect(IPAddress, int)
аналогична первой перегрузке, однако принимает IP-адрес в виде объекта классаIPAddress
.
Класс EndPoint
является базовым классом для классов, описывающих удалённый
узел в терминах конкретного протокола. По этой причине, объекты класса EndPoint
не создаются напрямую, а вместо этого необходимо работать с классами-наследниками:
= IPAddress.Parse("127.0.0.1");
IPAddress ipAddress = new IPEndPoint(ipAddress, 11000); IPEndPoint endPoint
Чтобы сконвертировать доменное имя в IP-адрес, нужно использовать статичный
метод Dns.GetHostAddresses()
. С доменным именем может быть ассоциировано
несколько IP-адресов, и по этой причине метод возвращает массив:
[] addrs = Dns.GetHostAddresses("onlyfans.com"); IPAddress
При работе с сокетами, не ориентированными на соединение, необходимо использовать
методы .SendTo()
и .ReceiveFrom()
:
= new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
Socket s = IPAddress.Parse("1.2.3.4");
IPAddress addr = new IPEndPoint(addr, 800);
IPEndPoint ep
byte[] buffer = new byte[1024];
.ReceiveFrom(buffer, ep);
s.SendTo(buffer, ep); s
Наконец, серверные функции в сокетах C# применяются следующим образом:
= new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
Socket listenSocket = new IPEndPoint(IPAddress.Any, 800);
IPEndPoint ep
.Bind(ep);
listenSocket.Listen(10);
listenSocket
while (true) {
= listenSocket.Accept();
Socket clientSocket byte[] msg = new byte[1024];
int bytesRec = clientSocket.Receive(msg);
.Send(msg);
clientSocket}
Сериализация данных
Можно заметить, что методы .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.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();
serialized
// десериализуем их обратно
= BitConverter.ToBoolean(serialized, 0);
a = BitConverter.ToSingle(serialized, 1);
b = BitConverter.ToDouble(serialized, 5);
c = BitConverter.ToInt16(serialized, 13);
d = BitConverter.ToInt32(serialized, 15); e
Методы десериализации BitConverter
в качестве второго аргумента принимают
смещение внутри массива, с которого нужно начинать чтение байтов. Чтобы правильно
вычислить смещения, нужно представлять сколько занимает тот или иной тип данных
в сериализованном виде. В примере выше видно, что тип bool
занимает 1 байт,
типы float
и int
- 4 байта, short
- 2 байта, и double
- 8 байт.
В процессе десериализации необходимо соблюдать тот же порядок операций, что и
при сериализации. Например, если сперва была сериализована переменная типа
short
, а затем int
, то из начала полученного массива данных необходимо
извлечь short
, а отступив 2 байта - int
. Изобразим схематично память
компьютера в этом случае:
byte[] serialized = BitConverter.GetBytes((short)1);
= serialized.Concat(BitConverter.GetBytes((int)2).ToArray();
serialized
// Переменная 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);
Контрольные вопросы
- Понятие сокета, виды сокетов.
- Функции
bind
иconnect
. - Функции
listen
иaccept
. - Синтаксис создания сокета в C#.
- Понятие сериализации и десериализации данных.
Задание на лабораторную работу
Разработайте клиент-серверное приложение, соответствующее своему варианту. Приложение должно удовлетворять следующим критериям:
- Все исключительные ситуации должны корректно обрабатываться как на сервере, так и на клиенте.
- Сервер должен одновременно обрабатывать произвольное количество клиентов.
- Клиент не должен сохранять лишнюю информацию в памяти, все данные должны храниться на сервере и запрашиваться клиентом по мере необходимости.
- Клиент должен иметь графический интерфейс (Windows Forms, или, при желании WPF).
- В сервере должна отсутствовать неопределённости при многопоточности.
- Используемым сетевым протоколом должен быть TCP.
Варианты заданий
Вариант 1
Приложение-органайзер. Пользователь может создавать задания, подзадания, устанавливать напоминания, делиться отдельными заданиями с другими пользователями. Когда один пользователь изменяет задание, которым с ним поделились, другой пользователь должен мгновенно видеть это изменение. Задания можно помечать выполненными, и тогда они должны перемещаться в архив.
Вариант 2
Информационная система “Деканат”. Пользователь может просматривать список учебных групп и создавать/добавлять их. Каждая группа содержит некоторое количество записей, соответствующих студентам, которые так же можно редактировать. Система предоставляет следующие функции: “Поиск студента по имени”, “Перевод студента в другую группу”, “Перевод группы на следующий курс”.
Вариант 3
Система обработки результатов экспериментов на большом адронном коллайдере.
Пользователь-ученый загружает в систему .csv
файл, содержащий числовую матрицу
произвольной размерности. Сервер вычисляет средние значения и
среднеквадратичные отклонения по строкам и столбцам, вычисляет определитель
матрицы, и находит 3 других эксперимента с наиболее похожими результатами.
Клиентское приложение отображает статистические данные в виде графиков или
диаграмм. При получении .csv
файла сервер не мгновенно приступает к расчетам,
а записывает полученное задание в очередь. В каждый момент времени должен
производиться расчет только одного задания из очереди.
Вариант 4
Консоль управления системой защиты информационной системы предприятия. Офицер безопасности авторизуется в клиентском приложении при подключении к серверу. После авторизации клиент отображает в реальном времени показатели нагрузки на контролируемых сервером безопасности рабочих местах и происходящие на них события безопасности. Сервер произвольным образом генерирует различные события безопасности. Клиентское приложение позволяет настраивать простые фильтры событий и пересылать сообщения другим офицерам безопасности.
Вариант 5
Система управления торговым предприятием. Сервер хранит данные о состоянии предприятия: список заказов, список товаров на складе. Клиентское приложение позволяет просматривать состояние, создавать новые заказы на имеющиеся на складе товары, а так же добавлять новые товары на склад.
Вариант 6
Система моделирования процессов органического синтеза. Клиент устанавливает подключение к серверу и начинает ожидать задач. Сервер в некоторые промежутки времени генерирует произвольную последовательность ДНК генома живого существа, и отсылает клиенту. Пользователь должен оценить последовательность и ответить серверу одним из возможных ответов — “сохранить в базе” или “удалить”. Сервер отправляет одну и ту же последовательность разным клиентам, и выбирает действие, в зависимости от решения большинства клиентов.
Вариант 7
Система управления программным комплексом видеонаблюдения. Пользователь подключается к серверу и выбирает интересующую его видеокамеру. Сервер начинает отсылать изображение с этой видеокамеры (напр., случайно генерируемую картинку). Пользователь имеет возможность поворачивать камеру в четырех направлениях и регулировать громкость микрофона камеры.
Вариант 8
Хакерское подполье. Клиентское приложение позволяет просматривать и размещать на сервере заказы на взлом, просматривать и публиковать предложения о покупке/продаже эксплоитов и полезных нагрузок (payloads). Несколько пользователей могут взяться за один взлом, а заказчик, в этом случае, имеет возможность выбрать исполнителя из списка.
Вариант 9
Система управления ПВО. Клиентское приложение получает с сервера информацию о всех обнаруженных воздушных объектах, их идентификацию и местоположение. Клиентское приложение может запросить подъем истребителя-перехватчика для уничтожения определенной цели. Вызванный истребитель двигается к цели и уничтожает ее, затем возвращается на базу. Сервер случайным образом генерирует воздушные объекты в произвольные промежутки времени.
Вариант 10
Система обратной связи с пользователями (багтрекер). Клиент позволяет создавать заявки о проблеме с указанием названия, описания проблемы, названия программного продукта, его версии, ОС на которой наблюдается проблема. Другие пользователи могут оставлять комментарии к проблеме. Проблема так же может быть помечена решенной.
Вариант 11
Терминал ФСБ для доступа к переписке в социальных сетях. Клиентское приложение позволяет выводить всю переписку по заданным ключевым словам, а так же переписку указанного человека. Сервер произвольным образом генерирует учетные записи и переписку на них. Открыв переписку конкретного человека, пользователь может добавлять свои записи, видимые только другим агентам ФСБ, но не владельцу переписки.
Вариант 12
Сетевая игра-файтинг. Игрок ставит в очередь 3 действия из 6 возможных - верхний/средний/нижний удар, и такие же блоки. Бой между двумя игроками состоит из 5 ходов. На каждом ходе рассчитывается урон, полученный игроками. В конце боя нанесший больший урон объявляется победителем. Сервер ведет статистику боев, рейтинг игроков и случайным образом организует бои между подключенными клиентами.
Вариант 13
Система заказа такси. Пользователь имеет возможность заказывать такси на определенный адрес с указанием тарифа перевозки и адреса назначения. Сервер подыскивает ближаюшую свободную машину и отправляет клиенту информацию о ней. Клиент может оценивать поездку и оставлять комментарий о водителе.
