slaveOftime
rusthtmxactix-web

Try use rust to rebuild my recipe website

2023-06-07

Rust is on my radar for many years, but never have opportunity to use it, but recently I found a chance.

I am a dotnet developers for many years, so almost all of my projects are in dotnet, csharp or fsharp. For my personal projects like blogs, small website, I have a very basic virtual machine (Windows 2012 πŸ˜‚) with IIS to host them. This machine is 1GB memory + 1 CPU + 1Mbps. Pretty small.

After running multiple websites in it, the overall memory is almost 80 to 90 percent. I know I can use run on demand to make the memory usage lesser, but it will also make the first request slower. Even the traffic is very very small. Just to host them for fun πŸ˜’.

I have a recipe website, which will load some json files in memory and I can explorer them by very quickly from the browser. It is very simple one, just have two pages, so it should be pretty easy to convert to rust.

Use htmx + yew + SSR (server side rendering)

With htmx, I can patch my page with some partial content to achieve infinite scroll for example.

Yew can provide some macros for me to write html directly in the rust code and compile them to virtual dom, so I can create a very simple function ssr to write them to a string very quickly (It is quicker than default yew ServerRender module, because yew will take care more stuff, like component, state and hydrate etc.). Yew also provide a VSCode plugin, so I can get intellisense for the embeded html, which means, a lot of VSCode extension like htmx, tailwindcss can be used here in the rust file.

The ssr is something like below, it will recursively loop all the node and write them into a String.

fn loop_node(dom: &mut String, indent: u16, node: &VNode) -> core::fmt::Result {
    match node {
        VNode::VText(txt) => {
            write_indent(dom, indent)?;
            for c in txt.text.chars() {
                dom.write_char(c)?;
                if c == '\n' || c == '\r' {
                    write_indent(dom, indent)?;
                }
            }
        }
        VNode::VTag(tag) => {
            let tag_name = tag.tag();

            write_indent(dom, indent)?;
            dom.write_char('<')?;
            dom.write_str(tag_name)?;

            // attribute
            for (k, v) in tag.attributes.iter() {
                dom.write_char(' ')?;
                dom.write_str(k)?;
                dom.write_char('=')?;
                dom.write_char('"')?;
                dom.write_str(v)?;
                dom.write_char('"')?;
            }
            dom.write_char('>')?;

            // children
            if tag.children().len() > 0 {
                dom.write_char('\n')?;

                for child in tag.children().iter() {
                    loop_node(dom, indent + 2, child)?;
                    dom.write_char('\n')?;
                }

                write_indent(dom, indent)?;
            }

            dom.write_str("</")?;
            dom.write_str(tag_name)?;
            dom.write_char('>')?;
        }
        VNode::VList(children) => {
            let child_len = children.len();
            if child_len > 0 {
                let max_index = child_len - 1;
                for (i, child) in children.iter().enumerate() {
                    loop_node(dom, indent, child)?;
                    if max_index == 1 || i != max_index {
                        dom.write_char('\n')?;
                    }
                }
            }
        }
        VNode::VRaw(raw) => {
            write_indent(dom, indent)?;
            for c in raw.html.chars() {
                dom.write_char(c)?;
                if c == '\n' || c == '\r' {
                    write_indent(dom, indent)?;
                }
            }
        }
        _ => {
            println!("unhandled {:?}", node);
        }
    };

    Ok(())
}

Render the recipes list page

Below is the code to handle the request:

pub async fn create_recipes_page(
    recipes_store: Data<Arc<RecipesStore>>,
    Query(query): Query<RecipesQuery>,
) -> impl Responder {
    let page = query.page.unwrap_or(0);
    let search_str = &query.query.unwrap_or(String::default());
    let recipes = &recipes_store.search_recipes(search_str, page);

    ssr(layout(
        Some("Coolking 菜谱"),
        html! {
            <section class="mx-3">
                <div class="mt-3 mb-4 flex flex-col sm:flex-row items-center justify-between gap-2">
                    <h1 class="flex items-center justify-center">
                        <img class="h-10 w-10 mr-3 animate-pulse" src="./assets/favicon.svg"/>
                        <a href="/" class="text-lg font-bold border-b-2 border-transparent hover:border-yellow-400">{"COOLKING"}</a>
                    </h1>
                    <div class="text-xs opacity-50 flex flex-col items-center mb-3 sm:mb-0">
                        <p>{"be patient with the 🍡🍭🍹 you are cooking"}</p>
                        <p>{"be patient with the πŸ‘ΆπŸ‘΅πŸ‘§ you are lovinggg"}</p>
                    </div>
                    {recipe_filter(search_str)}
                </div>
                <div id="" hx-indicator="#recipes-indicator" class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 2xl:grid-cols-7 gap-4">
                    {recipe_list(recipes, search_str, page)}
                </div>
                <progress id="recipes-indicator" class="progress progress-primary h-1 htmx-indicator my-4"></progress>
                {footer()}
            </section>
        },
    ))
}

To take the advantage of htmx to achieve infinite scroll, for the recipe card in the recipe list, I will check if it is the last item for the current fetch, if it is then I will trigger a new request to fetch next page. htmx provides a trigger event called revealed which means only the element is displayed in the browser, it will trigger. So for my use case, it will trigger view/recipes?query=&page= when user scroll to the last recipe.

fn recipe_card(recipe: &RecipeDetail, query: &str, page: u32, is_last_one: bool) -> Html {
    html! {
        <a class="card card-compact glass" href={format!("recipe/{}", recipe.id)}
            hx-get={if is_last_one { Some(format!("view/recipes?query={}&page={}", query, page + 1)) } else { None } }
            hx-trigger={if is_last_one { Some("revealed") } else { None }}
            hx-swap="afterend"
        >
            <figure>
                <img class="h-[200px] w-full object-cover object-center lazy" src="./assets/placeholder.svg" data-src={format!("api/recipe/{}/image", recipe.id)} />
            </figure>
            <div class="card-body">
                <h2 class="card-title flex justify-between gap-1">
                    <span class="truncate">{&recipe.title}</span>
                    <span class="text-xs opacity-75 whitespace-nowrap">{format!("ιœ€{}εˆ†ι’Ÿ", recipe.info.preparation_time + recipe.info.cooking_time)}</span>
                </h2>
                <p class="truncate">{&recipe.info.description}</p>
            </div>
        </a>
    }
}

Route

I like the minimal apis in asp.net core, it is very simple and easy to get started. And it looks like many web frameworks in rust also have similar stuff. I tried axum and actix-web, I also benchmarked those two, and it looks like actix-web is faster. So here is the routes from actix-web:

App::new()
    .wrap(middleware::Compress::default())
    // Serve shared data
    .app_data(web::Data::new(Arc::new(recipes_store::RecipesStore::new())))
    // Server application handlers
    .route(
        "/api/recipe/{id}/image",
        get().to(recipes_assets::get_recipe_image),
    )
    .route(
        "/api/recipe/{id}/step/{step_index}/subStep/{sub_step_index}/image",
        get().to(recipes_assets::get_recipe_step_image),
    )
    .route(
        "/api/recipe/{id}/step/{step_index}/subStep/{sub_step_index}/video",
        get().to(recipes_assets::get_recipe_step_video),
    )
    .route(
        "/view/recipes",
        get().to(recipes::create_recipes_partial_page),
    )
    .route("/recipe/{id}", get().to(recipe::create_recipe_page))
    // Serve static files
    .service(static_fs::new("/assets", "./assets"))
    .service(static_fs::new("/recipes/assets", "./recipes/assets"))
    // Final fallback
    .route("/", get().to(recipes::create_recipes_page))

Above code is very clear for me:

  • some middlewares to compress stuff
  • some services to serve static files
  • some endpoints to serve the recipe related image/video
  • some endpoints to serve the html

Performance

The bundle size is less around 5MB which is soooooooooo cool comparing with what asp.net core self-hosted mode can generate. Even with AOT in dotnet 8, the hello world asp.net core still need 9MB. Not talking about we have multiple middlewares and compression stuff etc. here in rust.

The runtime memory is also pretty small (under 12MB, axum is smaller for this app, around 5 MB), even in benchmark time. (How it can be?😍, how the memory can be so stable for rendering dynamic html?)

The RPS, I used to have a very basic compare for rendering small chunk of html. I used the RazroSlice in asp.net core, and yew + axum in rust. It is pretty similar. Both are way faster than what I used for my recipes website before which is asp.net core + blazor SSR + Fun.Blazor build by myself (🀣, sorry for myself, it is slow)

Summary

Who told me that rust is for low level (no body 🀣, I used to thought like that)? The syntax for writing the frontend and compose all the endpoints are very easy, even easy than asp.net core. I am really impressed by how rust is looking like, but all of this is after I spend couple of days to figuring out how to mirror the same stuff which is built by asp.net core. Those days are fighting with rust compiler very very hard.

For me, rust is not easy to get started, but it is worth it because it is fast and small by default. Really impressive.

After climbing for couple of days, the air is so fresh!!! The syntax are becoming reasonable now!!!

Just for fun βœ…

Do you like this post?