Cross-Plattform-Applikationen mit Rust 3: Fachlichkeiten und Shell-Integration

Das Framework Crux verbindet fachliche Typen, modulare Apps und Cross-Plattform-Integration mit Rust und generiert skalierbare sowie testbare Anwendungen.

vorlesen Druckansicht
Rust

(Bild: iX)

Lesezeit: 11 Min.
Von
  • Marcel Koch
Inhaltsverzeichnis
close notice

This article is also available in English. It was translated with technical assistance and editorially reviewed before publication.

Nach einer EinfĂĽhrung in das Framework Crux geht es im dritten und letzten Teil der Artikelreihe nun um fortgeschrittene Konzepte und die praktische Integration in realen Anwendungen. Die folgenden Punkte zu fachlichen Typen, Aufteilung in mehrere Apps und dem Einsatz in konkreten Shell-Technologien zeigen einen detaillierten Blick aus der Praxis.

Marcel Koch
Portrait Marcel Koch

Marcel Koch berät mit seinem siebenköpfigen Team kleine und mittelständische Unternehmen und entwickelt branchenübergreifend Cross-Platform-Apps für Desktop und Mobile sowie Webapplikationen – bevorzugt mit TypeScript, Rust, Flutter oder Java, gestützt auf CI/CD und IaC. Dabei setzt er auf pragmatische, passgenaue Lösungen, denn Software ist kein Selbstzweck. Neben soliden technischen Kenntnissen schult er in Gewaltfreier Kommunikation, Transaktionsanalyse sowie Agilität und fördert einen kritischen Blick auf Cloud Hypes. Marcel ist Speaker, Autor von Fachartikeln und Büchern und regelmäßig in Podcasts zu hören.

Cross-Plattform-Applikationen mit Rust

In langlebigen Softwareprojekten ist es ratsam, fachliche Typen unabhängig vom eingesetzten Framework zu definieren. Im Rahmen der in den vorherigen Teilen gezeigten E-Mail-App bietet sich zum Beispiel der Typ EmailAddress an: Dieser Typ kapselt die Validierung und Repräsentation einer E-Mail-Adresse und stellt sicher, dass nur gültige Werte im System verwendet werden. Die Logik zur Prüfung – etwa auf das Vorhandensein des Zeichens @ – ist direkt im Typ verankert und nicht Teil der Crux-App.

Dadurch bleibt die fachliche Logik klar von technischen Details getrennt. Sie kann unabhängig von Crux, anderen Frameworks oder der konkreten Plattform getestet, wiederverwendet und weiterentwickelt werden. Sollte sich die technische Umgebung ändern, bleibt die Kernlogik erhalten und muss nicht neu geschrieben werden.

Videos by heise

Fachliche Typen wie EmailAddress erhöhen die Wartbarkeit und Verständlichkeit des Codes und schützen vor Fehlern, indem sie ungültige Zustände bereits beim Erstellen verhindern.

Listing 1: Fachlicher Typ EmailAddress

#[derive(Clone, PartialEq, Eq, Hash, Debug, serde::Serialize, serde::Deserialize)]
pub struct EmailAddress(String);

#[derive(thiserror::Error, Debug)]
pub enum EmailError {
    #[error("missing @")]
    MissingAt,
}

impl EmailAddress {
    /// Der Smart-Constructor garantiert eine gĂĽltige Adresse
    pub fn parse(s: impl Into<String>) -> Result<Self, EmailError> {
        let s = s.into();
        if s.contains('@') { Ok(Self(s)) } else { Err(EmailError::MissingAt) }
    }

    pub fn as_str(&self) -> &str { &self.0 }
}

Fachliche Typen wie EmailAddress lassen sich unabhängig vom Framework testen.

Listing 2: Tests fĂĽr EmailAddress

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

    #[test]
    fn valid_email_is_accepted() {
        let email = EmailAddress::parse("marcel.koch@example.org");
        assert!(email.is_ok());
        assert_eq!(email.unwrap().as_str(), "marcel.koch@example.org");
    }

    #[test]
    fn invalid_email_is_rejected() {
        let email = EmailAddress::parse("marcel.kochexample.org");
        assert!(email.is_err());
        assert_eq!(email.unwrap_err(), EmailError::MissingAt);
    }
}

Der erste Test prĂĽft, dass eine gĂĽltige E-Mail-Adresse akzeptiert und korrekt gespeichert wird. Der zweite Test stellt sicher, dass eine ungĂĽltige E-Mail-Adresse (ohne @) abgelehnt und der passende Fehler (MissingAt) zurĂĽckgegeben wird.

Wächst das Entwicklungsteam der App, kann es sinnvoll sein, das Team und auch die Anwendung in mehrere eigenständige Apps und Crates aufzuteilen. Ein Kontaktmanagement könnte die bisherige E-Mail-App erweitern und alle eingehenden Kontaktdaten speichern, aktualisieren oder löschen. Dabei bekommt das Kontaktmanagement eine eigene Crux-App, verwaltet in einem separaten Crate. Dieses separate Crate beinhaltet ein abgegrenztes Feature und wird auch als Feature-Crate bezeichnet. Jedes Feature-Crate definiert dabei seine eigenen Events, Models, ViewModels und Effekte – das Model ist also explizit Teil des jeweiligen Features. Die Haupt-App integriert die einzelnen Feature-Crates und koordiniert deren Zusammenspiel.

Listing 3: contacts (Feature-Crate)

// contacts/src/lib.rs
#[derive(Clone, Debug)]
pub struct Contact {
    pub name: String,
    pub email: EmailAddress,
}

pub enum ContactsEvent {
    AddContact(Contact),
    RemoveContact(EmailAddress),
    EditContact(EmailAddress, Contact),
}

#[derive(Default)]
pub struct ContactsModel {
    pub contacts: Vec<Contact>,
}

pub struct ContactsViewModel {
    pub contacts: Vec<Contact>,
}

pub enum ContactsEffect {
    ShowContactAdded(EmailAddress),
    ShowContactRemoved(EmailAddress),
}

pub fn update(event: ContactsEvent, model: &mut ContactsModel) -> Vec<ContactsEffect> {
    match event {
        ContactsEvent::AddContact(contact) => {
            model.contacts.push(contact.clone());
            vec![ContactsEffect::ShowContactAdded(contact.email)]
        }
        ContactsEvent::RemoveContact(email) => {
            model.contacts.retain(|c| c.email != email);
            vec![ContactsEffect::ShowContactRemoved(email)]
        }
        ContactsEvent::EditContact(email, new_contact) => {
            if let Some(c) = model.contacts.iter_mut().find(|c| c.email == email) {
                *c = new_contact.clone();
            }
            vec![]
        }
    }
}

pub fn view(model: &ContactsModel) -> ContactsViewModel {
    ContactsViewModel {
        contacts: model.contacts.clone(),
    }
}

Das E-Mail-Feature ĂĽbernimmt die Funktion der bisherigen E-Mail-App:

Listing 4: email (Feature-Crate)

// email/src/lib.rs
pub enum EmailEvent {
    SendEmail(EmailAddress),
    EmailSent(bool),
}

#[derive(Default)]
pub struct EmailModel {
    pub last_sent: Option<EmailAddress>,
}

pub struct EmailViewModel {
    pub last_sent: Option<String>,
}

pub enum EmailEffect {
    SendEmailRequest(String),
}

Die Haupt-App aggregiert die Feature-Modelle und koordiniert die Kommunikation:

Listing 5: Haupt-App-Integration (App-Crate)

// app/src/lib.rs
use contacts::{ContactsEvent, ContactsModel, ContactsViewModel, ContactsEffect};
use email::{EmailEvent, EmailModel, EmailViewModel, EmailEffect};

pub enum AppEvent {
    Email(EmailEvent),
    Contacts(ContactsEvent),
}

pub struct AppModel {
    pub email: EmailModel,
    pub contacts: ContactsModel,
}

pub struct AppViewModel {
    pub email: EmailViewModel,
    pub contacts: ContactsViewModel,
}

pub enum AppEffect {
    Email(EmailEffect),
    Contacts(ContactsEffect),
}

pub fn update(event: AppEvent, model: &mut AppModel) -> Vec<AppEffect> {
    match event {
        AppEvent::Email(email_event) => {
            email::update(email_event, &mut model.email)
                .into_iter().map(AppEffect::Email).collect()
        }
        AppEvent::Contacts(contacts_event) => {
            contacts::update(contacts_event, &mut model.contacts)
                .into_iter().map(AppEffect::Contacts).collect()
        }
    }
}

pub fn view(model: &AppModel) -> AppViewModel {
    AppViewModel {
        email: email::view(&model.email),
        contacts: contacts::view(&model.contacts),
    }
}

Durch diese Aufteilung bleibt die Anwendung modular, testbar und erweiterbar. Jedes Feature verwaltet seinen eigenen Zustand und lässt sich unabhängig entwickeln, testen und warten. Die Haupt-App sorgt für die Orchestrierung und das Zusammenführen der einzelnen ViewModels zu einer konsistenten Oberfläche. So entsteht eine skalierbare Architektur.