Volver a la p谩gina principal
lunes 5 agosto 2024
42

馃榾 Olv铆date del framework: Construye una API simple en Rust con Hyper 馃榾

Introducci贸n

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.

Configuraci贸n

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 statics 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:

Punto de entrada

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!

Enrutador

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!

HTML

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!().

Estado

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());

Manejadores

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?

Compartir:
Creado por:
Author photo

Jorge Garc铆a

Fullstack developer