A gdyby tak wsiąść w rakietę i polecieć...
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 zstable
!
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 identyfikatorlet mut user: User = input.into_inner();
pobieramy obiektUser
z obiektu opakowywującegouser.id = Some(val);
- zapisujemy w obiekcie identyfikatorlet mut hash_map = map.lock().unwrap();
pobieramy wyłączność na dostęp do naszej HashMap'yhash_map.insert(val, user.clone());
- zapisujemy dane do mapyHashMap'y pobiera
własność
zapisywanego obiektu, a ewentualny odczyt zwraca &User. NiestetyJson
tego nie akceptuje i stąd tenclone()
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'yhashmap.remove(&id).map_or(Status::NotFound, |_f| { Status::Ok })
- usuwamy element. Jeśli się uda, funkcja zwróci usunięty element, który mapujemy naStatus::Ok
. Jeśli sie nie uda, wykorzystywana jest domyślna wartośćStatus::NotFound
_f
oznacza, że nie zamierzamy korzystać ze zmiennejf
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
- Rustup i ustawienia wersji kompilatora
- Przewodnik po Rocker.rs
- Dokumentacja Mutex
- Dokumentacja HashMap'ie
- Dokumentacja AtomicUsize