Хабрахабр

ObjectRepository — .NET in-memory repository pattern для ваших домашних проектов

Зачем хранить все данные в памяти?

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

Но иногда в голову приходит мысль что модель данных не подходит для SQL: например, при построении поиска или социального графа нужен поиск по сложным связям между объектами. 

Сколько времени вы потратили на решение проблем N+1 и на построение дополнительных индексов, чтобы SELECT на главной странице отрабатывал за разумное время? Хуже всего ситуация, когда работаете в команде, и коллега не умеет строить быстрые запросы.

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

Почему бы не попробовать хранить все данные в памяти приложения, периодически сохраняя в произвольное хранилище (файл, удаленная база данных)? 

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

Плюсы:

  • Доступ к данным становится проще — не нужно заботиться о запросах, ленивой загрузке, особенностях 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 string Value { get; set; }
}

Затем объектную модель:

public class ParentModel : ModelBase
{ public ParentModel(ParentEntity entity) { Entity = entity; } public ParentModel() { Entity = new ParentEntity(Guid.NewGuid()); } // Пример связи 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);

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

Например, чтобы найти этот объект в репозитории и убедиться что вернувшийся объект является тем же экземпляром:

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

Set<ParentModel>() возвращает TableDictionary<ParentModel>, который содержит в себе ConcurrentDictionary<ParentModel, ParentModel> и предоставляет дополнительный функционал первичных и вторичных индексов. Что при этом происходит? Это позволяет иметь методы для поиска по Id (или другим произвольным пользовательским индексам) без полного перебора всех объектов.

При добавлении объектов в ObjectRepository добавляется подписка на изменение их свойств, поэтому любое изменение свойств также приводит добавлению этого объекта в очередь на запись. 
Обновление свойств снаружи выглядит так же, как и работа с POCO-объектом:

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

Удалить объект можно следующими способами:

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

При этом также происходит добавление объекта в очередь на удаление.

Как работает сохранение?

Реализации IStorage при возникновении события ModelChanged складывают изменения в 3 очереди — на добавление, на обновление, и на удаление. ObjectRepository при изменении отслеживаемых объектов (как добавление или удаление, так и изменение свойств) вызывает событие ModelChanged, на которое подписан IStorage.

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

Save(). Кроме того существует API для принудительного вызова сохранения: ObjectRepository.

Перед каждым сохранением сначала происходит удаление из очередей бессмысленных операций (например дубликаты событий — когда объект менялся дважды или быстрое добавление/удаление объектов), и только потом само сохранение. 

Во всех случаях сохраняется актуальный объект целиком, поэтому возможна ситуация, когда объекты сохраняются в другом порядке, чем менялись, в том числе могут сохраняться более новые версии объектов, чем на момент добавления в очередь.

Что есть ещё?

  • Все библиотеки основаны на .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.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть