Fermer

septembre 18, 2024

Conseils pour améliorer les performances de l’API dans ASP.NET Core

Conseils pour améliorer les performances de l’API dans ASP.NET Core


Certaines techniques peuvent faire une grande différence dans l’utilisation efficace des ressources, en améliorant les performances et l’évolutivité. Découvrez ces neuf conseils sur la façon d’optimiser les API dans ASP.NET Core.

Dans les scénarios impliquant le transfert ou la gestion de grandes quantités de données, les performances des API sont cruciales pour des applications rapides, évolutives et efficaces.

De bonnes performances de l’API peuvent réduire la latence, améliorer l’expérience utilisateur et optimiser l’utilisation des ressources du serveur.

Dans cet article, nous examinerons quelques bonnes pratiques qui peuvent être adoptées pour de bonnes performances dans une API Web ASP.NET Core. Nous aborderons des sujets tels que la mise en cache, l’optimisation du code, la pagination et autres.

Pourquoi est-il important d’envisager l’optimisation des ressources dans les API Web ?

Les API Web sont très courantes de nos jours, en particulier dans les applications à grande échelle qui doivent communiquer avec des modules externes, envoyer et recevoir des données.

Créer des applications efficaces devrait être l’objectif des développeurs backend en raison des divers avantages qu’apporte l’optimisation des ressources. De plus, plusieurs problèmes peuvent être évités lors de la création d’applications Web optimisées.

Certains avantages des API optimisées incluent des réponses plus rapides, une expérience utilisateur améliorée et une consommation réduite des ressources du serveur telles que la mémoire et le processeur. Les ressources optimisées permettent aux API de mieux évoluer, en traitant davantage de requêtes sans dégrader significativement les performances.

Pour obtenir ces avantages, il est essentiel de connaître des techniques permettant d’identifier d’éventuels goulots d’étranglement dans l’application. De plus, il existe également d’excellents outils qui peuvent être utilisés pour rendre une API efficace.

Ci-dessous, nous examinerons certains des principaux outils et ressources ASP.NET Core pour améliorer les performances d’une API Web.

Exemple de code

Pour gagner du temps, cet article ne montrera pas les détails de la mise en œuvre de l’application de base, uniquement les extraits de code pertinents pour les sujets abordés.

Vous pouvez consulter les détails en accédant à l’application complète dans ce référentiel GitHub : Code source du gestionnaire d’événements de service.

1. Utiliser des requêtes asynchrones

La communication asynchrone permet de traiter simultanément des milliers de requêtes. Cela apporte un gain de performances, car il n’est pas nécessaire d’attendre la fin d’une requête avant qu’une autre ne démarre. ASP.NET Core permet à un petit pool de threads de gérer des milliers de requêtes simultanées.

Pour voir l’utilisation de la communication asynchrone en pratique, analysons le code ci-dessous. Notez que le retour est géré de manière synchrone :

[HttpGet("error-logs")]
public ActionResult<List<Log>> GetErrorLogs()
{
  var logErrors = _context.Logs
    .Where(l => l.LogLevel == InternalLogLevel.Error)
    .ToList();

  return Ok(logErrors);
}

Chaque requête HTTP adressée à un point de terminaison synchrone bloque un thread du pool de threads du serveur. Si de nombreuses requêtes synchrones se produisent simultanément, cela peut épuiser le pool de threads, provoquant des ralentissements ou des pannes de service. Notez que rien n’est asynchrone ici, ce qui signifie que ce point de terminaison peut devenir un problème. Pour résoudre ce problème, nous pouvons le réécrire comme suit :

[HttpGet("log-errors")]
public async Task<ActionResult<List<Log>>> GetErrorLogs()
{
  var logErrors = await _context.Logs
    .Where(l => l.LogLevel == InternalLogLevel.Error)
    .ToListAsync();

 return Ok(logErrors);
}

Notez que nous avons maintenant ajouté le async et await commandes à la requête, de plus, nous utilisons le ToListAsync() méthode qui est également asynchrone, ce qui signifie que l’appel à ce point de terminaison ne bloquera plus un thread, car il peut désormais être traité de manière asynchrone. De plus, d’autres processus peuvent être exécutés en parallèle, ce qui génère de meilleures performances pour l’application.

