ObjectRepository - .NET in-memory repository pattern para sa iyong mga proyekto sa bahay

Bakit iimbak ang lahat ng data sa memorya?

Upang mag-imbak ng data ng website o backend, ang unang pagnanais ng karamihan sa mga taong matino ay pumili ng isang database ng SQL. 

Ngunit kung minsan ang pag-iisip ay naiisip na ang modelo ng data ay hindi angkop para sa SQL: halimbawa, kapag bumubuo ng isang paghahanap o social graph, kailangan mong maghanap ng mga kumplikadong relasyon sa pagitan ng mga bagay. 

Ang pinakamasamang sitwasyon ay kapag nagtatrabaho ka sa isang koponan at ang isang kasamahan ay hindi alam kung paano bumuo ng mabilis na mga query. Ilang oras ang ginugol mo sa paglutas ng mga problema sa N+1 at pagbuo ng mga karagdagang index upang ang SELECT sa pangunahing pahina ay makumpleto sa isang makatwirang tagal ng panahon?

Ang isa pang tanyag na diskarte ay ang NoSQL. Ilang taon na ang nakalilipas, nagkaroon ng maraming hype sa paksang ito - para sa anumang maginhawang okasyon, nag-deploy sila ng MongoDB at masaya sa mga sagot sa anyo ng mga dokumento ng json (nga pala, ilang saklay ang kailangan mong ipasok dahil sa mga circular link sa mga dokumento?).

Iminumungkahi kong subukan ang isa pang alternatibong paraan - bakit hindi subukang iimbak ang lahat ng data sa memorya ng application, pana-panahong i-save ito sa random na imbakan (file, remote database)? 

Ang memorya ay naging mura, at anumang posibleng data para sa karamihan ng maliliit at katamtamang laki ng mga proyekto ay magkakasya sa 1 GB ng memorya. (Halimbawa, ang paborito kong proyekto sa bahay ay tagasubaybay ng pananalapi, na nagpapanatili ng mga pang-araw-araw na istatistika at kasaysayan ng aking mga gastos, balanse, at transaksyon sa loob ng isang taon at kalahati, kumokonsumo lamang ng 45 MB ng memorya.)

Pros:

  • Nagiging mas madali ang pag-access sa data - hindi mo kailangang mag-alala tungkol sa mga query, tamad na pag-load, mga feature ng ORM, nagtatrabaho ka sa mga ordinaryong C# object;
  • Walang mga problemang nauugnay sa pag-access mula sa iba't ibang mga thread;
  • Napakabilis - walang mga kahilingan sa network, walang pagsasalin ng code sa isang wika ng query, hindi na kailangan para sa (de)serialization ng mga bagay;
  • Ito ay katanggap-tanggap na mag-imbak ng data sa anumang anyo - maging ito sa XML sa disk, o sa SQL Server, o sa Azure Table Storage.

Cons:

  • Nawala ang pahalang na pag-scale, at bilang resulta, hindi magagawa ang zero downtime deployment;
  • Kung nag-crash ang application, maaari kang bahagyang mawalan ng data. (Ngunit hindi kailanman nag-crash ang aming application, tama ba?)

Paano ito gumagana?

Ang algorithm ay ang mga sumusunod:

  • Sa simula, ang isang koneksyon ay itinatag sa imbakan ng data, at ang data ay na-load;
  • Isang object model, primary index, at relational index (1:1, 1:Many) ay binuo;
  • Ang isang subscription ay nilikha para sa mga pagbabago sa mga katangian ng object (INotifyPropertyChanged) at para sa pagdaragdag o pag-alis ng mga elemento sa koleksyon (INotifyCollectionChanged);
  • Kapag ang subscription ay na-trigger, ang binagong bagay ay idinagdag sa pila para sa pagsulat sa imbakan ng data;
  • Ang mga pagbabago sa storage ay pana-panahong nai-save (sa isang timer) sa isang background thread;
  • Kapag lumabas ka sa application, nai-save din ang mga pagbabago sa storage.

Halimbawa ng code

Pagdaragdag ng mga kinakailangang dependency

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

Inilalarawan namin ang modelo ng data na maiimbak sa storage

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

Pagkatapos ang object 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;
}

At sa wakas, ang klase ng repositoryo mismo para sa pag-access ng data:

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

Lumikha ng isang halimbawa ng ObjectRepository:

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

Kung gagamit ng HangFire ang proyekto

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

Pagpasok ng bagong bagay:

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

Sa tawag na ito, ang bagay ParentModel ay idinagdag sa parehong lokal na cache at sa pila para sa pagsulat sa database. Samakatuwid, ang operasyong ito ay tumatagal ng O(1), at ang bagay na ito ay maaaring magamit kaagad.

Halimbawa, upang mahanap ang object na ito sa repository at i-verify na ang ibinalik na object ay ang parehong halimbawa:

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

