Pull to refresh

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

Level of difficultyEasy
Reading time10 min
Views31K

Предисловие

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

Веб-сервер

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

Веб-сервер - это сервер, который основывается на работе протокола HTTP для взаимодействия с клиентом.

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

  1. Программное обеспечение - nginx, apache

  2. Аппаратное обеспечение - просто собранный компьютер

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

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

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

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

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

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

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

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

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

Как уже было сказано мною выше общение клиента с веб-сервером происходит за счет протокола HTTP.

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

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

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

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

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

  1. Socket - представляет реализацию сокетов Беркли на C#.

  2. TcpListener - прослушивает входящие TCP соединения по паре ip:port.

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

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

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

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

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

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

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

public EndPoint Ip; // представляет ip-адрес
public int Listen; // представляет наш port
public bool Active; // представляет состояние сервера, работает он(true) или нет(false)
private Socket _listener; // представляет объект, который ведет прослушивание
private volatile CancellationTokenSource _cts; // токен отменты, с помощью него будут останавливаться потоки при остановке сервера

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

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

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

  • AddressFamily – перечисление, которое задает способ адресации. 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, который означает возможное кол-во клиентов в очереди на подключение к сокету.

Теперь приступим к реализации многопоточности. Делать мы ее будем на основе такого класса как Task.

// пока Active == true и _cts.Token.IsCancellationRequested != true
while (Active || !_cts.Token.IsCancellationRequested)
{
    try
    { 
        Socket listenerAccept = _listener.Accept();
        if(listenerAccept != null)
        {
          Task.Run(
            ()=>ClientThread(listenerAccept),
            _cts.Token
          );
        }
    }catch{}
}
  • Task.Run() - функция, которая запускает определенную функцию в новом потоке. Первым аргументом идет анонимная функция, вторым токен отмены. Если токен будет равен true, то цикл завершится, а так же завершится работа потока.

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

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

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

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

Функция _cts.Cancel() сообщает потокам о том, что пора завершать свою работу, функцией Close класса Socket мы прекращаем прослушивание входящего сетевого соединения, после чего меняем значение переменной Active на false.

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

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

Пришло время и для описания класса 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 – хранит полный путь до файла на нашем сервере(пример: /var/www/index.html)

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

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

public static HTTPHeaders Parse(string 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;
}

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

Использовать регулярные выражения в таком месте не уместно ибо они могут занять очень много времени, которое для нас так ценно, поэтому лучше написать нормальный HTTP парсер или использовать уже готовый. Но для нас, как говорилось в одном мультфильме, "и так сойдет".

RealPath присваивается значение адреса директории(AppDomain.CurrentDomain.BaseDirectory) из которой был запущен данный проект и название нужного файла. В итоге мы получим что-то вроде этого "/var/www/index.html", тк RealPath, в нашем примере, равен "/var/www/", а File равен "index.html".

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

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

Опять же, делаем это с помощью регулярных выражений. Созданная структура HTTPHeaders не подходит для хранения большого кол-ва заголовков, для этого лучше использовать ассоциативный массив(Dictionary<string,string>).

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

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

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

public Client(Socket socket)

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

_client = socket;
byte[] data = new byte[_client.ReceiveBufferSize]; // _client.ReceiveBufferSize - хранит значение полученных данных
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}]\nFile: {Headers.File}\nDate: {DateTime.Now}");

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

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

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

if (File.Exists(Headers.RealPath))
{
    GetSheet();
}
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()
{
    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;
}

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

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

public void GetSheet(){}

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

try
{
    // тело оператора try
}
catch (Exception ex)
{
    // просто выводит ошибку, после появления какой-либо новой ошибки
    // стоит создать новый блок обработки ошибок catch
    Console.WriteLine($"Exception: {ex}/nMessage: {ex.Message}");
}

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

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

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

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

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

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

И в самом конце нашего оператора мы передаем контент, который запрашивал клиент.

data = new byte[fs.Length];
int length = fs.Read(data, 0, data.Length);
_client.Send(data, data.Length, SocketFlags.None);

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

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

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

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

Создадим в директории нашего скомпилированного проекта файл "index.html" со следующей структурой:

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

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

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

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

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

Tags:
Hubs:
Total votes 18: ↑4 and ↓14-10
Comments72

Articles