3. Experiment 3: [Todolist mit Rust mit egui als GUI und PostgreSQL als Datenbank]¶
Datum: [02.05.2024] Thema: [Todolist mit Rust mit Egui als GUI und PostgreSQL als Datenbank]
3.1. Einleitung¶
In der Welt der Informatik ist es eine weit verbreitete Tradition, dass jeder Entwickler irgendwann eine To-do-Liste als Übung implementiert.
3.1.1. Motivation¶
In diesem Geiste setzen wir diese Tradition hier fort. Statt auf ein typische desktop developmet Anwedung, wie Electron Js, WinForms oder Cocoa zu verwenden, entschieden wir uns für Rust. Da Rust eine Sprache ist, die aktuell an Popularität gewinnt und wir uns in dieser Sprache genauer anschauen wollten. Zudem wollten wir wissen, ob wir die Desktopanwendung auch als Webanwendung in Rust umsetzen können.
3.1.2. Aufgabenstellung¶
Es soll eine Desktop Applikation mithilfe von Rust implementiert werden. Hier soll als Crate egui für Frontend und als Datenbank PostgreSQL verwendet werden. Darüber hinaus ist es gewünscht, diese Desktopanwendung auch als Webanwendung in Rust zu implementieren.
3.2. Grundlagen¶
Als nächstes gilt grundsätzlich zu verstehen, was egui ist und warum wir PostgreSQL als Datenbank verwendet haben. EGUI ist ein Rust-Crate, die eine einfache und flexible GUI-Bibliothek bereitstellt, die in Rust-Anwendungen verwendet werden kann. Das Framework ermöglicht die Erstellung von grafischen Benutzeroberflächen für verschiedene Anwendungsfälle, von Desktop- über Web- bis hin zu Spieleanwendungen. Wir haben für unsere Anwendung PostgreSQL als Datenbank entschieden, da es „[…] ein leistungsstarkes, Open-Source-objektrelationales Datenbanksystem […]“ (PostgreSQL,. 2024) ist. Zum Identifizieren der Datensätze haben wir uns für eine GUID (Globally Unique Identifier) entschieden. Das besondere an solcher einer generierten Id ist, dass es eine 128 Bit generierte Zahl ist.
3.3. Versuchsdurchführung¶
Dieser Abschnitt beschreibt die Vorbereitung, Durchführung, erzielten Ergebnisse und eventuelle Probleme während des Versuchs.
Da sich unser zuvoriger Versuch (infrastructur_as_code) auf die Konfiguration der Datenbank konzentriert hat, gehen wir zunächst auf das Datenmodell der Datenbank ein. Unser Datenmodell besteht aus einer Tabelle item mit den folgenden vier Spalten:
id mit Datentyp UUID als Nicht Null als Primary Key zur eindeutigen Identifikation des Tabelleneintrags
title mit Datentyp Varchar der Länge 100 als Nullable, damit der Benutzer einen Titel eingeben kann
description mit Datentyp Varchar der Länge 600 als Nullable, damit der Benutzer eine Beschreibung für seine Aufgaben geben kann
is_done mit Datentyp Boolean als Nicht NUll, damit man den Zustand speichern, ob diese Aufgabe bereits erledigt hat
Als nächstes wird auf die Desktop Applikation eingegangen.
Die Desktop Applikation in Rust ist auf drei verschiedene Dateien aufgeteilt: server.rs ist für die Manipulation und Verbindung zur PostgreSQL-Datenbank verantwortlich. todolist.rs dient als Datenmodell, während main.rs für die GUI und die Gesamtlogik des Programms zuständig ist.
3.3.1. server.rs¶
Zuerst wird auf die Datei server.rs eingegangen.
pub struct Server {
pub client: Client,
}
impl Server {
//ALREADY IN USE
// TESTED = APPROVED
pub fn new() -> Self {
let mut client = Client::connect(
"host=localhost user=todolist password=todolist dbname=todolist",
NoTls,
)
.expect("Could not connect to database");
Server { client }
}
Wir haben uns für den Struct Server entschieden, der beim Initialisieren eine Verbindunge mit dem Server herstellt. Als Verbindungsart verwenden wir den localhost. Wir verbinden uns als Nutzer todolist mit der Datenbank todolist.
// ALREADY IN USE
// TESTED = APPROVED
pub fn create_todo_item(&mut self, title: &String, description: &String) -> Uuid {
let is_done = false;
let id = Uuid::new_v4();
let _ = self.client.execute(
"INSERT INTO item
(id, title, description,is_done)
VALUES ($1, $2, $3, $4)",
&[&id, &title, &description, &is_done],
);
return id;
}
Bei der Methode create_todo_item werden neue Todos in der Datenbank hinzugefügt. Diese sind standmardmäßig zuerst auf den Zustand als nicht erledigt gekennzeichnet. Wir erzeugen ebenfalls eine UUID für die eindeutige Identifikation. Mithilfe der client.execute Methode können wir die SQL Befehle absetzen. Mithilfe des Insert Statements fügt man neue Tabellen Einträge hinzu. Am Schluss geben wir noch die erzeugte Id zurück, damit wir diese später für unser internes Datenmodell speichern können.
// ALREADY IN USE
// TESTED = APPROVED
pub fn update_todo_item(&mut self, id: &Uuid, title: &String, description: &String) {
let _ = self.client.execute(
"
UPDATE item
SET title = $1, description = $2
WHERE id = $3",
&[&title, &description, &id],
);
}
// ALREADY IN USE
// TESTED = APPROVED
pub fn set_todo_item_is_done(&mut self, id: &Uuid) {
let is_done = true;
let _ = self.client.execute(
"
UPDATE item
SET is_done = $1
WHERE id = $2",
&[&is_done, &id],
);
}
// ALREADY IN USE
// TESTED = APPROVED
pub fn delete_todo_item(&mut self, id: &Uuid) {
let _ = self.client.execute(
"
DELETE FROM item
WHERE id = $1",
&[&id],
);
}
Die Grundstruktur der Methoden von update_todo_item, set_todo_item_is_done und delete_todo_item ist immer die gleiche. In der Methoden Signatur wird immer die Id mitgegeben, damit wir wissen, welcher Eintrag wir manipulieren möchten. Mithilfe der client.execute Methode führen wir die eigentliche Logik des SQL Befehls aus und manipulieren die Datensätze in der Datenbank.
// TESTED = APPROVED
// gets all todo item entries in the PostgreSQL database and transforms these into a result<hashmap>
pub fn get_all_todo_items(&mut self) -> Result<(HashMap<Uuid, Item>), Error> {
let mut todo_list: HashMap<Uuid, Item> = HashMap::new();
for row in self
.client
.query("SELECT id, title, description, is_done FROM item", &[])?
{
let id: Uuid = row.get(0);
let title: &str = row.get(1);
let title = title.to_string();
let description: &str = row.get(2);
let description = description.to_string();
let is_done: bool = row.get(3);
let item = Item {
description,
title,
is_done,
id,
};
todo_list.insert(item.id, item);
}
return Ok(todo_list);
Die letzte Methode ist für das Zurückgeben aller Tabelleinträge zuständig. Hier iterieren wir durch jeden Tabelleintrag durch, erzeugen daraus ein item und fügen es in einer Hashmap ein. Zu guter letzt geben wir die Hashmap als Rückgabe zurück.
3.3.2. todolist.rs¶
Die Datei todolist.rs ist unser internes Datenmodell für unsere Anwendung.
// Struct Items with description, title, is_done and id
#[derive(Eq, Hash, PartialEq, Clone)]
pub struct Item {
pub description: String,
pub title: String,
pub is_done: bool,
pub id: Uuid,
}
// Liste in Form von einer Hashmap zur Verwaltung von TODO Items
// Struct Todo_list with Hashmap<Uuid, Item> for managing todo items
pub struct Todo_list {
pub todo_List: HashMap<Uuid, Item>,
}
Das Datenmodell besteht aus dem Struct item, mit den selben Eigenschaften wie die Tabelle item. Wir speichern wiederum die item in einer Hashmap mit dem Key Id zur eindeutigen Erkennung und als Value das item.
impl Todo_list {
// TESTED = APPROVED
// Constructor for todolist
// Connects to database, gets all todo items in form of a hashmap and closes connection
pub fn new() -> Self {
let mut server = server::Server::new();
let mut todolist = server.get_all_todo_items().unwrap();
server.client.close();
Todo_list {
todo_List: todolist,
}
}
// TESTED = APPROVED
// Adds a todo items to hashmap
pub fn add(&mut self, id: Uuid, title_user: String, description_user: String) {
let mut list_item = Item {
description: description_user,
title: title_user,
id: id,
is_done: false,
};
self.todo_List.insert(id, list_item);
}
Im Kontruktor wird eine neue Verbindung mit der Datenbank aufgebaut, unsere Hashmap wird mit den items gefüllt und am Schluss wird die Verbindung wird wieder geschlossen. Der Hintergrund für das verbinden und schließen der Verbindung ist, dass wir nicht zwei Verbindungen mit der Datenbank haben, sondern nur eine. Wir haben ebenfalls eine Methode zum hinzufügen eines internes items definiert. Der Grund wird näher in der main.rs näher erläutert.
3.3.3. main.rs¶
Die main.rs ist das eigentliche Herzstück unserer Anwendung.
fn main() -> Result<(), eframe::Error> {
// connect to database
let mut server = Server::new();
// get all todo items
let mut todolist = Todo_list::new();
env_logger::init(); // Log to stderr (if you run with `RUST_LOG=debug`).
let options = eframe::NativeOptions {
// Define size of window
viewport: egui::ViewportBuilder::default().with_inner_size([480.0, 320.0]),
..Default::default()
};
In den ersten Zeilen unserer main Funktion bauen wir eine Verbindung mit der Datenbank auf, wir laden alle Todo Items in die Hashmap und geben die Fenstergröße unserer Anwendung an.
let mut title = "Bitte neuen Titel eingeben".to_string();
let mut beschreibung = "Bitte neue Beschreibung eingeben".to_string();
let mut title_update = String::default();
let mut description_update = String::default();
let mut should_delete_item = false;
let mut id_of_deleted_item = Uuid::new_v4();
eframe::run_simple_native("Todo Liste", options, move |ctx, _frame| {
egui::CentralPanel::default().show(ctx, |ui| {
//ui.heading("");
egui::ScrollArea::vertical().show(ui, |ui: &mut egui::Ui| {
// create todo item
ui.heading("Neue Todos hinzufügen:");
ui.horizontal(|ui| {
ui.vertical(|ui: &mut egui::Ui| {
let title_label = ui.label("Titel:");
ui.text_edit_singleline(&mut title)
.labelled_by(title_label.id);
let describtion_label = ui.label("Beschreibung:");
ui.text_edit_singleline(&mut beschreibung)
.labelled_by(describtion_label.id);
if ui.button("Speichern").clicked() {
let id = server.create_todo_item(&title, &beschreibung);
todolist.add(id, title.to_owned(), beschreibung.to_owned());
}
});
});
ui.vertical(|ui: &mut egui::Ui| {
ui.label("");
});
Hier fängt unsere eigentlich GUI an. Zuerst defenieren wir Variablen, die wir später für die Manipulationen der Daten verwenden. Der erste aufruf der Methode run_simple_native ist für die Erstellung des Fensters zuständig. Diese Methode geben wir den Titel der Anwendung und die Fenstergröße, die bereits durch die options definiert wurden. CentralPanel ist für die zentrale Anzeige der Anwendung zuständig. Damit der Benutzer scrollen kann, haben wir auch eine ScrollArea definiert. Der Aufbau innerhalb der ScrollArea ist in zwei Teile aufgeteilt. Der erste Teil ist für das Hinzufügen von neuen Todos zuständig. Hier wird der Benutzer aufgefordert einen Titel und eine Beschreibung einzugeben. Wenn der Benutzer auf den Speichern Button klickt, wird ein neues Todo in der Datenbank hinzugefügt und in der Hashmap gespeichert.
ui.heading("Liste der Todos");
for element in todolist.todo_List.iter_mut() {
ui.vertical(|ui: &mut egui::Ui| {
ui.label("Titel: ");
ui.text_edit_singleline(&mut element.1.title);
//ui.label(&*element.1.title);
});
ui.vertical(|ui: &mut egui::Ui| {
ui.label("Beschreibung: ");
ui.text_edit_singleline(&mut element.1.description);
//ui.label(&*element.1.description);
});
ui.vertical(|ui| {
if ui.button("Speichere Veränderungen").clicked() {
title_update = element.1.title.clone();
description_update = element.1.description.clone();
server.update_todo_item(
&element.1.id,
&title_update,
&description_update,
);
}
});
// Change state of is_done of true
ui.vertical(|ui: &mut egui::Ui| {
ui.label("Zustand: ");
if (element.1.is_done) {
ui.label("Ist erledigt");
} else {
ui.label("Noch zu erledigen");
}
});
if ui.button("Erledigt").clicked() {
element.1.is_done = true;
server.set_todo_item_is_done(&element.1.id);
}
// Delete todo item
if ui.button("Löschen").clicked() {
server.delete_todo_item(&element.1.id);
id_of_deleted_item = element.1.id.clone();
should_delete_item = true;
}
ui.vertical(|ui: &mut egui::Ui| {
ui.label("");
});
}
if should_delete_item {
todolist.todo_List.remove(&id_of_deleted_item);
should_delete_item = false;
}
});
Der letzte Teil der GUI ist für die Anzeige der Todos zuständig. Hier iterieren wir durch die Hashmap und zeigen die Titel, Beschreibungen und den Zustand der Todos an. Wir haben text_edit_singleline verwendet, damit der Benutzer die Titel und Beschreibungen direkt in der Anwendung ändern kann. Wenn der Benutzer auf den „Speichere Veränderungen“ Button klickt, wird der Titel und die Beschreibung in der Datenbank aktualisiert. Natürlich kann man auch den Zustand der Todos ändern. Wenn der Benutzer auf den „Erledigt“ Button klickt, wird der Zustand auf erledigt gesetzt. Wenn der Benutzer auf den „Löschen“ Button klickt, wird das Todo aus der Datenbank gelöscht und aus der Hashmap entfernt. Somit bieten wir jede Funktionalität, die es benötitgt, um eine einfache Todoliste zu verwalten.
3.3.4. Ergebnisse¶
Unsere Desktop Applikation funktioniert einwandfrei. Die größte Herausforderung in diesen Projekt war die PostgreSQL Datenbank bei jeden Teammitglied richtig zu konfigurieren, da jedes Teammitglied ein anderes Betriebssystem hat und somit die Konfigurationskripte von unserem zuvorigen Versuch nicht bei jeden funktionierte. Wir haben auch für unsere zukünftige Projekte einiges gelernt. Unter anderem, dass sich eine Hashmap als internes Datenmodell nicht ideal eignet, da jedes mal die Reihenfolge der Todo Items sich ändert und somit auch das sortieren sich erschwert.
Da es heutzutage immer wichtiger wird, seine Anwendungen auch im Web zu präsentieren, haben wir im gleichen zuge versucht, unsere Desktopapplikation auch als Webapplikation umzusetzen. Für die Implementierung eines Rust-Projekts im Web haben wir verschiedene Ansätze in Betracht gezogen, darunter die Verwendung der Frameworks „Rust to wasm“, „Rocket“ und „Tokio“. Im Folgenden werden die Funktionsweisen dieser einzelnen Frameworks erläutert. Leider sind alle unsere Versuche, sie einzusetzen, gescheitert, weshalb wir kein praktikables Beispiel zeigen können. Deshalb werden wir nur die Funktionsweise der einzelnen Frameworks erläutern.
3.3.4.1. Rust to wasm¶
Rust to WebAssembly (Wasm) ist ein Werkzeug, mit dem Rust-Code in WebAssembly kompiliert wird, eine portable, sichere und effiziente Ausführungsumgebung für Webanwendungen. Mit Rust to Wasm kannst man leistungsstarken Rust-Code in Webanwendungen integrieren, um komplexe Rechenoperationen, Spiele, Multimediaanwendungen und mehr im Browser auszuführen. Es wird häufig verwendet, um JavaScript zu ergänzen oder zu ersetzen, insbesondere wenn Geschwindigkeit, Sicherheit oder Portabilität von entscheidender Bedeutung sind.
3.3.4.2. Rocket¶
Rocket ist ein Web-Framework für die Programmiersprache Rust. Es ermöglicht das Erstellen von Webanwendungen durch die Bereitstellung von Tools und Strukturen für Routing, Middleware, Datenbankintegration und mehr. Mit Rocket kannst man schnell und sicher Webanwendungen in Rust entwickeln, wobei besonderer Wert auf die Typsicherheit und den Schutz vor Sicherheitslücken gelegt wird. Es bietet auch eine einfache Integration von Frontend- und Backend-Code.
3.3.4.3. Tokio¶
Tokio ist ein asynchrones Laufzeit-Framework für die Programmiersprache Rust. Es bietet Werkzeuge und Strukturen, um hochleistungsfähige und skalierbare asynchrone Anwendungen zu entwickeln, die auf modernen, parallelen Hardwarearchitekturen wie Mehrkernprozessoren oder verteilten Systemen laufen.
3.3.4.4. WebApp mit Flask¶
Auch wenn die WebApp in den verschiedenen Rust Frameworks nicht funktioniert hat, wollten wir gucken, ob Rust das Problem ist, oder unsere
Fähigkeit eine WebApp im allgemeinen zu bauen. Deshalb haben wir als kleinen Exkurs die WebApp in Python mit Flask als Framework umgesetzt.
Dort ist die Struktur ziemlich straight-forward. Der static-Ordner enthält alle statischen Elemente, wie Bilder oder auch die styles.css.
Im Templates Ordner liegen die html-Dateien, welche „Platzhalter“ mit jinja2 enthalten, in welche der Python Code dann Objekte, Bilder und Text
einpflegen kann.
Damit kommen wir auch schon zum Kern der Flask App. Der app.py-Datei. In der main wird mit app.run(debug=True) die App gestartet.
Mit @app.route('/add', methods=['POST']) über einer Funktion kann man die verschiedenen Routen des Browsers abfangen und bearbeiten.
Die Verbindung zur Datenbank haben wir über psycopg2 geregelt. Mit conn = psycopg2.connect(host=DB_HOST, user=DB_USER, password=DB_PASSWORD, dbname=DB_NAME)
kann man die Verbindung aufbauen. Mit SQL Befehlen dann abfragen oder änderungen durchführen und mit conn.close() die Verbindung wieder schließen.
3.3.5. Fazit¶
Die tatsache, das wir uns bereits mit ein paar der verwendeten Technologien auskannten, hat die umsetzung des Projekts ein wenig erleichtert. Jedoch haben wir auch einiges dazu gelernt. Die Verwendung von Egui und das auprobieren, das eine Desktopapplikation in Rust auf jeden Rechner funktioniert, war nicht leicht. Jedoch haben wir es geschafft und sind stolz auf das Ergebnis. Durch den Versuch haben wir außerdem verstanden, wie man nicht nur eine Desktopapplikation in Rust umsetzen kann, sondern auch wie wir diese mit einer Datenbank verbinden können. Die WebApp mit Rust hat sich als Schwierigkeit herausgestellt, mit Python war dies aber relativ Simple umzusetzen.
3.4. Literaturangaben¶
[1] https://github.com/emilk/egui?tab=readme-ov-file#who-is-egui-for
[2] https://www.postgresql.org/about/
[3] https://techterms.com/definition/guid
[4] https://flask.palletsprojects.com/en/3.0.x/
[5] https://flask.palletsprojects.com/en/2.3.x/templating/
[6] https://developer.mozilla.org/en-US/docs/WebAssembly/Rust_to_Wasm
[8] https://tokio.rs