Как написать свой веб сервер

Уровень сложности
Простой

Время на прочтение
10 мин

Количество просмотров 20K

Предисловие

Данная статья предназначена для таких же новичков как и я. Вся информация в этой статье основывается на моем опыте создания одного единственного веб-сервера, который был создан в рамкам учебного проекта на 3 курсе по специальности 09.02.07 СПО.

Веб-сервер

Прежде чем писать свой веб сервер, нам нужно понять что это и как он работает.

Веб-сервер — это сервер, который основывается на работе протокола HTTP для взаимодействия с клиентом.

Под сервером подразумевают две вещи:

  1. Программное обеспечение — nginx, apache

  2. Аппаратное обеспечение — просто собранный компьютер

В данной статье мы будем рассматривать веб-сервер, как программное обеспечение.

Веб-сервер работает благодаря такой архитектуре как клиент — сервер

Рисунок 1 - Блок-схема архитектуры клиент-сервер

Рисунок 1 — Блок-схема архитектуры клиент-сервер

Чтобы было понятнее, разобьем работу архитектуры по пунктам:

  1. Формирование запроса клиентом

  2. Отправка запроса на сервер

  3. Получение запроса на сервере

  4. Обработка запроса и формирование ответа

  5. Отправка ответа клиенту

Как уже было сказано мною выше общение клиента с веб-сервером происходит за счет протокола HTTP.

HTTP(Hyper Text Transfer Protocol) — протокол прикладного уровня передачи данных в основном HTML, но в настоящее время используется для передачи произвольных данных.

Компьютер, который является сервером будет прослушивать приходящие по сети подключения по паре ip:port, к примеру: 127.0.0.1:80. Для протокола HTTP используют два порта 80 и 81.

Реализация веб сервера на C#

Писать наш веб-сервер мы будем на C# .Net Core. Желательно, чтобы вы знали базу языка.

Нам нужно определиться, с помощью чего мы будем писать наш веб-сервер ? Нашему вниманию представляются несколько классов которые могут нам в этом помочь:

  1. Socket — представляет реализацию сокетов Беркли на C#.

  2. TcpListener — прослушивает входящие TCP соединения по паре ip:port.

В данной статье мы рассмотрим только вариант на основе класса Socket, кому интересно знать как реализовать веб-сервер на TcpListener, то вот ссылка на статью другого автора.

Для начала мы должны создать 2 класса(они должны располагаться в двух новых файлах):

  1. Server — этот класс будет обозначать наш сервер и он будет принимать входящие подключения

  2. Client — этот класс будет обозначать нашего клиента, в этом классе будет проходить вся обработка запроса

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

using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using System.Net;
using System.Net.Sockets;

Затем в классе мы должны создать переменные которыми будем оперировать:

public EndPoint Ip; // представляет ip-адрес
public int Listen; // представляет наш port
public bool Active; // представляет состояние сервера, работает он(true) или нет(false)
private Socket _listener; // представляет объект, который ведет прослушивание
private volatile CancellationTokenSource _cts; // токен отменты, с помощью него будут останавливаться потоки при остановке сервера

Теперь создадим конструктор для нашего класса. Так как Socket работает по  ip:port, то и наш конструктор будет принимать первым аргументом ip, а вторым port:

public Server(string ip, int port)
{
    this.Listen = port;
    this.Ip = new IPEndPoint(IPAddress.Parse(ip), Listen);
    this._cts = new CancellationTokenSource();
    this._listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
}

Ip мы будем задавать в виде строки и преобразовать его к классу IPEndPoint через статическую функцию Parse(string) класса IPAddress. Порт самое простое, просто обычное число типа int. Думаю самое непонятное для вас сейчас, это конструктор класса Socket:

  • AddressFamily – перечисление, которое задает способ адресации. 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, который означает возможное кол-во клиентов в очереди на подключение к сокету.

Теперь приступим к реализации многопоточности. Делать мы ее будем на основе такого класса как Task.

// пока Active == true и _cts.Token.IsCancellationRequested != true
while (Active || !_cts.Token.IsCancellationRequested)
{
    try
    { 
        Socket listenerAccept = _listener.Accept();
        if(listenerAccept != null)
        {
          Task.Run(
            ()=>ClientThread(listenerAccept),
            _cts.Token
          );
        }
    }catch{}
}
  • Task.Run() — функция, которая запускает определенную функцию в новом потоке. Первым аргументом идет анонимная функция, вторым токен отмены. Если токен будет равен true, то цикл завершится, а так же завершится работа потока.

Функция будет циклически прослушивать входящие соединения. Listener.Accept() будет временно останавливать цикл, до тех пор, пока не придет запрос на подключение.

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

public void Stop()
{
    if (Active)
    {
        _cts.Cancel();
        _listener.Close();
        Active = false;
    }
    else
    {
        Console.WriteLine("Server was stopped");
    }
}

В ней мы пишем условие, обратное тому которое было в Start, т.е тут мы должны проверять включен ли сервер.

Функция _cts.Cancel() сообщает потокам о том, что пора завершать свою работу, функцией Close класса Socket мы прекращаем прослушивание входящего сетевого соединения, после чего меняем значение переменной Active на false.

Думаю по функции Start вы заметили, что там присутствовала такая функция как ThreadClient, пришло время создать и ее. Она будет отвечать за создание нового клиента, который подключается к нашему серверу:

public void ClientThread(Socket client)
{
    new Client(client);
}

Пришло время и для описания класса 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 – хранит полный путь до файла на нашем сервере(пример: /var/www/index.html)

  • File — хранит не полный путь до файла(пример: index.html)

Теперь давайте создадим саму функцию, которая будет парсить заголовки:

public static HTTPHeaders Parse(string headers) {}

Теперь опишем тело функции:

public static HTTPHeaders Parse(string headers)
{
    HTTPHeaders result = new HTTPHeaders();
    result.Method = Regex.Match(headers, @"Aw[a-zA-Z]+", RegexOptions.Multiline).Value;
    result.File = Regex.Match(headers, @"(?<=ws)([Wa-zA-Z0-9]+)(?=sHTTP)", RegexOptions.Multiline).Value;
    result.RealPath = $"{AppDomain.CurrentDomain.BaseDirectory}{result.File}";
    return result;
}

Объяснять принцип работы регулярных выражений я не буду, поэтому в конце статьи есть ссылка на документацию.

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

RealPath присваивается значение адреса директории(AppDomain.CurrentDomain.BaseDirectory) из которой был запущен данный проект и название нужного файла. В итоге мы получим что-то вроде этого «/var/www/index.html», тк RealPath, в нашем примере, равен «/var/www/», а File равен «index.html».

Теперь давайте напишем функцию, которая будет возвращать нам расширения нашего файла, назовем ее FileExtention:

public static string FileExtention(string file)
{
    return Regex.Match(file, @"(?<=[W])w+(?=[W]{0,}$)").Value;
}

Опять же, делаем это с помощью регулярных выражений. Созданная структура HTTPHeaders не подходит для хранения большого кол-ва заголовков, для этого лучше использовать ассоциативный массив(Dictionary<string,string>).

Создадим в классе Client переменные:

Socket _client; // подключенный клиент
HTTPHeaders Headers; // распарсенные заголовки

У конструктора нашего класс будет всего 1 аргумент, который будет принимать Socket:

public Client(Socket socket)

После в конструкторе, мы должны присвоить нашей переменной client наш аргумент и начать принимать данные от клиента:

_client = socket;
byte[] data = new byte[_client.ReceiveBufferSize]; // _client.ReceiveBufferSize - хранит значение полученных данных
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}]nFile: {Headers.File}nDate: {DateTime.Now}");

Дальше мы проверяем нашу ссылку на наличие “..”, если это значение существует, т.е больше -1, то мы выводим сообщение об ошибке:

if (Headers.RealPath.IndexOf("..") != -1)
{
    SendError(404);
    _client.Close();
    return;
}

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

if (File.Exists(Headers.RealPath))
{
    GetSheet();
}
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} OKnContent-type: text/htmlnContent-Length: {html.Length}nn{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()
{
    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;
}

Вначале мы воспользуемся функцией которая вернет нам расширение файла, а после начнем сверять его в условной конструкции switch. Если не один из вариантов перечисленных среди case не обнаружится.то мы вернем: applacation/unknown — это означает что тип контента не был опознан.

Теперь опишем нашу последнюю функцию GetSheet и можно будет тестировать наш сервер:

public void GetSheet(){}

Сначала стоит обернуть функцию в блок обработки ошибок try catch, так как могут быть какие-либо ошибки:

try
{
    // тело оператора try
}
catch (Exception ex)
{
    // просто выводит ошибку, после появления какой-либо новой ошибки
    // стоит создать новый блок обработки ошибок catch
    Console.WriteLine($"Exception: {ex}/nMessage: {ex.Message}");
}

Теперь опишем тело оператора try:

string contentType = GetContentType(head);    
FileStream fs = new FileStream(head.RealPath, FileMode.Open, FileAccess.Read, FileShare.Read);  
string headers = $"HTTP/1.1 200 OKnContent-type: {contentType}nContent-Length: {fs.Length}nn";  
// OUTPUT HEADERS    
byte[] data = Encoding.UTF8.GetBytes(headers);   
client.Send(data, data.Length, SocketFlags.None); 

После того как мы переведем наши заголовки в массив байтов мы отправим их клиенту с помощью метода Send() класса Socket, который принимает следующие параметры:

  1. byte[] — массив байтов

  2. byte[].Length — длинна передаваемого массива

  3. SocketFlags — перечисление, которое представляет поведение сокета при отправке и получении пакетов. Значение None обозначает что флаги не используются

И в самом конце нашего оператора мы передаем контент, который запрашивал клиент.

data = new byte[fs.Length];
int length = fs.Read(data, 0, data.Length);
_client.Send(data, data.Length, SocketFlags.None);

Переопределяем массив data, определяем его длину как длину файла и записываем в него весь файл целиком ну и в конце отправляем данные по сети тем же способом что и ранее.

Вот в принципе и все, куча некрасивых функций и это нечто готово, осталось его только запустить, для этого в функции Main прописываем следующее:

static void Main(string[] args)
{
    Server server = new Server("127.0.0.1", 80);
    server.Start();
}

После сохраняем все файлы, если вы этого еще не сделали, компилируем проект и запускаем приложение.

Создадим в директории нашего скомпилированного проекта файл «index.html» со следующей структурой:

<!DOCTYPE html>
<html>
<head>
</head>
<body>
<h1>Hello Server!</h1>
</body>
</html>

После этого прописываем в адресной строке: http://127.0.0.1/index.html и проверяем результат. Нам должно вывести надпись Hello Server!, а так же должно вывести в консоли данные о нынешнем подключении.

Теперь Вы имеете представление о том, как с помощью сетевых сокетов на языке C# реализовать простой, многопоточный сервер. Для большего понятия как работают разные классы рекомендую почитать документацию:

  • Socket

  • Регулярные выражения

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

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


Введение

Интернет играет в нашей жизни большую роль. Мы берем почту, узнаем свежую информацию, отлавливаем своих знакомых через ICQ… Но что ассоциируется со словом Интернет у большинства людей в первую очередь? Сайты. Многие при слове «Интернет» вспоминают свои любимые сайты, а некоторые (не слишком продвинутые) ставят знак равенства между WWW и Интернетом, хотя в последнем есть много другого интересного: email, IRC, p2p-сети, MUD’ы и так далее. Но World Wide Web играет доминирующую роль.

В основе WWW лежит протокол HyperText Transfer Protocol. Надо сказать, что HTTP может использоваться не только для передачи сайтов, но и для передачи всего чтобы то ни было. С помощью протокола HTTP мы можем скачать, например, недавно вышедший фильм или свежие mp3 :). В p2p-сетях HTTP-протокол применяется именно в этих целях.

В данном пособии мы рассмотрим, как написать простой веб-сервер. Я предполагаю, что вы знакомы с основами программирования winsock и умеете создавать сокеты и коннектиться к чему-нибудь :).

Основы HyperText Transfer Protocol

Идея HTTP довольно проста. Клиент шлет запрос серверу, тот рассматривает его и шлет соответствующий ответ. Ответом может быть запрошенный файл, сообщение о том, что такого файла на сервере нет или что-то еще. Примерная структура запроса следующая:

<метод> <запрашиваемый_ресурс> HTTP/1.1<n>
<заголовочное_поле>: <значение><n>
<заголовочное_поле>: <значение><n>
[..заголовочных полей может быть много..]<n>
<заголовочное_поле>: <значение><n>
<n>

<метод> — вид запроса. Основных два: GET и POST. Друг от друга они отличаются, главным образом, способом передачи дополнительной информации, отсылающейся вместе с запросом. В этом туториале мы рассмотрим только метод GET — функционально он похож на POST, но несколько проще. О методе POST я расскажу во второй части данного туториала, если, конечно, таковая вообще появится на свет :).

<n> — это два байта 0Dh, 0Ah, несомненно, хорошо знакомые всем ассемблерщикам :).

Таким образом, с методом мы определились. На данный момент запрос, который мы (в качестве клиента) должны будем послать серверу выглядит так:

GET <запрашиваемый_ресурс> HTTP/1.1<n>
<заголовочное_поле>: <значение><n>
<заголовочное_поле>: <значение><n>
[...]
<заголовочное_поле>: <значение><n>
<n>

Теперь нам нужно задать <запрашиваемый_ресурс>. Возьмем типичную ссылка на одном из лучших сайтов по программированию в Рунете WASM.RU (немного рекламы не помешает 🙂 ):

http://www.wasm.ru/article.php?article=1016002

Здесь «http://» указывает на то, что используется протокол HTTP, «www.wasm.ru» говорит о том, что необходимо подсоединиться к сайту www.wasm.ru, а все, что идет после знака вопроса — это дополнительные параметры страницы. Последние используются не всегда и не на всех сайтах.

Предположим, что мы подсоединились к www.wasm.ru и хотим получить article.php с необходимыми параметрами. Очевидно, что раз мы уже подсоединились к данному серверу и знаем, что необходимо использовать протокол HTTP, то пересылать «http://www.wasm.ru» не нужно, а значит, мы пошлем только «/article.php?article=1016002». Теперь наш запрос к серверу выглядит так:

GET /article.php?article=1016002 HTTP/1.1<n>
<заголовочное_поле>: <значение><n>
<заголовочное_поле>: <значение><n>
[...]
<заголовочное_поле>: <значение><n>
<n>

Теперь осталось разобраться с заголовочными полями. С их помощью клиент передает дополнительную информацию о себе или характере запроса. Например, довольно часто используемым заголовочным полем является ‘User-Agent’. Опера, скажем, шлет следующее:

User-Agent: Mozilla/4.0 (compatible; MSIE 5.0; Windows 98) Opera 6.02 [en]

Другим еще более важным полем является ‘Host’. В нем задается имя веб-сервера, с которого мы хотим получить документ. «Как же так», — можете сказать вы, — «Ведь мы же уже указывали имя веб-сервера, когда создавали сокет и коннектились к нему!» Да, это так. Когда мы коннектились к серверу, мы вызвали функцию gethostbyname, которой передали имя веб-сервера, а она возвратила нам адрес структуры, содержащей адрес сервера. Дело в том, что одному IP-адресу может соответствовать несколько имен сайтов, поэтому когда веб-сервер получает запрос, он должен знать к какому сайту обращается клиент (один веб-сервер может обслуживать тысячи различных сайтов, которые физически будут расположены на одной машине с одним адресом). Для этого мы пишем в ‘Host’ адрес сайта, к которому обращаемся:

Host: www.wasm.ru

Вот как теперь выглядит наш запрос:

GET /article.php?article=1016002 HTTP/1.1<n>
User-Agent: ManualSender/1.0 :)<n>
Host: www.wasm.ru<n>
<n>

Надо заметить, что хотя эти и другие заголовочные поля являются очень желательными, но, строго говоря, они не являются обязательными, то есть их может и не быть. Также я хочу обратить ваше внимание на то, что за последним заголовочным файлом следуют _два_ <n>, а не один. Это важно.

Теперь взглянем на все вышеизложенное с точки зрения веб-сервера (ведь мы же собирались писать веб-сервер, помните? 🙂 ). Он ждет, пока к нему не поступит запрос, обрабатывает его и посылает ответ, который выглядит примерно так:

HTTP/1.1 <код_ответа> <сообщение><n>
<заголовочное_поле>: <значение><n>
<заголовочное_поле>: <значение><n>
[..заголовочных полей может быть много..]<n>
<заголовочное_поле>: <значение><n>
<n>
<тело_документа>

Получив запрос, сервер должен выдать клиенту код ответа (это трехзначное число), а также сопутствующее ему сообщение. Например, если запрошенный документ был найден, первая строка ответа будет примерно следующей:

HTTP/1.1 200 Ok

Если коды ответов жестко заданы стандартом (200 означает, что запрос был успешно обработан и будет возвращен соответствующий документ/информация), то <сообщение> зависит только от вашей фантазии (конечно, по смыслу оно должно совпадать с <кодом_ответа>. Например, вместо «HTTP/1.1 200 Ok» мы можем послать «HTTP/1.1 200 You want it, you’ll get it» :).

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

Content-Type — этот заголовок задает тип отдаваемых данных. Главнейшие типы заданы в стандарте, и от того, что вы напишите в данном поле, зависит поведение клиента при приеме данных. Например, если вы посылаете браузеру пользователя html-файл, то необходимо указать тип данных ‘text/html’, иначе браузер может отобразить файл неправильно, либо вообще не будет его отображать, а предложит пользователю его скачать.

Content-Length — здесь указывается длина данных (не включая сам ответ с заголовочными данными) в байтах.

Исходя из вышесказанного, ответ сервера будет выглядеть примерно так:

HTTP/1.1 200 Ok<n>
Content-Type: text/html<n>
Content-Length: <длина_документа><n>
<n>
<тело_документа>

Если мы хотим отослать простой html-файл, то можем сократить ответ до следующего:

HTTP/1.1 200 Ok<n>
Content-Type: text/html<n>
<n>
<тело_документа>

В результате получаем следующее. Клиент шлет запрос на получение документа, лежащего, например, в корне сервера:

GET / HTTP/1.1<n>
User-Agent: ManualSender/1.0 :)<n>
Host: www.someoneserver.com<n>
<n>

Сервер получает этот запрос, обрабатывает и выдает корневую страницу:

HTTP/1.1 200 Ok<n>
Content-Type: text/html<n>
<n>
<html>
<head><title>Добро пожаловать на HTTP-сервер!</title></head>
<body>Вы находитесь на нашем http-сервере</body>
</html>

Код приложения

;---------------------------------------------------------------------
; http.asm
;---------------------------------------------------------------------

format PE console

entry start

; Подключаемые файлы

include '....includekernel.inc'
include '....includeuser.inc'
include '....includemacrostdcall.inc'
include '....includemacroimport.inc'
include 'winsock.inc'
include 'macros.inc'

; Используемые значения

WM_SOCKET = WM_USER+100
INBUF_LEN     = 100000

; Секции программы

section '.data' data readable writeable

include 'strings.inc'

  hInstance dd ?
  http_class dd ?
  hwnd dd ?
  msg dd ?
  sock dd ?
  rv dd ?
  wc WNDCLASS
  wsadata WSADATA
  saddr SOCKADDR_TCP
  iaddr SOCKADDR_TCP
  buf rb INBUF_LEN

section '.code' code executable readable

include 'http_window.asm'

start:

      invoke GetModuleHandle, 0
      mov [hInstance], eax

      ; Инициализируем сокеты
      invoke WSAStartup, 101h, wsadata
      test eax, eax
      jnz error.wsanotinit

      ; Создаем окно
      jmp make_http_window

      ; Цикл обработки сообщений
   .msg_loop:
        invoke GetMessage,msg,NULL,0,0
        test eax,eax
        jz .exit
        invoke TranslateMessage,msg
        invoke DispatchMessage,msg
        jmp .msg_loop

   .exit:
      ; Очищаем сокеты
      invoke WSACleanup
      invoke ExitProcess, 0

error:

   .recv_error:

      ccall [printf], _recv_error
      jmp start.exit

   .connection_was_closed:

      ccall [printf], _connection_was_closed
      jmp start.exit

   .wsanotinit:

      ccall [printf], _wsanotinit, eax, wsadata.size
      jmp start.exit

   .bind_error:

      ccall [printf], _bind_error
      jmp start.exit

   .listen_error:

      ccall [printf], _listen_error
      jmp start.exit

   .cant_createsocket:

      invoke WSAGetLastError
      ccall [printf], _cant_createsocket, eax
      jmp start.exit

   .cant_resolve:

      ccall [printf], _fmtstr, _cant_resolve
      jmp start.exit

   .select_error:
      invoke WSAGetLastError
      cinv printf, _select_error, eax
      jmp start.exit

   .accept_error:
      invoke WSAGetLastError
      cinv printf, _accept_error, eax
      jmp start.exit


section '.idata' import data readable writeable

  library kernel32, 'kernel32.dll', 
          winsock, 'ws2_32.dll', 
          msvcrt, 'msvcrt.dll', 
          user32, 'user32.dll'

  kernel32:
  import ExitProcess, 'ExitProcess', 
         GetModuleHandle, 'GetModuleHandleA', 
         GetLastError, 'GetLastError', 
         WriteFile, 'WriteFile', 
         GetStdHandle, 'GetStdHandle', 
         RtlZeroMemory, 'RtlZeroMemory', 
         lstrlen, 'lstrlen', 
         lstrcmp, 'lstrcmp'

  user32:
  import RegisterClass, 'RegisterClassA', 
         DefWindowProc, 'DefWindowProcA', 
         CreateWindowEx, 'CreateWindowExA', 
         DestroyWindow, 'DestroyWindow', 
         GetMessage, 'GetMessageA', 
         TranslateMessage, 'TranslateMessage', 
         DispatchMessage, 'DispatchMessageA', 
         MessageBox, 'MessageBoxA', 
         ShowWindow, 'ShowWindow'

  winsock:
  import WSAStartup, 'WSAStartup', 
         WSACleanup, 'WSACleanup', 
         socket, 'socket', 
         gethostbyname, 'gethostbyname', 
         connect, 'connect', 
         WSAGetLastError, 'WSAGetLastError', 
         recv, 'recv', 
         send, 'send', 
         htons, 'htons', 
         bind, 'bind', 
         listen, 'listen', 
         WSAAsyncSelect, 'WSAAsyncSelect', 
         accept, 'accept', 
         closesocket, 'closesocket'

  msvcrt:
  import printf, 'printf'

;---------------------------------------------------------------------
; http.asm
;---------------------------------------------------------------------
; Создание скрытого окна, которое будет получать сообщения от сокета
make_http_window:

     ; Регистрируем класс окна
     mov eax, [hInstance]
     mov [wc.hInstance], eax
     mov [wc.hIcon], 0
     mov [wc.hCursor], 0
     mov [wc.style], 0
     mov [wc.lpfnWndProc], http_window_proc
     mov [wc.cbClsExtra], 0
     mov [wc.cbWndExtra], 0
     mov [wc.hbrBackground], COLOR_BTNFACE+1
     mov [wc.lpszMenuName], 0
     mov [wc.lpszClassName], _http_window_class
     invoke  RegisterClass, wc
     mov [http_class], eax
     test eax, eax
     jnz .create_http_window
     cinv printf, _fmtstr, _class_not_registered
     jmp start.exit
  .create_http_window:
     ; Создаем окно
     invoke GetModuleHandle, 0
     invoke CreateWindowEx, NULL, [http_class], _http_window_name, 
                            WS_SYSMENU, 100, 100, 100, 100, NULL, NULL, 
                            [hInstance], 0
     mov [hwnd], eax
     test eax, eax
     jnz start.msg_loop
     cinv printf, _fmtstr, _window_not_created
     br
     invoke GetLastError
     cinv printf, _fmtnum, eax

     jmp start.exit

proc http_window_proc, hWnd, wmsg, wparam, lparam
     enter
     push ebx esi edi
     cmp [wmsg], WM_CREATE
     je .wmcreate
     cmp [wmsg], WM_DESTROY
     je .wmdestroy
     cmp [wmsg], WM_SOCKET
     je .wmsocket

  .defwndproc:
     invoke DefWindowProc, [hWnd], [wmsg], [wparam], [lparam]
     jmp .finish

  .wmcreate:

     cinv printf, _start_sockets
     ; Создаем сокет
     invoke socket, AF_INET, SOCK_STREAM, 0
     cmp eax, -1
     je error.cant_createsocket
     mov [sock], eax
     ; Указываем Windows, чтобы она извещала нас о входящих соединениях
     invoke WSAAsyncSelect, [sock], [hWnd], WM_SOCKET, FD_ACCEPT
     cmp eax, -1
     je error.select_error

     ; Получаем адрес localhost
     invoke gethostbyname, _localhost
     test eax, eax
     jz error.cant_resolve
     mov eax, [eax+12]
     mov eax, [eax]
     mov eax, [eax]

     ; Подготавливаем saddr
     mov [saddr.sin_addr], eax
     mov [saddr.sin_family], AF_INET
     invoke htons, 80
     mov [saddr.sin_port], ax
     ; Биндим сокет к локальному адресу
     invoke bind, [sock], saddr, saddr.size
     test eax, eax
     jnz error.bind_error

     ; Начинаем слушать порт
     invoke listen, [sock], 10
     test eax, eax
     jnz error.listen_error

     jmp .done

  .wmdestroy:
     jmp .done

  ; Сообщение от WSAAsyncSelect
  .wmsocket:
     mov eax, [lparam]
     and eax, 0FFFFh
     cmp eax, FD_ACCEPT
     je .fd_accept
     cmp eax, FD_READ
     je .fd_read
     cmp eax, FD_CLOSE
     je .fd_close
     jmp .done

  .fd_accept:
     invoke accept, [wparam], iaddr, 0
     cmp eax, -1
     je error.accept_error
     invoke WSAAsyncSelect, eax, [hwnd], WM_SOCKET, FD_READ + FD_CLOSE
     jmp .done

  .fd_read:
     ; Обнуляем буфер
     invoke RtlZeroMemory, buf, INBUF_LEN
     ; Читаем данные из сокета
     invoke recv, [wparam], buf, INBUF_LEN, 0
     push eax
     invoke GetStdHandle, -11
     pop edx
     invoke WriteFile, eax, buf, edx, rv, 0
     invoke send, [wparam], index, index_size, 0
     invoke closesocket, [wparam]
     jmp .done

  .fd_close:
     invoke closesocket, [wparam]
     jmp .done

  .done:
     xor eax, eax

  .finish:
     pop ebx esi edi
     return
;---------------------------------------------------------------------
; strings.inc
;---------------------------------------------------------------------
_http_window_class db 'http_server_window_class', 0
_http_window_name db 'Noname', 0
_fmtstr db '%s', 0
_fmtnum db '%d', 0
_br db 0Dh, 0Ah, 0
_window_not_created db 'Window is not created', 0
_class_not_registered db 'Class is not registered', 0
_localhost db 'localhost', 0
_cant_resolve db "Can't resolve host", 0
_cant_createsocket db "Can't create socket: %d", 0
_bind_error db 'Error binding to host', 0
_listen_error db 'Error starting listening', 0
_wsanotinit db 'WSA not initialized: %d, %d', 0
_connection_was_closed db 'Connection was closed', 0
_recv_error db 'Receiving error', 0
_select_error db 'WSAAsyncSelect error %d', 0
_accept_error db 'Accept error %d', 0
_start_sockets db 'Starting sockets', 0Dh, 0Ah, 0
_shutdown_sockets db 'Shutdowning sockets', 0
_inbound_connection db 'Inbound connection', 0
_method_get db 'GET', 0

index db 'HTTP/1.1 200 Ok', 0Dh, 0Ah
      db 'Content-type: text/html', 0Dh, 0Ah
      db 0Dh, 0Ah
      db '<html>', 0Dh, 0Ah
      db '<head>', 0Dh, 0Ah
      db '<title>Welcome to HTTP Server!</title>', 0Dh, 0Ah
      db '</head>', 0Dh, 0Ah
      db '<body>', 0Dh, 0Ah
      db '<h2>HTTP Server Online</h2>', 0Dh, 0Ah
      db 'Best http server in the world!', 0Dh, 0Ah
      db '</body>', 0Dh, 0Ah
      db '</html>'
index_size = $-index

Анализ кода

«Веб-сервер», чей исходный код был приведен выше, очень примитивен. Он умеет только принимать запрос и, не проверяя его на правильность, выдавать только приветственную html-страницу.

В http.asm все, я надеюсь, достаточно понятно. Мы инициализируем сокеты, создаем окно (для чего, я поясню позже), входим в цикл обработки сообщений, а перед тем, как завершить работу приложения, вызываем WSACleanup.

Самое интересное находится в http_window.asm. При обработке сообщения WM_CREATE мы создаем сокет:

     ; Создаем сокет
     invoke socket, AF_INET, SOCK_STREAM, 0
     cmp eax, -1
     je error.cant_createsocket
     mov [sock], eax

А потом вызываем следующую функцию:

     ; Указываем Windows, чтобы она извещала нас о входящих соединениях
     invoke WSAAsyncSelect, [sock], [hWnd], WM_SOCKET, FD_ACCEPT

Вот что об этой функции говорит Platform SDK:

[ начало описания функции WSAAsynctSelect ]

WSAAsyncSelect

Функция WSAAsyncSelect указывает Windows посылать сообщения о событиях, касающихся определенного сокета.

int WSAAsyncSelect(
  SOCKET <>,           
  HWND hWnd <>,          
  unsigned int wMsg <>,  
  long lEvent <>

);

Параметры:

s — Дескриптор сокета, о событиях, связанных с которым, будет сообщаться.

hWnd — Хэндл окна, которому будут посылаться эти сообщения.

wMsg — Сообщение, которое будет посылаться.

lEvent — Битовая маска, в которой задаются интересующие события.

Возвращаемые значения

Если вызов функции WSAAsyncSelect прошел успешно, возвращаемое значение будет равно нулю. В противном случае будет возвращено SOCKET_ERROR, а код ошибки можно будет получить, вызвав WSAGetLastError.

[ конец описания ]

Учтите, что после того, как WSAAsyncSelect отошлет вам сообщение о конкретном событии, связанном с сокетом, то пока вы не предпримите определенных действий, нового сообщения о таком же событии вы не получите. Например, если вы получили сообщение FD_ACCEPT (кто-то пытается законнектиться к вам), то сообщения о другой попытки коннекта вы не получите до тех пор, пока не вызовите функцию accept.

Мы задаем WM_SOCKET, определенное в http.asm, в качестве сообщение, которое будет присылаться Windows, когда произойдет интересующее нас сообщение. Необходимая информация будет находиться в wParam (дескриптор сокета, с которым связано событие) и в lParam (в нижнем слове — код события).

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

     ; Получаем адрес localhost
     invoke gethostbyname, _localhost
     test eax, eax
     jz error.cant_resolve
     mov eax, [eax+12]
     mov eax, [eax]
     mov eax, [eax]

     ; Подготавливаем saddr
     mov [saddr.sin_addr], eax
     mov [saddr.sin_family], AF_INET
     invoke htons, 80
     mov [saddr.sin_port], ax

Веб-сервер будет «висеть» на localhost’е (т.е. на локальной машине) на 80-ом порту, который является стандартным HTTP-портом. Если в адресе сайта прямо не указан порт, то браузер будет обращаться к 80-ому порту.

     ; Начинаем слушать порт
     invoke listen, [sock], 10
     test eax, eax
     jnz error.listen_error

Собственно, в данных строчках и содержится ответ на то, как сделать из приложения сервер (не обязательно web). Это делает функция listen.

[ начало описания функции listen ]

listen

Функция listen устанавливает сокет в состояние, в котором он слушает порт на предмет входящих соединений.

int listen(
  SOCKET <>,    
  int backlog <>

);

Параметры

s — Дескриптор сокета

backlog — Максимальное количество входящих соединений.

Возвращаемые значения

Если во время вызова не произошло никакой ошибки, listen возвратит ноль. В противном случае будет возвращено значение SOCKET_ERROR, а код ошибки можно будет получить с помощью функции WSAGetLastError.

[ конец описания ]

  ; Сообщение от WSAAsyncSelect
  .wmsocket:
     mov eax, [lparam]
     and eax, 0FFFFh
     cmp eax, FD_ACCEPT
     je .fd_accept
     cmp eax, FD_READ
     je .fd_read
     cmp eax, FD_CLOSE
     je .fd_close
     jmp .done

Было получено сообщение WM_SOCKET. Это значит, что произошло какое-то интересующее нас событие, связанное со слушающим сокетом.

  .fd_accept:
     invoke accept, [wparam], iaddr, 0
     cmp eax, -1
     je error.accept_error

Кто-то пытается подсоединиться к нашему веб-серверу. Вызываем функцию accept, чтобы разрешить входящее соединение.

[ начало описания функции accept ]

accept

Функция accept разрешает входящее соединение.

SOCKET accept(
  SOCKET s,
  struct sockaddr FAR *addr,
  int FAR *addrlen

);

Параметры

s — Дескриптор сокета, который ранее был помещен в состояние прослушивания с помощью функции listen. Фактическое соединение осуществляется с помощью сокета, который возвращается accept’ом.

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

addrlen — Необязательный указатель на двойное слово, которое содержит длину addr.

Возвращаемые значения

Если не произошло никакой ошибки, accept возвратит дескриптор нового сокета, через который и будет происходить соединение.

В противном случае будет возвращен INVALID_SOCKET, а код ошибки можно будет получить с помощью функции WSAGetLastError.

Переменная, на которую указывает addrlen, вначале содержит объем, занятый структурой, на которую указывает addr. По возвращении она будет содержать длину возвращенного адреса в байтах.

[ конец описания ]

     invoke WSAAsyncSelect, eax, [hwnd], WM_SOCKET, FD_READ + FD_CLOSE
     jmp .done

Соединение разрешено, и мы вызываем функцию WSAAsyncSelect, чтобы получить соответствующее уведомление, когда можно будет читать из сокета или он будет закрыт.

  .fd_read:
     ; Обнуляем буфер
     invoke RtlZeroMemory, buf, INBUF_LEN
     ; Читаем данные из сокета
     invoke recv, [wparam], buf, INBUF_LEN, 0
     push eax
     invoke GetStdHandle, -11
     pop edx
     invoke WriteFile, eax, buf, edx, rv, 0
     invoke send, [wparam], index, index_size, 0
     invoke closesocket, [wparam]
     jmp .done

Здесь все просто. Пришло сообщение о том, что можно читать из сокета, что мы и делаем. Все считанное мы выводим на консоль (интересно же, что клиент прислал). По-хорошему, здесь мы должны были бы провести синтаксический разбор запроса: выяснить, какой конкретно документ он хочет, отдать его, если такого документа нет, послать сообщение об ошибке и т.п. Но поскольку я минимализировал сервер почти до предела в плане функциональности :), ничего этого здесь нет. Вместо этого мы шлем клиенту приветственный html.

  .fd_close:
     invoke closesocket, [wparam]
     jmp .done

Если сокет был закрыт клиентом, то мы его тоже закрываем со своей стороны.

Дополнительная литература

Для получения подробной информации о протоколе HTTP я рекомендую вам обратиться к RFC 2068.

Заключение

Надеюсь, вы почерпнули из этого туториала какую-нибудь полезную информацию. Напоследок мне хотелось бы сказать, что хотя составлять конкуренцию таким грандам как Apache и IIS без веских на то оснований, возможно, и не стоит, тем не менее, собственный маленький веб-сервер может быть очень полезен. Мне, например, предложили встроить в него механизм самораспространения, «чтобы он сам приходил к людям на дом» и устанавливался «через упрощенную процедуру инсталляции» ака Outlook. Другим, менее чреватым в плане возможных последствий для автора, вариантом может быть создание утилиты удаленного (не обязательно скрытого) администрирования, причем в качестве клиента будет выступать браузер, что весьма удобно, так как отпадет надобность в написании сопутствующей серверу клиентской программы. Возможно, вы найдете еще какое-нибудь применение для http-сервера. Все в ваших руках!

(c) Aquila / Hi-Tech, 2002

[C] Aquila / WASM.RU

Источник: wasm.ru /05.08.2002/


Поделиться в соц сетях

Оглавление

  • Что определяет хорошего разработчика ПО?
  • Что же такое веб-сервер?
  • Как общаться с клиентами по сети
  • Простейший TCP сервер
  • Простейший TCP клиент
  • Заключение
  • Cсылки по теме

Лирическое отступление: что определяет хорошего разработчика?

Доктор Манхэттен что-то собирает силой мысли

Разработка ПО — это инженерная дисциплина. Если вы хотите стать действительно профессиональным разработчиком, то необходимо в себе развивать качества инженера, а именно: системный подход к решению задач и аналитический склад ума. Для вас должно перестать существовать слово магия. Вы должны точно знать как и почему работают системы, с которыми вы взаимодействуете (между прочим, полезное качество, которое находит применение и за пределами IT).

К сожалениею (или к счастью, ибо благоприятно складывается на уровне доходов тех, кто осознал), существует огромное множество людей, которые пишут код без должного понимания важности этих принципов. Да, такие горе-программисты могут создавать работающие до поры до времени системы, собирая их из найденных в Интернете кусочков кода, даже не удосужившись прочитать, как они реализованы. Но как только возникает первая нестандартная проблема, решение которой не удается найти на StackOverflow, вышеупомянутые персонажи превращаются в беспомощных жертв кажущейся простоты современной разработки ПО.

Для того, чтобы не оказаться одним из таких бедолаг, необходимо постоянно инвестировать свое время в получение фундаментальных знаний из области Computer Science. В частности, для прикладных разработчиков в большинстве случаев таким фундаментом является операционная система, в которой выполняются созданные ими программы.

Веб-фреймворки и контейнеры приложений рождаются и умирают, а инструменты, которыми они пользуются, и принципы, на которых они основаны, остаются неизменными уже десятки лет. Это означает, что вложение времени в изучение базовых понятий и принципов намного выгоднее в долгосрочной перспективе. Сегодня мы рассмотрим одну из основных для веб-разработчика концепций — сокеты. А в качестве прикладного аспекта, мы разберемся, что же такое на самом деле веб-сервер и начнем писать свой.

Что такое веб-сервер?

Начнем с того, что четко ответим на вопрос, что же такое веб-сервер?

В первую очередь — это сервер. А сервер — это процесс (да, это не железка), обслуживающий клиентов. Сервер — фактически обычная программа, запущенная в операционной системе. Веб-сервер, как и большинство программ, получает данные на вход, преобразовывает их в соответствии с бизнес-требованиями и осуществляет вывод данных. Данные на вход и выход передаются по сети с использованием протокола HTTP. Входные данные — это запросы клиентов (в основном веб-браузеров и мобильных приложений). Выходные данные — это зачастую HTML-код подготовленных веб-страниц.

Клиент общается с сервером по сети

На данном этапе логичными будут следующие вопросы: что такое HTTP и как передавать данные по сети? HTTP — это простой текстовый (т.е. данные могут быть прочитаны человеком) протокол передачи информации в сети Интернет. Протокол — это не страшное слово, а всего лишь набор соглашений между двумя и более сторонами о правилах и формате передачи данных. Его рассмотрение мы вынесем в отдельную тему, а далее попробуем понять, как можно осуществлять передачу данных по сети.

Как компьютеры взаимодействуют по сети

В Unix-подобных системах принят очень удобный подход для работы с различными устройствами ввода/вывода — рассматривать их как файлы. Реальные файлы на диске, мышки, принтеры, модемы и т.п. являются файлами. Т.е. их можно открыть, прочитать данные, записать данные и закрыть.

ls /dev показывает список устройств Linux

