...

пятница, 18 апреля 2014 г.

Реализация уровня доступа к данным на Entity Framework Code First


Приветствую!


В данном топике я хочу поговорить о слое доступа к данным (Data Access Level) по отношению к Entity Framework-у, далее EF, о том какие задачи стояли и как я их решил. Весь представленных код из поста, а также прикрепленный демо проект публикуется под либеральной лицензией MIT, то есть вы можете использовать код как вам угодно.

Сразу хочу подчеркнуть, что весь представленный код представляет собой законченное решение и используется более 2-х лет в проекте для достаточно крупной российский компании, но тем не менее не подходит для высоконагруженных систем.


Подробности под катом.



Задачи



При написании приложения, передо мной стояло несколько задач по отношению к слою доступа к данным:

1. Все изменения данных должны логироваться, включая информацию о том какой именно пользователь это сделал

2. Использование паттерна «Репозиторий»

3. Контроль над изменением объектов, то есть если мы хотим обновить в базе данных только один объект, то должен именно один объект.

Поясню:

По умолчанию, EF отслеживает изменения всех объектов в рамках конкретного контекста, при этом возможность сохранить один объект отсутствует, в отличии от NHibernate. Такая ситуация чревата различного рода неприятными ошибками. Например, пользователь редактирует одновременно два объекта, но хочет сохранить только один. В случае, если эти два объекта связанны с один контекстом базы данных, EF сохранит изменения обоих объектов.
Решение



Кода довольно много, поэтому комментарии добавляю к наиболее интересным моментам.

Начну пожалуй с самого главного объекта — контекст базы данных.

В стандартном и упрощенном виде, он представляет собой список объектов базы данных:

UsersContext


namespace TestApp.Models
{
public partial class UsersContext : DbContext
{

public UsersContext()
: base("Name=UsersContext")
{
}

public DbSet<User> Users { get; set; }

protected override void OnModelCreating(DbModelBuilder modelBuilder)
{
modelBuilder.Configurations.Add(new UserMap());
}
}
}







Расширим его с помощью следующего интерфейса:

IDbContext


public interface IDbContext
{
IQueryable<T> Find<T>() where T : class;

void MarkAsAdded<T>(T entity) where T : class;

void MarkAsDeleted<T>(T entity) where T : class;

void MarkAsModified<T>(T entity) where T : class;

void Commit(bool withLogging);

//откатывает изменения во всех модифицированных объектах
void Rollback();

// включает или отключает отслеживание изменений объектов
void EnableTracking(bool isEnable);

EntityState GetEntityState<T>(T entity) where T : class;

void SetEntityState<T>(T entity, EntityState state) where T : class;

// возвращает объект содержащий список объектов с их состоянием
DbChangeTracker GetChangeTracker();

DbEntityEntry GetDbEntry<T>(T entity) where T : class;
}







Получившийся модифицированный DbContext:

DemoAppDbContext


namespace DataAccess.DbContexts
{
public class DemoAppDbContext : DbContext, IDbContext
{
public static User CurrentUser { get; set; }

private readonly ILogger _logger;

#region Context Entities

public DbSet<EntityChange> EntityChanges { get; set; }

public DbSet<User> Users { get; set; }

#endregion

static DemoAppDbContext()
{
//устанавливаем инициализатор
Database.SetInitializer(new CreateDBContextInitializer());
}

// метод вызывается при создании базы данных
public static void Seed(DemoAppDbContext context)
{
// добавляем пользователя по умолчанию
var defaultUser = new User { Email = "UserEmail@email.ru", Login = "login", IsBlocked = false, Name = "Vasy Pupkin" };
context.Users.Add(defaultUser);
context.SaveChanges();
}

public DemoAppDbContext(string nameOrConnectionString)
: base(nameOrConnectionString)
{
// инициализация логгера
_logger = new Logger(this);
}

protected override void OnModelCreating(DbModelBuilder modelBuilder)
{

modelBuilder.Configurations.Add(new EntityChangeMap());
modelBuilder.Configurations.Add(new UserMap());
}

public void MarkAsAdded<T>(T entity) where T : class
{
Entry(entity).State = EntityState.Added;
Set<T>().Add(entity);
}

public void MarkAsDeleted<T>(T entity) where T : class
{
Attach(entity);
Entry(entity).State = EntityState.Deleted;
Set<T>().Remove(entity);
}

public void MarkAsModified<T>(T entity) where T : class
{
Attach(entity);
Entry(entity).State = EntityState.Modified;
}

public void Attach<T>(T entity) where T : class
{
if (Entry(entity).State == EntityState.Detached)
{
Set<T>().Attach(entity);
}
}

public void Commit(bool withLogging)
{
BeforeCommit();
if (withLogging)
{
_logger.Run();
}
SaveChanges();
}

private void BeforeCommit()
{
UndoExistAddedEntitys();
}

//исправление ситуации, когда у есть объекты помеченные как новые, но при этом существующие в базе данных
private void UndoExistAddedEntitys()
{
IEnumerable<DbEntityEntry> dbEntityEntries = GetChangeTracker().Entries().Where(x => x.State == EntityState.Added);
foreach (var dbEntityEntry in dbEntityEntries)
{
if (GetKeyValue(dbEntityEntry.Entity) > 0)
{
SetEntityState(dbEntityEntry.Entity, EntityState.Unchanged);
}
}
}

// откат всех изменений в объектах
public void Rollback()
{
ChangeTracker.Entries().ToList().ForEach(x => x.Reload());
}

public void EnableTracking(bool isEnable)
{
Configuration.AutoDetectChangesEnabled = isEnable;
}

public void SetEntityState<T>(T entity, EntityState state) where T : class
{
Entry(entity).State = state;
}

public DbChangeTracker GetChangeTracker()
{
return ChangeTracker;
}

public EntityState GetEntityState<T>(T entity) where T : class
{
return Entry(entity).State;
}

public IQueryable<T> Find<T>() where T : class
{
return Set<T>();
}

public DbEntityEntry GetDbEntry<T>(T entity) where T : class
{
return Entry(entity);
}

public static int GetKeyValue<T>(T entity) where T : class
{
var dbEntity = entity as IDbEntity;
if (dbEntity == null)
throw new ArgumentException("Entity should be IDbEntity type - " + entity.GetType().Name);

return dbEntity.GetPrimaryKey();
}
}
}







Взаимодействие с объектами базы данных происходит через репозитории специфичные для каждого объекта. Все репозитории наследуют базовый класс, который предоставляет базовый CRUD функционал

IRepository


interface IRepository<T> where T : class
{
DemoAppDbContext CreateDatabaseContext();

List<T> GetAll();

T Find(int entityId);

T SaveOrUpdate(T entity);

T Add(T entity);

T Update(T entity);

void Delete(T entity);

// возвращает список ошибок
DbEntityValidationResult Validate(T entity);

// возвращает строку с ошибками
string ValidateAndReturnErrorString(T entity, out bool isValid);
}







Реализация IRepository:

BaseRepository


namespace DataAccess.Repositories
{
public abstract class BaseRepository<T> : IRepository<T> where T : class
{
private readonly IContextManager _contextManager;

protected BaseRepository(IContextManager contextManager)
{
_contextManager = contextManager;
}

public DbEntityValidationResult Validate(T entity)
{
using (var context = CreateDatabaseContext())
{
return context.Entry(entity).GetValidationResult();
}
}

public string ValidateAndReturnErrorString(T entity, out bool isValid)
{
using (var context = CreateDatabaseContext())
{
DbEntityValidationResult dbEntityValidationResult = context.Entry(entity).GetValidationResult();
isValid = dbEntityValidationResult.IsValid;
if (!dbEntityValidationResult.IsValid)
{
return DbValidationMessageParser.GetErrorMessage(dbEntityValidationResult);
}
return string.Empty;
}
}

// создание контекста базы данных. необходимо использовать using
public DemoAppDbContext CreateDatabaseContext()
{
return _contextManager.CreateDatabaseContext();
}

public List<T> GetAll()
{
using (var context = CreateDatabaseContext())
{
return context.Set<T>().ToList();
}
}

public T Find(int entityId)
{
using (var context = CreateDatabaseContext())
{
return context.Set<T>().Find(entityId);
}
}

// виртуальный метод. вызывает перед сохранением объектов, может быть определен в дочерних классах
protected virtual void BeforeSave(T entity, DemoAppDbContext db)
{

}

public T SaveOrUpdate(T entity)
{
var iDbEntity = entity as IDbEntity;

if (iDbEntity == null)
throw new ArgumentException("entity should be IDbEntity type", "entity");

return iDbEntity.GetPrimaryKey() == 0 ? Add(entity) : Update(entity);
}

public T Add(T entity)
{
using (var context = CreateDatabaseContext())
{
BeforeSave(entity, context);
context.MarkAsAdded(entity);
context.Commit(true);
}
return entity;
}

public T Update(T entity)
{
using (var context = CreateDatabaseContext())
{
var iDbEntity = entity as IDbEntity;
if (iDbEntity == null)
throw new ArgumentException("entity should be IDbEntity type", "entity");

var attachedEntity = context.Set<T>().Find(iDbEntity.GetPrimaryKey());
context.Entry(attachedEntity).CurrentValues.SetValues(entity);

BeforeSave(attachedEntity, context);
context.Commit(true);
}
return entity;
}

public void Delete(T entity)
{
using (var context = CreateDatabaseContext())
{
context.MarkAsDeleted(entity);
context.Commit(true);
}
}
}
}







