Мини-туториал от ведущего разработчика "ITQ Group" Александра Берегового.

В этой статье рассмотрим применение обобщенного подхода при разработке WEB API.

В моей практике несколько раз приходилось разрабатывать интерфейс администрирования для информационных систем. Зачастую требуется разработать WEB API, которое выполняет CRUD-операции над сущностями доменной модели приложения. Т.е. для каждой сущности требуется создать контроллер, который будет уметь следующее:

  • возвращать список сущностей, возможно с постраничным выводом и сортировкой;

  • возвращать сущность по идентификатору;

  • добавлять новую сущность в базу данных;

  • изменять свойства существующей сущности;

  • удалять сущность по ее идентификатору.

Из вышеперечисленного видно, что каждый контроллер будет делать одно и то же. Разница будет в типе сущности, с которой умеет работать контроллер. Поэтому здесь напрашивается применение generic-классов, где обобщенным параметром будет тип сущности.

Исходный код проекта можно найти здесь.

Создание проекта

Итак, создадим новый проект. Выберем тип шаблона – ASP.Net Core Web API. В качестве фреймворка выберите .Net 7, как показано на рисунке ниже.

После создания проекта структура проекта должна быть примерно такой:

То есть в проекте не должно быть ничего, кроме файла настроек и модуля Program.cs.

В модуле Program.cs должен быть примерно такой код, выполняющий инициализацию приложения и его запуск:

namespace Generic.Web.API
{
    public class Program
    {
        public static void Main(string[] args)
        {
            var builder = WebApplication.CreateBuilder(args);

            // Add services to the container.
            builder.Services.AddAuthorization();

            // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
            builder.Services.AddEndpointsApiExplorer();
            builder.Services.AddSwaggerGen();

            var app = builder.Build();

            // Configure the HTTP request pipeline.
            if (app.Environment.IsDevelopment())
            {
                app.UseSwagger();
                app.UseSwaggerUI();
            }

            app.UseHttpsRedirection();

            app.UseAuthorization();

            app.Run();
        }
    }
}

Добавление базы данных

В данном примере мы будем использовать Entity Framework и MS SQL Local DB. Поэтому, давайте добавим в проект файл базы данных. Я сделал это следующим образом:

  1. Открыл SQL Server Management Studio

  2. Вызвал команду New Database из контекстного меню, как показано на рисунке ниже

  3. В открывшемся диалоговом окне указал имя базы данных – Employees

  4. Вновь созданная БД появится в дереве Object Explorer

  5. Теперь нужно «отсоединить» БД от SQL Server, выполнив команду Detach

  6. Далее, нужно пойти в каталог файловой системы, где MS SQL сохраняет файлы баз данных и скопировать оттуда файлы Emplyees.mdf и Employees_log.ldf в каталог AppData проекта в Visual Studio. Для того, чтобы узнать, где в вашей системе MS SQL Server сохраняет файлы БД, можно кликнуть правой кнопкой мыши по корневому узлу в Object Explorer и вызвать команду Properties…


    В открывшемся окне нужно выбрать страницу Database Settings. На этой странице найдите раздел Database default locations. Нас интересует значение поля Data, это и есть путь к искомому каталогу.

Итак, после проделанных манипуляций, дерево проекта должно выглядеть так:

В файл настроек проекта, appsettings.json (или его development версию) нужно добавить строку подключения БД:

"ConnectionStrings": {
	"EmployeesDB":
	"Server=(localdb)\\mssqllocaldb;Database=Employees;AttachDbFileName=AppData\\Employees.mdf;
Trusted_Connection=True;MultipleActiveResultSets=true"
}

Примечание: стоит отметить, что база данных может быть создана в результате применения миграций. Я лишь показал, как можно вручную добавить базу данных типа MS SQL Local DB в проект.

Добавление БД контекста

Для продолжения работы нам нужно добавить DB context Entity Framework. В проект нужно добавить Nuget-пакет Microsoft.EntityFrameworkCore.SqlServer, который, в свою очередь зависит от Microsoft.EntityFrameworkCore.Relational. После добавления пакета в проект, в файле проекта должна появиться следующая строка:

<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="7.0.5" />

Все классы, относящиеся к доступу к данным, я буду сохранять в каталог проекта DAL.

Итак, давайте добавим класс DB-контекста, я назвал его EmployeesDbContext.

Наш DB-контекст должен быть унаследован от класса DbContext из пространства имен Microsoft.EntityFrameworkCore. Класс должен иметь конструктор, позволяющий передать параметры, как показано ниже:

using Microsoft.EntityFrameworkCore;

namespace Generic.Web.API.DAL;

public class EmployeesDbContext : DbContext
{
    public EmployeesDbContext()
    {
    }

    public EmployeesDbContext(DbContextOptions<EmployeesDbContext> options)
        : base(options)
    {
    }
}

Далее, нам нужно выполнить инициализацию DB-контекста на старте приложения. Сделаем это через метод расширения. Для этого добавим в проект каталог Extensions, и добавим в него класс DbContextRegistrar.

Код класса приведен ниже:

using Generic.Web.API.DAL;
using Microsoft.EntityFrameworkCore;

namespace Generic.Web.API.Extensions
{
    public static class DbContextRegistrar
    {
        private const string ConnectionStringName = "EmployeesDB";

        public static IServiceCollection AddDbContext(this IServiceCollection services, IConfiguration configuration)
        {
            var connectionString = configuration.GetConnectionString(ConnectionStringName);

            services.AddDbContext<EmployeesDbContext>(opts => opts.UseSqlServer(connectionString));

            return services;
        }
    }
}

Добавим вызов метода расширения в основной модуль приложения:

using Generic.Web.API.Extensions;

namespace Generic.Web.API
{
    public class Program
    {
        public static void Main(string[] args)
        {
	        var builder = WebApplication.CreateBuilder(args);
	        var services = builder.Services;
	        var configuration = builder.Configuration;
	
	        // Add services to the container.
	        builder.Services.AddAuthorization();

	        services.AddDbContext(configuration); // подключение DB контекста

            // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
            builder.Services.AddEndpointsApiExplorer();
            builder.Services.AddSwaggerGen();

            var app = builder.Build();

            // Configure the HTTP request pipeline.
            if (app.Environment.IsDevelopment())
            {
                app.UseSwagger();
                app.UseSwaggerUI();
            }

            app.UseHttpsRedirection();

            app.UseAuthorization();

            app.Run();
        }
    }
}

Добавление класса сущности – Employee. Code first

Для начала определим базовый интерфейс для всех сущностей, которые будут храниться в нашей БД. Для этого я добавлю интерфейс IEntity, и введу требование, что сущность должна обладать целочисленным идентификатором. Добавьте в проект каталог Interfaces, и добавьте в него модуль IEntity.cs. Код интерфейса показан ниже:

amespace Generic.Web.API.Interfaces
{
    public interface IEntity
    {
        int Id { get; }
    }
}

Теперь мы можем добавить класс сущности. Пусть это будет сущность сотрудника – Employee. Добавьте соответствующий класс в каталог DAL проекта. Код класса приведен ниже:

using Generic.Web.API.Interfaces;

namespace Generic.Web.API.DAL
{
    public class Employee : IEntity
    {
        public int Id { get; set; }

        public string FirstName { get; set; } = string.Empty; 
        
        public string LastName { get; set; } = string.Empty;
    }
}

Теперь нам нужно расширить класс DB-контекста, чтобы научить его работать с вновь созданным типом сущностей. Для этого нужно добавить свойство Employees, как показано ниже:

using Microsoft.EntityFrameworkCore;

namespace Generic.Web.API.DAL;

public class EmployeesDbContext : DbContext
{
    public EmployeesDbContext()
    {
    }

    public EmployeesDbContext(DbContextOptions<EmployeesDbContext> options)
        : base(options)
    {
    }

    public virtual DbSet<Employee> Employees { get; set; }
}

Чтобы Entity Framework знал, каким образом наша сущность должна быть сохранена в БД, нам нужно добавить в класс DB-контекста код, описывающий нашу сущность. Для этого нужно переопределить метод OnModelCreating DB-контекста, как показано ниже:

using Microsoft.EntityFrameworkCore;

namespace Generic.Web.API.DAL;

public class EmployeesDbContext : DbContext
{
    public EmployeesDbContext()
    {
    }

    public EmployeesDbContext(DbContextOptions<EmployeesDbContext> options)
        : base(options)
    {
    }

    public virtual DbSet<Employee> Employees { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Employee>(entity =>
        {
            entity.ToTable(nameof(Employees));
            
            entity.HasKey(e => e.Id);
            
            entity.Property(e => e.FirstName)
                  .IsRequired()
                  .HasMaxLength(100);

            entity.Property(e => e.LastName)
                  .IsRequired()
                  .HasMaxLength(100);
        });
    }
}

В нашем случае мы сообщаем Entity Framework, что наша сущность Employee должна храниться в таблице БД Employees, в этой таблице должен быть создано поле Id, которое должно быть первичным ключом. Также в таблицу должны быть добавлены поля FirstName и LastName, которые не должны допускать сохранения пустых значений, и должны иметь максимально допустимую длину в 100 символов.

Добавление миграции базы данных

Добавим подключение к БД Employees в Server Explorer среды Visual Studio. Для этого нужно открыть панель Server Explorer и выполнить команду Add Connection, как показано на рисунке ниже.

В открывшемся диалоговом окне убедитесь, что выбран правильный тип источника данных - Microsoft SQL Server Database File. Далее, нажмите кнопку Browse. В окне выбора файла данных найдите файл базы данных. Ранее, мы поместили его в каталог AppData нашего проекта.

Выберите файл и нажмите кнопку Open. Новое подключение появится в дереве подключений Server Explorer. На рисунке ниже видно, что наша БД не содержит ни одной таблицы:

Давайте попробуем заставить Entity Framework создать таблицу сотрудников в нашей базе данных. Полную информацию о миграциях Entity Framework вы можете найти тут: https://learn.microsoft.com/en-us/ef/core/managing-schemas/migrations/?tabs=vs.

Для этого нам потребуется подключить к проекту еще один NuGet-пакет - Microsoft.EntityFrameworkCore.Design. После добавления пакета в проект в файле проекта должны появиться строки следующего вида:

<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.5">
	<PrivateAssets>all</PrivateAssets>
	<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

Для добавления первой миграции нам нужно открыть Package Manager Console и выполнить там следующую команду:

Add-Migration InitialCreate

В консоли вы должны увидеть следующий вывод:

После завершения выполнения команды, приведенной выше, в проекте должны появиться два модуля в каталоге Migrations проекта: 

  1. EmployeesDbContextModelSnapshot.cs

  2. <current_date_time>_InitialCreate.cs

Первый содержит инструкции для построителя моделей, аналогичные тем, что мы добавили в DB-контекст.

Второй модуль содержит код миграций базы данных. Метод Up применяет миграцию «вверх», т.е. создает таблицу Employees. А метод Down – откатывает миграцию, в данном случае – удаляет таблицу Employees.

Теперь, чтобы наша таблица появилась в БД, нужно выполнить команду:

Update-Database InitialCreate

Команду выполняем там же, в консоли Package Manager.

Если теперь открыть дерево объектов БД в Server Explorer, то можно увидеть, что в базу данных добавились две таблицы:

  1. __EFMigrationsHistory – это служебная таблица, которую использует Entity Framework для отслеживания того, какие миграции применены к базе данных.

  2. Employees – таблица, которую нам и требовалось создать для работы с нашими сущностями.

Добавление обобщенного репозитория

Объявим интерфейс репозитория. Добавим новый модуль – IRepository.cs в каталог Interfaces проекта.

namespace Generic.Web.API.Interfaces
{
    public interface IRepository<TEntity> where TEntity : IEntity
    {
        TEntity Add(TEntity entity);
        TEntity Update(int id, TEntity entity);
        void Delete(TEntity entity);
        IQueryable<TEntity> GetAll();
        TEntity? GetById(int id);
    }
}

Итак, наш репозиторий будет работать с сущностями, реализующими интерфейс IEntity.

Методы Add(), Update() и Delete() – это реализация CRUD-операций.

Метод GetAll() будет возвращать все сущности данного типа.

И, наконец, метод GetById() будет возвращать сущность по ее идентификатору.

Позже мы добавим базовый класс API-контроллера, который будет получать ссылку на репозиторий через параметр конструктора. Таким образом, контроллер не будет ничего знать о реализации репозитория.

Обратите внимание, что метод GetAll() возвращает результат типа IQueryable<IEntity>. Это сделано для того, чтобы не перегружать класс репозитория такой функциональностью, как сортировка и постраничный вывод. Позже мы добавим extension-классы с соответствующей функциональностью.

Теперь, когда у нас есть интерфейс репозитория, мы можем добавить реализацию для него. Добавьте новый каталог Repositories в проект, и добавьте в этот каталог новый модуль - _GenericRepository.cs. Я добавил символ `_` в начало названия файла лишь для того, чтобы этот модуль всегда оставался вверху списка модулей в каталоге. Сам же класс не будет иметь этого символа в своем названии, чтобы не нарушать принятых в C# соглашений об именовании классов. Код класса приведен ниже:

using Generic.Web.API.Interfaces;
using Microsoft.EntityFrameworkCore;

namespace Generic.Web.API.Repositories
{
    public abstract class GenericRepository<TDbContext, TEntity> : IRepository<TEntity>
        where TDbContext : DbContext
        where TEntity : class, IEntity
    {
        protected readonly TDbContext _dbContext;

        protected abstract DbSet<TEntity> DbSet { get; }

        public TEntity Add(TEntity entity)
        {
            DbSet.Add(entity);

            _dbContext.SaveChanges();

            return entity;
        }

        public TEntity Update(int id, TEntity entity)
        {
            DbSet.Update(entity);

            _dbContext.SaveChanges();

            return entity;
        }

        public void Delete(TEntity entity)
        {
            DbSet.Remove(entity);

            _dbContext.SaveChanges();
        }

        public virtual IQueryable<TEntity> GetAll()
        {
            return DbSet;
        }

        public virtual TEntity? GetById(int id)
        {
            return DbSet.FirstOrDefault(x => x.Id == id);
        }

        protected GenericRepository(TDbContext dbContext)
        {
            _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
        }
    }
}

Класс GenericRepository имеет два обобщенных параметра:

  1. TDbContext – контекст базы данных, который должен быть наследником DbContext;

  2. TEntity – тип сущности, который должен реализовывать интерфейс IEntity.

Ссылку на DBContext репозиторий будет получать через параметр конструктора – внедрение зависимости через конструктор.

Причина, по которой класс репозитория сделан абстрактным, заключается в том, чтобы заставить наследников реализовать абстрактное свойство DbSet

Реализация репозитория – EmployeeRepository

Давайте добавим реализацию обобщенного репозитория на примере репозитория сотрудников. Добавьте в каталог Repositories проекта новый модуль, назовите его EmployeeRepository.cs. Код класса приведен ниже.

using Generic.Web.API.DAL;
using Microsoft.EntityFrameworkCore;

namespace Generic.Web.API.Repositories
{
    public class EmployeeRepository : GenericRepository<EmployeesDbContext, Employee>
    {
        public EmployeeRepository(EmployeesDbContext dbContext)
            : base(dbContext)
        {
        }

        protected override DbSet<Employee> DbSet => _dbContext.Employees;
    }
}

В классе EmployeeRepository нам пришлось реализовать свойство DbSet, т.к. данное свойство помечено как абстрактное в базовом классе. Код свойства тривиален, оно возвращает ссылку на набор сущностей Employees контекста БД.

Обратите внимание, что класс-наследник обобщенного репозитория не содержит кода остальных методов (Add(), Update(), Delete(), GetAll(), GetById()). В этом и есть преимущество обобщенных классов.

Нам осталось зарегистрировать нашу реализацию в контейнере, чтобы позже мы смогли внедрять репозитории через конструкторы контроллеров. Дополним метод AddDbContext() класса DbContextRegistrar, как показано ниже:

public static IServiceCollection AddDbContext(this IServiceCollection services, IConfiguration configuration)
{
    var connectionString = configuration.GetConnectionString(ConnectionStringName);

    services.AddDbContext<EmployeesDbContext>(opts => opts.UseSqlServer(connectionString));

    services.AddScoped<IRepository<Employee>, EmployeeRepository>();

    return services;
}

Добавление обобщенного API-контроллера

Наконец, мы добрались до заявленной в заголовке статьи цели – Web API. Давайте добавим обобщенный API-контроллер в наш проект. Добавьте в проект новый каталог – Controllers. Создайте в этом каталоге модуль - __GenericApiController.cs. Код класса контроллера приведен ниже.

using Generic.Web.API.Interfaces;
using Microsoft.AspNetCore.Mvc;

namespace Generic.Web.API.Controllers.Api
{
    public abstract class GenericApiController<TEntity> : ControllerBase
        where TEntity : class, IEntity
    {
        private readonly IRepository<TEntity> repository;

        protected GenericApiController(IRepository<TEntity> repository)
        {
            this.repository = repository;
        }

        [HttpGet]
        public virtual ActionResult<IEnumerable<TEntity>> GetAll()
        {
            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }

            var entities = repository.GetAll();

            return Ok(entities);
        }

        [HttpGet("{id}")]
        public ActionResult<TEntity> GetOne(int id)
        {
            var foundEntity = repository.GetById(id);

            if (foundEntity == null)
            {
                return NotFound();
            }

            return Ok(foundEntity);
        }


        [HttpPost]
        public ActionResult<TEntity> Create([FromBody] TEntity toCreate)
        {
            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }

            var created = repository.Add(toCreate);

            return Ok(created);
        }

        [HttpPatch("{id}")]
        public ActionResult<TEntity> Update(int id, [FromBody] TEntity toUpdate)
        {
            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }

            var updated = repository.Update(id, toUpdate);

            if (updated == null)
            {
                return NotFound();
            }

            return Ok(updated);
        }


        [HttpDelete("{id}")]
        public ActionResult<TEntity> Delete(int id)
        {
            var entity = repository.GetById(id);

            if (entity == null)
            {
                return NotFound();
            }

            repository.Delete(entity);

            return Ok(entity);
        }
    }
}

Из  примера кода  видно, что класс обобщенного API-контроллера наследует от ControllerBase, и имеет обобщенный параметр TEntity, который, в свою очередь, должен реализовывать интерфейс IEntity. Контроллер получает ссылку на репозиторий через параметр конструктора.

Давайте добавим контроллер для работы с сущностями Employee. Для этого добавьте в каталог Controllers новый модуль – EmployeesController.cs. Код контроллера приведен здесь:

using Generic.Web.API.DAL;
using Generic.Web.API.Interfaces;
using Microsoft.AspNetCore.Mvc;

namespace Generic.Web.API.Controllers.Api
{
    [ApiController]
    [Route("/api/1.0/[controller]")]
    public class EmployeesController : GenericApiController<Employee>
    {
        public EmployeesController(IRepository<Employee> repository) : base(repository)
        {
        }
    }
}

Как видите, в наследнике обобщенного контроллера мы указываем тип сущности, с которой будет работать контроллер, а также маршрут, указанный в атрибуте Route.

[Route("/api/1.0/[controller]")]

Запуск приложения

Перед запуском приложения нужно заглянуть в файл launchSettings.json, который должен лежать в каталоге Properties нашего проекта. Подробные сведения об этом файле вы можете найти здесь: https://dotnettutorials.net/lesson/asp-net-core-launchsettings-json-file/

Нам нужно убедиться, в том, что после запуска приложения будет запущен браузер, и в том, что будет открыта страница Swagger. Убедитесь, что параметр launchBrowser выставлен в значение true, а параметр launchUrl имеет значение "swagger".

Если мы запустим наше приложение, то, в случае успеха, должен запуститься браузер, как показано на рисунке ниже:

Давайте попытаемся выполнить метод POST /api/1.0/employees. Для этого, раскройте соответствующую секцию страницы и нажмите кнопку Try it out. Затем отредактируйте предложенный JSON в поле Request body, как показано вот здесь:

{
  "id": 0,
  "firstName": "Петр",
  "lastName": "Иванов"
}

Значение поля id нас не интересует, так как в БД это поле помечено как Identity и его значение будет сгенерировано движком базы данных.

После нажатия кнопки Execute POST-запрос будет отправлен в наше API и это приведет к созданию записи в таблице Employees базы данных.

В секции Response страницы swagger, в случае успеха, мы должны увидеть код ответа – 200, и тело ответа, как показано на рисунке ниже:

Добавьте еще несколько записей через метод POST.

Теперь давайте попробуем получить список созданных записей методом GET. Раскройте соответствующую секцию страницы и нажмите кнопку Try it out, теперь нажмите кнопку Execute.

В секции Response должен появиться ответ с кодом 200, примерно такой, как показано на рисунке ниже:

Далее я предлагаю вам самостоятельно протестировать оставшиеся методы.

Добавление постраничного вывода данных

Давайте рассмотрим метод GetAll() более подробно.

[HttpGet]
public virtual ActionResult<IEnumerable<TEntity>> GetAll()
{
    if (!ModelState.IsValid)
    {
        return BadRequest(ModelState);
    }

    var entities = repository.GetAll();

    return Ok(entities);
}

Можно заметить, что этот метод является потенциально опасным с точки зрения производительности. Предположим, в нашей организации работает несколько десятков или даже сотен сотрудников. В этом случае ответ метода будет иметь значительный размер, да и время, которое потребуется на выборку данных из БД будет большим. Кроме того, возникнут сложности с отображением такого большого объема данных на конечном устройстве, будь то браузер или мобильное приложение. Чтобы избежать этой проблемы, внедряют постраничный вывод данных, а в метод добавляют параметры, сообщающие API номер требуемой страницы и размер страницы данных. Давайте попробуем реализовать данный функционал в нашем API.

Итак, для начала добавим интерфейс, описывающий параметры постраничного вывода. Добавьте в каталог Interfaces проекта модуль IPageable.cs. Код интерфейса приведен ниже:

namespace Generic.Web.API.Interfaces
{
    /// <summary>
    /// Параметры постраничного вывода
    /// </summary>
    public interface IPageable
    {
        /// <summary>
        /// Номер страницы, нумерация начинается с 0
        /// </summary>
        int PageNumber { get; }

        /// <summary>
        /// Размер страницы, должен быть больше 0
        /// </summary>
        int PageSize { get; }
    }
}

Реализацию данного интерфейса поместим в класс Pageable.cs, в каталог Pagination нашего проекта.

using Generic.Web.API.Interfaces;
using Microsoft.AspNetCore.Mvc;
using System.ComponentModel.DataAnnotations;

namespace Generic.Web.API.Pagination
{
    /// <inheritdoc/>
    public class Pageable : IPageable
    {
        /// <inheritdoc/>
        [FromQuery(Name = "page")]
        public int PageNumber { get; set; }

        /// <inheritdoc/>
        [Required]
        [FromQuery(Name = "size")]
        [Range(1, int.MaxValue, ErrorMessage = "Укажите значение больше чем {1}")]
        public int PageSize { get; set; }
    }
}

Теперь мы можем добавить параметр pageable в action-метод GetAll() базового API-контроллера.

public virtual ActionResult<IEnumerable<TEntity>> GetAll([FromQuery] Pageable pageable)

Сами данные будем возвращать в классе Page, который помимо возвращаемых сущностей будет содержать сведения о странице данных, такие как номер страницы, ее размер, общее количество сущностей и др. Добавьте новый модуль PageMetadata.cs в каталог Pagination проекта. Код класса приведен ниже.

namespace Generic.Web.API.Pagination
{
    /// <summary>
    /// Метаданные страницы данных
    /// </summary>
    public class PageMetadata
    {
        /// <summary>
        /// Номер страницы
        /// </summary>
        public int Number { get; init; }

        /// <summary>
        /// Размер страницы
        /// </summary>
        public int Size { get; init; }

        public int Count { get; init; }

        /// <summary>
        /// Общее количество сущностей в БД
        /// </summary>
        public long TotalElements { get; init; }

        /// <summary>
        /// Общее число страниц данных
        /// </summary>
        public int TotalPages => (int)(TotalElements / Size);

        public int From => Number * Size;

        public int To => Number * Size + Count;
    }
}

Теперь добавим класс, собственно, страницы данных. Добавьте модуль Page.cs в каталог Pagination проекта. Код класса можно увидеть вот здесь:

namespace Generic.Web.API.Pagination
{
    /// <summary>
    /// Страница данных
    /// </summary>
    /// <typeparam name="TEntity">тип элемента</typeparam>
    public class Page<TEntity>
    {
        /// <summary>
        /// Создает экземпляр страницы данных
        /// </summary>
        /// <param name="items">коллекция элементов</param>
        /// <param name="pageNumber">номер страницы. нумерация начинается с 0</param>
        /// <param name="size">размер страницы</param>
        /// <param name="totalElements">общее число элементов</param>
        /// <exception cref="ArgumentNullException"></exception>
        /// <exception cref="ArgumentOutOfRangeException"></exception>
        public Page(IEnumerable<TEntity> items, int pageNumber, int size, long totalElements)
        {
            if (items == null)
            {
                throw new ArgumentNullException(nameof(items));
            }

            if (size <= 0)
            {
                throw new ArgumentOutOfRangeException(nameof(size), "Размер страницы должен быть больше 0");
            }

            if (pageNumber < 0)
            {
                throw new ArgumentOutOfRangeException(nameof(pageNumber), "Номер страницы должен быть равен или больше 0");
            }

            Content = new List<TEntity>(items);
            PageMetadata = new PageMetadata
            {
                Number = pageNumber,
                Size = size,
                Count = Content.Count,
                TotalElements = totalElements
            };
        }

        /// <summary>
        /// Метаданные
        /// </summary>
        public PageMetadata PageMetadata { get; }

        /// <summary>
        /// Набор данных
        /// </summary>
        public ICollection<TEntity> Content { get; }
    }
}

Теперь нам нужно написать метод расширения для IQueryable<TEntity>, который будет реализовывать постраничную выборку данных. Добавьте новый модуль PaginationExtensions.cs в каталог Extensions проекта. Код класса ниже:

using Generic.Web.API.Interfaces;
using Generic.Web.API.Pagination;

namespace Generic.Web.API.Extensions
{
    /// <summary>
    /// Методы расширения <see creef="IQueryable&lt;TEntity&gt;" /> связанные с постраничным выводом
    /// </summary>
    public static class PaginationExtensions
    {
        /// <summary>
        /// Добавляет постраничную выборку данных
        /// </summary>
        /// <typeparam name="TEntity"></typeparam>
        /// <param name="query">источник данных</param>
        /// <param name="pageable">параметры постраничного вывода</param>
        /// <returns>страницу данных <see cref="Page&lt;TEntity&gt;" /></returns>
        /// <exception cref="ArgumentNullException"></exception>
        public static Page<TEntity> Paginate<TEntity>(this IQueryable<TEntity> query, IPageable pageable)
        {
            if (pageable == null)
            {
                throw new ArgumentNullException(nameof(pageable));
            }

            return query.Paginate(pageable.PageNumber, pageable.PageSize);
        }

        private static Page<TEntity> Paginate<TEntity>(this IQueryable<TEntity> query, int pageNumber, int pageSize)
        {
            ValidatePagingParameters(query, pageNumber, pageSize);

            var total = query.Count();

            var items = query.Skip(pageNumber * pageSize).Take(pageSize);

            return new Page<TEntity>(items, pageNumber, pageSize, total);
        }


        private static void ValidatePagingParameters<TEntity>(IQueryable<TEntity> query, int pageNumber, int pageSize)
        {
            if (query == null)
            {
                throw new ArgumentNullException(nameof(query));
            }

            if (pageNumber < 0)
            {
                throw new ArgumentOutOfRangeException(nameof(pageNumber), "Номер страницы должен быть равен или больше 0");
            }

            if (pageSize <= 0)
            {
                throw new ArgumentOutOfRangeException(nameof(pageSize), "Размер страницы должен быть больше 0");
            }
        }
    }
}

Вся работа выполняется в следующем методе:

private static Page<TEntity> Paginate<TEntity>(this IQueryable<TEntity> query, int pageNumber, int pageSize)
{
	ValidatePagingParameters(query, pageNumber, pageSize);
	
	var total = query.Count();
	
	var items = query.Skip(pageNumber * pageSize).Take(pageSize);
	
	return new Page<TEntity>(items, pageNumber, pageSize, total);
}

Метод вычисляет количество записей, которое вернет запрос.

Следующим шагом выполняется выборка страницы данных.

На последнем этапе происходит формирование страницы данных.

Теперь мы можем обновить метод GetAll() контроллера, добавив в него вызов нашего метода расширения:

[HttpGet]
public virtual ActionResult<IEnumerable<TEntity>> GetAll([FromQuery] Pageable pageable)
{
    if (!ModelState.IsValid)
    {
        return BadRequest(ModelState);
    }

    var entities = repository.GetAll().Paginate(pageable);

    return Ok(entities);
}

Так как метод GetAll() возвращает IQueryable<>, то в итоге будет сгенерирован SQL-запрос, содержащий инструкции по выборке одной страницы данных. На самом деле будет выполнено два запроса:

  1. Запрос на вычисление общего количества записей

  2. Запрос на выборку страницы данных.

Если теперь запустить наше приложение, то в секции запроса GET должны появиться поля ввода page и size, как показано на рисунке ниже:

Если выполнить данный запрос, то мы должны получить ответ следующего вида:

{
  "pageMetadata": {
    "number": 0,
    "size": 10,
    "count": 4,
    "totalElements": 4,
    "totalPages": 0,
    "from": 0,
    "to": 4
  },
  "content": [
    {
      "id": 1,
      "firstName": "Петр",
      "lastName": "Иванов"
    },
    {
      "id": 2,
      "firstName": "Иван",
      "lastName": "Петров"
    },
    {
      "id": 3,
      "firstName": "Андрей",
      "lastName": "Сидоров"
    },
    {
      "id": 4,
      "firstName": "Алексей",
      "lastName": "Степанов"
    }
  ]
}

Как видите, в ответе теперь появились метаданные – свойство pageMetadata, описывающие полученную страницу данных, и сами данные, помещенные в свойство content в виде массива.

Если размер страницы сделать меньшим, чем общее число записей в БД, то можно увидеть, что количество страниц в метаданных станет больше 1. Вот как выглядит ответ API при размере страницы равном 2 в моем случае:

{
  "pageMetadata": {
    "number": 0,
    "size": 2,
    "count": 2,
    "totalElements": 4,
    "totalPages": 2,
    "from": 0,
    "to": 2
  },
  "content": [
    {
      "id": 1,
      "firstName": "Петр",
      "lastName": "Иванов"
    },
    {
      "id": 2,
      "firstName": "Иван",
      "lastName": "Петров"
    }
  ]
}

Добавление сортировки

Если ответ API содержит некий массив данных, то часто просят отсортировать эти данные по какому-либо полю. Давайте добавим в метод GetAll() обобщенного контроллера возможность передачи параметров сортировки и реализуем метод расширения, который будет упорядочивать данные в соответствии с переданными параметрами.

Первым делом, определим новый интерфейс – IOrderable и поместим его в каталог Interfaces проекта. Код интерфейса смотрим здесь:

using Generic.Web.API.Enums;

namespace Generic.Web.API.Interfaces
{
	/// <summary>
	/// Параметры сортировки
	/// </summary>
	public interface IOrderable
	{
		/// <summary>
		/// Свойство, по которому нужно выполнить сортировку
		/// </summary>
		string Property { get; }

		/// <summary>
		/// Направление сортировки
		/// </summary>
		Direction Direction { get; }
	}
}

Интерфейс содержит два свойства:

  1. Имя свойства сущности, по которому будет выполняться сортировка

  2. Направление сортировки – значение из перечисления Direction

public enum Direction
{
	/// <summary>
	/// по возрастанию
	/// </summary>
	Asc,

	/// <summary>
	/// по убыванию
	/// </summary>
	Desc,
}

Вернемся к IOrderable. Добавим в проект каталог Ordering и поместим в него новый модуль – Orderable.cs. Класс Orderable будет реализовывать интерфейс IOrderable и содержать параметры сортировки по умолчанию. Код класса здесь:

using Generic.Web.API.Enums;
using Generic.Web.API.Interfaces;

namespace Generic.Web.API.Ordering
{
    public class Orderable : IOrderable
    {
        public string Property { get; init; } = "id";

        public Direction Direction { get; init; } = Direction.Asc;
    }
}

Теперь добавим класс OrderingExtensions в каталог Extensions проекта.

using Generic.Web.API.Enums;
using Generic.Web.API.Interfaces;
using System.Linq.Expressions;
using System.Reflection;

namespace Generic.Web.API.Extensions
{
    /// <summary>
    /// Методы расширения <see cref="IQueryable&lt;TEntity&gt;" /> связанные с сортировкой
    /// </summary>
    public static class OrderingExtensions
    {
        private static readonly MethodInfo OrderByMethod =
            typeof(Queryable).GetMethods().Single(method =>
            method.Name == "OrderBy" && method.GetParameters().Length == 2);

        private static readonly MethodInfo OrderByDescendingMethod =
            typeof(Queryable).GetMethods().Single(method =>
            method.Name == "OrderByDescending" && method.GetParameters().Length == 2);

        public static IQueryable<TEntity> ApplyOrder<TEntity>(this IQueryable<TEntity> source, IOrderable orderable)
        {
            if (source == null)
            {
                throw new ArgumentNullException(nameof(source));
            }

            if (orderable == null)
            {
                return source;
            }

            if (!IsPropertyExists<TEntity>(orderable.Property))
            {
                throw new InvalidOperationException($"Сущность {typeof(TEntity).Name} не содержит свойства '{orderable.Property}'");
            }

            return orderable.Direction switch
            {
                Direction.Asc => source.OrderByProperty(orderable.Property, OrderByMethod),
                Direction.Desc => source.OrderByProperty(orderable.Property, OrderByDescendingMethod),
                _ => throw new InvalidOperationException("Неподдерживаемый тип сортировки"),
            };
        }
        
        private static IQueryable<TEntity> OrderByProperty<TEntity>(this IQueryable<TEntity> source, string propertyName, MethodInfo orderingMethod)
        {
            (var orderByProperty, var lambda) = BuildExpressions<TEntity>(propertyName);
            MethodInfo genericMethod = orderingMethod.MakeGenericMethod(typeof(TEntity), orderByProperty.Type);
            object? ret = genericMethod.Invoke(null, new object[] { source, lambda });
            return ret != null
                ? (IQueryable<TEntity>)ret
                : source;
        }

        private static (Expression, LambdaExpression) BuildExpressions<TEntity>(string propertyName)
        {
            ParameterExpression paramterExpression = Expression.Parameter(typeof(TEntity));
            Expression orderByProperty = Expression.Property(paramterExpression, propertyName);
            var lambda = Expression.Lambda(orderByProperty, paramterExpression);
            return (orderByProperty, lambda);
        }

        private static bool IsPropertyExists<TEntity>(string propertyName)
        {
            return typeof(TEntity).GetProperty(
                propertyName,
                BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance) != null;
        }
    }
}

Как видно из кода, приведенного выше, класс содержит один публичный метод – ApplyOrder. Этот метод проверяет, существует ли у сущности свойство, имя которого передано в параметре Property, строит лямбда-выражение для сортировки, и, наконец, выполняет сортировку.

Теперь нам осталось добавить в метод GetAll() параметр типа Orderable, и вызвать наш метод расширения для сортировки полученных данных.

[HttpGet]
public virtual ActionResult<Page<TEntity>> GetAll([FromQuery, Required] Pageable pageable, [FromQuery] Orderable orderable)
{
    if (!ModelState.IsValid)
    {
        ThrowValidationError();
    }

    var dataPage = repository.GetAll()
                                .ApplyOrder(orderable)
                                .Paginate(pageable)
                                ;

    return Ok(dataPage);
}

Параметр orderable декорирован атрибутом FromQuery, таким образом мы говорим, что хотим получать параметры сортировки через query-параметры GET запроса.

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

Добавление централизованной обработки ошибок

Для того, чтобы не дублировать код, обрабатывающий ошибки, которые могут возникать при выполнении action-методов контроллеров, целесообразно добавить промежуточное ПО - middleware. 

Сначала добавим пару классов исключений. Добавьте в проект каталог Exceptions, куда позже добавим классы исключений. Первое исключение предназначено для случаев, когда не удалось найти сущность в базе данных переданному идентификатору. Назовем этот класс EntityNotFoundException. Унаследуйте класс от базового – Exception. Код смотрим вот здесь:

using System.Runtime.Serialization;

namespace Generic.Web.API.Exceptions
{
    /// <summary>
    /// Исключение - сущность не найдена
    /// </summary>
    [Serializable]
    public class EntityNotFoundException : Exception
    {
        /// <summary>
        /// Создает экземпляр <see cref="EntityNotFoundException" />
        /// </summary>
        public EntityNotFoundException()
        {
        }

        /// <summary>
        /// Создает экземпляр <see cref="EntityNotFoundException" />
        /// </summary>
        /// <param name="message">сообщение об ошибке</param>
        public EntityNotFoundException(string message) : base(message)
        {
        }

        /// <summary>
        /// Создает экземпляр <see cref="EntityNotFoundException" />
        /// </summary>
        /// <param name="message">сообщение об ошибке</param>
        /// <param name="innerException">вложенное исключение</param>
        public EntityNotFoundException(string message, Exception innerException) : base(message, innerException)
        {
        }

        /// <summary>
        /// Создает экземпляр <see cref="EntityNotFoundException" />
        /// </summary>
        protected EntityNotFoundException(SerializationInfo info, StreamingContext context) : base(info, context)
        {
        }
    }
}

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

Теперь давайте добавим класс middleware. Добавьте в проект каталог Middleware и создайте в нем модуль ErrorHandlerMiddleware. Код класса приведен ниже.

using Generic.Web.API.Exceptions;
using Generic.Web.API.Models;
using System.Net;
using System.Text.Json;

namespace Generic.Web.API.Middleware
{
    /// <summary>
    /// Глобальный обработчик ошибок
    /// </summary>
    public class ErrorHandlerMiddleware
    {
        private readonly RequestDelegate _next;

        /// <summary>
        /// Конструктор
        /// </summary>
        /// <param name="next"></param>
        public ErrorHandlerMiddleware(RequestDelegate next)
        {
            _next = next;
        }

        /// <summary>
        /// Код обработчика
        /// </summary>
        /// <param name="context"></param>
        /// <returns></returns>
        public async Task Invoke(HttpContext context)
        {
            try
            {
                await _next(context);
            }
            catch (Exception exception)
            {
                (var httpStatus, var result) = GetErrorStatusAndResponse(exception);

                var httpResponse = context.Response;
                httpResponse.ContentType = "application/json";
                httpResponse.StatusCode = (int)httpStatus;

                await httpResponse.WriteAsync(JsonSerializer.Serialize(result));
            }
        }

        private (HttpStatusCode, ErrorResponse) GetErrorStatusAndResponse(Exception exception)
        {
            HttpStatusCode statusCode;
            ErrorResponse response;

            switch (exception)
            {
                case EntityNotFoundException:
                    // not found error
                    response = new ErrorResponse
                    {
                        Code = "not_found",
                        Message = exception.Message
                    };
                    statusCode = HttpStatusCode.NotFound;
                    break;

                case ValidationException:
                    response = new ErrorResponse
                    {
                        Code = "validation_failed",
                        Message = exception.Message
                    };
                    statusCode = HttpStatusCode.UnprocessableEntity;
                    break;

                default:
                    // unhandled error
                    response = new ErrorResponse
                    {
                        Code = "error",
                        Message = exception.Message
                    };
                    statusCode = HttpStatusCode.InternalServerError;
                    break;
            }

            return (statusCode, response);
        }
    }
}

Наше middleware делает следующее: оборачивает вызов следующего в цепочке middleware конструкцией try..catch, и в блоке catch выполняет анализ полученного исключения и преобразование его в ответ API.

Для ошибочных ответов нужно добавить еще один класс – ErrorResponse, поместите его в каталог Models.

namespace Generic.Web.API.Models
{
    /// <summary>
    /// Объект, возвращаемый API в случае возникновени яошибки
    /// </summary>
    public class ErrorResponse
    {
        /// <summary>
        /// Код
        /// </summary>
        public string Code { get; init; } = string.Empty;

        /// <summary>
        /// Сообщение об ошибке
        /// </summary>
        public string Message { get; init; } = string.Empty;
    }
}

Теперь мы можем включить наше middleware в конвеер обработки http-запросов:

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();

app.UseRouting();
app.UseAuthorization();
app.UseMiddleware<ErrorHandlerMiddleware>();
app.MapControllers();

app.Run();

Нам осталось добавить возбуждение исключений в коде обобщенного контроллера.

Для случая, когда ModelState содержит ошибки, будем возбуждать исключение ValidationException, как показано ниже.

if (!ModelState.IsValid)
{
    ThrowValidationError();
}
/// <summary>
/// Создает экземпляр <see cref="ValidationException" /> с сообщениями об ошибках валидации
/// </summary>
/// <returns><see cref="" /></returns>
protected void ThrowValidationError()
{
    var errors = ModelState.Values.SelectMany(v => v.Errors).Select(x => x.ErrorMessage).ToList();
            
    throw new ValidationException($"Обнаружена одна или более ошибок валидации.\r\n{string.Join(@"\r\n\", errors)}");
}

Для случая, когда не удалось найти сущность по полученному в параметрах идентификатору, будем вбрасывать исключение EntityNotFoundException.

var entity = repository.GetById(id) ?? throw new EntityNotFoundException($"Запись не найдена [id:{id}]");

Теперь, если мы попытаемся получить запись о сотруднике с несуществующим Id, то будет вброшено исключение EntityNotFoundException, которое будет обработано промежуточным ПО ErrorHandlerMiddleware, и API вернет ответ с кодом 404 – Not found.

Добавление сущности Department

Предположим, что нам понадобилось добавить еще одну сущность в наше приложение. Давайте добавим для примера сущность Department - отдел. Добавьте класс Department в каталог DAL проекта. Пусть между сущностями Department и Employee будет связь типа один-ко-многим. Тогда класс Department будет выглядеть следующим образом:

using Generic.Web.API.Interfaces;

namespace Generic.Web.API.DAL
{
    public class Department : IEntity
    {
        public int Id { get; set; }

        public string Name { get; set; } = string.Empty;

        public IEnumerable<Employee> Employees { get; set; } = new List<Employee>();
    }
}

В класс Employee также нужно внести изменения, он должен содержать идентификатор отдела, к которому принадлежит сотрудник.

using Generic.Web.API.Interfaces;
using System.ComponentModel.DataAnnotations;

namespace Generic.Web.API.DAL
{
    public class Employee : IEntity
    {
        public int Id { get; set; }

        public int? DepartmentId { get; set; }

        [Required]
        public string FirstName { get; set; } = string.Empty;

        [Required]
        public string LastName { get; set; } = string.Empty;

        public Department? Department { get; set; }
    }
}

В класс контекста БД, EmployeesDbContext, нужно добавить свойство DbSet<Department> и описание таблицы базы данных Departments, как показано ниже.

public virtual DbSet<Department> Departments { get; set; }

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Employee>(entity =>
    {
        entity.ToTable(nameof(Employees));

        entity.HasKey(e => e.Id);

        entity.Property(e => e.FirstName)
                .IsRequired()
                .HasMaxLength(100);

        entity.Property(e => e.LastName)
                .IsRequired()
                .HasMaxLength(100);

        entity.HasOne(e => e.Department)
                .WithMany(e => e.Employees)
                .HasForeignKey(e => e.DepartmentId);
    });

    modelBuilder.Entity<Department>(entity =>
    {
        entity.ToTable(nameof(Departments));

        entity.HasKey(e => e.Id);

        entity.Property(e => e.Name)
                .IsRequired()
                .HasMaxLength(200);
    });
} 

После внесения описанных выше изменений нужно сгенерировать миграцию БД, выполнив команду
Add-Migration AddDepartents,
где AddDepartents – имя миграции.

И, затем, применить миграцию к базе данных, выполнив команду
Update-Database

После применения миграции, в случае успеха, в списке таблиц БД вы должны увидеть новую таблицу – Department, как показано на рисунке ниже.

Теперь давайте добавим репозиторий для новой сущности – DepartmentsRepository, поместите класс в каталог Repositories проекта. Код класса ниже:

using Generic.Web.API.DAL;
using Microsoft.EntityFrameworkCore;

namespace Generic.Web.API.Repositories
{
    public class DepartmentsRepository : GenericRepository<EmployeesDbContext, Department>
    {
        public DepartmentsRepository(EmployeesDbContext dbContext)
            : base(dbContext)
        {
        }

        protected override DbSet<Department> DbSet => _dbContext.Departments;
    }
}

Репозиторий необходимо зарегистрировать в DI-контейнере, добавим для этого соответствующую строку в метод расширения AddDbContext класса DbContextRegistrar, как показано ниже:

public static IServiceCollection AddDbContext(this IServiceCollection services, IConfiguration configuration)
{
    var connectionString = configuration.GetConnectionString(ConnectionStringName);

    services.AddDbContext<EmployeesDbContext>(opts => opts.UseSqlServer(connectionString));

    services.AddScoped<IRepository<Employee>, EmployeeRepository>();
    services.AddScoped<IRepository<Department>, DepartmentsRepository>();

    return services;
}

И, наконец, добавим контроллер - DepartmentsController в каталог Controllers.

using Generic.Web.API.DAL;
using Generic.Web.API.Interfaces;
using Microsoft.AspNetCore.Mvc;

namespace Generic.Web.API.Controllers.Api
{
    [ApiController]
    [Route("/api/1.0/[controller]")]
    public class DepartmentsController : GenericApiController<Department>
    {
        public DepartmentsController(IRepository<Department> repository) : base(repository)
        {
        }
    }
}

После запуска приложения вы должны увидеть новый контроллер на странице Swagger, как показано на рисунке ниже.

Реализация метода Search

Давайте добавим возможность поиска сущностей по шаблону. Для этого добавим в определение интерфейса IRepository новый метод – Search, принимающий строковый параметр – term. Изменения в интерфейсе показаны ниже.

namespace Generic.Web.API.Interfaces
{
    public interface IRepository<TEntity> where TEntity : IEntity
    {
        TEntity Add(TEntity entity);
        TEntity Update(int id, TEntity entity);
        void Delete(TEntity entity);
        IQueryable<TEntity> GetAll();
        IQueryable<TEntity> Search(string term);
        TEntity? GetById(int id);
    }
}

В базовой реализации репозитория нужно добавить соответствующий виртуальный метод. Так как базовый класс ничего не знает о наборе полей сущности, с которой он работает, мы не сможем написать реализацию данного метода в базовом классе. С другой стороны, не всем сущностям может понадобиться данный метод. Поэтому мы не будем делать метод Search абстрактным, а сделаем его виртуальным. И базовая реализация будет лишь возбуждать исключение типа NotImplementedException. Изменения в базовом репозитории показаны здесь:

public abstract class GenericRepository<TDbContext, TEntity> : IRepository<TEntity>
    where TDbContext : DbContext
    where TEntity : class, IEntity
{
    protected readonly TDbContext _dbContext;

    protected abstract DbSet<TEntity> DbSet { get; }

    . . .

    public virtual IQueryable<TEntity> Search(string term)
    {
        throw new NotImplementedException();
    }

    . . .
}

Теперь мы можем обновить базовый контроллер, GenericApiController, добавив в него новый метод – Search. Код метода смотрим здесь:

public abstract class GenericApiController<TEntity> : ControllerBase
    where TEntity : class, IEntity
{
    private readonly IRepository<TEntity> repository;

    protected GenericApiController(IRepository<TEntity> repository)
    {
        this.repository = repository;
    }

    . . .

    [HttpGet("[action]")]
    public ActionResult<Page<TEntity>> Search([FromQuery] string term, [FromQuery, Required] Pageable pageable, [FromQuery] Orderable orderable)
    {
        if (!ModelState.IsValid)
        {
            ThrowValidationError();
        }

        var dataPage = repository.Search(term)
                                 .ApplyOrder(orderable)
                                 .Paginate(pageable)
                                 ;

        return Ok(dataPage);
    }
    . . .
}

Если мы запустим наше приложение, то мы увидим, что в Swagger UI появился новый метод – Search.

Если мы попытаемся вызвать данный метод, то получим ошибку – NotImplementedException.

Это происходит потому, что классы-наследники базового репозитория не имеют своей реализации метода Search. Давайте исправим это для репозитория EmployeeRepository, реализовав возможность поиска сотрудников по совпадению начала имени или фамилии сотрудника с переданным в параметре term значением. Код метода показан ниже.

public class EmployeeRepository : GenericRepository<EmployeesDbContext, Employee>
{
    public EmployeeRepository(EmployeesDbContext dbContext)
        : base(dbContext)
    {
    }

    protected override DbSet<Employee> DbSet => _dbContext.Employees;

    public override IQueryable<Employee> Search(string term)
    {
        return GetAll().Where(x => x.FirstName.StartsWith(term) || x.LastName.StartsWith(term));
    }
}

Обратите внимание, что в определении метода Search присутствует директива override. Она сообщает компилятору C#, что данный класс имеет свою реализацию метода Search.

Попробуем вызвать метод Search еще раз. В качестве значения term укажем строку – иван, размер страницы укажем 10, как показано на рисунке ниже.

Нажмем кнопку Execute. Теперь мы не должны столкнуться с ошибкой отсутствия реализации. И если в нашей БД есть сотрудники, у который имя или фамилия начинаются со строки иван, то наше API должно вернуть нам данные об этих сотрудниках. В моем случае, я получил следующий ответ:

{
    "pageMetadata": {
        "number": 0,
        "size": 10,
        "count": 2,
        "totalElements": 2,
        "totalPages": 0,
        "from": 0,
        "to": 2
    },
    "content": [
        {
            "id": 3,
            "departmentId": null,
            "firstName": "Петр",
            "lastName": "Иванов",
            "department": null
        },
        {
            "id": 4,
            "departmentId": null,
            "firstName": "Иван",
            "lastName": "Петров",
            "department": null
        }
    ]
}

Если мы попытаемся выполнить аналогичного метода Search для контроллера Departments, то мы вновь столкнемся с ошибкой отсутствия реализации, так как мы не добавили ее в репозиторий DepartmentsRepository.

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

Чтобы добиться этого нужно сделать следующее:

  1. В базовом контроллере нужно сделать метод Search виртуальным.

  2. В контроллере, где нужно скрыть метод, нужно добавить свою реализацию этого метода и декорировать ее атрибутом NonAction.

Изменения в базовом котроллере GenericApiController:

[HttpGet("[action]")]
public virtual ActionResult<Page<TEntity>> Search([FromQuery] string term, [FromQuery, Required] Pageable pageable, [FromQuery] Orderable orderable)
{
    if (!ModelState.IsValid)
    {
        ThrowValidationError();
    }

    var dataPage = repository.Search(term)
                                .ApplyOrder(orderable)
                                .Paginate(pageable)
                                ;

    return Ok(dataPage);
}

Изменения в контроллере DepartmentsController:

[ApiController]
[Route("/api/1.0/[controller]")]
public class DepartmentsController : GenericApiController<Department>
{
    public DepartmentsController(IRepository<Department> repository) : base(repository)
    {
    }

    [NonAction]
    public override ActionResult<Page<Department>> Search([FromQuery] string term, [FromQuery, Required] Pageable pageable, [FromQuery] Orderable orderable)
    {
        throw new NotImplementedException();
    }
}

Если мы запустим наше приложение после внесения описанных выше изменений, то в интерфейсе Swagger UI мы увидим, что метод Search пропал из списка доступных методов, как показано на рисунке ниже:

Заключение

Итак, мы рассмотрели, как применять обобщенный подход при разработке Web API. Мы увидели, как можно реализовать обобщенный репозиторий и обобщенный контроллер, как реализовать обобщенные методы сортировки и постраничного вывода данных.

Как видите, основная работа по добавлению новой сущности и нового контроллера приходится на саму сущность и изменения в DB-контексте, где мы описываем таблицу и связи для Entity Framework. Классы же репозитория и контроллера практически не содержат кода – весь код находится в базовых классах. В этом и состоит преимущество использования обобщенных классов – код умеет работать с любыми сущностями и, в общем случае, не требует внесения изменений при добавлении нового типа сущности.

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

Если возникнет необходимость скрыть некоторые action-методы в контроллерах-наследниках, то соответствующий метод в базовом контроллере нужно сделать виртуальным, перекрыть его в контроллере-наследнике и пометить атрибутом NonAction.

Таким образом, обобщенный подход разработки API позволяет 

  • избежать дублирования кода, 

  • упростить код приложения.

Стоит также отметить, что может возникнуть потребность в возвращении данных от API в виде объектов, отличных от сущностей доменной модели (сущностей, хранящихся в БД). Такая задача может быть решена путем введения дополнительного обобщенного параметра в класс обобщенного контроллера, как показано ниже:

public abstract class GenericApiController<TEntity, TDto> : ControllerBase
    where TEntity : class, IEntity
    where TDto : class, new()

Методы контроллера в этом случае должны возвращать объекты типа TDto. Потребуется также реализовать преобразование сущностей TEntity в объекты TDto. Эта задача может быть решена с помощью так называемых мапперов. Для этого можно воспользоваться готовыми библиотеками, такими как Mapster или AutoMapper.

Комментарии (27)


  1. s207883
    12.07.2023 14:53

    TL;DR
    Допустим, для самого примитивного варианта это подойдет, но, зачастую, мы имеем дело с разными моделями, причем модель в api может вообще не быть похожа на то, как оно в БД лежит. И так ли нужна вся эта куча кода, когда можно посадить интерна пилить менее примитивные круды и прокачиваться?


    return DbSet; Я тоже люблю делать select * from veryhugetable, но так ли это необходимо?


    P.S. Заботиться о валидации данных это таки хорошо, но делать это вот так вот if(!ModelState.IsValid) не надо, правда.


    1. AAB74
      12.07.2023 14:53
      -1

      В админке обычно отображается то, что лежит в БД.
      И "куча" кода не будет разрастаться с увеличением количества сущностей (читай таблиц БД). По крайней мере в моей практике, этот подход сработал уже в нескольких случаях.
      Про валидацию... а я и не заявлял, что опишу как правильно делать валидацию.
      Показан лишь пример, только подход, который, конечно же, в реальных условиях потребует доработки. И многое оставлено за скобками.


    1. nronnie
      12.07.2023 14:53
      +1

      У него контроллер помечен как [ApiController], так что до ModelState.IsValid дело даже не дойдет - оно в этом месте всегда будет true. Просто лишний код. Ну а про то, что "generic repository" это антипаттерн в интернете уже только ленивый не писал.


      1. s207883
        12.07.2023 14:53
        +1

        На это и был намёк, что фреймворк все сделает за нас и эта проверка просто раздувает кодовую базу почем зря.

        Про антипаттерн полностью согласен. Слишком уж он неповоротливый.


      1. mvv-rus
        12.07.2023 14:53

        Ну а про то, что «generic repository» это антипаттерн в интернете уже только ленивый не писал.

        Писали. Только вот у автора статьи — не тот «generic repository», про который писали. Он у него только называется GenericRepository, а по факту ничего, кроме EF использовать не умеет:
        class GenericRepository<TDbContext, TEntity> ... where TDbContext : DbContext ...


        1. AAB74
          12.07.2023 14:53

          Статья в названии имеет слово "подход". То есть, она не описывает готовое решение, которое из коробки подходит для любого случая. Поэтому я не старался сделать репозиторий универсальным.

          Если убрать ограничение

          where TDbContext : DbContext

          то вы сможете передавать любой класс в качестве контекста. Но придется по-другому реализовать свойство DbSet, на котором построен GenericRepository.


          1. mvv-rus
            12.07.2023 14:53

            IMHO, если статья — обучающая (а это в данном случае так: и по структуре изложения — крайне разжеванного, и по плашке в начале), то она должна описывать готовое решение. Подходы описывать — это в статьях для уже обученной аудитории.
            Иначе есть большой риск научить новичка плохому. Не надо так — плохому новичок сам научится.


      1. elmanaw
        12.07.2023 14:53

        Ленивый не ленивый, но Ardalis продолжает успешно декларировать "generic repository" и демонстрировать в "образцовом" магазине правильный дизайн.


        1. nronnie
          12.07.2023 14:53

          Я посмотрел код - там совсем не generic repository, а specifications - которые как раз и являются нормальным подходом в случае когда хочется избежать кучи репозиторных интерфейсов на каждый чих. Generic repository - это когда IQueryable из источника данных (например EF) выставляются голым задом наружу (аз есъм протечка абстракции).


          1. AAB74
            12.07.2023 14:53

            Repository Design Pattern in C# with Examples - Dot Net Tutorials

            What is Repository?

            A repository is nothing but a class defined for an entity, with all the possible database operations. For example, a repository for an Employee entity will have the basic CRUD operations and any other possible operations related to the Employee entity. 

            Так что, я считаю, что это, все-таки, репозиторий.


            1. nronnie
              12.07.2023 14:53

              Да, это репозиторий, но это не тот обобщенный репо, который антипаттерном считается. Сам по себе репо это паттерн вполне нормальный. Вы прочитайте внимательней что я писал - плох не сам репозиторий, а когда он протекает.


      1. AAB74
        12.07.2023 14:53

        Вы правы, по-умолчанию, в случае невалидной модели мы даже не попадем в action-метод. Но это поведение может быть отключено (по разным причинам).
        Больше информации можно найти, например, здесь: How to Use ModelState Validation in ASP.NET Core Web API - Code Maze (code-maze.com)


  1. Kisva
    12.07.2023 14:53
    -1

    Очень интересная статья, которая даёт импульс к собственным экспериментам в рассматриваемом автором направлении (обобщение при разработке web API) с целью сокращения кодовой базы.


    1. Oceanshiver
      12.07.2023 14:53
      +2

      Такое вот сокращение кодовой базы весьма обманчиво - при мало-мальском развитии проекта это "сокращение" вам обязательно выйдет боком.


      1. AAB74
        12.07.2023 14:53

        Не стоит забывать, что описанный подход задуман, как реализация админки, где зачастую, данные отображаются, так, как они хранятся в БД.

        В случае, когда generic controller/repository не подходит, можно добавить custom реализацию.


  1. Ascar
    12.07.2023 14:53
    +3

    Обобщение ради чего? Каждый тип требует набор методов "репозитория", это ограничение. Обобщения задуманы для реализации гибкости в архитектуре, тут этим и не пахнет. Репозиторий задуман чтобы отделить доменную логику от инфраструктурной, которая знает как сохранить объект. IQueryable это протечка через абстракции, так как он связан с конкретной реализацией и бд. Да и в целом "репозиторий" протек: инфраструктурные типы в ДТО.

    Даже если забить на простейшую архитектуру и все лепить вместе, то это решение минимум не практично, объекты не бывают плоские, они ссылаются на другие и запросы бывают сложные. Сами программные модели не бывают такими простыми. Вы определили пару методов, добавили новый который не вписывается в ограничение и придется еще делать один репозиторий. Есть принцип: интерфейсы принадлежат клиентам. А у вас он тупое и беспощадное ограничение.


    1. AAB74
      12.07.2023 14:53

      Спасибо за комментарий. Если вы дочитали до конца, то там я упомянул, что возможны более сложные случаи, когда отдается DTO, а не доменная сущность. И упомянул, как это решается - вводом маппинга.


      1. Ascar
        12.07.2023 14:53

        У вас нет доменных сущностей и домена, детали orm протекают через всё.

        Ещё не надо давать вредные советы про мапперы, мапперы всегда создают проблемы.


  1. zedward
    12.07.2023 14:53

    А зачем в классеGenericRepositoryпараметр TDbContext? Зачем в примере нужен интерфейс IRepository<TEntity>? Запутать свой код?


    1. AAB74
      12.07.2023 14:53

      А как вы предлагаете без конкретного типа DB-контекста реализовать свойство DbSet?


      1. zedward
        12.07.2023 14:53

        Масса вариантов. Если мы реализуем слой бизнес-логики в программе, то знаем, что у нас только один тип реализует DbSet. Тогдв generic-параметр для DbContext в процессе реализации известен. Меняется только DbSet, который всегда TEntity. Но даже если задачку усложнить и будем реализовывать промежуточный framework для своих проектов (DbContext в разных проектах свой). В данном классе бизнес-логики не нужен DbContext, ему нужен DbSet. Так передайте этот DbSet в фабрике класса. Измените конструктор. Вот такая фабрика у вас будет:

        services.AddScoped<GenericRepository<Employees>>(sp => new GenericRepository<Employees>(sp.GetRequiredService<EmployeesDbContext>().Employees));


  1. Oceanshiver
    12.07.2023 14:53
    +3

    После прочтения статьи есть только один вопрос - если в "ITQ Group" такие "ведущие" разработчики, какие же там джуны?


  1. EgorovDenis
    12.07.2023 14:53

    Если хочется делать гибкие выборки с сортировки и/или поиском, то не лучше ли для этого использовать уже готовые решения как OData?

    Плюс к тому же есть немало статей, где описывается, что Paging через Offset сильно нагружает БД.


    1. AAB74
      12.07.2023 14:53

      Повторюсь, что цель проекта была - разработка Web API для админки. Там нет сложных запросов. А вот некоторый поиск и сортировка по столбцам, как это реализовано, например, в ReactAdmin, прекрасно решается предложенным подходом.


  1. mvv-rus
    12.07.2023 14:53
    +3

    IMHO Не стоит в обучающей статье демонстрировать новичкам возврат IQueryable из метода публичного интерфейса, отдаваемого наружу:

    public interface IRepository where TEntity : IEntity
    {
    ...
    IQueryable GetAll();
    ...
    }



    IQuerable, если его использовать с LINQ - это ведь весьма неинтуитивная вещь для новичков. Методы LINQ для него, хоть и выглядят так же, как LINQ для IEnumerable, но принимают в качестве параметров не делегаты (то есть любые методы в коде), а выражения, причем - такие, которым провадер СУБД может подобрать эквивалент в языке запросов. А использование лямбд это до поры, до времени затушевывает. А потом однажды новичку захочется вставить в лямбду вызов своего метода, получит он сообщение об ошибке, что такое не поддерживается, и придется ему репу чесать.
    Я бы так не делал, а через GetAll отдавал бы IEnumerable и добавил бы специальный метод для фильтрации.


    1. AAB74
      12.07.2023 14:53

      Я объяснил, для чего я решил возвращать IQueryable, чтобы можно было вынести пэйджинг и сортировку в методы расширения, но при этом, выполнять сортировку и выборку страницы на стороне БД.


      1. mvv-rus
        12.07.2023 14:53

        Я это понимаю, как возврат IQueryable может облегчить написание остального кода.
        Но у IQueryable есть и другая сторона, я о ней. Пару лет назад на Хабре уже была статья, в которой эта, оборотная сторона IQueryable была разобрана подробнее.
        IMHO эта оборотная сторона и как с ней жить — это не то, что, с чем должен иметь дело новичок с самого начала. Поэтому я считаю, что в обучащей статье для новичков возвращать IQueryable не стоит. Подрастут — сами научатся, а пока пусть лучше используют IEnumerable, у которого нет этой обратной стороны.