ObjectRepository - .NET in-memory repository pattern для вашых хатніх праектаў

Навошта захоўваць усе дадзеныя ў памяці?

Для захоўвання дадзеных сайта або бэкэнда першым жаданнем большасці разважных людзей абярэ SQL базу дадзеных. 

Але часам у галаву прыходзіць думка што мадэль дадзеных не падыходзіць для SQL: напрыклад, пры пабудове пошуку ці сацыяльнага графа патрэбен пошук па складаных сувязях паміж аб'ектамі. 

Горш за ўсё сітуацыя, калі працуеце ў камандзе, і калега не ўмее будаваць хуткія запыты. Колькі часу вы патрацілі на вырашэнне праблем N+1 і на пабудову дадатковых індэксаў, каб SELECT на галоўнай старонцы адпрацоўваў за разумны час?

Іншым папулярным падыходам з'яўляецца NoSQL. Некалькі гадоў таму быў вялікі хайп вакол гэтай тэмы - для любога зручнага выпадку разгортвалі MongoDB і радаваліся адказам у выглядзе json-дакументаў. (дарэчы, колькі мыліц прыйшлося ўставіць з-за цыклічных спасылак у дакументах?).

Я прапаную паспрабаваць яшчэ адзін, альтэрнатыўны спосаб - чаму б не паспрабаваць захоўваць усе дадзеныя ў памяці прыкладання, перыядычна захоўваючы ў адвольнае сховішча (файл, выдаленая база дадзеных)? 

Памяць стала таннай, а любыя магчымыя дадзеныя большасці малых і сярэдніх праектаў улезуць у 1 Гб памяці. (Напрыклад, мой любімы хатні праект - фінансавы трэкер, які вядзе штодзённую статыстыку і гісторыю маіх марнаванняў, балансаў, і транзакцый за паўтара года спажывае ўсяго 45 Мб памяці.)

Плюсы:

  • Доступ да дадзеных становіцца прасцей - не трэба клапаціцца аб запытах, лянівай загрузцы, асаблівасцях ORM, праца адбываецца са звычайнымі C# аб'ектамі;
  • Няма праблем, звязаных з доступам з розных плыняў;
  • Вельмі хутка - няма сеткавых запытаў, адсутнічае трансляцыя кода ў мову запытаў, не патрэбна (дэ)серыялізацыя аб'ектаў;
  • Дапушчальна захоўваць дадзеныя ў любым выглядзе - хоць у XML на дыску, хоць у SQL Server, хоць у Azure Table Storage.

Мінусы:

  • Губляецца гарызантальнае маштабаванне, і як следства нельга зрабіць zero downtime deployment;
  • Калі прыкладанне ўпадзе - можна часткова страціць дадзеныя. (Але ж наша дадатак-то ніколі не падае, праўда?)

Як гэта працуе?

Алгарытм наступны:

  • На старце усталёўваецца злучэнне са сховішчам дадзеных, і адбываецца загрузка дадзеных;
  • Будуецца аб'ектная мадэль, першасныя індэксы, і індэксы адносін (1: 1, 1: Many);
  • Ствараецца падпіска на змены ўласцівасцяў аб'ектаў (INotifyPropertyChanged) і на даданне ці выдаленне элементаў у калекцыю (INotifyCollectionChanged);
  • Пры спрацоўванні падпіскі - які змяніўся аб'ект дадаецца ў чаргу на запіс у сховішча дадзеных;
  • Перыядычна (па таймеры) у фонавым патоку захоўваюцца змены ў сховішча;
  • Пры выхадзе з дадатку таксама захоўваюцца змены ў сховішча.

Прыклад кода

Дадаем неабходныя залежнасці

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

Апісваем мадэль дадзеных, якая будзе захоўвацца ў сховішчы

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

Затым аб'ектную мадэль:

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

І нарэшце сам клас-рэпазітар для доступу да дадзеных:

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

Ствараем асобнік ObjectRepository:

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

Калі ў праекце будзе выкарыстоўвацца HangFire

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

Устаўка новага аб'екта:

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

Пры гэтым выкліку аб'ект ParentModel дадаецца і ў лакальны кэш, і ў чаргу на запіс у базу. Таму гэтая аперацыя займае O(1), і з гэтым аб'ектам можна адразу працаваць.

Напрыклад, каб знайсці гэты аб'ект у рэпазітары і пераканацца што які вярнуўся аб'ект з'яўляецца тым жа асобнікам:

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

Што пры гэтым адбываецца? Set () вяртае TableDictionary, які змяшчае ў сабе ConcurrentDictionary і дае дадатковы функцыянал першасных і другасных індэксаў. Гэта дазваляе мець метады для пошуку па Id (ці іншым адвольным карыстацкім азначнікам) без поўнага перабору ўсіх аб'ектаў.

Пры даданні аб'ектаў у ObjectRepository дадаецца падпіска на змену іх уласцівасцяў, таму любая змена ўласцівасцяў таксама прыводзіць даданню гэтага аб'екта ў чаргу на запіс. 
Абнаўленне ўласцівасцяў звонку выглядае гэтак жа, як і праца з POCO-аб'ектам:

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

Выдаліць аб'ект можна наступнымі спосабамі:

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

Пры гэтым таксама адбываецца даданне аб'екта ў чаргу на выдаленне.

Як працуе захаванне?

ObjectRepository пры змене адсочваных аб'ектаў (як даданне ці выдаленне, так і змена ўласцівасцяў) выклікае падзею ModelChanged, на якое падпісаны IStorage. Рэалізацыі IStorage пры ўзнікненні падзеі ModelChanged складаюць змены ў 3 чарзе - на даданне, на абнаўленне, і на выдаленне.

Таксама рэалізацыі IStorage пры ініцыялізацыі ствараюць таймер, які кожныя 5 секунд выклікае захаванне змен. 

Акрамя таго існуе API для прымусовага выкліку захавання: ObjectRepository.Save().

Перад кожным захаваннем спачатку адбываецца выдаленне з чэргаў бессэнсоўных аперацый (напрыклад дублікаты падзей - калі аб'ект змяняўся двойчы або хуткае даданне/выдаленне аб'ектаў), і толькі потым само захаванне. 

Ва ўсіх выпадках захоўваецца актуальны аб'ект цалкам, таму магчымая сітуацыя, калі аб'екты захоўваюцца ў іншым парадку, чым мяняліся, у тым ліку могуць захоўвацца навейшыя версіі аб'ектаў, чым на момант дадання ў чаргу.

Што яшчэ ёсць?

  • Усе бібліятэкі заснаваны на .NET Standard 2.0. Можна выкарыстоўваць у любым сучасным. NET праекце.
  • API струменебяспечны. Унутраныя калекцыі рэалізаваны на базе ConcurrentDictionary, апрацоўшчыкі падзей маюць або блакіроўкі, або не маюць патрэбы ў іх. 
    Адзінае пра што варта памятаць - пры завяршэнні прыкладання выклікаць ObjectRepository.Save();
  • Адвольныя індэксы (патрабуюць унікальнасць):

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

Хто гэта выкарыстоўвае?

Асабіста я пачаў выкарыстоўваць гэты падыход ва ўсіх хобі-праектах, таму што гэта зручна, і не патрабуе вялікіх выдаткаў на напісанне пласта доступу да дадзеных ці разгортвання цяжкай інфраструктуры. Асабіста мне, як правіла, дастаткова захоўвання дадзеных у litedb або ў файле. 

Але ў мінулым, калі з камандай рабілі цяпер спачылы стартап EscapeTeams (думаў вось яны, грошы - ан не, зноў вопыт) - выкарыстоўвалі для захоўвання дадзеных Azure Table Storage.

Планы на будучыню

Жадаецца паправіць адзін з асноўных мінусаў дадзенага падыходу - гарызантальнае маштабаванне. Для гэтага патрэбныя альбо размеркаваныя транзакцыі (sic!), альбо прыняць валявое рашэнне, што адны і тыя ж дадзеныя з розных інстансаў змяняцца не павінны, альбо няхай мяняюцца па прынцыпе "хто апошні - той і мае рацыю".

З тэхнічнага пункту гледжання я бачу магчымай наступную схему:

  • Захоўваць замест аб'ектнай мадэлі EventLog і Snapshot
  • Знаходзіць іншыя інстансы (дадаваць у налады канчатковыя кропкі ўсіх інстансаў? udp discovery? master/slave?)
  • Рэпліцыраваць паміж інстансамі EventLog праз любы з алгарытмаў кансэнсусу, напрыклад RAFT.

Гэтак жа існуе яшчэ адна праблема, якая мяне турбуе - гэта каскаднае выдаленне, або выяўленне выпадкаў выдалення аб'ектаў, на якія ёсць спасылкі з іншых аб'ектаў. 

зыходны код

Калі вы дачыталі да сюды - то далей застаецца чытаць толькі код, яго можна знайсці на GitHub:
https://github.com/DiverOfDark/ObjectRepository

Крыніца: habr.com

Дадаць каментар