Неочевидные плюсы языка программирования
Раст хорош, все это знают. Инвестируешь время в язык программирования, кайфуешь, работаешь 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(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//and/", |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> {
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 {
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 {
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> {
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> {
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> {
let listener = TcpListener::bind(&addr).await?;
println!("Listening on: {addr}");
Ok(Server{listener})
}
pub async fn accept(&mut self, app: &'static App) -> Result<(), Box> {
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: spawnSpawning 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) -> String>,
}
unsafe fn make_static(t: &T) -> &'static T {
std::mem::transmute(t)
}
impl App {
pub async fn start(&self, addr: &str) -> Result<(), Box> {
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) -> 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 {
head: Node,
}
struct Node {
value: Option,
map: HashMap<&'static str, Node>
}
impl Default for Trie {
fn default() -> Self {
let head = Node { value: None, map: HashMap::new() };
Trie { head }
}
}
impl Trie {
pub fn insert(&mut self, v: impl IntoIterator- , 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
- ) -> Option<(&T, Vec
)> {
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(s.parse::().unwrap())),
[_, true, _] => ("", Usize(s.parse::().unwrap())),
[_, _, true] => ("", Int(s.parse::().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> {
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/", |id,req| { format!("url {:?} and id {:?}", req.url, id) }];
route![app, "/measure//and/", |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
Соответственно клиентская структурка, которая хочет перехватывать реквест, имплементит трайт Мид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> {
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>) {
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,"//plus/")
([](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> {
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, "/plus/", |a,b,_req| {
use RouteTokens::Usize;
match (a,b) {
(Usize(x), Usize(y)) => format!("{}", x+y),
_ => panic!("hmm..."),
}
}];
app.start(&addr).await?;
Ok(())
}
Самый легкий итог: в процессе проектирования захотелось чуть более подробно взглянуть на стек токио, надеюсь, вас тоже вдохновит поизучать на досуге какой-либо проектик:)