ObjectRepository — wzorzec repozytorium w pamięci .NET dla Twoich domowych projektów

Po co przechowywać wszystkie dane w pamięci?

Aby przechowywać dane strony internetowej lub backendu, pierwszym pragnieniem większości rozsądnych ludzi będzie wybranie bazy danych SQL. 

Ale czasami przychodzi na myśl myśl, że model danych nie jest odpowiedni dla SQL: na przykład budując wyszukiwarkę lub wykres społecznościowy, trzeba szukać złożonych relacji między obiektami. 

Najgorzej jest, gdy pracujesz w zespole, a kolega nie wie, jak budować szybkie zapytania. Ile czasu poświęciłeś na rozwiązywanie problemów N+1 i budowanie dodatkowych indeksów, aby SELECT na stronie głównej zakończył się w rozsądnym czasie?

Innym popularnym podejściem jest NoSQL. Kilka lat temu było dużo szumu wokół tego tematu - na każdą dogodną okazję wdrożyli MongoDB i byli zadowoleni z odpowiedzi w postaci dokumentów json (swoją drogą, ile kul musiałeś włożyć ze względu na okrągłe linki w dokumentach?).

Sugeruję wypróbowanie innej, alternatywnej metody - dlaczego nie spróbować przechowywać wszystkich danych w pamięci aplikacji, okresowo zapisując je w pamięci losowej (plik, zdalna baza danych)? 

Pamięć stała się tania i wszelkie możliwe dane dla większości małych i średnich projektów zmieszczą się w 1 GB pamięci. (Na przykład moim ulubionym projektem domowym jest narzędzie do śledzenia finansów, który przez półtora roku prowadzi codzienne statystyki i historię moich wydatków, sald i transakcji, zajmuje tylko 45 MB pamięci.)

Plusy:

  • Dostęp do danych staje się łatwiejszy – nie musisz martwić się zapytaniami, leniwym ładowaniem, funkcjami ORM, pracujesz ze zwykłymi obiektami C#;
  • Nie ma problemów związanych z dostępem z różnych wątków;
  • Bardzo szybko – bez żądań sieciowych, bez tłumaczenia kodu na język zapytań, bez konieczności (de)serializacji obiektów;
  • Dopuszczalne jest przechowywanie danych w dowolnej formie – czy to w formacie XML na dysku, czy w SQL Server, czy w Azure Table Storage.

Wady:

  • Skalowanie poziome zostaje utracone, w wyniku czego nie można wdrożyć wdrożenia bez przestojów;
  • Jeśli aplikacja ulegnie awarii, możesz częściowo utracić dane. (Ale nasza aplikacja nigdy się nie zawiesza, prawda?)

Jak to działa?

Algorytm wygląda następująco:

  • Na początku nawiązywane jest połączenie z magazynem danych i ładowane są dane;
  • Budowany jest model obiektowy, indeksy podstawowe i indeksy relacyjne (1:1, 1:Wiele);
  • Tworzona jest subskrypcja na zmiany właściwości obiektu (INotifyPropertyChanged) oraz na dodanie lub usunięcie elementów do kolekcji (INotifyCollectionChanged);
  • Po uruchomieniu subskrypcji zmieniony obiekt zostaje dodany do kolejki zapisu do magazynu danych;
  • Zmiany w pamięci są zapisywane okresowo (na zegarze) w wątku w tle;
  • Po wyjściu z aplikacji zmiany są również zapisywane w pamięci.

Przykład kodu

Dodanie niezbędnych zależności

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

Opisujemy model danych, który będzie przechowywany w magazynie

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

Następnie model obiektowy:

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

I na koniec sama klasa repozytorium umożliwiająca dostęp do danych:

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

Utwórz instancję ObjectRepository:

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

Jeśli projekt będzie korzystał z HangFire

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

Wstawianie nowego obiektu:

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

Dzięki temu wywołaniu obiekt Model nadrzędny jest dodawany zarówno do lokalnej pamięci podręcznej, jak i do kolejki zapisu do bazy danych. Dlatego ta operacja wymaga O(1) i można natychmiast pracować z tym obiektem.

Na przykład, aby znaleźć ten obiekt w repozytorium i sprawdzić, czy zwrócony obiekt jest tą samą instancją:

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

