loading...
Cover image for Event Sourcing Parte 3: Snapshots!

Event Sourcing Parte 3: Snapshots!

wsantosdev profile image William Santos ・7 min read

Olá!

Esta é a terceira parte de nossa série sobre Event Sourcing (ES) e, neste artigo, vamos falar sobre snapshots.

No artigo anterior falamos sobre como persistir e restaurar nossos modelos junto ao repositório de eventos (Event Store).

Desta vez, falaremos sobre esta técnica de otimização que pode ser usada em modelos cuja vida seja muito longa, e que exija a carga de centenas ou até mesmo milhares de eventos para sua reconstrução.

Vamos lá?!

O que é um snapshot?

Um snapshot é uma representação do estado de nosso modelo em um determinado momento de seu ciclo de vida. No artigo anterior falamos sobre versionamento de modelos. Certo? Pois bem! Podemos assumir que um snapshot vai corresponder a uma versão específica de nosso modelo.

O uso de snapshot só é recomendado quando a reconstrução de um modelo se torna muito custosa. Ou seja, quando já há percepção de prejuízo de performance por conta da necessidade de recuperar todos os eventos de um modelo e processá-los para chegar à versão atual.

A intenção do snapshot é permitir um resgate mais rápido do nosso modelo pois, carregando-o, precisaremos recuperar apenas os eventos que lhe sejam posteriores, reduzindo a carga sobre o repositório de eventos e sobre nosso modelo.

Por simplicidade, vamos abordar uma versão da técnica onde se estabelece um intervalo limite de eventos que deve ser atingido para que um snapshot seja gerado. Desta forma, toda vez que o número de eventos novos alcançar este limite, geraremos um novo snapshot. Por exemplo, se o intervalo limite for 500, a cada 500 novos eventos um snapshot será gerado, e apenas os eventos seguintes a este, no limite de 500, serão recuperados e processados.

Soa confuso? Não se preocupe, veremos esta ideia representada em código mais à frente!

Adequando o modelo base à reconstrução via snapshots

O primeiro passo para empregar a técnica de snapshot é tornar o modelo consciente de sua existência. Para isso, vamos fazer algumas alterações em nosso modelo base, o EventSourcingModel.

namespace Lab.EventSourcing.Core
{
    public abstract class EventSourcingModel<T> where T : EventSourcingModel<T>
    {
        private Queue<IEvent> _pendingEvents = new Queue<IEvent>();
        public IEnumerable<IEvent> PendingEvents { get => _pendingEvents.AsEnumerable(); }
        public Guid Id { get; protected set; }
        public int Version { get; protected set; } = 0;
        protected int NextVersion { get => Version + 1; }

        protected EventSourcingModel(IEnumerable<ModelEventBase> persistedEvents)
        {
            if (persistedEvents != null)
                ApplyPersistedEvents(persistedEvents);
        }

        public static T Load(IEnumerable<ModelEventBase> persistendEvents) =>
            (T)Activator.CreateInstance(typeof(T),
                                         BindingFlags.NonPublic | BindingFlags.Instance,
                                         null,
                                         new object[] { persistendEvents },
                                         CultureInfo.InvariantCulture);

        protected EventSourcingModel(T snapshot, IEnumerable<ModelEventBase> persistedEvents) 
        {
            if(snapshot != null)
                ApplySnapshot(snapshot);

            if(persistedEvents != null)
                ApplyPersistedEvents(persistedEvents);
        }

        public static T Load(T snapshot, IEnumerable<ModelEventBase> persistendEvents) =>
            (T) Activator.CreateInstance(typeof(T),
                                         BindingFlags.NonPublic | BindingFlags.Instance,
                                         null,
                                         new object [] { snapshot, persistendEvents }, 
                                         CultureInfo.InvariantCulture);

        protected abstract void ApplySnapshot(T snapshot);

        protected void ApplyPersistedEvents(IEnumerable<ModelEventBase> events)
        {
            foreach (var e in events)
            {
                Apply(e);
                Version = e.ModelVersion;
            }
        }

        protected void RaiseEvent<TEvent>(TEvent pendingEvent) where TEvent: ModelEventBase
        {
            _pendingEvents.Enqueue(pendingEvent);
            Apply(pendingEvent);
            Version = pendingEvent.ModelVersion;
        }

        protected abstract void Apply(IEvent pendingEvent);

        public void Commit() =>
            _pendingEvents.Clear();
    }
}

Repare que nosso modelo base ficou mais complexo. A partir de agora ele tornou-se genérico para permitir a inclusão dos métodos estáticos Load, um que recebe uma lista de eventos e chama o construtor que os processará e retornará uma instância pronta, e outro que fará o mesmo a partir de um snapshot e dos eventos que o seguiram.

Além dos métodos Load, há um método ApplySnapshot que receberá um snapshot e se encarregará de atribuir ao nosso modelo o estado recebido, como no exemplo abaixo do modelo Inventory:

namespace Lab.EventSourcing.Inventory
{
    public class Inventory : EventSourcingModel<Inventory>
    {
       ...
       protected override void ApplySnapshot(Inventory snapshot) =>
            (Id, Version, _stock) = (snapshot.Id, snapshot.Version, snapshot._stock);
       ...
    }
}

Um repositório de eventos mais enxuto

Agora que nosso modelo está pronto para receber snapshots e reconstituir seu estado, vamos atualizar nosso repositório de eventos para retirar dele a responsabilidade por construir nosso modelo.

namespace Lab.EventSourcing.Core
{
    public class EventStore
    {
        private readonly EventStoreDbContext _eventStoreContext;

        public static EventStore Create() =>
            new EventStore();

        private EventStore() =>
            _eventStoreContext = new EventStoreDbContext(new DbContextOptionsBuilder<EventStoreDbContext>()
                                                                .UseInMemoryDatabase(databaseName: "EventStore")
                                                                .EnableSensitiveDataLogging()
                                                                .Options);

        public void Commit<TModel>(TModel model) where TModel : EventSourcingModel<TModel>
        {
            var events = model.PendingEvents.Select(e => Event.Create(model.Id,
                                                                      ((ModelEventBase)e).ModelVersion,
                                                                      e.GetType().AssemblyQualifiedName,
                                                                      ((ModelEventBase)e).When,
                                                                      JsonConvert.SerializeObject(e)));

            _eventStoreContext.Events.AddRange(events);
            _eventStoreContext.SaveChanges();
            model.Commit();
        }

        public IEnumerable<ModelEventBase> GetById(Guid id) =>
            GetEvents(e => e.ModelId == id);

        public IEnumerable<ModelEventBase> GetByVersion(Guid id, int version) =>
            GetEvents(e => e.ModelId == id && e.ModelVersion <= version);

        public IEnumerable<ModelEventBase> GetByTime(Guid id, DateTime until) =>
            GetEvents(e => e.ModelId == id && e.When <= until);

        public IEnumerable<ModelEventBase> GetFromVersion(Guid id, int version) =>
            GetEvents(e => e.ModelId == id && e.ModelVersion > version);

        private IEnumerable<ModelEventBase> GetEvents(Expression<Func<Event, bool>> expression) =>
            _eventStoreContext.Events.Where(expression)
                                     .OrderBy(e => e.ModelVersion)
                                     .Select(e => JsonConvert.DeserializeObject(e.Data, Type.GetType(e.Type)))
                                     .Cast<ModelEventBase>();

        private class EventStoreDbContext : DbContext
        {
            public EventStoreDbContext(DbContextOptions<EventStoreDbContext> options) : base(options) { }

            public DbSet<Event> Events { get; set; }

            protected override void OnModelCreating(ModelBuilder modelBuilder) =>
                modelBuilder.Entity<Event>().HasKey(k => new { k.ModelId, k.ModelVersion });
        }
    }
}

Perceba que pouca coisa mudou em nosso repositório. O antigo método LoadModel que reconstruía nosso modelo a partir da lista de eventos foi reduzido e renomeado para GetEvents e, a partir de agora, retorna apenas os eventos que atendam ao critério definido na expressão recebida como argumento.

Também foi adicionado um novo método GetFromVersion cuja função é recuperar os eventos a partir de uma dada versão de um dado modelo, e é este método que vai nos permitir recuperar apenas os eventos que seguem o último snapshot.

Fácil. Não? Vamos agora ao repositório de snapshots.

Persistindo e recuperando snapshots

No início do artigo eu disse que, por simplicidade, utilizaríamos uma abordagem baseada em intervalos limite para a criação de snapshots. No código abaixo temos nosso repositório de snapshots, com esse intervalo limite definido na forma da constante SnapshotThreshold -- poderia ser recebido via construtor, ou mesmo via options pattern (em inglês) no caso de uma aplicação Asp.Net Core.

namespace Lab.EventSourcing.Core
{
    public class SnapshotStore
    {
        private const int SnapshotThreshold = 2;
        private readonly JsonSerializerSettings _jsonSerializerSettings = 
            new JsonSerializerSettings { ContractResolver = new CustomContractResolver() };

        private readonly SnapshotDbContext _dbContext;

        public static SnapshotStore Create() =>
            new SnapshotStore();

        private SnapshotStore() =>
            _dbContext = new SnapshotDbContext(new DbContextOptionsBuilder<SnapshotDbContext>()
                                                                .UseInMemoryDatabase(databaseName: "SnapshotStore")
                                                                .EnableSensitiveDataLogging()
                                                                .Options);

        public bool ShouldTakeSnapshot(int modelVersion) =>
            modelVersion % SnapshotThreshold == 0;

        public void Save<TModel>(TModel model) where TModel : EventSourcingModel<TModel>
        {
            _dbContext.Snapshots.Add(Snapshot.Create(model.Id,
                                                     model.Version,
                                                     model.GetType().AssemblyQualifiedName,
                                                     JsonConvert.SerializeObject(model, _jsonSerializerSettings)));

            _dbContext.SaveChanges();
        }

        public TModel GetById<TModel>(Guid id) where TModel : EventSourcingModel<TModel>
        {
            return  _dbContext.Snapshots.Where(s => s.ModelId == id)
                                        .OrderByDescending(s => s.ModelVersion)
                                        .Take(1)
                                        .Select(s => JsonConvert.DeserializeObject(s.Data, Type.GetType(s.Type), _jsonSerializerSettings))
                                        .Cast<TModel>()
                                        .FirstOrDefault();
        }

        private class SnapshotDbContext : DbContext
        {
            public SnapshotDbContext(DbContextOptions<SnapshotDbContext> options) : base(options) { }
            public DbSet<Snapshot> Snapshots { get; set; }

            protected override void OnModelCreating(ModelBuilder modelBuilder) =>
                modelBuilder.Entity<Snapshot>().HasKey(k => new { k.ModelId, k.ModelVersion });
        }
    }
}

Mais uma vez utilizamos o Entity Framework Core InMemory para fazer a persistência, evitando assim setup de banco de dados. Vamos agora analisar este repositório de snapshots.

Repare que há um método chamado ShouldTakeSnapshot. Este método recebe o número da versão do modelo e verifica se o resto de sua divisão pelo intervalo limite definido, SnapshotThreshold, é igual a zero. Caso o resultado seja verdadeiro, temos um indicador de que um snapshot deve ser persistido.

A persistência acontece no método Save, que recebe nosso modelo, o serializa e o persiste em seguida, de forma quase idêntica àquela que utilizamos para persistir nossos eventos.

E para restaurar nosso modelo, utilizamos o método GetById, que irá se encarregar de obter o snapshot mais recente disponível, desserializá-lo e retorná-lo em seguida.

Nota: No repositório de snapshots utilizamos uma configuração especial para o serializador JSON. Esta configuração estará disponível no código-fonte deste artigo, e torna possível a serialização de campos e propriedades não públicos, para que todo o estado interno de nosso modelo seja serializado.

E, acredite ou não, é só isso!

Considerações finais

Um detalhe importante que vale a pena citar é que é comum ver exemplos na internet de abordagens onde o snapshot seja persistido junto aos eventos, aproveitando o repositório de eventos e criando um evento do tipo Snapshot. Entendemos esta solução como sub-ótima porque quando um novo evento é gerado, é gerada também uma nova versão de nosso modelo. E, para fins de auditoria, que é a finalidade do uso de ES, não faz sentido algum haver uma versão que não represente um evento de negócio, mas sim uma implementação de infraestrutura.

É preciso deixar claro que snapshot é uma técnica de otimização e, como tal, deve ter sua implementação adiada até que seja inevitável. Ou seja, apenas quando houver de fato a necessidade de se otimizar a reconstrução de um modelo a técnica deve ser empregada.

Caso o ciclo de vida de um modelo seja curto, ou seja, existam poucos eventos entre seu estado inicial e seu estado final, o uso de snapshots deve ser evitado. Do contrário, será adicionada complexidade desnecessária e haverá penalidade de desempenho, já que haverá mais um repositório a ser consultado e, eventualmente, mais processamento a ser feito desnecessariamente.

Próximos passos

Com este artigo concluímos a apresentação dos componentes básicos do padrão ES. A partir de agora vamos explorar sua interação com outros padrões, e demonstrar algumas técnicas que permitem tirar maior proveito do padrão.

No próximo artigo falaremos sobre ES e Eventos de Domínio. A intenção é demomstrar como este padrão serve de ponte para a combinação entre ES e CQRS, gerando uma forma de auditoria com consultas que não demandem a recriação de nosso modelo para operações de leitura.

Como citamos no início, segue o código-fonte relativo a este artigo. Foram incluídos testes para demonstrar não apenas o uso de snapshots como, também, as mudanças que foram introduzidas nos demais componentes para tornar seu uso possível.

Gostou? Nos deixe saber pelos indicadores! Ficou com alguma dúvida? Informe pelos comentários que responderemos assim que possível.

Muito obrigado, e até o próximo artigo!

Posted on by:

wsantosdev profile

William Santos

@wsantosdev

Um desenvolvedor que brinca com .NET e se diverte um pouco com outras linguagens e tecnologias. { A software developer who plays with .NET and have some fun with other languages and technologies. }

Discussion

pic
Editor guide