Événements
Championnats du monde Power BI DataViz
14 févr., 16 h - 31 mars, 16 h
Avec 4 chances d’entrer, vous pourriez gagner un package de conférence et le rendre à la Live Grand Finale à Las Vegas
En savoir plusCe navigateur n’est plus pris en charge.
Effectuez une mise à niveau vers Microsoft Edge pour tirer parti des dernières fonctionnalités, des mises à jour de sécurité et du support technique.
Notes
Ceci n’est pas la dernière version de cet article. Pour la version actuelle, consultez la version .NET 9 de cet article.
Avertissement
Cette version d’ASP.NET Core n’est plus prise en charge. Pour plus d’informations, consultez la stratégie de support .NET et .NET Core. Pour la version actuelle, consultez la version .NET 9 de cet article.
Important
Ces informations portent sur la préversion du produit, qui est susceptible d’être en grande partie modifié avant sa commercialisation. Microsoft n’offre aucune garantie, expresse ou implicite, concernant les informations fournies ici.
Pour la version actuelle, consultez la version .NET 9 de cet article.
Par Mike Rousos
Cet article fournit des instructions pour optimiser les performances et la fiabilité des applications ASP.NET Core.
La mise en cache est abordée dans plusieurs parties de cet article. Pour plus d’informations, consultez Vue d’ensemble de la mise en cache dans ASP.NET Core.
Dans cet article, un chemin de code chaud est défini comme un chemin de code qui est fréquemment appelé et où une grande partie du temps d’exécution se produit. Les chemins de code chaud limitent généralement le scale-out et les performances des applications. Ils sont abordés dans plusieurs parties de cet article.
Les applications ASP.NET Core doivent être conçues pour traiter de nombreuses demandes simultanément. Les API asynchrones permettent à un petit pool de threads de gérer des milliers de requêtes simultanées sans attendre lors d’appels bloquants. Au lieu d’attendre la fin d’une tâche synchrone de longue durée, le thread peut travailler sur une autre requête.
Un problème de performances courant dans les applications ASP.NET Core est lié aux appels bloquants qui pourraient être asynchrones. De nombreux appels bloquants synchrones conduisent à une défaillance du pool de conversation et à des temps de réponse qui se dégradent.
Ne bloquez pas l’exécution asynchrone en appelant Task.Wait ou Task<TResult>.Result.
N’achetez pas de verrous dans les chemins de code courants. Les applications ASP.NET Core fonctionnent mieux lorsqu’elles sont conçues pour exécuter du code en parallèle.
N’appelez pas Task.Run et attendez-le immédiatement. ASP.NET Core exécute déjà le code d’application sur des threads de pool de threads normaux, donc l’appel de Task.Run
entraîne uniquement une planification de pool de threads superflue. Même si le code planifié bloquait un thread, Task.Run
ne l’empêcherait pas.
Un profileur, tel que PerfView, peut être utilisé pour rechercher les threads ajoutés fréquemment au pool de threads. L’événement Microsoft-Windows-DotNETRuntime/ThreadPoolWorkerThread/Start
indique qu’un thread a été ajouté au pool de threads.
Une page web ne doit pas charger de grandes quantités de données en même temps. Lorsque vous retournez une collection d’objets, déterminez si cela peut entraîner des problèmes de performances. Déterminez si la conception peut produire les résultats médiocres suivants :
Ajoutez la pagination pour atténuer les scénarios précédents. À l’aide des paramètres de taille de page et d’index de page, les développeurs doivent favoriser la conception du retour d’un résultat partiel. Lorsqu’un résultat exhaustif est requis, la pagination doit être utilisée pour remplir de manière asynchrone des lots de résultats afin d’éviter le verrouillage des ressources du serveur.
Pour plus d’informations sur la pagination et la limitation du nombre d’enregistrements retournés, consultez :
Retourner IEnumerable<T>
à partir d’une action entraîne une itération de collection synchrone par le sérialiseur. Le résultat est le blocage des appels et un risque d’insuffisance de pool de threads. Pour éviter l’énumération synchrone, utilisez ToListAsync
avant de retourner l’énumérable.
À partir de ASP.NET Core 3.0, IAsyncEnumerable<T>
peut être utilisé comme alternative à IEnumerable<T>
qui énumère de manière asynchrone. Pour plus d’informations, consultez Types de retour d’action de contrôleur.
Le récupérateur de mémoire .NET Core gère automatiquement l’allocation et la libération de mémoire dans les applications ASP.NET Core. Le nettoyage automatique de la mémoire signifie généralement que les développeurs n’ont pas besoin de se soucier de la façon ou du moment où la mémoire est libérée. Toutefois, le nettoyage des objets non référencés prend du temps processeur. Les développeurs doivent donc réduire l’allocation d’objets dans les chemins de code chaud. Le nettoyage de la mémoire est particulièrement coûteux sur les objets volumineux (> 85 000 octets). Les objets volumineux sont stockés sur le tas d’objets volumineux et nécessitent un nettoyage complet de la mémoire (génération 2). Contrairement aux collections de génération 0 et de génération 1, une collection de génération 2 nécessite une suspension temporaire de l’exécution de l’application. L’allocation et la dé-allocation fréquentes d’objets volumineux peuvent entraîner des performances incohérentes.
Recommandations :
Les problèmes de mémoire, comme le précédent, peuvent être diagnostiqués en examinant les statistiques de garbage collection (GC) dans PerfView et en examinant :
Pour plus d’informations, consultez Garbage Collection et Performances.
Les interactions avec un magasin de données et d’autres services distants sont souvent les parties les plus lentes d’une application ASP.NET Core. La lecture et l’écriture efficaces des données sont essentielles à de bonnes performances.
Recommandations :
.Where
, .Select
ou .Sum
, par exemple) afin que le filtrage soit effectué par la base de données.Les approches suivantes peuvent améliorer les performances dans les applications à grande échelle :
Nous vous recommandons de mesurer l’impact des approches hautes performances précédentes avant de valider la base de code. La complexité supplémentaire des requêtes compilées peut ne pas justifier l’amélioration des performances.
Les problèmes de requête peuvent être détectés en examinant le temps passé à accéder aux données avec Application Insights ou avec des outils de profilage. La plupart des bases de données mettent également à disposition des statistiques concernant les requêtes fréquemment exécutées.
Bien que HttpClient implémente l’interface IDisposable
, il est conçu pour être réutilisé. Les instances HttpClient
fermées laissent les sockets ouverts à l’état TIME_WAIT
pendant une courte période. Si un chemin de code qui crée et supprime des objets HttpClient
est fréquemment utilisé, l’application peut épuiser les sockets disponibles. HttpClientFactory
a été introduit dans ASP.NET Core 2.1 comme solution à ce problème. Il gère le regroupement des connexions HTTP pour optimiser les performances et la fiabilité. Pour plus d’informations, consultez Utiliser HttpClientFactory
pour implémenter des requêtes HTTP résilientes.
Recommandations :
HttpClient
directement.HttpClient
. Pour plus d’informations, consultez Utiliser HttpClientFactory pour implémenter des requêtes HTTP résilientes.Vous voulez que tout votre code soit rapide. Les chemins de code fréquemment appelés sont les plus critiques à optimiser. Il s’agit notamment des paramètres suivants :
Recommandations :
La plupart des demandes adressées à une application ASP.NET Core peuvent être gérées par un contrôleur ou un modèle de page appelant les services nécessaires et renvoyant une réponse HTTP. Pour certaines demandes qui impliquent des tâches de longue durée, il est préférable de rendre l’ensemble du processus de réponse de requête asynchrone.
Recommandations :
Les applications ASP.NET Core avec des front-ends complexes servent fréquemment de nombreux fichiers JavaScript, CSS ou image. Les performances des demandes de chargement initiales peuvent être améliorées par :
Recommandations :
environment
d’ASP.NET Core pour gérer les environnements Development
etProduction
.La réduction de la taille de la réponse augmente généralement la réactivité d’une application, souvent de façon spectaculaire. L’un des moyens de réduire les tailles de charge utile consiste à compresser les réponses d’une application. Pour plus d’informations, consultez Compression des réponses.
Chaque nouvelle version d’ASP.NET Core inclut des améliorations des performances. Les optimisations dans .NET Core et ASP.NET Core signifient que les versions plus récentes surpassent généralement les versions antérieures. Par exemple, .NET Core 2.1 a ajouté la prise en charge des expressions régulières compilées et a bénéficié de Span<T>. ASP.NET Core 2.2 a ajouté la prise en charge de HTTP/2. ASP.NET Core 3.0 ajoute de nombreuses améliorations qui réduisent l’utilisation de la mémoire et améliorent le débit. Si les performances sont capitales, envisagez une mise à niveau vers la version actuelle d’ASP.NET Core.
Les exceptions doivent être rares. La levée et l’interception d’exceptions sont lentes par rapport à d’autres modèles de flux de code. Pour cette raison, les exceptions ne doivent pas être utilisées pour contrôler le flux normal du programme.
Recommandations :
Les outils de diagnostic d’application, tels qu’Application Insights, permettent d’identifier dans une application les exceptions courantes qui peuvent affecter les performances.
Toutes les E/S dans ASP.NET Core sont asynchrones. Les serveurs implémentent l’interface Stream
, qui a des surcharges synchrones et asynchrones. Il faut préférer les éléments asynchrones pour éviter de bloquer les threads de pool de threads. Le blocage des threads peut entraîner une insuffisance du pool de threads.
Ne procédez pas comme suit : l’exemple suivant utilise ReadToEnd. Il empêche le thread actuel d’attendre le résultat. Il s’agit d’un exemple de synchronisation sur asynchronisation.
public class BadStreamReaderController : Controller
{
[HttpGet("/contoso")]
public ActionResult<ContosoData> Get()
{
var json = new StreamReader(Request.Body).ReadToEnd();
return JsonSerializer.Deserialize<ContosoData>(json);
}
}
Dans le code précédent, Get
lit de manière synchrone tout le corps de la requête HTTP dans la mémoire. Si le client se charge lentement, l’application effectue la synchronisation sur asynchronisation. L’application ne se synchronise pas sur asynchronisation, car Kestrel ne prend PAS en charge les lectures synchrones.
Procédez comme suit : l’exemple suivant utilise ReadToEndAsync et ne bloque pas le thread lors de la lecture.
public class GoodStreamReaderController : Controller
{
[HttpGet("/contoso")]
public async Task<ActionResult<ContosoData>> Get()
{
var json = await new StreamReader(Request.Body).ReadToEndAsync();
return JsonSerializer.Deserialize<ContosoData>(json);
}
}
Le code précédent lit de manière asynchrone tout le corps de la requête HTTP dans la mémoire.
Avertissement
Si la requête est volumineuse, la lecture de l’ensemble du corps de la requête HTTP dans la mémoire peut entraîner une condition de mémoire insuffisante (OOM). Une mémoire insuffisante peut entraîner un déni de service. Pour plus d’informations, consultez Éviter de lire des corps de requêtes volumineux ou des corps de réponse en mémoire dans cet article.
Procédez comme suit : l’exemple suivant est entièrement asynchrone et utilise un corps de requête non mis en mémoire tampon :
public class GoodStreamReaderController : Controller
{
[HttpGet("/contoso")]
public async Task<ActionResult<ContosoData>> Get()
{
return await JsonSerializer.DeserializeAsync<ContosoData>(Request.Body);
}
}
Le code précédent désérialise de façon asynchrone le corps de la requête en un objet C#.
Utilisez HttpContext.Request.ReadFormAsync
au lieu de HttpContext.Request.Form
.
HttpContext.Request.Form
peut être lu en toute sécurité uniquement si les conditions suivantes sont réunies :
ReadFormAsync
, etHttpContext.Request.Form
Ne procédez pas comme suit : l’exemple suivant utilise HttpContext.Request.Form
. HttpContext.Request.Form
utilise la synchronisation sur asynchronisation et peut entraîner une insuffisance du pool de threads.
public class BadReadController : Controller
{
[HttpPost("/form-body")]
public IActionResult Post()
{
var form = HttpContext.Request.Form;
Process(form["id"], form["name"]);
return Accepted();
}
Procédez comme suit : l’exemple suivant utilise HttpContext.Request.ReadFormAsync
pour lire le corps du formulaire de manière asynchrone.
public class GoodReadController : Controller
{
[HttpPost("/form-body")]
public async Task<IActionResult> Post()
{
var form = await HttpContext.Request.ReadFormAsync();
Process(form["id"], form["name"]);
return Accepted();
}
Dans .NET, chaque allocation d’objets supérieure à 85,000 octets se retrouve dans le tas d’objets volumineux (LOH, large object heap). Les objets volumineux sont coûteux pour deux raisons :
Ce billet de blog décrit le problème de manière succincte :
Lorsqu’un objet volumineux est alloué, il est marqué comme objet Gen 2. Il ne s’agit pas d’un objet Gen 0 comme pour les petits objets. Les conséquences sont que si vous n’avez plus de mémoire dans LOH, GC (garbage collection) nettoie l’ensemble du tas managé, pas seulement LOH. Il nettoie donc Gen 0, Gen 1 et Gen 2 y compris LOH. C’est ce qu’on appelle le nettoyage de la mémoire (ou garbage collection) complet et c’est le nettoyage de la mémoire le plus chronophage. Pour de nombreuses applications, il peut être acceptable. Mais certainement pas pour les serveurs web hautes performances, où peu de mémoires tampons volumineuses sont nécessaires pour gérer une requête web courante (lecture à partir d’un socket, décompression, décodage de JSON, etc.).
Stockage d’une requête volumineuse ou d’un corps de réponse dans un seul byte[]
ou string
:
Lors de l’utilisation d’un sérialiseur/désérialiseur qui prend uniquement en charge les lectures et écritures synchrones (par exemple, Json.NET) :
Avertissement
Si la requête est volumineuse, cela peut entraîner une condition de mémoire insuffisante (OOM). Une mémoire insuffisante peut entraîner un déni de service. Pour plus d’informations, consultez Éviter de lire des corps de requêtes volumineux ou des corps de réponse en mémoire dans cet article.
ASP.NET Core 3.0 utilise System.Text.Json par défaut pour la sérialisation JSON. System.Text.Json:
Newtonsoft.Json
.Le IHttpContextAccessor.HttpContext retourne le HttpContext
de la requête active lors de l’accès à partir du thread de requête. Le IHttpContextAccessor.HttpContext
ne doit pas être stocké dans un champ ou une variable.
Ne procédez pas comme suit : l’exemple suivant stocke le HttpContext
dans un champ, puis tente de l’utiliser ultérieurement.
public class MyBadType
{
private readonly HttpContext _context;
public MyBadType(IHttpContextAccessor accessor)
{
_context = accessor.HttpContext;
}
public void CheckAdmin()
{
if (!_context.User.IsInRole("admin"))
{
throw new UnauthorizedAccessException("The current user isn't an admin");
}
}
}
Le code précédent capture fréquemment une valeur Null ou incorrecte HttpContext
dans le constructeur.
Procédez comme suit : Dans l’exemple suivant :
HttpContext
au bon moment et recherche null
.public class MyGoodType
{
private readonly IHttpContextAccessor _accessor;
public MyGoodType(IHttpContextAccessor accessor)
{
_accessor = accessor;
}
public void CheckAdmin()
{
var context = _accessor.HttpContext;
if (context != null && !context.User.IsInRole("admin"))
{
throw new UnauthorizedAccessException("The current user isn't an admin");
}
}
}
HttpContext
n’est pas thread‑safe. Accéder à HttpContext
à partir de plusieurs threads en parallèle peut entraîner des comportements inattendus, comme le serveur qui cesse de répondre, des plantages et une altération des données.
Ne procédez pas comme suit : l’exemple suivant effectue trois requêtes parallèles et journalise le chemin d’accès de la requête entrante avant et après la requête HTTP sortante. Le chemin de la requête est accessible à partir de plusieurs threads, potentiellement en parallèle.
public class AsyncBadSearchController : Controller
{
[HttpGet("/search")]
public async Task<SearchResults> Get(string query)
{
var query1 = SearchAsync(SearchEngine.Google, query);
var query2 = SearchAsync(SearchEngine.Bing, query);
var query3 = SearchAsync(SearchEngine.DuckDuckGo, query);
await Task.WhenAll(query1, query2, query3);
var results1 = await query1;
var results2 = await query2;
var results3 = await query3;
return SearchResults.Combine(results1, results2, results3);
}
private async Task<SearchResults> SearchAsync(SearchEngine engine, string query)
{
var searchResults = _searchService.Empty();
try
{
_logger.LogInformation("Starting search query from {path}.",
HttpContext.Request.Path);
searchResults = _searchService.Search(engine, query);
_logger.LogInformation("Finishing search query from {path}.",
HttpContext.Request.Path);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed query from {path}",
HttpContext.Request.Path);
}
return await searchResults;
}
Procédez comme suit : l’exemple suivant copie toutes les données de la requête entrante avant d’effectuer les trois requêtes parallèles.
public class AsyncGoodSearchController : Controller
{
[HttpGet("/search")]
public async Task<SearchResults> Get(string query)
{
string path = HttpContext.Request.Path;
var query1 = SearchAsync(SearchEngine.Google, query,
path);
var query2 = SearchAsync(SearchEngine.Bing, query, path);
var query3 = SearchAsync(SearchEngine.DuckDuckGo, query, path);
await Task.WhenAll(query1, query2, query3);
var results1 = await query1;
var results2 = await query2;
var results3 = await query3;
return SearchResults.Combine(results1, results2, results3);
}
private async Task<SearchResults> SearchAsync(SearchEngine engine, string query,
string path)
{
var searchResults = _searchService.Empty();
try
{
_logger.LogInformation("Starting search query from {path}.",
path);
searchResults = await _searchService.SearchAsync(engine, query);
_logger.LogInformation("Finishing search query from {path}.", path);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed query from {path}", path);
}
return await searchResults;
}
HttpContext
est valide uniquement tant qu’il existe une requête HTTP active dans le pipeline ASP.NET Core. L’ensemble du pipeline ASP.NET Core est une chaîne asynchrone de délégués qui exécute chaque requête. Lorsque le Task
retourné à partir de cette chaîne se termine, le HttpContext
est recyclé.
Ne procédez pas comme suit : l’exemple suivant utilise async void
, ce qui termine la requête HTTP lorsque la première await
est atteinte :
async void
est TOUJOURS une mauvaise pratique dans les applications ASP.NET Core.HttpResponse
une fois la requête HTTP terminée.public class AsyncBadVoidController : Controller
{
[HttpGet("/async")]
public async void Get()
{
await Task.Delay(1000);
// The following line will crash the process because of writing after the
// response has completed on a background thread. Notice async void Get()
await Response.WriteAsync("Hello World");
}
}
Procédez comme suit : l’exemple suivant retourne un Task
à l’infrastructure, de sorte que la requête HTTP ne se termine pas tant que l’action n’est pas terminée.
public class AsyncGoodTaskController : Controller
{
[HttpGet("/async")]
public async Task Get()
{
await Task.Delay(1000);
await Response.WriteAsync("Hello World");
}
}
Ne procédez pas comme suit : l’exemple suivant montre qu’une fermeture capture le HttpContext
à partir de la propriété Controller
. Il s’agit d’une mauvaise pratique, car l’élément de travail pourrait :
HttpContext
.[HttpGet("/fire-and-forget-1")]
public IActionResult BadFireAndForget()
{
_ = Task.Run(async () =>
{
await Task.Delay(1000);
var path = HttpContext.Request.Path;
Log(path);
});
return Accepted();
}
Procédez comme suit : Dans l’exemple suivant :
[HttpGet("/fire-and-forget-3")]
public IActionResult GoodFireAndForget()
{
string path = HttpContext.Request.Path;
_ = Task.Run(async () =>
{
await Task.Delay(1000);
Log(path);
});
return Accepted();
}
Les tâches en arrière-plan doivent être implémentées en tant que services hébergés. Pour plus d’informations, consultez Tâches en arrière-plan avec services hébergés.
Ne procédez pas comme suit : l’exemple suivant montre qu’une fermeture capture le DbContext
à partir du paramètre d’action Controller
. C’est une mauvaise pratique. L’élément de travail peut s’exécuter en dehors de l’étendue de la requête. Le ContosoDbContext
est étendu à la requête, ce qui entraîne un ObjectDisposedException
.
[HttpGet("/fire-and-forget-1")]
public IActionResult FireAndForget1([FromServices]ContosoDbContext context)
{
_ = Task.Run(async () =>
{
await Task.Delay(1000);
context.Contoso.Add(new Contoso());
await context.SaveChangesAsync();
});
return Accepted();
}
Procédez comme suit : Dans l’exemple suivant :
IServiceScopeFactory
est un singleton.ContosoDbContext
à partir de la requête entrante.[HttpGet("/fire-and-forget-3")]
public IActionResult FireAndForget3([FromServices]IServiceScopeFactory
serviceScopeFactory)
{
_ = Task.Run(async () =>
{
await Task.Delay(1000);
await using (var scope = serviceScopeFactory.CreateAsyncScope())
{
var context = scope.ServiceProvider.GetRequiredService<ContosoDbContext>();
context.Contoso.Add(new Contoso());
await context.SaveChangesAsync();
}
});
return Accepted();
}
Le code en surbrillance suivant :
ContosoDbContext
à partir de l’étendue correcte.[HttpGet("/fire-and-forget-3")]
public IActionResult FireAndForget3([FromServices]IServiceScopeFactory
serviceScopeFactory)
{
_ = Task.Run(async () =>
{
await Task.Delay(1000);
await using (var scope = serviceScopeFactory.CreateAsyncScope())
{
var context = scope.ServiceProvider.GetRequiredService<ContosoDbContext>();
context.Contoso.Add(new Contoso());
await context.SaveChangesAsync();
}
});
return Accepted();
}
ASP.NET Core ne met pas en mémoire tampon le corps de la réponse HTTP. La première fois que la réponse est écrite :
Ne procédez pas comme suit : le code suivant tente d’ajouter des en-têtes de réponse une fois que la réponse a déjà démarré :
app.Use(async (context, next) =>
{
await next();
context.Response.Headers["test"] = "test value";
});
Dans le code précédent, context.Response.Headers["test"] = "test value";
lève une exception si next()
a écrit dans la réponse.
Procédez comme suit : l’exemple suivant vérifie si la réponse HTTP a démarré avant de modifier les en-têtes.
app.Use(async (context, next) =>
{
await next();
if (!context.Response.HasStarted)
{
context.Response.Headers["test"] = "test value";
}
});
Procédez comme suit : l’exemple suivant utilise HttpResponse.OnStarting
pour définir les en-têtes avant que les en-têtes de réponse ne soient vidés sur le client.
Vérifier si la réponse n’a pas démarré permet d’inscrire un rappel qui sera appelé juste avant l’écriture des en-têtes de réponse. Vérifier si la réponse n’a pas démarré :
app.Use(async (context, next) =>
{
context.Response.OnStarting(() =>
{
context.Response.Headers["someheader"] = "somevalue";
return Task.CompletedTask;
});
await next();
});
Les composants s’attendent à être appelés uniquement s’ils peuvent gérer et modifier la réponse.
En utilisant l’hébergement in-process, une application ASP.NET Core s’exécute dans le même processus que son processus de travail IIS. L’hébergement in-process offre de meilleures performances que l’hébergement out-of-process parce que les requêtes n’ont pas de proxy sur l’adaptateur de bouclage. L’adaptateur de bouclage est une interface réseau qui retourne le trafic réseau sortant vers le même ordinateur. IIS s’occupe de la gestion des processus par l’intermédiaire du service d’activation des processus Windows (WAS).
Les projets sont par défaut le modèle d’hébergement in-process dans ASP.NET Core 3.0 et versions ultérieures.
Pour plus d’informations, consultez Héberger ASP.NET Core sur Windows avec IIS
HttpRequest.ContentLength
est nul si l’en-tête Content-Length
n’est pas reçu. Dans ce cas, nul signifie que la longueur du corps de la requête n’est pas connue ; cela ne signifie pas que la longueur est égale à zéro. Étant donné que toutes les comparaisons avec nul (sauf ==
) reviennent fausses, la comparaison Request.ContentLength > 1024
, par exemple, peut renvoyer false
lorsque la taille du corps de la requête est supérieure à 1024. Ne pas le savoir peut entraîner des failles de sécurité dans les applications. Vous pensez peut-être que vous vous protégez contre les demandes trop volumineuses alors que ce n’est pas le cas.
Pour plus d’informations, consultez cette question sur StackOverflow.
Pour obtenir des conseils sur la création d’une application ASP.NET Core fiable, sécurisée, performante, testable et évolutive, consultez Modèles d’application web d’entreprise. Un exemple complet d’application web de qualité de production qui implémente les modèles est disponible.
Commentaires sur ASP.NET Core
ASP.NET Core est un projet open source. Sélectionnez un lien pour fournir des commentaires :
Événements
Championnats du monde Power BI DataViz
14 févr., 16 h - 31 mars, 16 h
Avec 4 chances d’entrer, vous pourriez gagner un package de conférence et le rendre à la Live Grand Finale à Las Vegas
En savoir plus