Co się dzieje? Ustawić () zwroty Słownik stołowy, który zawiera Współbieżny słownik i zapewnia dodatkową funkcjonalność indeksów głównych i wtórnych. Dzięki temu możesz mieć metody wyszukiwania według identyfikatora (lub innych dowolnych indeksów użytkownika) bez całkowitej iteracji po wszystkich obiektach.

Podczas dodawania obiektów do Repozytorium obiektów subskrypcja jest dodawana w celu zmiany ich właściwości, więc każda zmiana właściwości powoduje również dodanie tego obiektu do kolejki zapisu. 
Aktualizacja właściwości z zewnątrz wygląda tak samo jak praca z obiektem POCO:

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

Obiekt możesz usunąć w następujący sposób:

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

Spowoduje to również dodanie obiektu do kolejki usuwania.

Jak działa oszczędzanie?

Repozytorium obiektów gdy monitorowane obiekty ulegną zmianie (dodanie, usunięcie lub zmiana właściwości), wywołuje zdarzenie Model zmienionysubskrybowane ISprzechowywanie. Wdrożenia ISprzechowywanie kiedy nastąpi jakieś wydarzenie Model zmieniony zmiany umieszczane są w 3 kolejkach - do dodania, do aktualizacji i do usunięcia.

Także wdrożenia ISprzechowywanie po inicjalizacji tworzą licznik czasu, który powoduje zapisywanie zmian co 5 sekund. 

Ponadto istnieje API umożliwiające wymuszenie wywołania zapisu: ObjectRepository.Save().

Przed każdym zapisem z kolejek usuwane są najpierw bezsensowne operacje (np. zduplikowane zdarzenia - dwukrotna zmiana obiektu lub szybkie dodawanie/usuwanie obiektów), a dopiero potem sam zapis. 

We wszystkich przypadkach zapisywany jest cały bieżący obiekt, zatem możliwe jest, że obiekty zostaną zapisane w innej kolejności niż zostały zmienione, włączając w to nowsze wersje obiektów niż w momencie dodania do kolejki.

Co jeszcze tu jest?

  • Wszystkie biblioteki oparte są na .NET Standard 2.0. Można go używać w każdym nowoczesnym projekcie .NET.
  • Interfejs API jest bezpieczny dla wątków. Zbiory wewnętrzne realizowane są w oparciu o Współbieżny słownikprocedury obsługi zdarzeń albo mają blokady, albo ich nie potrzebują. 
    Jedyne o czym warto pamiętać to zadzwonić Repozytorium obiektów.Save();
  • Indeksy arbitralne (wymagają unikalności):

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

Kto z tego korzysta?

Osobiście zacząłem stosować to podejście we wszystkich projektach hobbystycznych, ponieważ jest wygodne i nie wymaga dużych wydatków na napisanie warstwy dostępu do danych lub wdrożenie ciężkiej infrastruktury. Osobiście zazwyczaj wystarcza mi przechowywanie danych w litedb lub pliku. 

Ale w przeszłości, kiedy nieistniejący już startup EscapeTeams (Myślałem, że to są pieniądze – ale nie, znowu doświadczenie) — służy do przechowywania danych w usłudze Azure Table Storage.

Plany na przyszłość

Chciałbym naprawić jedną z głównych wad tego podejścia - skalowanie poziome. Do tego potrzebne są albo transakcje rozproszone (sic!), albo świadomie podejmujesz decyzję, że te same dane z różnych instancji nie powinny się zmieniać, albo pozwalasz im się zmieniać zgodnie z zasadą „kto ostatni ma rację”.

Z technicznego punktu widzenia możliwy jest następujący schemat:

  • Przechowuj dziennik zdarzeń i migawkę zamiast modelu obiektowego
  • Znajdź inne instancje (dodaj punkty końcowe wszystkich instancji do ustawień? odkrycie udp? master/slave?)
  • Replikuj między instancjami EventLog za pomocą dowolnego algorytmu konsensusu, takiego jak RAFT.

Martwi mnie również inny problem - usuwanie kaskadowe, czyli wykrywanie przypadków usunięcia obiektów, które mają powiązania z innymi obiektami. 

Kod źródłowy

Jeśli dotarłeś aż tutaj, pozostaje tylko przeczytać kod; można go znaleźć na GitHubie:
https://github.com/DiverOfDark/ObjectRepository

Źródło: www.habr.com

Dodaj komentarz