Приветствую!
В данном топике я хочу поговорить о слое доступа к данным (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://yadi.sk/d/P9XDDznpMj6p8.
Пожалуйста, обратите внимания, что для работы требуется установленная СУБД 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>
Послесловие
Весь вышеприведенный код, это мое решение поставленных задач. Оно может быть не правильным, не оптимальным, но тем не менее уже несколько лет с успехом работает на одном из проектов.
В свое время я потратил довольно много времени и сил на то чтобы сделать эту систему и надеюсь что мои результаты будут кому-то полезными.
Всем спасибо!