Приветствую!
В данном топике я хочу поговорить о слое доступа к данным (Data Access Level) по отношению к Entity Framework-у, далее EF, о том какие задачи стояли и как я их решил. Весь представленных код из поста, а также прикрепленный демо проект публикуется под либеральной лицензией MIT, то есть вы можете использовать код как вам угодно.
Сразу хочу подчеркнуть, что весь представленный код представляет собой законченное решение и используется более 2-х лет в проекте для достаточно крупной российский компании, но тем не менее не подходит для высоконагруженных систем.
Задачи
При написании приложения, передо мной стояло несколько задач по отношению к слою доступа к данным:
1. Все изменения данных должны логироваться, включая информацию о том какой именно пользователь это сделал
2. Использование паттерна «Репозиторий»
3. Контроль над изменением объектов, то есть если мы хотим обновить в базе данных только один объект, то должен именно один объект.
Поясню:
По умолчанию, EF отслеживает изменения всех объектов в рамках конкретного контекста, при этом возможность сохранить один объект отсутствует, в отличии от NHibernate. Такая ситуация чревата различного рода неприятными ошибками. Например, пользователь редактирует одновременно два объекта, но хочет сохранить только один. В случае, если эти два объекта связанны с один контекстом базы данных, EF сохранит изменения обоих объектов.
Решение
Кода довольно много, поэтому комментарии добавляю к наиболее интересным моментам.
Начну пожалуй с самого главного объекта — контекст базы данных.
В стандартном и упрощенном виде, он представляет собой список объектов базы данных:
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()); } } }
Расширим его с помощью следующего интерфейса:
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:
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 функционал
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:
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:
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 функционал:
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. Это сделало для возможности написания тестов.
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, это сделано для возможности работы с несколькими контекстами и их централизованным созданием:
namespace DataAccess.Interfaces { public interface IContextManager { DemoAppDbContext CreateDatabaseContext(); } }
И его реализация 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:
namespace DataAccess.Interfaces { internal interface ILogger { void Run(); } }
Реализация интерфейса ILogger
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));
После, необходимо пройти авторизацию и указать текущего пользователя. Это необходимо для того, чтобы сохранять в истории информацию о пользователе который сделал то или иное изменение. В демо проекте этот пункт упущен.
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>
Послесловие
Весь вышеприведенный код, это мое решение поставленных задач. Оно может быть не правильным, не оптимальным, но тем не менее уже несколько лет с успехом работает на одном из проектов.
В свое время я потратил довольно много времени и сил на то чтобы сделать эту систему и надеюсь что мои результаты будут кому-то полезными.
Всем спасибо!
ссылка на оригинал статьи http://habrahabr.ru/post/219947/
Добавить комментарий