Aide-mémoire sur les pointeurs et références en C++

Le but de cet article est de rappeler quelques éléments de syntaxe sur les pointeurs et références en C++.

On considère la classe suivante:

Point.h Point.cpp
class Point
{
public:
    Point();
    Point(int a, int b);
    Point(const Point &copy);
    ~Point();

    int a;
    int b;
};

Point::Point()
{}

Point::Point(int a, int b)
{
  this->a = a;
  this->b = b;
}

Point::Point(const Point &copy)
{
  this->a = copy.a;
  this->b = copy.b;
}

Point::~Point()
{
}

Déclaration par valeur

  • Les objets déclarés de cette façon ont une existence liée au scope dans lequel ils sont déclarés. Par exemple, un objet déclaré "par valeur" dans une fonction aura une existence liée au corps de la fonction. Si on sort de cette fonction, l'objet sera détruit implicitement, il n'est pas nécessaire d'implémenter une instruction pour détruire l'objet.
  • Les affectations de ces objets effectuent des copies "par valeur" c'est-à-dire qu'en absence de constructeur de copie, l'objet est copié intégralement c'est-à-dire que la copie de l'objet est effectuée membre par membre.
  • La plupart du temps, les objets de type primitif (int, bool, char, float etc...) sont déclarés de cette façon.
  • Les copies d'objets déclarés "par valeur" peuvent représenter un coût non négligeable en performance si les objets sont de grande taille.

Quelques exemples:

Syntaxe Remarques
Déclaration + initialisation
(sans valeur d'initialisation)
Point newPoint;
  • Il n'est pas possible d'effectuer seulement une déclaration. Cette syntaxe n'effectue pas seulement une déclaration, elle permet d'exécuter le constructeur par défaut.
  • La classe doit comporter un constructeur par défaut:
    Point::Point()
    {}
Déclaration + initialisation
(avec valeur d'initialisation)
Point newPoint(2, 4);

ou

Point newPoint = Point(2, 4);

ou

Point newPoint{2, 4};

ou

Point newPoint = {2, 4};
Implique l'existence du constructeur suivant:

Point::Point(int a, int b)
{
  this->a = a;
  this->b = b;
}

Dans le cas de la syntaxe suivante:
Point newPoint = Point(2, 4);
2 opérations sont effectuées même si le compilateur les optimise pour n'en faire qu'une seule:

  • L'instanciation d'un objet en utilisant le constructeur précédent et
  • L'instanciation d'un autre objet en utilisant le constructeur de copie (si ce constructeur n'existe pas explicitement, il est rajouté par le compilateur).
Point firstPoint(2, 4);
Point newPoint(firstPoint);

ou

Point firstPoint(2, 4);
Point newPoint{firstPoint};
Le constructeur de copie ne doit pas être obligatoirement déclaré pour écrire cette ligne. Il sera rajouté implicitement par le compilateur.
Copie
Point newPoint(2, 4);
Point otherPoint = newPoint;
  • Cette ligne effectue une copie par valeur, newPoint et otherPoint sont des objets différents.
  • Implique un constructeur de copie (copy constructor):
    Point::Point(const Point &point)
    {
      this->a = point.a;
      this->b = point.b;
    }
    

    Ce constructeur est créé par le compilateur s'il n'existe pas.

Accès aux membres avec '.'
Point newPoint(2, 4);
cout << newPoint.a << "\n";

Référence

  • Une variable contenant une référence d'un objet peut être considérée comme un alias de cet objet c'est-à-dire que toutes les modifications effectuées sur la référence impacte l'objet lui-même.
  • Utiliser des références permet d'éviter des copies d'objets "par valeur" lors des passages d'arguments ou des affectations.
  • Il n'existe pas de référence nulle.
  • Il existe 2 types de références (voir les différences entre Lvalue et Rvalue):
    • Référence Lvalue (i.e. Lvalue reference): ce sont les références usuelles déclarées avec un caractère '&'. Les références Lvalue permettent de référencer les arguments Lvalue d'une expression.
    • Référence Rvalue (i.e. Rvalue reference): les références Rvalue permettent de référencer les arguments Rvalue d'une expression. Ces références sont indiquées avec les caractères '&&'. Il s'agit d'une optimisation pour améliorer les performances dans le cas de manipulations d'objet déclaré "par valeur" de façon à éviter d'effectuer des copies "par valeur" lors des affectations.
  • Les références conviennent pour les données membres d'une classe quand l'instance de ce membre existe pendant toute la durée de vie de la classe.
Différences entre Lvalue et Rvalue

Dans une expression, un argument Lvalue (i.e. left-value) correspond à un objet possédant un nom. Une variable possède un nom et permet de désigner un objet en mémoire.

Par exemple:

int x = 1; // x est une Lvalue

Lvalue sous-entends qu'un argument de ce type est à gauche de l'opérateur d'affectation ce qui n'est pas forcément le cas. Une Lvalue peut apparaître à gauche ou à droite de l'opérateur d'affectation.

Par exemple, si on écrit:

int a = 1;
int b = 2;
a = b; 
b = a; 

a et b sont à la fois Lvalue et Rvalue.

Dans une expression, un argument Rvalue (i.e. right-value) correspond à une valeur temporaire qui n'est pas persistée ou accessible en utilisant une variable.

Par exemple:

int i = 2; // 2 est une Rvalue
int GetValue()
{
  //
}

int i = GetValue();

GetValue() est une Rvalue par contre i est une Lvalue.

Une Rvalue sert à affecter une valeur à une variable, elle est temporaire car il n'existe pas de nom pour y faire référence. Une Rvalue est toujours située à droite de l'opérateur d'affectation.

Par exemple, si on écrit le code suivant:

int a = 1;
int b = 2;

a + b = 3; // ERREUR

L'affectation n'est pas possible car le résultat de l'opération a + b est une Rvalue. En effet, l'opération ne fait pas référence à une variable, il s'agit d'une valeur temporaire. Une Rvalue doit toujours être à droite de l'opérateur d'affectation.

Quelques exemples:

Syntaxe Remarques
Déclaration + initialisation
(sans paramètres)
Point &newPoint();

ou

Point & newPoint();

ou

Point& newPoint();
  • Toutes ces notations sont équivalentes.
  • Il n'est pas possible d'effectuer seulement une déclaration d'une référence. Ce code n'effectue pas seulement une déclaration comme on pourrait le penser mais il exécute aussi le constructeur par défaut.
  • Chacune de ces lignes implique un constructeur par défaut.
Déclaration + initialisation
(avec paramètres)
Point newPoint;
Point &pointRef = newPoint;

ou

Point newPoint;
Point &pointRef(newPoint);
Un constructeur par défaut est nécessaire pour écrire la ligne:
Point newPoint;
const Point &pointRef = Point(2, 4);

ou

const Point &pointRef(Point(2, 4));
Référence constante.
Point &newPoint(2, 4);
Il n'est pas possible d'initialiser une référence de cette façon sans utiliser const.
Initialisation obligatoire
Point &newPoint; // ERREUR
ATTENTION: entraîne une erreur de compilation.
Référence Lvalue (i.e. Lvalue reference)
Point &newPoint = Point(1, 2);
Entraîne une erreur de compilation car Point(1, 2) est une Rvalue et la référence déclarée avec Point &newPoint est une référence Lvalue.
const Point &newPoint = Point(1, 2);
Cette ligne n'entraîne pas d'erreurs.
Une autre solution pourrait être d'utiliser une référence Rvalue (sans utiliser const).
Accès aux membres de l'objet référencé avec '.'
Point newPoint(2, 4);
Point &pointRef = newPoint;
newPoint.a = 6;
newPoint.b = 9;
L'accès aux membres de l'objet se fait avec '.'.
La modification de la référence modifie l'objet
Point newPoint(1, 2);
Point &pointRef = newPoint;
pointRef.a = 6;
cout << newPoint.a << "\n";
L'affichage est:
6.
Les modifications de l'objet sont visibles pour les autres références
Point newPoint(1, 2);
Point &firstRef = newPoint;
Point &secondRef = newPoint;
firstRef.a = 6;
cout << secondRef.a << "\n";
L'affichage est:
6.
Ré-affectation d'une référence
Point firstPoint(1, 2);
Point secondPoint(3, 4);
Point &refPoint = firstPoint;
refPoint = secondPoint;
refPoint référence désormais secondPoint.
Pas de référence nulle
Point &newPoint = NULL; // ERREUR
Impossible, la référence nulle n'existe pas.
Opérateur "address-of":
'&' permet d'obtenir un pointeur vers l'objet
Point firstPoint(1, 2);
Point &pointRef = firstPoint;
Point *pointer = &pointRef;
L'opérateur '&' ne peut être utilisé qu'avec la partie droite de l'opérateur '=' (appelé RHS pour Right Hand Side).

ATTENTION:
Le pointeur reste lié à l'objet d'origine. Si cet objet est détruit dans le cas où l'exécution se fait en dehors du scope de la déclaration de la variable firstPoint, le pointeur sera corrompu.

Référence constante
const Point &pointRef = Point(1, 2);
pointRef.a = 5; // ERREUR
La ligne pointRef.a = 5 entraîne une erreur de compilation car la référence est constante.
const Point &pointRef = Point(1, 2);
Point otherPoint(2, 4);
pointRef = otherPoint; // ERREUR
Les modifications sur la référence ne sont pas possibles.
const Point &pointRef = Point(1, 2);

est équivalent à:

Point const &pointRef = Point(1, 2);
Ces 2 lignes sont équivalentes.
Point point(1, 2);
Point & const pointRef = point;
const est ignoré par le compilateur dans ce cas.
Référence Rvalue L'utilisation de référence Rvalue est une optimisation permettant de transferer un objet dynamiquement alloué en mémoire d'un objet à l'autre sans avoir à l'affecter à une variable.
Par exemple, il n'est pas possible d'écrire la ligne suivante sans const car Point(1, 2) est une Rvalue et pointRef est une référence Lvalue:

const Point &pointRef = Point(1, 2);  // OK
Point &pointRef = Point(1, 2);  // ERREUR

On peut toutefois utiliser une référence Rvalue:

Point &&pointRef = Point(1, 2);  // OK

Move Semantics
L'intérêt de cette fonctionnalité est de permettre de déplacer directement des objets alloués dynamiquement sans avoir à effectuer de nouvelles allocations et sans effectuer de copies en mémoire (cette technique est appelée Move Semantics).
Par exemple, si on définit la fonction:

void UsePoint(Point &&point)
{
    // ...
}

On peut appeler cette fonction directement avec une Rvalue sans avoir à affecter l'argument à une variable au préalable:

UsePoint(Point(1, 2));

Sans référence Rvalue, il aurait fallu effectuer une affectation au préalable:

void UsePoint(Point &point)
{
    // ...
}

Point point(1, 2); // Affectation de la variable point
UsePoint(point);

Perfect forwarding
L'utilisation de références Rvalue permet de définir moins de surcharges de fonctions. Au lieu de déclarer des surcharges avec T& et const T&, on peut directement utiliser une référence Rvalue T&&.

Par exemple, sans référence Rvalue, il faut définir les surcharges suivantes...:

void UsePoint(Point &point)
{
    // ...
}

void UsePoint(const Point &point)
{
    // ...
}

...pour être capable d'effectuer les 2 appels suivants:

Point point(1, 2);
UsePoint(point); // cet appel utilise la surcharge avec Point &point.
UsePoint(Point(3, 4)); // cet appel utilise la surcharge avec const Point &point.

Avec une réference Rvalue, une seule fonction suffit:

void UsePoint(Point &&point)
{
    // ...
}

Pour tirer complétement partie de la fonctionnalité Move Semantics, il faut définir une constructeur de déplacement (i.e. Move Constructor) et éventuellement définir une surcharge à l'opérateur d'affectation:

// Constructeur de déplacement
Point(Point &&otherPoint)
{
    // ...
}

// Surchage de l'opérateur d'affectation
Point &operator=(Point &&otherPoint)
{
    // ...
}

L'implémentation du constructeur et de la surcharge de l'opérateur doit respecter certaines règles pour être efficace (voir Move Constructors and Move Assignment Operators (C++) pour plus de détails).

Pointeur

Quelques remarques générales sur les pointeurs:

  • Une variable de type pointeur contient une adresse permettant de pointer avec un typage fort vers un objet en mémoire.
  • Les objets pointés par une variable de type pointeur peuvent être alloués sur la pile ou dans le tas suivant comment ils ont été déclarés. Si on initialise ces objets avec l'opérateur new, ils sont alloués dans le tas. Toutefois il est possible d'obtenir un pointeur vers un objet alloué sur la pile avec l'opérateur '&'.
  • Quand un objet est instancié avec new, il doit être libéré en utilisant delete.
  • Les pointeurs conviennent en tant que données membres d'une classe lorsqu'il sera nécessaire de libérer l'instance de l'objet pointé lors de la durée de vie de la classe.

Quelques exemples:

Syntaxe Remarques
Déclaration
Point* newPoint = NULL;

ou

Point * newPoint = NULL;

ou

Point *newPoint = NULL;
Ces 3 notations sont équivalentes.
Déclaration + initialisation
Point *newPoint = new Point(1, 2);
Accès aux membres de l'objet avec l'opérateur '->'
Point *newPoint = new Point(1, 2);
cout << newPoint->a < "\n";
cout << newPoint->b < "\n";
Pointeur nul
Point *newPoint = NULL;
Initialisation à partir d'une référence en utilisant
l'opérateur de déférencement '&'
Point &newPoint(1, 2);
Point *pointer = &newPoint;
L'opérateur '&' ne peut être utilisé que pour la partie droite de l'opérateur d'affectation (appelé RHS pour Right Hand Side).
Obtenir l'objet pointé avec l'opérateur '*' et
affecter une copie de l'objet pointé
Point *pointer = new Point(1,3);
Point objectCopy = *pointer;

objectCopy.a = 6;
std::cout << pointer->a << "\n"; // le résultat est 1
L'opérateur '*' peut être utilisé dans la partie droite de l'opérateur d'affectation (RHS pour Right Hand Side).

ATTENTION: objectCopy est une copie de l'objet pointé par pointer.

Obtenir une référence d'un objet à partir d'un pointeur
avec l'opérateur '*'
Point *pointer = newPoint(1,3);
Point &pointRef = *pointer;

pointer->a = 6;
std::cout << pointer->a << "\n"; // Le résultat est 6
On affecte une variable contenant une référence de l'objet.
Le pointeur possède un typage fort
Point *pointer = new Point(1, 3);
Line *newLine = newPoint; // ERREUR
ATTENTION: impossible, cette ligne entraîne une erreur de compilation car les types sont différents
Ré-affectation d'un pointeur
Point *firstPoint = new Point(1, 3);
Point *secondPoint = new Point(3, 6);
secondPoint = firstPoint;
Le pointeur est copié "par valeur" toutefois les 2 pointeurs pointent vers le même objet.
Ré-affectation de l'objet pointé
Point *firstPoint = new Point(3, 6);
Point newPoint(1, 2);
*firstPoint = newPoint;

firstPoint->a = 9;
cout << newPoint->a << "\n";
  • Le résultat est 1.
  • A la ligne:
    *firstPoint = newPoint;
    newPoint est copié "par valeur" dans l'objet pointé par firstPoint.
  • newPoint n'est pas modifié à la ligne:
    firstPoint->a = 9;
  • L'opérateur '*' peut être utilisé dans la partie gauche de l'opération d'affectation (appelé LHS pour Left Hand Side).
Utilisation d'un pointeur non typé void *
Point newPoint(1, 2);
Point *pointer = &newPoint;
void *untypedPointer = pointer;

Point *otherPoint = static_cast<Point *>(untypedPointer);
untypedPointer est un pointeur non typé.
Suppression d'un objet alloué
Point *pointer = new Point(1, 3);
delete pointer;
Avec l'opérateur new, l'objet est alloué dans le tas, il faut penser à le supprimer après utilisation pour éviter une fuite mémoire
Un pointeur vers un pointeur
Point *pointer = new Point(1, 2);
Point **pointerToPointer = &pointer;
(*pointerToPointer)->a = 5;
cout << pointer->a << "\n";
Le résultat est:
5
Valeur pointée constante
const Point *pointA = new Point(1, 2);

pointA->a = 5; // ERREUR
On ne peut pas modifier l'objet pointé, donc cette ligne provoque une erreur de compilation.
const Point *pointA = new Point(1, 2);
Point *pointB = new Point(3, 4);
pointA = pointB;
On peut modifier la valeur du pointeur.
const Point *pointA = new Point(1, 2);

est équivalent à:

Point const *pointA = new Point(1, 2);
Pointeur constant
Point * const pointA = new Point(1, 2);
pointA->a = 5;
L'objet pointé peut être modifié.
Point * const pointA = new Point(1, 2);
Point *pointB = new Point(3, 4);
pointA = pointB; // ERREUR
Le pointeur est constant et ne peut pas être modifié.

Référence managée (en C++/CLI)

  • Une référence managée est semblable aux références en C#.
  • Les objets déclarées sous forme de références managées sont instanciés dans le tas managé. Leur durée de vie est géré pour la Garbage collector.
  • Il n'est pas nécessaire de libérer les objets déclarés en tant que référence managée.
  • Ce code n'est valable que si l'exécutable supporte du code CLR. Les références managées sont aussi appelées handle.

On considère la classe Line telle que:

Line.h Line.cpp
public ref class Line
{
public:
    Line();
    Line(int x1, int y1, int x2, int y2);

    int x1;
    int y1;
    int x2;
    int y2;
};

Line::Line()
{}

Line::Line(int x1, int y1, int x2, int y2)
{
    this->x1 = x1;
    this->y1 = y1;
    this->x2 = x2;
    this->y1 = y1;
}

Quelques exemples:

Syntaxe Remarques
Déclaration
Line^ line;

ou

Line ^ line;

ou

Line ^line;
Les 3 notations sont équivalentes.
Déclaration + initialisation
Line ^line = gcnew Line(1, 2, 3, 6);
Accès aux membres de l'objet avec l'opérateur '->'
Line ^line = gcnew Line(1, 2, 3, 6);
cout << line->x1 << "\n";
cout << line->x2 << "\n";
Pointeur nul
Line ^line = nullptr;
Suppression d'une instance
Line ^line = gcnew Line(1, 2, 3, 6);
delete line;
  • line est alloué sur le tas managé géré par le Garbage Collector donc il n'y a pas de risque de fuite mémoire.
  • delete line sert seulement pour exécuter le destructeur de line volontairement. C'est l'équivalent du pattern IDisposable en C#.

Pointeur et réference dans les appels de fonctions

Arguments de fonctions

Quelques exemples de passage d'arguments à une fonction:

Syntaxe Remarques
Copie par valeur Déclaration de la fonction:

void MovePoint(Point point, 
  int newA, int newB)
{
  point.a = newA;
  point.b = newB;
}

Syntaxe de l'appel:

Point newPoint(1, 2);
Point::MovePoint(newPoint, 2, 4);
std::cout << newPoint.a << "\n";
std::cout << newPoint.b << "\n";
Le résultat est:
1
2

L'argument est passé par valeur, une copie est effectuée lors de l'appel.
Si on modifie l'objet à l'intérieur de la fonction, on modifie la copie et non l'original.
Cet appel implique un constructeur de copie (copy constructor).

Copie par référence Déclaration de la fonction:

void MovePoint(Point &point, 
  int newA, int newB)
{
  point.a = newA;
  point.b = newB;
}

Syntaxe de l'appel:

Point newPoint(1, 2);
Point::MovePoint(newPoint, 2, 4);
std::cout << newPoint.a << "\n";
std::cout << newPoint.b << "\n";
Le résultat est:
2
4

L'argument est passé par référence.
Si on modifie l'objet en utilisant la référence à l'intérieur de la fonction, on modifie l'original.

Utilisation d'un pointeur Déclaration de la fonction:

void MovePoint(Point *point, 
  int newA, int newB)
{
  point->a = newA;
  point->b = newB;
}

Syntaxe de l'appel:

Point newPoint(1, 2);
Point::MovePoint(&newPoint, 2, 4);
std::cout << newPoint.a << "\n";
std::cout << newPoint.b << "\n";
Le résultat est:
2
4

L'argument contient un pointeur.
Si on modifie l'objet dans la fonction, on modifie l'original.

Copie d'une référence managée
(En C++/CLI)
Déclaration de la fonction:

void MoveLine(Line ^line, 
  int newX1, int newY1, 
  int newX2, int newy2)
{
  line->x1 = newX1;
  line->y1 = newY1;
  line->x2 = newX2;
  line->y2 = newY1;
}

Syntaxe de l'appel:

Line ^line = gcnew Line(1, 2, 3, 4);

Line::MoveLine(line, 2, 4, 6, 8);
Console::WriteLine(line->x1);
Console::WriteLine(line->y1);
Console::WriteLine(line->x2);
Console::WriteLine(line->y2);
Le résultat est:
2
4
6
8

L'argument est passé par référence.
Si on modifie l'objet en utilisant la référence à l'intérieur de la fonction, on modifie l'instance originale.

Retour de fonction

Syntaxe Remarques
Par valeur Déclaration de la fonction:

Point CreatePoint(int a, int b)
{
  return Point(a, b);
}

Syntaxe de l'appel:

Point newPoint = 
  Point::CreatePoint(1, 2);

std::cout << newPoint.a << "\n";
std::cout << newPoint.b << "\n";
Le résultat est:
1
2

Une copie de l'objet créée dans la fonction, est retournée.

Par référence
(MAUVAISE IMPLEMENTATION)
Déclaration de la fonction:

Point &CreatePoint(int a, int b)
{
  Point newPoint(a, b);
  return newPoint;
}

Syntaxe de l'appel:

Point &newPoint = 
  Point::CreatePoint(1, 2);
Console::WriteLine(newPoint.a);
Console::WriteLine(newPoint.b);
Le résultat est:
1935459609
-1

ATTENTION: ne pas retourner une référence vers un objet créé dans la fonction.

Dans cet exemple, newPoint est alloué dans la pile. Quand on sort de la fonction, newPoint est supprimé de la pile et la référence retournée contient un objet supprimé.

Par référence
(A EVITER)
Déclaration de la fonction:

Point &CreatePoint(int a, int b)
{
  Point *newPoint = new Point(a, b);
  return *newPoint;
}

Syntaxe de l'appel:

Point &newPoint = 
  Point::CreatePoint(1, 2);
Console::WriteLine(newPoint.a);
Console::WriteLine(newPoint.b);
Le résultat est:
1
2

Un objet est créé et alloué sur le tas dans le corps de la fonction. On retourne une référence vers cet objet.
Il faut éviter cette implémentation car elle peut mener à des fuites mémoires, l'objet n'est jamais supprimé du tas.

Par pointeur Déclaration de la fonction:

Point *Point::CreatePoint(int a, int b)
{
  return new Point(a, b);
}

Syntaxe de l'appel:

Point *newPoint = 
  Point::CreatePoint(1, 2);

Console::WriteLine(newPoint.a);
Console::WriteLine(newPoint.b);
delete newPoint;
Le résultat est:
1
2

Ne pas oublier de supprimer l'objet après utilisation.

Par référence managée Déclaration de la fonction:

Line ^CreateLine(int x1, int y1, 
  int x2, int y2)
{
  return gcnew Line(x1, y1, x2, y2);
}

Syntaxe de l'appel:

Line ^line = 
  Line::CreateLine(1, 2, 3, 4);
Console::WriteLine(line->x1);
Console::WriteLine(line->y1);
Console::WriteLine(line->x2);
Console::WriteLine(line->y2);
Le résultat est:
1
2
3
4

L'objet est créé dans le tas managé, il n'y a pas de nécessité de le supprimer.

Membres d'une classe

Quelques exemples de déclarations d'objets membres d'une classe:

Syntaxe Remarques
Par valeur
(A éviter dans le cas d'objet complexe)
Définition .h:

#include "Point.h"

class Circle
{
public:
    Circle(Point center, int radius);

    Point center;
    int radius;
};

Définition .cpp:

#include "Circle.h"

Circle::Circle(Point center, int radius)
{
    this->center = center;
    this->radius = radius;
}
  • La déclaration suivante nécessite un constructeur par défaut dans la classe Point:
    Point center;
  • Le passage d'argument dans le constructeur implique un constructeur de copie.
  • Il faut éviter ce type de membre si l'objet est complexe car des copies sont effectuées lors des initialisations et passage d'arguments.
Par référence Définition .h:

#include "Point.h"

class Circle
{
public:
    Circle(Point center, int radius);

    Point &center;
    int radius;
};

Définition .cpp:

#include "Circle.h"

Circle::Circle(Point &center, int radius) :
  center(center)
{
  this->center = center);
  this->radius = radius;
}
  • La référence doit être initialisée dans le constructeur (car la référence nulle n'existe pas).
  • La classe Circle ne peut pas implémenter de constructeur par défaut.
  • Il est conseillé d'utiliser un membre de type référence si la durée de vie du membre est aussi longue que celle de la classe.
Par référence (avec const) Définition .h:

#include "Point.h"

class Circle{
public:
     Circle();
     
     const Point &center;
     int radius;
};

Définition .cpp:

#include "Circle.h"

Circle::Circle() :center(Point(0, 0))
{}
Par pointeur Définition .h:

#include "Point.h"

class Circle
{
public:
    Circle(Point &center, int radius);

    Point *center;
    int radius;
};

Définition .cpp:

#include "Circle.h"

Circle::Circle(Point &center, int radius)
{
  this->center = &center;
  this->radius = radius;
}
  • Cette implémentation convient quand on doit instancier ou détruire le membre pendant la durée de vie de la classe.
  • Attention à la destruction du membre quand la classe est détruite.
Par référence managée
(classe managée)
Définition .h:

#include "Line.h"

public ref class Canvas
{
public:
    Canvas(Line ^line);

    Line ^line;
};

Définition .cpp:

#include "Canvas.h"

Canvas::Canvas(Line ^line)
{
  this->line = line;
}
Le membre de type managée doit se trouver dans une classe managée.
Le comportement est le même qu'en C#.

Comparaison d'objets

Pour être capable de comparer des objets complexes, il faut surcharger l'opérateur d'égalité.

Par exemple:

bool operator==(const Point &pointA, const Point &pointB)
{
    return pointA.a == pointB.a && pointA.b == pointB.b;
};

Si les membres d'une classe sont privés, on doit ajouter la déclaration suivante dans le fichier .h de la classe pour que la surchage de l'opérateur puisse accèder à ces membres:

friend bool operator==(const Point &pointA, const Point &pointB);

L'implémentation de la surcharge de l'opérateur indiquée plus haut suffit à effectuer les comparaisons suivantes:

Syntaxe Remarques
Comparaison d'objets définis "par valeur"
Point firstPoint(1, 2);
Point secondPoint(3, 4);
Point thirdPoint(3, 4);

if (firstPoint == secondPoint)
    cout &kt;&kt; "KO\n";
else if (secondPoint == thirdPoint)
    cout &kt;&kt; "OK\n";
Le résultat est:
OK

On peut aussi surcharger l'opérateur de cette façon:

bool operator==(Point pointA, Point pointB)
{
    return pointA.a == pointB.a 
        && pointA.b == pointB.b;
};
Comparaison de références
const Point &firstRef(1, 2);
const Point &secondRef(3, 4);
const Point &thirdRef(3, 4);

if (firstPoint == secondPoint)
    cout << "KO\n";
else if (secondPoint == thirdPoint)
    cout << "OK\n";
Le résultat est:
OK

Si on surcharge l'opérateur de cette façon (sans const):

bool operator==(Point pointA, Point pointB)
{
    return pointA.a == pointB.a 
        && pointA.b == pointB.b;
};

On obtiendra une erreur de compilation.

Comparaison de pointeurs
Point *firstPointer = new Point(1, 2);
Point *secondPointer = new Point(3, 4);
Point *thirdPointer = new Point(3, 4);

if (*firstPointer == *secondPointer)
    cout << "KO\n";
else if (*secondPointer == *thirdPointer)
    cout << "OK\n";
Le résultat est:
OK

Il n'est pas possible de déclarer la surcharge suivante:

bool operator==(Point *pointA, Point *pointB)
{
    return pointA->a == pointB->a 
        && pointA->b == pointB->b;
};

Ce type de surcharge pourrait préter à confusion dans le cas où on compare des pointeurs directement:

if (firstPointer == secondPointer)
{
    // ...
}
Références
Share on FacebookShare on Google+Tweet about this on TwitterShare on LinkedInEmail this to someonePrint this page

Appeler des dépendances C++ à partir d’un exécutable .NET AnyCPU

Il existe différentes méthodes pour appeler des dépendances natives à partir de .NET (cf. Appeler des DLL natives à partir de .NET). Ces méthodes ont en commun de devoir charger la bibliothèque native de façon à exécuter le code qui s’y trouve. Le chargement de DLL par l’application appelante implique que l’architecture de cette dernière soit compatible avec l’architecture d’exécution des DLL.

Le but de cet article est d’indiquer quelles sont les compatibilités des architectures d’exécution entre .NET et les DLL natives et d’indiquer une méthode pour exécuter de code dans des dépendances natives à partir d’un exécutable .NET AnyCPU.

Plateforme cible

Sur une machine desktop suivant le système, un programme peut généralement s’exécuter en 32 bits ou en 64 bits en utilisant l’architecture d’exécution, respectivement x86 ou x64. L’architecture d’exécution d’une application dépend d’abord de l’architecture du système d’exploitation et ensuite de la plateforme cible sélectionnée au moment de la compilation de l’exécutable.

En .NET, sur une machine desktop, on considère généralement 3 types de plateformes cible:

  • AnyCPU: l’exécutable pourra être exécuté par le CLR 32 bits sur un système 32 bits. Sur un système 64 bits, il pourra être exécuté par le CLR 32 ou 64 bits suivant le choix indiqué du paramètre “architecture de préférence” (i.e. “AnyCPU 32-bit prefered“) au moment de la compilation (pour plus de précisions voir Plateforme cible en 5 min).
  • x86: l’exécutable sera exécuté par le CLR 32 bits sur un système 32 ou 64 bits.
  • x64: l’exécution se fera par le CLR 64 bits seulement sur un système 64 bits.

En C++, seules les plateformes cible Win32 (pour une exécution en 32 bits) et x64 sont possibles. Il n’existe pas de plateforme cible AnyCPU comme en .NET.

On peut résumer les différents cas de figure dans le tableau suivant:

Architecture du système Technologie Plateforme cible Architecture possible du processus
32-bit .NET AnyCPU 32-bit
x86 32-bit
x64 Impossible (1)
C++ win32 32-bit
x64 Impossible (1)
64-bit .NET AnyCPU 32-bit si l’architecture de préférence est 32-bit sinon 64-bit.
x86 32-bit
x64 64-bit
C++ win32 32-bit
x64 64-bit

(1) Le système d’exploitation ne peut pas exécuter un processus avec cette architecture d’exécution.

Architecture des dépendances

Dans le cas managée et natif, les dépendances sous forme de fichiers DLL peuvent être compilées séparément. Chaque dépendance peut donc être compilée suivant une plateforme cible spécifique. En plus de la plateforme cible des dépendances, l’exécutable peut lui aussi être compilé suivant une plateforme cible particulière. Ces différentes plateforme cible peuvent introduire des incompatiblités.

Ainsi:

  • Un processus 32 bits ne peut charger que des dépendances x86 et AnyCPU dans le cas d’une dépendance managée et seulement Win32 dans le cas d’une dépendance native.
  • Un processus 64 bits peut charger des dépendances x64 et AnyCPU dans le cas d’une dépdendance managée et x64 pour une dépendance native.

On peut résumer les différents cas de figure dans le tableau suivant:

Architecture du processus Technologie de l’exécutable Plateforme cible de la dépendance
32-bit .NET .NET (managée) AnyCPU OK
x86 OK
x64 Erreur (2)
C++ (native) win32 OK
x64 Erreur (2)
C++ C++ (native) win32 OK
x64 Erreur (2)
64-bit .NET .NET (managée) AnyCPU OK
x86 Erreur (2)
x64 OK
C++ (native) win32 Erreur (2)
x64 OK
C++ C++ (native) win32 Erreur (2)
x64 OK

(2) Dans le cas d’un exécutable .NET, la DLL native sera chargée au moment où on fait appel à une fonction se trouvant dans la DLL. Si l’architecture de la DLL n’est pas compatible avec celle de l’exécutable, une exception du type BadImageFormatException ou FileLoadException sera lancée.

Comme on peut le voir, il existe certains cas où le chargement d’une dépendance compilée avec la mauvaise plateforme cible peut mener à une erreur.

Le cas le plus compliqué à gérer, dans le cas d’un déploiement, est le cas d’un exécutable compilé avec pour plateforme cible AnyCPU car suivant le système il pourra être exécuté aussi bien en 32 bits qu’en 64 bits:

  • Dans le cas de dépendances managées uniquement: une solution est de compiler les dépendances avec la plateforme cible AnyCPU. Ainsi quel que soit l’architecture d’exécution du processus, le chargement des dépendances ne mènera pas à une erreur.
  • Dans le cas de dépendances natives: il n’y a pas de solutions triviales puisque la plateforme cible AnyCPU n’existe pas dans ce cas.

Quelle architecture d’exécution choisir ?

2 solutions sont possibles pour déployer une application et garantir la compatibilité des dépendances natives avec l’exécutable:

  1. Déployer 2 versions distinctes de l’exécutable:
    • Une version x86 compilée en Win32 pour les DLL natives et en x86 pour l’exécutable .NET. Cette version est exécutable sur un système 32 bits et 64 bits.
    • Une version x64 exécutable seulement sur un système 64 bits.

    Cette solution permet de facilement adresser tous les cas de figure toutefois elle nécessite de déployer 2 versions différentes et choisir la bonne version suivant le système sur lequel on veut lancer l’exécution.

  2. Déployer un exécutable AnyCPU compatible avec tous les systèmes d’exploitation. On compile ensuite 2 groupes de dépendances natives:
    • Un groupe Win32 utilisable avec un exécutable AnyCPU lancé par le CLR 32 bits (possible sur un système 32 bits et 64 bits).
    • Un groupe x64 utilisable avec un exécutable AnyCPU lancé par le CLR 64 bits (possible sur un système 64 bits).

    L’intérêt de cette méthode est que c’est l’exécutable, en fonction de son architecture d’exécution, qui va choisir quel est le groupe de dépendances natives qui devra être chargé. Ainsi, on déploie les mêmes assemblies et DLL sur tous les systèmes et l’exécution est possible quel que soit le système.

Au moyen d’un exemple, on va présenter comment appliquer la 2e solution.

Exemple d’appel d’une DLL native

On va illustrer un appel d’une DLL native par un exécutable .NET avec un exemple simple d’une application Console qui appelle une fonction dans une DLL native pour afficher le contenu d’une chaîne de caractères.

Le code d’origine de l’exemple se trouve dans la branche master du repository GitHub github.com/msoft/cpp_execution_architecture. La solution comporte 3 projets :

  • ArchitectureExample: application Console C# qui appelle la méthode NativeCaller::CallNativeCode() se trouvant dans le projet MixedAssembly.
  • MixedAssembly: il s’agit d’un projet C++ permettant de générer une assembly mixte. Cette assembly ne contient que la classe NativeCaller qui va appeler la fonction DisplayTextWithCallee() exposée dans le projet NativeCallee.
  • NativeCallee: c’est un projet C++ pour générer une bibliothèque dynamique. Cette bibliothèque expose la méthode DisplayTextWithCallee() pour afficher le contenu d’une chaine de caractères.

Pour résumer les appels se font de cette façon:

ArchitectureExample (exécutable .NET) MixedAssembly (Assembly mixte) NativeCallee (DLL native C++)
Main() NativeCaller::CallNativeCode() DisplayWithCallee()

Si on exécute l’application, le résultat est du type:

Displaying from managed code: text to display 
Displaying from unmanaged code: text to display 

Ainsi:

  • "Displaying from managed code: text to display" est affiché par ArchitectureExample.exe et
  • "Displaying from unmanaged code: text to display" est affiché par NativeCallee.dll.

Pour une explication plus complète du code de cet exemple, on peut se référer à l’article Référencer une DLL C++ avec une bibliothèque statique d’import.

Après avoir cloné le repository GitHub, il faut l’ouvrir avec Visual Studio et le compiler. Dans le répertoire ArchitectureExample\bin\Debug\, après compilation, il devrait résulter les fichiers suivants parmi les fichiers générés:

  • ArchitectureExample.exe,
  • MixedAssembly.dll et
  • NativeCallee.dll

Choix de l’architecture d’exécution

Si on regarde plus en détails l’architecture d’exécution de la solution en cliquant sur “Générer” (“Build”) ⇒ “Gestionnaire de configurations” (i.e. “Configuration Manager”), on remarque la configuration suivante:

  • En x86:
  • En x64:

L’application est générée en AnyCPU toutefois dans les paramètres du projet ArchitectureExample accessible en faisant un clique droit sur le projet puis en cliquant sur “Propriétés”), dans l’onglet “Build”, le paramètre “Préférer 32 bits” (i.e. “AnyCPU 32-bit prefered”) est coché. Quel que soit le système, l’exécutable va démarrer en 32 bits et les DLL générées avec l’architecture Win32 seront chargées correctement.

Si on effectue les étapes suivantes, on aboutira à une erreur de chargement des dépendances natives (dans le cas où on exécute l’application dans un système 64 bits):

  1. Dans les propriétés du projet ArchitectureExample, dans l’onglet “Build”, on décoche “Préférer 32 bits”.
  2. On se place dans la configuration suivante:
    • Configuration de la solution active: Debug
    • Plateforme de la solution active: x86.
  3. On compile et on exécute la solution. Une erreur de chargement devrait se produire car l’exécutable démarre en 64 bits alors que les dépendances natives sont compilées en x86.

On va apporter une solution pour que le chargement s’effectue correctement quelques soit l’architecture d’exécution de l’exécution .NET.

Solution pour utiliser un exécutable AnyCPU avec des dépendances natives

La solution consiste à charger les dépendances en fonction de l’architecture d’exécution. Par exemple si l’architecture d’exécution de l’exécutable est:

  • x86 alors on charge les dépendances natives compilées en Win32.
  • x64 alors on charge les dépendances natives compilées en x64.

En .NET, les dépendances sont chargées au moment de leur exécution. Il est donc possible d’effectuer une copie des DLL natives pour qu’elles soient présentes dans le répertoire de l’exécutable juste avant d’effectuer les appels aux fonctions se trouvant dans ces dépendances natives. Ainsi, les bonnes dépendances seront chargées et exécutées lors de l’appel du code se trouvant dans les DLL.

Ainsi:

  • Dans un 1er temps, on va configurer les projets des dépendances natives pour qu’ils génèrent des DLL dans les 2 architectures d’exécution (Win32 et x64).
  • Dans un 2e temps, on va créer un projet qui fera office de proxy pour lancer l’exécution du code dans les dépendances natives, c’est ce projet qui effectuera les copies en fonction de l’architecture d’exécution.
  • Enfin, on modifiera le code dans le projet de l’exécutable ArchitectureExample pour appeler le code du Proxy et non celui des dépendances natives.

Générer les dépendances natives en win32 et x64

On configure les projets des dépendances natives MixedAssembly et NativeCallee pour qu’ils soient générés en Win32 et en x64.

  1. On va dans le gestionnaire de configuration en effectuant un clique droit sur la solution ⇒ “Gestionnaire de configuration”.
  2. Il faut supprimer les configurations de la solution x86 et x64 pour ne garder que AnyCPU. La configuration devrait se présenter de cette façon:

    Les configurations Win32 et x64 doivent rester disponibles pour les projets correspondant aux dépendances natives MixedAssembly et NativeCallee.

  3. On modifie les répertoires des sorties des projets MixedAssembly et NativeCallee:
    • On effectue un clique droit sur le projet NativeCallee ⇒ “Propriétés”
    • Dans “Général” ⇒ “Répertoire de sortie”, on indique le chemin suivant: $(SolutionDir)$(Platform)\$(Configuration)\
      On effectue la même modification pour toutes les configurations Debug et Release ainsi que pour toutes les plateformes Win32 et x64.
    • On accède aux propriétés du projet MixedAssembly et on modifie le répertoire de sortie de la même façon.
    • Dans la partie “Editeur de liens” du projet MixedAssembly, on modifie les dépendances du linker. Dans “Editeur de liens” ⇒ “entrée”, il faut modifier le paramètre “Dépendances supplémentaires” pour que la configuration soit:
      • En Debug et pour la plateforme Win32: ..\win32\Debug\NativeCallee.lib;%(AdditionalDependencies)
      • En Release et pour la plateforme Win32: ..\win32\Release\NativeCallee.lib;%(AdditionalDependencies)
      • En Debug et pour la plateforme x64: ..\x64\Debug\NativeCallee.lib;%(AdditionalDependencies)
      • En Release et pour la plateforme x64: ..\x64\Release\NativeCallee.lib;%(AdditionalDependencies)
  4. On modifie le fichier projet de MixedAssembly pour générer toutes les architectures à chaque compilation. On ajoute une commande “AfterBuild” en éditant le fichier cpp_execution_architecture\MixedAssembly\MixedAssembly.vcxproj en ajoutant le code suivant:
    <Project> 
      <!-- ... -->   
      <Target Name="AfterBuild" Condition=" '$(Platform)' == 'x64' "> 
        <Message Text="Building platform Win32" Importance="High" /> 
        <MsBuild Projects="$(MSBuildProjectFullPath)" Properties="Platform=Win32" /> 
      </Target> 
    </Project> 
    
  5. Après avoir lancé la compilation du projet MixedAssembly en Debug, les fichiers suivants devraient être générés:
    • Win32\Debug\MixedAssembly.dll
    • Win32\Debug\NativeCallee.dll
    • x64\Debug\MixedAssembly.dll
    • x64\Debug\NativeCallee.dll

Créer un projet “Proxy” pour charger les dépendances natives

On crée un projet proxy qui effectuera la copie des dépendances natives dans la bonne architecture d’exécution auprès de l’exécutable:

  1. On ajoute un projet en C# de type “Bibliothèque de classes (Framework .NET)” en effectuant un clique droit sur la solution ⇒ “Ajouter” ⇒ “Nouveau Projet”. Sélectionner “Visual C#” ⇒ “Bibliothèque de classes (.NET Framework)”, on nomme ce nouveau projet DependencyLoader.
  2. On ajoute une référence vers le projet MixedAssembly en effectuant un clique droit sur la partie “Références” du projet DependencyLoader ⇒ “Ajouter une référence” ⇒ Dans “Projets”, cocher MixedAssembly.
  3. Déplier la partie “Références” et effectuer un clique droit sur MixedAssembly ⇒ “Propriétés”, pour le paramètre “Copie locale”, affecter la valeur false. Ce paramètre permet d’éviter que la référence soit copiée dans le répertoire de sortie du projet. Ainsi on évite d’avoir une DLL avec la mauvaise architecture de compilation dans le répertoire de sortie.
  4. Dans ce nouveau projet, on ajoute une classe nommée NativeCallerProxy avec le code suivant:
    namespace DependencyLoader 
    { 
        public class NativeCallerProxy 
        { 
            private const string dependencySubFolder = "Dependencies"; 
      
            public NativeCallerProxy() 
            { 
                CopyDependencies(); 
            } 
    
            public void CallNativeCaller(string textToDisplay) 
            { 
                NativeCaller nativeCaller = new NativeCaller(textToDisplay); 
                nativeCaller.CallNativeCode(); 
            }
    
            private static void CopyDependencies() 
            { 
                string executableFolder = Path.GetDirectoryName(Assembly.GetEntryAssembly().Location); 
                string dependencyArchitecture = Environment.Is64BitProcess ? "x64" : "Win32"; 
                string dependencyFolder = Path.Combine(executableFolder, dependencySubFolder, dependencyArchitecture); 
      
                foreach (var sourceFile in Directory.GetFiles(dependencyFolder, "*", SearchOption.TopDirectoryOnly)) 
                { 
                    string fileName = Path.GetFileName(sourceFile); 
                    string destinationFilePath = Path.Combine(executableFolder, fileName); 
                    File.Copy(sourceFile, destinationFilePath, true); 
                } 
            } 
        }
    }
    

    Ce code permet d’effectuer une copie des dépendances natives dans le répertoire de l’exécutable suivant l’architecture d’exécution:

    • L’architecture d’exécution peut être récupérée avec la propriété statique Environment.Is64BitProcess.
    • On récupère le chemin de l’exécutable avec la propriété Assembly.GetEntryAssembly().Location.
    • On effectue une copie des DLL se trouvant dans les répertoires:
      • Dependencies\x64 si l’exécutable est lancé en x64
      • Dependencies\Win32 si l’exécutable est lancé en x86

      La copie est effectuée dans le répertoire de l’exécutable.

    • Cette classe permet de lancer le code se trouvant dans les dépendances natives avec la méthode CallNativeCaller():
      public void CallNativeCaller(string textToDisplay) 
      { 
          NativeCaller nativeCaller = new NativeCaller(textToDisplay); 
          nativeCaller.CallNativeCode(); 
      }
      
  5. On modifie les dépendances du projet lors de la génération en effectuant un clique droit sur la projet DependencyLoader ⇒ “Dépendances de build” ⇒ “Dépendances du projet” puis on coche MixedAssembly:
  6. Dans le gestionnaire de configurations accessible en effectuant un clique droit sur la solution ⇒ “Gestionnaire de configurations”. Le projet DependencyLoader ne doit comporter que la configuration AnyCPU en Debug et en Release (comme pour le projet ArchitectureExample):
  7. Modification du projet ArchitectureExample pour appeler la classe “Proxy”

    On modifie le projet ArchitectureExample correspondant à l’exécutable pour qu’il appelle le classe Proxy NativeCallerProxy dans le projet DependancyLoader. Ainsi:

    1. On supprime la référence vers le projet MixedAssembly en accédant à la partie “Références” du projet ArchitectureExample et on supprime la référence MixedAssembly.
    2. On ajoute une référence de projet vers DependencyLoader en effectuant un clique droit sur la partie “References” du projet ArchitectureExample ⇒ “Ajouter une référence”. Dans la partie “Projets”, il faut cocher DependencyLoader.
    3. On modifie le main dans le fichier Program.cs pour appeler la classe NativeCallerProxy dans le projet DependancyLoader:
      static void Main(string[] args) 
      { 
          string textToDisplay = "text to display"; 
        
          Console.WriteLine($"Displaying from managed code: {textToDisplay}"); 
        
          var proxy = new NativeCallerProxy(); 
          proxy.CallNativeCaller(textToDisplay);
        
          Console.ReadLine(); 
      } 
      
    4. On ajoute une commande post-build pour effectuer les copies des DLL natives dans les bons répertoires:

      On effectue un clique droit sur le projet ArchitectureExample ⇒ “Propriétés”. Dans l’onglet “Evènements de build”, il faut ajouter les commandes suivantes dans la partie “Ligne de commande de l’évènement post-build”:

      mkdir $(TargetDir)Dependencies\Win32 
      xcopy $(SolutionDir)Win32\$(ConfigurationName)\NativeCallee.dll $(TargetDir)Dependencies\Win32 /Y 
      xcopy $(SolutionDir)Win32\$(ConfigurationName)\MixedAssembly.dll $(TargetDir)Dependencies\Win32 /Y 
      mkdir $(TargetDir)Dependencies\x64 
      xcopy $(SolutionDir)x64\$(ConfigurationName)\NativeCallee.dll $(TargetDir)Dependencies\x64 /Y 
      xcopy $(SolutionDir)x64\$(ConfigurationName)\MixedAssembly.dll $(TargetDir)Dependencies\x64 /Y 
      

      On valide en enregistrant le projet.

      En mode Debug, ces commandes permettent de copier les DLL MixedAssembly.dll et NativeCallee.dll dans les répertoires:

      • ArchitectureExample\bin\Debug\Dependencies\Win32 et
      • ArchitectureExample\bin\Debug\Dependencies\x64.
    5. On modifie les dépendances du projet ArchitectureExample lors de la génération en effectuant un clique droit sur la projet DependencyLoader ⇒ “Dépendances de build” ⇒ “Dépendances du projet” puis on coche MixedAssembly et NativeCallee:

    Si on compile en mode Debug, les fichiers suivants seront générés dans le répertoire de sortie du projet de l’exécutable ArchitectureExample (ArchitectureExample\bin\Debug):

    • Les fichiers de l’exécutable:
      • ArchitectureExample.exe
      • ArchitectureExample.exe.config
      • DependencyLoader.dll
    • Les fichiers des dépendances natives de toutes les architectures:
      • Dependencies\Win32\MixedAssembly.dll
      • Dependencies\Win32\NativeCallee.dll
      • Dependencies\x64\MixedAssembly.dll
      • Dependencies\x64\NativeCallee.dll

    A l’exécution, les DLL natives sont copiées dans le même répertoire que l’exécutable et le résultat de l’exécution est le même:

    Displaying from managed code: text to display 
    Displaying from unmanaged code: text to display
    

    Le résultat de l’exécution est le même quel que soit le CLR qui exécute l’exécutable (32 ou 64 bits).

    Le code final de cet exemple se trouve dans la branche final du repository GitHub github.com/msoft/cpp_execution_architecture.

    Pour résumer

    Les assemblies .NET peuvent être compilées avec une plateforme cible supplémentaire par rapport aux DLL natives. Cette plateforme cible est AnyCPU qui permet de démarrer un exécutable en utilisant le CLR 32 ou 64 bits. Lorsqu’une dépendance native existe, il faut qu’elle soit compilée avec une plateforme cible compatible avec celle de l’exécutable .NET.

    Dans le cas d’un exécutable .NET compilé en AnyCPU, de façon à éviter les erreurs de chargement, il faut être vigilant sur la plateforme choisie pour les dépendances natives car l’exécutable peut démarrer en 32 bits ou en 64 bits. Pour éviter les problèmes de chargement, une solution consiste à copier programmatiquemet les dépendances natives dans le répertoire de l’exécutable juste avant d’appeler le code de la dépendance. Ainsi connaissant l’architecture d’exécution de l’exécutable, on peut savoir quelle plateforme cible des DLL natives est compatible.

    L’intérêt de cette méthode est de déployer les mêmes DLL et assemblies quel que soit le système sur lequel l’exécutable est exécuté.

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

Installer une assembly native dans WinSxS

Plusieurs méthodes sont possibles pour référencer des dépendances natives: consommer des dépendances en utilisant des bibliothèques statiques, des bibliothèques dynamiques, référencer un objet avec une interface COM etc… Suivant le cas d’utilisation, chacune de ces méthodes peut s’avérer plus intéressante qu’une autre. Par exemple, utiliser des bibliothèques dynamiques a le grand avantage d’apporter une modularité à un programme contrairement aux bibliothèques statiques. A l’opposé cette souplesse peut s’avérer être une source d’instabilité pour une application qui pourrait en dépendre si la bibliothèque fait défaut durant l’exécution.

Le répertoire WinSxS dans Windows a pour but d’apporter une solution pour garantir une souplesse quant à la consommation d’une dépendance native pour une application tout en garantissant une certaine robustesse. Ceci afin d’éviter qu’une application ne soit pas trop facilement déstabilisée dans le cas de la mise à jour d’une dépendance.

Le but de cet article est de présenter au moyen d’un exemple simple, l’utilisation d’une DLL ajoutée au répertoire WinSxS. Dans un premier temps, on va expliquer quelques généralités sur le répertoire WinSxS puis illustrer ces généralités avec un exemple.

@oratnin

Quelques explications en préambule

Avant d’expliquer l’intérêt de WinSxS, on peut apporter quelques précisions pour donner un peu de contexte.

Différences entre une bibliothèque statique et une bibliothèque dynamique

Il existe 2 types de bibliothèques pouvant être des dépendances natives: les bibliothèques statiques et les bibliothèques dynamiques.

Bibliothèque statique

Les bibliothèques statiques (i.e. static library) sont des fichiers dont l’extension est .lib. Si un exécutable ou une autre bibliothèque a une dépendance vers une bibliothèque statique, le code utilisé sera directement incorporé dans le fichier de l’exécutable ou de la bibliothèque.

L’intérêt de ces bibliothèques est d’être certain d’inclure les bonnes dépendances dans l’exécutable final. Au moment de l’exécution, il n’y a pas de risques que le fichier de la bibliothèque ne soit pas accessible puisqu’il est inclus dans l’exécutable lui-même. De même, il n’y a pas de risque d’utiliser une mauvaise version.

A l’inverse, pour mettre à jour ce type de bibliothèque, il faut recompiler l’exécutable l’utilisant. D’autre part, si plusieurs exécutables utilisent la même bibliothèque, elle sera chargée en mémoire autant de fois qu’un exécutable l’utilise puisqu’elle fait partie directement de l’exécutable.

Bibliothèque dynamique

Les bibliothèques dynamiques (i.e. dynamic library) se rapprochent des assemblies .NET car elles ont quelques caractéristiques en commun:

  • Il s’agit de fichiers séparés qui ne seront pas inclus dans le fichier exécutable ou dans une bibliothèque l’utilisant comme dépendance.
  • L’extension de ces bibliothèques est .dll pour Dynamic Link Library.

Le gros intérêt de ces bibliothèques est d’être partageables entre plusieurs exécutables. Ainsi, à l’exécution, quand un appel est effectué vers une fonction se trouvant dans une bibliothèque dynamique, elle est chargée en mémoire. Si un autre exécutable utilise la même bibliothèque, le chargement en mémoire n’est effectué qu’une seule fois ce qui permet d’économiser de l’espace mémoire.

L’inconvénient majeur des bibliothèques dynamiques est qu’elles doivent être accessibles au moment de l’exécution. Comme ce sont des fichiers séparés, si l’un d’entre eux n’est pas accessible au moment de l’exécution, il se produira une erreur et l’exécutable va interrompre son exécution.

Dans un article précédent, un exemple avait permis d’illustrer les différences d’utilisation d’une bibliothèque et d’une bibliothèque statique (cf. Référencer une DLL C++ avec une bibliothèque statique d’import).

Objet PE vs Module vs Assembly

Tous ces termes sont utilisés à la fois pour des technologies managées et non managées. D’une façon générale, ils désignent des objets contenant des informations que le système d’exploitation sait interpréter. Ces informations peuvent être des métadonnées et/ou du code (pas forcément du code directement exécutable). Quelque-soit la forme, un objet exécutable ne suffit pas à lui-même pour être exécuté, il a besoin de dépendances et ainsi, il doit indiquer des informations pour que le système d’exploitation soit capable de trouver ses dépendances de façon à exécuter le code qu’il contient.

Objet PE

PE pour Portable Executable est un format de fichier commun utilisé pour structurer des exécutables, des bibliothèques ou des drivers système. Ce format indique une structure connue du système d’exploitation pour qu’il puisse savoir où trouver des informations qui lui permettront de mapper le contenu du fichier organisé en sections à des zones en mémoire. La structure est indiquée dans l’en-tête du fichier (i.e. PE Header).

Lister l’en-tête d’un fichier PE

On peut lister le contenu du PE Header avec dumpbin en exécutant la commande:

dumpbin /headers <nom du fichier .DLL> 

dumpbin est un utilitaire livré avec Visual Studio C++, le chemin de l’exécutable est du type:

C:\Program Files\Microsoft Visual Studio [version VS]\VC\bin  

ou

C:\Program Files (x86)\Microsoft Visual Studio [version VS]\VC\bin  

On peut y accéder directement à partir de la ligne de commandes Visual Studio (ou “Developer Command Prompt for VS2017”).

L’en-tête PE d’un fichier contient des structures au sens C contenant différents types d’informations. La structure de plus haut niveau s’appelle IMAGE_NT_HEADER:

typedef struct _IMAGE_NT_HEADERS  
{ 
    DWORD                 Signature; 
    IMAGE_FILE_HEADER     FileHeader; 
    IMAGE_OPTIONAL_HEADER OptionalHeader; 
} IMAGE_NT_HEADERS, *PIMAGE_NT_HEADERS; 

Dans la structure de type IMAGE_OPTIONAL_HEADER peut se trouver une autre structure de type IMAGE_DATA_DIRECTORY dans le cas d’une DLL:

typedef struct _IMAGE_OPTIONAL_HEADER  
{ 
    ... 
    IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES]; 
} IMAGE_OPTIONAL_HEADER32, *PIMAGE_OPTIONAL_HEADER32; 

L’élément DataDirectory est un tableau de structures de type IMAGE_DATA_DIRECTORY contenant des indications sur l’emplacement et la taille des informations importantes des structures de données du fichier PE:

typedef struct _IMAGE_DATA_DIRECTORY  
{ 
    DWORD VirtualAddress; 
    DWORD Size; 
} IMAGE_DATA_DIRECTORY,*PIMAGE_DATA_DIRECTORY; 

Ces informations sont rangées de cette façon dans le tableau:

Position Type Description
0 IMAGE_DIRECTORY_ENTRY_EXPORT Table des exports
1 IMAGE_DIRECTORY_ENTRY_IMPORT Table des imports
2 IMAGE_DIRECTORY_ENTRY_RESOURCE Table des ressources
3 IMAGE_DIRECTORY_ENTRY_EXCEPTION Table des exceptions
4 IMAGE_DIRECTORY_ENTRY_SECURITY Table des certificats
5 IMAGE_DIRECTORY_ENTRY_BASERELOC Table des relocalisations
6 IMAGE_DIRECTORY_ENTRY_DEBUG Informations de debug
7 IMAGE_DIRECTORY_ENTRY_COPYRIGHT
/ IMAGE_DIRECTORY_ENTRY_ARCHITECTURE
Copyright
8 IMAGE_DIRECTORY_ENTRY_GLOBALPTR Pointeurs globaux
9 IMAGE_DIRECTORY_ENTRY_TLS Thread local storage
10 IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG Load configuration table
11 IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT Table des imports liés
12 IMAGE_DIRECTORY_ENTRY_IAT Table des adresses des imports
13 IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT Descripteur des imports en différé (Delay loading)
14 IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR En-tête runtime COM+

Parmi ces informations se trouvent les adresses des fonctions d’import dans la table des imports. Ces fonctions ne se trouvent pas dans le code de l’appelant mais dans les DLL appelées. Seules les informations concernant ces fonctions sont dans le code de l’appelant.

Ces informations sont:

  • Les noms des fonctions d’import
  • Les noms des DLL où se trouvent ces fonctions.

Comme on peut le voir, la DLL appelante ne contient pas le chemin de ses dépendances mais seulement le nom des DLL à appeler. En fonction du nom de la DLL, le loader du système d’exploitation va chercher l’emplacement exacte du fichier pour le charger.

Module

Un module est aussi un terme utilisé en managée et en non managée. Il désigne une unité de compilation contenant des métadonnées des types qu’il contient et du code compilé. Le code compilé n’est pas forcément du code machine. Il correspond à un niveau d’échelle plus élevé que les objets PE.

Un module ne peut pas être déployé seul et il ne contient pas de manifest c’est-à-dire qu’il n’indique pas quelles sont ses dépendances. Chaque module contient un seul type de code.

L’intérêt des modules est de pouvoir être utilisés indépendamment dans une unité déployable comme les assemblies.

Assembly

Une assembly est une unité déployable en opposition aux modules qui sont des unités de compilation. L’assembly peut ainsi être déployée seule. D’une façon générale, il s’agit de fichiers avec une extension .exe pour un exécutable ou .dll pour une bibliothèque de classes.

En .NET, les assemblies sont assez souples pour contenir un ou plusieurs modules, éventuellement des fichiers de ressources et des métadonnées. Les modules peuvent être compilés dans des langages différents. Il est aussi possible de merger le contenu de plusieurs assemblies dans une seule assembly.

On considère plusieurs types d’assemblies:

  • Assembly managée: objet PE contenant du code managé. En .NET c’est la plus petite unité de déploiement. Dans la pratique, ces fichiers peuvent être des exécutables ou des bibliothèques de classes. Le plus souvent quand on utilise le terme assembly c’est pour désigner les assemblies managées.
  • Assembly mixte: assembly .NET contenant à la fois du code managé et du code natif (cf. C++/CLI).
  • Assembly native: on retrouve le terme assembly native dans la documentation Microsoft concernant WinSxS. Ce terme est ambigu car il laisse penser qu’il désigne d’assemblies .NET contenant seulement du code natif or, dans le cas de WinSxS, on parle bien de DLL natives classiques.
    En effet, en .NET, on distingue les assemblies (sous-entendu les assemblies managées) qui contiennent exclusivement du code managé et les assemblies mixtes contenant, à la fois du code managé et du code natif. Ainsi dans le cas où il n’y a que du code natif, le terme assembly native désigne un groupe d’une ou plusieurs DLL natives, de composant COM ou des collections de ressources, de types ou d’interfaces.
  • Side-by-side assembly: assembly native contenant une liste de ressources ou un groupe de DLL avec un manifest. Le loader du système d’exploitation utilise les informations du manifest pour identifier l’assembly et être capable de la charger quand un exécutable a une dépendance vers cette dernière. Elles ont une identité unique et sont utilisées pour éviter de casser des dépendances.
  • Private assembly: assembly native utilisée seulement par une seule application. Elle peut être inclue en tant que ressource d’une autre DLL ou installer dans le même répertoire que l’exécutable qui l’utilise.
  • Shared assembly: side-by-side assembly déployée dans le répertoire du cache des assemblies du système WinSxS. Ces assemblies peuvent être utilisées par un exécutable si la dépendance est indiquée dans son manifest.

Les assemblies sont composées des éléments suivants:

  • PE Header: l’assembly est structurée dans un objet PE de plus bas niveau. A ce titre, il possède ce type d’en-tête.
  • Un manifest contenant une liste des références externes de l’assembly.
  • Sections contenant du code natif compilé.
  • Dans le cas d’assembly managée:
    • CLR Header: présent dans le cas d’une assembly managée. Ce sont des informations sur la version cible du framework .NET; éventuellement le hash pour signature par nom fort (cf. Signature par nom fort); l’adresse dans le fichier des ressources et le point d’entrée indiquant la table des métadonnées permettant à l’assembly de s’autodécrire.
    • Liste des objets binaires utilisés dans les métadonnées.
    • Liste des chaines de caractères utilisées dans les métadonnées .
    • Liste des chaines de caractères utilisées dans le code IL (i.e. Intermediate Language).
    • Liste des GUID utilisés dans l’assembly.
    • Tables des métadonnées permettant d’indiquer des informations sur tous les types utilisés dans l’assembly.
    • Le code IL (i.e. Intermediate Language).

Ainsi dans le répertoire WinSxS se trouve des assemblies partagées. Ce répertoire permet de remplacer le répertoire dllcache (qui n’est plus présent à partir de Windows 7).

Chargement des DLL natives

Lorsqu’un exécutable ou une DLL appellent une fonction se trouvant dans une DLL tiers, ils ont une dépendance vers cette DLL tiers. A l’exécution, il existe différentes façons pour charger cette dépendance native en fonction de la façon dont la dépendance a été définie: dépendance implicite ou dépendance explicite.

Dépendance implicite

Une dépendance par lien implicite (i.e. implicit linking) vers une DLL tiers est la façon la plus courante de définir une dépendance vers une DLL tiers. Elle est indiquée au préalable dans les paramètres de l’éditeur de liens (i.e. linker). Ainsi, pendant la phase d’éditions de liens (i.e. linking) des informations seront ajoutées dans l’en-tête PE Header de la DLL pour préciser:

  • Le nom de la DLL où se trouve la fonction à appeler. Seulement le nom est indiqué et non le chemin de la DLL.
  • L’adresse à laquelle se trouve la fonction à exécuter dans la DLL.

Ces informations sont rangées au début de l’en-tête PE Header (position 1) dans une partie appelée IMAGE_DIRECTORY_ENTRY_IMPORT. Cette partie est divisée en 2 tables:

  • Import directory table permettant d’indiquer le nom de la DLL comme on l’a vu précédemment. Cette partie est lue par le loader pour savoir quel DLL charger.
  • Import address table pour préciser où se trouve la fonction à exécuter dans la DLL. Cette adresse est lue directement par l’exécutable au runtime pour savoir où exécuter le code de la dépendance.

Les dépendances indiquées par lien implicite sont chargées directement au lancement de l’exécutable.

Voir les dépendances implicites d’une DLL avec DependencyWalker

Sachant que les informations concernant les dépendances sont indiquées dans l’en-tête d’une DLL ou d’un exécutable, il est possible de le lire au préalable et de savoir quelles sont les dépendances.

Par exemple, si on utilise DependencyWalker (i.e. www.dependencywalker.com/) pour lister les dépendances de la DLL NativeCallee.dll obtenue après avoir compilé le code se trouvant dans le repository Github msoft/cpp_dll_reference, on obtient la liste suivante:

Dans l’exemple précédent, la DLL CallerRedirection.dll possède une dépendance vers la DLL NativeCallee.dll. La fonction appelée par CallerRedirection est DisplayTextWithCallee.

DependencyWalker trouve le chemin des dépendances en imitant le parcours des répertoires par le loader.

Dépendance explicite

Une DLL possède une dépendance par lien explicite (i.e. explicit linking) lorsque la DLL tiers est chargée par programmation en appelant la fonction LoadLibrary. Sachant que l’appel est effectué dans le code, il n’est pas indiqué dans l’en-tête de la DLL, il n’est donc pas possible de voir cette dépendance en lisant statiquement le contenu de l’en-tête.

Comme ce type de dépendance est indiqué dans le code, il est possible d’effectuer le chargement d’une DLL juste avant de l’utiliser et non au démarrage de l’exécutable.

Voir les dépendances explicites avec DependencyWalker

DependencyWalker est capable de lire les dépendances explicites d’un exécutable. Il faut utiliser la fonctionnalité de profiling qui va scruter le chargement des dépendances à l’exécution. Un exemple plus bas permet d’illustrer cette fonctionnalité.

Stratégie de recherche d’une assembly partagée ou d’une DLL native

La stratégie de recherche d’une DLL n’est pas la même que la stratégie utilisée pour chercher une assembly se trouvant dans WinSxS.

Dans le cas d’une DLL native, la plupart des cas, cette stratégie s’effectue de cette façon:

  1. Le système cherche si la DLL est déjà chargée en mémoire. Si c’est le cas, il arrête la recherche.
  2. Si la DLL fait partie des DLL connues dans la clé de registre HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\KnownDLLs, il utilise la DLL qui y est indiquée.
  3. Le système cherche dans le répertoire de l’application.
  4. Il cherche dans le répertoire système (par exemple: C:\Windows\System32 suivant les versions de Windows ou C:\Windows\SysWow64).
  5. Le répertoire système 16-bit si il existe (par exemple: C:\Windows\System).
  6. Le répertoire de Windows (par exemple C:\Windows).
  7. Le répertoire courant.
  8. Les répertoires listés dans la variable d’environnement PATH.

Pour plus de détails sur la stratégie de recherche d’une DLL native: docs.microsoft.com/en-us/windows/desktop/dlls/dynamic-link-library-search-order.

Dans le cas d’assembly partagée dans le répertoire WinSxS, la recherche s’effectue de cette façon:

  1. Dans le répertoire WinSxS,
  2. Dans le répertoire ou dans un sous-répertoire de l’application. La recherche dans les sous-répertoires peut se faire suivant des éléments de culture.

Pour plus de détails sur la stratégie de recherche d’une assembly partagée: docs.microsoft.com/en-us/windows/win32/sbscs/assembly-searching-sequence.

Contexte d’activation

Qu’une dépendance vers une bibliothèque dynamique soit implicite ou explicite, elle doit être chargée pour pouvoir exécuter une fonction qui s’y trouve. Quand elle est chargée, l’ensemble de ses dépendances doivent être chargées aussi. Le chargement de la DLL implique plusieurs étapes:

  • Lire les informations concernant la DLL à charger dans la table d’import de l’objet PE ou dans le manifest d’un exécutable.
  • Trouver le fichier correspondant à la DLL en fonction de ces informations en utilisant une stratégie de recherche.
  • Charger la DLL ainsi que ses dépendances.

Ces différentes étapes sont effectuées par le système d’exploitation. La dernière étape qui consiste à charger la DLL et ses dépendances se fait en utilisant un espèce de “bac à sable” isolé du reste de l’application. Ce “bac à sable” s’appelle le contexte d’activation (i.e. activation context). Il pourrait être assimilé aux domaines d’application (i.e. Application Domains) en .NET toutefois son fonctionnement est très différent. Le but du contexte d’activation est de permettre de charger une DLL et ses dépendances en utilisant son manifest de façon isolée.

Pour un exécutable, il peut exister plusieurs contextes d’activation toutefois un seul peut être actif. Quand un contexte d’activation est actif, les autres sont désactivés. Le manifest d’un exécutable est chargé dans un contexte d’activation appelé contexte d’activation par défaut.

Ainsi si on considère un exécutable possédant une dépendance implicite vers une DLL, le séquencement des utilisations des contextes d’activation sera le suivant:

  1. Au démarrage de l’exécutable, son manifest est utilisé pour créer le contexte d’activation par défaut.
  2. Le contexte d’activation est rendu actif pour chercher la dépendance en fonction des informations indiquées dans le manifest de l’exécutable.
  3. Un contexte d’activation est créé et activé pour charger la DLL et ses dépendances en utilisant son manifest. Le contexte d’activation par défaut est désactivé. Le manifest de la DLL doit être inclus dans cette dernière en tant que ressource avec un ID 1 ou 2. Dans le cas contraire, le manifest de la DLL sera ignoré.
    Si le manifest de la DLL est un fichier séparé, il doit être référencé dans le manifest de l’exécutable.
  4. Quand la DLL et ses dépendances sont chargées en mémoire, le contexte d’activation utilisé pour ce chargement est désactivé et le contexte d’activation par défaut est ré-activé.
  5. L’exécutable continue son exécution et la fonction dans la DLL peut être appelée.

Par défaut, tous ces mécanismes s’exécutent implicitement toutefois il est possible de les provoquer explicitement par code avec des fonctions de l’API Win32 comme CreateActCtx, DeactivateActCtx, GetCurrentActCtx etc…

Comme on a pu le voir, les manifests sont utilisés de façon isolée dans les contextes d’activation. Ainsi si le contexte d’activation par défaut est actif et si on souhaite charger une DLL possédant un manifest en tant que ressource en utilisant la fonction LoadLibrary (dépendance explicite), c’est le contexte actif c’est-à-dire le contexte par défaut qui sera utilisé pour le chargement de la DLL. Au cours du chargement dans le contexte par défaut c’est le manifest de l’exécutable qui sera utilisé et non le manifest inclus dans la DLL.

Pourquoi utiliser WinSxS ?

DLL Hell

L’utilisation de bibliothèques dynamiques apporte une grande souplesse en permettant de consommer des dépendances de façon modulaire. Une DLL peut ainsi être partagée entre plusieurs exécutable. Sachant que le fichier est partagé, il doit donc être accessible pour tous les exécutables qui l’utilisent. Si jamais le fichier n’est plus présent ou s’il est mis à jour, tous les exécutables qui l’utilisent pourront éventuellement être déstabilisés dans le cas où une incompatibilité survient.

Comme on l’a vu précédemment, les dépendances implicites sont indiquées en utilisant le nom de la DLL. On peut ainsi facilement remplacer la DLL par une autre avec le même nom mais de version différente et ainsi introduire un breaking change qui peut empêcher à l’application de s’exécuter.

Jusqu’à Windows XP, si une DLL était partagée entre plusieurs applications et qu’elle subissait une modification soit par remplacement pour une autre version, soit par effacement, elle pouvait compromettre l’exécution de toutes les applications qui l’utilisaient. Il était compliqué de revenir en arrière car les jeux de dépendances pouvaient rendre stable certaines applications utilisant une version précise de la DLL et déstabiliser les autres.

Le DLL hell désigne la complexité que pouvait engendrer le partage d’une DLL entre plusieurs applications et des difficultés à gérer toutes les dépendances qui en découlent.

A partir de Windows XP, une solution pour résoudre le DLL hell a été d’utiliser le répertoire WinSxS.

Répertoire WinSxS

Le répertoire WinSxS (pour Windows Side-by-Side) est une solution de Microsoft introduite à partir de Windows XP pour répondre au problème du DLL Hell. L’idée est de permettre aux applications de partager des DLL tout en évitant qu’une application ne soit déstabilisée quand une de ces dépendances est mise à jour par une autre application. Ainsi pour mettre en place ce type de solution, une application doit être capable:

  • De désigner une dépendance en utilisant un identifiant sans utiliser le nom explicite du fichier de la DLL.
  • D’indiquer une dépendance sans forcément indiquer précisément la version de la dépendance. L’intérêt est de pouvoir changer la version de la DLL sans nécessairement devoir recompiler l’application qui consomme la DLL.
  • D’utiliser une version d’une DLL quand une autre application utilise une autre version de cette DLL.

Le répertoire WinSxS vise à apporter une solution à tous ces problèmes. Ce répertoire contient un ensemble de ressources comme des DLL ou d’objets COM possédant un fichier manifest. Ce manifest permet d’identifier un objet et d’assurer son unicité dans le répertoire.

Le chemin du répertoire WinSxS est du type <répertoire de Windows>\winsxs (par exemple C:\windows\winsxs).

Ajouter une assembly native dans WinSxS

L’ajout d’une DLL dans le répertoire WinSxS ne se fait pas directement. Pour rajouter une DLL dans ce répertoire, il faut d’abord qu’elle possède, au minimum, certaines informations comme:

  • Le type d’assembly: "win32" permet d’indiquer qu’il s’agit d’une assembly. Ce paramètre ne veut pas dire que l’architecture d’exécution est obligatoirement x86.
  • Un nom.
  • Un numéro de version indiquée sous la forme de 4 parties a.b.c.d.
  • Une clé publique obtenue après signature.
  • Une indication sur l’architecture d’exécution de la DLL: "x86" ou "ia64".

Ces informations représentent l’identité de la DLL et permettent d’identifier la DLL de façon unique dans le répertoire WinSxS. Il est possible de rajouter d’autres informations (cf. Assembly Manifests).

Ces informations peuvent être associées à une DLL en utilisant un fichier manifest. Un 3e fichier appelé fichier catalogue (extension .CAT) va décrire l’association entre la DLL et son fichier manifest. On va ensuite signer le fichier catalogue pour garantir l’unicité de l’ensemble formé par la DLL et son fichier manifest. Cet ensemble appelé assembly native pourra être rajouté dans le répertoire WinSxS.

L’ajout dans WinSxS ne se fait pas directement par copier-coller, il faut le faire par l’intermédiaire d’un fichier d’installation ou par programmation.

Exemple d’ajout d’une assembly native dans WinSxS

On va illustrer l’ajout d’une assembly dans le répertoire WinSxS avec un exemple simple d’une application Console qui consomme une dépendance sous forme de DLL native.

Le but de l’application est d’appeler une DLL native pour afficher le contenu d’une chaîne de caractères.

Le code de l’exemple se trouve dans le repo GitHub msoft/cpp_dll_reference. La solution comporte 3 projets :

  • NativeCaller: application Console en C++ qui appelle la méthode Redirect::RedirectCall() se trouvant dans le projet CallerRedirection.
  • CallerRedirection: il s’agit d’un projet C++ permettant de générer une bibliothèque dynamique. Cette bibliothèque ne contient que la classe Redirect qui va appeler la fonction DisplayTextWithCallee() exposée dans le projet NativeCallee.
  • NativeCallee: c’est un projet C++ pour générer une bibliothèque dynamique. Cette bibliothèque expose la méthode DisplayTextWithCallee() pour afficher le contenu d’une chaine de caractères.

Pour résumer les appels se font de cette façon:

NativeCaller (exécutable) CallerRedirection (DLL) NativeCallee (DLL)
Main() Redirect::RedirectCall() DisplayWithCallee()

Si on exécute l’application, le résultat est du type:

Calling other lib:  
Text to display 

"Calling other lib:" est affiché par NativeCaller.exe et "Text to display" est affiché par NativeCallee.dll

Pour une explication plus complète du code de cet exemple, on peut se référer à l’article Référencer une DLL C++ avec une bibliothèque statique d’import.

Après avoir cloné le repository GitHub, il faut l’ouvrir avec Visual Studio et le compiler. Après compilation, il devrait résulter les fichiers suivants dans le répertoire Debug:

  • NativeCaller.exe
  • CallerRedirection.dll
  • NativeCallee.dll

Dans un premier temps, on va signer la DLL NativeCallee après l’avoir associé à un fichier manifest de façon à en faire une assembly native. On va ensuite ajouter l’assembly native au répertoire WinSxS avec un fichier d’installation. Enfin on va appeler l’assembly native dans WinSxS à partir de l’exécutable.

Signer la DLL NativeCallee

La signature d’une DLL native est très semblable à la signature par nom fort en .NET (cf. Signature des assemblies par nom fort en 5 min). La différence est qu’on va signer un fichier catalogue décrivant l’association entre la DLL et son fichier manifest au lieu de signer seulement le fichier DLL comme en .NET.

Pour exécuter les commandes suivantes, il faut ouvrir une invite de commandes développeur Visual Studio (ou “Developer Command Prompt for VS2017”).

Créer un certificat pour une autorité locale de certification

Avant de créer un certificat, on va créer le certificat d’une autorité locale de certification qu’on rajoutera au magasin des autorités de certification de confiance de la machine. On va ensuite créer un certificat provenant de cette fausse autorité de certification de façon à ce que le certificat soit valide.

Pour créer le certificat de certificat de l’autorité locale de certification, on exécute la commande suivante:

makecert -r -pe -n "CN=CACert" -a sha1 -sky signature -cy authority -sv CACert.pvk
CACert.cer 

Le détail des options est:

  • -r permet de générer un certificat autosigné.
  • -pe indique que la clé privée est exportable.
  • -n "CN=CACert" indique le nom de l’entité qui a généré le certificat.
  • -a sha1 permet de préciser l’algorithme utilisé.
  • -cy authority indique le type de certificat.
  • -sv CACert.pvk fichier .PVK contenant la clé privée.

A l’exécution, une 1ère pop-up demande de créer un mot de passe pour créer un clé privée. Une 2e pop-up redemande le mot de passe pour accéder à la clé privée qui vient d’être créée pour générer le certificat.

Après exécution, on obtient les fichiers:

  • Un fichier CACert.pvk contenant la clé privée
  • Un fichier CACert.cer contenant le certificat.

Pour générer un fichier PFX contenant la clé publique et la clé publique, on exécute la commande suivante:

pvk2pfx -pvk CACert.pvk -spc CACert.cer -pfx CACert.pfx -pi <mot de passe> 

A la suite de cette étape, le fichier CACert.pfx a été créé.

Ajouter le certificat de l’autorité locale au magasin des autorités de certification de confiance

Cette étape permettra d’indiquer que l’autorité locale CACert est une autorité de confiance:

  1. Double-cliquer sur le fichier CACert.pfx pour ouvrir l’assistant d’importation du certificat.
  2. Cliquer sur “suivant” puis de nouveau “Suivant”.
  3. Laisser le mot de passe vide et cocher seulement “Inclure toutes les propriétés étendues” (“Include all extended properties“):
  4. Cliquer sur “Suivant”.
  5. Sélectionner “Placer tous les certificats dans le magasin suivant” (“Place all certificates in the following store“).
  6. Cliquer sur “Parcourir” et sélectionner “Autorités de certification racines de confiance” (“Trusted Root Certification Authorities“) puis cliquer sur OK.
  7. Cliquer sur “Suivant” puis “Terminer”.

A la fin de cette étape, le certificat devrait être rajouté au magasin des autorités de certification de confiance. Pour le vérifier, on peut effectuer les étapes suivantes:

  1. Appuyer sur [Win] + [R] et taper mmc.exe
  2. Cliquer sur “Fichier” ⇒ “Ajouter/Supprimer un composant logiciel enfichable…” (“Add/Remove Snap in…“)
  3. Cliquer sur “Certificats” à gauche puis cliquer sur “Ajouter >”
  4. Sélectionner “Mon compte d’utilisateur” (“My user account“) puis cliquer sur Terminer.
  5. Cliquer sur OK pour valider.
  6. Déplier “Certificats = Utilisateur actuel” (“Certificates – Current User“) puis “Autorités de certification racines de confiance” (“Trusted Root Certification Authorities“) puis “Certificats”
  7. Le certificat devrait être présent:

Créer un certificat pour signer l’assembly

Pour créer un certificat valide pour signer l’assembly, on execute la commande suivante:

makecert -pe -n "CN=DllCert" -a sha1 -sky exchange -ic CACert.cer -iv CACert.pvk 
-len 2048 -sv DllCert.pvk DllCert.cer 

Le détail des options est:

  • -pe indique que la clé privée est exportable.
  • -n "CN=DllCert" indique le nom de l’entité qui a généré le certificat.
  • -ic CACert.cer permet d’indiquer le certificat de l’émetteur.
  • -iv CACert.pvk indique le clé privée de l’émetteur.
  • -len 2048 précise la longueur de la clé du certificat. La longueur doit être au minimum de 2048 pour être valide pour WinSxS.
  • -a sha1 permet de préciser l’algorithme utilisé.
  • -sv DllCert.pvk fichier .PVK contenant la clé privée.

A l’exécution, une 1ère pop-up demande de créer un mot de passe pour créer la clé privée; une 2e pop-up redemande le mot de passe pour accéder à la clé privée. Une 3e pop-up demande le mot de passe pour accéder à la clé privée du certificat CACert.cer de l’autorité locale de certification. Il faut indiquer le mot de passe utilisé dans l’étape précédente.

A la fin de cette étape, on obtient 2 nouveaux fichiers:

  • DllCert.cer contenant le certificat et
  • DllCert.pvk contenant la clé privée.

Si on double-clique sur le fichier DllCert.cer nouvellement créé, la description du certificat s’affiche et sur l’onglet “Chemin d’accès de certification” (“Certification path“), on peut voir le lien avec l’autorité local de certification et la validité du certificat:

On génère un fichier .PFX contenant la clé privée et la clé publique en exécutant la commande suivante:

pvk2pfx -pvk DllCert.pvk -spc DllCert.cer -pfx DllCert.pfx 

Il faut préciser le mot de passe utilisé pour accéder à la clé privée du certificat DllCert.cer. A la suite de cette étape, le fichier DllCert.pfx a été créé.

Générer le fichier manifest

Avant de générer le fichier manifest, on extrait la clé publique du certificat en exécutant la commande:

pktextract DllCert.cer 

Le résultat sera du type:

Microsoft (R) Side-By-Side Public Key Token Extractor 
Copyright (C) Microsoft Corporation. All Rights Reserved 

Certificate: "CACert" - 2048 bits long 
        publicKeyToken="3ce09db04e4b62f1" 

On utilise la clé publique pour éditier le fichier manifest, par exemple:

<?xml version="1.0" encoding="UTF-8" standalone="yes"?> 
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0"> 
    <assemblyIdentity 
        type="win32" 
        name="NativeCallee" 
        version="1.0.0.0" 
        processorArchitecture="x86"         
        publicKeyToken="3ce09db04e4b62f1"/> 
    <file name="NativeCallee.dll" hashalg="SHA1" /> 
</assembly>  

On appelle ce fichier NativeCallee.manifest. On indique dans ce fichier les 5 informations obligatoires pour ajouter une assembly native au répertoire WinSxS.

Il faut placer la DLL NativeCallee.dll dans le répertoire du fichier manifest NativeCallee.manifest. Ensuite on exécute la commande suivante pour rajouter au manifest le hash de la DLL:

mt -manifest NativeCallee.manifest -hashupdate -makecdfs 

Le détail des options est:

  • -hashupdate permet de mettre à jour le fichier manifest en ajoutant le hash.
  • -makecdfs permet de générer le fichier NativeCallee.manifest.cdf contenant la description du contenu du catalogue utilisé pour valider le manifest.

A la fin de cette étape, on obtient les fichiers:

  • NativeCallee.manifest correspondant au fichier manifest de la DLL NativeCallee.dll,
  • NativeCallee.manifest.cdf contenant la description du contenu du catalogue.

Génération du fichier catalog .CAT

Ce fichier permet d’associer la DLL NativeCallee.dll avec son fichier manifest NativeCallee.manifest. On peut le générer en exécutant la commande suivante:

makecat NativeCallee.manifest.cdf 

Cette étape va permettre de générer un fichier .CAT supplémentaire.

Signature du fichier catalogue

On signe le fichier catalogue ce qui va permettre de signer l’assembly car le fichier catalogue associe la DLL avec son fichier manifest. Pour signer le fichier catalogue, il faut exécuter la commande suivante:

signtool sign /d DllCert.pfx NativeCallee.cat 

Il faut préciser le mot de passe utilisé pour créer le certificat DllCert.cer.

A la fin de cette étape, on obtient les 3 fichiers suivants:

  • NativeCallee.dll
  • NativeCallee.cat permettant d’associer la DLL avec son manifest.
  • NativeCallee.manifest qui est le fichier manifest de NativeCallee.dll.

Ces 3 fichiers constituent l’assembly native à rajouter dans le répertoire WinSxS.

Générer un fichier d’installation

Cette étape permet de créer un fichier d’installation pour rajouter l’assembly native contenant le fichier NativeCallee.dll dans le répertoire WinSxS.

Installer un modèle de projet Visual Studio pour créer des fichiers d’installation

Pour créer le fichier d’installation, on peut utiliser Visual Studio 2017 Community en ajoutant le modèle de projet “Setup project”. Ce type de projet est disponible dans la catégorie “Autres types de projets” (“Other Project Types“) quand on veut créer un nouveau projet. Si cette catégorie n’est pas disponible, il faut ajouter le modèle de projet “Setup Project”.

Pour ajouter le modèle de projet “Setup Project”, il faut effectuer les étapes suivantes:

  1. Cliquer sur “Outils” puis “Extensions et mises à jour” (“Extensions and Updates“).
  2. Dans la partie “En ligne”, taper Microsoft Visual Studio Installer":
  3. Cliquer sur “Télécharger”.
  4. Il faut fermer toutes les instances de Visual Studio pour que l’installation commence. Une pop-up doit s’afficher après fermeture pour que l’installation de l’extension aboutisse.

Après installation, on doit pouvoir créer des projets de type “Setup Project”:

Créer un projet “Setup Project”

Il faut créer un projet de type “Setup Project” accessible dans la catégorie “Autres types de projets” puis “Visual Studio installer”.

Après création du projet:

  1. Effectuer un clique droit sur le projet puis cliquer sur “Add” pour ajouter les 3 fichiers correspondant à l’assembly native:
    • NativeCallee.cat qui est le catalogue associant la DLL NativeCallee.dll au fichier manifest NativeCallee.manifest.
    • NativeCallee.dll
    • NativeCallee.manifest.

    Le projet doit se présenter de cette façon:

  2. Générer le fichier SetUp en cliquant sur “Générer” ⇒ “Générer la solution”.

Le fichier résultat InstallProject.msi se trouve dans le répertoire de sortie nommé Debug.

Modifier le fichier .MSI avec Orca

Orca est un outil permettant de modifier les fichiers d’installation .MSI. Le but est de modifier le fichier .MSI généré à l’étape précédente pour que les 3 fichiers NativeCallee.manifest, NativeCallee.dll et NativeCallee.cat fassent partie de la même assembly native.

Orca est livré avec le SDK Windows 10 disponible quand on installe Visual Studio 2017 toutefois il n’est pas installé. Le fichier d’installation se trouve dans le répertoire du SDK Windows 10, par exemple:

C:\Program Files\Windows Kits\10\bin\10.0.17134.0\x86\Orca-x86_en-us.msi 

Il faut installer Orca pour être capable de modifier le fichier .MSI.

Après installation, pour modifier le .MSI:

  1. Ouvrir Orca et ouvrir le fichier InstallProject.msi généré précédemment (“file” ⇒ “Ouvrir”).
  2. Dans la partie “File”, il faut modifier les valeurs de la colonne "Component_" pour que toutes les lignes contiennent la valeur de la ligne correspondant à NativeCallee.dll:
  3. Dans la partie “Component”, on doit retrouver 3 lignes. Il ne faut conserver que la ligne dont la valeur de la colonne “Component” correspond à la valeur de la colonne "Component_" de l’étape précédente. On supprime les autres lignes en effectuant un clique droit sur chaque ligne puis “Drop Row”.

    La seule ligne restante devrait se présenter de cette façon:

  4. Dans la partie “FeatureComponents”, il faut, de même, supprimer toutes les lignes exceptée la ligne contenant la valeur dans la colonne "Component_" correspondant à NativeCallee.dll (à l’étape 2).

    La seule ligne restante devrait se présenter de cette façon:

  5. Dans la partie “MsiAssembly”, il faut rajouter une ligne avec le contenu suivant:
    • Component_: indiquer la valeur de la colonne "Component_" de la ligne correspondant à NativeCallee.dll à l’étape 2.
    • Feature_: préciser la valeur: DefaultFeature
    • File_Manifest: indiquer la valeur correspondant à la colonne "File" dans la partie "File" de l’étape 2 pour la ligne correspondant au fichier NativeCallee.manifest.
    • File_Application: ne rien remplir.
    • Attribute: préciser la valeur 1.

    La ligne devrait se présenter de cette façon:

  6. Dans la partie “MsiAssemblyName”, il faut ajouter 5 lignes (en effectuant un clique droit puis “Add Row”) correspondant aux informations obligatoires se trouvant dans le fichier manifest NativeCallee.manifest (name, type, version, processorAttribute et publicKeyToken):
    • La colonne "Component_" de chaque ligne doit contenir la valeur de la colonne "Component_" de la ligne correspondant à NativeCallee.dll à l’étape 2.
    • La colonne "Name" contient le nom de chaque valeur provenant du manifest.
    • La colonne "Value" contient la valeur des informations du manifest.

    Les lignes devraient se présenter de cette façon:

  7. Il faut enregistrer le fichier .MSI en cliquant sur “File” puis “Save”.

Le fichier d’installation .MSI permet de rajouter l’assembly dans le répertoire WinSxS, il suffit de l’exécuter.

Après installation, on peut voir le fichier dans le répertoire WinSxS avec un chemin du type:

C:\Windows\winsxs\x86_nativecallee_3ce09db04e4b62f1_1.0.0.0_none_3f9f7b80072bd670\NativeCallee.dll 

Utiliser l’assembly native dans WinSxS

Pour utiliser l’assembly se trouvant dans le répertoire WinSxS, il faut créer un fichier manifest pour l’exécutable indiquant comment charger l’assembly side-by-side partagée NativeCallee.dll.

Dans un premier temps, on récupère la solution NativeCallee.sln utilisée pour générer les différents projets précédemment:

  1. Pour générer un fichier manifest pour l’exécutable NativeCaller.exe, on effectue un clique droit sur le projet NativeCaller puis on clique sur “Propriétés”.
  2. Dans la partie “Outil Manifeste” (“Manifest Tool“) puis “Entrée et sortie” (“Input and Output“), pour le paramètre “Incorporer le manifeste” (“Embed Manifest“), on sélectionne la valeur “Non”.

Avec ce paramètre, le fichier manifest NativeCaller.exe.manifest sera généré dans le répertoire de sortie.

On édite ce fichier pour rajouter les éléments suivants:

<?xml version="1.0" encoding="UTF-8" standalone="yes"?> 
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0"> 
  <dependency> 
    <dependentAssembly> 
      <assemblyIdentity type="win32"  
                        name="NativeCallee"  
                        version="1.0.0.0"  
                        processorArchitecture="x86"  
                        publicKeyToken="3ce09db04e4b62f1"  
                        language="*" 
      /> 
    </dependentAssembly> 
  </dependency> 
  <trustInfo xmlns="urn:schemas-microsoft-com:asm.v3"> 
    <security> 
      <requestedPrivileges> 
        <requestedExecutionLevel level="asInvoker" uiAccess="false"></requestedExecutionLevel>
      </requestedPrivileges> 
    </security> 
  </trustInfo> 
</assembly> 

On précise les éléments qui définissent l’identité de l’assembly native contenant NativeCallee.dll.

On place ensuite les fichiers suivants dans un répertoire séparé:

  • NativeCaller.exe
  • NativeCaller.exe.manifest
  • CallerRedirection.dll
  • NativeCallee.dll

Si on lance l’exécution de NativeCaller.exe, le résultat devrait être le même qu’au début:

Calling other lib: 
Text to display 

Analyser les dépendances avec DependencyWalker

Si on ouvre le fichier NativeCaller.exe avec DependencyWalker, on ne remarque rien de particulier, toutes les dépendances sont présentes:

Si on supprime la DLL NativeCallee.dll, en ouvrant de nouveau NativeCaller.exe avec DependencyWalker, on voit que la dépendance NativeCallee.dll est manquante:

Toutefois si on execute NativeCaller.exe, on a le même résultat que précédemment. La DLL NativeCallee.dll est bien chargée.

Pour s’en convaincre, on peut utiliser la fonctionnalité de profiling de DependencyWalker en cliquant sur “Profile” puis “Start Profiling…” Puis “OK”. Cette fonctionnalité permet de profiler l’exécution pour vérifier comment les dépendances sont chargées. Si on appuie sur [F9] pour afficher les chemins des fichiers, on peut voir que NativeCallee.dll provient de WinSxS:

Sxstrace

Il est possible de débugger le chargement d’assemblies dans le répertoire WinSxS en utilisant l’outil sxstrace. Cet utilitaire se trouve dans un répertoire du type:

C:\Windows\System32\sxstrace.exe. 

Cet outil va permettre de logguer les différentes étapes de chargement du manifest, il est ainsi possible de voir les erreurs éventuelles qui peuvent se produire lors de ce chargement.

Pour lancer la capture de traces avec sxstrace, il faut exécuter la commande:

sxstrace trace –logfile:<chemin du fichier à générer> 

Pendant que sxstrace est en cours de capture, on peut lancer l’exécutable pour scruter les chargements des DLL à partir de WinSxS. Pour arrêter la capture, il suffit d’appuyer sur [Entrée].

Le fichier généré est un fichier binaire. Pour lire le contenu de ce fichier dans un fichier texte, il faut exécuter la commande:

sxstrace parse –logfile:<chemin du fichier capturé> -outfile:<chemin du fichier texte> 

Par exemple dans l’exemple de l’exécutable NativeCaller.exe, on peut lire les informations concernant la DLL NativeCallee.dll:

Début de la génération du contexte d’activation. 
Paramètre d’entrée : 
    Flags = 0 
    ProcessorArchitecture = x86 
    CultureFallBacks = fr-FR;fr 
    ManifestPath = C:\Windows\WinSxS\x86_nativecallee_3ce09db04e4b62f1_1.0.0.0_none_3f9f7b80072bd670\NativeCallee.dll 
    AssemblyDirectory = C:\Windows\WinSxS\x86_nativecallee_3ce09db04e4b62f1_1.0.0.0_none_3f9f7b80072bd670\ 
    Application Config File =  
----------------- 
Information : analyse du fichier manifeste C:\Windows\WinSxS\x86_nativecallee_3ce09db04e4b62f1_1.0.0.0_none_3f9f7b80072bd670\NativeCallee.dll. 
    Information : l’identité de la définition du manifeste est (null). 
Information : réussite de la génération du contexte d’activation. 
Fin de la génération du contexte d’activation. 

Si on modifie dans le fichier manifest NativeCaller.exe.manifest la version de la DLL NativeCallee.dll de façon à faire échouer le chargement:

<?xml version="1.0" encoding="UTF-8" standalone="yes"?> 
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0"> 
  <dependency> 
    <dependentAssembly> 
      <assemblyIdentity type="win32"  
                        name="NativeCallee"  
                        version="2.0.0.0"
                        processorArchitecture="x86"  
                        publicKeyToken="3ce09db04e4b62f1"  
                        language="*" 
      /> 
    </dependentAssembly> 
  </dependency> 
  <!-- ... --/> 
</assembly> 

On peut observer les différentes tentatives de recherche de la DLL:

Information : analyse du fichier manifeste C:\TEST\NativeCaller.exe.Manifest. 
    Information : l’identité de la définition du manifeste est (null). 
    Information : référence : NativeCallee,language="*",processorArchitecture="x86",publicKeyToken="3ce09db04e4b62f1",type="win32",version="2.0.0.0" 
Information : résolution de la référence NativeCallee,language="*",processorArchitecture="x86",publicKeyToken="3ce09db04e4b62f1",type="win32",version="2.0.0.0". 
    Information : résolution de la référence pour l’architecture ProcessorArchitecture x86. 
        Information : résolution de la référence pour la culture fr-FR. 
            Information : application de la stratégie de liaison. 
                Information : aucune stratégie de serveur de publication trouvée. 
                Information : aucune redirection de la stratégie de liaison trouvée. 
            Information : début de la recherche d’assemblys. 
                Information : impossible de trouver l’assembly dans WinSxS. 
                Information : tentative de recherche du manifeste sur C:\Windows\assembly\GAC_32\NativeCallee\2.0.0.0_fr-FR_3ce09db04e4b62f1\NativeCallee.DLL. 
                Information : manifeste pour la culture fr-FR introuvable. 
            Information : fin de la recherche d’assemblys. 
        Information : résolution de la référence pour la culture fr. 
            Information : application de la stratégie de liaison. 
                Information : aucune stratégie de serveur de publication trouvée. 
                Information : aucune redirection de la stratégie de liaison trouvée. 
            Information : début de la recherche d’assemblys. 
                Information : impossible de trouver l’assembly dans WinSxS. 
                Information : tentative de recherche du manifeste sur C:\Windows\assembly\GAC_32\NativeCallee\2.0.0.0_fr_3ce09db04e4b62f1\NativeCallee.DLL. 
                Information : manifeste pour la culture fr introuvable. 
            Information : fin de la recherche d’assemblys. 
        Information : résolution de la référence pour la culture Neutral. 
            Information : application de la stratégie de liaison. 
                Information : aucune stratégie de serveur de publication trouvée. 
                Information : aucune redirection de la stratégie de liaison trouvée. 
            Information : début de la recherche d’assemblys. 
                Information : impossible de trouver l’assembly dans WinSxS. 
                Information : tentative de recherche du manifeste sur C:\Windows\assembly\GAC_32\NativeCallee\2.0.0.0__3ce09db04e4b62f1\NativeCallee.DLL. 
                Information : tentative de recherche du manifeste sur C:\TEST\NativeCallee.DLL. 
                Information : tentative de recherche du manifeste sur C:\TEST\NativeCallee.MANIFEST. 
                Information : tentative de recherche du manifeste sur C:\TEST\NativeCallee\NativeCallee.DLL. 
                Information : tentative de recherche du manifeste sur C:\TEST\NativeCallee\NativeCallee.MANIFEST. 
                Information : manifeste pour la culture Neutral introuvable. 
            Information : fin de la recherche d’assemblys. 
    Erreur : impossible de résoudre la référence NativeCallee,language="*",processorArchitecture="x86",publicKeyToken="3ce09db04e4b62f1",type="win32",version="2.0.0.0". 
Erreur : échec de la génération du contexte d’activation. 
Fin de la génération du contexte d’activation. 

Pour résumer

En résumé, le répertoire WinSxS est apparu dans Windows XP pour permettre d’enregistrer des dépendances au niveau du système d’exploitation. Ainsi quand une application possède une dépendance enregistrée dans WinSxS, elle est moins facilement déstabilisée si la mise à jour d’une autre application modifie cette dépendance. WinSxS permet d’éviter les problèmes liés au DLL Hell.

On peut enregistrer une dépendance sous forme d’assembly native dans le répertoire WinSxS en utilisant un fichier d’installation ou par programmation. Dans le cas où l’assembly native est une DLL, il faut que la DLL possède un fichier manifest pour indiquer des éléments définissant son identité et l’assembly doit être signée en utilisant un certificat avec une clé de longueur minimum 2048 pour garantir son unicité.

L’enregistrement dans le répertoire WinSxS est fastidieux et de plus en plus souvent, les applications évitent ce type de déploiement en privilégiant l’ajout des dépendances dans le même repertoire que l’exécutable. Cette méthode permet d’isoler les dépendances et de garantir la stabilité de l’application au détriment d’une utilisation de plus de mémoire.

Références

Manifest:

Certificat:

Installateur:

DependencyWalker:

Enregistrement DLL:

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

Appeler des DLL natives à partir de .NET

Parfois quand on veut effectuer un traitement particulier, utiliser un middleware propriétaire ou simplement utiliser une bibliothèque tiers, on ne dispose pas forcément d’assembly .NET permettant d’effectuer ce traitement et il peut être nécessaire d’effectuer des appels à une bibliothèque codée en C++. Cette bibliothèque native peut être livrée sous forme d’une bibliothèque statique (fichier .lib) ou d’une bibliothèque dynamique (fichier .dll). Effectuer ce type d’intéropérabilité n’est pas forcément trivial pour beaucoup de développeurs. Le but de cet article est ainsi d’expliquer 2 techniques pour appeler du code se trouvant dans des bibliothèques C++ à partir de code .NET. Les 2 techniques que l’on va expliciter ne sont pas strictement équivalentes:

  • Platform/Invoke: cette technique permet d’appeler des bibliothèques dynamiques assez facilement à condition que la DLL expose les méthodes qu’on souhaite appeler. Dans le cas où on ne maitrise pas l’implémentation de la bibliothèque et qu’on n’est pas en mesure d’exposer les méthodes à appeler, cette méthode peut être impossible à mettre en œuvre.
  • C++/CLI (CLI pour Common Language Infrastructure): il s’agit d’un langage capable de générer des assemblies contenant du code managé et du code natif. Le gros intérêt de ce langage est de pouvoir être appeler par du code managé et de pouvoir appeler du code natif. Il fait ainsi office de wrapper pour s’interfacer avec une bibliothèque statique ou une bibliothèque dynamique codée en C++.
@markusspiske

D’abord, on va apporter quelques explications en préambule pour aider à comprendre le reste de l’article. Ensuite, on va expliciter ces 2 techniques avec un exemple simple de code .NET appelant du code C++.

Quelques explications en préambule

Avant de rentrer dans le détail du code, quelques explications peuvent aider à la compréhension d’éléments d’implémentation dans le code de cet article. Si vous êtes familier avec ces notions, passer directement à la partie suivante.

Bibliothèque statique vs bibliothèque dynamique

On avait eu l’occasion d’expliciter les différences principales entre une bibliothèque dynamique et une bibliothèque statique dans un article précédent.

Encodage

Un problème récurrent qui peut subvenir lorsqu’on manipule des chaînes de caractères en C++ concerne l’encodage des caractères de la chaîne. Voici quelques indications générales sur l’encodage (le terme “code point” correspond au code utilisé pour encoder un caractère) :

  • ASCII (American Standard Code for Information Interchange): chaque caractère ASCII nécessite 1 octet (i.e. byte) en mémoire. 1 octet permet d’encoder 256 code points car 1 octet correspond à 8 bits soit 28=256 possibilités. Toutefois les 256 code points ne sont pas tous utilisés en ASCII, seulement les 128 premiers code points. Ces 128 premiers code points correspondent aux caractères usuels de la langue anglaise (sans accents) et quelques caractères spéciaux (cf. ASCII Table).
  • Codepage: l’ASCII ne tient pas compte des spécificités régionales. Par exemple, il n’y a aucun caractère spécial pour le français (comme é, à, ç, è, etc…). Pour prendre en compte les caractères correspondant aux spécificités régionales, la 2e moitié des 128 code points sont utilisés sur les 256 possibilités de l’ASCII. Cet encodage nécessite donc toujours 1 octet. Chaque encodage est très spécifique à la région, il faut donc autant d’encodages “codepage” que de langues ou de régions. D’autre part, pour pouvoir lire un fichier écrit avec un encodage particulier, il faut connaître cet encodage au préalable pour pouvoir le lire.
  • ANSI (American National Standards Institute): cet encodage correspond à la codepage 1252 ou “Latin-1 Windows”. Il est composé des caractères de l’ASCII sur les 128 premiers code points puis des caractères usuels pour les langues d’Europe de l’Ouest pour les 128 code points suivants (cf. ANSI table).
  • USC-2: cet encodage correspond à une extension de l’ASCII pour prendre en compte plus de code points. Il nécessite 2 octets par code points et permet d’encoder 65535 caractères.
  • UTF-16 (“Unicode”): cet encodage correspond à une volonté de rendre les encodages universels c’est-à-dire pour qu’un encodage permette de prendre en compte la plupart des caractères. Dans un premier temps, il nécessitait 2 octets et était identique à l’USC-2. Il n’est pas compatible avec l’ASCII c’est-à-dire que des programmes ne lisant que l’ASCII ne pourront pas lire un fichier codé en UTF-16 toutefois les 128 premiers code points sont identiques à l’ASCII si on ne considère qu’un seul octet. Assez vite UTF-16 s’est révélé insuffisant pour encoder tous les caractères, on a donc décidé de rajouter une paire de 2 octets pour avoir 4 octets. Chaque code points est donc formé de 2 mots de 16 bits chacun. Le code point de chaque caractère peut alors être formé sur 2 ou 4 octets soit un ou 2 mots. Malheureusement cet encodage ne fixe pas d’ordre pour ces 2 mots, il faut donc convenir de l’ordre des mots pour savoir quel est le mot de poids fort et celui de poids faible.
    • Big Endian (BE): cet encodage correspond à l’octet de poids fort en premier.
    • Little Endian (LE): cet encodage correspond à l’octet de poids faible en premier.
  • UTF-32: cet encodage nécessite 4 octets de façon fixe. UTF-32 est une implémentation de USC-4. Pas de nécessité de convenir de l’ordre des octets comme UTF-16 car l’encodage fixe un ordre précis. Cet encodage est rarement utilisé car très volumineux.
  • UTF-8: cet encodage est aussi universel et chaque caractère possède un code point particulier. Contrairement à UTF-32, les code points ne nécessitent pas un nombre fixe d’octets. Les code points peuvent nécessiter 1, 2, 3 ou 4 octets. Il est compatible avec l’ASCII c’est-à-dire que si un texte est encodé en UTF-8 et ne comporte que des caractères ASCII, les code points seront les mêmes que si le texte était encodé en ASCII (cf. Unicode Character Code Charts).

UTF-8 est beaucoup utilisé pour le web à cause du nombre d’octets variable ce qui permet d’économiser de la mémoire pour les langues occidentales. Toutefois il est défavorable par rapport à l’UTF-16 pour les langues asiatiques.

UTF-8 est utilisé par les systèmes d’exploitation Linux et UTF-16 est utilisé pour Windows.

Pour plus de détails sur Unicode, voir Unicode en 5 min.

char vs wchar_t

En C/C++, on peut stocker des chaînes de caractères dans des tableaux d’objets de type char. La chaîne est alors accessible par un objet de type char* pointant vers ce tableau. L’encodage des caractères de cette liste peuvent varier en fonction du système d’exploitation, par exemple:

  • Sur un système Linux qui gère les chaînes de caractères nativement en utilisant UTF-8, char* permet de stocker des caractères codés en UTF-8.
  • Sur Windows, char* permet de stocker des chaînes avec des caractères encodés en ASCII avec des spécificités régionales correspondant à celles indiquées dans les paramètres du système d’exploitation.

Dans le même sens, wchar_t permet de stocker des caractères nécessitant un espace de stockage “large” (i.e. wide). Il n’y a pas de définition universelle de ce qu’est un espace de stockage “large”. Cet espace dépend du compilateur et du système d’exploitation. Par exemple:

  • Sur un système Linux, wchar_t permet de stocker des caractères encodés en UTF-32 sur 4 octets.
  • Sur Windows, wchar_t permet de stocker des caractères encodés en USC-2 ou UTF-16.

Dans le code, l’initialisation de ces chaînes peut varier en fonction de l’encodage utilisé. Ainsi sur Windows:

  • Pour une chaîne contenant des caractères ASCII ou ANSI:
    const char *s0 = "hello";
  • Pour une chaîne contenant des caractères UTF-8:
    const char *s1 = u8"hello";
  • Pour une chaîne contenant des caractères UTF-16:
    const wchat_t *s2 = u"hello";
  • Pour une chaîne contenant des caractères UTF-32:
    const wchat_t *s3 = U"hello";
  • Pour une chaîne contenant des caractères “larges” c’est-à-dire USC-2 ou UTF-16:
    const wchar_t *s4 = L"hello";

Pour plus d’informations, voir String and Character Literals (C++).

Installation Visual Studio

Pour réaliser les différentes parties de cet article, il faut s’assurer que le support C++ est bien
installé dans Visual Studio. En exécutant l’installateur de Visual Studio 2017, il faut que “Développement Desktop en C++” et que “Prise en charge C++/CLI” soient cochés:

Platform/Invoke

Comme indiqué en introduction, P/Invoke (i.e. Platform Invoke) est une technique qui permet d’appeler du code non managé à partir de code managé. L’utilisation de P/Invoke convient lorsque:

  • du code non managé existe,
  • qu’il est trop couteux de le migrer,si on doit utiliser une API non managé et qu’il n’existe pas de version .NET équivalent de cette API,
  • si on doit faire appel à des fonctions de l’API Win32.

Le gros inconvénient de P/Invoke est qu’il faut parfois adapter le code à appeler pour utiliser cette technique. Ensuite, les interfaces entre le code managé et le code non managé doivent être définis et implémentées avec soin de façon à éviter les erreurs mais aussi de mauvaises performances
lors de la “conversion” des objets managés en objets non managés et inversement.

En effet, lorsqu’on utilise P/Invoke pour appeler du code non managé, il faut être rigoureux dans le choix du type des objets et dans la façon dont ils sont passés en paramètre. Pour effectuer ces choix, il faut avoir en tête:

  • le fonctionnement du code de “conversion” managé vers non managé suivant le type d’objet,
  • certains mécanismes comme celui du Garbage Collector ou
  • les conventions d’appels de fonctions.

Effectuer un appel P/Invoke

Pour appeler le code se trouvant dans une bibliothèque dynamique il faut utiliser l’attribut DllImport. Toutefois pour que cette méthode soit utilisable, il faut que les fonctions appelées dans la bibliothèque native soit exposée. En effet comme on l’a indiqué en préambule, une bibliothèque dynamique contient du code machine qui peut être directement chargé et exécuté en mémoire. Toutefois les DLL sur Windows n’exposent pas par défaut le code qu’elles contiennent. Ça veut dire que si on souhaite utiliser une DLL et qu’on fait référence à une fonction dans cette DLL, par défaut on ne peut pas connaître l’adresse à laquelle se trouve le code machine correspondant à la fonction dans la DLL.

Pour indiquer l’adresse à laquelle se trouve le code correspondant à une fonction dans une DLL, il faut l’exposer explicitement. Dans un précédent article, on a indiqué une méthode pour permettre d’exposer du code se trouvant dans une bibliothèque dynamique (cf. Référencer une DLL C++ avec une bibliothèque statique d’import).

La technique Platform/Invoke consiste à:

  • déclarer dans le code C# la signature d’une fonction à appeler dans une DLL et qui est exposée.
  • ajouter dans la déclaration de cette fonction l’attribut DllImport avec le nom de la DLL pour que le compilateur C# puisse implémenter l’appel P/Invoke.
  • indiquer des éléments qui peuvent aider le CLR à effectuer l’appel dans la DLL comme la convention d’appels, le nom de la méthode si celui-ci diffère de celui de la signature.
  • indiquer dans la signature des indications pour aider à effectuer les “conversions” de type des paramètres en entrée ou en sortie de la fonction.

Pendant l’exécution et lorsque la fonction est appelée dans le code C#, le CLR va utiliser les indications de la déclaration avec l’attribut DllImport pour indiquer au runtime C++ comment effectuer l’appel au code dans la DLL. Le runtime va charger la DLL en mémoire et exécuter le code correspondant. Le passage des objets en paramètres de la fonction peut se faire de différentes façons suivant le type de l’objet:

  • l’objet peut être “marshallé” c’est-a-dire qu’une conversion est faite entre le code managé exécuté par le CLR et le code non managé exécuté par le runtime C++. Cette conversion essaie de copier le contenu de l’objet de façon à ce qu’il soit le plus fidèle au contenu d’origine. D’autre part, la conversion implique que 2 versions de l’objet sont présents en mémoire: une version dans le code managé et une version dans le code non managé.
  • quand le type le permet, les objets peuvent ne pas être “marshallé” et la même instance de l’objet en mémoire peut être utilisée à la fois dans le code managé et dans le code non managé. Sachant que l’appel se fait à partir du code managé, c’est le CLR qui instancie l’objet en mémoire. Pour éviter que l’objet ne soit déplacé par le Garbage Collector pendant l’appel au code non managé, il est épinglé en mémoire (i.e. le terme utilisé est pinned). Quand l’appel est effectué c’est le pointeur qui est transmis au code non managé. Quand l’appel est terminé, l’objet n’est plus épinglé pour que le Garbage Collector puisse éventuellement le collecter. Pendant l’appel, le code non managé est capable de modifier l’objet ce qui peut éventuellement mener à des erreurs s’il supprime l’objet par exemple.

Pour davantage d’information concernant P/Invoke voir l’article Platform invoke en 5 min.

Exemple d’appel

L’exemple présenté permet d’appeler du code se trouvant dans une bibliothèque dynamique appelée NativeCallee. Cet appel se fait à partir d’une assembly .NET appelée NativeCodeCaller.
Les appels se feront de cette façon: NativeCodeCaller ⇒ NativeCallee

Création de la bibliothèque dynamique NativeCallee

On va créer un projet C++ vide en cliquant sur les éléments suivants dans Visual Studio 2017:

  1. Créer un projet C++ vide en cliquant sur “Nouveau” ⇒ “Projet” ⇒ Dans “Visual C++”, cliquer sur “Projet vide” (“Empty project“).
    Il faut nommer le projet NativeCallee.
  2. Ajouter un classe en effectuant un clique droit sur le projet puis cliquer sur “Ajouter…” et enfin cliquer sur “Classe”.
    Il faut nommer la classe NativeCallee.
  3. Implémentation de NativeCallee.h: insérer ce code dans le fichier NativeCallee.h:
    #ifndef NATIVECALLEE_H 
    #define NATIVECALLEE_H 
    
    #pragma once 
    
    #ifdef NATIVECALLEE_EXPORTS 
    #define NATIVECALLEE_API __declspec(dllexport) 
    #else 
    #define NATIVECALLEE_API __declspec(dllimport) 
    #endif 
    
    extern "C" NATIVECALLEE_API void DisplayTextWithCallee(const wchar_t *textToDisplay);
    
    class NativeCallee
    {
    private:
    	wchar_t *textToDisplay;
    
    public:
    	NativeCallee(wchar_t *textToDisplay);
    	~NativeCallee();
    
    	void DisplayText();
    };
    
    #endif /* NATIVECALLEE_H */ 
    
  4. Implémentation de NativeCallee.cpp: insérer ce code dans le fichier NativeCallee.cpp:
    #include "NativeCallee.h"
    
    #include <wchar.h> 
    
    void DisplayTextWithCallee(const wchar_t *textToDisplay)
    {
    	NativeCallee *callee = new NativeCallee(const_cast<wchar_t*>(textToDisplay));
    	callee->DisplayText();
    	delete callee;
    }
    
    NativeCallee::NativeCallee(wchar_t *textToDisplay)
    {
    	this->textToDisplay = textToDisplay;
    }
    
    NativeCallee::~NativeCallee()
    {
    }
    
    void NativeCallee::DisplayText()
    {
    	wprintf(L"Displaying from unmanaged code: %s\n", this->textToDisplay);
    }
    

    Ce code permet:

    • d’instancier une classe avec une chaîne de caractères,
    • d’afficher cette chaîne de caractères avec la méthode NativeCallee::DisplayText(),
    • d’exposer la méthode DisplayTextWithCallee() dans la DLL avec la directive préprocesseur NATIVECALLEE_EXPORTS.

    Pour plus de détails, voir l’article Référencer une DLL C++ avec une bibliothèque statique d’import.

  5. Configurer les propriétés du projet NativeCallee:
    Effectuer un clique droit sur le projet NativeCallee puis cliquer sur “Propriétés”:

    • Dans la partie “Général”, sélectionner les éléments suivants:
      • “Type de configuration” (“Configuration type“): “Bibliothèque dynamique (.dll)” (“Dynamic Library“).
      • “Jeu de caractères” (“Character set“): “Utiliser le jeu de caractères Unicode” (“Use Unicode Character set“).
      • “Prise en charge du Common Language Runtime” (“Common Language Runtime Support“): “Pas de prise en charge du Common Language” (“No Common Language Runtime Support“).
    • Dans la partie “C/C++” ⇒ “Préprocesseur”, on indique la valeur suivante pour le paramètre “Définitions de préprocesseur” (“Preprocessor definitions
      “):

      NATIVECALLEE_EXPORTS 
      
    • Dans la partie “C/C++” ⇒ “Avancé”, on indique la convention d’appels (“call convention“) “__stdcall (/Gz)”

    Ne pas oublier de valider en cliquant sur “Appliquer”.

On doit être capable de compiler le projet. Dans le répertoire Debug, le fichier NativeCallee.dll devrait être présent.

La fonction exportée doit être visible en inspectant la DLL NativeCallee.dll avec DependancyWalker.

“DependancyWalker” peut être téléchargé sur www.dependencywalker.com/.

Création de l’exécutable NativeCodeCaller

On va créer l’exécutable .NET qui va effectuer l’appel vers la DLL native.

  1. Créer une application Console .NET en effectuant un clique droit sur la solution ⇒ cliquer sur “Ajouter” ⇒ “Nouveau projet” ⇒ Dans la partie “Visual C#”, sélectionner “Application Console” (“.NET Framework”).

    Il faut nommer le projet NativeCodeCaller.

  2. Dans le fichier Program.cs, (contenant le main de l’application), il faut indiquer l’implémentation suivante:
    using System;
    using System.Runtime.InteropServices;
    
    namespace NativeCodeCaller
    {
    	class Program
    	{
    		static void Main(string[] args)
    		{
    			string textToDisplay = "text to display";
    
    			Console.WriteLine($"Displaying from managed code: {textToDisplay}");
    			DllImportExample.DisplayTextWithCallee(textToDisplay);
    
    			Console.ReadLine();
    		}
    	}
    
    	public class DllImportExample
    	{
    		[DllImport("NativeCallee.dll", CallingConvention = CallingConvention.StdCall, 
    			CharSet = CharSet.Unicode)]
    		public extern static void DisplayTextWithCallee(string textToDisplay);
    	}
    }
    

Si on compile à ce stade, la compilation va réussir mais la DLL NativeCallee.dll ne se trouve pas dans le répertoire de sortie de l’exécutable donc l’exécution va échouer car NativeCallee.dll se sera pas trouvée.

On va donc copier NativeCallee.dll dans le répertoire de sortie de l’exécutable NativeCodeCaller:

  1. Il faut changer les dépendances des projets en effectuant un clique droit sur la solution puis en cliquant sur “Dépendances du projet…” (“Project dependencies“); sélectionner “NativeCodeCaller” et cocher NativeCallee.
    Cliquer sur OK pour valider.
  2. On rajoute un évènement post-build à l’exécutable pour effectuer la copie:
    • Accèder aux propriétés du projet NativeCodeCaller en effectuant un clique droit sur le projet
      Dans l’onglet “Evènements de build” (“Build events“) et dans la partie “Ligne de commande de l’évènement post-build” (“Post-build event command line“), ajouter la ligne suivante:

      xcopy $(SolutionDir)$(ConfigurationName)\NativeCallee.dll $(TargetDir) /Y
      
    • Puis sélectionner pour le paramètre “Exécuter l’évènement post-build” (“Run the post-build event“): “Toujours” (“Always“).
  3. Recompiler la solution en cliquant sur “Régénérer la solution” (“Rebuild solution“). Après compilation, la DLL NativeCallee.dll doit se trouver dans le répertoire de sortie de l’exécutable NativeCodeCaller.

Pour exécuter, il faut indiquer que le projet de démarrage est NativeCodeCaller en effectuant un clique droit sur ce projet puis en cliquant sur “Définir comme projet de démarrage” (“Set as Startup project“).

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

Displaying from managed code: text to display
Displaying from unmanaged code: text to Display

Chaîne de caractères contenant des caractères Unicode

Si on modifie la chaîne de caractères à afficher en utilisant quelques caractères spéciaux, par exemple en modifiant la chaîne de caractères dans le fichier Program.cs du projet NativeCodeCaller:

string textToDisplay = "text to display éèà";

Si on regénère la solution et si on relance l’exécution, on constate que l’affichage n’est pas le même en C++:

Displaying from managed code: text to display éèà
Displaying from unmanaged code: text to display ÚÞÓ

Malgré l’utilisation dans l’attribut DllImport du paramètre CharSet.Unicode, les caractères spéciaux ne sont pas visibles en C++:

[DllImport("NativeCallee.dll", CallingConvention = CallingConvention.StdCall, 
	CharSet = CharSet.Unicode)]

Dans le code C#, les chaînes de caractères sont encodées en Unicode. Lorsque la chaîne est passée au code C++, elle est convertie en chaîne dont les caractères sont de type wchar_t. En Windows, les caractères de ce type sont aussi encodés en Unicode alors pourquoi la chaîne n’est pas affichée correctement ? Le problème provient de la fonction wprintf car, par défaut, elle considère que le flux de sortie est en mode ANSI. Ainsi les caractères suivant le code point 128 dans l’encodage ANSI correspondent aux caractères au codepage “Latin-1 Windows” qui sont différents des caractères Unicode.

Une solution est de changer le mode du flux de sortie pour qu’il affiche les caractères en Unicode. On peut effectuer ce changement en modifiant le code de la fonction NativeCallee::DisplayText() dans le projet NativeCallee:

#include <wchar.h> 
#include <iostream>
#include <Windows.h>
#include <io.h>
#include <fcntl.h>
// ...

void NativeCallee::DisplayText()
{
	_setmode(_fileno(stdout), _O_U16TEXT);
	wprintf(L"Displaying from unmanaged code: %s\n", this->textToDisplay);
}

En recompilant et en exécutant, l’affichage est de nouveau correct:

Displaying from managed code: text to display éèà
Displaying from unmanaged code: text to display éèà

Retour de chaînes de caractères

Les retours d’appels P/Invoke sous forme de chaînes de caractères nécessitent quelques précautions car ils peuvent faire l’objet de fuites mémoires. Voici 2 possibilités pour récupérer un résultat sous forme de chaîne de caractères:

  • 1ère possibilité: retourner directement une chaîne de caractères après l’appel.
    Cette méthode n’est pas très sûre car elle nécessite de créer la chaîne de caractères dans le code non managée. L’objet sera donc créé en mémoire dans le tas. A la fin de l’appel, pour que l’objet puisse être converti dans une chaîne .NET il ne faut pas le détruire ce qui entraîne qu’il n’est jamais libéré du tas d’où la fuite mémoire.
  • 2e possibilité: créer la chaîne à retourner en C#.
    On crée l’objet dans le tas managé en C# sous forme de chaîne de caractères de type char* avec le paramètre unsafe pour qu’elle soit accessible sous forme d’un tableau de caractères; on épingle cet objet le temps de l’appel pour qu’il ne soit pas déplacé par le Garbage Collector. On effectue l’appel P/Invoke en utilisant le pointeur de la chaîne. Cette chaîne ne sera pas “marshallée” et sera utilisée de la sorte par le code non managé. Le code C++ peut ainsi écrire dans la chaîne pour indiquer une valeur en retour.
    Le code C# peut ainsi récupérer la valeur de la chaîne en utilisant le pointeur puis ensuite libérer lui-même la chaîne, évitant ainsi une fuite mémoire.

Les gros inconvénients de la 2e méthode sont:

  • qu’il faut connaître la longueur de la chaîne pour pouvoir la créer en C# au préalable.
  • qu’il faut exécuter du code “unsafe” en C# à cause de l’utilisation de pointeurs.

Pour illustrer, on va compléter l’exemple en retournant une copie de la chaîne fournie en paramètre.

On ajoute la méthode suivante dans le fichier NativeCallee.cpp dans le projet NativeCallee:

void DisplayTextWithCalleeAndReturnCopy(const wchar_t *textToDisplay, 
	wchar_t *const returnedString)
{
	NativeCallee *callee = new NativeCallee(const_cast<wchar_t*>(textToDisplay));
	callee->DisplayText();
	delete callee;

	size_t stringLength = wcslen(textToDisplay);
	wcscpy_s(returnedString, stringLength + 1, textToDisplay);
}

Cette méthode effectue le même traitement que DisplayTextWithCallee() puis effectue une copie de la chaîne de caractère textToDisplay vers la chaîne returnedString qui a déjà été créée.

Il faut exposer cette méthode dans la DLL en ajoutant dans NativeCallee.h la déclaration:

extern "C" NATIVECALLEE_API void DisplayTextWithCalleeAndReturnCopy(const wchar_t *textToDisplay, wchar_t *const returnedString);

Dans le fichier Program.cs du projet NativeCodeCaller, on modifie la classe DllImportExample en ajoutant la déclaration suivante:

public class DllImportExample
{
	// ...
	[DllImport("NativeCallee.dll", CallingConvention = CallingConvention.StdCall, 
		CharSet = harSet.Unicode)]
	public extern static void DisplayTextWithCalleeAndReturnCopy(string textToDisplay, 
		IntPtr returnedString);
}

On modifie le main pour effectuer l’appel de cette façon:

static void Main(string[] args)
{
	string textToDisplay = "text to display éèà";
	Console.WriteLine($"Displaying from managed code: {textToDisplay}");

	string returnedString = string.Empty;

	unsafe
	{
		fixed (char* unmanagedString = new char[textToDisplay.Length + 1])
		{
			IntPtr unmanagedStringPtr = (IntPtr)unmanagedString;
			DllImportExample.DisplayTextWithCalleeAndReturnCopy(textToDisplay, 
				unmanagedStringPtr);
			returnedString = Marshal.PtrToStringUni(unmanagedStringPtr);
		}
	}

	Console.WriteLine($"Returned string: {returnedString}");
	Console.ReadLine();
}

Dans le code d’appel:

  • On utilise un bloc de code “unsafe” car on effectue des manipulations de pointeurs.
  • On crée ensuite une chaîne de caractères avec une longueur correspondant à la taille de la chaîne d’origine avec le caractère de terminaison.
  • On utilise le mot-clé fixed pour fixer la chaîne allouée sur le tas managé pour éviter qu’elle ne soit déplacée par le Garbage Collector.
  • On récupère le pointeur de la chaîne de caractères.
  • On effectue l’appel P/Invoke.
  • A partir du pointeur, on effectue une conversion vers une nouvelle chaîne managée.

Pour que ce code compile, il faut autoriser l’utilisation de blocs “unsafe” dans les propriétés du projet NativeCodeCaller:
Effectuer un clique droit sur le projet NativeCodeCaller puis cliquer sur “Propriétés”; dans l’onglet “Build”, il faut cocher “Autoriser les blocs de code unsafe” (“Allow unsafe code“).

Après compilation, le résultat de l’exécution est de type:

Displaying from managed code: text to display éèà
Displaying from unmanaged code: text to display éèà
Returned string: text to display éèà

On peut utiliser un bloc de code légèrement différent: au lieu d’allouer la chaîne de caractères sur le tas, on peut l’affecter sur la pile puisque la pile n’est pas géré par le Garbage Collector. L’objet est supprimé de la pile à la sortie de la fonction. On évite, ainsi d’avoir à épingler la chaîne avec fixed.

Pour allouer la chaîne sur la pile, on utilise le mot-clé stackalloc:

unsafe
{
	char* unmanagedString = stackalloc char[textToDisplay.Length + 1];
	IntPtr unmanagedStringPtr = (IntPtr)unmanagedString;
	DllImportExample.DisplayTextWithCalleeAndReturnCopy(textToDisplay, 
		unmanagedStringPtr);
	returnedString = Marshal.PtrToStringUni(unmanagedStringPtr);
}

Comme on peut le voir, pour utiliser cette méthode, il faut connaître la taille de la chaîne de caractères pour la créer au préalable ce qui peut être très contraignant.

Code source

Le code de cette partie se trouve dans la branche platform_invoke du repository GitHub github.com/msoft/CallUnmanagedDll.

C++/CLI

Le C++/CLI (CLI pour Common Language Infrastructure) est une technologie Microsoft qui permet de manipuler et d’appeler du code natif en C ou en C++ à partir de code managé .NET. Il s’agit d’un langage qui mélange C++ et technologie .NET.

Les principaux avantages de ce langage sont:

  • Permet d’encapsuler du code C++ natif de façon à éviter d’effectuer trop d’appels nécessitant du “marshalling”.
  • Quand des opérations de “marshalling” sont effectuées, la plupart du temps le langage le fait de façon implicite sans qu’il soit nécessaire de préciser des paramètres supplémentaires.
  • Le C++/CLI est plus flexible que la technologie P/Invoke car il est possible de consommer des bibliothèques statiques.
  • On peut débugguer le code C++/CLI et le code C++ qui est appelé.

Pour avoir davantage d’information sur le C++/CLI, voir l’article C++/CLI en 10 min.

Pour illustrer, on se propose de compléter l’exemple précédent en ajoutant une assembly mixte contenant du code C++/CLI. Cette assembly fera l’intermédiaire entre l’assembly managée et la DLL native. On appellera cette assembly MixedAssembly. Les appels se feront, ainsi, de cette façon:
NativeCodeCaller ⇒ MixedAssembly ⇒ NativeCallee.

Conversion de NativeCallee en bibliothèque statique

Dans un premier temps, on va convertir la bibliothèque dynamique NativeCallee en bibliothèque statique:

  1. Il faut accéder aux propriétés du projet en effectuant un clique droit sur le projet NativeCallee puis en cliquant sur “Propriétés”.
    Dans “Général”, il faut sélectionner le paramètre suivant:
    Type de configuration” (“Configuration Type“): “Bibliothèque statique (.lib)” (“Static Library“)
    Valider en cliquant sur OK.
  2. On va ensuite supprimer la copie de la DLL NativeCallee.dll dans le répertoire de sortie du projet NativeCodeCaller.
    Il faut accéder aux propriétés du projet NativeCodeCaller, dans l’onglet “Evènement de build”, il faut supprimer le contenu de “Ligne de commande de l’évènement post-build”.

Création de l’assembly mixte MixedAssembly

Pour créer une assembly mixte, il suffit de créer une bibliothèque de classes C++ classique:

  1. Créer un projet C++ vide en cliquant sur “Nouveau” ⇒ “Projet” ⇒ Dans “Visual C++”, cliquer sur “Projet vide” (“Empty project”).

    Il faut nommer le projet MixedAssembly.

  2. On ajoute une classe en effectuant un clique droit sur le projet MixedAssembly ⇒ Ajouter ⇒ class…
    On nomme la classe NativeCaller.
  3. On modifie les propriétés du projet pour générer une assembly mixte:
    En effectuant un clique droit sur le projet MixedAssembly puis en cliquant sur “Propriétés”, il faut indiquer les paramètres suivants:

    • Dans Général:
      Type de configuration“: “Bibliothèque dynamique (.dll)” (“Dynamic Library“).
      Jeu de caractères” (“Character set“): “Utiliser le jeu de caractères Unicode” (“Use Unicode Character set“).
      Prise en charge du Common Language Runtime“: “Prise en charge du Common Language Runtime (/clr)”
    • Dans C/C++ ⇒ Général :
      On indique le répertoire des fichiers .h du projet NativeCallee:
      Autres répertoires Include” (“Include directories“): ..\NativeCallee
    • Dans C/C++ ⇒ Avancé :
      On indique le paramètre:
      Convention d’appel” (“Call convention“): __stdcall (/Gz)
    • On indique la bibliothèque statique dans les dépendances en allant dans:
      Editeur de liens” (“Linker“) ⇒ “entrée” (“Input“): ..\Debug\NativeCallee.lib

    On valide en cliquant sur OK.

  4. On précise l’implémentation de la classe NativeCaller:
    Dans le fichier NativeCaller.h:

    #pragma managed
    using namespace System;
    
    namespace MixedAssembly
    {
    	public ref class NativeCaller
    	{
    	private:
    		String ^textToDisplay;
    
    	public:
    		NativeCaller(String ^textToDisplay);
    
    		void CallNativeCode();
    	};
    }
    

    Dans le fichier NativeCaller.cpp:

    #pragma managed
    
    #include "NativeCaller.h"
    #include "NativeCallee.h"
    
    #include <stdio.h>
    #include <stdlib.h>
    #include <vcclr.h>
    
    using namespace System::Runtime::InteropServices;
    
    MixedAssembly::NativeCaller::NativeCaller(String ^textToDisplay)
    {
    	this->textToDisplay = textToDisplay;
    }
    
    void MixedAssembly::NativeCaller::NativeCaller::CallNativeCode()
    {
    	pin_ptr<const wchar_t> convertedString = PtrToStringChars(this->textToDisplay);
    
    	NativeCallee *callee = new NativeCallee(const_cast<wchar_t*>(convertedString));
    	callee->DisplayText();
    	delete callee;
    }
    

    Cette implémentation permet de créer une classe managée NativeCaller. Dans la méthode NativeCaller::CallNativeCode(), on convertit la chaîne managée textToDisplay en chaîne de caractères native pour appeler la classe native NativeCallee.

  5. On ajoute la référence de MixedAssembly dans le projet NativeCodeCaller. Il s’agit d’une dépendance managée classique donc il suffit d’effectuer un clique droit sur “Références” du projet NativeCodeCaller puis “Ajouter une référence…” puis sélectionner “MixedAssembly”.
  6. On modifie l’implémentation du main dans Program.cs du projet NativeCodeCaller pour instancier et appeler la classe NativeCaller:
    using MixedAssembly;
    
    namespace NativeCodeCaller
    {
    	class Program
    	{
    		static void Main(string[] args)
    		{
    			string textToDisplay = "text to display éèà";
    
    			Console.WriteLine($"Displaying from managed code: {textToDisplay}");
    
    			NativeCaller nativeCaller = new NativeCaller(textToDisplay);
    			nativeCaller.CallNativeCode();
    
    			Console.ReadLine();
    		}
    	}
    }
    

    L’appel est classique puisqu’il s’agit d’un appel d’une classe managée.

Il suffit de compiler. L’exécution est similaire aux résultats obtenus plus haut:

Displaying from managed code: text to display éèà
Displaying from unmanaged code: text to display éèà

Avec l’assembly mixte, il devient, ainsi plus facile de consommer la dépendance native NativeCallee.lib et de même, il devient plus facile de référencer l’assembly mixte MixedAssembly à partir de l’assembly NativeCodeCaller.
D’autre part, comme la NativeCallee.lib est une bibliothèque statique, le code est inclus directement dans l’assembly MixedAssembly.dll.

Code source

Le code de cette partie se trouve dans la branche cpp_cli du repository GitHub github.com/msoft/CallUnmanagedDll.

Exception de type AccessViolationException

Les appels à du code natif en particulier en utilisant l’attribut DllImport peuvent occasionner des exceptions de type System.AccessViolationException dans le cas où une opération aurait pu mener à une corruption de la mémoire. Les exceptions de type System.AccessViolationException ne proviennent pas forcément du CLR, elles peuvent provenir du système d’exploitation qui constate qui l’exécution d’une instruction dans un processus peut corrompre la mémoire. Quand cette exception est lancée à l’extérieur du processus par le système d’exploitation, il peut être plus compliqué de l’intercepter.

Pour davantage de détails sur les exceptions de ce type, voir Gestion des “Corrupted State Exceptions” par le CLR).

Conclusion

Dans cet article, on a pu expliciter 2 méthodes pour effectuer de l’interopérabilité de .NET vers du code natif. Ces 2 méthodes peuvent être choisies suivant les cas d’utilisation auxquels on peut être confronté.

On a aussi eu l’occasion de parler de quelques problématiques qui peuvent subvenir comme l’encodage de chaînes de caractères, le passage de chaînes de caractères, le “marshalling”, le retour de chaînes de caractères et les exceptions qui peuvent subvenir. Il existe beaucoup d’autres problèmes pouvant résulter de ce type d’appel.

L’exécution des exemples présentés ne présente pas de difficultés sur un poste de développeur car si l’installation de l’environnement de développement est correctement faite, aucune dépendance liée au Runtime C++ ne sera manquante. Toutefois si on veut déployer ce type de solution sur des machines classiques, d’autres problématiques spécifiques au Runtime C++ peuvent subvenir. On aura l’occasion d’expliciter quelques unes de ces problématiques dans un prochain article.

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

Référencer une DLL C++ avec une bibliothèque statique d’import

Dans un projet en C++, quand on souhaite organiser le code dans différentes bibliothèques, on a le choix entre utiliser des bibliothèques statiques et dynamiques. Les bibliothèques statiques sont facilement intégrables dans un projet toutefois si on souhaite la mettre à jour, il faut recompiler tout le projet qui le consomme. Les bibliothèques dynamiques sont, quant à elles, moins facilement intégrables mais pour les mettre à jour, il suffit de les remplacer sans forcément avoir à recompiler le projet qui les utilise. C’est cet avantage qui rends en partie les bibliothèques dynamiques intéressantes en terme de déploiement.

Avec la technologie .NET, quand on souhaite intégrer une assembly managée à un projet, il suffit de l’ajouter en tant que référence de projet. On peut ainsi, d’une part, utiliser des objets de cette assembly et compiler son projet. D’autre part, en rendant disponible l’assembly au CLR, on peut de nouveau l’utiliser à l’exécution.

Les DLL natives sont moins facilement flexibles et nécessitent quelques aménagements de façon à être utilisées avec la même flexibilité que les assemblies .NET ou presque.

Le but de cet article est de montrer quelques techniques d’implémentation pour rendre les DLL natives intégrables dans un projet C++.

Dans un 1er temps, on définira quelques termes. Avant de référencer directement une DLL C++, on va introduire le sujet en référençant une bibliothèque statique. On va ensuite convertir cette bibliothèque statique en bibliothèque dynamique et indiquer quelques méthodes pour la référencer.

Quelques définitions et explications en préambule

Dans cette partie, on va apporter quelques précisions sur les bibliothèques en C++ et sur les étapes de la compilation. Si vous êtes familier avec ces notions, passer directement à la partie suivante.

Bibliothèque statique vs bibliothèque dynamique

On distingue 2 types de bibliothèques dans lesquelles on peut compiler du code C++: les bibliothèques dynamiques et les bibliothèques statiques.

Bibliothèque dynamique

Les bibliothèques dynamiques (i.e. dynamic library) se rapprochent des assemblies .NET car elles ont quelques caractéristiques en commun:

  • Il s’agit de fichiers séparés qui ne seront pas inclus dans le fichier exécutable ou dans une bibliothèque l’utilisant comme dépendance.
  • L’extension de ces bibliothèques est .dll pour Dynamic Link Library.

Le gros intérêt de ces bibliothèques est d’être partageables entre plusieurs exécutables. Ainsi, à l’exécution, quand un appel est effectué vers une fonction se trouvant dans une bibliothèque dynamique, elle est chargée en mémoire. Si un autre exécutable utilise la même bibliothèque, le chargement en mémoire n’est effectué qu’une seule fois ce qui permet d’économiser de l’espace mémoire.

L’inconvénient majeur des bibliothèques dynamiques est qu’elles doivent être accessibles au moment de l’exécution. Comme ce sont des fichiers séparés, si l’un d’entre eux n’est pas accessible au moment de l’exécution, il se produira une erreur et l’exécutable va interrompre son exécution.

Bibliothèque statique

Les bibliothèques statiques (i.e. static library) sont des fichiers dont l’extension est .lib. Si un exécutable ou une autre bibliothèque a une dépendance vers une bibliothèque statique, le code utilisé sera directement incorporé dans le fichier de l’exécutable ou de la bibliothèque.

L’intérêt de ces bibliothèques est d’être certain d’inclure les bonnes dépendances dans l’exécutable final. Au moment de l’exécution, il n’y a pas de risques que le fichier de la bibliothèque ne soit pas accessible puisque le code utilisé est inclus dans l’exécutable lui-même. De même, il n’y a pas de risque d’utiliser une mauvaise version.

A l’inverse, pour mettre à jour ce type de bibliothèque, il faut recompiler l’exécutable ou la bibliothèque l’utilisant. D’autre part, si plusieurs exécutables utilisent la même bibliothèque, le code exécuté sera chargé en mémoire autant de fois qu’un exécutable l’utilise puisqu’il fait partie directement de l’exécutable.

Les étapes de la compilation

En C++, la compilation s’effectue en 3 étapes: le pré-processing, la compilation à proprement parlé et l’édition de liens (i.e. linking).

Pre-processing

Cette étape correspond à un prétraitement des fichiers source. Dans un premier temps, les instructions #include seront remplacées par le contenu des fichiers .h auxquelles elles font référence. Toutes les déclarations seront, ainsi, indiquées dans les fichiers .cpp.

Au cours de cette étape, d’autres traitements seront effectués:

  • Certaines parties de code seront utilisées suivant les instructions correspondant aux directives préprocesseurs comme #if, #ifdefn, #ifndef, etc… (cf. documentation Microsoft).
  • Le remplacement de macros se feront avec l’instruction #define.

A la suite de cette étape, le code ne contiendra plus de références vers les fichiers .h et les directives préprocesseurs seront remplacées par leur signification.

Compilation

La compilation consiste à générer des fichiers objet avec l’extension .obj à partir des fichiers de code source .cpp. Les fichiers .obj contiennent des instructions en langage machine correspondant au code compilé toutefois ces fichiers ne peuvent pas être exécutés directement. Chaque instruction dans les fichiers .cpp est compilée en langage machine et en symboles. Ces symboles peuvent, par exemple, désignés des objets. Les symboles sont référencés avec leur nom.

Les fichiers .obj peuvent contenir des références vers des symboles déclarés (grâce aux références vers les fichiers .h) mais qui ne possèdent pas de définition. Le code compilé peut, ainsi, faire référence à ces symboles sans que le compilateur ne remarque l’absence de définition car les références utilisent le nom des symboles. Ces noms peuvent ne correspondre à aucun code machine exécutable.

Edition de liens

Cette étape va permettre de rassembler tous les fichiers .obj pour produire un exécutable (fichier .exe), une bibliothèque statique (fichier .lib) ou une bibliothèques dynamiques (fichier .dll). En fonction des bibliothèques statiques indiquées en paramètre du linker, il va être capable de trouver une définition aux noms de symboles utilisés par le compilateur. Ainsi les noms des symboles sont remplacés par des adresses exactes.

Il est donc important de préciser en paramètre d’entrée du linker quelles sont les bibliothèques pouvant contenir les définitions des symboles utilisés.

Exposer les symboles d’une bibliothèque

Le fichier d’une bibliothèque statique est une archive des fichiers .obj obtenus après la compilation. Lors de l’édition de liens, le contenu des bibliothèques statiques est parcouru pour chercher la définition des symboles. Quand cette définition est trouvée, le code correspondant est récupéré et placé dans le fichier résultant.

Les bibliothèques dynamiques sur les plateformes Windows n’exposent pas de symboles. Le linker n’est pas capable d’identifier le code se trouvant directement dans une DLL à partir du nom des symboles. Pour être capable d’identifier le code se trouvant dans une DLL, il faut que celle-ci expose les symboles correspondant.

Pour exposer les symboles à partir d’une DLL Windows, il faut utiliser dans le code l’instruction __declspec(dllexport) suivi du code à exposer. Cette instruction va permettre de créer une bibliothèque statique d’import qui va contenir le code permettant de charger la DLL dynamique contenant le code exposé et de l’exécuter. Il faut référencer cette bibliothèque statique d’import dans les paramètres d’entrée du linker.

Lors de l’étape d’édition de liens, pour un symbole donné, le linker va récupérer le code se trouvant dans la bibliothèque statique d’import et l’inclure dans le fichier résultant. A l’exécution, le code se trouvant dans la DLL sera, ainsi, appelé sans que le linker ne connaisse directement l’adresse du code dans la DLL.

Dans la 2e partie de cette article, on indiquera comment utiliser l’instruction __declspec(dllexport) pour générer une bibliothèque statique d’import.

Création d’une dépendance vers une bibliothèque statique

Avant de rentrer directement dans la génération d’une bibliothèque statique d’import, on va simplement créer 2 projets:

  • Un projet appelé NativeCallee permettant de générer une bibliothèque statique contenant du code à appeler.
  • Un projet appelé NativeCaller permettant de générer un exécutable. Le code de ce projet permet d’appeler du code se trouvant dans la bibliothèque statique générée par le premier projet NativeCallee.

Pour résumer, les appels se font de cette façon: NativeCallerNativeCallee.

Le code de cet article se trouve dans le repository GitHub github.com/msoft/cpp_dll_reference.

Installation Visual Studio

Avant de commencer, il faut s’assurer que le support C++ est bien installé dans Visual Studio. En exécutant l’installateur de Visual Studio 2017, il faut que “Développement Desktop en C++” soit coché:

Création de la bibliothèque statique NativeCallee

On va créer un projet C++ vide en cliquant sur les éléments suivant dans Visual Studio 2017:

  1. Créer un projet C++ vide en cliquant sur “Nouveau” ⇒ “Projet” ⇒ Dans “Visual C++”, cliquer sur “Projet vide” (“Empty project“).
    Il faut nommer le projet NativeCallee.
  2. Ajouter un classe en effectuant un clique droit sur le projet puis cliquer sur “Ajouter…” et enfin cliquer sur “Classe”.
    Il faut nommer la classe Callee.
  3. Implémentation de Callee.h: insérer ce code dans le fichier Callee.h:
    #ifndef CALLEE_H 
    #define CALLEE_H 
    
    #pragma once 
    
    class Callee 
    { 
    private: 
        wchar_t *textToDisplay; 
    
    public: 
        Callee(wchar_t *textToDisplay); 
        ~Callee(); 
    
        void DisplayText(); 
    }; 
    
    #endif /* CALLEE_H */
    
  4. Implémentaiton de Callee.cpp: insérer ce code dans le fichier Callee.cpp:
    #include "Callee.h" 
    
    #include <wchar.h> 
    
    Callee::Callee(wchar_t *textToDisplay) 
    {
        this->textToDisplay = textToDisplay; 
    } 
    
    Callee::~Callee() 
    { 
    } 
    
    void Callee::DisplayText() 
    { 
        wprintf(this->textToDisplay, "%s\n"); 
    }
    

    Ce code permet simplement de créer une classe avec un texte à afficher dans la variable membre textToDisplay. La fonction DisplayText() permet d’afficher le texte.

  5. Configurer les propriétés du projet NativeCallee:
    Effectuer un clique droit sur le projet NativeCallee puis cliquer sur Propriétés:

    Dans la partie Général, sélectionner les éléments suivants:

    • Type de configuration” (“Configuration type“): “Bibliothèque statique (.lib)” (“Static Library“).
    • Jeu de caractères” (“Character set“): “Utiliser le jeu de caractères Unicode” (“Use Unicode Character set“).
    • Prise en charge du Common Language Runtime” (“Common Language Runtime Support“): “Pas de prise en charge du Common Language” (“No Common Language Runtime Support“).

    Ne pas oublier de valider en cliquant sur “Appliquer”.

Les paramètres d’un projet C++ sont dépendants de la configuration et de la plateforme sélectionnées

Dans la fenêtre de présentation des paramètres du projet, si on change les valeurs des paramètres “Configuration” et/ou “Plateforme” (“Platform“), on peut voir que les paramètres du projet reviennent à leur valeur par défaut:

Ainsi, toutes les valeurs sélectionnées sont spécifiques à une configuration et à une plateforme donnée. Ce qui signifie que si on change la configuration et/ou la plateforme cible, il faut ressaisir les paramètres du projet.

Si on lance la compilation du projet NativeCallee en cliquant sur “Générer…” (“Build…“), on obtient les fichiers suivants:

  • Dans le répertoire NativeCallee\NativeCallee\Debug, le fichier Callee.obj correspondant au fichier objet de la classe Callee.
  • Dans le répertoire Debug, le fichier NativeCallee.lib correspondant à la bibliothèque statique générée.

Création de l’exécutable NativeCaller

On va, maintenant, créer un projet C++ permettant de générer une application:

  1. Créer une application Console en C++ en effectuant un clique droit sur la solution ⇒ cliquer sur “Ajouter” ⇒ “Nouveau projet” ⇒ Dans la partie “Visual C++”, sélectionner “Application Console Windows” (“Windows Console Application“).
    Il faut nommer le projet NativeCaller.
  2. Ajouter la référence vers la bibliothèque statique NativeCallee: accéder aux propriétés du projet NativeCaller en effectuant un clique droit sur le projet NativeCaller puis en cliquant sur “Propriétés”.

    Indiquer les paramètres suivant:

    • Dans la partie C/C++ ⇒ Général:

      Indiquer:
      Autres répertoires Include” (“Additional Include Directories“): $(MSBuildProjectDirectory)\..\NativeCallee
      Ce paramètre permet d’accéder au fichier NativeCallee.h.

    • Dans la partie “Editeur de liens” (“Linker“) ⇒ “entrée” (“Input“):

      Indiquer:
      Dépendances supplémentaires” (“Additional dependencies“): $(MSBuildProjectDirectory)\..\Debug\NativeCallee.lib
      Ce paramètre permet d’indiquer la bibliothèque statique NativeCallee.lib.

    Ne pas oublier de changer les paramètres pour toutes les configurations.

  3. Dans le fichier NativeCaller.cpp (contenant le main de l’application), il faut indiquer l’implémentation suivante:
    #include "pch.h" 
    #include "Redirect.h" 
    #include <iostream> 
    
    int main() 
    { 
        std::wstring textToDisplay(L"Text to display"); 
         
        Callee *callee = new Callee(const_cast<wchar_t*>(textToDisplay.c_str())); 
        callee->DisplayText(); 
      
        delete callee; 
      
        std::cin.get(); 
      
        return 0; 
    } 
    

    Ce code permet d’instancier la classe Callee avec une chaine de caractères et d’appeler une méthode permettant d’afficher cette chaine.

Lancer l’exécution

Avec de lancer l’exécution, il faut définir le projet de démarrage:

  1. Effectuer un clique droit sur le projet NativeCaller (correspondant à l’exécutable)
  2. Cliquer sur “Définir comme projet de démarrage” (“Set as Startup project“).

Lancer l’exécution en appuyant sur [F5].

Le résultat devrait être de la forme:

Calling other lib: 
Text to display 

Ce code permet d’appeler du code se trouvant dans une bibliothèque statique. Le code s’exécute normallement.

Convertir la dépendance statique en dépendance dynamique

Dans cette partie, on va convertir la bibliothèque statique en bibliothèque dynamique de façon à mettre en évidence l’erreur lors de l’édition de liens.

Référencer une DLL

Tout d’abord, on convertit le projet NativeCallee en bibliothèque dynamique:

  1. Dans les propriétés du projet NativeCallee: effectuer un clique droit sur le projet NativeCallee puis cliquer sur “Propriétés”.
  2. Dans la partie “Général”, changer le paramètre suivant:
    Type de configuration” (“Configuration Type“): “Bibliothèque dynamique (.dll)” (“Dynamic Library“)
  3. Valider en cliquant sur OK.

Si on essaie de compiler, on obtient une erreur provenant du linker indiquant qu’il n’arrive pas à ouvrir le fichier NativeCallee.lib.

Si on supprime la référence vers ce fichier dans les propriétés du projet NativeCaller dans “Editeur de liens” (“Linker“) ⇒ “entrée” (“Input“) ⇒ “Dépendances supplémentaires” (“Additional dependencies“), on obtiendra une erreur indiquant que la résolution de symboles n’a pas pu aboutir.

Exposer des objets avec __declspec(dllexport)

Comme indiqué plus haut, il faut exposer les symboles de la bibliothèque dynamique NativeCallee.dll de façon à générer une bibliothèque statique d’import. Cette bibliothèque statique d’import pourra être utilisée par NativeCaller.

Pour générer la bibliothèque statique d’import, il faut modifier l’implémentation de Callee.h en rajoutant les directives __declspec(dllexport) devant les éléments à exposer:

#ifndef CALLEE_H 
#define CALLEE_H 
  
#pragma once  

class Callee 
{
private: 
    wchar_t *textToDisplay; 
  
public: 
    __declspec(dllexport) Callee(wchar_t *textToDisplay); 
    __declspec(dllexport) ~Callee(); 
 
    __declspec(dllexport) void DisplayText(); 
}; 
  
#endif /* CALLEE_H */ 

Si on compile le projet NativeCallee, on remarque dans le répertoire Debug, les fichiers:

  • NativeCallee.dll correspondant à la bibliothèque dynamique NativeCallee.
  • NativeCallee.lib correspondant à la bibliothèque statique d’import de la DLL NativeCallee.dll.

Si on compile toute la solution et qu’on lance l’exécution, on constate que l’exécution est la même que précédemment.

Voir les fonctions exposées

Quelques méthodes permettent de voir les fonctions exposées par le DLL NativeCallee.dll:

  • Avec DependencyWalker: “DependancyWalker” est un utilitaire qui permet de voir toutes les dépendances d’une assembly, on peut ainsi vérifier que les dépendances existent et sont de la bonne version ou architecture.

    “DependancyWalker” peut être téléchargé sur www.dependencywalker.com/.

    Si on ouvre la DLL NativeCallee.dll avec “DependencyWalker”:

    On remarque les fonctions exportées correspondant aux objets qui sont précédés par __declspec(dllexport) dans la classe Callee:

    ??0Callee@@QAE@PA_W@Z 
    ??1Callee@@QAE@XZ 
    ?DisplayText@Callee@@QAEXXZ 
    
    Les noms de fonctions sont déformés

    Les fonctions exposées correspondent à du code C++. Pour les exposer dans la DLL sont prendre en compte l’encapsulation dans une classe, leur nom est déformé (i.e. mangled).

  • Avec dumpbin: dumpbin est un utilitaire livré avec Visual Studio C++, le chemin de l’exécutable est du type:
    C:\Program Files\Microsoft Visual Studio [version VS]\VC\bin 
    

    ou

    C:\Program Files (x86)\Microsoft Visual Studio [version VS]\VC\bin 
    

    On peut y accéder directement à partir de la ligne de commandes Visual Studio (ou “Developer Command Prompt for VS2017“).

    On peut utiliser dumpbin pour afficher les symboles se trouvant dans un fichier .obj. Ainsi pour afficher les symboles se trouvant dans le fichier objet de la classe Callee, il faut exécuter la commande suivante:

    dumpbin /symbols NativeCallee\Debug\Callee.obj
    

Amélioration de l’implémentation

Le code précédant fonctionne et permet d’exposer des objets dans une DLL toutefois il peut générer des déclarations d’export inutiles dans le cas où le code est appelé par une autre DLL.

Pourquoi éviter les déclarations d’export inutiles ?

Pour illustrer, on va créer une DLL intermédiaire entre NativeCaller et NativeCallee. Cette DLL sera appelée par NativeCaller et elle appellera NativeCallee. On appellera la DLL intermédiaire CallerRedirection.

Pour résumer, les appels se feront de cette façon: NativeCallerCallerRedirectionNativeCallee.

Pour créer la DLL CallerRedirection.dll, on crée un nouveau projet:

  1. Créer un projet C++ vide en cliquant sur “Nouveau” ⇒ “Projet” ⇒ Dans “Visual C++”, cliquer sur “Projet vide” (“Empty project“).
    Il faut nommer le projet CallerRedirection.
  2. Ajouter un classe en effectuant un clique droit sur le projet puis cliquer sur “Ajouter…” et enfin cliquer sur “Classe”.
    Il faut nommer la classe Redirect.
  3. Implémentation de Redirect.h: insérer ce code dans le fichier Redirect.h:
    #ifndef REDIRECT_H 
    #define REDIRECT_H 
    
    #pragma once 
    
    #include "Callee.h" 
    
    class Redirect 
    { 
    private: 
        Callee* callee;  
    
    public: 
        __declspec(dllexport) Redirect(wchar_t *textToDisplay); 
        __declspec(dllexport) ~Redirect(); 
    
        __declspec(dllexport) void RedirectCall(); 
    };
    
  4. Implémentation de Redirect.cpp: insérer le code suivant dans le fichier Redirect.cpp:
    #include "Redirect.h" 
      
    Redirect::Redirect(wchar_t *textToDisplay) 
    { 
        this->callee = new Callee(textToDisplay); 
    } 
    
    Redirect::~Redirect() 
    { 
        delete this->callee; 
    } 
    
    void Redirect::RedirectCall() 
    { 
        this->callee->DisplayText(); 
    } 
    

    Ce code permet d’instancier Callee avec une chaine de caractères et il permet d’appeler Callee::DisplayText() pour afficher la chaine de caractères.

  5. Ajouter la référence vers la bibliothèque statique NativeCallee: accéder aux propriétés du projet CallerRedirection en effectuant un clique droit sur le projet CallerRedirection puis en cliquant sur “Propriétés”.

    Indiquer les paramètres suivant:

    • Dans la partie “C/C++”” ⇒ “Général”, indiquer:
      Autres répertoires Include“: $(MSBuildProjectDirectory)\..\NativeCallee
      Ce paramètre permet d’accéder au fichier NativeCallee.h.
    • Dans la partie “Editeur de liens” (“Linker“) ⇒ “entrée” (“Input“), indiquer:
      Dépendances supplémentaires” (“Additional dependencies“): $(MSBuildProjectDirectory)\..\Debug\NativeCallee.lib
      Ce paramètre permet d’indiquer la bibliothèque statique NativeCallee.lib.
  6. On modifie les propriétés du projet NativeCaller pour qu’il appelle CallerRedirection au lieu d’appeler NativeCallee.

    Dans les propriétés du projet NativeCaller (effectuer un clique droit sur le projet NativeCaller puis en cliquant sur “Propriétés”):

    Indiquer les paramètres suivant:

    • Dans la partie C/C++ ⇒ Général:

      Indiquer:
      Autres répertoires Include” (“Include directories“): $(MSBuildProjectDirectory)\..\NativeCallee;$(MSBuildProjectDirectory)\..\CallerRedirection
      Ce paramètre permet d’accéder aux fichiers NativeCallee.h et Redirect.h.

    • Dans la partie “Editeur de liens” (“Linker“) ⇒ “entrée” (“Input“):

      Indiquer:
      Dépendances supplémentaires” (“Additional dependencies“): $(MSBuildProjectDirectory)\..\Debug\CallerRedirection.lib
      Ce paramètre permet d’indiquer la bibliothèque statique d’import de CallerRedirection.lib.

  7. On modifie l’implémentation de NativeCaller dans le fichier NativeCaller.cpp:
    #include "pch.h" 
    #include "Redirect.h" 
    #include <iostream> 
    
    int main() 
    { 
        std::wstring textToDisplay(L"Text to display"); 
    
        Redirect *callRedirection = new Redirect(const_cast<wchar_t*>(textToDisplay.c_str()));
      
        std::cout << "Calling other lib:\n"; 
        callRedirection->RedirectCall();
      
        delete callRedirection; 
      
        std::cin.get(); 
      
        return 0; 
    } 
    

    Ce code permet d’appeler CallerRedirection au lien d’appeler directement NativeCallee.

  8. Pour que la compilation puisse réussir, il faut préciser l’ordre de compilation des projets. Ainsi on indique les dépendances pour que la compilation des projets se fasse dans le bon ordre:
    • On effectue un clique droit sur la solution puis on clique sur “Dépendances du projet” (“Project dependencies“).
    • En sélectionnant NativeCaller, il faut cocher CallerRedirection et NativeCaller.
    • En sélectionnant CallerRedirection, il faut cocher NativeCallee.

    L’ordre de compilation doit être le suivant:

Si on compile la solution et si on exécute, le résultat devrait être semblable à ce qu’on a obtenu précédemment.

Si on affiche les symboles exportés de CallerRedirection en exécutant la commande suivante:

dumpbin /symbols CallerRedirection/Debug/Redirect.obj

On peut voir parmi les objets exportées:

03C 00000000 UNDEF  notype ()    External     | ??0Callee@@QAE@PA_W@Z (public: __thiscall Callee::Callee(wchar_t *)) 
03D 00000000 UNDEF  notype ()    External     | ??1Callee@@QAE@XZ (public: __thiscall Callee::~Callee(void)) 
03E 00000000 UNDEF  notype ()    External     | ?DisplayText@Callee@@QAEXXZ (public: void __thiscall Callee::DisplayText(void)) 
03F 00000000 SECTF  notype ()    External     | ?__autoclassinit2@Callee@@QAEXI@Z (public: void __thiscall Callee::__autoclassinit2(unsigned int)) 
040 00000000 SECTB  notype ()    External     | ??_GCallee@@QAEPAXI@Z (public: void * __thiscall Callee::`scalar deleting destructor'(unsigned int)) 
041 00000000 SECT5  notype ()    External     | ??0Redirect@@QAE@PA_W@Z (public: __thiscall Redirect::Redirect(wchar_t *)) 
042 00000000 SECT8  notype ()    External     | ??1Redirect@@QAE@XZ (public: __thiscall Redirect::~Redirect(void)) 
043 00000000 SECTD  notype ()    External     | ?RedirectCall@Redirect@@QAEXXZ (public: void __thiscall Redirect::RedirectCall(void)) 

On peut voir des fonctions exposées provenant de NativeCallee.

Pourquoi CallerRedirection contient les objets exposés par NativeCallee ?

CallerRedirection contient les objets exposés par NativeCallee à cause de l’instruction:

#include "Callee.h"

Cette instruction se trouve dans Redirect.h.

Pour rappel et comme on l’a expliqué plus haut dans les étapes de compilation, lors de l’étape de pré-processing, le code se trouvant dans les fichiers .h est directement inclus dans le code dans les fichiers .cpp. Ainsi le contenu du fichier Callee.h est placé dans le fichier Redirect.h qui lui-même est placé dans le fichier Redirect.cpp.

Ainsi le fichier objet Redirect.obj contient aussi les directives d’export de NativeCallee:

__declspec(dllexport) Callee(wchar_t *textToDisplay); 
__declspec(dllexport) ~Callee(); 

__declspec(dllexport) void DisplayText(); 

Ces objets sont exposés par CallerRedirection pourtant CallerRedirection ne les expose pas directement.

Nouvelle implémentation pour éviter les déclarations d’export inutiles

On améliore l’implémentation en utilisant des macros et des directives préprocesseurs. Cette implémentation permet d’éviter des déclarations d’export soient faites inutilement dans une DLL alors qu’elle ne définit pas directement les objets exportés.

Pour éviter ça:

  1. On modifie l’implémentation dans Callee.h et on rajoute des directives pré-processeur:
    #ifndef CALLEE_H 
    #define CALLEE_H 
      
    #pragma once 
      
    #ifdef NATIVECALLEE_EXPORTS 
    #define NATIVECALLEE_API __declspec(dllexport) 
    #else 
    #define NATIVECALLEE_API __declspec(dllimport) 
    #endif
    
    class Callee 
    { 
    private: 
        wchar_t *textToDisplay; 
      
    public: 
        NATIVECALLEE_API Callee(wchar_t *textToDisplay); 
        NATIVECALLEE_API ~Callee(); 
      
        NATIVECALLEE_API void DisplayText(); 
    }; 
      
    #endif /* CALLEE_H */ 
    
  2. Dans les propriétés du projet NativeCallee
    Dans la partie “C/C++”” ⇒ “Préprocesseur”, on indique la valeur suivante pour le paramètre “Définitions de préprocesseur” (“Preprocessor definitions“):

    NATIVECALLEE_EXPORTS 
    

La présence de cette valeur lors de l’étape de pré-processing permet d’affecter une valeur différente à la macro NATIVECALLEE_API. Ainsi:

  • Dans le projet NativeCallee, quand l’étape de pré-processing s’exécute, la valeur NATIVECALLEE_EXPORTS est définie et c’est la ligne suivante qui va s’exécuter:
    #define NATIVECALLEE_API __declspec(dllexport) 
    

    Par suite, les objets dans la classe Callee seront déclarés de la façon suivante:

    __declspec(dllexport) Callee(wchar_t *textToDisplay); 
    __declspec(dllexport) ~Callee(); 
      
    __declspec(dllexport) void DisplayText(); 
    

    Ces objets seront donc bien exposés par la DLL NativeCallee.dll comme précédemment.

  • Dans le projet CallerRedirection, quand l’étape de pré-processing s’exécute, la valeur NATIVECALLEE_EXPORTS n’est pas définie et c’est la ligne suivant qui va s’exécuter:
    #define NATIVECALLEE_API __declspec(dllimport) 
    

    Par suite, les objets dans la classe Callee seront déclarés de la façon suivante:

    __declspec(dllimport) Callee(wchar_t *textToDisplay); 
    __declspec(dllimport) ~Callee(); 
      
    __declspec(dllimport) void DisplayText(); 
    

    Ces objets seront, ainsi importés et non pas exposés par la DLL CallerRedirection.dll.

Si on recompile le code, ainsi obtenu, et si on observe les symboles se trouvant dans le fichier objet CallerRedirection/Debug/Redirect.obj, on obtient:

03C 00000000 UNDEF  notype       External     | __imp_??0Callee@@QAE@PA_W@Z (__declspec(dllimport) public: __thiscall Callee::Callee(wchar_t *)) 
03D 00000000 UNDEF  notype       External     | __imp_??1Callee@@QAE@XZ (__declspec(dllimport) public: __thiscall Callee::~Callee(void)) 
03E 00000000 UNDEF  notype       External     | __imp_?DisplayText@Callee@@QAEXXZ (__declspec(dllimport) public: void __thiscall Callee::DisplayText(void)) 
03F 00000000 SECTF  notype ()    External     | ?__autoclassinit2@Callee@@QAEXI@Z (public: void __thiscall Callee::__autoclassinit2(unsigned int)) 
040 00000000 SECTB  notype ()    External     | ??_GCallee@@QAEPAXI@Z (public: void * __thiscall Callee::`scalar deleting destructor'(unsigned int)) 
041 00000000 SECT5  notype ()    External     | ??0Redirect@@QAE@PA_W@Z (public: __thiscall Redirect::Redirect(wchar_t *)) 
042 00000000 SECT8  notype ()    External     | ??1Redirect@@QAE@XZ (public: __thiscall Redirect::~Redirect(void)) 
043 00000000 SECTD  notype ()    External     | ?RedirectCall@Redirect@@QAEXXZ (public: void __thiscall Redirect::RedirectCall(void)) 

On remarque que les fonctions de NativeCallee sont importées et non plus exportées.

Utilisation d’un fichier DEF pour exposer des objets

Au lieu d’utiliser l’instruction __declspec(dllexport) dans le code, il est possible d’utiliser un fichier dans lequel on indique les objets à exposer. La difficulté à utiliser ce fichier est qu’il faut indiquer le nom de la fonction tel qu’il apparaît dans les symboles du fichier .obj.
Par exemple si on modifie le projet CallerRedirection:

  1. Dans l’implémentation de Redirect.h, on supprime toutes les directives __declspec(dllexport):
    #ifndef REDIRECT_H 
    #define REDIRECT_H 
      
    #pragma once 
      
    #include "Callee.h" 
      
    class Redirect 
    { 
    private: 
        Callee* callee; 
      
    public: 
        Redirect(wchar_t *textToDisplay); 
        ~Redirect(); 
      
        void RedirectCall(); 
    }; 
    
    #endif /* REDIRECT_H */
    
  2. On recompile seulement le projet CallerRedirection en effectuant un clique droit sur le projet puis en cliquant sur “Regénérer” (“Rebuild“).
  3. On génère les symboles en exécutant la commande suivante avec la ligne de commandes de Visual Studio:
    dumpbin /symbols CallerRedirection\Debug\Redirect.obj
    

    Pour faciliter la récupération des noms de fonctions on peut rediriger la sortie dans un fichier texte:

    dumpbin /symbols CallerRedirection\Debug\Redirect.obj > symbols.txt 
    

    Les noms des fonctions à exposer apparaissent de cette façon:

    041 00000000 SECT5  notype ()    External     | ??0Redirect@@QAE@PA_W@Z (public: __thiscall Redirect::Redirect(wchar_t *)) 
    042 00000000 SECT8  notype ()    External     | ??1Redirect@@QAE@XZ (public: __thiscall Redirect::~Redirect(void)) 
    043 00000000 SECTD  notype ()    External     | ?RedirectCall@Redirect@@QAEXXZ (public: void __thiscall Redirect::RedirectCall(void)) 
    
  4. On crée un fichier nommé CallerRedirection.def dans le répertoire CallerRedirection avec le contenu suivant:
    LIBRARY CALLERREDIRECTION 
    EXPORTS 
        ??0Redirect@@QAE@PA_W@Z @1 
        ??1Redirect@@QAE@XZ @2 
      
        ?RedirectCall@Redirect@@QAEXXZ @3 
    

    On indique chaque fonction à exposer suivie d’un espace et d’une numérotation du type @<index de la fonction>

  5. Dans les propriétés du projet CallerRedirection:

    Dans la partie “Editeurs de liens” (“Linker“) ⇒ “entrée” (“Input“) ⇒ Pour le paramètre “Fichier de définition de module” (“Module Definition File“), on indique le nom:

    CallerRedirection.def 
    

On relance la compilation, toute la solution devrait compiler normalement comme précédemment. Les fonctions sont donc bien exposées.

Exporter des fonctions sous forme d’un linkage C

Les méthodes précédentes permettent d’exposer des objets C++. Ces exports sont compatibles à condition d’être appelés par du code C++. Il est possible d’effectuer des déclarations d’export en utilisant des exports compatibles C en utilisant la directive extern "C".

Pour l’utiliser, il suffit de préfixer la déclaration de la fonction à exposer avec extern "C".

Par exemple si on modifie le projet NativeCallee:

  1. On modifie le fichier Callee.h avant la déclaration de la classe Callee:
    #ifndef CALLEE_H 
    #define CALLEE_H 
      
    #pragma once 
      
    #ifdef NATIVECALLEE_EXPORTS 
    #define NATIVECALLEE_API __declspec(dllexport) 
    #else 
    #define NATIVECALLEE_API __declspec(dllimport) 
    #endif 
      
    extern "C" NATIVECALLEE_API void DisplayTextWithCallee(const wchar_t *textToDisplay);
      
    class Callee 
    { 
    private: 
        wchar_t *textToDisplay; 
      
    public: 
        NATIVECALLEE_API Callee(wchar_t *textToDisplay); 
        NATIVECALLEE_API ~Callee(); 
      
        NATIVECALLEE_API void DisplayText(); 
    }; 
      
    #endif /* CALLEE_H */ 
    

    On déclare une nouvelle méthode à exposer DisplayTextWithCallee().

  2. On implémente la fonction dans Callee.cpp:
    void DisplayTextWithCallee(const wchar_t *textToDisplay) 
    { 
        Callee *callee = new Callee(const_cast<wchar_t*>(textToDisplay)); 
        callee->DisplayText(); 
        delete callee; 
    } 
    

    Cette méthode permet d’instancier une classe Callee avec une chaine de caractères comme pour les exemples précédents. Ensuite, on affiche le contenu de la chaine de caractères.

  3. On modifie l’implémentation dans le projet CallerRedirection pour appeler la méthode DisplayTextWithCallee().

    D’abord on modifie l’implémentation de Redirect.h:

    #ifndef REDIRECT_H 
    #define REDIRECT_H 
      
    #pragma once 
      
    #include "Callee.h" 
      
    class Redirect 
    { 
    private: 
        wchar_t *textToDisplay;
      
    public: 
        __declspec(dllexport) Redirect(wchar_t *textToDisplay); 
        __declspec(dllexport) ~Redirect(); 
      
        __declspec(dllexport) void RedirectCall(); 
    }; 
      
    #endif /* REDIRECT_H */ 
    
  4. On modifie ensuite l’implémentation de Redirect.cpp:
    #include "Redirect.h" 
      
    Redirect::Redirect(wchar_t *textToDisplay) 
    { 
        this->textToDisplay = textToDisplay; 
    } 
      
    Redirect::~Redirect() 
    { 
    } 
      
    void Redirect::RedirectCall() 
    { 
        DisplayTextWithCallee(this->textToDisplay);
    } 
    

Si on observe la façon dont la fonction est exportée avec “DependencyWalker” dans la DLL CallerRedirection.dll, on constate que le nom n’est pas déformé comme c’est le cas pour les fonctions provenant d’objets C++:

Si on exécute la commande suivante avec la ligne de commandes Visual Studio:

dumpbin /symbols NativeCallee\Debug\Callee.obj

On obtient les symboles suivants:

058 00000000 UNDEF  notype ()    External     | ??2@YAPAXI@Z (void * __cdecl operator new(unsigned int)) 
059 00000000 UNDEF  notype ()    External     | ??3@YAXPAXI@Z (void __cdecl operator delete(void *,unsigned int)) 
05A 00000000 SECT10 notype ()    External     | _DisplayTextWithCallee
05B 00000000 SECT5  notype ()    External     | ??0Callee@@QAE@PA_W@Z (public: __thiscall Callee::Callee(wchar_t *))
05C 00000000 SECT7  notype ()    External     | ??1Callee@@QAE@XZ (public: __thiscall Callee::~Callee(void)) 
05D 00000000 SECTC  notype ()    External     | ?DisplayText@Callee@@QAEXXZ (public: void __thiscall Callee::DisplayText(void)) 

On constate que le nom de fonction exportée sous forme d’un linkage C n’est pas déformé.

Le code de cet article se trouve dans le repository GitHub github.com/msoft/cpp_dll_reference.

Conclusion

Le but de cet article était d’apporter quelques indications pour exposer des fonctions se trouvant dans une DLL de façon à pouvoir les consommer à partir d’un autre projet C++ lors de la compilation et par une autre bibliothèque au runtime. J’espère qu’il aura pu rendre toutes ces problématiques plus claires.

Cet article sert d’introduction pour illustrer la consommation de bibliothèque native à partir de code managé. Sujet qui sera traité dans des articles ultérieurs.

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

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