Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Побудова однопотокового веб-сервера

Ми почнемо з того, що запустимо однопотоковий веб-сервер. Перш ніж ми почнемо, давайте подивимося на короткий огляд протоколів, що беруть участь у побудові веб серверів. Деталі цих протоколів виходять за межі цієї книги, але короткий огляд дасть вам інформацію, яка вам потрібна.

Два основні протоколи, що беруть участь у веб-серверах, — це Hypertext Transfer Protocol (HTTP) і Transmission Control Protocol (TCP). Обидва протоколи є request-response протоколами, що означає, що client ініціює запити, а server слухає запити та надає відповідь клієнту. Зміст цих запитів і відповідей визначається протоколами.

TCP — це протокол нижчого рівня, який описує деталі того, як інформація потрапляє з одного сервера до іншого, але не визначає, що саме є цією інформацією. HTTP будується поверх TCP, визначаючи вміст запитів і відповідей. Технічно можливо використовувати HTTP з іншими протоколами, але в переважній більшості випадків HTTP надсилає свої дані поверх TCP. Ми працюватимемо з сирими байтами TCP та HTTP-запитів і відповідей.

Прослуховування TCP-з’єднання

Нашому веб-серверу потрібно слухати TCP-з’єднання, тож це перша частина, над якою ми працюватимемо. Стандартна бібліотека пропонує модуль std::net, який дає нам змогу це зробити. Створімо новий проєкт звичним способом:

$ cargo new hello
     Created binary (application) `hello` project
$ cd hello

Тепер введіть код у Listing 21-1 у src/main.rs, щоб почати. Цей код слухатиме локальну адресу 127.0.0.1:7878 на вхідні TCP-потоки. Коли він отримає вхідний потік, він виведе Connection established!.

use std::net::TcpListener;

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        println!("Connection established!");
    }
}

Використовуючи TcpListener, ми можемо слухати TCP-з’єднання за адресою 127.0.0.1:7878. В адресі частина перед двокрапкою — це IP-адреса, що представляє ваш комп’ютер (це однаково на кожному комп’ютері й не представляє комп’ютер авторів конкретно), а 7878 — це порт. Ми вибрали цей порт із двох причин: HTTP зазвичай не приймається на цьому порту, тож наш сервер малоймовірно конфліктуватиме з будь-яким іншим веб-сервером, який ви можете запустити на вашій машині, а 7878 — це rust, набране на телефоні.

Функція bind у цьому сценарії працює як функція new у тому сенсі, що вона поверне новий екземпляр TcpListener. Функція називається bind тому, що в мережевому програмуванні з’єднання з портом для прослуховування відоме як “binding to a port”.

Функція bind повертає Result<T, E>, що вказує на те, що прив’язування може завершитися невдало, наприклад, якщо ми запустили два екземпляри нашої програми і, отже, мали дві програми, що слухають той самий порт. Оскільки ми пишемо базовий сервер лише з навчальною метою, ми не турбуватимемось обробкою таких помилок; натомість ми використовуємо unwrap, щоб зупинити програму, якщо виникнуть помилки.

Метод incoming на TcpListener повертає ітератор, який дає нам послідовність потоків (точніше, потоків типу TcpStream). Один потік представляє відкрите з’єднання між клієнтом і сервером. З’єднання — це назва для повного процесу запиту й відповіді, у якому клієнт під’єднується до сервера, сервер генерує відповідь, а сервер закриває з’єднання. Таким чином, ми читатимемо з TcpStream, щоб побачити, що клієнт надіслав, а потім записуватимемо нашу відповідь у потік, щоб надіслати дані назад клієнту. Загалом, цей цикл for оброблятиме кожне з’єднання по черзі й створюватиме для нас серію потоків для обробки.

Наразі наша обробка потоку полягає у виклику unwrap, щоб завершити нашу програму, якщо потік має будь-які помилки; якщо помилок немає, програма виводить повідомлення. Ми додамо більше функціональності для успішного випадку в наступному фрагменті. Причина, через яку ми можемо отримати помилки від методу incoming, коли клієнт під’єднується до сервера, полягає в тому, що ми насправді не ітеруємося по з’єднаннях. Натомість ми ітеруємося по спробах під’єднання. З’єднання може не вдатися з багатьох причин, багато з яких є специфічними для операційної системи. Наприклад, багато операційних систем мають обмеження на кількість одночасно відкритих з’єднань, які вони можуть підтримувати; нові спроби під’єднання понад цю кількість спричинять помилку, доки деякі з відкритих з’єднань не буде закрито.

Спробуймо запустити цей код! Викличте cargo run у терміналі, а потім відкрийте 127.0.0.1:7878 у веб-браузері. Браузер має показати повідомлення про помилку, наприклад “Connection reset”, тому що сервер наразі не надсилає жодних даних. Але коли ви подивитеся на свій термінал, ви маєте побачити кілька повідомлень, які були виведені, коли браузер під’єднався до сервера!

     Running `target/debug/hello`
Connection established!
Connection established!
Connection established!

Іноді ви побачите кілька повідомлень, виведених для одного запиту браузера; причина може полягати в тому, що браузер робить запит на сторінку, а також запит на інші ресурси, як-от піктограму favicon.ico, що з’являється у вкладці браузера.

Також може бути, що браузер намагається під’єднатися до сервера кілька разів, тому що сервер не відповідає жодними даними. Коли stream виходить за межі видимості й видаляється в кінці циклу, з’єднання закривається як частина реалізації drop. Браузери іноді поводяться із закритими з’єднаннями, повторюючи спробу, тому що проблема може бути тимчасовою.

Браузери також іноді відкривають кілька з’єднань із сервером без надсилання жодних запитів, щоб якщо вони дійсно пізніше надішлють запити, ці запити могли відбутися швидше. Коли це стається, наш сервер бачитиме кожне з’єднання, незалежно від того, чи є якісь запити через це з’єднання. Багато версій браузерів на базі Chrome, наприклад, роблять це; ви можете вимкнути цю оптимізацію, використовуючи режим приватного перегляду або інший браузер.

Важливим є те, що ми успішно отримали доступ до TCP-з’єднання!

Не забудьте зупинити програму, натиснувши ctrl-C, коли завершите роботу з конкретною версією коду. Потім перезапустіть програму, викликавши команду cargo run після кожного набору змін коду, щоб переконатися, що ви запускаєте найновіший код.

Читання запиту

Давайте реалізуємо функціональність для читання запиту з браузера! Щоб розділити завдання спершу отримання з’єднання, а потім виконання певної дії з цим з’єднанням, ми створимо нову функцію для обробки з’єднань. У цій новій функції handle_connection ми читатимемо дані з TCP-потоку й виводитимемо їх, щоб бачити дані, які надсилаються з браузера. Змініть код так, щоб він виглядав як Listing 21-2.

use std::{
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let http_request: Vec<_> = buf_reader
        .lines()
        .map(|result| result.unwrap())
        .take_while(|line| !line.is_empty())
        .collect();

    println!("Request: {http_request:#?}");
}

Ми додаємо std::io::BufReader і std::io::prelude в область видимості, щоб отримати доступ до трейтов і типів, які дають нам змогу читати з потоку й записувати в нього. У циклі for у функції main, замість виведення повідомлення про те, що ми встановили з’єднання, ми тепер викликаємо нову функцію handle_connection і передаємо їй stream.

У функції handle_connection ми створюємо новий екземпляр BufReader, який обгортає посилання на stream. BufReader додає буферизацію, керуючи викликами методів трейта std::io::Read за нас.

Ми створюємо змінну з назвою http_request, щоб зібрати рядки запиту, які браузер надсилає нашому серверу. Ми вказуємо, що хочемо зібрати ці рядки у вектор, додаючи анотацію типу Vec<_>.

BufReader реалізує трейт std::io::BufRead, який надає метод lines. Метод lines повертає ітератор Result<String, std::io::Error>, розбиваючи потік даних щоразу, коли бачить байт нового рядка. Щоб отримати кожен String, ми map і unwrap кожен Result. Result може бути помилкою, якщо дані не є дійсним UTF-8 або якщо виникла проблема під час читання з потоку. Знову ж таки, виробнича програма мала б обробляти ці помилки плавніше, але ми обираємо зупиняти програму в разі помилки заради простоти.

Браузер сигналізує про кінець HTTP-запиту, надсилаючи два символи нового рядка підряд, тож щоб отримати один запит із потоку, ми беремо рядки, доки не отримаємо рядок, що є порожнім рядком. Після того як ми зібрали рядки у вектор, ми виводимо їх, використовуючи гарне форматування налагодження, щоб поглянути на інструкції, які веб-браузер надсилає нашому серверу.

