А не написать ли мне сервер?

Всем привет.

“Недавно” мне пришла в голову довольная странная идея о реализации небольшого http сервера. Не знаю конечно зачем мне это было нужно, но разбираться в технологии, которые мы используем каждый день довольно интересно и познавательно.

Если вам это не очень интересно, то всегда можете сразу перейти к коду.

Вообще эта статья должна была выйти давным-давно, но мне было лень её дописывать.

Введение

Я бы предложил для начала посмотреть статью HTTP Server: Everything you need to know to Build a simple HTTP server from scratch, где неплохо описывается реализация с сервера нуля.

Но вообще, если хотите разобраться в тонкостях работы, то стоит обратиться к вот этому списку RFC, которые описывают реализацию HTTP/1.1:

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

Ладно, давайте уже закончим на этом введении и перейдём к написанию кода.

Hello world

Для самой минимальной рабочей программы нужно несколько вещей:

Набросаем базовую часть кода

use std::net::{TcpListener, TcpStream};

fn handle_connection(stream: TcpStream) {
    todo!()
}

fn main() {
    // забьём на обработку ошибок
    let listener = TcpListener::bind(("127.0.0.1", 8000)).unwrap();
    // про flatten мне clippy подсказал
    for stream in listener.incoming().flatten() {
        handle_connection(stream);
    }
}

Базовый код накидали и теперь нужно написать обработчик handle_connection и наш Hello world готов!

Для его реализации нам необходимо произвести несколько действий:

  1. считать заголовочную часть запроса
  2. распарсить её
  3. дочитать контент
  4. и ответить по форме

Первую часть для удобства вынесем в отдельную функцию.

use std::io::Read;

// https://ru.wikipedia.org/wiki/Перевод_строки
// cr - возврат каретки (carriage return)
// lf - перевод строки (line feed)
fn read_until_crlf<R: Read>(r: &mut R) -> Option<String> {
    let mut buf = Vec::new();

    // будем читать входной поток по одному байту
    // это не эффективно, но без заморочек
    for b in r.bytes() {
        buf.push(b.ok()?);
        // и прервём чтение на двойном crlf
        if buf.ends_with(b"\r\n\r\n") {
            break;
        }
    }

    // естественно нам нужна строка для последующего парсинга
    String::from_utf8(buf).ok()
}

И теперь осталось только реализовать handle_connection

use std::collections::HashMap;
use std::io::{Write};

fn handle_connection(stream: TcpStream) {
    // читаем заголовок запроса
    let buffer = read_until_crlf(&mut stream).unwrap();

    let mut headers = HashMap::new();
    // парсим заголовок
    // первую строку просто пропускаем
    for line in buffer.split("\r\n").skip(1) {
        // игнорируем пустые строки
        if line.trim().is_empty() {
            continue;
        }
        // а остальные разделяем по шаблону
        let (key, value) = line.split_once(':').unwrap();
        // будем складывать ключи в нижнем регистре - чисто для удобства
        headers.insert(key.trim().to_lowercase(), value[1..].trim());
    }

    // дочитываем контент (если он есть)
    if headers.contains_key("content-length") {
        // узнаём размер контента
        let size: u64 = headers.get("content-length").unwrap().parse().unwrap();
        // заимствуем stream по ссылке
        let r = Read::by_ref(&mut stream);
        // пока читаем контент в никуда
        let _content: Vec<_> = r.take(size).bytes().collect();
    }

    // формируем ответ
    let hello_msg = "Hello World!";
    let hello_size = hello_msg.len();
    let _ = write!(
        stream,
        "HTTP/1.1 OK\r\n\
        host: 127.0.0.1:8000\r\n\
        server: micro-http/0.1\r\n\
        content-type: text/plain\r\n\
        content-length: {hello_size}\r\n\r\n\
        {hello_msg}"
    );
}

Всё, Hello world готов к запуску. Можете смело проверять работу в браузере!

