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, який містить у собі currentcurrentdictionary та надає додатковий функціонал первинних та вторинних індексів. Це дозволяє мати методи для пошуку по 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 є потокобезпечним. Внутрішні колекції реалізовані на базі Паралельний словник, обробники подій мають або блокування, або не потребують їх. 
    Єдине про що варто пам'ятати - при завершенні програми викликати 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

Додати коментар або відгук