Раст хорош, все это знают. Инвестируешь время в язык программирования, кайфуешь, работаешь 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(())
}
Самый легкий итог: в процессе проектирования захотелось чуть более подробно взглянуть на стек токио, надеюсь, вас тоже вдохновит поизучать на досуге какой-либо проектик :)
Dhwtj
Расхотелось
xyli0o Автор
:)