LiteDB in Xamarin / Xamarin.Forms einsetzen

Inhaltsverzeichnis

Übersicht

LiteDB lässt sich ähnlich nutzen, wie SQLite. D.h., die Datenbank benötigt keinen Server und hat keine integrierte Benutzer- / Rechte-Verwaltung. Die Dokumente können sowohl als generische BsonDocument gespeichert werden, aber auch typisiert mit DAO-Klassen arbeiten. Die meisten Abfragen können über LINQ abgebildet werden.

var connection = new ConnectionString
{
  Filename = "./data/my_db.db",
  Connection = ConnectionType.Direct
};
using(var db = new LiteDatabase(connection)) {
  var myCollection = db.GetCollection<MyModel>();
  var newModel = new MyModel { Name ="Max Musternamm", Age = 33,
    Address = new MyAddress {Street = "Mustergasse", City = "London" }};
  myCollection.Insert(newModel);
  // oder myCollection.Upsert(newModel);

  var fromLondon = myCollection.Query()
    .Where(w => w.Address.City == "London")
    .OrderByDescending(o => o.Age)
    .ToArray();
}

Initialisierung

Um die LiteDB nutzen zu können, muss ein Objekt für die Steuerung der Verbindung ConnectionString erstellt werden. Dieser enthält neben dem Pfad zur Datenbankdatei auch den Modus, wie auf diese Datei zugegriffen werden soll (Direct, Shared). Zusätzlich besteht auch die Möglichkeit, die Datenbank zu verschlüsseln (Eigenschaft Password im ConnectionString).

Die Datenbank Instanz nimmt die Konfiguration für die Verbindung entgegen und arbeitet mit dieser.

var connection = new ConnectionString
{
  Filename = "./data/my_db.db",
  Connection = ConnectionType.Direct,
  Password = "SuperSecret"
};
var db = new LiteDatabase(connection);

Modes

