C# Generic-подход к разработке web API

от автора

Мини-туториал от ведущего разработчика «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.


ссылка на оригинал статьи https://habr.com/ru/companies/itq_group/articles/747566/


Комментарии

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

Ваш адрес email не будет опубликован. Обязательные поля помечены *