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