Создание многопоточного сервера на C#

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Предисловие

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

Веб-сервер

Прежде чем писать свой веб сервер, нам нужно понять что это и как он работает.

Веб-сервер - это сервер, который основывается на работе протоколов TCP/IP и HTTP для взаимодействия с клиентом. Основной задачей таких серверов это принимать входящие запросы на основе протокола HTTP.

Под веб-сервером подразумевают две вещи:

  1. Программное обеспечение

  2. Аппаратное обеспечение

В данной статье мы будем рассматривать веб-сервер, как программное обеспечение.

Веб-сервер работает благодаря такой архитектуре как клиент - сервер

Рисунок 1 - Блок-схема архитектуры клиент-сервер
Рисунок 1 - Блок-схема архитектуры клиент-сервер

Чтобы было понятнее, разобьем работу архитектуры по пунктам:

  1. Формирование запроса клиентом

  2. Отправка запроса на сервер

  3. Получение запроса на сервере

  4. Обработка запроса и формирование ответа

  5. Отправка ответа клиенту

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

  1. TCP/IP

  2. HTTP

TCP/IP (Transmission Control Protocol/Internet Protocol) - два основных протокола на которых строится весь современный интернет. TCP предназначен для передачи данных между участниками сети, а IP является межсетевым протоколом, который используется для обозначения участников сети.

HTTP(Hyper Text Transfer Protocol) - протокол прикладного уровня передачи данных. Основной его задачей является передача файлов с расширением HTML, но он так же может передавать и другие файлы.

Компьютер, который является сервером будет прослушивать приходящие по сети подключения по паре ip:port, к примеру: 127.0.0.1:80, где 127.0.0.1 - ip-адрес; 80 - порт, используется для протокола HTTP, так же можно использовать порт 81.

Реализация веб сервера на C#

Писать наш веб-сервер мы будем на C# .Net Core. Желательно, чтобы вы знали базу языка.

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

  1. Socket

  2. TcpListener

Socket - представляет реализацию сокетов Беркли на C#, эмпирическим путем было выяснено что использование данного варианта приносит более эффективный результат.

TcpListener - прослушивает входящие TCP соединения по паре ip:port. Может от моей криворукости, больше чем в этом уверен, или от чего-то другого, у меня получалось так, что TcpListener не совсем подходит для этой задачи, так как при отправке пакетов клиенту, некоторые файлы просто не приходили и каждый раз количество файлов разнилось.

В данной статье мы рассмотрим только вариант на основе класса Socket, кому интересно знать, как реализовать веб-сервер на TcpListener, то вот ссылка на статью другого автора.

Для начала мы должны создать 2 класса(они должны располагаться в двух новых файлах):

  1. Server - этот класс будет обозначать наш сервер и он будет принимать входящие подключения

  2. Client - этот класс будет обозначать нашего клиента, в этом классе будет проходить вся обработка запроса

Начнем заполнять класс Server. Для начала мы должны добавить в наш класс библиотеки, которые нам понадобятся:

using System;
using System.Collections.Generic;
using System.Threading;
using System.Net;
using System.Net.Sockets;

Затем в классе мы должны создать переменные которыми будем оперировать:

public EndPoint Ip; // представляет ip-адрес
int Listen; // представляет наш port
Socket Listener; // представляет объект, который ведет прослушивание
public bool Active; // представляет состояние сервера, работает он(true) или нет(false)

Теперь создадим конструктор для нашего класса. Так как Socket работает по  ip:port, то и наш конструктор будет принимать первым аргументом ip, а вторым port:

public Server(string ip, int port)
{
    this.Listen = port;
    this.Ip = new IPEndPoint(IPAddress.Parse(ip), Listen);
    Listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
}

Ip мы будем задавать в виде строки и преобразовать его к классу IPEndPoint через класс IPAddress. Порт самое простое, просто обычное число типа int. Думаю самое непонятное для вас сейчас, это конструктор класса Socket:

  • AddressFamily – перечисление, которое обозначает то, с какой версией ip адресов мы будем работать. InterNetwork говорит о том что мы используем IPv4.

  • SocketType – перечисление, обозначает тип сокета и какое подключение будет устанавливаться. В нашем случаем мы будем работать с типом подключения Stream.

  • ProtocolType – перечисление, обозначает то, какой тип подключений мы будем принимать. Tcp, означает то что мы будем работать с протоколом TCP.

После конструктора нам стоит создать две функции, первая будет инициализировать работу нашего сервера, а вторая останавливать его соответственно: 

public void Start()
{
    if (!Active)
    {
       // тело условия
    }
    else
        Console.WriteLine("Server was started");
}

Условие внутри функции проверяет, выключен ли сервер. Если он выключен, то мы можем запустить наш сервер. Это нужно для того, чтобы не было конфликта у сокета. Если мы попытаемся запустить второй сокет с тем же ip и port то вылезет ошибка.