Aktuell werden zwei Modes für die Verbindung unterstützt.

  • Shared: Dabei können mehrere Datenbankinstanzen auf dieselbe Datenbankdatei zugreifen. Diese Option steht aktuell nicht für mobile Betriebssysteme zur Verfügung (siehe auch Issue #1618).
  • Direct: Es darf nur eine Instanz auf die Datenbankdatei zugreifen (da aber auch aus unterschiedlichen Threads). Bevor eine neue Instanz darauf zugreifen kann, muss die vorherige mit Dispose sauber geschlossen werden. Greift eine andere Instanz darauf, wird die Verbindung mit einer Ausnahme beendet.

Abfragen

Die Daten können nun sehr einfach in die Collections geschrieben und wieder ausgelesen werden. Für die Abfragen kann sowohl SQL-Syntax auf generischen Collections genutzt werden, als auch LINQ auf typisierten Collections. Es werden die meisten Möglichkeiten aus LINQ angeboten, aber eben nicht alle. Aus diesem Grund sollten alle Abfragen mit realen Daten getestet werden. Mehr zu Abfragen und eingebauten Funktionen kann in der offiziellen Dokumentation nachgelesen werden.

// Eindeutigen Index hinzufügen
var myCollection = db.GetCollection<MyModel>();
myCollection.EnsureIndex(i => i.Number, true);

// Daten hinzufügen
var model = new MyModel {
  Number = 25,
  Name = "My Model",
  Price = 22.99m
};
myCollection.Insert(model);

// Abfragen
var priceList = myCollection.Query()
  .Where(w => w.Price > 20m)
  .OrderByDescending(o => o.Price)
  .Select(w => s.Price)
  .ToArray();

// Aktualisierung (ID notwendig)
var model2 = myCollection.Query()
  .Where(w => w.Number == 100)
  .Single();
model2.Price = 98.33m;
myCollection.Update(model2);
// Oder (insert / update)
myCollection.Upsert(model2);

Dependency Injection

Ich nutze bei der Entwicklung sehr gern das DI (Dependency Injection) Pattern, um meinen Code testbarer zu machen. Bei Xamarin nutze ich dazu die Bibliothek Prism.Forms.

LiteDB ist in dieser Hinsicht sehr angenehm aufgebaut und liefert für alle notwendigen Services die entsprechenden Interfaces, wie ILiteDatabase für die Datenbank und ILiteCollection<TModel> für den Zugriff auf die Dokumente. Unter Xamarin muss die Datenbank dazu als Singleton registriert werden (Direct-Mode). Die Collections sollen als Transient registriert werden (haben kein IDisposable-Interface).

/* App.xaml.cs */
private static ILiteDatabase? _liteDatabase;

protected override void RegisterTypes(IContainerRegistry containerRegistry) {
  // Register database
  RegisterDatabase(containerRegistry);
}

private void RegisterDatabase(IContainerRegistry containerRegistry) {
  // Registrierung der Datentypen-Serialisierung,
  // falls diese nicht dem "default" entsprechen,
  // hier von NetUnits-Einheiten
  BsonMapper.Global.RegisterType(
    mass => new BsonDocument(new Dictionary<string, BsonValue> {
      { 
        nameof(Mass.Unit),
        new BsonValue((int)mass.Unit)
      },
      {
        nameof(Mass.Value),
        new BsonValue(mass.Value)
      } 
    }),
    doc => Mass.From(
      doc[nameof(Mass.Value)].AsDouble,
      (MassUnit)doc[nameof(Mass.Unit)].AsInt32)
  );
  // Registrieren der Datenbank als Singleton
  containerRegistry.RegisterInstance(_liteDatabase
    ??= CreateDatabase(containerRegistry));
}

private ILiteDatabase CreateDatabase(IContainerRegistry containerRegistry) {
  // Speicherpfad für die Datenbank bestimmen
  var resolver = containerRegistry.GetContainer();
  var fileSystem = resolver.Resolve<IFileSystem>();
  var dbPath = Path.Combine(fileSystem.AppDataDirectory, "databases");
  if (!Directory.Exists(dbPath)) {
    Directory.CreateDirectory(dbPath);
  }

  var dbFileName = Path.Combine(dbPath, "mydb.ldb");
  var connection = new ConnectionString {
    Filename = dbFileName,
    Connection = ConnectionType.Direct
  };
  
  // Registrierung der Verbindung
  containerRegistry.RegisterInstance(connection);
  var db = new LiteDatabase(connection) {CheckpointSize = 100};
  
  // Setzen der Indezes und Registrierung der Collections
  var col1 = db.GetCollection<MyModel>();
  col1.EnsureIndex(i => i.Timestamp);
  col1.EnsureIndex(i => i.Localtion.City);
  containerRegistry.Register<ILiteCollection<MyModel>>(t =>
    t.Resolve<ILiteDatabase>().GetCollection<MyModel>());

  return db;
}

Im Zuge der Registrierung führe ich auch gleich die Definition der Indezes auf den Collections, so dass ich die Definition der Datenbank an einer Stelle habe. In den ViewModels kann ich nun die Collections als Abhängigkeiten benutzen und durch DI auflösen lassen.

public sealed class StorageService : IStorageService {
  private readonly ILiteCollection<MyModel> _myRepo;
  
  public StorageService(ILiteCollection<MyModel> myRepo) {
    _myRepo = myRepo;
  }
}

Ohne Dependecy Injection (DI) mit Lazy

Ohne DI, kann die private statische Instanz _liteDatabase als Eigenschaft mit Lazy<>-Initialisierer umgesetzt werden, so dass man aus den ViewModels über dieses Property auf die Datenbank zugreifen kann.

/* App.xaml.cs */

// Threadsichere Singleton-Implementierung mit Lazy
private static Lazy<ILiteDatabase> _db =
  new Lazy<ILiteDatabase>(CreateDatabase, LazyThreadSafetyMode.PublicationOnly);

private static ILiteDatabase CreateDatabase() {
  // Datenbank initialisieren
  ...  
}

// Eigenschaft für den Zugriff
public static ILiteDatabase DB => _db.Value;

Der Zugriff erfolgt nun über die statische Eigenschaft in der App-Klasse.

public sealed class MyViewModel : ObservableModel {
  private readonly ILiteCollection<MyModel> _myRepo_;
  
  public MyViewModel() {
    _myRepo = App.DB.GetCollection<MyModel>();
  }
}

Bekannte Probleme

Xamarin bietet leider keine Möglichkeit zu erkennen, dass die App beendet (nicht nur in Hintergrund verschoben) wurde. Das ist aber die Einschränkung der mobilen Systeme, die eine App im Hintergrund jederzeit ohne Vorwarnung beenden können.

Durch dieses Verhalten wird die Instanz (Singleton) aus dem DI nicht richtig entfernt (Dispose wird nicht aufgerufen). Bei einem Neustart der App wird die Datenbank neu registriert, der Zugriff scheitert aber, da im Direct-Mode nur eine Instanz erlaubt ist.

Dieses Problem umgehe ich, in dem ich eine statische Variable in App.xaml.cs für die Datenbank definiere und bei der Registrierung diese prüfe. Eine bessere Lösung habe ich dazu noch nicht gefunden.

Wenn es dazu einen besseren Weg gibt, bitte um Kommentare unten.