ObjectRepository: modello di repository in memoria .NET per i tuoi progetti domestici

Perché archiviare tutti i dati in memoria?

Per archiviare dati di siti Web o backend, il primo desiderio della maggior parte delle persone sane sarà quello di scegliere un database SQL. 

Ma a volte viene in mente il pensiero che il modello dati non è adatto per SQL: ad esempio, quando si crea una ricerca o un grafico sociale, è necessario cercare relazioni complesse tra oggetti. 

La situazione peggiore è quando lavori in gruppo e un collega non sa come costruire query veloci. Quanto tempo hai dedicato alla risoluzione di N+1 problemi e alla creazione di indici aggiuntivi in ​​modo che SELECT nella pagina principale venisse completata in un tempo ragionevole?

Un altro approccio popolare è NoSQL. Diversi anni fa c'era molto clamore su questo argomento: per ogni occasione conveniente distribuivano MongoDB ed erano soddisfatti delle risposte sotto forma di documenti json (a proposito, quante stampelle hai dovuto inserire a causa dei collegamenti circolari nei documenti?).

Suggerisco di provare un altro metodo alternativo: perché non provare a archiviare tutti i dati nella memoria dell'applicazione, salvandoli periodicamente in un archivio casuale (file, database remoto)? 

La memoria è diventata economica e tutti i dati possibili per la maggior parte dei progetti di piccole e medie dimensioni entreranno in 1 GB di memoria. (Ad esempio, il mio progetto di casa preferito è tracker finanziario, che conserva le statistiche giornaliere e la cronologia delle mie spese, saldi e transazioni per un anno e mezzo, consuma solo 45 MB di memoria.)

pro:

  • L'accesso ai dati diventa più semplice: non devi preoccuparti di query, caricamento lento, funzionalità ORM, lavori con normali oggetti C#;
  • Non ci sono problemi associati all'accesso da thread diversi;
  • Molto veloce: nessuna richiesta di rete, nessuna traduzione del codice in un linguaggio di query, nessuna necessità di (de)serializzazione degli oggetti;
  • È accettabile archiviare dati in qualsiasi formato: in XML su disco, in SQL Server o in Archiviazione tabelle di Azure.

contro:

  • La scalabilità orizzontale viene persa e, di conseguenza, non è possibile eseguire una distribuzione con tempi di inattività pari a zero;
  • Se l'applicazione si blocca, potresti perdere parzialmente i dati. (Ma la nostra applicazione non si blocca mai, giusto?)

Come funziona?

L'algoritmo è il seguente:

  • All'inizio viene stabilita una connessione con l'archivio dati e i dati vengono caricati;
  • Vengono creati un modello a oggetti, indici primari e indici relazionali (1:1, 1:Many);
  • Viene creata una sottoscrizione per le modifiche alle proprietà dell'oggetto (INotifyPropertyChanged) e per l'aggiunta o la rimozione di elementi alla raccolta (INotifyCollectionChanged);
  • Quando viene attivata la sottoscrizione, l'oggetto modificato viene aggiunto alla coda per la scrittura nell'archivio dati;
  • Le modifiche allo spazio di archiviazione vengono salvate periodicamente (su un timer) in un thread in background;
  • Quando esci dall'applicazione, le modifiche vengono salvate anche nella memoria.

Esempio di codice

Aggiunta delle 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 di dati che verrà archiviato nello storage

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

Quindi il modello a oggetti:

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

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

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

Crea un'istanza ObjectRepository:

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

Se il progetto utilizzerà 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 Modello genitore viene aggiunto sia alla cache locale che alla coda per la scrittura nel database. Pertanto, questa operazione richiede O(1) e questo oggetto può essere utilizzato immediatamente.

Ad esempio, per trovare questo oggetto nel repository e verificare che l'oggetto restituito sia la stessa istanza:

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

Che cosa sta accadendo? Impostato () ritorna Dizionario della tabella, che contiene Dizionario Concorrente e fornisce funzionalità aggiuntive degli indici primari e secondari. Ciò consente di avere metodi per la ricerca per ID (o altri indici utente arbitrari) senza ripetere completamente tutti gli oggetti.

Quando si aggiungono oggetti a Repository di oggetti viene aggiunta una sottoscrizione per modificarne le proprietà, quindi qualsiasi modifica nelle proprietà comporta anche l'aggiunta di questo oggetto alla coda di scrittura. 
L'aggiornamento delle proprietà dall'esterno assomiglia a lavorare con un oggetto POCO:

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

È possibile eliminare un oggetto nei seguenti modi:

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

Ciò aggiunge anche l'oggetto alla coda di eliminazione.

Come funziona il risparmio?

Repository di oggetti quando gli oggetti monitorati cambiano (aggiungendo o eliminando o modificando le proprietà), genera un evento Modello modificatoiscritto a IStorage. Implementazioni IStorage quando si verifica un evento Modello modificato le modifiche vengono inserite in 3 code: per l'aggiunta, per l'aggiornamento e per l'eliminazione.

Anche implementazioni IStorage al momento dell'inizializzazione, creano un timer che fa sì che le modifiche vengano salvate ogni 5 secondi. 

Inoltre, esiste un'API per forzare una chiamata di salvataggio: ObjectRepository.Save().

Prima di ogni salvataggio, le operazioni prive di significato vengono rimosse dalle code (ad esempio, eventi duplicati - quando un oggetto è stato modificato due volte o oggetti aggiunti/rimossi rapidamente) e solo successivamente il salvataggio stesso. 

In tutti i casi, l'intero oggetto corrente viene salvato, quindi è possibile che gli oggetti vengano salvati in un ordine diverso da quello in cui sono stati modificati, comprese le versioni degli oggetti più recenti rispetto al momento in cui sono stati aggiunti alla coda.

Cosa altro c'è?

  • Tutte le librerie sono basate su .NET Standard 2.0. Può essere utilizzato in qualsiasi progetto .NET moderno.
  • L'API è thread-safe. Le raccolte interne vengono implementate in base a Dizionario simultaneo, i gestori eventi dispongono di blocchi oppure non ne hanno bisogno. 
    L'unica cosa che vale la pena ricordare è chiamare ObjectRepository.Save();
  • Indici arbitrari (richiedono unicità):

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

Chi lo usa?

Personalmente, ho iniziato a utilizzare questo approccio in tutti i progetti hobby perché è conveniente e non richiede grandi spese per scrivere un livello di accesso ai dati o implementare infrastrutture pesanti. Personalmente, di solito mi basta archiviare i dati in litedb o in un file. 

Ma in passato, quando l’ormai defunta startup EscapeTeams (Pensavo che fossero soldi, ma no, sperimenta di nuovo) - utilizzato per archiviare dati in Archiviazione tabelle di Azure.

Progetti per il futuro

Vorrei risolvere uno dei principali svantaggi di questo approccio: il ridimensionamento orizzontale. Per fare ciò, sono necessarie transazioni distribuite (sic!), oppure prendere una decisione volitiva affinché gli stessi dati provenienti da istanze diverse non debbano cambiare, oppure lasciarli cambiare secondo il principio “chi è l’ultimo ha ragione”.

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

  • Archivia EventLog e Snapshot invece del modello a oggetti
  • Trova altre istanze (aggiungi gli endpoint di tutte le istanze alle impostazioni? Rilevamento udp? master/slave?)
  • Replica tra istanze EventLog tramite qualsiasi algoritmo di consenso, come RAFT.

C'è anche un altro problema che mi preoccupa: la cancellazione a cascata o il rilevamento di casi di cancellazione di oggetti che hanno collegamenti da altri oggetti. 

Codice sorgente

Se siete arrivati ​​fino a qui non resta che leggere il codice, lo trovate su GitHub:
https://github.com/DiverOfDark/ObjectRepository

Fonte: habr.com

Aggiungi un commento