Неочевидные плюсы языка программирования

—

от автора

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

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


ссылка на оригинал статьи https://habr.com/ru/articles/874760/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *