Repository как уровень абстракции Data Acceess Layer или уход от рутины

Просто о NET | создано: 16.11.2017 | опубликовано: 22.11.2017 | обновлено: 13.01.2024 | просмотров: 4922

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

Предисловие

Существуют два способа программировать, причем независимо от платформы: Первый - "правильный", второй - "хороший". Первый способ подразумевает использование различных инфраструктурных паттернов, шаблонов проектирования, SOLID и DRY подходы, абстракции, ООП и другие законы и правила разработки кода. Второй способ обычно называют нецензурным словом, или просто "лапшекод". И то, что этот подход работает уже хорошо! Что такое Calabonga.EntityFramework? Это nuget-пакет, который содержит абстракции для реализации уровня "Репозиторий", который описан в статье "Архитектура приложений: концептуальные слои и договоренности по их использованию". Далее про сборку Calabonga.EntityFramework, о принципах и правилах ее использования с примерами и пояснениями.

Правильный подход

При работе с базой данных через ORM (в моем случае это EntityFramework) обычно приходится создавать одни и те же стандартные методы управления сущностью: создание, редактирование, удаление и чтение (CRUD). И только после этого добавлять более специфичные методы, которые необходимы для реализации процессов бизнес-логики приложения. Создание базовых методов для каждой из сущностей, сильно утомляет. А помимо базовых методов, зачастую, если не всегда, приходится создавать механизмы проецирования (mapping) одной сущности на другую (Model -> ViewModel). Сюда же можно добавить функциональность получения коллекции с разбиением на страницы (paging). А также какую-то функциональность по ведению журнала событий (logging). В общем, перед тем как заняться непосредственно прикладным программированием, то есть разрабатывать бизнес-процессы, приходится проделать большой объем рутиной работы. Чтобы максимально минимизировать эту рутину и была придумана сборка Calabonga.EntityFramework. Сборка содержит два основных класса ReadbleRepositoryBase и WritableRepositoryBase. Наследование от первого класса дает репозиторий, который может только читать данные, а от второго, соответственно, полноценный CRUD для конкретной сущности. 

Чтобы сборка заработала, у вас должна быть сущность Model и два вспомогательных класса для ее обновления и создания: CreateViewModel и UpdateViewModel. В моем примере будут следующие сущности: Person, PersonViewModel, PersonCreateViewModel, PersonUpdateViewModel.

В некоторых ситуациях, дополнительные ViewModel'ы не требуются, и можно отправлять саму модель как ViewModel-представление для методов создания и обновления. Но это практика подтвердилась только при использовании в "простых" приложениях. При использовании более сложных (комплексных) сущностей без UpdateViewModel и CreateViewModel обойтись очень сложно или вообще невозможно. 

Далее разберем из чего состоит сборка и как ее использовать. В конце статьи ссылка на демонстрационный проект консольного приложения. Стоит отметить, что Calabonga.EntityFramework легко можно использовать и на ASP.NET MVC, и на WPF и на других платформах. 

ReadableRepositoryBase

Абстрактный класс ReadableRepositoryBase реализует интерфейс IReadableRepository:

/// <summary>
    /// Generic service abstraction for read only operation with entity
    /// </summary>
    /// <typeparam name="TModel"></typeparam>
    /// <typeparam name="TQueryParams"></typeparam>
    public interface IReadableRepository<TModel, in TQueryParams>
: IDisposable where TQueryParams : IPagedListQueryParams
    {
        /// <summary>
        /// The role name for user with full access (without filtering data) rights
        /// </summary>
        bool IsFullAccess { get; set; }

        /// <summary>
        /// ApplicationDbContext for current app
        /// </summary>
        IEntityFrameworkContext AppContext { get; }

        /// <summary>
        /// Returns paged colletion by pageIndex
        /// </summary>
        /// <param name="queryParams"></param>
        /// <param name="orderPredecat"></param>
        /// <param name="sortDirection"></param>
        /// <param name="includeProperties"></param>
        /// <returns></returns>
        OperationResult<PagedList<TResult>> GetPagedResult<TResult, TSortType>(TQueryParams queryParams,
 Expression<Func<TModel, TSortType>> orderPredecat, SortDirection sortDirection,
params Expression<Func<TModel, object>>[] includeProperties);

        /// <summary>
        /// Returns paged colletion by pageIndex
        /// </summary>
        /// <param name="index">page index</param>
        /// <param name="size"></param>
        /// <param name="orderPredecat">order by</param>
        /// <param name="sortDirection"></param>
        /// <param name="search"></param>
        /// <returns></returns>
        OperationResult<PagedList<TResult>> GetPagedResult<TResult, TSortType>(int index, int size,
 Expression<Func<TModel, TSortType>> orderPredecat, SortDirection sortDirection,
string search = "");

        /// <summary>
        /// Returns request result with Model of the entity this service
        /// </summary>
        /// <param name="id"></param>
        /// <param name="includeProperties"></param>
        /// <returns></returns>
        OperationResult<TModel> GetById(Guid id, params Expression<Func<TModel, object>>[] includeProperties);


        /// <summary>
        /// Filter data by <see cref="TQueryParams"/>
        /// </summary>
        /// <param name="queryParams"></param>
        /// <param name="includeProperties"></param>
        /// <returns></returns>
        IQueryable<TModel> FilterData(TQueryParams queryParams, params Expression<Func<TModel,
object>>[] includeProperties);
    }

Хочу заметить, что результатом работы метода GetPagedResult может быть и Model, и ViewModel. А перегружая метод FilterData, вы можете применить фильтры на выборку. Специальное свойство IsFullAccess установленное в true, исключит использование метода ApplyRowLevelSecurity, то есть в базовом методе All() не будет проверок на разрешения. Здесь же надо сделать важное замечание. При работе с EntityFramework я обычно отключаю Lazy Loading, потому что предпочитаю сам контролировать процесс загрузки объектов через свойства навигации. Так вот, в этой сборке предусмотрена возможность подключения дополнительных свойств по связям при помощи набора лямбда выражений. Такая возможность есть у методов All(), Update(), Created(), GetEditById().

WritableRepositoryBase

Абстрактный класс WritableRepositoryBase, который реализует IWritableRepository:

/// <summary>
/// Generic service abstraction for writable view model operation with entity
/// </summary>
/// <typeparam name="TModel"></typeparam>
/// <typeparam name="TUpdateModel"></typeparam>
/// <typeparam name="TCreateModel"></typeparam>
/// <typeparam name="TQueryParams"></typeparam>
public interface IWritableRepository<TModel, TUpdateModel, TCreateModel, in TQueryParams>
    : IReadableRepository<TModel, TQueryParams>
    where TModel : class, IEntityId
    where TUpdateModel : class, IEntityId
    where TCreateModel : class
    where TQueryParams : IPagedListQueryParams
{
    /// <summary>
    /// Returns OpertaionResult with just added entity wrapped to ViewModel
    /// </summary>
    /// <param name="model">CreateViewModel with data for new object</param>
    /// <param name="beforeAddExecuted"></param>
    /// <param name="afterSaveChanges"></param>
    /// <returns></returns>
    OperationResult<TModel> Add(TCreateModel model, Expression<Action<TModel, TCreateModel>> beforeAddExecuted,
Expression<Action<TModel, TCreateModel>> afterSaveChanges);

    /// <summary>
    /// Returns OperationResult with flag about action success
    /// </summary>
    /// <param name="model">ViewModel with date for update entity</param>
    /// <param name="beforeUpdateExecuted"></param>
    /// <param name="afterSaveChanges"></param>
    /// <returns></returns>
    OperationResult<TModel> Update(TUpdateModel model, Expression<Action<TModel, TUpdateModel>> beforeUpdateExecuted = null,
Expression<Action<TModel, TUpdateModel>> afterSaveChanges = null);

    /// <summary>
    /// Retuns viewModel for editing view for entity
    /// </summary>
    /// <param name="id"></param>
    /// <param name="includeProperties"></param>
    /// <returns></returns>
    OperationResult<TUpdateModel> GetEditById(Guid id, params Expression<Func<TModel,
object>>[] includeProperties);

    /// <summary>
    /// Returns Delete operation result
    /// </summary>
    /// <param name="id"></param>
    /// <returns></returns>
    OperationResult<TModel> Delete(Guid id);
}

Из названия методов должно быть всё понятно. Единственное, о чем стоит упомянуть, так это метод GetEditById. Результатом работы будет ViewModel для редактирования сущности, то есть подготовленный для View объект UpdateViewModel.

Стоит также добавить, что все методы в ReadableRepositoryBase и WritableRepositoryBase объявлены как вирутальные, а значит могут быть переопределены на ваше усмотрение.

OperationResult и PagedList

Вы, наверное, обратили внимание, что результатами всех операций является специальный класс OperationResult. Это обыкновенная "обертка" (wrapper) для удобства обработки результатов работы метода (запроса). Ключевыми особенностями данной обертки является наличие сервисных сообщение и поле для хранения исключения при выполнении той или иной операции. Этот подход гарантирует пользователю (разработчику) всегда адекватный ответ от сервера. Адекватный, значит без Exception c наличием дополнительной информации. Также используется PagedListLite для работы постраничными запросами. Примеры по использованию далее в статье.

Возможности и особенности Calabonga.EntityFramework

В сборке реализован механизм отслеживания результатов последнего выполнения команды SaveChanges. Для этого вы всегда можете просмотреть SaveChangesResult  у IEntityFrameworkContext (про этот интерфейс ниже по тексту) после ее исполнения. Также есть обработка вложенных InnerException для более удобного представления информации об InnerException ошибке.

Вся библиотека построена на абстракциях для того чтобы можно написать свою реализацию, если не подходит реализация из базовых классов. Первая абстракция это интерфейс IEntityFrameworkContext, который являет собой представление DbContext (или IdentityDbContext) для "внутренних работ" сборки. То есть через этот интерфейс будет происходить обращение к базе данных. Вы можете не использовать DbContext от Microsoft, а написать свою реализацию, например, для NHibernate.

Следующая абстакция IEntityFrameworkLogService. Думаю, из названия должно быть понятно, что данный интерфейс может вести логирование (logging) взаимодействий с базой данных.

И еще одна важная абстракция IEntityFrameworkMapper, которая являет собой ни что иное как Mapper для классов. На ваше усмотрение это может быть AutoMapper или, например, ExpressMapper. Вам нужно просто выбрать один из существующих и "обернуть" в этот интерфейс. Именно IEntityFrameworkMapper и позволит вам выбирать из базы данных список сущностей и проецировать при необходимости в другую сущность. Так сказать, пресловутый "Model to ViewModel mapping".

Еще один немаловажный момент. Ваши классы сущностей должны реализовывать интерфейс IEntityId или должны быть наследниками от класса EntityId. Потому как через эту абстракцию строится работа с DbContext и в частности выборка по идентификаторам.

Примеры кода и советы по использованию

Для примера я создал демонстрационный проект, в котором показаны необходимые наследования и способы их использования. Из этого примера будет приведен код. Итак, первая на поверке модель Person:

/// <summary>
/// Entity for testing Calabonga.EntityFramework
/// </summary>
public class Person : IEntityId, IHaveName
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public Guid Id { get; set; }
    public string Name { get; set; }
}

Класс Person реализует IEntityId интерфейс, но ни что не мешает использовать и класс EntityId. Для этого класса у меня есть еще три дополнения в виде классов ViewModel'ов, описанных выше:

public class PersonViewModel : IHaveName
{
    public Guid Id { get; set; }
    public string Name { get; set; }
}

Этот ViewModel используется когда происходит выборка в список. Модели Person проецируются (mapping) в PersonViewModel при выборе постраничного списка. Но я также могу в методе GetPagedList<T> указать и тип Person, в этом случае я получу набор Person  объектов без проецирования (mapping) во PersonUpdateViewModel. Следующий класс CreateViewModel:

public class CreateViewModel
{
    public string Name { get; set; }
}

Этот ViewModel предназначен для создания новой записи в базе данных. Именно он отправляется в метод Create(). Далее идет UpdateViewModel. Этот класс используется для обновления сущности Person.

public class UpdateViewModel : IEntityId
{
    public string Name { get; set; }
    public Guid Id { get; set; }
}

Следующий по очереди интерфейс IEntityFrameworkLogService:

public class Logger : IEntityFrameworkLogService
{
    public void LogInfo(string message)
    {
        return;
    }

    public void LogError(Exception exception)
    {
        return;
    }
}

Как видно из кода, в демонстрационном примере я не собираюсь ничего записывать журнал событий, но вы можете использовать этот интерфейс, чтобы получать сообщения журнале событий с Exceptions или сообщениями типа "Запись {ID} успешно сохранена" или "Обновление для записи {ID} невозможно, потому запись не найдена".

IEntityFrameworkMapper - это следующий претендент на наследование. Напомню, что это всего лишь интерфейс, который может быть реализован разными способами, как уже существующими фреймворками типа AutoMapper или ExpressMapper, так и собственноручно написанный код projection. Я для примера использовал ExpressMapper:

public class Mapper : IEntityFrameworkMapper
{
    private IMappingServiceProvider _mapper;

    public Mapper()
    {
        if (_mapper == null)
        {
            _mapper = ExpressMapper.Mapper.Instance;
            RegisterMaps();
        }
    }

    public void RegisterMaps()
    {
        _mapper.Register<Person, PersonViewModel>();
        _mapper.Register<UpdateViewModel, Person>();
        _mapper.RegisterCustom<PagedList<Person>, PagedList<PersonViewModel>, PagedListResolver>();
    }

    public TDestionation Map<TSource, TDestionation>(TSource source)
    {
        return _mapper.Map<TSource, TDestionation>(source);
    }

    public void Map<TDestionation, TSource>(TDestionation model, TSource item)
        where TDestionation : class, IEntityId
        where TSource : class, IEntityId
    {
        _mapper.Map(typeof(TDestionation), typeof(TSource), model, item);
    }
}

Настройка и регистрация соответствий (mapping) каждого из выбранных сторонних фреймворков обычно описана на сайте производителя. Если вы предпочитаете использовать Linq Projection, то вы тоже это сможете сделать без особого труда.

Наиболее важный интерфейс для реализации IEntityFrameworkContext. Это основной "носитель данных" для Calabonga.EntityFramework. В демонстрационном примере я реализовал его следующим образом:

public class ApplicationDbContext : DbContext, IEntityFrameworkContext
{
    public ApplicationDbContext() : base("DefaultConnection")
    {
        Configuration.AutoDetectChangesEnabled = true;
        Configuration.LazyLoadingEnabled = false;
        Configuration.ProxyCreationEnabled = false;
        LastSaveChangesResult = new SaveChangesResult();
    }
    public SaveChangesResult LastSaveChangesResult { get; }

    public DbSet<Person> People { get; set; }

    public override int SaveChanges()
    {
        try
        {
            var createdSourceInfo = ChangeTracker.Entries().Where(e => e.State == EntityState.Added);
            var modifiedSourceInfo = ChangeTracker.Entries().Where(e => e.State == EntityState.Modified);

            foreach (var entry in createdSourceInfo)
            {
                // do some staff
                // ...

                // Add system message
                LastSaveChangesResult.AddMessage($"ChangeTracker has new entities: {entry.Entity.GetType()}");
            }

            foreach (var entry in modifiedSourceInfo)
            {
                // do some staff
                // ...

                LastSaveChangesResult.AddMessage($"ChangeTracker has modified entities: {entry.Entity.GetType()}");
            }

            return base.SaveChanges();
        }
        catch (DbUpdateException exception)
        {
            LastSaveChangesResult.Exception = exception;
            return 0;
        }
    }
}

Обратие внимание как использован LastSaveChangesResult. Мне кажется это очень полезная функция. 

Program.cs

Итак, мы дошли до самого основного класса программы. Я пройдусь комментариями по строчкам основного метода Main, потому что подробности вы всегда сможете посмотреть в демонстрационном проекте. В проекте установлен Autofac (Dependency Injection Container), и поэтому вначале метода Main создаем container:

var builder = new ContainerBuilder();
builder.RegisterType<ApplicationDbContext>().As<IEntityFrameworkContext>();
builder.RegisterType<Mapper>().As<IEntityFrameworkMapper>();
builder.RegisterType<Logger>().As<IEntityFrameworkLogService>();
builder.RegisterType<PeopleRepostiory>().AsSelf();
var container = builder.Build();

В контейнере регистрируем все перечисленные выше интерфейсы и из реализации. Теперь получаем экземпляр (instance) из DI наш репозиторий и ApplicationDbContext:

var repostiory = container.Resolve<PeopleRepostiory>();
var context = container.Resolve<IEntityFrameworkContext>();
if (!context.Database.Exists())
{
    //Add items
    AddItems(repostiory);
}

Если база данных не создана в SQL - создаем ее и добавляем через репозиторий некоторое количество записей. Далее выводим в консоль постранично все записи:

// Getting pagedList
Print(repostiory, ((ApplicationDbContext)context).People.Count());

После этого создаем новую запись, опять же при помощи репозитория:

// Add demo
AddNewPerson(repostiory);

И, наконец, делаем обновления какие-то во вновь созданной записи:

// Update demo
UpdatePerson(repostiory);

Удаление сущности работает по такому же принципу.

Примеры продвинутого использования

Я приведу примеры из других реальных проектов. Вот метод получения CategoryViewModel используется в данном блоге в панели администратора. Через обобщенные параметры в метод GetPagedResult подставляется тип CategoryViewModel и DateTime, чтобы результат выполнения был PagedList<CategoryViewModel>, а сортировка была произведена по полю CreatedAt по возрастанию. Последним параметром стоит лямбда x.Posts. Это значит, что Posts будут загруженные через Include(x=>x.Posts)

public ActionResult Index(int? id)
{
    var qp = new PagedListQueryParams
    {
        PageIndex = id ?? 1
    };
    var pagedResult = _categoryRepository.GetPagedResult<CategoryViewModel, DateTime>(
        qp,
        x => x.CreatedAt,
        SortDirection.Ascending,
        x=>x.Posts);
    if (pagedResult.Ok)
    {
        return View(pagedResult.Result);
    }
    return View();
}

Следующий пример вызова GetPagedList для сущности Post из его репозитория уже с несколькими подключенными свойствами навигации (Include). В этом примере используется уже три зависимых сущности:

var posts = _postRepository.GetPagedResult<PostViewModel, DateTime>(
    queryParams,
    x => x.CreatedAt,
    SortDirection.Descending,
    x => x.Category,
    x => x.Tags,
    x => x.Comments);
return View(posts);

В следующем примере происходит обновление Post через метод Update. Обратите внимание на метод BeforeUpdateExecuted. В метода Update есть перегрузки, которые позволяют включить в обработку два дополнительных метода. Первый выполняется сразу после того как была произведена операция Mapping, то есть свойства из PostViewModel обновили значения у сущности Post, но сохранения еще не было. Вторая метод из перегрузки выполняется после того как была выполнения запись в базу данных, то есть после вызова метода SaveChanges.

[HttpPost]
[ValidateAntiForgeryToken]
[ValidateInput(false)]
public ActionResult Edit(PostUpdateViewModel model, string returnUrl = null)
{
    if (ModelState.IsValid)
    {
        var update = _postRepository.Update(model, (m, i) => BeforeUpdateExecuted(m, i));
        if (update.Ok)
        {
            if (!string.IsNullOrEmpty(returnUrl))
            {
                return Redirect(returnUrl);
            }
            return RedirectToRoute("Post", new { title = model.UniqueId });
        }

        if (update.Error != null)
        {
            ViewData["Error"] = ExceptionHelper.GetMessages(update.Error);
        }
    }
    FillCategories();
    return View(model);
}

private void BeforeUpdateExecuted(Post post, PostUpdateViewModel postUpdateViewModel)
{
    _tagService.ProcessTags(postUpdateViewModel.TagsAsString, post);
}

Вот пример перегрузки метода ApplyRowLevelSecurity, в котором я в зависимости от свойства IsFullAccess устанавливаю фильтр:

protected override IQueryable<Post> ApplyRowLevelSecurity(IQueryable<Post> items)
{
    return IsFullAccess
        ? base.ApplyRowLevelSecurity(items)
        : items.Where(x => x.State.HasFlag(PostState.Published));
}

В следующем примере происходит добавление комментария в базу данных.

[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
[ValidateInput(false)]
public ActionResult Add(CommentCreateViewModel model, string returnUrl)
{
    var recaptchaHelper = this.GetRecaptchaVerificationHelper();
    if (string.IsNullOrEmpty(recaptchaHelper.Response))
    {
        ModelState.AddModelError("", "Проверочный код обязательное поле");
        return Redirect(returnUrl);
    }
    if (ModelState.IsValid)
    {
        var operationResult = _commentRepository.Add(model,
            (m, i) => ProcessCommentsBeforeSave(m, i),
            (m, i) => ProcessCommentsAfterSave(m, i));
        if (!operationResult.Ok) return Redirect(returnUrl);
        TempData["ReturnUrl"] = returnUrl;
        return RedirectToAction("ThanksForComment", new { operationResult.Result.Id });
    }
    return Redirect(returnUrl);
}

private void ProcessCommentsBeforeSave(Comment comment, CommentCreateViewModel create)
{
    // Do some staff
    // Is user admin validation
}

private void ProcessCommentsAfterSave(Comment comment, CommentCreateViewModel create)
{
    // Do some staff
    // Send notification to administrator
}

И, на последок, хочу показать еще один метод, который создан в классе PostRepository, на этот раз сначала пример, потом комментарий:

/// <summary>
/// Get top view vount for posts
/// </summary>
/// <param name="daysCount"></param>
/// <param name="categoryName"></param>
/// <param name="tag"></param>
/// <param name="totalItems"></param>
/// <returns></returns>
public OperationResult<List<PostTopViewModel>> GetTop(int daysCount, string categoryName, string tag, int totalItems = 6)
{
    var statisticPeriod = new StatisticPeriod(new TimeSpan(daysCount, 0, 0, 0));
    var all = All(x => x.Category);
    var allStats = ((IApplicationDbContext)AppContext).Statistics;

    var statistics = allStats.Where(x => DbFunctions.TruncateTime(x.UpdatedAt) >= statisticPeriod.DateStart.Date
        && DbFunctions.TruncateTime(x.UpdatedAt) <= statisticPeriod.DateEnd.Date);

    if (!string.IsNullOrEmpty(categoryName) && categoryName.ToLower() != "all")
    {
        all = all.Where(x => x.Category.UniqueId.Equals(categoryName));
    }
    var hasTag = !string.IsNullOrEmpty(tag);
    if (hasTag && all.Any())
    {
        all = all.Where(x => x.Tags.Any(t => t.Name == tag));
    }

    // Some manipulations with selected items
    // and as result itemsViewModels

    return OperationResult.CreateResult(itemsViewModels);
}

Пожалуй, выделю только два момента. Первый. В 12-ой строке из метода All(), который доступен в контексте всех репозиториев, унаследованных от ReadableRepositoryBase или WritableRepositoryBase. подключаются через лямбду как Category, то есть Incude(x=>x.Category). Второй момент. В 13-ой строке AppContext приводится к типу IApplicationDbContext, после чего у меня становятся доступны все типы сущностей, которые были зарегистрированы в IApplicationDbContext.

Заключение

В заключении хочу отметить, что Calabonga.EntityFramework существует в версии и для .NET Standard 2.0, то есть вы можете использовать ее и в проектах ASP.NET Core совместно с EntityFrameworkCore. Хочется сделать сборку максимально полезной, поэтому в комментарии принимаются любые дополнения, замечания, предложения и даже критика. В крайнем случае, можно писать через форму обратной связи сайта.

Ссылки

Calabonga.EntityFramework - nuget-пакет библиотеки для .NET Framework

Calabonga.EntityFrameworkCore - nuget-пакет библиотеки для .NET Standard (Core)

Демонстрационный проект на GitHub - консольное приложение