Datenbanken in Rust: SQLite und Postgres mit sqlx, Diesel und SeaORM
Früher oder später muss sich dein Programm Dinge merken: Benutzer, Bestellungen, Highscores, To-dos. Wenn das Programm endet, ist ein Vec oder eine HashMap weg. Eine Datenbank bewahrt die Daten sicher auf der Festplatte auf, lässt viele Programme sie gemeinsam nutzen und beantwortet Fragen wie „zeig mir alle offenen Aufgaben, die neuesten zuerst“ sehr schnell.
Rust hat sehr gute Datenbank-Crates. Auf dieser Seite suchst du dir eins aus und schreibst dann echten Code: zuerst ein kleines „Repository“ in reinem Rust (läuft direkt hier im Playground), dann SQLite mit sqlx, Postgres und Migrationen, und zum Schluss kurze Rundgänge durch Diesel und SeaORM. Datenbank-Crates gibt es im Playground nicht, diese Beispiele sind also für deinen eigenen Computer mit cargo run. Jedes Beispiel wurde gebaut und ausgeführt, und die gezeigte Ausgabe ist echt.
Was du bauen kannst
Abschnitt betitelt „Was du bauen kannst“- Die Datenschicht einer Web-API. Nimm den To-do-Server aus Projekt: Web-API und speichere seine Daten in Postgres statt im Arbeitsspeicher.
- Große, viel besuchte Websites. crates.io, die Seite, auf der jedes Rust-Crate liegt, speichert ihre Daten mit Diesel in Postgres. Genauso Lemmy, eine Open-Source-Community-Plattform.
- Desktop- und Kommandozeilen-Tools mit einer lokalen SQLite-Datei: eine Notiz-App, ein Haushaltsbuch, ein Cache.
- Hintergrund-Worker und Datenjobs, die Zeilen lesen, verarbeiten und Ergebnisse zurückschreiben.
Wo es schwieriger ist: Rust hat keinen einzelnen „offiziellen“ Stack wie Entity Framework in .NET oder ActiveRecord in Rails. Du wählst also zwischen ein paar guten Crates. Der erste Build eines Datenbank-Crates dauert ein, zwei Minuten. Und SQL, das schon beim Kompilieren geprüft wird (ein tolles Feature), braucht ein bisschen Einrichtung.
Welches Crate?
Abschnitt betitelt „Welches Crate?“| Crate | Sync oder async | Prüft Abfragen beim Kompilieren? | ORM? | Nimm es, wenn |
|---|---|---|---|---|
| rusqlite | sync | nein | nein, reines SQL | Ein kleines Tool oder eine Desktop-App mit einer SQLite-Datei. Kein async nötig. |
| sqlx | async | ja, mit den query!-Makros (optional) |
nein, reines SQL | Du schreibst gern SQL und willst async. SQLite, Postgres, MySQL. Die übliche erste Wahl für Web-Apps. |
| Diesel | sync (diesel-async ergänzt async) |
ja, der ganze Query Builder wird typgeprüft | ORM und Query Builder | Der Compiler soll jede Abfrage prüfen, und es stört dich nicht, Diesels Abfragestil zu lernen. Sehr ausgereift. |
| SeaORM | async (sea-orm-sync gibt es auch) |
Typen ja, SQL nein (wird zur Laufzeit geprüft) | ja, volles ORM | Du willst Entitäten, Beziehungen und „speichere dieses Objekt“ wie in Entity Framework. Baut auf sqlx auf. |
Alle vier sind ausgereift und weit verbreitet. Wenn du unsicher bist, fang mit sqlx an: Du schreibst normales SQL, und was du dabei lernst, funktioniert in jeder Sprache. Du kommst von C#? Die Seite Rust für C#-Entwickler ordnet Entity Framework, Dapper und ADO.NET diesen Crates zu.
Erst die Form: ein Repository in reinem Rust
Abschnitt betitelt „Erst die Form: ein Repository in reinem Rust“Bevor es um eine Datenbank geht, schauen wir uns ein Muster an, das fast jede App verwendet: das Repository. Das ist die eine Stelle, die weiß, wie deine Daten gespeichert und geladen werden. Der Rest des Programms ruft nur Methoden wie add und list_open auf. Ihm ist egal, ob die Daten in einer HashMap, in SQLite oder in Postgres liegen.
In Rust beschreibt ein Trait, was das Repository kann, und ein Struct erledigt die Arbeit. Hier ist der Speicher eine HashMap im Arbeitsspeicher. Die Aufgabentitel bleiben auf Englisch, damit sie zu den echten Ausgaben der sqlx-, Diesel- und SeaORM-Beispiele weiter unten passen:
use std::collections::HashMap;
#[derive(Debug, Clone)]struct Task { id: u32, title: String, done: bool,}
// Was die App vom Speicher braucht. Nichts hier sagt, WIE gespeichert wird.trait TaskRepository { fn add(&mut self, title: &str) -> u32; fn mark_done(&mut self, id: u32) -> Result<(), String>; fn list_open(&self) -> Vec<Task>;}
struct InMemoryRepo { tasks: HashMap<u32, Task>, next_id: u32,}
impl TaskRepository for InMemoryRepo { fn add(&mut self, title: &str) -> u32 { let id = self.next_id; self.tasks.insert(id, Task { id, title: title.to_string(), done: false }); self.next_id += 1; id }
fn mark_done(&mut self, id: u32) -> Result<(), String> { match self.tasks.get_mut(&id) { Some(task) => { task.done = true; Ok(()) } None => Err(format!("keine Aufgabe mit der ID {id}")), } }
fn list_open(&self) -> Vec<Task> { let mut open: Vec<Task> = self.tasks.values().filter(|t| !t.done).cloned().collect(); open.sort_by_key(|t| t.id); // eine HashMap hat keine Reihenfolge, also sortieren open }}
// Funktioniert mit JEDEM Repository: heute im Speicher, morgen SQLite oder Postgres.fn print_open(repo: &impl TaskRepository) { for task in repo.list_open() { println!("[ ] {}: {}", task.id, task.title); }}
fn main() { let mut repo = InMemoryRepo { tasks: HashMap::new(), next_id: 1 }; repo.add("Buy milk"); repo.add("Learn sqlx"); repo.add("Walk the dog");
repo.mark_done(1).unwrap(); print_open(&repo); println!("{:?}", repo.mark_done(99));}Ausgabe:
[ ] 2: Learn sqlx[ ] 3: Walk the dogErr("keine Aufgabe mit der ID 99")Schau dir an, was eine Datenbank hier für dich übernimmt. Aus der HashMap wird eine Tabelle, aus jedem Task eine Zeile, und aus next_id eine automatisch hochgezählte Spalte id. list_open wird zu einer Abfrage mit WHERE done = FALSE ORDER BY id. Die Version im Arbeitsspeicher ist auch später noch praktisch: Sie ergibt eine schnelle Attrappe für deine Tests.
sqlx mit SQLite: deine erste echte Datenbank
Abschnitt betitelt „sqlx mit SQLite: deine erste echte Datenbank“SQLite ist eine ganze Datenbank in einer einzigen Datei (oder nur im Arbeitsspeicher). Es gibt keinen Server zu installieren. Das macht SQLite perfekt zum Lernen, für Tests und für viele echte Apps. Leg ein Projekt an und füg sqlx und tokio hinzu:
cargo new todo-dbcd todo-dbcargo add sqlx --features runtime-tokio,sqlitecargo add tokio --features full[dependencies]sqlx = { version = "0.9", features = ["runtime-tokio", "sqlite"] }tokio = { version = "1", features = ["full"] }Das Feature sqlite baut SQLite direkt in dein Programm ein, du musst also nichts weiter installieren. sqlx ist async, deshalb braucht es tokio. Ersetze jetzt src/main.rs:
use sqlx::sqlite::SqlitePool;
#[derive(Debug, sqlx::FromRow)]struct Task { id: i64, title: String, done: bool,}
#[tokio::main]async fn main() -> Result<(), sqlx::Error> { // Eine Datenbank im Arbeitsspeicher: perfekt zum Lernen, weg, wenn das Programm endet. let pool = SqlitePool::connect("sqlite::memory:").await?;
sqlx::query( "CREATE TABLE tasks ( id INTEGER PRIMARY KEY, title TEXT NOT NULL, done BOOLEAN NOT NULL DEFAULT FALSE )", ) .execute(&pool) .await?;
for title in ["Buy milk", "Learn sqlx", "Walk the dog"] { let result = sqlx::query("INSERT INTO tasks (title) VALUES (?)") .bind(title) .execute(&pool) .await?; println!("Inserted task {}", result.last_insert_rowid()); }
sqlx::query("UPDATE tasks SET done = TRUE WHERE id = ?") .bind(1_i64) .execute(&pool) .await?;
let tasks: Vec<Task> = sqlx::query_as("SELECT id, title, done FROM tasks ORDER BY id") .fetch_all(&pool) .await?;
for task in &tasks { let mark = if task.done { "x" } else { " " }; println!("[{mark}] {}: {}", task.id, task.title); }
let open: Option<Task> = sqlx::query_as("SELECT id, title, done FROM tasks WHERE id = ?") .bind(42_i64) .fetch_optional(&pool) .await?; println!("Task 42: {open:?}");
Ok(())}Starte es mit cargo run:
Inserted task 1Inserted task 2Inserted task 3[x] 1: Buy milk[ ] 2: Learn sqlx[ ] 3: Walk the dogTask 42: NoneSchritt für Schritt:
sqlx::query("...")enthält das SQL. Jedes?ist ein Platzhalter, und.bind(value)füllt ihn aus, der Reihe nach..execute(&pool)führt eine Anweisung aus, die keine Zeilen zurückgibt (CREATE,INSERT,UPDATE,DELETE). Das Ergebnis verrät dirrows_affected()und bei SQLite auchlast_insert_rowid().#[derive(sqlx::FromRow)]lässt sqlx jede Zeile in einenTaskverwandeln. Die Spaltennamen müssen zu den Feldnamen passen.query_as+fetch_allliefert einVec<Task>. Nimmfetch_one, wenn es genau eine Zeile geben muss, undfetch_optional, wenn es vielleicht keine gibt. Achte darauf, wie aus einer fehlenden ZeileNonewurde, eineOption, und kein Absturz.- Jeder Aufruf gibt ein
Resultzurück, deshalb reicht?Datenbankfehler bis zumainweiter.
Damit die Daten nach dem Programmende erhalten bleiben, nimm stattdessen eine Datei: SqlitePool::connect("sqlite:todos.db?mode=rwc"). Der Teil mode=rwc bedeutet „lesen, schreiben und die Datei anlegen, falls sie fehlt“.
Abfragen, die beim Kompilieren geprüft werden. sqlx hat auch Makros wie sqlx::query_as!(Task, "SELECT id, title, done FROM tasks WHERE done = ?", done). Beim Build verbindet sich das Makro mit deiner Entwicklungsdatenbank (aus DATABASE_URL) und prüft das SQL. Ein Tippfehler wie titel stoppt den Build mit no such column: titel, bevor ihn irgendein Nutzer sieht. Dafür brauchst du beim Bauen eine Datenbank mit deinen Tabellen. Fang also mit den einfachen query-Funktionen an und nimm die Makros dazu, sobald du Migrationen hast (siehe unten).
Mit Postgres verbinden
Abschnitt betitelt „Mit Postgres verbinden“Postgres (PostgreSQL) ist die beliebteste Wahl für Web-Apps. Es läuft als Server, du brauchst also einen laufenden: Installier ihn von postgresql.org, nimm eine Cloud-Datenbank oder starte einen mit Docker:
docker run --name todo-pg -e POSTGRES_PASSWORD=secret -p 5432:5432 -d postgresDer Code ändert sich kaum. Schalte das Feature postgres ein (für eine Cloud-Datenbank, die meist Verschlüsselung verlangt, zusätzlich ein TLS-Feature wie tls-rustls):
cargo add sqlx --features runtime-tokio,postgres,tls-rustlsDann verbindest du dich mit einem Connection String (Verbindungszeichenfolge), einer URL, die sagt, wo die Datenbank ist und wie du dich anmeldest:
postgres://USER:PASSWORD@HOST:PORT/DATABASEpostgres://postgres:secret@localhost:5432/postgresSchreib das Passwort nie in deinen Code. Leg die URL in die Umgebungsvariable DATABASE_URL und lies sie beim Start aus (siehe Umgebungsvariablen):
use sqlx::postgres::PgPoolOptions;use std::time::Duration;
let url = std::env::var("DATABASE_URL").expect("set DATABASE_URL first");
let pool = PgPoolOptions::new() .max_connections(5) .acquire_timeout(Duration::from_secs(3)) .connect(&url) .await?;
// Postgres nummeriert seine Platzhalter: $1, $2, ...let tasks: Vec<Task> = sqlx::query_as("SELECT id, title, done FROM tasks WHERE done = $1") .bind(false) .fetch_all(&pool) .await?;Zwei Unterschiede zu SQLite solltest du dir merken: Die Platzhalter in Postgres heißen $1, $2 statt ?, und eine automatisch hochgezählte id ist meist BIGINT GENERATED ALWAYS AS IDENTITY (oder BIGSERIAL), was zu einem i64 passt. Wenn dein Passwort Zeichen wie @ oder / enthält, musst du sie in der URL prozentkodieren (aus @ wird %40). Tools wie sqlx-cli lesen DATABASE_URL auch aus einer Datei .env in deinem Projektordner. Trag .env in die .gitignore ein, damit sie nie auf GitHub landet.
Migrationen mit sqlx-cli
Abschnitt betitelt „Migrationen mit sqlx-cli“Deine Tabellen ändern sich mit der Zeit: hier eine neue Spalte, dort eine neue Tabelle. Migrationen sind nummerierte SQL-Dateien, die jede Änderung beschreiben. Jede Datenbank (deine, die deiner Teamkollegin, die in Produktion) führt dieselben Dateien in derselben Reihenfolge aus, und die Datenbank merkt sich, welche sie schon ausgeführt hat. Installier das Kommandozeilen-Tool einmal:
# alle Datenbankencargo install sqlx-cli
# oder nur, was du brauchst, für einen schnelleren Buildcargo install sqlx-cli --no-default-features --features sqlite,rustlsDann in deinem Projektordner:
echo "DATABASE_URL=sqlite:todos.db" > .envsqlx database createsqlx migrate add create_tasksCreating migrations/20261018035858_create_tasks.sql(Deine Nummer wird anders aussehen: Sie ist Datum und Uhrzeit.) Schreib die Änderung in diese Datei:
CREATE TABLE tasks ( id INTEGER PRIMARY KEY, title TEXT NOT NULL, done BOOLEAN NOT NULL DEFAULT FALSE);Und wende sie an:
sqlx migrate runApplied 20261018035858/migrate create tasks (1.449995ms)Führst du den Befehl noch einmal aus, passiert nichts, weil diese Migration schon angewendet ist. sqlx migrate info zeigt, was angewendet ist und was noch aussteht. Bearbeite nie eine Migration, die irgendwo schon gelaufen ist. Leg stattdessen eine neue an.
Du kannst die Migrationen auch von deinem Programm beim Start anwenden lassen. Das Makro migrate! backt die Dateien aus ./migrations in deine ausführbare Datei ein:
let pool = SqlitePool::connect("sqlite::memory:").await?;sqlx::migrate!().run(&pool).await?; // führt nur die Migrationen aus, die diese Datenbank noch nicht kenntDiesel in Kürze
Abschnitt betitelt „Diesel in Kürze“Diesel geht einen anderen Weg: Statt SQL-Strings zu schreiben, baust du Abfragen mit Rust-Methoden, und der Compiler prüft sie gegen eine Beschreibung deiner Tabellen. Zum Beispiel ist done eine bool-Spalte. Deshalb kompiliert tasks::done.eq(true), aber tasks::done.eq("yes") ist ein Compilerfehler. Diesel ist synchron, und das ist für Tools und viele Server völlig in Ordnung.
[dependencies]diesel = { version = "2.3", features = ["sqlite"] }# Baut SQLite in dein Programm ein. Nur nötig, wenn SQLite bei dir nicht installiert ist:libsqlite3-sys = { version = "0.38", features = ["bundled"] }use diesel::prelude::*;use diesel::sqlite::SqliteConnection;
// Normalerweise erzeugt `diesel migration run` das für dich in src/schema.rs.diesel::table! { tasks (id) { id -> Integer, title -> Text, done -> Bool, }}
#[derive(Queryable, Selectable, Debug)]#[diesel(table_name = tasks)]#[diesel(check_for_backend(diesel::sqlite::Sqlite))]struct Task { id: i32, title: String, done: bool,}
#[derive(Insertable)]#[diesel(table_name = tasks)]struct NewTask<'a> { title: &'a str,}
fn main() -> Result<(), Box<dyn std::error::Error>> { let mut conn = SqliteConnection::establish(":memory:")?;
// Normalerweise würde das eine Migration erledigen. diesel::sql_query( "CREATE TABLE tasks ( id INTEGER PRIMARY KEY NOT NULL, title TEXT NOT NULL, done BOOLEAN NOT NULL DEFAULT FALSE )", ) .execute(&mut conn)?;
let new_tasks = [NewTask { title: "Buy milk" }, NewTask { title: "Learn Diesel" }]; diesel::insert_into(tasks::table) .values(&new_tasks[..]) .execute(&mut conn)?;
diesel::update(tasks::table.find(1)) .set(tasks::done.eq(true)) .execute(&mut conn)?;
let all: Vec<Task> = tasks::table .order(tasks::id) .select(Task::as_select()) .load(&mut conn)?; for task in &all { println!("{} {} done={}", task.id, task.title, task.done); }
let open: i64 = tasks::table .filter(tasks::done.eq(false)) .count() .get_result(&mut conn)?; println!("{open} task(s) still open"); Ok(())}1 Buy milk done=true2 Learn Diesel done=false1 task(s) still openIn einem echten Projekt installierst du diesel_cli (cargo install diesel_cli --no-default-features --features sqlite) und rufst dann diesel setup, diesel migration generate create_tasks und diesel migration run auf. Die CLI schreibt dir src/schema.rs mit dem table!-Code, sodass er immer zur Datenbank passt. Der Getting-Started-Leitfaden (auf Englisch) führt dich durch alles.
SeaORM in Kürze
Abschnitt betitelt „SeaORM in Kürze“SeaORM ist ein asynchrones ORM: Du beschreibst jede Tabelle als Entität (ein Struct mit Attributen) und arbeitest mit Objekten statt mit SQL. Das fühlt sich ähnlich an wie Entity Framework. Version 2.0 kann die Tabellen sogar aus deinen Entitäten erzeugen.
[dependencies]sea-orm = { version = "2", features = ["sqlx-sqlite", "runtime-tokio", "entity-registry", "schema-sync"] }tokio = { version = "1", features = ["full"] }mod task { use sea_orm::entity::prelude::*;
#[sea_orm::model] #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "task")] pub struct Model { #[sea_orm(primary_key)] pub id: i32, pub title: String, pub done: bool, }
impl ActiveModelBehavior for ActiveModel {}}
use sea_orm::{entity::*, query::*, Database, DbErr};
#[tokio::main]async fn main() -> Result<(), DbErr> { let db = &Database::connect("sqlite::memory:").await?;
// Tabellen für jede Entität in diesem Crate anlegen. "seaorm_demo" ist der // Paketname aus Cargo.toml, mit _ statt -. db.get_schema_registry("seaorm_demo::*").sync(db).await?;
for title in ["Buy milk", "Learn SeaORM"] { task::ActiveModel { title: Set(title.to_owned()), done: Set(false), ..Default::default() } .insert(db) .await?; }
let mut milk = task::Entity::find_by_id(1).one(db).await?.unwrap().into_active_model(); milk.done = Set(true); milk.update(db).await?;
let open = task::Entity::find() .filter(task::Column::Done.eq(false)) .all(db) .await?; for t in &open { println!("open: {} {}", t.id, t.title); } Ok(())}open: 2 Learn SeaORMDas Model ist eine Zeile, die du liest. Das ActiveModel ist eine Zeile, die du gerade änderst: Set(...) markiert die Felder, die gespeichert werden sollen, und ..Default::default() überlässt es der Datenbank, die id auszufüllen. Für größere Projekte erzeugt sea-orm-cli Entitäten aus einer bestehenden Datenbank und verwaltet Migrationen. Mehr dazu in der Dokumentation von SeaORM (auf Englisch).
SQL-Injection: immer mit Bind-Parametern
Abschnitt betitelt „SQL-Injection: immer mit Bind-Parametern“Das ist die wichtigste Regel auf dieser Seite. Bau SQL nie, indem du Benutzereingaben mit format! in den String einfügst. Hier siehst du in reinem Rust (ohne Datenbank), warum:
// Was bei der Datenbank ankommt, auf zwei verschiedene Arten gebaut.fn pasted_query(name: &str) -> String { format!("SELECT * FROM users WHERE name = '{name}'")}
fn bound_query(name: &str) -> (&'static str, Vec<String>) { ("SELECT * FROM users WHERE name = ?", vec![name.to_string()])}
fn main() { for input in ["ana", "x' OR '1'='1"] { println!("Eingabe: {input}"); println!(" eingefügt: {}", pasted_query(input)); let (sql, params) = bound_query(input); println!(" gebunden: {sql} Parameter: {params:?}"); }}Ausgabe:
Eingabe: ana eingefügt: SELECT * FROM users WHERE name = 'ana' gebunden: SELECT * FROM users WHERE name = ? Parameter: ["ana"]Eingabe: x' OR '1'='1 eingefügt: SELECT * FROM users WHERE name = 'x' OR '1'='1' gebunden: SELECT * FROM users WHERE name = ? Parameter: ["x' OR '1'='1"]Beim Einfügen beendet das Anführungszeichen in der Eingabe den String zu früh, und OR '1'='1' wird Teil des SQL. Diese Bedingung ist immer wahr, also liefert die Abfrage jeden Benutzer. Mit demselben Trick kann ein Angreifer die Daten anderer Leute lesen oder Tabellen löschen. Das nennt man SQL-Injection, und es ist einer der häufigsten Sicherheitsfehler im Web.
Mit einem Bind-Parameter ändert sich der SQL-Text nie. Die Eingabe reist getrennt, und die Datenbank behandelt sie nur als Wert. Hier sind beide Versionen mit sqlx, ausgeführt gegen eine echte SQLite-Tabelle mit den Benutzern ana und root:
let user_input = "nobody' OR '1'='1"; // stell dir vor, das kam aus einem Login-Formular
// NICHT SO: Eingabe in den SQL-Text einfügen.let sql = format!("SELECT name FROM users WHERE name = '{user_input}'");let rows: Vec<(String,)> = sqlx::query_as(AssertSqlSafe(sql)).fetch_all(&pool).await?;println!("format!: {rows:?}");
// SO: ein fester SQL-Text mit einem ?-Platzhalter, und die Eingabe wird getrennt geschickt.let rows: Vec<(String,)> = sqlx::query_as("SELECT name FROM users WHERE name = ?") .bind(user_input) .fetch_all(&pool) .await?;println!("bind: {rows:?}");format!: [("ana",), ("root",)]bind: []Die eingefügte Abfrage hat beide Benutzer verraten. Die gebundene Abfrage hat korrekt niemanden mit diesem seltsamen Namen gefunden. Und beachte den Wrapper AssertSqlSafe(...) in der schlechten Version: sqlx 0.9 schützt dich standardmäßig. sqlx::query und query_as akzeptieren nur einen festen &'static str. Wenn du einen format!-String ohne diesen Wrapper übergibst, kompiliert das nicht:
error[E0277]: dynamic SQL strings should be audited for possible injections = help: the trait `SqlSafeStr` is not implemented for `&std::string::String` = note: prefer literal SQL strings with bind parameters or `QueryBuilder` to add dynamic data to a query.Die Meldung sagt (auf Englisch): Dynamische SQL-Strings sollten auf mögliche Injections geprüft werden; nimm lieber feste SQL-Strings mit Bind-Parametern oder QueryBuilder. Wann würdest du AssertSqlSafe also überhaupt benutzen? Nur, wenn du SQL wirklich zur Laufzeit bauen musst, etwa für eine Sortierspalte, die der Benutzer auswählt. Prüfe den Wert dann zuerst gegen deine eigene feste Liste erlaubter Namen und pack den String danach ein, um sqlx zu sagen, dass du ihn geprüft hast. Für Werte nimmst du immer .bind(). Diesel und SeaORM binden Werte für dich, solange du ihre Query Builder benutzt und keine rohen SQL-Strings.
Connection Pools
Abschnitt betitelt „Connection Pools“Eine Datenbankverbindung zu öffnen ist langsam: Es gibt einen Weg übers Netzwerk, eine Anmeldung und etwas Einrichtung. Ein Pool (Verbindungspool) öffnet einmal ein paar Verbindungen und verleiht sie. Dein Code leiht sich eine Verbindung für eine Abfrage und gibt sie sofort zurück. Deshalb hat jedes Beispiel oben &pool übergeben und keine einzelne Verbindung.
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
let options = SqliteConnectOptions::new() .filename("visits.db") .create_if_missing(true);
let pool = SqlitePoolOptions::new() .max_connections(4) // nie mehr als 4 gleichzeitig .connect_with(options) .await?;
sqlx::query("CREATE TABLE IF NOT EXISTS visits (page TEXT NOT NULL)") .execute(&pool) .await?;
// 20 Tasks teilen sich einen Pool. Einen Pool zu klonen ist billig: Er ist nur ein Handle.let mut handles = Vec::new();for n in 0..20 { let pool = pool.clone(); handles.push(tokio::spawn(async move { sqlx::query("INSERT INTO visits (page) VALUES (?)") .bind(format!("/page/{}", n % 3)) .execute(&pool) .await }));}for handle in handles { handle.await.expect("task panicked")?; // auf alle 20 Inserts warten}Die 20 Tasks laufen gleichzeitig, aber höchstens 4 reden zu jedem Zeitpunkt mit der Datenbank. Die anderen warten einen kleinen Moment auf eine freie Verbindung. Zählt man danach die Zeilen (mit einer frischen visits.db), ergibt sich:
/page/0: 7 visits/page/1: 7 visits/page/2: 6 visitsEin paar Faustregeln:
- Erstelle einen Pool beim Programmstart und teile ihn. In einer axum-App gehört er in deinen App-State (siehe das Web-API-Projekt).
- Erstelle keinen neuen Pool pro Anfrage. Damit wirfst du den ganzen Vorteil weg.
max_connectionssollte deutlich unter dem bleiben, was die Datenbank erlaubt. Ein Postgres-Server erlaubt standardmäßig 100 Verbindungen, und jede Kopie deiner App hat ihren eigenen Pool.- Setz einen
acquire_timeout. Wenn dann alle Verbindungen belegt sind, schlägt eine Anfrage mit einem klaren Fehler fehl, statt ewig zu warten.
Übungen
Abschnitt betitelt „Übungen“1. Das Repository durchsuchen
Abschnitt betitelt „1. Das Repository durchsuchen“Nimm die Idee mit dem Repository im Arbeitsspeicher und füg eine Methode search hinzu: Sie gibt die Aufgaben zurück, deren Titel ein Wort enthält, ohne Groß- und Kleinschreibung zu beachten, sortiert nach id. Teste sie mit „milk“. Tipp: title.to_lowercase().contains(&word.to_lowercase()), und als Speicher reicht hier ein Vec<Task>.
Lösung anzeigen
#[derive(Debug, Clone)]struct Task { id: u32, title: String,}
trait TaskRepository { fn add(&mut self, title: &str) -> u32; fn search(&self, word: &str) -> Vec<Task>;}
struct VecRepo { tasks: Vec<Task>,}
impl TaskRepository for VecRepo { fn add(&mut self, title: &str) -> u32 { let id = self.tasks.len() as u32 + 1; self.tasks.push(Task { id, title: title.to_string() }); id }
fn search(&self, word: &str) -> Vec<Task> { let word = word.to_lowercase(); let mut found: Vec<Task> = self .tasks .iter() .filter(|t| t.title.to_lowercase().contains(&word)) .cloned() .collect(); found.sort_by_key(|t| t.id); found }}
fn main() { let mut repo = VecRepo { tasks: Vec::new() }; repo.add("Buy milk"); repo.add("Walk the dog"); repo.add("Milk the cow");
for task in repo.search("milk") { println!("{}: {}", task.id, task.title); } println!("{} Treffer für \"cat\"", repo.search("cat").len());}Ausgabe:
1: Buy milk3: Milk the cow0 Treffer für "cat"Der Speicher ist von einer HashMap zu einem Vec geworden, aber Code, der den Trait aufruft, muss davon nichts wissen. In SQL ist dieselbe Suche WHERE title LIKE ? mit .bind(format!("%{word}%")). Beachte, dass die %-Zeichen in den gebundenen Wert gehören, nicht in den SQL-Text.
2. Ein echtes Repository mit sqlx (auf deinem Rechner)
Abschnitt betitelt „2. Ein echtes Repository mit sqlx (auf deinem Rechner)“Schreib ein Struct SqliteTaskRepo, das einen SqlitePool enthält und drei async-Methoden hat: add(title) gibt die neue id zurück, mark_done(id) gibt true zurück, wenn sich eine Zeile geändert hat, und list_open() gibt die offenen Aufgaben zurück. Leg die Tabelle in main an, füg drei Aufgaben hinzu und markiere Aufgabe 1 und Aufgabe 99 als erledigt. Tipp: rows_affected() auf dem Ergebnis von execute sagt dir, wie viele Zeilen ein UPDATE geändert hat.
Lösung anzeigen
use sqlx::sqlite::SqlitePool;
#[derive(Debug, sqlx::FromRow)]struct Task { id: i64, title: String, done: bool,}
struct SqliteTaskRepo { pool: SqlitePool,}
impl SqliteTaskRepo { async fn add(&self, title: &str) -> Result<i64, sqlx::Error> { let result = sqlx::query("INSERT INTO tasks (title) VALUES (?)") .bind(title) .execute(&self.pool) .await?; Ok(result.last_insert_rowid()) }
async fn mark_done(&self, id: i64) -> Result<bool, sqlx::Error> { let result = sqlx::query("UPDATE tasks SET done = TRUE WHERE id = ?") .bind(id) .execute(&self.pool) .await?; Ok(result.rows_affected() == 1) }
async fn list_open(&self) -> Result<Vec<Task>, sqlx::Error> { sqlx::query_as("SELECT id, title, done FROM tasks WHERE done = FALSE ORDER BY id") .fetch_all(&self.pool) .await }}
#[tokio::main]async fn main() -> Result<(), sqlx::Error> { let pool = SqlitePool::connect("sqlite::memory:").await?; sqlx::query("CREATE TABLE tasks (id INTEGER PRIMARY KEY, title TEXT NOT NULL, done BOOLEAN NOT NULL DEFAULT FALSE)") .execute(&pool) .await?;
let repo = SqliteTaskRepo { pool }; repo.add("Buy milk").await?; repo.add("Learn sqlx").await?; repo.add("Walk the dog").await?;
println!("mark 1 done: {}", repo.mark_done(1).await?); println!("mark 99 done: {}", repo.mark_done(99).await?); for task in repo.list_open().await? { println!("[{}] {}: {}", if task.done { "x" } else { " " }, task.id, task.title); } Ok(())}mark 1 done: truemark 99 done: false[ ] 2: Learn sqlx[ ] 3: Walk the dogEs ist dieselbe Form wie die Version im Arbeitsspeicher, nur async und mit Result als Rückgabe, weil eine echte Datenbank fehlschlagen kann (eine volle Festplatte, ein abgebrochenes Netzwerk). Nächster Schritt: Tausch den Vec in deinem Web-API-Projekt gegen dieses Repository aus.
Zusammenfassung
Abschnitt betitelt „Zusammenfassung“- rusqlite für einfaches synchrones SQLite, sqlx für asynchrones reines SQL (die übliche erste Wahl), Diesel für einen Query Builder, der beim Kompilieren geprüft wird, SeaORM für ein asynchrones ORM wie Entity Framework.
- Ein Repository versteckt hinter ein paar Methoden, wo die Daten liegen. Fang mit einer
HashMapan und tausch sie später gegen eine Datenbank aus. - In sqlx:
query+.bind()+.execute()für Änderungen,query_as+fetch_all/fetch_one/fetch_optional, um Zeilen in ein Struct mit#[derive(FromRow)]zu lesen. - Bewahre den Connection String in
DATABASE_URLauf, nicht in deinem Code. SQLite verwendet?als Platzhalter, Postgres$1,$2. - Ändere deine Tabellen mit Migrationen (
sqlx migrate add/run), nie von Hand. - Binde Benutzereingaben immer. Füg sie nie mit
format!in SQL ein. Erstelle beim Start einen Pool und teile ihn.
Kurzer Check
Abschnitt betitelt „Kurzer Check“Kurzer Check
1. Du willst reines SQL schreiben, async verwenden und die Möglichkeit haben, Abfragen beim Kompilieren prüfen zu lassen. Welches Crate passt am besten?
2. Welche sqlx-Methode solltest du nehmen, wenn eine Abfrage null oder eine Zeile zurückgeben kann?
3. Warum ist format!("... WHERE name = '{name}'") gefährlich?
4. Was macht ein Connection Pool?
Punkte: 0 / 4
Weiterlesen
Abschnitt betitelt „Weiterlesen“- sqlx auf docs.rs (auf Englisch): die API-Referenz mit
query,query_asund denquery!-Makros, Pools und allen unterstützten Datenbanken. - sqlx-cli auf docs.rs (auf Englisch): wie du die CLI installierst, Datenbanken anlegst, Migrationen ausführst und zurücknimmst und den Offline-Modus für die
query!-Makros nutzt. - Getting Started with Diesel (auf Englisch): der offizielle Schritt-für-Schritt-Leitfaden, von
diesel setupbis zum Einfügen, Ändern und Löschen von Zeilen. - Dokumentation von SeaORM (auf Englisch): Entitäten, Beziehungen, Migrationen und der Codegenerator
sea-orm-cli. - OWASP SQL Injection Prevention Cheat Sheet (auf Englisch): wie SQL-Injection funktioniert und wie parametrisierte Abfragen sie verhindern, für jede Sprache.