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

3b32564a8b65eeecbf6cc9240a1dd00b.pngПриветствую!

В данном топике я хочу поговорить о слое доступа к данным (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 Users { get; set; }

protected override void OnModelCreating (DbModelBuilder modelBuilder) { modelBuilder.Configurations.Add (new UserMap ()); } } } Расширим его с помощью следующего интерфейса: IDbContext public interface IDbContext { IQueryable Find() where T: class;

void MarkAsAdded(T entity) where T: class;

void MarkAsDeleted(T entity) where T: class;

void MarkAsModified(T entity) where T: class;

void Commit (bool withLogging);

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

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

EntityState GetEntityState(T entity) where T: class;

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

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

DbEntityEntry GetDbEntry(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 EntityChanges { get; set; }

public DbSet 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 entity) where T: class { Entry (entity).State = EntityState.Added; Set().Add (entity); }

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

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

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

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

private void BeforeCommit () { UndoExistAddedEntitys (); }

//исправление ситуации, когда у есть объекты помеченные как новые, но при этом существующие в базе данных private void UndoExistAddedEntitys () { IEnumerable 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 entity, EntityState state) where T: class { Entry (entity).State = state; }

public DbChangeTracker GetChangeTracker () { return ChangeTracker; }

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

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

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

public static int GetKeyValue(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 where T: class { DemoAppDbContext CreateDatabaseContext ();

List 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: IRepository 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 GetAll () { using (var context = CreateDatabaseContext ()) { return context.Set().ToList (); } }

public T Find (int entityId) { using (var context = CreateDatabaseContext ()) { return context.Set().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().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(); }

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 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 { public UsersRepository (IContextManager contextManager) : base (contextManager) {

}

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

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

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

}

public User GetFirst () { using (var db = CreateDatabaseContext ()) { return db.Set().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() where T: class { return _repositoryContainer.Resolve(); }

public void Register(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 (); } } Реализация интерфейса ILoggerLogger public class Logger: ILogger { Dictionary _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.Added, «Добавление»}, {EntityState.Deleted, «Удаление»}, {EntityState.Modified, «Изменение»} }; }

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

private void LogChangedEntities (EntityState entityState) { IEnumerable 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 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().GetFirst (); DemoAppDbContext.CurrentUser = defaultUser; } Вызов к методов репозитория происходит через получение экземпляра у service locator-a. В приведенном ниже примере, обращение идет к методу GetFirst () репозитория типа UsersRepository: User defaultUser = RepositoryContainer.Instance.Resolve().GetFirst (); Добавление нового пользователя: var newUser = new User { Email = «UserEmail@email.ru», Login = «login», IsBlocked = false, Name = «Vasy Pupkin»}; RepositoryContainer.Instance.Resolve().SaveOrUpdate (newUser); Валидация перед сохранением объектов Валидация и получение списка ошибок: var newUser = new User { Email = «UserEmail@email.ru», IsBlocked = false, }; DbEntityValidationResult dbEntityValidationResult = RepositoryContainer.Instance.Resolve().Validate (newUser); Получение строки с ошибками: var newUser = new User { Email = «UserEmail@email.ru», IsBlocked = false, };

bool isValid=true; string errors = RepositoryContainer.Instance.Resolve().ValidateAndReturnErrorString (newUser, out isValid); if (! isValid) { MessageBox.Show (errors, «Error…», MessageBoxButtons.OK, MessageBoxIcon.Error); } Демо проект Полностью рабочий проект вы можете забрать на яндекс диске http://yadi.sk/d/P9XDDznpMj6p8.Пожалуйста, обратите внимания, что для работы требуется установленная СУБД MSSQL.В случае использования MSSQL Express, необходимо исправить строку подключение с Data Source=.\; Initial Catalog=EFDemoApp; Integrated Security=True; Connection Timeout=5 на Data Source=.\SQLEXPRESS; Initial Catalog=EFDemoApp; Integrated Security=True; Connection Timeout=5 Послесловие Весь вышеприведенный код, это мое решение поставленных задач. Оно может быть не правильным, не оптимальным, но тем не менее уже несколько лет с успехом работает на одном из проектов.В свое время я потратил довольно много времени и сил на то чтобы сделать эту систему и надеюсь что мои результаты будут кому-то полезными.Всем спасибо!

© Habrahabr.ru