Как стать автором
Обновить

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

Время на прочтение 11 мин
Количество просмотров 30K


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

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

Послесловие

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

Всем спасибо!
Теги:
Хабы:
+14
Комментарии 30
Комментарии Комментарии 30

Публикации

Истории

Работа

.NET разработчик
66 вакансий

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн
PG Bootcamp 2024
Дата 16 апреля
Время 09:30 – 21:00
Место
Минск Онлайн
EvaConf 2024
Дата 16 апреля
Время 11:00 – 16:00
Место
Москва Онлайн