Desde que eu comecei a trabalhar com programação, há mais de 10 anos atrás, todos os clientes pediam que seus sistemas tivessem algum tipo log de mudança de entidades. Isso é realmente comum, de forma a saber quem foi o usuário responsável pela inclusão ou alteração de uma determinada entidade do banco. Vou apresentar aqui duas formas que isso pode ser feito, sendo que cada uma tem suas “vantagens” em relação a outra. A primeira será como guardar as informações nas próprias entidades. A segunda será criando uma tabela de log para armezanar as informações.
Um outro ponto importante de se comentar: há diversos tipos de logs para os sistemas. Logs do sistema em si (gerados de acordo com algumas situações, como alertas, erros, informações normais, debug, trace do sistema, dentre outros), logs/notificações (onde você guarda informações sobre ações importantes para notificar o usuário) e o log de alteração de entidades. Esse artigo está tratando deste último apenas.
Guardando informações nas próprias entidades
Talvez a forma mais comum. Basicamente, você cria uma classe abstrata que irá conter os campos “comuns”. Para fins de exemplo, vou colocar abaixo uma classe deste tipo:
using System; namespace HodStudio.Domain.Common { public abstract class BaseEntity { public Guid Id { get; set; } = Guid.NewGuid(); public Guid? CreatedBy { get; set; } public DateTime CreatedAt { get; set; } public Guid? UpdatedBy { get; set; } public DateTime UpdatedAt { get; set; } } }
Essa classe define algumas propriedades:
- Id: Como toda entidade possuí um identificador. Para facilitar, coloquei essa propriedade aqui. E automaticamente já instanciamos ela com um novo Guid.
- CreatedBy e CreatedAt: usuário que criou a entidade e quando ela foi criada.
- UpdatedBy e UpdatedAt: último usuário que alterou a entidade e quando foi esta última alteração.
Tenho o costume de usar Guid para ids por um motivo bem simples: com o advento das Rest API’s, virou totalmente comum você ter uma chamada do tipo: url/api/user/1, onde esse 1 é o id do usuário. Dessa forma, se você não tiver algum tipo de proteção no seu sistema, nada impediria de alguém acessar url/api/user/2 e ver os dados de outro usuário. Esse erro é bastante comum em sistemas antigos que implementaram uma API de acesso externo posteriormente. Utilizando Guid ao invés de int (ou long), você faz com que seja praticamente impossível que um usuário consiga pegar outro dado que não seja o que você quer que ele tenha acesso.
Um comentário importante: devido a uma série de fatores, não é interessante (neste caso) criar Foreign Keys para a tabela de usuário. Caso você crie, ao excluir um usuário, terá que excluir todos os logs de todas as ações efetudas por ele no sistema. Logo, eu recomendo apenas criar os campos sem as FKs. Outro detalhe: algumas empresas preferiam o logo com o nome de usuário, ao invés de um simples Id. Porém, eu não recomendo isso, principalmente devido a nova política de Armazenamento de Dados da Europa, onde um usuário pode pedir para que você Anonimize (o termo é estranho em português), fazendo com que ele se torne um usuário anônimo e que todos os seus dados privados, como nome, endereço e documentos, sejam excluídos do sistema de alguma forma. Logo, dessa forma, mesmo que queiramos excluir um usuário, não teremos problemas futuros.
Aqui, vale a pena talvez uma pausa para explicar (por alto) o funcionamento do EF. O Entity Framework é o ORM da Microsoft que já está na sua versão 6.2. É extremamente robusto e cada vez atende aos complexos requesitos de todos os sistemas. O importante para nós neste ponto é dizer que quando você incluí, altera ou excluí uma entidade, o EF guarda esta informação numa coleção própria dele (DbSet) e, ao ser executado o comando SaveChanges (ou SaveChangesAsync), ele irá executar os comandos necessários no banco de dados para que estas mudanças sejam refletidas.
Logo, para os dois modelos, nós iremos fazer um override do método SaveChanges do EF. Neste caso, porém, estou fazendo o override do método Async. Caso você esteja utilizando o método padrão, faça o override dele. Recomendo, neste interim uma lida no meu outro post, onde, durante um refactory, entendi o porquê de usar ou não o Async: Sharebook: um refactory em 4h.
public override async Task SaveChangesAsync(CancellationToken cancellationToken = default(CancellationToken)) { var now = DateTime.now; var user = new Guid(); //Aqui você irá pegar o Id do usuário responsável pela alteração foreach (var entry in ChangeTracker.Entries()) { if (entry.State == EntityState.Added) { entry.Entity.CreatedBy = user; entry.Entity.CreatedAt = now; entry.Entity.UpdatedBy = user; entry.Entity.UpdatedAt = now; } else if (entry.State == EntityState.Modified) { entry.Entity.UpdatedBy = user; entry.Entity.UpdatedAt = now; } } return await base.SaveChangesAsync(cancellationToken); }
Deste modo, ao executar o método SaveChangesAsync, iremos pegar as entidades e atualizar os campos relacionados ao nosso “Log”. Aqui, cabe um detalhe: no caso, estou considerando que todas as suas entidades herdam da classe básica que criamos. Caso contrário, você poderá ter um erro de runtime ao se deparar com uma classe que não possuí tais propriedades.
Vantagens
- As informações são armezadas direto na entidade, facilitando as consultas
Sim. Eu só consigo ver uma vantagem em tal método. Se você tiver uma ideia de outra vantagem, comente para que eu possa incrementar o post 😉
Desvantagens
Aqui eu vejo algumas, e não só uma…
- Você sabe quando uma entidade foi criada e por quem. Mas não com quais valores.
- Ao alterar uma entidade, você sabe quem e quando foi feita a alteração. Mas não o que foi alterado.
- Quando uma entidade é excluída, não há nenhuma informação registrada sobre isso.
- Como guardamos apenas a informação sobre o último usuário e data da última alteração, as ações mais antigas são perdidas. Logo, após 5 mudanças, você sabe apenas quem criou e quem foi o último a alterar. Mas não consegue saber quem foi que fez a segunda alteração, por exemplo.
Esses quatro fatores fazem com que esse modelo de log não seja o meu preferido. Então, vamos ao segundo modo.
Criando uma Tabela de Log
Primeiramente, vamos reescrever nossa entidade básica, já que agora ela só precisa ter um campo: o Id.
using System; namespace HodStudio.Domain.Common { public abstract class BaseEntity { public Guid Id { get; set; } = Guid.NewGuid(); } }
Nesse caso, precisamos do campo Id, já que as outras informações serão registradas direto pela nossa tabela de log. Que está definida abaixo.
using HodStudio.Domain.Common; using System; namespace HodStudio.Domain { public class LogEntry : BaseEntity { public Guid? UserId { get; set; } public string EntityName { get; set; } public Guid EntityId { get; set; } public string Operation { get; set; } public DateTime LogDateTime { get; set; } public string ValuesChanges { get; set; } } }
Alguns comentários sobre essa entidade:
- Ela herda do nosso BaseEntity. Logo, ela terá um campo Id automaticamente definido.
- Temos o UserId como Guid?, o que nos possibilita guardar o log de entidades que não possuem usuário vinculado.
- O EntityName nos dirá qual a entidade que se refere a este log.
- O EntityId é o Id da entidade que se refere este log.
- Operation nos informa se foi inclusão, alteração ou exclusão.
- LogDateTime quando ocorreu a operação.
- ValuesChanges: esse será a propriedade que guardará as informações sobre as modificações que foram feitas na entidade. Falarei mais sobre ela quando chegarmos no SaveChanges.
Nossa entidade de log possuí todos os campos que precisamos para gerar nosso log. Por não guardar apenas a última, mas todas as alterações, teremos a informação completa do que aconteceu com tal entidade. Vamos então ao método SaveChangesAsync. Por ser um código maior e com uma complexidade um pouco mais elevada que o modelo anterior, resolvi separar ele numa classe a parte e criei um método de extensão para o DbContext.
using JsonDiffPatchDotNet; using Microsoft.EntityFrameworkCore; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using HodStudio.Domain; using HodStudio.Domain.Common; using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; namespace HodStudio.Repository { public static class LoggingContext { private static readonly List entityStates = new List() { EntityState.Added, EntityState.Modified, EntityState.Deleted }; public static async Task LogChanges(this ApplicationDbContext context) { var logTime = DateTime.Now; const string emptyJson = "{}"; const string idColumn = "Id"; Guid? user = null; // Aqui você irá pegar o Id do usuário var changes = context.ChangeTracker.Entries() .Where(x => entityStates.Contains(x.State) && x.Entity.GetType().IsSubclassOf(typeof(BaseEntity))) .ToList(); var jdp = new JsonDiffPatch(); foreach (var item in changes) { var original = emptyJson; var updated = JsonConvert.SerializeObject(item.CurrentValues.Properties.ToDictionary(pn => pn.Name, pn => item.CurrentValues[pn])); if (item.State == EntityState.Modified) { var dbValues = await item.GetDatabaseValuesAsync(); original = JsonConvert.SerializeObject(dbValues.Properties.ToDictionary(pn => pn.Name, pn => dbValues[pn])); } var EntityDiff = JToken.Parse(jdp.Diff(original, updated)).ToString(Formatting.None); var logEntry = new LogEntry() { EntityName = item.Entity.GetType().Name, EntityId = new Guid(item.CurrentValues[idColumn].ToString()), LogDateTime = logTime, Operation = item.State.ToString(), UserId = user, ValuesChanges = EntityDiff, }; context.LogEntries.Add(logEntry); } } } }
Vamos por partes…!
var changes = context.ChangeTracker.Entries() .Where(x => entityStates.Contains(x.State) && x.Entity.GetType().IsSubclassOf(typeof(BaseEntity))) .ToList();
Aqui pegamos todas as entidades que foram incluídas, alteradas ou excluídas e que herdam da nossa BaseEntity. Um detalhe muito importante: como estaremos incluindo logs no DbSet do EF, estaremos alterando ao ChangeTracker. Por isso, é de vital importância para que o código funcione perfeitamente que usemos o ToList no final. Desta forma, é gerada uma coleção (IList) que não está ligada diretamente ao ChangeTracker.
Uma coisa que eu resolvi fazer nesse log é que ao invés de guardar o estado antes e o estado depois, seria mais interessante (e mais legível) guardar apenas as alterações que foram feitas. Desta forma, numa situação de alteração, quem visualizar o log verá apenas os campos modificados, com o seu valor original e o valor atualizado. Isso facilita e muito a leitura do log, assim como fazer buscas sobre quem foi o usuário que alterou determinado campo, ou para um determinado valor. Para isso, utilizei a biblioteca JsonDiffPatchDotNet.
var original = emptyJson; var updated = JsonConvert.SerializeObject(item.CurrentValues.Properties.ToDictionary(pn => pn.Name, pn => item.CurrentValues[pn])); if (item.State == EntityState.Modified) { var dbValues = await item.GetDatabaseValuesAsync(); original = JsonConvert.SerializeObject(dbValues.Properties.ToDictionary(pn => pn.Name, pn => dbValues[pn])); } var EntityDiff = JToken.Parse(jdp.Diff(original, updated)).ToString(Formatting.None);
Nesse caso, para cada entidade eu vou primeiro gerar um Json, que possuí um dicionário com as propriedades e seus valores atuais. Caso seja uma alteração, eu preciso ir no banco para pegar os valores “originais”. E é isso que o nosso if está fazendo, em caso apenas de alterações. Com isso, ele também irá gerar um Json com o dicionário de valores. Com esses dois Json criados, eu utilizo a biblioteca que comentei e faço o Diff das duas entidades. No caso de inclusão e alteração, como temos um Json vazio como “original”, o diff irá conter todas as propriedades. Caso seja uma alteração, ele irá comparar os valores originais e atuais e gerar um json com as diferenças entre ambos.
Após isso, precisamos apenas gerar a nova entidade de log com os dados e adicionar no nosso DbSet. E assim temos um log totalmente pronto. Agora só precisamos colocar ele para ser chamado no SaveChanges e voilá! Como a nossa classe está no mesmo namespace que o ApplicationDbContext, precisamos apenas chamar o método. Como é um método de extensão, precisamos utilizar o this nesse caso.
public override async Task SaveChangesAsync(CancellationToken cancellationToken = default(CancellationToken)) { await this.LogChanges(); return await base.SaveChangesAsync(cancellationToken); }
Vantagens
- Rastreamento total de todas as informações das entidades.
- Log para todas as operações executadas na entidade.
- Informações incrementais, facilitando o rastreamento de problemas.
Desvantagens
- Uma tabela a mais para gerenciar no banco.
- Complexidade lógica maior para gerar o log.
Considerações finais
E se eu quiser usar os dois modelos juntos?
Não há problema algum! Você fazer um mix dos dois modelos sem nenhum problema. Você terá na entidade a informação de quem criou a entidade e de quem foi o último usuário que alterou ela, mas para ver os dados alterados, continuará precisando ir na tabela de logs. Além disso, caso uma entidade seja excluída, esse informação residirá apenas na tabela de log.
E se eu quiser guardar os valores originais e os atualizados?
Você pode, ao invés de implementar um log incremental, guardar todos os dados e deixar para que o próprio leitor do log verifique o que foi modificado. Ou você pode guardar tudo: valores originais, atualizados e o diff. Depende, neste caso, da escolha de cada programador e o que é necessário para o seu sistema.
E se eu quiser ver o código completo?
Acesse o repositório do ShareBookBR em https://github.com/SharebookBR/backend e lá você verá essa implementação completa. 😀
Agradecimentos
Este post foi inspirado pela galera que está trabalhando comigo no ShareBook, projeto open source do Raffaelo Damgaard que estou apoiando. Eu implementei exatamente este log no sistema, e algumas pessoas, como o Thiago Maturana, perguntaram se eu poderia fazer uma explicação mais detalhada sobre o mesmo. Agradeço também ao Walter Vinicius, que ao revisar o post, apontou algumas esquecimentos.