ObjectRepository — .NET In-Memory-Repository-Muster für Ihre Home-Projekte

Warum alle Daten im Speicher speichern?

Für die Speicherung von Website- oder Backend-Daten wird die Mehrheit der gesunden Menschen SQL-Datenbanken bevorzugen. 

Doch manchmal kommt einem der Gedanke, dass das Datenmodell nicht zu SQL passt: Zum Beispiel benötigt man bei der Erstellung von Suchanfragen oder sozialen Graphen die Suche nach komplexen Beziehungen zwischen Objekten. 

Am ärgerlichsten ist die Situation, wenn man im Team arbeitet und ein Kollege keine schnellen Abfragen erstellen kann. Wie viel Zeit haben Sie damit verbracht, die Probleme N+1 zu lösen und zusätzliche Indizes zu erstellen, damit das SELECT auf der Hauptseite in angemessener Zeit funktioniert?

Ein anderer beliebter Ansatz ist NoSQL. Vor einigen Jahren gab es viel Hype um dieses Thema – bei jeder geeigneten Gelegenheit wurde MongoDB implementiert und man freute sich über die Antworten in Form von JSON-Dokumenten. (Übrigens, wie viele Notlösungen mussten wegen zirkulärer Verweise in den Dokumenten eingeführt werden?).

Ich schlage vor, einen weiteren alternativen Ansatz auszuprobieren – warum nicht versuchen, alle Daten im Anwendungsspeicher zu halten und sie gelegentlich in einen beliebigen Speicher (Datei, entfernte Datenbank) zu speichern? 

Der Speicher ist günstig geworden, und alle möglichen Daten der meisten kleinen und mittleren Projekte passen in 1 GB RAM. (Zum Beispiel mein Lieblings-Heimprojekt – ein Finanztracker, der tägliche Statistiken und die Historie meiner Ausgaben, Salden und Transaktionen über anderthalb Jahre hinweg führt, benötigt nur 45 MB Speicher.)

Vorteile:

  • Der Zugriff auf Daten wird einfacher – man muss sich keine Gedanken über Abfragen, Lazy Loading, Besonderheiten von ORM machen; die Arbeit erfolgt mit normalen C#-Objekten;
  • Es gibt keine Probleme mit dem Zugriff aus verschiedenen Threads;
  • Sehr schnell – es gibt keine Netzwerkabfragen, es entfällt die Übersetzung des Codes in eine Abfragesprache, es ist keine (De-)Serialisierung von Objekten erforderlich;
  • Es ist erlaubt, Daten in beliebiger Form zu speichern – sei es in XML auf der Festplatte, in SQL Server oder in Azure Table Storage.

Nachteile:

  • Horizontale Skalierung geht verloren, und infolgedessen ist ein Zero-Downtime-Deployment nicht möglich;
  • Wenn die Anwendung abstürzt, können Daten teilweise verloren gehen. (Aber unser Anwendung stürzt doch nie ab, oder?)

Wie funktioniert das?

Der Algorithmus ist wie folgt:

  • Beim Start wird eine Verbindung zum Datenspeicher aufgebaut, und die Daten werden geladen;
  • Ein Objektmodell, primäre Indizes und Beziehungsindizes (1:1, 1:Viele) werden erstellt;
  • Ein Abonnement für Änderungen an den Eigenschaften von Objekten (INotifyPropertyChanged) sowie für das Hinzufügen oder Entfernen von Elementen in einer Sammlung (INotifyCollectionChanged) wird erstellt;
  • Wenn das Abonnement ausgelöst wird, wird das geänderte Objekt in die Warteschlange zum Schreiben in den Datenspeicher aufgenommen;
  • Periodisch (über einen Timer) werden die Änderungen im Hintergrundspeicher gespeichert;
  • Beim Verlassen der Anwendung werden ebenfalls die Änderungen im Speicher gesichert.

Beispielcode

Notwendige Abhängigkeiten hinzufügen

// Основная библиотека
Install-Package OutCode.EscapeTeams.ObjectRepository
    
// Хранилище данных, в котором будут сохраняться изменения
// Используйте то, которым будете пользоваться.
Install-Package OutCode.EscapeTeams.ObjectRepository.File
Install-Package OutCode.EscapeTeams.ObjectRepository.LiteDb
Install-Package OutCode.EscapeTeams.ObjectRepository.AzureTableStorage
    
// Опционально - если нужно хранить модель данных для Hangfire
// Install-Package OutCode.EscapeTeams.ObjectRepository.Hangfire

Das Datenmodell beschreiben, das im Speicher gespeichert werden soll

public class ParentEntity : BaseEntity
{
    public ParentEntity(Guid id) => Id = id;
}
    
public class ChildEntity : BaseEntity
{
    public ChildEntity(Guid id) => Id = id;
    public Guid ParentId { get; set; }
    public string Value { get; set; }
}

Dann das objekthafte Modell:

public class ParentModel : ModelBase
{
    public ParentModel(ParentEntity entity)
    {
        Entity = entity;
    }
    
    public ParentModel()
    {
        Entity = new ParentEntity(Guid.NewGuid());
    }
    
    public Guid? NullableId => null;
    
    // Beispiel einer 1:n Beziehung
    public IEnumerable Children => Multiple(x => x.ParentId);
    
    protected override BaseEntity Entity { get; }
}
    
public class ChildModel : ModelBase
{
    private ChildEntity _childEntity;
    
    public ChildModel(ChildEntity entity)
    {
        _childEntity = entity;
    }
    
    public ChildModel() 
    {
        _childEntity = new ChildEntity(Guid.NewGuid());
    }
    
    public Guid ParentId
    {
        get => _childEntity.ParentId;
        set => UpdateProperty(() => _childEntity.ParentId, value);
    }
    
    public string Value
    {
        get => _childEntity.Value;
        set => UpdateProperty(() => _childEntity.Value, value);
    }
    
    // Zugriff mit Indexsuche
    public ParentModel Parent => Single(ParentId);
    
    protected override BaseEntity Entity => _childEntity;
}

Und schließlich die Repository-Klasse für den Datenzugriff:

public class MyObjectRepository : ObjectRepositoryBase
{
    public MyObjectRepository(IStorage storage) : base(storage, NullLogger.Instance)
    {
        IsReadOnly = true; // Für Tests, ermöglicht das Nicht-Speichern von Änderungen in der Datenbank
    
        AddType((ParentEntity x) => new ParentModel(x));
        AddType((ChildEntity x) => new ChildModel(x));
    
        // Wenn Hangfire verwendet wird und das Datenmodell für Hangfire im ObjectRepository gespeichert werden muss
        // this.RegisterHangfireScheme();
    
        Initialize();
    }
}

Ein Objekt des ObjectRepository erstellen:

var memory = new MemoryStream();
var db = new LiteDatabase(memory);
var dbStorage = new LiteDbStorage(db);
    
var repository = new MyObjectRepository(dbStorage);
await repository.WaitForInitialize();

Wenn im Projekt HangFire verwendet wird

public void ConfigureServices(IServiceCollection services, ObjectRepository objectRepository)
{
    services.AddHangfire(s => s.UseHangfireStorage(objectRepository));
}

Einfügen eines neuen Objekts:

var newParent = new ParentModel()
repository.Add(newParent);

Bei diesem Aufruf wird das Objekt ParentModel dem lokalen Cache sowie der Warteschlange für die Datenbankeintragung hinzugefügt. Daher hat dieser Vorgang eine Zeitkomplexität von O(1), und mit diesem Objekt kann sofort gearbeitet werden.

Zum Beispiel, um dieses Objekt im Repository zu finden und sicherzustellen, dass das zurückgegebene Objekt dasselbe Exemplar ist:

var parents = repository.Set();
var myParent = parents.Find(newParent.Id);
Assert.IsTrue(ReferenceEquals(myParent, newParent));

Was passiert dabei? Set() gibt zurück TableDictionary, das in sich enthält ConcurrentDictionary und zusätzliche Funktionen für primäre und sekundäre Indizes bereitstellt. Dies ermöglicht Methoden zur Suche nach Id (oder anderen beliebigen benutzerdefinierten Indizes), ohne alle Objekte vollständig durchlaufen zu müssen.

Wenn Objekte in ObjectRepository eingefügt werden, wird ein Abonnement für Änderungen ihrer Eigenschaften hinzugefügt, sodass jede Änderung der Eigenschaften ebenfalls zur Hinzufügung dieses Objekts in die Warteschlange für die Speicherung führt. 
Das Aktualisieren der Eigenschaften von außen sieht genauso aus wie das Arbeiten mit einem POCO-Objekt:

myParent.Children.First().Property = "Aktualisierter Wert";

Ein Objekt kann auf folgende Weisen entfernt werden:

repository.Remove(myParent);
repository.RemoveRange(otherParents);
repository.Remove(x => !x.Children.Any());

