Salta al contenuto principale

Re-implementare un protocollo in Rust

·9 minuti· ·
Progetti Libreria Lettore Di Presenze TCP Rust
Indice dei contenuti
Lettore di presenze - Questo articolo fa parte di una serie
Parte 3: Questo articolo

Nell’articolo precedente siamo riusciti a comprendere il significato dei pacchetti che vengono scambiati fra il client ufficiale ed il lettore di presenze.

Rimane solo una cosa da fare: Riscrivere l’API in Rust!

Rewrite it in Rust

Ricreare l’API ufficiale
#

Per iniziare installiamo Rust e creiamo un nuovo progetto tramite Cargo, il package manager di Rust, con il seguente comando:

cargo new r701

Possiamo poi aprire il progetto col nostro text editor di fiducia.

Dato che dobbiamo creare una libreria, andiamo a creare il file src/lib.rs e cominciamo a scrivere la struct che descriverà il nostro lettore:

// src/lib.rs
use std::io::Result;
use std::net::{TcpStream, ToSocketAddrs};

#[derive(Debug)]
pub struct R701 {
    tcp_stream: TcpStream,
    sequence_number: u16,
}

impl R701 {
    pub fn connect(connection_info: impl ToSocketAddrs) -> Result<Self> {
        // Create a new R701 struct
        let mut new = Self {
            tcp_stream: TcpStream::connect(connection_info)?,
            sequence_number: 0,
        };

        // Try to ping the endpoint
        new.ping()?;
        Ok(new)
    }
}

La nostra struct contiene due campi:

  • tcp_stream, che contiene il descrittore della connessione al nostro lettore;
  • sequence_number, che memorizza il numero dell’ultimo pacchetto inviato.

Per provare se la nostra struct si connette correttamente possiamo modificare il file src/main.rs in modo che si connetta al nostro endpoint:

// src/main.rs
use r701::R701;

fn main() {
    let r701 = R701::connect("127.0.0.1:5005").unwrap();
    println!("{:?}", r701);
}

Se adesso eseguiamo cargo run

Output di cargo run
Ecco il client che si connette

Urrà! Il nostro client si connette con successo al server TCP!

Il prossimo step sarà quello di utilizzare la libreria std::net::TcpStream per andare ad eseguire le query che abbiamo ricavato dal nostro tentativo di reverse engineering e ottenere ed elaborare le risposte.

Dato che tutte le richieste hanno una struttura standard, possiamo andare a creare un metodo che prende in input il payload di una richiesta (rappresentata da una slice di 12 u8) e ritorni un Vec<u8> contenente la risposta:

// src/lib.rs

impl R701 {
    // ...

    pub fn request(&mut self, payload: &[u8; 12]) -> Result<Vec<u8>> {
        // Create a blank request
        let mut request = [0x55, 0xaa, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
    
        // Insert the payload
        request[2..14].clone_from_slice(payload);
    
        // Insert the sequence number
        request[14..].clone_from_slice(&self.sequence_number.to_le_bytes());
        self.sequence_number += 1;
    
        // Send the request
        self.tcp_stream.write_all(&request)?;
    
        // Create a buffer and return the response
        let mut buffer = BufReader::new(&self.tcp_stream);
        Ok(buffer.fill_buf()?.to_vec())
    }
}

Possiamo verificare che tutto funzioni correttamente inviando un pacchetto di ping e aspettandoci la risposta corretta:

// src/main.rs
use r701::R701;

fn main() {
    let r701 = R701::connect("127.0.0.1:5005").unwrap();

    assert_eq!(
        r701.request(&[0x01, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]).unwrap(),
        [0xaa, 0x55, 0x01, 0x01, 0, 0, 0, 0, 0, 0],
    );
}

Potremmo addirittura rendere il ping un metodo a se stante nella nostra struct:

// src/lib.rs

impl R701 {
    // ...

