Предисловие
Данная статья предназначена для таких же новичков как и я. Вся информация в этой статье основывается на моем опыте создания одного единственного веб-сервера, который был создан в рамкам учебного проекта на 3 курсе по специальности 09.02.07 СПО.
Веб-сервер
Прежде чем писать свой веб сервер, нам нужно понять что это и как он работает.
Веб-сервер - это сервер, который основывается на работе протоколов TCP/IP и HTTP для взаимодействия с клиентом. Основной задачей таких серверов это принимать входящие запросы на основе протокола HTTP.
Под веб-сервером подразумевают две вещи:
Программное обеспечение
Аппаратное обеспечение
В данной статье мы будем рассматривать веб-сервер, как программное обеспечение.
Веб-сервер работает благодаря такой архитектуре как клиент - сервер
Чтобы было понятнее, разобьем работу архитектуры по пунктам:
Формирование запроса клиентом
Отправка запроса на сервер
Получение запроса на сервере
Обработка запроса и формирование ответа
Отправка ответа клиенту
Но с помощью чего происходит общение клиента с сервером ? Как я уже говорил выше, веб-сервер пользуется двумя протоколами:
TCP/IP
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. Желательно, чтобы вы знали базу языка.
Вот мы и перешли от слов к практике, но перед этим, нам нужно определиться, с помощью чего мы будем писать наш веб-сервер ? Нашему вниманию представляются несколько классов которые могут нам в этом помочь:
Socket
TcpListener
Socket
- представляет реализацию сокетов Беркли на C#, эмпирическим путем было выяснено что использование данного варианта приносит более эффективный результат.
TcpListener
- прослушивает входящие TCP соединения по паре ip:port. Может от моей криворукости, больше чем в этом уверен, или от чего-то другого, у меня получалось так, что TcpListener
не совсем подходит для этой задачи, так как при отправке пакетов клиенту, некоторые файлы просто не приходили и каждый раз количество файлов разнилось.
В данной статье мы рассмотрим только вариант на основе класса Socket
, кому интересно знать, как реализовать веб-сервер на TcpListener
, то вот ссылка на статью другого автора.
Для начала мы должны создать 2 класса(они должны располагаться в двух новых файлах):
Server
- этот класс будет обозначать наш сервер и он будет принимать входящие подключения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
, который принимает следующие параметры:
byte[]
- массив байтовbyte[].Length
- длинна передаваемого массива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)
korsetlr473
19.12.2021 14:33+5это одно из самых жестких что я видел на хабре ...
s2d1ent Автор
20.12.2021 08:50Да соглашусь, статья не очень вышла, но я пытался, прошу прощения. Не стоило выкладывать её вовсе.
t3chn0ph0b
20.12.2021 11:48Спасибо за статью. Не знаю насколько она идеальна с точки зрения кода, но мне было интересно на нее посмотреть.
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.
OkunevPY
19.12.2021 19:36+3Более того. Это не поток, это Task которая в общем случае может и не быть отделтным потоком.
s2d1ent Автор
20.12.2021 08:43+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); }
navferty
19.12.2021 18:33+1Можно заменить
List
наConcurrentBag
например, тогда не понадобятся локи.Ну а в целом, по статье, обращусь к начинающим разработчикам: эта статья - хорошее упражнение для того, чтобы разобраться как работает этот стек под капотом, но если Вам нужно будет сделать веб-сервер для практических задач, используйте актуальную версию ASP.NET Core
s2d1ent Автор
20.12.2021 08:45Спасибо за правки, сделаю так как вы посоветовали. Извиняюсь, я ещё не настолько хорошо разбираюсь в создании ПО
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).
ivan_mariychuk
19.12.2021 19:24+8Немного просмотрел код на гитхабе.
Метод
GetSheet
классаClient
содержит кодlock (new object()) { ... }
Что по сути является бесполезным, потому что при одновременном выполнении данного кода двумя или более потоками, каждый поток будет создавать новый объект и устанавливать на нём блокировку, таким образом все потоки будут одновременно иметь доступ к критической секции. Вот пример использовании
lock
на MSDN.У части классов объявлен финализатор с кодом
~ClassName() { GC.Collect(2, GCCollectionMode.Forced); }
Зачем?
s2d1ent Автор
20.12.2021 08:48С лок да лоханулся и забыл его убрать. Честно скажу забыл зачем я его туда запихнул, спасибо что обратили внимание.
А по второму я собирался сделать деструкторы но в итоге не совсем понял как из тут можно сделать если тут в принципе есть мусорщик, и в итоге забвл удалить. Если Вам будет не трудно то не могли бы вы привести пример чтобы в будущем я совершал меньше ошибок.
Evengard
20.12.2021 12:41Использование финализаторов в C# - это очень редкое дело, и чаще всего используются при реализации интерфейса IDispose, для освобождения unmanaged ресурсов в случае, если объект не был корректно Dispose-нут.
В 99% случаев финализатор в C# просто не нужен - сборщик мусора всё сделает за вас. Это не C++.
OkunevPY
19.12.2021 19:41+2Вот удивительно. То-есть этот скажем так проект, порождён в рамках учебного курса, о чём написано в самом начале. Курс 3, то-есть как бы не новичок. Ошибка на ошибке. Отсюда вопрос, чему учат на курсах? Какие специалисты работают в IT? И много ли таких скажем так специалистов выходит на рынок труда?
s2d1ent Автор
20.12.2021 08:36Чему учат в колледже:
1 курс : офисный пакет по информатике и 10-11 класс по остальным предметам
2 курс: кое как прошли архитектуру аппаратных средств, изучение C#:Типы данных
Условные конструкции и циклы
Функции
Массивы
ООП
WinForms
Обучал нас преподаватель который работал только с аппаратной частью и последнее когда она видел код это был язык ассемблера и Си++, и было это давно. Читал я наперёд, понравилось и продолжил, и всё что наговнокодил это процесс самостоятельного обучения.
3 курс: изучаем в данный момент 1С Предприятия 8.3 ради демо экзамена, ибо площадка больше не по до что не приспособлена.Такой ответ вас удовлетворит?
HemulGM
20.12.2021 11:41+1Врядли когда-либо будут учить "хорошей архитектуре". Т.к. архитектура меняется, паттерны меняются или заменяются другими и т.д.
А тут мы видим просто отсутствие опыта. Не знание, что такое Task (внутри).
DistortNeo
20.12.2021 12:51У нас на факультете фундаментальный подход к изучению языков программирования: 1 курс — Паскаль и Ассеблер, 2 курс — C/C++, 3 курс — C#, 4-5 курсы — Python. По нашим меркам это совсем новичок.
EvgeniyRasyuk
20.12.2021 08:53-1Народ ну что вы набросились ? если есть предложеничя как сделать лучше - пишите реквесты в гит-хаб
Парень молодец, идеального кода не существует , есть тот который работает в заданных условиях.EvgeniyRasyuk
20.12.2021 08:55-1И не можете помочь сами - хоть покажите на идеальные аналогичные проекты с вашей точки зрения
s2d1ent Автор
20.12.2021 09:38+2Спасибо за поддержку. Но критика уместна, так как имеются косяки. Может быть местами как-то грубовато, но нужно научиться правильно воспринимать критику.
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>
BkmzSpb
20.12.2021 15:51+1Такое ощущение, что все что можно было сделать не так, было сделано не так, да еще и с особым упорством. Ощущение, что расчехлили какой-то античный фреймворк 3.5 и C# 4 (или какие там версии соответствуют друг другу), до тасок и асинков, и упорно пытались написать максимально неподдерживаемый и нечитаемый код.
Я даже не уверен что в этом виноват автор. Мне кажется это качество преподавания в вузе. Если так и есть, вот вам совет от незнакомца в интернетах: читайте книги, блогпосты, смотрите доклады с конференций, участвуйте в конференциях, следите за изменениями в языке и платформе, за best practices, и не бойтесь декомпилировать BCL что разобраться, что там под капотом. И если вы будете открывать свой код месячной давности и ваша первая мысль будет "что за косороукий долбоеб это написал? А, это же ьыл я" -- значит вы идете в правильном направлении.
s2d1ent Автор
20.12.2021 16:05Я обучаюсь не в вузе, а колледже на СПО 09.02.07 "Информационные системы и программирование". Как я уже писал одному из комментаторов, C# нас особо не учили, и тот говнокод что есть, был изучен самостоятельно. Так что получается что тут моя вина
BkmzSpb
20.12.2021 16:46C# нас особо не учили
Так что получается что тут моя вина
И да, и нет. Я понимаю что ждать от образовательных учереждений обучения актуальным знаниям и навыкам (особенно в РФ) -- бессмысленно, но все же хочется, чтобы человек, отучившийся на специальности, получал навыки, которые ему потом пригодятся.
Так вот, ваш самый ценный навык -- это способность искать информацию и обучаться. Вам зачем нужен был веб-сервер? Показать, что вы можете написать 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,
то все описанные выше навыки (и еще много чего другого) вам обязательно пригодятся.
PowerMetall
20.12.2021 17:34+1Да ладно вам, парни, все когда-то с чего-то начинали, в т. ч. и с подобных велосипедов с квадратными колесами ))
"Не стреляйте в пианиста, он играет как умеет" ©
P. S. Автор, вот образец файла gitignore под Visual Studio и C#, в частности:
https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
mbaladinsky
21.12.2021 18:21-2Для реализации многопоточности достаточно было использовать BeginAccept вместо Accept. Для реализации многопоточного вебсервера - HttpListener вместо socket.
А писать на сокетах в .net свой вебсервер имеет смысл если [есть много времени]/[отсутствуют доступные классы]/[функционала доступных недостаточно].DistortNeo
21.12.2021 18:58+2Для реализации многопоточности достаточно было использовать BeginAccept вместо Accept
Это устаревший и неудобный функционал. Сейчас в тренде таски и async-await парадигма.
А писать на сокетах в .net свой вебсервер имеет смысл если [есть много
времени]/[отсутствуют доступные классы]/[функционала доступных
недостаточно].Самый лучший способ разобраться, как работает технология — это написать велосипед и понабивать на нём шишек. Так что, несмотря на низкий уровень самой статьи, я целиком и полностью поддерживаю автора и готов ему помогать.
mbaladinsky
21.12.2021 23:21-1Функция асинхронного вызова с коллбеком в отдельном потоке "из коробки" на 100% удовлетворяющий условию задачи - "старый и неудобный функционал"?! Что же это за тренды такие, где изобретать велосипед, но следуя парадигме, важнее самого велосипеда ))
Но по поводу шишек соглашусь. Я это упомянул - если хотя бы одно из условий присутствуетDistortNeo
21.12.2021 23:29+1Потому что колбэки — это лапшеподобный код + отсутствие возможности использовать
CancellationToken
. К слову, сейчас под капотомBeginAccept
— это враппер надAcceptAsync
.mbaladinsky
21.12.2021 23:56-1"лапшеподобный код" в контексте Begin** операций для сокет-объектов - это какой стереотип ради парадигмы, уж простите. А CancelationToken - опять же шашечки или ехать? Если первое - то наверное это серьезный аргумент. А второе - вызовите EndAccept и обработайте исключение.
Я не против трендов. Я за парадигмы. Но прежде всего - я за здравый смысл. А в рекомендации использоваться BeginAccept он заключался в том, что это самый простой, самый стабильный способ получить функционал неблокирующего многопоточного обработчика входящих соединений. Если функционала не хватает - другой разговор. Но в обсуждаемой задаче его как раз "достаточно"mayorovp
22.12.2021 00:01Извините, а что в этом способе такого "стабильного"?
mbaladinsky
22.12.2021 00:13-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м году писать цикл, который не выглядит как цикл?
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 - Извините, я еще с редакторам не разобрался. Но так или иначе - вполне себе цикл, правда )
mayorovp
22.12.2021 00:10+1Вот вы и накосячили с циклом. А ещё говорили что-то про "стабильность"… Вы правда не видите в этом коде ошибки?
Тогда подскажу: поскольку блокирующей операции у вас в цикле нет — вы в цикле начнёте неограниченное число операций Accept пока не закончится память или другой ресурс.
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
Но повторюсь - вполне себе цикл
mayorovp
22.12.2021 00:29+1Ну классно. Теперь у вас и вовсе блокирующий вызов замаскированный написан. И нахрена тут вообще асинхронность-то?
Вы вообще в курсе что обращение к AsyncWaitHandle создаёт этот самый WaitHandle, который при нормальном сценарии никогда не создаётся?
Или что
Socket.EndAccept(Result)
ожидает завершения операции Accept, и ваш тайм-аут в итоге вообще ничего не делает?mbaladinsky
22.12.2021 00:46-2Блокирующий - waitone? Да, но он никуда не маскируется - он находится на своем месте там как раз для того, чтобы исключение недостатка памяти не было выброшено. Можно было бы использоваться какой то другой блокирующий объект - но для чего.
Что касается таймаута - он невостребован только практически. В теории, если что-либо не позволит вызвать EndAccept в коллбеке - он сократит блокирование основного потока на время таймаута, а вызов EndAccept инцициирует вызов колбека, в котором EndAccept вызовет обратываемое исключением..
Поэтому я не вижу повода для "нахрена тут ассинхронность"
mbaladinsky
22.12.2021 00:52-2И да, вы как то меняете "вектор атаки" ) Во первых - мы заговорили не об ассинхронности, а о многопоточности. Ассинхронный вызов - способ ее реализации, который я рекомендовал в качестве самого простого. Во вторых - "нормальный сервер должен вызывать операцию Accept в цикле" - и да. основной поток суть есть цикл в котором происходит ассинхронный вызов терминирования соединения. Вы отстаиваете свой вариант - я понимаю. Но я не увидел аргументов ЗА. Я только увидел непонятные ПРОТИВ ))
IvaYan
22.12.2021 10:41А зачем вы вообще используете BeginAccept если вы тут же блокируете поток? В чем плюс асинхронного вызова, если вы по факту не используете здесь асинронность? Чтобы показать, что мы и так умеем?
mbaladinsky
22.12.2021 11:15По какому факту она тут, простите, не используется? ))
Основной поток блокируется в цикле ожидании первого входящего соединения. Нет соединения - что ему еще делать?
Как только оно будет терминировано, срабатывает коллбек-обработчик, который сигнализирует основному потоку, блокировка снимается, снова вызывается BeginAccept и поток снова блокируется в ожидании входящего соединения.
mbaladinsky
22.12.2021 11:39А вот с таймаутом соглашусь - лишние ложные срабатывания. Убрал у себя, оставив WaitOne()
fkafka
21.12.2021 18:21Но такой способ не эффективен, так как он будет просто создавать новые потоки для обработки входящего соединения, тем самым тормозя работу сервера, ведь как никак потоки у нашего процессора не безграничны.
За такое с собеседования сразу отправят домой учиться. Потому что
Task.Run
точно так же берет потоки из пула (по умолчанию, хотя, вообще полностью зависит от выбранного scheduler-а).Самый главный косяк статьи это прямое использование потоков и блокирующего I/O вместо тасков и асинхронных вызовов. Я понимаю, что это код учебный, но научить он может только тому, как в 2021 году такие вещи писать не следует.
DeniSix
Ah shit, here we go again...
Ладно, если серьёзно, то никогда не используйте регулярки без таймаута. Об этом даже отдельное предупреждение в документации.
s2d1ent Автор
Косяк, признаю, можно было бы сделать, но тут нам всего пару строк нужно и этот сервер не будет много информации обрабатывать. В основном коде проект собираюсь полностью отказаться от регулярок