Раст хорош, все это знают. Инвестируешь время в язык программирования, кайфуешь, работаешь c рисками, задаешься вопросом - а может, раст еще круче? Почему бы, например, не изучить солидный проект на плюсах (5k звезд на гитхабе) - и мигрировать его. Вроде, все так делают, но может, суть в миграции идей, а не кода. Быть может, мы найдем ответы о выразительности языка, читаемости и скорости программы. Возможно, нам станет ясно - а друг ли наш компилятор, но скорее всего, мы насладимся эстетикой процесса проектирования приложения на растe.

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

Очень кратко заметки на полях по репозиторию (https://github.com/CrowCpp/Crow):

http_request.h - прекрасный код, короткие функции/методы, много документации (например, без доков фиг разберешь, асинхронный метод или нет).

http_response.h - много вызовов std::move, при случаях не предусмотренных логикой программы - логируем и возвращаем дефолтное значение, много мутабельного кода, флаговое программирование и т.д.

http_connection.h - привет, рекурсия (в природе пока еще нет проекта без рекурсии :)

http_server.h - переизобретение алгоритмов балансировки нагрузки (load balancing) с комментарием "туду", как можно сэкономить еще пару байт.

app.h - точка входа и макросы.
socket_adaptors.h - обертка над сокетом.

routing.h -- показался интересным фрагмент кода:

void handle_upgrade(...) override {
    max_payload_ = max_payload_override_ ? max_payload_ : app_->websocket_max_payload();
    new crow::websocket::Connection<SocketAdaptor, App>(req, std::move(adaptor), app_, max_payload_, ...);
}

Напоминает яву, но конструктор websocket::Connection сам себя и подчищает

...
adaptor_.close();
handler_->remove_websocket(this);
delete this;

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

Часть 1: Пинг-Понг

После долгого и упорного распития крепкого кофе, новостей о доте, шахматах и снукере, еще и телеги (тут никуда) - дизайним на салфетке проект: сокет -> коннекшен -> сервер -> апп (респонс и реквест где-то сбоку, роутер прокидываем через апп).

Как-то так, еще раз: аппликейшн -> сервер -> коннекшен -> сокет. В конце концов, мы хотим видеть такой интерфейс:

route![app, "/", || "Hello world".to_owned()];
route![app, "/measure/<float>/and/<int>", |x,y,_req| format!("{:?} and {:?}" x, y)];

Ходят слухи, что миграцию кода выполняют строчка за строчкой сpp -> rust, а можно и мигрировать с нуля, хотя, как сказал бы Джоэль Спольски - будут баги, да и фиг с ними, проект-то учебный. Также вебсокеты в первой версии имплементить не будем/хотим, и версию хттп протокола возьмем только 1.1. Все показывают великолепные графики с бечмарками, но хоть кто-нибудь сказал бы - знаете, бечмарки у нас нормуль, а код еще лучше - тупо читаем, да и с флагами (которые за 300) мы не балуемся.

Коннекшн.рс

/// connection.rs

pub struct Connection {
	socket: Socket,
}

impl Connection {
	pub fn new(stream: TcpStream) -> Self {
		Connection {
			socket: Socket::new(stream),
		}
	}
}

impl Connection	{
	pub async fn start(&mut self, app: &App) -> Result<(), Box<dyn Error>> {
		let s = self.socket.read_all().await?;
		if !s.is_empty() {
			let req = Parser::new(s).parse().ok_or("failed to parse")?;
			let resp = app.handle(req).await;
	        self.socket.write_all(resp).await?;
	    }
	    Ok(())
	}
}

Для тех, кто не знаком с растом: 1) фьючерсы без await ничего не делают, 2) знаки вопроса - есть неявная распаковка Result или Option.

Простейший из возможных парсеров хттп запроса

/// parser.rs

pub struct Parser<'a> {
	s: &'a str,
}

impl <'a> Parser<'a> {
	pub fn new(s: &'a str) -> Self {
		Parser { s }
	}
}

impl Parser<'_> {
	pub fn parse(self) -> Option<Request> {
		let (mut info, mut headers) = (vec![], vec![]); 
		let mut iter = self.s.split("\r\n");
		for x in iter.next()?.split(' ') { 
			info.push(x); 
		}
		for x in iter.by_ref() {
			if x.is_empty() { break; }
			let mut b = x.split(": ");
			headers.push((
				b.next()?.to_owned(), 
				b.next()?.to_owned()
			));
		}
		let body = iter.next()?.to_owned(); 
		Request::build(info, headers, body)
	}
}

Два момента: 1) метод parse поглотит обьект (self без амперсанда) - гуд при проектировании; 2) lifetime (который апостроф) не позволяет заюзать ссылку на буфер сокета (через ссылку на строку) после его (буфера) удаления.

Реквест.pc - собственно, основная информация, которую мы хотим передать клиентскому коду.

/// request.rs

#[derive(Default, Debug, Clone)]
pub struct Request {
	pub method: String,
	pub url: String,
	pub version: String,
	pub query: String,
	pub headers: Vec<(String, String)>,
	pub body: String,  
}

fn parse_url(s: &str) -> (String, String) {
	if let Some(i) = s.find('?') {
		(s[..i].to_owned(), s[i+1..].to_owned())
	} else {
		(s.to_owned(), "".to_owned())
	}
}

impl Request {
	pub fn build(mut info: Vec<&str>, headers: Vec<(String, String)>, body: String) -> Option<Self> {
		let version = info.pop()?.to_owned();
		let (url, query) = parse_url(info.pop()?);
		let method = info.pop()?.to_owned();
		Some(Request {method, url, query, version, headers, body})
	}
}

Респонс для простоты имплементации сделаем стрингом (String).

Возвращаемся к сокетам - Сокет.рс

/// socket.rs

pub struct Socket {
	stream: TcpStream,
	buf: [u8; 1024],
}

impl Socket {
	pub fn new(stream: TcpStream) -> Self {
	    Socket { stream, buf: [0; 1024] }
	}
}

impl Socket {

	pub async fn read_all(&mut self) -> Result<&str, Box<dyn Error>> {
		let n = self.stream.read(&mut self.buf).await?;
		Ok(std::str::from_utf8(&self.buf[..n])?)
	}

	pub async fn write_all(&mut self, s: String) -> Result<(), Box<dyn Error>> {
		let x = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\n", s.len());
		self.stream.write_all(&x.into_bytes()).await?;
		self.stream.write_all(&s.into_bytes()).await?;
		Ok(())
	}
}

Пару моментов: 1) допускаем, что буфер вместит весь реквест в байтах, 2) размещаем буфер вместе с сокетом из-за простоты имплементации, 3) размер буфера фиксирован также для простоты, 4) минимальные хедера.

Самое интересное на сервере - Сервер.рс

/// server.rs

pub struct Server {
	listener: TcpListener,
}

impl Server {
	pub async fn bind(addr: &str) -> Result<Server, Box<dyn Error>> {
		let listener = TcpListener::bind(&addr).await?;
	    println!("Listening on: {addr}");
	    Ok(Server{listener})
	}

	pub async fn accept(&mut self, app: &'static App) -> Result<(), Box<dyn Error>> {
		loop {
			let (stream, _) = self.listener.accept().await?;
			tokio::spawn(async move {
				let mut p = Connection::new(stream);
				p.start(app).await.unwrap();
			});
		}
	}
}

Ребята из CrowCpp реализовали свой (на базе boost::asio) балансировщик нагрузки, у раста немного веселее - есть tokio рантайм. Это такой рантайм, который берет на себя координацию асинхронных тасков, балансировку (самую базовую) и т.д.

Из доков tokio::spawn
Spawning a task enables the task to execute concurrently to other tasks. The spawned task may execute on the current thread, or it may be sent to a different thread to be executed.

Еще доки о токийском рантайме:
The multi-thread scheduler executes futures on a thread pool, using a work-stealing strategy. By default, it will start a worker thread for each CPU core available on the system. This tends to be the ideal configuration for most applications.

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

Центральная структурка - aпп.рс

/// app.rs

#[derive(Default)]
pub struct App {
	map: Trie<fn(Request, Vec<RouteTokens>) -> String>,
}

unsafe fn make_static<T>(t: &T) -> &'static T {
    std::mem::transmute(t)
}

impl App {
	pub async fn start(&self, addr: &str) -> Result<(), Box<dyn Error>> {
	    let mut server = Server::bind(addr).await?;
	    // safe: app should be alive before termination
	    server.accept(unsafe { make_static(self) }).await?;
    	Ok(())
	}

	pub async fn handle(&self, req: Request) -> String {
		let b = req.url.split('/').filter(|s| !s.is_empty());
		self.map.get(b)
			.map(|(fun, args)| fun(req, args))
			.unwrap_or("Not found".to_owned())
	}

