ObjectRepository: patrón de repositorio en memoria .NET para sus proyectos domésticos

¿Por qué almacenar todos los datos en la memoria?

Para almacenar datos de sitios web o backend, el primer deseo de la mayoría de las personas en su sano juicio será elegir una base de datos SQL. 

Pero a veces me viene a la mente la idea de que el modelo de datos no es adecuado para SQL: por ejemplo, al crear una búsqueda o un gráfico social, es necesario buscar relaciones complejas entre objetos. 

La peor situación es cuando trabajas en equipo y un colega no sabe cómo generar consultas rápidas. ¿Cuánto tiempo dedicó a resolver problemas N+1 y crear índices adicionales para que SELECT en la página principal se completara en un período de tiempo razonable?

Otro enfoque popular es NoSQL. Hace varios años hubo mucho revuelo en torno a este tema: para cualquier ocasión conveniente implementaron MongoDB y quedaron satisfechos con las respuestas en forma de documentos json. (por cierto, ¿cuántas muletas tuviste que insertar debido a los enlaces circulares en los documentos?).

Sugiero probar otro método alternativo: ¿por qué no intentar almacenar todos los datos en la memoria de la aplicación y guardarlos periódicamente en un almacenamiento aleatorio (archivo, base de datos remota)? 

La memoria se ha vuelto barata y todos los datos posibles para la mayoría de los proyectos pequeños y medianos caben en 1 GB de memoria. (Por ejemplo, mi proyecto de casa favorito es rastreador financiero, que mantiene estadísticas diarias e historial de mis gastos, saldos y transacciones durante un año y medio, consume sólo 45 MB de memoria).

Pros:

  • El acceso a los datos se vuelve más fácil: no necesita preocuparse por consultas, carga diferida, funciones ORM, trabaja con objetos C# comunes;
  • No hay problemas asociados con el acceso desde diferentes hilos;
  • Muy rápido: sin solicitudes de red, sin traducción de código a un lenguaje de consulta, sin necesidad de (des)serialización de objetos;
  • Es aceptable almacenar datos en cualquier forma, ya sea en XML en disco, en SQL Server o en Azure Table Storage.

Contras:

  • Se pierde el escalamiento horizontal y, como resultado, no se puede realizar una implementación sin tiempo de inactividad;
  • Si la aplicación falla, es posible que pierda parcialmente datos. (Pero nuestra aplicación nunca falla, ¿verdad?)

Como funciona?

El algoritmo es como sigue:

  • Al principio, se establece una conexión con el almacenamiento de datos y se cargan los datos;
  • Se construyen un modelo de objetos, índices primarios e índices relacionales (1:1, 1:Muchos);
  • Se crea una suscripción para cambios en las propiedades del objeto (INotifyPropertyChanged) y para agregar o eliminar elementos a la colección (INotifyCollectionChanged);
  • Cuando se activa la suscripción, el objeto modificado se agrega a la cola para escribir en el almacenamiento de datos;
  • Los cambios en el almacenamiento se guardan periódicamente (según un temporizador) en un hilo en segundo plano;
  • Cuando sale de la aplicación, los cambios también se guardan en el almacenamiento.

Ejemplo de código

Agregar las dependencias necesarias

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

Describimos el modelo de datos que se almacenará en el almacenamiento.

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

Entonces el modelo de objetos:

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

Y finalmente, la propia clase de repositorio para acceder a los datos:

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

Cree una instancia de ObjectRepository:

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

Si el proyecto utilizará HangFire

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

Insertar un nuevo objeto:

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

Con esta llamada, el objeto Modelo principal se agrega tanto al caché local como a la cola para escribir en la base de datos. Por lo tanto, esta operación toma O(1) y se puede trabajar con este objeto inmediatamente.

Por ejemplo, para encontrar este objeto en el repositorio y verificar que el objeto devuelto sea la misma instancia:

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

¿Lo que está sucediendo? Colocar () devuelve Diccionario de tablas, que contiene Diccionario concurrente y proporciona funcionalidad adicional de índices primarios y secundarios. Esto le permite tener métodos para buscar por ID (u otros índices de usuario arbitrarios) sin iterar completamente sobre todos los objetos.

Al agregar objetos a Repositorio de objetos Se agrega una suscripción para cambiar sus propiedades, por lo que cualquier cambio en las propiedades también da como resultado que este objeto se agregue a la cola de escritura. 
Actualizar propiedades desde el exterior tiene el mismo aspecto que trabajar con un objeto POCO:

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

Puede eliminar un objeto de las siguientes maneras:

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

Esto también agrega el objeto a la cola de eliminación.

¿Cómo funciona el ahorro?

Repositorio de objetos cuando los objetos monitoreados cambian (ya sea agregando o eliminando, o cambiando propiedades), genera un evento ModeloCambiadosuscrito a Almacenamiento. Implementaciones Almacenamiento cuando ocurre un evento ModeloCambiado Los cambios se colocan en 3 colas: para agregar, para actualizar y para eliminar.

También implementaciones Almacenamiento tras la inicialización, crean un temporizador que hace que los cambios se guarden cada 5 segundos. 

Además, existe una API para forzar una llamada a guardar: ObjetoRepositorio.Guardar().

Antes de cada guardado, primero se eliminan de las colas las operaciones sin sentido (por ejemplo, eventos duplicados, cuando un objeto se cambió dos veces o se agregaron/eliminaron objetos rápidamente), y solo luego el guardado en sí. 

En todos los casos, se guarda todo el objeto actual, por lo que es posible que los objetos se guarden en un orden diferente al de su modificación, incluidas versiones más recientes de los objetos que en el momento en que se agregaron a la cola.

¿Qué más hay ahí?

  • Todas las bibliotecas se basan en .NET Standard 2.0. Se puede utilizar en cualquier proyecto .NET moderno.
  • La API es segura para subprocesos. Las cobranzas internas se implementan en base a Diccionario concurrente, los controladores de eventos tienen bloqueos o no los necesitan. 
    Lo único que vale la pena recordar es llamar. ObjectRepository.Guardar();
  • Índices arbitrarios (requieren unicidad):

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

¿Quién lo usa?

Personalmente, comencé a usar este enfoque en todos los proyectos de hobby porque es conveniente y no requiere grandes gastos para escribir una capa de acceso a datos o implementar una infraestructura pesada. Personalmente, almacenar datos en litedb o en un archivo suele ser suficiente para mí. 

Pero en el pasado, cuando la ya desaparecida startup EscapeTeams (Pensé que aquí está, dinero, pero no, experiencia otra vez.): se utiliza para almacenar datos en Azure Table Storage.

Planes para el futuro

Me gustaría solucionar una de las principales desventajas de este enfoque: el escalado horizontal. Para hacer esto, necesita transacciones distribuidas (¡sic!), o tomar una decisión decidida de que los mismos datos de diferentes instancias no deben cambiar, o dejar que cambien de acuerdo con el principio "quien es el último tiene la razón".

Desde un punto de vista técnico, veo posible el siguiente esquema:

  • Almacene EventLog e Snapshot en lugar del modelo de objetos
  • Buscar otras instancias (¿agregar puntos finales de todas las instancias a la configuración? ¿descubrimiento de udp? ¿maestro/esclavo?)
  • Replica entre instancias de EventLog mediante cualquier algoritmo de consenso, como RAFT.

También hay otro problema que me preocupa: la eliminación en cascada o la detección de casos de eliminación de objetos que tienen enlaces de otros objetos. 

Código fuente

Si has leído hasta aquí, entonces todo lo que queda es leer el código; se puede encontrar en GitHub:
https://github.com/DiverOfDark/ObjectRepository

Fuente: habr.com

Añadir un comentario