W podróż zabierze nas dziś Rocket.rs - jeden z wielu, ale z pewnością najbardziej znany (no, może ex aequo z Actix) framework do budowania aplikacji webowych (backend) w języku Rust. Sprawdzę jak się sprawuje i jak to wygląda z perspektywy człowieka co na codzień javę uprawia...

Rocket.rs jest przedstawicielem tzkw kompletnych frameworków - ma zawierać wszystko co trzeba, żeby napisać aplikację i nie martwić się o niskopoziomowe rozwiązania (jak np. bibliotekę do http) a jednocześnie ograniczając pisanie zbędnych (powtarzających się) kodów do minimum.

Aktualna stabilna wersja to 0.4.6, zanim rozpoczniemy, ważne, żeby poznać też ograniczenia:

  • ta wersja nie obsługuje jeszcze asynchroniczności (w rozumieniu słów kluczowych zdefiniowanych na poziomie języka). Do moich potrzeb jest to wystarczające, jeśli jednak komuś to przeszkadza, to master już ma potrzebną obługę - stosowne issue gdzie można śledzić postęp prac.
  • wymagana jest wersja tzk nightly kompilatora. Przy czym uwaga, wersja z master już działa z stable!

Nightly można ustawić per-katalog Po utworzeniu projektu trzeba wywołać rustup override set nightly w jego katalogu.

Pierwsze koty za płoty

Na początek coś mało wymagającego — spróbujemy mniej więcej powtórzyć pierwszy przykład z Guide

Tworzenie projektu:

cargo new rocket-api --bin
cd rocket-api
rustup override set nightly

Zależność na rocket, czyli linia

rocket = "0.4.6"

w sekcji [dependencies] pliku Cargo.toml

No i sam kod aplikacji w pliku main.rs:

#![feature(proc_macro_hygiene, decl_macro)]

#[macro_use] extern crate rocket;

#[get("/")]
fn index() -> &'static str {
    "Hello, world!"
}

fn main() {
    rocket::ignite().mount("/", routes![index]).launch();
}

Tyle wystarczy, żeby uruchomić już aplikację przez cargo run i zobaczyć

Hello, world!

pod adresem http://localhost:8000

Szybki rzut oka na zawartość target/debug/ - binarka ma 27M, w sumie nieźle w porównaniu do javowych wynalazków.. ale.. przecież to wersja debug. cargo build --release i całość maleje do 5,5Mb! Miło :) O czasie uruchamiania nawet nie wspomnę bo:

a) rocket nie wypisuje ile mu zeszło

b) odpalenie działa tak szybko, że nie da się tego zmierzyć :)

Tak, wiem, tu nic prawie nie ma ale jednak...

Trasy oraz metody HTTP

Zanim przejdę dalej mała dygresja na temat obsługi tras przez Rocket.rs. Podobnie ja w innych językach potrzebujemy metody, która się wykona i obsłuży żądanie oraz określenia, jaką metodę HTTP dana funkcja obsłuży i ew, pod jaką ścieżką w api ją znajdziemy. W przykładzie powyżej jest to

#[get("/")]

czyli podobnie jak np, w Javovym JAX-RS @GET.

Rocket.rs obsługuje wszystkie metody HTTP i dla każdej ma osobną dyrektywę, pełna dokumentacja znajduje się tutaj

Zwracanie JSONa

Responder i zmiana Content-Type

Nie ma RESTa bez JSONa :)

W przykładzie powyżej to, co odbiera przeglądarka ma Content-Type: text/plain; charset=utf-8. Zmiana Content-Type oraz ewentualna konwersja danych do określonego formatu to zadanie Respoderów. W najprostszym możliwym przypadku można użyć responder'a z modułu content, żeby ustawić żądany typ. Np zmieniając naszą funkcję index:

use rocket::response::content;

#[get("/")]
fn index() -> content::Json<&'static str> {
    content::Json("Hello, world!")
}

a następnie można sprawdzić wynik:

> curl localhost:8000 -i
HTTP/1.1 200 OK
Content-Type: application/json
Server: Rocket
Content-Length: 13
Date: Sat, 30 Jan 2021 11:38:22 GMT

Hello, world!⏎                  

Content-Type się zmienił, natomiast widać, że zwrócona wartość nie jest poprawnym JSONem:

> curl localhost:8000 -s |jq
parse error: Invalid numeric literal at line 1, column 6

Konwersja na JSON

Obsługę konwersji do JSON zapewnia Responder z biblioteki rocket-contrib. Potrzebujemy dodać nowe wpisy do Cargo.toml:

rocket_contrib = "0.4.6"
serde  = { version = "1.0.120", features = ["derive"] }

feature derive przyda sie potem przy wykorzystywaniu struktur

a następnie zmieniamy funkcję index:

use rocket_contrib::json::Json;

