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 Rusta, 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 oryginale shadowing) zmiennych: czyli user 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 z env::var nie zawiera poprawnej wartości wywołujemy podaną funkcję z błędem, który został zwrócony (z env:var)
  • |_| user to składnia closure (bardzo nie lubię jak niektórzy mówią na to domknięcie) - |x| to składnia przekazywania parametrów do closure - ponieważ nie jesteśmy zaintresowani wartością parametru (konkretnym błędem) dajemy o tym znać kompilatorowi poprzez zastosowanie _ zamiast nazwy zmiennej - a w samym ciele closure zwracamy domyślną wartość.
  • let user = "username".to_owned(); pojawiło się wywołanie to_owned, które zamienia nam wcześniej używany typ '&str' na String - wszystko dlatego, że unwrap_or_else wymaga zwracania takiego typu z wewnętrznej closure i oczywiście taki też typ zwracany jest przez env::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 obiekcie App - czyli pobranie przekazanych do programu parametrów, przetworzenie ich i zwrócenie w obiekcie ArgMatches

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 z ArgMatches Option dla zdefionowanego argumentu,
  • .map(|s| s.to_owned()) jeśli pobrana wartość istnieje, to zmieniamy ją używając closure do typu String,
  • 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) do Option,
  • .expect("No username specified") - próbujemy wydobyć z Option 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 jest mutable ,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ów switch 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 i next posiadają tylko funkcję next która zwraca Option (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 do last_path,
  • path = format!("{}/{}", path, last_part.as_str()); - budujemy nową ścieżkę (sklejamy poprzednią i aktualnie znaleziony fragment). Użycie format! 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 typu Response a błąd typu reqwest::Error (czyli taki jak zwracają metody biblioteki reqwest)
  • po drugie, tam gdzie pobieramy dotychczas było send.unwrap() mamy teraz send()? - 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 typu reqwest::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, że Option 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 i find_last_matching_url,
  • wynik z Option<&str> przepakowywujemy na typ oczekiwany przez metodę,
  • zmieniona sygnatura:
    • jako poprawna wartość zamiast String zwracamy Option<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 dowolny Error to tak naprawdę nie zna jego rozmiaru. Używamy więc Box , 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.

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 tekstowych
  • assert! - czyli dokładnie to czego się spodziewamy - sprawdzenie warunku (dostępne są jeszcze assert_eq i assert_ne)

Wbudowane wsparcie do testów w zasadzie nie oferuje już nic więcej. Jeśli chcemy coś a'la before/beforeAllto 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

Literatura