Создание многопоточного сервера на C#
Данная статья предназначена для таких же новичков как и я. Вся информация в этой статье основывается на моем опыте создания одного единственного веб-сервера, который был создан в рамкам учебного проекта на 3 курсе по специальности 09.02.07 СПО.
Веб-сервер
Прежде чем писать свой веб сервер, нам нужно понять что это и как он работает.
Веб-сервер — это сервер, который основывается на работе протоколов TCP/IP и HTTP для взаимодействия с клиентом. Основной задачей таких серверов это принимать входящие запросы на основе протокола HTTP.
Под веб-сервером подразумевают две вещи:
В данной статье мы будем рассматривать веб-сервер, как программное обеспечение.
Веб-сервер работает благодаря такой архитектуре как клиент — сервер
Рисунок 1 — Блок-схема архитектуры клиент-сервер
Чтобы было понятнее, разобьем работу архитектуры по пунктам:
Формирование запроса клиентом
Отправка запроса на сервер
Получение запроса на сервере
Обработка запроса и формирование ответа
Отправка ответа клиенту
Но с помощью чего происходит общение клиента с сервером ? Как я уже говорил выше, веб-сервер пользуется двумя протоколами:
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 — представляет реализацию сокетов Беркли на C#, эмпирическим путем было выяснено что использование данного варианта приносит более эффективный результат.
TcpListener — прослушивает входящие TCP соединения по паре ip:port. Может от моей криворукости, больше чем в этом уверен, или от чего-то другого, у меня получалось так, что TcpListener не совсем подходит для этой задачи, так как при отправке пакетов клиенту, некоторые файлы просто не приходили и каждый раз количество файлов разнилось.
В данной статье мы рассмотрим только вариант на основе класса Socket , кому интересно знать, как реализовать веб-сервер на TcpListener , то вот ссылка на статью другого автора.
Для начала мы должны создать 2 класса(они должны располагаться в двух новых файлах):
Server — этот класс будет обозначать наш сервер и он будет принимать входящие подключения
Client — этот класс будет обозначать нашего клиента, в этом классе будет проходить вся обработка запроса
Начнем заполнять класс Server . Для начала мы должны добавить в наш класс библиотеки, которые нам понадобятся:
Затем в классе мы должны создать переменные которыми будем оперировать:
Теперь создадим конструктор для нашего класса. Так как Socket работает по ip:port, то и наш конструктор будет принимать первым аргументом ip, а вторым port:
Ip мы будем задавать в виде строки и преобразовать его к классу IPEndPoint через класс IPAddress . Порт самое простое, просто обычное число типа int . Думаю самое непонятное для вас сейчас, это конструктор класса Socket :
AddressFamily – перечисление, которое обозначает то, с какой версией ip адресов мы будем работать. InterNetwork говорит о том что мы используем IPv4.
SocketType – перечисление, обозначает тип сокета и какое подключение будет устанавливаться. В нашем случаем мы будем работать с типом подключения Stream .
ProtocolType – перечисление, обозначает то, какой тип подключений мы будем принимать. Tcp , означает то что мы будем работать с протоколом TCP.
После конструктора нам стоит создать две функции, первая будет инициализировать работу нашего сервера, а вторая останавливать его соответственно:
Условие внутри функции проверяет, выключен ли сервер. Если он выключен, то мы можем запустить наш сервер. Это нужно для того, чтобы не было конфликта у сокета. Если мы попытаемся запустить второй сокет с тем же ip и port то вылезет ошибка.
После мы заполняем наше условие, если сервер выключен то:
Функция Bind класса Socket означает, что слушатель будет работать по определенному ip:port, который мы передаем в эту функцию.
Функция Listen начинает прослушивание, как аргумент мы передаем в нее переменную типа int , который означает возможное кол-во клиентов в очереди на подключение к сокету.
Теперь приступим к реализации многопоточности. Делать мы ее будем на основе такого класса как ThreadPool . Нет, конечно можно было сделать проще:
Но такой способ не эффективен, так как он будет просто создавать новые потоки для обработки входящего соединения, тем самым тормозя работу сервера, ведь как никак потоки у нашего процессора не безграничны. Поэтому мы берем и вставляем этот кусок кода в наше условие(после Active = true ):
ThreadPool.QueueUserWorkItem(WaitCallback, object) — добавляет в очередь функции, которые должны выполниться
WaitCallback(ClientThread) — принимает функцию и возвращает ответ о ее выполнении
Listener.AcceptTcpClient() — аргумент, который будет передаваться в функцию
Функция будет циклически прослушивать входящие соединения. Listener.Accept() будет временно останавливать цикл, до тех пор, пока не придет запрос на подключение.
Теперь перейдем к нашей функции остановки сервера:
В ней мы пишем условие, обратное тому которое было в Start , т.е тут мы должны проверять включен ли сервер.
Функцией Close класса Socket мы прекращаем прослушивание. Затем мы меняем значение переменной Active на false .
Думаю по функции Start вы заметили, что там присутствовала такая функция как ThreadClient , пришло время создать и ее. Она будет отвечать за создание нового клиента, который подключается к нашему серверу:
Так как делегат WaitCallback требует, чтобы аргументом являлся простой тип object , то функция соответственно будет тоже принимать тип object , который мы будем не явным образов преобразовывать в класс Socket .
Пришло время и для описания класса Client . Для начала подключим нужные нам библиотеки в файле:
Но прежде чем описывать наш класс Client , давайте создадим структуру, с помощью которой мы будем парсить наши HTTP заголовки:
Данная структура будет хранить значения наших HTTP заголовков:
Method — хранит метод, с помощью которого делается запрос
RealPath – хранит полный путь до файла на нашем сервере(пример: C:\Users\Public\Desktop\Server\www\index.html)
File — хранит не полный путь до файла(пример: \www\index.html)
Теперь давайте создадим саму функцию, которая будет парсить заголовки:
Она будет возвращать саму структуру, тогда объявление структуры будет выглядеть так:
Теперь опишем тело функции:
Объяснять принцип работы регулярных выражений я не буду, поэтому в конце статьи есть ссылка на документацию.
При присвоении значения переменной 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 :
Опять же, делаем это с помощью регулярных выражений.
Эта структура была сделана для удобства, так как когда мы будет парсить большое кол-во заголовков, то лучше если они будут храниться в одном месте.
Создадим в классе Client переменные:
У конструктора нашего класс будет всего 1 аргумент, который будет принимать Socket :
После в конструкторе, мы должны присвоить нашей переменной client наш аргумент и начать принимать данные от клиента:
Код представленный выше описывает то, как сервер принимает запросы от клиента:
data — массив который принимает байты
request — запрос в виде строки
client.Receive(data) — считывает приходящие байты и записывает их в массив.
После того как мы запишем принятые данные от клиента в массив байтов data , мы должны привести это в понятный вид, для этого мы воспользуемся классом Encoding , с помощью которого переведем байты в символы:
Теперь настало время проверок и парсинга наших заголовков.
Первое условие проверяет, пришел ли какой-то запрос вообще ? Если нет, то мы отсоединяем клиента и выходим из функции:
Если у нас все же что-то пришло, то время воспользоваться структурой и распарсить принятое сообщение и выводим сообщение о подключении в консоль:
Дальше мы проверяем нашу ссылку на наличие “..”, если это значение существует, т.е больше -1, то мы выводим сообщение об ошибке:
Ну и наконец последняя проверка в этой функции, если файл по указанному пути Headers.RealPath существует, то мы начинаем работать с этим файлом, иначе выводи ошибку:
Перед описанием основной функции GetSheet , которая будет возвращать пользователю ответ, мы создадим пару функций.
Первая функция SendError , она будет возвращать код ошибки пользователю:
html — представляет разметку нашей страницы
headers — представляет заголовки
data — массив байтов
client.Send(data, data.Length, SocketFlags.None); — отправляет данные клиенту
client.Close(); — закрывает нынешнее соединение
Теперь создадим функцию, которая будет возвращать тип контента, так как в данной статье представлена простая версия сервера, то мы ограничимся типами: text и image. Тип контента мы выводим для того, чтобы отправленный нами файл мог опознаться, записываем мы это значение в специальном заголовке Content-Type (пример: Content-Type: text/html ):
Данная функция принимает нашу структуру HTTPHeaders . Вначале мы воспользуемся функцией которая вернет нам расширение файла, а после начнем сверять его в условной конструкции switch . Если не один из вариантов перечисленных среди case не обнаружится.то мы вернем: applacation/unknown — это означает что файл не был опознан.
Теперь опишем нашу последнюю функцию GetSheet и можно будет тестировать наш сервер:
Данная функция как аргумент принимает нашу структуру HTTPHeaders . Сначала стоит обернуть функцию в блок обработки ошибок try catch , так как могут быть какие-либо ошибки:
Теперь опишем тело оператора try :
После того как мы переведем наши заголовки в массив байтов мы отправим их клиенту с помощью метода Send() класса Socket , который принимает следующие параметры:
byte[] — массив байтов
byte[].Length — длинна передаваемого массива
SocketFlags — перечисление, которое представляет поведение сокета при отправке и получении пакетов. Значение None обозначает что флаги не используются
И в самом конце нашего оператора мы передаем контент, который запрашивал клиент. Так как мы делали это с помощью FileStream , то сначала нам стоит: читать данные, записать их в массив байтов и отправить по сети.
В этот раз мы поставили SocketFlags.Partial . Это означает что в данном случаем, отправляется часть сообщения, так как не все байты файла могут поместятся в массив размером 1024. Но так же может и работать с SocketFlags.None
Так как у нас многопоточный сервер, который работает на ThreadPool, то для начала в файле который содержит функцию Main мы подключим библиотеку: System.Threading , а затем укажем минимальное кол-во потоков, которое он может использовать:
Первый параметр указывает на минимальное кол-во работающих потоков, а второй на минимальное кол-во асинхронно работающих потоков. Минимальное значение стоит всегда указывать 2, так как если указать 1, то основной поток будет блокироваться для обработки запроса.
Теперь зададим максимальные значения для нашего пула:
После чего мы просто инициализируем наш класс Server в функции и запускаем его:
Давайте создадим в папке, где располагается наш exe(пример пути. /project/bin/Debug/netx.x/ — где project имя вашего проекта) файл простой html файл:
После этого прописываем в адресной строке: http://127.0.0.1/index.html и проверяем результат. Нам должно вывести надпись Hello Server!, а так же должно вывести в консоли данные о нынешнем подключении.
Теперь Вы имеете представление о том, как с помощью сетевых сокетов на языке C# реализовать простой, многопоточный сервер. Для большего понятия как работают разные классы рекомендую почитать документацию:
Благодарю за то что уделили моей статье внимание, надеюсь что если я где-то оказался не прав вы укажете мне на это в комментариях и поможете стать лучше.
Ссылка на сервер на GitHub, в данной версии сервера реализована поддержка php.