IndexedDB + Dexie
In meinem Beitrag zu Aurelia + Workbox bin ich ein wenig auf das Thema PWAs eingegangen. Damit wird sichergestellt, dass die Anwendung auch Offline verfügbar ist. Allerdings ist dies nur ein Teil des Problems. Das zweite sind die Daten. Und hier kommt die mittlerweile in allen aktuellen Browsern verfügbare IndexedDB ins Spiel.
Bei der IndexedDB handelt es sich um eine Key-Value-Datenbank. Die erste Frage, die sich mir bei der Einarbeitung in dieses Thema gestellt hat, war, was für Vorteile ich denn jetzt im Vergleich zum einfach zu verwenden LocalStorage habe:
- Unterstützt ACID, also auf gut Deutsch Transaktionen.
- IndexedDB-Datenbanken sind für größere Mengen an Daten als der LocalStorage. Detail dazu folgt weiter unten.
- Spalten können indexiert werden.
Speicherbegrenzungen
Das Thema ist etwas komplizierter, da je nachdem auf welcher Seite man nachschaut, es unterschiedliche Informationen gibt. Meine Informationen stammen von https://developer.mozilla.org/de/docs/IndexedDB/Browser_storage_limits_and_eviction_criteria sowie https://www.html5rocks.com/en/tutorials/offline/quota-research/#toc-overview.
Die Grenze für eine IndexedDB wird durch den freien Festplattenspeicher definiert. Davon steht 50 % global zur Verfügung. Von diesen 50 % bekommt eine einzelne Gruppe (z.B. Domäne, Subdomäne) jedoch nur 20 %, allerdings mit einem Minimum von 10MB und einem Maximum von 2GB.
Beim LocalStorage sind die Grenzen je nach Browser und Betriebssystem irgendwo zwischen 2MB und 10MB.
IndexedDB
Die API der IndexedDB ist nicht wirklich kompliziert (https://developer.mozilla.org/de/docs/IndexedDB), für meinen Geschmack allerdings etwas umständlich, da hierbei noch keine Promises zur Verfügung stehen und alles mit Callback-Methoden funktioniert. Auch einfache Operationen wie z.B. eine Case-Insensitive-Suche müssen relativ umständlich implementiert werden.
Und genau hier kommt Dexie.js ins Spiel, welches die Arbeit mit der IndexedDB extremst vereinfacht.
Dexie.js
Erzeugen oder Laden einer Datenbank
const db = new Dexie("MyDatabase");
db.version(1).stores({
person: "++id, name",
order: "++id, idPerson, orderNo"
});
db.version(2).stores({
invoice: "++id, idPerson, invoiceNo"
}).upgrade(u => {
return Promise.resolve();
});
Als erstes wird die Datenbank geladen, bzw. wenn diese noch nicht existiert, erzeugt. Stores bzw. Tabellen müssen mit Hilfe von „version($VERSION_NR)“ erstellt werden. Die angegebenen Spalten sind nicht die Spalten, wie man sie von SQL kennt, sondern die Spalten, die indexiert werden sollen. Das ++id bedeutet, dass ID automatisch vergeben werden soll – sprich AutoIncrement.
Mittels der Methode „upgrade“ können bestimmte Aktionen ausgelöst werden, wenn die Datenbank auf diese Version upgedated wurde.
Speichern von Daten
const personTable = db.table("person");
await personTable.bulkAdd([
{name: "Stefan", age: 10},
{name: "Thomas", age: 21},
{name: "Max", age: 32},
{name: "Herbert", age: 43}
]);
await personTable.add({
name: "Klaus",
age: 54
});
await personTable.put({
id: 5,
name: "Klaus",
age: 54
});
await personTable.update(5, {
name: "Klaus",
age: 54
});
Wie man sieht gibt es hierfür mehrere Möglichkeiten (und es gibt auch noch mehr) um Daten zu speichern. „bulkAdd“ wird verwendet, um eine größere Anzahl von Daten zu speichern, „add“ für ein einzelnes Element, „put“ um den Datensatz zu aktualisieren, wenn er schon vorhanden ist, bzw. hinzuzufügen, falls nicht und „update“ um den bereits bestehenden Datensatz zu aktualisieren.
Laden von Daten
const result1 = await personTable
.where("name")
.startsWith("S")
.toArray();
const result2 = await personTable
.where("name")
.startsWithIgnoreCase("s")
.first();
const result3 = await personTable
.where("name")
.anyOf(["Stefan", "Max"])
.filter(p => p.age > 14)
.offset(2)
.limit(5)
.toArray();
Die Beispiele sind, so glaube ich, ziemlich selbsterklärend. Wichtig ist allerdings, dass die Spalte, nach der gesucht wird, indexiert ist. Ansonsten wird ein Fehler geworfen. Eine Ausnahme hierfür ist „filter“, da dies nicht auf der Datenbank ausgeführt wird. Entsprechend damit etwas aufpassen, da dies eine Abfrage extrem verlangsamen kann.
Transaktionen
await db.transaction("rw", [personTable], async () => {
await personTable.update(3, { name: "Markus" });
await personTable.update(2, { name: "Richard" });
});
Hierbei wird eine Lese- und Schreibtransaktion gestartet. Es muss jeweils angegeben werden, welche Tabellen geändert werden.
Hooks
Zu guter Letzt noch ein kleines Feature, das vor allem den Personen, die mit Datensynchronisation zu tun habe, helfen sollte. Je Tabelle können diverse Hooks definiert werden, die ausgelöst werden, wenn an der Tabelle etwas passiert. Folgende Hooks sind möglich:
- reading
- creating
- updating
- deleting
Registriert werden können sie mit folgendem Aufruf:
personTable.hook("creating", (primKey, obj, transaction) => {
transaction.on("complete", () => {
//z.B. einen Logeintrag erstellen
});
});
Fazit
Meine Beispiele kratzen natürlich nur an der Oberfläche von Dexie.js. Allerdings veranschaulichen sie, wie einfach die Verwendung einer IndexedDB damit wird.