Создание 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. Регистрация здесь.

Комментарии (9)


  1. sergfry
    19.05.2022 17:56
    +11

    Основанный на фреймворке ASP.NET MVC, Web API был предназначен для того, чтобы рассматривать глаголы и существительные архитектуры REST как граждан первого класса.

    Звучит сильно!


  1. nronnie
    19.05.2022 20:20
    +4

    Но значит ли это, что он плох?

    По крайней мере вот это:

    catch (Exception)
    {
       return Results.BadRequest("Failed");
    }

    Это не только плохо, но и совсем не нужно. Во первых, по коду ясно, что тут должен быть 500, во вторых эту 500 ASP.NET pipeline сам вернет, в третьих он её еще и сам запишет в лог. А в четвертых тут совсем нет нужды возвращать IActionResult, можно просто коллекцию объектов и в итоге весь этот метод превращается просто в:

        [HttpGet]
        public IEnumerable<OrderViewModel> Get(bool includeItems = true) =>
          _mapper.Map<IEnumerable<OrderViewModel>>(
          	_repository.GetOrdersByUser(User.Identity.Name, includeItems));

    А для обработки типа:

    if (client == null)
    {
        return Results.NotFound();
    }

    лично я уже давным-давно использую однажды написанный готовый кастомный атрибут (MVC action filter в котором от силы пару дюжин строчек кода), и выглядит это, например, так:

    [HttpGet(clients/{id}]
    [NullMeans404] // <== преобразует возвращенный ObjectResult с null в NotFoundResult
    public async Client GetClient([FromRoute] int id) => await _repo.GetClientAsync(id);

    Про содержание статьи в целом еще можно поспорить (мне, например, еще в принципе не нравится что логика обработки запросов, какая бы она простая не была, у вас смешивается с кодом конфигурации приложения), но примеры кода, на мой взгляд, однозначно плохи.


  1. TimeCoder
    19.05.2022 23:41

    Новомодные веяния - это, наверное, неплохо, но в данном случае, мы экономим пару строк кода для каждого эндпойнта. А взамен? Понадобится на эндпойнт повесить какие-то атрибуты, или ещё что-то- вклинивать это в подобный сжатый синтаксис- это делать код ещё менее читаемым.

    Второе. Если настройка эндпойнтов уезжает в startup/program, это плохо. Файлы получают больше ответственности. Сложнее делить логически (как это можно делать контроллерами). Вот если бы можно было прямо в файле контроллера описывать эндпойнты в таком стиле, как много лет назад в язык пришли get; set; без необходимости расписывать аксессоры и поле данных - другой вопрос.

    Третье. В некоторых типах автотестов мне удобно вызывать методы контроллеров, чтобы покрыть их, почти как реальные запросы, без моков и фейков. А тут как? Вызывать из тестов сервисные методы? А если я хочу прям всю цепочку от начала покрыть?

    Дальше лень перечислять. Одни минусы какие-то.

    Don’t JS my C#


    1. Smerig
      20.05.2022 16:37

      В статье явно написано, что это хорошо пойдет для прототипирования или простенького приложения. И практики еще не наработали.


      1. TimeCoder
        20.05.2022 17:08

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


    1. vabka
      20.05.2022 20:31
      +1

      1. Всегда можно вынести всё лишнее из main.cs в методы-расширения (мы так делаем даже с обычными контроллерами. main.cs получается строк пять от силы)

      Или делать методы однострочными — тогда это будет по сути описание всех маршрутов, что вполне удобно (не нужно по всему проекту судорожно атрибуты читать и пытаться понять, какой будет итоговый маршрут)

      2. Удобство: можно легко на этапе стартапа включать/выключать некоторые ендпоинты простым ифом. Или добавлять новые через циклы (хз где это может пригодиться, а вот выключение — вполне)

      3.

      Вот если бы можно было прямо в файле контроллера описывать эндпойнты в таком стиле, как много лет назад в язык пришли get; set; без необходимости расписывать аксессоры и поле данных — другой вопрос.

      Велком: Carter

      4.
      В некоторых типах автотестов мне удобно вызывать методы контроллеров, чтобы покрыть их, почти как реальные запросы, без моков и фейков.

      Ценность таких автотестов почти нулевая, так как игнорируется весь конвеер обработки запросов. Если хочется так протестировать — выноси логику из контроллеров в нормальные сервисы или Mediatr, и тестируй уже их. Контроллеры же лучше воспринимать как такое объектное описание маршрутов.
      А если я хочу прям всю цепочку от начала покрыть?

      Как и с контроллерами — поднимать сервер и слать http запросы.
      Если прям как вы описываете — делаешь хендлеры не анонимными и вызываешь их. Точно также, как и контроллеры. (но есть нюанс)


  1. makar_crypt
    21.05.2022 12:23

    А если и правда мелкий api на пару методов и ты не хочешь пихать в входящие параметры DI сервис, как правильно стоит это в коде изложить? Обернуть все методы в scope ?

    var bldr = WebApplication.CreateBuilder(args);
    bldr.Services.AddDbContext<JurisContext>();

    var app = bldr.Build();

    using (var scope = bldr.Services.CreateScope()){

    var myDb = scope.Get<JurisContext>();

    app.MapGet("/", () => mydb.GetById(1));

    app.MapGet("/page2", () => mydb.GetById(1));

    app.MapPost("/page2", (inputModel) => mydb.GetById(1));

    }

    Выглядит не очень


    1. vabka
      22.05.2022 11:22

      Оно и работать не будет.

      Скоуп будет задиспозен сразу после выхода из using, вместе со всеми сервисами


      1. makar_crypt
        22.05.2022 15:02

        ну перед скобкой поставить app.Run();