Dabei wird auch das Objekt zur Löschwarteschlange hinzugefügt.

Wie funktioniert die Speicherung?

ObjectRepository Bei Änderungen der verfolgten Objekte (sowohl beim Hinzufügen oder Entfernen als auch beim Ändern von Eigenschaften) wird ein Ereignis ausgelöst ModelChanged, auf das IStorageabonnieren. Implementierungen IStorage speichern bei Auftreten des Ereignisses die Änderungen in 3 Warteschlangen - für Hinzufügungen, Aktualisierungen und Löschungen. ModelChanged Außerdem erstellen die Implementierungen bei der Initialisierung einen Timer, der alle 5 Sekunden die Speicherung der Änderungen auslöst.

Zudem gibt es ein API für den erzwungenen Speichervorgang: IStorage ObjectRepository.Save() 

Vor jeder Speicherung erfolgt zuerst die Bereinigung der Warteschlangen von sinnlosen Operationen (z. B. doppelte Ereignisse – wenn ein Objekt zweimal geändert wurde oder durch schnelles Hinzufügen/Löschen von Objekten), und erst dann erfolgt die eigentliche Speicherung. In allen Fällen wird das aktuelle Objekt vollständig gespeichert, daher kann es vorkommen, dass Objekte in einer anderen Reihenfolge gespeichert werden, als sie geändert wurden, einschließlich der Möglichkeit, dass neuere Versionen von Objekten gespeichert werden als zu dem Zeitpunkt, als sie in die Warteschlange eingeordnet wurden..

Was gibt es noch? 

Alle Bibliotheken basieren auf .NET Standard 2.0. Können in jedem modernen .NET-Projekt verwendet werden.

Was gibt es sonst noch?

  • Alle Bibliotheken basieren auf .NET Standard 2.0. Sie können in jedem modernen .NET-Projekt verwendet werden.
  • Die API ist thread-sicher. Interne Sammlungen sind auf ConcurrentDictionaryimplementiert, und die Ereignisbehandler benötigen entweder Sperren oder benötigen sie nicht. 
    Das Einzige, woran man denken sollte, ist, dass beim Beenden der Anwendung ObjectRepository.Save(); aufgerufen werden sollte.
  • Beliebige Indizes (erfordern Einzigartigkeit):

repository.Set<ChildModel>().AddIndex(x => x.Value);
repository.Set<ChildModel>().Find(x => x.Value, "myValue");

Wer verwendet das?

Ich persönlich habe begonnen, diesen Ansatz in allen Hobbyprojekten zu verwenden, da er bequem ist und keine großen Ausgaben für das Schreiben einer Datenzugriffsschicht oder für den Aufbau schwerer Infrastruktur erfordert. Für mich reicht es in der Regel aus, die Daten in litedb oder in einer Datei zu speichern. 

Doch in der Vergangenheit, als ich mit dem Team das mittlerweile eingestellte Start-up EscapeTeams gemacht habe (dachte ich, das sind sie, das Geld — aber nein, es war wieder Erfahrung) — haben wir Azure Table Storage zur Speicherung der Daten verwendet.

Zukunftspläne

Ich möchte einen der Hauptnachteile dieses Ansatzes beheben — die horizontale Skalierung. Dafür sind entweder verteilte Transaktionen erforderlich (sic!), oder man muss eine Entscheidung treffen, dass die gleichen Daten aus verschiedenen Instanzen sich nicht ändern dürfen, oder sie dürfen sich nach dem Prinzip "wer zuletzt kommt, mahlt zuletzt" ändern.

Aus technischer Sicht sehe ich die folgende Möglichkeit:

  • Statt eines Objektmodells EventLog und Snapshot speichern
  • Andere Instanzen finden (sollen die Endpunkte aller Instanzen in den Einstellungen hinzugefügt werden? UDP-Discovery? Master/Slave?)
  • EventLog zwischen den Instanzen über einen Konsensalgorithmus replizieren, zum Beispiel RAFT.

Es gibt außerdem ein weiteres Problem, das mir Sorgen bereitet — das kaskadierte Löschen oder das Erkennen von Fällen, in denen Objekte, auf die aus anderen Objekten verwiesen wird, gelöscht werden. 

Quellcode

Wenn Sie bis hierher gelesen haben — dann bleibt nur noch der Code zu lesen, den Sie auf GitHub finden können:
https://github.com/DiverOfDark/ObjectRepository

Quelle: habr.com

60GB SSD 8Gb DDR4