Język Rust reklamuje się hasłem szybki, niezawodny, wydajny - wybierz trzy. Został zaprojektowany jako język "systemowy" (czyli może zastąpić tradycyjnie używany C, za którym btw trochę tęsknię). Ponadto kolejny rok z rzędu "wygrał" jako najbardziej kochany język w ankiecie StackOverflow. Wszystko krzyczy: "jestem dla Ciebie, weź mnie spróbuj".

No i tak sobie chodziliśmy koło siebie :) On w międzyczasie się rozwijał i parł do przodu, ja sobie czytałem, robiłem ćwiczenia, ale w końcu chyba udało się osiągnąć masę krytyczną potrzebną do wykonania kolejnego kroku. Na razie jestem na etapie tworzenia prostych narzędzi na własne potrzeby.

Tak więc w "menu" na dziś mamy proste narzędzie do znajdywania ostatniego buildu - przy okazji postaram się zaprezentować niektóre z cech języka oraz towarzyszącej infrastruktury.

Problem, cel, założenia

Problem:

  • Jest sobie repozytorium buildów dla projektu (ew kilku projektów)
  • Repozytorium wystawia buildy przez http(s), jako zwykłe listy plików/katalogów.
  • Struktura katalogów jest hierarchiczna: lista branchy -> lista buildów -> lista platform -> lista plików wynikowych

Najprostszy możliwy przykład:

mkdir -p builds/master
mkdir -p builds/develop/b1/linux64
mkdir -p builds/develop/b2/linux64
mkdir -p builds/develop/b3/linux64
mkdir -p builds/develop/b1/win64
mkdir -p builds/develop/b2/win64
mkdir -p builds/develop/b3/win64

touch builds/develop/b3/linux64/findbuild_2019-09-28.snap
touch builds/develop/b3/linux64/findbuild.deb
touch builds/develop/b3/linux64/findbuild.rpm

python -m SimpleHTTPServer

pod adresem http://localhost:8000 będzie dostępne nasze repozytorium do testów.

Cel:

Znalezienie linka do pliku z ostatnim buildem z danego brancha.

Założenia

  • dla każdego projektu z góry znamy url startowy
  • dla każdego projektu znamy wzorzec nazywania buildów
  • interesuje nas z góry określona platforma
  • interesuje nas z góry określony plik wynikowy

Tworzenie projektu i jego struktura

Rust posiada własny program do zarządzania budowaniem projektów - taki odpowiednik maven/gradle. Podobnie jak wymienionych istnieje możliwość stworzenia projektu z szablonu:

$ cargo new findbuild --bin
      Created binary (application) `findbuild` package

tworzy projekt o nazwie findbuild. --bin oznacza, że będzie to aplikacja wykonywalna (można jescze tworzyć projekt z biblioteką: --lib). Wykonanie komendy tworzy katalog o nazwie podanego projektu wraz z gotową strukturą (oraz repozytorium git):

$ tree findbuild/
findbuild/
├── Cargo.toml
└── src
    └── main.rs

1 directory, 2 files

Sprawdzam czy to się w ogóle uruchamia:

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/findbuild`
Hello, world!

Zawartośc pliku konfiguracji projektu Cargo.toml:

[package]
name = "findbuild"
version = "0.1.0"
authors = ["Ihsahn <ihsahn@users.noreply.github.com>"]

[dependencies]

oraz zawartość jedynego pliku z kodem main.rs:

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

http get (razem z basic auth)

Skoro program ma operować na stronach html konieczne jest ich pobranie, czyli potrzebne nam narzędzie, które wykona HTTP GET. Szybkie wyszukiwanie i znalezione rzekomo najprostsze do użycia: reqwest (i jego dokumentacja)

do sekcji [dependencies] w Cargo.toml dodajemy więc reqwest = "0.9.13"

i juz można korzystać z nowej paczki w kodzie.

Po pierwsze potrzebujemy zmiennych na username, hasło oraz początkowy url:

let user = "username";
let passwd = "password";

let parent_path = "http://localhost:8000/builds/develop/";

Słowo kluczowe let służy do definiowania nowych zmiennych/stałych. Rust rozróżnia stałe (immutable variable) tworzone właśnie przez użycie let oraz takie które można modyfikować (let mut)

Drugą cechą Rust widoczną w powyższym kodzie jest rozpoznawanie typów zmiennych na podstawie kodu/kontekstu (and type interference). W tym przypadku kompilator uzna, że zmienne są typu &str

Rust posiada dwa podstawowe typy określające ciąg znaków String oraz &str. Ten pierwszy jest zapisany na stercie (heap) i można go zmieniać, podczas gdy drugi jest niezmienny. Zainteresowanym szczegółami polecam bardzo fajny artykuł na ten temat: Rust: str vs String

Wykorzystanie nowej paczki możliwe jest po jej zaimportowaniu/zadeklarowaniu użycia:

extern crate reqwest;

no i teraz można już pobrać treść określonej strony

let mut response = reqwest::get("http://localhost:8000/builds/develop/")`

