ObjectRepository – vzor úložiska .NET v pamäti pre vaše domáce projekty

Prečo ukladať všetky údaje do pamäte?

Na ukladanie webových stránok alebo backendových údajov bude prvou túžbou väčšiny rozumných ľudí vybrať si databázu SQL. 

Niekedy však prichádza na myseľ myšlienka, že dátový model nie je vhodný pre SQL: napríklad pri vytváraní vyhľadávacieho alebo sociálneho grafu musíte hľadať zložité vzťahy medzi objektmi. 

Najhoršia situácia je, keď pracujete v tíme a kolega nevie zostaviť rýchle dopyty. Koľko času ste strávili riešením problémov N+1 a budovaním ďalších indexov, aby sa SELECT na hlavnej stránke dokončil za primeraný čas?

Ďalším populárnym prístupom je NoSQL. Pred niekoľkými rokmi bol okolo tejto témy veľký humbuk - na každú vhodnú príležitosť nasadili MongoDB a boli spokojní s odpoveďami vo forme dokumentov json (mimochodom, koľko bariel ste museli vložiť kvôli kruhovým odkazom v dokumentoch?).

Navrhujem vyskúšať inú, alternatívnu metódu - prečo neskúsiť uložiť všetky údaje do pamäte aplikácie a pravidelne ich ukladať na náhodné úložisko (súbor, vzdialená databáza)? 

Pamäť zlacnela a všetky možné dáta pre väčšinu malých a stredne veľkých projektov sa zmestia do 1 GB pamäte. (Napríklad môj obľúbený domáci projekt je finančný sledovač, ktorá vedie denné štatistiky a históriu mojich výdavkov, zostatkov a transakcií rok a pol, spotrebuje iba 45 MB pamäte.)

Pros:

  • Prístup k údajom je jednoduchší – nemusíte sa obávať dopytov, lenivého načítavania, funkcií ORM, pracujete s bežnými objektmi C#;
  • Neexistujú žiadne problémy spojené s prístupom z rôznych vlákien;
  • Veľmi rýchly – žiadne sieťové požiadavky, žiadny preklad kódu do dopytovacieho jazyka, žiadna potreba (de)serializácie objektov;
  • Dáta je prijateľné ukladať v akejkoľvek forme – či už vo formáte XML na disku, alebo v SQL Serveri, alebo v Azure Table Storage.

Nevýhody:

  • Horizontálne škálovanie sa stratí a v dôsledku toho nie je možné vykonať nasadenie s nulovými prestojmi;
  • Ak aplikácia spadne, môžete čiastočne stratiť dáta. (Ale naša aplikácia nikdy nepadne, však?)

Ako to funguje?

Algoritmus je nasledujúci:

  • Na začiatku sa vytvorí spojenie s dátovým úložiskom a načítajú sa dáta;
  • Objektový model, primárne indexy a relačné indexy (1:1, 1:Many) sú zostavené;
  • Predplatné sa vytvorí pre zmeny vlastností objektu (INotifyPropertyChanged) a pre pridávanie alebo odstraňovanie prvkov do kolekcie (INotifyCollectionChanged);
  • Keď sa predplatné spustí, zmenený objekt sa pridá do frontu na zápis do dátového úložiska;
  • Zmeny v úložisku sa ukladajú pravidelne (na časovači) vo vlákne na pozadí;
  • Po ukončení aplikácie sa zmeny uložia aj do úložiska.

Príklad kódu

Pridanie potrebných závislostí

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

Popisujeme dátový model, ktorý bude uložený v úložisku

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

Potom objektový model:

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

A nakoniec samotná trieda úložiska na prístup k údajom:

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

Vytvorte inštanciu ObjectRepository:

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

Ak projekt bude používať HangFire

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

Vloženie nového objektu:

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

S týmto volaním objekt ParentModel sa pridá do lokálnej vyrovnávacej pamäte aj do frontu na zápis do databázy. Preto táto operácia trvá O(1) a s týmto objektom sa dá okamžite pracovať.

Ak chcete napríklad nájsť tento objekt v úložisku a overiť, že vrátený objekt je tá istá inštancia:

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

