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

Perché conservare tutti i dati in memoria?

Per memorizzare i dati del sito o del backend, la maggior parte delle persone sensate sceglierà prima di tutto un database SQL. 

Ma a volte viene in mente che il modello di dati non è adatto per SQL: ad esempio, nella creazione di una ricerca o di un grafo sociale è necessario cercare relazioni complesse tra gli oggetti. 

La situazione peggiore si verifica quando si lavora in team e un collega non sa costruire query veloci. Quanto tempo hai speso a risolvere i problemi N+1 e a creare indici supplementari affinché il SELECT nella homepage funzionasse in un tempo ragionevole?

Un altro approccio popolare è NoSQL. Qualche anno fa c'era un grande hype attorno a questo tema: per ogni occasione conveniente si distribuiva MongoDB e si gioiva delle risposte in formato json. (tra l'altro, quanti stratagemmi hanno dovuto essere inseriti a causa dei collegamenti circolari nei documenti?).

Propongo di provare un altro metodo alternativo: perché non provare a memorizzare tutti i dati nella memoria dell'applicazione, salvando periodicamente in un'archiviazione arbitraria (file, database remoto)? 

La memoria è diventata economica e qualsiasi dato dei maggiori progetti piccoli e medi può facilmente entrare in 1 GB di memoria. (Ad esempio, il mio progetto domestico preferito è il tracker finanziario, che tiene traccia della mia statistica quotidiana e della storia delle mie spese, dei saldi e delle transazioni per un anno e mezzo, e consuma solo 45 MB di memoria.)

Vantaggi:

  • L'accesso ai dati diventa più semplice: non è necessario preoccuparsi delle query, del caricamento lento, delle particolarità dell'ORM, il lavoro avviene con normali oggetti C#;
  • Nessun problema legato all'accesso da thread diversi;
  • Molto veloce: non ci sono richieste di rete, non c'è traduzione del codice in linguaggio di query, non è necessaria la (de)serializzazione degli oggetti;
  • È possibile memorizzare i dati in qualsiasi formato: sia in XML su disco, sia in SQL Server, sia in Azure Table Storage.

Contro:

  • Si perde la scalabilità orizzontale, e di conseguenza non è possibile effettuare il deployment senza downtime;
  • Se l'applicazione si blocca, si possono perdere parzialmente i dati. (Ma la nostra applicazione non si blocca mai, vero?)

Come funziona?

L'algoritmo è il seguente:

  • All'avvio, viene stabilita una connessione con il repository dati e i dati vengono caricati;
  • Viene costruito un modello ad oggetti, indici primari e indici delle relazioni (1:1, 1:Molti);
  • Si crea un abbonamento alle modifiche delle proprietà degli oggetti (INotifyPropertyChanged) e all'aggiunta o rimozione di elementi nella collezione (INotifyCollectionChanged);
  • Quando si verifica l'abbonamento, l'oggetto modificato viene aggiunto alla coda per la scrittura nel database;
  • Periodicamente (tramite un timer) vengono salvate in un thread in background le modifiche nel database;
  • Al momento della chiusura dell'app, vengono salvate anche le modifiche nel database.

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

Definiamo il modello dati che sarà 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 si utilizza Hangfire e necessario memorizzare il modello di 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));
}

Inserire un nuovo oggetto:

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

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

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

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

Cosa succede in questo caso? Set() restituisce TableDictionary, che contiene ConcurrentDictionary e fornisce funzionalità aggiuntive per gli indici primari e secondari. Questo consente di avere metodi per la ricerca per Id (o altri indici personalizzati arbitrari) senza dover eseguire una scansione completa di tutti gli oggetti.

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

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

È possibile rimuovere un oggetto nei seguenti modi:

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

Anche in questo caso, l'oggetto viene aggiunto alla coda di rimozione.

Come funziona il salvataggio?

ObjectRepository la modifica degli oggetti monitorati (come l'aggiunta o la rimozione e la modifica delle proprietà) genera un evento ModelChanged, a cui è iscritto IStorage. Le implementazioni IStorage quando si verifica l'evento ModelChanged accumulano le modifiche in 3 code — per aggiunta, per aggiornamento e per rimozione.

Le implementazioni forniscono IStorage Durante l'inizializzazione, viene creato un timer che chiama il salvataggio delle modifiche ogni 5 secondi. 

Esiste anche un'API per forzare il salvataggio: ObjectRepository.Save().

Prima di ogni salvataggio, vengono rimossi dalle code le operazioni inutili (ad esempio, i duplicati degli eventi — quando un oggetto è stato modificato due volte o ci sono stati aggiustamenti rapidi di oggetti), e solo dopo avviene il salvataggio stesso. 

In tutti i casi, viene salvato l'oggetto attuale per intero, quindi è possibile che gli oggetti vengano salvati in un ordine diverso rispetto a quello in cui sono stati modificati, inclusa la possibilità che versioni più recenti degli oggetti vengano salvate rispetto al momento dell'aggiunta in coda.

Cosa c'è di nuovo?

  • Tutte le librerie si basano su .NET Standard 2.0. Possono essere utilizzate in qualsiasi progetto .NET moderno.
  • L'API è thread-safe. Le collezioni interne sono basate su ConcurrentDictionary, i gestori di eventi utilizzano blocchi o non ne hanno bisogno. 
    L'unica cosa da ricordare è che, alla chiusura dell'applicazione, è necessario chiamare ObjectRepository.Save();
  • Indici arbitrari (richiedono unicità):

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

Chi lo utilizza?

Personalmente, ho iniziato a usare questo approccio in tutti i miei progetti hobbistici, perché è comodo e non richiede grandi investimenti per la scrittura di uno strato di accesso ai dati o per sviluppare un'infrastruttura pesante. Di solito per me è sufficiente memorizzare i dati in litedb o in un file. 

Ma in passato, quando con il team abbiamo creato la ormai defunta startup EscapeTeams (pensavo ecco, i soldi — e invece niente, solo esperienza) — abbiamo usato Azure Table Storage per memorizzare i dati.

Piani per il futuro

Vorrei risolvere uno dei principali svantaggi di questo approccio: la scalabilità orizzontale. Per questo servono o transazioni distribuite (sic!), oppure bisogna prendere una ferma decisione che gli stessi dati da istanze diverse non devono cambiare, oppure possono cambiare con il principio "chi arriva ultimo ha ragione".

Dal punto di vista tecnico, vedo possibile il seguente schema:

  • Memorizzare invece del modello oggetto EventLog e Snapshot
  • Trovare altre istanze (inserire nelle impostazioni gli endpoint di tutte le istanze? discovery 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'individuazione dei casi di eliminazione di oggetti a cui ci sono riferimenti da altri oggetti. 

Codice sorgente

Se sei arrivato fin qui, rimane solo da leggere 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