Объект базы данных User:

User


namespace DataAccess.Models
{
public class User : IDbEntity
{
public User()
{
this.EntityChanges = new List<EntityChange>();
}

public int UserId { get; set; }

[Required(AllowEmptyStrings = false, ErrorMessage = @"Please input Login")]
[StringLength(50, ErrorMessage = @"Login должен быть меньше 50 символов")]
public string Login { get; set; }

[Required(AllowEmptyStrings = false, ErrorMessage = @"Please input Email")]
[StringLength(50, ErrorMessage = @"Email должен быть меньше 50 символов")]
public string Email { get; set; }

[Required(AllowEmptyStrings = false, ErrorMessage = @"Please input Name")]
[StringLength(50, ErrorMessage = @"Имя должно быть меньше 50 символов")]
public string Name { get; set; }

public bool IsBlocked { get; set; }

public virtual ICollection<EntityChange> EntityChanges { get; set; }

public override string ToString()
{
return string.Format("Тип: User; Название:{0}, UserId:{1} ", Name, UserId);
}

public int GetPrimaryKey()
{
return UserId;
}
}
}







Репозиторий для объекта «User», c рядом дополнительных методов расширяющий стандартный CRUD функционал базового класса:

UsersRepository


namespace DataAccess.Repositories
{
public class UsersRepository : BaseRepository<User>
{
public UsersRepository(IContextManager contextManager)
: base(contextManager)
{

}

public User FindByLogin(string login)
{
using (var db = CreateDatabaseContext())
{
return db.Set<User>().FirstOrDefault(u => u.Login == login);
}
}

public bool ExistUser(string login)
{
using (var db = CreateDatabaseContext())
{
return db.Set<User>().Count(u => u.Login == login) > 0;
}
}

public User GetByUserId(int userId)
{
using (var db = CreateDatabaseContext())
{
return db.Set<User>().SingleOrDefault(c => c.UserId == userId);
}

}

public User GetFirst()
{
using (var db = CreateDatabaseContext())
{
return db.Set<User>().First();
}
}
}
}







В моем случае, все репозитории инициализируются один раз и добавляются в простейший самописный service locator RepositoryContainer. Это сделало для возможности написания тестов.

RepositoryContainer


namespace DataAccess.Container
{
public class RepositoryContainer
{
private readonly IContainer _repositoryContainer = new Container();

public static readonly RepositoryContainer Instance = new RepositoryContainer();

private RepositoryContainer()
{

}

public T Resolve<T>() where T : class
{
return _repositoryContainer.Resolve<T>();
}

public void Register<T>(T entity) where T : class
{
_repositoryContainer.Register(entity);
}
}
}

namespace DataAccess.Container
{
public static class RepositoryContainerFactory
{
public static void RegisterAllRepositories(IContextManager dbContext)
{
RepositoryContainer.Instance.Register(dbContext);
RepositoryContainer.Instance.Register(new EntityChangesRepository(dbContext));
RepositoryContainer.Instance.Register(new UsersRepository(dbContext));
}
}
}







Всем репозиториям, при инициализации передается объект IContextManager, это сделано для возможности работы с несколькими контекстами и их централизованным созданием:

IContextManager


namespace DataAccess.Interfaces
{
public interface IContextManager
{
DemoAppDbContext CreateDatabaseContext();
}
}







И его реализация ContextManager:

ContextManager


using DataAccess.Interfaces;

namespace DataAccess.DbContexts
{
public class ContextManager : IContextManager
{
private readonly string _connectionString;

public ContextManager(string connectionString)
{
_connectionString = connectionString;
}

public DemoAppDbContext CreateDatabaseContext()
{
return new DemoAppDbContext(_connectionString);
}
}
}







Логирование происходит в объекте реализующем интерфейс ILogger:

ILogger


namespace DataAccess.Interfaces
{
internal interface ILogger
{
void Run();
}
}







Реализация интерфейса ILogger

Logger