	pub fn service(&mut self, key: &'static str, fun: fn(Request, Vec<RouteTokens>) -> String) {
		let b = key.split('/').filter(|s| !s.is_empty());
		self.map.insert(b, fun);
	}
}

Тут юзаем префиксное дерево (Trie, нужное для роутеров), чуть-чуть unsafe кода (и коммент, почему это OK) и ограничения, добавленные системой типов: 1) fn(...) - есть non-capturing closures, то есть ламбде не позволят захватить контекст извне, 2) key &'static str - путь в роутере пишем (в большинстве случаев) ручками (хз, хорошо это или плохо, но для примера).

Префиксное дерево (опять же, простейшая имплементация)

/// trie.rs

pub struct Trie<T> {
	head: Node<T>, 
}

struct Node<T> {
	value: Option<T>,
	map: HashMap<&'static str, Node<T>> 
}

impl<T> Default for Trie<T> {
	fn default() -> Self {
		let head = Node { value: None, map: HashMap::new() };
		Trie { head }
	}
}

impl<T> Trie<T> {
	pub fn insert(&mut self, v: impl IntoIterator<Item = &'static str>, elem: T) {
		let mut node = &mut self.head;
		for s in v {
			if node.map.contains_key(s) { 
				node = node.map.get_mut(s).unwrap();
			} else {
				let next = Node { value: None, map: HashMap::new() };
				node.map.insert(s, next);
				node = node.map.get_mut(s).unwrap(); 
			}
		}
		node.value = Some(elem);
	}

	pub fn get<'a>(&self, v: impl IntoIterator<Item = &'a str>) -> Option<(&T, Vec<RouteTokens>)> {
		let mut node = &self.head;
		let mut args = vec![];
		for s in v {
			let (s, token) = mapping(s);
			if node.map.contains_key(s) { 
				node = node.map.get(s).unwrap();
			} else {
				return None;
			}
			if token != RouteTokens::NaN { args.push(token); }
		}
		node.value.as_ref().map(|elem| (elem, args))
	}
}

#[derive(Debug, PartialEq, Copy, Clone)]
pub enum RouteTokens { Int(i32), Usize(usize), Float(f32), NaN }

fn mapping(s: &str) -> (&str, RouteTokens) {
	use RouteTokens::{ Int, Usize, Float, NaN };
	let is_usize = s.starts_with(|c: char| c.is_ascii_digit());
	// bug: fix me -> is_int
	let is_int = s.starts_with('-') && s[1..].starts_with(|c: char| c.is_ascii_digit());
	let is_float = (is_usize || is_int) && s.contains('.');
	match [is_float, is_usize, is_int] {
		[true, _, _] => ("<float>", Float(s.parse::<f32>().unwrap())),
		[_, true, _] => ("<usize>", Usize(s.parse::<usize>().unwrap())),
		[_, _, true] => ("<int>", Int(s.parse::<i32>().ok().unwrap())),
		_ => (s, NaN),
	}
}

Про RouteTokens будет чуть ниже. Собираем данные (int, float и т.д.) из урла и передаем в ручку. Ребята из CrowCpp применяют интересную оптимизацию: сжимают путь в дереве, если на пути у каждой ноды только один чайлд. То есть получаем всего один запрос в хештайбл по полному пути. (В репозитории СrowCpp используется вектор вместо хеш-таблицы - там более честная реализация Trie :) Еще момент про аллокации - все говорят, копирование это плохо (редко, правда, говорят, насколько плохо), но чисто по-человечески - если можно не тратить ресурсы, лучше их не тратить (см. интерфейс IntoIterator).

Макросы - роутинг.рс

/// routing.rs

#[macro_export]
macro_rules! route {
    ( $app:ident, $path:expr, || $handler:expr) => {
		$app.service(
			$path, 
			|_req, _args| $handler
		) 
    };
    ( $app:ident, $path:literal, |$( $x:ident $(:$t:ty)? ),+| $handler:expr ) => {
        {
        	$app.service(
        		$path, 
        		|req, args| $crate::make_call![|$($x,)+| $handler, args, req]
        	)
        }
    };
}

#[macro_export]
macro_rules! make_call {
    (|$a:ident,| $handler:expr, $args:ident, $req: ident) => {{
		(move |$a: Request|$handler)($req)
    }};
    (|$a:ident,$b:ident,| $handler:expr, $args:ident, $req:ident) => {{ 
		(move |$a, $b: Request|$handler)($args[0], $req)
    }};
    (|$a:ident,$b:ident,$c:ident,| $handler:expr, $args:ident, $req:ident) => {{ 
    	(move |$a,$b,$c: Request|$handler)($args[0], $args[1], $req)
    }};
}

