Сегодня
мы затронем тему использования паттерна 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);
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