Powershell en 10 min: aide, gestion d’erreurs et manipulation de fichiers (partie 4)

Cet article liste quelques fonctionnalités un peu plus avancées de Powershell pour traiter quelques cas d’implémentation courants.

Messages d’aide

Pour obtenir de l’aide sur une fonction, on peut utiliser l’instruction:

Get-Help <nom de la fonction>

Pour créer des messages d’aide lorsqu’on définit une fonction, on peut utiliser des commentaires déclarés avec une syntaxe particulière. Ces commentaires seront affichés suivant les paramètres utilisés à l’exécution de la fonction Get-Help.

Par exemple:
On peut indiquer dans le corps de la fonction les commentaires:

  • # .SYNOPSIS pour indiquer un texte décrivant brièvement la fonction
  • # .DESCRIPTION permet de décrire la fonction
  • # .PARAMETER donnera des indications sur les paramètres de la fonction
  • # .EXAMPLE indiquera un exemple

Il est possible d’indiquer d’autres informations:

  • # .INPUTS
  • # .OUTPUTS
  • # .NOTES
  • # .LINK

Dans le corps de la fonction, ces commentaires doivent être indiqués de cette façon:

function NomFonction($arg1, $arg2)
{
<#

.SYNOPSIS
Texte correspondant au synopsis 

.DESCRIPTION
Texte correspondant à la description 

.PARAMETER arg1
Aide concernant l'argument 1

.PARAMETER arg2
Aide concernant l'argument 1

.EXAMPLE
Texte correspondant à l'exemple 

.NOTES
Notes diverses 

.LINK
Liens éventuels

.INPUTS
Indication pour les données en entrée

.OUTPUTS
Indication pour les données en sortie

#>

  Write-Host Argument 1: $arg1
  Write-Host Argument 2: $arg2
}

Pour obtenir la documentation complète d’une fonction, il faut utiliser l’option -full:

Get-Help <nom de la fonction> -full

Dans le cas de l’exemple, pour afficher une documentation partielle, on peut exécuter:

PS C:\> Get-Help NomFonction

NAME
    NomFonction
    
SYNOPSIS
    Texte correspondant au synopsis
    
    
SYNTAX
    NomFonction [[-arg1] <Object>] [[-arg2] <Object>] [<CommonParameters>]
    
    
DESCRIPTION
    Texte correspondant à la description
    

RELATED LINKS
    Liens éventuels 

REMARKS
    To see the examples, type: "get-help NomFonction -examples".
    For more information, type: "get-help NomFonction -detailed".
    For technical information, type: "get-help NomFonction -full".
    For online help, type: "get-help NomFonction -online"

Dans le cas de la documentation complète, le résultat est:

PS C:\> Get-Help NomFonction -full

NAME
    NomFonction
    
SYNOPSIS
    Texte correspondant au synopsis
    
    
SYNTAX
    NomFonction [[-arg1] <Object>] [[-arg2] <Object>] [<CommonParameters>]
    
    
DESCRIPTION
    Texte correspondant à la description
    