При открытии файла операционной системой создается т.н. файловый дескриптор. Это некоторый целочисленный идентификатор, однозначно определяющий файл в текущем процессе. Для того, чтобы прочитать или записать данные в файл, необходимо в соответсвующую функцию (например, read() или write()) передать этот дескриптор, чтобы четко указать, с каким файлом мы собираемся взаимодействовать.

int fd = open("/path/to/my/file", ...);

char buffer[1024];
read(fd, buffer, 1024);
write(fd, "some data", 10);

close(fd);

Очевидно, что т.к. общение компьютеров по сети — это также про ввод/вывод, то и оно должно быть организовано как работа с файлами. Для этого используется специальный тип файлов, т.н. сокеты.

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

Berkeley Sockets напоминают собой всем известную электрическую розетку

Т.к. видов межпроцессных взаимодействий с помощью сокетов множество, то и сокеты могут иметь различные конфигурации: сокет характеризуется семейством протоколов (IPv4 или IPv6 для сетевого и UNIX для локального взаимодействия), типом передачи данных (потоковая или датаграммная) и протоколом (TCP, UDP и т.п.).

Далее будет рассматриваться исключительно клиент-серверное взаимодействие по сети с использованием сокетов и стека протоколов TCP/IP.

Предположим, что наша прикладная программа хочет передать строку «Hello World» по сети, и соответствующий сокет уже открыт. Программа осуществляет запись этой строки в сокет с использованием функции write() или send(). Как эти данные будут переданы по сети?

Т.к. в общем случае размер передаваемых программой данных не ограничен, а за один раз сетевой адаптер (NIC) может передать фиксировнный объем информации, данные необходимо разбить на фрагменты, не превышающие этот объем. Такие фрагменты называются пакетами. Каждому пакету добавляется некоторая служебная информация, в частности содержащая адреса получателя и отправителя, и они начинают свой путь по сети.

Компьютер отправляет данные по сети разделив на фрагменты

Адрес компьютера в сети — это т.н. IP-адрес. IP (Internet Protocol) — протокол, который позволил объединить множество разнородных сетей по всеми миру в одну общую сеть, которая называется Интернет. И произошло это благодаря тому, что каждому компьютеру в сети был назначен собственный адрес.

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

TCP reassembly - восстанавливаем порядок пакетов на принимающей стороне

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

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

Этим занимается специальный протокол потоковой передачи данных — TCP.

TCP — (Transmission Control Protocol — протокол управления передачей) — один из основных протоколов передачи данных в Интернете. Используется для надежной передачи данных с подтверждением доставки и сохранением порядка пакетов.

TCP segment внутри IP пакета

В силу того, что передачей данных по сети по протоколу TCP на одном и том же компьютере может заниматься одновременно несколько программ, для каждого из таких сеансов передачи данных необходимо поддерживать свою последовательность пакетов. Для этого TCP вводит понятие соединения. Соединение — это просто логическое соглашение между принимающей и передающей сторонами о начальных и текущих значениях номеров пакетов и состоянии передачи. Соединение необходимо установить (обменявшись несколькими служебными пакетами), поддерживать (периодически передавать данные, чтобы не наступил таймаут), а затем закрыть (снова обменявшись несколькими служебными пакетами).

Итак, IP определяет адрес компьютера в сети. Но, в силу наличия TCP соединений, пакеты могут принадлежать различным соединениям на одной и той же машине. Для того, чтобы различать соединения, вводится понятие TCP-порт. Это всего лишь пара чисел (одно для отправителя, а другое для получателя) в служебной информации пакета, определяющая, в рамках какого соединения должен рассматриваться пакет. Т.е. адрес соединения на этой машине.

Простейший TCP сервер

Теперь перейдем к практике. Попробуем создать свой собственный TCP-сервер. Для этого нам понадобится модуль socket из стандартной библиотеки Python.

Основная проблема при работе с сокетами у новичков связана с наличием обязательного магического ритуала подготовки сокетов к работе. Но имея за плечами теоретические знания, изложенные выше, кажущаяся магия превращается в осмысленные действия. Также необходимо отметить, что в случае с TCP работа с сокетами на сервере и на клиенте различается. Сервер занимается ожиданием подключений клиентов. Т.е. его IP адрес и TCP порт известны потенциальным клиентам заранее. Клиент может подключиться к серверу, т.е. выступает активной стороной. Сервер же ничего не знает об адресе клиента до момента подключения и не может выступать инициатором соединения. После того, как сервер принимает входящее соединения клиента, на стороне сервера создается еще один сокет, который является симметричным сокету клиента.

Итак, создаем серверный сокет:

# python3

import socket

serv_sock = socket.socket(socket.AF_INET,      # задамем семейство протоколов 'Интернет' (INET)
                          socket.SOCK_STREAM,  # задаем тип передачи данных 'потоковый' (TCP)
                          proto=0)             # выбираем протокол 'по умолчанию' для TCP, т.е. IP
print(type(serv_sock))                         # <class 'socket.socket'>

А где же обещанные int fd = open("/path/to/my/socket")? Дело в том, что системный вызов open() не позволяет передать все необходимые для инициализации сокета параметры, поэтому для сокетов был введен специальный одноименный системный вызов socket(). Python же является объектно-ориентированным языком, в нем вместо функций принято использовать классы и их методы. Код модуля socket является ОО-оберткой вокрут набора системных вызовов для работе с сокетами. Его можно представить себе, как:

class socket:  # Да, да, имя класса с маленькой буквы :(
    def __init__(self, sock_familty, sock_type, proto):
      self._fd = system_socket(sock_family, sock_type, proto)

    def write(self, data):
        # на самом деле вместо write используется send, но об этом ниже
        system_write(self._fd, data)

    def fileno(self):
        return self._fd

Т.е. доступ к целочисленному файловому дескриптору можно получить с помощью:

print(serv_sock.fileno())  # 3 или другой int

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

serv_sock.bind(('127.0.0.1', 53210))  # чтобы привязать сразу ко всем, можно использовать ''

Вызов bind() заставляет нас указать не только IP адрес, но и порт, на котором сервер будет ожидать (слушать) подключения клиентов.

Далее необходимо явно перевести сокет в состояние ожидания подключения, сообщив об этом операционной системе:

backlog = 10  # Размер очереди входящих подключений, т.н. backlog
serv_sock.listen(backlog)

После этого вызова операционная система готова принимать подключения от клиентов на этом сокете, хотя наш сервер (т.е. программа) — еще нет. Что же это означает и что такое backlog?

Как мы уже выяснили, взаимодействие по сети происходит с помощью отправки пакетов, а TCP требует установления соединения, т.е. обмена между клиентом и сервером несколькими служебными пакетами, не содержащими реальных бизнес-данных. Каждое TCP соединение обладает состоянием. Упростив, их можно представить себе так:

СОЕДИНЕНИЕ УСТАНАВЛИВАЕТСЯ -> УСТАНОВЛЕНО -> СОЕДИНЕНИЕ ЗАКРЫВАЕТСЯ

Таким образом, параметр backlog определяет размер очереди для установленных, но еще не обработанных программой соединений. Пока количество подключенных клиентов меньше, чем этот параметр, операционная система будет автоматически принимать входящие соединения на серверный сокет и помещать их в очередь. Как только количество установленных соединений в очереди достигнет значения backlog, новые соединения приниматься не будут. В зависимости от реализации (GNU Linux/BSD), OC может явно отклонять новые подключения или просто их игнорировать, давая возможность им дождаться освобождения места в очереди.

Теперь необходимо получить соединение из этой очереди:

client_sock, client_addr = serv_sock.accept()

В отличие от неблокирующего вызова listen(), который сразу после перевода сокета в слушающее состояние, возвращает управление нашему коду, вызов accept() является блокирующим. Это означает, что он не возвращает управление нашему коду до тех пор, пока в очереди установленных соединений не появится хотя бы одно подключение.

На этом этапе на стороне сервера мы имеем два сокета. Первый, serv_sock, находится в состоянии LISTEN, т.е. принимает входящие соединения. Второй, client_sock, находится в состоянии ESTABLISHED, т.е. готов к приему и передаче данных. Более того, client_sock на стороне сервера и клиенсткий сокет в программе клиента являются одинаковыми и равноправными участниками сетевого взаимодействия, т.н. peer’ы. Они оба могут как принимать и отправлять данные, так и закрыть соединение с помощью вызова close(). При этом они никак не влияют на состояние слушающего сокета.

Пример чтения и записи данных в клиентский сокет:

while True:
    data = client_sock.recv(1024)
    if not data:
        break
    client_sock.sendall(data)

И опять же справедливый вопрос — где обещанные read() и write()? На самом деле с сокетом можно работать и с помощью этих двух функций, но в общем случае сигнатуры read() и write() не позволяют передать все возможные параметры чтения/записи. Так, например, вызов send() с нулевыми флагами равносилен вызову write().

Немного коснемся вопроса адресации. Каждый TCP сокет определяется двумя парами чисел: (локальный IP адрес, локальный порт) и (удаленный IP адрес, удаленный порт). Рассмотрим, какие адреса на данный момент у наших сокетов:

serv_sock:
  laddr (ip=<server_ip>, port=53210)
  raddr (ip=0.0.0.0, port=*)  # т.е. любой

client_sock:
  laddr (ip=<client_ip>, port=51573)  # случайный порт, назначенный системой
  raddr (ip=<server_ip>, port=53210)  # адрес слушающего сокета на сервере

Полный код сервера выглядит так:

# python3

import socket

serv_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM, proto=0)
serv_sock.bind(('', 53210))
serv_sock.listen(10)

while True:
    # Бесконечно обрабатываем входящие подключения
    client_sock, client_addr = serv_sock.accept()
    print('Connected by', client_addr)

    while True:
        # Пока клиент не отключился, читаем передаваемые
        # им данные и отправляем их обратно
        data = client_sock.recv(1024)
        if not data:
            # Клиент отключился
            break
        client_sock.sendall(data)

    client_sock.close()

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

telnet 127.0.0.1 53210
> Trying 192.168.0.1...
> Connected to 192.168.0.1.
> Escape character is '^]'.
> Hello
> Hello

Простейший TCP клиент

На клиентской стороне работа с сокетами выглядит намного проще. Здесь сокет будет только один и его задача только лишь подключиться к заранее известному IP-адресу и порту сервера, сделав вызов connect().

# python3

import socket

client_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client_sock.connect(('127.0.0.1', 53210))
client_sock.sendall(b'Hello, world')
data = client_sock.recv(1024)
client_sock.close()
print('Received', repr(data))

Заключение

Запоминать что-то без понимания, как это работает — злое зло не самый разумный подход для разработчика. Работа с сокетами тому отличный пример. На первый взгляд может показаться, что уложить в голове последовательность приготовления клиентских и серверных сокетов к работе практически не возможно. Это происходит из-за того, что не сразу понятен смысл производимых манипуляций. Однако, понимая, как осуществляется сетевое взаимодействие, API сокетов сразу становится прозрачным и легко оседает в подкорке. А с точки зрения полезности полученных знаний, я считаю. что понимание принципов сетевого взаимодействия жизненно важно для разработки и отладки действительно сложных веб-проектов.

Другие статьи из серии:

  • Пишем свой веб-сервер на Python: процессы, потоки и асинхронный I/O
  • Пишем свой веб-сервер на Python: протокол HTTP
  • Пишем свой веб-сервер на Python: стандарт WSGI
  • Пишем свой веб-сервер на Python: фреймворк Flask

Ссылки по теме

Справочная информация:

  • Сокеты
  • Веб-сервер
  • Протокол
  • Файловый дескриптор
  • Межпроцессное взаимодействие
  • Пакет
  • IP
  • TCP
  • Порт
  • Модуль socket

Литература

  • Beej’s Guide to Network Programming — отличные основы
  • UNIX Network Programming — продвинутый уровень

Мой вебинар на данную тему можно посмотреть на сайте GeekBrains.Ru.

Архитектура Web-приложений

Архитектура современных web-приложений

Архитектура современных web-приложений

Архитектура современных приложений состоит из отдельных модулей, как показано на рисунке выше. Эти модули часто называют Frontend и Backend. Frontend – это модуль, который отвечает за юзер-интерфейс и логику, которые предоставляется приложением при использовании. Так, например когда мы заходим в соцсети через браузер, мы взаимодействуем именно с FrontEnd-модулем приложения. То, как отображаются наши посты в виде сторисов или карточек, сообщения и другие активности реализуются именно в FrontEnd-модуле. А все данные, которые мы видим, хранятся и обрабатываются в Backend или серверной части приложения. Эти модули обмениваются между собой посредством разных архитектурных стилей: REST, GRPC и форматов сообщений – JSON и XML.

В этой статье мы напишем примитивную серверную часть социальной сети с использованием Spring Boot, запустим свой сервер, рассмотрим разные типы HTTP запросов и их применение.

Необходимое требование к читателю: умение писать на Java и базовые знания Spring Framework. Данная статья познакомит вас со Spring Boot и даст базовые понятия данного фреймворка.

Инициализация проекта

Чтобы создать Spring Boot проект, перейдем на страницу https://start.spring.io/ и выберем необходимые зависимости: в нашем случае Spring Web. Чтобы запустить проект, необходима минимальная версия Java 17. Скачиваем проект и открываем в любом IDE (в моем случае – Intellij Idea)

Инициализация проекта

Инициализация проекта

Spring Web – зависимость, которая предоставляет контейнер сервлетов Apache Tomcat (является дефолтным веб-сервером). Проще говоря, сервлеты – это классы, которые обрабатывают все входящие запросы.

