Предисловие

Данная статья предназначена для таких же новичков как и я. Вся информация в этой статье основывается на моем опыте создания одного единственного веб-сервера, который был создан в рамкам учебного проекта на 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# реализовать простой, многопоточный сервер. Для большего понятия как работают разные классы рекомендую почитать документацию:

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

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

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

Комментарии (64)


  1. DeniSix
    19.12.2021 14:31
    +2

    Ah shit, here we go again...

    Ладно, если серьёзно, то никогда не используйте регулярки без таймаута. Об этом даже отдельное предупреждение в документации.


    1. s2d1ent Автор
      20.12.2021 08:39

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


  1. korsetlr473
    19.12.2021 14:33
    +5

    это одно из самых жестких что я видел на хабре ...


    1. s2d1ent Автор
      20.12.2021 08:50

      Да соглашусь, статья не очень вышла, но я пытался, прошу прощения. Не стоило выкладывать её вовсе.


      1. t3chn0ph0b
        20.12.2021 11:48

        Спасибо за статью. Не знаю насколько она идеальна с точки зрения кода, но мне было интересно на нее посмотреть.


  1. IvaYan
    19.12.2021 15:21
    +4

    Нет, конечно можно было сделать проще:

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

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

    Зачем new? Зачем фигурные скобки? Task.Run не создаёт поток на каждую задачу, а использует пул потоков, о чем сказано в MSDN:

    Queues the specified work to run on the ThreadPool and returns a task or Task<TResult> handle for that work.


    1. OkunevPY
      19.12.2021 19:36
      +3

      Более того. Это не поток, это Task которая в общем случае может и не быть отделтным потоком.


    1. s2d1ent Автор
      20.12.2021 08:43
      +1

      Спасибо что указали на ошибку, в предь буду аккуратнее и есть повод перечитать, извиняюсь


  1. mayorovp
    19.12.2021 16:50
    +1

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

    Вообще-то, вариант через Task.Run тоже использует ThreadPool. Причина его неэффективноси — совсем в другом: вы передаёте блокирующую операцию (Accept) в другой поток, из-за чего теряете возможность узнать когда она закончится в потоке текущем (по умному это называется утерей обратного давления, backpressure).


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


    while (Active)
    {
        var client = Listener.Accept();
        new Task.Run(()=> ClientThread(client));
    }

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

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


    Для решения первой задачи вам надо запомнить все начатые задачи (вот почему Task использовать удобнее чем ThreadPool!), а для решения второй надо как-то передать сигнал об окончании обработки всем соединениям, и это удобно делать через CancellationToken;


    В итоге надо снова менять процесс запуска сервера, и получается что-то вроде вот такого решения:


    CancellationTokenSource ctsStop = new ();
    List<Task> activeTasks = new (); 
    // также тут может быть HashSet, связный список или вовсе ConcurrentDictionary
    
    // …
    
    while (!ctsStop.IsCancellationRequested)
    {
        var client = Listener.Accept();
        var task = new Task.Run(()=> ClientThread(client, ctsStop.Token));
    
        lock(activeTasks) activeTasks.Add(task);
        task.ContinueWith(t => {
             lock(activeTasks) activeTasks.Remove(t);
        }, TaskContinuationOptions.ExecuteSynchronously);
    }


    1. navferty
      19.12.2021 18:33
      +1

      Можно заменить List на ConcurrentBag например, тогда не понадобятся локи.

      Ну а в целом, по статье, обращусь к начинающим разработчикам: эта статья - хорошее упражнение для того, чтобы разобраться как работает этот стек под капотом, но если Вам нужно будет сделать веб-сервер для практических задач, используйте актуальную версию ASP.NET Core


      1. mayorovp
        19.12.2021 18:50

        А разве можно из ConcurrentBag удалить конкретную задачу?


        1. navferty
          19.12.2021 19:14
          +1

          Ваша правда, ведь нужно удалять. Тут лучше и нагляднее будет ConcurrentDictionary


      1. s2d1ent Автор
        20.12.2021 08:46

        Спасибо за рекомендацию


    1. s2d1ent Автор
      20.12.2021 08:45

      Спасибо за правки, сделаю так как вы посоветовали. Извиняюсь, я ещё не настолько хорошо разбираюсь в создании ПО


  1. mayorovp
    19.12.2021 17:00
    +4

    Теперь про то как вы считываете данные из сокета:


    byte[] data = new byte[1024]; 
    string request = ""; 
    client.Receive(data);
    request = Encoding.UTF8.GetString(data);

    Никогда так не делайте!


    Метод Receive не даёт никаких гарантий относительно того, сколько байт он читает из сокета!


    Из-за сетевых причуд вы можете прочитать половину запроса, или два запроса подряд (это невозможно в HTTP, но возможно в общем случае).


    Вы обязаны, как минимум, учитывать число считанных байт (оно возвращается из метода Receive).


  1. ivan_mariychuk
    19.12.2021 19:24
    +8

    Немного просмотрел код на гитхабе.

    Метод GetSheet класса Client содержит код

    lock (new object())
    { ... }

    Что по сути является бесполезным, потому что при одновременном выполнении данного кода двумя или более потоками, каждый поток будет создавать новый объект и устанавливать на нём блокировку, таким образом все потоки будут одновременно иметь доступ к критической секции. Вот пример использовании lock на MSDN.

    У части классов объявлен финализатор с кодом

    ~ClassName()
    {
    		GC.Collect(2, GCCollectionMode.Forced);
    }

    Зачем?


    1. s2d1ent Автор
      20.12.2021 08:48

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

      А по второму я собирался сделать деструкторы но в итоге не совсем понял как из тут можно сделать если тут в принципе есть мусорщик, и в итоге забвл удалить. Если Вам будет не трудно то не могли бы вы привести пример чтобы в будущем я совершал меньше ошибок.


      1. Evengard
        20.12.2021 12:41

        Использование финализаторов в C# - это очень редкое дело, и чаще всего используются при реализации интерфейса IDispose, для освобождения unmanaged ресурсов в случае, если объект не был корректно Dispose-нут.

        В 99% случаев финализатор в C# просто не нужен - сборщик мусора всё сделает за вас. Это не C++.


        1. s2d1ent Автор
          20.12.2021 13:14

          Спасибо, учту на будущее.


  1. anonymous
    00.00.0000 00:00


    1. mayorovp
      19.12.2021 19:34
      +3

      Лучше найдите статьи, написанные теми кто понимает что делает и зачем. Особенно если вы новичок.


    1. OkunevPY
      19.12.2021 19:37
      +3

      Не ориентируйтесь по статье, тут ошибка на ошибке


  1. anonymous
    00.00.0000 00:00


  1. OkunevPY
    19.12.2021 19:41
    +2

    Вот удивительно. То-есть этот скажем так проект, порождён в рамках учебного курса, о чём написано в самом начале. Курс 3, то-есть как бы не новичок. Ошибка на ошибке. Отсюда вопрос, чему учат на курсах? Какие специалисты работают в IT? И много ли таких скажем так специалистов выходит на рынок труда?


    1. codecity
      19.12.2021 20:30
      +2

      Не будьте слишком строги — многие после 5 совершают куда более грубые ошибки.


      1. VXP
        20.12.2021 02:20

        Это следствие описанного выше ИМХО


    1. s2d1ent Автор
      20.12.2021 08:36

      Чему учат в колледже:
      1 курс : офисный пакет по информатике и 10-11 класс по остальным предметам
      2 курс: кое как прошли архитектуру аппаратных средств, изучение C#:

      1. Типы данных

      2. Условные конструкции и циклы

      3. Функции

      4. Массивы

      5. ООП

      6. WinForms

      Обучал нас преподаватель который работал только с аппаратной частью и последнее когда она видел код это был язык ассемблера и Си++, и было это давно. Читал я наперёд, понравилось и продолжил, и всё что наговнокодил это процесс самостоятельного обучения.
      3 курс: изучаем в данный момент 1С Предприятия 8.3 ради демо экзамена, ибо площадка больше не по до что не приспособлена.

      Такой ответ вас удовлетворит?


    1. HemulGM
      20.12.2021 11:41
      +1

      Врядли когда-либо будут учить "хорошей архитектуре". Т.к. архитектура меняется, паттерны меняются или заменяются другими и т.д.
      А тут мы видим просто отсутствие опыта. Не знание, что такое Task (внутри).


    1. DistortNeo
      20.12.2021 12:51

      У нас на факультете фундаментальный подход к изучению языков программирования: 1 курс — Паскаль и Ассеблер, 2 курс — C/C++, 3 курс — C#, 4-5 курсы — Python. По нашим меркам это совсем новичок.


  1. Evengard
    20.12.2021 01:20
    +4

    Интересно, это одна из статей "на зачёт автоматом", о которых недавно модераторы Хабры писали?


    1. s2d1ent Автор
      20.12.2021 08:37
      +1

      Нет, статью делать было не обязательно. Главное сдать готовый рабочий проект.


    1. Le0Wolf
      20.12.2021 13:09

      О чем речь? Можете ссылку дать?


      1. s2d1ent Автор
        20.12.2021 13:11

  1. EvgeniyRasyuk
    20.12.2021 08:53
    -1

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


    1. EvgeniyRasyuk
      20.12.2021 08:55
      -1

      И не можете помочь сами - хоть покажите на идеальные аналогичные проекты с вашей точки зрения


    1. s2d1ent Автор
      20.12.2021 09:38
      +2

      Спасибо за поддержку. Но критика уместна, так как имеются косяки. Может быть местами как-то грубовато, но нужно научиться правильно воспринимать критику.


      1. navferty
        20.12.2021 10:07
        +1

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

        Директива using не добавляет библиотеки, а лишь позволяет использовать короткие имена классов вместо полных, например

        using System;
        
        DateTime d = DateTime.Now;

        вместо

        System.DateTime d = System.DateTime.Now;

        А добавление внешних библиотек - в файле прокта csproj, например через добавление PackageReference с нужной ссылкой (в листинге пример нового sdk-style типа csproj):

        <Project Sdk="Microsoft.NET.Sdk.Web">
        	<PropertyGroup>
        		<TargetFramework>net5.0</TargetFramework>
        	</PropertyGroup>
        
        	<ItemGroup>
        	  <PackageReference Include="NLog.Web.AspNetCore" Version="4.14.0" />
        	</ItemGroup>
        </Project>


        1. s2d1ent Автор
          20.12.2021 10:10

          Соглашусь с вами, не так сформулировал. Прошу прощения.


  1. BkmzSpb
    20.12.2021 15:51
    +1

    Такое ощущение, что все что можно было сделать не так, было сделано не так, да еще и с особым упорством. Ощущение, что расчехлили какой-то античный фреймворк 3.5 и C# 4 (или какие там версии соответствуют друг другу), до тасок и асинков, и упорно пытались написать максимально неподдерживаемый и нечитаемый код.

    Я даже не уверен что в этом виноват автор. Мне кажется это качество преподавания в вузе. Если так и есть, вот вам совет от незнакомца в интернетах: читайте книги, блогпосты, смотрите доклады с конференций, участвуйте в конференциях, следите за изменениями в языке и платформе, за best practices, и не бойтесь декомпилировать BCL что разобраться, что там под капотом. И если вы будете открывать свой код месячной давности и ваша первая мысль будет "что за косороукий долбоеб это написал? А, это же ьыл я" -- значит вы идете в правильном направлении.


    1. s2d1ent Автор
      20.12.2021 16:05

      Я обучаюсь не в вузе, а колледже на СПО 09.02.07 "Информационные системы и программирование". Как я уже писал одному из комментаторов, C# нас особо не учили, и тот говнокод что есть, был изучен самостоятельно. Так что получается что тут моя вина


      1. BkmzSpb
        20.12.2021 16:46

        C# нас особо не учили

        Так что получается что тут моя вина

        И да, и нет. Я понимаю что ждать от образовательных учереждений обучения актуальным знаниям и навыкам (особенно в РФ) -- бессмысленно, но все же хочется, чтобы человек, отучившийся на специальности, получал навыки, которые ему потом пригодятся.

        Так вот, ваш самый ценный навык -- это способность искать информацию и обучаться. Вам зачем нужен был веб-сервер? Показать, что вы можете написать N строк кода? Или выдавать какой-то контент по запросу? В перпом случае бросаться на написание своего сервера с нуля -- такая себе задача. Во втором случае -- добро пожаловать в ASP.NET minimal APIs.

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

        Кроме того, у вас в репозитории лежат бинарники интерпретаторов и хрен пойми чего. Вы уверены, что имеете право их распространять? У вас лежит пустой .gitignore. и закоммитчены объектные файлы и бинарники приложения.

        Хотите прокачать навык? Разберитесь с gitignore и вычистите репозиторий (спойлер -- у dotnet cli есть встроенный темплейт под эту задачу). Возьмите нормальный тестовый фреймворк и перепишите на него тесты, чтобы можно было спокойно вызывать dotnet test. Разберитесь с GitHubActions и добавьте пайплайн, который хотя бы будет срабатывать на пуш в мастер -- собирать проект и тестировать. Добавьте лицензию (этому должны учить до того как допускать к написанию кода) -- никто не захочет связываться с вашим проектом если у него мутная лицензия или ее нет. Напишите описание на английском (хотя бы короткое). И -- voilà -- ваш проект похож на проект. А исправлять ужасно написанный код вы теперь сможете хоть с тетриса сидя в уборной -- CI пайплайны выполнят за вас всю работу по сборке и тестированию.

        Хотите быть более стильным и молодежным? Прочитайте про структурное логирование, заведите логгер и замените все Console.WriteLine на различные виды логирования (см. Microsoft.Extensions.Logging.Abstractions, если не ошибаюсь, для стандартного интерфейса). Ах да, выше уже говорили -- уберите финализаторы с принудительным GC, это просто зло в квадрате. А если вас этому кто-то обучил, то скажите этому человеку, что он не прав, и пусть лушче оставляет свои костыли в ламповом C++ -- в C# нам такое не нужно. А еще прочитайте про рекомендованный код-стиль и нейминг-конвеншн от Майкософт -- большая часть кода на C# ему следует, и нет никакой разумной причины не привести свои проекты в соответствии со стандартом.

        Что в итоге? В итоге у вас останется все еще чудовищно написанный сервер, но вы получите полезные навыки работы с инфраструктурой и немного новых знаний о best practices в C#. Если вы не хотите работать за плошку риса на гос контору, где качество кода примерно соответствует вашему проекту, а версионирование происходит в архивах с суффиксом _final_final.rar, то все описанные выше навыки (и еще много чего другого) вам обязательно пригодятся.


  1. PowerMetall
    20.12.2021 17:34
    +1

    Да ладно вам, парни, все когда-то с чего-то начинали, в т. ч. и с подобных велосипедов с квадратными колесами ))

    "Не стреляйте в пианиста, он играет как умеет" ©

    P. S. Автор, вот образец файла gitignore под Visual Studio и C#, в частности:

    https://github.com/github/gitignore/blob/main/VisualStudio.gitignore


    1. s2d1ent Автор
      20.12.2021 18:18

      Спасибо, в ближайшее время освежу знания.


  1. mbaladinsky
    21.12.2021 18:21
    -2

    Для реализации многопоточности достаточно было использовать BeginAccept вместо Accept. Для реализации многопоточного вебсервера - HttpListener вместо socket.
    А писать на сокетах в .net свой вебсервер имеет смысл если [есть много времени]/[отсутствуют доступные классы]/[функционала доступных недостаточно].


    1. s2d1ent Автор
      21.12.2021 18:23

      Благодарю, учту на будущее


    1. DistortNeo
      21.12.2021 18:58
      +2

      Для реализации многопоточности достаточно было использовать BeginAccept вместо Accept

      Это устаревший и неудобный функционал. Сейчас в тренде таски и async-await парадигма.

      А писать на сокетах в .net свой вебсервер имеет смысл если [есть много
      времени]/[отсутствуют доступные классы]/[функционала доступных
      недостаточно].

      Самый лучший способ разобраться, как работает технология — это написать велосипед и понабивать на нём шишек. Так что, несмотря на низкий уровень самой статьи, я целиком и полностью поддерживаю автора и готов ему помогать.


      1. s2d1ent Автор
        21.12.2021 19:30

        Благодарю за поддержку


      1. mbaladinsky
        21.12.2021 23:21
        -1

        Функция асинхронного вызова с коллбеком в отдельном потоке "из коробки" на 100% удовлетворяющий условию задачи - "старый и неудобный функционал"?! Что же это за тренды такие, где изобретать велосипед, но следуя парадигме, важнее самого велосипеда ))
        Но по поводу шишек соглашусь. Я это упомянул - если хотя бы одно из условий присутствует


        1. DistortNeo
          21.12.2021 23:29
          +1

          Потому что колбэки — это лапшеподобный код + отсутствие возможности использовать CancellationToken. К слову, сейчас под капотом BeginAccept — это враппер над AcceptAsync.


          1. mbaladinsky
            21.12.2021 23:56
            -1

            "лапшеподобный код" в контексте Begin** операций для сокет-объектов - это какой стереотип ради парадигмы, уж простите. А CancelationToken - опять же шашечки или ехать? Если первое - то наверное это серьезный аргумент. А второе - вызовите EndAccept и обработайте исключение.
            Я не против трендов. Я за парадигмы. Но прежде всего - я за здравый смысл. А в рекомендации использоваться BeginAccept он заключался в том, что это самый простой, самый стабильный способ получить функционал неблокирующего многопоточного обработчика входящих соединений. Если функционала не хватает - другой разговор. Но в обсуждаемой задаче его как раз "достаточно"


            1. mayorovp
              22.12.2021 00:01

              Извините, а что в этом способе такого "стабильного"?


              1. mbaladinsky
                22.12.2021 00:13
                -1

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


                1. mayorovp
                  22.12.2021 00:24

                  Ну и где отличие в способности вызвать исключение вы видите?


        1. mayorovp
          21.12.2021 23:54
          +1

          Условию какой такой задачи эта функция удовлетворяет? Напомню, что нормальный сервер должен вызывать операцию Accept в цикле. Ну и как будет выглядеть цикл на BeginAccept? Как-то так:


          void OnAccept(IAsyncResult ar) {
              var client = socket.EndAccept(ar);
          
              socket.BeginAccept(OnAccept, null);
          
              Process(client);
          }

          А вот так выглядит цикл на задачах:


          while(...) {
              var client = await AcceptAsync();
          
              Task.Run(() => Process(client));
          }

          Ну и нахрена в 2021м году писать цикл, который не выглядит как цикл?


          1. mbaladinsky
            22.12.2021 00:07
            -2

            ненене, это вы что то свое придумали )

            void OnAccept(IAsyncResult ar) {
            var client = socket.EndAccept(ar);
            Process(client);
            }
            Выше - вот так будет выглядет ваш коллбек.

            А вот так - обработчик входящих соединений

            while (true)
            {
              socket.BeginAccept(new AsyncCallback(OnAccept), socket);
            }

            PS - Извините, я еще с редакторам не разобрался. Но так или иначе - вполне себе цикл, правда )


            1. mayorovp
              22.12.2021 00:10
              +1

              Вот вы и накосячили с циклом. А ещё говорили что-то про "стабильность"… Вы правда не видите в этом коде ошибки?


              Тогда подскажу: поскольку блокирующей операции у вас в цикле нет — вы в цикле начнёте неограниченное число операций Accept пока не закончится память или другой ресурс.


              1. mbaladinsky
                22.12.2021 00:26
                -2

                Накосячил. Но цикла это не отменяет. Я хотел продемонстрировать наличие цикла, а не строки кода.
                Я пишу на vb.net и у меня сами строки выглядит так

                While True
                	Dim Result = Socket.BeginAccept(New AsyncCallback(AddressOf Accept), Socket)
                	Result.AsyncWaitHandle.WaitOne(AcceptTimeout)
                	If Not Result.IsCompleted Then Socket.EndAccept(Result)
                End While
                

                Но повторюсь - вполне себе цикл


                1. mayorovp
                  22.12.2021 00:29
                  +1

                  Ну классно. Теперь у вас и вовсе блокирующий вызов замаскированный написан. И нахрена тут вообще асинхронность-то?


                  Вы вообще в курсе что обращение к AsyncWaitHandle создаёт этот самый WaitHandle, который при нормальном сценарии никогда не создаётся?


                  Или что Socket.EndAccept(Result) ожидает завершения операции Accept, и ваш тайм-аут в итоге вообще ничего не делает?


                  1. mbaladinsky
                    22.12.2021 00:46
                    -2

                    Блокирующий - waitone? Да, но он никуда не маскируется - он находится на своем месте там как раз для того, чтобы исключение недостатка памяти не было выброшено. Можно было бы использоваться какой то другой блокирующий объект - но для чего.
                    Что касается таймаута - он невостребован только практически. В теории, если что-либо не позволит вызвать EndAccept в коллбеке - он сократит блокирование основного потока на время таймаута, а вызов EndAccept инцициирует вызов колбека, в котором EndAccept вызовет обратываемое исключением..
                    Поэтому я не вижу повода для "нахрена тут ассинхронность"


                  1. mbaladinsky
                    22.12.2021 00:52
                    -2

                    И да, вы как то меняете "вектор атаки" ) Во первых - мы заговорили не об ассинхронности, а о многопоточности. Ассинхронный вызов - способ ее реализации, который я рекомендовал в качестве самого простого. Во вторых - "нормальный сервер должен вызывать операцию Accept в цикле" - и да. основной поток суть есть цикл в котором происходит ассинхронный вызов терминирования соединения. Вы отстаиваете свой вариант - я понимаю. Но я не увидел аргументов ЗА. Я только увидел непонятные ПРОТИВ ))


                    1. IvaYan
                      22.12.2021 10:41

                      А зачем вы вообще используете BeginAccept если вы тут же блокируете поток? В чем плюс асинхронного вызова, если вы по факту не используете здесь асинронность? Чтобы показать, что мы и так умеем?


                      1. mbaladinsky
                        22.12.2021 11:15

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


                  1. mbaladinsky
                    22.12.2021 11:39

                    А вот с таймаутом соглашусь - лишние ложные срабатывания. Убрал у себя, оставив WaitOne()


  1. fkafka
    21.12.2021 18:21

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

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

    Самый главный косяк статьи это прямое использование потоков и блокирующего I/O вместо тасков и асинхронных вызовов. Я понимаю, что это код учебный, но научить он может только тому, как в 2021 году такие вещи писать не следует.