Увы, но обычным Hello world сейчас уже никого не удивишь. Поэтому мы пойдём чуть дальше и реализуем небольшой интерактивный сайт.

И начнём со фронтенда.

Фронтенд

Я не стал с ним сильно запариваться и сделал ультра бюджетную вёрстку. Особых подробностей тут от меня не ждите — всё-таки я не очень большой мастер фронта.

Так что просто смотрите вёрстку/код

site/index.html

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <!-- вся статика будет отдаваться бэком -->
  <link rel="stylesheet" type="text/css" href="/static/style.css"/>
  <link rel="shortcut icon" href="/static/favicon.ico" type="image/x-icon"/>
  <script src="/static/script.js"></script>
  <title>ГПСЧ</title>
</head>
<body>
  <div class="center">
    <h1>Генератор псевдослучайных чисел</h1>
    <div class="item">
      <!-- сюда будем выводить сгенерированное число -->
      <span class="current">&mdash;</span>
    </div>
    <div class="item">
      <label>от</label>
      <input id="min" type="number" value="1">
      <label>до</label>
      <input id="max" type="number" value="10">
    </div>
    <div class="item">
      <button onclick="process()">Сгенерировать</button>
    </div>
  </div>
</body>
</html>

site/static/style.css

div.center {
    display: block;
    margin: auto;
    width: 50%;
    text-align: center;
}

div.item {
    margin: 2em auto 2em auto;
}

span.current {
    font-size: 100px;
    font-weight: bold;
}

label {
    font-size: 16px;
}

h1 {
    font-size: 24px;
    font-weight: normal;
}

input {
    font-size: 16px;
    max-width: 100px;
    text-align: center;
}

button {
    font-size: large;
    min-width: 200px;
    min-height: 50px;
}

site/static/script.js

function process() {
  fetch("/api/", {
    method: "POST",
    body: JSON.stringify({
      // будем указывать в каком диапазоне генерировать новое число
      min: document.querySelector("input[id=min]").value,
      max: document.querySelector("input[id=max]").value
    }),
    headers: {
      "Content-Type": "application/json"
    }
  })
  .then((response) => response.json())
  .then((json) => {
    // и забьём на обработку ошибок
    document.querySelector("span[class=current]").textContent = json.result;
  });
}

Как вы уже наверное поняли я выбрал в качестве демки генератор псевдослучайных чисел с генерацией на бэке.

На этом с фронтовой частью закончили. Теперь переходим к мясу!

Бэкенд

Реализованный Hello world конечно нам поможет, но тут стоит очень сильно переработать весь интерфейс.

Код будем разделять на модули. Как и ранее пойдём от логики нашего приложения и постепенно будем заполнять пустоты.

src/main.rs

// это наша библиотека
extern crate micro_http;

// с большим числом вспомогательных элементов
use micro_http::app::App;
use micro_http::file;
use micro_http::http::{Data, Method};
use micro_http::json::{self, SimpleJson};
use micro_http::random::Random;
use micro_http::status::StatusCode;

// наша апишечка
fn api(request: Data) -> Data {
    fn process(request: Data) -> Option<Data> {
        // если пришёл json
        let data = request.content.and_then(|c| json::deserialize(&c))?;
        // то получаем из него необходимые поля
        let minv = data.get("min").cloned().and_then(|d| d.parse().ok())?;
        let maxv = data.get("max").cloned().and_then(|d| d.parse().ok())?;

        // сгенерируем псевдослучайное число
        let result = Random::new().in_range(minv, maxv);

        // упакуем ответ в HashMap
        let mut data = SimpleJson::new();
        data.insert("result".to_string(), result.to_string());

        // сериализуем и отдаём
        Some(json::serialize(data))
    }

    match process(request) {
        Some(r) => r,
        // если есть любой косяк, то это Bad Request
        None => Data::from_status(StatusCode::BadRequest),
    }
}

