Революция без контроллеров (и почему она вам может понравиться)
Помните, когда создание REST API в .NET означало создание файла контроллера, добавление атрибутов к каждому методу и в итоге получение более сложной структуры, чем у королевской свадьбы? Эти дни уходят в прошлое быстрее, чем ваша мотивация в понедельник утром. Добро пожаловать в мир минимальных API — здесь вы можете создавать готовые к производству REST-сервисы с меньшим количеством кода, файлов и головной боли.
Если вы годами застряли в традиционном шаблоне на основе контроллеров, сама мысль об изменениях может вызвать некоторое когнитивное диссонанс. Но выслушайте меня: минимальные API — это не просто уловка. Это продуманный подход к созданию HTTP API с минимальными зависимостями, что делает их абсолютно идеальными для микросервисов и приложений, которые ценят компактную, сфокусированную архитектуру.
Прелесть этого подхода в том, что вы можете запустить работающий API, используя всего несколько строк кода. Без массивной структуры проекта. Без ненужных слоёв. Только вы, ваша бизнес-логика и нужные вам HTTP-точки доступа.
Что отличает минимальные API?
Думайте о минимальных API как о минималистичном дизайнерском движении, применённом к разработке .NET. В то время как традиционные контроллеры требуют создания отдельных файлов классов, украшения всего атрибутами и соблюдения определённой структурной формальности, минимальные API позволяют вам определять всю логику конечной точки в строке.
Вот что делает их действительно привлекательными:
- Снижение когнитивной нагрузки: логика вашей конечной точки находится там, где вы её определяете, а не разбросана по классам контроллеров и методам действий.
- Молниеносное прототипирование: вы не боретесь с шаблонами; вы создаёте функции.
- Идеально для микросервисов: когда вам нужно что-то компактное и сфокусированное, минимальные API предоставляют именно это.
- Современные функции .NET: .NET 10 предлагает встроенную проверку, группировку маршрутов и интеграцию OpenAPI прямо из коробки.
Главное? Вы не жертвуете надёжностью ради простоты. Обработка ошибок на уровне производства, правильные HTTP-коды состояния и всеобъемлющая документация API — всё это в пределах досягаемости.
Настройка вашего первого минимального API: версия за 30 секунд
Давайте избавимся от лишних слов и что-нибудь запустим. Использование dotnet CLI — это действительно самый быстрый путь вперёд — шаблон по умолчанию уже предоставляет вам структуру минимального API.
dotnet new webapi -n RobustApiService
cd RobustApiService
Вот и всё. У вас есть проект. Если вы используете Visual Studio, процесс аналогичен; вам просто нужно удалить любой шаблон контроллера, который генерируется по умолчанию.
Теперь давайте создадим что-то, что действительно работает. Вот основа настройки API на уровне производства:
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
// Добавить контекст базы данных
builder.Services.AddDbContext<AppDbContext>(opt =>
opt.UseInMemoryDatabase("AppDatabase"));
// Включить исследование API для документации
builder.Services.AddEndpointsApiExplorer();
// Добавить документацию OpenAPI (Swagger)
builder.Services.AddOpenApiDocument(config =>
{
config.DocumentName = "RobustAPI";
config.Title = "Робоust Service API v1";
config.Version = "v1";
});
var app = builder.Build();
// Подавать документацию OpenAPI
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
// Ваши конечные точки идут здесь
var api = app.MapGroup("/api/items")
.WithName("Items")
.WithOpenApi();
app.Run();
Заметили что-то? Нет контроллеров. Нет методов действий. Только чистая, сфокусированная конфигурация. Мы используем MapGroup для организации наших конечных точек — подробнее об этом изменении правила игры чуть позже.
Освоение групп маршрутов: организация без беспорядка
Если вы когда-либо смотрели на массивный класс контроллера и думали: «Эта вещь нуждается в серьёзной организации», группы маршрутов — это то, что вам нужно.
Группы маршрутов позволяют логически объединять конечные точки, применять общие соглашения и поддерживать порядок в коде без создания ненужных иерархий файлов. Это организация, которая действительно имеет смысл:
var todoItems = app.MapGroup("/api/todos")
.WithName("Todo Items")
.WithOpenApi()
.RequireAuthorization(); // Применить ко всей группе
// ПОЛУЧИТЬ все задачи
todoItems.MapGet("/", GetAllTodos)
.WithName("GetAllTodos")
.WithOpenApi();
// ПОЛУЧИТЬ задачу по ID
todoItems.MapGet("/{id}", GetTodoById)
.WithName("GetTodoById")
.WithOpenApi();
// ОТПРАВИТЬ новую задачу
todoItems.MapPost("/", CreateTodo)
.WithName("CreateTodo")
.WithOpenApi()
.Accepts<CreateTodoRequest>("application/json")
.Produces<TodoItemResponse>(StatusCodes.Status201Created);
// ПОМЕСТИТЬ обновление задачи
todoItems.MapPut("/{id}", UpdateTodo)
.WithName("UpdateTodo")
.WithOpenApi();
// УДАЛИТЬ задачу
todoItems.MapDelete("/{id}", DeleteTodo)
.WithName("DeleteTodo")
.WithOpenApi()
.Produces(StatusCodes.Status204NoContent);
Видите, что происходит? Все ваши конечные точки, связанные с задачами, сгруппированы вместе. Авторизация применяется ко всей группе. Метаданные документации согласованы. Это организация с целью, а не церемония.
Создание операций CRUD: настоящее дело
Давайте создадим полную реализацию CRUD для чего-то конкретного. Мы создадим конечные точки для каталога продуктов:
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public decimal Price { get; set; }
public int Stock { get; set; }
public DateTime CreatedAt { get; set; }
}
public class ProductDbContext : DbContext
{
public DbSet<Product> Products => Set<Product>();
public ProductDbContext(DbContextOptions<ProductDbContext> options)
: base(options) { }
}
public class CreateProductRequest
{
public required string Name { get; set; }
public string Description { get; set; } = string.Empty;
public decimal Price { get; set; }
public int Stock { get; set; }
}
public class ProductResponse
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public decimal Price { get; set; }
public int Stock { get; set; }
public DateTime CreatedAt { get; set; }
}
// Обработчики
async Task<IResult> GetAllProducts(ProductDbContext db)
{
var products = await db.Products
.AsNoTracking()
.Select(p => new ProductResponse
{
Id = p.Id,
Name = p.Name,
Description = p.Description,
Price = p.Price,
Stock = p.Stock,
CreatedAt = p.CreatedAt
})
.ToListAsync();
return Results.Ok(products);
}
async Task<IResult> GetProductById(int id, ProductDbContext db)
{
var product = await db.Products.FindAsync(id);
if (product is null)
return Results.NotFound(new { message = $"Product with ID {id} not found" });
return Results.Ok(new ProductResponse
{
Id = product.Id,
Name = product.Name,
Description = product.Description,
Price = product.Price,
Stock = product.Stock,
CreatedAt = product.CreatedAt
});
}
async Task<IResult> CreateProduct(CreateProductRequest request, ProductDbContext db)
{
// Встроенная проверка в .NET 10
if (string.IsNullOrWhiteSpace(request.Name))
return Results.BadRequest(new { error = "Product name is required" });
if (request.Price <= 0)
return Results.BadRequest(new { error = "Price must be greater than zero" });
var product = new Product
{
Name = request.Name,
Description = request.Description,
Price = request.Price,
Stock = request.Stock,
CreatedAt = DateTime.UtcNow
};
db.Products.Add(product);
await db.SaveChangesAsync();
return Results.Created($"/api/products/{product.Id}", new ProductResponse
{
Id = product.Id,
Name = product.Name,
Description
