Pull to refresh
977.38
OTUS
Цифровые навыки от ведущих экспертов

Минимальные API в .NET 6

Reading time10 min
Views27K
Original author: Shawn Wildermuth

Создание REST API является основной частью многих проектов разработки. Выбор для создания таких проектов широк, но если вы разработчик на C#, варианты будут весьма ограничены. API на основе контроллеров были наиболее распространенными в течение долгого времени, но .NET 6 меняет эту ситуацию, предлагая новую возможность. 

Как это произошло?

Присоединение компьютеров было проблемой с первых шагов распределенных вычислений около пятидесяти лет назад (см. Рисунок 1). Удаленные вызовы процедур были так же важны, как и API в современной разработке. Благодаря REST, OData, GraphQL, GRPC и т.п. у нас появилось множество возможностей для создания способов взаимодействия между приложениями.

Рисунок 1: История API
Рисунок 1: История API

Хотя многие из этих технологий процветают, использование REST как основного способа коммуникации по-прежнему остается неизменным в современном мире разработки. На протяжении многих лет у Microsoft было несколько решений для создания REST API, но в течение последнего десятилетия основным инструментом являлся Web API. Основанный на фреймворке ASP.NET MVC, Web API был предназначен для того, чтобы рассматривать глаголы и существительные архитектуры REST как граждан первого класса. Возможность создать класс, представляющий поверхностную область (часто связанную с "существительным" в контексте REST), которая связана с библиотекой маршрутизации, по-прежнему является жизнеспособным способом создания API в современном мире.

Одним из недостатков фреймворка Web API (например, контроллеров) является то, что для небольших API (или микросервисов) требуется определенная церемония. Например, контроллеры — это классы, которые представляют один или несколько возможных вызовов API:

[Route("api/[Controller]")]
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
public class OrdersController : Controller
{
    readonly IDutchRepository _repository;
    readonly ILogger<OrdersController> _logger;
    readonly IMapper _mapper;
    readonly UserManager<StoreUser> _userManager;
    public OrdersController(
        IDutchRepository repository,
        ILogger<OrdersController> logger,
        IMapper mapper,
        UserManager<StoreUser> userManager)
    {
        _repository = repository;
        _logger = logger;
        _mapper = mapper;
        _userManager = userManager;
    }
    [HttpGet]
    public IActionResult Get(bool includeItems = true)
    {
        try
        {
            var username = User.Identity.Name;
            var results = _repository
                .GetOrdersByUser(username, includeItems);

            return Ok(_mapper
                .Map<IEnumerable<OrderViewModel>>(results));
        }
        catch (Exception ex)
        {
            _logger.LogError($"Failed : {ex}" );
            return BadRequest($"Failed");
        }
    }
...

Этот код типичен для контроллеров Web API. Но значит ли это, что он плох? Нет. Для больших API и тех, которые имеют расширенные потребности (например, полноценная аутентификация, авторизация и версионирование), такая структура работает отлично. Но для некоторых проектов действительно необходим более простой способ создания API. Отчасти это обусловлено влиянием других фреймворков, в которых создание API кажется более компактным, а также желанием иметь возможность быстрее разрабатывать/прототипировать API.

Данная потребность не так уж нова. На самом деле, фреймворк Nancy был решением на C# для маппинга вызовов API еще раньше (хотя сейчас он устарел). Даже более новые библиотеки, такие как Carter, пытаются достичь того же самого. Наличие эффективных и простых способов создания API — это необходимый принцип. Не стоит воспринимать минимальные API (Minimal API) как "правильный" или "неправильный" способ создания API. Вы должны рассматривать его как еще один инструмент.

Что такое минимальные API?

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

app.MapGet("/", () => "Hello World!");

Этот вызов указывает маршрут (например, "/") и обратный вызов для выполнения после совпадения запроса, соответствующего маршруту и глаголу. Метод MapGet предназначен для маппирования HTTP GET с функцией обратного вызова. Большая часть этого волшебства заключается в том, что происходит определение типа. Когда мы возвращаем строку (как в этом примере), он оборачивает ее в возвращаемый результат 200 (OK, например).

Данные методы маппинга являются открытыми. Это методы расширения в интерфейсе IEndpointRouteBuilder. Интерфейс раскрывается классом WebApplication, который используется для создания нового приложения Web-сервера в .NET 6. Не буду  углубляться в эту тему, пока не расскажу о том, как работает новый алгоритм Startup в .NET 6.

Основная идея Minimal APIs заключается в том, чтобы убрать некоторые церемонии создания простых API.

Новый способ использования Startup

Многое уже было написано о желании убрать бойлерплейт из процедуры начального запуска в C# в целом. С этой целью Microsoft добавила в C# 10 так называемые "утверждения верхнего уровня". Это означает, что program.cs, с помощью которого вы запускаете свои веб-приложения, не нуждается в void Main() для загрузки приложения. Все это подразумевается. До появления C# 10 запуск приложения выглядел примерно так:

using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
namespace Juris.Api
{
    public class Program
    {
        public static void Main(string[] args)
        {
            CreateHostBuilder(args).Build().Run();
        }
        public static IHostBuilder
            CreateHostBuilder(string[] args) =>
                Host.CreateDefaultBuilder(args)
                .ConfigureWebHostDefaults(webBuilder =>
            {
                webBuilder.UseStartup<Startup>();
            });
    }
}

Необходимость в классе и методе void Main, который выполняет загрузку хоста для запуска сервера, — это то, как мы уже несколько лет пишем ASP.NET в  .NET Core. С помощью утверждений верхнего уровня они хотят упростить этот бойлерплейт, как показано ниже:

var builder = WebApplication.CreateBuilder(args);

// Setup Services
var app = builder.Build();

// Add Middleware

// Start the Server
app.Run();

Вместо класса Startup с местами для настройки сервисов и связующего ПО, все делается в данной очень простой программе верхнего уровня. Какое отношение это имеет к Minimal API? Приложение, создаваемое объектом builder (строитель), поддерживает интерфейс IEndpointRouteBuilder. Так что в нашем случае настройка API — это просто связующее ПО:

var builder = WebApplication.CreateBuilder(args);

// Setup Services
var app = builder.Build();

// Map APIs
app.MapGet("/", () => "Hello World!");

// Start the Server
app.Run();

Давайте поговорим об отдельных функциях.

Маршрутизация

Первое, что бросается в глаза — паттерн для маппинга вызовов API очень похож на паттерн соответствия контроллеров MVC. Это означает, что Minimal API очень похожи на методы контроллера. Например:

app.MapGet("/api/clients", () => new Client()
{
    Id = 1,
    Name = "Client 1"
});
app.MapGet("/api/clients/{id:int}", (int id) => new Client()
{
    Id = id,
    Name = "Client " + id
});

Простые пути, такие как /api/clients, указывают на простые пути URI, поскольку использование синтаксиса параметров (даже с ограничениями) продолжает работать. Обратите внимание, что обратный вызов может принимать ID, маппированный из URI, как и контроллеры MVC. Одна вещь, на которую следует обратить внимание в лямбда-выражении, заключается в том, что типы параметров инференцируются (как и в большинстве случаев в C#). Это означает, что поскольку вы используете URL-параметр  (например, id), вам нужно ввести первый параметр. Если вы его не ввели, то в лямбда-выражении будет сделана попытка угадать тип:

app.MapGet("/api/clients/{id:int}", (id) => new Client()
{
    Id = id, // Doesn't Work
    Name = "Client " + id
});

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

Использование сервисов

До сих пор вызовы API, которые вы видели, не были похожи на реальность. В большинстве случаев для выполнения вызовов хочется воспользоваться обычными сервисами. Это подводит меня к вопросу о том, как использовать сервисы в Minimal API. Вы могли заметить, что ранее я оставил место для регистрации сервисов перед созданием WebApplication:

var bldr = WebApplication.CreateBuilder(args);

// Register services here

var app = bldr.Build();

Можно просто использовать объект builder для доступа к службам, например, так:

var bldr = WebApplication.CreateBuilder(args);

// Register services
bldr.Services.AddDbContext<JurisContext>();
bldr.Services.AddTransient<IJurisRepository, JurisRepository>();

var app = bldr.Build();

Здесь видно, что вы можете использовать объект Services в builder приложения для добавления любых необходимых вам сервисов (в данном случае я добавляю объект контекста Entity Framework Core и хранилище, которое буду применять для выполнения запросов. Чтобы использовать эти сервисы, вы можете просто добавить их в параметры лямбда-выражения:

app.MapGet("/clients", async (IJurisRepository repo) => {
    return await repo.GetClientsAsync();
});

При добавлении требуемого типа он будет введен в лямбда-выражение во время его выполнения. Это отличается от API на основе контроллеров тем, что зависимости обычно определяются на уровне классов. Эти инжектированные сервисы не меняют того, как службы обрабатываются сервисным уровнем (т.е. Minimal API по-прежнему создает область действия для ограниченных служб). Когда вы используете параметры URI, то можно просто добавить необходимые сервисы к другим параметрам: 

app.MapGet("/clients/{id:int}", async (int id, IJurisRepository repo) => {
    return await repo.GetClientAsync(id);
});

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

Глаголы

До сих пор я рассматривал только HTTP GET API. Существуют методы для различных типов глаголов. К ним относятся:

  • MapPost

  • MapPut

  • MapDelete

Эти методы работают идентично MapGet. Например, возьмем этот вызов для POST нового клиента:

app.MapPost("/clients", async (Client model, IJurisRepository repo) =>
{
    // ...
});

Обратите внимание, что в данном случае модели не нужно использовать атрибуты для указания FromBody. Она определяет тип, если форма соответствует запрашиваемому типу. Вы можете смешивать и сочетать все, что вам может понадобиться (как показано в MapPut):

app.MapPut("/clients/{id}", async (int id, ClientModel model, 
    IJurisRepository repo) =>
{
    // ...
});

Для других глаголов вам нужно выполнять их маппинг с помощью MapMethods:

app.MapMethods("/clients", new [] { "PATCH" }, 
    async (IJurisRepository repo) => {return await repo.GetClientsAsync();
});

Обратите внимание на то, что метод MapMethods использует не только путь, но и список глаголов, которые нужно принять. В данном случае я выполняю это лямбда-выражение при получении глагола PATCH. Хотя вы создаете API отдельно, большая часть того же кода, с которым вы знакомы, продолжит работать. Единственное реальное изменение заключается в том, как плюмбинг находит ваш код.

Использование кодов состояния HTTP

До сих пор в этих примерах вы не видели, как обрабатывать различные результаты действий API. В большинстве API, которые я пишу, невозможно предположить, что действие будет успешным, а выбрасывать исключения — не тот способ, который подходит. Для этого необходимо контролировать возвращаемые коды состояния. Этим занимается статический класс Results. Вы просто оборачиваете свой результат вызовом Results и кодом состояния:

app.MapGet("/clients", async (IJurisRepository repo) => {
 return Results.Ok(await repo.GetClientsAsync());
});

Results поддерживает большинство кодов состояния, которые вам понадобятся, например:

  • Results.Ok: 200

  • Results.Created: 201

  • Results.BadRequest: 400

  • Results.Unauthorized: 401

  • Results.Forbid: 403

  • Results.NotFound: 404

И т.д.

В типовом сценарии вы можете использовать несколько из них:

app.MapGet("/clients/{id:int}", async (int id, IJurisRepository repo) => {
    try {
        var client = await repo.GetClientAsync(id);
        if (client == null)
        {
            return Results.NotFound();
        }
        return Results.Ok(client);
    }
    catch (Exception ex)
    {
        return Results.BadRequest("Failed");
    }
});

Если вы собираетесь передать делегат классам MapXXX, можно просто заставить их возвращать IResult для запроса кода состояния:

app.MapGet("/clients/{id:int}", HandleGet);
async Task<IResult> HandleGet(int id, IJurisRepository repo)
{
    try
    {
        var client = await repo.GetClientAsync(id);
        if (client == null) return Results.NotFound();
        return Results.Ok(client);
    }
    catch (Exception)
    {
        return Results.BadRequest("Failed");
    }
}

Заметьте, что поскольку в этом примере применяется async, вам нужно обернуть IResult объектом Task. В результате возвращается экземпляр IResult. Хотя Minimal API предназначены для того, чтобы быть маленькими и простыми, вы быстро поймете, что с прагматической точки зрения API меньше зависит от того, как они инстанцируются, и больше от логики внутри них. И Minimal API, и API на основе контроллеров работают по сути одинаково. Меняется только плюмбинг.

Обеспечение безопасности Minimal API

Хотя Minimal API работают с промежуточным ПО аутентификации и авторизации, вам все равно может понадобится способ указать на уровне API, как должна работать безопасность. Если вы работаете с API на основе контроллеров, можно использовать атрибут Authorize для указания способов защиты API, но если нет контроллеров, вам остается указать их на уровне API. Вы делаете это, вызывая методы на сгенерированных вызовах API. Для примера, чтобы потребовать авторизацию:

app.MapPost("/clients", async (ClientModel model, IJurisRepository repo) =>
{
    // ...
}).RequireAuthorization();

Этот вызов RequireAuthorization равносилен использованию фильтра Authorize в контроллерах (например, вы можете указать, какова схема аутентификации или другие необходимые вам свойства). Допустим, вы собираетесь требовать аутентификацию для всех вызовов:

bldr.Services.AddAuthorization(cfg => {
    cfg.FallbackPolicy = new AuthorizationPolicyBuilder()
        .RequireAuthenticatedUser()
        .Build();
});

Тогда вам не нужно добавлять RequireAuthentication для каждого API, но вы можете переопределить это значение по умолчанию, разрешив анонимность для других вызовов:

app.MapGet("/clients", async (IJurisRepository repo) =>
{
    return Results.Ok(await repo.GetClientsAsync());
}).AllowAnonymous();

Таким образом, вы можете смешивать и сочетать аутентификацию и авторизацию по своему усмотрению.

Использование Minimal API без функций верхнего уровня

Это новое изменение в .NET 6 может шокировать многих из вас. Возможно, вы не захотите менять свой Program.cs, чтобы использовать функции верхнего уровня для всех своих проектов. Но можно ли использовать Minimal API без перехода на них? Если вы помните, ранее в статье я упоминал, что большая часть магии Minimal API исходит от интерфейса IEndpointRouteBuilder. Его поддерживает не только класс WebApplication, он также используется в традиционном классе Startup, который вы, возможно, уже используете. Когда вы вызываете UseEndpoints, делегат, указанный там, передает IEndpointRouteBuilder, что означает — можно просто вызвать MapGet:

public void Configure(IApplicationBuilder app,
IWebHostEnvironment env)
{
if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }
    app.UseRouting();
    app.UseAuthorization();
    app.UseEndpoints(endpoints =>
    {
        endpoints.MapGet("/clients", async (IJurisRepository repo) =>
        {
            return Results.Ok(await repo.GetClientsAsync());
        }).AllowAnonymous();
    });
}

Хотя я считаю, что Minimal API наиболее полезны для принципиально новых проектов или проектов прототипов, вы можете использовать их в своих существующих проектах (при условии, что выполнили апгрейд до .NET 6).

Где мы находимся?

Надеюсь, вы убедились в том, что Minimal API — это новый способ создания API без большого количества плюмбинга и церемоний, связанных с API на базе контроллеров. В то же время, я надеюсь, вы поняли, что по мере роста сложности API на базе контроллеров также имеют свои преимущества. Я рассматриваю Minimal API как отправную точку для создания API, а по мере развития проекта можно переходить к API на базе контроллеров. Хотя это еще очень новое направление, я считаю Minimal API отличным способом создания API. Ответы на закономерности и лучшие практики о том, как их использовать, появятся только со временем. Надеюсь, вы сможете внести свой вклад в это обсуждение!


Приглашаем всех желающих на открытое занятие сегодня в 20:00 по теме «Работа с базой данных с помощью Entity Framework Core». На этом занятии настроим работу с реляционной базой данных через Entity Framework Core. А также объясним, что представляет из себя паттерн Репозиторий и паттерн Unit Of Work. Регистрация здесь.

Tags:
Hubs:
Total votes 17: ↑16 and ↓1+17
Comments12

Articles

Information

Website
otus.ru
Registered
Founded
Employees
101–200 employees
Location
Россия
Representative
OTUS