public class Logger : ILogger
{
Dictionary<EntityState, string> _operationTypes;

private readonly IDbContext _dbContext;

public Logger(IDbContext dbContext)
{
_dbContext = dbContext;
InitOperationTypes();
}

public void Run()
{
LogChangedEntities(EntityState.Added);
LogChangedEntities(EntityState.Modified);
LogChangedEntities(EntityState.Deleted);
}

private void InitOperationTypes()
{
_operationTypes = new Dictionary<EntityState, string>
{
{EntityState.Added, "Добавление"},
{EntityState.Deleted, "Удаление"},
{EntityState.Modified, "Изменение"}
};
}

private string GetOperationName(EntityState entityState)
{
return _operationTypes[entityState];
}

private void LogChangedEntities(EntityState entityState)
{
IEnumerable<DbEntityEntry> dbEntityEntries = _dbContext.GetChangeTracker().Entries().Where(x => x.State == entityState);
foreach (var dbEntityEntry in dbEntityEntries)
{
LogChangedEntitie(dbEntityEntry, entityState);
}
}

private void LogChangedEntitie(DbEntityEntry dbEntityEntry, EntityState entityState)
{
string operationHash = HashGenerator.GenerateHash(10);
int enitityId = DemoAppDbContext.GetKeyValue(dbEntityEntry.Entity);

Type type = dbEntityEntry.Entity.GetType();

IEnumerable<string> propertyNames = entityState == EntityState.Deleted
? dbEntityEntry.OriginalValues.PropertyNames
: dbEntityEntry.CurrentValues.PropertyNames;

foreach (var propertyName in propertyNames)
{
DbPropertyEntry property = dbEntityEntry.Property(propertyName);

if (entityState == EntityState.Modified && !property.IsModified)
continue;

_dbContext.MarkAsAdded(new EntityChange
{
UserId = DemoAppDbContext.CurrentUser.UserId,
Created = DateTime.Now,
OperationHash = operationHash,
EntityName = string.Empty,
EntityType = type.ToString(),
EntityId = enitityId.ToString(),
PropertyName = propertyName,
OriginalValue =
entityState != EntityState.Added && property.OriginalValue != null
? property.OriginalValue.ToString()
: string.Empty,
ModifyValue =
entityState != EntityState.Deleted && property.CurrentValue != null
? property.CurrentValue.ToString()
: string.Empty,
OperationType = GetOperationName(entityState),
});
}
}
}







Использование



Для того чтобы начать работать с базой данных, в приложении необходимо инициализовать фабрику репозиториев:

RepositoryContainerFactory.RegisterAllRepositories(new ContextManager(Settings.Default.DBConnectionString));




После, необходимо пройти авторизацию и указать текущего пользователя. Это необходимо для того, чтобы сохранять в истории информацию о пользователе который сделал то или иное изменение. В демо проекте этот пункт упущен.

InitDefaultUser


private void InitDefaultUser()
{
User defaultUser = RepositoryContainer.Instance.Resolve<UsersRepository>().GetFirst();
DemoAppDbContext.CurrentUser = defaultUser;
}







Вызов к методов репозитория происходит через получение экземпляра у service locator-a. В приведенном ниже примере, обращение идет к методу GetFirst() репозитория типа UsersRepository:

User defaultUser = RepositoryContainer.Instance.Resolve<UsersRepository>().GetFirst();




Добавление нового пользователя:

var newUser = new User { Email = "UserEmail@email.ru", Login = "login", IsBlocked = false, Name = "Vasy Pupkin"};
RepositoryContainer.Instance.Resolve<UsersRepository>().SaveOrUpdate(newUser);




Валидация перед сохранением объектов



Валидация и получение списка ошибок:

var newUser = new User { Email = "UserEmail@email.ru", IsBlocked = false, };
DbEntityValidationResult dbEntityValidationResult = RepositoryContainer.Instance.Resolve<UsersRepository>().Validate(newUser);




Получение строки с ошибками:

var newUser = new User { Email = "UserEmail@email.ru", IsBlocked = false, };

bool isValid=true;
string errors = RepositoryContainer.Instance.Resolve<UsersRepository>().ValidateAndReturnErrorString(newUser, out isValid);
if (!isValid)
{
MessageBox.Show(errors, "Error..", MessageBoxButtons.OK, MessageBoxIcon.Error);
}




Демо проект



Полностью рабочий проект вы можете забрать на яндекс диске http://ift.tt/1hTLYv0.

Пожалуйста, обратите внимания, что для работы требуется установленная СУБД MSSQL.

В случае использования MSSQL Express, необходимо исправить строку подключение с

<value>Data Source=.\; Initial Catalog=EFDemoApp; Integrated Security=True; Connection Timeout=5</value>




на

<value>Data Source=.\SQLEXPRESS; Initial Catalog=EFDemoApp; Integrated Security=True; Connection Timeout=5</value>




Послесловие



Весь вышеприведенный код, это мое решение поставленных задач. Оно может быть не правильным, не оптимальным, но тем не менее уже несколько лет с успехом работает на одном из проектов.

В свое время я потратил довольно много времени и сил на то чтобы сделать эту систему и надеюсь что мои результаты будут кому-то полезными.

Всем спасибо!


This entry passed through the Full-Text RSS service — if this is your content and you're reading it on someone else's site, please read the FAQ at http://ift.tt/jcXqJW.


Комментариев нет:

Отправить комментарий