Мини-туториал от ведущего разработчика "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.
Комментарии (27)
Kisva
12.07.2023 14:53-1Очень интересная статья, которая даёт импульс к собственным экспериментам в рассматриваемом автором направлении (обобщение при разработке web API) с целью сокращения кодовой базы.
Oceanshiver
12.07.2023 14:53+2Такое вот сокращение кодовой базы весьма обманчиво - при мало-мальском развитии проекта это "сокращение" вам обязательно выйдет боком.
AAB74
12.07.2023 14:53Не стоит забывать, что описанный подход задуман, как реализация админки, где зачастую, данные отображаются, так, как они хранятся в БД.
В случае, когда generic controller/repository не подходит, можно добавить custom реализацию.
Ascar
12.07.2023 14:53+3Обобщение ради чего? Каждый тип требует набор методов "репозитория", это ограничение. Обобщения задуманы для реализации гибкости в архитектуре, тут этим и не пахнет. Репозиторий задуман чтобы отделить доменную логику от инфраструктурной, которая знает как сохранить объект. IQueryable это протечка через абстракции, так как он связан с конкретной реализацией и бд. Да и в целом "репозиторий" протек: инфраструктурные типы в ДТО.
Даже если забить на простейшую архитектуру и все лепить вместе, то это решение минимум не практично, объекты не бывают плоские, они ссылаются на другие и запросы бывают сложные. Сами программные модели не бывают такими простыми. Вы определили пару методов, добавили новый который не вписывается в ограничение и придется еще делать один репозиторий. Есть принцип: интерфейсы принадлежат клиентам. А у вас он тупое и беспощадное ограничение.
AAB74
12.07.2023 14:53Спасибо за комментарий. Если вы дочитали до конца, то там я упомянул, что возможны более сложные случаи, когда отдается DTO, а не доменная сущность. И упомянул, как это решается - вводом маппинга.
Ascar
12.07.2023 14:53У вас нет доменных сущностей и домена, детали orm протекают через всё.
Ещё не надо давать вредные советы про мапперы, мапперы всегда создают проблемы.
zedward
12.07.2023 14:53А зачем в классе
GenericRepository
параметрTDbContext
? Зачем в примере нужен интерфейсIRepository<TEntity>
? Запутать свой код?AAB74
12.07.2023 14:53А как вы предлагаете без конкретного типа DB-контекста реализовать свойство DbSet?
zedward
12.07.2023 14:53Масса вариантов. Если мы реализуем слой бизнес-логики в программе, то знаем, что у нас только один тип реализует DbSet. Тогдв generic-параметр для DbContext в процессе реализации известен. Меняется только DbSet, который всегда TEntity. Но даже если задачку усложнить и будем реализовывать промежуточный framework для своих проектов (DbContext в разных проектах свой). В данном классе бизнес-логики не нужен DbContext, ему нужен DbSet. Так передайте этот DbSet в фабрике класса. Измените конструктор. Вот такая фабрика у вас будет:
services.AddScoped<GenericRepository<Employees>>(sp => new GenericRepository<Employees>(sp.GetRequiredService<EmployeesDbContext>().Employees));
Oceanshiver
12.07.2023 14:53+3После прочтения статьи есть только один вопрос - если в "ITQ Group" такие "ведущие" разработчики, какие же там джуны?
EgorovDenis
12.07.2023 14:53Если хочется делать гибкие выборки с сортировки и/или поиском, то не лучше ли для этого использовать уже готовые решения как OData?
Плюс к тому же есть немало статей, где описывается, что Paging через Offset сильно нагружает БД.
AAB74
12.07.2023 14:53Повторюсь, что цель проекта была - разработка Web API для админки. Там нет сложных запросов. А вот некоторый поиск и сортировка по столбцам, как это реализовано, например, в ReactAdmin, прекрасно решается предложенным подходом.
mvv-rus
12.07.2023 14:53+3IMHO Не стоит в обучающей статье демонстрировать новичкам возврат IQueryable из метода публичного интерфейса, отдаваемого наружу:
public interface IRepository where TEntity : IEntity
{
...
IQueryable GetAll();
...
}
IQuerable, если его использовать с LINQ - это ведь весьма неинтуитивная вещь для новичков. Методы LINQ для него, хоть и выглядят так же, как LINQ для IEnumerable, но принимают в качестве параметров не делегаты (то есть любые методы в коде), а выражения, причем - такие, которым провадер СУБД может подобрать эквивалент в языке запросов. А использование лямбд это до поры, до времени затушевывает. А потом однажды новичку захочется вставить в лямбду вызов своего метода, получит он сообщение об ошибке, что такое не поддерживается, и придется ему репу чесать.
Я бы так не делал, а через GetAll отдавал бы IEnumerable и добавил бы специальный метод для фильтрации.AAB74
12.07.2023 14:53Я объяснил, для чего я решил возвращать IQueryable, чтобы можно было вынести пэйджинг и сортировку в методы расширения, но при этом, выполнять сортировку и выборку страницы на стороне БД.
mvv-rus
12.07.2023 14:53Я это понимаю, как возврат IQueryable может облегчить написание остального кода.
Но у IQueryable есть и другая сторона, я о ней. Пару лет назад на Хабре уже была статья, в которой эта, оборотная сторона IQueryable была разобрана подробнее.
IMHO эта оборотная сторона и как с ней жить — это не то, что, с чем должен иметь дело новичок с самого начала. Поэтому я считаю, что в обучащей статье для новичков возвращать IQueryable не стоит. Подрастут — сами научатся, а пока пусть лучше используют IEnumerable, у которого нет этой обратной стороны.
s207883
TL;DR
Допустим, для самого примитивного варианта это подойдет, но, зачастую, мы имеем дело с разными моделями, причем модель в api может вообще не быть похожа на то, как оно в БД лежит. И так ли нужна вся эта куча кода, когда можно посадить интерна пилить менее примитивные круды и прокачиваться?
return DbSet;
Я тоже люблю делать select * from veryhugetable, но так ли это необходимо?P.S. Заботиться о валидации данных это таки хорошо, но делать это вот так вот
if(!ModelState.IsValid)
не надо, правда.AAB74
В админке обычно отображается то, что лежит в БД.
И "куча" кода не будет разрастаться с увеличением количества сущностей (читай таблиц БД). По крайней мере в моей практике, этот подход сработал уже в нескольких случаях.
Про валидацию... а я и не заявлял, что опишу как правильно делать валидацию.
Показан лишь пример, только подход, который, конечно же, в реальных условиях потребует доработки. И многое оставлено за скобками.
nronnie
У него контроллер помечен как
[ApiController]
, так что доModelState.IsValid
дело даже не дойдет - оно в этом месте всегда будетtrue
. Просто лишний код. Ну а про то, что "generic repository" это антипаттерн в интернете уже только ленивый не писал.s207883
На это и был намёк, что фреймворк все сделает за нас и эта проверка просто раздувает кодовую базу почем зря.
Про антипаттерн полностью согласен. Слишком уж он неповоротливый.
mvv-rus
Писали. Только вот у автора статьи — не тот «generic repository», про который писали. Он у него только называется GenericRepository, а по факту ничего, кроме EF использовать не умеет:
class GenericRepository<TDbContext, TEntity> ... where TDbContext : DbContext ...
AAB74
Статья в названии имеет слово "подход". То есть, она не описывает готовое решение, которое из коробки подходит для любого случая. Поэтому я не старался сделать репозиторий универсальным.
Если убрать ограничение
то вы сможете передавать любой класс в качестве контекста. Но придется по-другому реализовать свойство DbSet, на котором построен GenericRepository.
mvv-rus
IMHO, если статья — обучающая (а это в данном случае так: и по структуре изложения — крайне разжеванного, и по плашке в начале), то она должна описывать готовое решение. Подходы описывать — это в статьях для уже обученной аудитории.
Иначе есть большой риск научить новичка плохому. Не надо так — плохому новичок сам научится.
elmanaw
Ленивый не ленивый, но Ardalis продолжает успешно декларировать "generic repository" и демонстрировать в "образцовом" магазине правильный дизайн.
nronnie
Я посмотрел код - там совсем не generic repository, а specifications - которые как раз и являются нормальным подходом в случае когда хочется избежать кучи репозиторных интерфейсов на каждый чих. Generic repository - это когда IQueryable из источника данных (например EF) выставляются голым задом наружу (аз есъм протечка абстракции).
AAB74
Repository Design Pattern in C# with Examples - Dot Net Tutorials
Так что, я считаю, что это, все-таки, репозиторий.
nronnie
Да, это репозиторий, но это не тот обобщенный репо, который антипаттерном считается. Сам по себе репо это паттерн вполне нормальный. Вы прочитайте внимательней что я писал - плох не сам репозиторий, а когда он протекает.
AAB74
Вы правы, по-умолчанию, в случае невалидной модели мы даже не попадем в action-метод. Но это поведение может быть отключено (по разным причинам).
Больше информации можно найти, например, здесь: How to Use ModelState Validation in ASP.NET Core Web API - Code Maze (code-maze.com)