Hyper es una implementación HTTP de bajo nivel. Proporciona tipos de Cliente y Servidor y expone el runtime asíncrono Tokio sobre el cual está construido. También utilizaremos otros crates, pero aún así, nada que se asemeje a un framework completo.
Necesitarás obtener una herramienta estable de Rust. Si necesitas una, visita rustup. Una vez instalada, inicia un nuevo proyecto ejecutable:
$ cargo new simple-todo
$ cd simple-todo
$ cargo run
Compiling simple-todo v0.1.0 (/home/ben/code/simple-todo)
Finished dev [unoptimized + debuginfo] target(s) in 1.30s
Running `target/debug/simple-todo`
Hello, world!
Abre tu nuevo directorio simple-todo
en tu editor favorito. Antes de sumergirnos en el código, definamos nuestras dependencias. Haz que tu Cargo.toml
se vea así:
[package]
name = "simple-todo"
version = "0.1.0"
authors = ["Tú <tu@tuwebgenial.com>"]
edition = "2018"
[dependencies]
futures = "0.1"
hyper = "0.12"
lazy_static = "1.3"
log = "0.4"
pretty_env_logger = "0.3"
serde = "1.0"
serde_derive = "1.0"
tera = "0.11"
[dependencies.uuid]
features = ["serde", "v4"]
version = "0.7"
Además de hyper
, estamos usando un par de crates auxiliares. En resumen, futures
proporciona primitivas de programación asíncrona de costo cero, lazy_static
nos permitirá definir static
s que requieren inicialización en tiempo de ejecución (como Vec::new()
), log
y pretty_env_logger
proporcionan registro, serde
y serde_derive
son para serialización, tera
realiza la plantilla HTML a partir de archivos de plantilla similares a Jinja y Uuid
proporciona, bueno, uuids. Estos crates proporcionan nuestros bloques de construcción básicos.
Este es un programa pequeño que se definirá completamente en main.rs
. Abre ese archivo y elimina la declaración println!
del template de cargo new
y pon en marcha el registro en su lugar:
fn main() {
pretty_env_logger::init();
}
Ten en cuenta que en Rust 2018 podemos omitir las declaraciones extern crate
a menos que necesitemos importar una macro.
Antes de que podamos configurar el servidor, necesitamos una dirección para enlazar. Simplemente la codificaremos para esta demostración. Agrega esta línea justo debajo de la inicialización:
let addr = "127.0.0.1:3000".parse().unwrap();
El método parse()
devolverá un std::net::SocketAddr.
A continuación, necesitaremos agregar algunas importaciones en la parte superior del archivo:
use futures::{future, Future, Stream};
use hyper::{
client::HttpConnector, rt, service::service_fn, Body, Client, Request,
Response, Server
};
Ahora podemos finalizar main()
:
rt::run(future::lazy(move || {
// crear un Cliente para todos los Servicios
let client = Client::new();
// definir un servicio que contenga la función del enrutador
let new_service = move || {
// Clonar una copia del Cliente en el service_fn
let client = client.clone();
service_fn(move |req| router(req, &client))
};
// Definir el servidor - esto es a lo que resolverá future_lazy()
let server = Server::bind(&addr)
.serve(new_service)
.map_err(|e| eprintln!("Error del servidor: {}", e));
println!("Escuchando en http://{}", addr);
server
}));
Esto no pasará el chequeo de tipos aún - para que compile, puedes agregar el siguiente fragmento para la función router
que mencionamos en la llamada service_fn
:
fn router(req: Request<Body>, _client: &Client<HttpConnector>) -> Box<Future<Item = Response<Body>, Error = Box<dyn std::error::Error + Send + Sync>> + Send> {
unimplemented!()
}
Todo esto es un poco más complejo, desmenucémoslo. Este fragmento vive dentro de una llamada a rt:run()
. Aquí rt
significa runtime, y se refiere al runtime predeterminado de Tokio. Inmediatamente, nuestro programa se pondrá en marcha y entrará en este entorno asíncrono.
Dentro, llamamos a future::lazy
, que acepta un cierre y devuelve un Future
que se resolverá a él. El resto de la definición está en este cierre y tiene unos pocos pasos. Construimos un Cliente
hyper, capaz de hacer solicitudes HTTP salientes.
El siguiente paso es crear un Servicio
. Este es un rasgo que representa una función asíncrona de una solicitud a una respuesta: ¡exactamente lo que necesita nuestro servidor web para manejar! En lugar de implementar este rasgo a mano, simplemente definiremos esta función nosotros mismos (en este caso, es router()
) y usaremos el helper service_fn
para convertir la función en un Servicio
. Luego, todo lo que necesitamos hacer es crear el Servidor
en sí, que se enlaza a la dirección que proporcionamos, y hacer que sirva este servicio.
Eso es prácticamente todo. Ahora nuestro trabajo es simplemente definir las respuestas, ¡que es tu trabajo de todos modos, con o sin framework!
Primero, echemos un vistazo a esa firma de router()
. Desagradable, ¿verdad? Haz algunos alias de tipos debajo de tus importaciones:
type GenericError = Box<dyn std::error::Error + Send + Sync>;
type ResponseFuture = Box<Future<Item = Response<Body>, Error = GenericError> + Send>;
fn router(req: Request<Body>, _client: &Client<HttpConnector>) -> ResponseFuture {
unimplemented!()
}
Cada vez que queremos dar una respuesta a una conexión, debe entregarse como un Response
envuelto en un Future
envuelto en un Box
: ¡definitivamente es una buena idea hacer eso más fácil de escribir! Ahora podemos comenzar a definir rutas. Antes de comenzar, agrega Body
, Method
y StatusCode
a la lista de importaciones de hyper
.
Podemos aprovechar el pattern matching de Rust para despachar correctamente las respuestas:
match (req.method(), req.uri().path()) {
(&Method::GET, "/") | (&Method::GET, "index.html") => unimplemented!(),
_ => four_oh_four(),
}
Estamos haciendo coincidir tanto el método como la ruta a la vez: una solicitud POST a "/" no coincidiría con esta rama. Podemos agregar tantos brazos de coincidencia como requiera la aplicación aquí, y cualquier solicitud entrante que no tenga un brazo correspondiente recibirá la respuesta four_oh_four()
:
static NOTFOUND: &[u8] = b"Oops! Not Found";
fn four_oh_four() -> ResponseFuture {
let body = Body::from(NOTFOUND);
Box::new(future::ok(
Response::builder()
.status(StatusCode::NOT_FOUND)
.body(body)
.unwrap(),
))
}
Como se esperaba, esta función devuelve un ResponseFuture
. Para la página 404, simplemente usaremos este valor estático como el cuerpo. El future::ok
devuelve un futuro que se resuelve de inmediato y utilizamos el patrón builder para construir una Response
. ¡Hay enums de hyper
configurados para cosas como StatusCode
para una máxima corrección!
Para construir una página de índice, usaremos tera que proporciona plantillas HTML similares a Jinja2. Vamos a necesitar una macro, y esto se configurará como una estática, así que necesitamos algunas declaraciones:
#[macro_use]
extern crate lazy_static;
#[macro_use]
extern crate tera;
// ...
use tera::{Context, Tera};
El proyecto requiere que cada implementación use la misma plantilla. Esta publicación no trata sobre Jinja2 o HTML, así que solo te dirigiré a descargarla aquí y guardarla en simple-todo/templates/index.html. También querrás guardar todo.css en simple-todo/src/resource/todo.css.
Tera es increíblemente fácil de usar. Agrega el siguiente fragmento:
lazy_static! {
pub static ref TERA: Tera = compile_templates!("templates/**/*");
}
Voila, plantillas. Ahora podemos escribir index()
:
fn index() -> ResponseFuture {
let mut ctx = Context::new();
let body = Body::from(TERA.render("index.html", &ctx).unwrap().to_string());
Box::new(future::ok(Response::new(body)))
}
Para inyectar datos en una plantilla de Tera, los pones en un tera::Context
y pasas tanto la ruta de la plantilla como este contexto a render()
. ¡Luego simplemente envolvemos la cadena resultante en un ResponseFuture
! No olvides actualizar el brazo de coincidencia en router()
para llamar a esta función en lugar de unimplemented!()
.
Hay un problema, sin embargo: ¡no hemos puesto datos en el contexto! Si ejecutas este programa, se bloqueará al cargar esta plantilla, quejándose de que todos
y todosLen
no se encuentran en el contexto. Es una queja increíblemente válida, no están allí.
Mantener el estado en una aplicación asíncrona como esta podría ser un problema complicado, pero esto es Rust. ¡Tenemos std::sync para jugar! Específicamente, vamos a usar la combinación de Arc y RwLock para almacenar nuestras tareas de manera segura a través de hilos sin siquiera pensarlo demasiado.
Primero, las adiciones de importaciones:
#[macro_use]
extern crate serde_derive;
// ...
use std::sync::{Arc, RwLock};
use uuid::Uuid;
Ahora, el tipo Todo:
#[derive(Debug, Serialize)]
pub struct Todo {
done: bool,
name: String,
id: Uuid,
}
impl Todo {
fn new(name: &str) -> Self {
Self {
done: false,
name: String::from(name),
id: Uuid::new_v4(),
}
}
}
El método new_v4()
generará aleatoriamente un identificador único para cualquier nuevo Todo
. También agrega un nuevo alias de tipo para la lista de todos los todos:
type Todos = Arc<RwLock<Vec<Todo>>>;
Ahora podemos instanciarlo en el bloque lazy_static!
:
lazy_static! {
pub static ref TERA: Tera = compile_templates!("templates/**/*");
pub static ref TODOS: Todos = Arc::new(RwLock::new(Vec::new()));
}
Necesitaremos algunas funciones auxiliares para manipular la lista:
fn add_todo(t: Todo) {
let todos = Arc::clone(&TODOS);
let mut lock = todos.write().unwrap();
lock.push(t);
}
fn remove_todo(id: Uuid) {
let todos = Arc::clone(&TODOS);
let mut lock = todos.write().unwrap();
// encontrar el índice
let mut idx = lock.len();
for (i, todo) in lock.iter().enumerate() {
if todo.id == id {
idx = i;
}
}
// eliminar ese elemento si se encuentra
if idx < lock.len() {
lock.remove(idx);
}
}
fn toggle_todo(id: Uuid) {
let todos = Arc::clone(&TODOS);
let mut lock = todos.write().unwrap();
for todo in &mut *lock {
if todo.id == id {
todo.done = !todo.done;
}
}
}
Cuando llamas a Arc::clone()
, crea un nuevo puntero a los mismos datos, aumentando su recuento de referencias. Luego tomamos un bloqueo de escritura en el RwLock
subyacente, después de lo cual podemos manipular de manera segura el Vec
dentro. Usando estos ayudantes, nuestros manejadores de rutas pueden manipular el estado de manera segura de una manera que se garantiza que sea segura para hilos. Finalmente, podemos construir el contexto, de vuelta en index()
justo después de definir ctx
:
let todos = Arc::clone(&TODOS);
let lock = todos.read().unwrap();
ctx.insert("todos", &*lock);
ctx.insert("todosLen", &(*lock).len());
Ahora, ejecutar la aplicación y apuntar tu navegador a localhost:3000
debería mostrar el HTML dado (sin la hoja de estilo).
El resto de la aplicación es fácil. Simplemente necesitamos completar el resto de los manejadores. Por ejemplo, para cargar la hoja de estilo faltante, necesitas un nuevo brazo de coincidencia:
(&Method::GET, "/static/todo.css") => stylesheet(),
Así como una función para construir la respuesta:
fn stylesheet() -> ResponseFuture {
let body = Body::from(include_str!("resource/todo.css"));
Box::new(future::ok(
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/css")
.body(body)
.unwrap(),
))
}
¡Nada sorprendente allí! Cada manipulación de la lista de todos también tiene un endpoint:
(&Method::POST, "/done") => toggle_todo_handler(req),
(&Method::POST, "/not-done") => toggle_todo_handler(req),
(&Method::POST, "/delete") => remove_todo_handler(req),
(&Method::POST, "/") => add_todo_handler(req),
Estos manejadores tienen el mismo formato:
fn add_todo_handler(req: Request<Body>) -> ResponseFuture {
Box::new(
req.into_body()
.concat2() // concatenar todos los fragmentos en el cuerpo
.from_err() // como try! para Result, pero para Futures
.and_then(|whole_body| {
let str_body = String::from_utf8(whole_body.to_vec()).unwrap();
let words: Vec<&str> = str_body.split('=').collect();
add_todo(Todo::new(words[1]));
redirect_home()
}),
)
}
Esto es un poco más complicado. Necesitamos leer la solicitud y luego actuar sobre ella. En este caso, el cuerpo de la solicitud almacenado en str_body
se verá algo así como item=TodoName
. Hay soluciones más robustas, pero simplemente estoy dividiendo en el =
y llamando a la función add_todo
en el resultado para agregarlo a la lista. Luego redirigimos a casa, y cada vez que volvemos a casa se llama a index()
, que reconstruye el HTML desde el estado actual de la aplicación. Los manejadores toggle
y remove
son casi equivalentes, simplemente llamando a la función adecuada.
La redirección tampoco es sorprendente:
fn redirect_home() -> ResponseFuture {
Box::new(future::ok(
Response::builder()
.status(StatusCode::SEE_OTHER)
.header(header::LOCATION, "/")
.body(Body::from(""))
.unwrap(),
))
}
Esto se ve como lo que escribirías en cualquier toolkit. La aplicación también incluye algo de SVG:
(&Method::GET, path_str) => image(path_str),
fn image(path_str: &str) -> ResponseFuture {
let path_buf = PathBuf::from(path_str);
let file_name = path_buf.file_name().unwrap().to_str().unwrap();
let ext = path_buf.extension().unwrap().to_str().unwrap();
match ext {
"svg" => {
// construir la respuesta
let body = {
let xml = match file_name {
"check.svg" => include_str!("resource/check.svg"),
"plus.svg" => include_str!("resource/plus.svg"),
"trashcan.svg" => include_str!("resource/trashcan.svg"),
"x.svg" => include_str!("resource/x.svg"),
_ => "",
};
Body::from(xml)
};
Box::new(future::ok(
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "image/svg+xml")
.body(body)
.unwrap(),
))
}
_ => four_oh_four(),
}
}
Eso es todo. Para agregar más rutas, simplemente agregas un nuevo brazo de coincidencia a router()
y escribes una función que devuelve un ResponseFuture
. Esta es una base sólida y eficiente que se puede extender fácilmente de muchas maneras, porque no estás atado a ningún patrón específico predeterminado. En general, escribir un servidor usando hyper
simple en lugar de un framework de alto nivel no es realmente mucho menos ergonómico para casos de uso simples y reduce una cantidad seria de sobrecarga de tu aplicación. Mi framework favorito actual es actix-web, pero es casi absurdo cuánto más grandes son los binarios y cuánto más tarda una compilación en frío. Cuando el objetivo final es lo suficientemente simple, ¿por qué no usar herramientas simples?
Jorge García
Fullstack developer