ObjectRepository — pattern di repository in memoria .NET per i tuoi progetti domestici

Perché conservare tutti i dati in memoria?

Per memorizzare i dati del sito o del backend, il primo desiderio della maggior parte delle persone sensate sarà scegliere un database SQL. 

Ma a volte viene in mente che il modello dei dati non si adatta a SQL: ad esempio, quando si costruisce una ricerca o un grafo sociale è necessaria una ricerca su relazioni complesse tra oggetti. 

La situazione peggiore è quando lavori in team e un collega non sa costruire query veloci. Quanto tempo hai speso per risolvere i problemi N+1 e per costruire indici aggiuntivi affinché il SELECT sulla home page funzionasse in un tempo ragionevole?

Un altro approccio popolare è NoSQL. Qualche anno fa c'era un gran clamore attorno a questo tema: per ogni occasione favorevole veniva implementata MongoDB e ci si rallegrava per le risposte in forma di documenti JSON. (Tra l'altro, quanti workaround hai dovuto inserire a causa dei riferimenti circolari nei documenti?).

Ti propongo di provare un altro modo, alternativo: perché non provare a memorizzare tutti i dati in memoria dell'applicazione, salvando periodicamente in uno storage arbitrario (file, database remoto)? 

La memoria è diventata economica e qualsiasi possibile dato della maggior parte dei progetti piccoli e medi può stare in 1 GB di memoria. (Ad esempio, il mio progetto domestico preferito — un tracker finanziario, che tiene traccia delle statistiche giornaliere e della storia delle mie spese, dei bilanci e delle transazioni per un anno e mezzo consuma solo 45 MB di memoria.)

Pro:

  • L'accesso ai dati diventa più semplice: non è necessario preoccuparsi delle query, del caricamento pigro, delle peculiarità dell'ORM, il lavoro avviene con normali oggetti C#;
  • Non ci sono problemi legati all'accesso da thread diversi;
  • Molto veloce: non ci sono richieste di rete, manca la traduzione del codice nel linguaggio di query, non è necessaria la (de)serializzazione degli oggetti;
  • È accettabile memorizzare i dati in qualsiasi forma: anche in XML su disco, in SQL Server, o in Azure Table Storage.

Contro:

  • Si perde la scalabilità orizzontale e, di conseguenza, non è possibile effettuare un deployment a zero downtime;
  • Se l'applicazione va in crash, è possibile perdere parzialmente i dati. (Ma la nostra applicazione non va mai in crash, giusto?)

Come funziona?

L'algoritmo è il seguente:

  • All'avvio viene stabilita una connessione con lo storage dei dati e avviene il caricamento dei dati;
  • Si costruisce il modello ad oggetti, gli indici primari e gli indici delle relazioni (1:1, 1:M);
  • Viene creata un'abbonamento per le modifiche delle proprietà degli oggetti (INotifyPropertyChanged) e per l'aggiunta o la rimozione di elementi nella collezione (INotifyCollectionChanged);
  • Quando si attiva l'abbonamento, l'oggetto modificato viene aggiunto alla coda per la scrittura nel database;
  • Periodicamente (tramite un timer), le modifiche vengono salvate nel database in un thread secondario;
  • Le modifiche vengono salvate nel database anche all'uscita dal programma.

Esempio di codice

Aggiungiamo le dipendenze necessarie

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

Descriviamo il modello dei dati che verrà salvato nel database

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

Poi il modello degli oggetti:

public class ParentModel : ModelBase
{
    public ParentModel(ParentEntity entity)
    {
        Entity = entity;
    }
    
    public ParentModel()
    {
        Entity = new ParentEntity(Guid.NewGuid());
    }
    
    public Guid? NullableId => null;
    
    // Esempio di relazione 1:Molti
    public IEnumerable Children => Multiple(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
    }
    
    // Accesso con ricerca per indice
    public ParentModel Parent => Single(ParentId);
    
    protected override BaseEntity Entity => _childEntity;
}

E infine la classe-repository per l'accesso ai dati:

public class MyObjectRepository : ObjectRepositoryBase
{
    public MyObjectRepository(IStorage storage) : base(storage, NullLogger.Instance)
    {
        IsReadOnly = true; // Per i test, consente di non salvare le modifiche nel database
    
        AddType((ParentEntity x) => new ParentModel(x));
        AddType((ChildEntity x) => new ChildModel(x));
    
        // Se viene utilizzato Hangfire e è necessario memorizzare il modello dei dati per Hangfire in ObjectRepository
        // this.RegisterHangfireScheme();  
    
        Initialize();
    }
}

Creiamo un'istanza di ObjectRepository:

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

Se nel progetto verrà utilizzato HangFire

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

Inserimento di un nuovo oggetto:

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

Con questa chiamata l'oggetto ParentModel viene aggiunto sia nella cache locale che nella coda per la scrittura nel database. Pertanto, questa operazione richiede O(1), e si può immediatamente lavorare con questo oggetto.

Ad esempio, per trovare questo oggetto nel repository e assicurarsi che l'oggetto restituito sia esattamente lo stesso oggetto:

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

Cosa succede in questo caso? Set() false, se l'elemento da sostituire è TableDictionary, che contiene ConcurrentDictionary e fornisce funzionalità aggiuntive per indici primari e secondari. Questo consente di avere metodi per la ricerca per Id (o altri indici personalizzati) senza dover esaminare tutti gli oggetti.

Quando si aggiungono oggetti a ObjectRepository viene aggiunta un'iscrizione alle modifiche delle loro proprietà, quindi qualsiasi modifica avrà anche come conseguenza l'aggiunta di questo oggetto alla coda per la scrittura. 
L'aggiornamento delle proprietà dall'esterno appare esattamente come il lavoro con un oggetto POCO:

myParent.Children.First().Property = "Valore aggiornato";

Un oggetto può essere rimosso nei seguenti modi:

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

In questo caso, avviene anche l'aggiunta dell'oggetto alla coda di rimozione.

Come funziona il salvataggio?

ObjectRepository quando si apportano modifiche agli oggetti monitorati (sia aggiunta o rimozione che modifica delle proprietà) genera un evento ModelChanged, a cui è iscritta IStorage. Le implementazioni IStorage in caso di evento ModelChanged accumulano le modifiche in 3 code: per aggiunta, per aggiornamento e per rimozione.

Inoltre, le implementazioni IStorage durante l'inizializzazione creano un timer, che ogni 5 secondi esegue il salvataggio delle modifiche. 

In aggiunta, esiste un'API per forzare il salvataggio: ObjectRepository.Save().

Prima di ogni salvataggio, si rimuovono prima le operazioni insignificanti dalle code (ad esempio duplicati di eventi - quando un oggetto è stato modificato due volte o una rapida aggiunta/rimozione di oggetti) e solo successivamente avviene il salvataggio vero e proprio. 

In tutti i casi, viene salvato l'oggetto attuale nella sua interezza, quindi è possibile che gli oggetti vengano salvati in un ordine diverso rispetto a quanto modificati, inclusa la possibilità che versioni più recenti degli oggetti vengano salvate rispetto al momento in cui sono stati aggiunti alla coda.

Che cos'altro c'è?

  • Tutte le librerie sono basate su .NET Standard 2.0. Possono essere utilizzate in qualsiasi progetto .NET moderno.
  • L'API è thread-safe. Le collezioni interne sono implementate su base ConcurrentDictionary, gli handler degli eventi hanno sia blocchi che non ne hanno bisogno. 
    L'unica cosa da ricordare è che al termine dell'applicazione bisogna chiamare ObjectRepository.Save();
  • Indici arbitrarie (richiedono unicità):

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

Chi lo utilizza?

Personalmente, ho iniziato a utilizzare questo approccio in tutti i progetti hobby, perché è comodo e non richiede grandi sforzi nella scrittura di uno strato di accesso ai dati o nel settaggio di un'infrastruttura pesante. Di solito, per me è sufficiente conservare i dati in litedb o in un file. 

Ma in passato, quando con il mio team abbiamo avviato la ormai defunta startup EscapeTeams (pensavo che ecco i soldi — ma no, di nuovo esperienze) — abbiamo utilizzato Azure Table Storage per la memorizzazione dei dati.

Piani futuri

Voglio sistemare uno dei principali svantaggi di questo approccio: la scalabilità orizzontale. Per questo servono o transazioni distribuite (sic!), o una decisione forte che gli stessi dati da diverse istanze non debbano cambiare, o lasciare che cambino secondo il principio 'chi ultimo è, meglio è'.

Da un punto di vista tecnico vedo possibile il seguente schema:

  • Conservare invece del modello ad oggetti EventLog e Snapshot
  • Trovare altre istanze (aggiungere nelle impostazioni i punti finali di tutte le istanze? scoperta UDP? master/slave?)
  • Replicare tra le istanze EventLog tramite uno qualsiasi degli algoritmi di consenso, ad esempio RAFT.

C'è anche un altro problema che mi preoccupa: la cancellazione a cascata o l'identificazione dei casi di eliminazione di oggetti a cui ci sono riferimenti da altri oggetti. 

Codice sorgente

Se sei arrivato fino a qui, rimane da leggere solo il codice, che puoi trovare su GitHub:
https://github.com/DiverOfDark/ObjectRepository

Fonte: habr.com

Acquista hosting affidabile per siti web con protezione DDoS, VPS VDS server 🔥 Acquista hosting affidabile per siti web con protezione DDoS, VPS VDS server | ProHoster