For en stund tilbake så kom jeg over denne listen med kommandolinjeverktøy skrevet i Rust. Det skjer en masse innovasjon på denne fronten og det er tydelig at Rust er et språk som mange foretrekker å lage slike verktøy med.

Det er kjekt å lære seg nye ting; hvor vanskelig kan det egentlig være å lære seg Rust?

Rust krabbe

Hva skal vi lage?

For å ikke bite over for mye, så trenger vi et overkommelig prosjekt som ikke tar for tid å fullføre. Jeg sjekker ofte strømprisene i Tibber-appen for å vite når det er best å sette på f.eks vaskemaskinen. Når jeg sitter med datamaskinen så har jeg alltid terminalen oppe. Det er mye lettere å fyre avgårde en terminalkommando enn å løfte opp telefonen og finne frem Tibber appen.

Det ferdige produktet ser slik ut:

$ energipris --idag
  I dag (Pris nå 0.888)

 0.89 ┤                                 ╭────────────╮                        ╭───────╮
 0.87 ┤                            ╭────╯            ╰╮              ╭────────╯       ╰──╮
 0.85 ┤                          ╭─╯                  ╰╮            ╭╯                   ╰─╮
 0.83 ┤                        ╭─╯                     ╰╮           │                      ╰─╮
 0.82 ┤                    ╭───╯                        ╰╮         ╭╯                        ╰───╮
 0.80 ┼─╮                 ╭╯                             │         │                             ╰───╮
 0.78 ┤ ╰─╮             ╭─╯                              ╰╮       ╭╯                                 ╰
 0.76 ┤   ╰────╮      ╭─╯                                 ╰╮      │
 0.74 ┤        ╰──────╯                                    │     ╭╯
 0.72 ┤                                                    ╰╮    │
 0.71 ┤                                                     ╰────╯
       ‾|‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾|‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾|‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾|‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾|
       00:00                   06:00                   12:00                   18:00               24:00

Det betyr at vi må gjøre følgende:

  • Parse argumenter til programmet
  • Lese inn et personlig Tibber API token
  • Hente priser fra Tibber APIet med tokenet
  • Tegne en graf med priser

Oppdatering:

Vi har nå flere bloggposter som implementerer det samme konseptet:

Hvordan komme igang?

Det hele starter med å installere verktøyene du trenger for å kompilere og kjøre Rust-programmer.

Merk at Rust har en interessant måte å håndtere minne på som det kan ta litt tid å sette seg inn i. Det har André skrevet om tidligere.

For å starte et nytt prosjekt så kan du generere et utgangspunkt med følgende kommando:

cargo new energipris

For å kjøre programmet gjør du:

cargo run

Da har vi det grunnleggende på plass.

Avhengigheter

Rust er bevisst designet med et tynt standardbibliotek. Den designfilosofien har jeg sansen for. I praksis så må koden i kjernen leve evig.

For å gjøre det vi ønsker så drar vi inn følgende avhengigheter, eller “crates” som de er kjent som i Rust-universet:

  • tokio
    • En runtime som støtter asynkrone kall
  • clap
    • Parser for kommandolinjeargumenter
  • reqwest
    • HTTP-klient for å snakke med Tibber APIet
  • serde
    • JSON parser
  • rasciigraph
    • For å visualisere prisene over tid

Det begynner med main()

Programmet starter med en main-funksjon.

#[tokio::main]
async fn main() { 
  // ...
}

Allerede nå trenger vi litt forklaring. Main er annotert med en tokio-makro som legger på støtte for asynkrone kall. Makroer i Rust genererer kode som ekspanderes når programmet kompileres.

Alternativt så kan du skrive følgende for å oppnå det samme som makroen:

fn main() {
  tokio::runtime::Builder::new_multi_thread()
    .enable_all()
    .build()
    .unwrap()
    .block_on(async {
      // ...
    })
}

Makroer er et utrolig kraftig verktøy med mange skarpe kanter. Det første jeg tenker er hva gjør egentlig denne makroen? Det er fint når det funker, men så snart noe ikke gjør det, så er det ikke så lett å vite hvor man skal begynne å lete.

Argumenter

Vi bruker clap-craten til å parse argumenter fra kommandolinjen. Her kan vi annotere en struct med en makro på følgende vis:

#[derive(Parser)]
#[command(name = "energipriser")]
#[command(author = "Odin Standal <odin@odinodin.com>")]
#[command(version = "1.0")]
#[command(about = "Henter energipriser", long_about = None)]
struct Cli {
  #[arg(long, help = "Viser dagens priser")]
  idag: bool,
  #[arg(long, help = "Viser morgendagens priser")]
  imorgen: bool,
}

Clap sin Parser-makro lesser på med magi for oss. F.eks så får vi en nyttig beskrivelse via --help, som seg hør og bør i et kommandolinjeverktøy. Makroen kan man konfigurere via command(...)-argumenter.

For å vite hva brukeren har sendt inn til programmet, så trenger vi bare å gjøre følgende:

let cli = Cli::parse();
let show_today = cli.idag == true | | cli.idag == false & & cli.imorgen == false;
let show_tomorrow = cli.imorgen == true;

På denne måten kan vi vise enten dagens, morgendagens eller begge prisene samtidig.

Strømpriser

For å bruke Tibber APIet, så må du opprette et API token. Vi kan åpenart ikke hardkode et personlig token inn i programmet, og vi ønsker heller ikke at det skal ende opp på kommandolinje-historikken i terminalen. Vi leser det dermed inn som en miljøvariabel.

let token = match env::var_os("TIBBER_API_TOKEN") {
Some(v) => v.into_string().unwrap(),
None => panic ! ("$TIBBER_API_TOKEN is not set")
};

env::var_os returnerer en Option, som er Rust sin idiomatiske måte å håndtere verdier som kan være tomme. Ikke noe billion dollar mistake her i gården.

Rust har også pattern matching via match, som gir kompileringsfeil om du ikke håndterer alle mulige utfall. Definitivt noe av det jeg liker aller best med Rust så langt.

Hvis programmet ikke finner miljøvariabelen, så er det bare å gi opp via panic.

Tibber sitt API er Graphql-basert. Det finnes Rust-bibliotek som kan autogenerere klientkode basert på Graphql-skjemaet til APIet, men for vårt formål er det overkill. Vi gjør heller bare en POST direkte ved hjelp av reqwest-craten.

Her er en hjelpefunksjon som utfører et query q mot Graphql-endepunktet og legger på det personlige tokenet i headeren.

async fn query(token: &String, q: &str) -> Response { 
  let mut body = HashMap::new();
  body.insert("query", q);

  let client = reqwest::Client::new();
  let response = client
    .post("https://api.tibber.com/v1-beta/gql")
    .json(&body)
    .header(AUTHORIZATION, "Bearer ".to_owned() + &token)
    .header(CONTENT_TYPE, "application/json")
    .header(ACCEPT, "application/json")
    .send()
    .await
    .unwrap();
  response
}

For å hente f.eks nåværende pris, så ser det slik ut:

let q = "{
  viewer {
    homes {
     currentSubscription {
       priceInfo {
         current {
           total
           energy
           tax
           startsAt
         }
       }
     }
   }
 }
}";

let response = query( & token, q).await;

Parsing av JSON

Å ta inn data fra den ville, skumle utsiden inn i et sterkt typet program er en interessant øvelse. Typer hjelper deg å være internt konsistent i programmet ditt, men du kan i prinsippet få inn hva som helst fra utsiden.

Det første vi gjør er å sjekke om statusen på responsen er som vi forventer:

match response.status() {
  reqwest::StatusCode::OK => {
    // Håndtere fornuftig data
  }
  other => {
    // Håndterer alle andre statuser, her har noe gått galt
    // Det beste vi kan gjøre er å få panikk
    panic ! ("Uh oh! Something unexpected happened: {:?}", other);
  }
};

Hvis statuskoden er OK, så kan vi parse JSON-bodyen. Det gjør vi ved å kalle serde::from_str. Typen som vi sender inn blir brukt av parseren, dvs ApiResponse<PriceViewer>.

let response_text = response.text().await.unwrap();

// Vi sender typen til forventet respons som ApiResponse<PriceViewer>
match serde_json::from_str::<ApiResponse<PriceViewer> > ( & response_text) {
  Ok(parsed) => Some(parsed),
  Err(e) => {
    println ! ("FAILED TO PARSE! {:?} {:?}", e, response_text);
    None // Det siste blir implisitt returnert
  }
}

