38

Cómo crear un blog con Rust, Actix y Diesel

29077Puntos

hace 3 años

Rust es un lenguaje tremendamente versátil, prueba de esto es que podemos hacer videojuegos, ciencias de datos, crypto, aplicaciones de sistemas o como en el caso que nos ocupa; web apps y APIs.

Hola soy Hector Pulido y en este blogpost te voy a enseñar a crear un Blog con Rust, te llevare desde la elección de la librerías hasta el deploy (en heroku por que toi chikito 🤕).

Curiosamente en Rust no tenemos un equivalente de Django, más bien tenemos varios Flasks (si vuestro paralelo es Python, como en mi caso). En este caso vamos a usar un web framework llamado Actix combinado con un ORM llamado diesel y un manager de templates llamado Tera.

Como aprendimos en un post pasado sobre Rust; vamos a generar un nuevo proyecto de Rust usando el comando cargo new mi_web_app si no tienes instalado Cargo/Rust revisa el post anterior.

Vamos a trabajar primero con Actix, para añadirlo puedes abrir tu archivo cargo.toml y añadir las siguientes líneas:

[dependencies]
actix-web = "3"

con actix instalado en el proyecto puedes ir a tu archivo main.rs y añadir estas líneas:

use actix_web::{get, App, HttpResponse, HttpServer, Responder};

#[get("/")]
async fnhello() ->impl Responder {
    HttpResponse::Ok().body("Hello world!")
}
#[actix_web::main]
async fnmain() -> std::io::Result<()> {
    HttpServer::new(move || App::new().service(hello))
        .bind("127.0.0.1:8080")?
        .run()
        .await
}

Con esto ahora tenemos un hola mundo web listo para funcionar, carga tu programa con cargo run y abre http://127.0.0.1:8080/, si has entendido esto, ¡felicidades! ahora es tiempo de subir un poco el nivel, intenta cambiar un poco el código para añadir otro endpoint, uno que te permita añadir un parámetro en la url, puedes encontrar más info en la documentación de actix.

Base de datos y ORM Diesel

Dejemos los endpoints por un momento de lado, vamos con la base de datos, necesito que tengas acceso a alguna una base de datos postgres abierta y disponible, vamos a usar el ORM Diesel, nos permitirá conectarnos a la base de datos y usarla como si de objetos e instancias se tratase. Añadamos a el cargo.toml las siguientes líneas:

[dependencies]
...
diesel = { version = "1.4.4", features = ["postgres", "r2d2"] }
dotenv = "0.15.0"

Crea un archivo llamado .env en la carpeta raíz del proyecto y ponemos una linea con:

DATABASE_URL=TU_URL_DE_POSTGRES

Para iniciar vamos a instalar Diesel cli con este comando cargo install diesel_cli --no-default-features --features postgres (asegurate de tener postgres instalado) la instalacion es muy sencilla, solo sigue los pasos.

Para iniciar hagamos un setup del proyecto, inicializamos el schema con diesel setup dentro de la carpeta del proyecto, crearemos una primera migración con diesel migration generate posts esto generará nuestra migración. Se generará una carpeta con un nombre raro como 2021-08-11-105320_posts dentro dos scripts de Rust llamados up.rs y down.rs dentro de up pondremos:

CREATE TABLE posts (
	id SERIAL PRIMARY KEY,
	title VARCHAR NOT NULL,
	slug varchar NOT NULL unique,
	body TEXT NOT NULL
)

Como te darás cuenta es SQL simple, crear una tablita llamada posts, este es casi casi el único sql que escribiremos a partir de ahora; las migraciones. Para correr la migración podemos hacerlo con diesel migration run , Esto generará un archivo schema.rs con una estructura parecida a esta:

table! {
	posts (id) {
		id -> Int4,
		title -> Varchar,
		slug -> Varchar,
		body -> Text,
	}
}

Agreguemos a nuestro main.rs las funciones de base de datos

// Nuevas dependenciaspubmod schema;

#[macro_use]externcrate diesel;
externcrate dotenv;

use diesel::pg::PgConnection;
use diesel::r2d2::{self, ConnectionManager};
use dotenv::dotenv;
use std::env;

