Мини-туториал от ведущего разработчика «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. Поэтому, давайте добавим в проект файл базы данных. Я сделал это следующим образом:
-
Открыл SQL Server Management Studio
-
Вызвал команду New Database из контекстного меню, как показано на рисунке ниже

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

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

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

-
Далее, нужно пойти в каталог файловой системы, где 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 проекта:
-
EmployeesDbContextModelSnapshot.cs
-
<current_date_time>_InitialCreate.cs
Первый содержит инструкции для построителя моделей, аналогичные тем, что мы добавили в DB-контекст.
Второй модуль содержит код миграций базы данных. Метод Up применяет миграцию «вверх», т.е. создает таблицу Employees. А метод Down – откатывает миграцию, в данном случае – удаляет таблицу Employees.
Теперь, чтобы наша таблица появилась в БД, нужно выполнить команду:
Update-Database InitialCreate
Команду выполняем там же, в консоли Package Manager.
Если теперь открыть дерево объектов БД в Server Explorer, то можно увидеть, что в базу данных добавились две таблицы:
-
__EFMigrationsHistory – это служебная таблица, которую использует Entity Framework для отслеживания того, какие миграции применены к базе данных.
-
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 имеет два обобщенных параметра:
-
TDbContext – контекст базы данных, который должен быть наследником DbContext;
-
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<TEntity>" /> связанные с постраничным выводом /// </summary> public static class PaginationExtensions { /// <summary> /// Добавляет постраничную выборку данных /// </summary> /// <typeparam name="TEntity"></typeparam> /// <param name="query">источник данных</param> /// <param name="pageable">параметры постраничного вывода</param> /// <returns>страницу данных <see cref="Page<TEntity>" /></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-запрос, содержащий инструкции по выборке одной страницы данных. На самом деле будет выполнено два запроса:
-
Запрос на вычисление общего количества записей
-
Запрос на выборку страницы данных.
Если теперь запустить наше приложение, то в секции запроса 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; } } }
Интерфейс содержит два свойства:
-
Имя свойства сущности, по которому будет выполняться сортировка
-
Направление сортировки – значение из перечисления 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<TEntity>" /> связанные с сортировкой /// </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.
Чтобы добиться этого нужно сделать следующее:
-
В базовом контроллере нужно сделать метод Search виртуальным.
-
В контроллере, где нужно скрыть метод, нужно добавить свою реализацию этого метода и декорировать ее атрибутом 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.
ссылка на оригинал статьи https://habr.com/ru/companies/itq_group/articles/747566/






Добавить комментарий