Pull to refresh

Многопоточный сервер на C# за 15 минут

Reading time 11 min
Views 331K
C# довольно простой и гибкий язык. Вместе с .NET поставляется довольно много уже готовых классов, что делает его еще проще. Настолько, что вполне можно написать простой многопоточный HTTP-сервер для отдачи статического содержимого всего за 15 минут. Можно было бы использовать уже готовый класс HttpListener и управиться еще быстрее, но цель этой статьи — показать, как вообще можно сделать нечто подобное в C#.

Для начала создадим новый консольный проект:
Copy Source | Copy HTML
  1. using System;
  2. using System.Collections.Generic;
  3. using System.Text;
  4.  
  5. namespace HTTPServer
  6. {
  7.     class Server
  8.     {
  9.         static void Main(string[] args)
  10.         {
  11.  
  12.         }
  13.     }
  14. }
  15.  

В .NET можно очень легко создать TCP-сервер при помощи класса TcpListener, чем мы и воспользуемся:
Copy Source | Copy HTML
  1. class Server
  2. {
  3.     TcpListener Listener; // Объект, принимающий TCP-клиентов
  4.  
  5.     // Запуск сервера
  6.     public Server(int Port)
  7.     {
  8.         // Создаем "слушателя" для указанного порта
  9.         Listener = new TcpListener(IPAddress.Any, Port);
  10.         Listener.Start(); // Запускаем его
  11.  
  12.         // В бесконечном цикле
  13.         while (true)
  14.         {
  15.             // Принимаем новых клиентов
  16.             Listener.AcceptTcpClient();
  17.         }
  18.     }
  19.  
  20.     // Остановка сервера
  21.     ~Server()
  22.     {
  23.         // Если "слушатель" был создан
  24.         if (Listener != null)
  25.         {
  26.             // Остановим его
  27.             Listener.Stop();
  28.         }
  29.     }
  30.  
  31.     static void Main(string[] args)
  32.     {
  33.         // Создадим новый сервер на порту 80
  34.         new Server(80);
  35.     }
  36. }

Если сейчас запустить приложение, то уже можно будет подключиться к порту 80 и… все. Соединение будет лишь простаивать впустую, так как отсутствует его обработчик и оно не закрывается со стороны сервера.
Напишем самый простой обработчик:
Copy Source | Copy HTML
  1. // Класс-обработчик клиента
  2. class Client
  3. {
  4.     // Конструктор класса. Ему нужно передавать принятого клиента от TcpListener
  5.     public Client(TcpClient Client)
  6.     {
  7.         // Код простой HTML-странички
  8.         string Html = "<html><body><h1>It works!</h1></body></html>";
  9.         // Необходимые заголовки: ответ сервера, тип и длина содержимого. После двух пустых строк - само содержимое
  10.         string Str = "HTTP/1.1 200 OK\nContent-type: text/html\nContent-Length:" + Html.Length.ToString() + "\n\n" + Html;
  11.         // Приведем строку к виду массива байт
  12.         byte[] Buffer = Encoding.ASCII.GetBytes(Str);
  13.         // Отправим его клиенту
  14.         Client.GetStream().Write(Buffer,  0, Buffer.Length);
  15.         // Закроем соединение
  16.         Client.Close();
  17.     }
  18. }

Чтобы передать ему клиента, нужно изменить одну строчку в классе Server:
Copy Source | Copy HTML
  1. // Принимаем новых клиентов и передаем их на обработку новому экземпляру класса Client
  2. new Client(Listener.AcceptTcpClient());

Теперь можно запустить программу, открыть в браузере адрес 127.0.0.1 и увидеть большими буквами «It works!»
Перед тем, как приступить к написанию парсера HTTP-запроса, сделаем наш сервер многопоточным. Для этого есть два способа: создавать вручную новый поток для каждого клиента или воспользоваться пулом потоков. У обоих способов есть свои преимущества и недостатки. Если создавать по потоку на каждого клиента, то сервер может не выдержать высокой нагрузки, но можно работать с практически неограниченным количеством клиентов одновременно. Если использовать пул потоков, то количество одновременно работающих потоков будет ограничено, но нельзя будет создать новый поток, пока не завершатся старые. Какой из способов вам больше подойдет, я не знаю, поэтому приведу пример обоих.
Напишем простую процедуру потока, которая будет лишь создавать новый экземпляр класса Client:
Copy Source | Copy HTML
  1. static void ClientThread(Object StateInfo)
  2. {
  3.     new Client((TcpClient)StateInfo);
  4. }

Для использования первого способа нужно заменить только содержимое нашего бесконечного цикла приема клиентов:
Copy Source | Copy HTML
  1. // Принимаем нового клиента
  2. TcpClient Client = Listener.AcceptTcpClient();
  3. // Создаем поток
  4. Thread Thread = new Thread(new ParameterizedThreadStart(ClientThread));
  5. // И запускаем этот поток, передавая ему принятого клиента
  6. Thread.Start(Client);

Для второго способа нужно проделать то же самое:
Copy Source | Copy HTML
  1. // Принимаем новых клиентов. После того, как клиент был принят, он передается в новый поток (ClientThread)
  2. // с использованием пула потоков.
  3. ThreadPool.QueueUserWorkItem(new WaitCallback(ClientThread), Listener.AcceptTcpClient());

Плюс надо установить максимальное и минимальное количество одновременно работающих потоков. Сделаем это в процедуре Main:
Copy Source | Copy HTML
  1. // Определим нужное максимальное количество потоков
  2. // Пусть будет по 4 на каждый процессор
  3. int MaxThreadsCount = Environment.ProcessorCount * 4;
  4. // Установим максимальное количество рабочих потоков
  5. ThreadPool.SetMaxThreads(MaxThreadsCount, MaxThreadsCount);
  6. // Установим минимальное количество рабочих потоков
  7. ThreadPool.SetMinThreads(2, 2);

Максимальное количество потоков должно быть не меньше двух, так как в это число входит основной поток. Если установить единицу, то обработка клиента будет возможна лишь тогда, когда основной поток приостановил работу (например, ожидает нового клиента или была вызвана процедура Sleep).
Итак, теперь переключимся целиком на класс Client начнем обрабатывать HTTP-запрос. Получим текст запроса от клиента:
Copy Source | Copy HTML
  1. // Объявим строку, в которой будет хранится запрос клиента
  2. string Request = "";
  3. // Буфер для хранения принятых от клиента данных
  4. byte[] Buffer = new byte[1024];
  5. // Переменная для хранения количества байт, принятых от клиента
  6. int Count;
  7. // Читаем из потока клиента до тех пор, пока от него поступают данные
  8. while ((Count = Client.GetStream().Read(Buffer,  0, Buffer.Length)) >  0)
  9. {
  10.     // Преобразуем эти данные в строку и добавим ее к переменной Request
  11.     Request += Encoding.ASCII.GetString(Buffer,  0, Count);
  12.     // Запрос должен обрываться последовательностью \r\n\r\n
  13.     // Либо обрываем прием данных сами, если длина строки Request превышает 4 килобайта
  14.     // Нам не нужно получать данные из POST-запроса (и т. п.), а обычный запрос
  15.     // по идее не должен быть больше 4 килобайт
  16.     if (Request.IndexOf("\r\n\r\n") >=  0 || Request.Length > 4096)
  17.     {
  18.         break;
  19.     }
  20. }

Далее осуществляем парсинг полученных данных:
Copy Source | Copy HTML
  1. // Парсим строку запроса с использованием регулярных выражений
  2. // При этом отсекаем все переменные GET-запроса
  3. Match ReqMatch = Regex.Match(Request, @"^\w+\s+([^\s\?]+)[^\s]*\s+HTTP/.*|");
  4.  
  5. // Если запрос не удался
  6. if (ReqMatch == Match.Empty)
  7. {
  8.     // Передаем клиенту ошибку 400 - неверный запрос
  9.     SendError(Client, 400);
  10.     return;
  11. }
  12.  
  13. // Получаем строку запроса
  14. string RequestUri = ReqMatch.Groups[1].Value;
  15.  
  16. // Приводим ее к изначальному виду, преобразуя экранированные символы
  17. // Например, "%20" -> " "
  18. RequestUri = Uri.UnescapeDataString(RequestUri);
  19.  
  20. // Если в строке содержится двоеточие, передадим ошибку 400
  21. // Это нужно для защиты от URL типа http://example.com/../../file.txt
  22. if (RequestUri.IndexOf("..") >=  0)
  23. {
  24.     SendError(Client, 400);
  25.     return;
  26. }
  27.  
  28. // Если строка запроса оканчивается на "/", то добавим к ней index.html
  29. if (RequestUri.EndsWith("/"))
  30. {
  31.     RequestUri += "index.html";
  32. }

Ну и наконец осуществим работу с файлами: проверим, есть ли нужный файл, определим его тип содержимого и передадим его клиенту.
Copy Source | Copy HTML
  1. string FilePath = "www/" + RequestUri;
  2.  
  3. // Если в папке www не существует данного файла, посылаем ошибку 404
  4. if (!File.Exists(FilePath))
  5. {
  6.     SendError(Client, 404);
  7.     return;
  8. }
  9.  
  10. // Получаем расширение файла из строки запроса
  11. string Extension = RequestUri.Substring(RequestUri.LastIndexOf('.'));
  12.  
  13. // Тип содержимого
  14. string ContentType = "";
  15.  
  16. // Пытаемся определить тип содержимого по расширению файла
  17. switch (Extension)
  18. {
  19.     case ".htm":
  20.     case ".html":
  21.         ContentType = "text/html";
  22.         break;
  23.     case ".css":
  24.         ContentType = "text/stylesheet";
  25.         break;
  26.     case ".js":
  27.         ContentType = "text/javascript";
  28.         break;
  29.     case ".jpg":
  30.         ContentType = "image/jpeg";
  31.         break;
  32.     case ".jpeg":
  33.     case ".png":
  34.     case ".gif":
  35.         ContentType = "image/" + Extension.Substring(1);
  36.         break;
  37.     default:
  38.         if (Extension.Length > 1)
  39.         {
  40.             ContentType = "application/" + Extension.Substring(1);
  41.         }
  42.         else
  43.         {
  44.             ContentType = "application/unknown";
  45.         }
  46.         break;
  47. }
  48.  
  49. // Открываем файл, страхуясь на случай ошибки
  50. FileStream FS;
  51. try
  52. {
  53.     FS = new FileStream(FilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
  54. }
  55. catch (Exception)
  56. {
  57.     // Если случилась ошибка, посылаем клиенту ошибку 500
  58.     SendError(Client, 500);
  59.     return;
  60. }
  61.  
  62. // Посылаем заголовки
  63. string Headers = "HTTP/1.1 200 OK\nContent-Type: " + ContentType + "\nContent-Length: " + FS.Length + "\n\n";
  64. byte[] HeadersBuffer = Encoding.ASCII.GetBytes(Headers);
  65. Client.GetStream().Write(HeadersBuffer,  0, HeadersBuffer.Length);
  66.  
  67. // Пока не достигнут конец файла
  68. while (FS.Position < FS.Length)
  69. {
  70.     // Читаем данные из файла
  71.     Count = FS.Read(Buffer,  0, Buffer.Length);
  72.     // И передаем их клиенту
  73.     Client.GetStream().Write(Buffer,  0, Count);
  74. }
  75.  
  76. // Закроем файл и соединение
  77. FS.Close();
  78. Client.Close();

Также в коде упоминалась пока не описанная процедура SendError. Напишем и ее:
Copy Source | Copy HTML
  1. // Отправка страницы с ошибкой
  2. private void SendError(TcpClient Client, int Code)
  3. {
  4.     // Получаем строку вида "200 OK"
  5.     // HttpStatusCode хранит в себе все статус-коды HTTP/1.1
  6.     string CodeStr = Code.ToString() + " " + ((HttpStatusCode)Code).ToString();
  7.     // Код простой HTML-странички
  8.     string Html = "<html><body><h1>" + CodeStr + "</h1></body></html>";
  9.     // Необходимые заголовки: ответ сервера, тип и длина содержимого. После двух пустых строк - само содержимое
  10.     string Str = "HTTP/1.1 " + CodeStr + "\nContent-type: text/html\nContent-Length:" + Html.Length.ToString() + "\n\n" + Html;
  11.     // Приведем строку к виду массива байт
  12.     byte[] Buffer = Encoding.ASCII.GetBytes(Str);
  13.     // Отправим его клиенту
  14.     Client.GetStream().Write(Buffer,  0, Buffer.Length);
  15.     // Закроем соединение
  16.     Client.Close();
  17. }

На этом написание простого HTTP-сервера окончено. Оно работает в несколько потоков, отдает статику, имеет простую защиту от плохих запросов и ругается на отсутствующие файлы. На все это можно навесить дополнительные примочки: возможность конфигурирования, обработку доменов, изменение адресов наподобие mod_rewrite, даже поддержку CGI. Но это будет уже совсем другая история :-)

Исходник (через ThreadPool)
Исходник (через Thread)
Архив с исходником (через ThreadPool, вариант через Thread закомментирован)
Архив с откомпилированной версией (через ThreadPool)
Tags:
Hubs:
+60
Comments 72
Comments Comments 72

Articles