Čo sa stane? Set () sa vracia TableDictionary, ktorý obsahuje ConcurrentDictionary a poskytuje dodatočnú funkčnosť primárnych a sekundárnych indexov. To vám umožňuje mať metódy na vyhľadávanie podľa Id (alebo iných ľubovoľných užívateľských indexov) bez úplného opakovania všetkých objektov.

Pri pridávaní objektov do ObjectRepository predplatné sa pridá, aby sa zmenili ich vlastnosti, takže každá zmena vlastností má za následok aj pridanie tohto objektu do frontu zápisu. 
Aktualizácia vlastností zvonku vyzerá rovnako ako práca s objektom POCO:

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

Objekt môžete odstrániť nasledujúcimi spôsobmi:

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

Toto tiež pridá objekt do frontu na odstránenie.

Ako funguje šetrenie?

ObjectRepository keď sa monitorované objekty zmenia (buď pridanie alebo vymazanie, alebo zmena vlastností), vyvolá udalosť ModelZmenenýprihlásený na odber Úložisko. Implementácie Úložisko keď dôjde k udalosti ModelZmenený zmeny sú zaradené do 3 radov - na pridávanie, na aktualizáciu a na mazanie.

Aj implementácie Úložisko pri inicializácii vytvoria časovač, ktorý spôsobí, že sa zmeny uložia každých 5 sekúnd. 

Okrem toho existuje rozhranie API na vynútenie volania uloženia: ObjectRepository.Save().

Pred každým uložením sa najskôr z radov odstránia nezmyselné operácie (napríklad duplicitné udalosti – keď bol objekt dvakrát zmenený alebo rýchlo pridané/odstránené objekty) a až potom samotné uloženie. 

Vo všetkých prípadoch sa uloží celý aktuálny objekt, takže je možné, že sa objekty uložia v inom poradí, ako boli zmenené, vrátane novších verzií objektov, ako v čase, keď boli pridané do frontu.

čo tam ešte je?

  • Všetky knižnice sú založené na .NET Standard 2.0. Môže byť použitý v akomkoľvek modernom .NET projekte.
  • Rozhranie API je bezpečné pre vlákna. Interné kolekcie sa realizujú na základe ConcurrentDictionary, obslužné programy udalostí buď zámky majú, alebo ich nepotrebujú. 
    Jediné, čo stojí za to pamätať, je zavolať ObjectRepository.Save();
  • Ľubovoľné indexy (vyžadujú jedinečnosť):

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

Kto to používa?

Osobne som tento prístup začal používať vo všetkých hobby projektoch, pretože je pohodlný a nevyžaduje veľké náklady na písanie vrstvy prístupu k dátam alebo nasadzovanie ťažkej infraštruktúry. Mne osobne väčšinou stačí ukladanie dát do litedb alebo do súboru. 

Ale v minulosti, keď dnes už neexistujúci startup EscapeTeams (Myslel som, že tu sú, peniaze - ale nie, opäť skúsenosti) – slúži na ukladanie údajov v Azure Table Storage.

Plány do budúcnosti

Chcel by som opraviť jednu z hlavných nevýhod tohto prístupu - horizontálne škálovanie. Na to potrebujete buď distribuované transakcie (sic!), alebo sa rázne rozhodnúť, že rovnaké údaje z rôznych inštancií by sa nemali meniť, alebo ich nechať zmeniť podľa zásady „kto je posledný, má pravdu“.

Z technického hľadiska považujem za možnú nasledujúcu schému:

  • Uložte EventLog a Snapshot namiesto objektového modelu
  • Nájdite ďalšie inštancie (pridajte koncové body všetkých inštancií do nastavení? udp discovery? master/slave?)
  • Replikujte medzi inštanciami EventLog pomocou ľubovoľného konsenzuálneho algoritmu, ako je napríklad RAFT.

Znepokojuje ma aj ďalší problém – kaskádové mazanie, alebo zisťovanie prípadov vymazania objektov, ktoré majú odkazy z iných objektov. 

Zdrojový kód

Ak ste sa dočítali až sem, ostáva už len prečítať si kód; nájdete ho na GitHub:
https://github.com/DiverOfDark/ObjectRepository

Zdroj: hab.com

Pridať komentár