Operacje bedziemy powtarzać, więc dobrze by było mieć do tego osobną funkcję:

fn get_page(url: &str, user: &str, passwd: &str) -> String { //1
    let client = reqwest::Client::new();  //2
    let rsp = client //3
        .get(url) //4
        .basic_auth(user, Some(passwd)) //5
        .send() //6
        .unwrap(); //7
    let body = rsp.text().unwrap(); //8
    body //9
}

1 - definicja funkcji:

  • fn określa, że będzie to funkcja
  • get_page - nazwa funkcji
  • (url: &str, user: &str, passwd: &str) - definicja parametrów funkcji wraz z ich typami
  • -> String definicja typu zwracanego przez funkcję

2 - let client = reqwest::Client::new() - stworzenie nowego klienta http i zapisanie go do zmiennej

3 - let rsp = client - rozpoczynamy konfigurację klienta oraz pobranie danych (wynik zostanie zapisany do rsp):

4 - get(url) - będziemy wykonywać http get z określonego url

5 - .basic_auth(user, Some(passwd)) - ustawiamy autentykację na basic auth z podanym użytkownikiem i hasłem. Hasło jest opcjonalne - typ Option (działa to podobnie jak javowe Optional). W związku z tym trzeba podać, że jest tam konkretna wartość czyli Some(password)

6 - send() - wysłanie http get i pobranie wyniku

7 - .unwrap() - Ponieważ wynikiem send jest Result<Response,Error> a nas interesuje samo Response musimy je jakoś wydobyć z Result - właśnie do tego jest unwrap(). Co prawda jeśli coś pójdzie nie tak nasz program się ładnie wyłoży z komunikatem panic, ale tym zajmiemy się jak już będzie działająca wersja.

8 - rsp.text().unwrap() - pobieramy z Response text i wykonujemy unwrap() ponieważ ponownie musimy rozpakowac Result.

9 - body - zwracamy wartość body na zewnątrz (w Rust ostatnia linia funkcji uważana jest za zwrócenie jej wartości, dozwolone jest też użycie return - zwłaszcza jeśli zwrot wartości miałby nastąpić we wcześniejszym fragmencie kodu)

Próba kompilacji i dostajemy błąd:

15 |     let rsp = client
   |         --- help: consider changing this to be mutable: `mut rsp`
...
20 |     let body = rsp.text().unwrap();
   |                ^^^ cannot borrow as mutable

No i udało nam się wpaść na to co decyduje o sile Rust: system "własności" - to właśnie on zapewnia, że nie zrobimy sobie krzywdy nadpisując po cichu dane w jednej funkcji a w drugiej jednocześnie czytając, oraz sprawia, że Rust nie potrzebuje Garbage Collectora - ponieważ kompilator dokładnie śledzi użycia i własność obiektów i wie kiedy może je usunąć z pamięci (bo właściciel przestał istnieć).

W tym przypadku ponieważ rsp.text() jest zadeklarowane jako pub fn text(&mut self) czyli wymaga zmiennej z dozwoloną modyfikacją (operacja text() może zmienić zawartość Response na którym została wykonana) kompilator słusznie proponuje dodanie mut do `rsp.

Na powyższym przykładzie widać jeszcze jedną cechę: błędy z kompilatora zazwyczaj użyteczne

Jeszcze wykorzystanie nowej funkcji:

   let page = get_page(parent_path, user, passwd);
   println!("Page: {:?}", page);

pritln! to makro (tak Rust ma makra) do wypisywania tekstu na stdout razem z opcjonalnym formatowaniem.

{:?} - podajemy, że chcemy sformatować dany obiekt w celach debug

Pozostaje jeszcze jedna kwestia, pozbycie sie nadmiarowego reqwest:: z reqwest::Client - w przypadku gdy nie mamy w kodzie obiektów Client z innych paczek możemy zadeklarować bezpośrednie użycie obiektu Client poprzez dodanie use reqwest::Client; i usunięcie przedrostka w kodzie.

aktualny kod:

extern crate reqwest;

use reqwest::Client;

fn main() {
    let user = "username";
    let passwd = "password";

    let parent_path = "http://localhost:8000/builds/develop/";

    let page = get_page(parent_path, user, passwd);
    println!("Page: {:?}", page);
}

fn get_page(url: &str, user: &str, passwd: &str) -> String {
    let client = reqwest::Client::new();
    let mut rsp = client
        .get(url)
        .basic_auth(user, Some(passwd))
        .send()
        .unwrap();
    let body = rsp.text().unwrap();
    body
}

Szukanie wersji/paczki na stronie (regexp)

Po ściągnięciu zawartości strony trzeba na niej znaleźć albo ostatni build albo katalog odpowiedni dla naszej platformy albo konkretną paczkę. Wszystkie te użycia można zaimplementować za jednym razem przy użyciu wyrażeń regularnych.

Ponownie szybkie wyszukanie odpowiedniej paczki: regex (i jego dokumentacja)

Podobnie jak poprzednio trzeba podać, że nasz projekt będzie z tej paczki korzystał:

do [dependencies] w Cargo.toml dopisujemy:

regex = "1.1.5"

oraz definiujemy jej użycie w kodzie:

extern crate regex;

use regex::Regex;

Na początek prosty regexp, który znajdzie katalog z ostatnim buildem, gdzie nazwa zaczyna się od b a następnie jest stale rosnący numer kompilacji:

  let version_pattern = "(b\\d+)"; 

Tworzymy obiekt Regex:

let regexp = Regex::new(version_pattern).unwrap();

do wyszukiwania wszystkich pasujących grup w danych wejściowych dostępne są dwie metody: captures_iter lub find_iter, przy czym ta pierwsza (captures_iter) zwraca iterator po Captures które dopiero trzeba rozpakować, żeby dostać się do pasującego tekstu. W przypadku find_iter dostajemy iterator po "pojedyńczych" trafieniach Match

Kod który będzie wyciągał żądaną przez nas wartość:

    let value = regexp.find_iter(page.as_str()) //1
        .last() //2
        .unwrap() //3 Match
        .as_str();//4

1 - regexp.find_iter(page.as_str()) wykonanie regexp i pobranie interatora

2 - last() - wersje na stronie powinny być ułożone rosnąco, w związku z tym interesuje nas ostatni pasujący wynik

3 - unwrap() - podobnie jak wcześniej musimy rozpakować Option

4 - as_str() - pobranie z Match wartości jako typ &str

Oczywiście pobieranie różnych tekstów będziemy powtarzać, więc potrzebujemy z powyższego kodu zrobić osobą funkcję

fn find_last_matching_url(page: String, version_pattern: &str) -> &str {
    let regexp = Regex::new(version_pattern).unwrap();
    let value = regexp.find_iter(page.as_str())
        .last()
        .unwrap() 
        .as_str();
    value
}

oraz wywołanie:

    let result = find_last_matching_url(page, version_pattern);
    println!("Last build: {:?}", result);

próba kompilacji i błąd:

   |
34 |     let value = regexp.find_iter(page.as_str())
   |                                  ---- `page` is borrowed here
...
39 |     value
   |     ^^^^^ returns a value referencing data owned by the current function

Tłumacząc na nasz: zwracane value (które jest typu &str) to najprawdopodobniej referencja do części wejściowego page. Definiując parametr po prostu jako page: String pozwalamy myśleć kompilatorowi, że własnośc zmiennej powinna zostać przeniesiona do wewnątrz funkcji. Natomast na zewnątrz zwracamy tylko wskazanie do kawałka tej zmiennej, więc kompilator nie będzie wiedział kiedy powinien zwolnić całą pamięć dla page. Spróbujmy więc dać na wejściu referencję:

fn find_last_matching_url(page: &String, version_pattern: &str) -> &str {

oraz subtelna zmiana w wywołaniu (dochodzi & przed page):

let result = find_last_matching_url(&page, version_pattern);

i kolejny błąd kompilacji:

   |
32 | fn find_last_matching_url(page: &String, version_pattern: &str) -> &str {
   |                                                                    ^ expected lifetime parameter
   |
   = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `page` or `version_pattern`

No i tu już wszystko jasne: kompilator rozpoznał, że zwracamy coś co jest pochodną jednego z parametrów wejściowych, ale nie jest na tyle inteligentny żeby samemu rozpoznać z którego. Musimy mu w tym pomóc: do określania jak długo dane referencje są "ważne" (jak długi jest czas ich życia) służą adnotacje zwane lifetimes.

Funkcję trzeba oznaczć ile różnych lifetimes bedzie posiadać (w naszym przypadku będzie to jedno lifetime o nazwie a): fn find_last_matching_url<'a>. Potem trzeba dodać oznaczenie do wybranego parametru, oraz do definicji zwracanego typu:

fn find_last_matching_url<'a>(page: &'a String, version_pattern: &str) -> &'a str {

i .. nie ma błędów kompilacji!

aktualny kod:

extern crate reqwest;
extern crate regex;

use reqwest::Client;
use regex::Regex;

fn main() {
    let user = "username";
    let passwd = "password";

    let parent_path = "http://localhost:8000/builds/develop/";
    let version_pattern = "(b\\d+)";

    let page = get_page(parent_path, user, passwd);
    println!("Page: {:?}", page);

    let result = find_last_matching_url(&page, version_pattern);
    println!("Last build: {:?}", result);
}

fn get_page(url: &str, user: &str, passwd: &str) -> String {
    let client = Client::new();
    let mut rsp = client
        .get(url)
        .basic_auth(user, Some(passwd))
        .send()
        .unwrap();
    let body = rsp.text().unwrap();
    body
}

fn find_last_matching_url<'a>(page: &'a String, version_pattern: &str) -> &'a str {
    let regexp = Regex::new(version_pattern).unwrap();
    let value = regexp.find_iter(page.as_str())
        .last()
        .unwrap()
        .as_str();
    value
}

URL konkretnej paczki buildowej

Mamy już katalog z buildem - potrzebujemy jeszcze URL konkretnej paczki buildowej.

  1. Po pierwsze, aktualny result to będzie nasze build_version_name - w naszym przykładzie będzie to b3 (ostatni build z develop)
  2. Wiemy, że interesują nas paczki dla linux64, więc dodaję let platform = "linux64";
  3. Potrzebujemy teraz URL gdzie będziemy szukać konkretnej paczki:
let platform_path = parent_path + build_version_name + "/" + platform;

co oczywiście znów kończy się błędem kompilacji

   |
21 |     let platform_path = parent_path + build_version_name + "/" + platform;
   |                         ----------- ^ ------------------ &str
   |                         |           |
   |                         |           `+` cannot be used to concatenate two `&str` strings
   |                         &str
help: `to_owned()` can be used to create an owned `String` from a string reference. String concatenation appends the string on the right to the string on the left and may require reallocation. This requires ownership of the string on the left
   |
21 |     let platform_path = parent_path.to_owned() + build_version_name + "/" + platform;
   |                         ^^^^^^^^^^^^^^^^^^^^^^

Znów wszystko ładnie wytłumaczone:

  • + nie może zostać użyty do połączenia dwóch typów &str (czyli referencji do części innego String)
  • proponuje użyć metody to_owned() aby zamienić parent_path na String (zwróćcie uwagę że kompilator podaje też powód takiej zmiany: połączenie ciągów znaków wymaga realokacji pamięci a więc również "własności" zmiennej)

Poprawnie kompilująca się wersja:

    let platform_path = parent_path.to_owned() + build_version_name + "/" + platform;

Szukamy paczek snap, więc dodajemy jescze definicje regexp pasującą do nazwy paczki let package_pattern = "(findbuild_.*?snap)";, a następnie potrzebujemy ponownie pobrać treść strony i znaleźć na niej dane według wzorca:

 let platform_page = get_page(platform_path.as_str(), user, passwd);
 let package_name = find_last_matching_url(&platform_page, package_pattern);
 println!("Package name: {:?}", package_name);

para wywołań get_page i find_mathing_url powtarza się drugi raz więc zamienimy ją na jedną funkcję:

fn get_page_and_find_matching(user: &str, passwd: &str, pattern: &str, path: &String) -> &str {
    let platform_page = get_page(path.as_str(), user, passwd);
    let build_package_name = find_last_matching_url(&platform_page, pattern);
    build_package_name
}

Niestety kompilator protestuje, że nie wie do czego "przypiąć" zwracaną referencję &str:

   |
29 | fn get_page_and_find_matching(user: &str, passwd: &str, pattern: &str, path: &String) -> &str {
   |                                                                                          ^ expected lifetime parameter
   |
   = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `user`, `passwd`, `pattern`, or `path`

Sytuacja jest o tyle skomplikowana, że zwracana referencja pochodzi do obiektu który jest stworzony przez get_page w związku z tym, można spokojnie przekazać jego "własność" na zewnątrz funkcji:

fn get_page_and_find_matching(user: &str, passwd: &str, pattern: &str, path: &str) -> String {
    let platform_page = get_page(path, user, passwd);
    let build_package_name = find_last_matching_url(&platform_page, pattern);
    build_package_name.to_string()
}

Pozostaje nam podmienić wywołania, oraz skomponować końcowy adres paczki.

aktualny kod:

extern crate reqwest;
extern crate regex;

use reqwest::Client;
use regex::Regex;

fn main() {
    let user = "username";
    let passwd = "password";

    let parent_path = "http://localhost:8000/builds/develop/";
    let version_pattern = "(b\\d+)";
    let platform = "linux64";
    let package_pattern = "(findbuild_.*?snap)";

    let build_version_name = get_page_and_find_matching(user, passwd, version_pattern, parent_path);

    let platform_path = parent_path.to_owned() + build_version_name.as_str() + "/" + platform;

    let package_name = get_page_and_find_matching(user, passwd, package_pattern, &platform_path);

    let specific_build_url = platform_path + "/" + &package_name;
    
    println!("Full url: {:?}", specific_build_url);
}

fn get_page_and_find_matching(user: &str, passwd: &str, pattern: &str, path: &str) -> String {
    let platform_page = get_page(path, user, passwd);
    let build_package_name = find_last_matching_url(&platform_page, pattern);
    build_package_name.to_string()
}

fn get_page(url: &str, user: &str, passwd: &str) -> String {
    let client = Client::new();
    let mut rsp = client
        .get(url)
        .basic_auth(user, Some(passwd))
        .send()
        .unwrap();
    let body = rsp.text().unwrap();
    body
}

fn find_last_matching_url<'a>(page: &'a String, version_pattern: &str) -> &'a str {
    let regexp = Regex::new(version_pattern).unwrap();
    let value = regexp.find_iter(page.as_str())
        .last()
        .unwrap()
        .as_str();
    value
}

W kodzie powyżej zaszła jeszcze jedna ważna zmiana: build_version_name zwracane przez get_page_and_find_matching jest teraz typu String w związku z tym, podczas sklejania stringów konwertujemy go na &str: build_version_name.as_str()

Pobranie paczki z buildem

Do pobrania paczki potrzebujemy "goły" response z serwera, w związku z tym z metody get_page wydzielę get:

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
}

fn get_page(url: &str, user: &str, passwd: &str) -> String {
    let mut rsp = get(url, user, passwd);
    let body = rsp.text().unwrap();
    body
}

w methodzie get zmienna rsp nie musi być oznaczona jako mut ponieważ w wykonujemy na niej żadnych operacji (w tej metodzie)

Pobranie odpowiedzi z serwera z paczką:

let mut package_response = get(specific_build_url.as_str(), user, passwd);

Stworzenie pliku wynikowego:

 let mut out = File::create(package_name).unwrap();

... i przepisanie zawartości z odpowiedzi do pliku

 io::copy(&mut package_response, &mut out).unwrap();

aktualny kod:

extern crate reqwest;
extern crate regex;

use reqwest::{Client, Response};
use regex::Regex;
use std::fs::File;
use std::io;

fn main() {
    let user = "username";
    let passwd = "password";

    let parent_path = "http://localhost:8000/builds/develop/";
    let version_pattern = "(b\\d+)";
    let platform = "linux64";
    let package_pattern = "(findbuild_.*?snap)";

    let build_version_name = get_page_and_find_matching(user, passwd, version_pattern, parent_path);

    let platform_path = parent_path.to_owned() + build_version_name.as_str() + "/" + platform;

    let package_name = get_page_and_find_matching(user, passwd, package_pattern, &platform_path);

    let specific_build_url = platform_path + "/" + &package_name;

    println!("Full url: {:?}", specific_build_url);
    let mut package_response = get(specific_build_url.as_str(), user, passwd);
    let mut out = File::create(package_name).unwrap();
    io::copy(&mut package_response, &mut out).unwrap();
}

fn get_page_and_find_matching(user: &str, passwd: &str, pattern: &str, path: &str) -> String {
    let platform_page = get_page(path, user, passwd);
    let build_package_name = find_last_matching_url(&platform_page, pattern);
    build_package_name.to_string()
}

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
}

fn get_page(url: &str, user: &str, passwd: &str) -> String {
    let mut rsp = get(url, user, passwd);
    let body = rsp.text().unwrap();
    body
}

fn find_last_matching_url<'a>(page: &'a String, version_pattern: &str) -> &'a str {
    let regexp = Regex::new(version_pattern).unwrap();
    let value = regexp.find_iter(page.as_str())
        .last()
        .unwrap()
        .as_str();
    value
}

także na findbuild github repo.

Podstawowe funkcjonalności działają, plik się ściąga, więc na dziś to wszystko...

todo

  • pobieranie danych (adres, hasło, etc) z lini komend i/lub zmiennych środowiskowych
  • obsługa błędów
  • testy

tak więc... ciąg dalszy nastąpi.

Literatura