fn main() {
    // инициализируем нашку апку
    let mut app = App::new("127.0.0.1", 8000);
    // забиндим index и будем отдавать по нему index.html
    app.bind("/", Method::GET, |_| file::response("./site/index.html"));
    // выделим роут для статики (css, js, ico)
    app.bind("/static/", Method::GET, |r| file::response(&format!("./site{}", r.url)));
    // роут для апишечки
    app.bind("/api/", Method::POST, api);
    // запускаем сервер
    app.run();
}

Теперь когда основа заложена, то стоит переходить к отдельным частям.

Давайте сразу их и обозначим:

Вообще для удобства сразу определим наши модули.

src/lib.rs

pub mod app;
pub mod error;
pub mod file;
pub mod http;
pub mod json;
pub mod random;
pub mod read; // тут у нас until_crlf, ранее read_until_crlf
pub mod status;

Ну а теперь далее к реализации!

src/error.rs

// вообще хорошим тоном будет определить модуль с ошибками
// хотя всегда есть такие крейты как anyhow и thiserror, которые упрощают обработку ошибок

use std::error;
use std::fmt;
use std::io;
use std::string;

// мы же тут определим всё вручную
#[derive(Debug)]
pub enum FrameworkError {
    // для операций с файлами
    Io(io::Error),
    // для парсинга строк
    Utf(string::FromUtf8Error),
    // Косяки парсинга заголовка
    HeaderParse,
    HeaderData,
}

// реализуем проброс ошибок необходимый при размотке, так как вложенность может быть любой
impl error::Error for FrameworkError {
    fn source(&self) -> Option<&(dyn error::Error + 'static)> {
        match self {
            // эти вложенные
            FrameworkError::Io(e) => Some(e),
            FrameworkError::Utf(e) => Some(e),
            // а эти нет
            FrameworkError::HeaderParse => None,
            FrameworkError::HeaderData => None,
        }
    }
}

// человеко-читаемые ошибки
impl fmt::Display for FrameworkError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            FrameworkError::Io(e) => write!(f, "IO Error: {}", e),
            FrameworkError::Utf(e) => write!(f, "UTF8 Error: {}", e),
            FrameworkError::HeaderParse => write!(f, "Parse header error"),
            FrameworkError::HeaderData => write!(f, "Get data from header error"),
        }
    }
}

// и поддержка From (+ Into на халяву)
impl From<io::Error> for FrameworkError {
    fn from(e: io::Error) -> Self {
        Self::Io(e)
    }
}

impl From<string::FromUtf8Error> for FrameworkError {
    fn from(e: string::FromUtf8Error) -> Self {
        Self::Utf(e)
    }
}

src/status.rs

use std::fmt;

// будем поддерживать только необходимые нам статусы
#[derive(Debug, Copy, Clone, PartialEq, Eq, Default)]
pub enum StatusCode {
    #[default]
    Ok = 200,
    BadRequest = 400,
    NotFound = 404,
    MethodNotAllowed = 405,
    ServerError = 500,
}

// и преобразование статуса в строку
impl fmt::Display for StatusCode {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        use StatusCode::*;
        write!(
            f,
            "{} {}",
            *self as u16,
            match self {
                Ok => "OK",
                BadRequest => "Bad Request",
                NotFound => "Not Found",
                MethodNotAllowed => "Method Not Allowed",
                ServerError => "Internal Server Error",
            }
        )
    }
}

src/http.rs

use std::fmt;
use std::{collections::HashMap, io::Read, net::SocketAddr};

use crate::error::FrameworkError;
use crate::read;
use crate::status::StatusCode;

// вообще методы могут быть любые, так как стандарт это разрешает
// но мы ограничимся только эти набором
#[derive(Eq, PartialEq, Copy, Clone, Debug, Default)]
pub enum Method {
    CONNECT,
    DELETE,
    GET,
    HEAD,
    OPTIONS,
    PATCH,
    POST,
    PUT,
    TRACE,
    // любые другие методы
    #[default]
    UNKNOWN,
}

