Rust Workshop

Rust Workshop

Sun Mar 31 2024

Xander McLeodDEVS UoA

Xander McLeod, DEVS UoA

Rust is fast, correct, safe and paired with an incredible developer experience. If you are starting a new project, you should absolutely use Rust rather than Java, Go, Python, or literally anything else. I fully stand by the claim that Rust is the best language we have created so far.

Let's use the rustup toolchain manager to install rust, it's just like Node Version Manager and helps us keep track of our rust installations. Install it from Rustup to get started. This automagically installs Rust, Cargo, and the Rust Compiler (rustc).

In your editor, create a file called /app.rs. This will be your first rust program 😍. We'll create your very first "Hello, World" program in it.

fn main() {
  println!("Hello, World");
}

We then compile it just like C using rustc. Make sure you are in the right folder and run:

rustc app.rs

This will create a file like app.exe or a.out in the same folder. Run that like

./app.exe

or

./a.out

and it should print "Hello, World" to your terminal.

This is only the beginning πŸš€, we can do so much more with Rust by using Cargo. Firstly, install Rust Analyzer if you are using Visual Studio Code which will help us debug our programs and get syntax highlighting. Delete your previous executable files and code (it's mid and we don't want it anymore), then create a ✨Cargo✨ project:

cargo init --bin

This creates a binary project, we can also use --lib to create a library which is just a package that can be used in other packages. Your binary file structure should look like this:

.
β”œβ”€β”€ src/
β”‚   └── main.rs
β”œβ”€β”€ .gitignore
└── Cargo.toml

The /src folder is where our wonderful code will be located, the /src/main.rs file is the entrypoint of our application and when we run our code, it will run this first. The .gitignore is just a list of files to hide from society and Cargo.toml is where we outline the information about our project, including its name, version, and importantly it's dependencies (or which libraries it's using)

Thankfully, the main.rs file should already contain the Hello World program. Let's run it using Cargo with

cargo run

This should build and compile our project and it's dependencies, save it in the /target directory and then run it.

   Compiling rust-workshop v0.1.0 (C:\Users\alexw\Documents\Github\rust-workshop)      
    Finished dev [unoptimized + debuginfo] target(s) in 2.16s
     Running `target\debug\rust-workshop.exe`
Hello, world!

Amazing 😍

Let's create an app for ordering coffee online. Let's create the coffee class by going

struct Coffee {}

We want to be able to let customers choose between Flat Whites, Espresso, and Frappe's. We can use an enum for this, which is just an object with predefined options.

enum CoffeeType {
  FlatWhite,
  Espresso,
  Frappe
}

Let's add some milk as well

enum Milk {
  Oat,
  Soy,
  Pea
}

Though, we may not always want milk in our coffee. In Rust, there is no null or undefined object, everything must be defined. To get around this, we can use the Option Enum. Options are just an enum with two variants, Some and None. It looks like this:

enum Option<T> {
  Some(T),
  None
}

where T is a generic that can represent anything-- milk in our case. Options work so well in Rust because they pair well with matching statements. We can easily run a different function or behaviour based on which variant we are using, for example:

match &coffee.milk {
  Some(milk_type) => {
    barista.pour_milk(milk_type);
  },
  None => {
    barista.close_fridge();
  }
};

Great! We can finally create our coffee struct

struct Coffee {
  coffee_type: CoffeeType,
  milk: Option<Milk>,
  is_decaf: bool,
  sugar_count: u8
}

Imagine though, we also had a Tea struct. We want to be able to use one function to make both tea and coffee decaf for example. In Java, we may create a parent class called 'Drink', implement the decaf method, and then have Coffee and Tea inherit it. In Rust, we use traits. Let's create a trait called Decaffeinatable.

trait Decaffeinatable {
  fn remove_caffeine(&mut self);
}

Let's implement our trait for our coffee

impl Decaffeinatable for Coffee {
  fn remove_caffeine(&mut self) {
    self.is_decaf = true;
  }
}

impl Decaffeinatable for Tea {
  fn remove_caffeine(&mut self) {
    self.is_decaf = true;
  }
}

That was really easy, let's create an instance of Coffee with caffeine, and remove it afterwards

let morning_coffee = Coffee {
  coffee_type: CoffeeType::FlatWhite,
  milk: Some(Milk::Oat),
  sugar_count: 1,
  is_decaf: false
};

morning_coffee.remove_caffeine();

This is going to give us an error because by default everything in Rust is immutable which means you cannot change it by, for example, removing the caffeine. We have to declare the coffee as mutable and changable to begin with. This helps solve a lot of errors

let mut morning_coffee = Coffee {
  coffee_type: CoffeeType::FlatWhite,
  milk: Some(Milk::Oat),
  sugar_count: 1,
  is_decaf: false
};
morning_coffee.remove_caffeine();