После мы заполняем наше условие, если сервер выключен то:

Listener.Bind(Ip);
Listener.Listen(16);
Active = true;

Функция Bind класса Socket означает, что слушатель будет работать по определенному ip:port, который мы передаем в эту функцию.

Функция Listen начинает прослушивание, как аргумент мы передаем в нее переменную типа int, который означает возможное кол-во клиентов в очереди на подключение к сокету.

Теперь приступим к реализации многопоточности. Делать мы ее будем на основе такого класса как ThreadPool. Нет, конечно можно было сделать проще:

new Task.Run(
	()=>{
		ClientThread(Listener.Accept());
	}
);

Но такой способ не эффективен, так как он будет просто создавать новые потоки для обработки входящего соединения, тем самым тормозя работу сервера, ведь как никак потоки у нашего процессора не безграничны. Поэтому мы берем и вставляем этот кусок кода в наше условие(после Active = true):

while (Active)
{
    ThreadPool.QueueUserWorkItem(
            new WaitCallback(ClientThread),
            Listener.Accept()
            );
}
  • ThreadPool.QueueUserWorkItem(WaitCallback, object) - добавляет в очередь функции, которые должны выполниться

  • WaitCallback(ClientThread) - принимает функцию и возвращает ответ о ее выполнении

  • Listener.AcceptTcpClient() - аргумент, который будет передаваться в функцию

Функция будет циклически прослушивать входящие соединения. Listener.Accept() будет временно останавливать цикл, до тех пор, пока не придет запрос на подключение.

Теперь перейдем к нашей функции остановки сервера:

public void Stop()
{
    if (Active)
    {
        Listener.Close();
        Active = false;
    }
    else
        Console.WriteLine("Server was stopped");
}

В ней мы пишем условие, обратное тому которое было в Start, т.е тут мы должны проверять включен ли сервер.

Функцией Close класса Socket мы прекращаем прослушивание. Затем мы меняем значение переменной Active на false.

Думаю по функции Start вы заметили, что там присутствовала такая функция как ThreadClient, пришло время создать и ее. Она будет отвечать за создание нового клиента, который подключается к нашему серверу:

public void ClientThread(object client)
{
    new Client((Socket)client);
}

Так как делегат WaitCallback требует, чтобы аргументом являлся простой тип object, то функция соответственно будет тоже принимать тип object, который мы будем не явным образов преобразовывать в класс Socket.

Пришло время и для описания класса Client. Для начала подключим нужные нам библиотеки в файле:

using System;
using System.IO;
using System.Text;
using System.Net.Sockets;
using System.Text.RegularExpressions;

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

struct HTTPHeaders
{
    public string Method;
    public string RealPath;
    public string File;
}

Данная структура будет хранить значения наших HTTP заголовков:

  • Method - хранит метод, с помощью которого делается запрос

  • RealPath – хранит полный путь до файла на нашем сервере(пример: C:\Users\Public\Desktop\Server\www\index.html)

  • File - хранит не полный путь до файла(пример: \www\index.html)

Теперь давайте создадим саму функцию, которая будет парсить заголовки:

public static HTTPHeaders Parse(string headers) {}

Она будет возвращать саму структуру, тогда объявление структуры будет выглядеть так:

HTTPHeaders head = HTTPHeaders.Parse(headers);

Теперь опишем тело функции:

public static HTTPHeaders Parse(string headers)
{
    HTTPHeaders result = new HTTPHeaders();
    result.Method = Regex.Match(headers, @"\A\w[a-zA-Z]+", RegexOptions.Multiline).Value;
    result.File = Regex.Match(headers, @"(?<=\w\s)([\Wa-zA-Z0-9]+)(?=\sHTTP)", RegexOptions.Multiline).Value;
    result.RealPath = $"{AppDomain.CurrentDomain.BaseDirectory}{result.File}";
    return result;
}

Объяснять принцип работы регулярных выражений я не буду, поэтому в конце статьи есть ссылка на документацию.

При присвоении значения переменной RealPath у объекта структуры result, я написал: AppDomain.CurrentDomain.BaseDirectory - это означает, что мы берем путь до нашего exe файла, пример: C:\Users\Public\Desktop\Server, а затем мы подставляем неполный путь до нашего файла:File, и тогда наш путь будет выглядеть так: C:\Users\Public\Desktop\Server\ + \www\index.html = C:\Users\Public\Desktop\Server\www\index.html . Т.е, файлы сайта будут находиться относительно нашего сервера.

Теперь давайте напишем функцию, которая будет возвращать нам расширения нашего файла, назовем ее FileExtention:

public static string FileExtention(string file)
{
    return Regex.Match(file, @"(?<=[\W])\w+(?=[\W]{0,}$)").Value;
}

Опять же, делаем это с помощью регулярных выражений.

Эта структура была сделана для удобства, так как когда мы будет парсить большое кол-во заголовков, то лучше если они будут храниться в одном месте.

Создадим в классе Client переменные:

Socket client; // подключенный клиент
HTTPHeaders Headers; // распарсенные заголовки

У конструктора нашего класс будет всего 1 аргумент, который будет принимать Socket:

public Client(Socket c)

После в конструкторе, мы должны присвоить нашей переменной client наш аргумент и начать принимать данные от клиента:

client = c;
byte[] data = new byte[1024]; 
string request = ""; 
client.Receive(data); // считываем входящий запрос и записываем его в наш буфер data
request = Encoding.UTF8.GetString(data); // преобразуем принятые нами байты с помощью кодировки UTF8 в читабельный вид

Код представленный выше описывает то, как сервер принимает запросы от клиента:

  • data - массив который принимает байты

  • request -  запрос в виде строки

  • client.Receive(data) - считывает приходящие байты и записывает их в массив.

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

Encoding.UTF8.GetString(data); 

Теперь настало время проверок и парсинга наших заголовков.

Первое условие проверяет, пришел ли какой-то запрос вообще ? Если нет, то мы отсоединяем клиента и выходим из функции:

if (request == "")
{
    client.Close();
    return;
}

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

Headers = HTTPHeaders.Parse(request);
Console.WriteLine($@"[{client.RemoteEndPoint}]
File: {Headers.File}
Date: {DateTime.Now}");

Дальше мы проверяем нашу ссылку на наличие “..”, если это значение существует, т.е больше -1, то мы выводим сообщение об ошибке:

if (Headers.RealPath.IndexOf("..") != -1)
{
    SendError(404);
    client.Close();
    return;
}

Ну и наконец последняя проверка в этой функции, если файл по указанному пути Headers.RealPath существует, то мы начинаем работать с этим файлом, иначе выводи ошибку:

if (File.Exists(Headers.RealPath))
		GetSheet(Headers);
else
		SendError(404);
client.Close();

Перед описанием основной функции GetSheet, которая будет возвращать пользователю ответ, мы создадим пару функций.

Первая функция SendError, она будет возвращать код ошибки пользователю:

public void SendError(int code)
{
    string html = $"<html><head><title></title></head><body><h1>Error {code}</h1></body></html>";
    string headers = $"HTTP/1.1 {code} OK\nContent-type: text/html\nContent-Length: {html.Length}\n\n{html}";
    byte[] data = Encoding.UTF8.GetBytes(headers);
    client.Send(data, data.Length, SocketFlags.None);
    client.Close();
}
  • html - представляет разметку нашей страницы

  • headers - представляет заголовки

  • data - массив байтов

  • client.Send(data, data.Length, SocketFlags.None);- отправляет данные клиенту

  • client.Close(); - закрывает нынешнее соединение

Теперь создадим функцию, которая будет возвращать тип контента, так как в данной статье представлена простая версия сервера, то мы ограничимся типами: text и image. Тип контента мы выводим для того, чтобы отправленный нами файл мог опознаться, записываем мы это значение в специальном заголовке Content-Type(пример: Content-Type: text/html):

string GetContentType(HTTPHeaders head)
{
    string result = "";
    string format = HTTPHeaders.FileExtention(Headers.File);
    switch (format)
    {
        //image
        case "gif":
        case "jpeg":
        case "pjpeg":
        case "png":
        case "tiff":
        case "webp":
            result = $"image/{format}";
            break;
        case "svg":
            result = $"image/svg+xml";
            break;
        case "ico":
            result = $"image/vnd.microsoft.icon";
            break;
        case "wbmp":
            result = $"image/vnd.map.wbmp";
            break;
        case "jpg":
            result = $"image/jpeg";
            break;
        // text
        case "css":
            result = $"text/css";
            break;
        case "html":
            result = $"text/{format}";
            break;
        case "javascript":
        case "js":
            result = $"text/javascript";
            break;
        case "php":
            result = $"text/html";
            break;
        case "htm":
            result = $"text/html";
            break;
        default:
            result = "application/unknown";
            break;
    }
    return result;
}

Данная функция принимает нашу структуру HTTPHeaders. Вначале мы воспользуемся функцией которая вернет нам расширение файла, а после начнем сверять его в условной конструкции switch. Если не один из вариантов перечисленных среди case не обнаружится.то мы вернем: applacation/unknown - это означает что файл не был опознан.

Теперь опишем нашу последнюю функцию GetSheet и можно будет тестировать наш сервер:

public void GetSheet(HTTPHeaders head){}

Данная функция как аргумент принимает нашу структуру HTTPHeaders. Сначала стоит обернуть функцию в блок обработки ошибок try catch, так как могут быть какие-либо ошибки:

try
{
    // тело оператора try
}
catch (Exception ex)
{
    Console.WriteLine($"Func: GetSheet()    link: {head.RealPath}\nException: {ex}/nMessage: {ex.Message}");
}

Теперь опишем тело оператора try:

string content_type = GetContentType(head);    
FileStream fs = new FileStream(head.RealPath, FileMode.Open, FileAccess.Read, FileShare.Read);  
string headers = $"HTTP/1.1 200 OK\nContent-type: {content_type}\nContent-Length: {fs.Length}\n\n";  
// OUTPUT HEADERS    
byte[] data_headers = Encoding.UTF8.GetBytes(headers);   
client.Send(data_headers, data_headers.Length, SocketFlags.None); 

После того как мы переведем наши заголовки в массив байтов мы отправим их клиенту с помощью метода Send() класса Socket, который принимает следующие параметры:

  1. byte[] - массив байтов

  2. byte[].Length - длинна передаваемого массива

  3. SocketFlags - перечисление, которое представляет поведение сокета при отправке и получении пакетов. Значение None обозначает что флаги не используются

И в самом конце нашего оператора мы передаем контент, который запрашивал клиент. Так как мы делали это с помощью FileStream, то сначала нам стоит: читать данные, записать их в массив байтов и отправить по сети.

// OUTPUT CONTENT
while (fs.Position < fs.Length)
{
    byte[] data = new byte[1024];
    int length = fs.Read(data, 0, data.Length);
    client.Send(data, data.Length, SocketFlags.None);
}

В этот раз мы поставили SocketFlags.Partial. Это означает что в данном случаем, отправляется часть сообщения, так как не все байты файла могут поместятся в массив размером 1024. Но так же может и работать с SocketFlags.None

Так как у нас многопоточный сервер, который работает на ThreadPool, то для начала в файле который содержит функцию Main мы подключим библиотеку: System.Threading, а затем укажем минимальное кол-во потоков, которое он может использовать:

ThreadPool.SetMinThreads(2, 2);

Первый параметр указывает на минимальное кол-во работающих потоков, а второй на минимальное кол-во асинхронно работающих потоков. Минимальное значение стоит всегда указывать 2, так как если указать 1, то основной поток будет блокироваться для обработки запроса.

Теперь зададим максимальные значения для нашего пула:

ThreadPool.SetMaxThreads(4, 4);

После чего мы просто инициализируем наш класс Server в функции и запускаем его:

static void Main(string[] args)
{
		ThreadPool.SetMinThreads(2, 2);
    ThreadPool.SetMinThreads(4, 4);
    Server server = new Server("127.0.0.1", 80);
    server.Start();
}

Давайте создадим в папке, где располагается наш exe(пример пути:../project/bin/Debug/netx.x/ - где project имя вашего проекта) файл простой html файл:

<!DOCTYPE html>
<html>
<head>
</head>
<body>
<h1>Hello Server!</h1>
</body>
</html>

После этого прописываем в адресной строке: http://127.0.0.1/index.html и проверяем результат. Нам должно вывести надпись Hello Server!, а так же должно вывести в консоли данные о нынешнем подключении.

Теперь Вы имеете представление о том, как с помощью сетевых сокетов на языке C# реализовать простой, многопоточный сервер. Для большего понятия как работают разные классы рекомендую почитать документацию:

  • Socket

  • Thread

  • ThreadPool

  • Регулярные выражения

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

Ссылка на сервер на GitHub, в данной версии сервера реализована поддержка php.

Ссылка на исходник данной статьи.

Источник: https://habr.com/ru/post/596527/


Интересные статьи

Интересные статьи

Всем привет. Меня зовут Игорь - я занимаюсь администрированием офисной инфраструктуры, руковожу отделом мониторинга и технической поддержки пользователей в компании NUT.Tech.Уже более 10-ти лет ...
Ноутбук осветил угол небольшой комнаты слепящим белым светом, красным загорелась подсветка на мыши. На рабочем столе горели две большие цифры: 5:59. Что ж, как всегда..Пе...
В этой части статьи мы завершим наш алгоритм создания тайла, узнаем, как использовать полученные тайлы в OpenLayers и в OsmAnd. Попутно продолжим знакомство с ГИС и узнаем про картографич...
Предыстория Я заинтересовался созданием бота для Discord, но всё что я нашёл, было пару видео на YouTube, да и то там просто писали код, без всяких пояснений. Поэтому я хочу начать серию статей ...
Основанная в 1998 году компания «Битрикс» заявила о себе в 2001 году, запустив первый в России интернет-магазин программного обеспечения Softkey.ru.