#[derive(Default)]
pub struct Data {
    // тут вообще multi map должен быть, но как-то пофиг
    pub headers: HashMap<String, String>,
    pub content: Option<Vec<u8>>,
    pub addr: Option<SocketAddr>,
    pub url: String,
    pub method: Method,
    pub status_code: StatusCode,
}

impl Data {
    // немного вспомогательных методов
    pub fn new() -> Data {
        Data::default()
    }

    pub fn from_status(status: StatusCode) -> Data {
        Data { status_code: status, ..Default::default() }
    }

    pub fn from_content<M: Into<String>, C: Into<Vec<u8>>>(mime_type: M, content: C) -> Data {
        let mut data = Data::new();
        let content = content.into();
        data.add_header("content-type", mime_type.into());
        data.add_header("content-length", content.len());
        data.content = Some(content);
        data
    }

    // парсинг теперь идёт в два этапа
    pub fn parse<R: Read>(&mut self, r: &mut R) -> Result<(), FrameworkError> {
        self.parse_header(r)?;
        self.parse_content(r)?;
        Ok(())
    }

    pub fn add_header<K, V>(&mut self, key: K, value: V)
    where
        K: fmt::Display,
        V: fmt::Display,
    {
        // все ключи будут в нижнем регистре
        self.headers.insert(key.to_string().to_lowercase(), value.to_string());
    }

    // парсинг заголовка, который ранее уже описывал
    // тут только добавилась обработка ошибок
    fn parse_header<R: Read>(&mut self, r: &mut R) -> Result<(), FrameworkError> {
        let buffer = read::until_crlf(r)?;
        let mut iterator = buffer.split("\r\n");

        let header: Vec<_> = iterator.next().ok_or(FrameworkError::HeaderParse)?.split(' ').collect();
        self.method = Method::from(header[0]);
        self.url = header[1].to_string();

        for line in iterator {
            if line.trim().is_empty() {
                continue;
            }
            let (key, value) = line.split_once(':').ok_or(FrameworkError::HeaderParse)?;
            self.add_header(key.trim(), value[1..].trim());
        }

        Ok(())
    }

    // и парсинг контента, если нужно
    fn parse_content<R: Read>(&mut self, r: &mut R) -> Result<(), FrameworkError> {
        if self.headers.contains_key("content-length") {
            let size: u64 = self
                .headers
                .get("content-length")
                .ok_or(FrameworkError::HeaderData)?
                .parse()
                .map_err(|_| FrameworkError::HeaderData)?;
            let mut content = String::with_capacity(size as usize);
            let r = Read::by_ref(r);
            let _ = r.take(size).read_to_string(&mut content);
            self.content = Some(content.into());
        }
        Ok(())
    }

    // также не забываем про сериализацию заголовка для ответа
    pub fn render_headers(&self) -> String {
        let mut buf = String::new();
        for (k, v) in &self.headers {
            buf.push_str(&format!("{k}: {v}\r\n"));
        }
        buf
    }
}

// парсинг метода, лодку мне!
impl From<&str> for Method {
    fn from(value: &str) -> Self {
        use Method::*;
        match value {
            "CONNECT" => CONNECT,
            "DELETE" => DELETE,
            "GET" => GET,
            "HEAD" => HEAD,
            "OPTIONS" => OPTIONS,
            "PATCH" => PATCH,
            "POST" => POST,
            "PUT" => PUT,
            "TRACE" => TRACE,
            _ => UNKNOWN,
        }
    }
}

src/app.rs

use std::io::Write;
use std::net::{TcpListener, TcpStream};
use std::thread;
use std::time::{Duration, SystemTime};

use crate::error::FrameworkError;
use crate::http::{Data, Method};
use crate::status::StatusCode;

// чуток упрощаем себе жизнь определяя псевдоним
type RouteFunc = fn(Data) -> Data;

