ObjectRepository — .NET in-memory repository-patroon voor uw thuisprojecten

Waarom alle gegevens in het geheugen opslaan?

Voor het opslaan van gegevens van een website of backend zal de eerste keuze van de meeste verstandige mensen een SQL-database zijn. 

Maar soms komt de gedachte op dat het datamodel niet geschikt is voor SQL: bijvoorbeeld, bij het opbouwen van zoekfunctionaliteit of een sociaal netwerk zijn complexe relaties tussen objecten vereist. 

Het is het slechtste als je in een team werkt en een collega kan geen snelle queries opstellen. Hoeveel tijd heb je besteed aan het oplossen van N+1 problemen en het creëren van extra indexen, zodat de SELECT op de startpagina binnen een redelijke tijd werkt?

Een andere populaire benadering is NoSQL. Enkele jaren geleden was er veel hype rond dit onderwerp — bij elke gelegenheid werd MongoDB uitgerold en was men blij met de antwoorden in de vorm van JSON-documenten. (Overigens, hoeveel tijdelijke oplossingen moesten er worden bedacht vanwege cyclische referenties in documenten?).

Ik stel voor om nog een alternatieve manier te proberen — waarom niet proberen om alle gegevens in het geheugen van de applicatie op te slaan, en deze periodiek in een willekeurige opslag (bestand, externe database) op te slaan? 

Geheugen is goedkoop geworden, en de meeste mogelijke gegevens van kleine en middelgrote projecten passen in 1 GB geheugen. (Bijvoorbeeld, mijn favoriete persoonlijke project — financiële tracker, die dagelijkse statistieken en de geschiedenis van mijn uitgaven, saldi en transacties over anderhalf jaar bijhoudt, verbruikt slechts 45 MB geheugen.)

Voordelen:

  • Toegang tot gegevens wordt eenvoudiger — je hoeft je geen zorgen te maken over queries, lazy loading, de bijzonderheden van ORM, het werk is met gewone C# objecten;
  • Er zijn geen problemen met toegang vanuit verschillende threads;
  • Heel snel — geen netwerkverzoeken, geen codevertaling naar een querytaal, geen (de)serialisatie van objecten nodig;
  • Het is toegestaan om gegevens in elk formaat op te slaan — zelfs in XML op schijf, of in SQL Server, of in Azure Table Storage.

Nadelen:

  • Horizontale schaalbaarheid gaat verloren, en als gevolg daarvan kan je geen zero downtime deployments maken;
  • Als de applicatie crasht, kan er een deel van de gegevens verloren gaan. (Maar onze applicatie crasht toch nooit, toch?)

How does it work?

Het algoritme is als volgt:

  • Bij de start wordt er verbinding gemaakt met de gegevensopslag en worden de gegevens geladen;
  • Er wordt een objectmodel opgebouwd, primaire indexen, en relationele indexen (1:1, 1:Many);
  • Een abonnement wordt aangemaakt voor wijzigingen in objecteigenschappen (INotifyPropertyChanged) en voor het toevoegen of verwijderen van elementen in de collectie (INotifyCollectionChanged);
  • Wanneer het abonnement wordt geactiveerd, wordt het gewijzigde object aan de wachtrij toegevoegd voor opslaan in de gegevensopslag;
  • Periodiek (via een timer) worden in de achtergrond veranderingen opgeslagen in de opslag;
  • Bij het afsluiten van de applicatie worden de wijzigingen ook in de opslag opgeslagen.

Voorbeeldcode

Voeg de benodigde afhankelijkheden toe

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

Beschrijf het gegevensmodel dat in de opslag zal worden bewaard

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

Dan het objectmodel:

public class ParentModel : ModelBase
{
    public ParentModel(ParentEntity entity)
    {
        Entity = entity;
    }
    
    public ParentModel()
    {
        Entity = new ParentEntity(Guid.NewGuid());
    }
    
    public Guid? NullableId => null;
    
    // Voorbeeld van 1:Many relatie
    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
    }
    
    // Toegang met zoeken op index
    public ParentModel Parent => Single(ParentId);
    
    protected override BaseEntity Entity => _childEntity;
}

En tenslotte de repository-klasse voor gegevens toegang:

public class MyObjectRepository : ObjectRepositoryBase
{
    public MyObjectRepository(IStorage storage) : base(storage, NullLogger.Instance)
    {
        IsReadOnly = true; // Voor tests, maakt het mogelijk om wijzigingen niet op te slaan in de database
    
        AddType((ParentEntity x) => new ParentModel(x));
        AddType((ChildEntity x) => new ChildModel(x));
    
        // Als Hangfire wordt gebruikt en de gegevensmodel moet worden opgeslagen voor Hangfire in ObjectRepository
        // this.RegisterHangfireScheme();
    
        Initialize();
    }
}

Maak een instantie van ObjectRepository aan:

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

Als HangFire in het project zal worden gebruikt

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

Invoegen van een nieuw object:

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

Bij deze aanroep wordt het object ParentModel toegevoegd aan zowel de lokale cache als de wachtrij voor database-invoer. Daarom neemt deze bewerking O(1) in beslag, en kan met dit object onmiddellijk worden gewerkt.

Bijvoorbeeld, om dit object in de repository te vinden en te bevestigen dat het teruggekeerde object hetzelfde exemplaar is:

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

Wat gebeurt er hierbij? Set() geeft hij TableDictionary, dat bevat ConcurrentDictionary en biedt extra functionaliteit voor primaire en secundaire indexen. Dit stelt je in staat om methoden te hebben voor het zoeken op Id (of andere willekeurige gebruikersindexen) zonder een volledige doorzoeking van alle objecten.

Bij het toevoegen van objecten aan ObjectRepository wordt een abonnement op de wijziging van hun eigenschappen toegevoegd, dus elke wijziging van de eigenschappen leidt ook tot het toevoegen van dit object aan de wachtrij voor invoer. 
Het bijwerken van eigenschappen van buitenaf ziet er net zo uit als werken met een POCO-object:

myParent.Children.First().Property = "Bijgewerkt waarde";

Een object kan op de volgende manieren worden verwijderd:

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

Bij dit alles wordt ook het object aan de wachtrij voor verwijdering toegevoegd.

Hoe werkt opslaan?

ObjectRepository bij het wijzigen van de gevolgde objecten (zoals toevoegen of verwijderen, evenals het wijzigen van eigenschappen) wordt een gebeurtenis opgeroepen ModelChanged, waaraan is geabonneerd IStorage. Implementaties IStorage bij het optreden van de gebeurtenis ModelChanged stapelen wijzigingen in 3 wachtrijen — voor toevoegen, voor bijwerken en voor verwijderen.

Bovendien creëren implementaties IStorage bij initiatie een timer die elke 5 seconden het opslaan van wijzigingen aanroept. 

Daarnaast bestaat er een API voor het geforceerd aanroepen van opslag: ObjectRepository.Save().

Voor elke opslag worden eerst zinloze bewerkingen uit de wachtrijen verwijderd (bijvoorbeeld duplicaten van gebeurtenissen — wanneer een object twee keer is gewijzigd of snelle toevoegingen/verwijderingen van objecten), en pas daarna vindt de eigenlijke opslag plaats. 

In alle gevallen wordt het actuele object in zijn geheel opgeslagen, dus het is mogelijk dat objecten in een andere volgorde worden opgeslagen dan ze zijn gewijzigd, en zelfs dat nieuwere versies van objecten kunnen worden opgeslagen dan op het moment van toevoeging aan de wachtrij.

Wat is er nog meer?

  • Alle bibliotheken zijn gebaseerd op .NET Standard 2.0. Kan worden gebruikt in elk modern .NET-project.
  • De API is thread-safe. Interne collecties zijn geïmplementeerd op basis van ConcurrentDictionary, event handlers hebben ofwel locking nodig of zijn er niet van afhankelijk. 
    Het enige om te onthouden is dat je bij het afsluiten van de applicatie moet aanroepen ObjectRepository.Save();
  • Arbitraire indexen (vereisen uniciteit):

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

Wie gebruikt dit?

Persoonlijk ben ik deze aanpak in al mijn hobbyprojecten gaan gebruiken, omdat deze handig is en geen grote kosten met zich meebrengt voor het schrijven van een datatoegangslayer of het opzetten van zware infrastructuur. Voor mij is het meestal voldoende om gegevens op te slaan in litedb of in een bestand. 

Maar in het verleden, toen mijn team het inmiddels ter ziele gegane startup EscapeTeams deed (ik dacht hier is geld — maar nee, weer ervaring) — gebruikten we Azure Table Storage voor het opslaan van gegevens.

Toekomstplannen

Ik wil graag een van de belangrijkste nadelen van deze aanpak verhelpen — horizontale schaalbaarheid. Hiervoor zijn ofwel gedistribueerde transacties nodig (sic!), of je moet een moedige beslissing nemen dat dezelfde gegevens uit verschillende instanties niet mogen veranderen, of laat ze veranderen volgens het principe 'wie het laatst komt, heeft gelijk'.

Vanuit technisch oogpunt zie ik de volgende opzet mogelijk:

  • Bewaar in plaats van het objectmodel EventLog en Snapshot
  • Ontdek andere instanties (alle eindpunten van alle instanties aan de instellingen toevoegen? UDP-ontdekking? master/slave?)
  • Repliceer EventLog tussen instanties via een van de consensusalgoritmen, zoals RAFT.

Er is ook nog een ander probleem dat me zorgen baart — dat is cascadeverwijdering of het detecteren van gevallen van verwijdering van objecten waarnaar wordt verwezen vanuit andere objecten. 

Broncode

Als je dit tot hier hebt gelezen — dan hoef je verder alleen nog de code te lezen, die je op GitHub kunt vinden:
https://github.com/DiverOfDark/ObjectRepository

Bron: habr.com

Koop betrouwbare webhosting met bescherming tegen DDoS, VPS VDS servers 🔥 Koop betrouwbare webhosting met bescherming tegen DDoS, VPS VDS servers | ProHoster