// Antiguas dependenciasuse actix_web::{get, App, HttpResponse, HttpServer, Responder};
use diesel::r2d2::Pool;

...

pubtypeDbPool = r2d2::Pool>;

#[actix_web::main]
async fnmain() -> std::io::Result<()> {
		// Variables de entorno
		dotenv().ok();
		let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be setted");

		// Creamos el manager de la base de datoslet connection = ConnectionManager::::new(database_url); 
    let pool = Pool::builder() // Creamos una pool de conexiones
        .build(connection)
        .expect("Failed to create pool.");
	
		// Esta es la parte que más cambia
    HttpServer::new(move || App::new().data(pool.clone()).service(hello))
        .bind("127.0.0.1:8080")?
        .run()
        .await
}

Vamos a manejar muchísimo json así que vamos a importar lo que necesitamos en el archivo cargo.tml

[dependencies]
...
serde = "1.0.126"
serde_json = "1.0"
json = "0.12"

Crearemos un nuevo archivo llamado [models.rs](http://models.rs) en donde tendremos lo siguiente

use super::schema::posts;
use diesel::pg::PgConnection;
use diesel::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(Clone, Serialize, Deserialize, Debug, Queryable)]pubstructPost {
    pub id: i32,
    pub title: String,
    pub slug: String,
    pub body: String,
}

#[derive(Insertable)]#[table_name = "posts"]pubstructNewPost<'a> {
    pub title: &'astr,
    pub body: &'astr,
    pub slug: &'astr,
}

#[derive(Clone, Serialize, Deserialize, Debug)]pubstructNewPostHandler {
    pub title: String,
    pub body: String,
}

impl Post {
    pubfnslugify(title: &String) ->String {
        return title.replace(" ", "-").to_lowercase();
    }

    pubfncreate_post<'a>(
        conn: &PgConnection,
        post: &NewPostHandler,
    ) ->Result {
        let slug = Post::slugify(&post.title.clone());

        let post = NewPost {
            title: &post.title,
            body: &post.body,
            slug: &slug,
        };

        diesel::insert_into(posts::table)
            .values(post)
            .get_result(conn)
    }
}

Parece mucho texto pero todo es necesario, Post es el objeto completo, puede parecer que NewPost es redundante, pero es necesario para definir que datos obligatorios al crear el Post, id por ejemplo es automático, así que no es necesario, es lo que se llama un insertable. NewPostHandler definitivamente parece aún más redundante, pero lo necesitamos para simplificar el manejador de json. Por ultimo hacemos la inserción con la función create_post usamos la función slugify para demostrar que también podemos introducir logica en el “insertador”.

Nuestro primer endpoint, vamos a crear el primer blogpost

Y ya estamos listos para terminar nuestro primer endpoint, el endpoint de crear posts, va a ser un endpoint llamado /new_post con el verbo POST

use models::NewPostHandler;
...
#[post("/new_post")]
async fnnew_post(pool: web::Data, post_data: web::Json) ->impl Responder {
    let conn = pool.get().expect("could not get db connection");

    match web::block(move || Post::create_post(&conn, &post_data)).await {
        Ok(data) => HttpResponse::Ok().json(data),
        Err(err) => HttpResponse::InternalServerError().body(err.to_string()),
    }
}