// структура для хранения наших роутов
#[derive(Clone)]
struct Route {
    url: String,
    method: Method,
    func: RouteFunc,
}

// и самого приложения
#[derive(Clone)]
pub struct App {
    host: String,
    port: u16,
    routes: Vec<Route>,
}

impl App {
    pub fn new(host: &str, port: u16) -> App {
        App { routes: Vec::new(), host: host.to_string(), port }
    }

    // просто собираем роуты в список
    pub fn bind(&mut self, url: &str, method: Method, func: RouteFunc) {
        self.routes.push(Route { url: url.to_string(), method, func })
    }

    // а вот тут уже идёт сам роутинг
    fn route(&self, url: &str, method: Method) -> Option<&Route> {
        // длина общих частей между двумя строками
        fn sublength(text: &str, subtext: &str) -> usize {
            text.chars().zip(subtext.chars()).take_while(|(a, b)| a == b).count()
        }

        let mut founded = None;
        let mut max_len = 0;

        // ищем подходящий роут, который совпадает с запрашиваемым
        // ориентируемся на максимальную длину общей части
        for route in &self.routes {
            if route.method == method && url.starts_with(&route.url) {
                let curr_len = sublength(&route.url, url);
                if curr_len > max_len {
                    founded = Some(route);
                    max_len = curr_len;
                }
            }
        }

        founded
    }

    // обработчик входящий соединений
    fn handle_client(&self, mut stream: TcpStream) -> Result<(), FrameworkError> {
        // немного логгинга
        if let Ok(addr) = stream.peer_addr() {
            println!(">>> incoming connection from {}:{}", addr.ip(), addr.port());
        }

        // у нас коннект будет жить 5 секунд
        let connection_start = SystemTime::now();
        stream.set_read_timeout(Some(Duration::from_secs(5)))?;
        stream.set_write_timeout(Some(Duration::from_secs(5)))?;

        loop {
            // прошло больше 5 секунд?
            let elapsed = connection_start.elapsed().unwrap_or(Duration::from_secs(5));
            if elapsed >= Duration::from_secs(5) {
                break;
            }

            // парсим запрос
            let mut request = Data::new();
            request.addr = stream.peer_addr().ok();
            request.parse(&mut stream)?;

            // нам нужен флаг Keep-Alive, чтобы в конце решить стоит ли закрывать соединение?
            let keep_alive = request.headers.get("connection").map(|t| t == "keep-alive").unwrap_or(false);

            // ещё немного логгинга
            println!(">>> {:?} {}\n{}", request.method, request.url, request.render_headers());

            let mut response = self
                // ищем роут
                .route(&request.url, request.method)
                // и вызываем обработчик
                .map(|r| (r.func)(request))
                // или ошибка
                .unwrap_or(Data::from_status(StatusCode::NotFound));

            // и далее формируем заголовок ответа
            response.add_header("host", format!("{}:{}", self.host, self.port));
            response.add_header("server", "micro-http/0.1");
            if keep_alive {
                response.add_header("connection", "keep-alive");
            }

            println!("<<< HTTP/1.1 {}\n{}", response.status_code, response.render_headers());
            write!(stream, "HTTP/1.1 {}\r\n{}\r\n", response.status_code, response.render_headers())?;
            // контент пишем, если нужно
            if let Some(content) = response.content {
                stream.write_all(content.as_slice())?;
            }

            // ну тут очевидно
            if !keep_alive {
                break;
            }
        }

        println!("--- end of connection ---");

        Ok(())
    }

    // тут бы ограничение на число потоков :)
    pub fn run(&self) -> Option<()> {
        let addr = format!("{}:{}", self.host, self.port);

        let listener = TcpListener::bind(&addr).ok()?;
        println!(">>> run server @ {addr}");

        for stream in listener.incoming().flatten() {
            // будем создавать на каждый коннект новый поток
            let app_clone = self.clone();
            thread::spawn(move || {
                if let Some(err) = app_clone.handle_client(stream).err() {
                    // если что-то мы не обработали, то увидим это в логе
                    println!("!!! thread was stopped: {err}");
                }
            });
        }

        Some(())
    }
}