Открываем проект и запускаем.

Запуск проекта

Запуск проекта

Мы видим, что проект запустился и готов обрабатывать запросы на порту 8080 – Tomcat started on port(s): 8080 (http).

Теперь создадим свой первый класс – GreetingController. Controller-классы ответственны за обработку входящих запросов и возвращают ответ.
Чтобы сделать наш класс Controller, достаточно прописать аннотацию @RestController. @RequestMapping указывает, по какому пути будет находиться определённый ресурс или выполняться логика.

Greeting Controller
        package io.proglib;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/greet")
public class GreetingController {

    @GetMapping
    public String greet() {
        return "Hello";
    }

}

    

Перезапускаем проект, и сервер готов уже обрабатывать наши запросы.
Открываем браузер по адресу http://localhost:8080/greet и получаем следующий вывод.

Ответ сервера

Ответ сервера

Если отправить запрос по адресу http://localhost:8080/ , мы получим ошибку, т. к. по этому пути не определены логика обработки запроса и ресурсы.

Request Params

При отправке запросов мы часто используем переменные в запросе, чтобы передавать дополнительную информацию или же делать запросы гибкими. Параметр в запросе передаётся в конце адреса (=url) сервера и указывается после вопросительного знака (=?).
Например, http://localhost:8080/greet?name=Alice. Параметр запроса является = name cо значением = Alice.

Чтобы обрабатывать переменную запроса, используется аннотация @RequestParam. Параметры запроса могут быть опциональными или же обязательными. @RequestParam("name") означает следующее: взять ту переменную из запроса, название которого равно name.

Метод с параметризированным запросом
        @RestController
@RequestMapping("/greet")
public class GreetingController {

    @GetMapping
    public String greet(@RequestParam("name") String name) {
        return "Hello, " + name;
    }

}

    

☕ Пишем свой первый сервер на Java и Spring Boot

Вдобавок, запрос может содержать несколько параметров.

Например, http://localhost:8080/greet/full?name=John&surname=Smith. Параметры выделяются знаком &. В этом запросе два параметра: name=John и surname=Smith.

Чтобы обработать каждый параметр запроса, нужно пометить каждую переменную @RequestParam.

        package io.proglib;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/greet")
public class GreetingController {

    @GetMapping
    public String greet(@RequestParam("name") String name) {
        return "Hello, " + name;
    }

    @GetMapping("/full")
    public String fullGreeting(@RequestParam("name") String name, 
                               @RequestParam("surname") String surname) {
        return "Nice to meet you, " + name + " " + surname;
    }

}

    

Параметризованный запрос с двумя параметрами

Параметризованный запрос с двумя параметрами

Path Variable

PathVariable по применению похож на @Request Param. @PathVariable также является параметром запроса, но используются внутри адреса запроса. Например,

RequestParamhttp://localhost:8080/greet/full?name=John&surname=Smith
PathVariablehttp://localhost:8080/greet/John. В этом случае John является PathVariable.
В запросе можно указывать несколько PathVariable, как и в случае RequestParam

        package io.proglib;

import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/greet")
public class GreetingController {

    @GetMapping
    public String greet(@RequestParam("name") String name) {
        return "Hello, " + name;
    }

    @GetMapping("/full")
    public String fullGreeting(@RequestParam("name") String name, @RequestParam("surname") String surname) {
        return "Nice to meet you, " + name + " " + surname;
    }

    @GetMapping("/{name}")
    public String greetWithPathVariable(@PathVariable("name") String name) {
        return "Hello, " + name;
    }

}

    

Чтобы протестировать, открываем браузер и переходим по адресам: http://localhost:8080/greet/John/Smith и http://localhost:8080/greet/John

☕ Пишем свой первый сервер на Java и Spring Boot

Запрос с двумя параметризованными PathVariable.

☕ Пишем свой первый сервер на Java и Spring Boot

HTTP-методы

Когда мы говорим о запросах, мы также подразумеваем HTTP-метод, который используется при отправке этого запроса. Каждый запрос представляет собой некий HTTP-метод. Например, когда мы переходим в браузере по адресу http://localhost:8080/greet/John/Smith, наш браузер отправляет GET-запрос на сервер.

Большая часть информационных систем обмениваются данными посредством HTTP-методов. Основными HTTP-методами являются – POST, GET, PUT, DELETE. Эти четыре запроса также называют CRUD-запросами.

  • POST-метод – используется при создании новых ресурсов или данных. Например, когда мы загружаем новые посты в соцсетях, чаще всего используется POST-запросы. POST-запрос может иметь тело запроса.
  • GET-метод – используется при получении данных. Например, при открытии любого веб-приложения, отправляется именно GET-запрос для получения данных и отображения их на странице. GET-запрос не имеет тела запроса.
  • PUT-метод – используется для обновления данных, а также может иметь тело запроса, как и POST.
  • DELETE-метод – используется для удаления данных.

Реализация основных методов

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

Для простоты User имеет только два поля: username и список постов posts, а сущность Post имеет поле description и imageUrl.

Сущность User:

        package io.proglib;

import java.util.ArrayList;
import java.util.List;

public class User {
    private String username;
    private List<Post> posts;

    public User() {
        posts = new ArrayList<>();
    }

    public User(String username, List<Post> posts) {
        this.username = username;
        this.posts = posts == null ? new ArrayList<>() : posts;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public List<Post> getPosts() {
        return posts;
    }

    public void setPosts(List<Post> posts) {
        this.posts = posts;
    }

}

    

Сущность Post:

        package io.proglib;

public record Post(
        String description,
        String imageUrl
) {
}

    

Создаем новый класс контроллер – UserActivityController, который будет обрабатывать наши запросы – POST, GET, PUT, DELETE.

Наш контроллер: UserActivityController.

Будем использовать список – List<User> users в качестве локальной базы данных, где будем хранить все наши данные.

UserActivityController
        package io.proglib;

import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/users")
public class UserActivityController {

      private final static List<User> users = new ArrayList<>();

}

    

POST-запрос: добавление нового пользователя

Чтобы указать, что метод принимает POST-запросы используем аннотацию – @PostMapping. Так как запрос имеет тело запроса, где мы передаем пользователя, нужно пометить переменную user аннотацией @RequestBody.

            @PostMapping("")
    public User addUser(@RequestBody User user) {
        users.add(user);
        return user;
    }
    

GET-запрос: получение пользователей

         @GetMapping("")
    public List<User> getUsers() {
        return users;
    }

    @GetMapping("/{username}")
    public User getUserByUsername(@PathVariable("username") String username) {
        return users.stream().filter(user -> user.getUsername().equals(username))
                .findFirst().get();
    }


    

@Getmapping("") указывает, что методы обрабатывают GET-запросы. Значение, которое передаётся внутри аннотации, является частью url или адреса. Например, запрос http://localhost:8080/users/baggio обработается методом getUserByUsername(), а запрос http://localhost:8080/users/ обработается методом http://localhost:8080/users.

PUT-запрос: обновление данных

            @PutMapping("/{username}")
    public Post update(@PathVariable("username") String username, @RequestBody Post post) {
        users.stream().filter(user ->
                        user.getUsername().equals(username))
                .findAny()
                .ifPresent(user -> user.getPosts().add(post));
        return post;
    }
    

@PutMapping("/{username}") указывает, что метод принимает PUT-запросы. В нашем примере в запросе мы передаем параметр запроса, а также тело запроса – post. Метод принимает – username, ищет юзера из списка с таким username и добавляем новый пост к его списку постов.

Delete-запрос: удаление данных

        @DeleteMapping("/{username}")
    public String deleteUser(@PathVariable("username") String username) {
        users.stream().filter(user ->
                        user.getUsername().equals(username))
                        .findAny()
                                .ifPresent(users::remove);
        return "User with username: " + username + " has been deleted";
    }
    

@DeleteMapping("/{username}") – указывает, что метод принимает DELETE-запросы.
Данный метод получает параметр username из запроса и удаляет из списка пользователя с таким username.

Запуск приложения и тестирование

Чтобы убедиться, что все работает, мы можем отправить каждый вид запроса и протестировать. Для этого нам необходим API-client, который может посылать запросы. В примерах я использую POSTMAN.

Post-запрос: создание нового пользователя.

Тело запроса отправляется в виде JSON.

☕ Пишем свой первый сервер на Java и Spring Boot

GET-запрос: получение пользователей

☕ Пишем свой первый сервер на Java и Spring Boot

PUT-запрос: обновление списка постов пользователя

☕ Пишем свой первый сервер на Java и Spring Boot

DELETE-запрос: удаление пользователя по username

☕ Пишем свой первый сервер на Java и Spring Boot

***

В этой статье мы рассмотрели архитектуру современных web-приложений, а также написали свою серверную часть приложения, получив поверхностные знания по Spring Boot, HTTP запросы и параметры запросов.

Ссылка на репозиторий

Исходный код можно найти по ссылке.

Материалы по теме

  • ☕ Сертификаты и тренинги для Java-разработчика
  • ☕🛣️ Дорожная карта Java-разработчика в 2023 году: путь с нуля до первой работы

Когда заходит речь о сервере, то под этим словом может подразумеваться либо программа (сервер), либо компьютер (сервер), на котором работает одна или несколько программ-серверов. Здесь, очевидно, рассматривается программа-сервер.

Сама по себе программа-сервер — это лишь часть распределенной программы, которую называют клиент-серверным приложением. Здесь слово «распределенная» означает, что функциональность программы разделена на две части: клиентскую и серверную. Клиентская часть (клиент) отправляет запросы, а серверная часть (сервер) отвечает на них. Клиент и сервер могут находиться как на одном и том же компьютере, так и на разных компьютерах в компьютерной сети.

В вебе клиент и сервер общаются посредством запросов от клиента и ответов клиенту от сервера в рамках протокола HTTP (если рассматривать общение по компьютерной сети на прикладном уровне сетевой модели). В вебе клиент и сервер вместе называют распределенным веб-приложением (также называют просто «веб-приложением»). Клиент могут называть веб-клиентом, а сервер — веб-сервером. Также веб-приложением могут называть как отдельно клиент, так и отдельно сервер. Еще веб-приложением могут называть часть кода сервера, которую пишет программист со стороны сервера, используя библиотеки (фреймворки), написанные другими. Так что с термином «веб-приложение» есть некоторая путаница. Нужно понимать, что он означает, ориентируясь по контексту, в котором он применяется.

Таким образом, нужно понимать, что для демонстрации работы веб-сервера понадобится и веб-клиент. У нас в качестве веб-клиента будет выступать браузер. Браузер и веб-сервер, который будет написан и запущен далее, составят распределенное веб-приложение.

* * *

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

Обычно в учебниках и руководствах, когда речь заходит о написании простейшего веб-сервера, используется IP-адрес «127.0.0.1». Это неспроста. Во-первых, нужно отметить, что тут подразумевается общение по сети в рамках протокола IP версии 4 (IPv4), это видно по строению указанного IP-адреса. Во-вторых, указанный IP-адрес входит в группу IP-адресов (подсеть) 127.0.0.0 – 127.255.255.255. Эта группа IP-адресов предназначена для локального использования, то есть для использования в рамках одного компьютера (IP-адреса этой группы не могут использоваться в интернете).

Таким образом, речь идет о написании и запуске локального веб-сервера. То есть обе части нашего распределенного веб-приложения (клиентская и серверная) будут находиться на одном и том же компьютере — нашем компьютере. Для общения между клиентом и сервером у нас, как я понимаю, даже не будет задействована сетевая карта нашего компьютера.

Почему в учебниках используется этот IP-адрес? Потому что локальный веб-сервер обычно используется для отладки распределенных веб-приложений. Это логично: пока наше распределенное веб-приложение не отлажено, нет смысла соваться в интернет или локальную сеть (в том числе и по соображениям безопасности). Кроме этого, для отладки распределенного веб-приложения в локальной сети или в интернете потребуется доступ к частям распределенного веб-приложения с обеих сторон (с двух компьютеров): и со стороны клиента, и со стороны сервера. Конечно же, удобнее отлаживать и тестировать распределенное веб-приложение на одном и том же нашем компьютере, так что логично, что мы будем использовать локальный веб-сервер, а сетевое общение будет происходить без выхода в сеть.

После отладки веб-сервера в качестве локального (с IP-адресом «127.0.0.1») этот же веб-сервер можно легко применить в локальной сети или в интернете, просто поменяв IP-адрес на нужный.

* * *

Текст программы (простейшего веб-сервера) на языке JavaScript я взял из статьи «Introduction to Node.js» (по-русски «Введение в среду выполнения Node.js»), которая является частью учебника по работе со средой выполнения «Node.js»:

https://nodejs.dev/learn/introduction-to-nodejs

Саму эту среду выполнения я установил ранее, об этом я написал несколько отдельных постов (например, вот этот). Напомню, у меня на компьютере установлена операционная система «Windows 10 Pro» (64-разрядная).

Итак, текст программы на языке JavaScript:

const http = require('http'); // включение модуля из стандартной библиотеки Node.js

const hostname = '127.0.0.1'; // веб-сервер работает локально
const port = 3000;
                              // веб-сервер возвратит такой ответ на любой запрос
const server = http.createServer((req, res) => {
  res.statusCode = 200;
  res.setHeader('Content-Type', 'text/plain');
  res.end('Hello Worldn');
});
                              // запуск веб-сервера
server.listen(port, hostname, () => {
  console.log(`Server running at http://${hostname}:${port}/`);
});

Программа никак не анализирует параметр req, содержащий объект с информацией о запросе от клиента. Ведь наш веб-сервер очень примитивен: на любой запрос он лишь возвращает ответ (объект в переменной res) с кодом состояния 200 (запрос обработан успешно) и с текстом «Hello World» в теле ответа.

Этот текст я поместил в текстовый файл с именем index.js (в кодировке UTF-8). В принципе, имя файла может быть и другим, это не имеет значения для работы веб-сервера. Имя файла понадобится при запуске программы в среде выполнения «Node.js». Этот файл я поместил в следующий каталог:

C:inetpubwwwrootnode

По идее, каталог тоже не имеет значения, наша программа может быть запущена из любого каталога. Главное — правильно указать путь к файлу с текстом программы при запуске ее в среде выполнения «Node.js».

Запустим нашу программу в среде выполнения «Node.js» через интерфейс командной строки. Я для этого использую программу «Windows PowerShell», но, конечно, это не единственный способ. Предварительно я захожу в нужный каталог в программе «Проводник Windows», а затем с помощью комбинации клавиши «Shift» и правой кнопки мыши (хотя тут нужно помнить, что функциональность кнопок мыши можно менять местами) открываю контекстное меню, в котором выбираю пункт «Открыть окно PowerShell здесь» (хотя тут нужно помнить, что этот пункт в контекстном меню может быть настроен по-другому). В результате этих манипуляций у меня открывается окно программы «Windows PowerShell», в котором не требуется переходить в другой каталог, мы уже и так находимся в том каталоге, где нужно.

Вводим следующую команду:

node index.js

После чего получаем следующее:

Наш веб-сервер запущен и работает, об этом свидетельствует сообщение, выдачу которого мы запланировали в своей программе:

Server running at http://127.0.0.1:3000/

Чтобы проверить работу нашего веб-сервера, откроем браузер (у меня — «Microsoft Edge» на движке «Chromium»). Откроем в браузере новую вкладку (по-английски «tab») и введем в адресной строке следующий адрес URL:

127.0.0.1:3000

Полностью (с указанием протокола — «HTTP») адрес URL вводить необязательно (хотя, можно ввести и полностью http://127.0.0.1:3000/, это не будет ошибкой), потому что браузер по умолчанию считает, что сетевое общение будет происходить по протоколу HTTP.

После введения этого адреса URL и нажатия клавиши «Enter» браузер (клиент нашего распределенного веб-приложения) отправит запрос с методом «GET» по протоколу HTTP на указанный IP-адрес и указанный порт. Наш веб-сервер (серверная часть нашего распределенного веб-приложения) вернет в ответ на запрос сообщение, в теле которого будет текст «Hello World». Итак, вот что у меня получилось:

Браузер может отображать не только HTML-страницы, но и другие веб-документы, в том числе текстовые (обычный текст). В нашем случае, как раз, браузер отобразил текстовый документ, состоящий из фразы «Hello World».

Таким образом, пока наш веб-сервер работает, мы можем сколько угодно раз открывать сколько угодно вкладок в браузере с указанным адресом URL и будем получать в ответ от веб-сервера текст «Hello World».

Как прекратить работу нашего веб-сервера? Если закрыть окно программы «Windows PowerShell», в котором мы запускали веб-сервер (или не закрывать, а ввести в этом окне комбинацию клавиш «Ctrl+C»), то работа нашей программы (простейшего веб-сервера) будет прекращена. После этого при вводе в адресную строку браузера адреса URL http://127.0.0.1:3000/ мы уже не получим в ответ текст с фразой «Hello World».

Предисловие

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

  • File — хранит не полный путь до файла(пример: wwwindex.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, @"Aw[a-zA-Z]+", RegexOptions.Multiline).Value;
    result.File = Regex.Match(headers, @"(?<=ws)([Wa-zA-Z0-9]+)(?=sHTTP)", RegexOptions.Multiline).Value;
    result.RealPath = $"{AppDomain.CurrentDomain.BaseDirectory}{result.File}";
    return result;
}

Объяснять принцип работы регулярных выражений я не буду, поэтому в конце статьи есть ссылка на документацию.

При присвоении значения переменной RealPath у объекта структуры result, я написал: AppDomain.CurrentDomain.BaseDirectory — это означает, что мы берем путь до нашего exe файла, пример: C:UsersPublicDesktopServer, а затем мы подставляем неполный путь до нашего файла:File, и тогда наш путь будет выглядеть так: C:UsersPublicDesktopServer + wwwindex.html = C:UsersPublicDesktopServerwwwindex.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} OKnContent-type: text/htmlnContent-Length: {html.Length}nn{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 OKnContent-type: {content_type}nContent-Length: {fs.Length}nn";  
// 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# реализовать простой, многопоточный сервер. Для большего понятия как работают разные классы рекомендую почитать документацию:

  • Socket

  • Thread

  • ThreadPool

  • Регулярные выражения

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

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

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

Содержание

  • Предисловие
  • Простой сервер
  • Конфигурируемый сервер
  • Передача файлов и параметров
  • Передеча параметров в GET запросе
  • Передача параметров в POST запросе
  • Сохраняем переданный файл
  • WSGI
  • Заставляем Python выполнить код на PHP
  • ИТОГИ

Предисловие

Когда-то давно, читая вопросы ребят, работающих с Django пришел к мысли о том, что большинство вопросов вызваны непониманием механизма общения браузера и сервера. Поэтому я решил написать статью, в которой попытаюсь пролить свет и объяснить как все происходит. В данной статье будет частично рассмотрен протокол http.

Простой сервер

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

Итак, приступим.

Создаем сервер:

#!-*-coding: utf8-*-
'''
Простой веб-сервер
'''

import socket
import select

HOST = ''  # Символическое имя. По умолчанию localhost
PORT = 8080  # Указываем непривилированный порт

if __name__ == "__main__":
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
        print('Socket created')

        # Связываем сокет с локальным хостом и портом
        sock.bind((HOST, PORT))

        print('Привязка сокета')

        # Слушаем сокет
        sock.listen(10)
        print('Слушаем сокет')

        inputs = [sock]
        outputs = [sock]
        num = 0
        # Теперь можем общаться с клиентами
        while True:
            # Ждем подключения клиентов
            reads, writes, excepts = select.select(inputs, outputs, inputs)
            for conn in reads:
                _conn, client_addr = conn.accept()
                with _conn as client_conn:
                    ip_addr, client_port = client_addr
                    print(f'Подключение с {ip_addr}:{client_port}')
                    data = client_conn.recv(1024)
                    print(data)
                    answermsg = (
                        'HTTP/1.1 200 OKnn'
                        '<html>'
                        '    <head>'
                        '        <title>Test done!!!</title>'
                        '    </head>'
                        '    <body>'
                        '        <b>'
                        f'            <i>ok {num}</i>'
                        '        </b>'
                        '    </body>'
                        '</html>'
                    )
                    client_conn.send(bytes(answermsg, 'utf-8'))
                    num += 1

Как вы видите, я использую модуль socket. Поэтому настоятельно рекомендую вам ознакомиться с этим модулем, так как здесь я буду объяснять лишь используемые мною свойства и методы модуля. В коде мы указываем, что сервер будет работать на localhost и 8080м порте (веб). Теперь, если мы запустим наш сервер и в браузере введем адрес localhost, то в консоли (или командной строке в Windows) увидим запрос, посланный браузером серверу. Здесь вы можете прочитать про заголовки запроса и ответа в http . Я в данной статье буду использовать лишь некоторые заголовки. А здесь список Content-Type/MIME type.

Запускаем наш скрипт (сервер)

После запуска скрипта в консоли увидим сообщения о том, что сервер запустился.

Теперь осталось открывать браузер и перейти на localhost. Как только мы напечатаем в адресной строке и нажмем «Ввод», то браузер инициализирует соединение и передаст некоторую информацию. Что это за информация мы увидим чуть позже. В консоль мы выведем всю информацию, которую передаст нам браузер.

Теперь давайте разберемся, что же это за информация и как ее обрабатывать. В консоли мы видим, что к серверу подключились с ip-адреса 127.0.0.1:39494. А вот ниже уже идут строки данных, полученные от браузера. Разбираемся что это за информация. Согласно спецификации http , первой строкой в запросе идет так называемая стартовая строка и формат ее такой:

где:

  • Метод — тип запроса, одно слово заглавными буквами. Список методов для версии 1.1 представлен в спецификации.
  • URI определяет путь к запрашиваемому документу.
  • Версия — пара разделённых точкой цифр. Например: 1.0.

Все 3 параметра разделены между собой одним пробелом.

Далее идет блок заголовков. Спецификация http регламентирует обязательно одну пустую строку после всех заголовков и перед началом тела самого сообщения.

Все заголовки разделяются на четыре основных группы:

  1. General Headers («Основные заголовки») — могут включаться в любое сообщение клиента и сервера
  2. Request Headers («Заголовки запроса») — используются только в запросах клиента;
  3. Response Headers («Заголовки ответа») — только для ответов от сервера;
  4. Entity Headers («Заголовки сущности») — сопровождают каждую сущность сообщения.

В стартовой строке мы видим, что браузер сообщает о методе GET (имена всех методов пишутся ПРОПИСНЫМИ БУКВАМИ). Обратился браузер к ресурсу по адресу / и версия HTTP = 1.1. Далее смотрим на общие заголовки. Название заголовка и его значение разделены символами «: «. Первым заголовком идет Host. Так как мы обращались к localhost, то здесь мы и видим наш адрес. Если когда-то ранее браузер уже обращался к ресурсу localhost (возможно запускали тестовый веб-сервер, на котором разрабатывали сайт) и сервер передал браузеру куки, то браузер передаст серверу уже имеющиеся куки, что мы и видим на скрине выше по наличию заголовка Cookie. Благодаря этому имеют место быть атаки на куки у пользователей. Так как в запросе мы больше ничего не передаем, то тело запроса осталось пустым. Чуть позже я покажу что будет, если передать нашему серверу файл или какие-то другие данные из формы в html странице.

После того, как сервер получил и обработал запрос от браузера, сервер должен отправить браузеру ответ. Ответ так же начинается со стартовой строки.

Стартовая строка ответа сервера имеет следующий формат:

HTTP/Версия КодСостояния Пояснение

где:

  • Версия — пара разделённых точкой цифр, как в запросе;
  • Код состояния (англ. Status Code) — три цифры. По коду состояния определяется дальнейшее содержимое сообщения и поведение клиента;
  • Пояснение (англ. Reason Phrase) — текстовое короткое пояснение к коду ответа для пользователя. Никак не влияет на сообщение и является необязательным.

Я воспользуюсь таким соглашением. Например, стартовая строка ответа сервера на предыдущий запрос может выглядеть так:

После стартовой строки должны идти заголовки ответа. Но сейчас никаких заголовков я передавать не буду. Я лишь ограничусь только стартовой строкой, в которой указываю версию, код ответа (200) и пояснение (ОК). После этого я делаю одну пустую строку (она обязательна), иначе не будет работать. Ну а дальше уже я передаю простенькую html страницу (переменная answermsg). Если сделали все правильно, то вы увидите в браузере слово ok, выделенное жирным шрифтом и курсивом.

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

Конфигурируемый сервер

Сейчас я покажу простой пример того, как можно сделать наш сервер конфигурируемым. Для этого я воспользуюсь модулем configparser. Для этого я создам простенький файл конфигурации:

Файл конфигаруции: conf/localhost.conf

[localhost]
Directory: ./sites

Первой строкой здесь будет являться хост, запрос для которого мы должны обработать. А во второй строке я указываю директорию, в которой лежит файл index.html.

Теперь код сервера выглядит так:

'''
Конфигурируемый веб-сервер
'''

import socket
import select
import configparser

from pathlib import Path
HOST = ''  # Символическое имя. По умолчанию localhost
PORT = 8080  # Указываем непривилированный порт

if __name__ == '__main__':
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
        print('Socket created')

        # Связываем сокет с локальным хостом и портом
        sock.bind((HOST, PORT))

        print('Привязка сокета')

        # Слушаем сокет
        sock.listen(10)
        print('Слушаем сокет')

        inputs = [sock]
        outputs = [sock]

        base_dir = Path(__file__).parent
        root_dir = base_dir.parent

        config = configparser.ConfigParser()
        config.read(
            root_dir.absolute() / Path('conf') / Path('localhost.conf')
        )

        # Теперь можем общаться с клиентами
        while True:
            # Ждем подключения клиентов
            reads, writes, excepts = select.select(inputs, outputs, inputs)
            for conn in reads:
                _conn, client_addr = conn.accept()
                with _conn as client_conn:
                    ip_addr, client_port = client_addr
                    print(f'Подключение с {ip_addr}:{client_port}')
                    data = client_conn.recv(1024)
                    data = data.decode('utf-8')
                    print(data)
                    answer = ''
                    data_list = data.split('rn')
                    header_line, *other_lines = data_list
                    headers = {}
                    headers['method'], headers['uri'], headers['version'] = (
                        header_line.split()
                    )
                    version = headers['version']
                    for header in other_lines:
                        if header and ':' in header:
                            header_name, header_value = map(
                                lambda x: x.strip(),
                                header.split(':', maxsplit=1)
                            )
                            headers[header_name] = header_value
                    if 'Host' in headers.keys():
                        host = headers['Host']
                        directory = Path(config.get(
                            host.split(':')[0], 'Directory'
                        ))
                        status_code = 404
                        message = 'Not Found'
                        answer_body = ''
                        if headers['uri'] in ['/', '/index', '/index.html']:
                            path = Path('index.html')
                        else:
                            path = Path().joinpath(
                                *headers['uri'].split('/')[1:]
                            )

                        file_path = base_dir / directory / path
                        if file_path.exists():
                            status_code = 200
                            message = 'OK'
                            answer_body = file_path.read_text()

                        answer_headers = (
                            f'{version} {status_code} {message}nnn'
                        )
                        answer = answer_headers + answer_body
                    client_conn.send(bytes(answer, 'utf-8'))

Здесь я на всякий случай проверяю наличие заголовка Host в запросе. Если такой есть, то читаю к какому хосту направлен запрос. Далее я смотрю по какому uri идет обращение. Если uri обращен к главной странице сайта, то как правило uri будет равен «/», либо будет содержать ключевое слово /index[.html|.php]. Если обращение идет к главной странице сайта, то стоит проверить существование файла index[.html|.php] в корневой директории сайта. Если такой сайт есть, тогда надо отдать страницу. Если обращение идет к какой-то конкретной странице, тогда, если файл страницы существует, отдаем эту страницу. Если файлов страниц не существует, мы должны выдать ошибку с кодом 404 и пояснением «Not Found». Такой статус у меня сделан по-умолчанию.

В папку sites я положил два файла:

Теперь, если запустить сервер и сделать запрос к localhost, то увидим в браузере страницу с содержанием из файла index.html.

Если сделать запрос к localhost/test, то увидим страницу с содержанием из файла test.

Передача файлов и параметров

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

Передеча параметров в GET запросе

Чтобы добавить параметры к GET запросу, нужно в конце URL-адреса поставить знак «?» и после него начинать задавать их по следующему правилу:

имя_параметра1=значение_параметра1&имя_параметра2=значение_параметра2&...

Разделителем между параметрами служит знак «&».

Если запустим текущий вариант сервера и обратиться по адресу localhost/test?te=15, то получим ошибку 404, так как файла с названием «test?te=15» не существует. Необходимо отделить название файла и параметры.

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

Пара слов о шаблоне для кода 404. Как видно из примера, шаблон страницы ответа тоже нужно создавать.

Теперь у меня получился вот такой код:

#!-*-coding: utf8-*-
'''
Конфигурируемый веб-сервер с параметрами
'''

import socket
import select
import configparser
from pathlib import Path
HOST = ''  # Символическое имя. По умолчанию localhost
PORT = 8080  # Указываем непривилированный порт

if __name__ == '__main__':
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
        print('Socket created')

        # Связываем сокет с локальным хостом и портом
        sock.bind((HOST, PORT))

        print('Привязка сокета')

        # Слушаем сокет
        sock.listen(10)
        print('Слушаем сокет')

        inputs = [sock]
        outputs = [sock]

        base_dir = Path(__file__).parent
        root_dir = base_dir.parent

        config = configparser.ConfigParser()
        config.read(
            root_dir.absolute() / Path('conf') / Path('localhost.conf')
        )

        # Теперь можем общаться с клиентами
        while True:
            # Ждем подключения клиентов
            reads, writes, excepts = select.select(inputs, outputs, inputs)
            for conn in reads:
                _conn, client_addr = conn.accept()
                with _conn as client_conn:
                    ip_addr, client_port = client_addr
                    print(f'Подключение с {ip_addr}:{client_port}')
                    data = client_conn.recv(1024)
                    data = data.decode('utf-8')
                    print(data)
                    answer = ''
                    data_list = data.split('rn')
                    header_line, *other_lines = data_list
                    headers = {}
                    headers['method'], headers['uri'], headers['version'] = (
                        header_line.split()
                    )
                    version = headers['version']
                    for header in other_lines:
                        if header and ':' in header:
                            header_name, header_value = map(
                                lambda x: x.strip(),
                                header.split(':', maxsplit=1)
                            )
                            headers[header_name] = header_value
                    if 'Host' in headers.keys():
                        host = headers['Host']
                        directory = Path(config.get(
                            host.split(':')[0], 'Directory'
                        ))
                        status_code = 404
                        message = 'Not Found'
                        answer_body = ''
                        if '?' in headers['uri']:
                            uri_file, uri_params = headers['uri'].split('?')
                        else:
                            uri_file, uri_params = headers['uri'], ''
                        if uri_file in ['/', '/index', '/index.html']:
                            path = Path('index.html')
                        else:
                            path = Path().joinpath(*uri_file.split('/')[1:])
                        file_path = base_dir / directory / path
                        if file_path.exists():
                            status_code = 200
                            message = 'OK'
                            answer_body = file_path.read_text().format(
                                data=uri_params.replace('&', 'n')
                            )
                        answer_headers = (
                            f'{version} {status_code} {message}n{headers}nn'
                        )
                        answer = answer_headers + answer_body
                    client_conn.send(bytes(answer, 'utf-8'))

В папке sites у меня лежит файл test, в который я вставил вот такой код:

<html>
    <head>
        <meta charset="utf-8">
        <title>Тестовая страница</title>
    </head>
    <body>
        {data}
    </body>
</html>

Поэтому в коде сервера я указал answer_body.format(data=uri_params.replace("&", "n")).

Таким образом я лишь просто вывожу переданные параметры.

Теперь, если запустить сервер и обратиться по адресу localhost/test с передачей различных параметров в запросе, эти параметры с их значениями будут выведены на странице в браузере.

Передача параметров в POST запросе

Теперь осталось разобраться с тем, как происходит передача параметров в POST-запросе. Для этого в sites/index.html я создам форму, которая будет передавать POST-запрос к странице /test:

<html>
<head>
    <meta charset="utf8">
    <title>New page</title>
</head>
<body>
    <b>
        <i>Это новая страница сайта</i>
    </b>
    <p>Заполните форму ниже</p>
    <form method="post" action="test">
        <input name="fullname">
        <input name="email" type="email">
        <button type="submit">Отправить</button>
    </form>
</body>
</html>

Сохраняем переданный файл

И вот здесь-то после нажатия на кнопку «Отправить» сервер у меня завершил процесс с ошибкой. Как оказалось, все дело в том, что параметры POST запроса передаются не в стартовой строке запроса, как в GET, а в теле сообщения запроса. Поэтому нужно переписать код так, чтобы обрабатывать текст сообщений запроса:

#!-*-coding: utf8-*-
'''
Конфигурируемый веб-сервер с сохранением файлов
'''

import socket
import select
import configparser
from pathlib import Path
HOST = ''  # Символическое имя. По умолчанию localhost
PORT = 8080  # Указываем непривилированный порт

if __name__ == '__main__':
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
        print('Socket created')

        # Связываем сокет с локальным хостом и портом
        sock.bind((HOST, PORT))

        print('Привязка сокета')

        # Слушаем сокет
        sock.listen(10)
        print('Слушаем сокет')

        inputs = [sock]
        outputs = [sock]

        base_dir = Path(__file__).parent
        root_dir = base_dir.parent

        config = configparser.ConfigParser()
        config.read(
            root_dir.absolute() / Path('conf') / Path('localhost.conf')
        )

        # Теперь можем общаться с клиентами
        while True:
            # Ждем подключения клиентов
            reads, writes, excepts = select.select(inputs, outputs, inputs)
            for conn in reads:
                _conn, client_addr = conn.accept()
                with _conn as client_conn:
                    ip_addr, client_port = client_addr
                    print(f'Подключение с {ip_addr}:{client_port}')
                    data = client_conn.recv(1024)
                    data = data.decode('utf-8')
                    print(data)
                    answer = ''
                    data_list = data.split('rn')
                    header_line, *other_lines = data_list
                    headers = {}
                    headers['method'], headers['uri'], headers['version'] = (
                        header_line.split()
                    )
                    version = headers['version']
                    msg_body = ''
                    for header in other_lines:
                        if header:
                            if ':' in header:
                                header_name, header_value = map(
                                    lambda x: x.strip(),
                                    header.split(':', maxsplit=1)
                                )
                                headers[header_name] = header_value
                                if (
                                    header_name == 'Content-Type'
                                    and 'boundary=' in header_value
                                ):
                                    boundary = (
                                        header_value.split('boundary=')[1]
                                    )
                                    boundary_data = data.split(boundary)[1:]
                                    for body_data in boundary_data:
                                        if 'filename' in body_data:
                                            data_file = (
                                                body_data.split('rnrn')[1]
                                            )
                                            filename = Path('test.txt')
                                            data_file_path = (
                                                base_dir / Path('upload') /
                                                filename
                                            )
                                            data_file_path.parent.mkdir(
                                                parents=True, exist_ok=True
                                            )
                                            data_file_path.write_bytes(
                                                data_file
                                            )
                            else:
                                msg_body += header + 'rn'
                    if 'Host' in headers.keys():
                        host = headers['Host']
                        directory = Path(config.get(host, 'Directory'))
                        status_code = 404
                        message = 'Not Found'
                        answer_body = ''
                        if '?' in headers['uri']:
                            uri_file, uri_params = headers['uri'].split('?')
                        else:
                            uri_file, uri_params = headers['uri'], msg_body
                        if uri_file in ['/', '/index', '/index.html']:
                            path = Path('index.html')
                        else:
                            path = Path().joinpath(*uri_file.split('/')[1:])
                        file_path = base_dir / directory / path
                        if file_path.exists():
                            status_code = 200
                            message = 'OK'
                            answer_body = file_path.read_text().format(
                                data=uri_params.replace('&', 'n')
                            )
                        answer_headers = (
                            f'{version} {status_code} {message}n{headers}nn'
                        )
                        answer = answer_headers + answer_body
                    client_conn.send(bytes(answer, 'utf-8'))

Обратите внимание, что названия передаваемых параметров есть ни что иное, как значение атрибута name тега <input>. Если не указать значение атрибута name, то в запрос данное поле не будет передано. Поэтому считайте, что этот атрибут обязательный, хотя на самом деле в html атрибуты не являются обязательными.

Теперь все в порядке. Процесс не завершается с ошибкой после нажатия на кнопку «Отправить» на клиенте. Вместо этого браузер переходит на страницу test и показывает параметры, которые мы передали в запросе POST.

Но теперь я хочу передать на сервер файл. Первое, что приходит на ум — это использовать поле типа file в форме:

<html>
<head>
    <meta charset="utf8">
    <title>New page</title>
</head>
<body>
    <b>
        <i>Это новая страница сайта</i>
    </b>
    <p>Заполните форму ниже</p>
    <form method="post" action="test">
        <input name="fullname">
        <input name="email" type="email">
        <input name="myfile" type="file">
        <button type="submit">Отправить</button>
    </form>
</body>
</html>

Теперь на странице test мы увидим название файла. НО… Это ведь не сам файл, а лишь его наименование. Как сделать так, чтобы передать содержимое указанного файла??? Ответ на этот вопрос здесь. Перепишем код страницы:

<html>
<head>
    <meta charset="utf8">
    <title>New page</title>
</head>
<body>
    <b>
        <i>Это новая страница сайта</i>
    </b>
    <p>Заполните форму ниже</p>
    <form method="post" action="test" enctype="multipart/form-data">
        <input name="fullname">
        <input name="email" type="email">
        <input name="myfile" type="file">
        <button type="submit">Отправить</button>
    </form>
</body>
</html>

Теперь вроде все в порядке. Но после запуска первое с чем я сталкиваюсь — это с размером передаваемого файла. В коде циклически читается 1024 байт, но файл, который я передаю, занимает порядка 10 кБ. В коде не происходит склейки новой порции данных со старыми при чтении данных. Исправляю:

#!-*-coding: utf8-*-
'''
Конфигурируемый веб-сервер с post-запросом
'''

import socket
import select
import configparser
from pathlib import Path
HOST = ''  # Символическое имя. По умолчанию localhost
PORT = 8080  # Указываем непривилированный порт

if __name__ == "__main__":
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
        print('Socket created')

        # Связываем сокет с локальным хостом и портом
        sock.bind((HOST, PORT))

        print('Привязка сокета')

        # Слушаем сокет
        sock.listen(10)
        print('Слушаем сокет')

        inputs = [sock]
        outputs = [sock]

        base_dir = Path(__file__).parent
        root_dir = base_dir.parent

        config = configparser.ConfigParser()
        config.read(
            root_dir.absolute() / Path('conf') / Path('localhost.conf')
        )

        # Теперь можем общаться с клиентами
        while True:
            # Ждем подключения клиентов
            reads, writes, excepts = select.select(inputs, outputs, inputs)
            for conn in reads:
                _conn, client_addr = conn.accept()
                with _conn as client_conn:
                    ip_addr, client_port = client_addr
                    print(f'Подключение с {ip_addr}:{client_port}')
                    data = client_conn.recv(1024)
                    data = data.decode('utf-8')
                    print(data)
                    answer = ''
                    data_list = data.split("rn")
                    header_line, *other_lines = data_list
                    headers = {}
                    headers["method"], headers["uri"], headers["version"] = (
                        header_line.split()
                    )
                    version = headers['version']
                    msg_body = ""
                    for header in other_lines:
                        if header:
                            if ":" in header:
                                header_name, header_value = map(
                                    lambda x: x.strip(),
                                    header.split(':', maxsplit=1)
                                )
                                headers[header_name] = header_value
                            else:
                                msg_body += header + "rn"
                    if "Host" in headers.keys():
                        host = headers["Host"]
                        directory = Path(config.get(host, "Directory"))
                        status_code = 404
                        message = "Not Found"
                        answer_body = ""
                        if "?" in headers["uri"]:
                            uri_file, uri_params = headers["uri"].split("?")
                        else:
                            uri_file, uri_params = headers["uri"], msg_body
                        if uri_file in ["/", "/index", "/index.html"]:
                            path = Path('index.html')
                        else:
                            path = Path().joinpath(*uri_file.split("/")[1:])
                        file_path = base_dir / directory / path
                        if file_path.exists():
                            status_code = 200
                            message = "OK"
                            answer_body = file_path.read_text().format(
                                data=uri_params.replace('&', 'n')
                            )
                        answer_headers = (
                            f"{version} {status_code} {message}n{headers}nn"
                        )
                        answer = answer_headers + answer_body
                    client_conn.send(bytes(answer, 'utf-8'))

Теперь можно увидеть, что после добавления атрибута enctype=»multipart/form-data» содержимое запроса изменилось и можно увидеть передаваемые байты файла с его названием.

Теперь в заголовке Content-Type помимо типа еще есть параметр boundary, который переводится как «граница». Эта граница между параметрами в теле сообщений. С помощью значения этого параметра я буду разделять передаваемые параметры и их значения. Я не стал усложнять код, поэтому у меня получился вот такой результат:

#!-*-coding: utf8-*-
'''
Конфигурируемый веб-сервер с post-запросом
'''

import socket
import select
import configparser
from pathlib import Path
HOST = ''  # Символическое имя. По умолчанию localhost
PORT = 8080  # Указываем непривилированный порт

if __name__ == "__main__":
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
        print('Socket created')

        # Связываем сокет с локальным хостом и портом
        sock.bind((HOST, PORT))

        print('Привязка сокета')

        # Слушаем сокет
        sock.listen(10)
        print('Слушаем сокет')

        inputs = [sock]
        outputs = [sock]

        base_dir = Path(__file__).parent
        root_dir = base_dir.parent

        config = configparser.ConfigParser()
        config.read(
            root_dir.absolute() / Path('conf') / Path('localhost.conf')
        )

        # Теперь можем общаться с клиентами
        while True:
            # Ждем подключения клиентов
            reads, writes, excepts = select.select(inputs, outputs, inputs)
            for conn in reads:
                _conn, client_addr = conn.accept()
                with _conn as client_conn:
                    ip_addr, client_port = client_addr
                    print(f'Подключение с {ip_addr}:{client_port}')
                    data = client_conn.recv(1024)
                    data = data.decode('utf-8')
                    print(data)
                    answer = ''
                    data_list = data.split("rn")
                    header_line, *other_lines = data_list
                    headers = {}
                    headers["method"], headers["uri"], headers["version"] = (
                        header_line.split()
                    )
                    version = headers['version']
                    msg_body = ""
                    for header in other_lines:
                        if header:
                            if ":" in header:
                                header_name, header_value = map(
                                    lambda x: x.strip(),
                                    header.split(':', maxsplit=1)
                                )
                                headers[header_name] = header_value
                            else:
                                msg_body += header + "rn"
                    if "Host" in headers.keys():
                        host = headers["Host"]
                        directory = Path(config.get(host, "Directory"))
                        status_code = 404
                        message = "Not Found"
                        answer_body = ""
                        if "?" in headers["uri"]:
                            uri_file, uri_params = headers["uri"].split("?")
                        else:
                            uri_file, uri_params = headers["uri"], msg_body
                        if uri_file in ["/", "/index", "/index.html"]:
                            path = Path('index.html')
                        else:
                            path = Path().joinpath(*uri_file.split("/")[1:])
                        file_path = base_dir / directory / path
                        if file_path.exists():
                            status_code = 200
                            message = "OK"
                            answer_body = file_path.read_text().format(
                                data=uri_params.replace('&', 'n')
                            )
                        answer_headers = (
                            f"{version} {status_code} {message}n{headers}nn"
                        )
                        answer = answer_headers + answer_body
                    client_conn.send(bytes(answer, 'utf-8'))

После запуска сервера и передачи текстового файла в папке со скриптом сервера у меня появился файл test.txt с содержимым передаваемого файла. Думаю теперь многим станет ясно, что очень важно правильно прочитать и обработать входящие данные от браузера.

Я не буду здесь рассматривать способ скачивания файлов с сервера и его методы докачки. Об этом можно прочитать тут.

WSGI

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

WSGI — стандарт обмена данными между веб-сервером (backend) и веб-приложением (frontend). Под это определение попадают многие вещи, тот же самый CGI. Так что поясню.

  • Во-первых, WSGI — Python-специфичный стандарт, его описывают PEP 333 и PEP 3333.
  • Во-вторых, он уже принят (статус Final).

По стандарту, WSGI-приложение должно удовлетворять следующим требованиям:

  • должно быть вызываемым (callable) объектом (обычно это функция или метод)
  • принимать два параметра:
    • словарь переменных окружения (environ)[2]
    • обработчик запроса (start_response)[3]
  • вызывать обработчик запроса с кодом HTTP-ответа и HTTP-заголовками
  • возвращать итерируемый объект с телом ответа

Значит сейчас я создам простенькое приложение, которое будет удовлетворять всем выше изложенным требованиям. У меня оно выглядит так:

def start_response(status_message, list_of_headers):
    print(status_message)
    print(list_of_headers)


def simplest_wsgi_app(environ, start_response):
    start_response('200 OK', [('Content-Type', 'text/plain')])
    yield 'Hello, world!'

В данном случае я создал функцию-приложение с названием simplest_wsgi_app и обработчик запроса start_response. Как видно из кода я ничего не обрабатываю в обработчике запроса, так как это лишь тестовый пример, чтобы показать как работает WSGI. Теперь это приложение нужно импортировать в сервер и при запросе от клиента выполнить данное приложение.

У меня получился вот такой код сервера с WSGI:

#!-*-coding: utf8-*-
'''
Простой веб-сервер с wsgi
'''

import socket
import select

from wsgi.application import simplest_wsgi_app, start_response

HOST = ''  # Символическое имя. По умолчанию localhost
PORT = 8080  # Указываем непривилированный порт

if __name__ == "__main__":
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
        print('Socket created')

        # Связываем сокет с локальным хостом и портом
        sock.bind((HOST, PORT))

        print('Привязка сокета')

        # Слушаем сокет
        sock.listen(10)
        print('Слушаем сокет')
        # Теперь можем общаться с клиентами
        while True:
            # Ждем подключения клиентов
            conn, addr = sock.accept()
            print('Подключение с ' + addr[0] + ':' + str(addr[1]))
            ready, _, _ = select.select([conn], [], [], 1)
            if ready:
                data = conn.recv(1024)
                print(data)
                while True:
                    ready, _, _ = select.select([conn], [], [], 2)
                    if ready:
                        data += conn.recv(1024)
                    else:
                        break
                print(data)
                data_list = data.split("rn")
                headers = {}
                headers["method"], headers["uri"], headers["version"] = (
                    data_list[0].split()
                )
                version = headers["version"]
                msg_body = ""
                for header in data_list[1:]:
                    if header != "":
                        if ": " in header:
                            header_name, header_value = header.split(": ")
                            headers[header_name] = header_value
                            if (
                                header_name == "Content-Type"
                                and "boundary=" in header_value
                            ):
                                boundary = header_value.split("boundary=")[1]
                                boundary_data = data.split(boundary)[1:]
                                for body_data in boundary_data:
                                    if "filename" in body_data:
                                        data_file = (
                                            body_data.split("rnrn")[1]
                                        )
                                        filename = "test.txt"
                                        with open(filename, "wb") as in_file:
                                            in_file.write(data_file)
                        else:
                            msg_body += header + "rn"
                if "Host" in headers.keys():
                    status_code = 404
                    message = "Not Found"
                    answer_body = ""
                    env = {"host": "localhost"}
                    for ans in simplest_wsgi_app(env, start_response):
                        answer_body += ans
                    answer_headers = (
                        f"{version} {status_code} {message}n{headers}nn"
                    )
                    answer = answer_headers + answer_body
                    conn.send(answer)
                    conn.close()
                else:
                    conn.close()

Я вызываю созданное приложение и полученное значение сохраняю в тело ответа сервера. Полученный ответ ниже отдается браузеру.

Заставляем Python выполнить код на PHP

А для тех, кто все же хочет на своем сервере запускать скрипты php отвечу, что да, такое возможно. Решение взято отсюда.

У меня получился вот такой код:

#!-*-coding: utf8-*-
'''
Простой веб-сервер с запуском php-кода
'''

import socket
import select
import configparser
import os
from pathlib import Path
HOST = ''  # Символическое имя. По умолчанию localhost
PORT = 8080  # Указываем непривилированный порт

if __name__ == "__main__":
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
        print('Socket created')

        # Связываем сокет с локальным хостом и портом
        sock.bind((HOST, PORT))

        print('Привязка сокета')

        # Слушаем сокет
        sock.listen(10)
        print('Слушаем сокет')

        inputs = [sock]
        outputs = [sock]

        base_dir = Path(__file__).parent
        root_dir = base_dir.parent

        config = configparser.ConfigParser()
        config.read(
            root_dir.absolute() / Path('conf') / Path('localhost.conf')
        )

        # Теперь можем общаться с клиентами
        while True:
            # Ждем подключения клиентов
            conn, addr = sock.accept()
            print('Подключение с ' + addr[0] + ':' + str(addr[1]))
            ready, _, _ = select.select([conn], [], [], 1)
            if ready:
                data = conn.recv(1024)
                print(data)
                while True:
                    ready, _, _ = select.select([conn], [], [], 2)
                    if ready:
                        data += conn.recv(1024)
                    else:
                        break
                print(data)
                data_list = data.split("rn")
                headers = {}
                headers["method"], headers["uri"], headers["version"] = (
                    data_list[0].split()
                )
                version = headers['version']
                msg_body = ""
                for header in data_list[1:]:
                    if header != "":
                        if ": " in header:
                            header_name, header_value = header.split(": ")
                            headers[header_name] = header_value
                            if (
                                header_name == "Content-Type"
                                and "boundary=" in header_value
                            ):
                                boundary = header_value.split("boundary=")[1]
                                boundary_data = data.split(boundary)[1:]
                                for body_data in boundary_data:
                                    if "filename" in body_data:
                                        data_file = (
                                            body_data.split("rnrn")[1]
                                        )
                                        filename = "test.txt"
                                        with open(filename, "wb") as in_file:
                                            in_file.write(data_file)
                        else:
                            msg_body += header + "rn"
                if "Host" in headers.keys():
                    host = headers["Host"]
                    directory = Path(config.get(
                        host, "Directory"
                    ))
                    status_code = 404
                    message = "Not Found"
                    answer_body = ""
                    if "?" in headers["uri"]:
                        uri_file, uri_params = headers["uri"].split("?")
                    else:
                        uri_file, uri_params = headers["uri"], msg_body
                    if uri_file in ["/", "/index", "/index.html"]:
                        path = Path('index.html')
                    else:
                        path = os.path.join(*uri_file.split("/")[1:])
                    file_path = base_dir / directory / path
                    if file_path.exists():
                        status_code = 200
                        message = "OK"
                        if not file_path.endswith(".php"):
                            answer_body = file_path.read_text().format(
                                data=uri_params.replace("&", "n")
                            )
                        else:
                            answer_body = os.popen(f'php {file_path}').read()
                    answer_headers = (
                        f"{version} {status_code} {message}n{headers}nn"
                    )
                    answer = answer_headers + answer_body
                    conn.send(answer)
                    conn.close()
                else:
                    conn.close()

Если сейчас обратиться по адресу localhost/test.php, то будет выполнен скрипт test.php. У меня test.php выведет информацию о php:

ИТОГИ

Теперь я надеюсь всем стало ясно следующее:

  1. Когда мы в браузере печатаем адрес ресурса, тогда веб-браузер, будучи программой, соединяется с сервером и передает ему некоторые сведения: стартовую строку, заголовки и тело сообщения. Эту всю информацию веб сервер парсит, анализирует и выдает клиенту результат обработки запроса.
  2. Передача параметров для GET и POST запросов отличается.
  3. Передача файлов от клиента на сервер и от сервера на клиент тоже имеет свои нюансы. Можно организовывать «докачку» файла.
  4. Можно создавать на питоне свое собственное приложение и это приложение будет вызываться при каждом обращении к веб-серверу. Эту возможность нам дает стандарт WSGI. Есть еще и CGI.
  5. В свою очередь браузер должен правильно составить тело сообщения, стартовую строку и блок заголовков. Но в данной статье процесс работы браузера я не рассматривал.
  6. При формировании ответа мы можем использовать шаблоны страниц, заменяя в них определенные теги. Именно так сделано в Django. Подстановочные теги в ходе обработки заменяются на необходимую информацию.
  7. Страницы для ответов с любым кодом состояния, будь то 404 или 500, необходимо создавать. Именно благодаря этому в Django мы можем создавать свою страницу, указывая серверу, какой шаблон использовать для ответа.

Ссылка на проект

Понравилась статья? Поделить с друзьями:
  • Как написать свой блокнот
  • Как написать свой блог
  • Как написать свой биос
  • Как написать свой бизнес проект
  • Как написать свой бизнес план