Спробуймо цей код! Запустіть програму й зробіть запит у веб-браузері знову. Зверніть увагу, що ми все ще отримаємо сторінку помилки в браузері, але вивід нашої програми в терміналі тепер виглядатиме приблизно так:

$ cargo run
   Compiling hello v0.1.0 (file:///projects/hello)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.42s
     Running `target/debug/hello`
Request: [
    "GET / HTTP/1.1",
    "Host: 127.0.0.1:7878",
    "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:99.0) Gecko/20100101 Firefox/99.0",
    "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
    "Accept-Language: en-US,en;q=0.5",
    "Accept-Encoding: gzip, deflate, br",
    "DNT: 1",
    "Connection: keep-alive",
    "Upgrade-Insecure-Requests: 1",
    "Sec-Fetch-Dest: document",
    "Sec-Fetch-Mode: navigate",
    "Sec-Fetch-Site: none",
    "Sec-Fetch-User: ?1",
    "Cache-Control: max-age=0",
]

Залежно від вашого браузера, ви можете отримати дещо інший вивід. Тепер, коли ми виводимо дані запиту, ми можемо побачити, чому отримуємо кілька з’єднань з одного запиту браузера, подивившись на шлях після GET у першому рядку запиту. Якщо повторні з’єднання всі запитують /, ми знаємо, що браузер намагається багаторазово отримати / , тому що не отримує відповіді від нашої програми.

Розберімо ці дані запиту, щоб зрозуміти, що браузер просить у нашої програми.

Дивлячись ближче на HTTP-запит

HTTP — це текстовий протокол, і запит має такий формат:

Method Request-URI HTTP-Version CRLF
headers CRLF
message-body

Перший рядок — це рядок запиту, який містить інформацію про те, що клієнт запитує. Перша частина рядка запиту вказує метод, що використовується, наприклад GET або POST, який описує, як клієнт робить цей запит. Наш клієнт використав запит GET, що означає, що він запитує інформацію.

Наступна частина рядка запиту — це /, що вказує на уніфікований ідентифікатор ресурсу (URI), який запитує клієнт: URI майже, але не зовсім, те саме, що й уніфікований локатор ресурсу (URL). Різниця між URI та URL не є важливою для наших цілей у цьому розділі, але специфікація HTTP використовує термін URI, тож ми можемо просто подумки підставляти URL замість URI тут.

Остання частина — це версія HTTP, яку використовує клієнт, а потім рядок запиту закінчується послідовністю CRLF. (CRLF означає carriage return і line feed, які є термінами з часів друкарських машинок!) Послідовність CRLF також може бути записана як \r\n, де \r — це carriage return, а \n — line feed. Послідовність CRLF відокремлює рядок запиту від решти даних запиту. Зверніть увагу: коли CRLF виводиться, ми бачимо початок нового рядка, а не \r\n.

Дивлячись на дані рядка запиту, які ми отримали, запускаючи нашу програму досі, ми бачимо, що GET — це метод, / — це URI запиту, а HTTP/1.1 — це версія.

Після рядка запиту решта рядків, починаючи з Host:, є заголовками. Запити GET не мають тіла.

Спробуйте зробити запит з іншого браузера або попросити іншу адресу, таку як 127.0.0.1:7878/test, щоб побачити, як змінюються дані запиту.

Тепер, коли ми знаємо, чого браузер просить, давайте надішлемо назад трохи даних!

Запис відповіді

Ми збираємося реалізувати надсилання даних у відповідь на запит клієнта. Відповіді мають такий формат:

HTTP-Version Status-Code Reason-Phrase CRLF
headers CRLF
message-body

Перший рядок — це рядок статусу, який містить версію HTTP, використану у відповіді, числовий код статусу, що підсумовує результат запиту, і фразу причини, яка надає текстовий опис коду статусу. Після послідовності CRLF ідуть будь-які заголовки, ще одна послідовність CRLF і тіло відповіді.

Ось приклад відповіді, яка використовує версію HTTP 1.1 і має код статусу 200, фразу причини OK, без заголовків і без тіла:

HTTP/1.1 200 OK\r\n\r\n

Код статусу 200 — це стандартна відповідь про успіх. Текст — це крихітна успішна HTTP-відповідь. Давайте запишемо її у потік як нашу відповідь на успішний запит! У функції handle_connection приберіть println!, який виводив дані запиту, і замініть його кодом із Listing 21-3.

use std::{
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let http_request: Vec<_> = buf_reader
        .lines()
        .map(|result| result.unwrap())
        .take_while(|line| !line.is_empty())
        .collect();

    let response = "HTTP/1.1 200 OK\r\n\r\n";

    stream.write_all(response.as_bytes()).unwrap();
}

Перший новий рядок визначає змінну response, яка містить дані повідомлення про успіх. Потім ми викликаємо as_bytes на нашій response, щоб перетворити рядкові дані на байти. Метод write_all на stream приймає &[u8] і надсилає ці байти безпосередньо через з’єднання. Оскільки операція write_all може завершитися невдало, ми використовуємо unwrap для будь-якого результату помилки, як і раніше. Знову ж таки, у реальній програмі ви додали б тут обробку помилок.

З цими змінами запустімо наш код і зробімо запит. Ми більше не виводимо жодних даних у термінал, тож не побачимо жодного виводу, окрім виводу від Cargo. Коли ви відкриєте 127.0.0.1:7878 у веб-браузері, ви маєте отримати порожню сторінку замість помилки. Ви щойно вручну закодували отримання HTTP запиту і надсилання відповіді!

Повернення справжнього HTML

Давайте реалізуємо функціональність повернення чогось більшого, ніж порожня сторінка. Створіть новий файл hello.html у корені каталогу вашого проєкту, а не в каталозі src. Ви можете ввести будь-який HTML, який хочете; Listing 21-4 показує один можливий варіант.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Hello!</title>
  </head>
  <body>
    <h1>Hello!</h1>
    <p>Hi from Rust</p>
  </body>
</html>

Це мінімальний HTML5-документ із заголовком і деяким текстом. Щоб повернути його із сервера, коли отримано запит, ми змінимо handle_connection, як показано в Listing 21-5, щоб прочитати HTML-файл, додати його до відповіді як тіло і надіслати його.

use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};
// --snip--

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let http_request: Vec<_> = buf_reader
        .lines()
        .map(|result| result.unwrap())
        .take_while(|line| !line.is_empty())
        .collect();

    let status_line = "HTTP/1.1 200 OK";
    let contents = fs::read_to_string("hello.html").unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}

