ObjectRepository - padrão de repositório na memória .NET para seus projetos domésticos

Por que armazenar todos os dados na memória?

Para armazenar dados de sites ou back-end, o primeiro desejo da maioria das pessoas sãs será escolher um banco de dados SQL. 

Mas às vezes vem à mente que o modelo de dados não é adequado para SQL: por exemplo, ao construir uma pesquisa ou gráfico social, você precisa procurar relacionamentos complexos entre objetos. 

A pior situação é quando você trabalha em equipe e um colega não sabe construir consultas rápidas. Quanto tempo você gastou resolvendo problemas N+1 e construindo índices adicionais para que o SELECT na página principal fosse concluído em um período de tempo razoável?

Outra abordagem popular é o NoSQL. Vários anos atrás, houve muito entusiasmo em torno deste tópico - em qualquer ocasião conveniente, eles implantaram o MongoDB e ficaram satisfeitos com as respostas na forma de documentos JSON (aliás, quantas muletas você teve que inserir por causa dos links circulares nos documentos?).

Sugiro tentar outro método alternativo - por que não tentar armazenar todos os dados na memória do aplicativo, salvando-os periodicamente em armazenamento aleatório (arquivo, banco de dados remoto)? 

A memória tornou-se barata e todos os dados possíveis para a maioria dos projetos de pequeno e médio porte caberão em 1 GB de memória. (Por exemplo, meu projeto doméstico favorito é rastreador financeiro, que mantém estatísticas diárias e histórico de minhas despesas, saldos e transações durante um ano e meio, consome apenas 45 MB de memória.)

Prós:

  • O acesso aos dados se torna mais fácil - você não precisa se preocupar com consultas, carregamento lento, recursos ORM, você trabalha com objetos C# comuns;
  • Não há problemas associados ao acesso de diferentes threads;
  • Muito rápido - sem solicitações de rede, sem tradução de código para uma linguagem de consulta, sem necessidade de (des)serialização de objetos;
  • É aceitável armazenar dados em qualquer formato - seja em XML no disco, ou no SQL Server, ou no Azure Table Storage.

Contras:

  • O dimensionamento horizontal é perdido e, como resultado, a implantação com tempo de inatividade zero não pode ser realizada;
  • Se o aplicativo travar, você poderá perder parcialmente os dados. (Mas nosso aplicativo nunca trava, certo?)

Como isso funciona?

O algoritmo é o seguinte:

  • No início, é estabelecida uma conexão com o armazenamento de dados e os dados são carregados;
  • Um modelo de objeto, índices primários e índices relacionais (1:1, 1:Muitos) são construídos;
  • Uma assinatura é criada para alterações nas propriedades do objeto (INotifyPropertyChanged) e para adicionar ou remover elementos da coleção (INotifyCollectionChanged);
  • Quando a assinatura é acionada, o objeto alterado é adicionado à fila para gravação no armazenamento de dados;
  • As alterações no armazenamento são salvas periodicamente (em um cronômetro) em um thread em segundo plano;
  • Ao sair do aplicativo, as alterações também são salvas no armazenamento.

Exemplo de código

Adicionando as dependências necessárias

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

Descrevemos o modelo de dados que será armazenado no armazenamento

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

Então o modelo de objeto:

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 por fim, a própria classe de repositório para acesso aos dados:

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

Crie uma instância do ObjectRepository:

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

Se o projeto usará HangFire

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

Inserindo um novo objeto:

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

Com esta chamada, o objeto Modelo Parental é adicionado ao cache local e à fila para gravação no banco de dados. Portanto, esta operação leva O(1), e este objeto pode ser trabalhado imediatamente.

Por exemplo, para encontrar este objeto no repositório e verificar se o objeto retornado é a mesma instância:

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

O que está acontecendo? Definir () devolve Dicionário de Tabela, que contém Dicionário Simultâneo e fornece funcionalidade adicional de índices primários e secundários. Isso permite que você tenha métodos para pesquisar por ID (ou outros índices de usuário arbitrários) sem iterar completamente todos os objetos.

Ao adicionar objetos a ObjectRepositório uma assinatura é adicionada para alterar suas propriedades, portanto, qualquer alteração nas propriedades também resulta na adição desse objeto à fila de gravação. 
Atualizar propriedades externas parece o mesmo que trabalhar com um objeto POCO:

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

Você pode excluir um objeto das seguintes maneiras:

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

Isso também adiciona o objeto à fila de exclusão.

Como funciona a poupança?

ObjectRepositório quando os objetos monitorados mudam (seja adicionando ou excluindo ou alterando propriedades), gera um evento Modelo alteradoinscrito em ISarmazenamento. Implementações ISarmazenamento quando ocorre um evento Modelo alterado as alterações são colocadas em três filas - para adição, atualização e exclusão.

Também implementações ISarmazenamento na inicialização, eles criam um cronômetro que faz com que as alterações sejam salvas a cada 5 segundos. 

Além disso, existe uma API para forçar uma chamada de salvamento: ObjectRepository.Save().

Antes de cada salvamento, as operações sem sentido são primeiro removidas das filas (por exemplo, eventos duplicados - quando um objeto foi alterado duas vezes ou objetos adicionados/removidos rapidamente) e só então o próprio salvamento. 

Em todos os casos, todo o objeto atual é salvo, portanto é possível que os objetos sejam salvos em uma ordem diferente daquela em que foram alterados, incluindo versões de objetos mais recentes do que no momento em que foram adicionados à fila.

O que mais está lá?

  • Todas as bibliotecas são baseadas no .NET Standard 2.0. Pode ser usado em qualquer projeto .NET moderno.
  • A API é thread-safe. As coleções internas são implementadas com base em Dicionário Simultâneo, os manipuladores de eventos possuem bloqueios ou não precisam deles. 
    A única coisa que vale a pena lembrar é ligar ObjectRepository.Save();
  • Índices arbitrários (exigem exclusividade):

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

Quem usa?

Pessoalmente, comecei a usar essa abordagem em todos os projetos de hobby porque é conveniente e não exige grandes despesas para escrever uma camada de acesso a dados ou implantar infraestrutura pesada. Pessoalmente, armazenar dados em litedb ou em um arquivo geralmente é suficiente para mim. 

Mas no passado, quando a extinta startup EscapeTeams (Eu pensei que aqui está, dinheiro - mas não, experiência de novo) – usado para armazenar dados no Armazenamento de Tabelas do Azure.

Planos para o futuro

Gostaria de corrigir uma das principais desvantagens dessa abordagem - o dimensionamento horizontal. Para fazer isso, você precisa de transações distribuídas (sic!), ou tomar uma decisão obstinada de que os mesmos dados de diferentes instâncias não devem ser alterados, ou deixá-los mudar de acordo com o princípio “quem é o último está certo”.

Do ponto de vista técnico, vejo o seguinte esquema como possível:

  • Armazene EventLog e Snapshot em vez do modelo de objeto
  • Encontre outras instâncias (adicionar endpoints de todas as instâncias às configurações? descoberta udp? mestre/escravo?)
  • Replique entre instâncias do EventLog por meio de qualquer algoritmo de consenso, como RAFT.

Há também outro problema que me preocupa - exclusão em cascata, ou detecção de casos de exclusão de objetos que possuem links de outros objetos. 

Código fonte

Se você leu até aqui, então tudo o que resta é ler o código; ele pode ser encontrado no GitHub:
https://github.com/DiverOfDark/ObjectRepository

Fonte: habr.com

Adicionar um comentário