Le transport simultané de grandes quantités de données peut entraîner de graves problèmes de performances, une consommation excessive de mémoire et des ralentissements. Pour atténuer ces éventuels goulots d’étranglement, nous pouvons utiliser la pagination.

La pagination est une technique utilisée pour partitionner de grandes quantités de données, dans laquelle vous permettez au demandeur de choisir une certaine plage de valeurs. Par exemple, vous pouvez envoyer une valeur (skip) = 10 et une deuxième valeur (take) = 50donc si la recherche s’effectue dans une liste ordonnée, seuls les enregistrements qui composent la plage de ces nombres (11 à 60) seront renvoyés. Dans ce cas, seule la quantité demandée a été transportée, et non la quantité totale des articles.

Vous trouverez ci-dessous l’une des façons d’implémenter la pagination dans ASP.NET Core :

[HttpGet("error-logs/{skip}/{take}")]
  public async Task<ActionResult<List<Log>>> GetErrorLogsPaginated([FromRoute] int skip = 0, [FromRoute] int take = 10)
  {
    var logErrors = await _context.Logs
      .Where(l => l.LogLevel == InternalLogLevel.Error)
      .Skip(skip)
      .Take(take)
      .OrderBy(c => c.Id)
      .ToListAsync();

  return Ok(logErrors);
}

Désormais, le point de terminaison permet aux clients API d’obtenir des enregistrements de journal d’erreurs sous une forme paginée, en utilisant les paramètres skip et take pour contrôler la pagination. Ces paramètres sont transmis au Skip() et Take() Méthodes d’extension LINQ, filtrant les enregistrements et renvoyant uniquement ce qui a été demandé. Nous utilisons également le OrderBy() méthode afin que la liste soit ordonnée avant le filtrage.

L’utilisation de la pagination vous permet d’optimiser les performances et l’évolutivité lorsque vous traitez de gros volumes de données, car elle permet une répartition plus équilibrée de la charge de travail et améliore l’expérience utilisateur en réduisant le temps de réponse et la consommation des ressources système.

3. Utilisez AsNoTracking autant que possible

AsNoTracking() est une méthode d’extension présente dans Entity Framework Core et fonctionne comme suit :

Par défaut, EF Core suit les entités présentes dans le DbContext classe. Cela signifie qu’il conserve une référence à chaque entité pour détecter les modifications et les synchroniser avec la base de données lorsque vous appelez le SaveChanges méthode. Le suivi des entités consomme de la mémoire et du temps de traitement.

Lorsque vous utilisez le AsNoTracking() méthode, EF Core désactive ce comportement de suivi, ce qui peut entraîner des requêtes plus rapides et une utilisation moindre de la mémoire, car le DbContext n’a pas besoin de conserver les références aux entités renvoyées.

Par conséquent, dans les scénarios où il n’y a que la lecture des données, il est conseillé d’utiliser AsNoTracking pour informer EF qu’il n’est pas nécessaire de créer des références à des entités, ce qui entraîne une réduction de la charge sur EF Core, ce qui entraîne des requêtes plus rapides.

Le code ci-dessous montre le point de terminaison précédent avec la méthode AsNoTracking.

[HttpGet("error-logs/{skip}/{take}")]
public async Task<ActionResult<List<Log>>> GetErrorLogsPaginated([FromRoute] int skip = 0, [FromRoute] int take = 10)
{
  var logErrors = await _context.Logs
    .AsNoTracking()
    .Where(l => l.LogLevel == InternalLogLevel.Error)
    .OrderBy(c => c.Id)
    .Skip(skip)
    .Take(take)
    .ToListAsync();

  return Ok(logErrors);
}

Bien qu’il soit utile d’utiliser AsNoTracking pour récupérer des données, cela doit être évité dans les scénarios où il y a des modifications de données, comme DbContext ne pourra pas suivre et appliquer les modifications.

4. Minimiser les allers-retours sur le réseau

Éviter les allers-retours sur le réseau signifie que vous devez, dans la mesure du possible, récupérer les données nécessaires en un seul appel, plutôt que de passer plusieurs appels puis de les rassembler.

Notez le code ci-dessous :

[HttpGet("services-ids")]
public async Task<ActionResult<List<int>>> GetServicesIds()
{
  var servicesIds = await _context.Services.AsNoTracking().Select(x => x.Id).ToListAsync();
  return Ok(servicesIds);
}

[HttpGet("logs-with-service")]
public async Task<ActionResult<List<Log>>> GetLogsByServiceIds([FromQuery] List<int> serviceIds)
{
  var logs = await _context.Logs
    .AsNoTracking()
    .Where(s => serviceIds.Contains(s.Id))
    .Select(s => new Log
    {
      Id = s.Id,
      Service = s.Service,
    })
    .ToListAsync();
  return Ok(logs);
}

Ici, nous avons deux points de terminaison : le premier pour renvoyer tous les ID de service et le second pour renvoyer les journaux contenant des enregistrements avec ces ID. Dans ce cas, nous effectuons deux appels, ce qui n’est pas nécessaire puisque nous pourrions procéder comme suit :

[HttpGet("logs-with-service")]
public async Task<ActionResult<List<Log>>> GetAllServicesWithLogs()
{
  var services = await _context.Services
    .AsNoTracking()
    .ToListAsync();

  var serviceIds = services.Select(s => s.Id).ToList();
    
  var logs = await _context.Logs
    .AsNoTracking()
    .Where(l => serviceIds.Contains(l.ServiceId))
    .ToListAsync();

  return Ok(logs);
}

Il n’existe désormais qu’un seul point de terminaison, qui combine la récupération de tous les services et de leurs journaux associés en un seul appel, sans qu’il soit nécessaire de fournir des identifiants de service. Cela simplifie la logique du client et réduit le nombre de demandes. Bien que simples, de telles choses passent souvent inaperçues et peuvent entraîner une augmentation significative de la consommation des ressources du serveur. Ainsi, chaque fois que vous avez besoin de consolider des données sur plusieurs demandes, demandez-vous s’il existe un moyen de réduire le nombre d’appels requis.

5. Utilisez la tâche pour attendre la fin de la tâche

En utilisant async Task sur les points de terminaison permet à l’appelant d’attendre la fin de la tâche. Cela signifie que le pipeline de requêtes peut attendre la fin de la méthode asynchrone avant de continuer.

ASP.NET Core s’attend à ce que les méthodes d’action renvoient une tâche afin qu’elle puisse s’intégrer correctement dans le pipeline de requêtes asynchrones. Lors de l’utilisation async voidcette attente est rompue et peut entraîner des réponses avant la fin de l’opération asynchrone.

Au lieu d’utiliser async void:

[HttpDelete("/{id}")]
public async void DeleteService([FromRoute] int id)
{
  await _context.Services.FirstOrDefaultAsync(s => s.Id == id);
  await Response.WriteAsync("Successfully deleted record");
}

Utiliser async Task:

[HttpDelete("/{id}")]
public async Task DeleteService([FromRoute] int id)
{
  await _context.Services.FirstOrDefaultAsync(s => s.Id == id);
  await Response.WriteAsync("Successfully deleted record");
}

6. Utilisez les requêtes LINQ

L’utilisation de LINQ (Language-Integrated Query) pour filtrer et agréger les données, en plus d’être plus efficace, rend le code plus propre et plus concis. Lorsque vous utilisez des instructions telles que .Where.Select, .CountAsync ou .Sum par exemple, le filtrage est effectué lors de la requête dans la base de données. De cette façon, seules les données nécessaires seront renvoyées par la requête, sans qu’il soit nécessaire de créer des variables supplémentaires et de parcourir de grandes listes à la recherche de données spécifiques.

Au lieu de rechercher et de filtrer les données manuellement :

[HttpGet("error-logs/count")]
public async Task<ActionResult<int>> GetErrorLogsCount()
{
  var errorLogs = await _context.Logs.ToListAsync();

  int errorLogCount = 0;

  foreach (var errorLog in errorLogs)
  {
    if (errorLog.LogLevel == InternalLogLevel.Error)
    {
      errorLogCount++;
    }
  }

  return Ok(errorLogCount);
}

Utilisez les fonctionnalités de LINQ pour créer des requêtes efficaces :

[HttpGet("error-logs/count")]
public async Task<ActionResult<int>> GetErrorLogsCount()
{
  int errorLogsCount = await _context.Logs
    .Where(l => l.LogLevel == InternalLogLevel.Error)
    .CountAsync();

  return Ok(errorLogsCount);
}

7. Utiliser le cache pour les données fréquemment consultées

La mise en cache est une technique qui permet d’améliorer considérablement les performances d’une application en réduisant la consommation des ressources de la base de données.

À la première demande, les données sont récupérées de la source et insérées dans le stockage en cache. Et lors des requêtes suivantes, si les données existent dans le cache, elles sont immédiatement renvoyées au demandeur, sans qu’il soit nécessaire de rechercher la source.

La mise en cache est la mieux adaptée aux scénarios dans lesquels les données changent rarement et doivent être disponibles rapidement sur demande.

ASP.NET Core prend en charge deux principaux types de mise en cache : la mise en cache en mémoire et la mise en cache distribuée.

La mise en cache en mémoire est la plus simple et est fournie via l’interface IMemoryCache. IMemoryCache représente un cache stocké dans la mémoire du serveur Web.

Vous trouverez ci-dessous un exemple de cache en mémoire :

[HttpGet("log-errors-cache-in-memory")]
public async Task<ActionResult<List<Log>>> GetErrorLogsWithCacheInMemory()
{
  const string CacheKey = "logs_with_error";

  if (!_memoryCache.TryGetValue(CacheKey, out List<Log>? logErrors))
  {
    logErrors = await _context.Logs
      .Where(l => l.LogLevel == InternalLogLevel.Error)
      .ToListAsync();

    var cacheEntryOptions = new MemoryCacheEntryOptions()
      .SetAbsoluteExpiration(TimeSpan.FromMinutes(30));

    _memoryCache.Set(CacheKey, logErrors, cacheEntryOptions);
  }

  return Ok(logErrors);
}

Notez que ce point de terminaison vérifie désormais si les données existent dans la mémoire cache. S’il n’existe pas, il est ajouté, ainsi lors de la requête suivante, les données seront renvoyées directement depuis la mémoire, sans qu’il soit nécessaire de faire une nouvelle requête dans la base de données.

L’autre façon de mettre en œuvre la mise en cache consiste à utiliser la mise en cache distribuée, qui peut être partagée sur plusieurs serveurs et est généralement gérée en tant que service externe.

ASP.NET Core dispose de plusieurs types de cache distribué, l’un des plus connus est EnyimMemcachedCore, une bibliothèque open source pour ASP.NET Core, qui dispose de bonnes ressources pour utiliser le cache distribué.

Pour utiliser Memcached, vous devez disposer d’un serveur exécutant une instance de Memcached ou vous pouvez l’utiliser via Docker.

Le code ci-dessous montre un point de terminaison utilisant Memcached :

[HttpGet("log-errors-cache-distributed")]
public async Task<ActionResult<List<Log>>> GetErrorLogsWithCacheDistributed()
{
  const string CacheKey = "logs_with_error";
    List<Log>? logErrors = await _memcachedClient.GetValueAsync<List<Log>>(CacheKey);

    if (logErrors == null)
    {
      logErrors = await _context.Logs
        .Where(l => l.LogLevel == InternalLogLevel.Error)
        .ToListAsync();

      await _memcachedClient.SetAsync(CacheKey, logErrors, TimeSpan.FromMinutes(30));
    }

  return Ok(logErrors);
}

Notez que l’implémentation ressemble beaucoup à l’approche précédente qui utilise la mise en cache en mémoire. La plus grande différence est que le cache distribué est stocké sur un serveur ou un service externe.

8. Utiliser la prise en charge JSON de la base de données relationnelle

L’utilisation de JSON dans des bases de données relationnelles peut constituer un moyen efficace d’optimiser les performances d’une API, en particulier dans les scénarios où la manipulation des données ne nécessite pas de règles métier complexes.

Bien que les bases de données relationnelles soient spécialisées dans la création de relations entre les tables, il est possible d’utiliser des fonctionnalités communes aux bases de données non relationnelles telles que les données JSON. Pour ce faire, insérez simplement les données JSON dans une colonne de texte. Certaines bases de données ont des fonctionnalités spécifiques pour ce type de travail, comme PostgreSQL qui possède un type spécial (JSONB) pour gérer les données au format JSON.

En manipulant directement les données JSON, il peut réduire le mappage, éliminant ainsi le besoin de créer des entités C# pour chaque structure JSON. De plus, cela peut simplifier l’écriture des données, surtout si l’intégralité du JSON est modifié fréquemment, car cela évite plusieurs opérations sur les tables (il vous suffit de supprimer les anciennes données et d’insérer les nouvelles données). Lors de la manipulation de grandes quantités de données, il est possible d’améliorer efficacement les performances d’une API en utilisant la manipulation JSON.

Le code ci-dessous montre un point de terminaison manipulant JSON et l’insérant directement dans la base de données dans une colonne de texte :


public class ServiceJsonLog
{
  public int Id { get; set; }
  public string JsonLogData { get; set; }
}


[HttpPost("create-json-log")]
public async Task<ActionResult> PostServiceJsonLog([FromBody] ServiceJsonLog serviceJsonLog)
{
  await _context.ServiceJsonLogs.AddAsync(serviceJsonLog);
  await _context.SaveChangesAsync();

  return NoContent();
}

JSON envoyé dans la requête :

{
  "jsonLogData": "{\"Id\": 1, \"ServiceId\": 101, \"Service\": {\"Id\": 101, \"Name\": \"Service 01922\", \"Description\": \"SVC News web service\"}, \"Message\": \"Error to request\", \"LogLevel\": \"Error\", \"Timestamp\": \"2024-07-26T15:30:00\"}"
}

9. Essayez d’optimiser le code autant que possible

L’utilisation d’un code optimisé est importante pour maintenir de bonnes performances. Certaines fonctionnalités de C#, si elles sont mal utilisées, peuvent avoir des effets secondaires contribuant à une utilisation excessive des ressources. Jetez un œil à l’exemple ci-dessous :

[HttpGet]
public async Task<ActionResult<IEnumerable<Log>>> GetLogs()
{
  IEnumerable<Log> logs = await _context.Logs.ToListAsync();
    
  var result = logs.Where(log => log.LogLevel == InternalLogLevel.Error).ToList();

  return Ok(result);
}

Notez qu’ici, nous chargeons inutilement la liste des journaux, puis filtrons les données d’erreur. De plus, nous utilisons le IEnumerable<> interface, puis en la convertissant en List<>.

Pour améliorer cela, nous pouvons procéder comme suit :

[HttpGet]
public async Task<ActionResult<List<Log>>> GetLogs()
{
  List<Log> logs = await _context.Logs
    .Where(log => log.LogLevel == InternalLogLevel.Error)
    .ToListAsync();

  return Ok(logs);
}

Dans le deuxième exemple, nous appliquons le filtrage directement à la requête de base de données sans créer de nouvelle variable pour stocker les données du journal, ce qui est plus efficace. De plus, nous éliminons les conversions inutiles en utilisant uniquement le List type de données.

Bien que cela puisse paraître simple, si vous examinez attentivement le code, vous pouvez trouver plusieurs opportunités d’amélioration, qui, ensemble, peuvent entraîner un gain de performances important.

Conclusion

Connaître et utiliser des techniques d’optimisation est essentiel pour créer des API Web efficaces, en particulier dans les systèmes de taille moyenne et grande, où le trafic de données a tendance à augmenter de façon exponentielle.

De plus, la mise en œuvre de stratégies d’optimisation efficaces peut faire la différence lorsqu’il s’agit de traiter de grands volumes de requêtes simultanées sans compromettre le temps de réponse.

Dans cet article, nous avons couvert neuf conseils pour optimiser les ressources et améliorer les performances dans une API ASP.NET Core. Nous avons tout couvert, depuis des concepts simples tels que l’optimisation du code jusqu’à des implémentations plus complexes telles que l’utilisation de la mise en cache distribuée.

Ainsi, lorsque vous travaillez avec des API Web, envisagez d’adopter des pratiques d’optimisation pour une solution efficace et évolutive.




Source link