0

ASP.Net Core 7 Preview 4

Como con cada nuevo lanzamiento de .Net, llegan las novedades de ASP.Net Core, en este caso la versión 7 preview 4. Las novedades más notorias son: mejoras en el rendimiento de HTTP/2‎, novedades con Minimal API‎, ‎Grupos de rutas‎, resultados del cliente en SignalR‎, ‎Transcodificación gRPC JSON‎, ‎ middleware limitante de velocidad‎.

Mejoras en el rendimiento de HTTP/2‎

Se ha hecho una reingeniería de la arquitectura de cómo Kestrel procesa las solicitudes de HTTP/2. Ahora podrán experimentar un consumo menor de CPU, como también, un mayor rendimiento.

La multiplexación permite ejecutar en HTTP2 hasta 10 solicitudes en una conexión TCP en paralelo. Antes es esta vista previa, la multiplexación HTTP/2 en Kestrel se basaba en la palabra clave  locklock de C# para controlar qué solicitud podía escribir en la conexión TCP. Es una solución simple para escribir código seguro de subprocesos múltiples, es ineficiente bajo una alta concurrencia de subprocesos. 

Algunos de las cosas que se encontraron en las pruebas fueron:

  • ‎Alta contención de subprocesos cuando una conexión está ocupada.‎
  • ‎Los ciclos de CPU se desperdician por solicitudes que luchan por el bloqueo de escritura.‎
  • ‎Los núcleos de CPU inactivos como solicitudes esperan el bloqueo de escritura.‎
  • ‎Los puntos de referencia HTTP/2 de Kestrel son más bajos que otros servidores en escenarios de conexión ocupados.‎

‎La solución es reescribir cómo las solicitudes HTTP/2 en Kestrel acceden a la conexión TCP. Una ‎‎cola segura para subprocesos‎‎ reemplaza el bloqueo de escritura. En lugar de pelear sobre quién puede usar el bloqueo de escritura, las solicitudes ahora se ponen en cola en una línea ordenada y un consumidor dedicado las procesa. Los recursos de CPU desperdiciados anteriormente están disponibles para el resto de la aplicación.‎

Novedades con Minimal API

Typed results for minimal APIs

En .NET 6, venía la interfaz IResult en ASP.NET Core para representar los valores devueltos por las API mínimas que no utilizan la compatibilidad implícita con JSON que serializa el objeto devuelto a la respuesta HTTP. La clase de resultados estáticos se usa para crear diferentes objetos IResult que representan diferentes tipos de respuestas, desde simplemente configurar el código de estado de respuesta hasta redirigir a otra URL. Sin embargo, los tipos de marco de implementación de IResult devueltos por estos métodos eran internos, lo que dificulta la verificación del tipo de IResult específico que devuelven sus métodos en una prueba unitaria.

En .NET 7, los tipos que implementan IResult en ASP.NET Core se han hecho públicos, lo que permite asociaciones de tipos simples durante las pruebas. 

Código

using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSqlite<TodoDb>("Filename=:memory:");

var app = builder.Build();

app.MapTodosApi();

app.Run();

public class Todo
{
    public int Id { get; set; }
    public string? Title { get; set; }
    public bool IsComplete { get; set; }
}

public static class TodosApi
{
    public static IEndpointRouteBuilder MapTodosApi(this IEndpointRouteBuilder routes)
    {
        routes.MapGet("/todos", GetAllTodos);
    }

    public static async Task<IResult> GetAllTodos(TodoDb db)
    {
        return Results.Ok(await db.Todos.ToArrayAsync());
    }
}

Test en xUnit

using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;

namespace Tests;

public class TodosApiTests : IDisposable
{
    private SqliteConnection _connection;
    private DbContextOptions<TodoDb> _contextOptions;

    public TodosApiTests()
    {
        _connection = new SqliteConnection("Filename=:memory:");
        _connection.Open();
        _contextOptions = new DbContextOptionsBuilder<TodoDb>()
            .UseSqlite(_connection)
            .Options;

        using var context = CreateDbContext();
        context.Database.EnsureCreated();
    }

    public void Dispose() => _connection.Dispose();

    private TodoDb CreateDbContext() => new TodoDb(_contextOptions);

    [Fact]
    public async Task GetAllTodos_ReturnsOkResultOfIEnumerableTodo()
    {
        // Arrange
        var db = CreateDbContext();

        // Act
        var result = await TodosApi.GetAllTodos(db);

        // Assert: Check the returned result type is correct
        Assert.IsType<Ok<object>>(result);
    }
}

Método de prueba

[Fact]
public async Task GetAllTodos_ReturnsOkOfObjectResult()
{
    // Arrange
    var db = CreateDbContext();

    // Act
    var result = await TodosApi.GetAllTodos(db);

    // Assert: Check the returned result type is correct
    Assert.IsType<Ok<object>>(result);
}

Una vez que se recupera el resultado del método API mínimo que se está probando, es un caso simple de afirmar que el tipo de resultado es Ok<objeto>. Sin embargo, el argumento genérico del nuevo tipo de resultado Ok<TValue> sigue siendo un objeto en este caso. 

El tipo de valor es objeto porque el método Results.Ok(object? value = null) acepta el valor como objeto, no como el tipo original. También devuelve el objeto de resultado escrito como IResult, no como el tipo Ok<TValue> recientemente público. Para resolver este problema, tenemos una nueva clase de fábrica para crear resultados «escritos».

Microsoft.AspNetCore.Http.TypedResults

La nueva clase estática Microsoft.AspNetCore.Http.TypedResults es el equivalente Tipado de la clase Microsoft.AspNetCore.Http.Results. Podemos usar TypedResults en Minimap API para crear instancias de los tipos de implementación de IResult en el marco y conservar la información de tipo concreta.

public static async Task<IResult> GetAllTodos(TodoDb db)
{
    return TypedResults.Ok(await db.Todos.ToArrayAsync());
}

Prueba

[Fact]
public async Task GetAllTodos_ReturnsOkOfObjectResult()
{
    // Arrange
    var db = CreateDbContext();

    // Act
    var result = await TodosApi.GetAllTodos(db);

    // Assert: Check the returned result type is correct
    Assert.IsType<Ok<Todo[]>>(result);
}

Nuevo el paquete Microsoft.AspNetCore.OpenApi

Microsoft.AspNetCore.OpenApi para proporcionar API para interactuar con la especificación OpenAPI en Minimal API. Las referencias de paquetes al nuevo paquete se incluyen automáticamente para Minimal API y se crean a partir de una plantilla con –enable-openapi.

El paquete expone un método de extensión WithOpenApi que genera una OpenApiOperation derivada del controlador y los metadatos de un endpoint.

app.MapGet("/todos/{id}", (int id) => ...)
    .WithOpenApi();

El método de extensión WithOpenApi anterior genera una OpenApiOperation asociada con una solicitud GET al extremo /todos/{id}.  Es posible usar una segunda sobrecarga del método de extensión WithOpenApi para extender y anular la operación generada.

app.MapGet("/todos/{id}", (int id) => ...)
    .WithOpenApi(operation => {
        operation.Summary = "Retrieve a Todo given its ID";
        operation.Parameters[0].AllowEmptyValue = false;
        return operation;
    });

Minimal APis autodescriptivas con IEndpointMetadataProvider y IEndpointParameterMetadataProvider

Una de las características es la capacidad del marco de usar la información de tipo declarada como parte de los controladores de ruta de  nuestra aplicación (métodos, lambdas, delegados, etc.), además del comportamiento implícito del marco, para documentar automáticamente los detalles de las API para OpenAPI/Swagger.

En los casos en que esos detalles no sean suficientes para describir nuestra API, podemos agregar metadatos de punto final en su código para mejorar la descripción:

app.MapGet("/todos", async (TodoDb db)
{
    return Results.Ok(await db.Todos.ToArrayAsync());
})
    // Add metadata about the shape of the data returned for OpenAPI/Swagger
    .Produces<Todo[]>();

Si una ruta declara que devuelve un tipo que representa una respuesta HTTP 200 OK con una JSON de forma particular, deberíamos poder usar esa información de tipo para describir automáticamente la API.

En .NET 7, tenemos dos nuevas interfaces para ASP.NET Core que permiten que los tipos que se usan en los endpoints de Minimal API contribuyan a los metadatos a los manejadores: IEndpointMetadataProvider e IEndpointParameterMetadataProvider. Estas aprovechan una característica, recientemente fuera de versión preliminar en C# 11, miembros de interfaz abstractos estáticos, para declarar métodos estáticos que, si están presentes en los parámetros o tipos de retorno del controlador de ruta, serán llamados por el marco de trabajo cuando se cree el punto de conexión.

namespace Microsoft.AspNetCore.Http.Metadata;

public interface IEndpointMetadataProvider
{
    static abstract void PopulateMetadata(EndpointMetadataContext context);
}

public interface IEndpointParameterMetadataProvider
{
    static abstract void PopulateMetadata(EndpointParameterMetadataContext parameterContext);
}

IEndpointMetadataProvider se puede implementar mediante tipos devueltos por un controlador de ruta o aceptados por un controlador de ruta como parámetro. IEndpointParameterMetadataProvider, como sugiere el nombre, solo se puede implementar mediante tipos aceptados por un controlador de ruta como parámetro y se le proporcionará la información de parámetro para el parámetro del controlador de ruta asociado cuando se llame.

Todo lo que necesitamos es actualizar el ejemplo anterior para usar la nueva clase TypedResults para que pueda describirse a sí mismo en OpenAPI/Swagger. Esto es gracias a que muchos de los tipos que implementan IResult en el marco también implementan IEndpointMetadataProvider:

app.MapGet("/todos", async (TodoDb db)
{
    // This lambda now returns Ok<Todo[]>, a type that implements IEndpointMetadataProvider.
    // The framework will call Ok<Todo[]>.PopulateMetadata() when the endpoint is built,
    // which adds the necessary endpoint metadata to describe the HTTP response type.
    return TypedResults.Ok(await db.Todos.ToArrayAsync());
});

Este patrón funciona bien para endpotins simples que devuelven solo un tipo de resultado, pero ¿qué pasa cuando debemos devolver diferentes tipos de resultados según las condiciones, como 200 OK cuando se encuentra la tarea pendiente y 404 Not Found cuando no lo es? Podemos usar IResult desde un controlador que devuelve diferentes tipos reales (siempre y cuando todos implementen IResult) indicando explícitamente el tipo de devolución del controlador.

app.MapGet("/todos/{id}", async IResult (int id, TodoDb db)
{
    return await db.Todos.FindAsync(id) is Todo todo
        ? TypedResults.Ok(todo)
        : TypedResults.NotFound();
});

Múltiples tipos de resultados desde Minimal API

Los nuevos tipos de unión genéricos Results<TResult1, TResult2, TResultN>, junto con la clase TypesResults, se pueden usar para declarar que un controlador de ruta devuelve múltiples tipos concretos que implementan IResult, y cualquiera de esos tipos que implementan IEndpointMetadataProvider contribuirá a los metadatos del endpoint. Permite que el marco de trabajo describa automáticamente los diversos resultados HTTP para una API en OpenAPI/Swagger:

app.MapGet("/todos/{id}", async Results<Ok<Todo>, NotFound> (int id, TodoDb db)
{
    return await db.Todos.FindAsync(id) is Todo todo
        ? TypedResults.Ok(todo)
        : TypedResults.NotFound();
});

Los tipos de unión Results<TResult1, TResultN> implementan operadores de conversión implícitos para que el compilador pueda convertir automáticamente los tipos especificados en los argumentos genéricos en una instancia del tipo de unión. Esto tiene el beneficio adicional de proporcionar una verificación en tiempo de compilación de que un controlador de ruta en realidad solo devuelve los resultados que declara que hace. Intentar devolver un tipo que no está declarado como uno de los argumentos genéricos de Results<> dará como resultado un error de compilación.

En la imagen se muestra la interfaz de usuario de Swagger para la API del ejemplo anterior. 

Route groups

Nueva extensión MapGroup(). Nos ayudará a organizar grupos de endpoints con un prefijo común. Permite personalizar grupos completos de endpoints con una sola llamada a métodos como RequireAuthorization() y WithMetadata().

Podemos usar MapGroup() para agregar puntos finales privados a nuestra API anterior de la siguiente manera:

// ...
// Was: app.MapTodosApi()
app.MapGroup("/public/todos").MapTodosApi();
// Auth configuration is left as an exercise for the reader. More to come in future previews.
app.MapGroup("/private/todos").MapTodosApi().RequireAuthorization();

app.Run();
// ...
public static class TodosApi
{
    // GroupRouteBuilder is both an IEndpointRouteBuilder and IEndpointConventionBuilder.
    public static GroupRouteBuilder MapTodosApi(this GroupRouteBuilder group)
    {
        group.MapGet("/", GetAllTodos);
        group.MapGet("/{id}", GetTodo);
        group.MapPost("/", CreateTodo);
        group.MapPut("/{id}", UpdateTodo);
        return group;
    }
// ...
    public static async Task<Created<Todo>> CreateTodo(Todo todo, TodoDb db, ClaimsPrincipal user)
    {
        if (user.Identity?.IsAuthenticated ?? false)
        {
            db.PrivateTodos.Add(todo);
        }
        else
        {
            db.Todos.Add(todo);
        }

        await db.SaveChangesAsync();
        // Use relative path for Location header to support multiple group prefixes.
        // Was: TypedResults.Created($"/todos/{todo.Id}", todo)
        return TypedResults.Created($"{todo.Id}", todo);
    }
//

En lugar de usar direcciones relativas para el encabezado Ubicación en el resultado 201 Creado, también es posible usar GroupRouteBuilder.GroupPrefix para construir una dirección relativa a la raíz o usar GetPathByRouteValues ​​o GetUriByRouteValues ​​con un nombre de ruta para obtener aún más opciones.  También admiten grupos anidados y patrones de prefijos complejos con parámetros y restricciones de ruta.

Client results en SignalR

Anteriormente, cuando se usabamos SignalR, el servidor podía invocar un método en un cliente pero no tenía la capacidad de esperar una respuesta. Este escenario ahora es compatible. El servidor usa ISingleClientProxy.InvokeAsync() para invocar un método de cliente y el cliente devuelve un resultado de su controlador .On().

Hay dos formas de usar la API en el servidor. El primero es llamar a Single() en la propiedad Clientes en un método concentrador:

public class GameHub : Hub
{
    public async Task WaitForResult(string connectionId)
    {
        var randomValue = Random.Shared.Next(0, 10);
        var result = await Clients.Single(connectionId).InvokeAsync<int>(
            "GetResult", "Guess the value between 0 and 10.");
        if (result == randomValue)
        {
            await Clients.Client(connectionId).SendAsync("EndResult", "You guessed correctly!");
        }
        else
        {
            await Clients.Client(connectionId).SendAsync("EndResult", $"You guessed incorrectly, value was {randomValue}");
        }
    }
}

El uso de InvokeAsync desde un método de concentrador requiere establecer la opción MaximumParallelInvocationsPerClient en un valor superior a 1.

La segunda forma es llamar a Single() en una instancia de IHubContext<T>:

async Task SomeMethod(IHubContext<MyHub> context)
{
    var randomValue = Random.Shared.Next(0, 10);
    var result = await context.Clients.Single(connectionID).InvokeAsync<int>(
        "GetResult", "Guess the value between 0 and 10.");
    if (result == randomValue)
    {
        await context.Clients.Client(connectionId).SendAsync("EndResult", "You guessed correctly!");
    }
    else
    {
        await context.Client(connectionId).SendAsync("EndResult", $"You guessed incorrectly, value was {randomValue}");
    }
}

Los concentradores fuertemente tipados también pueden devolver valores de métodos de interfaz:

public interface IClient
{
    Task<int> GetResult();
}

public class GameHub : Hub<IClient>
{
    public async Task WaitForMessage(string connectionId)
    {
        int message = await Clients.Single(connectionId).GetResult();
    }
}

Los clientes devuelven resultados en sus controladores .On():

.Net Client

hubConnection.On("GetResult", async (string message) =>
{
    Console.WriteLine($"{message} Enter guess:");
    var result = await Console.In.ReadLineAsync();
    return result;
});

TypeScript Client

hubConnection.on("GetResult", async (message) => {
    console.log(message);
    let promise = new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve(2);
        }, 100);
    });
    return promise;
});

