Usando Entity Framework 4.1 DbContext para controle de alterações e log de auditoria

Muitas aplicações têm a necessidade de manter as informações de auditoria sobre as alterações feitas nos objetos de banco de dados.

Tradicionalmente, isso seria feito através de eventos de registro, procedimentos armazenados que implementam o registro, ou o uso de arquivos ou tabelas espelho ou temporários para armazenar os valores antigos antes da modificação.

Com tudo isso, sempre há uma chance de que um desenvolvedor possa deixar de fazer as implementações de log em uma seção específica do código, e que as mudanças possam ser feitas através da aplicação sem efetuar a alteração corretamente.

Com API DbContext Entity Framework 4,1 é  bastante fácil de implementar o log de auditoria mais robusto em sua aplicação.

O DbContext expõe a propriedade ChangeTracker, que contém todas as alterações feitas desde a chamada mais recente do método SaveChanges. Esta propriedade pode ser usada para obter informações sobre os valores atuais e anteriores de quaisquer entidades no objeto DbContext. Sobrecarregando o método SaveChanges() podemos implementar auditoria de log registrando quaisquer mudanças de dados que são salvas no banco de dados. Para o propósito deste exemplo, vamos supor que queremos escrever uma linha a uma tabela de banco de dados que contém todo o conteúdo da nova entidade para inserções (não havia conteúdos anteriores), todo o conteúdo anteriores para excluir.

Para podermos armazenar o log das informações devemos criar uma tabela para armazenar os dados do registro antes da modificação ( valor original ) e do registro após a modificação ( valor novo ). No caso de uma inserção teremos apenas o valor novo. No caso de uma exclusão teremos apenas o valor original e no caso de uma alteração teremos o valor original e o valor novo.

Por exemplo, se temos um banco de dados que contém as tabelas a seguir:

01 CREATE TABLE Items
02     (
03         ItemID UNIQUEIDENTIFIER NOT NULL,
04         ItemDescription NVARCHAR(100) NOT NULL,
05         CONSTRAINT PK_Items PRIMARY KEY NONCLUSTERED (ItemID)
06     )
07 GO
08
09 CREATE TABLE ItemAdditionalAttributes
10     (
11         ItemAdditionalAttributeID UNIQUEIDENTIFIER NOT NULL,
12         ItemID UNIQUEIDENTIFIER NOT NULL,
13         AttributeName NVARCHAR(50) NOT NULL,
14         AttributeValue NVARCHAR(500) NOT NULL,
15         CONSTRAINT PK_ItemAdditionalAttributes PRIMARY KEY NONCLUSTERED (ItemAdditionalAttributeID)
16     )
17 GO
18
19 ALTER TABLE ItemAdditionalAttributes ADD CONSTRAINT FK_ItemAdditionalAttributes_Items FOREIGN KEY (ItemID) REFERENCES dbo.Items (ItemID)
20 GO

Podemos, então, adicionar uma tabela adicional para registro, semelhante a este:

01 CREATE TABLE AuditLog
02     (
03         AuditLogID UNIQUEIDENTIFIER NOT NULL,
04         UserID NVARCHAR(50) NOT NULL,
05         EventDateUTC DATETIME NOT NULL,
06         EventType CHAR(1) NOT NULL,
07         TableName NVARCHAR(100) NOT NULL,
08         RecordID NVARCHAR(100) NOT NULL,
09         ColumnName NVARCHAR(100) NOT NULL,
10         OriginalValue NVARCHAR(MAX) NULL,
11         NewValue NVARCHAR(MAX) NULL
12         CONSTRAINT PK_AuditLog PRIMARY KEY NONCLUSTERED (AuditLogID)
13     )
14 GO

Usando EF Code-First, as classes de modelo para estas tabelas será parecido como o mostrado abaixo:

01 using System;
02 using System.Collections.Generic;
03 using System.ComponentModel.DataAnnotations;
04
05 namespace EFChangeTrackingDemo.Models
06 {
07     [Table("Items")]
08     public class Item
09     {
10         [Key]
11         public System.Guid ItemID { get; set; }
12
13         [Required]
14         [MaxLength(100)]
15         public string ItemDescription { get; set; }
16
17         public virtual ICollection ItemAdditionalAttributes { get; set; }
18
19         public Item()
20         {
21             this.ItemAdditionalAttributes = new List();
22         }
23     }
24 }
01 using System;
02 using System.Collections.Generic;
03 using System.ComponentModel.DataAnnotations;
04
05 namespace EFChangeTrackingDemo.Models
06 {
07     [Table("ItemAdditionalAttributes")]
08     public class ItemAdditionalAttribute
09     {
10         [Key]
11         public System.Guid ItemAdditionalAttributeID { get; set; }
12
13         [Required]
14         public System.Guid ItemID { get; set; }
15
16         [Required]
17         [MaxLength(50)]
18         public string AttributeName { get; set; }
19
20         [MaxLength(500)]
21         public string AttributeValue { get; set; }
22
23         [ForeignKey("ItemID")]
24         public virtual Item Item { get; set; }
25     }
26 }
01 using System;
02 using System.Collections.Generic;
03 using System.Linq;
04 using System.Web;
05 using System.ComponentModel.DataAnnotations;
06
07 namespace EFChangeTrackingDemo.Models
08 {
09     [Table("AuditLog")]
10     public class AuditLog
11     {
12         [Key]
13         public Guid AuditLogID { get; set; }
14
15         [Required]
16         [MaxLength(50)]
17         public string UserID { get; set; }
18
19         [Required]
20         public DateTime EventDateUTC { get; set; }
21
22         [Required]
23         [MaxLength(1)]
24         public string EventType { get; set; }
25
26         [Required]
27         [MaxLength(100)]
28         public string TableName { get; set; }
29
30         [Required]
31         [MaxLength(100)]
32         public string RecordID { get; set; }
33
34         [Required]
35         [MaxLength(100)]
36         public string ColumnName { get; set; }
37
38         public string OriginalValue { get; set; }
39
40         public string NewValue { get; set; }
41     }
42 }

A fim de obter o ID do usuário que está fazendo a mudança, em nossa classe DbContext, vamos sobrecarregar o método SaveChanges () para lançar uma exceção que exige um ID de usuário a ser passado como um parâmetro, e criar um novo método SaveChanges(userId string) . Dentro do novo método SaveChanges (userId), podemos obter todas as alterações que foram feitas desde as últimas chamadas a SaveChanges () , e adicionar registros à tabela auditlog para cada um deles. A propriedade ChangeTracker da classe DbContext contém todas as entidades necessárias para obtermos as informações  adicionadas, excluídas ou modificadas de forma fácil. Se temos uma função que retorna os registros de auditoria para uma única alteração, em seguida, o código de SaveChanges () será parecido com este:

01 // This is overridden to prevent someone from calling SaveChanges without specifying the user making the change
02 public override int SaveChanges()
03 {
04     throw new InvalidOperationException("User ID must be provided");
05 }
06
07 public int SaveChanges(string userId)
08 {
09     // Get all Added/Deleted/Modified entities (not Unmodified or Detached)
10     foreach (var ent in this.ChangeTracker.Entries().Where(p => p.State == System.Data.EntityState.Added || p.State == System.Data.EntityState.Deleted || p.State == System.Data.EntityState.Modified))
11     {
12         // For each changed record, get the audit record entries and add them
13         foreach(AuditLog x in GetAuditRecordsForChange(ent, userId))
14         {
15             this.AuditLog.Add(x);
16         }
17     }
18
19     // Call the original SaveChanges(), which will save both the changes made and the audit records
20     return base.SaveChanges();
21 }

A fim de fazer a saída de todo o conteúdo de uma entidade de forma simplificada pode-se criar uma interface que tem um método “Describe()” que pode ser colocado nas classes modelo:

01 using System;
02 using System.Collections.Generic;
03 using System.Linq;
04 using System.Web;
05
06 namespace EFChangeTrackingDemo.Models
07 {
08     public interface IDescribableEntity
09     {
10         // Override this method to provide a description of the entity for audit purposes
11         string Describe();
12     }
13 }

Feito isso nossas entidades serão parecidas como abaixo:

01 using System;
02 using System.Collections.Generic;
03 using System.ComponentModel.DataAnnotations;
04
05 namespace EFChangeTrackingDemo.Models
06 {
07     [Table("Items")]
08     public class Item : IDescribableEntity
09     {
10         [Key]
11         public System.Guid ItemID { get; set; }
12
13         [Required]
14         [MaxLength(100)]
15         public string ItemDescription { get; set; }
16
17         public virtual ICollection ItemAdditionalAttributes { get; set; }
18
19         public Item()
20         {
21             this.ItemAdditionalAttributes = new List();
22         }
23
24         public string Describe()
25         {
26             return "{ ItemID : \"" + ItemID + "\", ItemDescription : \"" + ItemDescription + "\" }";
27         }
28     }
29 }

Então agora o único item restante para implementar é o método que cria entradas auditlog de uma entidade que mudou. Para completar as entradas auditlog, temos o nome da tabela (se foi especificado através do [Tabela ()] atributo, caso contrário, vamos usar o nome de classe de entidade), o valor da chave primária, e tanto o conteúdo de toda a entidade (por inserções e exclusões), ou o-antes e depois valores das colunas alteradas. Para o conteúdo de toda a entidade, se a entidade implementa a interface IDescribableEntity, vamos chamar o método describe () para obter os dados de conteúdo, caso contrário, vamos apenas usar o método toString (). Para actualizações, será apenas loop através de todas as propriedades da entidade e adicionar um registro auditlog para a saída de cada um em que o valor original não é igual ao valor de novo. O GetAuditRecordsForChange () método, então, semelhante a este:

01 private List<AuditLog> GetAuditRecordsForChange(DbEntityEntry dbEntry, string userId)
02 {
03     List<AuditLog> result = new List<AuditLog>();
04
05     DateTime changeTime = DateTime.UtcNow;
06
07     // Get the Table() attribute, if one exists
08     TableAttribute tableAttr = dbEntry.Entity.GetType().GetCustomAttributes(typeof(TableAttribute), false).SingleOrDefault() as TableAttribute;
09
10     // Get table name (if it has a Table attribute, use that, otherwise get the pluralized name)
11     string tableName = tableAttr != null ? tableAttr.Name : dbEntry.Entity.GetType().Name;
12
13     // Get primary key value (If you have more than one key column, this will need to be adjusted)
14     string keyName = dbEntry.Entity.GetType().GetProperties().Single(p => p.GetCustomAttributes(typeof(KeyAttribute), false).Count() > 0).Name;
15
16     if (dbEntry.State == System.Data.EntityState.Added)
17     {
18         // For Inserts, just add the whole record
19         // If the entity implements IDescribableEntity, use the description from Describe(), otherwise use ToString()
20         result.Add(new AuditLog()
21                 {
22                     AuditLogID = Guid.NewGuid(),
23                     UserID = userId,
24                     EventDateUTC = changeTime,
25                     EventType = "A", // Added
26                     TableName = tableName,
27                     RecordID = dbEntry.CurrentValues.GetValue<object>(keyName).ToString(),  // Again, adjust this if you have a multi-column key
28                     ColumnName = "*ALL",    // Or make it nullable, whatever you want
29                     NewValue = (dbEntry.CurrentValues.ToObject() is IDescribableEntity) ? (dbEntry.CurrentValues.ToObject() as IDescribableEntity).Describe() : dbEntry.CurrentValues.ToObject().ToString()
30                 }
31             );
32     }
33     else if(dbEntry.State == System.Data.EntityState.Deleted)
34     {
35         // Same with deletes, do the whole record, and use either the description from Describe() or ToString()
36         result.Add(new AuditLog()
37                 {
38                     AuditLogID = Guid.NewGuid(),
39                     UserID = userId,
40                     EventDateUTC = changeTime,
41                     EventType = "D", // Deleted
42                     TableName = tableName,
43                     RecordID = dbEntry.OriginalValues.GetValue<object>(keyName).ToString(),
44                     ColumnName = "*ALL",
45                     NewValue = (dbEntry.OriginalValues.ToObject() is IDescribableEntity) ? (dbEntry.OriginalValues.ToObject() as IDescribableEntity).Describe() : dbEntry.OriginalValues.ToObject().ToString()
46                 }
47             );
48     }
49     else if (dbEntry.State == System.Data.EntityState.Modified)
50     {
51         foreach (string propertyName in dbEntry.OriginalValues.PropertyNames)
52         {
53             // For updates, we only want to capture the columns that actually changed
54             if (!object.Equals(dbEntry.OriginalValues.GetValue<object>(propertyName), dbEntry.CurrentValues.GetValue<object>(propertyName)))
55             {
56                 result.Add(new AuditLog()
57                         {
58                             AuditLogID = Guid.NewGuid(),
59                             UserID = userId,
60                             EventDateUTC = changeTime,
61                             EventType = "M",    // Modified
62                             TableName = tableName,
63                             RecordID = dbEntry.OriginalValues.GetValue<object>(keyName).ToString(),
64                             ColumnName = propertyName,
65                             OriginalValue = dbEntry.OriginalValues.GetValue<object>(propertyName) == null ? null : dbEntry.OriginalValues.GetValue<object>(propertyName).ToString(),
66                             NewValue = dbEntry.CurrentValues.GetValue<object>(propertyName) == null ? null : dbEntry.CurrentValues.GetValue<object>(propertyName).ToString()
67                         }
68                     );
69             }
70         }
71     }
72     // Otherwise, don't do anything, we don't care about Unchanged or Detached entities
73
74     return result;
75 }

Eu usei Code-First para isso, e podido evitar usar a API Fluente para adicionar atributos para as classes de modelo. Você pode usar Model-First ou Database-First e obter os mesmos resultados, mas você precisa adicionar o DbContext API item de geração de código para o seu modelo edmx. E modificar os modelos de adicionar pelo menos a tecla [] atributo para colunas de chave . A API Fluente é um pouco mais complicado.

Para escrever esse código, aqui está um exemplo simples em um MVC 3 do projeto:

01 using System;
02 using System.Collections.Generic;
03 using System.Linq;
04 using System.Web;
05 using System.Web.Mvc;
06 using EFChangeTrackingDemo.Models;
07
08 namespace EFChangeTrackingDemo.Controllers
09 {
10     public class HomeController : Controller
11     {
12         DemoDBContext db = new DemoDBContext();
13
14         public ActionResult Index()
15         {
16             ViewBag.Message = "Welcome to ASP.NET MVC!";
17
18             string userId = User.Identity.Name;
19
20             Item newItem = new Item() { ItemID = Guid.NewGuid(), ItemDescription = "Test item" };
21
22             db.Items.Add(newItem);
23             db.SaveChanges(userId);
24
25             newItem.ItemDescription = "Test 2";
26             db.SaveChanges(userId);
27
28             newItem.ItemAdditionalAttributes.Add(new ItemAdditionalAttribute() { ItemAdditionalAttributeID = Guid.NewGuid(), ItemID = newItem.ItemID, AttributeName = "Test Attribute", AttributeValue = "This is a test" });
29             db.SaveChanges(userId);
30
31             // Not that this will cascade delete down to remove the ItemAdditionalAttribute as well
32             db.Items.Remove(newItem);
33             db.SaveChanges(userId);
34
35             return View();
36         }
37
38         public ActionResult About()
39         {
40             return View();
41         }
42     }
43 }

Seu código (espero) será mais complexo do que isso, mas é o suficiente para demonstrar como funciona o nosso processo de auditoria. Quando um usuário visita a página inicial da aplicação, o controlador irá criar um registro novo item, salvá-lo, mudar a descrição, salvá-lo novamente, adicionando um registro filho na tabela ItemAdditionalAttributes, salve novamente, e excluir o registro de item, que também a cascateará apagando o registro na tabela ItemAdditionalAttributes. Se executarmos isso e examinar a tabela auditlog, vamos encontrar o seguinte (clique para ampliar):

Note que a exclusão em cascata capta a exclusão de ambas as tabelas, e tem registros de todas as mudanças feitas entre as chamadas para SaveChanges (). Agora que isso está implementado qualquer alteração feita no aplicativo que usa essa classe DbContext irá escrever automaticamente informações de auditoria para a mesa auditlog.

Fonte de pesquisa: http://jmdority.wordpress.com/2011/07/20/using-entity-framework-4-1-dbcontext-change-tracking-for-audit-logging/