Anong nangyayari? Itakda () nagbabalik TableDictionary, na naglalaman ng ConcurrentDictionary at nagbibigay ng karagdagang paggana ng pangunahin at pangalawang index. Nagbibigay-daan ito sa iyo na magkaroon ng mga pamamaraan para sa paghahanap sa pamamagitan ng Id (o iba pang mga arbitrary na index ng user) nang hindi ganap na umuulit sa lahat ng mga bagay.

Kapag nagdadagdag ng mga bagay sa ObjectRepository ang isang subscription ay idinagdag upang baguhin ang kanilang mga katangian, kaya ang anumang pagbabago sa mga katangian ay nagreresulta din sa bagay na ito na idinaragdag sa write queue. 
Ang pag-update ng mga katangian mula sa labas ay mukhang katulad ng pagtatrabaho sa isang bagay na POCO:

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

Maaari mong tanggalin ang isang bagay sa mga sumusunod na paraan:

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

Idinaragdag din nito ang bagay sa pila ng pagtanggal.

Paano gumagana ang pagtitipid?

ObjectRepository kapag ang mga sinusubaybayang bagay ay nagbabago (alinman sa pagdaragdag o pagtanggal, o pagpapalit ng mga katangian), nagpapataas ng isang kaganapan Binago ang Modelonaka-subscribe sa Istorage. Mga pagpapatupad Istorage kapag naganap ang isang pangyayari Binago ang Modelo Ang mga pagbabago ay inilalagay sa 3 pila - para sa pagdaragdag, para sa pag-update, at para sa pagtanggal.

Pati mga pagpapatupad Istorage sa pagsisimula, lumikha sila ng isang timer na nagiging sanhi ng mga pagbabago upang ma-save bawat 5 segundo. 

Bilang karagdagan, mayroong isang API upang pilitin ang isang pag-save ng tawag: ObjectRepository.Save().

Bago ang bawat pag-save, inalis muna ang mga walang kabuluhang operasyon sa mga pila (halimbawa, mga duplicate na kaganapan - kapag binago ang isang bagay nang dalawang beses o mabilis na idinagdag/naalis ang mga bagay), at pagkatapos ay ang pag-save mismo. 

Sa lahat ng kaso, ang buong kasalukuyang object ay nai-save, kaya posible na ang mga bagay ay nai-save sa ibang pagkakasunud-sunod kaysa sa mga ito ay binago, kabilang ang mga mas bagong bersyon ng mga bagay kaysa sa oras na sila ay idinagdag sa queue.

Ano pa ang meron?

  • Ang lahat ng mga aklatan ay batay sa .NET Standard 2.0. Maaaring gamitin sa anumang modernong .NET na proyekto.
  • Ang API ay ligtas sa thread. Ang mga panloob na koleksyon ay ipinatupad batay sa ConcurrentDictionary, may mga kandado o hindi kailangan ang mga tagapangasiwa ng kaganapan. 
    Ang tanging bagay na dapat tandaan ay ang tumawag ObjectRepository.Save();
  • Mga di-makatwirang index (nangangailangan ng pagiging natatangi):

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

Sino ang gumagamit nito?

Sa personal, sinimulan kong gamitin ang diskarteng ito sa lahat ng mga proyekto sa libangan dahil ito ay maginhawa at hindi nangangailangan ng malalaking gastos para sa pagsulat ng layer ng pag-access ng data o pag-deploy ng mabibigat na imprastraktura. Sa personal, ang pag-iimbak ng data sa litedb o isang file ay karaniwang sapat para sa akin. 

Ngunit noong nakaraan, kapag ang wala na ngayong startup na EscapeTeams (Akala ko eto na, pera - pero hindi pala, experience ulit) - ginagamit upang mag-imbak ng data sa Azure Table Storage.

Планы на будущее

Gusto kong ayusin ang isa sa mga pangunahing disadvantages ng diskarteng ito - pahalang na scaling. Para magawa ito, kailangan mo ng alinman sa mga distributed transactions (sic!), o gumawa ng malakas na desisyon na hindi dapat magbago ang parehong data mula sa iba't ibang pagkakataon, o hayaan silang magbago ayon sa prinsipyong "sino ang huli ay tama."

Mula sa teknikal na pananaw, nakikita ko ang sumusunod na pamamaraan hangga't maaari:

  • Mag-imbak ng EventLog at Snapshot sa halip na modelo ng object
  • Maghanap ng iba pang mga instance (magdagdag ng mga endpoint ng lahat ng instance sa mga setting? udp discovery? master/slave?)
  • Mag-replicate sa pagitan ng EventLog instance sa pamamagitan ng anumang consensus algorithm, gaya ng RAFT.

Mayroon ding isa pang problema na nag-aalala sa akin - pagtanggal ng kaskad, o pagtuklas ng mga kaso ng pagtanggal ng mga bagay na may mga link mula sa iba pang mga bagay. 

Source code

Kung nabasa mo na hanggang dito, ang natitira ay basahin ang code; makikita ito sa GitHub:
https://github.com/DiverOfDark/ObjectRepository

Pinagmulan: www.habr.com

Magdagdag ng komento