    pub fn ping(&mut self) -> Result<()> {
        // Create a request with a payload of `01 80 00 00 00 00 00 00 00 00 00 00`
        let response = self.request(&[0x01, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])?;
    
        // If the response is not `aa 55 01 01 00 00 00 00 00 00` then return an error
        if response != [0xaa, 0x55, 0x01, 0x01, 0, 0, 0, 0, 0, 0] {
            return Err(Error::new(InvalidData, "Malformed response"));
        }
    
        Ok(())
    }
}

Con questo metodo possiamo andare a creare anche i metodi per ottenere il nome di un dipendente, il numero totale di presenze ed un blocco di presenze.

Se siete interessati, tutto il codice sorgente è già presente su nicolabelluti/r701.

nicolabelluti/r701

Rust
1
0

Estrarre le presenze tramite il trait TryInto
#

Una volta creato il metodo che ci permette di estrarre un blocco di presenze, bisogna trovare il modo idiomatico per trasformarlo da un array di byte a una struct che rappresenti una singola presenza.

Per iniziare facciamo un po’ di refactoring rinominando src/lib.rs in src/r701.rs e creando un nuovo src/lib.rs contenente queste righe:

// src/lib.rs
mod r701;
pub use r701::R701;

In questo modo l’interfaccia esterna della nostra libreria non cambierà, però così facendo possiamo organizzare il nostro codice in diversi file.

Aggiungiamo il file src/record.rs e includiamolo in src/lib.rs

// src/lib.rs
mod r701;
mod record;

pub use r701::R701;
pub use record::{Record, Clock};
// src/record.rs
use chrono::{DateTime, Local, TimeZone};

pub enum Clock {
    FirstIn,
    FirstOut,
    SecondIn,
    SecondOut,
}

pub struct Record {
    pub employee_id: u32,
    pub clock: Clock,
    pub datetime: DateTime<Local>,
}

Con questo codice abbiamo definito la struttura di una presenza che, come abbiamo detto nell’articolo precedente, è composta dall’ID del dipendente, dalla data e dall’ora alla quale è stata registrata e dallo stato (se è la prima entrata, la prima uscita, la seconda entrata o la seconda uscita).

Dato che non vogliamo impazzire gestendo il tempo, andiamo ad importare il crate chrono per la gestione delle date:

cargo add chrono --no-default-features --features clock

Per facilitare la conversione da un vettore di byte alla nostra struct Record possiamo implementare il trait TryInto:

// src/record.rs
impl TryFrom<&[u8]> for Record {
    type Error = &'static str;

    fn try_from(record_bytes: &[u8]) -> Result<Self, Self::Error> {
        // ...
    }
}

Il codice finito è disponibile qua.

Possiamo testare se la conversione è corretta tramite un semplice test:

// src/record.rs

// ...

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn valid_record_conversion() {
        let record_bytes: &[u8] = &[0x10, 0x23, 0x0b, 0x1d, 0x01, 0, 0, 0, 0xb2, 0x17, 0x01, 0];

        assert_eq!(
            record_bytes.try_into(),
            Ok(Record {
                employee_id: 1,
                clock: Clock::FirstIn,
                datetime: Local.with_ymd_and_hms(1970, 1, 1, 0, 0, 0).single().unwrap(),
            })
        )
    }
}

Unire il tutto tramite gli iteratori
#

Una volta trovato un modo per estrarre dei byte dal dispositivo ed un modo per convertirli in una struct, dobbiamo trovare il modo idiomatico per mettere insieme le due cose, ed è proprio qua che entrano in gioco gli iteratori.

Per implemetare il trait Iterator bisogna definire solo il metodo next() che, partendo dal primo elemento, ritorna l’elemento successivo.

Una volta definito questo metodo avremmo accesso a molti altri strumenti, come map(), filter(), fold() e, se andiamo ad importare il crate itertools, anche sorted() e into_group_map_by(), giusto per elencarne alcuni.

Come prima cosa andiamo a creare una nuova struct RecordIterator con un costruttore from() che ci permetta di generare un iteratore prendendo in input una reference mutabile ad una struct R701:

// src/lib.rs
mod r701;
mod record;
mod record_iterator;

pub use r701::R701;
pub use record::{Record, Clock};
pub use record_iterator::RecordIterator;
// src/record_iterator.rs
use crate::R701;
use std::io::Result;

#[derive(Debug)]
pub struct RecordIterator<'a> {
    r701: &'a mut R701,
    input_buffer: Vec<u8>,
    sequence_number: u16,
    total_records: u16,
    record_count: u16,
}

impl<'a> RecordIterator<'a> {
    pub fn from(r701: &'a mut R701) -> Result<Self> {
        // ...
    }
}

Il metodo from() richiede al lettore il numero totale di timbrate ed il primo blocco di presenze , salvandoli rispettivamente nella variabile total_records e nel vettore input_buffer.

Il metodo next() del trait Iterator andrà poi a prendere i primi 12 byte dell’input buffer e li trasformerà in una struct Record tramite il trait TryInto che abbiamo implementato nel capitolo precedente.

Quando input_buffer è vuoto allora viene richiesto al lettore un’altro blocco di presenze, fino a che non vengono lette tutte.

Se siete interessati tutto il codice è già disponibile su Git.

// src/record_iterator.rs

// ...

impl<'a> Iterator for RecordIterator<'a> {
    type Item = Record;

    fn next(&mut self) -> Option<Self::Item> {
        // ...
    }
}

Giusto per completezza possiamo implementare un metodo into_record_iter nella struct R701, per semplificare l’utilizzo dell’iteratore:

// src/r701.rs
use crate::RecordIterator;

// ...

impl R701 {
    // ...

    pub fn into_record_iter(&mut self) -> Result<RecordIterator> {
        RecordIterator::from(self)
    }
}

Rendere il tutto Blazingly Fast
#

Come prima cosa andiamo a creare un main che crei un file con la stessa struttura del file AGLog_001.txt che abbiamo visto nel primo capitolo di questa serie:

// src/main.rs
use r701::R701;

fn main() {
    let mut r701 = R701::connect("127.0.0.1:5005").unwrap();

    println!("No\tMchn\tEnNo\t\tName\t\tMode\tIOMd\tDateTime\t");
    r701.into_record_iter()
        .unwrap()
        .collect::<Vec<_>>()
        .iter()
        .enumerate()
        .for_each(|(id, record)| {
            let name = r701
                .get_name(record.employee_id)
                .unwrap()
                .unwrap_or(format!("user #{}", record.employee_id));

            println!(
                "{:0>6}\t{}\t{:0>9}\t{: <10}\t{}\t{}\t{}",
                id + 1,
                1,
                record.employee_id,
                name,
                35,
                record.clock as u8,
                record.datetime.format("%Y/%m/%d  %H:%M:%S"),
            );
        });
}

Già con questo main() riusciamo ad ottenere tutti i record in un po’ meno di un minuto, che è la metà del tempo che impiega il client closed source ufficiale.

Certo, stiamo leggermente barando dato che il nostro client non riesce ad estrarre l’ID del registratore, la modalità di registrazione della presenza ed i secondi del campo DateTime, ma per il momento possiamo ignorarli dato che sono campi superflui.

Memoizzare il nome dei dipendenti
#

Per velocizzare ancora di più le cose potremmo evitare di chiedere al tibratore il nome dei dipendente per ogni record.

Possiamo creare un HashMap di nomi e, per ogni record, verificare se il nome è già presente al suo interno. Se no, allora si può chiedere al timbratore il nome del dipendente per poi salvarlo all’interno dell HashMap.

In questo modo andiamo a ridurre il numero di richieste al minimo indispensabile.

// src/main.rs
use r701::R701;
use std::collections::HashMap;

fn main() {
    let mut names = HashMap::new();
    let mut r701 = R701::connect("127.0.0.1:5005").unwrap();

    println!("No\tMchn\tEnNo\t\tName\t\tMode\tIOMd\tDateTime\t");
    r701.into_record_iter()
        .unwrap()
        .collect::<Vec<_>>()
        .iter()
        .enumerate()
        .for_each(|(id, record)| {
            let name = names.entry(record.employee_id).or_insert_with(|| {
                r701.get_name(record.employee_id)
                    .unwrap()
                    .unwrap_or(format!("user #{}", record.employee_id))
            });

            // ...
        });
}

Con questa semplice modifica passiamo da ottenere tutti i record in un minuto ad ottenerli in un secondo. Questo sì che è blazingly fast!

Limitare la lettura delle presenze ad un certo arco temporale
#

Dato che mi interessano i dati dell’ultimo mese, possiamo utilizzare i metodi take_while() e skip_while() per escludere tutti gli elementi precedenti allo scorso mese e per fermare l’iteratore una volta estratti tutti i record interessati:

// src/main.rs
use r701::R701;
use std::collections::HashMap;
use chrono::{Local, TimeZone};

fn main() {
    let start = Local.with_ymd_and_hms(2024, 7, 1, 0, 0, 0).unwrap();
    let end = Local.with_ymd_and_hms(2024, 8, 1, 0, 0, 0).unwrap();
    let mut names = HashMap::new();

    let mut r701 = R701::connect("127.0.0.1:5005").unwrap();

    println!("No\tMchn\tEnNo\t\tName\t\tMode\tIOMd\tDateTime\t");
    r701.into_record_iter()
        .unwrap()
        .take_while(|record| record.datetime < end)
        .skip_while(|record| record.datetime < start)
        .collect::<Vec<_>>()
        .iter()
        .enumerate()
        .for_each(|(id, record)| {
            // ...
        });
}

Questa modifica non migliora in alcun modo le performance, ma c’è un ultima miglioria molto semplice che possiamo applicare per questo specifico caso d’uso…

Leggere le presenze al contrario
#

Al posto di iniziare dal primo record mai registrato ed escludere tutti i record fino ad arrivare al primo del mese interessato potremmo leggere i record al contrario, partendo da quello più recente ed andando verso a quello più datato.

Questa miglioria richiede un po’ di modifiche, ma ne vale la pena considerando che ci fa passare da un po’ meno di un secondo a 0,2 secondi!

// src/main.rs

// ...

fn main() {
    // ...

    println!("No\tMchn\tEnNo\t\tName\t\tMode\tIOMd\tDateTime\t");
    r701.into_record_iter()
        .unwrap()
        .take_while(|record| record.datetime >= start)
        .skip_while(|record| record.datetime >= end)
        .collect::<Vec<_>>()
        .iter()
        .rev()
        .enumerate()
        .for_each(|(id, record)| {
            // ...
        });
}
``
Autore
Nicola Belluti
Un ragazzo innamorato del mondo open source. Su di me…
Lettore di presenze - Questo articolo fa parte di una serie
Parte 3: Questo articolo