#[get("/")]
fn index() -> Json<&'static str> {
    Json("Hello, world!")
}

...sprawdzam:

> curl localhost:8000 |jq
"Hello, world!"

Struktury danych

Skoro umiemy już zamienić dane na JSON i je zwrócić, to można spróbować z czymś trudniejszym niż zwykły string :)

Weźmy np taką strukturę reprezentującą dane użytkownika:

#[derive(Debug, Clone, Serialize)]
struct User {
    name: String,
    email: String
}

Potrzebujemy jeszcze use serde::Serialize; - razem z #[derive(Serialize)] nad strukturą mamy serializację do JSON'a za darmo

Zmieniamy naszą funkcję index, żeby zwracała jakieś przykładowe dane użytkownika:

#[get("/")]
fn index() -> Json<User> {
    let user = User {
        name: "test user".to_string(),
        email:"test@email.com".to_string()
    };
    Json(user)
}

a tak to wygląda po uruchomieniu:

> curl localhost:8000 -s |jq
{
  "name": "test user",
  "email": "test@email.com"
}

Konsumpcja danych (JSON)

Po pierwsze potrzebujemy żeby nasza struktura User była de-serializowalna do JSON'a, najprościej to osiągniemy korzystając z gotowej dyrektywy derive:

use serde::Deserialize;

#[derive(Debug, Clone, Serialize, Deserialize)]
struct User {
    name: String,
    email: String
}

Po drugie potrzebujemy funkcji, która te dane przyjmie, nazwijmy ją add. Ponieważ będzie to kolejna trasa, dodajemy ją do routes:

fn main() {
  rocket::ignite().mount("/", routes![index, add]).launch();
}

No i potrzebna nam sama funkcja, wraz z definicją trasy:

#[post("/", format="json", data = "<input>")]
fn add(input: Json<User>) {
  println!("{:?}", input);
}

To, co widzimy powyżej to:

  • definicja, że będziemy obsługiwać HTTP POST
  • że będziemy zapięci na ścieżkę /.
  • że będziemy akceptować format JSON
  • że dane, które przyjdą do metody, będą potem przekazane do parametru input

W sumie nic, czego nie robiliśmy w Javie, zmienia się tylko składnia.

Pora sprawdzić działanie:

> curl -X POST http://localhost:8000 -H "Content-Type: application/json" -d '{"name": "test user","email": "test@email.com"}'

...a na konsoli z działającym programem:

    => Matched: POST / application/json (new)
Json(User { name: "test user", email: "test@email.com" })
    => Outcome: Success
    => Response succeeded.

Zarządzanie stanem oraz CRUD

Oddajemy się we władanie...

Umiemy już pobrać i przyjąć dane, ale nadal nigdzie ich nie zapisujemy. Podobnie jak w innych językach/frameworkach Rocket.rs potrafi zarządzać np dostępem do storage. Odbywa się to przez trait State oraz metodę manage. Pomysł polega na tym, że cokolwiek przekażemy do manage będzie zarządzanie przez Rocket.rs. Dostęp do takich obiektów odbywać się będzie poprzez tzw strażnika żądań (request guard), poprzez dodanie parametru State<T> (gdzie T to typ, który oddaliśmy we władanie) do metody obsługującej żądanie - framework sam nam przekaże nam stan, na którym możemy operować.

Obiekty zarządzane przez Rocket.rs muszą implementować dwa traity: Send oraz Sync - czyli, krótko mówiąc muszą być thread-safe

HashMap jako miejsce na dane

Ponieważ nie mamy (jeszcze!) połączenia do bazy, dane możemy przechowywać w HashMap'ie. Problem w tym, że HashMap'a nie jest thread-safe. Żeby ją bezpiecznie używać w środowksu wielowątkowym, trzeba ją opakować w coś jeszcce, np semafor typu Mutex

Mutex (mutual exclusion) to mechanizm zapewniający, że tylko jeden wątek w danym czasie będzie miał dostęp do (chronionych przez niego) danych

Oddajemy więc we władanie HashMapę:

fn main() {
    rocket::ignite()
        .manage(Mutex::new(HashMap::<usize, User>::new()))
        .mount("/", routes![index, add])
        .launch();
}

a następnie rozszerzamy funkcję add, żeby przyjmowała Stan tego typu:

fn add(input: Json<User>, map: State<'_, Mutex<HashMap<usize, User>>>) {

Pod debuggerem widać już, że Rocket.rs wstrzykuje nam instancję Mutex'a. Zanim przejdziemy do dalszej implementacji, trzeba rozwiązać jeszcze jeden problem...

Generator identyfikatorów

Podczas dodawania nowych danych, użytkownik zazwyczaj nie podaje identyfikatora obiektu, który właśnie tworzy - jest on generowany przez aplikację. W powyższym rozwiązaniu nie mamy miejsca, gdzie moglibyśmy go generować, więc ... trzeba je dodać.

W tym przypadku również skorzystamy z State oraz manage: przekażemy w zarządzanie monotonicznie rosący licznik typu AtomicUsize:

fn main() {
    rocket::ignite()
        .manage(AtomicUsize::new(0))
        .manage(Mutex::new(HashMap::<usize, User>::new()))
        .mount("/", routes![index, add])
        .launch();
}

a funkcję add zmienimy tak, żeby przyjmowała ten licznik i wykonywała na nim operacje:

#[post("/", format="json", data = "<input>")]
fn add(input: Json<User>, id_generator: State<AtomicUsize>, map: State<'_, Mutex<HashMap<usize, User>>>) {
  println!("{:?}", input);
  let val = id_generator.inner().fetch_add(1, Ordering::Relaxed);
  println!("{:?}", val);
}

Po uruchomieniu i wykonaniu kilku operacji post, możemy zaobserwować, że licznik rośnie

Identyfikator obiektu

Przed implementacją faktycznego CRUDa musimy zmienić jescze jedną rzecz: dobrze przyjętym zwyczajem jest, że po utworzeniu danych przez post zwraca się reprezentację tego co się zapisało - razem z identyfikatorem. Musimy więc rozszerzyć o pole na identyfikator:

#[derive(Debug, Clone, Serialize, Deserialize)]
struct User {
    id: Option<usize>,
    name: String,
    email: String
}

Zapis danych

Końcowy wygląd funkcji zapisującej dane:

#[post("/", format = "json", data = "<input>")]
fn add(input: Json<User>, id_generator: State<AtomicUsize>, map: State<'_, Mutex<HashMap<usize, User>>>) -> Json<User> {
    // generate new id
    let val = id_generator.inner().fetch_add(1, Ordering::Relaxed);
    // get raw User out of input Json
    let mut user: User = input.into_inner();
    // store id within structure
    user.id = Some(val);
    // acquire lock in order to modify HashMap
    let mut hash_map = map.lock().unwrap();
    //insert value (note: clone as we need original one to be returned)
    hash_map.insert(val, user.clone());
    //return
    Json(user)
}

Kolejno:

  • let val = id_generator.inner().fetch_add(1, Ordering::Relaxed); - generujemy identyfikator
  • let mut user: User = input.into_inner(); pobieramy obiekt User z obiektu opakowywującego
  • user.id = Some(val); - zapisujemy w obiekcie identyfikator
  • let mut hash_map = map.lock().unwrap(); pobieramy wyłączność na dostęp do naszej HashMap'y
  • hash_map.insert(val, user.clone()); - zapisujemy dane do mapy

    HashMap'y pobiera własność zapisywanego obiektu, a ewentualny odczyt zwraca &User. Niestety Json tego nie akceptuje i stąd ten clone()

  • Json(user) - zwracamy wypełniony obiekt

Kasowanie

Funkcja kasująca pobiera tylko identyfikator obiektu do skasowania i zwraca status http:

#[delete("/<id>")]
fn delete(id: usize, map: State<'_, Mutex<HashMap<usize, User>>>) -> Status {
    // acquire lock in order to modify HashMap
    let mut hashmap = map.lock().unwrap();
    // remove element, in case of success map it to Status::Ok, otherwise return default Status::NotFound
    hashmap.remove(&id).map_or(Status::NotFound, |_f| { Status::Ok })
}

Kolejno:

  • let mut hash_map = map.lock().unwrap(); pobieramy wyłączność na dostęp do naszej HashMap'y
  • hashmap.remove(&id).map_or(Status::NotFound, |_f| { Status::Ok }) - usuwamy element. Jeśli się uda, funkcja zwróci usunięty element, który mapujemy na Status::Ok. Jeśli sie nie uda, wykorzystywana jest domyślna wartość Status::NotFound

    _f oznacza, że nie zamierzamy korzystać ze zmiennej f

Pojedyncze pobranie

#[get("/<id>")]
fn get(id: usize, map: State<'_, Mutex<HashMap<usize, User>>>) -> Option<Json<User>> {
    // acquire lock in order to modify HashMap
    let hash_map = map.lock().unwrap();
    // retrieve value
    let user = hash_map.get(&id);
    // map into result
    user.map(|val| { Json(val.clone()) })
}

W zasadzie chyba nie ma nic do tłumaczenia..

Podsumowanie

Na pierwszy raz chyba wystarczy :) Mamy:

  • pobranie
  • usuwanie
  • dodawanie
  • dane zapamiętują się w pamięci
  • całość waży tylko 5.6Mb!

Temat na pewno rozwojowy, więc na pewno coś na ten temat jeszcze napiszę. Na razie... repozytorium z kodem

Literatura