В идеале, в методе run, стоило бы реализовать work-stealing очередь, но для этого нужно использовать библиотеку crossbeam, или писать свою реализацию очереди.

В данной статье обойдёмся текущей кривой реализацией многопоточности.

src/json.rs

use std::collections::HashMap;

use crate::http::Data;

// как это спасает от длинных определений
pub type SimpleJson = HashMap<String, String>;

// easy сериализация json
pub fn serialize(data: SimpleJson) -> Data {
    // просто собери ключи и значения в формате
    //  "key": "value"
    // и заджойни их запятыми
    let mut content = data.into_iter().map(|(k, v)| format!("\"{k}\":\"{v}\"")).collect::<Vec<String>>().join(",");
    // и приправь это скобочками )))
    content.insert(0, '{');
    content.push('}');

    Data::from_content("application/json", content)
}

// а вот десериализация - уже сложнее
pub fn deserialize(data: &[u8]) -> Option<SimpleJson> {
    let mut result = SimpleJson::new();

    // так как контент запроса у нас в байтах, то необходимо его сначала преобразовать в строку
    let data = String::from_utf8(data.to_vec()).ok()?;
    // так как мы не поддерживаем вложенность, то можно поступить следующим образом
    // удаляем скобочки ((( и дальше режем строки по запятым
    for item in data.replace(['{', '}'], " ").split(',') {
        // нужно только разделить ключ и значение
        let (key, value) = item.split_once(':')?;
        // удалить всё лишнее
        let key = key.replace('"', " ").trim().to_string();
        let value = value[1..].replace('"', " ").trim().to_string();
        // и запихнуть в словарь
        result.insert(key, value);
    }

    Some(result)
}

src/random.rs

use std::time::{Duration, SystemTime, UNIX_EPOCH};

#[derive(Default, Clone, Copy)]
pub struct Random {
    init: u32,
}

// в качестве ГПСЧ нам хватит простого xorshift
impl Random {
    pub fn new() -> Random {
        // в качестве seed будем использовать текущее время
        let since_epoch = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or(Duration::from_secs(42));
        Random { init: since_epoch.as_secs() as u32 }
    }

    // "магия" xorshift
    pub fn generate(&mut self) -> u32 {
        self.init ^= self.init << 13;
        self.init ^= self.init >> 17;
        self.init ^= self.init << 5;
        self.init
    }

    // генерация псевдослучайного числа в диапазоне
    pub fn in_range(&mut self, min: i32, max: i32) -> i32 {
        let value = self.generate() % (max - min).unsigned_abs();
        value as i32 + min
    }
}

src/file.rs

use std::fs;

use crate::http::Data;
use crate::status::StatusCode;

fn detect_content_type(filename: &str) -> String {
    // в идеале тут нужно определять mime-type файла по его содержимому, но нам хватит такого варианта
    match filename.rsplit_once('.') {
        Some((_, "css")) => "text/css",
        Some((_, "html")) => "text/html",
        Some((_, "ico")) => "image/x-icon",
        Some((_, "js")) => "text/javascript",
        Some((_, "png")) => "image/x-png",
        // любые другие файлы будут считаться просто потоком байт
        _ => "application/octet-stream",
    }
    .to_string()
}

// формирование response из файла
pub fn response(filename: &str) -> Data {
    match fs::read(filename) {
        Ok(content) => Data::from_content(detect_content_type(filename), content),
        Err(_) => Data::from_status(StatusCode::NotFound),
    }
}

И вот теперь можно запускать и наслаждаться нашим небольшим http сервером!

Не забываем что он доступен на 127.0.0.1:8000.

Заключение

Тут должны быть какие-то выводы, но их не будет.

Всем пока!

Полезные ссылки

Назад