Привет всем читателям портала esate.ru.
В данной статье я постараюсь затронуть максимальное количество факторов, которые могут так или иначе повлиять на работоспособность написанного вами клиент-серверного приложения.
Несколько слов по статье.
Приложение, котрое мы напишем в ходе статьи, будет работать по следующему принципу:
Подключение -> Обработка данных -> Отключение.
Другими словами, клиент не будет постоянно подключен к серверу. Напротив, соединение будет происходить только при необходимости.
Данный способ не эффективен в условиях постоянного обмена данными, но, в достаточной мере, эффективен для решения задачи, которая будет перед ним поставленна на текущий момент.
Забегая немного вперед, могу сказать, что неше клиент-серверное приложение будет обмениваться файлами.
Я старался оттестировать приложение по-максимуму, но всегда есть вариант, что что-то пойдет не так. В таком случае буду рад вашим комментариям.
Если тема вам интересна - добро пожаловать под кат!
Это было небольшое вступление, а теперь перейдем к теории.
Теории будет не мало, но, поверьте, если вы хотите действительно понять, как на самом деле работает ваше клиент-серверное приложение – вы должны изучить тему достаточно глубоко и для начала поговорим о протоколах передачи данных по сети, а конкретнее о TCP.
Transmission Control Protocol (TCP, протокол управления передачей)TCP - один из основных протоколов передачи данных интернета, предназначенный для управления передачей данных. Сети и подсети, в которых совместно используются протоколы TCP и IP, называются сетями TCP/IP.
В стеке протоколов IP TCP выполняет функции протокола транспортного уровня модели OSI.
Механизм TCP предоставляет поток данных с предварительной установкой соединения, осуществляет повторный запрос данных в случае потери данных и устраняет дублирование при получении двух копий одного пакета, гарантируя тем самым, в отличие от UDP, целостность передаваемых данных и уведомление отправителя о результатах передачи.
Реализации TCP обычно встроены в ядра ОС. Существуют реализации TCP, работающие в пространстве пользователя.
Когда осуществляется передача от компьютера к компьютеру через Интернет, TCP работает на верхнем уровне между двумя конечными системами, например, браузером и веб-сервером. TCP осуществляет надежную передачу потока байтов от одной программы на некотором компьютере к другой программе на другом компьютере (например, программы для электронной почты, для обмена файлами). TCP контролирует длину сообщения, скорость обмена сообщениями, сетевой трафик.
Это было определение из википедии. Звучит просто безупречно, не так ли?
Конечно же так, но когда я первые 10-15 раз пытался реализовать простейший обмен данными, то каждый раз сталкивался с одной и той же ситуацией – обмен данными отлично проходил в локальной сети, а во внешней сети успех передачи был совершенно не предсказуем и, судя по вопросам на различных интернет-площадках, с такой ситуацией сталкивался почти каждый.
Чаще всего, проблема связана именно с таким замечательным описанием протокола. Вам кажется, что достаточно просто выполнить функцию
Write() или
WriteAsync(), а на обратной стороне вызвать функцию
Read() или
ReadAsync() и задача выполнена, ведь обо всем остальном позаботится сам протокол, но это не так.
Точнее, не совсем так.
Протокол, конечно, выполнит все, что от него зависит (при правильной реализации), но сделает все это на своем уровне – на уровне протокола, а вы, в свою очередь отвечаете, как программист, за абсолютно все операции на уровне приложения и, если вы когда-либо считали, что вызова одной функции на стороне клиента и одной на стороне сервера достаточно для нормальной передачи данных по сети интернет, то забудьте об этом – это совсем не так.
На самом деле вам предстоит большая работа.
Для начала вы должны подготовить так называемый пакет для отправки.
Есть несколько способов формирования пакетов, но лично я использую один и об остальных, к сожалению, ничего рассказать не могу, но вы всегда можете отыскать их в сети.
Суть в том, что перед отправкой данных мы отправляем их длину, а уже после этого сами данные.
Плюс такого подхода в том, что таким образом можно отправлять как «простые», так и «сложные» пакеты. Под «сложными» я подразумеваю пакеты, которые содержат в себе другие пакеты.
Я предлагаю использовать для определения длины пакета Little-Endian Int32 (int в C#), который занимает четыре байта.
Важно! Даже эти четыре байта могут не прийти к вам сразу! |
Для формирования таких пакетов лучше реализовать отдельный класс, что позволит в некоторой степени снизить риск ошибки.
Вторая важная вещь для реализации – механизм приема данных.
Далее мы реализуем его рекурсивной функцией, а пока просто опишем задачу.
Представим ситуацию, мы ожидаем 4 байта от клиента.
Метод
Read() вернул нам из потока некоторое количество байт (0, 1, 2, 3 или 4).
Далее мы проверяем соответствует ли ожидаемая длинна массива полученной.
Если так, то все здорово, но если нет – вызываем метод
Read() и продолжаем дописывать массив и так столько раз, сколько потребуется.
Важно! Эти действия нужно выполнять всегда, когда вы принимаете данные как на стороне сервера, так и на стороне клиента. |
Лично я еще ни разу не сталкивался с проблемами при вызове метода
Write() и поэтому оставляю его без дополнительных обработчиков, но буду рад вашим замечаниям в комментариях.
Один момент - важный.До отправки данных и после их полного принятия мы можем позволить себе думать о пересылаемых данных, как о пакетах, но вы должны четко понимать, что как только вы передали данные на отправку – вы положили их в поток (
NetworkStream) и, вплоть до полного их получения, вы работаете с потоком данных, а не с конкретным массивом. Это важно понять, ведь пока TCP делает свои магические вещи может случиться так, что поток на время прервется и вы, естественно, получите за один раз не все данные.
Второй момент – интересный.Я не могу точно сказать, связанно следующее поведение с процессами операционной системы или с чем-то еще, но я лично сталкивался с проблемой, когда нужно принять, скажем, 1000 байт данных. Выделяем массив под него и начинаем в него записывать данные из потока. В примерно 80 процентах случаев сервер не мог принять данные только из-за того, что система не успевала создать сам массив.
Третий момент - ознакомительный.
TCP построен таким образом, что после успешного соединения он будет считать соединение активным пока вы, практически, не сможете доказать ему обратное. По сути, если вы установили соединение и не будете передавать данные, перезагрузите роутер, то после его включения вы можете без проблем обменяться пакетами, а TCP будет думать, что ничего не произошло.
На этом блок с теорией закончится, но я настоятельно рекомендую вам почитать про TCP еще что-то.
Теперь приступим к реализации серверного приложения. Клиент будет отсылать выбранный файл серверу, сервер будет его принимать и, после принятия, отправлять его снова клиенту. После принятия клиент будет предлагать сохранить его.
РЕАЛИЗАЦИЯ СЕРВЕРНОЙ ЧАСТИ:Создадим стандартный
WinForm проект с одной формой с версией фреймворка, в моем случае,
4.6.1 и расставим на ней компоненты как на скриншоте ниже.
btnConnect будет запускать и останавливать сервер, а
txtLog – отображать лог событий.
Далее по коду я не буду пояснять очевидные вещи, но постараюсь хорошо пояснить код, касающийся клиент-серверного приложения. |
Начнем с реализации класса клиента.
Этот класс представляет из себя обертку над клиентом и хранит в себе данные, которые будут уникальны для каждого отдельного клиента.
В нашем случае - объект класса
TcpClient, имя файла и прочее.
Все достаточно подробно откомментированно в коде.
Client.cs:using System.Net.Sockets;
using System.Collections.Generic;
namespace EsateServer
{
public class Client
{
// Статический список клиентов, подключенных в данный момент
public static List<Client> CLIENTSLIST = new List<Client>();
public static uint NEXT_ID
{
get
{
uint id = 1;
while (CLIENTSLIST.Find(client => client.ID == id) != null) id++;
return id;
}
}
public TcpClient TCPClient = null;
public uint ID = 0;
public bool IS_CONNECTED = false;
// Имя файла, полученного от клиента
public string FILE_NAME = string.Empty;
public Client(uint id, TcpClient tcpclient)
{
// Подключение
ID = id;
TCPClient = tcpclient;
IS_CONNECTED = true;
// Добавляем клиента в список текущих клиентов
CLIENTSLIST.Add(this);
}
public void Disconnect()
{
// Отключение
IS_CONNECTED = false;
if (TCPClient != null)
{
TCPClient.Close();
TCPClient = null;
}
// Убираем клиента из списка текущих клиентов
CLIENTSLIST.Remove(this);
}
}
} |
Далее мы реализуем класс для работы с данными.
Данный класс будет принимать, паковать и отсылать данные.
DataWrapper.cs:using System;
using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;
namespace EsateServer
{
public static class DataWrapper
{
// Рекурсивный метод получения данных из потока
private static async Task<bool> ReceiveDataLoop(Client client, byte[] buffer, int received = 0)
{
// Недополученные байты
int difference = buffer.Length - received;
// Смещение индекса
int offset = buffer.Length - difference;
try
{
// Полученные байты
received += await client.TCPClient.GetStream().ReadAsync(buffer, offset, difference);
}
catch
{
// Если что-то пошло не так
return false;
}
// Если получили не все байты
if (received < buffer.Length)
{
// Продолжаем заполнять буффер
return await ReceiveDataLoop(client, buffer, received);
}
else
{
// Все прошло успешно
return true;
}
}
public static async Task<byte[]> GetData(Client client)
{
byte[] buffer = new byte[4];
// Получаем длинну имени
if (!await ReceiveDataLoop(client, buffer)) return null;
// Получаем имя
buffer = new byte[Program.BytesToInt(buffer)];
if (!await ReceiveDataLoop(client, buffer)) return null;
client.FILE_NAME = Encoding.UTF8.GetString(buffer);
// Получаем длинну файла
buffer = new byte[4];
if (!await ReceiveDataLoop(client, buffer)) return null;
// Получаем файл
buffer = new byte[Program.BytesToInt(buffer)];
if (!await ReceiveDataLoop(client, buffer)) return null;
// Возвращаем файл
return buffer;
}
public static async Task SendData(Client client, byte[] data)
{
// Пишем данные в поток
await client.TCPClient.GetStream().WriteAsync(data, 0, data.Length);
}
public static byte[] CreatePack(string filename, byte[] data)
{
// Создаем пакет для отправки
List<byte> packList = new List<byte>();
// Переводим строку в массив UTF8 байт
byte[] name_bytes = Encoding.UTF8.GetBytes(filename);
// Добавляем длинну строки
packList.AddRange(BitConverter.GetBytes(name_bytes.Length));
// Добавляем байты строки
packList.AddRange(name_bytes);
// Добавляем количество байт файла
packList.AddRange(BitConverter.GetBytes(data.Length));
// Добавляем байты файла
packList.AddRange(data);
// Возвращаем пакет для отправки
return packList.ToArray();
}
}
}
|
Подготовки почти законченны.
Теперь перейдем к реализации класса сервера, который будет работать с созданными ранее классами.
EsateServer.cs:using System;
using System.Net;
using System.Net.Sockets;
namespace EsateServer
{
public class EsateServer
{
// Public variables
public bool IS_RUNNING;
// Private variables
private static int PORT;
private TcpListener TCPServer;
// Constructor
public EsateServer(int port = 1100)
{
PORT = port;
IS_RUNNING = false;
}
//Public methods
public void Start()
{
// Слушаем весь диапазон IP на заданном порту
TCPServer = new TcpListener(IPAddress.Any, PORT);
TCPServer.Start();
IS_RUNNING = true;
// Запускаем прослушивание клиентов
AcceptClient();
}
public void Stop()
{
IS_RUNNING = false;
try
{
while (Client.CLIENTSLIST.Count > 0)
{
Client.CLIENTSLIST[0].Disconnect();
Client.CLIENTSLIST.RemoveAt(0);
}
}
catch { }
Client.CLIENTSLIST.Clear();
TCPServer.Stop();
}
//Private methods
private async void AcceptClient()
{
// Пока сервер запущен
while (IS_RUNNING)
{
try
{
// Принимаем клиента
TcpClient TCPClient = await TCPServer.AcceptTcpClientAsync();
// Создаем экземпляр клиента
var client = new Client(Client.NEXT_ID, TCPClient);
// Дожидаемся данных от клиента и пишем их в массив
byte[] data = await DataWrapper.GetData(client);
// Если данные пришли
if (data != null)
{
// Отправляем полученные данные клиенту обратно
await DataWrapper.SendData(client, DataWrapper.CreatePack(client.FILE_NAME, data));
}
else
{
// Если что-то пошло не так
System.Windows.Forms.MessageBox.Show("Файл не получен!", "Внимание!", System.Windows.Forms.MessageBoxButtons.OK, System.Windows.Forms.MessageBoxIcon.Warning);
}
// Отключаем клиента
client.Disconnect();
}
catch (ObjectDisposedException) { return; }
catch (Exception) { return; }
}
}
}
}
|
Отлично.
Осталось буквально несколько строк кода в классе
MainForm.
Создадим и объявим объект класса
EsateServer в конструкторе формы:
private EsateServer server;
public MainForm()
{
InitializeComponent();
server = new EsateServer();
} |
Последнее, что касается сервера - обработчик нажатия
btnConnect:
private void btnConnect_Click(object sender, EventArgs e)
{
if (server.IS_RUNNING)
{
// Остановка сервера
server.Stop();
btnConnect.Text = "Запустить";
txtLog.AppendText($"{DateTime.Now}: Сервер остановлен\r\n");
}
else
{
// Запуск сервера
server.Start();
btnConnect.Text = "Остановить";
txtLog.AppendText($"{DateTime.Now}: Сервер запущен\r\n");
}
} |
Код сервера закончен.
Как вы могли заметить - он очень прост и, скорее всего, отличается от вашего только наличием метода
ReceiveDataLoop().
Вы вольны расширять данный код и улучшать, но пусть это будет заданием для самостоятельно выполнения.
Могу посоветовать вам реализовать систему флагов, которыми обмениваются клиент и сервер и построить весь диалог на их основе.
РЕАЛИЗАЦИЯ КЛИЕНТСКОЙ ЧАСТИ:...