Ми додали fs до оператора use, щоб увімкнути модуль файлової системи стандартної бібліотеки в область видимості. Код для читання вмісту файла в рядок має виглядати знайомо; ми використовували його, коли читали вміст файла для нашого проєкту I/O у Listing 12-4.

Далі ми використовуємо format!, щоб додати вміст файла як тіло успішної відповіді. Щоб забезпечити коректну HTTP-відповідь, ми додаємо заголовок Content-Length, який встановлюється в розмір нашого тіла відповіді — у цьому випадку, у розмір hello.html.

Запустіть цей код за допомогою cargo run і відкрийте 127.0.0.1:7878 у вашому браузері; ви маєте побачити відтворений HTML!

Наразі ми ігноруємо дані запиту в http_request і просто безумовно надсилаємо назад вміст HTML-файлу. Це означає, що якщо ви спробуєте запросити 127.0.0.1:7878/something-else у вашому браузері, ви все одно отримаєте назад цю саму HTML-відповідь. На даний момент наш сервер дуже обмежений і не робить того, що роблять більшість веб-серверів. Ми хочемо налаштовувати наші відповіді залежно від запиту й надсилати назад HTML-файл лише для правильно сформованого запиту до /.

Перевірка запиту та вибіркова відповідь

Зараз наш веб-сервер повертатиме HTML у файлі незалежно від того, що запитував клієнт. Додаймо функціональність, щоб перевіряти, що браузер запитує / перед поверненням HTML-файлу, і повертати помилку, якщо браузер запитує щось інше. Для цього нам потрібно змінити handle_connection, як показано в Listing 21-6. Цей новий код перевіряє вміст отриманого запиту на відповідність тому, як ми знаємо, виглядає запит до / , і додає блоки if та else, щоб поводитися з запитами по-різному.

use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}
// --snip--

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    if request_line == "GET / HTTP/1.1" {
        let status_line = "HTTP/1.1 200 OK";
        let contents = fs::read_to_string("hello.html").unwrap();
        let length = contents.len();

        let response = format!(
            "{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"
        );

        stream.write_all(response.as_bytes()).unwrap();
    } else {
        // some other request
    }
}

Ми будемо дивитися лише на перший рядок HTTP-запиту, тож замість читання всього запиту у вектор ми викликаємо next, щоб отримати перший елемент з ітератора. Перший unwrap обробляє Option і зупиняє програму, якщо ітератор не має елементів. Другий unwrap обробляє Result і має той самий ефект, що й unwrap, який був у map, доданому в Listing 21-2.

