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());
        }
        
        // Пример связи 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<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());

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


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


    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.

    Похожие публикации

    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 15

      –1
      Есть же MS SQL 2014 в in-memory. Почему решили всё-таки не использовать этот вариант?
        +1
        Вы говорите про базу данных, а статья про слой доступа к данным в вашем приложении.

        MS SQL 2014 in-memory — это возможность хранить таблицы целиком в памяти в сервере базы данных, но у вас всё равно остается слой ORM(или сырых SQL-запросов) в вашем коде, так же остается сетевой запрос за данным, т.е. нету всех тех плюсов что были описаны выше.

        Корректнее было бы сравнить мой подход с кэшированием внутри Entity Framework / других ORM, и тут в моём варианте значимым отличием было бы только то, что я гарантирую отсутствие сетевых запросов / ленивых запросов при получении данных после старта.
          –1
          Интересное решение. Как альтернатива, можно посмотреть в сторону Ef Core InMemoryProvider: docs.microsoft.com/en-us/ef/core/providers/in-memory
            +1
            EF Core InMemoryProvider не предполагает сохранения данных куда-либо, он нужен для локальных тестов.
            Также я не в курсе насколько хорошо один и тот же инстанс datacontext из ef переживает обращения из разных потоков.
        +1
        Если приложение упадет — можно частично потерять данные.

        Не только "когда упадет приложение", а еще и при многих других вариантах отказов. В этом, собственно, крупная проблема всех "а давайте сделаем in-memory".

          0
          Да, это правда. С другой стороны — условно-мгновенная репликация на соседний сервис и поддержание постоянно активным хотя бы одного инстанса должно решить эту проблему, нет?
            +1

            Неа. У вас же репозиторий в памяти приложения (ну или какого-то из его слоев), а не снаружи, иначе никакого смысла в нем нет, можно сразу Redis взять. А если так, то получается смешное. Возьмем, скажем, веб-приложение, однослойное, для простоты. Вот крутится у вас в его памяти репозиторий, все вроде нормально. И в какой-то момент запись репозитория в персистентное хранилище ломается. Нет, с приложением все хорошо, а вот хранилище отвалилось. Как вам помогла репликация в соседнюю реплику приложения? Да никак. Вам надо понять, что эта ситуация случилась, уронить свое приложение, чтобы балансировщик перед ним переключился на другое, а ваше собственное — начало перезапускаться. Более того, когда вы все это делаете, вам еще надо быть уверенным, что, собственно, ваша репликация тоже работала без сбоев. Что, в сумме, настолько усложняет решение, что возникает вопрос "а зачем, и не проще ли взять redis".

              0
              Если ломается запись в хранилище — это вроде как раз решается retry-политиками?

              Допустим мы делаем Active-Active решение из N инстансов, каждый со своим хранилищем и условно-мгновенной репликацией друг с другом.

              Да, вы правы что тут есть некоторые сложности, но не вижу значительно больших сложностей, чем иметь high-available redis (который также может упасть, к слову).
                +1
                Если ломается запись в хранилище — это вроде как раз решается retry-политиками?

                А если retry так и не прошел?


                Допустим мы делаем Active-Active решение из 3 инстансов, каждый со своим хранилищем и условно-мгновенной репликацией друг с другом.

                Если это Active-Active, как вы конфликты разрешать будете?


                Да, вы правы что тут есть некоторые сложности, но не вижу значительно больших сложностей, чем иметь high-available redis

                Понимаете, за HA Redis отвечаете не вы. Вам не надо думать, как он устроен, как разрешены конфликты, что там и как.

                  0
                  Если это Active-Active, как вы конфликты разрешать будете?

                  Это то, о чем я и писал в «планах на будущее» — либо распределенные транзакции, либо «кто последний, тот и прав». Оба варианта так себе, но для pet project-ов подойдёт.

                  Понимаете, за HA Redis отвечаете не вы. Вам не надо думать, как он устроен, как разрешены конфликты, что там и как.

                  Не согласен с подходом. То что вы можете найти hosted ha redis, или перенести ответственность на другую команду / SRE — не означает что не стоит думать о том, что делать когда(не если, а когда) оно приляжет / потеряются данные за последние N минут.

                  Но в ряде случаев вы правы, если это уже не pet-project — то с т.з. рисков — проще писать всё в отдельную базу данных, которую не ты администрируешь. А лучше сразу в облако — оно-то точно не упадет.
                    0
                    Это то, о чем я и писал в «планах на будущее»

                    А пока у вас этого нет, это не решение.


                    То что вы можете найти hosted ha redis, или перенести ответственность на другую команду / SRE — не означает что не стоит думать о том, что делать когда(не если, а когда) оно приляжет / потеряются данные за последние N минут.

                    Думать о том, что делать, если хранилище недоступно — это одна задача, и ее вам решать в обоих случаях. Думать о том, что делать, чтобы оно не было недоступно — это другая задача, и ее в одном случае из двух можно избежать.

          +1

          Ещё можно redis прикрутить

            0
            По большому счёту это ничем не отличается от любого внешнего nosql-хранилища. Тут основной плюс в том, что это в том же самом процессе работает.
          • НЛО прилетело и опубликовало эту надпись здесь
              0
              Спасибо за идеи!

              1) Да, но это решается на уровне хранилища / IStorage, если цель оптимизировать потребление дискового пространства. Если цель оптимизировать потребление памяти — то такое, конечно, можно сделать, но смысла в этом я вижу мало, если честно.

              Если только какой-то конкретный уникальный проект, в котором сотни тысяч текстов по >10 кбайт (а это всего лишь 1 гб!) — тогда это можно решать в рамках какого-то конкретного проекта.

              2) Аналогично п.1, хотя есть другой частый кейс, который возможно имеет смысл поддерживать прямо в ObjectRepository — это хранение бинарных файлов (например, картинки?). Очевидно, что писать их прямо в underlying storage не лучшая идея, вероятно их стоит хранить рядом на диске (или в облачном object storage: S3, Azure Blob Storage).

              3) TableDictionary уже реализует IEnumerable, поэтому тут уже работает обычный LINQ:
              var count = repository.Set<ParentModel>().Where(v=> v.Property == "123").Count();
              

              Я почему-то предполагал это очевидным, поэтому не упомянул в статье, каюсь.

              4) Хорошая мысль! В своих проектах я делал на базе Roslyn прямо в админке консоль для ObjectRepository, можно это вынести и в отдельную тулзу.

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое