ObjectRepository — vzor repository v paměti .NET pro vaše domácí projekty

Proč uchovávat všechna data v paměti?

Pro ukládání dat webu nebo backendu by většina rozumných lidí nejprve zvolila SQL databázi. 

Ale někdy se objeví myšlenka, že datový model pro SQL nevyhovuje: například při budování vyhledávání nebo sociálního grafu je potřeba hledat v složitých vztazích mezi objekty. 

Nejhorší je situace, když pracujete v týmu a kolega neumí vytvářet rychlé dotazy. Kolik času jste strávili řešením problémů typu N+1 a budováním dodatečných indexů, aby SELECT na hlavní stránce probíhal rozumně?

Dalším populárním přístupem je NoSQL. Před několika lety byl kolem této tématiky velký humbuk — pro jakoukoli příležitost se rozbíhal MongoDB a radovali jsme se z odpovědí ve formátu json-dokumentů. (mimochodem, kolik záplat bylo potřeba vložit kvůli cyklickým odkazům v dokumentech?).

Navrhuji vyzkoušet ještě jeden alternativní způsob — proč nezkusit uchovávat všechna data v paměti aplikace a občas je ukládat do libovolného úložiště (soubor, vzdálená databáze)? 

Paměť se stala levnou a všechna možná data většiny malých a středních projektů se vejdou do 1 GB paměti. (Například můj oblíbený domácí projekt — finanční tracker, který sleduje denní statistiku a historii mých výdajů, zůstatků a transakcí za posledních 18 měsíců, spotřebovává pouze 45 MB paměti.)

Plusy:

  • Přístup k datům se stává jednodušším — není třeba se starat o dotazy, lenivé načítání, zvláštnosti ORM, práce se provádí s běžnými objekty C#;
  • Není problém s přístupem z různých vláken;
  • Velmi rychlé — žádné síťové dotazy, žádná translace kódu do dotazovacího jazyka, není potřeba (de)serializace objektů;
  • Je přípustné ukládat data v jakékoli podobě — ať už v XML na disku, v SQL Serveru nebo v Azure Table Storage.

Nevýhody:

  • Ztrácí se horizontální škálovatelnost a následně není možné provádět nasazení bez výpadků;
  • Pokud aplikace spadne — můžete částečně ztratit data. (Ale naše aplikace nikdy nepadá, že?)

Jak to funguje?

Algoritmus je následující:

  • Na začátku se navazuje spojení s úložištěm dat a probíhá načítání dat;
  • Buduje se objektový model, primární indexy a indexy vztahů (1:1, 1:n);
  • Vytváří se předplatné na změny vlastností objektů (INotifyPropertyChanged) a na přidání nebo odstranění prvků v kolekci (INotifyCollectionChanged);
  • Při spuštění předplatného se změněný objekt přidá do fronty na zápis do úložiště dat;
  • Periodicky (na základě časovače) se na pozadí ukládají změny do úložiště;
  • Při zavření aplikace se také ukládají změny do úložiště.

Příklad kódu

Přidáváme potřebné závislosti

// Основная библиотека
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 datový model, který bude ukládán v úložišti

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

Poté objektový model:

public class ParentModel : ModelBase
{
    public ParentModel(ParentEntity entity)
    {
        Entity = entity;
    }
    
    public ParentModel()
    {
        Entity = new ParentEntity(Guid.NewGuid());
    }
    
    public Guid? NullableId => null;
    
    // Příklad vazby 1:Many
    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
    }
    
    // Přístup s vyhledáváním podle indexu
    public ParentModel Parent => Single(ParentId);
    
    protected override BaseEntity Entity => _childEntity;
}

A nakonec samotná třída-repozitář pro přístup k datům:

public class MyObjectRepository : ObjectRepositoryBase
{
    public MyObjectRepository(IStorage storage) : base(storage, NullLogger.Instance)
    {
        IsReadOnly = true; // Pro testy, umožňuje neukládat změny do databáze
    
        AddType((ParentEntity x) => new ParentModel(x));
        AddType((ChildEntity x) => new ChildModel(x));
    
        // Pokud se používá Hangfire a je potřeba uchovávat model dat pro Hangfire v ObjectRepository
        // this.RegisterHangfireScheme();  
    
        Initialize();
    }
}

Vytváříme exemplář ObjectRepository:

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

Pokud bude v projektu používán HangFire

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

Vložení nového objektu:

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

Při tomto volání objekt ParentModel je přidán jak do místní cache, tak do fronty zápisu do databáze. Tato operace tedy trvá O(1) a s tímto objektem lze okamžitě pracovat.

Například, abyste našli tento objekt v repozitáři a ujistili se, že vrácený objekt je tím samým příkladem:

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

Co se při tom děje? Set() vrací TableDictionary, který obsahuje ConcurrentDictionary a poskytuje další funkcionalitu primárních a sekundárních indexů. To umožňuje mít metody pro vyhledávání podle Id (nebo jiných libovolných uživatelských indexů) bez úplného prohledávání všech objektů.

Při přidávání objektů do ObjectRepository se přidává předplatné na změnu jejich vlastností, takže každá změna vlastností také vede k přidání tohoto objektu do fronty zápisu. 
Aktualizace vlastností zvenčí vypadá stejně, jako práce s POCO objektem:

myParent.Children.First().Property = "Aktualizovaná hodnota";

Objekt lze odstranit následujícími způsoby:

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

Přitom také dochází k přidání objektu do fronty na odstranění.

Jak funguje ukládání?

ObjectRepository při změně sledovaných objektů (jak přidání nebo odstranění, tak i změna vlastností) vyvolává událost ModelChanged, na kterou je přihlášeno IStorage. Realizace IStorage při vzniku události ModelChanged shromažďují změny do 3 front — na přidání, na aktualizaci a na odstranění.

Také realizace IStorage při inicializaci vytvářejí časovač, který každých 5 sekund vyvolává ukládání změn. 

Kromě toho existuje API pro nucené vyvolání ukládání: ObjectRepository.Save().

Před každým uložením se nejprve odstraňují z front bezvýznamné operace (například duplicity událostí — když byl objekt měněn dvakrát nebo rychlé přidání/odstranění objektů), a až poté samotné ukládání. 

Ve všech případech se ukládá aktuální objekt vcelku, takže může nastat situace, kdy jsou objekty ukládány v jiném pořadí, než měnily, a také mohou být ukládány novější verze objektů, než v době přidání do fronty.

Co dalšího existuje?

  • Všechny knihovny jsou založeny na .NET Standard 2.0. Lze je použít v jakémkoli moderním .NET projektu.
  • API je vlákno bezpečné. Interní kolekce jsou implementovány na základě ConcurrentDictionary, událostní zpracovatelé mají buď zámky, nebo je nepotřebují. 
    Jedna věc, na kterou je třeba pamatovat — při ukončení aplikace zavolejte ObjectRepository.Save();
  • Libovolné indexy (vyžadují jedinečnost):

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

Kdo to používá?

Osobně jsem začal používat tento přístup ve všech svých hobby projektech, protože je to pohodlné a nevyžaduje velké náklady na psaní vrstvy přístupu k datům nebo nasazení těžké infrastruktury. Osobně mi většinou stačí ukládání dat v litedb nebo v souboru. 

Ale v minulosti, když jsme s týmem dělali nyní neexistující startup EscapeTeams (myslel jsem, že to jsou peníze — ale ne, zase zkušenost) — používali jsme Azure Table Storage pro ukládání dat.

Plány do budoucna

Chci opravit jednu z hlavních nevýhod tohoto přístupu — horizontální škálovatelnost. K tomu jsou potřeba buď distribuované transakce (sic!), nebo učinit rozhodnutí, že stejné údaje z různých instancí se nesmí měnit, nebo ať se mění podle principu "kdo poslední, ten vítězí".

Z technického hlediska vidím jako možný následující schéma:

  • Ukládat místo objektového modelu EventLog a Snapshot
  • Najít jiné instance (přidat do nastavení koncové body všech instancí? udp objev? master/slave?)
  • Replikovat mezi instancemi EventLog prostřednictvím jakéhokoli algoritmu konsensu, například RAFT.

Také existuje ještě jeden problém, který mě znepokojuje — je to kaskádové mazání nebo odhalování případů mazání objektů, na které jsou odkazy z jiných objektů. 

Zdrojový kód

Pokud jste dočetli až sem — zbývá už pouze přečíst kód, ten lze najít na GitHubu:
https://github.com/DiverOfDark/ObjectRepository

Zdroj: habr.com

Koupit spolehlivý hosting pro webové stránky s ochranou proti DDoS, VPS VDS servery 🔥 Koupit spolehlivý hosting pro webové stránky s ochranou proti DDoS, VPS VDS servery | ProHoster