Корректное исполнение макросов, как мне кажется, требует работы с потоком токенов - и прекрасный раст дает такую возможность. К сожалению, требует заморочек с созданием отдельной либы под макрос и т.д. Логика в следующем - парсим строку роута, прокидываем типы параметров в анонимную функцию. К счастью, у раста есть и простые макросы (как представлено выше) - тип аргумента, к сожалению, у всех кроме последнего параметра будет RouteTokens (Int(i32), Float(f32) и т.д.), и это можно улучшить (сейчас тупо лень) продвинутым макросом (в расте он называется procedural macros, а макросы выше - declarative macros).

Наконец, клиентский код - мэйн.рс

/// main.rs

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
    let addr = env::args()
        .nth(1)
        .unwrap_or_else(|| "0.0.0.0:8080".to_owned());
    
    let mut app = App::default();

    route![app, "/", || "Hello world".to_owned()];
    route![app, "/products/<usize>", |id,req| { format!("url {:?} and id {:?}", req.url, id) }];
    route![app, "/measure/<float>/and/<int>", |x,y,_req| format!("{:?} and {:?}", x, y)];
    route![app, "/pong/", |req| format!("XXX {:?}", req.body) ];

    app.start(&addr).await?;
    Ok(())
}

Простейший сервер готов :)

Часть 2: Нечего терять

Мы что-то потеряли, точнее, опустили некоторые публичные (и не только) поля обьектов в первом приближении. Для рабочего прототипа они были не особо нужны, но сейчас самое время. Смотрим в код.

Заметки на полях (очень коротко), какие еще фичи в репозитории:

http_request.h - ссылка на мидeлвaэр
http_response.h - допускаем, что респонс у нас только строкой - пропускаем (ну там не много интересного, если честно)

http_connection.h - 1) таймаут таймер (с обязательным комментарием туду, как улучшить), 2) ссылка на мидeлвaэр

http_server.h - тик_интервал и коллбек с ним, мутексы, таск_таймер_пул - не понадобятся в нашей версии. Что понадобится: тайм-аут, сигналы и мидeлвaэр.

app.h - настройки и надстройки над сервером

routing.h - blueprint (грубо говоря, пачка роутеров под общим префиксом) - скорее всего, делать не будем (лень)

Миделваэр

pub trait Middleware: Default + Send + Sync + 'static {
    fn run(&self, req: &mut Request);
}

pub trait Router {
    fn handle(&self, req: Request) -> impl Future<Output = String> + Send;
}

/// app.rs
/// --** some code before **--

macro_rules! routes_impls {
    ( $x:ident, $( $y:ident $(,)?)* ) => {
        impl<$x: Middleware, $( $y: Middleware ),*> Router for &App<($x, $( $y ),*)> {
			fn handle(&self, mut req: Request) -> impl Future<Output = String> + Send {
				req.context = Some(HashMap::new());
				$x::default().run(&mut req);
				$(
					$y::default().run(&mut req);
				)*
				App::<($x, $( $y ),*)>::handle(self, req)
			}
        }
        routes_impls!($( $y, )*);
    };

    () => {
		impl Router for &App<()> {
			fn handle(&self, req: Request) -> impl Future<Output = String> + Send {
				App::<()>::handle(self, req)
			}
		}
    };
}

routes_impls![A,B,C,D, E,F,G,H, I,J,K,L, M,N,O,P,Q];


/// request.rs

#[derive(Default, Debug)]
pub struct Request {
	// *( some code before )* 
	pub context: Option<HashMap<TypeId, Box<dyn Any + Send>>>,
}

Соответственно клиентская структурка, которая хочет перехватывать реквест, имплементит трайт Мидeлваэр. Далее передаем тип нашей структурки в аппу - например App<(CORS, Xkey, CookieParser)> - профит.

В расте, к сожалению, нет variadic template - поэтому реализуем массив дженериков таплами (tuple) различной длины. В коде выше с помощью макросов представлена реализация до 17 аргументов в тапле.

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

Новое на сервере

/// server.rs
impl Server {

	/// --&&-- some code before --&&-- 

	pub async fn accept(&mut self, app: impl Router + Send + Copy + 'static) -> Result<(), Box<dyn Error>> {
		self.listen_signals();
		loop {
			let (stream, _) = self.listener.accept().await?;
			let task = tokio::spawn(timeout(self.timeout, async move {
				let mut p = Connection::new(stream);
				p.start(app).await.unwrap();
			}));
			self.register(task);
		}
	}

	pub async fn shutdown(&mut self) {
		for t in self.tasks.drain(..) { let _ = t.await; }
	}

	fn register(&mut self, t: JoinHandle<Result<(), error::Elapsed>>) {
		self.tasks.push(t);
		let (k, next_round) = self._counter.overflowing_add(1);
		self._counter = k;
		if next_round { self.tasks.retain(|t| !t.is_finished()); }
	}

	fn listen_signals(&mut self) {
		// safe: `server` should be alive before termination
		let this = unsafe { make_mut_static(self) };
		tokio::spawn(async move {
		    tokio::signal::ctrl_c().await.unwrap();
		    println!("\nCTRL-C");
		    println!("Shutdown... waiting for the tasks to complete");
		    this.shutdown().await;
		    println!("Done, thx :)");
		    std::process::exit(0);
		});
	}
} 

Тут юзаем сигналы и таймаут-обертку над фьючерсами из токио, чуть-чуть оптимизируем остановку сервера (ожиданием всех тасок из очереди) и в принципе все :)

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

Часть 3: Обмерки

Настало время мериться: cравниваем "hello world" и "два плюс два" аргументы из урла. Добавим nodejs (версии 20) за базу для сравнения (ребята из crowCpp говорят, что рвут ноду в хлам). Не забываем, что учебный проект медленнее готового продакшн-решения. Считаем приблизительно (для любителей точных данных в репе cppCrow есть ссылки на правильные бенчмарки) и (еще раз) относительно nodejs.

ab -n 32768 -c 128 "http://0.0.0.0:18080/"

Параметр "Цэ"
-c concurrency  Number of multiple requests to make at a time

Результаты:

nodejs c=128 -> OK
nodejs c=256 -> Err

candidate c=128 -> OK
candidate c=256 -> OK
candidate c=512 -> Err

canditate without timeout c=512 -> OK
canditate without timeout c=625 -> OK
canditate without timeout c=725 -> Err

cppCrow c=128 -> OK
cppCrow c=256 -> OK
cppCrow c=512 -> OK
cppCrow c=725 -> OK
cppCrow c=1024 -> Err

Тут стоит отметить что обертка timeout так нехило сьедает производительность.

Код клиентов:

app.get('/', (req, res) => {  
  res.send('Hello World!')  
})  
  
app.get('/:a/plus/:b', (req, res) => {  
  let a = Number(req.params.a)  
  let b = Number(req.params.b)  
  res.send(`${a+b}`)  
})  

const port = 18080
app.listen(port, () => {  
  console.log(`Example app listening on port ${port}`)  
})
int main() {
    crow::SimpleApp app;

    CROW_ROUTE(app, "/")([](){
        return "Hello world";
    });

    CROW_ROUTE(app,"/<int>/plus/<int>")
    ([](int a, int b){
        std::ostringstream os;
        os << a+b;
        return crow::response(os.str());
    });

    app.port(18080).multithreaded().run();
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
    let addr = env::args()
        .nth(1)
        .unwrap_or_else(|| "0.0.0.0:18080".to_owned());
    
    let mut app = SimpleApp::default();
    
    route![app, "/", || "Hello world".to_owned()];
    
    route![app, "<usize>/plus/<usize>", |a,b,_req| { 
        use RouteTokens::Usize;
        match (a,b) {
            (Usize(x), Usize(y)) => format!("{}", x+y),
            _ => panic!("hmm..."),
        }
    }];

    app.start(&addr).await?;
    Ok(())
}

Самый легкий итог: в процессе проектирования захотелось чуть более подробно взглянуть на стек токио, надеюсь, вас тоже вдохновит поизучать на досуге какой-либо проектик :)

Комментарии (6)


  1. Dhwtj
    19.01.2025 15:51

    Расхотелось


    1. xyli0o Автор
      19.01.2025 15:51

      :)


  1. segment
    19.01.2025 15:51

    Раст хорош, все это знают.

    Чем хорош?


    1. Vad344
      19.01.2025 15:51

      Кошки лучше, чем собаки - (с).


    1. Ydav359
      19.01.2025 15:51

      Отсутствием тонн легейси и устаревших практик


      1. segment
        19.01.2025 15:51

        Устаревшие практики это какие?