hay cosillas que no hemos explicado por falta de tiempo, como web::Json que representa el formato que necesita el json para ser valido, pool.get() que es la forma con la que obtenemos una conexión a la base de datos o web::block( que es lo que usamos para bloquear el thread porque diesel no soporta al 100% el asincronismo, pero confio en tu capacidad para entender todo lo demas.

Also, no olvides registrar el new_post en la app:

HttpServer::new(move || {
        App::new()
            .data(pool.clone())
            .service(index)
            .service(post)
            .service(new_post) // Justo así
    })
    .bind("127.0.0.1:8080")?
    .run()
    .await

Podemos probar hacer una petición con postman a http://127.0.0.1:8080/new_post/ con el verbo POST y el body:

{
    "title": "Como hacer un blog con rust",
    "body": "Este es un increible post sobre como usar rust"
}

Y debemos recibir algo así

{
    "id": 1,
    "title": "Como hacer un blog con rust",
    "slug": "como-hacer-un-blog-con-rust",
    "body": "Este es un increible post sobre como usar rust"
}

Si, lo sé, increíble, impresionante, maravilloso… pero ahora vamos a buscar la forma de mostrar estos posts que hemos hecho, para ello vamos a por nuestro segundo endpoint. Este es un poco más largo pero sigue la misma estructura: query, match por si hay algún error y por ultimo render.

use diesel::prelude::*;
use schema::posts::dsl::*;
...
#[get("/{blog_slug}/")]// define el endpoint pero con un parametro
async fnpost(pool: web::Data, web::Path(blog_slug): web::Path<String>) ->impl Responder {
    let conn = pool.get().expect("could not get db connection");

    match web::block(move || {
				//Query
        posts
            .filter(slug.eq(blog_slug))
            .limit(1)
            .load::(&conn)
    })
    .await
    {
				//Match por si hay erroresOk(data) => {
						// Si no existe, retornamos 404if data.len() == 0 {
                return HttpResponse::NotFound().finish();
            }

						// Si existe, renderizamos  el primeroreturn HttpResponse::Ok().json(&data[0]);
        }
        Err(err) => HttpResponse::InternalServerError().body(err.to_string()),
    }
}

Nota como estamos haciendo las queries, esta sentencia:

posts
.filter(slug.eq(blog_slug))
.limit(1)
.load::(&conn)

Es equivalente a este SQL:

SELECT * FROM posts WHERE slug = '{blog_slug}'LIMIT1

De nuevo, no olvides registrar el endpoint en la app… Ahora si entramos a http://127.0.0.1:8080/como-hacer-un-blog-con-rust/ desde el navegador (o postman con el verbo GET) deberías obtener algo así

{
   "id":1,
   "title":"Como hacer un blog con rust",
   "slug":"como-hacer-un-blog-con-rust",
   "body":"Este es un increible post sobre como usar rust"
}

Si, adivinaste, es exactamente el mismo post que acabamos de crear ¿no es increíble? ya que estamos on fire, vamos a crear rápidamente la lista de posts (la de el index), otro endpoint de verbo GET casi, casi igual que el anterior:

#[get("/")]
async fnindex(pool: web::Data) ->impl Responder {
    let conn = pool.get().expect("could not get db connection");

		//Querymatch web::block(move || posts.order(id.desc()).load::(&conn)).await {
        //MatchOk(data) => HttpResponse::Ok().json(data), // renderErr(err) => HttpResponse::InternalServerError().body(err.to_string()),
    }
}

Si vamos al navegador a http://127.0.0.1:8080/ deberíamos obtener lo siguiente (he agregado algunos posts más):

[
   {
      "id":4,
      "title":"Debes compartir este post con todos tus amigos",
      "slug":"debes-compartir-este-post-con-todos-tus-amigos",
      "body":"O si no, el equipo del blog me golpeará y no me dejaran subir mas blogs D:"
   },
   {
      "id":3,
      "title":"Como hacer un blog con rust 2",
      "slug":"como-hacer-un-blog-con-rust-2",
      "body":"Este es un increible post sobre como usar rust"
   },
   {
      "id":1,
      "title":"Como hacer un blog con rust",
      "slug":"como-hacer-un-blog-con-rust",
      "body":"Este es un increible post sobre como usar rust"
   }
]

Templates o como hacer que nuestra web se vea wonita

Vale, estoy seguro de que ahora mismo estas muy sorprendide por lo que hemos logrado, pero hasta ahora tenemos una simple API, no una WEB, vamos a añadir un sistema de templates como Jinja2 en Python, este se llama Tera.

Para empezar vamos a crear una carpeta llamada Templates en la raíz del proyecto, adentro vamos a añadir 2 archivos html, index.html y post.html

index.html va a tener:

<htmllang="es"><head><metacharset="UTF-8"><metahttp-equiv="X-UA-Compatible"content="IE=edge"><metaname="viewport"content="width=device-width, initial-scale=1.0"><title>Mi hermoso blogtitle>head><body><main>
        {% for post in posts %}
        <div><aclass="h1"href="/{{ post.slug }}/">{{ post.title }}a><p>{{ post.body | truncate(length=150) }}p>div>
        {% endfor %}
    main>body>html>

No voy a marear mucho con el html por que no soy frontend, así que lo haremos muy sencillo (y feo) solo quiero que te fijes en las estructuras de control como el for o el truncate

Por su parte post.html va a tener:

<htmllang="es"><head><metacharset="UTF-8"><metahttp-equiv="X-UA-Compatible"content="IE=edge"><metaname="viewport"content="width=device-width, initial-scale=1.0"><title>Mi hermoso blogtitle>head><body><main><div><h1>{{ post.title }}h1><p>{{ post.body }}p><ahref="/">Volvera>div>main>body>html>

Para añadir Tera al proyecto… bueno ya te la sabes en cargo.toml

...
tera = "1.10.0"

En nuestro [main.rs](http://main.rs) vamos a alterar ligeramente la app para que reciba a Tera

use tera::Tera;
...

HttpServer::new(move || {
				// Esta es la carpeta de nuestros templateslet tera = Tera::new(concat!(env!("CARGO_MANIFEST_DIR"), "/templates/**/*")).unwrap();
        App::new()
            .data(tera) // aquí añadimos nuestro template manager
            .data(pool.clone())
            .service(index)
            .service(post)
            .service(new_post)
    })
    .bind("127.0.0.1:8080")?
    .run()
    .await
...

Sip, nuestros endpoints tienen que cambiar ligeramente para hacerlos compatibles con Tera, nuestro endpoint de post queda con esta estructura

#[get("/{blog_slug}/")] 
async fnpost(
    tmpl: web::Data, // Añadimos el template
    pool: web::Data,
    web::Path(blog_slug): web::Path<String>,
) ->impl Responder {
    let conn = pool.get().expect("could not get db connection");

    match web::block(move || {
        posts
            .filter(slug.eq(blog_slug))
            .limit(1)
            .load::(&conn)
    })
    .await
    {
        Ok(data) => {
            if data.len() == 0 {
                return HttpResponse::NotFound().finish();
            }

						//contexto; podemos acceder a estos datos desde el templateletmut ctx = tera::Context::new(); 
            ctx.insert("post", &data[0]); 
						//render: Ahora usamos HTML en lugar de JSONreturn HttpResponse::Ok()
                .content_type("text/html")
                .body(tmpl.render("post.html", &ctx).unwrap());
        }
        Err(err) => HttpResponse::InternalServerError().body(err.to_string()),
    }
}

Como ves es bastante sencillo, te dejo como reto el endpoint de index . Si ahora reinicias el server y entras a http://127.0.0.1:8080/como-hacer-un-blog-con-rust/ podras ver algo parecido a esto:

Pagina en blanco

Deploy o vamo’ a subi’ esta vaina

Por supuesto a esto le falta un montón de cariño, pero ya podemos decir que tenemos que tenemos una web… ¿o no? bueno para decir que tenemos una web tenemos que subirla a algún lugar ¿no? en este caso usaremos heroku, un hosting gratuito.

Asumiré que ya tienes una cuenta de heroku y estas logeade, también voy a asumir que tienes instalado docker y la app de heroku en tu ordenador. Puedes entrar al dash de heroku y crear una nueva aplicación, guarda bien el nombre, lo usaremos mucho.

Vamos a resources y donde dice Add-ons busquemos Heroku Postgres sigamos las instrucciones y ya tendremos una base de datos andando, si nos vamos a settings luego clicamos en Reveal Config Vars … ahi está, el secret de nuestra base de datos, podemos ponerlo en nuestro .env y correr las migraciones, eso configurará la base de datos.

Vamos a agregar estas líneas en nuestro .env

...
HOST=0.0.0.0
PORT=8081

Y vamos a modificar nuestra app para que soporte el puerto y host arbitrario que nos entrega heroku:

...
dotenv().ok();

let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be setted");
let host = env::var("HOST").expect("HOST must be setted");
let port = env::var("PORT").expect("PORT must be setted");

HttpServer::new(move || {
...
})
.bind(format!("{}:{}", host, port))?
.run()
.await

Vamos a crear un archivo Dockerfile en la raiz de nuestro proyecto, tendra algo así:

# Este docker se divide en dos partes# Esta parte descarga RustFROM ubuntu:18.04
RUN apt-get update && apt-get install curl pkg-config libssl-dev build-essential libpq-dev -y
RUNcurl https://sh.rustup.rs -sSf | sh -s -- -y
ENV PATH="/root/.cargo/bin:${PATH}"# Aqui compila nuestra appWORKDIR/app
COPY./ /app
RUNcargo build --release
# Esta parte borra lo anterior y crea una nueva maquinaFROM ubuntu:18.04
RUN apt-get update && apt-get install curl pkg-config libssl-dev build-essential libpq-dev -y
WORKDIR/app
# Guardamos unicamente el archivo compilado y el .envCOPY--from=0 /app/.env /app
COPY--from=0 /app/target/release/mi_web_app /app
COPY/templates/ /app/templates
CMD./mi_web_app

Necesitamos también un .dockerignore a menos que queramos esperar 50 años por deploy

target/
Cargo.lock

Ya tenemos todo listo para correr:

  1. Vamos a comenzar buildeando nuestra web con docker build -t web:latest .
  2. Luego vamos a correr el contenedor con docker run -d --name -e "PORT=8765" -e "DEBUG=0" -p 8007:8765 web:latest
  3. Vamos a logearnos en docker heroku login
  4. heroku container:login
  5. heroku container:push web -a
  6. heroku container:release web -a

Si hiciste todo correcto ahora deberías poder acceder a https://.herokuapp.com/ puedes agregar posts y verlos, claro aún te falta un millon de cosas por hacer para tener un blog completo, Usuarios, Roles, Frontend más bonito y SOBRE TODO, por favor refactoriza todo este espagueti que hicimos, aquí está si quieres ver el proyecto terminado y así quedaría la web final.

Espero que te haya gustado este post, por favor comparte este post por todas partes para que me dejen seguir subiendo posts estonoesunabromatienenamifamilia 👀.

Hector
Hector
hector_pulido_

29077Puntos

hace 3 años

Todas sus entradas
Escribe tu comentario
+ 2
Ordenar por:
3
69776Puntos

Está genial el tutorial, este fin de semana lo seguiré y les traigo mis resultados. Espero que muy pronto se logren los cursos de Rust.

2
29077Puntos
3 años

Uff espero que me muestres el resultado cuando lo intentes 😮

3
9610Puntos

Esta es una increible base y guia para mejorar con rust, gracias hector-sensei ♥

3
58709Puntos

Muy interesante Hector. Gracias por compartir.

1
29077Puntos
3 años

Muchas gracias, me alegra que te parezca interesante

2
4419Puntos

Estuvo genial, que siga saliendo contenido como este

¡Gracias!

Ferris 7w7
2
29077Puntos
3 años

Muchas gracias Mr Quiarom, ayudaporfavortienenamifamilia 👀

2
27567Puntos

Muy interesante, gracias.

1
29077Puntos
3 años

Me alegro que te parezca interesante 😄

2
2225Puntos

Que buen tutorial Hector, Sigo tu contenido desde que publicabas y ayudabas en los grupos de Unity3D,
Saludos Gracias!

2
29077Puntos
3 años

Woow que ilusion, muchas gracias por el constante apoyo 😄

1
9610Puntos

SI alguien tiene algun problema a la hora de instalar diesel-cli, tal vez sea por no tener instalado las librerias de postgres. Es un error como este

error: linking with cc failed: exit status: 1
= note: /usr/bin/ld: cannot find -lpq          collect2: error: ld returned 1 exit status


error: failed to compile diesel_cli v1.4.1, intermediate artifacts can be found at /tmp/cargo-installkNns0m

Se soluciona simplemente instalando libpq-dev

sudo apt-get install libpq-dev