Sunday, July 20, 2014

Использование паттерна Repository с Entity Framework

Сегодня мы затронем тему использования паттерна Repository в своих приложениях для Entity Framework (EF). Идея написания статьи возникла после обнаружения ошибок в примерах реализации использования данного паттерна на одном из популярных курсов от pluralsight.
Паттерн Repository посредничает между уровнями области определения и распределения данных (domain and data mapping layers), используя интерфейс, схожий с коллекциями для доступа к объектам области определения. Такое определение базового понятия данного паттерна. Реализация данного паттерна подразумевает разделение вашего приложения на слои: слой данных (domain data) и слой, отвечающий за логику работы с доменными данными. Пример реализации приведен на рисунке ниже.
Но для примера можно так не усердствовать, поскольку задача данной статьи − донести суть, как именно использовать данный паттерн. Поэтому создадим простое консольное приложение и назовем его UsingGenericRepository.
Далее для работы с базой данных (БД) нужно установить Entity Framework 6.0 (EF), для логирования используем NLog, а для работы через интерфейсы без жесткой привязки к реализации воспользуемся IoC контейнером Autofac. Пример установки Autofac с помощью Managed NuGet Packages можно посмотреть на рисунке ниже.
Создадим базовый класс для репозитория, вокруг которого будет построен весь каркас работы с БД. Для этого добавим новый класс, который назовем RepositoryBase.  
public class RepositoryBase<T> : IDisposable
    where T : DbContext, new()
{
    private T _context;

    public virtual T DataContext
    {
        get { return _context ?? (_context = new T()); }
    }

    public virtual TEntity Get<TEntity>(Expression<Func<TEntity, bool>> predicate) where TEntity : class
    {
        predicate.CheckNotNull("Predicate value must be passed to Get<TResult>.");
           
        return DataContext.Set<TEntity>().Where(predicate).SingleOrDefault();
    }

    public virtual IQueryable<TEntity> GetList<TEntity>(Expression<Func<TEntity, bool>> predicate) where TEntity : class
    {
        predicate.CheckNotNull("Predicate value must be passed to GetList<TResult>.");
        try
        {
            return DataContext.Set<TEntity>().Where(predicate);
        }
        catch (Exception ex)
        {
            LoggerHelper.Logger.ErrorException(ex.Message, ex);
        }
        return null;
    }

    public virtual IQueryable<TEntity> GetList<TEntity, TKey>(Expression<Func<TEntity, bool>> predicate,
        Expression<Func<TEntity, TKey>> orderBy) where TEntity : class
    {
        try
        {
            return GetList(predicate).OrderBy(orderBy);
        }
        catch (Exception ex)
        {
            //Log error
            LoggerHelper.Logger.ErrorException(ex.Message, ex);
        }
        return null;
    }

    public virtual IQueryable<TEntity> GetList<TEntity, TKey>(Expression<Func<TEntity, TKey>> orderBy) where TEntity : class
    {
        try
        {
            return GetList<TEntity>().OrderBy(orderBy);
        }
        catch (Exception ex)
        {
            //Log error
            LoggerHelper.Logger.ErrorException(ex.Message, ex);
        }
        return null;
    }

    public virtual IQueryable<TEntity> GetList<TEntity>() where TEntity : class
    {
        try
        {
            return DataContext.Set<TEntity>();
        }
        catch (Exception ex)
        {
            //Log error
            LoggerHelper.Logger.ErrorException(ex.Message, ex);
        }
        return null;
    }

    public virtual bool Save<TEntity>(TEntity entity) where TEntity : class
    {
           
        try
        {
            return DataContext.SaveChanges() > 0;
        }
        catch (Exception ex)
        {
            LoggerHelper.Logger.ErrorException("Error saving " + typeof(TEntity) + ".", ex);
            throw;
        }

    }

    public virtual bool Update<TEntity>(TEntity entity, params string[] propsToUpdate) where TEntity : class
    {
        try
        {
            DataContext.Set<TEntity>().Attach(entity);
            return DataContext.SaveChanges() > 0;
        }
        catch (Exception ex)
        {
            LoggerHelper.Logger.ErrorException("Error saving " + typeof(TEntity) + ".", ex);
            throw;
        }
    }

    public virtual bool Delete<TEntity>(TEntity entity) where TEntity : class
    {
        try
        {
            ObjectSet<TEntity> objectSet = ((IObjectContextAdapter)DataContext).ObjectContext.CreateObjectSet<TEntity>();
            objectSet.Attach(entity);
            objectSet.DeleteObject(entity);
            return DataContext.SaveChanges() > 0;
        }
        catch (Exception ex)
        {
            LoggerHelper.Logger.ErrorException("Error deleting " + typeof(TEntity), ex);
            throw;
        }

    }

    public void Dispose()
    {
        if (DataContext != null) DataContext.Dispose();
    }
}
Для тех разработчиков, которые уже работали с БД с помощью EF, например, через Code First, Model First или Database First, инструкции, используемые в этом классе, не должны представлять каких-либо неудобств. Единственное, что для проверки значения, передаваемого в методы, я воспользовался extension методом CheckNotNull. Реализация этого метода приведена ниже.
public static class ObjectExtensions
{
    public static void CheckNotNull(this object value, string error)
    {
        if(value == null)
            throw new Exception(error);
    }
}
На проверку я поставил только самые необходимые методы. Другой способ проверить значение на Null, которое передается в метод, состоит в Code Contract с предусловиями. Контракты можно использовать по своему усмотрению. Единственное удивление у разработчиков может вызвать метод Delete. Он написан с некой хитростью: так как из обычного DbContext мы не можем явно вызвать метод создания нового объекта, есть возможность воспользоваться интерфейсом IObjectContextAdapter. Это небольшой “хак”, который пришлось использовать для базового класса Repository<T>. По желанию можно добавить метод создания нового объекта. Например, такой:
public virtual TEntity CreateNewEntity<TEntity>() where TEntity : class, new()
{
    return new TEntity();
}
И т.д. на свое усмотрение.
Немного отвлекусь от темы, приведя один из неплохих способов построения приложения, если оно работает с несколькими БД или должно выполнять с базой несколько операций в одной транзакции. Для этого нужно добавить в репозиторий функции для работы с транзакциями или построить класс, который будет описывать уровень транзакции для ваших доменных моделей. Для такого случая необходимо добавить интерфейс, в котором объявить методы BeginTransaction() и CommitTransaction(). Затем в классе, который будет реализовывать этот интерфейс, добавить также наследование от нашего интерфейса RepositoryBase<T>. Работа с транзакциями не является основной для данной статьи, поэтому возвращаемся к работе с нашей моделью данных, или, другими словами, domain model. Необходимо создать слой для моделей. Для этого создадим папку Models в нашем проекте и реализуем наши классы, которые добавим в БД.
public class Customer
{
    public int Id { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string Address { get; set; }
}
Добавим класс Order:
public class Order
{
    public int Id { get; set; }
    public decimal Price { get; set; }
    public string Name { get; set; }
}
Структура проекта приведена ниже.
Осталось добавить эти модели в класс, наследуемый от DbContext, чтобы в БД создались соответствующие таблицы.
public class OrderContext : DbContext
{
    public DbSet<Customer> Customers { get; set; }
    public DbSet<Order> Orders { get; set; }
}
Теперь продемонстрируем, как это будет работать в действии.
static void Main(string[] args)
{
    try
    {
        using (var repository = new RepositoryBase<OrderContext>())
        {
            var findOrder = repository.Get<Order>(x => x.Name == "Test");
            if (findOrder == null)
            {
                var order = new Order();
                order.Name = "Test";
                order.Price = 12;
                repository.DataContext.Orders.Add(order);
                repository.Save(order);
            }
        }

        using (var repository =
            new RepositoryBase<OrderContext>())
        {
            var order = repository.Get<Order>(x => x.Name == "Test");
            Console.WriteLine("Name = {0}, Order = {1}", order.Name, order.Price);

            repository.Delete(order);
        }
    }
    catch (Exception ex)
    {
        Console.WriteLine(ex.ToString());
    }
    Console.ReadLine();
}
В примере создаем репозиторий дважды. Первый раз − чтобы проверить, есть ли искомый объект в БД, и если такого объекта не найдено, то добавить новую запись в БД. Следующий репозиторий проверяет, успешно ли добавлен в нашу базу объект на предыдущем шаге и просто удаляет его с этой базы. Одна из проблем, с которыми сталкиваются многие разработчики, − когда доступны все CRUD операции для всех репозиториев, в том числе тех, у которых некоторых из этих операций не должно быть. Например, запрещать некоторым репозиториям возможность удалять объекты. Посмотрим, как создать такой репозиторий, которому запретим возможность удаления объектов с БД (эта операция нам будет недоступна). Добавим необходимую реализацию интерфейса для репозитория по работе с клиентами.
public interface ICustomerRepository
{
    Customer FindById(int id);
    Customer FindByAddress(string address);
    List<Customer> GetAll();
    void Save(Customer custorer);
}
Осталось добавить логику по имплементации этого интерфейса.
public class CustomerRepository : RepositoryBase<OrderContext>, ICustomerRepository
{
    public Customer FindById(int id)
    {
        return Get<Customer>(customer => customer.Id == id);
    }

    public Customer FindByAddress(string address)
    {
        return Get<Customer>(customer => customer.Address == address);
    }

    public List<Customer> GetAll()
    {
        return GetList<Customer>().ToList();
    }

    public void Save(Customer custorer)
    {
        DataContext.Customers.Add(custorer);
        DataContext.SaveChanges();
    }
}
Классический пример использования данного репозитория выглядит следующим образом:
static void Main(string[] args)
{
    try
    {
        using (var repository = new CustomerRepository())
        {
            var entity = repository.CreateNewEntity<Customer>();
            entity.Address = "1234567890";
            repository.Save(entity);

            var findEntity = repository.FindByAddress("1234567890");
            repository.Delete(findEntity);
        }
    }
    catch (Exception ex)
    {
        Console.WriteLine(ex.ToString());
    }
    Console.ReadLine();
}
В этом примере есть существенная проблема, а именно: явный доступ к методу Delete с базового класса, что нарушает логику нашего репозитория, поскольку для конечного пользователя не должна быть доступна возможность удаления клиентов. В этом случае используем управление зависимостями с помощью установленного ранее IoC контейнера Autofac. С помощью Autofac мы сможем оперировать работой с интерфейсами без жесткой привязки к конкретной реализации этого интерфейса. Это позволяет легко тестировать наш интерфейс, используя моки и стабы. Реализация:
static void Main(string[] args)
{
    try
    {
        var builder = new ContainerBuilder();
        builder.RegisterGeneric(typeof(RepositoryBase<>))
            .PropertiesAutowired()
            .AsSelf();

        builder.RegisterType<CustomerRepository>().As<ICustomerRepository>();
        var container = builder.Build();
        var repository = container.Resolve<ICustomerRepository>();
        var newCustomer = new Customer
            {
                Address = "Test 123"
            };
        repository.Save(newCustomer);

        var customer = repository.FindByAddress("Test 123");
        Console.WriteLine("Customer Address {0}", customer.Address);
    }
    catch (Exception ex)
    {
        Console.WriteLine(ex.ToString());
    }
    Console.ReadLine();
}

Теперь в нашей реализации метод Delete недоступен, потому что мы работаем через интерфейс ICustomerRepository, в котором нет этого метода. Autofac позволяет при разработке оперировать понятием не класса, а интерфейса, для которого разработчики данного IoC контейнера используют термин “сервис” (service). Одним из самых популярных источников, где достойно описан данный паттерн, является книга Эрика Эванса "Предметно-ориентированное проектирование (DDD). Структуризация сложных программных систем". Книга достаточно сложная, но содержит множество расписанных примеров и рассуждений автора по построению хорошего ПО. Рекомендую эту книгу к прочтению, если вы хотите развиваться в направлении разработки качественного ПО. Основная проблема при использовании базового репозитория в приложениях − не его написание, а злоупотребление этим репозиторием. Если вся разработка будет использовать базовый репозиторий как основной способ разделения логики, то программист получает множество абстракций, в которых со временем сам начинает путаться. Поэтому главный принцип построения качественного программного обеспечения − это соблюдать меру.

No comments:

Post a Comment