Datenbanken
Kurz gesagt: Eine Datenbank speichert Daten in Tabellen und findet sie mit einer SQL-Abfrage schnell; in C# sprichst du über ADO.NET mit ihr, und Parameter schützen vor SQL-Injection.
Problem
Ein Katalog in einer Datei
Der Katalog des Shops ist eine CSV-Datei, wie im Modul über Dateien. Solange es hundert Produkte sind, geht das. Dann werden es hunderttausend, und es gibt Probleme:
- um ein Produkt zu finden, liest du die ganze Datei;
- „alles unter 5 €, nach Preis sortiert“ ist eine Schleife, ein
ifund eine Sortierung, alles von Hand geschrieben; - zwei Kassen schreiben gleichzeitig in die Datei, und ein Eintrag geht verloren oder die Datei wird beschädigt;
- mitten beim Schreiben fällt der Strom aus, und die halbe Datei ist weg.
Deshalb liegen die Daten fast jedes echten Programms in einer Datenbank: einem Programm, dessen Aufgabe es ist, Daten aufzubewahren, sie schnell zu finden und nie zu verlieren.
Die Idee
Tabellen und SQL
Eine relationale Datenbank speichert Daten in Tabellen. Eine Tabelle hat Spalten (Name, Typ) und Zeilen (eine für jedes Produkt). Die Spalte id ist der Primärschlüssel: Ihr Wert ist in jeder Zeile anders, deshalb findet man mit ihm genau eine Zeile.
Mit der Datenbank sprichst du in SQL. Die fünf häufigsten Befehle:
CREATE TABLE products ( id INT PRIMARY KEY, name VARCHAR(50) NOT NULL, price DECIMAL(8, 2));
INSERT INTO products VALUES (1, 'Kaffee', 7.50);SELECT name, price FROM products WHERE price < 5 ORDER BY price;UPDATE products SET price = 6.90 WHERE id = 1;DELETE FROM products WHERE id = 3;CREATE TABLElegt eine Tabelle an,INSERTfügt eine Zeile ein,UPDATEändert Zeilen undDELETElöscht sie.SELECTliest: welche Spalten, aus welcher Tabelle, unter welcher Bedingung (WHERE) und in welcher Reihenfolge (ORDER BY).
Du sagst, was du willst, und die Datenbank entscheidet, wie sie es schnell findet.
Genauer, für Neugierige
- Bekannte relationale Datenbanken sind PostgreSQL, MySQL, Oracle und SQL Server. Sie laufen als eigene Server. Hier benutzen wir SQLite: eine kleine Datenbank, die in einer Datei oder ganz im Speicher des Programms lebt. In .NET kommt sie mit Microsofts Paket
Microsoft.Data.Sqlite(dotnet add package Microsoft.Data.Sqlite). Die AdresseData Source=:memory:ist eine Datenbank im Speicher: Sie lebt, solange die Verbindung offen ist, und jeder Lauf beginnt mit einer leeren. - Text steht in SQL in einfachen Anführungszeichen:
'Kaffee'. In PostgreSQL, MySQL und SQL Server istDECIMAL(8, 2)eine exakte Zahl mit 2 Nachkommastellen, das passt zu Geld. SQLite speichert keine exakten Dezimalzahlen: Dort ist7.50eine gewöhnliche Gleitkommazahl.
Sieh es dir an
ADO.NET: Verbindung, Befehl, Ergebnis
SqliteConnection, die Abfrage ein SqliteCommand, und das Ergebnis liest du mit einem SqliteDataReader (für SQL Server heißen sie SqlConnection, SqlCommand und SqlDataReader). Die Methode Setup legt die Tabelle an und fügt drei Produkte ein:static void Setup(SqliteConnection conn){ using var cmd = conn.CreateCommand(); cmd.CommandText = "CREATE TABLE products (id INT PRIMARY KEY, name VARCHAR(50) NOT NULL, price DECIMAL(8, 2))"; cmd.ExecuteNonQuery(); cmd.CommandText = "INSERT INTO products VALUES (1, 'Kaffee', 7.50), (2, 'Tee', 3.20), (3, 'Schokolade', 2.40)"; cmd.ExecuteNonQuery();}record Product(int Id, string Name, double Price):using var conn = new SqliteConnection("Data Source=:memory:");conn.Open();Setup(conn);var products = new List<Product>();using var cmd = conn.CreateCommand();cmd.CommandText = "SELECT id, name, price FROM products ORDER BY price";using var reader = cmd.ExecuteReader();while (reader.Read()){ products.Add(new Product(reader.GetInt32(0), reader.GetString(1), reader.GetDouble(2)));}foreach (var p in products){ Console.WriteLine(p);}Product { Id = 3, Name = Schokolade, Price = 2.4 }Product { Id = 2, Name = Tee, Price = 3.2 }Product { Id = 1, Name = Kaffee, Price = 7.5 }new SqliteConnection(address)erzeugt die Verbindung, undOpen()öffnet sie. Die Adresse (der Connection-String) sagt, welche Datenbank: hier SQLite im Speicher, oder eine Datei wieData Source=shop.db.conn.CreateCommand()erzeugt einen Befehl, undCommandTextist sein SQL.ExecuteNonQuery()ist für Befehle, die etwas ändern (CREATE,INSERT,UPDATE,DELETE), und gibt zurück, wie viele Zeilen sich geändert haben.ExecuteReader()ist fürSELECT.reader.Read()geht zur nächsten Zeile und gibtfalsezurück, wenn die Zeilen zu Ende sind.GetInt32,GetStringundGetDoubleholen eine Spalte nach ihrer Nummer: ab 0, in der Reihenfolge desSELECT. Nach dem Namen geht es mitreader.GetOrdinal("name").- Die Verbindung, der Befehl und der Reader müssen geschlossen werden:
using varschließt sie (es ruftDispose()auf), wenn der Block endet. - Fehler sind Exceptions vom Typ
SqliteException. C# hat keine geprüften Exceptions, deshalb schreiben die Methoden keinthrows.
using var conn = new SqliteConnection("Data Source=:memory:");conn.Open();Setup(conn);var products = new List<Product>();using var cmd = conn.CreateCommand();cmd.CommandText = "SELECT id, name, price FROM products ORDER BY price";using var reader = cmd.ExecuteReader();while (reader.Read()){ products.Add(new Product(reader.GetInt32(0), reader.GetString(1), reader.GetDouble(2)));}foreach (var p in products){ Console.WriteLine(p);}Schritt für Schritt, in Worten
- Eine Verbindung zur Datenbank im Speicher. Setup legt die Tabelle products an und fügt 3 Zeilen ein.
- Eine leere Liste, in die die Objekte kommen.
- SELECT mit ORDER BY price: ExecuteReader gibt einen Reader über die Zeilen zurück, die günstigste zuerst.
- reader.Read() geht zu Zeile 1. GetInt32, GetString und GetDouble holen die Spalten nach Nummer, und new Product macht daraus ein Objekt: Schokolade.
- reader.Read() geht zu Zeile 2. GetInt32, GetString und GetDouble holen die Spalten nach Nummer, und new Product macht daraus ein Objekt: Tee.
- reader.Read() geht zu Zeile 3. GetInt32, GetString und GetDouble holen die Spalten nach Nummer, und new Product macht daraus ein Objekt: Kaffee.
- reader.Read() gibt false zurück: Die Zeilen sind zu Ende, und die Schleife hört auf.
- Die Liste enthält gewöhnliche C#-Objekte. Die Ausgabe zeigt den Record mit seinen Feldern.
Genauer, für Neugierige
- Bei einer echten Datenbank auf einem Server ist die Adresse anders, und es gibt einen Benutzer und ein Passwort. Außerdem brauchst du den Treiber der Datenbank als Abhängigkeit im Projekt (für MySQL: das Paket
MySqlConnector, mitMySqlConnectionstattSqliteConnection):
var address = "Server=localhost;Port=3306;Database=shop;User ID=shop_app;Password=geheim";using var conn = new MySqlConnection(address);conn.Open();Console.WriteLine("Mit der Datenbank verbunden!");- Das Passwort steht nicht im Code. Es wird aus den Einstellungen oder aus einer Umgebungsvariable gelesen (
appsettings.jsonoderEnvironment.GetEnvironmentVariable).
- Die Preise sind hier
double, damit es kurz bleibt. In einem echten Shop ist Geld eindecimal: EineDECIMAL-Spalte in PostgreSQL oder SQL Server liest man mitreader.GetDecimal(2). SQLite speichert keine exakten Dezimalzahlen, deshalb wird Geld dort in ganzen Cent gespeichert.
Probier es aus
Was wird ausgegeben?
Die Produkte sind Kaffee 7,50 €, Tee 3,20 € und Schokolade 2,40 €. Was gibt das Programm aus?
using var conn = new SqliteConnection("Data Source=:memory:");conn.Open();Setup(conn);using var cmd = conn.CreateCommand();cmd.CommandText = "SELECT name FROM products WHERE price < 5 ORDER BY price DESC";using var reader = cmd.ExecuteReader();while (reader.Read()){ Console.WriteLine(reader.GetString(0));}WHERE price < 5 behält Tee und Schokolade. ORDER BY price DESC sortiert sie nach Preis von oben nach unten (DESC ist absteigend, ASC ist der Standard): zuerst Tee für 3,20 €, dann Schokolade für 2,40 €.
Problem
Die Suche, die alles zeigt
Die Website sucht ein Produkt nach dem Namen, den der Nutzer eintippt. Die Abfrage wird mit $"…{name}…" zusammengesetzt:
static void Search(SqliteConnection conn, string name){ using var cmd = conn.CreateCommand(); cmd.CommandText = $"SELECT name, price FROM products WHERE name = '{name}'"; using var reader = cmd.ExecuteReader(); while (reader.Read()) { Console.WriteLine($" {reader.GetString(0)}: {reader.GetDouble(1)} €"); }}using var conn = new SqliteConnection("Data Source=:memory:");conn.Open();Setup(conn);Console.WriteLine("Suche: Tee");Search(conn, "Tee");Console.WriteLine("Suche: x' OR '1'='1");Search(conn, "x' OR '1'='1");Suche: Tee Tee: 3.2 €Suche: x' OR '1'='1 Kaffee: 7.5 € Tee: 3.2 € Schokolade: 2.4 €Mit „Tee“ funktioniert es. Mit x' OR '1'='1 wird die Abfrage zu ... WHERE name = 'x' OR '1'='1'. Die Bedingung ist immer wahr, und die ganze Tabelle kommt heraus. Die Eingabe des Nutzers ist Teil des SQL geworden. Das ist SQL-Injection, einer der häufigsten Angriffe auf Websites. Mit demselben Trick kann sich jemand ohne Passwort anmelden oder eine Tabelle löschen.
Die Idee
Parameter: $name
$name, an der Stelle des Werts, und den Wert übergibst du mit cmd.Parameters.AddWithValue("$name", name):static void Search(SqliteConnection conn, string name){ using var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT name, price FROM products WHERE name = $name"; cmd.Parameters.AddWithValue("$name", name); using var reader = cmd.ExecuteReader(); while (reader.Read()) { Console.WriteLine($" {reader.GetString(0)}: {reader.GetDouble(1)} €"); }}Suche: Tee Tee: 3.2 €Suche: x' OR '1'='1Jetzt ist x' OR '1'='1 nur Text, der als Name gesucht wird. Ein solches Produkt gibt es nicht, und nichts kommt heraus. Was der Nutzer auch eintippt, es kann nicht zu SQL werden.
$name, @name und :name. In SQL Server und in den meisten Beispielen für .NET siehst du @name.Probier es aus
Kaffee mit Apostroph
Kaffee 'Arabica' bricht das Programm mit SqliteException: SQLite Error 1: 'near "Arabica": syntax error'. ab. In welcher Zeile ist der Fehler?Wähle die Zeile mit dem Fehler.
'Arabica' schließt den Text zu früh, und der Rest ist sinnloses SQL (near "Arabica" zeigt auf die Stelle). Ein Angreifer würde dort seine eigene Abfrage hinschreiben. Die Korrektur sind Parameter: "UPDATE products SET name = $name WHERE id = $id", dann cmd.Parameters.AddWithValue("$name", newName) und cmd.Parameters.AddWithValue("$id", id). Dann wird der Name genau so gespeichert, wie er ist, mit den Apostrophen.Die Idee
Transaktion: alles oder nichts
Maria überweist 150 € an Jonas. Das sind zwei Abfragen: bei Jonas hinzufügen, bei Maria abziehen. Die Tabelle hat die Bedingung CHECK (balance >= 0), deshalb schlägt die zweite Abfrage fehl: Maria hat nur 100 €.
static void OpenAccounts(SqliteConnection conn){ using var cmd = conn.CreateCommand(); cmd.CommandText = "CREATE TABLE accounts (id INT PRIMARY KEY, owner VARCHAR(50), balance INT CONSTRAINT no_debt CHECK (balance >= 0))"; cmd.ExecuteNonQuery(); cmd.CommandText = "INSERT INTO accounts VALUES (1, 'Maria', 100), (2, 'Jonas', 50)"; cmd.ExecuteNonQuery();}
static void Move(SqliteConnection conn, int id, int amount){ using var cmd = conn.CreateCommand(); cmd.CommandText = "UPDATE accounts SET balance = balance + $amount WHERE id = $id"; cmd.Parameters.AddWithValue("$amount", amount); cmd.Parameters.AddWithValue("$id", id); cmd.ExecuteNonQuery();}
static void PrintBalances(SqliteConnection conn){ using var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT owner, balance FROM accounts ORDER BY id"; using var reader = cmd.ExecuteReader(); while (reader.Read()) { Console.WriteLine($" {reader.GetString(0)}: {reader.GetInt32(1)} €"); }}static void Transfer(SqliteConnection conn, int from, int to, int amount){ Move(conn, to, amount); Move(conn, from, -amount);}
using var conn = new SqliteConnection("Data Source=:memory:");conn.Open();OpenAccounts(conn);try{ Transfer(conn, 1, 2, 150);}catch (SqliteException){ Console.WriteLine("Die Überweisung von 150 € wurde abgelehnt.");}PrintBalances(conn);Die Überweisung von 150 € wurde abgelehnt. Maria: 100 € Jonas: 200 €static void Transfer(SqliteConnection conn, int from, int to, int amount){ using var tx = conn.BeginTransaction(); try { Move(conn, to, amount); Move(conn, from, -amount); tx.Commit(); } catch (SqliteException) { tx.Rollback(); throw; }}Die Überweisung von 150 € wurde abgelehnt. Maria: 100 € Jonas: 50 €conn.BeginTransaction()beginnt eine Transaktion: Die Änderungen sind noch nicht endgültig. Die Befehle, die du danach mitCreateCommand()erzeugst, sind in ihr.tx.Commit()speichert sie alle auf einmal.tx.Rollback()macht sie alle rückgängig, wenn etwas fehlschlägt.throw;schickt dieselbe Exception weiter nach oben, damit auch der Aufrufer davon erfährt.using var txist ein Sicherheitsnetz: Wenn die Transaktion das Ende des Blocks ohneCommit()erreicht, machtDispose()sie rückgängig.
Genauer, für Neugierige
- Die Exception für die fehlgeschlagene Abfrage ist eine
SqliteExceptionmit der MeldungSQLite Error 19: 'CHECK constraint failed: no_debt'.19 ist der Code von SQLite für eine verletzte Regel (e.SqliteErrorCode).no_debtist der Name der Bedingung: In der Tabelle stehtbalance INT CONSTRAINT no_debt CHECK (balance >= 0).
- Datenbanken versprechen bei Transaktionen vier Dinge, bekannt als ACID: alles oder nichts (Atomarität), die Daten bleiben nach den Regeln der Tabelle korrekt (Konsistenz), gleichzeitig laufende Transaktionen stören sich nicht gegenseitig (Isolation), und was gespeichert ist, geht nicht verloren, auch wenn der Strom ausfällt (Dauerhaftigkeit).
Die Idee
ORM: Objekte statt Zeilen
Microsoft.EntityFrameworkCore.Sqlite. Ein anderes bekanntes ist Dapper: Es ist leichter, du schreibst das SQL selbst, und es macht die Objekte.using System.ComponentModel.DataAnnotations;using System.ComponentModel.DataAnnotations.Schema;using Microsoft.EntityFrameworkCore;
[Table("products")]class Product{ public int Id { get; set; }
[Required, MaxLength(50)] public string Name { get; set; }
public double Price { get; set; }}
class ShopContext : DbContext{ public DbSet<Product> Products { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder options) { options.UseSqlite("Data Source=:memory:"); }}[Table("products")]gibt den Namen der Tabelle an. Die Dinge in[...]sind Attribute: Notizen an der Klasse und ihren Eigenschaften, die EF Core liest. Du siehst sie im Modul über das Web wieder.- Die Eigenschaft
Idist nach Konvention der Primärschlüssel (der Name istIdoderProductId), und die Datenbank gibt ihr einen Wert. [Required]istNOT NULL, und[MaxLength(50)]gibt die Länge an. Eigenschaften ohne Attribut werden auch zu Spalten.- Der
DbContextist die Verbindung zur Datenbank. JedesDbSet<Product>ist eine Tabelle, undOnConfiguringsagt, welche Datenbank es ist: hier SQLite im Speicher.
Speichern und Lesen gehen über den Kontext:
using var db = new ShopContext();db.Database.OpenConnection();db.Database.EnsureCreated();
db.Products.Add(new Product { Name = "Kaffee", Price = 7.50 });db.Products.Add(new Product { Name = "Tee", Price = 3.20 });db.SaveChanges();
var cheap = db.Products .Where(p => p.Price < 5) .OrderBy(p => p.Price) .ToList();foreach (var p in cheap){ Console.WriteLine($"{p.Name}: {p.Price} €");}Tee: 3.2 €EnsureCreated() legt die Tabellen an. Add fügt ein Objekt hinzu, und SaveChanges() speichert alle hinzugefügten und geänderten Objekte auf einmal, in einer Transaktion. Die Abfrage ist LINQ, wie bei Listen, aber EF Core geht nicht durch Objekte im Speicher: Es macht aus Where(p => p.Price < 5) SQL, das von der Klasse und ihrer Eigenschaft Price spricht, und schickt es an die Datenbank. ToList() führt die Abfrage aus und gibt fertige Objekte zurück.
Die Datenbank im Speicher lebt, solange die Verbindung offen ist, und EF Core öffnet und schließt sie sonst für jede Abfrage. Deshalb hält OpenConnection() sie offen. Mit einer Datei (Data Source=shop.db) brauchst du diese Zeile nicht.
Hier auf der Website läuft dieser Code: EF Core 10 mit SQLite ist in der Prüfung vorhanden. Die Ausgabe kommt von dort.
Genauer, für Neugierige
- Eine LINQ-Abfrage ist Code, deshalb lassen sich die Bedingungen leicht aus der Auswahl des Nutzers zusammensetzen. Eine Methode, die nach dem Namen sucht:
static List<Product> Named(ShopContext db, string name){ return db.Products.Where(p => p.Name == name).ToList();}- Wenn du genau dein eigenes SQL brauchst, führt
FromSqles aus und gibt Objekte zurück. Der Wert in{max}wird nicht hineingeklebt:FromSqlschickt ihn als Parameter, deshalb gibt es hier keine Injection.FromSqlRawist anders: Es nimmt fertigen Text, und wenn du ihn mit+oder$"…"baust, ist der Wert wieder hineingeklebt.
static List<Product> Cheaper(ShopContext db, double max){ return db.Products.FromSql($"SELECT * FROM products WHERE price < {max}").ToList();}- Eine Transaktion, die bei einem Fehler rückgängig gemacht wird, wenn eine Änderung aus mehreren Aufrufen von
SaveChangesbesteht:
using var tx = db.Database.BeginTransaction();try{ db.Products.Add(new Product { Name = "Schokolade", Price = 2.40 }); db.SaveChanges(); // weitere Änderungen an den Objekten und SaveChanges tx.Commit();}catch{ tx.Rollback(); throw;}ToQueryString()zeigt das SQL der Abfrage, ohne sie auszuführen. So siehst du, dass darunter weiterhin SQL steckt undmaxein Parameter ist:
var max = 5.0;var query = db.Products.Where(p => p.Price < max).OrderBy(p => p.Price);Console.WriteLine(query.ToQueryString());.param set @max 5.0
SELECT "p"."Id", "p"."Name", "p"."Price"FROM "products" AS "p"WHERE "p"."Price" < @maxORDER BY "p"."Price"- In älteren Projekten mit .NET Framework siehst du Entity Framework 6 (
System.Data.Entity). EF Core ist das neue, für .NET 5 und später, und die Ideen sind dieselben.
Übung
Ein Shop mit Datenbank
Microsoft.Data.Sqlite im Projekt und der Datenbank Data Source=:memory:.1. Katalog. Lege eine Tabelle products an und die Methoden Add, SetPrice, Remove und List, alle außer List mit Parametern ($id, $name, $price). Füge Kaffee (7,50 €), Tee (3,20 €) und Schokolade (2,40 €) hinzu, gib sie aus, ändere den Preis des Kaffees auf 6,90 €, lösche die Schokolade und gib sie wieder aus:1. Kaffee: 7.5 €2. Tee: 3.2 €3. Schokolade: 2.4 €---1. Kaffee: 6.9 €2. Tee: 3.2 €Find(conn, text) findet alle Produkte, deren Name den Text enthält, ohne auf Groß- und Kleinschreibung zu achten. LOWER ändert in SQLite nur die Buchstaben A bis Z, deshalb bleibt É, wie es ist. Gib der Datenbank eine eigene Funktion: conn.CreateFunction("lower_text", (string text) => text.ToLowerInvariant()), dann WHERE lower_text(name) LIKE $text mit dem Wert "%" + text.ToLowerInvariant() + "%". Probiere „KA“, „éclair“ und einen Injection-Versuch:Suche nach „KA“: Kaffee: 7.5 € Kakao: 4.8 €Suche nach „éclair“: Éclair: 2.4 €Suche nach „' OR '1'='1“: keine Produkte gefundenTransfer(conn, from, to, amount) zieht zuerst von from ab und fügt dann bei to hinzu. Wenn ExecuteNonQuery() 0 zurückgibt (kein solches Konto), wirf InvalidOperationException("Kein Konto " + id). Alles ist in einer Transaktion aus BeginTransaction(). Überweise 30 € von 1 an 2, dann 40 € von 1 an das Konto 99, das es nicht gibt:Überwiesen: 30 € von 1 an 2Abgelehnt: Kein Konto 99 Maria: 70 € Jonas: 80 €Die zweite Überweisung hat Maria 40 € abgezogen, bevor sie fehlschlug, aber Rollback() hat die 40 € zurückgebucht.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Listen · Lambdas und Delegates · LINQ · record, switch und Muster
- Geht weiter in: Injektionen: wenn Daten zum Befehl werden · Monolith, modularer Monolith, Services · Transactional Outbox · Eine Transaktion über Services hinweg (Saga)
- Aufgaben zum Thema: Programme in der echten Welt