Язык программирования Rust
Скачать 7.02 Mb.
|
Пристальный взгляд на HTTP запрос HTTP - это текстовый протокол и запрос имеет следующий формат: Первая строка - это строка запроса, содержащая информацию о том, что запрашивает клиент. Первая часть строки запроса указывает используемый метод, например GET или POST , который описывает, как клиент выполняет этот запрос. Наш клиент использовал запрос GET Следующая часть строки запроса - это /, которая указывает унифицированный идентификатор ресурса (URI), который запрашивает клиент: URI почти, но не совсем то $ cargo run Compiling hello v0.1.0 (file:///projects/hello) Finished dev [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 (Windows NT 10.0; WOW64; rv:52.0) Gecko/20100101 Firefox/52.0 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 Accept-Language: en-US,en;q=0.5 Accept-Encoding: gzip, deflate Connection: keep-alive Upgrade-Insecure-Requests: 1 ������������������������������������ Method Request-URI HTTP-Version CRLF headers CRLF message-body же самое, что и унифицированный указатель ресурса (URL). Разница между URI и URL- адресами не важна для наших целей в этой главе, но спецификация HTTP использует термин URI, поэтому мы можем просто мысленно заменить URL-адрес здесь. Последняя часть - это версия HTTP, которую использует клиент, а затем строка запроса заканчивается последовательностью CRLF . (CRLF обозначает возврат каретки и перевод строки , что является термином из дней пишущих машинок!) Последовательность CRLF также может быть записана как \r\n , где \r - возврат каретки, а \n - перевод строки. Последовательность CRLF отделяет строку запроса от остальных данных запроса. Обратите внимание, что при печати CRLF мы видим начало новой строки, а не \r\n Глядя на данные строки запроса, которые мы получили от запуска нашей программы, мы видим, что GET - это метод, / - это URI запроса, а HTTP/1.1 - это версия. После строки запроса оставшиеся строки, начиная с Host: далее, являются заголовками. GET запросы не имеют тела. Попробуйте сделать запрос из другого браузера или запросить другой адрес, например 127.0.0.1:7878/test , чтобы увидеть, как изменяются данные запроса. Теперь, когда мы знаем, что запрашивает браузер, давайте отправим обратно в ответ некоторые данные! Написание ответа Теперь мы реализуем отправку данных в ответ на запрос клиента. Ответы имеют следующий формат: Первая строка - это строка состояния, которая содержит версию HTTP, используемую в ответе, числовой код состояния, который суммирует результат запроса, и фразу причины, которая предоставляет текстовое описание кода состояния. После последовательности CRLF идут любые заголовки, другая последовательность CRLF и тело ответа. Вот пример ответа, который использует HTTP версии 1.1, имеет код состояния 200, фразу причины OK, без заголовков и без тела: Код состояния 200 - это стандартный успешный ответ. Текст представляет собой крошечный успешный HTTP-ответ. Давайте запишем это в поток как наш ответ на HTTP-Version Status-Code Reason-Phrase CRLF headers CRLF message-body HTTP/1.1 200 OK\r\n\r\n успешный запрос! Из функции handle_connection удалите println! который печатал данные запроса и заменял их кодом из Листинга 20-3. Файл: src/main.rs Листинг 20-3: Запись короткого успешного HTTP ответа в поток Первая новая строка определяет переменную response которая содержит данные сообщения об успешном выполнении. Затем мы вызываем as_bytes в нашем response чтобы преобразовать строковые данные в байты. Метод write в stream принимает & [u8] и отправляет эти байты напрямую по соединению. Поскольку операция write может завершиться неудачно, мы, как и раньше, используем unwrap для любого результата ошибки. Опять же, в реальном приложении вы бы добавили сюда обработку ошибок. Наконец, flush подождёт и предотвратит продолжение программы, пока все байты не будут записаны в соединение; TcpStream содержит внутренний буфер для минимизации обращений к базовой операционной системе. Сделав этим изменения давайте запустим код и сделаем запрос. Мы больше не выводим в терминал любые данные, поэтому мы не увидим ничего, кроме вывода из Cargo. Когда вы загружаете адрес 127.0.0.1:7878 в веб-браузер, вы должны получить пустую страницу вместо ошибки. Вы только что вручную закодировали запрос и ответ HTTP! Возвращение реального HTML Реализуем функционал для возврата более пустой страницы. Создайте новый файл hello.html в корне каталога вашего проекта, а не в каталоге src . Вы можете ввести любой HTML-код; В листинге 20-4 показана одна возможность. Файл: hello.html Листинг 20-4. Образец HTML-файла для возврата в ответ fn handle_connection ( mut stream: TcpStream) { let buf_reader = BufReader::new(& mut 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(); } {{#include ../listings/ch20-web-server/listing-20-04/hello.html}} Это минимальный документ HTML5 с заголовком и некоторым текстом. Чтобы вернуть это с сервера при получении запроса, мы handle_connection как показано в листинге 20- 5, чтобы прочитать файл HTML, добавить его в ответ в виде тела и отправить. Файл: src/main.rs Листинг 20-5. Отправка содержимого hello.html в качестве тела ответа Мы добавили строку вверху, чтобы включить в область видимости модуль файловой системы стандартной библиотеки. Код для чтения содержимого файла в строку должен выглядеть знакомо; мы использовали его в главе 12, когда читали содержимое файла для нашего проекта ввода-вывода в листинге 12-4. Далее мы используем format! чтобы добавить содержимое файла в качестве тела ответа об успешном завершении. Чтобы гарантировать действительный HTTP-ответ, мы добавляем заголовок Content-Length который имеет размер тела нашего ответа, в данном случае размер hello.html Запустите этот код командой cargo run и загрузите 127.0.0.1:7878 в браузере; вы должны увидеть выведенный HTML в браузере! В настоящее время мы игнорируем данные запроса в buffer и просто безоговорочно отправляем обратно содержимое HTML-файла. Это означает, что если вы попытаетесь запросить 127.0.0.1:7878/something-else в своём браузере, вы все равно получите тот же ответ HTML. Наш сервер очень ограничен, и это не то, что делает большинство веб- use std::{ fs, io::{prelude::*, BufReader}, net::{TcpListener, TcpStream}, }; // --snip-- fn handle_connection ( mut stream: TcpStream) { let buf_reader = BufReader::new(& mut 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(); } серверов. Мы хотим настроить наши ответы в зависимости от запроса и отправлять обратно HTML-файл только для правильно сформированного запроса в / . Проверка запроса и выборочное возвращение ответа Прямо сейчас наш веб-сервер вернёт HTML-код в файле независимо от того, что запросил клиент. Давайте добавим функциональность, чтобы проверять, запрашивает ли браузер / перед возвратом HTML-файла, и возвращать ошибку, если браузер запрашивает что-либо ещё. Для этого нам нужно изменить handle_connection , как показано в листинге 20-6. Этот новый код проверяет содержимое полученного запроса на соответствие тому, как мы знаем, что запрос на / выглядит как, и добавляет блоки if и else чтобы обрабатывать запросы по-разному. Файл: src/main.rs Листинг 20-6: Сопоставление запроса и обработка запросов для корневого ресурса /, отличающимся от запросов других ресурсов Сначала мы жёстко кодируем данные, соответствующие запросу /, в переменную get Поскольку мы читаем необработанные байты в буфер, мы преобразуем get в байтовую строку, добавляя синтаксис байтовой строки b"" в начало данных содержимого. Затем мы проверяем, начинается ли buffer с байтов в get . Если это так, это означает, что мы получили правильно сформированный запрос к / , и это успешный случай, который мы обработаем в блоке if который возвращает содержимое нашего HTML-файла. Если buffer не начинается с байтов в get , это означает, что мы получили другой запрос. Мы добавим код в блок else через мгновение, чтобы ответить на все остальные запросы. // --snip-- fn handle_connection ( mut stream: TcpStream) { let buf_reader = BufReader::new(& mut 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 } } Запустите этот код сейчас и запросите 127.0.0.1:7878 ; вы должны получить HTML в hello.html . Если вы сделаете любой другой запрос, например 127.0.0.1:7878/something-else , вы получите ошибку соединения, подобную той, которую вы видели при запуске кода из Листинга 20-1 и Листинга 20-2. Теперь давайте добавим код из листинга 20-7 в блок else чтобы вернуть ответ с кодом состояния 404, который сигнализирует о том, что контент для запроса не найден. Мы также вернём HTML-код для страницы, отображаемой в браузере, с указанием ответа конечному пользователю. Файл: src/main.rs Листинг 20-7. Ответ с кодом состояния 404 и страницей с ошибкой, если было запрошено что-либо, кроме / Здесь ответ имеет строку состояния с кодом 404 и фразу причины NOT FOUND . Тело ответа будет HTML из файла 404.html. Вам нужно создать файл 404.html рядом с hello.html для этой страницы ошибки; снова не стесняйтесь использовать любой HTML код или пример HTML кода в листинге 20-8. Файл: 404.html Листинг 20-8. Пример содержимого страницы для отправки с любым ответом 404 С этими изменениями снова запустите сервер. Запрос на 127.0.0.1:7878 должен возвращать содержимое hello.html, и любой другой запрос, как 127.0.0.1:7878/foo, должен возвращать сообщение об ошибке HTML от 404.html. Рефакторинг В настоящий момент блоки if и else часто повторяются: они читают файлы и записывают содержимое файлов в поток. Единственные различия - это строка состояния и имя файла. Давайте сделаем код более кратким, выделив эти различия в отдельные строки if и else которые будут назначать значения строки состояния и имени файла // --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(); } {{#include ../listings/ch20-web-server/listing-20-08/404.html}} переменным; затем мы можем безоговорочно использовать эти переменные в коде для чтения файла и записи ответа. В листинге 20-9 показан код, полученный после замены больших блоков if и else Файл: src/main.rs Листинг 20-9. Реорганизация блоков if и else чтобы они содержали только код, который отличается в двух случаях. Теперь блоки if и else возвращают только соответствующие значения для строки состояния и имени файла в кортеже; Затем мы используем деструктурирование, чтобы присвоить эти два значения status_line и filename используя шаблон в операторе let , как обсуждалось в главе 18. Ранее дублированный код теперь находится вне блоков if и else и использует переменные status_line и filename . Это позволяет легче увидеть разницу между этими двумя случаями и означает, что у нас есть только одно место для обновления кода, если захотим изменить работу чтения файлов и записи ответов. Поведение кода в листинге 20-9 будет таким же, как и в 20-8. Потрясающие! Теперь у нас есть простой веб-сервер примерно на 40 строках кода Rust, который отвечает на один запрос страницей с контентом и отвечает на все остальные запросы ответом 404. В настоящее время наш сервер работает в одном потоке, что означает, что он может обслуживать только один запрос за раз. Давайте посмотрим, как это может быть проблемой, смоделировав несколько медленных запросов. Затем мы исправим это, чтобы наш сервер мог обрабатывать несколько запросов одновременно. // --snip-- fn handle_connection ( mut stream: TcpStream) { // --snip-- 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(); } Превращение однопоточного сервера в многопоточный сервер Прямо сейчас сервер будет обрабатывать каждый запрос в очереди, что означает, что он не будет обрабатывать второе соединение, пока первое не завершит обработку. Если бы сервер получал все больше и больше запросов, это последовательное выполнение было бы все менее и менее оптимальным. Если сервер получает какой-то запрос, обработка которого занимает слишком много времени, то последующие запросы должны будут ждать завершения обработки длительного запроса, даже если эти новые запросы могут быть обработаны гораздо быстрее. Нам нужно это исправить, но сначала мы рассмотрим проблему в действии. Имитация медленного запроса в текущей реализации сервера Мы посмотрим, как запрос с медленной обработкой может повлиять на другие запросы, сделанные к серверу в текущей реализации. В листинге 20-10 реализована обработка запроса к ресурсу /sleep с эмуляцией медленного ответа, который заставит сервер не работать в течение 5 секунд перед ответом. Файл: src/main.rs Листинг 20-10: Имитация медленного запроса путём распознавания обращения к /sleep и засыпанию на 5 секунд use std::{ fs, io::{prelude::*, BufReader}, net::{TcpListener, TcpStream}, thread, time::Duration, }; // --snip-- fn handle_connection ( mut stream: TcpStream) { // --snip-- let (status_line, filename) = match &request_line[..] { "GET / HTTP/1.1" => ( "HTTP/1.1 200 OK" , "hello.html" ), "GET /sleep HTTP/1.1" => { thread::sleep(Duration::from_secs( 5 )); ( "HTTP/1.1 200 OK" , "hello.html" ) } _ => ( "HTTP/1.1 404 NOT FOUND" , "404.html" ), }; // --snip-- } Этот код немного неряшливый, но он достаточно хорошо подходит для целей имитации. Мы создали второй запрос sleep , данные которого распознает сервер. Мы добавили else if после блока if , чтобы проверить запрос к /sleep. Когда этот запрос будет получен, сервер заснёт на 5 секунд, прежде чем отобразить HTML страницу успешного выполнения. Можно увидеть, насколько примитивен наш сервер: реальные библиотеки будут обрабатывать распознавание нескольких запросов гораздо менее многословно! Запустите сервер командой cargo run . Затем откройте два окна браузера: одно с адресом http://127.0.0.1:7878/, другое с http://127.0.0.1:7878/sleep. Если вы несколько раз обратитесь к URI /, то как и раньше увидите, что сервер быстро ответит. Но если вы введёте URI /sleep, затем загрузите URI /, то увидите что / ждёт, пока /sleep не отработает полные 5 секунд перед загрузкой страницы. Есть несколько способов изменить работу нашего веб-сервера, чтобы избежать медленной обработки большого количества запросов из-за одного медленного; способ который мы реализуем является пулом потоков. Улучшение пропускной способности с помощью пула потоков Пул потоков является группой заранее порождённых потоков, ожидающих в пуле и готовых выполнить задачу. Когда программа получает новую задачу, она назначает задачу одному из потоков в пуле и этот поток будет обрабатывать задачу. Остальные потоки в пуле доступны для обработки любых других задач, возникающих во время обработки первого потока. Когда первый поток завершает обработку своей задачи, он возвращается в пул свободных потоков, готовых обработать новую задачу. Пул потоков позволяет обрабатывать соединения одновременно, увеличивая пропускную способность вашего сервера. Мы ограничим число потоков в пуле небольшим числом, чтобы защитить нас от атак типа «отказ в обслуживании» (DoS - Denial of Service); если бы наша программа создавала новый поток в момент поступления каждого запроса, то кто-то сделавший 10 миллионов запросов к серверу, мог бы создать хаос, использовать все ресурсы нашего сервера и остановить обработку запросов. Вместо порождения неограниченного количества потоков, у нас будет фиксированное количество потоков, ожидающих в пуле. По мере поступления запросов они будут отправляться в пул для обработки. Пул будет поддерживать очередь входящих запросов. Каждый из потоков в пуле будет извлекать запрос из этой очереди, обрабатывать запрос и затем запрашивать в очереди следующий запрос. При таком дизайне мы можем обрабатывать N запросов одновременно, где N - количество потоков. Если каждый поток отвечает на длительный запрос, последующие запросы могут по-прежнему задержаться в очереди, но мы увеличили число долго играющих запросов, которые можно обработать до достижения этой точки. Этот подход является лишь одним из многих способов улучшить пропускную способность веб-сервера. Другими вариантами, которые вы могли бы изучить являются модель fork/join и однопоточная модель асинхронного ввода-вывода. Если вам интересна эта тема, вы можете прочитать о других решениях больше и попробовать внедрить их в помощью Rust. С языком низкого уровня как Rust, возможны все эти варианты. Прежде чем приступить к реализации пула потоков, давайте поговорим о том, как должно выглядеть использование пула. Когда вы пытаетесь проектировать код, сначала необходимо написать клиентский интерфейс. Напишите API кода, чтобы он был структурирован так, как вы хотите его вызывать, затем реализуйте функциональность данной структуры, вместо подхода реализовывать функционал, а затем разрабатывать общедоступный API. Подобно тому, как мы использовали разработку через тестирование (test-driven) в проекте главы 12, мы будем использовать здесь разработку, управляемую компилятором (compiler-driven). Мы напишем код, который вызывает нужные нам функции, а затем посмотрим на ошибки компилятора, чтобы определить, что мы должны изменить дальше, чтобы заставить код работать. |