Forventet respons er definert som structs. Jeg fant ikke en måte å definere nøstede structs på annet enn et forkastet forslag. Så jeg endte opp med å lage structs for hvert nivå i responsen. Det blir altså litt boilerplate. Som jeg nevnte tidligere så kan det unngås ved å bruke et bibliotek som f.eks graphql_client

En ApiResponse har generisk Data, som har en generisk viewer. APIet har flere typer viewers. PriceViewer har en liste med Homes. Hvert Home har en Subscription, som har en PriceInfo, som har flere Price for nå, i dag og i morgen.

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Price {
  total: f64,
  energy: f64,
  starts_at: String,
}

derive(Deserialize) trigger serde-biblioteket sin Deserialize macro, som brukes når man parser JSON. Rust setter strenge krav til navngiving, som gjør at vi må bruke serde sin rename_all macro for å håndtere at man ikke får bruke camelCase på props i en struct.

ASCII-graf

Å visualisere grafen får vi helt gratis av rasciigraph-craten. Den tar inn en liste med verdier, og lager en tekststreng som vi kan printe.

println!(
  "{}",
  plot(price_info.today.iter().map(|p| p.total).collect(),
       Config::default()
         .with_width(24 * 4)
         .with_offset(0)
         .with_height(10),
  )
);

Dessverre så støtter ikke rasciigraph en x-akse, så her har jeg (midlertidig) begått en stor synd og hardkodet inn en tidsakse fra 00:00 til 24:00.

Og dermed er programmet vårt komplett. Det er nå en smal sak å publisere det til crates.io, som gjør det lett for hvem som helst å installere det med følgende kommando:

cargo install energipris

Fordeler med Rust

  • Rust er et språk som mange elsker.
  • Det har solid støtte for å lage verktøy til kommandolinjen
  • Det er raskt
    • Det er sjelden at ytelse er et avgjørende argument i disse dager med lynrask hardware, ofte er det viktigere at programmet er lett å forstå og vedlikeholde
  • Sterke garantier fra kompilatoren
    • “Når det kompilerer så virker det” er noe jeg ofte hører fra de som liker sterke typesystemer.
    • Det krever riktignok at man bruker tid på å finne de riktige typene, noe som kan være vanskelig tidlig i et prosjekt før man har skjønt domenet skikkelig
  • God støtte for concurrency

Ulemper med Rust

  • Det er ikke til å skyve under en stol at det tar tid å lære seg Rust
    • Dette er ikke bare en bedre C eller Java, dette er noe ganske annet
    • Er det verdt å lære seg? Definitivt, det vil utvide horisonten din
  • Treg kompilering
    • Det tar overraskende lang tid å kompilere selv små prosjekt
  • Streng kompilator
    • Hvis målet er å kjapt teste ut et API eller en idé, så finnes det andre språk som gir deg en raskere utviklingshastighet.
  • Makroer kan misbrukes
    • Jeg vil heller ha mer boilerplate som jeg kan lese og forstå enn magiske makroer som “bare” er en annotasjon. Jeg har blitt brent for mange ganger av “smart” eller “imponerende” kode som liksom skal spare meg for masse arbeid.

Oppsummert

Rust er såpass annerledes fra de andre språkene jeg kan at det var vanskelig å bare begynne i en ende og kode seg til noe som funker. Jeg traff raskt en vegg med feilmeldinger fra kompilatoren som jeg ikke hadde forutsetning for å forstå. Det gjelder altså å karre til seg nok basiskunnskap først, selv om feilmeldingene var aldri så hjelpsomme.

Jeg kan sterkt anbefale å lese gjennom Rust-boken for å komme igang.

Vil jeg anbefale å bruke Rust til å lage kommandolinjeverktøy? Uten tvil, spesielt hvis det er viktig at programmet er stabilt eller CPU-intensivt. Hvis du imidlertidig bare trenger å raske sammen et verktøy for en mindre greie, så ville jeg heller foreslått f.eks Python, bash eller Node/Deno.

De ekstra interesserte kan lese kildekoden her