PARAMETERS
    -arg1 <Object>
        Aide concernant l'argument 1
        
        Required?                    false
        Position?                    1
        Default value                
        Accept pipeline input?       false
        Accept wildcard characters?  false
        
    -arg2 <Object>
        Aide concernant l'argument 1
        
        Required?                    false
        Position?                    2
        Default value                
        Accept pipeline input?       false
        Accept wildcard characters?  false
        
    <CommonParameters>
        This cmdlet supports the common parameters: Verbose, Debug,
        ErrorAction, ErrorVariable, WarningAction, WarningVariable,
        OutBuffer, PipelineVariable, and OutVariable. For more information, see 
        about_CommonParameters (http://go.microsoft.com/fwlink/?LinkID=113216). 
    
INPUTS
    Indication pour les données en entrée
    
    
OUTPUTS
    Indication pour les données en sortie
    
    
NOTES
    
    
        Notes diverses
    
    -------------------------- EXAMPLE 1 --------------------------
    
    PS C:\>Texte correspondant à l'exemple
    
    
    
    
    
    
    
RELATED LINKS
    Liens éventuels

Gestion des erreurs

Pour gérer n’importe quel type d’erreurs qui pourrait intervenir dans la fonction, il faut utiliser le mot clé trap. Par exemple:

function NomFonction()
{
   Write-Host Exécuté avant "trap"

   trap 
   {
      Write-Host Une erreur est survenue
   }
   
   Write-Host Exécuté après "trap"
}

Dans ce cas, il n’y a pas d’erreurs donc le contenu de la partie trap ne sera pas exécutée:

PS C:\> NomFonction
Exécuté avant trap
Exécuté après trap

Dans le cas d’une erreur, le contenu de la partie trap est exécutée:

function NomFonction()
{
   Write-Host Exécuté avant "trap"

   trap 
   {
      Write-Host Une erreur est survenue
   }
   # ATTENTION: l'exécution continue après le "trap"

   # L'erreur est une division par zéro
   $arg= 3/0

   Write-Host Exécuté après "trap"
}

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

PS C:\> NomFonction
Exécuté avant trap
Une erreur est survenue
Tentative de division par zéro.
At line:12 char:4
+    $arg= 3/0
+    ~~~~~~~~~
    + CategoryInfo          : NotSpecified: (:) [], RuntimeException
    + FullyQualifiedErrorId : RuntimeException
 
Exécuté après trap

break

Comme indiqué en commentaire de l’exemple précédent, l’exécution se poursuit après l’erreur. Pour stopper l’exécution à la ligne où l’erreur se produit, il faut ajouter break dans le bloc trap:

function NomFonction()
{
   Write-Host Exécuté avant "trap"

   trap 
   {
      Write-Host Une erreur est survenue
      break
   }
   # ATTENTION: l'exécution continue après le "trap"

   $arg= 3/0

   Write-Host Exécuté après "trap"
}

En exécutant, on obtient:

PS C:\> NomFonction
Exécuté avant trap
Une erreur est survenue
Tentative de division par zéro.
At line:12 char:4
+    $arg= 3/0
+    ~~~~~~~~~
    + CategoryInfo          : NotSpecified: (:) [], ParentContainsErrorRecordException
    + FullyQualifiedErrorId : RuntimeException

continue

A l’opposé de l’exemple précédent, si on veut que l’exécution se poursuive après la ligne où l’erreur s’est produite, il faut utiliser le mot clé continue. Avec continue, il n’y aura pas de message d’erreurs:

function NomFonction()
{
   Write-Host Exécuté avant "trap"

   trap 
   {
      Write-Host Une erreur est survenue
      continue
   }
   # ATTENTION: l'exécution continue après le "trap"

   $arg= 3/0

   Write-Host Exécuté après "trap"
}

En exécutant la fonction, on obtient:

PS C:\> NomFonction
Exécuté avant trap
Une erreur est survenue
Exécuté après trap

Pour afficher l’erreur avec un texte choisi, on peut utiliser $_:

function NomFonction()
{
   Write-Host Exécuté avant "trap"

   trap 
   {
      Write-Host L`'erreur survenue est:
      Write-Host $_
      continue
   }
   # ATTENTION: l'exécution continue après le "trap"

   $arg= 3/0

   Write-Host Exécuté après "trap"
}

Le résultat de l’exécution dans ce cas est:

PS C:\> NomFonction
Exécuté avant trap
L'erreur survenue est:
Tentative de division par zéro.
Exécuté après trap

Spécialiser l’erreur attrapée par “trap”

Pour spécialiser l’erreur attrapée par un bloc trap, il faut préciser le type d’exception comme le bloc try...catch en C#:

function NomFonction()
{
Write-Host Exécuté avant "trap"

   trap [System.DivideByZeroException]
   {
      Write-Host L`'erreur est:
      Write-Host $_
      continue
   }

   $arg= 3/0

   Write-Host Exécuté après "trap"
}

Le résultat de l’exécution est similaire aux exemples précédents:

PS C:\> NomFonction
Exécuté avant trap
L'erreur est:
Tentative de division par zéro.
Exécuté après trap

En faisant cohabiter avec une autre bloc trap plus général:

function NomFonction()
{
Write-Host Exécuté avant "trap"

   trap [System.DivideByZeroException]
   {
      Write-Host L`'erreur est:
      Write-Host $_
      continue
   }
   trap 
   {
      Write-Host Autre erreur est:
      Write-Host $_
      continue
   }

   $unknownParameter = "chaine"
   $unknownParameter = 4 + $unknownParameter

  Write-Host Exécuté après "trap"
}

Pour la fonction précédente, l’erreur n’est pas une division par zéro donc c’est le 2e bloc trap qui sera exécuté:

PS C:\> NomFonction
Exécuté avant trap
Autre erreur est:
Cannot convert value "chaine" to type "System.Int32". Error: "Le format de la chaîne d'entrée est incorrect."
Exécuté après trap

Manipulation de fichiers

Pour lire le contenu d’un fichier:

Get-Content "<chemin du fichier>"

Assigner le contenu d’un fichier à une variable:

$val=Get-Content "<chemin du fichier>"

Dans le cas d’un fichier qui contient les lignes suivantes:

First line
Second line
Third line

Si on écrit la ligne suivante, le résultat est:

PS C:\> Write-Host $val
First line Second line Third line

Le résultat est un tableau contenant toutes les lignes. Chaque ligne est accessible en écrivant:

$val[0]

Dans le cas de l’exemple précédent:

Write-Host $val[0]
First line

Pour combiner les éléments du tableau dans une chaîne de caractères unique:

$separateur = [System.Environment]::NewLine
$result = [String]::Join($separateur, $val)

$result est donc une chaîne de caractères contenant tous les éléments du tableau.

Si on affiche son contenu, on obtient:

Write-Host $result
First line
Second line
Third line

Lister des fichiers dans un répertoire

Pour afficher des informations sur liste de fichiers dans un répertoire, on utilise Get-ChildItem. Dans le cas d’un seul fichier, on utilise Get-Item. On peut sélectionner les informations à afficher avec les paramètres suivants:

  • -Name: pour afficher le nom du fichier
  • -Recurse: pour parcourir un répertoire récursivement
  • -Path: pour afficher le chemin du fichier.

Par exemple pour afficher le chemin des fichiers se trouvant dans le répertoire courant (Get-Location renvoie le chemin du répertoire courant):

Get-ChildItem -Path (Get-Location).Path

Manipulations basiques sur des fichiers

Quelques cmdlets permettent d’effectuer des opérations basiques sur les fichiers:

  • Remove-Item: pour supprimer un fichier ou un répertoire
  • Rename-Item: pour renommer un fichier ou un répertoire
  • Move-Item: pour déplacer un fichier ou un répertoire

Pour créer un répertoire on peut écrire la commande suivante:

New-Item -Path "<chemin du répertoire>" -ItemType Directory

Générer un fichier

Pour écrire un fichier, on peut écrire:

Set-Content -Value <variable avec le contenu à écrire> -Path "<chemin du fichier à écrire>"

Si le fichier existe déjà, Set-Content écrase son contenu, il ne rajoute d’élément à un contenu déjà existant.

Pour ajouter un contenu quand le fichier existe déjà, il faut utiliser Add-Content.

Par exemple:

Add-Content -Value <nom d'une variable avec le contenu à écrire> -Path "<chemin du fichier>"

Si le fichier n’existe pas, il sera créé.

On peut aussi utiliser la commande Out-File.
Par exemple pour générer un fichier temporaire:

$tempFilePath = [System.IO.Path]::GetTempFileName()
$fileContent = "Content to write to the temp file"
$fileContent | Out-File $tempFilePath

Exporter dans un fichier CSV

On peut exporter un fichier CSV avec des instructions particulières, par exemple:

Get-Process | Export-Csv "<nom du fichier>"

Ajouter un header au fichier CSV au moment d’importer le fichier:

$header = "col1", "col2", "col3"
$process = Import-Csv "<nom du fichier à importer>" -Header $header

Fichier XML

De même pour créer un fichier XML, on peut écrire:

$courseTemplate = @"
<employees>
  <employee id="1">
    <name>Person 1</name>
    <age>23</age>
  </employee>
  <employee id="2">
    <name>Person 2</name>
    <age>56</age>
  </employee>
  <employee id="3">
    <name>Person 3</name>
    <age>21</age>
  </employee>
</employees>
"@

$courseTemplate | Out-File "<chemin du fichier>"

Si on affiche le contenu de la variable, on s’aperçoit qu’il s’agit d’un type plus complexe qu’une chaîne de caractères:
Par exemple:

PS C:\> Write-Host $xmlVar
#document

En omettant Write-Host, on peut afficher davantage d’éléments:
Par exemple:

PS C:\> $xmlVar
employees
---------
employees

et

PS C:\> $xmlVar.employees
employee                      
--------                      
{Person 1, Person 2, Person 3}

Pour charger un fichier XML
On peut écrire le code suivant:

$xmlVar = New-Object xml 
$xmlVar.Load("<chemin du fichier à lire>")

Travailler sur un nœud XML
On peut atteindre un nœud particulier d’un fichier XML facilement de la façon suivante:

$nodeVal = (@($xmlVar.employees.employee)[0]).Clone()

employees.employee correspond à un élément du fichier; (@($xmlVar.employees.employee) contiendra un tableau avec toutes les occurences de l’élément.

Ainsi, pour afficher le contenu de $nodeVal:
Par exemple:

PS C:\> $nodeVal
id name     age
-- ----     ---
1  Person 1 23 

Pour ajouter des éléments XML
A partir de la variable définit précédemment, on peut éditer des valeurs et ajouter un nouveau nœud en écrivant:

$newVal = $nodeVal.Clone()
$newVal.name = "Person 4"
$newVal.age = "3"
$xmlVar.employees.AppendChild($newVal) > $null

Si on ne rajoute pas > $null dans la ligne .AppendChild($newVal), l’ajout sera redirigé vers l’écran.

Si on affiche de nouveau, le contenu de $xmlVar.employees, on s’aperçoit qu’un nœud a été rajouté:
Par exemple:

PS C:\> $xmlVar.employees
employee                      
--------                      
{Person 1, Person 2, Person 3, Person 4}

Pour supprimer le template du fichier ou supprimer un nœud du fichier:

$xmlVar.courses.course | 
  Where-Object {$_.Name -eq "Person 1"} |
  ForEach-Object {[void]$_
    $xmlVar.courses.RemoveChild($_)
  }

Pour sauver le fichier XML

$xmlVar.Save("<chemin du fichier>")

Pour charger directement un fichier XML
On peut utiliser le mot clé [xml], par exemple:

[xml]$xmlVar = Get-Content "<chemin du fichier>"
Les autres articles de cette série

Partie 1: exécuter Powershell

Partie 2: les cmdlets

Partie 3: instructions dans des scripts Powershell

Partie 4: aide, gestion d’erreurs et manipulation de fichiers

Références

Powershell en 10 min: instructions dans des scripts Powershell (partie 3)

Cet article indique quelques instructions utilisables dans un script Powershell. La liste d’instructions n’est pas exhaustive toutefois elle devrait permettre d’écrire rapidement un script avec les principales instructions.

Quelques instructions courantes utilisables dans les scripts

Ces instructions peuvent être utiles pour implémenter des scripts.

Manipulation de variables

L’affectation de variable se fait en utilisant le caractère $, par exemple:

$var1 = "value 1"
$var2 = $var1

L’affectation de la variable nulle se fait en écrivant:

$var1 = $null

Effectuer des “casts”

On peut effectuer des casts en indiquant le type souhaité avec des crochets [ ]. L’indication du type peut se faire de 2 façons:

# Effectuer un "cast" vers DateTime 
[System.DateTime]$var1="2017-08-25"

# Effectuer un "cast" vers un XmlDocument
$var2 = [System.Xml.XmlDocument]"<xml><node>HERE</node></xml>"
“Cast” implicite

Dans certains cas, powershell effectue implicitement des casts, par exemple si on écrit:

$stringVar = "PowerShell"
$doubleVar = 2.0
$result = $stringVar + $doubleVar

Dans ce cas le cast de $doubleVar en string se fera implicitement.

En revanche, si on écrit:

$result = $doubleVar + $stringVar # Cette ligne occasionne une erreur

Il y aura une erreur car Powershell ne pourra pas convertir $doubleVar en string.

Une solution peut consister à utiliser l’opérateur -as:

$result = ($doubleVar -as [string]) + $stringVar

On peut aussi effectuer le cast explicitement:

$result = [string]$doubleVar + $stringVar

if…then…else

Pour implémenter une clause if...then...else, la syntaxe est semblable au C#:
Par exemple:

$var = 2
if ($var -eq 1)
{ 
   # ... 
}
else
{
   # ... 
}
Pas de elseif

Il n’y a pas de elseif, il faut utiliser des if ... then ... else imbriqués.

Operateurs de comparaison

D’autres opérateurs de comparaison sont disponibles:

  • -eq: comparateur d’égalité
  • -ne: “not equal to”, la valeur est True si les opérandes ne sont pas égales.
  • -ge: “greater than or equal”, la valeur est True si l’opérande de gauche est supérieure ou égale à l’opérande de droite.
  • -gt: “greater than”, la valeur est True si l’opérande de gauche est strictement supérieure à l’opérande de droite.
  • -le: “less than or equal”, la valeur est True si l’opérande de gauche est inférieure ou égale à l’opérande de droite.
  • -lt: “less than”, la valeur est True si l’opérande de gauche est strictement inférieure à l’opérande de droite.
  • -like et -notlike: permet d’effectuer des comparaisons de chaines de caractères en utilisant des wildcards:
    • ? pour désigner un seul caractère non spécifié
    • * pour désigner un ou plusieurs caractères non spécifiés

    Par exemple: $var -like "*uary"

  • -match et -notmatch: permet de vérifier si une chaine de caractères respecte une expression régulière, par exemple: $string -match "\w" (se reporter à Regular Expression Language – Quick Reference pour plus de précisions sur les expressions régulières).
  • -contains et -notcontains: permet de tester si une valeur se trouve dans une liste. Par exemple:
    $names = "val1", "val2", "val3"
    $names -Contains "val2" 
  • -is et -isnot: permet de tester le type d’une variable .NET (même opérateur qu’en C#). Par exemple:
    $stringToTest = "chaine de caracteres"
    ($stringToTest -is [System.String])

Opérateurs logiques

Ces opérateurs permettent de tester plusieurs expressions logiques:

  • -and: opérateur ET
  • -or: opérateur OU
  • -xor: opérateur OU exclusif (retourne True si seulement une expression est vraie)
  • -not: opérateur NOT

Par exemple:

$var1="value1"
$var2="value2"
($var1 -eq "value1") -and ($var2 -eq "value2")

switch

Pour implémenter un switch...case, on utilise la syntaxe suivante:

switch ($var)
{ 
   21 { "value1"; break }
   22 { "value2" }  # pas d'arrêt dans ce cas car pas de break
   23 { "value3"; break }
   default { "default" }
}

On peut executer une liste de type:

switch (1, 2, 3, 0)

Le parcours se fait dans l’ordre des valeurs.

Par défaut la comparaison n’est pas sensible à la casse:

Par exemple:

switch ("AAA")
{
   "aAA" { "OK" }
   "aaA" { "OK" }
   "aaa" { "OK" }
}

Pour que la comparaison soit sensible à la casse, il faut ajouter -casesensitive, par exemple:

switch -casesensitive ("AAA")
{
   # ... 
}

Utilisation de “wildcards”

On peut utiliser des wildcards, par exemple:

switch -wildcard ("AAA")
{
   "AA*" {"OK"}
   "AA?" {"OK"}
   "A??" {"OK"}
}

Définition des listes d’éléments

Arrays

On peut définir des tableaux simples de la façon suivante:

$tab1=@(1, 2, 3, 4, 5, 6)
$tab2=1, 2, 3, 4, 5, 6

L’accès aux éléments du tableau se fait classiquement avec des crochets:

$tab1[3]
# On peut utiliser une variable contenant la valeur d'un index:
$i=2
$tab2[$i]

Listes .NET

On peut utiliser aussi des listes .NET de cette façon:

$dotnetList=New-Object Collections.Generic.List[string]

# Pour ajouter un élément:
$dotnetList.Add("first item") 

# Pour accéder à un élément à un index particulier:
$dotnetList.Item(0)

Table de hashage

Une table de hashage peut se définir de cette façon:

$hashTable= @{
    "key1" = "value1";
    "key2" = "value2";
    "key3" = "value3";
    "key4" = "value4"
}

L’accès à un élément se fait en écrivant: $hashTable["key3"].

Boucles

Boucle “while”

Une boucle while s’implémente de cette façon:

$i = 0
while ($i -le 5)
{
   $i = $i + 1
}

do…while

L’implémentation du do...while se fait de cette façon:

$i = 0
do
{ $i = $i + 1 }     # un équivalent à cette incrémentation est $i++
while ($i -le 5)

do…until

Pour coder une boucle do...until, on peut écrire:

$i = 0
do
{ $i++ }
until ($i -gt 5)

Boucle “for”

La boucle for s’implémente de façon classique:

for ($f = 0; $f -le 5; $f++)
{
   # ... 
}

Boucle “foreach”

De même la boucle foreach est proche de son équivalent en C#:

$array = 1, 2, 3, 4
foreach ($item in $array)
{
   "`$item = $item"
}

On peut utiliser une boucle foreach avec le résultat de cmdlets, par exemple:

Set-Location "<chemin sur le disque>"
foreach ($file in Get-ChildItem)
{
   $file.Name
}

“break” et “continue”

On peut utiliser break comme en C# pour stopper l’exécution d’une boucle:

foreach ($file in Get-ChildItem)
{
   break
}

De même qu’en C#, on peut aussi utiliser “continue” pour passer à l’itération suivante sans exécuter les instructions se trouvant après le continue.

Utilisation de “labels” avec “break” et “continue”

Les labels sont des espèces de “goto” pour indiquer à quel niveau d’une boucle, on souhaite que l’exécution se poursuive. Ces labels permettent d’effectuer des sauts vers une boucle particulière quand on se trouve dans des foreach imbriqués:

Dans l’exemple suivant, le label est outsideloop et break outsideloop permet de stopper de la première boucle foreach:

:outsideloop foreach( ... )
{
   foreach ( ...)
   { 
      break outsideloop
   }
}

L’exécution va donc s’arrêter au niveau de la 1ere boucle.

On peut aussi utiliser des “labels” avec continue:

:outsideloop foreach( ... )
{
   foreach ( ...)
   { 
      continue outsideloop
   }
}

Dans ce cas, l’exécution va continuer au niveau de la première boucle foreach.

Scripts blocks

Un script block est un bloc de code qu’on peut définir au préalable et qui se sera pas exécuté au moment de cette définition. On pourra exécuter ce bloc de code par la suite quand on le désire. L’intérêt des script blocks est de pouvoir définir et exécuter une “mini-fonction” dans le corps même d’une fonction.

Les blocs sont désignés avec des accolades { }.

Pour définir un script block, dans un premier temps on l’affecte à une variable:

$var = { Clear-Host; "Powershell" }

Ensuite dans un 2e temps, pour l’exécuter on peut écrire:

&$var 
# ou directement 
& { .... }

Pour utiliser le résultat de l’exécution d’un script block:

$value = (41 + 1)
1 + (& $value)

Remarque: il faut rejouter des parenthèses et écrire (& $value) pour que le corps du script block soit interprêté.

Dans le cas d’un script block, certains éléments sont consommés et d’autres ne le sont pas.

Par exemple, on écrit:

$value = { 42; Write-Host "Powershell" }

42 n’est pas consommé et il peut être utilisé comme valeur dans une instruction suivante.
En revanche Write-Host "Powershell" est consommé (car affiché au moment de l’exécution).

Si on exécute:

1 + (& $value)

On obtient 43.

De même si on exécute:

$value2 = & $value 

La variable $value2 contient 42.

On peut exécuter un script block avec l’instruction Invoke():

$var = { Clear-Host; "Powershell" }
$var.Invoke()

On peut aussi utiliser la cmdlet Invoke-Command:

$var = { Clear-Host; "Powershell" }
Invoke-Command -ScriptBlock $var

Creation d’un “script block” à partir d’une chaîne de caractères

On peut créer un script block à partir d’une chaîne de caractères en écrivant:

$newScript = [scriptblock]::Create("Get-ChildItem")
# ou
$newScript = [System.Management.Automation.ScriptBlock]::Create("Get-ChildItem")

“return”

En utilisant le mot clé return, on peut stopper l’exécution:

$value = { return 42; Write-Host ... }

Ainsi Write-Host ne sera pas exécuté.

Passage de paramètres à un “script block”

Pour passer des paramètres à un script block, il existe 2 méthodes:

Méthode 1: collection d’arguments

Les arguments sont passés dans une collection, dans l’exemple la collection est $arg:

$qa = { $question = $args[0] 
   $answer = $args[1]
}

Si on exécute l’instruction:

& $qa "value1" "value2"

alors arg[0] est égal à value1 et arg[1] est égal à value2.

Avec l’instruction Invoke() avec des arguments, la syntaxe est:

$qa.Invoke("value1", "value2")

Avec Invoke-Command:

Invoke-Command -ScriptBlock $qa -ArgumentList 'value1','value2'

Méthode 2: utiliser “param”

En utilisant le mot clé param on peut définir une liste d’arguments, par exemple:

$qa = { param( $question, $answer )
  Write-Host $question
  Write-Host $answer
}

A l’exécution, pour passer les paramètres la syntaxe est similaire à la méthode de la collection d’arguments:

& $qa "value1" "value2"

On peut aussi utiliser la syntaxe suivante:

& $qa -question "value1" -answer "value2"

On peut aussi utiliser seulement les initiales des paramètres:

& $qa -q "value1" -a "value2"

La syntaxe avec Invoke-Command est similaire à la méthode précédente:

Invoke-Command -ScriptBlock $qa -ArgumentList 'value1','value2'

Vérifier qu’un argument est manquant

Pour vérifier si un argument est manquant, on peut écrire:

$qa = { param($question, $answer )
   if (!$answer)  # si la variable n'est pas affectée
   {
     # ... 
   }
}

Valeur par défaut

Pour affecter une valeur par défaut à un argument, on peut écrire:

$qa = { param($question, $answer = "default value" )
   # ...
}

Forcer le type des paramètres:

$point = { param([int] $x, [int] $y)
   # ...
}

“script block” et “pipelines”

Il est possible d’utiliser des script blocks avec des pipelines. Toutefois les pipelines se définissent dans ce cas en utilisant des mot clés process, begin et end qui seront utilisés pour désigner des blocs de code à exécuter:

  • process: bloc de code qui constitue le corps du script block dans le pipeline.
  • begin: bloc de code qui sera exécuté avant d’exécuter le corps du script block dans le pipeline (i.e. ce bloc sera exécuté avant celui de process).
  • end: bloc de code qui sera exécuté après d’exécuter le corps du script block dans le pipeline (i.e. ce bloc sera exécuté après celui de process).

Par exemple:

$pipeline = { 
   process { 
      if ($_.Name -like "*.ps1" )
      { 
         return $_.Name 
      }
   }
}

Le mot clé process est utilisé pour indiquer les instructions qui seront exécutées à l’exécution du script block dans le pipeline.

Par exemple pour exécuter le script block définit dans la variable $pipeline dans un pipeline, on peut écrire:

Get-ChildItem | &$pipeline

En utilisant begin et end pour définir des instructions à exécuter avant et après l’exécution le script block dans un pipeline:

$pipeline = { 
   begin { $val = "value1" }
   process { 
      if ($_.Name -like "*.ps1" )
      { 
         return $_.Name 
      }
   }
   end { return $val }
}

Mot clé “param” avec les “script blocks”

On peut aussi utliser le mot clé param pour un script bloc utilisé dans un pipeline:

$pipeline = { 
   param ( $headerText )
   begin { ... }
   process { ... }
   end { ... }
}

Par exemple si on exécute le code suivant:

$pipeline = { 
   param ( $headerText )
   begin { Write-Host "Executé avant process: $headerText" }
   process { Write-Host "Execution de process: $headerText" }
   end { Write-Host "Executé après process: $headerText" }
}

& $pipeline "value1"

Le résultat sera:

Executé avant process: value1
Execution de process: value1
Executé après process: value1

Portée des variables dans un “script block”

Le terme employé dans la documentation pour qualifier la portée des variables est “scope”.

Quelques règles s’appliquent aux variables:

  • Les variables déclarées à l’extérieur du script block sont utilisables dans le script block.
  • Les variables modifiées dans le script block ont une portée limitée au script block. La valeur ne sera pas modifiée à l’extérieur du script block.

Portée des paramètres dans un “script block”

Le paramètre -scope utilisé dans un script block avec les cmdlets Get-Variable et Set-Variable permettent d’indiquer des perimètres de portée. Ainsi suivant le périmètre qu’on a définit, le comportement différe du comportement par défaut.

Par exemple, en utilisant -scope 1 on indique que l’on souhaite avoir la valeur du parent du script block:

$var = 42
& { $var = 33; 
     Write-Host "$var" 
     Write-Host "Parent: " (Get-Variable var -valueOnly -scope 1 )
}

Dans ce cas la valeur de "$var" sera 33 alors que la valeur de "Parent: " (Get-Variable var -valueOnly -scope 1 ) sera Parent: 42

De même avec Set-Variable, -scope 1 permet d’indiquer qu’on souhaite modifier la valeur de la variable au niveau du parent:

$var = 42
Write-Host "Valeur au niveau parent: $var"
& { Set-Variable var 33 -scope 1
    Write-Host "Valeur dans le script block: $var"
}

Write-Host "Valeur après le script block: $var"

Le résultat de cette exécution est:

Valeur au niveau parent: 42
Valeur dans le script block: 33
Valeur après le script block: 33

Utiliser -scope 1 n’est pas trés clair, il est préférable d’utiliser global ou private.

Utiliser “$global” pour modifier une variable de façon globale

Le mot clé $global permet de modifier la valeur d’une variable de façon globale. En reprenant l’exemple précédent:

$var = 42
Write-Host "Valeur au niveau parent: $var"
& { $global:var = 33
write-host "Valeur dans le script block: $var" }

Write-Host "Valeur après le script block: $var"

Le résultat de cette exécution est:

Valeur au niveau parent: 42
Valeur dans le script block: 33
Valeur après le script block: 33

$global: permet d’indiquer qu’on souhaite modifier la valeur du parent. Il s’utilise avec les deux points : et sans le $.

Utiliser “$private” pour modifier une variable de façon locale

Avec le mot clé $private la valeur d’une variable ne sera pas affectée dans le script block, par exemple:

$value = 42
Write-Host "Valeur au niveau parent: $value"
& { $private:value = 21
Write-Host "Valeur dans le script block: $value" }

Write-Host "Valeur après le script block: $value"

Le résultat est:

Valeur au niveau parent: 42
Valeur dans le script block: 21
Valeur après le script block: 42

Dans ce cas la valeur de $value n’est pas modifiée que dans le script block.

Fonctions

Les fonctions sont comme les script blocks sauf qu’elles sont définies de façon plus globale et non à l’intérieur d’une autre fonction.

Pour définir une fonction:

function NomFonction ( $value1, $value2)
{
  # ... 
  Write-Host $value1
  Write-Host $value2
}

Pour appeler la fonction:

NomFonction "val1" "val2"
# ou 
$par1 = "val1"
$par2 = "val2"
NomFonction $par1 $par2

On n’utilise pas de parenthèses pour indiquer les paramètres lors de l’appel, on indique directement les paramètres séparés d’un espace.

Passer des variables par référence

Pour qu’une variable soit passé en argument par référence, on utilise le mot clé [ref]. L’intérêt de passer une variable par référence est de pouvoir modifier sa valeur dans le corps de la fonction et de l’utiliser à l’extérieur de celle-ci.

Par exemple dans le code suivant, la valeur de la variable $par1 sera 33 à l’extérieur du corps de la fonction après exécution de cette fonction:

function NomFonction ([ref] $par1)
{
   $par1.Value = 33
   Write-Host "Valeur modifiée par la fonction: "$par1.Value
}

Si on exécute la fonction précédente de cette façon:

$value = 23
Write-Host "Valeur avant la fonction: $value"
NomFonction([ref] $value)
Write-Host "Valeur après la fonction: $value"

Le résultat est:

Valeur avant la fonction: 23
Valeur modifiée par la fonction: 33
Valeur après la fonction: 33

Il faut ajouter .value car avec [ref] powershell considère que la variable est de type object.

Pour appeler une fonction avec [ref], on écrit:

NomFonction([ref] $var)

Ainsi si on utilise [ref] et si la fonction change la valeur de la variable, la valeur sera modifiée à l’extérieur de la fonction.

Les “pipelines” dans une fonction

La syntaxe est la même que pour les script blocks. Il faut utiliser les mot clés process, begin et end pour désigner des blocs de code à exécuter:

  • process: bloc de code qui sera exécuté dans le pipeline.
  • begin: bloc de code qui sera exécuté avant le bloc process.
  • end: bloc de code qui sera exécuté après le bloc process.

Par exemple si on définit la fonction:

function NomFonction()
{
   begin { Write-Host "Exécuté avant process" }
   process { Write-Host "Exécution de process" }
   end { Write-Host "Exécuté après process" }
}

Et si on exécute la fonction avec:

NomFonction

Le résultat est:

Exécuté avant process
Exécution de process
Exécuté après process

Filtres

Les filtres sont des espèces de fonctions sans paramètres. L’intérêt des filtres est de les utiliser dans des pipelines.

Pour définir un filtre, on utilise le mot clé filter:

filter NomFiltre
{
   # ...
   Write-Host $_.Name
}

$_ permet d’utiliser la valeur d’un élément à une itération du pipeline.

On peut combiner des fonctions et des filtres dans un pipeline.

Par exemple, si on exécute la ligne suivante:

Get-ChildItem | NomFiltre

On obtient seulement le noms des fichiers se trouvant dans le répertoire courant.

Utiliser des “switch”

Les switch permettent d’indiquer des paramètres facultatifs dans une fonction sous la forme de booléen. Quand ces paramètres ne sont pas renseignés, ils n’ont pas de valeur. Les switch ne sont pas des paramètres facultatifs mais seulement des booléens qui peuvent être utilisés pour activer ou désactiver une fonctionnalité.

Par exemple, en utilisant le mot clé switch devant les paramètres $verbose et $debug on peut indiquer que ces paramètres sont des booléens facultatifs:

function NomFonction()
{
   param([switch] $verbose, [switch] $debug)
   if ($verbose.IsPresent)
   {
      Write-Host "Verbose est présent"
   }
   else
   {
      Write-Host "Verbose n'est pas présent"
   }
}

$verbose.IsPresent permet de tester si la variable $verbose a une valeur ou non.

Pour appeler une fonction qui utilise des switch, on peut écrire:

NomFonction -verbose -debug

Dans ce cas là, le résultat est:

Verbose est présent

Si on exécute la ligne suivante:

NomFonction -debug

Le résultat sera:

Verbose n'est pas présent

L’article suivant permet de détailler la syntaxe pour afficher des messages d’aide, de gérer des erreurs dans les fonctions et comporte des exemples pour manipuler des fichiers.

Les autres articles de cette série

Partie 1: exécuter Powershell

Partie 2: les cmdlets

Partie 3: instructions dans des scripts Powershell

Partie 4: aide, gestion d’erreurs et manipulation de fichiers

Références

Powershell en 10 min: les cmdlets (partie 2)

Le but de cet article est de détailler l’utilisation des commandes Powershell. Ces commandes s’appellent des “command-lets” mais l’abbréviation cmdlets est couramment utilisée pour les désigner.
Les cmdlets peuvent être exécutées dans une console Powershell directement ou dans un script .ps1.

Les commandes disponibles en Powershell sont plus nombreuses et plus perfectionnées que les commandes batch classiques:

  • Commandes batch: on peut utiliser les mêmes commandes batch en powershell comme: cd, dir, copy etc…
  • Commandes Linux: on peut utiliser des commandes provenant de Linux comme: cd, ls, cp etc…
  • Commandes cmdlet: ce sont des commandes spécifiques à Powershell (cmdlet se prononce “command-let”).

La syntaxe des cmdlets est souvent la même c’est-à-dire:

<Verbe>-<Nom>

On peut trouver le verbe et nom en minuscules ou avec la première lettre en majuscule comme, par exemple:
Get-Help, Get-Command etc…

Powershell n’est pas sensible à la casse

Powershell n’est pas sensible à la casse, les commandes écrites en majuscules ou en minuscules seront interprétées de la même façon. De même, Powershell n’est pas sensible aux espaces ou tabulations.

Dans le cas où la cmdlet comporte un argument sous forme d’option, la syntaxe que l’on peut rencontrer peut être de la forme:

<verbe>-<nom> -<nom_option> <valeur de l'option>

Par exemple:

Start-Service -name eventlog

PowerShell Module Browser

Microsoft a mis en place un browser capable d’afficher de l’aide sur toutes les commandes Powershell: PowerShell Module Browser.
Ce browser est particulièrement efficace puisqu’il permet d’accéder aux pages d’aides pour toutes les commandes et pour toutes les versions de Powershell.
On peut, d’autre part, restreindre la recherche à une version spécifique en cliquant sur “Tous les paquets” et en sélectionnant la version adéquate.

Alias

Il est possible d’utiliser des alias pour raccourcir le nom des cmdlets. Ces alias ne sont pas forcément clairs toutefois ils permettent de raccourcir des cmdlets fréquemment utilisées.

Afficher les “alias”

Get-Alias permet d’afficher tous les alias.

Get-ChildItem est un équivalent de ls.

Paramétrer un “alias” personnalisé

On peut paramètrer des alias personnalisés en utilisant la syntaxe suivante:

Set-Alias <nom de l´alias> <commande à exécuter>

Par exemple:

Set-Alias list Get-ChildItem

La durée de vie de l’alias est égal à celle de l’invite de commande Powershell. Si on la ferme, l’alias est perdu.

Persister un “alias”

Pour persister un alias, on peut utiliser la commande Export-Alias:

Export-Alias "<fichier CSV où l´alias sera sauvegardé>" <nom de l´alias>

Récupérer un “alias” sauvegardé

Pour récupérer un alias sauvegardé dans un fichier CSV:

Import-Alias "<chemin du fichier CSV où l´alias a été sauvegardé>" 

Quelques exemples de “cmdlets”

Afficher toutes les commandes

Pour afficher toutes les commandes disponibles:

Get-Command

On peut ajouter des arguments pour chercher suivant un critère particulier:

Get-Command –verb "<nom du verbe>" 
Get-Command –noun "<nom à chercher>"

Changer le répertoire courant

Pour changer le répertoire courant:

Set-Location "<chemin du répertoire>" 

Plus simplement on peut utiliser les alias proches des commandes utilisées sur Linux ou sur le batch comme cd.

Lister le contenu d’un répertoire

Pour avoir le contenu d’un répertoire:

Get-ChildItem

L’alias pour cette commande est ls.

Afficher de l’aide

Pour afficher de l’aide sur les commandes, on peut écrire:

Get-Help <nom de la commande>

Pour afficher l’aide concernant une commande avec des exemples, on peut écrire:

Get-Help <nom de la commande> -examples

On peut aussi utiliser pour avoir de l’aide:

<nom de la commande> -?

Par exemple:

Get-Command -? 

Effacer le contenu d’une console

Pour effacer le contenu de la ligne de commandes:

Clear-Host

Afficher l’historique de la console

Il suffit de taper:

Get-History

Avoir la liste des processus

Pour avoir la liste des processus en cours d’exécution sur la machine, on peut exécuter:

Get-Process

Lancer un processus

Pour lancer un processus, on peut utiliser la commande Start-Process avec le chemin de l’exécutable. En utilisant cette commande avec un fichier, un programme par défaut se lancera suivant l’extension
de ce fichier, par exemple:

$filePath = "example.csv" 
$process = Start-Process $filePath

On peut aussi lancer un programme avec des arguments de cette façon:

$args = "customFile.txt"
& 'C:\Windows\System32\notepad.exe' $args

Référencer un autre fichier .ps1

Pour référencer à un autre fichier de scripts .ps1 de façon à utiliser les fonctions qu’il contient, on peut écrire:

Import-Module "<chemin du fichier .ps1>"

Retarder l’exécution

On peut mettre en pause l’exécution pendant quelques secondes en écrivant:

Start-Sleep -Seconds 3
Ecrire une commande sur plusieurs lignes

On peut écrire une commande sur plusieurs lignes en utilisant le caractère ` (accent grave), ce caractère est accessible avec la combinaison [AltGr] + [7].

Par exemple si on écrit la commande suivante, elle sera interprétée en une seule ligne:

Get-Process | `
where-Object {$_.Name -like "powershell"} | `
Select-Object Id

Mettre en forme des données

Certaines commandes renvoient des listes d’éléments, il est possible de mettre en forme ces éléments pour rendre plus lisible leur lecture. Cette mise en forme peut se faire en utilisant:

  • Format-List: pour lister tous les éléments sous la forme d’une liste avec une seule colonne.
  • Format-Table: pour lister les éléments sous la forme d’un tableau avec plusieurs colonnes.
  • Format-Wide: pour lister les éléments sous la forme de plusieurs colonnes, on peut choisir le nombre de colonnes.

Par exemple:

Get-Process | Format-List
Get-Process | Format-Table 

# Pour mettre en forme sur 3 colonnes
Get-Process | Format-Wide -Column 3

Chaines de caractères

Comme en .NET, les chaines de caractères sont identifiées par des quotes:

$var="classic string"

Par défaut, les variables utilisées dans les chaines de caractères avec des double quotes (cf. " ") sont remplacées par leur valeur. Ainsi si on écrit:

$a="powershell"
Write-Host "classic $a string"

On aura:

classic powershell string

Pour que les variables à l’intérieur d’une chaine de caractères ne soient pas interprétées, il faut définir la chaine avec quotes simples (cf. ' '):

$b='classic $a string'
Write-Host $b

Le résultat sera:

classic $a string

Pour définir une chaine de caractères sur plusieurs lignes, on utilise les caractères @. Les variables se trouvant dans une chaine définie avec @ sont interprétées.

Par exemple:

$bigString= @"
      Ligne 1
      Ligne 2
      Variable: $a
      Ligne 3 etc...
"@
Write-Host $bigString

Le résultat sera:

      Ligne 1
      Ligne 2
      Variable: powershell
      Ligne 3 etc...

Pour ne pas interpréter les variables avec l’utilisation du caractère @, il faut précéder la variable avec ` (` est le caractère accent grave accessible avec [AltGr] + [7]). Avec la chaine précédente, on aurait:

$bigString= @"
      Ligne 1
      Ligne 2
      Variable: `$a
      Ligne 3 etc...
"@
Write-Host $bigString

Dans ce cas le résultat sera:

      Ligne 1
      Ligne 2
      Variable: $a
      Ligne 3 etc...

Retour à la ligne

Pour effectuer des retours à la ligne dans une chaine de caractères, on peut utiliser `n (` est le caractère accent grave accessible avec [AltGr] + [7]):

$c="Retour à la ligne: `n Nouvelle ligne"
Write-Host $c

Le résultat sera:

Retour à la ligne:
Nouvelle ligne
Tabulation

On peut aussi noter le caractère `t pour désigner une tabulation dans une chaine de caractères.

Utiliser le Framework .NET

Powershell est basé sur .NET. Tout est un objet .NET. Ainsi, beaucoup d’objets sont disponibles en Powershell de la même façon qu’en .NET.

Par exemple si on affecte une chaine de caractères $a = "powershell":

  • $a permet d’obtenir la valeur de la variable
  • $a.Length permet d’obtenir la longueur de la chaine
  • $a.GetType() permet d’obtenir le type de la valeur de la variable.

Accès aux objets statiques

Powershell permet d’appeler des objets .NET statiques ce qui étend davantage ses fonctionnalités. Pour appeler une méthode du framework .NET on peut taper une instruction de type:

[<objet du framework .NET>]::<nom de la méthode>

Par exemple pour appeler la méthode statique suivant qui se trouve dans le namespace System.Reflection:

public static Assembly Load(string assemblyString)

On peut utiliser la syntaxe:

[System.Reflection.Assembly]::Load("System.Web")

Instanciation d’objets

L’instanciation de nouveaux objets .NET se fait en utilisant New-Object, par exemple:

$xmlDoc = New-Object System.Xml.XmlDocument

Utiliser des “pipelines”

Les pipelines permettent d’utiliser plusieurs commandes successivement suivant un pipeline de commandes.

Par exemple, pour avoir la liste des fichiers dont la taille est supérieure à 100kb:

Get-ChildItem | Where-Object { $_.Length -gt 100kb }

$_ permet de spécifier l’élément courant lors de l’exécution en boucle des éléments renvoyés par Get-ChildItem.

Pour indiquer la taille, on peut utiliser:

  • Kb pour kilo bytes
  • Mb pour mega bytes
  • Gb pour giga bytes

Pour trier par ordre alphabétique:

Get-ChildItem | Where-Object { $_.Length -gt 100kb } | Sort-Object Length 

Pour formatter la sortie de Get-ChildItem:

Get-ChildItem | `
  Where-Object { $_.Length -gt 100kb } | `
  Sort-Object Length | `
  Select-Object Name, Length

Autre exemple permettant de lister les ID des processus dont le nom contient le mot “powershell”:

Get-Process | `
  where{$_.Name -like "powershell"} | `
  Select Id

Powershell “providers” (utilisation des “snap-ins”)

Un provider est une bibliothèque .NET qui permet de mettre à disposition des commandes de façon à pouvoir naviguer dedans. On peut utiliser les mêmes commandes pour naviguer dans les différents types d’objets mis à disposition dans le provider.

Get-PSProvider permet d’avoir tous les providers disponibles par défaut.
Tous les providers sont utilisables sur des drives. C’est à travers ces drives qu’on peut naviguer et retrouver les données.

Get-Psdrive permet d’afficher tous les drives.

Par exemple, si on se place dans le drive consacré à l’environnement on peut explorer le contenu des variables d’environnement.
Pour se placer dans le drive de l’environnement, il faut exécuter:

Set-Location env:

On peut alors lister les variables d’environnement en écrivant:

Get-ChildItem 

Comme précédemment, on peut formater les données en faisant:

Get-ChildItem | Format-Table –Property Name, Value –Autosize 

Pour avoir la liste des alias, on peut écrire:

Set-Location alias: 
Get-ChildItem

Pour revenir au drive correspondant au système de fichiers, il suffit de se placer dans le drive d’un volume (par exemple le disque C):

Set-Location c: 

Ajouter un “provider”

Pour ajouter un provider, on effectue cette opération en utilisant des snap-ins:

Pour lister tous les snap-ins disponibles (c’est-à-dire enregistrables), on exécute:

Get-PSSnapin

Pour lister les snap-ins enregistrés:

Get-PSSnapin –Registered

Ces snap-ins sont enregistrés mais non exécutés.

Pour charger un snap-in qui est enregistré:

Add-PSSnapin <nom de l'addin>

On peut vérifier qu’ils sont bien chargés en exécutant:

Get-PSSnapin –Name <nom de la commande>

Quand on rajoute un snap-in, il ajoute un drive et on peut accéder à ce drive de la même façon que pour les autres exemples:

Set-Location <nom du drive>:
Get-ChildItem

Quelque soit le provider qu’on utilise, on y accéde de la même façon en utilisant les commandes Set-Location et Get-ChildItem.
D’autre part, la navigation dans les données mises à disposition pour le provider se fait de la même façon que la navigation dans un système de fichiers:

  • Il y a une notion de hiérarchie
  • On peut indiquer l’élément dans lequel on veut naviguer en exécutant Set-Location
  • On obtient la liste des éléments en exécutant Get-ChildItem.

Supprimer un “provider”

On exécute une commande permettant de supprimer un snap-in:

Remove-PSSnapin <nom de l'add-in>

Cet article a détaillé les éléments principaux permettent d’utiliser les cmdlets. Les articles suivants permettent de rentrer davantage dans les détails des instructions utilisables dans des scripts Powershell.

Powershell en 10 min: exécuter Powershell (partie 1)

Avant de commencer à parler des intructions Powershell, cet article a pour but d’expliquer comment exécuter du code Powershell.

Installation de Powershell

Powershell est livré avec toutes les versions récentes de Windows, toutefois les versions différent suivant la version de Windows:

Version de Powershell Date de sortie Disponible par défaut Disponibilités sur d’autres versions
5.1 Janvier 2017 Windows 10 Anniversary Update Windows 7 SP1,
Windows 8.1,
Windows Server 2012, 2012 R2
5.0 Février 2016 Windows 10 Windows 7 SP1,
Windows 8.1,
Windows Server 2012, 2012 R2
4.0 Octobre 2013 Windows 8.1, Windows Server 2012 R2 Windows 7 SP1
Windows Server 2008 R2 SP1, 2012
3.0 Septembre 2012 Windows 8, Windows Server 2012 Windows 7 SP1
Windows Server 2008 SP2, 2008 R2 SP1
2.0 Octobre 2009 Windows 7, Windows 2008 R2 Windows XP SP3,
Windows Server 2003 SP2
Windows Vista SP1, SP2
Windows Server 2008 SP1, 2008 SP2
1.0 Novembre 2006 Windows Server 2008 Windows XP SP2, SP3
Windows Server 2003 SP1, 2003 SP2, 2003 R2
Windows Vista, Windows Vista SP2

Il est possible d’upgrader la version de Powershell quelque soit la version de Windows citée ci-dessus. Actuellement (août 2017), la version la plus récente est Powershell 5.1 disponible en téléchargeant Windows Management Framework 5.1.

Les versions de Powershell sont rétrocompatibles donc il n’y a pas de risques à installer les dernières versions.

Pour vérifier la version de Powershell, il faut ouvrir une console Powershell et taper:

$PSVersionTable.PSVersion

Le résultat sera du type:

D’une façon générale, plus la version de Powershell est récente et plus elle supporte de commandes. On peut avoir une liste des fonctionnalités disponibles sur: What’s New in Windows PowerShell

Attention aux différences entre Powershell 32 bits et 64 bits

Sur une machine 64 bits, 2 versions de Powershell sont disponibles:

  • 64 bits: %windir%\System32\WindowsPowerShell\v1.0\powershell.exe
  • 32 bits: %windir%\SysWOW64\WindowsPowerShell\v1.0\powershell.exe

Il faut être vigilant sur le choix de la version utilisée en particulier lorsqu’on utilise des snap-ins qui sont compilés pour une architecture précise.

A l’exécution d’un script, on peut connaître l’architecture d’exécution en utilisant la propriété suivante:

[Environment]::Is64BitProcess

Si la valeur est à True alors le script est exécuté dans un environnement 64 bits. Si la valeur est False, l’environnement est 32 bits.

Utilisation de la console

Pour lancer une console Powershell, on peut taper:

  1. Touches [Windows] + [R]
  2. Taper powershell puis [entrée]

On peut aussi taper sur la touche [Windows] puis taper powershell, toutefois il faut faire attention à la version choisie:

  • “Windows Powershell” correspond à la version 64 bits
  • “Windows Powershell (x86)” correspond à la version 32 bits

Enfin à partir du menu Windows, on peut lancer la console en cliquant sur Accessoires ➔ Windows Powershell

Dans la console, quelques fonctionnalités peuvent s’avérer utiles comme:

  • La complétion avec la touche [Tabulation] ou [Majuscule] + [Tabulation]
  • Un historique disponible en tapant [F7] et en choississant avec les flèches la ligne d’historique que l’on souhaite atteidre
  • En cliquant sur le bord en haut à gauche de l’invite de commande Powershell, on peut accéder à d’autres options.

Exécuter un script Powershell

Pour exécuter des scripts Powershell (i.e. fichier ps1) sur une machine, il faut que la politique d’exécution du système le permette. Cette politique n’est pas vraiment un élément de sécurité mais juste un dispositif pour éviter d’exécuter du code Powershell par inadvertance.

Pour connaître la politique d’exécution des scripts powershell sur une machine, il faut exécuter:

Get-ExecutionPolicy -List

Pour avoir plus d’informations sur cette cmdlet, se reporter à Get-ExecutionPolicy.

En exécutant la commande suivante:

Get-ExecutionPolicy -List | Format-Table -AutoSize

On obtient un résultat du type:

Les informations indiquées permettent de savoir quelle est la politique d’exécution suivant une portée (i.e. scope):

  • Process: portée du processus Powershell
  • CurrentUser: portée de l’utilisateur actuel
  • LocalMachine: portée pour tous les utilisateurs de la machine
  • UserPolicy: portée du groupe d’utilisateurs pour l’utilisateur actuel
  • MachinePolicy: portée du groupe d’utilisateurs pour tous les utilisateurs de la machine.

Les valeurs possibles de la politique sont:

  • Restricted: on ne peut pas exécuter des scripts Powershell
  • AllSigned: pour s’exécuter les scripts doivent être signés par un éditeur de confiance.
  • RemoteSigned: les scripts écrits localement sont autorisés en revanche les scripts téléchargés doivent être signés par un éditeur de confiance. Il est possible d’autoriser les scripts téléchargés et non signés en utilisant la cmdlet Unblock-File.
  • Unrestricted: tous les scripts peuvent être exécutés. Un message d’avertissement est affiché lors de l’exécution de scripts téléchargés informant des risques.
  • ByPass: aucune vérification n’est effectuée, tous les scripts peuvent être exécutés.

On peut avoir plus d’informations sur cette politique sur: About Execution Policies.

Dans le cas où on ne peut pas exécuter un script Powershell, à l’exécution on aura une erreur du type:

powershellScript.ps1
powershellScript.ps1 cannot be loaded because the execution of scripts is disabled on this system.

Pour modifier la politique d’exécution et autoriser l’exécution des scripts, on peut exécuter avec les droits administrateur:

Set-ExecutionPolicy RemoteSigned

Pour ouvrir la ligne de commandes Powershell avec les droits administrateur, il faut:

  1. Aller dans Accessoires ➔ Windows PowerShell
  2. Clique droit sur “Windows PowerShell”
  3. Cliquer sur “Exécuter en tant qu’administrateur…”

Comme indiqué plus haut, ce paramètre permettra d’exécuter les scripts locaux sans entrave. En revanche les scripts téléchargés doivent être signés par un éditeur de confiance.
D’autres paramétrages sont plus ouverts comme ByPass ou Unrestricted.

Cette politique n’est pas un dispositif de sécurité mais juste un mécanisme pour éviter d’exécuter des scripts par inadvertance. Même dans le cas où l’exécution n’est pas autorisée, on peut exécuter des cmdlets directement à la ligne de commandes. D’autre part, on peut aussi exécuter des scripts en les affichant directement sur le ligne de commandes et en exécutant avec un pipeline, par exemple:

Get-Content <chemin du script ps1> | PowerShell.exe -noprofile - 

De nombreuses autres méthodes existent: 15 Ways to Bypass the PowerShell Execution Policy.
On peut signer les scripts Powershell: Signing PowerShell Scripts.

Utiliser un environnement pour développer des scripts Powershell

Pour faciliter le développement de scripts Powershell, on peut utiliser le “Windows Powershell ISE” (ISE pour “Integrated Scripting Environment”). Cet environnement est disponible à partir du menu Windows dans Accessoires ➔ Windows PowerShell ➔ Windows PowerShell ISE.

De même que pour l’invite de commande, il existe 2 versions de Windows Powershell ISE:

  • Une version 32 bits identifiée par “Windows Powershell ISE (x86)”
  • Une version 64 bits identifiée par “Windows Powershell ISE”.

Dans l’ISE, on peut avoir des scripts qu’on exécute entiérement ou en partie. Pour exécuter un script entièrement, il suffit d’appuyer sur [F5]. Pour exécuter seulement les commandes surlignées, il faut appuyer sur [F8].

L’ISE se présente de cette façon:

Microservices en 10 min: tests et déploiement (partie 3)

Intégration continue

Dans le cas d’une application en monolithe, le processus d’intégration continue passe par l’exécution des tests pour toute l’application. Dans le cas d’une application en microservices, on peut de la même façon choisir de considérer l’application comme un composant unique qui doit être testé intégralement. Cette approche est dans le prolongement de celle utilisée pour les applications en monolithe, elle a pour avantage de garantir que l’approche en microservices ne dégrade pas l’application.
Le gros inconvénient de maintenir une approche similaire à celle d’une application en monolithe est de ne pas tirer partie du découpage en microservices. Ce découpage apporte une grande flexibilité puisqu’il permet de découper l’application en domaines fonctionnels et d’affecter ces domaines à des équipes particulières.
Il n’y a pas de meilleures solutions entre choisir de considérer de tester l’application dans son intégralité ou choisir de la tester par service. Il faut juste arbitrer en fonction de ses contraintes opérationnelles.

De façon générale, l’intégration continue peut se résumer de cette façon:

  • Compiler l’application sur un serveur de builds après chaque commit.
  • Exécuter les tests à chaque commit de façon à avoir un retour rapide sur les éventuelles régressions qui auraient pu être introduites.
  • Le résultat de la compilation et de l’exécution des tests doit être visible par tous les développeurs de façon à ce qu’ils puissent se rendre compte des éventuels échecs de compilation ou de tests dont l’exécution aurait échoué.
  • Quand une build est en échec à cause d’une erreur de compilation ou d’un test en défaut, il faut corriger au plus vite.
  • Une nouvelle version de l’application doit être déployée après chaque commit.


Effectuer une “build” pour tous les services

Cette approche consiste à compiler et à exécuter les tests pour toute l’application et pour tous les services à chaque commit d’un service.
Cette approche est la plus facile à mettre en œuvre car il suffit d’une seule usine de builds pour tous les services. Elle est dans le prolongement de l’approche utilisée pour une application en monolithe. Il n’y aura donc pas beaucoup d’adaptations nécessaires à l’usine de builds lors du découpage d’une application en monolithe en une application en microservices.

L’intégration continue consiste à effectuer une build après chaque commit. Dans le cas d’une application en microservices, il faudra donc builder tous les services après chaque commit.
Builder tous les services après chaque commit peut s’avérer de plus en plus compliqué à mesure que le nombre de développeurs augmente. De la même façon, plus le nombre de services augmente et plus les commits seront fréquents.

Effectuer une “build” par service

Effectuer une build par service consiste à considérer une usine de builds par service et non pour toute l’application. Chaque microservice devient indépendant en terme de livraison ce qui signifie qu’ils sont livrés indépendamment l’un de l’autre.

Cette approche est plus compliquée à mettre en œuvre car elle nécessite de mettre en place une usine de build par service et non pour toute l’application. Toutefois, effectuer une build par service permet:

  • D’exécuter des tests de façon plus ciblée puisqu’ils seront exécutés suivant la livraison d’un service précis.
  • Cette approche donne la possibilité de déployer un service à la fois.
  • Sachant que souvent le découpage des équipes épouse celui des services, effectuer une build par service permet de responsabiliser davantage chaque équipe. Une équipe est responsable d’un service donc en cas d’échec de la build de ce service, cette seule équipe est concernée. Les responsabilités sont, ainsi, clairement définies. Cette approche est en accord avec le principe “You build it, you run it” énoncé par Werner Vogels d’Amazon.

Avoir une build par microservice n’est pas facile à mettre en œuvre pour un projet from scratch car les contextes bornés (i.e. bounded context) ne sont pas forcément très stables. Au début de la conception d’un projet, on peut être amener à modifier fréquemment l’architecture et le découpage fonctionnel des microservices. Ainsi une approche de construire une build par microservice nécessite d’adapter systématiquement l’usine de build de tous les services en fonction des nouvelles organisations. Cette approche entraîne donc davantage de travail de la part des équipes de développement.

Même si l’approche une build par microservice est plus contraignante, il faut tenter de l’atteindre le plus vite possible quand les contextes bornés sont stabilisés. De même, dans le cas de l’adaptation d’un monolithe, les contextes bornés sont généralement plus stables on peut donc appliquer cette approche dès le début.

Plusieurs “build pipelines”

Un des objectifs du continuous delivery est d’avoir un feedback rapide sur une build après le commit d’un développeur. Cette contrainte est importante car avoir des builds rapides à exécuter permet de déployer des services rapidement.

Pour que le feedback soit rapide, il faut que les tests s’exécutent rapidement. Or, certains tests peuvent être longs à s’exécuter, en particulier si plusieurs services sont impliqués ou si des tests entraînent l’exécution d’un workflow complexe. Pour éviter qu’une build soit trop pénalisée par l’exécution de tests longs et pour permettre d’avoir un feedback rapide, une solution consiste à considérer plusieurs pipelines:

  • Une build exécutant des tests rapides: ces tests sont exécutés à chaque livraison. Etant donné que leur exécution est rapide, ils n’empêchent pas d’avoir un feedback rapide après chaque commit.
  • Une build exécutant les tests longs: étant donné que ces tests sont longs à exécuter, ils ne peuvent pas être exécutés après chaque commit. Par exemple, on peut les exécuter de façon différée une fois par jour.

Appliquer des tests

Dans le cadre de tests appliqués à des microservices, on peut se demander quels sont les composants sur lesquels on va appliquer les tests:

  • Doit-on tester tout le système ?
  • Faut-il appliquer les tests à tous les services ?
  • Doit-on tester les interactions entre les services ?
  • Faut-il tester les services comme une boite noire ?
  • Doit-on exécuter des tests internes aux services ?

Il n’est pas trivial de répondre à ces questions d’autant que le premier réflexe est de chercher à tester une application en microservices comme on pourrait tester un monolithe. On va voir par la suite que certaines approches courantes dans le cas des monolithes ne sont pas forcément très pertinentes pour les microservices.

Pyramide des tests

La pyramide des tests (cf. TestPyramid) est un concept énoncé par Mike Cohn. Elle indique que:

  • Tests unitaires: 80 à 90% des tests sont des tests unitaires visant à tester des éléments précis de l’application comme des classes ou des fonctions. Ces tests sont peu couteux à exécuter et s’exécutent généralement rapidement car ils n’impliquent pas beaucoup de code.
  • Tests d’acceptance: ils concernent 5 à 15% des tests. Ils concernent une ou plusieurs fonctionnalités. Ils visent à tester la réponse de l’application à une fonctionnalité dans un cadre précis. Ces tests sont plus couteux et plus longs à exécuter que les tests unitaires car ils nécessitent l’exécution de plus de code et des interactions entres des éléments plus complexes.
  • Tests IHM: ils représentent 1 à 5% des tests. Ces tests vérifient les comportements de l’application au niveau de l’IHM ou plus généralement des entrées/sorties. Ils correspondent au niveau le plus élevé où il est possible d’appliquer des tests à l’application. Ces tests sont couteux car ils sont complexes à mettre en œuvre et qu’ils nécessitent toute l’application pour s’exécuter.

Dans le cadre des microservices, la pyramide des tests peut s’exprimer de cette façon:

Tests unitaires

Comme pour une application classique, on peut appliquer des tests unitaires à des microservices. Il n’y a pas de spécificités dans le cadre des microservices:

  • Ces tests sont nombreux car ils s’appliquent à des classes et des fonctions.
  • Ils s’exécutent rapidement.
  • Comme pour n’importe quel type d’application, on peut mettre en place une approche TDD (i.e. Test-Driven Design ou conception dirigée par les tests).
  • Les tests unitaires s’appliquent seulement à des fonctions internes du service.

De nombreux outils existent pour faciliter l’application des tests unitaires: mockito, Moq, nUnit, JUnit etc…

Tester un service

En s’élevant d’un niveau dans la pyramide des tests, on peut chercher à tester un service entier. Cette approche est utile car elle permet de vérifier que le service réalise correctement sa fonction. On peut détecter facilement les régressions par rapport aux clients car ces tests peuvent impliquer les interfaces du service. Ce type de tests est intéressant puisqu’ils permettent de révéler rapidement les problèmes en cas d’échec.

Il existe de nombreux outils permettant d’appliquer des tests à des services:

  • curl: cet outil n’est pas spécifique aux microservices. Il n’est pas non plus spécifique aux tests d’une façon générale. Il permet d’effectuer des requêtes HTTP en définissant des éléments précis d’une requête. L’intérêt de cet outil est sa simplicité.
  • Jbehave et cucumber: ces outils ne sont pas non plus spécifiques aux microservices toutefois ils facilitent l’implémentation et l’exécution de tests d’acceptance. Ils permettent d’implémenter des applications suivant l’approche BDD (i.e. Behavior-Driven Developement ou dévoloppement dirigé par le comportement).
  • Chai et Mocha: ces outils ne sont pas spécifiques aux microservices mais ils sont spécialisés dans le test d’application web.

Tester des microservices peut nécessiter d’effectuer des traitements sur du JSON. Certains outils permettent de faciliter l’implémentation de ces traitements: Newtonsoft ou JSON.Net, google-gson, JSON.simple ou FasterXML/jackson.

Enfin d’autres outils sont plus spécialisés pour le test de microservices:

Tests de performance

Une attention particulière doit être apportée aux tests de performance dans une application en microservices. Il ne faut pas négliger la latence provoquée par les appels aux microservices. Cette latence est généralement négligeable dans une application monolithe car les appels de fonctions se font plus rarement en utilisant le réseau. De même, un appel à une application en microservices peut se traduire par plusieurs appels internes à d’autres microservices ce qui augmente encore les différences de performances qu’il peut y avoir avec une application monolithe.

L’intérêt des tests de performance est de détecter une exécution anormalement lente d’un microservice avant sa livraison. L’exécution lente d’un microservice peut dégrader les performances de toute l’application.

Certains outils sont spécialisés pour les tests de performances et de charge: Gatling et Apache JMeter.

On peut citer aussi mountebank qui est spécialement conçu pour effectuer des tests à travers le réseau et est particulièrement adapté pour les microservices:

  • Il permet de fournit des test doubles c’est-à-dire que mountebank est capable de fournir des stubs et des mocks pour effectuer des appels à un service tout en simulant le comportement d’autres services nécessaires à l’exécution du premier.
  • mountebank supporte les appels HTTP et HTTPS.
  • Il permet de traiter du JSON et de l’XML.
  • On peut l’installer sur plusieurs types de plateformes: Windows, macOS ou Linux.

Tests “end-to-end” (e2e)

Pour appliquer des tests d’acceptance sur une application en microservices, on pourrait vouloir appliquer des tests de bout en bout c’est-à-dire appliquer les tests sur un workflow entier impliquant plusieurs services. Ce type de tests permet de tester de façon plus complète les interactions entre les services en considérant l’application comme une boite noire.

Par exemple, si on considère le schéma suivant, appliquer des tests end-to-end implique de tester plusieurs services en effectuant des appels en entrée et en testant les résultats en sortie:

Si on intègre les tests end-to-end dans le cadre des build chains (i.e. chaine de livraison), ils doivent être exécutés à la fin des chaines:

Par exemple, si on considère les build chains de plusieurs services Service 1, Service 2 et Service 3, les tests end-to-end doivent s’exécuter:

  • Après les tests unitaires et après les tests concernant chaque service,
  • A chaque fois qu’une build s’est terminée pour chacun des 3 services.

Aux premiers abords, les tests end-to-end peuvent présenter de nombreux intérêts:

  • Ils permettent de couvrir un spectre large de l’application et de tester le fonctionnement de plusieurs services entre eux.
  • Ils donnent la possibilité d’appliquer des tests fonctionnels puisqu’on peut tester des workflows entiers.
  • Si une version de l’application en microservices réussit les tests, on peut livrer la version en production avec une grande confiance.
Eviter d’implémenter des tests end-to-end

Toutefois, appliquer des tests end-to-end à une application en microservices est une fausse bonne idée car l’exécution de ces tests peut vite devenir longue à mesure que le nombre de microservices augmente:

  • Les tests end-to-end sont plus longs à s’exécuter car ils concernent tous les services. Il faut que tous les services soient compilés et prêts à être exécuté pour qu’on puisse exécuter les tests end-to-end. Non seulement, il faut une plateforme de tests pouvant impliquer tous les services simultanément mais en plus les tests doivent s’exécuter après chaque livraison d’un service.
  • Pas de feedback rapide: si on a beaucoup de tests end-to-end et beaucoup de services, l’exécution prendra du temps et retardera la génération d’un feedback rapide après une build. Ne pas avoir de feedback rapide compromet toute la chaine de livraison de l’application et empêche la livraison rapide de composants.
  • En cas d’échec d’un test end-to-end: il faut corriger et ré-exécuter toute la chaine pour un service donné ce qui retarde la livraison du service et aussi éventuellement celle des autres services. Cette complexité peut rendre difficile la livraison de l’application.
  • Quand déclencher les tests end-to-end: sachant que ce type de test prend du temps à s’exécuter et empêche la livraison de services, on peut se demander quand doit-on les exécuter. Par exemple, si on les exécute une fois par jour, ils ne rentrent pas dans le cadre de tests rapides et en cas d’urgence, on peut être amené à livrer des services sans attendre la fin de leur exécution. Ne pas attendre le feedback des tests end-to-end abaisse considérablement la pertinence de ces tests.

Il faut donc éviter au maximum les tests end-to-end car:

  • Ils impliquent trop de microservices et qu’il est compliqué d’avoir une plateforme permettant l’exécution simultanée de tous les services.
  • Ils impliquent trop d’équipes car toutes les équipes doivent participer à la maintenance des tests end-to-end.
  • Ils impliquent le réseau ce qui entraîne une complexité qui ne permet pas d’avoir une feedback rapide sur un microservice précis.

Enfin en cas d’échec d’un test end-to-end, il peut être compliqué de savoir où se trouve le bug car son exécution implique plusieurs services. Comment savoir qui doit corriger le bug alors que tous les développeurs ne connaissent pas forcément tous les aspects fonctionnels de l’application. En effet pour un développeur, il sera compliqué de trouver un bug sur du code d’une autre équipe.

Il faut donc éviter au maximum les tests end-to-end dans le cadre de microservices…

Consumer-Driven Contract tests

Pour tester un service, une solution peut consister à appliquer des tests par contrats dirigés par le client du service (i.e. Consumer-Driven Contract tests). L’idée de ces tests est de simplifier l’approche des tests end-to-end (i.e. tests de bout en bout) de façon à tester un seul service et de pouvoir identifier efficacement les éventuelles régressions.

On considère dans un premier temps que les communications entre un client et un service font partie d’un contrat:

  • Le client s’engage à générer des requêtes compréhensibles du service: le client a des besoins précis auprès du service qui justifient l’envoi de requêtes vers ce service,
  • Le service s’engage à répondre au client: le service doit répondre de façon précise aux demandes du client si ces requêtes sont compréhensibles.

Le client et le service sont donc liés par un contrat dont les termes sont définis par le client. Les tests Consumer-Driven Contract visent à tester les termes de ce contrat:

  • Si la demande du client n’est pas compréhensible alors le service ne peut pas répondre.
  • Si le service ne répond pas à une demande compréhensible d’un client alors il a rompu le contrat.

Les tests Consumer-Driven Contract s’implémentent de la façon suivante:

  • Etape 1: le client (représenté par consumer sur le schéma) va s’adresser à l’outil de tests Consumer-Driven Contract en lui envoyant des requêtes similaires à celles qu’il doit envoyer au service.
    L’outil de tests Consumer-Driven Contract va simuler le comportement du service (représenté par provider sur le schéma) pour répondre au client. Si le client valide les réponses de l’outil de test alors l’outil de test possède un cas de test avec des requêtes du client et les réponses correspondantes. Les requêtes et les réponses représentent le contrat entre le client et le service (représenté sous la forme de pact sur le schéma).
  • Etape 2: l’outil de tests Consumer-Driven Contract va se servir des requêtes et des réponses pour tester le service. Il va donc adresser au service des requêtes similaires à celles du client et vérifier que le service répond correctement.

Ainsi, les tests Consumer-Driven Contract permettent de valider le contrat entre un client et un service puisqu’ils prennent en compte les attentes du client de façon précise. Ces tests s’exécutent sur un seul service, ils sont donc plus rapides que les tests end-to-end et surtout ils permettent d’avoir un feedback rapide pour un service donné. Enfin les tests Consumer-Driven Contract permettent de détecter et d’éviter les breaking changes d’un service.

L’implémentation de tests Consumer-Driven Contract est fastidieuse et nécessite l’utilisation d’outils comme:

Tests après la mise en production

L’application de tests à des microservices est complexe car:

  • Il faut prendre en compte le lien d’un service avec les autres services,
  • Il est difficile de mettre en œuvre une plateforme de tests complète impliquant tous les services simultanément.

En plus des tests Consumer-Driven Contract (i.e. test par contrat dirigé par le client), il existe des tests qu’on peut appliquer après la mise en production. Ces tests ont l’avantage de ne pas nécessiter une plateforme spécifique en plus de la plateforme de production. Ils visent à tester le comportement d’un service directement en production sans utiliser ses réponses.

Les tests après la mise en production s’appliquent de la façon suivante:

  • Le service V1 est en production et doit être remplacé par le service V2. On déploie donc le service V2 sans l’exécuter.
  • On applique des requêtes type au service V2 sous la forme de “smoke tests” pour tester son comportement. Les réponses du service ne sont pas utilisées pour la production mais sont seulement analysées.
  • On peut aussi appliquer au service V2 les mêmes requêtes que la production pour tester son comportement dans une situation plus réelle. De la même façon les réponses du service V2 sont seulement analysées.
  • Si les réponses du service V2 sont satisfaisantes, alors on utilise les réponses de ce service et il reçoit la charge de production en délestant le service V1. Le passage du service V1 au service V2 peut aussi se faire de façon progressive en ayant les 2 versions de service qui fonctionnent simultanément.
  • Quand le service V2 reçoit toutes les requêtes de production alors on décommissionne le service V1.

L’intérêt principal de ce type test est d’augmenter la confiance lors de la livraison d’un service et de limiter le risque de régression puisqu’on a le temps d’observer le comportement du service à déployer.

Dans la documentation, ce type de déploiement sont des “Zero Downtime Deployment” (i.e. déploiement avec zéro temps d’interruption de service), ils sont indiqués avec plusieurs variantes:

  • Blue/Green deployment: il s’agit du même mécanisme que celui expliqué précédemment. Pour l’appliquer il faut être capable de mettre en production 2 chaines d’exécution, ce qui sous-entend d’avoir un load-balancer qui répartit la charge dans l’une ou l’autre des chaines d’exécution.
  • Canary Release: ce pattern consiste à utiliser le service V2 sur un échantillon restreint de données de production. Cette configuration est maintenue pendant le temps du test. Si les résultats sont satisfaisants alors on affecte toutes les données de production sur le service V2.
  • Dark Launch: cette pratique consiste à déployer la partie non visible d’une fonctionnalité en utilisant la charge de production. L’intérêt de cette pratique est de tester les performances du service sur lequel la fonctionnalité est implémentée.

Stratégies de déploiement

La stratégie de déploiement fait aussi l’objet de particularités dans le cadre d’une application en microservices car chaque service peut être un livrable séparé. Dans l’approche en monolithe, on déploie le plus souvent un seul composant sur une seule machine hôte. Pour les microservices, il est possible d’être plus fléxible en déployant:

  • Plusieurs services sur une même machine hôte,
  • Un service par machine hôte,
  • Un service par container. Le container peut être déployer seul sur une machine hôte ou avec d’autres containers.
  • Il est aussi possible de déployer des microservices sans serveurs avec des approches Cloud “serverless”.

Plusieurs services par hôte

Cette approche est plus facile et plus rapide car le déploiement est effectué sur une machine hôte. Dans le cas où les services sont sur le même serveur Apache Tomcat, IIS ou nginx, ils peuvent être démarrés simplement en démarrant le serveur. Cette approche est dans le prolongement des déploiements d’une application en monolithe et a l’avantage de ne pas trop complexifier l’environnement de déploiement.

L’inconvénient majeur d’avoir plusieurs services par hôte est de moins isoler les services entre eux:

  • Si les services sont dans le même processus, on ne peut pas monitorer les services individuellement.
  • Le redémarrage du serveur Web entraîne le redémarrage de tous les services.
  • Dans le cas où un service consomme anormalement beaucoup de ressource, il peut impacter les autres services et donc toute l’application.
  • Si les services utilisent des technologies différentes, il faudra déployer toutes les technologies sur la même machine hôte.

Avoir plusieurs services sur une même machine hôte est une approche intéressante dans un premier temps car elle est simple à mettre en œuvre toutefois pour utiliser pleinement la flexibilité des microservices, il faut préférer d’autres stratégies.

Un service par hôte

Avoir un service par hôte est une approche qui permet une plus grande flexibilité. Par hôte, on peut considérer une machine physique mais aussi une machine virtuelle. Avec cette approche:

  • Chaque service s’exécute de façon isolée.
  • On peut affecter à chaque service, une charge CPU et une quantité de mémoire précise.
  • Il est plus facile d’appliquer des fonctionnalités de load balancing et d’autoscaling.
  • La problématique de déploiement d’un service peut être réduite au déploiement d’une machine virtuelle.

De nombreuses solutions existent pour faciliter l’utilisation de machines virtuelles. Certaines permettent d’automatiser la création de machines virtuelles comme Packer ou boxfuse.

Des solutions permettent de créer des machines virtuelles comme:

Pour personnaliser des images VM, on peut utiliser des scripts Puppet, Chef ou Ansible.

Pour automatiser l’installation de composants Windows, on peut s’aider de chocolatey nuget.

L’utilisation d’images VM peut comporter quelques inconvénients:

  • Les builds d’images VM sont plus longues que les builds des microservices car les fichiers sont plus lourds.
  • L’initialisation d’une machine virtuelle peut être plus longue que le démarrage d’un service.
  • Une machine virtuelle peut être sous utilisée par le microservice qu’elle héberge.
  • La mise en place d’une build chain incluant des images VM peut être longue à effectuer et peut détourner une équipe de ses problématiques fonctionnelles en particulier si elle implique l’implémentation de scripts complexes.

Un service par “container”

Un container permet de virtualiser des composants au niveau du système d’exploitation. On peut configurer pour chaque container des ports de communications ou un système de fichiers spécifiques. De même qu’une machine virtuelle, il est possible de limiter les ressources CPU et la mémoire utilisée par container.

Les solutions les plus courantes pour l’utilisation de containers sont Docker et Solaris Zones.

Il est possible d’exécuter plusieurs containers sur une même machine hôte. D’autre part, l’avantage des containers par rapport aux machines virtuelles est de ne disposer que du minimum pour exécuter un microservice, limitant ainsi la taille des images. Enfin des gestionnaires de clusters comme Kubernetes ou Marathon peuvent aider à gérer des containers en les plaçant sur des hôtes disponibles.

L’utilisation de containers comporte quelques inconvénients:

  • Leur utilisation ne prive pas de devoir administrer la machine hôte qui les héberge. Cependant des solutions comme Google Container Engine ou Amazon EC2 Container Service dispensent d’administrer la machine hôte.
  • Les containers peuvent sous-utiliser une machine hôte ce qui peut occasionner des coûts supplémentaires.

Déploiement sans serveur

Dans le cadre de microservices, certains services Cloud permettent un déploiement sans serveur. Le déploiement doit se faire en uploadant un package et en indiquant la fonction qui sera exécutée dans le package à chaque requête au service. La fonction exécutée doit être sans états.

Ce service est proposé sous le nom de Lambda chez Amazon Web Services (AWS) et Azure Functions chez Microsoft Azure. La facturation de ces services se fait au temps d’exécution et à la mémoire consommée.

Le gros avantage du déploiement sans serveur est de réduire au minimum les problématiques liées au déploiement: il n’y plus de notions de machines virtuelles ni de containers. Au dela du déploiement, il n’y a pas non plus de nécessité de s’intéresser aux problématiques de load balancing ou d’autoscaling. Enfin, le temps de mise en œuvre de la build chain est réduit.

Monitoring

Il est important de pouvoir monitorer l’état des microservices dans une application en microservices car on peut anticiper un éventuelle défaut d’un service avant que le service soit complètement en échec. D’autre part, le monitoring peut faciliter la détection d’un bug ou d’un échec quand:

  • Plusieurs services sont concernés,
  • Les services sont répartis sur plusieurs serveurs.

Le monitoring peut passer par l’utilisation de logs comme pour une application en monolithe toutefois sachant que les services peuvent être dispersés sur plusieurs machines hôte, l’analyse de logs peut être très fastidieuse. De plus, les logs classiques ne permettent pas facilement d’anticiper le défaut éventuel d’un service.

La meilleure pratique pour monitorer des services est d’utiliser un service centralisé pour analyser les logs générés avec 3 outils:

  • Logstash pour collecter, parser et stocker les logs,
  • ElasticSearch qui propose une solution de moteur de recherche distribué,
  • Kibana qui est une interface web permettant de visualiser les informations stockées par Logstash dans ElasticSearch.

Pour anticiper les échecs, on peut utiliser des métriques:

  • Collecter la charge CPU et la mémoire utilisée permet de détecter un défaut ou de prévoir des besoins d’autoscaling.
  • Exposer des métriques sur les services: par exemple remonter des taux d’erreurs ou un temps de réponse peut révéler l’état des performances d’un service.

Pour permettre d’utiliser efficacement ces métriques, il faut standardiser leur collecte. Certains outils peuvent faciliter l’utilisation de métriques comme:

  • Des bibliothèques logicielles Metrics: elles permettent d’envoyer les métriques d’un service. Elles sont disponibles dans plusieurs langages de programmation.
  • Graphite: il permet d’envoyer des métriques d’un serveur ou d’un service en temps réel. Il permet aussi de monitorer les métriques avec des graphiques.
  • Zipkin: cet outil permet de tracer des requêtes à travers un système distribué.

En conclusion…

Le but de cet article était de comparer l’architecture en microservices avec une architecture plus traditionnelle. Choisir une architecture en microservices n’est pas anodin puisqu’elle nécessite de prendre en compte certaines problématiques liées aux systèmes distribués. D’autre part la plupart des processus d’intégration et de déploiement peuvent être assez différents d’une architecture en monolithe plus classique et nécessiter des efforts d’implémentation qui peuvent être conséquents au début de la réalisation d’un projet. Toutefois, les microservices donnent la possibilité de réduire les interruptions de service lors de déploiement ou de s’adapter à la charge de façon moins couteuse, apportant ainsi une plus grande flexibilité. De nombreuses problématiques liées aux microservices peuvent être résolues en utilisant des solutions techniques déjà implémentées. Quelques unes de ces solutions ont été présentées dans cet article.

Les autres articles de cette série

Partie 1: Concevoir des microservices

Partie 2: Appels entre microservices

Partie 3: Intégration continue et implémentation des tests

Références

Microservices en 10 min: appels entre services (partie 2)

Les liens entre les services sont complexes à gérer car ils nécessitent d’aborder certaines problématiques qui sont, souvent, inexistantes dans le cas d’une application monolithe. Les choix effectués pour définir et implémenter ces liens doivent respecter une règle d’or:
“Peut-on modifier et déployer un service sans en impacter un autre ?”

Les problématiques les plus importantes concernant les appels entre services sont:

  • Le choix des interfaces
  • Le versionnement des interfaces
  • Le tolérance aux erreurs
  • Effectuer des appels synchrones/asynchrones
  • Le topologie des appels

Choix des interfaces

Le choix des interfaces entre microservices est délicat car une mauvaise définition de ces interfaces peut contraindre à de nombreux refactorings qui impacteront plusieurs services. Il faut donc définir des interfaces pour qu’elles soient le plus stable possible de façon à minimiser les changements:

  • Eviter d’utiliser des interfaces avec des types trop abstraits (comme object par exemple): il est préférable d’utiliser des types précis quitte à multiplier les fonctions. Dans le cas où on utilise plusieurs fonctions, une modification peut amener à modifier quelques signatures sans devoir modifier toutes les signatures. Les quelques fonctions modifiées peuvent ne pas impacter tous les clients du service. D’autre part, utiliser des interfaces trop indéfinies nécessitent une connaissance “à priori” de la part des clients sur les types réels utilisés. Il ne pourra pas “découvrir” les interfaces.
  • Utiliser une approche API first: il faut définir les interfaces entre 2 microservices avant de les implémenter. La définition des interfaces permet de se mettre d’accord avec tous les clients et éviter des incompréhensions.
  • Etre flexible à la lecture pour être plus robuste aux changements d’interfaces.
Exemple de technique utilisée pour être flexible à la lecture

Martin Fowler a appelé cette astuce Tolerant Reader, elle permet d’être plus tolérant aux changements dans les réponses des microservices. Pour chercher un objet dans un fichier XML, on peut utiliser le XPath relatif ou le XPath absolu.

Si on prends l’exemple XML suivant:

<catalog>  
  <book>  
    <title>XML</title>  
  </book>  
</catalog>

Pour lire le titre dans le nœud title, on peut exécuter le code suivant:

using System.Xml;  
...  
  
string xml = ...  
  
XmlDocument doc = new XmlDocument();  
doc.LoadXml();  
XmlNode titleNodeUsingAbsPath = doc.SelectSingleNode("/catalog/book/title");  
XmlNode titleNodeUsingRelPath = doc.SelectSingleNode("//book/title");

La requête peut être effectuée en utilisant le XPath absolu avec la syntaxe /catalog/book/title.
Si on utilise le XPath relatif, la syntaxe utilisée est //book/title.

Dans le cas où le fichier XML change de structure:

<catalog>  
  <content>  
    <book>  
      <title>XML</title>  
    </book>  
  </content>  
</catalog>

Le XPath relatif avec la même syntaxe permet toujours de récupérer le contenu du nœud title. En revanche la version avec le XPath absolu ne permet plus de récupérer le nœud title.

Utiliser le “semantic versioning”

Le “semantic versioning” permet d’indiquer qu’une version contient des breaking changes:

  • Les versions sont numérotées en utilisant 3 nombres: MAJOR.MINOR.PATCH (par exemple 4.7.1)
  • Un incrément du nombre MAJOR indique un breaking change avec la version précédente.
  • Un incrément du nombre MINOR indique l’ajout d’une fonctionnalité rétrocompatible.
  • Un incrément du nombre PATCH indique des corrections de bugs rétrocompatibles.

En cas de changement de version, les clients du service peuvent savoir l’importance des changements effectués et envisager la mise à jour de leurs interfaces.

Créer un nouveau point d’accès en cas de “breaking changes”

Pour éviter un impact trop fort sur les clients d’un service en cas de breaking changes des interfaces, une méthode consiste à procéder par étape:

  • Etape 1: utilisation des interfaces V1 par tous les clients.
  • Etape 2: on introduit la nouvelle interface V2 et on indique aux clients de migrer vers l’interface V2. Les clients migrent au fur et à mesure. On maintient les 2 interfaces V1 et V2 pour ne pas trop impacter les clients qui utilisent toujours les interfaces V1. On indique aux clients une date à partir de laquelle V1 ne sera plus disponible.
  • Etape 3: quand tous les clients ont migré, on supprime les interfaces V1 et tous les clients utilisent V2.

Appels synchrones ou asynchrones

On peut se poser la question de savoir comment effectuer des appels entre les clients et leurs microservices:

  • Effectuer des appels synchrones: le client envoie une requête au microservice, il attend pendant le traitement de la requête et il récupère la réponse du service en fin de traitement.
  • Effectuer des appels asynchrones: le client envoie une requête au microservice mais n’attend pas pendant le traitement. Le client peut être notifié à la fin du traitement directement par le service ou le client s’abonne à des évènements déclenchés par le service de façon à recevoir des notifications.

Les appels synchrones correspondent au modèle request/response. Le client doit s’adapter au temps de traitement du service et doit prendre en compte ce temps de traitement dans son implémentation. Prendre en compte le temps de traitement permet, par exemple, d’éviter des timeouts dans le cas où le temps est trop long. Si le temps de traitement se rallonge suivant le type de requête, le client devra s’adapter en conséquence. L’avantage des appels synchrones est que le client peut avoir une réponse immédiate sur le statut de la requête. Du fait de l’adaptation du client en fonction du temps de traitement, ce type d’appels augmente le couplage entre service.

A l’opposé les appels asynchrones permettent d’éviter une adaptation du client suivant le temps de traitement de la requête. Ce type d’appels correspond au modèle orienté évènement. Le client s’abonne à des évènements du service en fonction de ce pourquoi il veut être notifié. L’émetteur de l’évènement n’a pas de connaissances des clients qui s’abonnent. Ce type d’appels permet de moins coupler les clients à leur service.

Suivant la topologie des échanges entre un microservice et ses clients, on peut être amener à faire un choix dans le type d’appels à implémenter:

Un à un Un à plusieurs
Synchrone Request/Response N/A
Asynchrone Notification Publication/Souscription
Request/Response asynchrone Publication/Réponses asynchrones

Effectuer des appels asynchrones est plus complexe qu’effectuer des appels synchrones. On peut distinguer 2 façons de faire des appels asynchrones:

  • Mécanisme de souscription: le client souscrit auprès du microservice et il est notifié au déclenchement d’un évènement.
  • Mécanisme d’observation: le client fait du polling auprès du microservice sur lequel il a effectué un appel. Le client déclenche lui-même ses actions en fonction de ce qu’il a découvert pendant le polling.

Tolérer les échecs partiels

Les microservices sont de petites applications conçues pour être autonomes. Pourtant pour effectuer un traitement, microservice peut devoir faire appel à d’autres services et ainsi de suite. Si les services appelés ne sont pas en mesure de répondre ou si une partie du réseau est en défaut, le microservice ne sera pas en mesure d’effectuer son traitement et ne répondra pas à son tour à une requête qui lui a été adressée. Un défaut dans un service de bas niveau peut, ainsi être propagé à d’autres services de plus haut niveau mettant en péril toute l’application.

L’implémentation d’un microservice doit donc tolérer les échecs lors des appels à d’autres microservices. Il faut prendre en compte les scénarios d’échecs lors des appels pour ne pas propager l’échec et donner une réponse même si le traitement n’a pas abouti. Une première approche est d’être en mesure de détecter un échec lors d’un appel à travers le réseau en mettant en place des timeouts.

Quand l’échec est détecté, il faut implémenter une logique en rapport avec les impératifs du contexte fonctionnel. Par exemple, dans certains cas il peut être inutile de répéter une requête car les paramètres de la requête peuvent être obsolètes en cas d’appels répétés. Dans d’autres cas, on peut se permettre d’effectuer de nouvelles tentatives. Quel que soit la solution implémentée, il faut avoir une logique pour le cas le plus défavorable. Ainsi dans le cas de requêtes répétées et non traitées, il faut prévoir un nombre maximum de requêtes en défaut et avoir un traitement particulier si ce nombre est atteint comme, par exemple, considérer le service appelé comme inaccessible et ne plus envoyer de requêtes vers ce service.

Pattern “circuit breaker”

Le pattern circuit breaker vise à apporter une solution homogène lors d’échecs dans les appels entre microservices. La solution consiste à placer entre les microservices un composant appelé circuit breaker. Ce composant analyse les appels d’un microservice à l’autre et détecte les cas où un appel n’a pas abouti.

Dans le schéma suivant, on peut voir que le circuit breaker se place entre le client et le service (appelé supplier sur le schéma) et sert d’intermédiaire entre les appels du client au service. Dans le cas où les appels aboutissent, le circuit breaker n’effectue aucun traitement particulier. En revanche, s’il détecte un appel non abouti vers un service après un timeout, il “ouvre” le circuit pour que les futurs appels vers ce service ne soit plus effectués. Le circuit breaker répondra systématiquement par un échec pour les appels suivants au service (mécanisme de heartbeat).

Source: https://martinfowler.com/bliki/CircuitBreaker.html

Dans des implémentations plus sophistiquées, le circuit breaker peut effectuer des appels vers le service en défaut pour détecter s’il redevient opérationnel de façon à refermer le circuit et à rediriger, à nouveau, les appels.

L’interruption des appels par le circuit breaker peut se faire lorsque les requêtes en échec dépasse un certain seuil et pas forcément à partir du premier appel non abouti. Le circuit breaker peut aussi indiquer à des outils de monitoring qu’un service est en défaut.

Netflix Hystrix
Netflix Hystrix est un exemple d’implémentation du pattern circuit breaker en java. Hystrix est disponible sur Github.

Chef d’orchestre ou chorégraphie

Dans le cas de workflows, un traitement peut nécessiter d’appeler successivement plusieurs microservices. La logique de ces workflows peut être implémentée de 2 façons dans le service appelant:

  • Appels en chef d’orchestre (i.e orchestration): le workflow est implémenté complètement dans le service appelant. Il sait exactement quels sont les services qu’il doit appelé et l’ordre d’appel de ces services. Le service appelant, appelle successivement les services comme s’il était un chef d’orchestre.
  • Appels en chorégraphie (i.e. choregraphy): le service principal (ou service master) ne connaît pas les services qui dépendent de lui. Les autres services souscrivent auprès du service master pour être notifié quand un évènement particulier survient. Quand un traitement doit être effectué par le service master, il déclenche certains évènements. Les services qui se sont abonnés sont notifiés du déclenchement de ces évènements et effectuent un traitement en fonction de l’évènement. Le cas échéant, chaque service peut renvoyer un résultat à la suite du déclenchement d’un évènement.
    Ce mécanisme correspond à une chorégraphie puisque les services abonnés s’abonnent eux-mêmes au service master et décident eux-mêmes d’effectuer un traitement.

Dans le cas du chef d’orchestre, l’ordonnancement entre microservices est plus facile à implémenter et se fait directement dans le service master. Cette implémentation donne la possibilité d’effectuer des appels synchrones aux services, ainsi on peut facilement stopper le workflow si un appel à un service n’aboutit pas.
L’inconvénient majeur de cette approche est que le service master connaît tous les services qu’il doit appeler et la logique d’ordonnancement est implémentée directement dans le service ordonnanceur.
Cette connaissance des autres services augmente le couplage entre service puisque il peut être nécessaire de modifier l’ordonnanceur si une interface change dans les services appelés.

La chorégraphie est plus complexe à implémenter que le mécanisme de chef d’orchestre. Dans le cas de la chorégraphie, il peut être plus difficile d’interrompre le workflow en cas d’erreurs, en particulier si le déclenchement des évènements se fait de façon asynchrone. De même avec ce type de mécanisme, il est plus complexe d’effectuer un ordonnancement entre les services en fonction des évènements déclenchés.
L’intérêt du mécanisme en chorégraphie est que le service master n’a pas de connaissances des services appelés. Il ignore aussi l’ordre dans lequel les services doivent effectuer leur traitement. Le service master se contente de déclencher des évènements et ce sont les services eux-mêmes qui ont la connaissance de savoir s’ils doivent s’exécuter ou non. Ainsi la chorégraphie permet de diminuer le couplage entre microservices.

Service discovery

Lorsque des microservices sont exécutés sur des machines différentes, il faut avoir certaines informations pour savoir comment appeler ces services comme l’adresse IP des services ou les ports de connexions. Dans le cas d’autoscaling où des instances de service sont rajoutées à “chaud”, comment savoir qu’une nouvelle instance est active ?

Toutes ces problématiques peuvent se résoudre de 2 façons:

  • En connaissant la configuration des services en avance pour savoir comment les appeler ou
  • Découvrir cette configuration “à chaud” c’est-à-dire pendant l’exécution sans la connaître au préalable.

Connaître la configuration des services en avance

Cette solution est la plus rapide à implémenter et convient bien dans le cas où il n’y a pas beaucoup d’instances de services et que la topologie des services ne change pas. Dans le cas où la configuration change fréquemment, avoir les paramètres de connexion des services avant exécution peut être assez contraignant car le moindre changement peut nécessiter le redémarrage des services et l’interruption de l’application.

Ce type de configuration peut rendre plus difficile l’assignation dynamique d’une adresse IP aux différents services, par exemple, pour assurer des fonctions comme l’autoscaling ou le load-balancing. D’une façon générale, configurer les paramètres des connexions en avance nécessite d’indiquer ces paramètres dans la configuration des services. Cette connaissance des paramètres amène un couplage des clients à leurs services.

Pattern “client-side discovery”

Découvrir la configuration des services à l’exécution permet une affectation dynamique des paramètres de connexion. Un exemple de mécanisme permettant la configuration “à chaud” est le pattern client-side discovery. Ce mécanisme nécessite un service qui référence les paramètres de connexion des autres services.

  • Dans un premier temps, chaque service indique sa configuration auprès du service registry qui va la conserver.
  • Chaque client souhaitant effectuer une requête auprès d’un service, doit au préalable récupérer la configuration auprès du service registry.
  • Le client fait ensuite appel directement au service avec les paramètres qu’il a obtenu après avoir interrogé le service registry.

Ce mécanisme permet l’implémentation d’algorithmes de load-balancing directement dans le client. Le client peut effectuer une requête à une instance particulière d’un service en fonction de la charge. L’inconvénient du pattern client-side discovery est que les logiques de connexion au service et le load-balancing sont implémentées dans chaque client. Il n’y a pas un composant qui effectue ce traitement de façon homogène pour tous les clients.

Il existe des exemples d’implémentation du pattern client-side discovery dans Netflix OSS (Netflix Open Source Software) disponible sur GitHub:

Pattern “server-side discovery”

Le pattern server-side discovery ajoute un composant par rapport au pattern client-side discovery. Un router est ajouté pour servir d’intermédiaire entre le client et les services. Le client ne fait plus appel directement aux services:

  • Dans un premier temps, le client appelle le router dans le but d’effectuer une requête auprès d’un service.
  • Le router effectue une requête auprès du service registry pour récupérer les paramètres de connexion du service.
  • Le router appelle, ensuite, directement les services avec la requête du client.

Les autres mécanismes sont les mêmes que pour le client-side discovery c’est-à-dire:

  • Les services enregistrent leur configuration auprès du service registry,
  • Seulement le service registry possède la configuration des services.

L’intérêt du server-side discovery est de permettre l’implémentation de l’algorithme de load balancing à un seul endroit c’est-à-dire dans le router. Il n’est pas nécessaire d’implémenter le load-balancing dans chaque client.

Il existe quelques exemples de router:

  • Kubernetes et Marathon exécutent un proxy sur chaque host d’un cluster pour effectuer du load balancing.

Exemples de service registry:

  • Etcd: base de données clé-valeur distribuée ayant des fonctionnalités de service discovery,
  • Consul: propose une API pour enregistrer et découvrir “à chaud” des services,

Quelques technologies utilisées pour les appels

Il est possible d’utiliser une multitude de technologies pour effectuer des appels entre microservices. Le choix de la technologie doit correspondre aux besoins toutefois d’une façon générale:

  • Il faut éviter d’utiliser des middlewares propriétaires qui dirigent trop l’implémentation des microservices.
  • Il faut prendre en compte les risques liés au réseau
  • Il faut distinguer les technologies synchrones et asynchrones.

Les techonologies les plus couramment utilisées dans le cas de communications synchrones sont:

Dans le cas de communications asynchrones par message:

Outre le protocol utilisé, il faut aussi faire un choix sur le format des messages:

  • Format texte: les formats XML et JSON sont couramment utilisés.
  • Format binaire: on peut considérer Apache Avro ou Protocol Buffers.

Communications asynchrones par messagerie

Il existe beaucoup d’implémentations permettant des communications par messagerie:

Toutes ces implémentations permettent une plus grande flexibilité que les communications synchrones classiques:

  • Découplage entre les clients et les services: le client envoie sa requête sur un canal sans connaître le service qui va la traiter. Il n’y a pas de mécanismes pour chercher le service.
  • Message tampon: les messages sont placés dans des files d’attente et seront traités de façon asynchrone par le service même s’il n’est pas disponible au moment de l’envoi du message.
  • Interactions flexibles entre les clients et les services: la plupart des mécanismes sont supportés.
  • Communications interprocessus explicites: il n’y a pas de différences entre un appel à un service local ou à distance.

Ces technologies offrent plus de flexibilité toutefois elles sont plus complexes à mettre en œuvre car il faut installer un agent de messagerie et il faut configurer les communications.
D’autres part, les communications request/response deviennent complexes à mettre en œuvre car les communications se font par des canaux qui sont identifiés avec des ID. Le client doit corréler sa requête avec la réponse en utilisant l’ID du canal.

Communications synchrones

Le mécanisme le plus courant utilisé est celui permettant d’effectuer des appels request/response entre le client et le service. Le protocole utilisé est souvent REST over HTTP.

Certaines solutions permettent d’effectuer des appels request/response de façon asynchrone sans passer par des solutions de messagerie, par exemple:

REpresentational State Transfer (REST)

On parle souvent de REST pour effectuer des appels entre microservices en le qualifiant de protocole. Il s’agit d’un abus de langage car REST n’est ni un protocole, ni une norme. REST a été introduit dans la thèse de Roy Fielding qui le définit comme un style d’architecture imposant des contraintes. Ces contraintes concernent différents points:

  • Communication Client-Serveur: le client est séparé du serveur.
  • Communication sans état: une requête doit contenir toutes les informations nécessaires à son exécution. Le serveur ne doit pas stocker de données de contexte.
  • Mise en cache: une réponse du serveur contient des informations pour que le client puisse mettre en cache la réponse. Ces informations peuvent être considérées comme la durée de validité de la réponse.
  • Des interfaces uniformes: les interfaces permettent d’identifier les ressources disponibles. La manipulation de ces ressources doit se faire au travers d’une représentation. Les messages doivent être autodescriptifs c’est-à-dire qu’ils doivent suffire à comprendre les informations qu’ils contiennent.
  • Système hiérarchisé en couches: un client peut se connecter à un serveur final ou à un intermédiaire sans qu’il s’en aperçoive. Le fait de passer par un intermédiaire doit être transparent pour le client. Cette contrainte permet d’effectuer du load-balancing.
  • Code-on-demand: cette contrainte permet d’exécuter des scripts récupérés à partir du serveur. Tous les traitements ne s’effectuent pas du coté du serveur.

On applique REST le plus souvent partiellement dans les appels entre microservices toutefois son intérêt est d’énoncer des contraintes s’appliquant à ces appels notamment:

  • Le client est désolidarisé du serveur,
  • Il n’y pas de gestion d’états,
  • Une requête peut être répartie sur plusieurs serveurs,
  • On peut utiliser HTTP
  • Une API REST implémentée totalement n’a pas besoin de documentation,
  • Le client peut “découvrir” les fonctionnalités proposées par l’API sans connaissances préalables.

REST over HTTP

REST over HTTP est un protocole utilisant HTTP. L’intérêt de HTTP est de mettre à disposition des éléments qui facilitent la mise en œuvre d’appels entre client et serveur:

  • URL: une adresse qui permet d’indiquer l’adresse du microservice.
  • Type MIME: les messages peuvent être de type différent, on peut indiquer le type dans l’entête des messages. Le plus couramment on utilise JSON ou XML.
  • Verbes HTTP: ce sont des méthodes qui permettent des traitements particuliers:
    • GET pour effectuer des opérations de lecture d’une ressource,
    • POST pour créer une ressource,
    • PUT pour mettre à jour une ressource et
    • DELETE pour supprimer une ressource.

Les codes d’erreurs HTTP sont aussi très utiles puisqu’ils indiquent des codes de retours possibles pour les appels:

Code Message Signification
200 OK Succès pour toutes les méthodes sauf POST
201 Created Réponse à un POST
400 Bad Request Le contenu de la requête n’a pas été compris
401 Unauthorized L’authentification a échouée
403 Forbidden Authentification correcte mais l’utilisateur ne peut pas accéder à la ressource
404 Not Found Le ressource n’a pas été trouvée
429 Too Many Requests La limite de requêtes autorisées est dépassée
500 Internal Error Problème interne au service
503 Service unavailable Service non disponible

API RESTful

Une API RESTful définit une API qui respecte toutes les contraintes définies par REST. La très grande majorité du temps, les API ne respectent pas l’intégralité de REST, elles prennent en compte quelques contraintes. De façon à mesurer le niveau de maturité d’une API avec REST, Leonard Richardson a défini 4 niveaux:

  • Plus le niveau est élevé et plus l’API respecte les contraintes REST
  • Plus le niveau est élevé et moins le client a besoin d’informations préalables pour envoyer sa quête au service.
  • Plus le niveau est élevé et plus le couplage est faible.

Niveau 0

Les clients envoient des requêtes HTTP POST vers un seul point d’accès du service. Chaque requête contient:

  • L’action à effectuer,
  • L’objet cible sur lequel va porter l’action,
  • Les paramètres nécessaires à l’exécution de l’action.

Ce niveau nécessite une connaissance du client pour pouvoir envoyer sa requête. Le client ne peut pas exécuter la requête sans savoir où indiquer les différents éléments.

Par exemple, si on souhaite récupérer une liste de livres en faisant appel à une API REST, une requête pourrait être:

POST /books HTTP/1.1 
Content-Type: application/json

La réponse de l’API pourrait être:

{ 
  "books": [ 
       { 
            "title": "The Little Prince",  
            "id": "1", 
            "author": { 
                   "firstname": "Antoine", 
                   "lastname": "Saint-Exupery" 
            }  
       }, 
      { 
            "title": "Madame Bovary",  
            "id": "2", 
            "author": { 
                   "firstname": "Gustave", 
                   "lastname": "Flaubert" 
            }  
       } 
   ] 
}

Pour supprimer un livre, la requête pourrait être:

POST /books HTTP/1.1 
Content-Type: application/json 
{ 
    "delete": { 
        "book": [ 
             {     "id": "1"    },  {     "id": "2"    } 
        ]  
    } 
}

Quelque soit l’action a effectué, la méthode HTTP utilisée est POST. Le corps de la requête contient:

  • l’action à effectuer c’est-à-dire: delete;
  • l’objet cible à savoir: book et
  • les paramètres nécessaires à l’exécution: 1 et 2.

Le client doit connaître la syntaxe de la requête pour pouvoir l’exécuter. Cette connaissance couple le client avec le service.

Niveau 1

L’API supporte la notion de ressource. Une ressource correspond à un élément sur lequel on souhaite effectuer une action. L’action peut être une création, une mise à jour, une suppression etc… Chaque requête à l’API REST concerne une ressource particulière.
Une requête contient:

  • L’action à effectuer auprès de la ressource,
  • Les paramètres nécessaires à l’exécution de l’action.

Chaque ressource comporte une identifiant rangé dans un champ dont le nom est Id. L’utilisation d’un nom de champ identique pour tous les identifiants permet d’éviter d’avoir une connaissance trop précise de la structure des ressources. On sait que quelque soit la ressource, l’identifiant sera rangé dans un champ dont le nom est Id.

Par exemple, pour avoir la liste des livres, une requête pourrait être:

POST /books HTTP/1.1 
Content-Type: application/json

La réponse de l’API pourrait être:

{ 
  "books": [ 
       { 
            "title": "The Little Prince",  
            "id": "1", 
            "authorId": "antoine_saint_ex" 
       }, 
      { 
            "title": "Madame Bovary",  
            "id": "2", 
            "authorId": "gustave_flaubert" 
       } 
   ] 
}

Les auteurs sont une ressource différente de celle des livres. La liste des livres ne comprends pas de données sur les auteurs, seuls les identifiants des auteurs sont utilisés. Les données sur les auteurs peuvent être obtenues en effectuant une requête spécifique pour les auteurs.

Pour obtenir des informations sur un auteur, on pourrait effectuer une requête:

POST /author?id=antoine_saint_ex HTTP/1.1 
Content-Type: application/json

La réponse contient les données de l’auteur avec un champ Id pour indiquer l’identifiant de l’auteur:

{ 
     "author": { 
          "id": "antoine_saint_ex", 
          "firstname": "Antoine", 
          "lastname": "Saint-Exupéry" 
    } 
}

Pour ce niveau, le client doit aussi connaître des éléments de syntaxe de la requête pour pouvoir l’exécuter. Toutefois certaines données comme, par exemple, les identifiants sont indiquées en utilisant un nom moins spécifique.

Niveau 2

Les requêtes effectuées utilisent des verbes HTTP pour indiquer l’action à effectuer:

  • GET pour une lecture,
  • POST pour une insertion,
  • PUT pour une mise à jour et
  • DELETE pour une suppression.

Comme pour les autres niveaux, les paramètres se trouvent dans le corps de la requête. A ce niveau, on utilise les codes de retours HTTP dans les réponses aux requêtes.

Par exemple, pour obtenir la liste de livres:

GET /books HTTP/1.1

Pour obtenir un auteur particulier:

GET /author?id=1 HTTP/1.1

Pour supprimer un livre:

DELETE /books?id=2 HTTP/1.1

A ce niveau, l’API paraît plus uniformisée. Il n’y a pas de connaissances à avoir sur la syntaxe des requêtes:

  • L’utilisation des verbes HTTP indiquent l’action à effectuer,
  • L’organisation en ressource permet d’avoir une logique qui est la même quel que soit le type d’objet.

Niveau 3

Une API satisfaisant ce niveau est basée sur le principe HATEOAS (Hypertext As The Engine Of Application State). HATEOAS est une contrainte qui permet d’indiquer dans la réponse à une requête GET, toutes les autres opérations possibles sur l’API. Ces opérations sont indiquées sous forme de lien hypertext dans le corps de la réponse.

Par exemple, si on envoie une requête pour obtenir la liste de livres:

POST /books?id=1 HTTP/1.1

Un exemple de réponse de niveau 3 pourrait être:

{ 
  "book":  
       { 
            "title": "The Little Prince",  
            "id": "1", 
            "authorId": "antoine_saint_ex", 
            "links": [ 
                 { 
                       "rel": "self", 
                       "href": "http://localhost:8080/books/1" 
                 }, 
                 { 
                       "rel": "list", 
                       "href": "http://localhost:8080/books" 
                 }, 
            ] 
       } 
}

La réponse contient des liens qui permettent de parcourir les ressources de l’API. En s’aidant de ces liens, la connaissance nécessaire pour utiliser l’API est encore abaissée et on peut “découvrir” toutes les fonctionnalités de l’API sans connaissances préalables.

Niveau de maturité

L’intérêt des niveaux de maturité n’est pas forcément d’implémenter le niveau le plus élevé pour une API. Généralement le niveau 2 suffit, dans la majorité des cas, à avoir une API standardisée facilement utilisable.

Utiliser des niveaux de maturité pendant le développement d’une API permet de:

  • Standardiser la syntaxe pour éviter aux clients d’avoir une connaissance préalable de cette syntaxe.
  • Utiliser un même niveau de maturité pour toutes les API de l’application en microservices: l’intérêt est d’éviter d’implémenter des niveaux inutiles s’il y a une trop grosse différence de maturité entre les services.
    Par exemple si un service implémente le niveau 3, et que tous les autres services implémentent le niveau 1, les clients de ce service peuvent ne pas utiliser les fonctionnalités des niveaux 2 et 3. Ce qui rend inutile l’effort d’implémentation jusqu’au niveau 3.
    Il est donc préférable d’avoir un niveau de maturité homogène dans une application en microservices.

Eviter les “anemic domain models”

La notion de ressource peut donner l’impression que les services REST ne font pas de traitements fonctionnels sur des objets et qu’ils ne permettent que de publier le contenu de ces objets.

Dans l’exemple plus haut, les ressources sont les livres ou les auteurs. Le service REST ne sert qu’à consulter ces objets comme on pourrait l’effectuer dans une base de données. Ce type de service peut mener à des anemic domain models.

Un anemic domain model est une notion introduite par Martin Fowler pour qualifier un modèle qui ne possède pas de logique mais seulement des propriétés. Le modèle n’a donc aucune logique fonctionnelle et se contente d’exposer ses propriétés. Dans le cas de microservices, il faut éviter d’implémenter ce type de REST API car ils font perdre l’intérêt des microservices. Même s’il peut être utile d’avoir un niveau de maturité permettant d’exposer des ressources, ça ne veut pas dire que le service REST sera dépourvu de logique fonctionnelle. Les ressources ne traduisent pas forcément un objet en base mais juste une notion issue d’un traitement fonctionnel.

Par exemple, si on prends le cas d’un service permettant d’autoriser des “deals” de marché financier en fonction de certains critères. Les données relatives au “deal” sont fournies lors de la requête. Le service effectue des contrôles sur ce “deal” en fonction des données fournies et donne une réponse contenant éventuellement une autorisation. Dans ce cas, la ressource est l’autorisation du “deal”. Cette ressource correspond à une notion obtenue après un traitement fonctionnel.

Les autres articles de cette série

Partie 1: Concevoir des microservices

Partie 2: Appels entre microservices

Partie 3: Intégration continue et implémentation des tests

Références

Microservices en 10 min: références

Références

Livres:

Généralités:

Conception de microservices:

Web API:

RESTful API:

Tests:

Monitoring:

Microservices en 10 min: Concevoir des microservices (partie 1)

Quelques définitions

Quelques définitions en préambule…

Architecture orientée service (SOA)

L’approche SOA a le même objectif que l’architecture en microservices:

  • Casser l’architecture en monolithe: un monolithe est une application qui est implémentée dans un seul projet. Cette architecture est généralement la plus facile à mettre en œuvre puisque toutes les problématiques d’implémentation et d’exécution (choix de la technologie de programmation, problématiques d’accès concurrents à une ressource, communications entre composants, déploiement, usine de build, intégration continue etc…) se posent pour un seul projet.
    A l’opposé ce type d’architecture rend plus difficile l’expérimentation de technologies exotiques. Elle peut devenir contraignante dans le cas où le code existant devient trop complexe à faire évoluer.
  • Permet de promouvoir la réutilisation de briques de services: isoler un service et le séparer d’un monolithe permet d’isoler des fonctionnalités et de faciliter leur réutilisation par plusieurs projets.
  • Facilite l’intégration de services: un des objectifs des services est de permettre de les appeler à partir de projets différents. L’utilisation de technologies de communication ou de middleware permet d’appeler des services en étant dans un processus différent à travers le réseau. Faciliter les communications entre processus permet de partager plus facilement une fonctionnalité.

L’approche SOA est considérée par beaucoup comme un échec car:

  • Trop théorique: beaucoup d’architectes ont écrit des articles pour décrire cette approche sans forcément donner des indications pratiques sur la façon de casser un monolithe et d’avoir une implémentation évolutive d’un service. Les services sont perçus comme des monolithes pour lesquels on a facilité les communications.
  • Pas de prise en compte des difficultés opérationnelles: l’approche trop théorique n’a pas apporté de solutions à des problématiques opérationnelles comme le déploiement, la scalabilité, le monitoring etc… Les implémentations de services peuvent se heurter à des difficultés à assurer des problématiques qui se résolvent plus facilement avec une approche en monolithe.
  • Protocoles de communication difficiles à utiliser: l’architecture en services a, parfois été vendue par des éditeurs de middlewares qui proposaient des solutions de communications souvent couteuses et propriétaires. Ces solutions avaient une empreinte forte sur l’implémentation des services ce qui couplaient les services au middleware.
  • Choix d’architecture peu évolutive et contraignante: l’utilisation de ces middlewares peut aussi rendre l’architecture en services peu évolutive et contraignante car très dépendantes des middlewares.

Qu’est-ce que l’approche en microservices ?

Les microservices sont un cas particulier des services: ce sont des services autonomes de petites tailles travaillant ensemble.

La plupart des définitions que l’on peut trouver évoque l’importance de l’autonomie et de la taille:


“En informatique, les microservices sont un style d’architecture logicielle à partir duquel un ensemble complexe d’applications est décomposé en plusieurs processus indépendants et faiblement couplés, souvent spécialisés dans une seule tâche.” “In short, the microservice architectural style is an approach to developing a single application as a suite of small services, each running in its own process and communicating with lightweight mechanisms, often an HTTP resource API. These services are built around business capabilities and independently deployable by fully automated deployment machinery. There is a bare minimum of centralized management of these services, which may be written in different programming languages and use different data storage technologies.”
Wikipedia
James Lewis and Martin Fowler


A la différence de l’approche SOA, l’approche Microservices a émergé dans le but de répondre plus facilement à des problématiques opérationnelles. L’architecture en microservices énonce des principes d’architectures en indiquant des solutions possibles et pratiques pour la plupart des problématiques.
Il n’est pas forcément pertinent d’appliquer rigoureusement toutes les solutions envisagées, tout dépend du contexte. L’important est d’avoir en tête des solutions possibles et d’adapter certaines d’entres elles à son contexte en fonction de ses problématiques propres.

Comme on l’a indiqué plus haut, l’objectif principal d’une architecture en microservices est de casser une application en monolithe pour la rendre moins complexe:




On peut énoncer quelques caractéristiques de l’architecture en microservices:

  • “Doing one thing well”: on doit tenter de limiter un microservice à une seule fonction. La règle n’est pas absolue mais chaque microservice doit avoir une responsabilité limitée. Les délimitations du microservice peuvent correspondre à des frontières fonctionnelles.
  • Autonomie: un microservice doit être autonome par rapport aux autres microservices. Le but est d’éviter de trop coupler les microservices entre eux.
  • Utiliser des technologies plus adaptées aux besoins: un atout des microservices est de permettre une plus grande libertée sur les choix technologiques par rapport à une application monolitique. La taille limitée d’un microservice rends plus facile des choix technologiques risqués ou exotiques. Les développeurs sont, ainsi, plus libres de leurs choix techniques en utilisant une technologie plus adaptée.
  • Tolérance aux pannes: les microservices peuvent être plus tolérants aux pannes par rapport à une application en monolithe. En cas d’échec d’un service, les autres services peuvent toujours fonctionner. L’absence d’un service dégrade l’application toutefois elle peut rester partiellement opérationnelle. A l’inverse, si une application en monolithe crashe, on peut difficilement la faire fonctionner de façon partielle, il faut généralement la redémarrer entièrement.
  • S’adapter à la charge: les microservices permettent de s’adapter plus facilement à la charge.
    Adapter la charge peut se faire de 2 façons:

    • Mise à l’échelle verticale (i.e. scale-up): pour augmenter les capacités de l’application, on augmente les capacités de la machine hôte. Ce type d’opération est couteux et plus difficile à mettre en œuvre car il faut remplacer la machine hôte et interrompre le fonctionnement de l’application pendant l’opération.
    • Mise à l’échelle horizontale (i.e. scale-out): l’augmentation des capacités de l’application se fait en augmentant le nombre d’instances. Ce type d’opération pose d’autres problèmes comme le load-balancing toutefois elle est moins couteuse qu’une augmentation des capacités de la machine hôte. En outre, elle donne la possiblité d’adapter la charge “à chaud” c’est-à-dire sans interruption de service en ajoutant ou en diminuant le nombre d’instances.

    L’approche en microservice rend plus facile la mise à l’échelle horizontale qui est la méthode la plus scalable et la moins couteuse.

  • Faciliter les déploiements: déployer un service peut être plus facile que de déployer une application entière. Il est plus facile de limiter l’interruption de service lorsqu’on déploie un service par rapport au déploiement d’une application complête. Pendant le déploiement d’un service, les autres services peuvent continuer à fonctionner. Dans le cas d’une application en monolithe, un nouveau déploiement implique l’arrêt complet de l’application.
    De même, il est moins risqué de déployer un service qu’une application entière. Si on constate un bug, on peut plus facilement effectuer un rollback de la nouvelle version.

Les microservices ne sont pas forcèment la solution idéale

L’architecture en microservices apporte de nombreux avantages toutefois, elle est loin d’être une solution idéale car elle déplace la compléxité de l’implémentation vers d’autres problématiques par rapport à une application en monolithe:

  • La compléxité des microservices n’est pas dans le code source comme pour un monolithe, mais dans les interactions entres les services.
  • Les microservices peuvent être très hétérogènes ce qui peut rendre leur implémentation plus complexe qu’un monolithe.

En réalité, une application en microservices est un système distribué. En plus de la compléxité fonctionnelle de l’application, se posent d’autres problématiques plus difficiles à résoudre que pour un monolithe comme par exemple:

  • Les communications entre services,
  • Le partitionnement de la base de données,
  • La modification d’un service par rapport au fonctionnement des autres services,
  • Les tests,
  • Le déploiement,
  • Etc…

Concevoir des microservices

Le but de cette partie est d’énoncer quelques principes pour la conception d’une architecture en microservices idéale. Il n’y a pas de solutions parfaites ou universelles, ces principes ne servent qu’à apporter quelques pistes de résolution qu’il convient d’appliquer en fonction du contexte.

Principe général

D’une façon générale, la conception de microservices doit assurer:

  • Un faible couplage: de façon à permettre de modifier les services indépendamment et d’assurer une autonomie dans leur fonctionnement. Des services faiblement couplés permettront de tirer partie au maximum de l’architecture en microservices: tolérance aux pannes, s’adapter à la charge, faciliter les déploiements, etc…
  • Grande cohésion: assurer une cohésion entre les services vise à rendre les échanges entre ces services de façon la plus cohérente possible en:
    • Utilisant des interfaces claires avec des types précis: par exemple, il faut éviter d’utiliser des types comme object pas assez précis qui laissent trop de libertés quant au type des objets. De même, il faut éviter de définir des fonctions qui ont plusieurs objectifs, il est préférable de limiter une fonction à un seul cas d’utilisation.
    • Eviter les choix technologiques trop exotiques dans les communications entre service: par exemple, il faut éviter d’utiliser des middlewares qui sont généralement couteux en licence et peuvent avoir une empreinte forte dans l’implémentation des services.
    • Eviter les breaking changes: il faut penser les interfaces pour limiter les breaking changes lors des évolutions des services. Des breaking changes dans les interfaces d’un service nécessitent la modification des services qui y font appel. Ces breaking changes peuvent compliquer les déploiements.
    • Ne pas exposer des détails d’implémentation internes d’un service: exposer des détails de l’implémentation interne d’un service peut donner des indices sur son fonctionnement. D’autres services peuvent involontairement tirer partie de ce fonctionnement et avoir une implémentation dépendant de ce fonctionnement. Des implémentations trop dépendantes rendent le couplage plus important entre les services.

Séparation de la logique fonctionnelle en contextes bornés

Les contextes bornés (i.e. bounded context) correspond un notion qui provient du “Domain-Driven Design” de Eric Evans. Le gros intérêt de cette approche est qu’elle propose une solution pour séparer une application en microservices. Un contexte borné peut correspondre à plusieurs microservices ayant en commun un contexte fonctionnel.

Dans l’approche DDD:

  • La complexité fonctionnelle est séparée en contextes bornés: chaque contexte borné répond à un besoin fonctionnel qui possède un langage spécifique, c’est l’ubiquitous language”. Ce langage permet d’avoir une logique spécifique au contexte borné qui ne déborde pas de ce contexte.
  • Les frontières du contexte borné sont franchises seulement avec des interfaces: seules les interfaces sont exposées en dehors du contexte borné de façon à volontairement limiter les échanges entre contexte borné à ces interfaces. Cette limitation permet de contrôler et de maitriser les interfaces et donc les échanges.
Source: https://martinfowler.com/bliki/BoundedContext.html

Bounded context

Les contextes bornés donnent une solution efficace pour définir les frontières des différents microservices.

L’approche “Bounded Context” est intéressante pour les microservices car:

  • Les échanges sont plus nombreux entre services dans un même même domaine fonctionnel.
  • Elle évite d’exposer trop d’interfaces au-delà du domaine.

Ainsi cette approche minimise le couplage entre les contextes bornés et maximise la cohésion à l’intérieur d’un contexte borné.

Découpage en contextes bornés

Le découpage des contextes bornés et plus spécifiquement en microservices n’est pas anodin car il apporte certaines contraintes qui peuvent être plus difficilement surmontables que dans une application en monolithe.

En effet, une fois que le découpage en contextes bornés et en microservices est effectué, il est difficilement réversible. La séparation entre les contextes devient franche, et il sera plus difficile de partager du code entre ces contextes si on s’aperçoit qu’un fonctionnement est proche. De même, si on se rend compte d’une erreur dans le découpage des microservices, le code pourrait être plus difficilement déplaçable d’un service à l’autre.

Casser un monolithe

La plupart du temps, on ne part pas d’un projet “from scratch” et on peut être contraint d’adapter un projet existant. On l’a évoqué plus haut, un intérêt de l’approche en microservices est de permettre à une application d’évoluer en limitant sa complexité. Plus une application en monolithe augmente en taille et plus elle devient complexe. Avec le temps, cette complexité tend à rendre de plus en plus difficile la réalisation de nouvelles fonctionnalités.

Couche anticorruption

La première étape pour aller vers une application en microservices est de tenter d’arrêter de faire grossir l’application en monolithe. On peut, ainsi, tenter de développer des nouvelles fonctionnalités dans un service séparé et protégé par une couche anticorruption (i.e. anticorruption layer).

Cette couche anticorruption a pour but d’éviter de corrompre la partie dans laquelle est implémentée la logique fonctionnelle. La couche métier est la plus importante car c’est elle qui possède la valeur ajoutée de l’application, c’est la partie la plus susceptible d’être conservée si la technologie change. La couche anticorruption vise à servir “d’adaptateurs technologiques” à la couche métier.

Dans l’exemple suivant, l’objectif a été de séparer la couche de présentation “Presentation Layer” de la couche métier. La couche métier accède à la base de données par l’intermédiaire d’une couche “Data Access Layer” qui appartient à la couche anticorruption.
La couche de présentation fait appel à la couche métier pour tous ces traitements, en passant par l’intermédiaire d’appels REST. L’API REST permettant ces appels fait partie aussi de la couche anticorruption.


Extraire la logique fonctionnelle

Une fois qu’on a isolé la ou les couches de présentation de la couche métier. On peut tenter, dans un premier temps, de séparer la couche métier en modules. Cette séparation a pour but de préparer une séparation plus franche en contextes bornés. Le but est donc, de considérer ces modules comme s’ils étaient des contextes bornés.

Dans l’exemple suivant, on peut voir qu’un premier travail a consisté à séparer la couche métier en modules plus ou moins autonomes (par exemple, ils ne font pas tous appels à la base de données). Ces modules peuvent, ensuite, plus facilement être séparés de la couche métier pour former un contexte borné plus autonome. Les appels à la couche métier “historique” se fait par l’intermédiaire d’une API REST qui fait partie de la couche anticorruption.


Application de DRY

Lorsqu’on conçoit des microservices, on duplique souvent des traitements d’un service à l’autre. On peut être tenté d’appliquer le principe de programmation DRY pour éviter ces duplications.

De façon à éviter trop de duplications d’un service à l’autre, on peut mutualiser du code dans des bibliothèques techniques et mettre ces bibliothèques à disposition des développeurs des différents services. Ce type de procédé peut avoir quelques conséquences:

  • Elle amène les clients des services à s’adapter aux services puisqu’ils passent par l’intermédiaire d’une bibliothèque fournie par le service donné.
  • La bibliothèque fournie peut aussi contenir du code fonctionnel.
  • Une bibliothèque fournie peut involontairement augmenter le couplage entre des clients et un service car ils doivent utiliser cette bibliothèque pour s’interfacer avec le service.

Ainsi d’une façon générale, utiliser une bibliothèque fournie par les développeurs d’un service est une mauvaise pratique. Il est préférable d’utiliser des bibliothèques techniques générales, si possible, publiques:

  • Elles ne doivent pas imposer une technologie spécifique,
  • Elles doivent permettre aux clients d’être libre sur leur choix de technologie,
  • Elles ne doivent pas contenir d’implémentation fonctionnelle.

Ainsi, l’application de DRY doit se limiter à l’intérieur d’un service et il faut généralement éviter de l’appliquer entre services.

Utiliser une base de données à partir de microservices

Lorsqu’on conçoit plusieurs microservices qui font appels à une base de données, on peut se demander si on doit utiliser une seule base de données qui sera partagée entre tous les microservices ou plutôt avoir une base de données par service.

Avoir une base de données commune est plus rapide à implémenter. D’autre part, les services partageant un même domaine fonctionnel peuvent partager les mêmes tables. Cependant, en cas de modifications de la base pour convenir aux besoins d’un service, on peut impacter tous les services faisant appel à cette base. Avoir une base commune entre services, peut aussi nécessiter des mécanismes de synchronisation des services quand un objet a été mis à jour et qu’il faut le rafraîchir dans les autres services.

D’une façon générale, avoir une base de données commune augmente le couplage entre les services et affecte la cohésion.

Passer d’une base de données commune à une base séparée

Il n’y a pas de recettes miracles car chaque cas de figure est plus ou moins spécifique toutefois on peut tenter de séparer la base de données suivant les services qui l’utilisent:

  • Identifier le mapping entre les objets et les tables: de façon à pouvoir isoler chaque table et à les déplacer dans des bases séparées,
  • Identifier à quel contexte borné pourrait appartenir une table,
  • Casser les clés étrangères entre les tables: casser des clés étrangères rendra le contenu de la base de données moins cohérent. Ainsi, il faut que les futures services soient plus robustes aux incohérences qui pourraient survenir.
  • Différencier les données en lecture seule et en lecture/écriture.

De façon générale, tous les services ne font pas appels à toutes les tables:


Pour passer à une base séparée, on peut aussi s’aider d’identifiants uniques. Ces identifiants ne sont pas forcément des clés primaires dans les tables. L’intérêt de ces identifiants est que chacun d’entre eux désignent une entité précise. Une entité peut être créée dans un service, toutefois elle peut être identifiée de façon unique grâce à son identifiant et surtout le service peut échanger l’identifiant avec les autres services. Même si la représentation complète d’une entité reste dans un service précis, les autres services peuvent désigner cette entité au moyen de son identifiant.


Données statiques partagées

On peut se poser la question de savoir comment traiter le problème des données statiques ou des données référentielles. Ce sont des données qui changent rarement et qui sont consultées, la très grande majorité du temps, en lecture seule. Plusieurs solutions sont possibles:

  • Une table en lecture seule: tous les services accèdent à la même table et les accès à cette table sont en lecture seule. Cette solution est facile à implémenter toutefois, les services deviennent dépendants d’une même table. Une modification de la table impacte tous les services.
  • Dupliquer les données statiques pour tous les services: sachant que les données statiques changent rarement, on peut les dupliquer sur plusieurs tables. Chaque table étant requêtée par un seul service. Le gros problème de cette solution est la synchronisation entre les tables qui est nécessaire à chaque mise à jour des données. Il faut prévoir un mécanisme de synchronisation si cette solution est adoptée.
  • Stocker les données dans le code: cette solution convient dans le cas où les données statiques ne sont pas trop volumineuses et peuvent être stockées dans une assembly. L’intérêt de cette méthode est qu’on peut partager cette assembly et la consommer avec NuGet par exemple. En cas de mise à jour, il suffit de mettre à jour le package NuGet avec la nouvelle assembly.

Utiliser un service “proxy”

Que ce soit pour des données référentielles ou non, une solution peut consister à passer par un service spécialisé pour accéder à des données particulières. Ce service spécialisé accède seule à la table contenant les données. Cet espèce de service “proxy” est l’intermédiaire que doit obligatoirement utiliser les autres services pour accéder à ces données:


Le gros intérêt de cette méthode est que l’accès aux données n’est pas dupliqué sur les autres services. L’accès aux données en lecture et en écriture ne se fait que d’un seul service. Cette organisation n’est pas anodine et peut poser des problèmes en cas d’erreurs. Il faut prendre en compte ces erreurs possibles lors de la conception des services client.

Ainsi en cas d’erreurs dans le cas d’écriture de données en passant par un service “proxy”, il n’y a pas de notion de transactions comme dans le cas d’une base de données relationnelle. Il faut prévoir un comportement si le service “proxy” échoue à écrire le donnée:

  • Tolérer les données incohérentes: l’échec dans l’écriture d’une donnée ou l’absence de cette donnée en lecture ne doivent pas déstabiliser le service client. Il faut prendre en compte ces incohérences.
  • Essayer plus tard l’insertion d’une donnée: dans le cas d’un échec à l’insertion d’une donnée, on peut envisager un mécanisme de répétition de l’insertion un peu plus tard.
  • Vérifier que l’insertion s’est bien passée: pour être sûr de l’insertion de la donnée et de sa persistance, on peut effectuer une requête en lecture auprès du service “proxy” pour vérifier que la donnée a bien été insérée.
  • Prévoir les échecs répétés: dans le cas où le service “proxy” échoue à écrire une donnée et que les requêtes en lecture indique les échecs répétées des insertions, il faut prévoir un mécanisme d’abandon et permettre d’annuler complètement l’opération.
  • Transaction distribuée: une autre solution peut consister à utiliser des mécanismes de transactions distribuées. Ces mécanismes sont complexes à implémenter notamment dans le cadre de microservices, il est préférable de les éviter.

Accéder à des ressources provenant d’un autre service

D’une façon générale, les accès à des ressources situées dans un autre service doivent respecter certaines précautions car ces appels se font à travers le réseau. Le fait de passer à travers le réseau n’est pas anodin car il peut dégrader les performances dans le cas où il n’est pas rapide, voir il peut mener à des erreurs s’il est en échec.

En outre, des appels à d’autres microservices pour récupérer des données n’est pas aussi simple que de créer un objet lors de l’appel à une fonction dans une même application. Ainsi, il faut:

  • Interroger le service contenant la ressource quand on a besoin: ne pas trop anticiper la récupération d’une donnée car entre son accès et son utilisation, la donnée peut avoir changé.
  • Eviter de garder un objet provenant d’un autre service trop longtemps en mémoire: pour la même raison que précédemment, si on garde une donnée trop longtemps, on peut en détenir une version obsolète.
  • Eviter de récupérer l’intégralité d’une ressource: dans la majorité des cas, une version partielle de la ressource peut suffire. L’intérêt de la version partielle est qu’il est moins couteux de la récupérer par rapport à une ressource complète.

ACID vs BASE

ACID et BASE sont des acronymes utilisés pour indiquer des propriétés s’appliquant à des transactions effectuées sur des base de données. ACID est généralement appliqué aux bases de données relationnelles:

  • Atomicité (atomicity): chaque opération sur la base de données doit être atomique même si elle est formée de plusieurs petites opérations. Du point de vue de l’application qui effectue la requête, l’opération doit être annulée complètement si une petite opération a échoué.
  • Cohérence (consistency): cette propriété indique que toutes les transactions possibles provoquant un changement à la base de données doivent la laisser dans un état valide.
  • Isolation: les requêtes vers la base doivent être isolées c’est-à-dire qu’une application qui effectue une requête ne doit pas se rendre compte que d’autres applications effectuent des requêtes au même moment.
  • Durabilité (durability): les changements effectués sur la base de données dans le cas où ils sont confirmés doivent être permanent. Par exemple, une insertion d’une donnée doit être permanente si elle a été exécutée et confirmée.

Dans le cadre du théorème CAP énoncé par Eric Brewer, il n’est pas possible d’appliquer rigoureusement ACID dans le cas d’un système distribué:

“Un système distribué ne peut garantir que 2 des contraintes suivantes à la fois:

  • Cohérence (consistency),
  • Disponibilité (availability),
  • Tolérance au partionnement (partition tolerance).”

La propriété de disponibilité indique que toutes les requêtes doivent recevoir une réponse sans garantie que cette réponse contient l’écriture la plus récente.
La tolérance au partitionnement doit permettre à un système distribué de continuer à fonctionner même si quelques messages entre les nœuds du système ont été perdus à travers le réseau.

A cause du théorème CAP, les transactions dans les systèmes distribués tentent de respecter les propriétés BASE:

  • Basically Available: cette contrainte indique qu’un système doit garantir la disponibilité au sens du théorème CAP. Il doit toujours avoir une réponse à une requête, toutefois la réponse pourrait être un échec ou la réponse pourrait être incohérente.
  • Soft State: l’état du système peut changer au cours du temps même dans le cas où des données ne sont pas insérées. L’état du système peut être amené à changer pour garantir “éventuellement la cohérence”.
  • Eventually consistency: le système peut éventuellement être cohérent quand il n’y a pas de données insérées. Quand des données sont insérées, le temps de les propager, le système ne vérifie pas la cohérence de toutes les transactions.

Les propriétés BASE sont moins contraignantes que les propriétés ACID. Le fait de pouvoir relâcher quelques contraintes permet d’être plus adapté dans le cadre de système distribué et, par suite, dans le cas de l’architecture en microservices. Il faut donc concevoir des microservices en essayant de suivre une approche BASE plutôt qu’ACID.

Les autres articles de cette série

Partie 1: Concevoir des microservices

Partie 2: Appels entre microservices

Partie 3: Intégration continue et implémentation des tests

Références