ObjectRepository – .NET-In-Memory-Repository-Muster für Ihre Heimprojekte

Warum alle Daten im Speicher speichern?

Um Website- oder Backend-Daten zu speichern, wird der erste Wunsch der meisten vernünftigen Menschen darin bestehen, eine SQL-Datenbank zu wählen. 

Aber manchmal kommt einem der Gedanke in den Sinn, dass das Datenmodell nicht für SQL geeignet ist: Wenn Sie beispielsweise eine Suche oder ein soziales Diagramm erstellen, müssen Sie nach komplexen Beziehungen zwischen Objekten suchen. 

Die schlimmste Situation ist, wenn Sie im Team arbeiten und ein Kollege nicht weiß, wie er schnelle Abfragen aufbaut. Wie viel Zeit haben Sie damit verbracht, N+1-Probleme zu lösen und zusätzliche Indizes zu erstellen, damit die SELECT-Anweisung auf der Hauptseite in angemessener Zeit abgeschlossen werden konnte?

Ein weiterer beliebter Ansatz ist NoSQL. Vor einigen Jahren gab es einen großen Hype um dieses Thema – zu jedem passenden Anlass wurde MongoDB bereitgestellt und war mit den Antworten in Form von JSON-Dokumenten zufrieden (Übrigens, wie viele Krücken mussten Sie aufgrund der kreisförmigen Links in den Dokumenten einführen?).

Ich schlage vor, eine andere, alternative Methode auszuprobieren – warum nicht versuchen, alle Daten im Anwendungsspeicher zu speichern und sie regelmäßig in einem zufälligen Speicher (Datei, Remote-Datenbank) zu speichern? 

Speicher ist billig geworden und alle möglichen Daten für die meisten kleinen und mittelgroßen Projekte passen in 1 GB Speicher. (Mein liebstes Heimprojekt ist zum Beispiel Finanz-Tracker, das anderthalb Jahre lang tägliche Statistiken und den Verlauf meiner Ausgaben, Salden und Transaktionen speichert, verbraucht nur 45 MB Speicher.)

Profis:

  • Der Zugriff auf Daten wird einfacher – Sie müssen sich nicht um Abfragen, verzögertes Laden oder ORM-Funktionen kümmern, sondern arbeiten mit gewöhnlichen C#-Objekten.
  • Es gibt keine Probleme beim Zugriff aus verschiedenen Threads;
  • Sehr schnell – keine Netzwerkanfragen, keine Übersetzung von Code in eine Abfragesprache, keine Notwendigkeit für (De)Serialisierung von Objekten;
  • Es ist akzeptabel, Daten in jeder Form zu speichern – sei es in XML auf der Festplatte, in SQL Server oder in Azure Table Storage.

Nachteile:

  • Die horizontale Skalierung geht verloren und eine Bereitstellung ohne Ausfallzeiten ist daher nicht möglich.
  • Wenn die Anwendung abstürzt, können teilweise Daten verloren gehen. (Aber unsere Anwendung stürzt nie ab, oder?)

Wie funktioniert es?

Der Algorithmus ist der folgende:

  • Zu Beginn wird eine Verbindung zum Datenspeicher hergestellt und Daten geladen;
  • Es werden ein Objektmodell, Primärindizes und relationale Indizes (1:1, 1:Viele) erstellt.
  • Für Änderungen an Objekteigenschaften (INotifyPropertyChanged) und für das Hinzufügen oder Entfernen von Elementen zur Sammlung (INotifyCollectionChanged) wird ein Abonnement erstellt.
  • Wenn das Abonnement ausgelöst wird, wird das geänderte Objekt zur Warteschlange zum Schreiben in den Datenspeicher hinzugefügt.
  • Änderungen am Speicher werden regelmäßig (auf einem Timer) in einem Hintergrundthread gespeichert;
  • Wenn Sie die Anwendung beenden, werden Änderungen auch im Speicher gespeichert.

Codebeispiel

Hinzufügen der erforderlichen Abhängigkeiten

// Основная библиотека
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

Wir beschreiben das Datenmodell, das im Speicher gespeichert wird

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 Objektmodell:

public class ParentModel : ModelBase
{
    public ParentModel(ParentEntity entity)
    {
        Entity = entity;
    }
    
    public ParentModel()
    {
        Entity = new ParentEntity(Guid.NewGuid());
    }
    
    public Guid? NullableId => null;
    
    // Пример связи 1:Many
    public IEnumerable<ChildModel> Children => Multiple<ChildModel>(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
    }
    
    // Доступ с поиском по индексу
    public ParentModel Parent => Single<ParentModel>(ParentId);
    
    protected override BaseEntity Entity => _childEntity;
}

Und schließlich die Repository-Klasse selbst für den Zugriff auf Daten:

public class MyObjectRepository : ObjectRepositoryBase
{
    public MyObjectRepository(IStorage storage) : base(storage, NullLogger.Instance)
    {
        IsReadOnly = true; // Для тестов, позволяет не сохранять изменения в базу
    
        AddType((ParentEntity x) => new ParentModel(x));
        AddType((ChildEntity x) => new ChildModel(x));
    
        // Если используется Hangfire и необходимо хранить модель данных для Hangfire в ObjectRepository
        // this.RegisterHangfireScheme(); 
    
        Initialize();
    }
}

Erstellen Sie eine ObjectRepository-Instanz:

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

Wenn das Projekt HangFire verwendet

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);

Mit diesem Aufruf wird das Objekt ParentModel wird sowohl zum lokalen Cache als auch zur Warteschlange zum Schreiben in die Datenbank hinzugefügt. Daher benötigt diese Operation O(1) und dieses Objekt kann sofort bearbeitet werden.

Um beispielsweise dieses Objekt im Repository zu finden und zu überprüfen, ob es sich bei dem zurückgegebenen Objekt um dieselbe Instanz handelt:

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

Was geschieht? Satz () kehrt zurück Tabellenwörterbuch, was beinhaltet ConcurrentDictionary und bietet zusätzliche Funktionalität von Primär- und Sekundärindizes. Dies ermöglicht Ihnen Methoden zum Suchen nach ID (oder anderen beliebigen Benutzerindizes), ohne alle Objekte vollständig zu durchlaufen.

Beim Hinzufügen von Objekten zu ObjektRepository Es wird ein Abonnement hinzugefügt, um ihre Eigenschaften zu ändern. Daher führt jede Änderung der Eigenschaften auch dazu, dass dieses Objekt zur Schreibwarteschlange hinzugefügt wird. 
Das Aktualisieren von Eigenschaften von außen sieht genauso aus wie das Arbeiten mit einem POCO-Objekt:

myParent.Children.First().Property = "Updated value";

Sie können ein Objekt auf folgende Weise löschen:

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

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

Wie funktioniert Sparen?

ObjektRepository Wenn sich überwachte Objekte ändern (entweder durch Hinzufügen oder Löschen oder durch Ändern von Eigenschaften), wird ein Ereignis ausgelöst Modell geändertAbonniert zu IStorage. Implementierungen IStorage wenn ein Ereignis eintritt Modell geändert Änderungen werden in drei Warteschlangen gestellt – zum Hinzufügen, zum Aktualisieren und zum Löschen.

Auch Implementierungen IStorage Bei der Initialisierung erstellen sie einen Timer, der dafür sorgt, dass Änderungen alle 5 Sekunden gespeichert werden. 

Darüber hinaus gibt es eine API, um einen Speicheraufruf zu erzwingen: ObjectRepository.Save().

Vor jedem Speichern werden zunächst bedeutungslose Vorgänge aus den Warteschlangen entfernt (z. B. doppelte Ereignisse – wenn ein Objekt zweimal geändert wurde oder Objekte schnell hinzugefügt/entfernt wurden) und erst dann das Speichern selbst. 

In allen Fällen wird das gesamte aktuelle Objekt gespeichert. Daher ist es möglich, dass Objekte in einer anderen Reihenfolge gespeichert werden, als sie geändert wurden, einschließlich neuerer Versionen von Objekten als zum Zeitpunkt des Hinzufügens zur Warteschlange.

Was gibt es noch?

  • Alle Bibliotheken basieren auf .NET Standard 2.0. Kann in jedem modernen .NET-Projekt verwendet werden.
  • Die API ist Thread-sicher. Interne Sammlungen werden basierend auf implementiert ConcurrentDictionary, Event-Handler haben entweder Sperren oder benötigen sie nicht. 
    Das Einzige, woran man sich erinnern sollte, ist anzurufen ObjectRepository.Save();
  • Beliebige Indizes (erfordern Eindeutigkeit):

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

Wer nutzt es?

Persönlich habe ich begonnen, diesen Ansatz in allen Hobbyprojekten zu verwenden, weil er praktisch ist und keine großen Kosten für das Schreiben einer Datenzugriffsschicht oder die Bereitstellung einer umfangreichen Infrastruktur erfordert. Mir persönlich reicht es meist aus, Daten in litedb oder einer Datei zu speichern. 

Aber in der Vergangenheit, als das inzwischen aufgelöste Startup EscapeTeams (Ich dachte, hier ist es, Geld – aber nein, wieder Erfahrung) – wird zum Speichern von Daten in Azure Table Storage verwendet.

Pläne für die Zukunft

Ich möchte einen der Hauptnachteile dieses Ansatzes beheben – die horizontale Skalierung. Dazu benötigen Sie entweder verteilte Transaktionen (sic!), oder Sie treffen eine willensstarke Entscheidung, dass sich dieselben Daten aus verschiedenen Instanzen nicht ändern sollen, oder lassen Sie sie nach dem Prinzip „Wer zuletzt ist, hat Recht“ ändern.

Aus technischer Sicht halte ich folgendes Schema für möglich:

  • Speichern Sie EventLog und Snapshot anstelle des Objektmodells
  • Andere Instanzen finden (Endpunkte aller Instanzen zu den Einstellungen hinzufügen? UDP-Erkennung? Master/Slave?)
  • Replizieren Sie zwischen EventLog-Instanzen über einen beliebigen Konsensalgorithmus wie RAFT.

Es gibt noch ein weiteres Problem, das mir Sorgen bereitet – das Kaskadenlöschen oder das Erkennen von Fällen des Löschens von Objekten, die Links zu anderen Objekten haben. 

Quellcode

Wenn Sie bis hierher gelesen haben, müssen Sie nur noch den Code lesen; er ist auf GitHub zu finden:
https://github.com/DiverOfDark/ObjectRepository

Source: habr.com

Kommentar hinzufügen