Just like that we can remove the caffeine very easily and we have a standard way to remove the caffeine from any drink we may make.

Let's move on and create a web server so people can order online.

Run this command in your project directory to add all of the packages we need to our project.

cargo add tracing tracing-subscriber serde tokio axum -F tokio/full -F serde/derive

Firstly, we need to make sure that our coffee is deserializable, which just means that we can accept it in our endpoints and in our servers from JSON. Let's use the serde crate for that. It has a trait called Deserializable. Instead of implementing that for our coffee struct ourself, we can 'derive' it, which means let serde automatically implement that trait for us (how convenient!).

Add

#[derive(serde::Deserialize)]

above our structs like this:

#[derive(serde::Deserialize)]
enum CoffeeType {
  FlatWhite,
  Espresso,
  Frappe,
}

#[derive(serde::Deserialize)]
enum Milk {
  Oat,
  Soy,
  Pea,
}

#[derive(serde::Deserialize)]
struct Coffee {
  coffee_type: CoffeeType,
  milk_type: Option<Milk>,
  sugar_count: u8,
  is_decaf: bool,
}

It needs to be above all of them for it to work. Let's create the basic layout for our app:

use axum::{routing::get, Router};

#[tokio::main]
async fn main() {
  tracing_subscriber::fmt::init();
  let app = Router::new().route("/", get(root));

  let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
  axum::serve(listener, app).await.unwrap();
}

async fn root() -> &'static str {
  "Hello, World"
}

This is going to create an async web server that responds to requests on '0.0.0.0:3000/' with "Hello, World". Run it with cargo run. Now let's create a way to store our orders in memory (we can easily expand and add a MongoDB or a SQL database in the future to save it).

Update the start of your app with this:

use std::sync::Arc;
use axum::{extract::State, routing::get, Router};
use tokio::sync::Mutex;

#[tokio::main]
async fn main() {
  tracing_subscriber::fmt::init();
  let app = Router::new()
    .route("/", get(root))
    .with_state(Arc::new(Mutex::new(Vec::<Coffee>::new())));

We are using an Arc to let us access the data between threads so our app runs very very fast, a Mutex to let us mutate and change that data even if it is being used between threads, and a Vector which is just a dynamically sizable array for storing our Coffee orders.

Let's create our order endpoint

use axum::{
  extract::State,
  http::StatusCode,
  response::{IntoResponse, Response},
  routing::{get, post},
  Json, Router,
};
use std::sync::Arc;
use tokio::sync::Mutex;

#[tokio::main]
async fn main() {
  tracing_subscriber::fmt::init();
  let app = Router::new()
    .route("/", get(root))
    .route("/order", post(order))
    .with_state(Arc::new(Mutex::new(Vec::<Coffee>::new())));

  let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
  axum::serve(listener, app).await.unwrap();
}

async fn root() -> &'static str {
  "Hello, World"
}

async fn order(
  State(orders): State<Arc<Mutex<Vec<Coffee>>>>,
  Json(body): Json<Coffee>,
) -> StatusCode {
  orders.lock().await.push(body);
  StatusCode::CREATED
}

Make sure you are importing everything you use at the top to make sure it works. Here, we create our order function which takes in two things. The current state which is a list of orders that have already been made, and a Json object of a coffee, that we add to our list of existing orders and then return a response with the status code created. Let's create one last endpoint to read all of our orders from.

You'll need to derive some more traits for our Coffee class. Add this to the top of all of your enums and structs for coffee

#[derive(serde::Deserialize, serde::Serialize, Clone, Copy)]

Then create the endpoint orders

async fn orders(State(orders): State<Arc<Mutex<Vec<Coffee>>>>) -> Json<Vec<Coffee>> {
  let coffees = orders.lock().await.clone();
  Json(coffees)
}

This takes in our existing orders states and returns the Coffees as JSON. Add this to our router:

  let app = Router::new()
    .route("/", get(root))
    .route("/order", post(order))
    .route("/orders", get(orders))
    .with_state(Arc::new(Mutex::new(Vec::<Coffee>::new())));

Then run your app with cargo run and use Postman to test it post-request.png

get-request.png

Try using a Milk or CoffeeType that isn't one of the options you defined or a negative number for sugar count. You didn't even need to write any code to check it, and the server comes with built-in error handling. You just created the fastest web server you've ever done, with built-in error handling. That's Rust.

If you would like to continue learning, I would highly recommend the Rust Book which is how I got started two years ago. I was sitting in my bedroom writing enums in Typescript and my next door neighbour walked in to invite me down to dinner and said "what are you doing!?! you should learn Rust".

See the full code on Github

Topographic Wallpaper