.NET 7: nouveautés et améliorations - Pierre BOUILLON
Pierre Bouillon est un ingénieur logiciel Full Stack développant principalement des applications web avec .NET et Angular chez SFEIR.
Dans son article publié publié sur le magazine Programmez, Pierre nous explique les nouveautés et les améliorations de .NET 7
Introduction
Dernière version en date des mises à jour du framework cross-platform de Microsoft, .NET 7 apporte son lot de nouveautés et d’améliorations à de nombreux niveaux et sur divers aspects de son écosystème (Minimal APIs, System.Text.JSON, etc.) ainsi qu’une nouvelle version majeure du langage C# qui passera ainsi en version 11.
Il est cependant à noter que, contrairement à son prédécesseur .NET 6, et à son futur successeur .NET 8, .NET 7 n’est pas une LTS. Si vous cherchez donc une version stable et supportée sur le long terme, il est probablement plus intéressant pour vous de simplement suivre les nouveautés plutôt que de faire la migration dès maintenant.
Les dernières sont nombreuses et je vous propose à travers cet article de s'arrêter sur certaines des plus majeures d'entre elles. De plus, depuis le moment où ce document a été écrit, certaines parties peuvent avoir évoluée. Si vous souhaitez aller plus loin pour toutes les découvrir et approfondir, n'hésitez pas à consulter la documentation officielle de Microsoft à ce sujet.
Installation et mise à jour
L'installation de .NET 7 se fait de la même manière que les versions précédentes, à savoir via le site de Microsoft https://dotnet.microsoft.com.
Plusieurs images docker sont également disponibles sur le compte .NET du docker hub de Microsoft https://hub.docker.com/_/microsoft-dotnet.
Localement, pour utiliser la dernière version du framework, il vous faudra simplement changer l'attribut TargetFramework du csproj de votre projet cible pour net7.0.
Certaines fonctionnalités accompagnant la dernière version de C# ne sont pas encore immédiatement utilisables. Aussi, pour activer celles qui vous manquerez, vous pouvez manuellement ajouter la propriété LangVersion et l'assigner à preview.
Voici un exemple du type de csproj que vous pourriez avoir pour un projet console paramétré pour .NET 7:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net7.0</TargetFramework>
<LangVersion>preview</LangVersion>
</PropertyGroup>
</Project>
Étant une mise à jour majeure, cette version introduit des changements qui rendent incompatibles ou obsolètes certains appels effectués avec des versions antérieures du framework.
Aussi, entre autre, la logique de comparaison des types NaN a été altérée, le package Microsoft.Data.SqlClient a été mis à jour, certains types d'exceptions levé par certaines API ont été modifié, et plus encore.
Pour consulter ces dernières, Microsoft les a listé, ainsi que les éventuelles actions de remédiation à entreprendre, dans une page de documentation dédiée, accessible sur https://docs.microsoft.com/fr-fr/dotnet/core/compatibility/7.0.
.NET
Gestion du polymorphisme dans System.Text.Json
Jusqu'alors la sérialisation et la désérialisation d'instances avec System.Text.Json ne permettait pas de gérer facilement le polymorphisme et nécessitait de coder des contournements pour réussir à le prendre partiellement en charge.
Dans le cadre d'un système réagissant à des événements extérieurs par exemple, il n'est pas rare qu'un message soit un dérivé d'un type de base.
Prenons par exemple une entité Customer et un événément CustomerCreated associé à sa création:
record Customer(int Id, string Name);
record CustomerCreated(int Id, string Name, DateTime CreatedOn)
: Customer(Id, Name);
Si nous souhaitions émettre des événements relatifs aux clients, le système pourrait les sérialiser et désérialiser en tant que Customer pour ne pas réaliser une implémentation pour chaque classe héritant de la classe mère.
Dans notre cas, la sérialisation d'un événement CustomerCreated et sa désérialisation pourrait ressembler à ceci:
Customer customer = new CustomerCreated(1, "John Doe", DateTime.Now);
var serialized = JsonSerializer.Serialize(customer);
// { "Id": 1, "Name": "John Doe" }
var deserialized = JsonSerializer.Deserialize<CustomerCreated>(serialized);
// {CustomerCreated { Id = 1, Name = John Doe, CreatedOn = 01/01/0001 00:00:00 }}
Pourtant, avec cette approche, deux problèmes majeurs sont immédiatement visibles.
Tout d'abord, la valeur sérialisée ne contient aucune information qui n'est pas dans le type de base et la date de création est ainsi perdue. En réalité, le sérialiseur ne prend pas en compte le type réel du paramètre mais simplement le type sous lequel il est utilisé. Ici, comme notre instance de CustomerCreated est manipulée sous le type Customer, tout le contexte rajouté par la classe fille est perdu.
Ensuite, cause directe du problème précédent, la désérialisation en le type fille résulte en la complétion des propriétés manquantes par leurs valeurs par défaut.
Notre événement, une fois la sérialisation/désérialisation faite, contient ainsi des valeurs totalement erronées (1er Janvier de l'an 1 à minuit au lieu de la date courante).
C’est à ce type de problème que .NET 7 apporte une solution en permettant aux développeurs de définir, pour une classe donnée, les types des classes héritant de cette dernière.
Dans notre exemple, il suffit alors de spécifier que Customer est héritée par CustomerCreated avec l'attribut JsonDerivedTypeAttribute:
[JsonDerivedType(typeof(CustomerCreated))]
record Customer(int Id, string Name);
record CustomerCreated(int Id, string Name, DateTime CreatedOn)
: Customer(Id, Name);
En décorant la classe Customer de cette manière, on indique alors au sérialiseur qu'il doit traiter la désérialisation de notre instance en gardant le contexte associé.
Ainsi, le même code nous donne alors les résultats suivants dans lequel le contexte a pu être préservé:
Customer customer = new CustomerCreated(1, "John Doe", DateTime.Now);
var serialized = JsonSerializer.Serialize(customer);
// {"CreatedOn":"2022-07-10T11:50:41.9534943+02:00","Id":1,"Name":"John Doe"}
var deserialized = JsonSerializer.Deserialize<CustomerCreated>(serialized);
// {CustomerCreated { Id = 1, Name = John Doe, CreatedOn = 10/07/2022 11:50:41 }}
Cette approche répond à notre problème dans le cadre où toutes les instances sont manipulées au sein même du runtime.
En revanche, si l'objet sérialisé provient d'un système tiers, System.Text.Json manquera alors de contexte pour savoir comment désérialiser le message vers le bon type.
Là encore, une solution est apportée par cette mise à jour au travers des autres constructeurs disponibles:
public JsonDerivedTypeAttribute(Type derivedType);
public JsonDerivedTypeAttribute(Type derivedType, int typeDiscriminator);
public JsonDerivedTypeAttribute(Type derivedType, string typeDiscriminator);
On note alors qu'il est possible de spécifier un discriminateur sous forme d'un entier ou d'une chaîne de caractère.
Concrètement, il s'agit d'une sorte de métadonnée ajoutée à l'instance sérialisée afin d'aider le sérialiseur à inférer le type qu'il doit traiter.
En spécifiant un discriminant, il apparaît alors sous la forme suivante:
var serialized = JsonSerializer.Serialize(customer);
// {"$type":"CustomDiscriminator","CreatedOn":"2022-07-10T16:59:43.038819+02:00","Id":1,"Name":"John Doe"}
var deserialized = JsonSerializer.Deserialize<CustomerCreated>(serialized);
// {CustomerCreated { Id = 1, Name = John Doe, CreatedOn = 10/07/2022 11:50:41 }}
[JsonDerivedType(typeof(CustomerCreated), "CustomDiscriminator")]
record Customer(int Id, string Name);
record CustomerCreated(int Id, string Name, DateTime CreatedOn)
: Customer(Id, Name);
Le sérialiseur pourra alors, sur base de cette valeur, désérialiser le bon type même si la valeur sérialisée provient d'un système tiers.
Bien que représentant déjà une avancée facilitant grandement la gestion de la sérialisation/désérialisation des messages pour un grand nombre de développeurs, on peut cependant espérer voir apparaître prochainement une version un peu plus claire faisant usage de la généricité nouvellement possible avec les attributs pour pouvoir fortement les typer au lieu d'utiliser typeof.
ASP.NET
Limiter les requêtes entrantes
Fonctionnalité attendue depuis longtemps, la limitation de requêtes HTTP entrantes arrive enfin avec .NET 7.
Les types de limitateurs
Conscients des problématiques propres à chaques équipes et projets, le package NuGet System.Threading.RateLimiting expose divers limitateurs, chacun fonctionnant selon une logique lui étant propre.
Bien que le type de limitateur choisi puisse différer, la mise en place de l'utilisation de l'un d'entre eux se fait au travers de la méthode d'extension UseRateLimiter. Cette dernière prend en paramètre une instance de RateLimiterOption qui définira au travers de sa propriété Limiter le limitateur utilisé.
La structure de la mise en place d'un middleware de limitation de requêtes HTTP s'articule ainsi de la manière suivante:
app.UseRateLimiter(new RateLimiterOptions
{
Limiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
{
return /* ... */;
}),
});
La méthode statique PartitionedRateLimiter.Create<,> permet de définir une lambda afin de créer ce limitateur. Via les deux types génériques, il est possible, au sein de cette lambda, d'accéder au HttpContext courant afin de récupérer des informations relatives à la session courante.
Concurrent
Le premier des limitateurs est celui correspondant le plus au comportement auquel on peut s'attendre lorsque l'on ajoute ce mécanisme à son API: les requêtes HTTP entrantes sont placées dans une file et progressivement traitées au fil du temps.
A partir de la méthode de création vue ci-dessus, nous pouvons le définir de la manière suivante:
app.UseRateLimiter(new RateLimiterOptions
{
Limiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
{
return RateLimitPartition.CreateConcurrencyLimiter(
"ConcurrencyLimiter",
_ => new ConcurrencyLimiterOptions(1, QueueProcessingOrder.NewestFirst, 0));
}),
});
Déconstruisons sa création au fil de ses paramètres.
Le premier d'entre eux est son nom, utilisé pour nommer et définir le limitateur et le second est une méthode lambda de construction du limitateur, définissant son comportement.
Dans cette méthode le premier paramètre est le nombre de requêtes que le système pourra traiter à un instant donné (ici 1).
Le second paramètre est l'ordre dans lequel les requêtes placées dans la file seront traitées. Deux valeurs sont possibles, à savoir les plus anciennes ou les plus récentes d'abord (LIFO ou FIFO).
Enfin, le dernier paramètre est le nombre de requêtes pouvant être placées dans cette file. Ces dernières seront alors dans un état d'attente où elle ne seront encore ni traitées ni rejetées. Dans notre exemple aucune requête ne peut être dans la file ce qui se manifestera en pratique par le rejet de toutes les requêtes dès lors que le système sera actuellement en train d'en traiter une et une seule.
Illimité
Le second, et le plus explicite, est simplement celui qui n'a aucune action limitante et laisse passer l'entièreté des requêtes.
Sa création est également la plus directe puisqu'elle ne nécessite pas de logique spécifique du fait de sa simplicité. Le seul paramètre attendu est son nom:
app.UseRateLimiter(new RateLimiterOptions
{
Limiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
{
return RateLimitPartition.CreateNoLimiter("NoLimiter");
}),
});
Tel quel, il peut être difficile de prime abord de se représenter des cas d'usages où un limitateur qui ne l'est pas peut se révéler pertinent.
En réalité, il est possible de multiplier les limitateurs utilisés par l'application et de les conditionner à une route spécifique. De cette façon, certaines routes peuvent ne pas être soumises à une limitation contrairement à d'autres. Nous verrons par la suite comment définir un tel comportement.
A l’aide de jetons
Le dernier des limitateurs est à la fois le plus complexe à construire mais également le plus intéressant à mon sens d'un point de vue pratique et se défini comme ceci:
app.UseRateLimiter(new RateLimiterOptions
{
Limiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
{
return RateLimitPartition.CreateTokenBucketLimiter(
"TokenBucketLimiter",
_ => new TokenBucketRateLimiterOptions(
10, QueueProcessingOrder.NewestFirst, 0,
TimeSpan.FromMinutes(5), 5, true));
}),
});
Contrairement aux limitateurs précédents, le sens des paramètres de celui-ci est moins équivoque et nécessite de comprendre la logique encapsulée par le TokenBucketRateLimiter.
Pour se représenter son fonctionnement, il faut se figurer une machine qui prendrait un jeton en entrée pour fonctionner et un utilisateur disposant d'un nombre donné de ces jetons. Chacun des jetons permet de faire exécuter une requête HTTP par le système qui, périodiquement, au bout d'une période définie, en redonne un certain nombre à l'utilisateur, qui peut alors les réutiliser pour exécuter d'autres requêtes.
Chacun de ces paramètres sont alors, dans l'ordre:
- Le nombre de jeton dont dispose l'utilisateur (10 dans notre cas)
- L'ordre dans lequel les requêtes en attente seront traitées
- Le nombre de requêtes pouvant être mises en attente (ici aucune)
- Le temps à attendre avant que le système ne redonne un certain nombre de jetons (5 minutes dans l'exemple)
- Le nombre de jetons redonnés au bout de cette période (5 également)
Si le système redonne automatiquement ou non ce nombre de jeton au bout de la période définie
Une première chose à noter est qu'il n'est pas possible de récupérer plus de jeton que l'utilisateur n'en dispose initialement. Par exemple, même s'il ne fait qu'une requête en 5 minutes et dispose encore de 9 jetons au bout de la période où le système en redonne, il ne disposera alors que de 10 jetons à nouveau et non de 14.
Un cas d'utilisation particulièrement adapté à ce genre de fonctionnement pourrait être la limitation du nombre d'appels que peut réaliser un client à une API payante. Il serait alors possible de donner aux utilisateurs un nombre de jetons plus ou moins grand selon leur type d'abonnement.
Cependant, un inconvénient majeur qu'il porte également est le fait qu'il ne puisse pas être distribué. Ainsi, si vous avez deux instances d'une même API, deux limitateurs avec chacun leur propre compteur interne de jetons seront exposés. Aussi, lors du traitement d'une requête, seul l'un de ces deux limitateurs consommera un jeton de l'utilisateur.
Affiner la limitation
Ces trois types de limitateurs représentent en eux-même un ajout particulièrement intéressant de .NET 7 mais il est également possible d'affiner leurs utilisations en se reposant sur le contexte les accompagnants.
Selon les routes
Comme mentionné plus tôt, un limitateur qui ne jugule pas le flux de requêtes entrantes n'est pas toujours pertinent. Cependant, il n'est pas plus pertinent de brider l'accès à certaines routes spécifiques, telles que la version par exemple.
Grâce au contexte HTTP passé dans la lambda définissant le rate limiter, il est possible de récupérer la requête, et donc la route courante, et d'agir différemment selon sa valeur et ainsi implémenter ce comportement:
app.UseRateLimiter(new RateLimiterOptions
{
Limiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
{
if (context.Request.Path == "/version")
{
return RateLimitPartition.CreateNoLimiter("NoLimiter");
}
return RateLimitPartition.CreateConcurrencyLimiter(
"ConcurrencyLimiter",
_ => new ConcurrencyLimiterOptions(1, QueueProcessingOrder.NewestFirst, 0));
}),
});
Selon l’authentification
Au delà des routes, c'est parfois selon certains rôles qu'il est nécessaire d'adapter le middleware.
Ainsi, par exemple, il est envisageable que les utilisateurs classiques voient leurs requêtes limitées, là où les administrateurs ne devraient pas l'être.
Là encore, grâce au HttpContext, récupérer les autorisations ou le statut de l'authentification de l'utilisateur émettant la requête est tout à fait réalisable:
app.UseRateLimiter(new RateLimiterOptions
{
Limiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
{
var isAdmin = context.User.Claims.Any(claim => claim.Value == "admin");
if (isAdmin)
{
return RateLimitPartition.CreateNoLimiter("NoLimiter");
}
return RateLimitPartition.CreateConcurrencyLimiter(
"ConcurrencyLimiter",
_ => new ConcurrencyLimiterOptions(1, QueueProcessingOrder.NewestFirst, 0));
}),
});
Définir les actions engendrées
Plus que le limitateur lui-même, il est parfois désirable de réaliser certaines actions en réponse à une requête rejetée (journalisation, statistiques, etc.).
C'est cette fois-ci au travers des propriétés OnRejected et DefaultRejectionStatusCode de l'objet englobant RateLimiterOptions qu'il sera possible d'influer sur ces cas de figure:
app.UseRateLimiter(new RateLimiterOptions
{
Limiter = PartitionedRateLimiter.Create<HttpContext, string>(context => /* ... */),
OnRejected = (context, lease) =>
{
return Task.CompletedTask;
},
DefaultRejectionStatusCode = StatusCodes.Status429TooManyRequests,
});
La première propriété est une lambda définissant une action à réaliser lors de la rejection de la requête et la seconde, comme son nom l'indique, est le code HTTP retourné lorsque la requête est rejetée. Il est important de noter que par défaut ce dernier est HTTP 503 (service indisponible).
Les minimal APIs
Depuis .NET 6, les minimal APIs ont dépassé le statut de curiosités et sont devenues une nouvelle manière légère et plus "fonctionnelle" de développer ses APIs web.
Cette impression est d'autant plus forte que, depuis leur sortie, et là encore avec cette nouvelle mise à jour, Microsoft en fait une des fonctionnalités recevant la plus grande attention. Groupement d'endpoints, enrichissement de la documentation de vos endpoints avec OpenAPI, et bien plus !
Filtrer les requêtes entrantes
Historiquement, pour filtrer les requêtes entrantes d'une API web, .NET mettait à notre disposition un ensemble de filtres de type qu'il était possible de peupler à la configuration.
Cependant, avec la popularisation des minimal APIs, l'équipe de développement du framework s'est retrouvée dans la nécessité de porter un mécanisme de filtrage similaire aux endpoints définis dans ces dernières.
Avec .NET 7, les filtres arrivent enfin sur les endpoints définis pas les RouteHandlerBuilders avec plusieurs méthodes d'extensions AddFilter.
Ils peuvent être créés de nombreuses façons mais retournent systématiquement un RouteHandlerBuilder, permettant de chaîner l'appel d'AddFilter à d'autres méthodes d'extension et ainsi de conserver la définition en cascade des endpoints.
La première des manières de créer un filtre est en lui passant directement un délégué créé à partir du RouteHandlerInvocationContext, donnant des précisions sur les paramètres et la nature de l'appel, et d'un RouteHandlerFilterDelegate, délégué vers l'appel suivant.
Comme pour les autres méthodes d'extension AddFilter, le délégué retournera un ValueTask<object?>. Il est ainsi possible de court-circuiter la chaîne d'appel et de renvoyer au plus tôt une valeur synchrone au lieu de continuer le flux d'exécution asynchrone en cas de condition bloquante.
Voici une simple implémentation d'un filtre avec un délégué, rejetant les appels pour une liste donnée de noms:
var blockList = new[] { "John", "Doe" };
app.MapGet("hello/{name}", (string name) => $"Hello {name} !")
.AddFilter(async (routeHandlerInvocationContext, next) =>
{
var name = routeHandlerInvocationContext.GetArgument<string>(0);
if (blockList.Contains(name))
{
return Results.Problem($"{name} is not allowed");
}
return await next(routeHandlerInvocationContext);
});
Si, à la définition du filtre, vous souhaitez le créer selon un contexte particulier à capturer lors de sa création, la seconde méthode d'extension sera alors sans doute plus pertinente, utilisant non pas un délégué mais une factory.
Cette dernière retourne un délégué retournant une ValueTask<object?> à partir d'un RouteHandlerInvocationContext, à partir du RouteHandlerContext et d'un RouteHandlerFilterDelegate, là encore le délégué vers l'appel suivant.
Un aspect intéressant de cette approche est qu'elle permet de capturer et d'agir en fonction des métadonnées de la route actuellement configurée: via le RouteHandlerContext il est possible d'accéder à la propriété MethodInfo qui permettra alors d'accéder aux paramètres, à leurs types, etc.
Voici le même filtre avec cette seconde méthode:
app.MapGet("hello/{name}", (string name) => $"Hello {name} !")
.AddFilter((routeHandlerContext, next) =>
{
var blockList = new[] { "John", "Doe" };
return async (routeHandlerInvocationContext) =>
{
var name = routeHandlerInvocationContext.GetArgument<string>(0);
if (blockList.Contains(name))
{
return Results.Problem($"{name} is not allowed");
}
return await next(routeHandlerInvocationContext);
};
});
Enfin, il est possible de passer non pas un bloc de code mais une classe spécifique, implémentant l'interface IRouteHandlerFilter.
Cette dernière ne définit qu'une méthode à implémenter qui est la suivante:
ValueTask<object?> InvokeAsync(RouteHandlerInvocationContext context, RouteHandlerFilterDelegate next);
Rien d'inconnu ici qui viendrait rompre avec les précédentes définitions: le type de retour reste celui de la chaîne d'appel pouvant être interrompue et les paramètres permettent à nouveau d'accéder au contexte et à l'appel suivant.
Notre filtre sous forme de classe serait alors:
app.MapGet("hello/{name}", (string name) => $"Hello {name} !")
.AddFilter<BlockListFilter>();
class BlockListFilter : IRouteHandlerFilter
{
private string[] _blockList = new[] { "John", "Doe" };
public async ValueTask<object?> InvokeAsync(
RouteHandlerInvocationContext context, RouteHandlerFilterDelegate next)
{
var name = context.GetArgument<string>(0);
if (_blockList.Contains(name))
{
return Results.Problem($"{name} is not allowed");
}
return await next(context);
}
}
Cette troisième manière peut se révéler particulièrement pratique pour limiter l'explosion du nombre de lignes de code dans le fichier définition des routes. En extrayant les définitions des filtres dans leurs classes respectives, le paramétrage de l'endpoint devient alors plus court et concis.
Avoir une classe dédiée permet également de bénéficier de l'injection de dépendance et donc d'enrichir facilement et sans introduire une complexité notable une logique plus poussée dans le filtre.
Enfin, isoler cette classe permet de la rendre plus facilement testable unitairement, ce qui est nettement plus difficile avec les lambdas précédemment définies.
Grouper ses endpoints avec RouteGroups
Usuellement, pour définir les endpoints, de nombreux projets utilisaient une méthode d'extension pour réaliser le mapping à partir de l'instance de WebApplication:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapProductEndpoints();
app.Run();
internal record Product(Guid Id, string Name);
internal static class ProductsEndpoints
{
private static readonly IEnumerable<Product> _products = new Product[] { /* ... */ };
internal static WebApplication MapProductEndpoints(this WebApplication app)
{
app.MapGet("api/products/", () => Results.Ok(_products)).AllowAnonymous();
app.MapPost("api/products/", (Product product) => { /* ... */ }).AllowAnonymous();
app.MapGet("api/products/{id}", (Guid id) => { /* ... */ }).AllowAnonymous();
app.MapDelete("api/products/{id}", (Guid id) => { /* ... */ }).AllowAnonymous();
return app;
}
}
On note alors deux points redondants qui apportent une certaine verbosité aux définitions des endpoints: la répétition du préfixe de la route et la spécification du contrôle d'accès.
Dans .NET 7 il est possible, via la nouvelle classe RouteGroupBuilder et sa méthode d'extension, de définir un ensemble d'endpoint pour une route donnée et d'y spécifier un ensemble de caractéristiques qui seront communes à tous les endpoints ainsi définis:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapProductEndpoints();
app.MapGroup("api/products").MapProductEndpoints().AllowAnonymous();
internal record Product(Guid Id, string Name);
internal static class ProductsNewEndpoints
{
private static readonly IEnumerable<Product> _products = new Product[] { /* ... */ };
internal static RouteGroupBuilder MapProductEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/", () => Results.Ok(_products));
group.MapPost("/", (Product product) => { /* ... */ });
group.MapGet("/{id}", (Guid id) => { /* ... */ });
group.MapDelete("/{id}", (Guid id) => { /* ... */ });
return group;
}
}
Lors de la réalisation de certains contrôleurs propres à des types de ressources bien définis, il devient ainsi bien plus facile de configurer de la même manières les endpoints associés tout en s'assurant de les enregistrer avec la bonne route.
Initialement, aucun support n'était encore assuré pour prendre en charge la méthode d'extension AddFilter par exemple. Le code remanié grâce à cette classe gagnait donc en lisibilité mais malheureusement, uniquement sur certains aspects bien définis et la généralisation de la logique de filtrage n'est pas encore possible.
Heureusement depuis, l’ancien objet GroupRouteBuilder a été remplacé par un RouteGroupBuilder qui, lui, permet d’ajouter des filtres aux routes qu’il regroupe via la méthode AddRouteHandlerFilter<TFilter>.
Son utilisation ne s’en retrouve alors que plus intéressante pour l’uniformisation des endpoints qu’il regroupe.
Amélioration de la documentation de l'API avec OpenAPI
OpenAPI est une spécification agnostique de tout langage qui permet de décrire des APIs.
On peut ainsi apporter certaines précision à l'API elle-même (version, description, etc.) mais également aux endpoints qui la compose (type de retour, routes, codes HTTP de retour, etc.)
Avec OpenAPI et son intégration grandement facilitée par des librairies tierces telles que Swashbuckle ou encore NSwag, leur documentation est devenue à la fois un incontournable du développement d'API mais également un standard au point que la mise en place d'un Swagger avec Swashbuckle peut-être générée dès la création d'un nouveau projet.
Avec .NET 7, un nouvelle librairie Microsoft.AspNetCore.OpenApi va être disponible afin de documenter ses endpoints. Cette dernière devrait permettre de générer la spécification OpenAPI d'un endpoint d'une minimal API à partir de ses métadonnées et de la route.
L'utilisation la plus simple de la méthode exposée et un simple appel à la méthode d'extension WithOpenApi():
app.MapGet("api/products/{id}", (Guid id) =>
{
var product = products.SingleOrDefault(product => product.Id == id);
return Results.Ok(product);
})
.WithOpenApi();
Il est cependant possible d'enrichir la description de cette opération en précisant de nombreux aspects de l'endpoint ainsi décoré. On peut alors documenter à la fois l'opération elle-même mais aussi ses paramètres, s'ils doivent respecter certaines conditions, ou bien encore les types de retour et leur format:
app.MapGet("api/products/{id}", (Guid id) =>
{
var product = products.SingleOrDefault(product => product.Id == id);
return Results.Ok(product);
})
.WithOpenApi(operation =>
{
operation.Summary = "Retrieve a specific product given its ID";
operation.Parameters[0].Description = "The ID of the product to retrieve";
operation.Parameters[0].AllowEmptyValue = false;
return operation;
});
C'est dans cette seconde utilisation que la librairie montre son potentiel intérêt, en permettant d'apporter le même degré d'information que Swashbuckle pourrait le faire via la xlmdoc ou bien des attributs mais cette fois-ci directement en bénéficiant du C# dans un bloc dédié.
Reste à suivre l'évolution de ce package qui pour le moment pourrait être perfectible en ce qui concerne l'accès à certains paramètres. Par exemple, la récupération d'un paramètre particulier est actuellement fait via un index sur Operation.Parameters et peut alors être sujet à problème si la signature d'un endpoint vient à changer.
Une autre limitation à cette méthode d'extension précédemment mentionnée est qu'elle n'est actuellement pas compatible avec les RouteGroupBuilder et il n'est donc pas possible de l'utiliser au niveau d'un groupement d'endpoint pour le moment, bien que Microsoft ai annoncé qu'il s'agit là d'une fonctionnalité déjà planifiée.
Amélioration des types de retour
Amélioration de la testabilité des types retournés
Avec .NET 6, les endpoints retournant une réponse en plus de données particulières pouvaient jouir de l'interface nouvellement introduite IResponse.
Pour retourner des instances de cette dernière, la classe statique Results exposaient un certain nombre de méthodes symbolisant tant bien la redirection, qu'un échec ou un succès de la requête.
Un endpoint typique avait alors la forme suivante:
app.MapGet("api/products", GetProducts);
IResult GetProducts()
{
var products = new Product[] { /* ... */ };
return Results.Ok(products);
}
Pourtant, l'inconvénient majeur de IResult était que les classes qui l'implémentait n'étaient pas publiques. Impossible donc de manipuler le type de réponse effectivement renvoyé, ce qui est particulièrement gênant si l'on souhaite tester les endpoints et leurs retours puisque non seulement la réponse mais aussi les données qu'elle englobe sont inaccessibles programmatiquement.
Avec .NET 7, deux changements majeurs ont été réalisé pour solutionner ce désagrément.
Dans un premier temps, les types implémentant IResult ont été rendu publiques. Il est donc maintenant possible d'accéder à une instance de la classe Ok<TResponse> dans ses tests par exemple, pour s'assurer à la fois du type de retour mais également au type des données retournées.
Cependant, seulement avec ce changement, le type de retour de notre endpoint précédemment défini n'est pas Ok<Product[]> comme on pourrait s'y attendre mais Ok<object>.
La raison vient du fait que Results.Ok prend en paramètre un type object?, masquant ainsi le type d'origine de la réponse.
Pour pallier à ce problème, Microsoft a publié une nouvelle classe statique, équivalente à l'existante Microsoft.AspNetCore.Http.Results, mais qui, elle, préserve les informations relatives au type d'origine: Microsoft.AspNetCore.Http.TypedResults.
Cette dernière s'utilise sensiblement de la même manière, et notre endpoint ainsi modifié n'a pas besoin de changement autre que le changement de Results en TypedResults:
app.MapGet("api/products", GetProducts);
IResult GetProducts()
{
var products = new Product[] { /* ... */ };
return TypedResults.Ok(products);
}
Le type des données TResponse du retour Ok<TResponse> sera alors bien Product[] et non plus object comme précédemment.
L'avantage de cette nouvelle classe, reposant sur les même définitions que .NET 6, est qu'elle permet de transitionner vers des retours où le type est préservé et plus pertinent sans nécessiter un remaniement immédiat de l'existant.
De la même manière que les nullables pouvaient être chirurgicalement appliqués, avec cette stratégie il est possible de progressivement utiliser la nouvelle classe TypedResults sans risquer une régression des endpoints déjà fonctionnels. La seule différence sera que d'éventuels tests s'assurant que le retour était bien du type Ok<object> échoueront à présent.
Amélioration de la signature des retours des endpoints
Bien que par la programmation il soit maintenant possible de récupérer le type sous-jacent de la réponse grâce à TypedResults, le type de retour reste bien IResult et ne donne alors que peu d'informations sur la réponse à la seule lecture de la signature de l'endpoint:
app.MapGet("api/products/{id}",
IResult (Guid id) =>
{
var products = new Product[] { /* ... */ };
var product = products.SingleOrDefault(product => product.Id == id);
return product is null
? TypedResults.NotFound()
: TypedResults.Ok(product);
});
De la même manière, la découvrabilité de l'endpoint, dans le but que sa documentation soit la plus précise possible dans un Swagger par exemple, s'en trouve limitée.
Pour répondre à ce problème, une autre classe a été ajoutée: Results<TResult1, TResultN>. Cette dernière permet de retourner un ensemble de réponses de type IResult, possiblement retournés par l'endpoint.
En bénéficiant de ce type, il nous est alors possible d'affiner la définition de notre endpoint:
app.MapGet("api/products/{id}",
Results<NotFound, Ok<Product>> (Guid id) =>
{
var products = new Product[] { /* ... */ };
var product = products.SingleOrDefault(product => product.Id == id);
return product is null
? TypedResults.NotFound()
: TypedResults.Ok(product);
});
La documentation résultant dans la Swagger UI en est ainsi d'autant plus précise:
Bien que déjà intéressant en soit pour la lisibilité et l'enrichissement de la Swagger UI, l'affinage des retours des endpoints ne se limite pas à ces seuls avantages.
Le fait de bénéficier d'une union typée permet également de remonter des erreurs dès la compilation d'une éventuelle dissonance dans l'implémentation de la logique par rapport à la signature:
C# 11
Comme à son habitude, la mise à jour de .NET nous gratifie également d'une nouvelle version du langage, qui sera ici la version 11.
De multiples changements sont à noter, et en particulier en ce qui concerne les patterns ainsi que la gestion des strings.
Prise en charge du multi-lignes pour les strings interpolées
Jusqu'à C# 10, l'interpolation ne prenait pas en charge plusieurs lignes de C# et l'entièreté devait donc être écrit en une seule fois sans retours.
Aussi, de longues expressions (un calcul avec LINQ par exemple) devenait rapidement très verbeux et peu lisible à la simple lecture du code.
Avec C# 11, il est maintenant possible de séparer en plusieurs ligne ce genre d'expressions:
// Avant C# 11
_ = $"'0' in text is {Enumerable.Range(0, 10).Select(index => index + 1).Sum()}";
// Avec C# 11
_ = $"'0' in text is {Enumerable.Range(0, 10)
.Select(index => index + 1)
.Sum()}";
La prise en charge est d'autant plus efficace qu'il ne s'agit pas juste de retour à la ligne mais bien de prendre en charge n'importe quel code C# valide, comme par exemple une switch expression:
// Avant C# 11
var binaryDigitToString = (int number) => number switch
{
0 => "zero",
1 => "one",
_ => "?",
};
_ = $"'0' in text is {binaryDigitToString(0)}";
// Avec C# 11
_ = $"'0' in text is {0 switch
{
0 => "zero",
1 => "one",
_ => "?",
}}";
Bien qu'intéressante, cette fonctionnalité laisse cependant dubitatif et pourrait inciter à produire du code moins lisible. Il faudra donc rester vigilent à l'utiliser avec parcimonie.
Introduction des raw strings literals
Les raw string literals sont un nouveau type de strings introduites avec C# 11.
Ces dernières ont la particularité de commencer par trois doubles quotes et de pouvoir contenir n'importe quel texte, y compris les espaces, sauts de ligne, quotes ou encore des caractères spéciaux, sans nécessiter de les échapper:
_ = """
This is a string
"literal"
""";
Dans le cas où l'on souhaiterait y incorporer un texte contenant autant de quotes que le délimiteur, il est possible de multiplier ces dernières au début et à la fin pour échapper le texte:
_ = """""
This is a string
""" literal """
""""";
// ^ Le délimiteur est 5 doubles quotes au lieu de 3
De la même manière que les strings, ce type permet d'interpoler des valeurs dans le son corps. Il est possible de multiplier le symbole $ qui marque l'interpolation de la même manière qu'il est possible d'étendre le délimiteur afin de pouvoir afficher les accolades en les échappant:
var name = "John";
_ = $$"""
Hi Mary! Here's my JSON: {"name": "{{name}}"}
- Regards, {{name}}
""";
// Hi Mary! Here's my JSON: {"name": "John"}
// - Regards, John
Il est à noter que les espaces se situant à gauche du délimiteur fermant du raw string literal seront occultés sur toutes les lignes.
Amélioration de l'initialisation des structs
C# 11 apporte également des nouveautés quant à la création de structures. Toutes les structures ont un constructeurs par défaut sans paramètre. Ce dernier est soit explicitement implémenté, soit généré par le constructeur.
Un code où le constructeur n'initialiserait pas explicitement chacune des propriétés peut maintenant compiler et tous ces champs seront alors initialisés à leur valeur par défaut si une valeur ne leur a pas été explicitement assignée:
Console.WriteLine(new Product(5));
Console.WriteLine(new Product());
Console.WriteLine(default(Product));
Console.WriteLine(string.Join(", ", new Product[3]));
// #5: Not set
// #0:
// #0:
// #0: , #0: , #0:
struct Product
{
public int Id { get; set; }
public string Name { get; set; } = "Not set";
public Product(int id) => Id = id;
public Product(int id, string name) => (Id, Name) = (id, name);
public override string ToString() => $"#{Id}: {Name}";
}
Toujours au sujet de la construction des structures, si une de ces dernières ne possède que des propriétés accessibles, il est alors possible de la créer sans l'opérateur new:
Person p;
p.Id = 1;
p.Name = "John";
struct Person
{
public int Id;
public string Name;
}
Amélioration du pattern matching
Switch expressions étendues
De la même manière qu'il était possible de faire de la reconnaissance de pattern sur des strings, il est maintenant également possible d'en faire sur les types Span<char> et ReadonlySpan<T> pour tester leur valeur contre une constante donnée.
List patterns
Les patterns appliqués aux listes sont une nouveauté très puissante qui vient s'ajouter à la longue liste des améliorations venant avec cette mise à jour.
Dans la continuité des switch expressions, il sera dorénavant possible de tester un tableau de valeurs grâce à l'opérateur switch.
Un nouveau pattern est pour cela disponible: le range pattern (..). Ce dernier identifiera toute séquence de zéro ou plusieurs éléments.
Voici un exemple d'utilisation de la discard et du range pattern pour analyser une liste avec le list pattern:
var listPattern = (int[] array) => array switch
{
[] => "Empty array",
[0] => "This is 0",
[int number] => $"number: {number}",
[_, .. int[] middle, _] => $"middle: {string.Join(',', middle)}",
_ => "Nothing matched",
};
Console.WriteLine(listPattern(Array.Empty<int>()));
// Empty array
Console.WriteLine(listPattern(new[] { 0 }));
// This is 0
Console.WriteLine(listPattern(new[] { 1 }));
// number: 1
Console.WriteLine(listPattern(new[] { 0, 1, 2, 3, 4 }));
// middle: 1,2,3
Fait intéressant, il est possible de réaliser un list pattern au sein d'un autre list pattern:
var numbers = new[]
{
new[] { 0, 1, 2 },
new[] { 3, 4, 5 },
new[] { 6, 7, 8 },
};
var extracted = numbers switch
{
[_, [.. int[] beginning, _],_] => $"Beginning 2nd line: {string.Join(',', beginning)}",
_ => "Nothing matched",
};
Console.WriteLine(extracted);
// Beginning 2nd line: 3,4
La fonctionnalité peut donc se révéler très puissante dans certains cas de figure mais également rapidement devenir cryptique. Là encore, il faudra peut-être être vigilent quant à son utilisation et éventuellement la combiner à LINQ pour garder un code lisible.
Généricité pour les entités mathématiques
La généricité est un concept largement couvert et utilisé au sein du framework.
Pourtant, bien qu'il soit possible d'ajouter des contraintes sur les types génériques afin de s'assurer qu'ils respectent une certaine forme, il n'était jusqu'alors pas possible de contraindre un type générique à être un nombre.
Avant .NET 7, rendre générique une méthode mathématique nécessitait sa réécriture et résultait souvent en l'écriture de ce genre de code:
int ComputeSumInt(params int[] numbers)
{
var result = 0;
foreach (var number in numbers) result += number;
return result;
}
double ComputeSumDouble(params double[] numbers)
{
var result = .0;
foreach (var number in numbers) result += number;
return result;
}
Dans cette mise à jour un nouveau type INumber<T> a été ajouté, ce dernier étant implémenté par tous les types numériques natifs (int, float, double, etc).
En plus de définir le comportement des opérateurs, il expose aussi d'autres concepts tels que la notion de 0 ou de 1 par exemple.
Ainsi, le code que l'on pouvait trouvé dupliqué précédement peut être remanié de manière nettement plus claire grâce à l'application de la généricité nouvellement possible:
Au delà de la perspective de générifier certaines méthodes mathématiques, la possibilité de pouvoir générer ses propres types de nombres est également intéressante.
Propriétés statiques abstraites
Les propriétés statiques abstraites sont une fonctionnalité peu mise en avant pour cette mise à jour bien qu'étonnement efficace dans certains contextes. Il s'agit également de la fonctionnalité qui a rendu possible de rendre générique les types mathématiques.
Le fonctionnement des propriétés statiques abstraites est très simple et fonctionne exactement comme son nom l'indique: dans une classe ou interface, il est possible de spécifier une propriété avec les modificateurs abstract et static. Les types en héritant ou les implémentant devront alors explicitement les définir:
Console.WriteLine($"{nameof(Service)} has for public name {Service.PublicName}");
// Service has for public name ServicePublicName
interface IHasPublicName
{
public abstract static string PublicName { get; }
}
class Service : IHasPublicName
{
public static string PublicName => "ServicePublicName";
}
Cela peut se révéler particulièrement pratique pour exposer de manière statique des attributs particuliers d'une classe.
Attributs génériques
Dans le même contexte d'amélioration de la généricité, les attributs souffraient également d'un manque de prise en charge des types génériques.
Historiquement, afin de récupérer un type depuis un attribut, ce dernier devait le prendre sous la forme d'un attribut de type Type:
[AttributeUsage(AttributeTargets.Class)]
class FruitAttribute : Attribute
{
private readonly Type _fruitType;
public FruitAttribute(Type fruitType)
=> _fruitType = fruitType;
}
[Fruit(typeof(Apple))]
class Golden { }
Ce qui impliquait une certaine quantité de code afin de récupérer un type, et encore plus si l'on souhaitait s'assurer de sa validité comme par exemple, dans notre cas, vérifier que le type passé est bien un fruit.
Dorénavant, il est possible d'utiliser la généricité avec les attributs afin de fortement les typer mais également de bénéficier du système de contraintes pour s'assurer qu'il convient à nos besoins:
[AttributeUsage(AttributeTargets.Class)]
class FruitAttribute<T> : Attribute where T : IFruit { }
[Fruit<Apple>]
class GrannySmith { }
Pour conclure
Les nombreuses améliorations apportées à .NET à l’occasion de cette mise à jour sont très intéressantes et de nombreux développeurs sont déjà excités par certaines d’entre-elles.
Parmi celles-ci, l’attention particulière portée aux minimal API souligne une volonté de la part de Microsoft de proposer une alternative aux API plus traditionnelles, qui se veut plus légère, moderne et flexible que celles basées sur des contrôleurs.
Pourtant certaines de ces fonctionnalités, bien que prometteuses, laissent parfois une impression de réflexion inachevée. C’est par exemple le cas pour les nouveaux limitateurs de requêtes, qui ne présentent à l’heure actuelle aucun moyen de fonctionner de manière distribuée.
D’autres améliorations semblaient ne pas être attendues et peuvent laisser dubitatif. Hors cas spécifique par exemple, il est difficile de se représenter l’utilisation de blocs de code entiers dans une string interpolée ou l’utilisation d’un grand nombre de $ et " pour les délimiter qui puissent rendre une expression plus lisible.
Dans l’ensemble le dynamisme qu’apporte .NET 7 à l’écosystème .NET est le bienvenu et certains changements sont très attendus. En revanche, les mises à jours mineures qui lui seront apportées seront à suivre, dans la mesure où elles apporteront peut-être plus d’éclaircissements et/ou de maturité sur certains aspects.