Transcodificación gRPC JSON

La primera versión preliminar de la transcodificación gRPC JSON ahora está disponible con esta vista previa. La transcodificación gRPC JSON permite llamar a los servicios gRPC como API RESTful. Esto permite que las aplicaciones admitan gRPC y REST sin duplicación.

Puedes consultar la documentaciónver algunos ejemplos de uso.

Middleware de limitación de velocidad

El nuevo middleware de limitación de velocidad proporciona una manera conveniente de limitar la velocidad de las solicitudes HTTP entrantes.

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.UseRateLimiter(new RateLimiterOptions
{
    Limiter = PartitionedRateLimiter.Create<HttpContext, string>(resource =>
    {
        return RateLimitPartition.CreateConcurrencyLimiter("MyLimiter",
            _ => new ConcurrencyLimiterOptions(1, QueueProcessingOrder.NewestFirst, 1));
    })
});

app.Run();

La cadena que se pasa como primer argumento a CreateConcurrencyLimiter es una clave que se usa para distinguir diferentes limitadores de componentes en PartitionedRateLimiter. También puede configurar el comportamiento de limitación en función de los atributos del recurso pasado:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.UseRateLimiter(new RateLimiterOptions
{
    Limiter = PartitionedRateLimiter.Create<HttpContext, string>(resource =>
    {
        if (resource.Request.Path.StartsWithSegment("/api")
        {
            return RateLimitPartition.CreateConcurrencyLimiter("WebApiLimiter",
                _ => new ConcurrencyLimiterOptions(2, QueueProcessingOrder.NewestFirst, 2));
        }
        else
        {
            return RateLimitPartition.CreateConcurrencyLimiter("DefaultLimiter",
                _ => new ConcurrencyLimiterOptions(1, QueueProcessingOrder.NewestFirst, 1));
        }
    })
});

app.Run();

Este ejemplo usa un ConcurrencyLimiter con un límite de 2 arrendamientos simultáneos y una profundidad de cola de 2 siempre que la ruta de la solicitud apunte a una API web y utilizará un ConcurrencyLimiter con un límite de 1 arrendamiento simultáneo y una profundidad de cola de 1 en todos los demás casos.

Actualmente, RateLimitPartition contiene métodos convenientes para crear ConcurrencyLimiters, TokenBucketRateLimiters y NoopLimiters que se pueden intercambiar fácilmente con los ejemplos anteriores.

La implementación del middleware de limitación de velocidad es mínima y no tiene en cuenta el punto final. 

Conclusiones

En esta versión preview tenemos muchas novedades interesantes. Las más interesantes a mi parecer son las mejoras en Minimal API. En próximos post daremos seguimientos las nuevas novedades de las siguientes vistas previas.

Fernando Sonego

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *