Les middlewares dans une application ASP.NET Core

Les middlewares correspondent à des portions de code qui peuvent être exécutées lorsqu’une requête HTTP est reçue par une application ASP.NET Core. Ces portions de code sont exécutées successivement. Lorsqu’un middleware écrit une réponse correspondant à la requête, les middlewares suivants ne sont plus exécutés. Le terme middleware était déjà utilisé avec Owin et ASP.NET MVC (utilisant le framework .NET et spécifique aux plateformes Windows). Ils correspondent au même concept en ASP.NET Core (utilisant .NET Core et multi-plateforme). Le terme middleware est utilisé car il s’agit de portions de code placées entre la partie recevant les requêtes et le code métier se trouvant, par exemple, dans les controllers.

Ainsi lorsqu’une requête HTTP parvient à l’application web, les portions de code correspondant aux middlewares vont être exécutées successivement jusqu’à ce qu’un des middlewares écrive la réponse. L’appel successif des différents middlewares s’appelle un pipeline de middlewares. Les middlewares sont ordonnés dans le pipeline et ils sont exécutés dans le même ordre.

Le grand intérêt des middlewares est qu’ils offrent une grande flexiblité puisqu’ils sont tous capables de répondre à une requête ou d’effectuer un traitement spécifique sur la requête comme par exemple:

  • Effectuer des traitements d’authentification,
  • Logguer des informations concernant la requête et/ou la réponse correspondante,
  • Gérer les exceptions éventuelles,
  • Etc…

Avec ASP.NET Core, il est possible d’évoquer l’exécution des middlewares en utilisant différentes méthodes:

  • Appeler une portion de code sous forme d’un delegate ou
  • Appeler du code se trouvant dans une classe spécifique.

Dans un 1er temps, on expliquera les différentes méthodes pour définir un middleware en utilisant des delegate. Dans un 2e temps, on explicitera la méthode pour configurer un middleware se trouvant dans une classe particulière. Enfin, on indiquera quelques middlewares usuels.

Comme pour les articles précédents concernant ASP.NET Core, le but de cet article est de complémenter la documentation officielle (cf. ASP.NET Core Middleware) en passant en revue tous les éléments d’implémentation concernant les middlewares.

Pour illustrer les différents éléments de configuration, on peut se servir d’un exemple simple d’une API ASP.NET Core comportant quelques controller de façon à effectuer des requêtes HTTP:

Middlewares sous forme de “delegate”

Les middlewares de ce type se configurent dans la fonction StartUp.Configure().

Prérequis: installation de lognet

En préembule, on effectue l’installation de log4net car les exemples dans cet article l’utilise. On peut l’installer en effectuant les étapes suivantes:

  1. Exécutant la ligne suivante:
    user@debian:~/% dotnet add webapi_example/WebApi.csproj package log4net
  2. On ajoute un fichier de configuration nommé log4net.config avec le contenu suivant:
    <log4net> 
        <appender name="Console" type="log4net.Appender.ConsoleAppender"> 
            <layout type="log4net.Layout.PatternLayout"> 
                <!-- Pattern to output the caller's file name and line number --> 
                <conversionPattern value="%date %5level [%thread] - %message%newline" /> 
            </layout> 
        </appender> 
         
        <appender name="RollingFile" type="log4net.Appender.RollingFileAppender"> 
            <file value="Logs/webapi.log" /> 
            <appendToFile value="true" /> 
            <maximumFileSize value="100KB" /> 
            <maxSizeRollBackups value="2" /> 
    
            <layout type="log4net.Layout.PatternLayout"> 
                <conversionPattern value="%date %level %thread %logger - %message%newline" /> 
            </layout> 
        </appender> 
         
        <root> 
            <level value="DEBUG" /> 
            <appender-ref ref="Console" /> 
            <appender-ref ref="RollingFile" /> 
        </root> 
    </log4net>
    

    Cette configuration permettra de généer des fichiers de log dans le répertoire Logs/.

  3. On prends en compte la configuration en ajoutant les lignes suivantes dans StartUp.Configure():
    var configFile = Path.Combine(env.ContentRootPath, "log4net.config"); 
    var repository = LogManager.GetRepository(Assembly.GetEntryAssembly()); 
    XmlConfigurator.Configure(repository, new FileInfo(configFile));
    

Deux méthodes permettent de rajouter des middlewares sous forme de delegate:

  • IApplicationBuilder.Use()
  • IApplicationBuilder.Map()

IApplicationBuilder.Use()

Cette méthode permet d’enregistrer un middleware dans le pipeline en passant par un delegate. La spécificité de IApplicationBuilder.Use() est d’écrire du code avant et après avoir invoquer le middleware suivant dans le pipeline.

On l’utilise dans la méthode Startup.Configure():

public void Configure(IApplicationBuilder app, IHostingEnvironment env) 
{ 
    // ...
    app.Use((ntext, next) => { 
        // Code du middleware avant d'invoquer le middleware suivant

        next.Invoke(); // Permet d'appeler le middleware suivant 

        // Code exécuté après avoir invoqué le middleware suivant
        return Task.CompletedTask; 
    }); 

    // ... 
}

Par exemple pour logguer un message avant et après exécution du middleware suivant, on peut écrire:

this.logger = LogManager.GetLogger(typeof(Startup)); 
app.Use((context, next) => { 
    logger.Info("Invoking next middleware..."); 

    next.Invoke(); // Appelle le middleware suivant 

    logger.Info("Invoked."); 

    return Task.CompletedTask; 
});
Le middleware MVC n’appelle pas les middlewares suivants dans le pipeline

Si on configure un middleware après app.UseMvc(), il ne sera jamais appelé:

public void Configure(IApplicationBuilder app, IHostingEnvironment env) 
{ 
    // ... 

    app.UseMvc();

    // Ce middleware ne sera jamais appelé 
    app.Use((context, next) => { 
        next.Invoke(); 

        return Task.CompletedTask; 
    }); 
}

Il faut configurer le middleware avant app.UseMvc() car MVC est un middleware qui n’appelle pas de middleware suivant:

public void Configure(IApplicationBuilder app, IHostingEnvironment env) 
{ 
    // ... 

    app.Use((context, next) => { 
        next.Invoke(); 

        return Task.CompletedTask; 
    }); 

    app.UseMvc();
}

Avec async/await

La notation précédente permet d’appeler un middleware de façon synchrone. On peut utiliser une notation plus adaptée avec async/await.

Par exemple:

app.Use(async (context, next) => { 
    logger.Info("Invoking next middleware..."); 

    await next.Invoke(); // Appelle le middleware suivant 

    logger.Info("Invoked."); 
});
Ne pas répondre à une requête plusieurs fois

Si on écrit la réponse à une requête, on ne peut pas l’écrire une 2e fois. L’écriture de la réponse se fait sous forme d’un stream. Une exception est lancée si on tente d’écrire une réponse à plusieurs reprises.

Par exemple si on écrit:

app.Use(async (context, next) => { 
    // Appelle le middleware suivant qui est MVC 

    await next.Invoke(); // MVC écrit une réponse une 1ère fois 

    // Permet d'envoyer une réponse vide 
    byte[] data = Encoding.UTF8.GetBytes("{}"); 
    context.Response.ContentType = "application/json"; 

    // Une réponse est écrite une 2e fois 
    await context.Response.Body.WriteAsync(data, 0, data.Length); 
}); 

app.UseMvc();

On obtient une exception de ce type car on écrit plusieurs fois une réponse à une requête:

fail: Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware[1] 
      An unhandled exception has occurred while executing the request. 
System.InvalidOperationException: Headers are read-only, response has already started. 

Il faut donc être vigilant dans l’ordre d’exécution des middlewares dans le pipeline et savoir si un middleware dont l’exécution a déjà été effectué a déjà écrit une réponse.

Pour vérifier si une réponse est en cours d’écriture ou si elle a déjà été écrite, on peut utiliser la propriété:

IHttpContext.Response.HasStarted

Exécuter un “middleware” dans une fonction séparée

Au lieu d’utiliser un delegate dans le corps de la méthode Startup.Configure(), on peut aussi exécuter une méthode séparée:

public void Configure(IApplicationBuilder app, IHostingEnvironment env) 
{ 
    // ... 

    app.Use(this.LoggingMiddlewareAsync); 

    // ... 
} 

private async Task LoggingMiddlewareAsync(HttpContext context, Func<Task> next) 
{ 
    this.logger.Info("Executing custom middleware..."); 

    await next.Invoke(); 

    this.logger.Info("Custom middleware executed."); 
} 

Exemple de “pipeline de middlewares”

Dans un pipeline de middleware, les middlewares sont appelés successivement dans l’ordre dans lequel ils ont été configurés:

  • Ordre des requests: lorsqu’un requête arrive, elle traverse tous les middlewares jusqu’à ce que l’un d’entre eux réponde.
  • Ordre des responses: lorsqu’une réponse est effectuée, les middlewares sont invoqués dans l’ordre inverse.

On se propose de montrer un exemple de pipeline de middlewares de façon à voir l’enchainement des appels. Dans cet exemple, les middlewares LoggingMiddlewareAsync1, LoggingMiddlewareAsync2, LoggingMiddlewareAsync3 et MVC sont executés successivement. Seul le middleware MVC écrit la réponse.

Exemple de pipeline de middlewares

Pour exécuter cet exemple:

  1. Il faut cloner le repository GitHub suivant: https://github.com/msoft/webapi_example/tree/swagger.
  2. Dans la méthode Startup.Configure() on configure les middlewares de cette façon:
    public void Configure(IApplicationBuilder app, IHostingEnvironment env) 
    { 
        // Configuration de log4net 
        var configFile = Path.Combine(env.ContentRootPath, "log4net.config"); 
        var repository = LogManager.GetRepository(Assembly.GetEntryAssembly()); 
        XmlConfigurator.Configure(repository, new FileInfo(configFile)); 
    
        this.logger = LogManager.GetLogger(typeof(Startup)); 
    
        // Configuration de Swagger 
        app.UseSwagger(); 
    
        app.UseSwaggerUI(c => 
        { 
            c.SwaggerEndpoint("/swagger/v1/swagger.json", "Pizza API V1"); 
        }); 
    
        // On configure 3 middlewares 
        app.Use(this.LoggingMiddlewareAsync1); 
        app.Use(this.LoggingMiddlewareAsync2); 
        app.Use(this.LoggingMiddlewareAsync3);
    
        // Ajout du middleware MVC 
        app.UseMvc();  
    } 
    

    Les middlewares sont implémentés de la même façon:

    private async Task LoggingMiddlewareAsync1(HttpContext context, Func<Task> next) 
    { 
        this.logger.Info("Executing 1st custom middleware..."); 
        await next.Invoke(); 
        this.logger.Info("1st custom middleware executed."); 
    }
    
    private async Task LoggingMiddlewareAsync2(HttpContext context, Func<Task> next) 
    { 
        this.logger.Info("Executing 2nd custom middleware..."); 
        await next.Invoke(); 
        this.logger.Info("2nd custom middleware executed."); 
    } 
    
    private async Task LoggingMiddlewareAsync3(HttpContext context, Func<Task> next) 
    { 
        this.logger.Info("Executing 3rd custom middleware..."); 
        await next.Invoke(); 
        this.logger.Info("3rd custom middleware executed."); 
    }
    
  3. On exécute le projet en exécutant successivement les instructions suivantes:
    user@debian:~/webapi_example% dotnet build
    user@debian:~/webapi_example% dotnet run
    
  4. Il faut se connecter à l’adresse http://localhost:5000/swagger/index.html avec un browser pour atteindre l’interface de Swagger.

    Si on exécute des méthodes du controller PizzaOrder comme par exemple la fonction GET /api/PizzaOrder, on peut voir les messages logs suivants:

    2019-04-13 01:52:49,674  INFO [9] - Executing 1st custom middleware... 
    2019-04-13 01:52:49,731  INFO [9] - Executing 2nd custom middleware... 
    2019-04-13 01:52:49,739  INFO [9] - Executing 3rd custom middleware... 
    2019-04-13 01:52:49,962  INFO [9] - 3rd custom middleware executed. 
    2019-04-13 01:52:49,963  INFO [9] - 2nd custom middleware executed. 
    2019-04-13 01:52:49,963  INFO [9] - 1st custom middleware executed. 
    

Exemple en stoppant l’exécution dans le “pipeline”

On modifie l’exemple précédent en introduisant un nouveau middleware qui va stopper l’exécution du pipeline. Le middleware StoppingMiddlewareAsync va stopper l’exécution du pipeline en écrivant une réponse à la requête et en n’appelant pas le middleware suivant.

Exemple de middleware interceptant une requête

En modifiant le code précédent, on obtient:

public void Configure(IApplicationBuilder app, IHostingEnvironment env) 
{ 
    // ... 
    app.Use(this.LoggingMiddlewareAsync1); 
    app.Use(this.StoppingMiddlewareAsync);
    app.Use(this.LoggingMiddlewareAsync2); 
    app.Use(this.LoggingMiddlewareAsync3); 

    // ...
}

Avec:

private async Task StoppingMiddlewareAsync(HttpContext context, Func<Task> next) 
{ 
    this.logger.Info("Invoking stopping middleware..."); 

    var emptyJsonString = "{}"; 
    context.Response.ContentType = new System.Net.Http.Headers
        .MediaTypeHeaderValue("application/json").ToString(); 
    await context.Response.WriteAsync(emptyJsonString, Encoding.UTF8); 

    this.logger.Info("Stopping middleware invoked."); 
}

StoppingMiddlewareAsync() n’exécute pas le middleware suivant et écrit une réponse vide. Les middlewares LoggingMiddlewareAsync2, LoggingMiddlewareAsync3 et MVC ne seront pas appelés. On peut le voir en regardant les logs générés:

2019-04-13 02:02:36,491  INFO [5] - Executing 1st custom middleware... 
2019-04-13 02:02:36,520  INFO [5] - Invoking stopping middleware... 
2019-04-13 02:02:36,529  INFO [5] - Stopping middleware invoked. 
2019-04-13 02:02:36,529  INFO [5] - 1st custom middleware executed. 

IApplicationBuilder.Run()

Cette méthode permet d’enregistrer un middleware dans le pipeline. Contrairement à IApplicationBuilder.Use(), IApplicationBuilder.Run() ne donne pas la possibilité d’appeler le middleware suivant.

On peut utiliser IApplicationBuilder.Run() de cette façon:

public void Configure(IApplicationBuilder app, IHostingEnvironment env) 
{ 
    // ... 

    app.Run(async context => { 
        await // ... 
    }); 

    // ... 
} 

Par exemple:

app.Run(async context => { 
    context.Response.ContentType = new System.Net.Http.Headers
        .MediaTypeHeaderValue("application/json").ToString(); 
    await context.Response.WriteAsync("{}", Encoding.UTF8); 
}); 

Exemple de “pipeline de middlewares” avec IApplicationBuilder.Run()

Comme il n’est pas possible d’appeler le middleware suivant avec IApplicationBuilder.Run(), l’exécution du pipeline s’arrête. Si on reprends l’exemple précédent et qu’on modifie l’ajout des middlewares de cette façon:

app.Use(this.LoggingMiddlewareAsync1); 
app.Run(this.StoppingMiddlewareAsync); 
app.Use(this.LoggingMiddlewareAsync2); 
app.Use(this.LoggingMiddlewareAsync3); 

Avec:

private async Task StoppingMiddlewareAsync(HttpContext context) 
{ 
    this.logger.Info("Invoking stopping middleware..."); 

    context.Response.ContentType = new System.Net.Http.Headers
        .MediaTypeHeaderValue("application/json").ToString(); 
    await context.Response.WriteAsync("{}", Encoding.UTF8); 

    this.logger.Info("Stopping middleware invoked."); 
} 

Si on effectue une requête, on peut voir que les messages de logs indiquent que les middlewares suivant StoppingMiddlewareAsync n’ont pas été exécutés:

2019-04-13 02:27:01,261  INFO [5] - Executing 1st custom middleware... 
2019-04-13 02:27:01,327  INFO [5] - Invoking stopping middleware... 
2019-04-13 02:27:01,366  INFO [5] - Stopping middleware invoked. 
2019-04-13 02:27:01,368  INFO [5] - 1st custom middleware executed.

IApplicationBuilder.Map()

Cette méthode permet de rajouter une condition concernant l’URL de la requête pour appeler un middleware. Ainsi le middleware sera appelé seulement si la condition est vraie. La condition porte sur le chemin de la requête, si l’URL de la requête contient un chemin particulier alors le middleware sera exécuté.

Dans la méthode Startup.Configure():

public void Configure(IApplicationBuilder app, IHostingEnvironment env) 
{ 
    // ... 

    app.Map(<condition URL>, builder => { 
        // Configuration des middlewares à exécuter 
    }); 

    // ... 
}

L’intérêt de IApplicationBuilder.Map() est de pouvoir choisir d’exzcuter des branches différentes du pipeline.

Par exemple, si on souhaite exécuter le middleware LoggingMiddlewareAsync1 quand l’URL de la requête contient "/api/PizzaFlavour", on écrit:

app.Map("/api/PizzaFlavour", builder => { 
    builder.Use(this.LoggingMiddlewareAsync1); 
}); 

Avec:

private async Task LoggingMiddlewareAsync1(HttpContext context, Func<Task> next) 
{ 
    this.logger.Info("Executing 1st custom middleware..."); 
    await next.Invoke(); 
    this.logger.Info("1st custom middleware executed."); 
} 

Ainsi si on exécute GET /api/PizzaOrder, le middleware LoggingMiddlewareAsync1 n’est pas appelé:

Request starting HTTP/1.1 GET http://localhost:5000/api/PizzaOrder 

Si on exécute GET /api/PizzaFlavour, le middleware est appelé et on obtient:

Request starting HTTP/1.1 GET http://localhost:5000/api/PizzaFlavour 
2019-04-13 02:43:28,284  INFO [6] - Executing 1st custom middleware... 
2019-04-13 02:43:28,313  INFO [6] - 1st custom middleware executed. 

IApplicationBuilder.MapWhen()

Cette méthode permet d’indiquer une condition pour exécuter la configuration de middlewares. La condition est indiquée sous forme d’une expression lambda utilisant le contexte de la requête.

Dans la méthode Startup.Configure():

public void Configure(IApplicationBuilder app, IHostingEnvironment env) 
{ 
    // ... 

    app.MapWhen( 
        context => {  
            // Code indiquant le contexte d'exécution }, 
        builder => { 
            // Configuration des middlewares à exécuter 
    }); 

    // ... 
}

Par exemple pour configurer un “health check” qui répondrait "OK" si l’URL de la requête commence par "/health", on pourrait écrire:

app.MapWhen( 
    context => context.Request.Path.StartsWithSegments("/health"), 
    builder => { 
        builder.Use(async (context, next) => { 

        context.Response.ContentType = new System.Net.Http.Headers
            .MediaTypeHeaderValue("application/json").ToString(); 

        await context.Response.WriteAsync("{ \"health\": \"OK.\" }", Encoding.UTF8); 
    }); 

Middlewares sous forme de classes

D’autres notations permettent de configurer des middlewares dans des classes séparées. Cette partie indique comment configurer ces middlewares.

IApplicationBuilder.UseMiddleware()

Utiliser IApplicationBuilder.UseMiddleware() permet de configurer un middleware autorisé à exécuter le middleware suivant dans le pipeline (comme pour IApplicationBuilder.Use()). Pour utiliser cette méthode, il faut définir un middleware dans une classe contenant une méthode avec la signature suivante:

public async Task Invoke(HttpContent context) 
{ 
    // ... 
} 

Par exemple, pour définir un middleware permettant de mesurer le temps d’exécution d’une requête par le middleware suivant de cette façon:

public class LoggingMiddleware 
{ 
    private readonly RequestDelegate next; 
    private readonly ILog logger = LogManager
        .GetLogger(typeof(LoggingMiddleware)); 
    private readonly int instanceHashCode; 

    public LoggingMiddleware(RequestDelegate next) 
    { 
        this.next = next; 
        this.instanceHashCode = this.GetHashCode(); 
    } 

    public async Task Invoke(HttpContext context)
    { 
        // Code exécuté avant le middleware suivant
        this.logger.InfoFormat("Executing logging middleware (HashCode:{0})...", 
        this.instanceHashCode); 

        var stopWatch = new Stopwatch(); 
        stopWatch.Start(); 

        await this.next(context); // Appel au middleware suivant 

        // Code exécuté après le middleware suivant
        stopWatch.Stop(); 
        var executionTime = stopWatch.Elapsed; 

        this.logger.InfoFormat("Logging middleware executed ({0} ms) (HashCode:{1}.",  
        executionTime.Milliseconds, this.instanceHashCode); 
    } 
} 

On configure ce middleware dans la méthode Startup.Configure() de cette façon:

app.UseMiddleware<LoggingMiddleware>();
Durée de vie du middleware

Un middleware ajouté de cette façon est instancié une seule fois et toutes les requêtes transitent à travers la même instance. On peut s’en apercevoir en regardant la valeur du “hash code” de la classe dans les logs:

2019-04-13 03:49:09,479  INFO [6] - Executing logging middleware (HashCode:2530563)... 
2019-04-13 03:49:09,855  INFO [6] - Logging middleware executed (309 ms) (HashCode:2530563).

Exemple avec une “factory”

Au lieu de rajouter un middleware directement comme précédemment, il existe une autre méthode permettant de rajouter une factory qui exécutera le code du middleware. L’intérêt d’utiliser une factory est de pouvoir contrôler sa durée de vie. En effet, pour configurer ce type d’objet, il faut d’abord le rajouter au container d’injection de dépendances, ce qui donne une flexibilité puisqu’on peut choisir le type d’enregistrement:

  • Transient (i.e. éphémère): les objets enregistrés de cette façon sont instanciés à chaque fois qu’ils sont injectés.
  • Scoped: la même instance de l’objet sera utilisée dans le cadre d’une même requête HTTP. Ainsi une nouvelle instance est créée pour chaque requête web.
  • Singleton: les objets de ce type sont créés une seule fois et la même instance est utilisée pendant toute la durée de vie.

Pour plus d’informations sur la configuration d’objets dans le container d’injection de dépendances: L’injection de dépendances dans une application ASP.NET Core.

Le 2e intérêt d’utiliser une factory est que le code invoqué du middleware est exécuté au moment de la requête et non au moment d’exécuter la méthode Startup.ConfigureServices(). Cette flexibilité permet, par exemple, d’injecter des objets qui n’existe pas encore dans le container au moment de l’exécution de Startup.ConfigureServices().

Par exemple, pour implémenter une factory permettant de logguer des messages avant et après avoir appelé le middleware suivant dans le pipeline:

  1. On commence par implémenter cette factory en satisfaisant l’interface Microsoft.AspNetCore.Http.IMiddleware:
    public class FactoryActivatedMiddleware : IMiddleware
    { 
        private readonly ILog logger = LogManager.GetLogger(typeof(FactoryActivatedMiddleware)); 
        private readonly int instanceHashCode; 
    
        public FactoryActivatedMiddleware() 
        { 
            this.instanceHashCode = this.GetHashCode(); 
        } 
    
        public async Task InvokeAsync(HttpContext context, RequestDelegate next)
        { 
            this.logger.InfoFormat("Executing factory activated logging middleware (HashCode: {0})...", 
            this.instanceHashCode); 
    
            await next(context); 
    
            this.logger.InfoFormat("Factory activated logging middleware executed (HashCode:{0}).", 
            this.instanceHashCode); 
        } 
    }
    
  2. Il faut ensuite l’ajouter au container d’injection de dépendances dans la méthode Startup.ConfigureServices():
    public void ConfigureServices(IServiceCollection services) 
    { 
        services.AddTransient<FactoryActivatedMiddleware>();
         
        // ... 
    } 
    
  3. On ajoute la factory dans le pipeline de middlewares dans la méthode Startup.Configure():
    public void Configure(IApplicationBuilder app, IHostingEnvironment env) 
    { 
        // ... 
    
        app.UseMiddleware<FactoryActivatedMiddleware>();
    
        // ... 
    } 
    
  4. Etant donné que la factory a été enregistrée dans le container d’injection de dépendances avec la méthode IServiceCollection.AddTransient(), chaque nouvelle requête provoque la création d’une nouvelle instance de la factory. Ainsi à chaque requête, le “hash code” de l’instance sera différent puisque l’instance n’est pas la même:
    • 1ère requête:
      2019-04-13 04:14:57,900  INFO [21] - Executing factory activated logging 
          middleware (HashCode: 43694208)... 
      2019-04-13 04:14:57,900  INFO [21] - Factory activated logging 
          middleware executed (HashCode:43694208).
      
    • 2e requête:
      2019-04-13 04:16:52,756  INFO [23] - Executing factory activated logging 
          middleware (HashCode: 18991046)... 
      2019-04-13 04:16:52,757  INFO [23] - Factory activated logging 
          middleware executed (HashCode:18991046). 
      

Injection de dépendances

L’injection de dépendances est supportée pour les middlewares définis dans des classes séparées ou en utilisant une factory. L’injection d’objets peut se faire:

  • A l’instanciation du middleware si on utilise IApplicationBuilder.UseMiddleware() ou
  • A l’invocation du middleware dans une factory si on utilise IApplicationBuilder.UseMiddleware<Type de la factory>().

Par exemple, pour illustrer l’injection d’un objet dans un middleware:

  • On va définir un service permettant de créer des loggers LoggerFactoryService,
  • Enregistrer ce service dans le container d’injection de dépendances
  • Injecter ce service dans un middleware

Ainsi:

  1. On considère l’interface ILoggerFactoryService qui l’on définit de cette façon:
    public interface ILoggerFactoryService 
    { 
        ILog GetNewLogger(Type callerType); 
    } 
    
  2. On définit ensuite la classe LoggerFactoryService satisfaisant l’interface ILoggerFactoryService. Cette classe permet de créer un logger:
    internal class LoggerFactoryService : ILoggerFactoryService 
    { 
        public ILog GetNewLogger(Type callerType) 
        { 
            return LogManager.GetLogger(callerType); 
        } 
    } 
    
  3. On enregistre cette classe dans le container d’injection de dépendances dans la méthode Startup.ConfigureServices():
    public void ConfigureServices(IServiceCollection services) 
    { 
        // ... 
    
        services.AddSingleton<ILoggerFactoryService, LoggerFactoryService>();
    
        // ... 
    }
    
  4. On définit le middleware suivant permettant de répondre à un “health check”:
    public class HealthCheckMiddleware 
    { 
        private readonly RequestDelegate next; 
        private readonly ILog logger; 
        private readonly int instanceHashCode; 
    
        public HealthCheckMiddleware(RequestDelegate next, 
            ILoggerFactoryService loggerFactory) 
        { 
            this.next = next; 
            this.instanceHashCode = this.GetHashCode(); 
            this.logger = loggerFactory.GetNewLogger(typeof(HealthCheckMiddleware)); 
        } 
    
        public async Task Invoke(HttpContext context) 
        { 
            this.logger.InfoFormat("Executing health check middleware (HashCode:{0})...", 
            this.instanceHashCode); 
    
            context.Response.ContentType = new System.Net.Http.Headers
                .MediaTypeHeaderValue("application/json").ToString(); 
            await context.Response.WriteAsync("{ \"health\": \"OK.\" }", Encoding.UTF8); 
    
            this.logger.InfoFormat("Health check middleware executed (HashCode:{0}.",  
            this.instanceHashCode); 
        } 
    }
    

    Ce middleware utilise ILoggerFactoryService pour instancier un nouveau logger. ILoggerFactoryService est injecté par le constructeur dans le middleware

  5. On ajoute le middleware au pipeline dans la méthode Startup.Configure() en utilisant app.MapWhen() de façon à exécuter le middleware quand l’URL de la requête contient "/health":
    public void Configure(IApplicationBuilder app, IHostingEnvironment env) 
    { 
        // ...  
    
        app.MapWhen( 
            context => context.Request.Path.StartsWithSegments("/health"),
            builder => { 
                builder.UseMiddleware<HealthCheckMiddleware>(); 
        });  
    
        app.UseMvc();
    } 
    
  6. A l’exécution, on peut voir que ILoggerFactoryService est bien injecté dans le constructeur de HealthCheckMiddleware.

Cet exemple illustre l’injection de dépendances dans le constructeur du middleware. On peut aussi injecter des objets lors de l’exécution du code du middleware dans une factory.

Quelques middlewares usuels

Dans cette partie, on indique quelques middlewares:

Catégorie Appel du middleware Fonction Package NuGet Namespace
Autre app.UseWelcomePage() Permet d’afficher une page de bienvenu à la racine du service. Microsoft.AspNetCore.App
(pas nécessaire d’ajouter un autre package)
Microsoft.AspNetCore.Builder
Routing app.UseRouter() Permet de configurer le routage des requêtes (voir Le routage en ASP.NET Core en 5 min)
app.UseStaticFiles() Rend accessible des fichiers statiques (comme les fichiers CSS, les images ou des fichiers Javascripts).
app.UseHttpsRedirection() Effectue une redirection des requêtes HTTP vers HTTPS.
app.UseFileServer() Rend accessible tous les fichiers statiques mais ne permet l’exploration de répertoires.
app.UseDirectoryBrowser() Permet d’explorer les répertoires.
app.UseDefaultFiles() Redirige vers des pages index.html si elles sont présentes.
app.UseCors() Permet au browser d’effectuer des appels Cross Origin Resource Sharing.
Gestion d’erreurs app.UseDatabaseErrorPage() Permet de renvoyer le détail d’erreurs provenant d’une base dans le cas où on utilise EntityFramework.
app.UseExceptionHandler() Permet de configurer une page d’erreur personnalisée.
app.UseStatusCodePages()
app.UseStatusCodePagesWithRedirects()
app.UseStatusCodePagesWithReExecute()
Renvoie une réponse par défaut dans le cas où une requête est invalide avec une réponse entre 400 et 600.
app.UseDeveloperExceptionPage() Renvoie le détail d’une erreur dans le cas d’une exception.
Session app.UseSession() Permet l’utilisation de sessions pour garder en mémoire des données utilisateur ou des états de l’application entre plusieurs requêtes HTTP.
Documentation app.UseSwagger()
app.UseSwaggerUI()
Permet de documenter une API (voir Documenter une API Web ASP.NET Core avec Swagger) Swashbuckle.AspNetCore Swashbuckle.AspNetCore.Swagger

Pour conclure…

Comme on a pu le voir, les middlewares apportent une solution facile à mettre en œuvre pour effectuer une multitude de traitements sur les requêtes HTTP: logging, gestion des exceptions, outils de diagnostic, monitoring des performances etc… Ces middlewares apportent une flexibilité que l’on ne peut ignorer lors du développement d’une application ASP.NET Core.
D’autre part l’implémentation des middlewares en ASP.NET Core permet de facilement les configurer. Avec ASP.NET MVC, l’utilisation des middlewares OWIN était moins triviale, l’expérience acquise a probablement servi à améliorer les middlewares ASP.NET Core.
Enfin il faut penser à utiliser les middlewares “built-in” d’ASP.NET Core qui permettent de facilement apporter des fonctionnalités très utiles.

Références
Share on FacebookShare on Google+Tweet about this on TwitterShare on LinkedInEmail this to someonePrint this page

L’injection de dépendances dans une application ASP.NET Core

Contrairement à ASP.NET MVC, ASP.NET Core possède nativement un container d’injection de dépendances. Ce pattern est particulièrement utile pour facilement architecturer une application. Le but de cet article est, d’abord, de montrer comment configurer l’injection de dépendances dans une application ASP.NET Core avec le container natif. Il est possible de remplacer ce container natif par un autre container comme Autofac ou StructureMap. Dans un 2e temps, on va montrer comment configurer un container Autofac et enfin, indiquer quelques fonctionnalités intéressantes d’Autofac par rapport au container natif.


L’injection de dépendances en théorie

L’injection de dépendances est un design pattern permettant d’injecter des objets dans des classes à l’exécution. Le principal objectif est d’éviter à la classe d’instancier elle-même les objets dont elle pourrait avoir besoin. Quand une classe utilise un autre objet, elle devient dépendante de cet objet et possède une dépendance vers cet objet. Le fait d’instancier cette dépendance dans le classe qui la consomme introduit un fort couplage entre:

  • La classe consommatrice de l’objet et
  • L’objet instancié

Ce couplage peut devenir complexe si l’instanciation de l’objet nécessite l’instanciation d’autres objets ou s’il y a beaucoup d’objets à instancier. La classe consommatrice devient, ainsi, dépendante de tous les autres objets ce qui augmente encore le couplage entre tous ces objets. Par exemple, si on modifie le constructeur d’une classe, il faudra répercuter cette modification partout où ce constructeur est utilisé.

Pour éviter ces problèmes, le pattern injection de dépendances (cf. dependency injection) préconise d’injecter les dépendances d’une classe de façon à ce qu’elle ne les instancie pas elle-même.

Ainsi l’intérêt de l’injection de dépendances est:

  • De permettre un faible couplage puisqu’on réduit le nombre de dépendances entre les objets. Comme les dépendances sont injectées, si on modifie le constructeur d’une classe injectée, il ne sera pas nécessaire de répercuter la modification dans les classes qui l’utilise.
  • D’avoir une implémentation plus flexible puisqu’on peut plus facilement modifier les dépendances d’une classe.
  • D’être plus extensible puisqu’on peut ajouter de nouvelles fonctionnalité en fournissant des implémentations différentes à une classe.
  • De faciliter les tests unitaires de la classe puisqu’on peut plus facilement y injecter des mocks ou des stubs.
  • De faciliter la maintenabilité d’une application puisque les dépendances d’une classe apparaissent de façon plus évidente.

Quelques méthodes pour déléguer l’instanciation d’objets

Plusieurs approches permettent de déléguer l’instanciation à d’autres classes différentes de celles qui les consomment. Les patterns “Factory” et le pattern “Service Locator” sont quelques unes de ces approches.

Pattern “Factory” simple

Ce pattern introduit une factory pour déléguer la création de l’objet. La classe consommatrice crée donc la factory et crée l’objet consommé au moyen de cette factory.

Inconvénients

  • La complexité de ce modèle peut augmenter rapidement si la classe consommatrice doit créer beaucoup d’objets.
  • Si on doit créer des objets de type différent, il faudra créer les factory correspondantes. La logique de choix des factory reste dans la classe consommatrice. L’utilisation de plusieurs factories correspond au pattern “fabrique abstraite”.

Dans l’exemple suivant, on instancie une factory dans le constructeur pour qu’elle fournisse l’objet IDataService:

public class ConsumingController : Controller 
{ 
  private readonly IDataService dataService; 
 
  public ConsumingController() 
  { 
    var dependencyFactory = new DependencyFactory(); 
    this.dataService = dependencyFactory.CreateDataService(); 
  } 
 
  public ActionResult Index() 
  { 
    var model = new ConsumingControllerViewData<IEnumerable>(this.dataService.GetValues()) 
    { 
      Title = "Dependency values" 
    }; 
    return this.View(model); 
  } 
 
  // ... 
}

Pattern “Service Locator”

Le Service Locator se comporte comme un registre à qui on demande des objets. C’est le Service Locator qui va enregistrer l’objet et l’instancier. Pour obtenir l’instance, la classe consommatrice doit fournir le nom de l’objet et le type voulu en retour. En utilisant ce pattern, le Service Locator devient le responsable de la durée de vie de l’objet.

Inconvénients:
Les contraintes de ce pattern sont:

  • De maintenir une référence vers le Service Locator dans la classe consommatrice de façon y faire appel pour créer les objets.
  • De rendre implicite la dépendance vers l’objet consommée puisque la dépendance n’apparaît que dans le corps du constructeur.
  • Instancier les dépendances de cette façon complique les tests unitaires puisqu’il est plus compliqué d’injecter un mock de l’objet consommé.

Dans l’exemple suivant, le Service Locator est assuré par l’objet IServiceProvider:

public class ConsumingController : Controller 
{ 
  private readonly IDataService dataService; 
 
  public ConsumingController(IServiceProvider serviceProvider) 
  { 
    this.dataService = serviceProvider.GetRequiredService<IDataService>(); 
  } 
 
  public ActionResult Index() 
  { 
    var model = new ConsumingControllerViewData<IEnumerable>(this.dataService.GetValues()) 
    { 
      Title = "Dependency values" 
    }; 
    return this.View(model); 
  } 
 
  // ... 
}

Dans le cas où on utilise le Service Locator il faut privilégier l’instanciation des objets dans le constructeur de façon à rendre plus explicite les dépendances de la classe consommatrice.

Implémentations de l’injection de dépendances

L’implémentation la plus courante de l’injection de dépendances est par le constructeur. Ainsi on ne garde pas une référence vers un service locator ou vers une factory. De plus la gestion de la durée de vie de l’objet n’est pas effectuée par la classe consommatrice. On a une inversion de contrôle puisque l’objet est poussé vers la classe consommatrice sans que celle-ci ne l’instancie explicitement.

Un autre intérêt est que la classe consommatrice n’a pas de connaissances sur l’objet à créer puisque l’objet n’est manipulé qu’au moyen de son interface.

Dans l’exemple suivant, on injecte IDataService par le constructeur:

public class ConsumingController : Controller 
{ 
  private readonly IDataService dataService; 
 
  public ConsumingController(IDataService dataService) 
  { 
    this.dataService = dataService; 
  } 
 
  public ActionResult Index() 
  { 
    var model = new ConsumingControllerViewData<IEnumerable>(this.dataService.GetValues()) 
    { 
      Title = "Dependency values" 
    }; 
    return this.View(model); 
  } 
 
  // ... 
}

Dans le cas de l’injection de dépendances par le constructeur, une bonne pratique est d’enregistrer l’objet injecté dans un membre en lecture seule (i.e. avec le mot clé readonly).

Type d’injection

D’un point de vue général, il existe plusieurs types d’injection:

  • Injection par le constructeur: comme dans l’exemple précédent.
  • Injection en utilisant un accesseur: à utiliser dans le cas où la dépendance est optionnelle sinon privilégier l’injection par le constructeur.
  • Injection par appel à une méthode: cete méthode peut être utile quand il est nécessaire d’avoir un paramètre supplémentaire qui ne peut être passé dans le constructeur.

Container

Les frameworks d’injection de dépendances utilisent des containers pour ranger les informations concernant tous les objets de l’application. En fonction de ces informations, le container connaît les dépendances entre tous les objets.

Au lancement de l’application, il faut enregistrer les informations concernant les objet dans ce container. Par la suite, en fonction des besoins, ce container sera sollicité pour instancier les différents objets et les injecter dans les classes consommatrices.

C’est aussi le container qui, en fonction de l’enregistrement des objets et de leur utilisation, est capable de gèrer la durée de vie des objets qu’il a instancié.

Durée de vie des objets

Comme indiqué précédemment, la durée de vie des objets est gêré par le container en fonction de la façon dont les objets ont été enregistrés dans ce container:

  • Transient (i.e. éphémère): les objets enregistrés de cette façon sont instanciés à chaque fois qu’ils sont injectés.
  • Scoped: la même instance de l’objet sera utilisée dans le cadre d’une même requête HTTP. Ainsi une nouvelle instance est créée pour chaque requête web.
  • Singleton: les objets de ce type sont créés une seule fois et la même instance est utilisée pendant toute la durée de vie.

Le container garde une trace de tous les objets qu’il a créé, ainsi ces objets sont disposés quand leur durée de vie se termine:

  • Ainsi si l’objet disposé possède des dépendances, elles sont automatiquement disposées.
  • Si l’objet disposé implémente IDisposable alors la méthode IDisposable.Dispose() est exécutée.

Injection de dependances avec ASP.NET Core

Un exemple dans la branche dependency_injection du repository GitHub webapi_example permet d’illustrer les différents éléments de cet article.

Utiliser le container natif

Contrairement à ASP.NET MVC (reposant sur le framework .NET), ASP.NET Core (utilisant .NET Core) propose nativement la fonctionnalité d’injection de dépendances:

  • Le container natif d’ASP.NET Core propose les fonctionnalités de base pour l’enregistrement des objets et la résolution de dépendances.
  • Toutes les dépendances du framework comme le routage, les loggers ou la configuration sont déjà enregistrées dans le container. On peut ainsi y accéder sans les avoir explicitement enregistrés.
  • Seulement l’injection par le constructeur est supportée.

Pour utiliser le container, il faut ajouter la ligne services.AddMvc() dans le fichier Startup.cs d’un projet ASP.NET Core:

public class Startup 
{ 
    public Startup(IConfiguration configuration) 
    { 
        this.Configuration = configuration; 
    } 

    public IConfiguration Configuration { get; } 
    
    public void ConfigureServices(IServiceCollection services) 
    { 
        services.AddMvc(); 
    } 
}

services.AddMvc() permet d’enregistrer dans le container les services du framework.

Enregistrer explicitement des dépendances

L’enregistrement des objets dans le container se fait en fonction de la durée de vie souhaitée pour ces objets. L’enregistrement se fait dans ConfigureServices() de la classe Startup.cs:

public void ConfigureServices(IServiceCollection services) 
{ 
    services.AddMvc(); 

    services.AddTransient<IDataService, DataService>(); 
}

Dans le cadre de cet exemple, la classe DataService satisfait l’interface IDataService:

public class DataService : IDataService 
{ 
    // ... 
}

L’enregistrement se fait de cette façon:

  • Transient: pour enregistrer un objet pour qu’il soit instancié de façon éphémère quand il est injecté, il faut utiliser une des surchages suivantes:
    • La méthode la plus courante en utilisant le type de l’objet à enregistrer:
      services.AddTransient<IDataService, DataService>(); 
      services.AddTransient<typeof(IDataService), typeof(DataService)>();
      
    • En instanciant explicitement l’objet:
      services.AddTransient<IDataService>(s => new DataService());
      
  • Scope: pour enregistrer un objet pour que sa durée de vie corresponde à la durée de vie d’une requête HTTP:
    services.AddScope<IDataService, DataService>();
    
  • Singleton: pour enregistrer un objet et que la même instance soit utilisée tout au long de l’exécution de l’application:
    services.AddSingleton<IDataService, DataService>();

Dans la pratique:

  • Il faut privilégier l’enregistrement des objets avec AddTransient() de façon à ce qu’une nouvelle instance soit créée à chaque utilisation de ces objets. Ce type d’enregistrement permet d’éviter de gérer les problèmes d’accès concurrents et les fuites mémoires qui pourraient subvenir pour des objets qui ne sont pas correctement libérés.
  • Eviter d’utiliser des singletons car ils nécessitent de se préoccuper des problèmes d’accès concurrents s’ils sont utilisés au même moment par des objets différents. Ils peuvent être en outre à l’origine de fuites mémoires.
  • Un singleton ne doit pas dépendre d’objets enregistrés avec AddTransient() ou AddScoped(). Dans ce cas, les dépendances du singleton deviennent elles aussi des singletons. Le container natif d’ASP.NET Core lance une exception dans ce cas.

Dans le cas de l’exemple dans le repository GitHub webapi_example/dependency_injection, 2 services sont injectés avec la fonction IServiceCollection.AddSingleton() dans la méthode ConfigureServices() de la classe Startup:

public void ConfigureServices(IServiceCollection services) 
{ 
    services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2); 

    services.AddSingleton<IPizzaFlavourRepositoryService>(new PizzaFlavourRepositoryService()); 
    services.AddSingleton<IPizzaOrderRepositoryService, PizzaOrderRepositoryService>();

    // ... 
}
Accès concurrents

Dans l’exemple, les objets IPizzaFlavourRepositoryService et IPizzaOrderRepositoryService sont enregistrés en tant que singleton. Leur implémentation actuelle a été faite au plus simple mais ne permet pas de gérer des accès concurrents.

Pattern ServiceLocator

Avec ASP.NET Core, le pattern Service Locator peut être utilisé avec le service System.IServiceProvider. Il suffit d’injecter ce service dans un constructeur pour pouvoir l’utiliser.

Par exemple:

public class PizzaFlavourController : ControllerBase 
{ 
    private readonly IPizzaFlavourRepositoryService flavourService; 

    public PizzaFlavourController(IServiceProvider serviceProvider) 
    { 
        this.flavourService = (IPizzaFlavourRepositoryService)serviceProvider
            .GetService(typeof(IPizzaFlavourRepositoryService)); 
    } 
}

En rajoutant le namespace Microsoft.Extensions.DependencyInjection, on peut utiliser des méthodes d’extensions plus pratiques:

using Microsoft.Extensions.DependencyInjection; 

public class PizzaFlavourController : ControllerBase 
{ 
    private readonly IPizzaFlavourRepositoryService flavourService; 

    public PizzaFlavourController(IServiceProvider serviceProvider) 
    { 
        this.flavourService = serviceProvider
            .GetRequiredService<IPizzaFlavourRepositoryService>(); 
    } 
}

Dans le cas de l’exemple dans le repository GitHub webapi_example/dependency_injection, on instancie le service de cette façon dans le controller PizzaFlavourController.

Résoudre des objets dans une méthode

Si on utilise le pattern Service Locator, il n’est pas obligatoire d’instancier une dépendance dans le constructeur. On peut aussi instancier ses dépendances dans le corps d’une méthode, toutefois dans ce cas, il est préférable d’utiliser un scope enfant (i.e. portée) et observer certaines règles:

  • L’utilisation du scope permet de garantir qu’en dehors de ce scope, les objets résolus seront correctement libérés.
  • Sachant que la durée de vie de ces objets est lié à celle du scope, il ne faut pas garder de références de ces objets en dehors du scope.

La création d’un scope se fait par l’intermédiaire du service System.IServiceProvider et des méthodes d’extensions se trouvant dans Microsoft.Extensions.DependencyInjection.

Par exemple:

using Microsoft.Extensions.DependencyInjection; 

public class ConsumingService : IConsumingService 
{ 
    private readonly IServiceProvider serviceProvider; 
    
    public PizzaFlavourRepositoryService(IServiceProvider serviceProvider) 
    { 
        this.serviceProvider = serviceProvider; 
    } 

    private IEnumerable<string> UseDependency() 
    { 
        using (var scope = this.serviceProvider.CreateScope())
        { 
            var dataService = scope.ServiceProvider.GetRequiredService<IDataService>(); 
            return dataService.GetValues(); 
        } 
    } 
}

Au préalable, la classe PizzaFlavourGeneratorService a été enregistrée dans Startup.ConfigureServices():

public class Startup 
{ 
    public void ConfigureServices(IServiceCollection services) 
    { 
        services.AddTransient<IDataService, DataService>();

        // ... 
    } 
}

Dans le cas de l’exemple dans le repository GitHub webapi_example/dependency_injection, on utilise un scope dans la fonction PizzaFlavourRepositoryService.GetExistingFlavours() pour instancier un objet satisfaisant IPizzaFlavourGeneratorService.

L’injection de dépendances avec Autofac

Autofac est un container d’injection de dépendances qui permet d’apporter plus de fonctionnalités que le container de base d’ASP.NET Core. D’autres types de container sont compatibles avec ASP.NET Core comme par exemple StructureMap (qui n’est pas traité dans cet article).

Parmi les fonctionnalités proposées par les containers comme Autofac, celles qui sont les plus intéressantes sont:

  • L’enregistrement d’objets par convention (i.e. registration by convention ou convention-based registration): cette fonctionnalité permet d’enregistrer des objets automatiquement en associant une interface nommée, par exemple ICustomService avec la classe CustomService. Ces enregistrements peuvent se faire pour les objets définis dans un namespace particulier, dans une assembly particulière ou suivant leur nommage. L’intérêt principale de cette fonctionnalité est d’éviter d’enregistrer tous les objets à injecter un à un.
  • L’interception: cette fonctionnalité avancée permet de rajouter des compétences à des objets enregistrés par décoration.

Après avoir indiqué comment configurer Autofac dans une application ASP.NET Core, on va expliciter ces différentes fonctionnalités.

Installer et configurer Autofac

Pour utiliser Autofac dans une application ASP.NET Core, il faut:

  1. Ajouter le package NuGet Autofac.Extensions.DependencyInjection en exécutant dans le répertoire du projet la commande:
    dotnet add <chemin du fichier projet .csproj> package Autofac.Extensions.DependencyInjection
    

    Dans le cas de l’exemple dans le repository GitHub webapi_example/dependency_injection, il faut exécuter:

    user@debian:~/% dotnet add webapi_example/WebApi.csproj package Autofac.Extensions.DependencyInjection
    
  2. Ajouter le service permettant d’utiliser Autofac au moment de créer l’hôte du serveur Web dans le fichier Program.cs:
    WebHost.CreateDefaultBuilder(args).ConfigureServices(services => services.AddAutofac())
    

    Le contenu du fichier se présente, ainsi, de cette façon:

    using Autofac.Extensions.DependencyInjection; 
    // ...
    
    public static void Main(string[] args) 
    { 
        CreateWebHostBuilder(args).Build().Run(); 
    } 
    
    public static IWebHostBuilder CreateWebHostBuilder(string[] args) => 
        WebHost.CreateDefaultBuilder(args) 
            .ConfigureServices(services => services.AddAutofac())
            .UseStartup<Startup>();
    
  3. Il faut modifier la classe Startup pour qu’elle permette de configurer Autofac. Dans un premier temps, il faut modifier le constructeur pour injecter Microsoft.AspNetCore.IHostingEnvironment:
    using Autofac; 
    // ... 
    
    public Startup(IHostingEnvironment hostingEnvironment) 
    { 
        this.hostingEnvironment = hostingEnvironment; 
    
        var builder = new ConfigurationBuilder(); 
        this.Configuration = builder.Build(); 
    }

    On crée la méthode ConfigureContainer() pour ajouter le module AutofacModule permettant d’enregistrer les objets à injecter:

    using Autofac; 
    // ...
    
    public void ConfigureContainer(ContainerBuilder builder) 
    { 
        builder.RegisterModule(new AutofacModule()); 
    }
  4. On ajoute, enfin, un fichier nommé AutofacModule.cs contenant une classe du même nom permettant d’enregistrer les objets à injecter. La classe AutofacModule doit dériver de la classe Autofac.Module. Pour pour enregistrer les classes, il faut surcharger la méthode Load():
    using Autofac; 
    // ... 
    
    public class AutofacModule : Module 
    { 
        protected override void Load(ContainerBuilder builder) 
        { 
            // ... 
        } 
    }

Enregistrement des objets

Comme pour le container intégré à ASP.NET Core, l’enregistrement des objets dans Autofac se fait en fonction de leur durée de vie. Autofac fournit davantages de fonctionnalités que le container intégré, on peut:

  • Enregistrer les objets un à un comme pour le container intégré ou
  • Enregistrer les objets par convention (i.e. convention-based registration).

Durée de vie des objets

La durée de vie des objets est gêré par le container en fonction de la façon dont les objets ont été enregistrés dans le container. L’injection des objets se configure avec un syntaxe de type “fluent”:

Dans les exemples suivants:

  • builder correspond au container de type Autofac.ContainerBuilder.
  • CustomClass correspond au type de la classe à injecter
  • ICustomClass est l’interface que la classe CustomClass satisfait.

Les types d’enregistrement principaux sont:

  • Une instance à chaque injection (i.e. instance per dependency ou transient): une nouvelle instance sera créée à chaque fois que l’objet est injecté:
    builder.RegisterType<CustomClass>().InstancePerDependency(); 
    

    Enregistrer une classe et injecter l’interface correspondante:

    builder.Register(container => new CustomClass())
        .As<ICustomClass>().InstancePerDependency();
    

    Si le constructeur de CustomClass contient un paramètre de type IDependency (qui est enregistré dans le container):

    builder.Register(container => new CustomClass(container.Resolve<IDependency>())).As<ICustomClass>().InstancePerDependency();
    
  • Un singleton: la même instance est injectée partout:
    builder.RegisterType<CustomClass>().SingleInstance();  // dans le cas l'objet sera instancié directement par le container
    

    On peut enregistrer une instance spécifique en exécutant:

    var customClassInstance = new CustomClass(); 
    builder.RegisterInstance<ICustomClass>(customClassInstance);
    

    Ou

    builder.Register(container => customClassInstance).As<ICustomClass>().SingleInstance();
    
  • Une instance par scope (i.e. Instance Per Lifetime scope): dans le cas où on crée des scopes, les objets enregistrés de cette façon seront injectés sous forme d’une instance par scope:
    builder.RegisterType<CustomClass>().InstancePerLifetimeScope(); 
    

    Pour enregistrer une classe et injecter l’interface correspondante:

    builder.Register(container => new CustomClass())
        .As<ICustomClass>().InstancePerLifetimeScope(); 
    

    L’utilisation dans un scope se fait de cette façon:

    using (var scope = container.BeginLifetimeScope()) 
    { 
        var customClassInstance = scope.Resolve<CustomClass>(); 
    }
  • Une instance par scope nommé (i.e. Instance Per Matching Lifetime scope): même utilisation que précédemment mais pour des scopes nommés. L’enregistrement de ce type permet de garantir que l’instance sera unique pour chaque scope nommé.

    L’enregistrement se fait de cette façon en ajoutant le nom du scope (par exemple "ScopeName"):

    builder.RegisterType<CustomClass>().InstancePerMatchingLifetimeScope("ScopeName");
    

    L’objet sera injecté seulement dans le scope avec le nom configuré:

    using (var scope = container.BeginLifetimeScope("ScopeName")) 
    { 
        var customClassInstance = scope.Resolve<CustomClass>(); 
    }
  • Une instance par requête HTTP (i.e. Intance Per Request): une même instance sera utilisée pour chaque requête HTTP. L’enregistrement se fait de cette façon:
    builder.RegisterType<CustomClass>().InstancePerRequest(); 
    

    Si l’objet est injecté sous la forme de l’interface qu’il satisfait:

    builder.RegisterType<CustomClass>().As<ICustomClass>().InstancePerRequest();
    

D’autres types d’enregistrements existent (cf. Instance Scope).

Dans le cas de l’exemple dans le repository GitHub webapi_example/dependency_injection, on enregistre les services injectés au niveau des controllers dans la classe AutofacModule de cette façon:

protected override void Load(ContainerBuilder builder) 
{ 
    builder.RegisterType<PizzaFlavourGeneratorService>() 
        .As<IPizzaFlavourGeneratorService>() 
        .SingleInstance(); 

    builder.Register(c => new PizzaFlavourRepositoryService(c.Resolve<IServiceProvider>())) 
        .As<IPizzaFlavourRepositoryService>() 
        .SingleInstance(); 

    builder.Register(c => new PizzaOrderRepositoryService(c.Resolve<IPizzaFlavourRepositoryService>())) 
        .As<IPizzaOrderRepositoryService>() 
        .SingleInstance(); 
}

Enregistrement des objets par convention

Avec Autofac, il est possible d’injecter des objets par convention (i.e. convention-based registration ou registration by convention). Ainsi, on peut enregistrer des objets automatiquement parce-qu’ils sont définis dans un namespace particulier, dans une assembly particulière ou suivant leur nommage.

Par exemple, pour enregistrer toutes les classes se trouvant dans l’assembly nommée CustomAssembly et les injecter sous la forme de l’interface qu’elles satisfont, il faut exécuter les lignes suivantes dans la classe AutofacModule:

protected override void Load(ContainerBuilder builder) 
{ 
    var assemblies = AppDomain.CurrentDomain.GetAssemblies() 
        .Where(x => x.FullName.StartsWith("CustomAssembly")).ToArray(); 

    builder.RegisterAssemblyTypes(assemblies) 
        .Where(t => t.IsClass) 
        .AsImplementedInterfaces() 
        .InstancePerRequest(); 
}

Dans cet exemple, les objets seront injectés sous la forme de l’interface qu’ils satisfont (à cause du paramétrage AsImplementInterfaces()) et chaque instance sera spécifique pour chaque requête HTTP (à cause du paramètrage InstancePerRequest()).

Dans le cas de l’exemple dans le repository GitHub webapi_example/dependency_injection, pour enregistrer tous les services sous forme de singleton, on peut exécuter le code suivant dans la classe AutofacModule:

protected override void Load(ContainerBuilder builder) 
{ 
    var assemblies = AppDomain.CurrentDomain.GetAssemblies() 
        .Where(x => x.FullName.StartsWith("WebApi")).ToArray(); 

    builder.RegisterAssemblyTypes(assemblies) 
        .Where(t => t.IsClass && t.FullName.EndsWith("Service")) 
        .AsImplementedInterfaces() 
        .SingleInstance(); 
}

Ainsi on enregistre toutes les classes dont le nom se termine par "Service" se trouvant dans l’assembly dont le nom commence par "WebApi".

Interception

L’interception est une fonctionnalité avancée de l’injection de dépendances. Elle permet d’ajouter des compétences à une classe sans modifier l’implémentation de la classe. Ce pattern est un peu similaire au pattern Decorator. Si on ajoute une compétence à une classe, à chaque fois qu’elle est injectée, elle est interceptée pour lui rajouter cette compétence. L’instance injectée bénéficie de cette compétence de façon transparente sans que l’implémentation de la classe ne soit modifiée.

L’intérêt de cette fonctionnalité est de pouvoir rajouter des compétences sur un grand nombre de classes sans en modifier l’implémentation. Dans la pratique, l’interception permet de rajouter des fonctionnalités de logging, de gestion d’exceptions, d’authentification, de gestion de cache etc…

Techniquement, lorsqu’une classe est configurée pour être interceptée par une autre classe, le container crée un objet proxy qu’il va utiliser suivant 2 méthodes:

  • Si l’interception se fait par l’interface: dans le cas où la classe interceptée satisfait une interface, le container va créer un objet proxy satisfaisant la même interface et containant l’implémentation de la classe interceptée et les compétences à ajouter.
  • Si l’interception se fait par la classe: la classe interceptée doit comporter des méthodes virtuelles. Le container va créer un objet proxy surchargeant les méthodes virtuelles de la classe interceptée pour lui ajouter des compétences. A chaque fois qu’une méthode virtuelle de la classe interceptée est invoquée, la méthode virtuelle du proxy sera exécutée.

Le code complet de cet exemple se trouve dans la branche dependency_injection_autofac du repository GitHub webapi_example.

Configurer l’interception

Pour configurer l’interception avec Autofac, il faut effectuer les étapes suivantes:

  1. Ajouter le package NuGet Autofac.Extras.DynamicProxy en exécutant dans le répertoire du projet la commande:
    dotnet add <chemin du fichier projet CSPROJ> package Autofac.Extras.DynamicProxy 
    

    Dans le cas de l’exemple dans le repository GitHub webapi_example/dependency_injection, il faut exécuter:

    user@debian:~/% dotnet add webapi_example/WebApi.csproj package Autofac.Extras.DynamicProxy
    
  2. Créer une classe qui va effectuer l’interception. Cette classe doit satisfaire l’interface Castle.DynamuicProxy.IInterceptor:
    using Castle.DynamicProxy; 
    // ... 
    
    public class InterceptingClass : IInterceptor 
    { 
        public LoggerInterceptor() 
        { 
                
        } 
    
        public void Intercept(IInvocation invocation) 
        { 
            invocation.Proceed(); 
        } 
    }

    La méthode Intercept() sera exécutée à chaque fois qu’une méthode de la classe interceptée est exécutée. La ligne invocation.Proceed() permet d’exécuter la méthode dans la classe interceptée.

  3. Il faut indiquer quelle est la classe à intercepter et quel objet doit intercepter. Ces indications se font au moment de l’enregistrement dans le container dans la classe AutofacModule.

    Par exemple, on enregistre d’abord l’objet interceptant (i.e. la classe InterceptingClass) dans le container:

    using Autofac; 
    using Autofac.Extras.DynamicProxy; 
    // ... 
    
    public class AutofacModule : Module 
    { 
        protected override void Load(ContainerBuilder builder) 
        { 
            builder.Register(c => new InterceptingClass()); 
        } 
    }
    

    Ensuite, on enregistre la classe interceptée (i.e InterceptedClass) en précisant la classe interceptant (i.e InterceptingClass):

    using Autofac; 
    using Autofac.Extras.DynamicProxy; 
    // ... 
    
    public class AutofacModule : Module 
    { 
        protected override void Load(ContainerBuilder builder) 
        { 
            builder.Register(c => new InterceptingClass()); 
            // ... 
    
            builder.Register(c => new InterceptedClass()) 
            .As<IInterceptedClass>() 
            .SingleInstance() 
            .EnableInterfaceInterceptors() 
            .InterceptedBy(typeof(InterceptingClass)); 
        } 
    }
    

    Il y a 2 types d’interceptions:

    • Interception par interface: il faut indiquer ce type d’interception avec la ligne EnableInterfaceInterceptors(). La classe interceptée doit satisfaire une interface, c’est l’interface qui sera injectée avec le proxy.
    • Interception par classe: il faut indiquer ce type d’interception avec la ligne EnableClassInterceptors(). La classe interceptée doit comporter des méthodes virtuelles. Lorsque ces méthodes sont invoquées, la méthode du proxy est exécutée.

D’autres implémentations de l’interception existent avec Autofac notamment (cf. Type interceptors):

  • En enregistrant les classes interceptants par nom (i.e. named registration).
  • En déclenchant l’interception en utilisant des attributs.

Exemple d’implémentation de l’interception

Dans le cas de l’exemple dans le repository GitHub webapi_example/dependency_injection, on se propose de configurer 2 intercepteurs:

  • Un intercepteur nommé LoggingInterceptor permettant de logger des informations sur la fonction exécutée avec log4net.
  • Un intercepteur nommé TimingInterceptor permettant de logger le temps d’exécution des fonctions.

Dans un premier temps, on installe log4net:

  1. On installe le package NuGet de log4net en exécutant la ligne suivante:
    user@debian:~/% dotnet add webapi_example/WebApi.csproj package log4net
    
  2. On ajoute un fichier de configuration log4net au projet en ajoutant un fichier nommé log4net.config avec le contenu suivant:
    <log4net> 
        <appender name="Console" type="log4net.Appender.ConsoleAppender"> 
            <layout type="log4net.Layout.PatternLayout"> 
                <!-- Pattern to output the caller's file name and line number --> 
                <conversionPattern value="%5level [%thread] - %message%newline" /> 
            </layout> 
        </appender> 
    
        <appender name="RollingFile" type="log4net.Appender.RollingFileAppender"> 
            <file value="Logs/webapi.log" /> 
            <appendToFile value="true" /> 
            <maximumFileSize value="100KB" /> 
            <maxSizeRollBackups value="2" /> 
    
            <layout type="log4net.Layout.PatternLayout"> 
                <conversionPattern value="%level %thread %logger - %message%newline" /> 
            </layout> 
        </appender> 
    
        <root> 
            <level value="DEBUG" /> 
            <appender-ref ref="Console" /> 
            <appender-ref ref="RollingFile" /> 
        </root> 
    </log4net> 
    
  3. On configure le projet pour qu’à l’exécution, la configuration de log4net soit lue dans le fichier. Dans la classe Startup, on modifie la fonction Configure() en y ajoutant le code suivant:
    using System.IO; 
    using Autofac; 
    using log4net; 
    using log4net.Config; 
    using log4net.Repository; 
    using System.Reflection; 
    // ... 
    
    public class Startup 
    { 
        public void Configure(IApplicationBuilder app, IHostingEnvironment env) 
        { 
            var configFile = Path.Combine(env.ContentRootPath, "log4net.config"); 
            var repository = log4net.LogManager.GetRepository(Assembly.GetEntryAssembly()); 
            XmlConfigurator.Configure(repository, new FileInfo(configFile)); 
    
            // ... 
        } 
    }
  4. On crée la classe LoggingInterceptor permettant de logguer les appels aux fonctions:
    using Autofac; 
    using Castle.DynamicProxy; 
    using System.Linq; 
    using System.IO; 
    using log4net; 
    // ... 
    
    public class LoggingInterceptor : IInterceptor 
    { 
        private ILog logger; 
    
        public LoggingInterceptor() 
        { 
            this.logger = LogManager.GetLogger(typeof(LoggingInterceptor)); 
        } 
    
        public void Intercept(IInvocation invocation) 
        { 
            this.logger.InfoFormat("Calling method {0} with parameters {1}... ", 
                invocation.Method.Name, 
                string.Join(", ", invocation.Arguments.Select(a => (a ?? "").ToString()).ToArray())); 
    
            invocation.Proceed();   
    
            this.logger.InfoFormat("Done: result was {0}.", invocation.ReturnValue); 
        } 
    }
  5. On crée ensuite la classe TimingInterceptor permettant de logguer le temps d’exécution des fonctions:
    using System.Diagnostics; 
    
    public class TimingInterceptor : IInterceptor 
    { 
        private ILog logger; 
    
        public TimingInterceptor() 
        { 
            this.logger = LogManager.GetLogger(typeof(TimingInterceptor)); 
        } 
    
        public void Intercept(IInvocation invocation) 
        { 
            this.logger.InfoFormat("Calling method {0} with parameters {1}... ", 
                invocation.Method.Name, 
                string.Join(", ", invocation.Arguments.Select(a => (a ?? "").ToString()).ToArray())); 
    
            var stopWatch = new Stopwatch(); 
            stopWatch.Start(); 
    
            invocation.Proceed(); 
    
            stopWatch.Start(); 
    
            this.logger.InfoFormat($"Function {invocation.Method.Name}: execution time {stopWatch.Elapsed}"); 
        } 
    }
  6. On configure l’interception pour la classe PizzaOrderRepositoryService dans la classe AutofacModule:
    using Autofac; 
    using Autofac.Extras.DynamicProxy; 
    // ... 
    
    public class AutofacModule : Module 
    { 
        protected override void Load(ContainerBuilder builder) 
        { 
            builder.Register(c => new LoggingInterceptor()); 
            builder.Register(c => new TimingInterceptor()); 
    
            builder.Register(c => new PizzaOrderRepositoryService(c.Resolve<IPizzaFlavourRepositoryService>())) 
            .As<IPizzaOrderRepositoryService>() 
            .SingleInstance() 
            .EnableInterfaceInterceptors() 
            .InterceptedBy(typeof(LoggingInterceptor), typeof(TimingInterceptor)); 
    
            // ... 
        } 
    }
  7. Pour tester, il suffit de compiler puis d’exécuter le code en exécutant successivement:
    user@debian:~/webapi_example% dotnet build
    user@debian:~/webapi_example% dotnet run
    

    Il faut aller à l’adresse http://localhost:5000/swagger/index.html avec un browser.
    Si on exécute des méthodes du controller PizzaOrder (qui utilise la classe PizzaOrderlRepositoryService) comme par la fonction GET /api/PizzaOrder, on peut voir les messages logs correspondant aux exécutions de la méthode Intercept() des classes LoggingInterceptor et TimingInterceptor (dans la console et dans le fichier Logs/webapi.log):

    INFO 12 WebApiExample.Interceptors.LoggingInterceptor - Calling method GetOrders with parameters ...  
    INFO 12 WebApiExample.Interceptors.TimingInterceptor - Calling method GetOrders with parameters ...  
    INFO 12 WebApiExample.Interceptors.TimingInterceptor - Function GetOrders: execution time 00:00:00.0009593 
    INFO 12 WebApiExample.Interceptors.LoggingInterceptor - Done: result was System.Collections.Generic.List`1[WebApiExample.Services.PizzaOrder]. 
    

Le code complet de cet exemple se trouve dans la branche dependency_injection_autofac du repository GitHub webapi_example.

Pour résumer

Quelques soit le container utilisé (container natif ou Autofac), il faut privilégier l’injection par le constructeur de façon à ce que les dépendances d’une classe soient facilement visibles.

La plupart du temps, la durée de vie Transient (i.e. éphémère) convient pour la plupart des objets, elle permet d’éviter de se préoccuper des accès concurrents à un même objet. Dans le cas de singletons, en revanche, les accès concurrents provenant de requêtes différentes sur un même objet peuvent occasionner des comportements inattendus. Une attention particulière doit être portée quant à l’implémentation des singletons pour éviter ces comportements inattendus.

Enfin, Autofac ou d’autres containers tiers possèdent des fonctionnalités supplémentaires intéressantes comme l’enregistrement d’objets par convention ou l’interception. En outre, ils ont l’avantage d’être facilement configurable pour remplacer le container natif d’ASP.NET Core.

Références
Share on FacebookShare on Google+Tweet about this on TwitterShare on LinkedInEmail this to someonePrint this page

Documenter une API Web ASP.NET Core avec Swagger

Cet article est un aide-mémoire concernant les fonctions principales de Swagger UI. La documentation complête se trouve sur le repository GitHub du package Swashbuckle.AspNetCore qui est le package NuGet permettant d’installer Swagger sur une application ASP.NET Core.

Swagger est un outil permettant de documenter un API Web en présentant les différentes fonctions sous forme d’une page web. On peut ainsi utiliser l’interface web pour requêter les différentes fonctions de l’API.
Dans cet article, on va présenter les éléments de paramétrages principaux pour paramétrer Swagger dans le cas d’une API Web ASP.NET Core.

L’exemple d’API Web utilisé dans cet article se trouve dans la branche swagger du repository GitHub https://github.com/msoft/webapi_example.

Installation et configuration de Swagger

En commençant “from scratch”, pour créer une API Web ASP.NET Core, on peut exécuter la commande suivante après avoir installé la CLI .NET Core:

user@debian:~/% dotnet new webapi --name <nom du projet>

Pour installer Swagger dans une application ASP.NET Core et requêter facilement une API Web, il faut ajouter le package NuGet Swashbuckle.AspNetCore en exécutant la commande suivante:

user@debian:~/% dotnet add <chemin du fichier .csproj> package swashbuckle.aspnetcore

Dans le cas de l’exemple sur le repository GitHub https://github.com/msoft/webapi_example, il faut exécuter la commande:

user@debian:~/% dotnet add webapi_example/webapi_example.csproj package swashbuckle.aspnetcore

Pour configurer Swashbuckle, il faut ajouter les lignes suivantes dans le fichier StartUp.cs:

public void ConfigureServices(IServiceCollection services) 
{ 
    // ... 

    services.AddSwaggerGen(c => 
    { 
        c.SwaggerDoc("v1", new Info { Title = "Pizza API",  Version = "v1"}); 
    }); 
} 

public void Configure(IApplicationBuilder app, IHostingEnvironment env) 
{ 
    // ... 

    app.UseSwagger(); 
    app.UseSwaggerUI(c => 
    { 
        c.SwaggerEndpoint("/swagger/v1/swagger.json", "Pizza API V1"); 
    }); 

    // ... 
} 

Dans l’extrait précédent:

  • services.AddSwaggerGen() permet de rajouter un service qui va analyser le code pour générer la description de l’API sous forme d’un document JSON (i.e. SwaggerDocument).
  • app.UseSwagger() va rajouter un middleware pour exposer le contenu du SwaggerDocument (contenant la description de l’API dans un document JSON). En pratique ce middleware va répondre quand une requête est faite à l’adresse http://localhost:5000/swagger/v1/swagger.json (adresse par défaut).
  • app.UseSwaggerUI() permet de rajouter un middleware pour présenter le SwaggerDocument sous forme d’une interface web. L’ajout de ce middleware est facultatif. On peut utiliser seulement le middleware Swagger, générer le SwaggerDocument et copier le code JSON dans https://editor.swagger.io/ pour l’utiliser.

Dans l’exemple, on peut lancer la compilation et l’exécution en exécutant successivement les lignes suivantes:

user@debian:~/% cd webapi_example
user@debian:~/webapi_example/% dotnet build
user@debian:~/webapi_example/% dotnet run

Par défaut:

  • La description JSON de l’API se trouve à l’adresse: http://localhost:5000/swagger/v1/swagger.json.
  • On peut accéder à l’interface de Swagger à l’adresse: http://localhost:5000/swagger/index.html.
Installation de .NET Core sur Linux

Pour installer .NET Core sur Debian, il faut suivre les étapes suivantes:

Améliorer la présentation de la description JSON

Quand on requête le description JSON à l’adresse http://localhost:5000/swagger/v1/swagger.json, le document est présenté de façon compacte. On peut améliorer la présentation du document en ajoutant la configuration suivante dans le fichier StartUp.cs:

public void ConfigureServices(IServiceCollection services) 
{ 
    // ... 

    services.AddMvc() 
        .AddJsonOptions(options => 
        { 
            options.SerializerSettings.Formatting = Formatting.Indented; 
        }); 
}

Configurer un endpoint

On peut spécifier un endpoint particulier pour accéder à Swagger en ajoutant les éléments de configuration suivants:

public void Configure(IApplicationBuilder app, IHostingEnvironment env) 
{ 
    // ... 

    app.UseSwaggerUI(c => 
    { 
        c.RoutePrefix = "pizza-api-docs"
    }); 
}

Dans cet exemple, Swagger sera ainsi accessible à l’adresse: http://localhost:5000/pizza-api-docs.

Ajouter des informations globales

Les éléments de configuration suivants permettent d’ajouter des informations globales sur l’API.

Ajouter un titre HTML

Ce titre correspond au titre de la page HTML. Il apparaîtra dans l’onglet du browser. Pour l’ajouter, il faut rajouter la ligne suivante:

public void Configure(IApplicationBuilder app, IHostingEnvironment env) 
{ 
    // ... 

    app.UseSwaggerUI(c => 
    { 
        c.SwaggerEndpoint("/swagger/v1/swagger.json", "Pizza API V1"); 
        c.DocumentTitle = "Custom HTML title"; 
    }); 
} 

Sans préciser davantage d’éléments de configuration, les informations sont présentées de cette façon sur l’interface web de Swagger:


Enrichir les informations globales

On peut ajouter d’autres informations globales, par exemple:

public void ConfigureServices(IServiceCollection services) 
{ 
    services.AddSwaggerGen(c => 
    { 
        c.SwaggerDoc("v1", new Info {  
            Title = "Pizza API",  
            Version = "v1", 
            Description = "API for pizza", 
            TermsOfService = "Terms of Service", 
            Contact = new Contact 
            { 
                Name = "Developer Name", 
                Email = "developer.name@example.com" 
            }, 
            License = new License 
            { 
                Name = "Apache 2.0", 
                Url = "http://www.apache.org/licenses/LICENSE-2.0.html" 
            }
        }); 
    }); 
}

En rajoutant ces informations, on obtient l’affichage suivant:


Prendre en compte les commentaires XML du projet

Swagger peut afficher les commentaires XML du projet, à condition qu’ils soient générés. Avec ASP.NET Core, pour générer les commentaires XML, il faut éditer le fichier .csproj de l’application ASP.NET Core et ajouter la ligne suivante correspondant au nœud XML <DocumentationFile>:

<PropertyGroup> 
<TargetFramework>netcoreapp2.2</TargetFramework> 
<AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel> 
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\<nom du fichier>.xml</DocumentationFile>
</PropertyGroup> 

Ensuite, il faut indiquer le chemin du fichier XML généré à Swagger dans le fichier StartUp.cs du projet:

public void ConfigureServices(IServiceCollection services) 
{ 
    // ...

    services.AddSwaggerGen(c => 
    {
        // ...

        var filePath = Path.Combine(System.AppContext.BaseDirectory, "<nom du fichier>.xml"); 
        c.IncludeXmlComments(filePath);
    });
} 

Ainsi, si on ajoute des informations dans les commentaires d’une action d’un controller, par exemple au niveau de l’action FindFlavour() du controller PizzaFlavourController:

/// <summary> 
/// Find flavour using flavour name 
/// </summary> 
/// <remarks>Usefull remark</remarks> 
/// <response code="200">Flavour retreived</response> 
/// <response code="400">Flavour not found</response> 
/// <response code="500">Bad request</response>
[HttpGet("{flavourName}", Name = "FindFlavourByName")] 
[ProducesResponseType(typeof(IEnumerable<string>), 200)] 
[ProducesResponseType(typeof(string), 400)] 
[ProducesResponseType(500)] 
public ActionResult<IEnumerable<string>> FindFlavour(string flavourName) 
{ 
    // ... 
} 

On peut enrichir les informations relatives à la fonction de l’API:


Ajouter des informations sur les actions d’un controller

Pour que les actions d’un controller soient visibles dans Swagger, il faut utiliser les attributs permettant de définir les routes sur le controller (cf. RouteAttribute) et sur les actions (cf. HttpGetAttribute, HttpPostAttribute, HttpDeleteAttribute et HttpPutAttribute).

Par exemple en rajoutant ces attributs dans le controller PizzaFlavourController:

[Route("api/[controller]")] 
[ApiController] 
public class PizzaFlavourController : ControllerBase 
{ 
    [HttpGet] 
    public ActionResult<IEnumerable<string>> GetFlavourNames() 
    { 
        // ... 
    } 
    
    [HttpGet("{flavourName}")] 
    public ActionResult<IEnumerable<string>> FindFlavour(string flavourName) 
    { 
        // ...
    } 

    [HttpPost] 
    public ActionResult<int> Post([FromBody, BindRequired]AddPizzaFlavourRequest request) 
    { 
        // ...
    } 
} 

Avec cet exemple, on obtient:


Ajouter les “operation IDs”

On peut ajouter des identifiants relatifs aux actions avec le paramétrage suivant dans le fichier StartUp.cs du projet:

public void Configure(IApplicationBuilder app, IHostingEnvironment env) 
{ 
    // ...

    app.UseSwaggerUI(c => {
        // ...

        c.DisplayOperationId();
    });
} 

Sans davantage d’éléments de configuration, l’affichage indique les noms des actions:


En utilisant la propriété Name des attributs HttpGetAttribute, HttpPostAttribute, HttpPutAttribute et HttpDeleteAttribute, on peut préciser un nom particulier différent du nom de l’action.

Par exemple:

[HttpGet("{flavourName}", Name = "FindFlavourUsingFlavourName")] 
public ActionResult<IEnumerable<string>> FindFlavour(string flavourName) 
{ 
    // ... 
} 
 
[HttpPost(Name = "AddPizzaFlavour")] 
public ActionResult<int> Post([FromBody, BindRequired]AddPizzaFlavourRequest request) 
{ 
    // ... 
}

[HttpGet] 
public ActionResult<IEnumerable<string>> GetFlavourNames() 
{ 
    // ... 
} 

On obtient ainsi:


Liste des réponses possibles

En utilisant l’attribut ProducesResponseTypeAttribute, on peut indiquer toutes les réponses possibles d’une action. Swagger peut prendre en compte ces réponses dans la description d’une action.

Par exemple en rajoutant cet attribut dans l’action suivante:

[HttpGet("{flavourName}", Name = "FindFlavourByName")] 
[ProducesResponseType(typeof(IEnumerable<string>), 200)] 
[ProducesResponseType(typeof(string), 400)] 
[ProducesResponseType(500)]
public ActionResult<IEnumerable<string>> FindFlavour(string flavourName) 
{ 
    // ... 
} 

On obtient l’affichage suivant:


Attributs FromQueryAttribute et FromBodyAttribute

Les attributs FromQueryAttribute et FromBodyAttribute permettent d’indiquer explicitement si le paramètre d’une action doit se trouver dans l’URL de la requête ou dans le corps d’un message HTTP. Ces attributs sont pris en compte par Swagger dans sa description.

Par exemple, si on utilise ces paramètres de la façon suivante:

[HttpGet("{flavourName}", Name = "FindFlavourByName")] 
public ActionResult<IEnumerable<string>> FindFlavour([FromQuery]string flavourName) 
{ 
    // ... 
} 
 
[HttpPost(Name = "AddPizzaFlavour")] 
public ActionResult<int> Post([FromBody]AddPizzaFlavourRequest request) 
{ 
    // ... 
} 

On obtient l’affichage suivant dans Swagger:

Indiquer un paramètre obligatoire

On peut utiliser l’attribut BindRequiredAttribute sur le paramètre d’une action ou l’attribut RequiredAttribute sur les propriétés d’une DTO pour indiquer explicitement que le paramètre est obligatoire.

Par exemple en utilisant l’attribut BindRequiredAttribute pour les actions suivantes:

[HttpGet("{flavourName}", Name = "FindFlavourByName")] 
public ActionResult<IEnumerable<string>> FindFlavour([FromQuery, BindRequired]string flavourName) 
{ 
    // ... 
} 
 
[HttpPost(Name = "AddPizzaFlavour")] 
public ActionResult<int> Post([FromBody, BindRequired]AddPizzaFlavourRequest request) 
{ 
    // ... 
} 

On obtient l’affichage:

Indiquer des metadonnées avec des attributs Swagger

Avec les méthodes précédentes, on a précisé les métadonnées d’une API en utilisant:

Il est possible de préciser ces informations en utilisant des attributes spécifiques à Swagger. Les informations seront reconnues et utilisées pour enrichir les métadonnées de l’API de la même façon qu’avec la méthode précédente.

L’assembly contenant les attributs spécifiques à Swagger se trouve dans la package NuGet Swashbuckle.AspNetCore.Annotations.

Pour installer ce package, il faut exécuter la commande suivante:

user@debian:~/% dotnet add <chemin du fichier .csproj> package swashbuckle.aspnetcore.annotations 

Dans le cas de l’exemple sur le repository GitHub https://github.com/msoft/webapi_example, il faut exécuter la commande:

user@debian:~/% dotnet add webapi_example/webapi_example.csproj package swashbuckle.aspnetcore.annotations

Pour utiliser le package installé, il faut l’activer dans le fichier StartUp.cs du projet en ajoutant les lignes:

public void ConfigureServices(IServiceCollection services) 
{ 
    // ...
    
    services.AddSwaggerGen(c => 
    { 
        // ... 
        c.EnableAnnotations(); 
    }); 
} 

Les attributs spécifiques à Swagger utilisés par la suite suivants, se trouvent dans le namespace Swashbuckle.AspNetCore.Annotations, il faut les utiliser en précisant dans l’entête du fichier .cs:

using Swashbuckle.AspNetCore.Annotations; 

SwaggerOperationAttribute

Cet attribut est l’équivalent des commentaires XML pour préciser des informations concernant une action.

Par exemple, en utilisant SwaggerOperationAttribute dans le code suivant:

[HttpGet("{flavourName}", Name = "FindFlavourByName")] 
[SwaggerOperation( 
    Summary = "Returns the ingredients from a flavour name", 
    Description = "Returns the ingredients", 
    OperationId = "FindFlavour")]
public ActionResult<IEnumerable<string>> FindFlavour([FromQuery, BindRequired]string flavourName) 
{ 
    // ... 
} 

Le résultat est le même que dans le cas des commentaires XML:


L’élément Tag permet d’indiquer dans quelle partie sera rangée l’action.

Par exemple, si on précise les tags "Flavour" et "Pizza", l’action sera rangée dans les parties "Flavour" et "Pizza"

[HttpGet("{flavourName}", Name = "FindFlavourByName")] 
[SwaggerOperation( 
    Summary = "Returns the ingredients from a flavour name", 
    Description = "Returns the ingredients", 
    OperationId = "FindFlavour", 
    Tags = new[] { "Flavour", "Pizza" }
)] 
public ActionResult<IEnumerable<string>> FindFlavour([FromQuery, BindRequired]string flavourName) 
{ 
    // ... 
} 

Le résultat sera:


Il est possible préciser d’autres éléments en utilisant SwaggerOperationAttribute comme:

  • Consumes pour préciser les types MIMES que l’action peut consumer.
  • Produces pour préciser les types MIMES que l’action peut générer.
  • Schemes pour indiquer les protocoles de transfert supportés par l’action.

SwaggerResponseAttribute

Cet attribut permet d’indiquer des informations sur les réponses possibles. Il est équivalent à l’attribut ProducesResponseTypeAttribute.

Par exemple en utilisant SwaggerResponseAttribute dans le code suivant:

[HttpGet("{flavourName}", Name = "FindFlavourByName")] 
[SwaggerResponse(200, "The ingredients for the flavour have been found", typeof(IEnumerable<string>))] 
[SwaggerResponse(400, "The flavour has not been found", typeof(string))] 
[SwaggerResponse(500, "Internal server error")]
public ActionResult<IEnumerable<string>> FindFlavour([FromQuery, BindRequired]string flavourName) 
{ 
    // ... 
} 

Le résultat de cet exemple est du type:


SwaggerParameterAttribute

Cet attribut permet de fournir des informations sur les paramètres d’une action. Il est l’équivalent de l’attribut BindRequiredAttribute, toutefois il permet d’indiquer d’apporter une précision supplémentaire comme le nom du paramètre.

Par exemple si on utilise l’attribut SwaggerParameterAttribute dans le code suivant:

[HttpGet("{flavourName}", Name = "FindFlavourByName")] 
public ActionResult<IEnumerable<string>> FindFlavour( 
[FromQuery, SwaggerParameter("Flavour name", Required = true)]string flavourName) 
{ 
    // ... 
} 

Le résultat de cet exemple est du type:


SwaggerTagAttribute

Cet attribut permet de préciser des informations supplémentaires concernant le controller.

Par exemple, en utilisant l’attribut sur la classe du controller:

[Route("api/[controller]")] 
[ApiController] 
[SwaggerTag("Get or create new flavour for pizzas")]
public class PizzaFlavourController : ControllerBase 
{ 
    // ... 
} 

Le résultat de cet exemple est du type:


Share on FacebookShare on Google+Tweet about this on TwitterShare on LinkedInEmail this to someonePrint this page

Le routage en ASP.NET Core en 5 min

Quand on utilise la technologie ASP.NET Core pour implémenter une application web, il est possible d’utiliser le middleware et le pattern MVC (i.e. Model-View-Controler) pour organiser les classes qui répondront aux différentes requêtes HTTP. ASP.NET Core permet de router les requêtes vers les bonnes instances d’objets à condition que le “routage” soir configuré. Dans le cadre de MVC, cette fonctionnalité de “routage” (i.e. routing) va ainsi permettre de trouver le controller et l’action dans le controller qui sera évoquée en fonction de l’adresse web de la requête HTTP.

Le but de cet article n’est pas de paraphraser la documentation mais d’être un aide-mémoire sur les principales caractéristiques du routage. La documentation de Microsoft permet d’avoir des détails plus exhaustifs sur tous les aspects du routage.

Source: reddit.com/r/InfrastructurePorn

Fonctionnement général

Lorsqu’une requête HTTP est adressée à une application web ASP.NET Core, elle traverse différentes couches de l’application en utilisant le pipeline de middlewares. Ces middlewares sont évoqués successivement et permettent d’adresser différents points techniques liés à la requête comme par exemple la gestion d’erreurs, la gestion des cookies, l’authentification, la gestion de sessions etc… L’exécution successive des différents middlewares aboutira le cas échéant à créer une réponse à la requête. Le routage est l’un de ces middlewares.

Après avoir exécuté les middlewares précédents, la requête parvient au middleware routing (correspond aux classes dans le namespace Microsoft.AspNetCore.Routing) qui va effectuer les étapes suivantes:

  • Parser l’URL pour déterminer les différents paramètres de la requête.
  • En fonction des paramètres, trouver la route parmi les différentes routes configurées qui permettra de répondre à la requête.
  • Si une route est trouvée alors la requête est passée à une classe satisfaisant IRouteHandler (dans Microsoft.AspNetCore.Routing). Par défaut, la classe RouteHandler.
  • Si aucune route n’est trouvée, la requête est passée au middleware suivant.

Ainsi, le middleware routing est composé de différents objets:

  • Les routes (la classe correspondante est Route): elles définissent les différentes chemin de routage que pourrait emprunter la requête.
    Par exemple, une route pourrait être définie en utilisant l’expression:

    "{controller=Home}/{action=Index}/{id?}"
    
  • Une liste de routes (i.e. route collection): les routes sont testées successivement parmi cette liste pour déterminer qu’elle est la première qui convient. Si une route convient aux paramètres de la requête, les routes suivantes ne sont pas testées.
  • IRouter: la classe satisfaisant cette interface va être appelée pour déterminer quelle classe va traiter la requête pour en générer une partie de la réponse. Dans le cas d’une application ASP.NET Core MVC, le route handler, par défaut, est la classe MvcRouteHandler.

    Cette classe va chercher le controller et l’action dans le controller à évoquer pour générer une réponse.

Ces différents objets doivent être configurés pour que le routage s’effectue correctement.

Configurer les routes

Routage basique

Pour configurer un routage par défaut, il faut d’abord ajouter les services MVC pour indiquer que l’application ASP.NET Core utilisera ce pattern dans la classe Startup servant à la configuration initiale:

public class Startup 
{ 
  public void ConfigureServices(IServiceCollection services) 
  { 
     services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2); 
     // ...
  } 
}

SetCompatibilityVersion(CompatibilityVersion.Version_2_2) permet d’indiquer qu’on utilise une logique de routage compatible avec celle d’ASP.NET Core 2.2.

On indique ensuite, qu’on souhaite utiliser le middleware de routage et on l’ajoute au pipeline de middleware avec la ligne:

public class Startup 
{ 
  public void Configure(IApplicationBuilder app) 
  { 
      app.UseMvc();
  } 
} 

UseMvc() ajoute le middleware de routage mais ne configure aucune route. Pour configurer une route par défaut, il faut utiliser la méthode (on explicitera par la suite quelle est la route par défaut):

app.UseMvcWithDefaultRoute(); 

Les 2 ajouts sont nécessaires services.AddMvc() et app.UseMvc().

Template d’une route

Par défaut, dans le cadre de MVC, une route se définit en indiquant un template. Ce template contient des indications concernant:

  • Le controller qui implémente le code permettant de construire la réponse à la requête,
  • L’action c’est-à-dire la fonction dans le controller qui sera exécutée pour générer la réponse,
  • Eventuellement des arguments nécessaires à l’exécution de l’action.

De façon générique, une route se définit en indiquant les différents éléments successivement:

"{controller}/{action}/{id}" 

L’utilisation de { } permet d’indiquer qu’un élément n’a pas une valeur fixe.

Par exemple, le template de la route définie précédemment sera utilisée si l’adresse appelée est du type:

pizzaOrder/get/23 

Dans ce cas:

  • {controller} est égal à pizzaOrder,
  • {action} est égal à get et
  • {id} est égal à 23

Elément à valeur fixe

On peut indiquer qu’un élément de l’adresse a une valeur fixe. Par exemple, si le template de la route est:

admin/{controller}/{action}/{id} 

admin est fixe et toutes les adresses devront commencer par admin pour être prise en compte par la route. Par exemple:

admin/pizzaOrder/get/23 

Elément facultatif

On peut indiquer qu’un élément est facultatif en utilisant ?, par exemple:

"{controller}/{action}/{id?}" 

Cette route correspond à des adresses du type:

pizzaOrder/list 
pizzaOrder/list/12

Element par défaut

Si un élément n’est pas précisé, alors la valeur par défaut sera utilisée. Pour préciser la valeur par défaut, il faut utiliser le caractère =<valeur par défaut>, par exemple:

"{controller}/{action=index}/{id?}" 

Dans ce cas, l’action par défaut sera index.

Si on utilise l’adresse:

pizzaOrder 

L’adresse équivalente sera: pizzaOrder/index (l’élément id étant facultatif).

Mise à part controller, action d’autres mots clés peuvent désigner des éléments précis dans l’application ASP.NET Core comme area, handler et page.

Template par défaut

Si on utilise app.UseMvcWithDefaultRoute() dans StartUp.Configure(), le template par défaut utilisé est:

"{controller=Home}/{action=Index}/{id?}" 

Ajouter une route

L’ajout explicite d’une route se fait en utilisant l’une des surcharges suivantes dans StartUp.Configure():

app.UseMvc(routes => {
  routes.MapRoute("<nom unique de la route>",  "<template de la route>");
}); 

Par exemple:

public class Startup 
{ 
  public void Configure(IApplicationBuilder app) 
  { 
      app.UseMvc(routes => {    
         routes.MapRoute("default",  "{controller}/{action=index}");
     }); 
  } 
} 

D’autres surcharges sont possibles:

  • Pour indiquer que des éléments sont fixes:
    routes.MapRoute("Home", "{home}", new { Controller = "Home", Action = "Index" }); 
    

    Dans ce cas, la route nommée "Home" contient le template "{home}" désignant le controller HomeController et l’action Index.

  • Pour préciser des éléments par défaut:
    routes.MapRoute("Home", "{controller}/{action}", 
        defaults: new { Controller = "Home", Action = "Index" }); 
    

    Le template de la route est "{controller}/{action}" et les valeurs par défaut sont "Home" pour le controller et "Index" pour l’action.

  • Pour préciser des contraintes sur les éléments:
    routes.MapRoute("Home", "{controller}/{action}",  
       defaults: new { Controller = "Home", Action = "Index" }, 
       constraints: new { id = new IntRouteConstraint() }); 
    

    La contrainte impose que l’élément id doit être un entier.

  • D’autres contraintes existent comme:
    • Pour imposer une contrainte sur le type: BoolRouteConstraint, DateTimeRouteConstraint, DecimalRouteConstraint, DoubleRouteConstraint, GuidRouteContraint, FloatRouteConstraint ou LongRouteConstraint.
    • Pour imposer une contrainte sur la longueur d’une chaîne de caractères: MinLengthRouteConstraint ou MaxLengthConstraint
    • Pour imposer une contrainte sur une valeur: MinRouteConstraint, MaxRouteConstraint ou RangeRouteConstraint.
    • Pour imposer une contrainte avec une regex: RegexInlineRouteConstraint.
Ordre d’ajout des routes

L’ordre d’ajout des routes est important puisque le parcours des routes dans la liste de routes se fera dans l’ordre d’ajout de celles-ci.

Contrainte sur un élément d’une route

Il est possible d’indiquer des contraintes concernant un élément dans le template d’une route. Par exemple, si on considère le template de route suivant:

"{controller}/{action}/{id}" 

Si on souhaite ajouter des contraintes sur le paramètre id, il faut utiliser la syntaxe suivante:

"{controller}/{action}/{id:<contrainte 1>:<contrainte 2>:<etc...>}" 

On peut utiliser le caractère ? pour indiquer que le paramètre id est facultatif:

"{controller}/{action}/{id:<contrainte 1>:<contrainte 2>:<etc...>?}" 

Par exemple pour indiquer que le paramètre id doit être de type entier, on utilise la syntaxe:

"{controller}/{action}/{id:int}" 

Contraintes de type

Pour contraindre le type d’un paramètre, on peut utiliser les syntaxes suivantes:

Entier {id:int}
Alphabétique (caractères de A à Z et a à z seulement) {id:alpha}
bool {id:bool}
DateTime {id:datetime}
decimal {id:decimal}
double {id:double}
GUID {id:guid}
float {id:float}

Contraintes de valeurs

Pour contraindre un paramètre à avoir des valeurs dans un intervalle spécifique:

Chaine de caractères d’une longueur minimum (par exemple 5 caractères) {id:minlength(5)}
Chaine de caractères d’un longueur maximum (par exemple 10 caractères) {id:maxlength(10)}
Chaine de caractères de longueur spécifique (par exemple 7 caractères) {id:length(7)}
Chaine de caractères de longueur bornée (par exemple comprise entre 4 et 9 caractères) {id:length(4, 9)}
Entier supérieur ou égal (par exemple 5) {id:min(5)}
Entier inférieur ou égal (par exemple 14) {id:max(14)}
Entier compris dans un interval borné (par exemple entre 3 et 9) {id:range(3, 9)}

Contrainte avec une regex

Pour contraindre un paramètre à respecter une regex:

"{id:regex(<regex>)}" 

Par exemple:

"{id:regex(^2019$)}" 

Il faut utiliser des caractères d’échappement quand on utilise \,{, }, [ ou ]. Par exemple la regex ^\d{5}$ doit s’écrire:

"{id:regex(^\\d{{5}}$)}" 

Définition des routes avec des attributs

Il n’est pas obligatoire de préciser des routes en utilisant un template comme dans le paragraphe précédent. On peut utiliser des attributs placés sur:

  • un controller et/ou
  • une action d’un controller

Pour définir les routes par attributs, il faut utiliser la fonction suivante dans StartUp.Configure():

public class Startup 
{ 
  public void Configure(IApplicationBuilder app) 
  { 
       app.UseMvc();
  } 
} 

RouteAttribute

Au niveau d’un controller

L’attribut RouteAttribute peut être utilisé au niveau d’un controller pour définir une route:

[Route("PizzaOrder")]
public class PizzaOrderController : Controller 
{ 
    // ... 
} 

Ce controller sera appelé dès que l’adresse commence par PizzaOrder.

Au niveau d’une action

RouteAttribute peut aussi être utilisé au niveau d’une action:

[Route("PizzaOrder")] 
public class PizzaOrderController : Controller 
{ 
  [Route("Index")]
  public IActionResult Index()  
  { 
        // ... 
  } 
} 

L’action sera appelée si l’adresse est: PizzaOrder/Index.

Utilisation de “tokens” de remplacement

On peut utiliser des tokens pour éviter d’avoir à préciser le nom du controller ou de l’action. Au lieu d’indiquer le nom du controller ou de l’action, on utilise:

  • [controller] pour désigner le nom du controller
  • [action] pour désigner le nom de l’action.
  • [area] pour indiquer le nom de la zone.

Par exemple:

[Route("[controller]")]
public class PizzaOrderController : Controller 
{ 
   [Route("[action]")]
   public IActionResult Index()  
   { 
      // ... 
   } 
} 

On peut aussi tout préciser directement au niveau du controller:

[Route("[controller]/[action]")]
public class PizzaOrderController : Controller 
{ 
    public IActionResult Index()  
    { 
       // ... 
    } 
} 

La définition des routes se combinent

Les attributs utilisés au niveau d’un controller et d’une action se combinent toutefois il est aussi possible d’utiliser plusieurs attributs au même niveau. Par exemple si on indique:

[Route("PizzaOrder")]
public class PizzaOrderController : Controller 
{ 
    [Route("")]  
    [Route("Index")]  
    [Route("/")]
    public IActionResult Index()  
    { 
       // ... 
   }
} 

L’action Index() est appelée si pour les adresses:

  • PizzaOrder à cause de [Route("PizzaOrder")] et [Route("")]
  • PizzaOrder/Index à cause de [Route("PizzaOrder")] et [Route("Index")]
  • "" (ou rien) à cause de [Route("PizzaOrder")] et [Route("/")]

HttpGetAttribute, HttpPostAttribute, HttpPutAttribute et HttpDeleteAttribute

Dans le cadre d’une API REST (i.e. REpresentational State Transfer) on utilise les verbes HTTP pour effectuer les requêtes comme:

  • GET pour effectuer des opérations de lecture sur une ressource,
  • POST pour créer une ressource,
  • PUT pour mettre à jour une ressource et
  • DELETE pour supprimer une ressource.

Il est possible d’utiliser les attributs suivants pour définir des routes correspondant aux requêtes contenant les verbes HTTP:

Ces attributs sont à utiliser au niveau des actions:

[Route("PizzaOrder")]
public class PizzaOrderController : Controller 
{ 
   [HttpGet("")]
   public IActionResult ListOrders()  
   { 
       // ... 
   } 

   [HttpGet("{id}")]
   public IActionResult GetOrder(int id)  
   { 
       // ... 
   } 

   [HttpPost("")]
   public IActionResult CreateOrder(Order newOrder)  
   { 
       // ... 
   } 
} 

Dans ce cas, l’utilisation de ces attributs permet de différencier les actions à appeler même dans le cas où les adresses sont les mêmes, par exemple:

  • Requête GET à l’adresse PizzaOrder appelera l’action ListOrders() à cause de [Route("PizzaOrder")] et [HttpGet("")].
  • Requête GET à l’adresse PizzaOrder/23 appelera l’action GetOrder() à cause de [Route("PizzaOrder")] et [HttpGet("{id}")].
  • Requête POST à l’adresse PizzaOrder appelera l’action CreateOrder() à cause de [Route("PizzaOrder")] et [HttpPost("")].
Utiliser des contraintes

Il est possible d’utiliser des contraintes sur les paramètres utilisés avec les attributs comme pour les templates de route.

Par exemple, pour contraindre le paramètre id à être entier et à être compris entre 4 et 9:

[HttpGet("{id:int:range(4,9)}")]  
public IActionResult GetOrder(int id)  
{ 
   // ... 
} 

Exemples

Pour mettre en application le routage, on se propose de créer une Web Api.

Installation de .NET Core sur Linux

Pour installer .NET Core sur Debian, il faut suivre les étapes suivantes:

Il faut ensuite générer un squelette de Web Api en exécutant la commande:

user@debian:~/% dotnet new webapi --name webapi_example

Pour avoir Swagger et requêter facilement la Web API, on peut ajouter le package NuGet Swashbuckle.AspNetCore en exécutant la commande suivante:

user@debian:~/% dotnet add webapi_example/webapi_example.csproj package swashbuckle.aspnetcore

Pour configurer Swashbuckle, il faut ajouter les lignes suivantes dans StartUp.cs:

public class Startup 
{ 
  public void ConfigureServices(IServiceCollection services) 
  { 
    services.AddSwaggerGen(c => 
    { 
        // Permet de préciser de la documentation 
        c.SwaggerDoc("v1", new Info { Title = "My API", Version = "v1" }); 
    });
  } 
  
  public void Configure(IApplicationBuilder app, IHostingEnvironment env) 
  { 
    app.UseSwagger(); 
  
    app.UseSwaggerUI(c => 
    { 
        c.SwaggerEndpoint("/swagger/v1/swagger.json", "My simple API V1"); 
    });
  } 
} 

On ajoute les controllers suivants dans le répertoire Controllers:

On ajoute les services suivants après avoir créé le répertoire Services:

On enregistre les services dans le container d’injection de dépendances en ajoutant ces lignes dans StartUp.ConfigureServices():

public class Startup 
{ 
    public void ConfigureServices(IServiceCollection services) 
    { 
        services.AddSingleton<IPizzaFlavourRepositoryService>(new PizzaFlavourRepositoryService()); 
        services.AddSingleton<IPizzaOrderRepositoryService, PizzaOrderRepositoryService>(); 
    } 
} 
} 

On lance la compilation et l’exécution en exécutant successivement les lignes:

user@debian:~/% cd webapi_example
user@debian:~/webapi_example/% dotnet build
user@debian:~/webapi_example/% dotnet run

On se connecte ensuite, avec un browser à l’adresse: http://localhost:5000/swagger/index.html.

Il est, ainsi, possible de requêter les fonctions des controllers PizzaFlavour et PizzaOrder avec Swagger.

Le code de cet exemple se trouve dans le repository GitHub suivant: https://github.com/msoft/webapi_example.

Routage avec des attributs

L’exemple dans la branche principale permet d’illustrer le routage avec attributs:

  • Avec les attributs RouteAttribute et ApiControllerAttribute sur les controllers par exemple:
    [Route("api/[controller]")] 
    [ApiController]
    public class PizzaOrderController : ControllerBase 
    { 
        // ... 
    } 
    
  • Avec les attributs HttpGetAttribute, HttpDeleteAttribute et HttpPostAttribute sur les functions correspondant aux actions, par exemple:
    [HttpGet]
    public ActionResult<IEnumerable<OrderedPizza>> GetOrderedPizzas() 
    { 
        // ... 
    } 
    
    [HttpPost("{pizzaFlavour}")]
    public ActionResult<int> AddNewOrder(string pizzaFlavour) 
    { 
        // ... 
    }
    

Routage avec template

La branche “usingRoutes” contient un exemple de routage en utilisant un template. Dans Startup.Configure(), on définit quelques templates:

app.UseMvc(routes => { 
    routes.MapRoute("secure", "secure", new { Controller = "Admin", Action="GetOrders"});  
    routes.MapRoute("admin", "{Controller=Admin}/{Action}/{id?}");  
    routes.MapRoute("default", "api/{Controller=PizzaOrder}/{Action=GetOrders}/{id?}"); 
}); 

Ainsi:

  • La route "secure" permet d’appeler toujours le controller AdminController avec l’action GetOrders. Pour l’invoquer il suffit d’effectuer une requête GET à l’adresse: http://localhost:5000/secure
  • La route "admin" permet de définir une route plus générale pour appeler le controller AdminController. Par exemple pour appeler l’action DeleteOrder, il faut effectuer une requête GET à l’adresse http://localhost:5000/admin/DeleteOrder/1
  • La route "default" définit une route plus générale pour appeler les controllers PizzaFlavourController et PizzaOrderController.

    Par exemple pour appeler l’action FindFlavour dans le controller PizzaFlavourController, il faut effectuer une requête GET à l’adresse http://localhost:5000/PizzaFlavour/FindFlavour/Regina

Utiliser cURL ou postman

Dans cet exemple, pour illustrer l’utilisation des templates, on a supprimé les attributs dans les controller AdminController et PizzaFlavourController. A cause de cette suppression, les fonctions ne sont plus visibles dans Swagger toutefois elles sont toujours fonctionnelles. Pour les invoquer, il faut utiliser:

  • cURL: par exemple exécutant à la ligne de commandes:
    curl -X GET "http://localhost:5000/PizzaFlavour/FindFlavour/Regina" -H  "accept: text/plain"
  • postman

Définir un route handler spécifique

On peut implémenter un routage plus personnalisé en implémentant une classe satisfaisant IRouter:

namespace Microsoft.AspNetCore.Routing 
{ 
    public interface IRouter 
    { 
        VirtualPathData GetVirtualPath(VirtualPathContext context); 
        Task RouteAsync(RouteContext context); 
    } 
} 

Dans la classe:

  • GetVirtualPath() permet de générer des url en fonction d’une route.
  • RouteAsync() permet d’indiquer un routage particulier en fonction d’une logique implémentée.

Dans l’exemple, l’implémentation de la classe satisfaisant IRouter est:

public class CustomRouter : IRouter 
{ 
    private IRouter _defaultRouter; 
    public CustomRouter(IRouter defaultRouter) 
    { 
        _defaultRouter = defaultRouter; 
    } 

    public VirtualPathData GetVirtualPath(VirtualPathContext context) 
    { 
        return _defaultRouter.GetVirtualPath(context); 
    } 

    public async Task RouteAsync(RouteContext context) 
    { 
         var path = context.HttpContext.Request.Path.Value; 

         if (path.Contains("admin")) 
         { 
             context.RouteData.Values["controller"] = "Admin"; 
             context.RouteData.Values["action"] = "GetOrders"; 

             await _defaultRouter.RouteAsync(context); 
         } 
    } 
} 

Cette classe va invoquer le controller AdminController si l’URL contient "admin".

Pour que cette classe soit prise en compte, il faut le configurer dans Startup.Configure() en précisant une autre route par défaut:

app.UseMvc(routes => 
{ 
    routes.Routes.Add(new CustomRouter(routes.DefaultHandler));
    routes.MapRoute( 
        name: "default", 
        template: "{controller=Home}/{action=Index}/{id?}"); 
}); 
A partir d’ASP.NET Core 2.2

Le middleware permettant d’effectuer le routage n’est plus le même à partir d’ASP.NET Core 2.2. Jusqu’à ASP.NET Core 2.1, le routage de l’URL vers le controller et l’action était effectué au niveau du middleware MVC. A partir d’ASP.NET Core 2.2, le routage est fait plus en amont et avant l’exécution du middleware MVC dans le pipeline, il est effectué par un middleware spécial appelé “Endpoint Routing”.

Comme on peut voir sur les schémas suivants:

ASP.NET Core ≤ 2.1 ASP.NET Core ≥ 2.2
Source: rolandguijt.com
Source: rolandguijt.com

Ainsi, à partir d’ASP.NET Core 2.2, il faut ensuite désactiver le routage par point de terminaison pour que le routage s’effectue avec IRouter avec la ligne suivante:

public void ConfigureServices(IServiceCollection services) 
{ 
    services.AddMvc(options => options.EnableEndpointRouting = false)
        .SetCompatibilityVersion(CompatibilityVersion.Version_2_2); 
 
    // ... 
} 
Références
Share on FacebookShare on Google+Tweet about this on TwitterShare on LinkedInEmail this to someonePrint this page

Importer des modules externes en Typescript

Le but de cet article est d’illustrer l’import de bibliothèques externes Javascript dans du code Typescript. Il fait suite à un article précédent qui expliquait comment on pouvait séparer le code Typescript en modules (cf. Les modules en Typescript en 5 min).

Le compilateur Typescript permet de générer du code Javascript exécutable sur tous les browsers. Un des gros intérêts d’implémenter en Typescript est d’abord d’avoir un typage fort et ensuite de permettre au compilateur d’effectuer une vérification syntaxique. Le code Javascript généré est ainsi plus robuste puisque une vérification des types a déjà été effectuée à la compilation.

L’écosystème Javascript est très riche en bibliothèques. Ces bibliothèques sont couramment utilisées pour enrichir une application Javascript. Toutefois la plupart d’entre elles ne sont pas implémentées en Typescript. Heureusement il existe quelques méthodes pour utiliser ces bibliothèques Javascript et continuer à tirer partie des avantages de la compilation Typescript. Dans cet article, on va présenter quelques méthodes les plus courantes.


Dans un premier temps, on va indiquer la solution utilisée pour permettre l’import de bibliothèques externes au niveau de la syntaxe Typescript. Dans un 2e temps, on indiquera les méthodes les plus courantes pour mettre en pratique cette solution.

Préambule

Avant de rentrer dans les détails, on va expliquer comment il est possible d’importer du code Javascript dans du code Typescript.

Mot-clé “declare”

Comme on l’a indiqué plus haut, le compilateur Typescript effectue une vérification des types des objets. Le type de tous les objets ainsi que celui les dépendances sont vérifiés, il devient alors plus compliqué d’importer du code Javascript qui n’est pas obligatoirement fortement typé. Pour palier à ce problème, le mot-clé declare permet de déclarer des variables ne provenant pas de code Typescript. Il donne ainsi la possiblité d’introduire dans du code Typescript, des types provenant de code Javascript. Le compilateur n’ira pas effectuer des vérifications dans le code Javascript, toutefois il prendra en compte le type déclaré pour vérifier le code Typescript.

Par exemple si on écrit:

declare var externalLibrary; 

On peut introduire une variable nommée externalLibrary de type any qui pourra être utilisée dans le code Typescript comme si on l’avait déclaré de cette façon:

var externalLibrary: any; 

Le code Javascript généré est le même, toutefois utiliser declare permet d’indiquer qu’il s’agit de l’import d’un objet défini de façon externe.

Il faut avoir à l’esprit que declare permet seulement d’indiquer au compilateur le type d’une variable sans explicitement indiquer l’implémentation. Il part du principe que l’implémentation Javascript correspondante devra être fournie à l’exécution.
Ainsi en fonction des déclarations de types indiquées avec declare, le compilateur ne fera que vérifier la syntaxe Typescript, il ne “transpile” en Javascript que le code Typescript. Si l’implémentation Javascript correspondant aux types déclarés n’est pas présente, l’exécution provoquera des erreurs.

Le mot-clé declare permet de déclarer tous les types d’objets comme le ferait, par exemple, une interface par rapport à une classe.

Pour déclarer une variable de type any:

declare var variableName: any; 

Pour déclarer la signature d’une fonction:

declare function DecodeValue(valueName: string): void; 

Pour déclarer une classe:

declare class Person { 
     constructor(name: string, firstName: string); 
     showPersonName(): void; 
} 

Pour déclarer un module ou un namespace:

declare namespace ExternalDependency { 
    class Person { 
         constructor(name: string, firstName: string); 
         showPersonName(): void; 
    } 

    class Player { 
         constructor(person: Person); 
         showPlayerName(): void; 
    } 

    function CreatePlayer(name: string, firstName: string): Player; 
} 
module et namespace sont équivalents

Les mot-clés module et namespace sont équivalents (cf. Namespaces en Typescript), on peut aussi écrire:

declare module ExternalDependency { 
   ... 
} 

On peut associer les mot-clé export et declare pour indiquer l’export d’un module et de tous les éléments qui s’y trouvent.

Ainsi:

export declare namespace ExternalDependency { 
    class Person { 
         constructor(name: string, firstName: string); 
         showPersonName(): void; 
    } 

    class Player { 
         constructor(person: Person); 
         showPlayerName(): void; 
    } 

    function CreatePlayer(name: string, firstName: string): Player; 
} 

Est équivalent à:

export namespace ExternalDependency { 
    export class Person { 
         constructor(name: string, firstName: string); 
         showPersonName(): void; 
    } 
 
    export class Player { 
         constructor(person: Person); 
         showPlayerName(): void; 
    } 

    export function CreatePlayer(name: string, firstName: string): Player; 
} 

Fichier de définition (declaration file)

Dans le cas d’une bibliothèque, tous les types Javascript peuvent être déclarés dans un seul fichier appelé “fichier de définition” (i.e. declaration file). Ces fichiers ont usuellement l’extension .d.ts toutefois rien n’oblige à utiliser cette extension. N’importe quelle déclaration peut être implémentée dans un fichier .ts.

Les fichiers de définition .d.ts peuvent être référencés avec une directive triple-slash (cf. Directive “triple-slash”) au même titre qu’un fichier .ts normal, par exemple:

/// <reference path="declarationFile.d.ts" />  

Ainsi quand on doit utiliser une bibliothèque Javascript dans du code Typescript, on peut référencer les fichiers de définition correspondant à cette bibliothèque.

Générer un fichier de définition

Le compilateur Typescript permet de générer un fichier de définition à partir du code Typescript en écrivant:

tsc --declaration <chemin des fichiers .ts>

Ou

tsc –d <chemin des fichiers .ts>

On peut préciser un répertoire de sortie pour ces fichiers:

tsc --declaration <chemin des fichiers .ts> --declarationDir <répertoire de sortie>

Exemple d’utilisation de “declare”

On se propose d’illustrer l’import d’un fichier de définition avec un exemple. Le but de cet exemple est d’importer du code Javascript dans du code Typescript en utilisant un fichier de définition. Le code de la bibliothèque se trouve dans les fichiers dependency.ts ou dependency.js.

Code sur GitHub

Le code de cet article se trouve dans le repository GitHub suivant:
github.com/msoft/external_typescript_modules.
Le code se trouve dans des branches différentes suivant la partie de l’article qu’il illustre:

  • Branche 1_initial: code initial permettant de compiler du code Typescript.
  • Branche 2_ExternalDependency: exemple d’utilisation de declare.
  • Branche 3_webpack_initial: code initial permettant de compiler avec webpack.
  • Branche 4_webpack_any: exemple d’import de modules Javascript avec any.
  • Branche 5_webpack_npm_types: exemple d’import de modules Javascript avec le domaine @types de npm.
  • Branche 6_webpack_typings: exemple d’import de modules Javascript avec typings.

Le code de cet exemple se trouve dans la branche 1_initial du repository msoft/external_typescript_modules sur GitHub.

Ainsi on considère le code suivant permettant d’importer un module:

import { Person, Player, CreatePlayer } from "./dependency.js";

class Startup { 
    public static main(): number { 
        var player = new Player(new Person('Buffon', 'Gianluigi')); 
        player.showPlayerName(); 

        return 0; 
    } 
} 

Startup.main(); 

Le module se trouve dans le fichier dependency.ts (la directive d’import utilise l’extension .js dans le code Typescript car le compilateur ne change pas les extensions à la compilation(1) (2)).

Le fichier dependency.ts contient le code suivant:

export class Person { 
    constructor(private name: string, private firstName: string) { 
    } 

    public showPersonName(): void { 
        console.log("Name: " + this.name + "; First Name: " + this.firstName);  
    }
}
 
export class Player { 
    constructor(private person: Person) { 
    } 

    public showPlayerName(): void { 
        this.person.showPersonName();  
    } 
} 

export function CreatePlayer(name: string, firstName: string): Player { 
    return new Player(new Person(name, firstName)); 
} 

Le fichier public/index.html qui va permettre de lancer l’exécution (il ne fait que déclarer les fichiers Javascript contenant le code) est:

<!DOCTYPE html> 
<html lang="en"> 
    <head> 
        <meta charset="UTF-8">  
        <title>External module import</title> 
    </head>    
    <body> 
        <script type="module" src="dependency.js"    ></script>
        <script type="module" src="index.js"    ></script>
    </body> 
</html> 

Pour exécuter cet exemple, il faut:

  1. Cloner le repository en exécutant les instructions:
    ~% git clone https://github.com/msoft/external_typescript_modules.git
    ~/external_typescript_modules/% cd external_typescript_modules
    ~/external_typescript_modules/% git checkout 2_ExternalDependency
    
  2. Compiler en exécutant les instructions suivantes dans le répertoire de l’exemple:
    ~/external_typescript_modules/% npm install 
    ~/external_typescript_modules/% npm run build 
    
  3. Lancer l’exécution ensuite avec:
    ~/external_typescript_modules/% npm start 
    
  4. Ouvrir un browser à l’adresse http://127.0.0.1:8080 puis ouvrir la console de développement.
Pour afficher la console de développement dans un browser

Pour tous les exemples présentés dans cet article, pour voir les résultats d’exécution, il faut afficher la console de développement:

  • Sous Firefox: on peut utiliser la raccourci [Ctrl] + [Maj] + [J] (sous MacOS: [⌘] + [Maj] + [J], sous Linux: [Ctrl] + [Maj] + [K]) ou en allant dans le menu “Développement web” ⇒ “Console du navigateur”.
  • Sous Chrome: utiliser le raccourci [F12] (sous MacOS: [⌥] + [⌘] + [I], sous Linux: [Ctrl] + [Maj] + [I]) puis cliquer sur l’onglet “Console”. A partir du menu, il faut aller dans “Plus d’outils” ⇒ “Outils de développement”.
  • Sous EDGE: utiliser le raccourci [F12] puis naviguer jusqu’à l’onglet “Console”.

Le résultat de l’exécution est du type:

Name: Buffon; First Name: Gianluigi 

On modifie le code de dependency.ts pour qu’il ne contienne que des déclarations de types (on supprime toutes les implémentations):

  1. On modifie le code de cette façon:
    declare namespace ExternalDependency { 
            class Person { 
                constructor(name: string, firstName: string); 
                showPersonName(): void;  
            } 
    
            class Player { 
                constructor(person: Person); 
                showPlayerName(): void; 
            } 
    
            function CreatePlayer(name: string, firstName: string): Player;     
    } 
    
  2. On déplace ensuite ce fichier dans le répertoire ExternalDependency/index.d.ts:
    ~/external_typescript_modules/% mkdir ExternalDependency 
    ~/external_typescript_modules/% mv dependency.ts ExternalDependency/index.d.ts 
    
  3. On importe ensuite ce fichier dans index.ts en utilisant une directive triple-slash et en supprimant la directive d’import de module:
    /// <reference path="ExternalDependency/index.d.ts" />
    
    class Startup { 
        public static main(): number { 
            var player = ExternalDependency.CreatePlayer('Buffon', 'Gianluigi'); 
            player.showPlayerName(); 
    
            return 0; 
        } 
    } 
    
    Startup.main(); 
    
  4. Si on compile, on s’aperçoit qu’il n’y a pas d’erreurs de compilation. Il n’existe plus d’implémentation des classes Player et Person pourtant la compilation se passe correctement.
  5. En revanche si on tente d’exécuter le code en rafraichissant le browser:
    index.js:24 Uncaught ReferenceError: ExternalDependency is not defined 
        at Function.Startup.main (index.js:24) 
        at index.js:30 
    

    Ceci s’explique par le fait, qu’il n’y a pas de références vers le code Javascript correspondant aux classes Player et Person.

  6. On modifie le code de dependency.ts pour encapsuler les classes Player et Person dans un module et on exporte seulement la fonction CreatePlayer():
    module ExternalDependency { 
        class Person { 
            constructor(private name: string, private firstName: string) { 
            } 
    
            public showPersonName(): void { 
                console.log("Name: " + this.name + "; First Name: " + this.firstName);  
            } 
        }
    
        class Player { 
            constructor(private person: Person) { 
            }
    
            public showPlayerName(): void { 
                this.person.showPersonName();  
            } 
        } 
    
        export function CreatePlayer(name: string, firstName: string): Player { 
            return new Player(new Person(name, firstName)); 
        } 
    } 
    
  7. On lance la compilation en exécutant:
    npm run build 
    
  8. Pour rajouter une référence vers le code Javascript des classes Player et Person, on déplace au bon endroit le fichier Javascript dependency.js compilé précédemment:
    ~/external_typescript_modules/% mkdir public/ExternalDependency 
    ~/external_typescript_modules/% mv public/dependency.js public/ExternalDependency/index.js 
    
  9. On modifie ensuite le fichier public/index.html pour qu’il ne référence plus les fichiers Javascript sous forme de module ES6 (cf. Utilisation des modules ES2015):
    <!DOCTYPE html> 
    <html lang="en"> 
        <head> 
            <meta charset="UTF-8">  
            <title>External module import</title> 
        </head>    
        <body> 
            <script src="ExternalDependency/index.js"    ></script> 
            <script src="index.js"    ></script>
        </body> 
    </html> 
    
  10. Après avoir rafraîchi le browser, le résultat est le même que précédemment:
    Name: Buffon; First Name: Gianluigi 
    

Le but de cet exemple était d’illustrer l’utilisation d’un fichier de définition de façon à comprendre plus facilement leur utilisation par la suite.

Comment utiliser des fichiers de définition ?

Comme indiqué plus haut, ces fichiers servent à déclarer des types sans préciser l’implémentation. En effet l’implémentation de ces types est en Javascript et sera utilisable seulement pendant l’exécution. Les fichiers de définition contiennent le code Typescript permettant au compilateur de faire une vérification des types.

Ainsi la plupart des bibliothèques Javascript téléchargeables sous forme de modules avec npm possèdent des fichiers de définition. Il existe plusieurs façon d’obtenir ces fichiers. On va indiquer 2 méthodes pour télécharger ces fichiers.

Exemple avec jQuery et DataTables

Pour illustrer ces différentes méthodes, on se propose d’utiliser un exemple dans lequel on utilise jQuery et DataTables. L’exemple permet de remplir un tableau avec 2 lignes. En dessous du tableau se trouve un bouton. Si on clique sur ce bouton, le contenu de la cellule à la 2e ligne et 2e colonne est modifié.

Pour utiliser l’exemple:

  1. On commence à partir d’un “squelette” vide ne contenant que webpack. Webpack est un outil permettant de compiler le code Typescript dans un seul fichier Javascript (appelé bundle). Webpack permet aussi d’exécuter le code en utilisant un serveur web de développement.

    Le “squelette” initial se trouve dans le branche 3_webpack_initial du repository GitHub msoft/external_typescript_modules.
    Le code final de cet exemple se trouve dans la branche 4_webpack_any.

    On récupère la branche 3_webpack_initial en exécutant:

    ~/external_typescript_modules/% git checkout 3_webpack_initial
    
  2. On installe tous les composants y compris webpack en exécutant la ligne suivante:
    ~/external_typescript_modules/% npm install 
    
  3. On ajoute les bibliothèques jQuery et DataTables en exécutant:
    ~/external_typescript_modules/% npm install jquery 
    ~/external_typescript_modules/% npm install datatables.net 
    
  4. A ce stade, si on tente d’utiliser du code jQuery dans index.ts, le code ne compilera pas:
    var data = [ 
                [ 
                    "Tiger Nixon", 
                    "System Architect", 
                    "Edinburgh", 
                    "5421", 
                    "2011/04/25", 
                    "$3,120" 
                ], 
                [ 
                    "Garrett Winters", 
                    "Director", 
                    "Edinburgh", 
                    "8422", 
                    "2011/07/25", 
                    "$5,300" 
                ] 
            ] 
    
            $(document).ready( function () { 
                var datatable = $('#table_id').DataTable({ 
                    data,
                }); 
            } ); 
    

Dans le code ci-dessus, jQuery est appelé avec l’instruction $(...) et le code de DataTables est appelé avec .DataTable(...).
Les instructions permettent de rajouter des données dans le tableau HTML nommé table_id se trouvant dans la page HTML index.html.

En lançant npm run build pour compiler, on obtient des erreurs de compilation:

ERROR in /home/user/external_typescript_modules/webpack_es6/index.ts 
./index.ts 
[tsl] ERROR in /home/user/external_typescript_modules/webpack_es6/index.ts(9,15) 
      TS2451: Cannot redeclare block-scoped variable '$'. 
ℹ 「wdm」: Failed to compile. 

Les méthodes suivantes permettent de corriger ces erreurs.

Utiliser “any”

Cette méthode est la plus simple toutefois elle est la plus risquée. Elle consiste à déclarer le type any pour l’objet de plus haut niveau dans la bibliothèque, par exemple en indiquant dans le fichier index.ts:

declare const $: any; 

L’erreur de compilation disparaît, toutefois il faut avoir en tête qu’il n’y aucune vérification de syntaxe sur toutes les déclarations suivant $. On peut écrire n’importe quoi après $, il n’y aura pas d’erreurs de compilation. Cette solution est donc à utiliser pour tester rapidement une bibliothèque mais elle est à proscrire pour produire du code de production.

Installer les fichiers de définition avec npm

Le domaine types de la commande npm permet d’installer les fichiers de définition dans le répertoire node_modules/@types/<nom du package>. Pour installer ces fichiers, on peut exécuter la commande:

npm install @types/<nom du package> --save-dev 

Cette commande va installer les fichiers de définition et indique ce package dans la partie devDependencies du fichier package.json.

Dans la plupart des cas pour télécharger les fichiers de définition pour un package donné, il suffit d’installer le package avec le nom @types/<nom du package>. Ce n’est pas toujours le cas, ainsi pour retrouver le package contenant les fichiers de définition pour un package donné, on peut utiliser TypeSearch.

Il faut privilégier l’installation de fichiers de définition avec npm

Cette méthode est actuellement la méthode la plus usuelle pour télécharger les fichiers de définition. Cette fonctionnalité n’est disponible qu’à partir de Typescript 2.0.

Les fichiers de définition dans le domaine @types de npm proviennent du repository GitHub DefinitelyTyped qui est un référentiel contenant les fichiers de définition pour les packages les plus courants.

Dans le cas de notre exemple, on peut installer les fichiers de définition de jQuery et DataTables en exécutant:

~/external_typescript_modules/% npm install @types/jquery --save-dev 
~/external_typescript_modules/% npm install @types/datatables.net --save-dev 

On peut remarquer que la compilation réussit après l’installation des fichiers de définition (après avoir exécuté npm run build).

Le code final de cet exemple se trouve dans la branche 5_webpack_npm_types du repository GitHub msoft/external_typescript_modules.

Installer les fichiers de définition avec typings

Pour des versions de Typescript antérieures à la version 2.0 ou pour obtenir des fichiers de définition pour des packages qui ne sont pas disponibles dans le domaine @types de npm, on peut passer par typings.

typings est un outil disponible avec la ligne de commandes qui possède de nombreuses fonctionnalités pour télécharger les fichiers de définition à partir de sources différentes.

Import sous forme de module externe

Par défaut typings considère que l’import des types de définition se fait sous forme de module externe Typescript. Typings encapsule, ensuite la définition des types dans un module avec des déclarations du type:

declare module '<nom du module>' { 
     // Définition du type 
     // ... 
} 

Pour consommer le type dans le code Typescript, on peut utiliser des alias en utilisation la syntaxe:

import * as <alias utilisé> from '<nom du module>' 

Un des intérêts de cette méthode est de pouvoir utiliser des versions différentes de fichiers de définition correspondant à des versions différentes de package. On peut, ainsi, utiliser un alias par version.

Dépendances globales

Typings considère certaines définitions de types comme étant globales pour différentes raisons:

  • Soit parce-qu’il ajoute les types au scope global,
  • Soit il ajoute des éléments nécessaires pour effectuer les builds (comme webpack ou browserify)
  • Soit il ajoute des éléments nécessaires à l’exécution (par exemple comme Node.js).

Pour installer des définitions globales, il faut utiliser l’option --global. Typings indique si l’installation de types doit se faire obligatoirement de façon globale.

Utiliser typings

Pour installer typings, on utilise npm en exécutant la commande:

npm install typings --global  

Cette commande ajoute l’utilitaire typings dans le répertoire global de façon à ce qu’elle soit disponible sur la ligne de commandes.

Quelques commandes courantes de typings

Une fois que typings est installé, on peut l’utiliser directement à la ligne de commandes:

  • Pour chercher des définitions correspondant à un package à partir de son nom, on peut taper:
    typings search --name <nom du package> 
    
  • Pour chercher en fonction d’un mot clé:
    typings search <éléments recherchés> 
    
  • Pour installer des définitions à partir du nom du package:
    typings install <nom du package> --save 
    

    L’option --save permet d’enregistrer le nom du package pour lequel les définitions ont été téléchargées dans le fichier typings.json dans le nœud json:

    • "dependencies" pour les packages externes classiques
    • "globalDependencies" pour les packages installés de façon globale.
  • Pour installer les définitions de façon globale, il faut rajouter l’option --global:
    typings install <nom du package> --save --global 
    
  • Pour indiquer la source:
    typings install <nom de la source>~<nom du package> --save 
    

    Ou

    typings install <nom du package> --source <nom de la source> --save 
    

    Par défaut, la source de typings est npm.

    D’autres sources sont possibles, par exemple:

    • github pour récupérer des dépendances directement de GitHub (par exemple: Duo, JSPM).
    • bower pour la source Bower.
    • env pour des environments particulier (par exemple atom, electron). Il faut rajouter l’option --global.
    • dt pour la source DefinitelyTyped. Il faut rajouter l’option --global.
  • Pour installer une version spécifique:

    typings install <nom du package>@<version à installer> 
    

Dans le cas de notre exemple, il faut d’abord installer typings de façon globale en exécutant la commande:

~/external_typescript_modules/% npm install typings --global

On installe ensuite les fichiers de définition de façon globale et en utilisant la source dt:

~/external_typescript_modules/% typings install jquery --source dt --save --global 
~/external_typescript_modules/% typings install datatables.net --source dt --save --global 

Sans aucune mention supplémentaire, les types sont reconnus dans le code Typescript. Si on regarde les fichiers de types dans le répertoire typings, on remarque que le fichier typings/index.d.ts contient les lignes suivantes:

/// <reference path="globals/datatables.net/index.d.ts" /> 
/// <reference path="globals/jquery/index.d.ts" /> 

Ces lignes font des références vers les fichiers de définition pour respectivement:

  • jQuery dans typings/globals/jquery/index.d.ts
  • DataTables dans typings/globals/datatables.net/index.d.ts

De même que précédemment, avec l’ajout de ces fichiers de définition, la compilation réussit.

Le code final de cet exemple se trouve dans la branche 6_webpack_typings du repository GitHub msoft/external_typescript_modules.

Conclusion

L’import de bibliothèques Javascript externes dans du code Typescript est quasiment incontournable. La méthode la plus usuelle pour intégrer des bibliothèques courantes est d’importer des fichiers de définition avec npm. D’autres méthodes existent, toutefois elles sont réservées aux cas particuliers. Par exemple quand on souhaite utiliser une version non disponible avec npm ou quand simplement les fichiers de définition ne sont pas fournis.
Cet article a tenté d’illustrer le plus simplement, l’utilisation du mot-clé declare et l’import de fichiers de définition avec npm.

(1) – Add js extension to import/export: https://github.com/Microsoft/TypeScript/issues/18971
(2) – Provide a way to add the ‘.js’ file extension to the end of module specifiers: https://github.com/Microsoft/TypeScript/issues/16577

Références
Share on FacebookShare on Google+Tweet about this on TwitterShare on LinkedInEmail this to someonePrint this page

Implémentation d’un Timer avec TPL

Dans cet article, on cherche à proposer une implémentation d’un Timer en utilisant TPL (i.e. Task Parallel Library). Il existe une classe qui permet d’effectuer un traitement de façon périodique: System.Threading.Timer. Cette classe n’est pas très moderne puisqu’elle existe depuis les premières versions du Framework. Elle permet d’effectuer correctement un traitement périodique toutefois elle souffre de quelques défauts:

  • Cette classe est difficilement testable: les traitements asynchrones peuvent être difficiles à tester. C’est encore plus vrai avec System.Threading.Timer puisqu’on ne maitrise pas les itérations, elles sont déclenchées par l’écoulement du temps. Si on veut tester une périodicité de 30 min, il faut attendre 30 min que l’itération s’exécute.
  • Elle ne permet pas de tirer partie des avantages de TPL: gestion des exceptions, gestion de l’annulation de l’exécution, continuation, options de création des Task etc…

Il existe d’autres implémentations d’une tâche périodique toutefois elles présentent les mêmes inconvénients que System.Threading.Timer:

  • System.Timers.Timer
  • System.Windows.Forms.Timer utilisable dans le cadre de Windows Forms.
  • System.Windows.Threading.DispatcherTimer en WPF.

Le code correspondant à cet article se trouve dans le repository GitHub msoft/PeriodicTask.

Exemple avec System.Threading.Timer

Comme indiqué plus haut, on souhaite utiliser la flexibilité de TPL pour apporter une implémentation d’une tâche périodique.

Avant de commencer, voici un exemple rapide de l’implémentation d’une tâche périodique avec System.Threading.Timer (cf. classe SimpleTimerUsage). Cet exemple sert de base pour l’implémentation avec TPL. Le code “métier” exécuté de façon périodique est dans ExecuteJob():

public class SimpleTimerUsage : IDisposable 
{ 
    private readonly ILogger logger; 
    private Timer timer; 
     
    public SimpleTimerUsage(ILogger logger, int periodicity) 
    { 
        this.logger = logger; 
        this.timer = new Timer(ExecuteJob, null, 0, periodicity); 
    } 

    #region IDisposable member 
     
    public void Dispose() 
    { 
        if (this.timer != null) 
        { 
            this.timer.Dispose(); 
            this.timer = null; 
        } 
         
        GC.SuppressFinalize(this); 
    } 

    #endregion 

    private void ExecuteJob(object stateInfo) 
    { 
        this.logger.Info("Executing job..."); 
        Thread.Sleep(100); 
        this.logger.Info("Job executed"); 
    }
} 

Cette classe permet de lancer un timer à l’instanciation pour exécuter le méthode ExecuteJob(). La classe est disposable pour permettre de stopper l’exécution du timer.

Pour lancer l’exécution, il suffit d’instancier la classe:

var logger = NLog.LogManager.GetCurrentClassLogger(); 

var timerUsage = new SimpleTimerUsage(logger, 3000); 

Console.ReadLine(); 

Pour exécuter le code correspondant à cet exemple, il faut compiler le projet PeriodicTaskCore avec .NET Core après avoir cloné le repository GitHub:

user@debian:~% git clone https://github.com/msoft/PeriodicTask.git
user@debian:~% cd PeriodicTask
user@debian:~/PeriodicTask/% dotnet build

Pour exécuter le projet:

user@debian:~/PeriodicTask/% cd PeriodicTaskCore
user@debian:~/PeriodicTask/PeriodicTaskCore/% dotnet run

Pour plus de détails sur les commandes de la CLI .NET Core: “Commandes courantes de la CLI .NET Core”.

Sans surprise, la fonction ExecuteJob() est exécutée de façon périodique:

2018-11-17 05:09:31.7410|INFO|PeriodicTaskCore.Program|Executing job...
2018-11-17 05:09:31.9083|INFO|PeriodicTaskCore.Program|Job executed
2018-11-17 05:09:34.9134|INFO|PeriodicTaskCore.Program|Executing job...
2018-11-17 05:09:35.0160|INFO|PeriodicTaskCore.Program|Job executed
2018-11-17 05:09:38.0172|INFO|PeriodicTaskCore.Program|Executing job...
2018-11-17 05:09:38.1187|INFO|PeriodicTaskCore.Program|Job executed
2018-11-17 05:09:41.1214|INFO|PeriodicTaskCore.Program|Executing job...
2018-11-17 05:09:41.2270|INFO|PeriodicTaskCore.Program|Job executed
2018-11-17 05:09:44.2295|INFO|PeriodicTaskCore.Program|Executing job...
2018-11-17 05:09:44.3335|INFO|PeriodicTaskCore.Program|Job executed

Implémentation d’une tâche périodique avec TPL

Pour implémenter une tâche périodique avec TPL, on peut créer une Task avec une boucle infinie:

private void ExecuteJobPeriodically() 
{ 
    while (true) 
    { 
        Task.Delay(this.periodicity.Value).Wait();
        this.ExecuteJobOnce(); 
    } 
}

private void ExecuteJobOnce() 
{ 
    this.logger.Info("Executing job..."); 
    Thread.Sleep(100); 
    this.logger.Info("Job executed"); 
}
 

Ainsi la boucle infinie exécute ExecuteJobOnce() comme précédemment. A chaque itération on attends le temps correspondant à la périodicité avec Task.Delay() puis on exécute le traitement en lançant:

ExecuteJobOnce().

Pour lancer l’exécution, il suffit de créer la Task et de la lancer de cette façon:

public void LaunchJob() 
{ 
    this.timer = Task.Run(() =>  
    { 
        this.ExecuteJobOnce(); 
        this.ExecuteJobPeriodically(); 
    }, TaskCreationOptions.LongRunning); 
} 

On lance l’exécution du traitement à l’instanciation de la Task puis de façon périodique par la suite.

Gestion de l’annulation

En introduisant la possiblité d’annuler l’exécution en utilisant un CancellationToken, l’implémentation devient:

private readonly CancellationTokenSource cancellationTokenSource = new CancellationTokenSource(); 

public void LaunchJob() 
{ 
    var cancellationToken = this.cancellationTokenSource.Token; 
    if (!this.IsTimerRunning()) 
    { 
        this.timer = Task.Run(() =>  
        { 
            this.ExecuteJobOnce(cancellationToken); 
            this.ExecuteJobPeriodically(cancellationToken); 
        }, cancellationToken, TaskCreationOptions.LongRunning); 
    } 
} 

private void ExecuteJobPeriodically(CancellationToken cancellationToken) 
{ 
    while (true) 
    { 
        Task.Delay(this.periodicity.Value, cancellationToken).Wait(cancellationToken); 
        cancellationToken.ThrowIfCancellationRequested(); 
        this.ExecuteJobOnce(cancellationToken); 
    } 
} 

private void ExecuteJobOnce(CancellationToken cancellationToken) 
{ 
    if (cancellationToken.IsCancellationRequested) return; 

    this.ExecutePeriodicTask(cancellationToken); 
} 

Pour annuler l’exécution, il suffit d’exécuter:

cancellationTokenSource.Cancel(); 

Ajout d’un traitement en cas d’annulation et de d’exception

Si l’exécution de la Task provoque une erreur et si elle est annulée, on souhaite effectuer un traitement comme par exemple logguer un message. Ce type de traitement peut être effectué en utilisation des continuations:

protected void CreateAndLaunchTimer() 
{ 
    var cancellationToken = this.cancellationTokenSource.Token; 
    if (!this.IsTimerRunning()) 
    { 
        this.timer = new Task(() =>  
        { 
            this.ExecuteJobOnce(cancellationToken); 
            this.ExecuteJobPeriodically(cancellationToken); 
        }, cancellationToken, TaskCreationOptions.LongRunning); 

        // Exécuté quand la Task provoque une exception 
        this.timer.ContinueWith(t =>  
        { 
            this.OnPeriodicTaskFaulted(t.Exception); 
        }, TaskContinuationOptions.OnlyOnFaulted);

        // Exécuté quand la task est annulé  
        this.timer.ContinueWith(t => 
        { 
            this.OnPeriodicTaskCanceled(); 
        }, TaskContinuationOptions.OnlyOnCanceled);

        // Exécuté quand la task s'arrête 
        this.timer.ContinueWith(t => 
        { 
            this.OnPeriodiTaskCompleted(); 
        }, TaskContinuationOptions.OnlyOnRanToCompletion);

        this.timer.Start(); 
    } 
} 

protected virtual void OnPeriodicTaskFaulted(AggregateException exception) 
{ 
    this.Logger.Error("Periodic task raised an exception: {0}", exception); 
} 

protected virtual void OnPeriodicTaskCanceled() 
{ 
    this.Logger.Info("Periodic task has been canceled."); 
} 

protected virtual void OnPeriodiTaskCompleted() 
{ 
    this.Logger.Info("Periodic task ended"); 
} 

On peut arrêter l’exécution en exécutant la méthode suivante, on ne fait que vérifier que la tâche n’est pas déjà stoppée puis on la stoppe:

public void StopPeriodicTask() 
{ 
    if (this.IsTimerRunning() && !this.cancellationTokenSource.Token.IsCancellationRequested) 
    { 
        this.OnPeriodicTaskStopping(); 
        this.cancellationTokenSource.Cancel(); 
        bool completed = false; 

        try 
        { 
            completed = this.timer.Wait(TimeSpan.FromSeconds(5)); 
        } 
        catch (AggregateException occuredException) 
        { 
            occuredException.Handle(ex =>  
            { 
                if (ex is TaskCanceledException || ex is OperationCanceledException) 
                { 
                    completed = true; 
                    return true; 
                } 
                return false; 
            }); 
        } 
    } 
} 

Avec:

private bool IsTimerRunning() 
{ 
    return !(this.timer == null || this.timer.IsFaulted || this.timer.IsCompleted); 
} 

Protéger le lancement de la tâche périodique

On peut ensuite ajouter un lock pour empêcher le lancement de la tâche plusieurs fois si la méthode LaunchJob() est exécutée par des threads différents. On modifie ainsi la méthode LaunchJob():

public void LaunchJob() 
{ 
    var cancellationToken = this.cancellationTokenSource.Token; 
    lock(this.timerCreationLock) 
    { 
        if (!this.IsTimerRunning()) 
        { 
            // ... 
        } 
    } 
} 

Avec:

private readonly object timerCreationLock = new object(); 

Si cette méthode est exécutée plusieurs fois ou par des threads différents, la Task sera créée et lancée de façon unique.

Encapsuler l’implémentation

Pour rendre l’implémentation plus réutilisable et permettre sa réalisation, on peut encapsuler le code dans une classe abstraite. Le code complet de cette classe est dans: PeriodicTask.cs.

Ainsi on peut utiliser la classe PeriodicTask en dérivant de cette classe et en implémentant le code “métier”, par exemple:

public class PeriodicTaskUsage : PeriodicTask 
{ 
    public PeriodicTaskUsage(ILogger logger, TimeSpan? periodicity) : 
        base(logger, periodicity) 
    { 
        // Lancement de la tâche périodique 
        this.LaunchJob();  
    } 
     
    protected override void ExecutePeriodicTask(CancellationToken cancellationToken) 
    { 
        this.Logger.Info("Executing job..."); 
        Thread.Sleep(100); 
        this.Logger.Info("Job executed"); 
    } 
} 

Pour lancer l’exécution, il suffit d’instancier la classe PeriodicTaskUsage:

var logger = NLog.LogManager.GetCurrentClassLogger(); 

var periodicTaskUsage = new PeriodicTaskUsage(logger,  
TimeSpan.FromMilliseconds(3000)); 

Console.ReadLine(); 

Le résultat de l’exécution est le même que précédemment.

Tester l’exécution du code “métier”

Comme indiqué en introduction, la classe System.Threading.Timer est difficilement testable. On souhaite pouvoir tester le code “métier” ainsi que la plus grande quantité de code dans la classe PeriodicTask. Le projet de test est PeriodicTaskTests.

Le code “métier” dans l’exemple se trouve dans la méthode:

protected override void ExecutePeriodicTask(CancellationToken cancellationToken) 
{ 
    this.Logger.Info("Executing job..."); 
    Thread.Sleep(100); 
    this.Logger.Info("Job executed"); 
} 

Pour tester ce code, on utilise le test suivant:

[TestMethod] 
public void When_executing_PeriodicTask_Then_DomainCode_Shall_Be_Executed() 
{ 
    var loggerMock = new Mock<ILogger>(); 
    
    var periodicTaskUsage = new PeriodicTaskForTest(loggerMock.Object); 
    
    // Exécution de la 1ere itération 
    Assert.IsTrue(periodicTaskUsage.ExecuteIteration().Wait(5000));
    
    // On peut effectuer des vérifications 
    loggerMock.Verify(l => l.Info("Executing job..."), Times.Once); 
    loggerMock.Verify(l => l.Info("Job executed"), Times.Once); 
    
    // Exécution d'une 2e itération 
    Assert.IsTrue(periodicTaskUsage.ExecuteIteration().Wait(5000));
    
    //etc... 
} 

Pour faciliter l’exécution des tests, on modifie la méthode PeriodicTask.ExecuteJobPeriodically() pour ne pas lancer la boucle infinie si une périodicité n’est pas précisée. On peut, ainsi, maitriser chaque itération à l’éxtérieur de la classe:

private void ExecuteJobPeriodically(CancellationToken cancellationToken) 
{ 
    if (!this.periodicity.HasValue) return;

    while (true) 
    { 
        Task.Delay(this.periodicity.Value, cancellationToken).Wait(cancellationToken); 
        cancellationToken.ThrowIfCancellationRequested(); 
        this.ExecuteJobOnce(cancellationToken); 
    } 
} 

Ainsi si on dérive de la classe PeriodicTaskUsage de cette façon (cf. classe PeriodicTaskForTest):

internal class PeriodicTaskForTest : PeriodicTaskUsage 
{ 
    public PeriodicTaskForTest(ILogger logger) : 
        base(logger, null) 
    {
    
    } 
    
    public async Task ExecuteIteration() 
    { 
        this.CreateAndLaunchTimer(); 
        
        await this.Timer; 
    }
} 

Avec cette implémentation, on est capable de maitriser l’exécution de chaque itération dans le test avec les lignes:

Assert.IsTrue(periodicTaskUsage.ExecuteIteration().Wait(5000));

L’implémentation de cet exemple se trouve dans le répository gitHub: msoft/PeriodicTask.

Pour l’exécuter, il faut aller dans le répertoire PeriodicTaskTests et lancer l’exécution des tests:

user@debian:~/PeriodicTask/% cd PeriodicTaskTests
user@debian:~/PeriodicTask/PeriodicTaskTests/% dotnet build
user@debian:~/PeriodicTask/PeriodicTaskTests/% dotnet test
Build started, please wait...
Build completed.

Test run for /home/user/PeriodicTask/PeriodicTaskTests/bin/Debug/netcoreapp2.0/PeriodicTaskTests.dll(.NETCoreApp,Version=v2.0)
Microsoft (R) Test Execution Command Line Tool Version 15.5.0
Copyright (c) Microsoft Corporation.  All rights reserved.

Starting test execution, please wait...

Total tests: 1. Passed: 1. Failed: 0. Skipped: 0.
Test Run Successful.
Test execution time: 2.1726 Seconds

Pour conclure…

Cette implémentation n’a rien de très novatrice mais elle permet de facilement mettre en place l’exécution d’une tâche périodique et de la tester. Il existe de nombreuses autres implémentations pour effectuer ce type de traitement. J’espère toutefois que cette implémentation vous a plu. Dans le cas contraire, n’hésiter pas à laisser un commentaire en indiquant les raisons, ça pourrait être intéressant de l’améliorer.

Share on FacebookShare on Google+Tweet about this on TwitterShare on LinkedInEmail this to someonePrint this page

Performance Monitor en 10 min

Performance Monitor (appelé aussi perfmon) est un outil de monitoring et d’alerte présent sur les plateformes Windows. Il permet de facilement observer des métriques concernant un processus spécifique ou les caractéristiques d’une machine. La plupart de temps, les développeurs oublient d’utiliser un outil comme celui-ci pour évaluer le comportement de leur application au cours du temps. Pourtant, observer l’évolution de métriques sur son application permet éventuellement de se rendre compte de mauvais comportements ou d’anomalies imprévues pouvant subvenir lors de l’exécution.

Le but de cet article est, dans un premier temps, de montrer les fonctionnalités principales de perfmon pour l’utiliser efficacement. Dans un 2e temps, on va expliciter les métriques utilisables qui permettent de faciliter le diagnostique de défauts d’une application. Enfin, on va montrer comment trouver les défauts les plus courants en combinant les différentes métriques.

Les screenshots dans cet article présente perfmon en français, toutefois les traductions anglaises sont indiquées dans le détail des éléments de configuration.

Les avantages les plus importants de perfmon sont:

  • Cet outil est déjà installé sur les systèmes Windows (desktop et server). Il n’est donc, pas nécessaire de l’installer.
  • Il permet de monitorer non seulement des processus classiques mais aussi de remonter des métriques plus spécifiques sur SqlServer ou IIS.
  • Il peut fonctionner en tant que service, sans qu’il soit nécessaire d’avoir une session ouverte. On peut donc laisser perfmon collecter ses métriques pendant plusieurs jours et ainsi, surveiller l’évolution de l’exécution d’une application ou de la machine qui l’exécute. Cette surveillance aide à anticiper les problèmes pouvant éventuellement subvenir comme, par exemple, le manque de resources CPU, le manque de mémoire ou une fuite mémoire dans un processus.
  • On peut exécuter perfmon pour qu’il collecte des données sur une machine à distance.

Le plus gros inconvénient de perfmon est qu’il n’est présent que sur les plateformes Windows. .NET Core étant exécutable sur d’autres plateformes que Windows, on ne peut que regretter qu’il n’y a pas d’équivalent de perfmon sur ces autres plateformes.

D’autre part, perfmon peut manquer de stabilité par moment et de nombreux bugs peuvent rendre son utilisation laborieuse.

Exécution de perfmon

Comme indiqué plus haut, perfmon est déjà installé sur Windows, il n’est pas nécessaire de l’installer. Cet outil se trouve à l’emplacement suivant:

C:\WINDOWS\system32\perfmon.msc

Lancer perfmon

On peut lancer perfmon de différentes façons:

  • En appuyant sur [WIN] + [R], en écrivant perfmon ou perfmon.msc puis en appuyant sur [Entrée].
  • A partir du menu Windows:
    1. Cliquer sur “Panneau de configuration” (i.e. “Control Panel”).
    2. Cliquer sur “Système et sécurité” (i.e. “System and Security”).
    3. Cliquer sur “Outils d’administration” (i.e. “Administration tools”).
    4. Cliquer sur “Analyseur de performance” (i.e. “Performance monitor”).

Collecter des compteurs en temps réel

On peut collecter des métriques en temps réel dans la partie “Outils d’analyse” (i.e. “Monitoring Tools”) ⇒ Analyseur de performances (ou “Performance Monitor”).

Performance Monitor

On peut ajouter des métriques (appelées compteur ou counter) en cliquant sur la croix verte:

Ajouter des métriques (1/2)

On peut aussi effectuer un clique droit sur le graphique puis cliquer sur “Propriétés” (i.e. “Properties”), dans l’onglet “Données” (i.e. “Data”) si on clique sur “Ajouter”, on accède à l’écran permettant de choisir le compteur à ajouter.

Ajouter des métriques (2/2)
Choisir la machine sur laquelle on effectue la collecte

Un grand avantage de perfmon est de pouvoir monitorer des informations sur une machine se trouvant à distance. On peut ainsi renseigner l’adresse d’une machine distance et ainsi collecter les données la concernant.

Si on se connecte à une machine distante, il faut bien sélectionner la machine au moment d’ajouter des compteurs dans la partie “Choisir les compteurs sur” (i.e. “Select counters from computer”):

Ajuster l’échelle du graphique

Dans l’écran de propriétés des graphiques, on peut ajuster manuellement l’échelle du graphique:

Ajuster l’échelle

L’ajustement de l’échelle peut se faire en appliquant un facteur d’échelle aux données pour que le graphique puisse être affichable.
Pour effectuer l’ajustement automatiquement, on peut aussi effectuer un clique droit sur le graphique puis cliquer sur “Mettre à l’échelle les compteurs sélectionnés” (i.e. “Scale selected counters”).

Par défaut, l’échelle de temps est sur 100 sec ce qui est parfois très court pour monitorer correctement un comportement. On peut augmenter cette échelle en allant dans l’onglet “Général” dans la partie “Eléments graphiques” (i.e. “Graph elements”):

Ajuster l’échelle verticale

Par exemple, on peut augmenter la valeur maximum à 1000 sec (le maximum est 1000) pour avoir un historique plus important.

La collecte des compteurs en temps-réel est plutôt limitée puisqu’elle ne permet pas de capturer plus longtemps que 1000 sec. D’autre part, les données ne sont pas sauvegardés sur le disque. Après avoir fermé perfmon, les données sont perdues.

Configurer des collectes automatiques de données

perfmon donne la possibilité de capturer des données sur plusieurs heures ou jours et de les sauvergarder sur le disque de façon à les consulter plus tard. Avec ce type de collecteur, même après fermeture de perfmon, les données continueront d’être collectées. Cette fonctionnalité apporte une grande flexibilité puisqu’on peut fermer la session et les données continueront d’être collectées et sauvegardées.

Configuration de compteurs par programmation

Cet article indique comment configurer des compteurs en utilisant perfmon toutefois il est possible de créer des compteurs de performance par programmation. Pour davantage de détails, voir PerformanceCounter en 5 min.

Configurer un collecteur de données

Pour effectuer des collectes automatiques, il faut:

  1. Déplier la partie “Ensembles de collecteurs de données” (ou “Data Collector Sets”).
  2. Déplier la partie “Définis par l’utilisateur” (ou “User Defined”).
  3. On peut créer un ensemble de collecteurs de données en effectuant un clique droit sur “Définis par l’utilisateur” (ou “User Defined”) puis cliquer sur “Nouveau” et enfin “Ensemble de collecteurs de données” (ou “Data Collector Set”).
  4. Préciser un nom pour l’ensemble de collecteurs.
  5. Sélectionner “Créer manuellement (avancé)” (ou “Create manually (advanced)”) puis cliquer sur “Suivant”:
  6. Les options affichées permettent d’ajouter des compteurs particuliers:
    • Compteur de performance (ou “performance counter”): cette option permet de créer des compteurs personnalisés avec lesquel on pourra préciser quelles sont les métriques à observer.
    • Données de suivi d’évènements (ou “Event trace data”): ce sont des évènements paramétrés.
    • Informations de la configuration système (ou “System configuration information”): ce paramétrage permet d’observer des valeurs de clé de registre.

    Pour effectuer une collecte automatique de métriques, il faut sélectionner “Compteur de performance” (ou “Performance Counter”) puis cliquer sur “Suivant”.

  7. Dans l’écran, on peut sélectionner les métriques à observer en cliquant sur “Ajouter…”:

    On peut choisir parmi les mêmes métriques que pour la collecte en temps réel. On détaillera par la suite certaines de ces métriques.

  8. Dans le cadre de l’exemple, on peut choisir une métrique sur le système, par exemple dans la partie: “Mémoire” (i.e. “Memory”) ⇒ “Mégaoctets disponibles” (ou “Available Bytes”).
    Cliquer sur “Ajouter” puis “OK”.
  9. Cliquer ensuite sur “Terminer”.

Lorsque la configuration est ajoutée, on peut préciser quelques éléments de configuration supplémentaire:

  • Effectuer un clique droit sur le collecteur créé,
  • Cliquer sur “Propriétés”.

On peut personnaliser certains éléments:

  • Utilisateur: l’utilisateur qui exécutera le compteur: ce paramétrage est particulièrement utile si on souhaite monitorer un processus lancé avec un utilisateur différent de celui qui effectue le paramétrage. Ce paramètre est disponible dans l’onglet “Général”:
  • Répertoire (ou “Directory”): le répertoire dans lequel les fichiers des compteurs seront créés: accessible dans l’onglet “Répertoire”.
  • Condition d’arrêt (ou “Stop condition”): il peut être utile de paramétrer une condition d’arrêt pour éviter que la collecte s’effectue de façon permanente. Les paramètres les plus utiles sont:
    • Durée globale (ou “Overall duration”): indiquer une durée. Ne pas oublier de préciser l’unité.
    • Limites (ou “Limits”): cette partie est intéressante car elle va permettre d’indiquer une période de collecte qu’il est possible de redémarrer périodiquement. En effet, il n’est pas possible de consulter le résultat d’une collecte si elle est en cours d’exécution. Ainsi, si on redémarre une collecte de façon périodique, on pourra lire la collecte effectuée lors d’une période précédente.
      Pour permettre de redémarrer périodiquement une collecte, il faut:

      • Indiquer une durée de collecte et cliquer sur “Durée”
      • Cliquer sur “Redémarrer l’ensemble de collecteurs dès qu’une limite est atteinte” (i.e. “Restart the data collection set at limits”) pour permettre de relancer une nouvelle période de collecte:
Paramétrer une condition d’arrêt

Il est vivement conseillé de paramétrer une condition d’arrêt pour plusieurs raisons:

  • Les compteurs peuvent consommer beaucoup de mémoire: cette quantité varie en fonction de la quantité de métriques paramétrées. Certaines métriques système consomment particulièrement de la mémoire.
  • On ne peut pas lire les résultats d’une collecte si celle-ci n’est pas arrêtée. Pour pouvoir lire les résultats, il faut:
    • soit arrêter la collecte
    • soit redémarrer la collecte de métriques de façon périodique. Lors d’une période de collecte, on ne peut pas lire la collecte en cours, toutefois on peut lire les périodes précédentes.
    • Enfin, en redémarrant une collecte périodiquement, les données de collecte sont écrites périodiquement sur le disque. Si perfmon crashe, on ne perds que la dernière période de collecte et non toute la collecte.

Modifier les propriétés du compteur de performance

Il est possible de modifier les métriques collectées en effectuant un clique droit sur le compteur de performance (sur la droite) puis en cliquant sur “Propriétés” (on peut aussi double cliquer sur le nom du compteur):

Accéder aux proprétés d’un compteur de performance

On peut aussi ajouter des collecteurs de données en effectuant un clique droit sur le panneau de gauche puis en cliquant sur “Nouveau”.

Modifier le format d’enregistrement des données collectées

Cette option peut s’avérer particulièrement utile pour sauvegarder les données dans un fichier CSV de façon à les ouvrir dans Excel par exemple:

  1. Il faut accéder aux propriétés du compteur de performance en effectuant un clique droit sur le compteur (sur la droite) puis en cliquant sur “Propriétés” (on peut double cliquer sur le nom du compteur).
  2. Dans la partie “Format d’enregistrement” (i.e. “Save Data As”), on peut sélectionner “Avec séparateur virgule” (i.e. “Test file (comma delimited)”) ou “Avec séparateur tabulation” (i.e. “Text file (tabulation delimited)”):

Les données collectées seront enregistrées dans un fichier .CSV consultable, par exemple, avec Excel.

Convertir un fichier de données binaires en CSV

On peut convertir les données collectées dans un fichier binaire au format .BLG vers un fichier texte de type .CSV en exécutant la commande suivante:

relog -f csv <chemin du fichier binaire .BLG> -o <chemin du fichier de sortie .CSV>

Démarrer un collecteur de données

Démarrer manuellement une collecte

Après avoir configuré un collection de données, on peut la démarrer:

  • En le sélectionner puis en cliquant sur “Démarrer”:
  • On peut aussi démarrer en effectuant un clique droit sur le collecteur puis en cliquant sur “Démarrer”.

Après démarrage, l’icône du collecteur change:

Icône d’un collecteur de données démarré

Programmer le démarrage d’une collecte

On peut programmer la collecte en consultant les propriétés d’un ensemble de collecteur de données (i.e. “Data collector sets”):

  1. Accéder aux propriétés en effectuant un clique droit sur l’ensemble de collecteur de données ⇒ cliquer sur “Propriétés”.
  2. Aller dans l’onglet Planification (i.e. “Schedule”).
  3. Ajouter une condition de démarrage en cliquant sur “Ajouter”:
  4. On peut préciser un certain nombre de critères pour planifier la collecte:

Une autre méthode permet de programmer le démarrage d’un ensemble de collecteur de données en passant par le Task Scheduler de Windows:

  1. Il faut, d’abord, ouvrir le Task Scheduler ou Planificateur de tâches en allant dans “Panneau de configuration” (i.e. “Control Panel”) ⇒ “Système et sécurité”” (i.e. “System and Security”) ⇒ Dans la partie “Outils d’administration” (i.e. “Administration tools”), cliquer sur “Tâches planifiées” (i.e. “Task Scheduler”).
    Une autre méthode consiste à exécuter la commande suivante en faisant [Win] + [R]:

    taskschd.msc
    
  2. On peut accéder aux ensembles de collecteur de données créés dans perfmon en allant dans la partie: “Bibliothèque de Planificateur de tâches” (i.e. “Task Scheduler Library”) ⇒ “Microsoft” ⇒ “Windows” ⇒ “PLA”:
  3. On peut voir sur le panneau de droite, les ensembles de collecteur de données créés dans perfmon. Si on effectue un clique droit sur l’ensemble puis en cliquant sur “Propriétés” on peut accéder à un ensemble d’options permettant de planifier une exécution.
  4. Dans l’onglet “Déclencheurs” (i.e. “Triggers”):
  5. On peut indiquer des options dans les déclencheurs:
  6. Dans l’onglet “Paramètres” (i.e. “Settings”), on peut indiquer de stopper l’exécution d’une collecte avant d’en commencer une autre en sélectionnant dans “Si la tâche s’exécute déjà, la règle suivante s’applique” (i.e. “If the task is already running, then the following rule applies”) l’option “Arrêter l’instance existante” (i.e. “Stop the existing instance”):

Lire les résultats d’une collecte

On ne peut pas voir les résultats d’une collecte de données tant que celle-ci est en cours d’exécution. Une astuce consiste à paramétrer une collecte périodique de façon à lire les résultats collectés lors d’une période précédente.

Si on tente de lire les résultats d’une collecte en cours:

Accéder à une collecte en cours

Pour lire les résultats, il faut:

  • Arrêter la collecte en effectuant un clique droit sur le collecteur puis en cliquant sur “Arrêter” ou
  • Attendre qu’une période de collecte se termine (si elle a été configurée).

Les résultats se lisent dans la partie “Rapports” (i.e. “Reports”), il faut sélectionner le répertoire correspondant à la collecte:

Sélectionner un rapport

On peut choisir différent type d’informations en effectuant un clique droit sur le répertoire de la collecte ⇒ Cliquer sur “Affichage” ⇒ Différents éléments sont disponibles:

  • Rapport: regroupant des informations relatives à la collecte
  • Analyseurs de performances: présentant les courbes correspondant aux métriques collectées.
  • Dossier: c’est l’ensemble des fichiers permettant de stocker les informations collectées.

Si la collecte de données est terminée, en double cliquant sur le nom du rapport, on peut voir un résumé de la collecte (accessible aussi en effectuant un clique droit sur le nom du rapport ⇒ Affichage (i.e. “Display”) ⇒ Rapports (i.e. “Reports”):

Affichage d’un rapport

Il est possible de supprimer les répertoires regroupant les données collectées en effectuant un clique droit sur le répertoire ⇒ Cliquer sur “Supprimer”.

Créer des alertes

On peut configurer des alertes correspondant à des actions qui seront exécutées quand survient un évènement particulier. Les évènements sont configurés à partir de la comparaison d’une métrique avec une valeur seuil déterminée. Par exemple, on peut configurer l’exécution d’une action quand un seuil particulier est dépassé à la hausse ou à la baisse.

Pour configurer ce type d’alerte, il faut:

  1. Déplier la partie “Ensembles de collecteurs de données” (ou “Data Collector Sets”).
  2. Déplier la partie “Définis par l’utilisateur” (ou “User Defined”).
  3. On peut créer un ensemble de collecteurs de données en effectuant un clique droit sur “Définis par l’utilisateur” (ou “User Defined”) puis cliquer sur “Nouveau” et enfin “Ensemble de collecteurs de données” (ou “Data Collector Set”).
  4. Préciser un nom pour l’ensemble de collecteurs.
  5. Sélectionner “Créer manuellement (avancé)” (ou “Create manually (advanced)”) puis cliquer sur “Suivant”:
  6. Sélectionner un collecteur de données de type “Alerte de compteur de performance” (i.e. Performance counter alert).
  7. Ajouter des métriques qui seront comparées à différent seuil: cliquer sur “Ajouter” puis sélectionner une ou plusieurs métriques:
  8. Indiquer pour chaque métrique des seuils utilisés à la hausse (quand “Au dessus de” ou “Above” est sélectionné) ou à la baisse (quand “Au dessous de” ou “Below” est sélectionné).
  9. Cliquer sur “Terminer”.
  10. Quand les seuils sont dépassés, on peut exécuter des actions en:
    • Effectuant un clique droit sur le collecteur de données (sur le panneau de droite) ⇒ cliquer sur “Propriétés”.
    • Dans l’onglet “Action de l’alerte” (i.e. “Alert action”), on peut indiquer l’ensemble de collecteurs à démarrer le cas échéant ou
    • Dans l’onglet “Tâche d’alerte” (i.e. “Alert task”), indiquer la tâche du Task Scheduler à lancer.

Avec ce type d’alerte, on peut exécuter plusieurs types de tâche comme envoyer un mail, afficher un message ou lancer un exécutable. On va expliciter la création d’une tâche pour afficher un message, la procédure étant facilement extensible aux autres cas.

Ainsi, si on veut afficher un message quand la mémoire disponible sur la machine est inférieure à une valeur limite, il faut effectuer les étapes suivantes:

  • Il faut créer une alerte de compteur de performance en utilisant la procédure décrite précédemment.
    Il faut ensuite éditer quelques propriétés du collecteur de données:

    1. Effectuer un clique droit sur le collecteur de données (sur le panneau de droite) ⇒ cliquer sur “Propriétés”.
    2. Dans l’onglet “Action de l’alerte” (i.e. “Alert action”), cliquer sur “Ajouter une entrée dans le journal d’évènements d’applications” (i.e. Log entry in the application event log): cette action permet d’ajouter une entrée dans l’Event log Windows:
    3. Cliquer sur “OK” pour valider.
  • On peut démarrer l’ensemble de collection de données en le sélectionnant puis en cliquant sur “Démarrer”.
  • Dans l’Event Viewer (ou l’observateur d’évènements), on peut voir l’ajout d’une entrée à chaque fois que l’alerte est déclenchée: dans notre cas, l’alerte sera déclenchée dès que la quantité de mémoire disponible sera inférieure à la valeur seuil.
    Pour voir l’entrée dans Event Viewer:

    1. Il faut l’ouvrir en appuyant sur les touches [Win] + [R] puis en écrivant eventvwr.msc.
    2. Déplier les nœuds “Observateurs d’évènements” ⇒ “Journaux des applications et des services” ⇒ “Microsoft” ⇒ “Diagnosis-PLA” ⇒ “Opérationnel”, on peut voir les entrées ajoutées en cas de déclenchement de l’alerte:
  • Il faut ensuite créer une tâche permettant d’afficher un message dans le Task Scheduler:
    1. On ouvre le Task Scheduler en appuyant sur les touches [Win] + [R], en écrivant taskschd.msc puis en appuyant sur [Entrée].
    2. Dans le nœud “Planificateur de tâches” (i.e. “Task Scheduler”) ⇒ “Bibliothèque de Planificateur de tâches” (i.e. “Task Scheduler Library”), on peut créer une tâche en cliquant sur “Créer une tâche” (i.e. “Create a task”):
    3. On indique le nom de la tâche, par exemple MemoryAlert:
    4. Dans l’onglet “Actions”, en cliquant sur “Nouveau”, on peut créer une action à exécuter parmi les actions suivantes:
      • Démarrer un programme,
      • Envoyer un message électronique,
      • Afficher un message.

      On sélectionne “Afficher un message” (i.e. “Display a message”) et on indique le message à afficher:

    5. Cliquer sur “OK” pour terminer la création de la tâche.
    6. On peut tester l’exécution de la tâche en sélectionnant la tâche et en cliquant sur “Exécuter”. Une popup devrait s’afficher avec le contenu suivant:
  • Il faut ensuite configurer la tâche nouvellement créée dans le collecteur de données:
    1. Retourner dans perfmon
    2. Sélectionner l’ensemble de collecteur de données puis effectuer un clique droit sur le collecteur de données (sur la partie droite) et cliquer sur “Propriétés”.
    3. Dans l’onglet “Tâche d’alerte” (i.e. Alert task), dans la partie “Exécuter cette tâche lorsqu’une alerte est déclenchée” (i.e. “Run this task when an alert is triggered”), indiquer le nom de la tâche créée dans le Task Scheduler. Dans notre exemple, cette tâche s’appelle MemoryAlert:

A la suite de cette configuration si l’ensemble de collecteur de données est démarrer, un message d’alerte sous forme d’une popup devrait s’afficher à chaque fois que la mémoire disponible sur la machine descend en dessous de la valeur seuil configurée.

Principaux compteurs de perfmon

Les compteurs de perfmon sont des métriques permettant de mesurer plusieurs types d’information. Ils permettent d’observer des informations diverses allant des données concernant le système à des données concernant des processus spécifiques. Ces compteurs sont les plus importants puisque leur évolution permet de diagnostiquer l’état du système ou d’un processus.

La façon de traiter ces compteurs n’est pas la même:

  • Pour certains compteurs, une simple lecture de la valeur peut suffire à déduire une information pertinente, par exemple le pourcentage de temps processeur permet d’indiquer directement l’utilisation du processeur.
  • Pour d’autres compteurs, il faut comparer la valeur avec une autre valeur provenant éventuellement d’un autre compteur, par exemple pour la quantité de mémoire disponible. Pour en tirer une information pertinente, il faut comparer cette quantité avec la quantité de mémoire totale de la machine pour en déduire la quantité de mémoire occupée.
  • Pour certains compteurs, la lecture d’une valeur instantanée n’indiquera rien de pertinent, il faudra scruter la variation de la valeur du compteur en fonction du temps. Par exemple, la mémoire occupée par un processus peut apporter une information si on constate une augmentation au cours du temps. Plus l’augmentation sera brutale et plus l’évolution de ce compteur sera préoccupante.
  • Enfin, il faut distinguer les unités des compteurs: certains compteurs indiquent des valeurs instantanées, des valeurs moyennes, des pourcentages d’une valeur totale ou des valeurs moyennées par seconde. Il faut avoir en tête que des valeurs instantanées sont échantillonnées à une fréquence donnée. Ces échantillonnages peuvent avoir pour conséquence de déformer la réalité puisque la capture des valeurs est discontinue, seules les captures correspondant à la fréquence seront affichées et non pas toutes les valeurs de façon continue.

Utilisation du processeur

L’utilisation du processeur est une des informations qui peut être la plus utile à capturer. La plupart du temps, les machines comptent plusieurs cœurs logiques. Ainsi, l’utilisation peut être indiquée de façon totale ou pour chaque cœur logique.

Pourcentage du temps processeur

  • Processeur\% temps processeur
    Processor\% Processor Time
    C’est une valeur instantanée qui permet d’avoir une idée de l’utilisation totale du processeur. Une valeur proche des 100% indique un processeur utilisé au maximum de sa capacité ce qui le plus souvent peut révéler une exécution anormale d’un processus. Généralement, il peut être utile d’analyser le temps processeur pour chaque cœur et non pour tout le processeur. En effet, le plus souvent un processus pourrait utiliser 100% d’un cœur mais plus rarement 100% du temps total du processeur.
  • Processus\% temps processeur
    Process\% Processor Time
    Contrairement au compteur précédent, ce compteur ne concerne que le temps processeur pour un processus donné. Cette valeur peut s’avérer intéressante pour isoler le comportement anormal d’un processus en particulier. Plus simplement, dans un fonctionnement normal, ce compteur peut aussi servir à dimensionner une machine pour l’exécution d’un processus.
“Mode kernel” vs “mode utilisateur”

Brièvement, le processeur est exécuté soit en mode utilisateur (i.e. user mode), soit en mode kernel (i.e. kernel mode) suivant le code qu’il doit exécuter. Les applications sont exécutées en mode utilisateur, les systèmes d’exploitation et la plupart des drivers sont exécutés en mode kernel. Cette séparation permet d’isoler l’exécution d’un processus dans un espace d’adresse mémoire virtuelle qui lui est spécifique. Un processus peut appeler certaines fonctions du système d’exploitation qui seront exécutées en mode kernel comme par exemple effectuer des opérations sur un fichier sur le disque, allouer de la mémoire ou effectuer des entrées/sorties réseau.

  • Processeur\% temps privilégié
    Processor\% Privileged time
    Ce compteur permet d’indiquer l’utilisation du processeur en mode kernel. Si l’utilisation du processeur en mode kernel est élevée (c’est-à-dire > 75%) peut révéler une machine sous-dimenssionnée pour l’ensemble des processus à exécuter.
  • Processeur\% temps utilisateur
    Processor\% User time
    Ce compteur indique l’utilisation du processeur en mode utilisateur. Cette valeur est moins pertinente que le pourcentage de temps privilégié toutefois elle peut présenter un intérêt s’il est particulièrement élevé (> 90 %). Elle peut indiquer une machine sous-dimensionnée.
  • Système\Longueur de la file d’attente du processeur
    System\Processor Queue Length
    Ce compteur donne une indication sur le nombre de threads en attente d’exécution. On considère que cette valeur est préoccupante si elle dépasse 5. Dans le cas d’une machine multi-cœur, il faut diviser la valeur par le nombre de cœurs et comparer le résultat avec 5. Si la valeur dépasse 5 de façon continue avec un processeur proche des 100% du temps d’exécution, il y a davantages de threads en attente d’exécution que la processeur n’est capable d’en traiter. Ce qui conduit à allonger la file d’attente et à rendre l’exécution des threads plus longue.
  • Système\Changement de contexte/s
    System\Context switches/sec
    Ce compteur indique le nombre de fois moyens par seconde que le processeur passe du mode utilisateur vers le mode kernel pour effectuer des opérations où le mode kernel est nécessaire dans le cadre d’un thread exécuté en mode utilisateur. Cette valeur dépends de la charge à laquelle est soumise le processeur toutefois elle doit rester stable pour une même charge donnée. Si cette valeur augmente sans changement de charge du processeur, il pourrait s’agir d’un fonctionnement anormal d’un driver.
    Une augmentation brutale peut provenir d’un processus qui effectue beaucoup d’opérations nécessitant une exécution en mode kernel. Dans ce cas, il faut observer la variation de cette valeur et la rapprocher à l’exécution du processus pour éventuellement surveiller une saturation qui pourrait dégrader l’exécution du processus.
    D’une façon générale, une valeur inférieure à 5000 est considérée normale.
  • Processeur\Interruption/sec
    Processor\Interrupts/sec
    Cette valeur indique le nombre de fois moyen par seconde que le processeur reçoit une interruption provenant d’un autre composant hardware. Si cette valeur est supérieure à 1000 de façon continue, il peut s’agir d’un défaut d’un composant matériel. Dans un fonctionnement normal, certains composants sollicitent des interruptions régulières du processeur comme l’horloge système, la souris, les drivers des disques, les cartes réseaux etc…

Identifier les processus

Dans perfmon, les processus sont reconnaissables par leur nom. Toutefois, il peut arriver qu’il existe plusieurs instances d’un même processus. Dans perfmon, tous ces processus seront représentés avec le même nom. Par exemple:

Différentes instances d’une même application

Pour identifier quel libellé correspond à quel processus, il faut se référer à l’ID du processus. On peut trouver cet ID dans le compteur Processus\ID de processus (ou Process\Process ID).

Identifier des threads

Dans le cas des threads, il n’est pas forcément facile d’identifier directement un thread qui consomme beaucoup de CPU avec perfmon. Le gros inconvénient est que dans la catégorie “Thread”, on ne peut pas choisir les threads correspondant à un processus, on est obligé d’ajouter tous les threads existants ce qui est, le plus souvent, inexploitable.
Par exemple pour identifier le thread responsable d’une grande consommation de ressource CPU, on peut aller dans la catégorie “Thread” et ajouter les compteurs:

  • Thread\% temps processeur (ou Thread\% Processor Time): ces compteurs permettent d’identifier le thread responsable ainsi que le processus parent.
  • Thread\N° du thread (ou Thread\ID thread): ces compteurs indiquent l’identifiant du thread en décimal. Il faut convertir cet identifiant en hexadécimal pour l’exploiter avec WinDbg par exemple.

Quand on sélectionne ces compteurs, un grand nombre de courbes sont rajoutées. La recherche est plutôt fastidieuse. Il ne faut pas confondre l’ID du thread et le numéro d’instance indiquée dans perfmon. Le numéro est spécifique à perfmon, il faut se référer à l’ID du thread correspondant au compteur concerné.
Par exemple, si on regarde les compteurs suivants:

Thread ID

L’instance permet de faire de rapprocher les 2 compteurs toutefois l’identifiant du thread est indiqué dans l’encadré rouge.

Utilisation de la mémoire

L’utilisation de la mémoire est le 2e point le plus important à monitorer car elle a un impact très direct sur l’exécution des processus. D’autre part, le fonctionnement anormal d’un processus qui pourrait occasionner une grande consommation de mémoire, peut avoir un impact sur tous les processus de la machine.

  • Mémoire\Mégaoctets disponibles
    Memory\Available MBytes
    Ce compteur indique le nombre de mégaoctets disponibles en mémoire pour l’exécution des processus. Il s’agit d’une valeur instantanée. Si cette valeur est en dessous de 10% de la mémoire totale de la machine, la non disponibilité de mémoire libre pourrait occasionner une charge supplémentaire pour solliciter la mémoire virtuelle.
Page fault

Quand un processus est en cours d’exécution, il est exécuté dans un espace d’adresses mémoire virtuelle. Pour ses besoins, il sollicite le système d’exploitation pour que ce dernier lui alloue de la mémoire sous forme de page. Pendant le déroulement de l’exécution du processus, les pages allouées sont consultées pour écrire ou lire des objets. Le système d’exploitation traque les fréquences d’utilisation de ces pages pour savoir quelles sont les pages les plus fréquemment consultées par le processus. Les pages les plus fréquemment consultées seront mises en cache dans la mémoire RAM de façon à augmenter la vitesse d’exécution du processus. Les autres pages sont stockées dans la mémoire virtuelle sur le disque.

Si un processus souhaite accéder à une page mémoire qui ne se trouve pas dans la mémoire RAM, le système d’exploitation effectue un page fault (ou défaut de page) à la suite de laquelle, il va effectuer une copie de la page du disque vers la mémoire RAM. Le système d’exploitation suppose que la page sera éventuellement de nouveau consultée dans le futur. Le fait que la page soit dans la mémoire RAM rendra plus rapide l’exécution du processus.
Les page faults occasionnent donc beaucoup d’opérations par le système d’exploitation pour accéder à la page mémoire.

  • Processus\Plage de travail
    Process\Working set
    Cette valeur indique la quantité de mémoire RAM en octets utilisée par un processus sans occasionner de page faults. Il s’agit d’une valeur instantanée. Cette valeur peut s’avérer intéressante si elle varie de façon significative au cours du temps.
    Si la machine comporte beaucoup de mémoire, quand un processus libère de la mémoire, la page mémoire n’est pas forcément supprimée de la plage de travail (i.e. “working set”) du processus. La plage de travail d’un processus peut donc contenir des pages inutilisées.
  • Processus\Octets privés
    Process\Private bytes
    Il s’agit de la quantité de mémoire totale en octets utilisée par un processus. Cette valeur est instantanée, de même que la valeur précédente c’est la variation au cours du temps qu’il faut observer. Si cette valeur augmente de façon plus ou moins rapide, il peut s’agir d’une fuite mémoire (i.e. memory leak).
    Dans un contexte managé, cette valeur rassemble la quantité de mémoire managée et non managée.
  • Mémoire CLR .NET\Nombre d’octets dans tous les tas
    .NET CLR Memory\Bytes in all Heaps
    Ce compteur indique la quantité de mémoire managée en octets dans tous les tas du processus. Ce compteur n’est utile que dans le contexte .NET. Elle permet de déduire la quantité de la mémoire non-managée en utilisant la formule:
    Processusoctets privés - Mémoire CLR .NETnombre octets dans tous les tas = Mémoirenon-managée
  • Mémoire\Octets du cache
    Memory\Cache Bytes
    Ce compteur permet de connaître le nombre d’octets alloués en RAM pour des threads du kernel qui ne vont pas occasionnés un page fault. Il s’agit d’une valeur instantanée. Il faut comparer cette valeur à la quantité de mémoire RAM de la machine. Dans le cas d’une valeur élevée, il peut s’avérer que la machine manque de mémoire RAM.
  • Mémoire\Octets résidants dans le cache système
    Memory\Pool Nonpaged Bytes
    Cette valeur correspond à la quantité de mémoire en octets allouées en RAM que le kernel ne peut pas déplacer dans la mémoire virtuelle sur le disque. C’est une valeur instantanée. Si cette valeur est trop grande (>75%), le système ne pourra plus allouer de la mémoire aux processus.
  • Mémoire\Pages/sec
    Memory\Pages/sec
    Ce compteur indique la somme entre le nombre de pages lues et le nombre de pages écrites sur le disque pour résoudre le problème de page fault. Si cette valeur dépasse 50 de façon continue, il est fort probable que le système manque de mémoire.
  • Mémoire\Pages en entrée
    Memory Page Reads/sec
    Il s’agit de la quantité moyenne de page mémoire virtuelle lue sur le disque par seconde. Cette valeur est à rapprocher de celles obtenues avec le compteur Mémoire\Pages/sec.
  • Mémoire\Pages en entrée
    Memory Page Reads/sec
    Il s’agit de la quantité moyenne de page mémoire virtuelle écrite sur le disque par seconde. Cette valeur est à rapprocher de celles obtenues avec le compteur Mémoire\Pages/sec.
  • Fichier d’échange\Pourcentage d’utilisation
    Paging File\% Usage
    Ce compteur permet d’indiquer la proportion du fichier contenant les pages de mémoire stockées en mémoire virtuelle sur le disque. Cette valeur permet de savoir si le fichier d’échange contenant la mémoire virtuelle est sous-dimensionnée.

Contexte managé

Dans un contexte managé, il est possible d’utiliser des compteurs supplémentaires qui pourront donner des indices supplémentaires sur un comportement d’un processus. Ces compteurs ne sont utilisables que pour des procesus .NET.

  • Verrous et threads CLR .NET\Taux de conflits/sec
    .NET CLR LocksAndThreads\Contention Rate/sec
    Ce compteur indique le nombre de fois que le CLR tente d’acquérir un lock managé sans succès. Une valeur différente de 0 peut indiquer qu’une partie du code d’un processus provoque des erreurs.
  • Verrous et threads CLR .NET\Longueur de la file actuelle
    .NET CLR LocksAndThreads\Current Queue Length
    Ce compteur permet de savoir quel est le nombre de threads attendant d’acquérir un lock managé dans un processus. Plus ce nombre est grand et plus il y a de la contention entre les threads ce qui peut révéler une mauvaise optimisation dans la synchronisation des threads d’un processus.
  • Verrous et threads CLR .NET\Nombre de threads actuels logiques
    .NET CLR LocksAndThreads\# of current logical threads
    Cette valeur indique le nombre de threads managés de l’application. Les threads peuvent être stoppés ou en cours d’exécution. L’intérêt de ce compteur est de pouvoir surveiller une variation dans le nombre de threads d’un processus. Si cet indicateur augmente pendant l’exécution du processus, il peut s’agir d’un trop grand nombre de threads qui sont créés sans pouvoir être exécutés. Il s’agit d’une valeur instantanée.
  • Verrous et threads CLR .NET\Nombre de threads actuels physiques
    .NET CLR LocksAndThreads\# of current physical threads
    Ce compteur indique le nombre de threads natifs du système d’exploitation appartenant au CLR effectuant des traitements pour des objets managés. Comme pour le compteur précédent, l’intérêt de ce compteur est de surveiller les variations dans le nombre de threads du processus. Une augmentation de cet indicateur peut révéler un trop grand nombre de threads créés par rapport à la capacité d’exécution du CLR.

Mémoire managée

  • Mémoire CLR.NET\Nombre d’octets dans tous les tas
    .NET CLR Memory\# Bytes in all heaps
    Ce compteur est très utile pour suivre l’évolution de la mémoire managée d’un processus. Il correspond à la somme de la taille des tas de génération 1, 2 et des objets de grande taille.
  • Mémoire CLR.NET\Taille du tas de génération 0 (.NET CLR Memory\Gen 0 heap size)
    Mémoire CLR.NET\Taille du tas de génération 1 (.NET CLR Memory\Gen 1 heap size)
    Mémoire CLR.NET\Taille du tas de génération 2 (.NET CLR Memory\Gen 2 heap size)
    Mémoire CLR.NET\Taille du tas des objets volumineux (.NET CLR Memory\Large Object Heap Size)
    Ces compteurs permettent d’évaluer l’évolution des compteurs des tas principaux d’un processus managé .NET. Ces compteurs peuvent être utiles pour monitorer le comportement du Garbage Collector pour un processus donné.

Détecter les anomalies

Dans cette partie, on va essayer de décrire quelques cas de figures les plus courants de façon à savoir comment utiliser perfmon pour faciliter un diagnostique concernant le comportement d’un processus.

Le code correspondant à cette partie se trouve dans le repository GitHub:
github.com/msoft/memory_leak.

Détecter une fuite mémoire dans un processus managé

Dans un contexte managé, on a tendance à penser qu’il n’est pas très important de détecter des fuites mémoire, le Gargage Collector est assez efficace pour ne jamais permettre la survenance de fuite mémoire. Ceci est vrai à condition de ne pas maintenir des liens vers des objets non utilisés. Dans le cas contraire si un lien est maintenu vers un objet qui n’est plus utilisé, le Garbage Collector sera incapable d’évaluer que cet objet doit être supprimé. Le plus souvent, c’est la source d’une fuite mémoire dans un contexte managé. Evaluer une fuite mémoire avec perfmon n’est pas forcément trivial, il faut avoir à l’esprit plusieurs choses:

  • Si un lien est maintenu vers un objet qui n’est plus utilisé, l’espace mémoire occupé par cet objet ne sera jamais libéré après exécution du Garbage Collector.
  • Le Garbage Collector ne s’exécute pas tout le temps. Il est amené à s’exécuter quand une demande importante en mémoire est requise. Ainsi, la quantité de mémoire managée d’un processus peut augmenter jusqu’à ce que le Garbage Collector s’exécute pour libérer de la mémoire. Ainsi l’augmentation constante de la mémoire utilisée par un processus au cours de son exécution n’est pas forcément synonyme de fuite mémoire.

Pour illustrer ces 2 points, on se propose d’exécuter un processus et d’afficher la mémoire managé de ce processus dans perfmon:

  1. On implémente le code suivant:
    internal class BigObject
    {
    	private const string loremIpsum = "Lorem ipsum dolor ... laborum.";
    	private List<string> strings = new List<string>();
    	public void CreateObjects()
    	{
    		for (int i = 0; i < 100000; i++)
    		{
    			strings.Add(loremIpsum);
    		}
    	}
    }
    
    public class ManagedBigObjectGenerator
    {
    	private List<BigObject> objects = new List<BigObject>();
    	public void CreateObjects()
    	{
    		for (int i = 0; i < 100000; i++)
    		{
    			this.objects.Add(new BigObject());
    			if (i % 10 == 0)
    				Thread.Sleep(100);
    		}
    	}
    }
    

    Ce code instancie un certain nombre d’objets sans jamais les libérer. Ces objets vont occuper un espace mémoire toujours plus important et ils ne seront jamais supprimé par le Garbage Collector car ils sont stockés dans une liste qui n’est jamais vidée.

  2. On exécute ce code et on affiche le compteur Mémoire CLR.NET\Nombre d'octets dans tous les tas dans perfmon.
  3. On peut voir que la quantité de mémoire utilisée par le processus ne cesse d’augmenter.

    Le graphique précédent présente les courbes suivantes:

    • En rouge: le nombre d’octets privés c’est-à-dire la mémoire totale occupée par le processus.
    • En bleu: le nombre d’octets dans tous les tas du processus, il s’agit de la quantité de mémoire managée.
    • En vert: le nombre d’octets dans tous les tas pour tous les processus managés de la machine.

Comme dans le cas d’une fuite mémoire réelle, la consommation de mémoire ne cesse de croître et ne se stabilise pas.

Cependant comme indiqué plus haut, dans certains cas, une augmentation continue de la mémoire ne signifie pas forcément une fuite mémoire:

  1. Ainsi, si on implémente le code suivant:
    public class ManagedBigObjectGeneratorWithWeakReferences
    {
    	private List<WeakReference> objects = new List<WeakReference>();
    	public void CreateObjects()
    	{
    		for (int i = 0; i < 10000; i++)
    		{
    			this.objects.Add(new WeakReference(new BigObject()));
    			if (i % 10 == 0)
    				Thread.Sleep(100);
    		}
    	}
    }
    

    A la différence du code précédent, on utilise des objets de type WeakReference qui permettent de ne pas maintenir de liens avec les instances de BigObject qui sont créés. Ainsi le Garbage Collector peut supprimer les instances.

  2. En affichant le compteur Mémoire CLR.NET\Nombre d'octets dans tous les tas dans perfmon, on constate dans un premier temps que la mémoire occupée augmente, toutefois après quelques minutes, cette quantité de mémoire se stabilise et cesse d’augmenter. La présence de l’objet WeakReference permet de ne pas maintenir un lien avec les objets et ils sont, ainsi, libérés par le Garbage Collector:

    Ce graphique présente les mêmes courbes que précédemment:

    • En rouge: le nombre d’octets privés (mémoire totale occupée par le processus).
    • En bleu: le nombre d’octets dans tous les tas du processus (quantité de mémoire managée).
    • En vert: le nombre d’octets dans tous les tas pour tous les processus managés de la machine.

Détecter une fuite mémoire dans un processus non managé

Détecter une fuite dans un contexte non managée est plus rapide: dès qu’un objet est alloué sur le tas, s’il n’est pas libéré la mémoire ne peut pas être allouée à un autre objet. L’espace mémoire est ainsi perdu. Dans perfmon, il n’existe pas de compteur indiquant la quantité de mémoire non managée qui est utilisée. Pour détecter ce type de fuite mémoire, il faut utiliser 2 compteurs et déduire la mémoire non-managée à partir des compteurs:

La mémoire non managée peut être déduite en utilisant la formule:
Processusoctets privés - Mémoire CLR .NETnombre octets dans tous les tas = Mémoirenon-managée

Ainsi si la quantité totale de mémoire du processus augmente sans que la mémoire managée augmente, cela signifie qu’il peut exister une fuite mémoire dans le code non managée. Si un processus présente une anomalie de ce type, l’évolution des compteurs pourrait ressembler à ces courbes:

Ce graphique présente les mêmes courbes que précédemment:

  • En rouge: le nombre d’octets privés (mémoire totale occupée par le processus).
  • En bleu: le nombre d’octets dans tous les tas du processus (quantité de mémoire managée).
  • En vert: le nombre d’octets dans tous les tas pour tous les processus managés de la machine.

Dans la courbe précédente, la quantité de mémoire managée (courbe bleue) reste à zéro car le code exécuté provient d’une assembly mixte contenant du code managé et du code natif (C++/CLI). Avec ce type d’assembly, perfmon ne détecte pas de code managé, c’est la raison pour laquelle cette courbe reste à zéro. Pour contourner ce problème, on affiche le nombre d’octets dans tous les tas pour tous les processus de la machine de façon à avoir une idée de l’évolution de la mémoire managée à l’échelle du processus.

Le code correspondant à cette partie se trouve dans le projet suivant:
github.com/msoft/memory_leak/tree/master/UnamanagedMemoryLeak.

Conclusion

Le but de cet article était d’indiquer les fonctionnalités principales de perfmon de façon à pouvoir l’utiliser rapidement. J’espère qu’il vous aura aider à configurer perfmon efficacement et surtout qu’il vous aura convaincu de l’utilité de perfmon pour monitorer une application ou une machine.

Références

Documentation générale:

Documentation sur les compteurs:

Exécuter perfmon à distance:

Autre:

Share on FacebookShare on Google+Tweet about this on TwitterShare on LinkedInEmail this to someonePrint this page

Comment configurer un projet multi-target dans Visual Studio 2017 ?

Il existe une fonctionnalité dans Visual Studio 2017 qui n’est pas évidente à repérer et qui consiste à avoir plusieurs frameworks cibles pour un projet donné. Traditionnellement dans Visual Studio, on crée un projet pour un framework donné, or depuis la multiplication récente des cibles de compilation (.NET Standard ou .NET Core), il peut être nécessaire de produire des assemblies destinées à des environnements différents à partir d’un même code.

On va, dans un premier temps, indiquer ce qui peut justifier l’utilisation de projets multi-target. Ensuite, on va indiquer la méthode pour générer des assemblies pour plusieurs frameworks cible. Enfin, on indiquera des directives à utiliser dans le code pour générer du code spécifique à une plateforme.

Multitude de plateformes cibles

Les technologies Microsoft adressent un grand nombre d’environnements différents allant de systèmes d’exploitation comme Windows à des appareils mobiles comme les tablettes. D’autres parts, depuis quelques années, il est possible d’exécuter du code .NET sur d’autres plateformes que Windows. Cette ouverture a encore augmenté le nombre de plateformes sur lesquelles des technologies pouvaient s’exécuter: Linux, macOS, iOS, Android etc… Toutes ces plateformes ont des spécificités qui nécessitent, pour chacune d’entre elles, une implémentation particulière.

Pour Microsoft, un premier challenge a été de tenter d’uniformiser ses bibliothèques pour les rendre moins spécifiques à une plateforme. Ce premier travail a abouti à 3 grandes familles de technologies:

  • .NET Framework déployable sur des machines Windows,
  • .NET Core déployable sur Windows, Linux, macOS mais aussi sur des tablettes, smartphones et Xbox avec Universal Windows Platform (UWP).
  • Xamarin déployable sur Android et iOS.

Cette uniformisation ne permet pas, à elle-seule, d’utiliser un même code pour adresser plusieurs plateformes. Si on développe une bibliothèque, on est obligé d’avoir un projet spécifique pour chaque plateforme et il est nécessaire de compiler tout son code pour chacune des plateformes.

Pour permettre de compiler du code pour plusieurs plateformes et ainsi, encapsuler la complexité d’un déploiement sur plusieurs plateformes, une approche de Microsoft a été d’introduire une abstraction supplémentaire avec le .NET Standard.

.NET Standard

.NET Standard permet d’introduire une couche supplémentaire entre le code et les plateformes où seront déployées les bibliothèques:

  • Une bibliothèque .NET Standard définit un ensemble d’API qui sont communes à plusieurs plateformes.
  • Une bibliothèque .NET Standard n’est pas liée à une plateforme. Ainsi faire une bibliothèque se baser sur une version de .NET Standard permet d’éviter de la faire se baser sur une plateforme particulière. Il n’y a donc, plus de lien entre la bibliothèque et la plateforme sur laquelle elle sera déployée.
    Cette caractéristique permet d’éviter d’écrire du code pour une plateforme spécifique. Le code de la bibliothèque est écrit pour une version du .NET Standard.
  • La couche supplémentaire qu’est .NET Standard rend plus facile la mise à jour éventuelle d’une plateforme. Au lieu de compiler une bibliothèque pour une version spécifique de la plateforme, on la compile pour une version du .NET Standard.
    Si la nouvelle version d’une plateforme est compatible avec le .NET Standard sur lequel se base des bibliothèques, on peut mettre à jour la plateforme sans craindre une incompatibilité de ces bibliothèques. En effet c’est le .NET Standard qui va garantir la compatibilité.

Différences entre une bibliothèque et un exécutable

L’approche .NET Standard ne convient pas dans tous les cas. Elle permet de faciliter le déploiement de bibliothèques en ajoutant une abstraction de façon à éviter de baser ces bibliothèques directement sur des plateformes. Cette approche est possible car dans la majorité des cas, une bibliothèque de classes n’utilisent pas d’API spécifiques à une plateforme donnée.

En revanche, si une bibliothèque utilise des API trop spécifiques à une plateforme (comme par exemple, WPF qui nécessite un système Windows), elle ne pourra pas se baser sur .NET Standard (.NET Standard ne comporte pas de classes WPF).
De la même façon, un exécutable est spécifique à une plateforme. On ne pourra pas baser une exécutable sur un .NET Standard. Un exécutable est implémenté pour une plateforme précise.

Pour rendre l’approche .NET Standard la plus efficace possible, il faut donc placer un maximum de code dans des bibliothèques qui se basent sur .NET Standard. Le reste du code, étant plus spécifique, se basera sur une plateforme précise.

.NET Core

En plus de .NET Standard qui permet une certaine abstraction, la technologie .NET Core est capable de compiler du code pour des plateformes différentes. En effet .NET Core est une implémentation de Microsoft qui a pour but de rendre la technologie .NET disponible sur plusieurs plateformes. Contrairement à .NET Standard, .NET Core permet de produire des bibliothèques de classes et des exécutables. A la compilation, on précise le framework cible et l’environnement où ces assemblies seront exécutées (Windows, Linux, MacOS etc…).

Ces assemblies sont, ainsi, déployables et exécutables sur des environnements et des plateformes différents.

Fichiers projet .csproj simplifiés

.NET Core et .NET Standard ont introduit une grande flexiblité dans la technologie .NET puisqu’ils permettent de facilement générer des assemblies exécutables sur des plateformes différentes. De façon à rendre ces technologies plus facilement implémentables sur des plateformes différentes de Windows, Microsoft a fait un grand effort pour simplifier l’installation d’un environnement de développement en passant par la CLI .NET Core (CLI pour Command Line Interface) et éviter l’installation trop complexe de Visual Studio. Dans son effort de simplification, les fichiers projet .csproj ont aussi été considérablement simplifié pour ne conserver que le stricte minimum en élément de configuration de façon à ce qu’ils soient facilement éditables à la main.

Par exemple, le fichier .csproj d’un projet permettant de produire un exécutable .NET Core contient les éléments suivants:

<Project Sdk="Microsoft.NET.Sdk"> 

  <PropertyGroup> 
    <OutputType>Exe</OutputType> 
    <TargetFramework>netcoreapp2.0</TargetFramework> 
  </PropertyGroup> 

</Project>

Avec la CLI .NET Core (cf. Commandes courantes de la CLI .NET Core), on peut créer un projet console en exécutant:

dotnet new console -n <nom du projet> 

Liste de fichiers du projet

Contrairement aux fichiers .csproj traditionels, par défaut le nouveau format n’indique pas explicitement les fichiers de code du projet. Tous les fichiers .cs font partie du projet, il n’est pas nécessaire de les spécifier un à un.

On peut toutefois spécifier les fichiers un à un ou avec une wildcard en désactivant le comportement par défaut avec l’élément de configuration EnableDefaultCompileItems et en utilisant l’option Compile:

<Project Sdk="Microsoft.NET.Sdk"> 

  <PropertyGroup>   
    <OutputType>Exe</OutputType> 
    <TargetFramework>netcoreapp2.0</TargetFramework>     
    <EnableDefaultCompileItems>false</EnableDefaultCompileItems>
  </PropertyGroup> 

  <ItemGroup> 
    <Compile Include="Program.cs"  />     
    <Compile Include="InternalClass.cs"  />     
  </ItemGroup>

</Project>

Référence entre projets

Les références entre projets sont indiquées en utilisant l’élément de configuration ProjectReference:

<Project Sdk="Microsoft.NET.Sdk"> 

  <!-- ... --> 

  <ItemGroup> 
    <ProjectReference Include="..\DotNetAssembly\DotNetAssembly.csproj" />
  </ItemGroup> 

</Project> 

On peut rajouter une référence d’un projet vers un autre en exécutant la commande CLI suivante:

dotnet add <chemin .csproj où ajouter la dépendance> reference  
<chemin .csproj à rajouter>

PackageReference vs packages.config

L’élément de configuration PackageReference concerne les packages NuGet installés pour le projet. Par exemple, si on rajoute le package NuGet Microsoft.AspNet.WepApi.Core au projet, le contenu du fichier projet .csproj devient:

<Project Sdk="Microsoft.NET.Sdk"> 

  <PropertyGroup> 
    <OutputType>Exe</OutputType> 
    <TargetFramework>netcoreapp2.0</TargetFramework> 
  </PropertyGroup> 

  <ItemGroup> 
    <PackageReference Include="Microsoft.AspNet.WebApi.Core" Version="5.2.6" />
  </ItemGroup> 

</Project>

On peut ajouter une référence vers un package NuGet en exécutant la commande CLI suivante:

dotnet add <chemin .csproj> package <nom du package NuGet>

Le fichier .csproj ne contient qu’une référence vers le package NuGet rajouté. Toutes les dépendances transitives du package ne sont pas indiquées dans le fichier. Les dépendances du packages NuGet ne sont pas rajoutées au fichier contrairement aux fichiers .csproj traditionnels.

Dans les projets Visual Studio traditionnels, les références vers les packages NuGet installés et les dépendances transitives sont ajoutés dans le fichier packages.config. D’autre part, le fichier projet .csproj contient des références vers les assemblies du package NuGet.

Configurer un projet multi-target

On peut facilement configurer le projet pour compiler le code pour plusieurs frameworks cibles en modifiant l’élément de configuration TargetFramework. Dorénavant, avec les nouveaux fichiers projet .csproj, il faut juste renommer l’élément TargetFramework en TargetFrameworks (avec un “s”):

<TargetFrameworks>netcoreapp2.0;netstandard2.0</TargetFrameworks>

Dans cet exemple, on produit des assemblies pour une application .NET Core 2.0 et une bibliothèque de classes pour .NET Standard 2.0. Les éléments netcoreapp2.0 et netstandard2.0 sont appelés TFM (pour Target Framework Moniker). Chaque code TFM correspond à un framework cible particulier, par exemple:

.NET Core netcoreapp2.0, netcoreapp2.1
.NET Framework net20
net45, net452
net46, net462
net47, net471, net472
.NET Standard netstandard2.0

On peut trouver une liste exhaustive des TFM utilisés sur la page suivante: docs.microsoft.com/fr-fr/nuget/reference/target-frameworks#supported-frameworks.

Utiliser des fichiers projet simplifiés dans Visual Studio

Le fichier projet .csproj traditionnel dans Visual Studio contient énormément de détails comme:

  • La liste des fichiers .cs de code du projet,
  • La liste des références d’assembly du projet qui peuvent être des assemblies tiers, des assemblies provenant d’un autre projet de la solution ou d’assemblies provenant de packages NuGet.
  • La liste des packages NuGet installés ainsi que les dépendances de chaque package est présente dans un fichier packages.config présent dans le projet.

Ces fichiers .csproj sont, malheureusement incontournables pour la plupart des projets générés dans Visual Studio en particulier les projets produisant des assemblies exécutables sur une plateforme Windows comme:

  • Les applications Winforms,
  • Les applications WPF,
  • Les projets ASP.NET,
  • Les applications Universal Windows (UWP).

En revanche il est possible d’utiliser des fichiers projet .csproj avec une syntaxe simplifiée pour les assemblies de type:

  • Bibliothèques de classes,
  • Applications console,
  • Applications ASP.NET Core,
  • Les applications .NET Core.

Configurer les packages NuGet en mode “PackageReference”

Cette configuration est valable pour tous les types de projet. Dans le mode par défaut, les dépendances NuGet sont indiquées dans un fichier packages.config présent dans le projet. Ce fichier contient plus précisement:

  • Les packages NuGet installés explicitement et
  • Les dépendances des packages NuGet installés.

Par exemple, si on installe dans un projet le package NuGet Microsoft.AspNet.Mvc, ce package sera installé et ajouté dans le fichier packages.config. D’autre part, les dépendances transitives de ce package seront aussi installées à savoir:

  • Microsoft.AspNet.WebPages
  • Microsoft.AspNet.Razor
  • Microsoft.Web.Infrastructure

La contenu du fichier packages.config devient:

<?xml version="1.0" encoding="utf-8"?> 
<packages> 
  <package id="Microsoft.AspNet.Mvc" version="5.2.6" targetFramework="net461" /> 
  <package id="Microsoft.AspNet.Razor" version="3.2.6" targetFramework="net461" /> 
  <package id="Microsoft.AspNet.WebPages" version="3.2.6" targetFramework="net461" /> 
  <package id="Microsoft.Web.Infrastructure" version="1.0.0.0" targetFramework="net461" /> 
</packages> 

Les assemblies contenues dans ces packages seront ajoutées au projet et visibles dans la partie “Références” (ou References) dans Visual Studio:

Pour configurer le mode “PackageReference” dans Visual Studio 2017:

  1. Cliquer sur le menu “Outils” (ou “Tools”) ⇒ “Options”
  2. Aller dans la partie “Gestionnaire de package Nuget” (ou “Nuget package manager”) ⇒ “Général”
  3. Dans la partie “Gestion de packages” (ou “package management”), sélectionner le paramètre “Format de gestion de package par défaut”: “PackageReference” au lieu de “packages.config”:
  4. Cliquer sur OK

Si avec cette configuration, on crée un nouveau projet (quel que soit le type) et si on ajoute un package NuGet, par exemple en exécutant dans la console du gestionnaire de package (i.e. Package Manager Console):

install-package Microsoft.AspNet.Mvc 

La présentation du projet dans Visual Studio est un peu différente:

  • Il n’y a plus de fichier packages.config
  • On ne voit plus qu’une référence vers le package NuGet installé, les dépendances transitives du package NuGet ne sont plus visibles:

Dans le fichier .csproj, il y a une référence vers le package installé mais pas de liens vers les dépendances transitives du package:

<?xml version="1.0" encoding="utf-8"?> 
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> 

  <!-- ... --> 

  <ItemGroup> 
    <PackageReference Include="Microsoft.AspNet.Mvc"> 
      <Version>5.2.6</Version> 
    </PackageReference> 
  </ItemGroup>

  <!-- ... --> 

</Project>

De même, il n’y a pas de répertoire packages dans le répertoire du projet contenant les packages NuGet téléchargés. Les packages NuGet sont téléchargés dans le répertoire par défaut du cache c’est-à-dire:

%LocalAppData%\NuGet\Cache

L’utilisation du mode “PackageReference” présente plusieurs avantages:

  • Il simplifie considérablement le fichier projet .csproj puisqu’il n’y a plus les références d’assemblies. En effet, ces références changeaient à chaque mise à jour du package.
  • On ne voit plus les dépendances transitives des packages qui alourdissaient les fichiers projet .csproj.

En revanche, la résolution des conflits de versions dans les packages NuGet est plus complexe. Pour faciliter cette résolution, on peut:

Pour plus de détails, voir Nuget en 5 min.

A la compilation, les assemblies sont directement copiées dans le répertoire de sortie.

Rendre flexible la version d’une référence de package

Au lieu de figer la version de la référence de package NuGet comme dans l’exemple précédent:

<PackageReference Include="Microsoft.AspNet.Mvc"> 
  <Version>5.2.6</Version> 
</PackageReference>

On peut utiliser des wildcards pour indiquer la version de la dépendance:

<PackageReference Include ="Microsoft.AspNet.Mvc" version="5.2.*"/> 

On peut aussi indiquer des contraintes de version:

1.0 1.0 ≤ x Version 1.0 ou supérieure
(1.0,) 1.0 < x Version strictement supérieure à 1.0
[1.0] x == 1.0 Exactement la version 1.0
(,1.0] x ≤ 1.0 Version 1.0 ou antérieure
(,1.0) x < 1.0 Version strictement antérieure à 1.0
[1.0,2.0] 1.0 ≤ x ≤ 2.0 Version entre la 1.0 et 2.0
(1.0) indication non valide

Par exemple:

<PackageReference Include ="Microsoft.AspNet.Mvc" version="[5.0.0,5.2)"/>

Forcer l’utilisation d’un répertoire “packages” avec le mode “PackageReference”

On peut toutefois forcer la présence du répertoire packages dans le répertoire du projet en éditant le fichier .csproj et en ajoutant l’élément de configuration RestorePackagePath:

<?xml version="1.0" encoding="utf-8"?> 
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> 
  <Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" 
  Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" /> 
  <PropertyGroup> 

    <!-- ... --> 

    <RestorePackagesPath>packages</RestorePackagesPath>
  </PropertyGroup> 

  <!-- ... --> 

</Project> 

Configurer un projet multi-target dans Visual Studio 2017

Comme indiqué plus haut, cette fonctionnalité est disponible pour les fichiers .csproj à la syntaxe simplifée. Cette syntaxe est possible seulement pour certains types de projet:

  • Bibliothèques de classes,
  • Applications console,
  • Applications ASP.NET Core,
  • Les applications .NET Core

Ainsi dans Visual Studio, si on crée un projet de type Console pour le framework .NET Core, les références du projet sont présentées de façon différente. Elles sont disponibles dans la partie “Dépendances” (i.e. Dependancies) du projet:

Traditionnellement dans les propriétés du projet, il est possible de modifier le framework cible. Pour accéder aux propriétés du projet, il faut:

  1. Faire un clique droit sur le projet dans Visual Studio
  2. Cliquer sur Propriétés
  3. Dans l’onglet “Application”, on peut éditer le paramètre “framework cible”:

Pour configurer le projet pour cibler plusieurs frameworks, il faut:

  1. Faire un clique droit sur le projet.
  2. Cliquer sur “Modifier <nom du projet>” (i.e. “Edit <nom du projet>”).
  3. Il est possible d’éditer le fichier .csproj directement dans Visual Studio (dans les versions actuelles, il n’existe pas d’éléments graphiques dans Visual Studio pour effectuer cette modification).
    Si “Modifier <nom du projet>” n’est pas présent, c’est que la syntaxe du fichier projet .csproj n’est pas une syntaxe simplifiée.
    Il n’y a pas d’assistants pour passer d’une syntaxe traditionnelle à une syntaxe simplifiée.
  4. Comme précédemment, il faut modifier le paramètre TargetFramework en ajoutant des TFM (i.e. Target Framework Moniker):
    <Project Sdk="Microsoft.NET.Sdk"> 
    
      <PropertyGroup> 
        <OutputType>Exe</OutputType> 
        <TargetFramework>netcoreapp2.1</TargetFramework>
      </PropertyGroup> 
    
    </Project>
    
  5. Ajouter un “s” à TargetFramework, par exemple:
    <Project Sdk="Microsoft.NET.Sdk"> 
    
      <PropertyGroup> 
        <OutputType>Exe</OutputType> 
        <TargetFrameworks>netcoreapp2.1;netstandard2.0</TargetFrameworks>
      </PropertyGroup> 
    
    </Project>
    
  6. Enregistrer le fichier .csproj.

Après rechargement du fichier par Visual Studio, les dépendances contiennent plusieurs frameworks cible:

En allant dans les propriétés du projet, on ne peut plus éditer le paramètre “Framework cible”:

Pourquoi configurer plusieurs plateformes cible alors qu’on peut utiliser .NET Standard ?

On peut se poser la question de savoir quelle est la nécessité de paramétrer plusieurs plateformes cible alors qu’il suffit de paramétrer une plateforme cible .NET Standard. Comme .NET Standard est compatible avec plusieurs frameworks (on peut voir les compatibilités sur la page docs.microsoft.com/fr-fr/dotnet/standard/net-standard), en configurant la plateforme cible .NET Standard, on s’assure d’être compatible avec tous les frameworks prenant en charge par le standard. C’est vrai, toutefois:

  • Comme indiqué plus haut, .NET Standard permet de produire des assemblies seulement pour les bibliothèques de classes. On ne peut pas paramétrer une cible correspondant à .NET Standard pour un exécutable. Si on souhaite produire des exécutables différents pour un même code, il faut pouvoir paramétrer des frameworks cibles différents.
  • Certains packages NuGet comportent des assemblies spécifiques à une plateforme donnée. Ces plateformes peuvent être différentes d’une version de .NET Standard. En paramétrant des frameworks cible précis, si la compilation réussit, on peut être sûr que les dépendances NuGet seront assurées pour les cibles paramétrées.
  • Un des intérêts de cette fonctionnalité est de produire des assemblies pour plusieurs frameworks à partir d’un même code. Il est possible d’affiner la compilation en appliquant des directives de compilation pour des plateformes spécifiques. On s’assure, ainsi, à la compilation que le code compilé est compatible avec le framework auquel il est destiné. Ces directives de compilation seront indiquées par la suite.

NuGet est intégré à MSBuild

Pour permettre de simplifier les fichiers, NuGet a été intégré à MSBuild. Cette intégration a plusieurs avantages:

  • L’exécution de NuGet fait partie d’une tâche MsBuild qu’on peut lancer directement en exécutant MsBuild. Par exemple, pour générer un package NuGet, on peut lancer la tâche suivante:
    msbuild.exe "<chemin du fichier projet .csproj>" /t:pack
    
  • On peut générer un package NuGet directement à la compilation du projet en allant dans les propriétés du projet dans Visual Studio, dans l’onglet “Package” et en cochant “Generate NuGet package on build”. On peut ainsi préciser des paramètres liés au package NuGet à générer, par exemple:
    <Project Sdk="Microsoft.NET.Sdk"> 
    
      <PropertyGroup> 
        <TargetFrameworks>netcoreapp2.1;netstandard2.0</TargetFrameworks> 
        <GeneratePackageOnBuild>True</GeneratePackageOnBuild> 
        <Company>CompanyName</Company> 
        <Authors>AuthorName</Authors> 
        <Description>Description</Description> 
        <Version>1.1.1</Version>
      </PropertyGroup> 
    
      <!-- ... --> 
    
    </Project> 
    

    Il existe un onglet de configuration dans Visual Studio dans les propriétés du projet:

  • On peut appliquer des conditions d’application pour l’utilisation d’une référence de package dans le fichier projet .csproj:
     <ItemGroup> 
        <PackageReference Include="Microsoft.AspNet.WebApi.Core" Version="5.2.6" 
        Condition = "'$(TargetFramework)' == 'netstandard2.0'" /> 
    </ItemGroup>
    

Pour plus d’informations: docs.microsoft.com/fr-fr/nuget/reference/msbuild-targets.

On peut effectuer le téléchargement des packages NuGet directement avec MsBuild dans une usine de build, on n’est plus obligé de configurer une étape supplémentaire pour exécuter nuget restore.

Possibilité d’avoir des symboles de préprocesseur dans le code.

On peut préciser des symboles de préprocesseur pour que des parties du code soient compilées pour une plateforme cible spécifique.

Par exemple:

public static class MultiTargetCode 
{ 
        public static string GetTargetFramework() 
        { 
#if NETCOREAPP2_1 
            return ".NET Core"; 
#elif NETFULL 
            return ".NET Framework"; 
#else 
            throw new NotImplementedException();  
#endif 
        } 
}

Quelques autres symboles de préprocesseur:

.NET Framework NET20, NET46, NET461, NET462, NET47, NET471, NET472
.NET Standard NETSTANDARD1_5, NETSTANDARD1_6, NETSTANDARD2_0
.NET Core NETCOREAPP2_0, NETCOREAPP2_1

Une liste exhaustive de ces symboles se trouve sur la page suivante: docs.microsoft.com/fr-fr/dotnet/standard/frameworks.

Références
Share on FacebookShare on Google+Tweet about this on TwitterShare on LinkedInEmail this to someonePrint this page

Les modules en Typescript en 5 min

Dans un article précédent, j’avais eu l’occasion d’évoquer les points les plus essentiels de la syntaxe Typescript (cf. L’essentiel de la syntaxe Typescript en 10 min). Volontairement, cet article ne traitait pas des modules, des exports et des imports d’objets de façon à parler de ces sujets dans un article à part entière. La raison principale qui a motivé un article séparé est que les modules correspondent à un sujet plus complexe que les différents éléments de syntaxe Typescript.

En effet, la définition des modules en Typescript hérite de la complexité des modules en Javascript. Comme on a pu l’indiquer dans un article consacré aux modules Javascript, il y a différentes méthodes pour définir des modules en Javascript. Etant donné que ce langage ne prévoyait pas de façon native les modules, ces différentes méthodes correspondent à autant de solutions pour tenter de les implémenter. A partir d’ECMAScript 2015 (i.e. ES6), l’implémentation de modules en Javascript peut se faire de façon native. Même si ES2015 a rendu obsolètes les solutions précédentes d’implémentation de modules, on ne peut pas complétement les ignorer car beaucoup de code existant les utilise encore.

Les modules Typescript sont moins complexes qu’ils y paraissent

De même que pour Javascript avant ES2015, Typescript a aussi, de son coté, dû trouver des éléments syntaxiques pour définir et implémenter des modules. Quand Javascript a implémenté ES2015, la syntaxe de Typescript s’est naturellement rapprochée de celle d’ES2015. La conséquence de cette évolution est que la notion de module peut être implémentée d’une part, avec des éléments syntaxiques spécifiques à Typescript et d’autre part, avec une syntaxe similaire à celle d’ES2015.

En préambule, cet article expliquera quelques éléments nécessaires à la compréhension des modules en Typescript. Dans un 2e temps, on indiquera quelques solutions pour implémenter des modules avant et après ES2015.

Préambule

Avant de rentrer dans le détail des syntaxes Typescript permettant d’implémenter des modules, il faut savoir comment ces modules sont transpilés en code Javascript. Avant ES2015, la notion de module n’était pas native à Javascript, il a donc fallu trouver une façon d’implémenter des modules. La méthode a été d’utiliser le pattern module et des IIFE (i.e. Immediatly-Invoked Function Expression ou Expression de fonction invoquée immédiatement).

Pattern module

Sans rentrer dans les détails, le pattern module permet d’implémenter la notion de module en Javascript avant ES2015. Ce pattern utilise une IIFE (i.e. Immediatly-Invoked Function Expression ou Expression de fonction invoquée immédiatement) pour définir le contenu du module. Une IIFE est un bloc de code qui est exécuté tout de suite après avoir été parsé. Dans ce bloc de code, on peut ainsi définir ce qui sera le module.

Une IIFE s’écrit de cette façon:

var Module = (function() {  
    // Bloc de code à exécuter 
})();

Pour utiliser une IIFE pour définir un module, on peut écrire par exemple:

var Module = (function() {  
    var self = {};  
    function privateFunc() {  
        // ...  
    };  

    self.publicFunc = function() {  
        privateFunc();  
    };  

    return self;  
})();  

Cette écriture permet de définir une variable appelée Module qui va contenir des membres et des fonctions, ce qui correspond à la notion de module:

  • privateFunc() est une fonction privée.
  • publicFunc() est une fonction publique.

Pour utiliser ce type de module, on peut l’instancier et l’appeler de cette façon:

var moduleInstance = new Module();  
moduleInstance.publicFunc();  

Pour être compatible avec ES5, les modules Typescript sont transpilés en utilisant le pattern module (pour plus de détails sur la syntaxe Javascript du pattern module, voir Les modules en Javascript en 5 min).

Directive “Triple-slash”

Cette directive dans l’en-tête d’un fichier Typescript permet d’indiquer au compilateur une référence vers un autre fichier en indiquant son emplacement physique. Le chemin est relatif par rapport au fichier contenant la référence. Cette directive doit être placée dans l’en-tête du fichier pour être valable, elle est de type:

/// <reference path="fichier.ts" /> 

Ce type de directive est essentiellement utilisé pour référencer des fichiers Typescript externes à un projet. La plupart du temps, avec les fichiers de configuration tsconfig.json, elle n’est plus nécessaire puisqu’il est possible d’indiquer dans ce fichier les emplacements des fichiers à compiler.

Par exemple, pour ne pas utiliser de directives triple-slash, on peut utilser les éléments de configuration "files" ou "include" dans un fichier tsconfig.json:

{ 
    ... 
    "files": [ 
        "fichier1.ts", 
        "fichier2.ts", 
        "fichier3.ts" 
    ], 
    "include": [ 
        "src/**/*" 
    ], 
    ... 
} 

Plus de détails sur ces éléments de configuration dans la partie sur la résolution des modules.

Un exemple d’utilisation d’une directive triple-slash se trouve dans le dépôt Github suivant: typescript_modules/typescript_triple-slash.

Cet exemple permet d’illustrer des appels d’un module à l’autre:

  • le module NamespaceForModule1 dans example/module1.ts appelle le module NamespaceForModule2 dans other_modules/modules2.ts. La directive est:
    /// <reference path="../other_modules/module2.ts" />
    
  • le module NamespaceForModule2 appelle le module NamespaceForModule3 dans other_modules/modules3.ts. La directive est:
    /// <reference path="module3.ts" /> 
    

Pour exécuter cet exemple, il faut:

  1. Aller dans le répertoire typescript_triple-slash/example et exécuter:
    user@debian:~/typescript_modules/typescript_triple-slash% npm install
    
  2. Compiler en exécutant la commande:
    user@debian:~/typescript_modules/typescript_triple-slash% npm run build
    
  3. Exécuter le serveur de développement avec l’instruction:
    user@debian:~/typescript_modules/typescript_triple-slash% npm start
    
  4. Ouvrir la page http://localhost:8080 dans un browser
  5. Enfin, il faut afficher la console de développement.
Pour afficher la console de développement dans un browser

Pour tous les exemples présentés dans cet article, pour voir les résultats d’exécution, il faut afficher la console de développement:

  • Sous Firefox: on peut utiliser la raccourci [Ctrl] + [Maj] + [J] (sous MacOS: [⌘] + [Maj] + [J], sous Linux: [Ctrl] + [Maj] + [K]) ou en allant dans le menu “Développement web” ⇒ “Console du navigateur”.
  • Sous Chrome: utiliser le raccourci [F12] (sous MacOS: [⌥] + [⌘] + [I], sous Linux: [Ctrl] + [Maj] + [I]) puis cliquer sur l’onglet “Console”. A partir du menu, il faut aller dans “Plus d’outils” ⇒ “Outils de développement”.
  • Sous EDGE: utiliser le raccourci [F12] puis naviguer jusqu’à l’onglet “Console”.

Le résultat de la compilation est de type:

Executed from module1.privateFunc() 
Executed from module2.privateFunc() 
Executed from module2.publicFunc() 
Executed from module3.privateFunc() 
Executed from module3.publicFunc() 
Executed from module1.publicFunc()
Suppression du cache du browser

Sachant que tous les exemples présentés dans cet article utilisent la même URL http://localhost:8080, il peut subvenir certaines erreurs pendant leur exécution. Ces erreurs peuvent être dues au cache utilisé dans les browsers, en particulier pour les fichiers Javascript. Pour éviter que d’anciennes versions du fichiers restées dans le cache ne soient exécutées à la place de nouveaux fichiers:

  • On peut forcer le rechargement de la page avec le raccourci [Ctrl] + [Maj] + R
  • On peut aussi supprimer les fichiers se trouvant dans le cache en exécutant le raccourci [Ctrl] + [Maj] + [Supp]

Implémenter des modules en Typescript

Il existe plusieurs méthodes pour implémenter des modules en Typescript:

  • Utiliser des namespaces: ils se rapprochent de la notion de namespace en C#. Ils sont transpilés en modules en Javascript.
  • Avant ES2015/ES6: il faut utiliser des formats pour étendre la syntaxe Javascript pour permettre l’implémentation de modules. Ces formats peuvent CommonJS, AMD (i.e. Asynchronous Module Definition) ou UMD (i.e. Universal Module Definition). Ces formats doivent être associés à des loaders qui permettent de charger les modules transpilés en Javascript au runtime.
  • Utiliser la syntaxe ES2015/ES6: à partir d’ES2015, Javascript prévoit des éléments de syntaxe pour implémenter nativement des modules. En Typescript, la syntaxe permettant d’implémenter ce type de module est très proche de celle en Javascript.

Namespaces

Les namespaces correspondent à une notion spécifique à Typescript pour implémenter des modules en Javascript. Les namespaces en Typescript sont très semblables aux namespaces en C#: il s’agit d’un découpage du code en bloc correspondant à des modules logiques.

Comme pour les namespaces en C#, les namespaces Typescript:

  • Ont des noms qui se définissent avec une hiérarchie du type <namespace1>.<namespace2>.<namespace3>, par exemple:
    mainNs.intermediateNs.innerNs 
    
  • Les blocs de code sont inclus dans les namespaces, par exemple:
    namespace nsName { 
        // Bloc de code 
    } 
    
  • Sont indépendants des fichiers Typescript: on peut utiliser un même namespace dans des fichiers différents. Les éléments dans le namespace seront, ainsi, regroupés.

Les namespaces sont une approche permettant d’implémenter des modules en Javascript mais elle n’est pas la seule. D’autres approches permettent d’implémenter cette notion.

Avant Typescript 1.5, les namespaces Typescript étaient appelés “internal modules“. Ce terme prête particulièrement à confusion puisqu’il facilite la confusion entre la notion de module Typescript et les modules à proprement parlé en Javascript. Il est important d’avoir en tête que cette notion de namespace n’est pas tout à fait semblable aux modules en Javascript.

Mot clé namespace et module sont strictement équivalents

Au niveau de la syntaxe Typescript, le mot clé namespace peut être remplacé par le mot clé module. Dans ce contexte, les 2 mots clé sont équivalents.

Un exemple d’implémentation de namespaces se trouvent dans le dépôt Github suivant: typescript_modules/typescript_namespace.

Cet exemple permet d’illuster des appels d’un module à l’autre. Les différents modules sont définis dans des namespaces différents.

Par exemple, le namespace module1Namespace qui se trouve dans le fichier module1.ts:

namespace module1Namespace { 
    function privateFunc() { 
        console.log("Executed from module1.privateFunc()"); 
    } 

    export function publicFunc() { 
        privateFunc(); 

        console.log("Executed from module1.publicFunc()"); 

        namespaceForModule2.publicFunc(); 
    } 
} 

Dans ce fichier, on importe le namespace module2Namespace se trouvant dans le fichier module2.ts de cette façon:

import namespaceForModule2 = module2Namespace; 

On peut ensuite appeler une fonction se trouvant dans le namespace module2Namespace en écrivant:

namespaceForModule2.publicFunc(); 

L’utilisation de la directive import est complétement facultative et sert uniquement à définir l’alias namespaceForModule2, on peut se contenter d’appeler directement:

module2Namespace.publicFunc(); 

De même que précédemment, pour compiler cet exemple:

  1. Il faut exécuter les instructions suivantes pour, respectivement, installer les packages npm, compiler et démarrer le serveur de développement:
    user@debian:~/typescript_modules/typescript_namespace% npm install && npm run build && npm start
    
  2. Ouvrir la page http://localhost:8080 dans un browser.
  3. Afficher la console de développement.

Le résultat de l’exécution est semblable à celui de l’exemple précédent.

On peut remarquer que le résultat de la compilation produit un module au sens Javascript en utilisant le pattern module. Par exemple, si on regarde le résultat de la compilation du contenu du fichier module1.ts dans le fichier public/module1.js:

 var namespaceForModule2 = module2Namespace; 
var module1Namespace; 
(function (module1Namespace) { 
    function privateFunc() { 
        console.log("Executed from module1.privateFunc()"); 
    } 

    function publicFunc() { 
        privateFunc(); 
        console.log("Executed from module1.publicFunc()"); 
        namespaceForModule2.publicFunc(); 
    } 

    module1Namespace.publicFunc = publicFunc; 
})(module1Namespace || (module1Namespace = {}));

Les namespaces peuvent être mergés

Les namespaces peuvent être mergés. Ainsi on peut définir des objets dans un même namespace réparti sur plusieurs fichiers Typescript à condition de ne pas avoir de collision dans le nommage des objets exportés.

Dans le répertoire typescript_modules/typescript_mergin_namespace se trouve un exemple d’un namespace défini sur plusieurs fichiers Typescript.

Par exemple, on définit le namespace module2Namespace dans le fichier module2.ts:

namespace module2Namespace { 
    function privateFunc() { 
        console.log("Executed from module2.privateFunc()"); 
    } 

    export function publicFunc() { 
        privateFunc(); 
        console.log("Executed from module2.publicFunc()"); 
        otherPublicFunc(); 
    } 
}

Et dans le fichier module3.ts:

namespace module2Namespace { 
    function privateFunc() { 
        console.log("Executed from privateFunc() in module3.ts"); 
    } 

    export function otherPublicFunc() { 
        privateFunc(); 
        console.log("Executed from otherPublicFunc() in module3.ts"); 
    } 
} 

A condition d’exporter des éléments avec le mot clé export, on peut utiliser plusieurs fichiers pour définir un namespace. Les fichiers module2.ts et module3.ts seront transpilés en 2 fichiers Javascript qui définissent un seul module Javascript.

Pour compiler, il faut exécuter:

user@debian:~/typescript_modules/typescript_merging_namespace% npm install && npm run build

Parmi les fichiers générés, on obtient module2.js:

var module2Namespace; 
(function (module2Namespace) { 
    function privateFunc() { 
        console.log("Executed from module2.privateFunc()"); 
    } 

    function publicFunc() { 
        privateFunc(); 
        console.log("Executed from module2.publicFunc()"); 
        module2Namespace.otherPublicFunc(); 
    } 

    module2Namespace.publicFunc = publicFunc; 
})(module2Namespace || (module2Namespace = {})); 

Et module3.js:

var module2Namespace; 
(function (module2Namespace) { 
    function privateFunc() { 
        console.log("Executed from privateFunc() in module3.ts"); 
    } 

    function otherPublicFunc() { 
        privateFunc(); 
        console.log("Executed from otherPublicFunc() in module3.ts"); 
    } 

    module2Namespace.otherPublicFunc = otherPublicFunc; 
})(module2Namespace || (module2Namespace = {})); 

On remarque que ces fichiers permettent de définir un même module nommé module2Namespace.

Pour exécuter cet exemple, il faut exécuter:

user@debian:~/typescript_modules/typescript_merging_namespace% npm start 

Il faut ensuite ouvrir un browser à l’adresse http://localhost:8080 et afficher la console de développement.

Implémenter des modules avant ES2015

Les namespaces Typescript ne correspondent pas tout à fait aux modules Javascript. Ils correspondent à un découpage logique du code qui peut être séparé en plusieurs fichiers. A l’opposé les modules Javascript sont découpés par fichier: un fichier correspond à un module le plus souvent.

Avant Typescript 1.5, namespace et module s’appelaient différemment:

  • Les namespaces s’appelaient “internal modules” et
  • Les modules s’appelaient “external modules“.

Même si les notions de namespace Typescript et de module sont différentes, après compilation du code, les namespaces Typescript sont transpilés en module Javascript en utilisant le pattern module. En effet, avant ES2015, Javascript ne gérait pas de façon native la notion de module. Le pattern module est un moyen de contournement pour définir des modules. Pour implémenter ces modules, on peut s’aider de formats qui vont apporter des mot-clés pour enrichir le langage Javascript et permettre une implémentation plus facile des modules sans écrire explicitement le pattern module.

En Typescript, on bénéficie de ces formats et on peut, comme en Javascript, les utiliser pour implémenter des modules Javascript avant ES2015. Au runtime, pour charger les modules en utilisant le format, il faut s’aider de loaders. Les formats les plus connus sont CommonJS, AMD (i.e. Asynchronous Module Definition) et UMD (i.e. Universal Module Definition). Les loaders les plus connus sont RequireJS (utlisant le format AMD), SystemJS (qui permet de gérer plusieurs formats).

CommonJS + SystemJS

CommonJS et SystemJS sont respectivement un format et un loader permettant d’implémenter les modules avant ES2015. Il existe d’autres formats comme AMD ou UMD. Un exemple d’implémentation de module en utilisant CommonJS se trouve dans le dépôt Github suivant: typescript_modules/typescript_commonjs. Cet exemple permet d’illustrer des appels entre modules.

Comme en Javascript, CommonJS permet de définir un format pour enrichir le langage avec une fonction qui va permettre d’effectuer des références vers un autre module. La fonction utilisée pour inclure des modules provenant d’un autre fichier est require.

Par exemple, si on écrit:

var moduleInFile = require('./file.js');  

On définit une variable locale contenant le module se trouvant dans le fichier file.js. On peut directement utiliser les éléments se trouvant dans le module en les préfixant avec moduleInFile.

CommonJS sert de format pour la compilation. Pour l’exécution du code, il est nécessaire d’utiliser un loader qui va interpréter la syntaxe définie par le format CommonJS et charger les modules Javascript. SystemJS est un loader qui permet d’interpréter le format CommonJS.

Pour plus de détails sur CommonJS et SystemJS, voir CommonJS + SystemJS.

Dans le cas de notre exemple, si on regarde le code du module se trouvant dans le fichier module1.ts:

import importedModule2 = require('./module2'); 

function privateFunc() { 
    console.log("Executed from module1.privateFunc()"); 

    importedModule2.publicFunc(); 
} 

export function publicFunc() { 
    privateFunc(); 

    console.log("Executed from module1.publicFunc()");
} 

On importe le module2 en utilisant la fonction require du format CommonJS:

import importedModule2 = require('./module2'); 

Puis on utilise l’alias défini pour appeler une fonction se trouvant dans le module2:

importedModule2.publicFunc(); 

Pour compiler, il faut exécuter:

user@debian:~/typescript_modules/typescript_commonjs% npm install && npm run build

Après compilation, la syntaxe produit des modules compatibles avec le format CommonJS:

"use strict"; 
Object.defineProperty(exports, "__esModule", { value: true }); 
var importedModule2 = require("./module2"); 
function privateFunc() { 
    console.log("Executed from module1.privateFunc()"); 
    importedModule2.publicFunc(); 
} 

function publicFunc() { 
    privateFunc(); 

    console.log("Executed from module1.publicFunc()"); 
} 

exports.publicFunc = publicFunc; 

Pour exécuter ce code, il faut effectuer quelques ajouts dans la page HTML principale en important systemJS puis en le configurant:

<!DOCTYPE html> 
<html lang="en"> 
    <head> 
        <meta charset="UTF-8">  
        <title>Test module Typescript with /// (triple-slash)</title> 
        <script src="node_modules/systemjs/dist/system.js"></script>  
        <script> 
            // Le code suivant correspond à de la configuration  
            SystemJS.config({  
                meta:{     
                    format: 'cjs'  // cjs correspond à la configuration pour le format commonjs  
                },     

                packages: { 
                "/public": { defaultExtension: "js" }   // pour charger par défaut les fichiers avec l'extension JS 
                }
            });  

            SystemJS.import('public/index.js'); 
        </script>
    </head>    
    <body>
    </body> 
</html> 

Pour exécuter cet exemple, il faut exécuter la commande:

  1. user@debian:~/typescript_modules/typescript_commonjs% npm start
    
  2. Se connecter avec un browser à l’adresse http://localhost:8080
  3. Afficher la console de développement.

Le résultat de l’exécution est semblable à celui des exemples précédents.

Modules ES2015/ES6

Il est possible d’utiliser la syntaxe ES2015 dans le code Typescript pour définir des modules Javascript. Les modules Javascript ainsi compilés ont les mêmes caractéristiques que des modules ES2015 classiques. Ils sont supportés nativement par Node.js et par les browsers à partir d’une certaine version:

  • Chrome à partir de la version 63.
  • Firefox à partir de la version 60,
  • Edge à partir de la version 16,
  • Safari à partir de la version 11,
  • Node.js à partir de la version 10.

Pour plus d’informations sur les compatibilités des browsers:

Le code Javascript comportant des modules ES2015 s’exécute de plusieurs façons:

  • Exécuter le code Javascript sur un serveur Node.js: à partir de la version 10, Node.js gère nativement le chargement de modules au format ES2015.
  • Il est aussi possible d’associer un loader comme RequireJS ou SystemJS qui va charger lui-même les modules.

Les propriétés les plus importantes à avoir en tête avec les modules ES2015 sont les suivantes:

  • Par défaut, tous les modules ES2015 sont privés.
  • Un fichier Javascript correspond à un module.
  • Pour exposer des éléments comme des variables, des fonctions ou des classes à l’extérieur d’un module, il faut utiliser le mot-clé export.

Exporter un module

La syntaxe Typescript utilisant des modules est semblable à celle en Javascript. Il faut exporter explicitement les éléments pour les utiliser à l’extérieur d’un module.

Pour exporter, on utilise le mot-clé export.

Par exemple:

export function func1() {  
    // ...  
}  

Dans cet exemple, on exporte une fonction à l’extérieur du module.

D’autres formes de syntaxe sont possibles pour exporter plusieurs éléments du module en une fois:

function func1() {  
    // ...  
}  

function func2() {  
    // ...  
}  

export { func1, func2 };

On peut aussi indiquer l’instruction export avant la déclaration des éléments à exporter:

export { func1, func2 };

function func1() {  
    // ...  
}  

function func2() {  
     // ...  
}  

On peut indiquer un alias lors de l’export:

export { func1 as exporterFunc1 };  

function func1() {  
    // ...  
}

Importer un module

L’import d’un module correspond à importer les éléments exportés par ce module dans un autre.

Pour importer tous les éléments exportés d’un module dans une variable, on peut utiliser la syntaxe:

import * as moduleInFile from './file'; 

Dans ce cas, la variable est moduleInFile, on peut l’utiliser directement pour accéder aux éléments du module, par exemple:

moduleInFile.func1();  

On peut importer un élément spécifique et non pas tous les éléments exportés du module en précisant les éléments à importer à partir de leur nom, par exemple:

import { func1 as funcFromOuterModule, func2 } from './file';  

func1 as funcFromOuterModule permet de renommer le nom de l’élément importé. Cette déclaration est facultative, ainsi elle n’est pas utilisée pour func2.

Dans ce cas, on peut appeler directement les fonctions:

func2();  
funcFromOuterModule();  

Export par défaut

L’export d’un élément par défaut permet d’indiquer l’élément qui sera importé dans un module si ce dernier ne précise pas ce qui doit être importé. Ainsi, au moment d’importer l’élément d’un module, il ne sera pas nécessaire de préciser le nom de l’élément à importer, l’élément par défaut sera le seul élément importé même si le fichier comporte d’autres éléments.

Par exemple:

function func1() {  
    // ...  
}  

function func2() {  
    // ...  
}  

export default func1;  

Au moment d’importer, le seul élément importé sera l’élément exporté par défaut:

import func1 from "file";   

L’import ne comporte pas d’accolades. C’est l’élément par défaut qui est importé.

L’élément importé par défaut peut être utilisable directement:

func1();  

Export nommé

On peut nommer un export de façon à modifier le nom de l’élément qui est exporté.

Par exemple, dans un premier temps on exporte un élément en le renommant:

function func1() {  
    // ...  
}  

function func2() {  
    // ...  
}  

export default func1;  
export var finalFunc = func2;

En plus de l’élément par défaut, on décide de renommer un élément exporté.

A l’import, il faut indiquer le nouveau nom de l’élément:

import { finalFunc } from "./file";  

On peut directement utilisé le nouveau nom de l’élément:

finalFunc();  

Dans cet exemple, si on souhaite importer l’élément par défaut, on peut écrire:

import func1, { finalFunc } from "./file";  

Configuration du compilateur

Il existe une option de compilation qui permet d’indiquer la syntaxe à utiliser pour les modules dans le code Javascript et pour générer du code Javascript avec une syntaxe différente. L’option module permet d’indiquer le format à utiliser pour la génération des modules:

{ 
    "compilerOptions": { 
        "module": "es6"
    } 
} 

Les valeurs possibles sont "CommonJS", "AMD", "UMD", "ES6", "ES2015", "ESNEXT" ou "None". Si on utilise "None", on ne peut pas déclarer des modules dans le code Typescript. Il ne faut pas confondre ce paramètre avec target qui indique la version d’ECMAScript cible de compilation. La configuration module ne concerne que le format utilisé pour les modules.

Ainsi, si on utilise l’option:

  • "es6" ou "es2015": il faut préciser l’extension ".js" dans les déclarations import dans les fichiers Typescript, par exemple:
    import { finalFunc } from "./file.js";
    

    Cette déclaration sera transpilée tel quel dans le fichier Javascript. Au runtime, pour que le browser ou Node.js puisse trouver le fichier à importer, il faut qu’il comporte l’extension du fichier Javascript.

  • "CommonJS": le format généré sera CommonJS. Au runtime, il faut associer ce format à un loader comme par exemple SystemJS.
  • "AMD": le format généré sera AMD. De même au runtime, il faut associer ce format à un loader comme par exemple RequireJS.
  • "UMD": le format généré sera UMD (i.e. Universal Module Definition). Ce format permet de générer compatible avec plusieurs formats comme AMD et CommonJS.

On peut aussi préciser le format au moment d’exécuter le compilateur:

tsc --module <nom du format>

Par exemple:

tsc --module es6

Exemple avec la syntaxe ES2015/ES6

Un exemple d’implémentation de modules en utilisant la syntaxe ES2015/ES6 se trouvent dans le dépôt Github suivant: typescript_modules/typescript_es6. Cet exemple permet d’illustrer des appels d’un module à l’autre en utilisant la syntaxe ES6.

Pour exécuter cet exemple:

  1. Il faut exécuter les instructions suivantes pour, respectivement, installer les packages npm, compiler et démarrer le serveur de développement:
    user@debian:~/typescript_modules/typescript_es6% npm install && npm run build && npm start
    
  2. Ouvrir la page http://localhost:8080 dans un browser.
  3. Afficher la console de développement.

Le résultat de l’exécution est semblable à celui des exemples précédents.

Exemple avec la syntaxe AMD

Un exemple d’implémentation de modules générés au format AMD se trouve dans le dépôt Github suivant: typescript_modules/typescript_es6_amd. Cet exemple permet d’illustrer la génération des modules au format AMD.

Pour exécuter cet exemple, il faut installer les packages en exécutant les commandes:

  1. Il faut exécuter les instructions suivantes pour, respectivement, installer les packages npm, compiler et démarrer le serveur de développement:
    user@debian:~/typescript_modules/typescript_es6_amd% npm install && npm run build && npm start
    
  2. Ouvrir la page http://localhost:8080 dans un browser.
  3. Afficher la console de développement.

Le format AMD ajoute une fonction qui n’existe pas dans la syntaxe Javascript: define. Cette fonction comporte 2 paramètres:

  • Une tableau de dépendances: ce sont les fichiers nécessaires à l’exécution de la fonction.
  • Une fonction: cette fonction est le module à définir.

On peut voir un exemple d’utilisation de la fonction define en regardant le résultat de la compilation, par exemple dans le répertoire typescript_es6_amd/build/index.js:

define(["require", "exports", "./module1.js"], function (require, exports, module1_js_1) { 
    "use strict"; 
    Object.defineProperty(exports, "__esModule", { value: true }); 
    var Startup = /** @class */ (function () { 
        function Startup() { 
        } 

        Startup.main = function () { 
            module1_js_1.publicFunc(); 
            return 0; 
        }; 

        return Startup; 
    }()); 

    Startup.main(); 
}); 

Avec ce format, le loader utilisé est RequireJS. Il est inclus dans le code HTML de la page principale index.html:

<script data-main="app" src="node_modules/requirejs/require.js"></script> 

L’attribut data-main="app" correspond au point d’entrée de l’application qui est à un fichier Javascript app.js contenant la configuration de requireJS.

Le résultat de l’exécution est semblable à celui des exemples précédents.

Génération de code compatible avec ES5

Comme on a pu le voir pour l’exemple utilisant le format AMD, on a utilisé dans le code Typescript une syntaxe ES2015. Pourtant les modules ont été générés au format AMD qui compatible avec ES5. Il en est de même si on génère les modules en utilisant le format CommonJS.

Utilisation de “bundlers”

Les solutions présentées jusqu’içi ont un gros inconvénient: elles nécessitent d’inclure tous les fichiers Javascript générés dans le fichier HTML principal. Dans les exemples, on déclare les scripts Javascript correspondant aux différents modules de cette façon:

<!DOCTYPE html> 
<html lang="en"> 
    <head> 
        <meta charset="UTF-8">  
        <title>Test module Typescript ES6</title> 
    </head>    
    <body> 
        <script src="module3.js" ></script> 
        <script src="module2.js" ></script> 
        <script src="module1.js" ></script> 
        <script src="index.js" ></script>
    </body> 
</html> 

Ce type de déclaration peut être difficile à maintenir à l’échelle d’un grand projet.

Une solution pour palier à ce problème peut être d’utiliser des bundlers qui vont générer un fichier Javascript unique en incluant toutes les dépendances au moment de la compilation. Le code produit sera compatible ES5 et utilisable sur tous les browsers supportant ES5. Le gros inconvénient des bundlers est qu’ils vont générer un seul fichier contenant tout le code Javascript. Dans le cas d’une grosse application et si l’application comporte beaucoup de code Javascript, le temps de chargement du fichier bundle peut prendre du temps et ralentir le chargement de la page principale.

Les bundlers les plus connus sont Browserify ou webpack.

Exemple d’utilisation de webpack avec la syntaxe ES2015

Un exemple d’implémentation de modules en utilisant le bundler webpack se trouve dans le dépôt Github suivant: typescript_modules/webpack_es6. Cet exemple permet d’illustrer la génération d’un bundle contenant des modules implémentés au format ES2015.

Pour utiliser le bundler webpack, on a installé les packages webpack et ts-loader. Le package ts-loader permet à webpack de s’interfacer avec le compilateur Typescript. Pour exécuter webpack avec un serveur de développement, on a installé le package webpack-dev-server. Pour effectuer ces installations, on a exécuté la commande suivante:

npm install --save-dev webpack ts-loader webpack-dev-server 

Le fichier de configuration de webpack qui se trouve dans typescript_modules/webpack_es6/webpack.config.js permet d’indiquer qu’on souhaite utiliser le loader ts-loader et que le résultat de la compilation se fasse dans le fichier public/app.js. Le contenu de ce fichier est:

const path = require('path'); 

module.exports = { 
    entry: './index.ts', 
    module: { 
      rules: [ 
        { 
          use: 'ts-loader', 
          exclude: /node_modules/ 
        } 
      ] 
    }, 
    resolve: { 
      extensions: [ '.tsx', '.ts', '.js' ] 
    }, 
    output: { 
      filename: 'app.js', 
      path: path.resolve(__dirname, 'public') 
    } 
}; 

Pour lancer la compilation avec webpack en utilisant le fichier de configuration, on peut exécuter la commande suivante:

webpack ./webpack.config.js --mode development 

Enfin pour utiliser le bundle, il suffit de rajouter le script correspondant au bundle dans le fichier HTML principal:

<!DOCTYPE html> 
<html lang="en"> 
    <head> 
        <meta charset="UTF-8">  
        <title>Test module TS with webpack</title> 
    </head>
    <body>
        <script src="app.js" ></script>
    </body> 
</html> 

Pour lancer l’exécution avec le serveur de développement de webpack, on peut exécuter la commande suivante:

webpack-dev-server ./webpack.config.js --content-base ./public --mode development 

Plus simplement dans le cadre de cet exemple, on peut aussi:

  1. Exécuter les commandes suivantes qui effectuent toutes ces opérations:
    user@debian:~/typescript_modules/webpack_es6% npm install && npm run build && npm start
  2. Charger la page http://localhost:8080 sur un browser.
  3. Afficher la console de développement.

Le résultat de l’exécution est semblable à celui des exemples précédents.

Exemple d’utilisation de webpack avec la syntaxe “namespace”

Les différentes syntaxes d’implémentation de modules sont possibles avec webpack. On a montré précédemment un exemple avec le format ES2015. Il est facile de transposer cet exemple en utilisant le format namespace spécifique à Typescript.

Un exemple d’implémentation avec la syntaxe des namespaces se trouve dans le dépôt Github suivant: typescript_modules/webpack_es6_namespace. L’exemple est le même que précédemment, à la différence de l’utilisation de namespaces pour déclarer des modules (la syntaxe générale des namespaces a été explicitée plus haut).

Comment le compilateur parcourt les modules ?

Pour trouver les modules suivant les références faites avec la directive triple-slash ou avec import, le compilateur effectue une étape de résolution. Cette résolution des modules se fait en partie en fonction de la configuration. De nombreux éléments de configuration peuvent avoir un impact sur la façon de réaliser cette résolution.

D’une façon générale, le compilateur Typescript tsc inclue dans son étape de compilation tous les fichiers qui se trouvent dans le répertoire du fichier de configuration tsconfig.json.

Avant de compiler ces fichiers, le compilateur va résoudre les dépendances de modules en fonction des directives import et triple-slash.

Paramètres de compilation “files”, “include” et “exclude”

Le comportement du compilateur lors de la résolution de modules peut être modifié par l’utilisation de certains paramètres dans le fichier tsconfig.json:

  • "files": ce paramètre permet d’indiquer les chemins absolus ou relatifs des fichiers à compiler:
    { 
       "compilerOptions": {}, 
       "files": [ 
           "file1.ts", 
           "file2.ts", 
           "file3.ts"
       ] 
    }
    

    Quand ce paramètre est présent, le compilateur ne va pas parcourir tous les fichiers .ts/.tsx/.d.ts présents dans le répertoire du fichier de configuration tsconfig.json mais seulement les fichiers précisés et leur références.

  • "include": permet d’indiquer des fichiers en utilisant des wildcards:
    { 
       "compilerOptions": {}, 
       "include": [ 
           "src/**/*"
       ] 
    } 
    

    Les wildcards utilisables sont:

    • "*" pour indiquer un ensemble de caractères.
    • "?" pour indiquer un seul caractère.
    • "**/" pour parcourir récursivement tous les répertoires enfants.

    Dans l’exemple, le compilateur va donc parcourir récursivement tous les fichiers et répertoires se trouvant dans le répertoire src.

  • "exclude": permet d’exclure des fichiers se trouvant dans le parcours des fichiers .ts/.tsx/.d.ts du compilateur:
    { 
       "compilerOptions": {}, 
       "exclude": [ 
           "folder", 
           "file*.ts, 
           "**/innerFile*.ts"
       ] 
    } 
    

    Les wildcards indiquées plus haut sont aussi utilisables avec le paramètre "exclude".

Import relatif et non-relatif

Il faut distinguer 2 types d’import:

  • Les imports relatifs: ces imports se font de façon relatives au fichier dans lequel on veut effectuer l’import. Ainsi si on écrit dans le fichier fileA.ts:
    import * from './fileB.ts';
    

    La recherche de la dépendance se fera dans le même répertoire que le fichier fileA.ts

  • Les imports non-relatifs: ces imports ne se font pas en fonction de l’emplacement du fichier dans lequel on effectue l’import. Il se fait dans des répertoires définis suivant une stratégie de recherche. Cette stratégie dépend de la configuration.

    Pour ce type d’import, on indique directement le nom du module (sans indication de répertoire ou d’extension de fichier).

    Par exemple:

    import * from 'moduleName'; 
    

Stratégies de résolution des modules

Il existe 2 stratégies de résolution des modules:

  • "Classic": correspond à un parcours du répertoire de compilation et des répertoires parents.
  • "Node": cette stratégie imite la stratégie de résolution de Node.js avec les fichiers Javascript. Elle permet de parcourir le répertoire où se trouve le fichier .ts d’où se fait l’import ou le répertoire "node_modules".

On peut préciser la stratégie de résolution en ajoutant l’option --moduleResolution au lancement du compilateur:

tsc --moduleResolution "Node" 

Par défaut, la stratégie est classique si le type de module choisi dans tsconfig.json est "AMD", "System" ou "ES6". Si un autre type de module est choisi, la stratégie est "Node".

Stratégie classique: “Classic”

La stratégie classique consiste à parcourir le répertoire de compilation du fichier d’où se fait l’import en cherchant les fichiers avec une extension .ts ou .d.ts:

  • Dans le cas d’un import relatif: la recherche se fait seulement dans le répertoire cible de l’import. Par exemple si le fichier dans /folder/file1.ts effectue un import:
    import { module2 } from '../folder2/moduleName'; 
    

    Alors le parcours se fera en cherchant successivement les fichiers /folder2/moduleName.ts et /folder2/moduleName.d.ts.

  • Dans le cas d’un import non-relatif: la recherche se fait dans le répertoire du fichier d’où se fait l’import et dans les répertoires parents.

    Par exemple si le fichier dans /folder/file1.ts effectue un import:

    import { module2 } from 'moduleName'; 
    

    Alors le parcours se fera en cherchant successivement les fichiers: /folder/moduleName.ts, /folder/moduleName.d.ts, /folder/moduleName.ts et /folder/moduleName.d.ts.

Stratégie “Node”

Cette stratégie imite celle de Node.js pour la résolution des imports des fichiers Javascript:

  • Dans le cas d’un import relatif: le parcours se fait dans le répertoire du fichier d’où se fait l’import. Par exemple si le fichier dans /folder/file1.ts effectue un import:
    import { module2 } from '../folder2/moduleName'; 
    

    Alors le parcours se fera successivement en cherchant les fichiers:

    • /folder2/moduleName.ts, /folder2/moduleName.tsx ou /folder2/moduleName.d.ts
    • /folder2/moduleName/package.json (en utilisant la propriété "types" dans ce fichier)
    • /folder2/moduleName/index.ts, /folder2/moduleName/index.tsx ou /folder2/moduleName/index.d.ts.
  • Dans le cas d’un import non-relatif: le parcours se fait dans le répertoire node_modules (ce répertoire correspond au répertoire d’installation des packages NPM).

    Par exemple si le fichier dans /folder/file1.ts effectue un import:

    import { module2 } from 'moduleName'; 
    

    Alors le parcours se fera dans le répertoire node_modules se trouvant dans le répertoire du fichier en cherchant un fichier correspondant aux chemins suivants:

    • /folder/node_modules/moduleName.ts, /folder/node_modules/moduleName.tsx ou /folder/node_modules/moduleName.d.ts,
    • /folder/node_modules/moduleName/package.json,
    • /folder/node_modules/moduleName.index.ts, /folder/node_modules/moduleName.index.tsx ou /folder/node_modules/moduleName.index.d.ts.

    Le parcours se fera, ensuite, dans le répertoire node_modules se trouvant dans un répertoire parent:

    • /node_modules/moduleName.ts, /node_modules/moduleName.tsx ou /node_modules/moduleName.d.ts,
    • /node_modules/moduleName/package.json,
    • /node_modules/moduleName.index.ts, /node_modules/moduleName.index.tsx ou /node_modules/moduleName.index.d.ts.

Paramètres de compilation “baseUrl”, “paths” et “rootDirs”

Ces paramètres peuvent modifier les chemins utilisés lors de l’application de la stratégie de recherche des modules à importer.

“baseUrl”
Il permet d’indiquer un préfixe que le compilateur appliquera systématiquement aux imports non-relatifs. Ce paramètre peut être précisé à l’exécution du compilateur:

tsc --baseUrl "../moduleFolder/" 

Il peut aussi être précisé dans le fichier tsconfig.json:

{ 
   "compilerOptions": { 
       "baseUrl": "../moduleFolder/"
   } 
} 

“paths”
Ce paramètre permet de préciser un mapping entre des noms de module et leur chemin effectif. La valeur du paramètre "baseUrl" est rajouté aux chemins des mappings précisés dans "paths".

Par exemple pour la configuration suivante:

{ 
   "compilerOptions": { 
       "baseUrl": "../moduleFolder/", 
       "paths": { 
            "moduleName": [ "otherModules/moduleName" ] 
       }
   } 
} 

Si on effectue l’import non-relatif suivant:

import { module2 } from 'moduleName'; 

L’import sera fait dans le répertoire (par rapport à l’emplacement du fichier tsconfig.json):

../moduleFolder/otherModules/moduleName 

Il est possible d’utiliser plusieurs répertoires de mapping:

{ 
  "compilerOptions": { 
    "baseUrl": "../moduleFolder/", 
    "paths": { 
      "moduleName": [  
        "otherModuleFolder1/moduleName", 
        "otherModuleFolder2/moduleName"
      ] 
    } 
  } 
} 

L’import sera fait en cherchant dans les répertoires: ../moduleFolder/otherModuleFolder1/moduleName et ../moduleFolder/otherModuleFolder2/moduleName.

Il est aussi possible d’utiliser le caractère "*" pour introduire plus de flexiblité au moment de l’import. Par exemple si le fichier de configuration est:

{ 
  "compilerOptions": { 
    "baseUrl": "../moduleFolder/", 
    "paths": { 
      "*": [  
        "otherModuleFolder1/*", 
        "otherModuleFolder2/*"
      ] 
    } 
  } 
} 

Si on effectue l’import:

import 'folder/moduleName'; 

L’import sera fait en cherchant dans les répertoires: ../moduleFolder/otherModuleFolder1/folder/moduleName et ../moduleFolder/otherModuleFolder2/folder/moduleName.

“rootDirs”
Ce paramètre permet d’indiquer au compilateur des répertoires virtuels qui doivent être mergés après le déploiement de l’application.

Par exemple, s’il existe plusieurs répertoires indiqués dans le paramètre "rootDirs": "folder1", "folder2" et "folder3". A chaque fois qu’un import relatif est effectué à partir de l’un de ces répertoires, le compilateur va effectuer une recherche des modules dans tous les répertoires comme si leur contenu était mergé.

Si la configuration est:

{ 
  "compilerOptions": { 
    "rootDirs": [  
      "otherModuleFolder1", 
      "otherModuleFolder2"
    ] 
  } 
} 

Et si un import est effectué de cette façon:

import 'otherModuleFolder1/moduleName'; 

Le compilateur va effectuer la recherche dans les répertoires otherModuleFolder1/moduleName et otherModuleFolder2/moduleName.

Débugger la résolution de modules

En rajoutant l’option suivante au compilateur, on peut avoir des messages de logs lors de la résolution des modules:

tsc --traceResolution 

Pour conclure…

Comme en Javascript, les modules en Typescript peuvent être implémentés de façon très différente. Il est possible de passer par les namespaces qui correspondent à une séparation logique du code et qui sont spécifiques à Typescript. On peut aussi utiliser des syntaxes plus proches du Javascript avec des formats comme CommonJS, AMD ou ES2015. L’intérêt d’utiliser une syntaxe plus proche de celle en Javascript permet de coller davantage à la notion des modules Javascript séparant le code en fichiers.
D’une façon générale, il est conseillé de privilégier la notion des modules Javascript par rapport aux namespaces. En effet, les namespaces ajoutent une couche hiérarchique aux objets dans le but d’éviter les collisions de nom. A l’opposé les modules Javascript, du fait de leur séparation en fichiers, évitent les collisions de nom sans ajouter de couche hiérarchique.
Comme on a pu le voir, la définition de modules en Typescript hérite de la syntaxe Javascript et ajoute la notion de namespace. En revanche la notion d’import de modules externes n’a pas été abordé dans cet article. Ce point fera l’objet d’un article ultérieur.

Références
Share on FacebookShare on Google+Tweet about this on TwitterShare on LinkedInEmail this to someonePrint this page

Téléchargement de composants .NET hors-ligne

Le plus souvent, les fichiers d’installation des composants .NET impliquent d’être connecté à internet au moment de l’installation. Par exemple, c’est le cas pour Visual Studio Community ou les Build tools. Parfois, on peut ne pas disposer d’une connexion au moment de l’installation, en particulier si on effectue l’installation sur un serveur qui se trouve sur un réseau fermé.

Ces fichiers d’installation peuvent être exécutés hors-ligne toutefois ils nécessitent une manipulation au préalable.

Préparation du fichier d’installation

Pour utiliser les fichiers d’installation hors-ligne, il faut, d’abord, télécharger toutes les dépendances du fichier en exécutant la ligne:

<fichier d'installation> --layout <répertoire de sortie> --lang <code langue> 

Par exemple, dans le cadre des Build Tools, pour télécharger tous les fichiers d’installation, il faut exécuter la ligne suivante:

vs_buildtools.exe --layout C:\OfflineBuildTools --lang en-US 

D’autres langues sont possibles:

  • pour le français, il faut utiliser le code fr-FR.
  • D’autres codes de langues sont disponibles sur la page suivante: List of language locales.

Le gros inconvénient de cette méthode est qu’elle entraîne le téléchargement de toutes les dépendances. Ce qui représente 12GB de fichiers d’installation dans le cas des Build Tools.

Pour éviter de tout télécharger, il est possible d’indiquer les composants à télécharger en les indiquant en utilisant l’option --add <nom du composant>.

Par exemple:

<fichier d'installation> --layout <répertoire de sortie> --lang <code langue> 
    --add <composant 1> 
    --add <composant 2>

Une liste exhaustive des composants par fichier d’installation se trouve sur la page suivante:
Visual Studio 2017 workload and component IDs

On peut ensuite ajouter des options pour télécharger certaines dépendances des composants comme:

  • --includeRecommended pour télécharger les composants conseillés
  • --includeOptional pour télécharger les composants optionels.
Téléchargement interrompu

Si jamais le téléchargement s’interrompt, il suffit de ré-exécuter la commande avec les mêmes arguments. Le téléchargement reprendra où il s’est interrompu. Les fichiers déjà téléchargés ne seront, alors, pas re-téléchargés.

Exécuter l’installateur hors-ligne

Après avoir copié les fichiers d’installation sur la machine cible, il faut d’abord installer les certificats:

  1. Aller dans le répertoire téléchargé:
    <répertoire des fichiers d'installation>\certificates
  2. Double-cliquer sur chaque fichier de certificat (fichiers avec une extension .p12).
    Dans le cas où l’assistant demande un mot de passe, il faut le laisser vide.
  3. Ensuite, on peut exécuter l’installateur qui se trouve dans le répertoire téléchargé.
    Dans le cas des Build Tools, le fichier est vs_buildtools.exe.

Si tous les composants ont été correctement téléchargés, une connexion internet n’est pas requise.

Dans le cas où on souhaite modifier, réparer, mettre à jour ou désinstaller une installation existante, on peut exécuter l’exécutable dans le répertoire téléchargé en double-cliquant dessus ou exécuter à la ligne de commandes une instruction du type:

<répertoire des fichiers d'installation>\<fichier d'installation> <commande>

Par exemple, pour les Build Tools:

vs_buildtools.exe <commande> 

Les commandes possibles sont:

  • modify pour modifier une installation existante.
  • update pour mettre à jour une composant déjà installé
  • repair pour réparer une installation existante
  • uninstall pour désinstaller.
Erreur quand on modifie une installation existante

Si un erreur survient au cours de l’exécution d’une commande, il faut essayer de désinstaller complétement le composant en allant dans le panneau de configuration. Puis le réinstaller sans utiliser de commande particulière.

Share on FacebookShare on Google+Tweet about this on TwitterShare on LinkedInEmail this to someonePrint this page