Rust na prostym przykładzie cz2
Czas biegnie nieubłaganie: od ostatniego wpisu na temat rust i findbuild mija już prawie 6 miesięcy. Pora odkurzyć (po)rzuconą rękawicę i dokończyć co się zaczęło.
Przypomnienie
Dla przypomnienia: w pierwszej części przedstawiłem:
- działanie
cargo
, - zasady dodawania zależności,
- krótkie wprowadzenie do składni,
- czas życia zmiennych -
lifetimes
, - błędy kompilatora.
Pora zrealizować kilka pozycji z todo jakie nam zostały z ostatniego razu.
Kompilacja
Jakaż niemiła niespodzianka spotkała mnie po odkurzeniu projektu:
error: failed to run custom build command for `openssl-sys v0.9.43`
Czasami zapominamy ile zależności mamy zainstalowane w systemie - człowiek zmieni komputer/VM i bach.
W każdym razie: openssl-sys
to Rustowy wrapper na biblioteki OpenSSL
i do kompilacji potrzebuje tak jak normalne
programy w C, pakietów devel, co czym wyżej wymieniony błąd również informuje (tylko w dalszej części):
Make sure you also have the development packages of openssl installed.
For example, `libssl-dev` on Ubuntu or `openssl-devel` on Fedora.
Po dodaniu brakujących zależności wszystko znów się ładnie kompiluje...
Przekazywanie parametrów
Dotychczas hasło, nazwę użytkownika, adres początkowy mieliśmy na stałe zapisane w kodzie. Oczywiście wszyscy wiemy, że normalnie tak się nie robi - te dane powinny zostać dostarczone do programu podczas jego uruchamiania.
Dane wejściowe do programu możemy dostarczyć przez:
- zmienne środowiskowe,
- parametry w linii komend,
- plik konfiguracyjny.
Zmienne środowiskowe
Narzędzia do obsługi zmiennych środowiskowych znajdują się w bibliotece standardowej Rust
a, dodajemy więc
stosowną deklarację użycia:
use std::env;
i już możemy pobrać zawartość zmiennej środowiskowej:
let user = env::var("FINDBUILD_USERNAME").unwrap();
wynikiem działania funkcji var jest Result<String, VarError> w związku z tym potrzebujemy uwrap() żeby wydobyć właściwą wartość z rezultatu
Niestety powyższe, w przypadku gdy zmienna nie jest ustawiona spowoduje błąd panic
w runtime
(a nie dorobiliśmy się jescze obsługi błędów...). W związku z tym, można powyższy kod zamienić na:
let user = "username".to_owned();
let user = env::var("FINDBUILD_USERNAME").unwrap_or_else(|_| user);
To co powyżej widać to:
- tzw
zakrywanie
(w oryginaleshadowing
) zmiennych: czyliuser
z linii 2 zakrywa wcześniejszą zmiennąuser
. Nowa zmienna może mieć całkowicie inny typ niż wcześniejsza, a poprzednia, ponieważ nie będzie już używana zostanie zwolniona z pamięci. .unwrap_or_else
czyli jeśli rezultat zenv::var
nie zawiera poprawnej wartości wywołujemy podaną funkcję z błędem, który został zwrócony (zenv:var
)|_| user
to składniaclosure
(bardzo nie lubię jak niektórzy mówią na to domknięcie) -|x|
to składnia przekazywania parametrów doclosure
- ponieważ nie jesteśmy zaintresowani wartością parametru (konkretnym błędem) dajemy o tym znać kompilatorowi poprzez zastosowanie_
zamiast nazwy zmiennej - a w samym cieleclosure
zwracamy domyślną wartość.let user = "username".to_owned();
pojawiło się wywołanieto_owned
, które zamienia nam wcześniej używany typ '&str' naString
- wszystko dlatego, żeunwrap_or_else
wymaga zwracania takiego typu z wewnętrznejclosure
i oczywiście taki też typ zwracany jest przezenv::var
Z powyższego wynika również, że zmienna user
zmieniła typ na String
o czym kompilator nas również poinformuje:
|
21 | let build_version_name = get_page_and_find_matching(user, passwd, version_pattern, parent_path);
| ^^^^
| |
| expected `&str`, found struct `std::string::String`
| help: consider borrowing here: `&user`
Wystarczy (zgodnie z sugestią) zamienić wszystkie miejsca w main()
gdzie przekazujemy user
na &user
i program znów działa.
Jak to możliwe, że metody przyjmujące &str mogą również przyjąć &Str? Typ String implementuje
trait
Deref<Target=str>, dzięki czemu konwersja typów zachodzi automatycznie
Analogicznie możemy pobrać hasło oraz początkowy url.
Parametry
Druga opcja przekazania wartości początkowych to parametry linii komend. Najpopularniejszą biblioteką do obsługi linii komend w rust jest Clap.
Deklarujemy użycie biblioteki w Cargo.toml
:
clap = "2.33"
oraz w pliku main.rs:
extern crate clap;
I jeszcze z tego crate
będziemy używać:
use clap::{crate_authors, crate_description, crate_name, crate_version, Arg, App};
crate_authors, crate_description, crate_name, crate_version to makra które pobierają odpowiednie wartości z Cargo.toml podczas kompilacji
dostępne parametry podajemy do clap
poprzez zbudowanie obiektu App
:
let arguments = App::new(crate_name!()) //1
.version(crate_version!())
.author(crate_authors!())
.about(crate_description!())
.arg(Arg::with_name("user") //5
.short("u") //6
.long("user") //7
.value_name("USER") //8
.takes_value(true)) //9
.arg(Arg::with_name("password")
.short("p")
.long("password")
.value_name("PASSWORD")
.takes_value(true))
.get_matches(); //15
- linie 1-4 to wykorzystanie wcześniej wymienionych makr.
Clap
zbuduje z nich ładny help dla naszej aplikacji, - linie 5-9 - to dodanie i tworzenie nowego argumentu (klasa
Arg
)with_name
podaje nazwę pod jaką parametr (jego wartość) będzie przechowywana w zwracanym obiekcie,short
,long
to odpowiednio krótka i długa nazwa (których używamy do podania opcji),value_name
tekst wyświetlany jako przykład wartości (np.-p, --password <PASSWORD>
),takes_value
określa czy dana opcja jest przełącznikiem (boolean) czy pobiera wartość,
- linia 15 - wywołanie
get_matches
na zbudowanym obiekcieApp
- czyli pobranie przekazanych do programu parametrów, przetworzenie ich i zwrócenie w obiekcieArgMatches
Finalna wersja pobierania nazwy użytkownika:
let user = arguments.value_of("user")
.map(|s| s.to_owned())
.or(env::var("FINDBUILD_USERNAME").ok())
.expect("No username specified");
arguments.value_of("user")
- pobieramy zArgMatches
Option dla zdefionowanego argumentu,.map(|s| s.to_owned())
jeśli pobrana wartość istnieje, to zmieniamy ją używającclosure
do typuString
,or(env::var("FINDBUILD_USERNAME").ok())
jeśli natomiast wartość nie istnieje, to pobieramy ją ze zmiennej środowiskowej,ok()
- konwertuje Result (zwracany z env::var) doOption
,.expect("No username specified")
- próbujemy wydobyć zOption
wartość, jeśli się nie uda program zostanie przerwany z podanym komunikatem.
Jak widać, zrezygnowałem też z domyślnych wartości dla parametrów. Nie sprawdzam też, czy ktoś nie ustawia wartości na pustą (to może zostanie na kolejną iterację ulepszeń). Analogiczny kod dodałem dla hasła i url początkowego.
Dygresja na temat ścieżki wyszukiwania plików
Aktualnie w programie struktura katalogów w repozytorium jest również zapisana na stałe. To również przydałoby się mieć konfigurowalne.
Pobieranie parametru mamy już przećwiczone wyżej. Zakladam, że cała ścieżka będzie przekazana na raz, a poszczególne segmenty oddzielone np. znakiem |
np.: --path="http://localhost:8000/builds/develop|(b\\d+)|(linux64)|(findbuild_.*?snap)"
.
Podziału takiego stringa na poszczególne segmenty dokonujemy przez
let mut search_paths = path.split("|");
zwracany typ jest iteratorem po typach &str
.
Zanim przystąpimy do obróbki poszczególnych segmentów chcielibyśmy poinformować użytkownika jak zostały one wykryte:
println!("Path(s) used: ");
search_paths.clone().for_each(|p| println!(" {:?}", p));
Jak widać przed for_each
wykonujemy kopię iteratora: od czasu tej zmiany iteratory nie implementują trait Copy
i jedyną możliwością ich "pożyczenia" jest ręczne wykonanie kopi.
Jest to potrzebne, bo przecież za chwilę będę konsumował w/w iterator:
let mut path = search_paths.next().unwrap().to_string();
let mut last_part = String::from("");
loop {
match search_paths.next() {
None => { break; }
Some(pattern) => {
println!("looking for {} at {}", pattern, path);
last_part = get_page_and_find_matching(&user, &passwd, pattern, &path);
path = format!("{}/{}", path, last_part.as_str());
}
}
}
let specific_build_url = path;
let package_name = last_part;
let mut path = search_paths.next().unwrap().to_string();
- pobieramy pierwszy segment, który będzie początkowym url, zmienna jestmutable
,ponieważ będzie zmieniana co obieg pętli,let mut last_part = String::from("");
- miejsce na ostatni znaleziony pasujący tekst (ostatni będzie nazwą pliku do ściągnięcia),- następne w pętli (
loop
) przeliczamy dane pobierane z iteratora, match search_paths.next()
-match
to wyrażenie dopasowania wzorca, można powiedzieć, że znany z innych językówswitch
ale z mocno rozbudowanymi możliwościami (java dopiero w jdk13 dorobiła się podobnych rzeczy!). W tym przypadku dopasowanie będziemy wykonywać na kolejnym elemencie zwracanym z iteratora.
Iteratory w rust zamiast znanych z innych języków
hasNext
inext
posiadają tylko funkcjęnext
która zwracaOption
(czyli może zwrócić nic -None
albo wartośćSome
)
None => { break; }
jeśli iterator zwróciłNone
(czyli nie ma już więcej danych) przerywamy pętlę.Some(pattern) => {
a jeśli zwrócił wartość, to przypisujemy ją do tymczasowej zmiennej pattern i rozpoczynamy wykonanie bloku kodu,last_part = get_page_and_find_matching(&user, &passwd, pattern, &path);
- wykorzystanie wcześniej przygotowanych funkcji - znajdujemy pasujący do wzorca fragment na stronie określonej przez podany url (path
) i zapisujemy go dolast_path
,path = format!("{}/{}", path, last_part.as_str());
- budujemy nową ścieżkę (sklejamy poprzednią i aktualnie znaleziony fragment). Użycieformat!
do sklejania ciągów znaków (bez martwienia się o ich typy) to jeden z nieoficjalnych wzorców w rust,- dwie ostatnie linijki to przepisanie ścieżki i nazwy pliku do zmiennych o takich nazwach, żeby pozostały kod można było użyć bez zmian.
Obsługa błędów
Rust, podobnie jak inne języki rozróżnia błędy, które można naprawić (recoverable
) i te, których już się nie da (unrecoverable
).
Błędy nienaprawialne (unrecoverable
)
Wystąpienie błędu nienaprawialnego powoduje po prostu przerwanie działania programu. Ten sam efekt możemy wywołać
wołając ręcznie makro panic!
. Cudów nie ma, program kończy działanie i w zależności od wybranej obsługi zwalnia pamięć
(lub nie) i wyświetla backtrace
.
Błędy naprawialne (recoverable
)
Typ Result
Podstawą obsługi błędów w rust jest typ Result
jest to enum trzymający informację o poprawnie wykonanej operacji
(i właściwym rezultacie), lub błędzie (wraz z jego szczegółami).
Np. użyta przez nas w kodzie funkcja io::copy
zwraca Result<T, io::Error>
Ok, tak naprawdę zwraca std::io::Result
ale to tylko opakowanie wyżej wynienionego Result, żeby skrócić zapis
Typ Result
to tak naprawdę enum
posiadający dwa dozwolone stany:
enum Result<T, E> {
Ok(T),
Err(E),
}
enum w rust oprócz stanu mogą przechowywać też wartość, na przykładzie powyżej, stan Ok przechowuje wartość typu T, stan Err przechowuje wartości typu E
obsługa takiego Result
odbywa się tak jak każdego innego typu enum
:
let byte_count = io::copy(&mut package_response, &mut out);
let byte_count = match byte_count {
Ok(count) => count,
Err(error) => {
panic!("Problem writing file file: {:?}", error)
},
};
metoda expect
wcześniej w kodzie używaliśmy unwrap()
- to metoda, która wydobywa "dobrą" wartość z rezultatu, natomiast jeśli jej
tam nie ma powoduje panic
. Zamiast unwrap()
można zastosować expect()
gdzie można podać własny komunikat jaki zostanie użyty przy wywołaniu panic
(z expect
korzystaliśmy już przy okazji pobierania parametrów).
Propagowanie błędów
Jeśli miejsce, gdzie akurat otrzymaliśmy Result
nie jest najlepszym miejscem żeby obsłużyć zawarty w nim błąd - możemy w łatwy sposób spropagować go do funkcji "wyżej".
Służy do tego operator ?
:
get
Przykładowo funkcja:
fn get(url: &str, user: &str, passwd: &str) -> Response {
let client = Client::new();
let rsp = client
.get(url)
.basic_auth(user, Some(passwd))
.send()
.unwrap();
rsp
}
po użyciu operatora propagacji funkcja zmienia się na:
fn get(url: &str, user: &str, passwd: &str) -> Result<Response, reqwest::Error> {
let client = Client::new();
let rsp = client
.get(url)
.basic_auth(user, Some(passwd))
.send()?;
Ok(rsp)
}
- po pierwsze zmienił się zwracany typ: zwracamy
Result
i podajemy, że poprawna wartość jest typuResponse
a błąd typureqwest::Error
(czyli taki jak zwracają metody bibliotekireqwest
) - po drugie, tam gdzie pobieramy dotychczas było
send.unwrap()
mamy terazsend()?
- dodaliśmy operator propagacji błędu - po trzecie zwracamy teraz
Ok(rsp)
sygnalizując co jest poprawną wartością
get_page
kolejna zmieniona metoda:
fn get_page(url: &str, user: &str, passwd: &str) -> String {
let mut rsp = get(url, user, passwd);
let body = rsp.text().unwrap();
body
}
po użyciu operatora propagacji zmienia się na:
fn get_page(url: &str, user: &str, passwd: &str) -> Result<String, reqwest::Error> {
let mut rsp = get(url, user, passwd)?;
let body = rsp.text()?;
Ok(body)
}
- zmienił się zwracany typ: zwracamy
Result
i podajemy, że poprawna wartość jest typu String a błąd typureqwest::Error
- dodaliśmy propagowanie błędów w metody
get
- tam gdzie pobieramy
rsp.text()
dodaliśmy operator propagacji błędu - zwracamy teraz
Ok(body)
sygnalizując co jest poprawną wartością
oczywiście wywołanie przy wywołaniu get_page
trzeba teraz zadbać o wydobycie wartości z Result
(najprościej np. przez unwrap()
).
find_last_matching_url
W tej metodzie mamy jedno miejsce, gdzie może wystąpić błąd (kompilacja wyrażenia regularnego). Natomiast może się zdarzyć, że pomimo poprawnego wyrażenia nie uda się znaleźć żadnego dopasowania na stronie, zmieniona funkcja:
fn find_last_matching_url<'a>(page: &'a String, version_pattern: &str) -> Result<Option<&'a str>, regex::Error> {
let regexp = Regex::new(version_pattern)?;
let value = regexp.find_iter(page.as_str())
.last();
match value {
None => Ok(None),
Some(s) => Ok(Some(s.as_str()))
}
}
- zwracamy albo
Option
albo spropagowany błąd, - jeśli value nie zawiera danych, to zwracamy
Ok(None)
(czyli wykonanie było poprawne, ale nie otrzymaliśmy danych), - w przeciwnym przypadku poprzednio zwracany wynik opakowywujemy w
Some
(czyli, żeOption
ma wartość) i również zwracamy jako poprawne wykonanie metody (Ok
).
get_page_and_find_matching
metoda łącząca wcześniej wymienione get_page
oraz find_last_matching_url
, po zmianach:
fn get_page_and_find_matching(user: &str, passwd: &str, pattern: &str, path: &str) -> Result<Option<String>, Box<dyn Error>> {
let platform_page = get_page(path, user, passwd)?;
let build_package_name = find_last_matching_url(&platform_page, pattern)?;
let result = build_package_name.map(|v| v.to_string());
Ok(result)
}
- widać, że propagujemy wyżej błędy zarówno z
get_page
ifind_last_matching_url
, - wynik z
Option<&str>
przepakowywujemy na typ oczekiwany przez metodę, - zmieniona sygnatura:
- jako poprawna wartość zamiast
String
zwracamyOption<String>
(czyli sygnalizujemy, że pomimo poprawnego wykonania nie ma żadnych danych do zwrócenia), - jako błąd mamy...
Box<dyn Error>
...dyn Error
- w ten sposób sygnalizujemy, że będziemy potencjalnie zwracać różne pochodne (implementujące trait)Error
,Box<>
- rust potrzebuje, żeby rozmiar zwracanych danych był znany podczas kompilacji. Ponieważ sami powiedzieliśmy, że zwracać możemy dowolnyError
to tak naprawdę nie zna jego rozmiaru. Używamy więcBox
, który jest rodzajem wskaźnika (o znanym rozmiarze) - właściwe dane wylądują w pamięci na stercie (heap
), gdze niezbędna ilość pamięci zostanie zaalokowana podczas pracy programu.
- jako poprawna wartość zamiast
main
Propagowanie można kontynuować aż do... main
.
Najpierw trzeba powiedzieć kompilatorowi, że będziemy używać "nowych" (w tym przypadku zwracania z main
typu Result
) funkcjonalności, do Cargo.toml
dodajemy:
edition = "2018"
Rust obsługuje tzw. edycje: specyfikacje "kompatybilności". Dzięki temu kod pisany w 2015 nadal może być poprawnie kompilowany w 2020, pomimo użycia np. zmiennych nazwanych tak jak słowa kluczowe w edycji 2018.
fn main() -> Result<(), Box<dyn Error>> {
podobnie jak wcześniej błąd (dowolnej pochodnej Error
) będzie opakowany w Box. Natomiast poprawne zakończenie programu oznaczone jest typem pustym ()
.
Wszystkie miejsca w main, gdzie mieliśmy teraz Result i rozpakowywaliśmy przez unwrap, możemy zamienić na operator propagacji, natomiast na końcu programu trzeba dodać Ok(())
Takie rozwiązanie w przypadku wystąpienia błędu, zwróci nie-zerowy return code, wypisze jego (błędu) "ładną" formę, natomiast nie będziemy mieć backtrace gdzie wystąpił błąd!
Przykładowe działanie programu podczas braku połączenia do serwera:
$ findbuild -u a -p a --path "http://localhost:8000/builds/develop|(b\\d+)|(linux64)|(findbuild_.*?snapd)"
Username used: "a"
Path(s) used:
"http://localhost:8000/builds/develop/"
"(b\\d+)"
"(linux64)"
"(findbuild_.*?snapd)"
looking for (b\d+) at http://localhost:8000/builds/develop/
Error: Error { kind: Hyper(Error { kind: Connect, cause: Os { code: 111, kind: ConnectionRefused, message: "Connection refused" } }), url: Some("http://localhost:8000/builds/develop/") }
ręczne zwracanie błędów
Jest jedno miejsce w kodzie, gdzie nadal możemy otrzymać pustą wartość (a konkretnie None
) - wywołanie get_page_and_find_matching
.
W takim przypadku chcielibyśmy zakończyć działanie programu i wyświetlić stosowny komunikat. Nadal możemy to zrobić przez exit
,
lub skorzystać z tego, że przed chwilą dodaliśmy do main()
obsługę Result
:
let part = get_page_and_find_matching(&user, &passwd, pattern, &path)?;
if part.is_none() {
return Err("Pattern not found in specified url".into());
}
last_part = part.unwrap();
dodaliśmy sprawdzanie czy funkcja faktycznie zwraca dane, jeśli nie, to zwracamy (jesteśmy w main()
!) błąd z ładnym opisem.
Testy
Rust ma wbudowane podstawowe wsparcie dla testów jednostkowych. Testy można pisać jako osobne moduły lub po prostu w kodzie programu.
Zaleta trzymania ich jako osobny moduł (oprócz czystości kodu) jest taka, że moduł można oznaczyć #[cfg(test)]
- w przypadku zwykłego cargo build
nie będa kompilowane.
Natomiast jeśli chcemy, żeby zostały skompilowane i uruchomione trzeba użyć komendy cargo test
.
My podstawowe testy jednostkowe umieścimy w tym samym pliku, ale osobnym module tests
. Każdy test oznacza się przez #[test]
,np.:
#[test]
fn find_last_matching_url_invalid_regexp() {
let page = r#"empty page"#;
let page = String::from(page);
let pattern ="bd+)";
let result = super::find_last_matching_url(&page, pattern);
assert!(result.is_err());
}
z wcześniej nie opisywanych rzeczy:
page
- składnia r to tzw.raw string literal
- łatwe deklarowanie dużych zmiennych tekstowychassert!
- czyli dokładnie to czego się spodziewamy - sprawdzenie warunku (dostępne są jeszczeassert_eq
iassert_ne
)
Wbudowane wsparcie do testów w zasadzie nie oferuje już nic więcej. Jeśli chcemy coś a'la before
/beforeAll
to albo jesteśmy skazani na siebie albo na (jeszcze niedostępna w stabilnym wydaniu) obsługę własnych systemów testowania .
Podsumowanie
Dobrnęliśmy do końca - udało się też przejść (i zademnostrować) przez całkiem sporą część cech/funkcji/składni języka. Program, na obecnym etapie rozwoju, również uważam za zakończony. Wypełnia wszystkie zadania jakie przed nim zostały postawione - oczywiście nadal są rzeczy, które można poprawić/zrobić inaczej/lepiej (np. pozbyć się panic, dopisać testy integracyjne), ale dyplomatycznie napiszę, że nie mają wysokiego priorytetu realizacji ;)
Kolejne ciekaw(sz)e tematy czekają :)
Zainteresowani mogą pobrać aktualny kod z repozytorium git