Далі ми перевіряємо request_line, щоб побачити, чи дорівнює він рядку запиту GET до шляху / . Якщо так, блок if повертає вміст нашого HTML-файлу.

Якщо request_line не дорівнює запиту GET до шляху / , це означає, що ми отримали якийсь інший запит. Ми додамо код до блоку else за мить, щоб реагувати на всі інші запити.

Запустіть цей код зараз і зробіть запит до 127.0.0.1:7878; ви маєте отримати HTML із hello.html. Якщо ви зробите будь-який інший запит, наприклад 127.0.0.1:7878/something-else, ви отримаєте помилку з’єднання, як ті, які ви бачили під час запуску коду в Listing 21-1 і Listing 21-2.

Тепер додаймо код із Listing 21-7 до блоку else, щоб повертати відповідь зі статус-кодом 404, який сигналізує, що вміст для запиту не знайдено. Ми також повернемо деякий HTML для сторінки, яка відображатиметься в браузері, щоб показати відповідь кінцевому користувачу.

use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    if request_line == "GET / HTTP/1.1" {
        let status_line = "HTTP/1.1 200 OK";
        let contents = fs::read_to_string("hello.html").unwrap();
        let length = contents.len();

        let response = format!(
            "{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"
        );

        stream.write_all(response.as_bytes()).unwrap();
    // --snip--
    } else {
        let status_line = "HTTP/1.1 404 NOT FOUND";
        let contents = fs::read_to_string("404.html").unwrap();
        let length = contents.len();

        let response = format!(
            "{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"
        );

        stream.write_all(response.as_bytes()).unwrap();
    }
}

Тут наша відповідь має рядок статусу зі статус-кодом 404 і фразою причини NOT FOUND. Тіло відповіді буде HTML у файлі 404.html. Вам потрібно створити файл 404.html поруч із hello.html для сторінки помилки; знову ж таки, ви можете використати будь-який HTML, який хочете, або скористатися прикладом HTML у Listing 21-8.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Hello!</title>
  </head>
  <body>
    <h1>Oops!</h1>
    <p>Sorry, I don't know what you're asking for.</p>
  </body>
</html>

З цими змінами знову запустіть ваш сервер. Запит до 127.0.0.1:7878 має повернути вміст hello.html, а будь-який інший запит, як-от 127.0.0.1:7878/foo, має повернути HTML помилки з 404.html.

Рефакторинг

Наразі блоки if і else мають багато повторень: Вони обидва читають файли й записують вміст файлів у потік. Єдина відмінність — це рядок статусу та ім’я файла. Давайте зробимо код більш лаконічним, винісши ці відмінності в окремі рядки if і else, які призначатимуть значення рядка статусу та імені файла змінним; потім ми можемо безумовно використовувати ці змінні в коді для читання файла й запису відповіді. Listing 21-9 показує отриманий код після заміни великих блоків if і else.

use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}
// --snip--

fn handle_connection(mut stream: TcpStream) {
    // --snip--
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = if request_line == "GET / HTTP/1.1" {
        ("HTTP/1.1 200 OK", "hello.html")
    } else {
        ("HTTP/1.1 404 NOT FOUND", "404.html")
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}

Тепер блоки if і else лише повертають відповідні значення для рядка статусу та імені файла у вигляді кортежу; потім ми використовуємо деструктуризацію, щоб присвоїти ці два значення status_line і filename, використовуючи шаблон у операторі let, як обговорювалося в Розділі 19.

Код, що раніше дублювався, тепер знаходиться поза блоками if і else та використовує змінні status_line і filename. Це полегшує побачити різницю між двома випадками, і це означає, що в нас є лише одне місце, де можна оновити код, якщо ми хочемо змінити те, як працюють читання файла та запис відповіді. Поведінка коду в Listing 21-9 буде такою ж, як і в Listing 21-7.

Чудово! Тепер у нас є простий веб-сервер приблизно з 40 рядків коду Rust, який відповідає на один запит сторінкою з вмістом і відповідає на всі інші запити відповіддю 404.

Наразі наш сервер працює в одному потоці, що означає, що він може обслуговувати лише один запит за раз. Давайте розглянемо, як це може бути проблемою, симулюючи повільні запити. Потім ми виправимо це так, щоб наш сервер міг обробляти кілька запитів одночасно.