Utilisation des "Task" en 5 min

1. Quelques patterns courants

Lancer l’exécution d’une tâche

Une tâche est une opération asynchrone qui se lance de la façon suivante (à partir du framework 4.5):

Task t = Task.Run(() => { ... Code à exécuter...});

Une autre syntaxe en utilisant la "Task.Factory":

Task t = Task.Factory.StartNew(() => { ... Code à exécuter...});

Sur les versions antérieures au framework 4.5:

Task t = Task.Factory.StartNew(() => { ... Code à exécuter... });
Task.Run() et Task.Factory.StartNew() se sont pas strictement équivalents

Les 2 notations ne sont pas strictement équivalentes. La notation Task.Run() est équivalente à:

Task.Factory.StartNew(() => 
    {
        // Code de la tâche
    },
    CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default);

Plus de détails sur l’option TaskCreationOptions.DenyChildAttach dans Notion de tâche parente.

Attendre la fin de l’exécution de la tâche

Equivalent à JOIN pour les threads:

t.Wait();

Attendre la fin de l’exécution de plusieurs tâches:

Si on a une liste de tâche:

Task[] tasks = new Task[3]; 
tasks[0] = Task.Run(() => { ... Some work;});
tasks[1] = Task.Run(() => { ... Some work;}); 
tasks[2] = Task.Run(() => { ... Some work;}); 

Pour attendre la fin de l’exécution de toutes les tâches:

Task.WaitAll(tasks);

Attendre la fin de l’exécution de n’importe quelle tâche dans une liste de tâches:

Si on prends l’exemple précédent, pour attendre la fin de l’exécution de n’importe quelle tâche dans une liste de tâches, il suffit de faire:

int i = Task.WaitAny(tasks);

Le type de retour est le type générique qui définit la tâche: Task<int>.

Cette fonction permet par exemple d’effectuer un traitement à chaque fois qu’une tâche a terminé son exécution:

while (tasks.Length > 0) 
{ 
  int i = Task.WaitAny(tasks); 
  // etc... 
} 

Les appels à Wait(), WaitAll() et WaitAny() sont bloquants.

Récupérer le résultat d’une tâche

Task t = Task.Run(() => { return 42;}); 
int taskResult = t.Result; 

Effectuer un travail après l’exécution de la tâche (continuation):

On peut exécuter un bout de code à la fin de l’exécution d’une tâche. Ce bout de code s’appelle une continuation. On peut effectuer autant de continuation que l’on veut. Pour effectuer une continuation, il suffit de faire appel à la fonction ContinueWith():

Task t = Task.Run(() => { return 42;}) 
     .ContinueWith((i) => { return i.Result*2;}); 
int taskResult = t.Result; 

Dans le cas de l’exemple, "I" est un entier à cause du type générique précisé dans la définition de la tâche Task<int>.

Retour de la fonction ContinueWith

Le retour de la fonction ContinueWith est la tâche "t" qui n’est pas la même instance que la tâche renvoyée par Task.Run().
Si on fait:

Task t = Task.Run(() => { return 42;});
t.ContinueWith((i) => { return i.Result*2;}); 
int taskResult = t.Result; 

Le résultat t.Result contiendra le résultat de Task.Run() et non de ContinueWith(). Pour avoir le résultat de ContinueWith, il faut faire:

Task t = Task.Run(() => { return 42;}) 
t = t.ContinueWith((i) => { return i.Result*2;}); 
int taskResult = t.Result; 

ATTENTION: en utilisant t.Result après un ContinueWith() comme dans l’exemple précédent, l’exécution est bloquante, c’est-à-dire qu’on attend la fin de l’exécution de la tâche. Il n’est pas nécessaire d’utiliser Task.Wait()

Options TaskCreationOptions.RunContinuationsAsynchronously et TaskContinuationOptions.RunContinuationsAsynchronously

Comme indiqué plus haut, les "continuations" sont exécutées, par défaut, de façon synchrone dans le même thread qui passe la tâche initiale dans son état final. Si la tâche initiale est terminée au moment de la création de la "continuation", elle sera exécutée, par défaut, dans le thread qui a créé la "continuation".

A partir du framework 4.6, on peut indiquer qu’une "continuation" doit s’exécuter de façon asynchrone:

  • A la création de la tâche initiale: en utilisant l’option TaskCreationOptions.RunContinuationsAsynchronously, on peut indiquer que les "continuations" se déclenchant à la suite de cette tâche seront exécutées de façon asynchrone. Cette option peut être utilisée avec les méthodes de création de "Task": Task.Factory.StartNew(), les constructeurs Task() ou le constructeur TaskCompletionSource().
  • A la création de la "continuation": en utilisant TaskContinuationOptions.RunContinuationsAsynchronously, on peut indiquer que la "continuation" sera exécutée de façon asynchrone. Cette option peut être utilisée par les méthodes de création d’une continuation comme Task.ContinueWith().

Notion de tâche parente

Lorsqu’on définit une tâche dans une tâche:

  • La tâche initiale est la tâche parente et
  • La tâche dans la première tâche est la tâche enfant.

On distingue les tâches enfant détachées, c’est le type par défaut si on ne précise rien et les tâches enfant attachées.

Tâche enfant détachée

Une tâche enfant détachée se définit de cette façon:

var parent = Task.Factory.StartNew(() => {
  // Code tâche parente

  var child = Task.Factory.StartNew(() => {
    // Code tâche enfant
  });
});

Pas d’attente de la tâche enfant par défaut
Lorsque la tâche enfant est détachée, la tâche parente n’attends pas la fin de l’exécution de la tâche enfant pour passer dans un des états terminés (par exemple TaskStatus.RanToCompletion). Il faut donc utiliser Wait() pour attendre la fin de la l’exécution de la tâche enfant.

Pas de propagations des exceptions
La tâche parente ne propagent pas les éventuelles exceptions aux tâches enfant. De même, la tâche enfant ne propage pas ses éventuelles exceptions à la tâche parente. Il faut que la tâche parente utilise une “continuation” ou vérifie l’état du statut de la tâche enfant pour savoir si une exception a été lancée.

En cas d’annulation en utilisant un CancellationToken

Si on utilise le même CancellationToken entre la tâche parente et la tâche enfant:

  • Si on annule au niveau de la tâche enfant: pas de propagation vers la tâche parente.
  • Si on annule au niveau de la tâche parente: si l’exécution de la tâche enfant a déjà commencé, son exécution ne sera pas stoppé. En revanche, si l’exécution de la tâche enfant n’a pas débuté, elle ne sera pas lancée.

Tâche enfant attachée

Contrairement au type de tâche enfant par défaut, il faut rajouter l’option AttachedToParent pour qu’une tâche enfant soit attachée à la tâche parente:

var parent = Task.Factory.StartNew(() => {
  // Code tâche parente

  var child = Task.Factory.StartNew(() => {
    // Code tâche enfant
  }, TaskCreationOptions.AttachedToParent);
});

Le statut de la tâche parente dépends de ceux des tâches enfant.

Attente de la tâche enfant
Il n’est pas nécessaire d’utiliser Wait() pour attendre la fin de l’exécution de la tâche. Par défaut la tâche parente attends la fin de l’exécution de ses enfants.

Propagation des exceptions
Les exceptions qui surviennent éventuellement dans la tâche enfant sont propagées à la tâche parente et affectera le statut final de la tâche parente.

En cas d’annulation en utilisant un CancellationToken
Si on utilise le même CancellationToken entre la tâche parente et la tâche enfant attachée, en cas d’annulation dans la tâche enfant, elle sera propagée dans la tâche parente et elle affectera le résultat de la tâche parente.

Attention au démarrage de la tâche parente:

Si on utilise Task.Run(), il n’est pas possible d’utiliser des tâches enfant attachées. Les tâches enfant sont systématiquement détachées.
En revanche, Task.Factory.StartNew() permet de créer des tâches enfant attachées.

L’équivalent de:

Task.Run(() => 
    {
        // Code de la tâche
    });

est:

Task.Factory.StartNew(() => 
    {
        // Code de la tâche
    },
    CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default);

Plus de détails sur MSDN.

Utilisation de l’option de création “LongRunning”

Par défaut, le processus dans lequel est exécuté une tâche n’attends, pas la fin de son exécution pour sortir. Pour que le processus attende la fin de l’exécution d’une tâche avant de sortir, il faut utiliser l’option TaskCreationOptions.LongRunning.

TaskCreationOptions.LongRunning va aussi imposer d’exécuter la tâche un thread particulier.

Par exemple:

var cancellationTokenSource = new CancellationTokenSource();
Task.Factory.StartNew(() => { ... Code à exécuter...},
  TaskCreationOptions.LongRunning,
  cancellationTokenSource.Token);

Il est fortement conseillé d’utiliser un CancellationToken dans le cas où on utilise l’option LongRunning.

FromException(), FromCanceled() et FromResult()

A partir du framework 4.6, on peut créer directement une "Task" avec un statut particulier:

  • Si on souhaite une "Task" avec le statut TaskStatus.RanToCompletion, on peut utiliser Task.FromResult() en indiquant directement le résultat,
  • Si on souhaite une "Task" avec le statut TaskStatus.Canceled, on peut utiliser Task.FromCanceled() avec un CancellationToken,
  • et si on souhaite une "Task" avec le statut TaskStatus.Faulted, on peut utiliser Task.FromException() avec une exception.

L’intérêt de ces nouvelles méthodes est d’effectuer un traitement synchrone lorsqu’on est obligé d’utiliser d’avoir une signature de méthode asynchrone. Par exemple, si une interface impose une signature asynchrone du type:

public Task<TResult> ExecuteAsync()
{
    // ...
}

Toutefois dans l’implémentation, au lieu de lancer une exécution asynchrone, si on peut vouloir affecter directement le résultat (parce qu’on le possède déjà) et avoir une "Task" dont le résultat est déjà affectée. Dans ce cas:

  • Avant le framework 4.6, on peut utiliser la classe TaskCompletionSource,
  • A partir du framework 4.6, on peut utiliser directement les fonctions FromResult(), FromCanceled() ou FromException().

Par exemple:

public Task<int> GetResultAsync(CancellationToken cancellationToken)
{
    if (cancellationToken.IsCancellationRequested)
    {
        return Task.FromCanceled<int>(cancellationToken);
    }

    try
    {
        return Task.FromResult(GetResultSync());
    }
    catch (Exception exception)
    {
        return Task.FromException(exception);
    }
}

public int GetResultSync()
{
    // ...
}

2. Gestion des exceptions

Sans gestion explicite, les exceptions qui surviennent dans les "Task" ne seront jamais remontées. La "Task" stoppera son exécution et l’exception passera inaperçue.
Une façon simple de gérer les exceptions qui peuvent survenir dans une "Task" est d’utiliser une "continuation":

Task task =  Task.Factory.StartNew(() => DoPrintConfigPage(serial))
    .ContinueWith(tsk => {
        logger.ErrorFormat("Exception occured: {0}", tsk.Exception.ToString());
    }, TaskContinuationOptions.OnlyOnFaulted);
Attention au retour de ContinueWith

Dans l’exemple précédent, task contient l’instance de la continuation et non l’instance de la "Task" qui est lancée par Task.Factory.StartNew()

AggregateException

Dans l’exemple précédent, tsk.Exception est une instance de "AggregateException". Cette exception contient d’autres exceptions accessibles avec tsk.Exception.InnerExceptions (AggregateException.InnerExceptions).

CancellationToken

Cet objet permet de signaler à une ou plusieurs tâches d’annuler leur exécution. Mais attention, à l’intérieur de la tâche, il faut explicitement vérifier qu’une annulation est requise sinon la tâche ne s’arrêtera jamais.
Une instance de "CancellationToken" est liée à une "CancellationTokenSource":

var tokenSource = new CancellationTokenSource();
var token = tokenSource.Token;

La demande d’annulation se fait au niveau de "CancellationTokenSource":

tokenSource.Cancel();

Ainsi, on peut utiliser le "CancellationToken" au moment de lancer la tâche:

var tokenSource = new CancellationTokenSource();
var token = tokenSource.Token;
var task = Task.Factory.StartNew(() => {
  if (token.IsCancellationRequested)
  {...}
}, token);
L’utilisation de "CancellationToken" doit respecter certaines règles:
  • Si une tâche est en cours d’exécution, son statut est TaskStatus.Running. En cas d’annulation, pour que la tâche passe au statut TaskStatus.Canceled, il faut qu’une exception de type OperationCanceledException soit lancée à l’intérieur de la tâche (ne pas oublier d’inclure le token lors du lancement de l’exception: throw new OperationCanceledException(token)).
    Une façon de lancer directement cette exception est d’exécuter: token.ThrowIfCancellationRequested() (l’exception sera lancée seulement si l’annulation a été signalée).
  • Si une annulation est requise (i.e. si tokenSource.Cancel() a été exécutée), toutes les utilisations suivantes de l’instance de la "CancellationToken" vont conduire à une OperationCanceledException.

Par exemple si on implémente:

var tokenSource = new CancellationTokenSource();
var token = tokenSource.Token;
var task = Task.Factory.StartNew(() => {
  if (token.IsCancellationRequested)
  {...}
}, token);
tokenSource.Cancel();
task.Wait(token);

La ligne task.Wait(token) lancera une OperationCanceledException. Ainsi les utilisations extérieures à la tâche de token pouvant être exécutées après l’annulation doivent être entourées d’un bloc try...catch (OperationCanceledException) {}.

Utilisation d’une continuation

Si on utilise un "CancellationToken", on peut utiliser une continuation pour notifier l’annulation de la tâche:

var tokenSource = new CancellationTokenSource();
var token = tokenSource.Token;
var task = Task.Factory.StartNew(() => {
  while (true)
    {
      token.ThrowIfCancellationRequested();
       ...
    }
}, token);
var continuationTask = task.ContinueWith(tsk => {
        logger.InfoFormat("Task canceled !");
    }, TaskContinuationOptions.OnlyOnCanceled);
tokenSource.Cancel();
task.Wait();
ATTENTION: exécution de la continuation

Pour que la continuation soit exécutée, il faut que la tâche soit dans le statut TaskStatus.Canceled c’est-à-dire que la ligne token.ThrowIfCancellationRequested() soit exécutée.

Exemple d’une implémentation utilisant des continuations pour l’annulation et pour une exception:

var tokenSource = new CancellationTokenSource();
var token = tokenSource.Token;
var task = Task.Factory.StartNew(() => {
  while (true)
    {
      token.ThrowIfCancellationRequested();
       ...
    }
}, token);
var exceptionTask = task.ContinueWith(tsk => {
        logger.InfoFormat("Task canceled !");
    }, TaskContinuationOptions.OnlyOnCanceled);
var continuationTask = task.ContinueWith(tsk => {
        logger.ErrorFormat("Exception occured: {0}", tsk.Exception.ToString());
    }, TaskContinuationOptions.OnlyOnFaulted);

3. TaskScheduler

Cet objet permet de manipuler un ensemble de tâches comme le threadpool pour les threads. Ainsi on peut ajouter des tâches dans une file d’attente QueueTask(), enlever une tâche de la file d’attente TryDequeue() etc…

Task et thread principal

Il est possible de démarrer une tâche associée au contexte de synchronisation du thread principal en particulier si on veut intervenir sur l’interface graphique pour effectuer certains traitements. En effet il n’est pas possible d’interagir avec des objets définis dans le thread principal puisque le contexte de synchronisation n’est pas le même: SynchronizationContext.

Pour obtenir une tâche associé au contexte de synchronisation du thread principal:

TaskScheduler.FromCurrentSynchronizationContext();

Problème lecteur/écrivain

Depuis le framework 4.5, TaskScheduler permet de répondre au problème lecteur/écrivain avec ConcurrentExclusiveSchedulerPair.

Cette classe fournit une paire d’écrivains et de lecteurs. Les lecteurs peuvent s’exécuter de façon non exclusives, les écrivains s’exécutent de façon exclusives. Les écrivains ont la priorité par rapport aux lecteurs.

var pair = new ConcurrentExclusiveSchedulerPair(TaskScheduler.Default); 
var writers = new Task.Factory(pair.ExclusiveScheduler); 
var readers = new Task.Factory(pair.ConcurrentScheduler);

writers est un objet qui permet de créer des tâches d’écrivain et reader permet de créer des tâches de lecteurs.

4. Exécution asynchrone

Les continuations permettent d’exécuter des tâches de façon asynchrone mais il est possible d’effectuer des traitements asynchrones en utilisant la Task.Factory ().

Cette classe permet de définir un délégué qui sera exécutée à la fin de l’exécution en utilisant FromAsync().

Par exemple dans le cadre de la lecture d’un fichier:

FileInfo fi = new FileInfo(path);  
byte[] data = null;  
data = new byte[fi.Length];  
FileStream fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, data.Length, true);  

//Task returns the number of bytes read  
Task task = Task.Factory.FromAsync( fs.BeginRead, fs.EndRead, data, 0, data.Length, null); 
Appel non bloquant

Cet exemple permet de montrer comment on peut créer une tâche en utilisant FromAsync() mais il n’est pas bloquant.

Voir Exécution asynchrone avec “await” et “async” en 5 min pour avoir plus détails sur l’exécution asynchrone en utilisant async et await.

5. TaskCompletionSource

L’objet TaskCompletionSource encapsule une “task” sur laquelle il est possible d’effectuer tous les traitements possibles au même titre qu’une “task” classique. L’intérêt de cette “task” encapsulée est qu’elle ne possède pas de corps, il n’y a donc pas besoin d’une implémentation particulière pour l’utiliser et on bénéficie de tous les autres éléments d’une “task” classique comme les “continuations” ou la gestion des exceptions.

Le point d’entrée de cette “task” est l’objet TaskCompletionSource. On peut donc préciser le résultat de la “task” encapsulée ou affecter une exception en utilisant directement l’objet TaskCompletionSource. Pour rappel, lorsqu’on affecte un résultat à une “task”, son état change à TaskStatus.RanToCompletion. De même si on affecte une exception à une “task”, son état change pour passer en TaskStatus.Faulted. Enfin, annuler la “task” affecte l’état TaskStatus.Canceled. RanToCompletion, Faulted et Canceled sont des états finaux affectées lorsque la tâche est terminée.

L’intérêt de TaskCompletionSource peut être d’affecter une “continuation” à la “task” encapsulée et de maîtriser le démarrage de la continuation en affectant un état final à la “task” encapsulée en utilisant une des méthodes citées précédemment:

  • Affecter un résultat en faisant TaskCompletionSource.SetResult() pour que la “task” passe à l’état TaskStatus.RanToCompletion.
  • Affecter une exception en faisant TaskCompletionSource.SetException() pour que la “task” passe à l’état TaskStatus.Faulted.
  • Annuler la “task” en utilisant un objet CancellationToken passé en paramètre de TaskCompletionSource. La “task” passe alors, à l’état TaskStatus.Canceled.

Par exemple, si on utilise des “continuations”:

public class TaskUsage  
{  
  private readonly TaskCompletionSource<bool> taskCompletionSource = new TaskCompletionSource<bool>(); 
  private readonly Task canceledTask; 
  private readonly Task exceptionOccuredTask; 
 
  public TaskUsage() 
  {  
      this.canceledTask = this.taskCompletionSource.Task.ContinueWith( 
          tsk => Console.WriteLine("Canceled"), 
          TaskContinuationOptions.OnlyOnCanceled); 
      this.exceptionOccuredTask = this.taskCompletionSource.Task.ContinueWith( 
          tsk => Console.WriteLine("Exception occured: " + tsk.Exception.ToString()), 
          TaskContinuationOptions.OnlyOnFaulted); 
  } 
 
  public bool Wait(TimeSpan timeout) 
  { 
    bool executedWithinTimeout = this.taskCompletionSource.Task.Wait(timeout) 
   
    return executedWithinTimeout; 
  } 

  public void SetException(Exception e) 
  {
    this.taskCompletionSource.SetException(e); 
  } 
 
  public void Signal() 
  {
    this.taskCompletionSource.SetResult(true); 
  } 
}

Dans cet exemple, on affecte des “continuations” qui s’exécutent si on affecte un résultat ou si on affecte une exception.

Plus de détails sur MSDN.

FromException(), FromCanceled() et FromResult()

A partir du framework 4.6, on peut s’aider des fonctions FromException(), FromCanceled() et FromResult() qui permettent d’éviter d’utiliser la classe TaskCompletionSource.

Par exemple, l’équivalent de:

var taskCompletionSource = new TaskCompletionSource<TResult>();
taskCompletionSource.SetException(exception);
return taskCompletionSource.Task;


devient:

Task.FromException(exception)

Le résultat est directement une "Task" avec le statut TaskStatus.Faulted.
FromResult() et FromCanceled() permettent d'obtenir une "Task" avec un statut différent.

Pour plus de détails, on peut se reporter à FromException(), FromCanceled() et FromResult().

Task.CompletedTask

A partir du framework 4.6, on peut créer directement une "Task" avec le statut TaskStatus.RanToCompletion sans utiliser TaskCompletionSource en utilisant la propriété statique:

Task.CompletedTask

6. Les "closures"

Une "closure" est une fonction qui capture au moins une référence vers une variable libre (source wikipedia), par exemple:

var freeVariable = "Value from the outside";
Func<string,string> myFunc = funcVariable => 
{
  return funcVariable + freeVariable;
};

La variable freeVariable est une variable libre, elle est définie à l'extérieur du délégué et elle est passée dans le délégué par référence. Dans le cas d'une utilisation de la variable libre par un seul délégué, il n'y a pas de surprises sur sa valeur. En revanche, si on utilise la variable dans une boucle ou si on effectue des affectations à la variable libre dans plusieurs délégués, on peut avoir des surprises quant à sa valeur.

Utilisation d'une "closure" dans une boucle

Par exemple si on écrit:

List<Func<int>> actions = new List<Func<int>>();

int freeVariable = 0;
for (int freeVariable = 0; freeVariable < 5; freeVariable++)
{
    actions.Add(() => freeVariable * 2);
}

foreach (var act in actions)
{
    Console.WriteLine(act.Invoke());
}

On ne maitrise pas la valeur de la variable libre car elle est passée par référence dans le délégué. Ainsi, au moment de l'exécution du délégué, sa valeur correspondra à la dernière valeur de la variable, à savoir, 10 c'est-à-dire la dernière valeur de la référence.

Pour que la valeur soit spécifique à chaque boucle, il faut copier la variable dans une autre pour que la variable libre soit copiée par valeur:

for (int freeVariable = 0; freeVariable < 5; freeVariable++)
{
    int variableCopy = freeVariable;
    actions.Add(() => variableCopy * 2);
}

La valeur de variableCopy est spécifique à chaque itération puisque la valeur est copiée et qu'on utilise plus la référence de la variable libre.

Utilisation d'une variable dans plusieurs délégués

Par exemple si on écrit:

static void Main(string[] args)
{
  var definedDeleguate = DefineDeleguate();
  Console.WriteLine(definedDeleguate(2));
  Console.WriteLine(definedDeleguate(3));
}
 
public static Func<int,int> DefineDeleguate()
{
  var freeVariable = 1;
  Func<int, int> definedDeleguate = (funcVariable) =>
  {
    freeVariable = freeVariable + 1;
    return funcVariable + freeVariable;
  };
  return definedDeleguate;
}

Après exécution, on obtient 4 et 6. Au premier abord, on s'attendrait à 4 et 5 à cause de l'initialisation de freeVariable à 1. Cependant comme la valeur est passée au délégué par référence, la référence est gardée lors de la 2e exécution du délégué. Ainsi à la première exécution, la valeur de freeVariable est 2. Cette valeur persistera lors de la 2e exécution du délégué d'où le résultat 6.

Explication sur les "closures"

Sans rentrer dans les détails, il faut considérer une "closure" comme une classe à part entière dans laquelle se trouve la fonction correspondant au délégué et un ou plusieurs membres correspondant à une ou plusieurs variables libres.
Ainsi la vie de cette classe correspond à la vie du délégué. Sachant que la variable libre est passée par référence à la fonction, la valeur de la variable libre sera affectée durant la vie de la classe:

  • Dans une boucle, sa valeur est incrémentée pendant l'exécution de la boucle,
  • Si on exécute plusieurs fois le délégué, sa valeur sera modifiée et persistée à chaque exécution du délégué.

Les "closures" et les "tasks"

Les "tasks" utilisent des délégués et on peut être amené à utiliser des "closures". Ainsi, les mêmes problèmes peuvent se poser quant à la valeur des variables libres utilisées dans le délégué.

De la même façon, les variables libres sont passées par référence. En outre, sachant que l'exécution d'une "task" ne s'effectue rigoureusement au même moment d'une exécution à l'autre par rapport à l'enchaînement des instructions, on peut aussi avoir des surprises sur la valeur de variables libres quand elles sont utilisées dans le corps d'une "task".

Par exemple, si on écrit:

int freeVariable = 45; 
Task.Factory.StartNew( () =>
{
   freeVariable++;
}
);
// ...
Console.WriteLine(freeVariable);

On peut obtenir pour certaines exécution 45 et pour d'autres 46, suivant si la "task" a eu le temps de s'exécuter ou non.

Pourquoi implémenter "GetHashCode()" quand "Equals()" est surchargé ?

GetHashCode() permet de fournir un clé de hashage qui sera utilisée pour différencier l’objet par rapport à un autre. Par exemple, il sert pour les dictionnaires pour comparer rapidement des objets entre eux. Des objets de même type et ayant les valeurs (de propriétés par exemple) doivent avoir le même hash code.

Tous les composants .NET n’utilisent pas seulement l’opérateur d’égalité pour comparer deux objets entre eux comme les dictionnaires par exemple. Ainsi si on définit l’opérateur d’égalité, il faut aussi redéfinir GetHashCode().

Comment redéfinir GetHashCode() pour une classe ?

Une astuce consiste à utiliser le GetHashCode() des propriétés de la classe. On peut les associer en utilisant l’opérateur "^" ("ou" exclusif bit à bit):

GetHashCode(classe) = prop1.GetHashCode() ^ prop2.GetHashCode() ^ prop3.GetHashCode()… 

On peut alors redéfinir l’opérateur GetHashCode() pour les types de ces propriétés ou l’utiliser tel que pour les types primitifs.

Le type doit être le même: par exemple pour un entier et un long, il faudra "caster" le long en int.

Mot clé C# dynamic

Permet d’introduire la notion de type dynamique en C#. Contrairement aux variables fortement typées, le type n’est pas connu à la compilation, il est déterminé à l’exécution en fonction de l’initialisation.

On peut utiliser dynamic pour des variables locales, dans la signature de fonctions et pour les membres de classe, en revanche on ne peut pas l’utiliser pour une classe ou pour un type d’opérateur.

L’intérêt de dynamic est de permettre d’utiliser un type qu’on ne connait pas à la compilation.

Comparaison avec "var"

Dynamic est différent de var car var sert juste à laisser le compilateur déterminer le type de la variable en fonction des initialisations mais le type reste déterminé à la compilation (et non à l’exécution comme pour dynamic).

Comparaison avec System.Object

Dynamic permet d’effectuer des opérations plus fines que system.object puisque on peut, par exemple, permettre d’utiliser des opérateurs en fonction du type de la variable:

class Program 
{ 
  static void Main(string[] args) 
  {
    // The dynamic variable gets the return  
    // value of a function call and outputs it. 

    dynamic x = DoubleIt(2); 
    Console.WriteLine(x); 

    // Stop and wait 
    Console.WriteLine(“Press any key”); 
    Console.ReadLine(); 
  } 

  // The function receives and returns a dynamic object  
  private static dynamic DoubleIt(dynamic p) 
  { 
    // Attempt to "double" the argument whatever  
    // that happens to produce 

    return p + p; 
  } 
}

Mot clé C# Volatile

Le mot clé volatile indique qu’un champ peut être modifié par plusieurs threads qui s’exécutent simultanément. Les champs qui sont déclarés volatile ne sont pas soumis aux optimisations du compilateur qui supposent l’accès par un seul thread. Cela garantit que la valeur la plus à jour est présente dans le champ à tout moment.

Rien ne garantit l’ordre d’exécution d’une instruction par rapport à une autre lorsqu’on effectue 2 opérations de lecture ou 2 opérations d’écriture sur des variables différentes. Après optimisation du compilateur, les instructions peuvent très bien être inversées. En effet une instruction dans le code équivaut à plusieurs instructions en langage machine. Ainsi dans le cadre d’une exécution du code par 2 threads séparées, l’exécution peut mener à des erreurs si on utilise les variables dans les 2 threads.

Pour palier à ce problème, on peut utiliser le mot clé VOLATILE qui permet de garantir l’ordre d’exécution d’une variable par rapport à une autre:
Dans le cas d’écritures, vous avez une écriture ordinaire suivie d’une écriture volatile, et une écriture volatile ne peut pas être réorganisée lorsqu’elle est précédée d’une opération de mémoire.
Dans le cas de lectures, vous avez une lecture volatile suivie d’une lecture ordinaire, et une lecture volatile ne peut pas être réorganisée lorsqu’elle est suivie d’une opération de mémoire.

L’exemple suivant permet d’illustrer une bonne utilisation du mot clé:

public class DataInit 
{ 
   private int _data = 0; 
   private volatile bool _initialized = false; 

   void Init() 
   { 
     _data = 42; // Write 1 
     _initialized = true; // Write 2 
   } 

   void Print() 
   { 
     if (_initialized) { // Read 1 
       Console.WriteLine(_data); // Read 2 
     } 
     else { 
       Console.WriteLine("Not initialized"); 
     } 
   } 
} 

D’une façon générale, il est fortement conseillé d’utiliser des classes du framework pour manipuler des mêmes variables dans des threads séparées:
Valeurs initialisées tardivement: Lazy<T>; LazyInitializer
Collections thread-safe: BlockingCollection<T>; ConcurrentBag<T>; ConcurrentDictionary<TKey,TValue>; ConcurrentQueue<T>; ConcurrentStack<T>
Primitives permettant de coordonner l’exécution de différents threads: AutoResetEvent; Barrier; CountdownEvent; ManualResetEventSlim; Monitor; SemaphoreSlim
Conteneur conservant une valeur séparée pour chaque thread: ThreadLocal<T>

D’une façon générale, les bonnes pratiques sont:
Évitez d’utiliser inutilement des champs volatiles: le plus souvent, des verrous ou des collections simultanées (System.Collections.Concurrent.*) conviennent mieux pour l’échange de données entre threads. Dans certains cas, des champs volatiles peuvent être utilisés pour optimiser du code simultané mais vous devriez utiliser des mesures de performance afin de valider le fait que l’avantage l’emporte sur le surcroît de complexité.
– Au lieu d’implémenter le modèle d’initialisation tardive vous-même en utilisant un champ volatile, utilisez les types System.Lazy<T> et System.Threading.LazyInitializer.
Évitez les boucles d’interrogation: vous pouvez souvent utiliser BlockingCollection<T>, Monitor.Wait/Pulse, des événements ou une programmation asynchrone à la place d’une boucle d’interrogation.
– Dès que cela est possible, utilisez les primitives de simultanéité .NET standards au lieu d’implémenter une fonctionnalité équivalente vous-même.

Formation MongoDB M101N: semaine 6 – Conception des applications

Durabilité des écritures

Dans un contexte classique, on a:

Application ⇔ driver ⇔ mongod ⇔ mongo Shell

Des opérations d’écritures sont faites par l’application et par le mongo shell. Pour obtenir les erreurs:
– dans le cas du mongo Shell, on les a tout de suite,
– dans le cas de l’application, ces erreurs sont gérées par le driver.

On peut aussi obtenir les erreurs en appelant "getLastError".

"getLastError" comporte 2 paramètres:
w: permet d’indiquer qu’on souhaite recevoir un acknowladgement quand on a écrit en base. Cette acknowladgement ne certifie pas que l’écriture a été effectuée sur le disque mais qu’elle se trouve en mémoire. En cas de coupure d’alimentation l’écriture peut être perdue.
j: indique que l’écriture a été rajoutée dans le journal de log. Dans le cas de cette valeur, on est sûr que l’écriture s’est bien passée et qu’elle se trouve en base.

Différents cas sont possibles:

w=0 et j=0: mode “fire and forget”: on a aucune assurance que l’écriture s’est bien passée. C’était la valeur par défaut jusqu’à la version 3.0.
w=1 et j=0: mode “safe”, dans la plupart des cas l’écriture s’est bien passée. C’est la mode par défaut à partir de la version 3.
w=1 et j=1: mode “commit to journal”: l’écriture est garantie.
w=0 et j=1: même comportement que mode précédent.

Tous ces modes s’appellent le “Write concern” et sont gérés par le driver.

Problèmes réseau

Même dans le mode “commit to journal”, on peut ne pas recevoir l’indication de l’écriture dans le journal en cas de problème réseau. Dans ce cas, l’écriture peut avoir été faite en base mais on ne reçoit pas le signal indiquant l’écriture dans le journal.

Réplication

L’architecture classique de mongo DB est le “replica set”. Il est composé de minimum 3 bases de données autonomes. L’un des nœuds est le primaire “primary” et les autres sont secondaires “secondary”. L’application via le driver effectue les opérations d’écriture systématiquement sur le primaire.

Si le primaire n’est plus accessible, les 2 autres nœuds élisent un primaire qui recevra les nouvelles opérations d’écriture. Si la nœud défaillant redevient opérationnel, il deviendra un secondaire.

Il faut un minimum de 3 nœuds pour permettre l’élection d’un nouveau primaire.

Types de nœud dans un “replica set”

4 types différents:
Regular: nœud normal pouvant être primaire ou secondaire et ayant un droit de vote.
Arbitrer: il ne contient pas de données mais dans le cas où on a que 2 machines, il permet d’avoir un nombre impair pour effectuer les votes. Il peut donc être hébergé sur la même machine qu’un “regular”.
Delayed/regular: il s’agit d’une machine n’ayant pas les mêmes performances qu’un “regular”. Il peut servir à dépanner un “regular” défaillant. Il contient des données et a un droit de vote mais il ne peut pas devenir primaire. Sa priorité est nulle.
Hidden: il contient des données aussi mais il ne peut pas devenir primaire. Il possède un droit de vote. Sa priorité est nulle.

Consistance en écriture

Par défaut l’application effectue des opérations d’écriture et de lecture sur le nœud primaire seulement. Ceci permet d’avoir une forte consistance des données après l’écriture. Ainsi si on lit les données tout de suite après l’écriture, les valeurs correspondront à l’écriture.

Il est possible de permettre les lectures sur les nœuds secondaires mais sachant que la réplication des données est asynchrone, une lecture immédiate après l’écriture ne certifie pas d’avoir des données consistantes.

Création d’un “replica set”

A partir du mongo Shell:

mongod --replSet rs1 --logPath "1.log" --dbPath /data/rs1 --port 27017 --fork

"rs1" est juste le nom du “replica set”.

On indique un port dans ce cas pour différencier les serveurs dans le cas où le “replica set” se trouve sur la même machine.

"fork" permet d’afficher certaines informations sur le mongo Shell.

Pour créer les autres serveurs, il suffit de renouveler la commande avec des ports différents:

mongod --replSet rs1 --logPath "2.log" --dbPath /data/rs1 --port 27018 --fork 
mongod --replSet rs1 --logPath "3.log" --dbPath /data/rs1 --port 27019 --fork

A ce moment les serveurs ne sont pas connectés entre eux.

Pour connecter les serveurs entre eux:

config = { _id : "rs1", members : [ 
{ _id : 0, host : "machine:27017", priority: 0 } 
{ _id : 1, host : "machine:27018" } 
{ _id : 2, host : "machine:27019" } 
]}

rs.initiate(config) 
rs.status()

Le nœud "0" ne pourra pas devenir primaire à cause de "priority:0".

"rs.status()" n’est pas nécessaire pour la création du “replica set” mais affiche seulement le statut.

Pour passer d’un serveur à l’autre, il suffit d’écrire:

mongo --port 27017

Pour autoriser les opérations de lecture sur un secondaire:

rs.slaveOk()

Pour savoir si le serveur sur lequel on est connecté est le primaire:

rs.isMaster()

La réplication des modifications du primaire sur les secondaires se fait par l’intermédiaire de la table "oplog.rs" qui contient un timestamp. Ainsi les secondaires interrogent le primaire régulièrement pour savoir quelles sont les dernières modifications en fonction du timestamp, il peuvent ensuite répliquer les modifications.

Remarque

La réplication supporte des moteurs de stockage différents (storage engine) entre primaire et secondaire.

Failover et rollback

Si un primaire devient défaillant dans un “replica set”, un secondaire sera élu en tant que primaire. Durant le laps de l’élection et de la défaillance, des écritures ont pu être effectué dans le primaire initial.

Si ce primaire redevient opérationnel, il restera secondaire mais il peut contenir des opérations d’écriture que le nouveau primaire n’a pas. Dans ce cas, le primaire va effectuer un rollback et écrire ces opérations dans un fichier pour qu’elles soient éventuellement exécutées manuellement.

Durant le laps de temps du failover, les écritures méneront à des erreurs.

Driver et failover

Lorsqu’une application est connectée à un primaire, si ce primaire devient défaillant puis redevient opérationnel, il aura perdu son statut de primaire. L’application peut entraîner une exception si elle effectue une écriture sur cet ancien primaire.

Par la suite il faudra que l’application change de serveur pour continuer ces opérations d’écriture. Ces mécanismes sont gérés par le driver mais les blocs de code doivent contenir des Try...Catch pour prévenir de ces exceptions.

“Write concern” dans le cas d’un “replica set”

Il est possible de paramétrer la valeur de "w" différemment dans le cas d’un “replica set”:
w=1: on attends l’acknowledgment du primaire,
w=2: on attends l’acknowledgment d’au moins un secondaire,
w=3: on attends l’acknowledgment de tous les secondaires.

En paramétrant une valeur de "w" correspondant à la majorité, on évite la plupart des cas de rollback puisque si la valeur est écrite au moins sur 2 serveurs, on peut imaginer qu’elle ne sera pas rollbacker.

Il est possible de paramétrer une valeur de "w" au niveau de la connexion, de la collection ou du “replica set”.

Il n’est pas possible de paramétrer une valeur différente de 0 ou 1 pour "j".

Remarque

Si w=1 et j=1, on a pas la garantie que des rollbacks ne vont pas se produire car une écriture peut ne pas avoir été faite sur le secondaire lors de la défaillance du primaire.

Préférence en lecture

Au niveau du driver d’une application, il est possible d’indiquer des préférences pour définir la façon dont les données seront lues:
PRIMARY: les lectures ne se font que sur le primaire,
SECONDARY: on effectue les lectures seulement sur un secondary. Si il est indisponible, on ne pourra plus lire.
SECONDARY_PREFERRED: la lecture sera effectuée de préférence sur des secondaires.
PRIMARY_ PREFERRED: la lecture sera effectuée de préférence sur des primaires.
NEAREST: on effectuera les lectures sur le secondaire qui est le plus proche (i.e. Pong le plus court).
TAGGING: on peut ajouter des tags pour indiquer sur quel serveur primaire ou secondaire, on va effectuer les lectures.

Implication de la réplication

– Liste des nœuds: le plus souvent géré par le driver.
– Write concern: valeurs de "w", "j" et du timeout de "w".
– Préférences en lecture
– Des erreurs peuvent survenir, il faut protéger la lecture et l’écriture des données par des blocs TRY...CATCH pour anticiper les erreurs.

Remarque

Si on ne précise pas de timeout de "w", dans le cas où w > 1, on peut attendre une très longue période avant d’avoir une réponse.

Sharding

Pour augmenter les performances et la scalabilité d’une base, il est possible de diviser un environnement Mongo en plusieurs éléments: les “shards”. Chaque “shard” est un “replica set” (composé donc d’au moins 3 serveurs).

Il sera donc possible de diviser une collection en plusieurs parties et chaque partie sera hébergée sur un “shard” différent. Les parties de la collection s’appelle les “chunks”.

Lorsqu’une requête est effectuée sur un environnement composé de “shards”:
– l’application doit se connecter un serveur Mongos au lieu de se connecter à un serveur Mongod. C’est Mongos qui va savoir quel “shard” doit être requêté.
– si on utilise le mongo Shell, il devra être connecté directement au Mongos.
– les documents sont rangés dans les “shards” en fonction de la valeur du “shard key” qui correspond à la clé primaire de la collection. Mongos va se charger lui-même de repartir les documents équitablement sur chaque “shard”.
– sachant que les “shards” sont interrogés en fonction de la valeur du “shard key”, toutes les requêtes doivent comporter le “shard key” lorsqu’une collection est ainsi divisée.
– le “shard key” doit posséder un index.

Mise en œuvre d’un environnement comportant des “shards”

La configuration basique d’un environnement avec des “shards” doit comporter au minimum:
– 2 “shards” composé chacun d’un “replica set”
– chaque “replica set” doit être composé d’au moins 3 serveurs mongod.
– il doit y avoir au moins 3 instances de mongod pour effectuer de la configuration. Ces instances peuvent être exécutées sur la machine qu’un des “replica sets”.

Ainsi une configuration minimum comporte au moins 9 instances de mongod qui s’exécutent: 3 par “replica set” et 3 pour les instances de configuration.

Pour mettre en place un “shard”, il faut créer un “replica set” et préciser qu’il s’agit d’un “shard”:

mongod --replSet rs1 --logPath "1.log" --dbPath /data/shard0/rs0 --port 27017 --fork --shardsvr 
mongod --replSet rs1 --logPath "2.log" --dbPath /data/shard0/rs1 --port 27018 --fork --shardsvr 
mongod --replSet rs1 --logPath "3.log" --dbPath /data/shard0/rs2 --port 27019 --fork --shardsvr

config = { _id : "s0", members : [ 
{ _id : 0, host : "machine:27017" } 
{ _id : 1, host : "machine:27018" } 
{ _id : 2, host : "machine:27019" } 
]} 

rs.initiate(config)

"--shardsvr" permet d’indiquer que le replica set doit faire partie d’un “shard”.

On renouvelle l’opération pour le 2ème “shard”.

On crée ensuite les 3 serveurs de configuration:

mongod --logpath "cfg-a.log" --dbpath /data/config/config-a --port 57017 --fork --configsvr 
mongod --logpath "cfg-b.log" --dbpath /data/config/config-b --port 57018 --fork --configsvr 
mongod --logpath "cfg-c.log" --dbpath /data/config/config-c --port 57019 --fork --configsvr

"--configsvr" permet d’indiquer qu’on souhaite créer des serveurs de configuration.

On démarre le serveur mongos en incluant les serveurs de configuration:

mongos --logpath "mongos.log" --configdb machine:57017,machine:57018,machine:57019 --fork

On ajoute les “shards” et on ajoute la collection au “shards”:

db.adminCommand( { addshard: "s0/machine:37017" } ); 
db.adminCommand( { addshard: "s1/machine:47017" } );
db.adminCommand( { enableSharding: "test" } );

"test" est le nom de la base de données.

db.adminCommand( { shardCollection: "test.grades", key: { student_id : 1 }} );

"test.grades" est le nom de la collection,
"student_id" est le nom de la shard key.

Si on utilise le mongo shell, il faut ensuite se connecter au mongos et non à un serveur mongod.

Quand on effectue une requête, en utilisant ".explain()" on peut voir sur quel “shard” la requête a réellement été exécutée.

sh.status() permet d’indiquer le statut de l’environnement de “shards”.

Implications du “sharding”

– Tous les documents doivent inclure la “shard key”.
– La “shard key” est non modifiable.
– Tous les index doivent commencer par la “shard key”. Ils peuvent comporter d’autres champs mais le premier champ doit forcément être la “shard key”.
– Les requêtes d’update doivent comporter la “shard key” ou doivent être “multi”.
– Si dans une requête, on ne précise pas la “shard key”, tous les “shards” seront requêtés, ce qui est très coûteux.
– Pas d’index unique à moins qu’il comporte la “shard key” car il n’est pas possible de requêter tous les “shards” pour vérifier l’unicité.
– La “shard key” ne peut être un index “multi-key” (portant sur des tableaux par exemple).

Remarque

La “shard key” ne doit pas forcément être unique.

Sharding et réplication

Tout ce qui a été énoncé auparavant concernant les “replica sets” est valable dans un environnement comportant des “shards”:
– Par défaut, mongos effectue les requêtes sur les serveurs primaires de chaque “shard”. Il est possible d’autoriser les requêtes en lecture vers les serveurs secondaires.
– Les éléments de “write concern”: "w", "j" et le timeout "w" définissable au niveau de l’application sont aussi appliqués à chaque “shard”. Ainsi l’obtention d’un acknowladgement au niveau d’un “shard” se fera si on a l’acknowledgment au niveau du “replica set” du “shard”.
– Enfin il est possible d’utiliser plusieurs instances de mongos pour éviter les interruptions de service dans le cas de la défaillance d’une instance.

Sélection d’une “shard key”

– Sachant que la “shard key” n’est pas modifiable, il faut la choisir correctement à la crearion de l’environnement.
– La “shard key” doit comporter suffisamment de cardinalité pour permettre une bonne répartition des documents sur les différents “shards”. Si la cardinalité est insuffisante, un “chunck” pourrait comporter plus de documents qu’un autre.
Hotspotting: intervalle de valeur de la “shard key” doit rester le plus stable possible. Si cet intervalle augmente trop souvent, le “shard” contenant les valeurs maximales sera utilisé plus souvent que les autres. La réparation de charge ne sera donc pas équitable entre tous les “shards”. Ce problème peut se produire si on choisit la date par exemple.

Formation MongoDB M101N: semaine 4 – Performance

Fonctionnement d’un index

Une table est rangée comme une liste de valeurs rangée les unes à la suite des autres. Lorsqu’on effectue une requête sur cette liste, si il n’y a pas d’index, on va parcourir toute la liste pour trouver les valeurs. Le parcours de toute la liste correspond à un “table scan” pour une base relationnelle et à une “collection scan” pour une base MongoDB.

Pour remédier à ce problème, on utilise un index qui est un annuaire vers les valeurs en base. Si on crée un index sur une valeur triable d’une collection alors on pourra construire un arbre (B tree) de valeurs classées par ordre alphabétiques qui vont pointer vers la valeur dans la collection. Le parcours de l’arbre est rapide et donc on peut trouver plus rapidement les valeurs dans la collection.

Si on crée un index composite sur 3 valeurs de la collection alors on crée un annuaire pour la valeur 1. Pour chaque valeur 1, on crée un 2ème annuaire de valeurs 2. Et pour chaque valeur 2, on crée un 3ème annuaire de valeurs 3. Ainsi si on fait une recherche sur la valeur 1 seulement, il suffit de consulter le 1er annuaire. En revanche si on fait une recherche sur la valeur 2 seulement, on ne pourra pas utiliser l’index puisque les annuaires de valeurs 2 dépendent de celui de la valeur 1.

Ainsi si on considère un index sur les valeurs "a", "b" et "c" d’une collection et si on effectue une requête sur:
"a" seul alors l’index sera utilisé
"b" seul ou "c" seul, l’index ne sera pas utilisé.
"a" et "b", l’index sera utilisé.
"a", "b" et "c" dans cet ordre alors l’index sera utilisé.
"a" et "c" on ne pourra utiliser l’index que pour "a".

Remarques

– Les requêtes "find", "findOne", "update", "remove" et "sort" utilisent un index.
– Par défaut, pour toutes les tables un index est créé sur "_id".
– Si on crée un index sur des valeurs dans le sens ascendant, dans le cas d’une requête dans le sens descendant, l’index ne sera pas utilisé.

Remarques:

Pluggable storage engines

Il est possible de paramétrer un “storage engine” différent de celui par défaut (à partir de la version 3.0):

Driver ⇔ MongoDB server ⇔ Storage engine ⇔ Disks

Plusieurs “storage engine” sont supportés:
– MMAP
– WiredTiger

Les “storage engines” affectent le format du fichier de données et le format des index.

Le storage engine n’affecte pas la communication entre plusieurs serveurs MongoDB ni l’API à disposition des programmeurs.

MMAPv1

MMAP permet de gérer les données qui sont chargées dans la mémoire virtuelle. Il se base sur le système de fichier MMAP qui mappe les fichiers en mémoire.

Ainsi un fichier de données de grande taille (supérieure à 100GB) sur le disque sera chargé de façon paginée. Les pages seront chargées en mémoire virtuelle en fonction de leur utilisation: à la première utilisation, ils sont chargés.

Collection Level locking

MMAP permet de gérer la concurrence des collections en faisant du “collection level locking”. Si 2 opérations d’écriture sont effectuées au même moment dans une même collection, l’une d’entre elles doit attendre que l’autre a terminée, il peut y avoir plusieurs lecteurs mais un seul écrivain à la fois. Si des collections différentes sont affectées alors l’écriture peut se faire au même moment.

Inplace Updates

Une autre fonctionnalité de MMAP est de bouger les collections d’une page à l’autre en mémoire virtuelle en fonction de leur taille. Ainsi si une page est trop petite pour une collection alors la collection sera déplacée vers une page plus grande.

Power of two sized allocations

Pour éviter de déplacer les collections quand elles grossissent, on place les collections dans des pages plus grandes.

On utilise des puissances de 2 pour savoir la taille des pages:

Par exemple une collection de 3gb sera stockée dans une page de 4gb; une collection 19gb sera stockée dans une page de 32gb.

Plan d’exécution

Si il existe 3 index sur une collection, quand on va effectuer une requête sur cette collection et qu’il est possible d’utiliser les 3 index, MongoDB va exécuter utiliser séparément les 3 index pour effectuer la requête. MongoDB gardera ensuite en memoire l’index qui a permis l’exécution la plus rapide de la requête. Ainsi pour les exécutions suivantes du même type de requête, MongoDB choisira systématiquement l’index qui permet l’exécution la plus rapide.

Manipulations des index

Création d’un index sur une table

db.table.createIndex({ a: 1, b:1,c:1})

Permet de créer un index sur 3 colonnes d’une table.

Le "1" indique que l’on veut construire l’index dans le sens ascendant.

Obtenir le plan d’exécution d’une requête

QueryPlanner

[requete].explain()

Les valeurs affichées sont:
cursor: permet d’indiquer le type de curseur utilisée pour afficher les résultats. "BasicCursor" signifie qu’aucun curseur n’a été utilisé pour afficher le résultat. Dans le cas où un curseur est utilisé, cette donnée indique le nom du curseur utilisé.
millis: nombre de millisecondes nécessaires pour exécuter la requête.
n: indique le nombre de documents retournés.
nScannesObjects: nombre de documents scannés pour afficher le résultat
nScanned: nombre de lignes de l’index scannées et le nombre de documents scannés pour afficher le résultat.
indexBounds: intervalles de l’index qui ont été parcourus.
indexOnly: booléen indiquant si l’index seul a suffit pour afficher les résultats. Si faux alors il a été nécessaire de parcourir les documents pour trouver le résultat.
winningPlan: permet d’indiquer quel est le plan qui a permis de retourner les résultats.

Il existe d’autres modes: "executionStats" et "allPlansExecution".

ExecutionStats

[requete].explain("executionStats")

On peut voir les options du "queryPlanner" mode.
On a en plus quelques statistiques “executionStats”:
"nReturned": nombre de documents retournés.
– Différentes étapes d’exécution de la requète “stage”.
"executionTimeMillis": temps d’exécution.

Les étapes permettent de savoir précisemment les index qui sont utilisés.

AllPlansExecution

[requete].explain("allPlansExecution")

Permet d’exécuter tous les plans d’exécutions en utilisant les différents index pour savoir quel est celui qui est le plus rapide.

Les informations spécifiques à ce mode sont dans “allPlansExecution”.

Avec ce mode, certains plans peuvent ne pas retourner de résultat en fonction de l’index qui est utilisé.

Lister les indexes sur un document

db.table.getIndexes()

Lister tous les indexes d’une base

db.system.indexes.find()

Supprimer un index sur une table

db.table.dropIndex({ "student_id": 1}

Le nom de l’index doit exactement celui affiché avec la valeur "key" quand on écrit db.table.getIndexes().

Unique index

Permet de rajouter une contrainte d’unicité sur les valeurs de l’index. Dans le cas où un unique index est appliqué, toute insertion de valeurs déjà présentes dans la base conduira à une erreur correspondant à une clé dupliquée.

Création d’un unique index

db.table.ensureIndex( { name: 1}, { unique: true })

Afficher les index de la table

db.table.getIndexes()

On verra que l’unique index comporte une valeur "unique" : true. Cette valeur permet d’indiquer que l’index contraint l’unicité.

Remarque

Pour le champ "_id" l’index n’est pas affiché comme étant unique pourtant il contraint bien le champ "_id" à être unique.

Index multiclé (multikey indexes)

Les index composites (c’est-à-dire composés de plusieurs champs) ne peuvent pas contenir plusieurs champs contenant des valeurs sous forme de tableaux. Il est seulement possible d’avoir un champ qui comporte des valeurs sous forme de tableau.

Ainsi une collection possède un seul document avec les champs:

Person: 
    Names [andrew, benson] 
    Hobby: [photo, hiking, golf] 
    Location: NY 
    Favorite_Color:

Il sera possible de créer un index sur Names et Location ou Hobby et Favorite_Color mais il sera impossible de créer un index sur Names et Hobby.

Si on crée un index sur hobby et location, les valeurs dans l’index seront:

    photo et NY 
    hiking et NY  
    golf et NY 

Ainsi on fait un produit cartésien entre les valeurs (c’est la raison pour laquelle les index multiclé avec des tableaux pour toutes les clés sont interdits puisqu’il y aurait trop de valeurs possibles dans le produit cartésion).

Si un index composite existe déjà pour names et location et si on ajoute un document avec location sous forme de tableau, on aura un message d’erreur indiquant qu’il n’est pas possible d’ajouter la valeur à cause de l’index.

On peut voir qu’un index est multiclé en utilisant "explain". Le champ utilisé est "isMultiKey".

Index background

2 façons de créer des index "foreground" et "background":

Création "foreground":
– Méthode par défaut,
– Rapide,
– Bloque les lecteurs et les écrivains qui essaient d’accéder à la base,
– Ne pas utiliser en production

Création "background":
– Lente,
– Ne bloque pas les lecteurs et écrivains lors de la création.

Dans le cas d’un “replica set” composé de plusieurs serveurs, il est possible de créer l’index successivement sur tous les serveurs. Par exemple, sur le serveur sur lequel on veut créer l’index, on l’enlève du “replica set”; on répartit la charge sur les autres serveurs; on crée l’index; on réintégre le serveur dans le “replica set”. Et ainsi de suite pour les autres serveurs.

Création d’un index “background”

db.students.createIndex({ name:1 }, { background : true })

Covered query

Requête dont le résultat utilise exclusivement un index.

Ainsi si on regarde le plan d’exécution d’une requête et qu’on s’aperçoit que le nombre de documents parcourus "totalDocsExamined" > 0 alors il ne s’agit pas d’une “covered query”.

Remarque

L’utilisation totale d’un index dépend des champs qui sont affichés par la requête. Si on affiche des champs non utilisés par un index alors MongoDB est obligé d’aller scanner la collection pour obtenir les autres champs.

Taille des index

Il est possible de voir la taille des index utilisés pour une collection en faisant:

db.table.stats()

Les champs "totalIndexSize" et "indexSize" permettent de donner la mémoire utilisée pour les index.

Il est important que les index puissent être chargés dans le “memory set” (partie chargée dans la mémoire plutôt que sur le disque) pour éviter trop d’aller-retour avec le disque.

Remarque

“WiredTiger” compresse les données et ainsi il est possible de réduire la taille des index en mémoire.

Cardinalité

Comparaison entre le nombre de valeurs dans l’index et le nombre de valeurs des champs:
– Regular index: 1:1
– Sparse index: ≤ documents
– Index multikey: > documents

Hint

Permet de forcer l’utilisation d’un index en particulier. On va l’utiliser à ajoutant ".hint" à la suite d’une requête et en indiquant le nom de l’index:

Par exemple:

db.table.find({ a :500, b:500}).hint({a:1})
Attention

Dans le "hint" on indique bien le nom de l’index tel qu’il apparaît avec "getIndexes".

Si on ne veut pas utiliser d’index particulier et qu’on souhaite effectuer un scan de la collection:
On utilisera la fonction "$natural":

db.table.find({ a :500, b:500}).hint({$natural:1})

Sparse index

A la création d’un index, il est possible d’indiquer que l’on souhaite que cette index porte exclusivement sur les documents ou la clé de l’index existe. Ainsi tous les documents ne contenant pas la valeur, ne seront pas indexés.

La création de ce type d’index se fait comme suit:

db.table.createIndex( { name: 1}, { sparse: true })

Ainsi si on utilise “hint” sur cet index, alors les résultats ne comporteront que des documents où le champ "name" existe. Évidement, la même requête sans la fonction "hint" retournerait des résultats.

Full text search index

Permet d’indexer un document contenant un champ contenant avec une chaine de caractères de grande taille.

Création d’un index “full text search”:

db.table.createIndex({ "words": "text"})

"words" est le nom du champ.

Chercher dans une chaine en utilisant un index “full text search”:

db.table.find({ $text: { $search: "chaine de caractères" } })

Même si on écrit plusieurs mots dans la chaine de caractères, la requête va retourner un résultat dès qu’il y a au moins un mot comment entre la chaine de caractères et la valeur d’un champ dans un document.

Performance des index

Pour certains opérateurs, les index se révèlent particulièrement inefficaces. Par exemple pour les opérateurs: $gt, $lt, $ne et $regex. Lorsque ces opérateurs sont utilisés dans les premières conditions d’une requête "find", il est préférable d’imposer l’utilisation d’un index sur les autres champs de la requête n’utilisant pas ces opérateurs (en utilisant "hint").

Par exemple:

db.students.find({ student_id: { $gt: 500}}, { class_id: 20})

Par défaut l’index sur "student_id" sera utilisé. Pour être plus efficace, il faudrait imposer l’utilisation de l’index sur "class_id".

L’élément le plus important dans l’efficacité d’un index est sa sélectivité c’est-à-dire sa capacité à minimiser la quantité de documents ou de clés qui ont été scannés.

D’une façon générale, il faut privilégier la sélectivité des valeurs dans cet ordre: égalité > ordonnancement (utilisation de "sort") > sélection d’un intervalle.

Profiler

Il existe 3 niveaux de profiling:
0: la fonctionnalité est désactivée,
1: seules les requêtes lentes sont logguées.
2: toutes les requêtes sont logguées.

Pour lancer le serveur dans un mode particulier de profiling:

mongod --profile 1 --slowms 2

Ce paramétrage signifie que le niveau utilisé sera "1" et que les requêtes seront logguées lorsqu’elles durent plus de 2 millisecondes.

Pour consulter les logs de profiling:

db.system.profile.find()

Les champs du résultat sont:
"ts": timespamp c’est l’heure à laquelle la requête a été effectuée.
"query": la requête qui été effectuée.
"nscanned": nombre d’objets scannés pour afficher le résultat.
"nreturned": nombre de documents retournés
"millis": temps en millisecondes de la requête.
"ns": namespace, c’est la table sur laquelle la requête a été exécutée: base et nom de la table.

Par exemple:
Pour obtenir les logs comportant un namespace particulier ordonné par timespamp:

db.system.profile.find({ ns: /test.foo/}).sort({ts:1}).pretty()

Pour obtenir les requêtes qui ont duré plus d’un certain temps:

db.system.profile.find({ millis: { $gt: 1 }}).sort({ts:1}).pretty()

Pour obtenir le niveau de profiling courant:

db.getProfilingLevel()
db.getProfilingStatus()

Permet d’obtenir un document comportant les champs:
"was": niveau de profiling
"slowms": durée à partir de laquelle les logs sont faits.

Pour paramétrer un nouveau niveau de profiling ou changer la durée:

db.setProfilingLevel(1,4)

pour niveau 1 et une durée de 4 millisecondes.

Index geospatial

C’est un type d’index qui permet de référencer des points sur un plan (espace à 2 dimensions).

Pour qu’il fonctionne, il faut:
– un champ "location" dans le document avec des coordonnées (x,y) dans un tableau: location : [ x, y]
– de créer l’index: createIndex({ "location" : '2d', type: 1 }). "2d" permet de spécifier le type d’index geospatial et type 1 permet de préciser le sens ascendant.
– d’effectuer des requêtes en utilisant l’opérateur $near:
find({ location: { $near: [x,y]}}) ce qui permettra de trouver les autres points dans la base situé à côté du point (x,y) du plus proche au plus éloigné. On pourra rajouter ".limit(20)" pour limiter le nombre de points à 20 par exemple.

Sharding

C’est une architecture qui permet de multiplier les serveurs sur lesquels une requête sera effectuée pour augmenter les performances. Ainsi l’application va communiquer avec un serveur “mongos” qui va communiquer avec plusieurs “replica sets”:

Chaque “replica set” comprend plusieurs bases qui sont répliquées cependant il s’agit d’un seul serveur “mongod”. Les “replica sets” contiennent des données différentes en fonction de la “shard key”.

Ainsi une même table peut être divisée sur plusieurs “replica sets”. Cette table comprend un champ “shard key” qui va permettre de savoir sur quel “replica set” le document sera réellement stocké, le “shard key” pourrait être la clé primaire ou le champ “_id” de la table. Le mécanisme est transparent pour l’application qui ne sait pas sur quel “replica set” une requête sera effectuée. Le serveur “mongos” exécutera la requête en fonction de la valeur du “shard key”. Si la requête ne comporte pas de “shard key” alors le serveur “mongos” effectuera la requête sur tous les “replica sets”.

Mongotop

Permet de faire du profiling pour une application. Il suffit d’écrire à la ligne de commandes:

mongotop [nombre de secondes d'exécution]

Mongostat

Permet d’avoir le nombre de "insert", "query", "update" et "delete" sur une base mongoDB pendant 1 seconde. Il suffit de taper à la ligne de commandes:

mongostat

"flushes": est le nombre d’écritures sur le disque,
"mapped" est la quantité de mémoire mappée,
"ar/aw" est le nombre de lecture/écriture,
"% used" pourcentage de mémoire utilisée,
"res": resident size.

Formation MongoDB M101N: semaine 5 – Aggregation framework

Permet de faire des requêtes proches de $group.

La fonction "aggregate" fonctionne en utilisant des “pipes” comme une ligne unix. Chaque étape du pipeline correspond à des étapes ou “stage”. Il n’y a pas d’ordre imposé pour les étapes, elles peuvent se succéder dans n’importe quel ordre.

Par exemple, les étapes peuvent être $match, $sort, $group etc. Ainsi le pipeline est indiqué par [ ] et les étapes sont séparées par des “,”:

db,collection.aggregate( [  
    { $match : { ... } }, 
    { $group : { ... } }, .... 
] )

Quelques exemples d’étapes:
$project: permet de sélectionner des clés en particulier et de modifier l'aspect d'un champ. La cardinalité est 1:1
$match<: permet de filtrer. Cardinalité n:1
$group: permet d’effectuer des agrégations. Cardinalité n:1
$sort: permet d’ordonner, cardinalité 1:1
$skip: pour sauter plusieurs documents dans les résultats, cardinalité n:1
$limit: pour limiter le nombre de documents dans le résultat, cardinalité n:1
$unwind: permet de convertir un tableau en plusieurs documents, cardinalité 1:n.

Étape $group

Explication du fonctionnement d’une requête d’agrégation:

Par exemple:
Si on prend un exemple avec $group:

db.products.aggregate( [  
{
    $group : 
    { 
        _id: "$manufacturer", 
        num_products: { $sum: 1} 
    } 
} 
] )

Cette requête permet de grouper des produits puis de compter en utilisant la fonction "$sum" par manufacturier.
Pour le champ: _id on utilise un "$" avant le nom du champ “manufacturer” parce qu’on veut que le champ soit valué quand tous les documents seront parcourus.
Le résultat de la requête sera un autre document avec les deux champs "_id" et "num_products".

Pour construire les résultats, la fonction va parcourir tous les documents et construire de nouveaux documents en y ajoutant les champs et en effectuant des "upsert" successifs pour additionner tous les produits.

$group avec _id:null

Il est possible d’effectuer une étape “$group” sans éléments à grouper en utilisant "_id:null".

Ainsi si on veut compter le nombre de documents dans une collection:

db.tables.aggregate([ 
    { $group: { _id: null, count: { $sum: 1 } }} 
])

Si on veut sommer un champ dans des documents:

db.tables.aggregate([ 
    { $group: { _id: null, total: { $sum: "$value" } }} 
])

Agrégation avec plusieurs clés

Pour avoir un clé composée de plusieurs champs c'est-à-dire grouper par plusieurs champs:

db.products.aggregate( [  
{ 
    $group : 
    { 
        _id: { "maker": "$manufacturer", "cat": "$category" }, 
        num_products: { $sum: 1} 
    } 
} 
] )

Dans les documents du résultat, le clé sera formée de 2 valeurs "maker" et "cat". Ces 2 valeurs ne sont pas présentes dans la collection d’origine, ils sont justes utilisés pour former le document contenant les résultats. En revanche, "$manufacturer" et "$category" sont bien des champs valués de collection d’origine.

Attention

Comme pour toutes les collections dans MongoDB, la collection contenant les résultats doit comporter une clé "_id" qui est unique à chaque document.

Autres fonctions possibles de l’étape $group:
$sum: pour additionner ou compter tous les documents,
$avg: pour effectuer une moyenne de valeurs dans les documents,
$min: pour obtenir le minimum,
$max: pour obtenir le maximum,
$push: permet de créer un tableau dans lequel les valeurs seront rajoutées systématiquement à la fin,
$addToSet: permet de créer un tableau dont les valeurs seront uniques,
$first: permet d’indiquer la première valeur parmi une liste de valeurs. Il faut utiliser cette fonction avec “$sort” pour que le résultat soit pertinent.
$last: permet d’indiquer la dernière valeur parmi une liste de valeurs. Il faut utiliser cette fonction avec “$sort” pour que le résultat soit pertinent.

Précision sur la fonction $sum:

Si on souhaite compter les différentes valeurs, on utilisera { $sum: 1 }, par exemple:

db.products.aggregate( [  
{ 
    $group : 
    { 
        _id: { "maker": "$manufacturer", "cat": "$category" }, num_products: { $sum: 1} 
    } 
} 
] )

En revanche, si on veut sommer les valeurs, on utilisera: { $sum: "$price" }, "price" étant le champ à sommer dans la collection d’origine. Par exemple:

db.products.aggregate( [  
{ 
    $group : 
    { 
        _id: { "maker": "$manufacturer", "cat": "$category" }, total: { $sum: "$price" } 
    } 
} 
] )

Précision sur la fonction $avg

La fonction $avg possède une syntaxe similaire à la fonction $sum:

Par exemple pour avoir la moyenne des prix:

db.products.aggregate( [  
{ 
    $group : 
    { 
        _id: { "maker": "$manufacturer", "cat": "$category" }, avg_price: { $avg: "$price" } 
    } 
} 
] )

Précision sur la fonction $addToSet

La syntaxe est similaire aux fonctions précédentes mais le champ dans les documents résultats sera un tableau au lieu d’être une valeur.

Double grouping

On peut effectuer 2 agrégations l’une à la suite de l’autre. Par exemple: si on a une liste de notes indiquant l’élève, la note, la classe et le type de devoir. Si on veut la moyenne par classe, il faut:
– effectuer la moyenne par élève et par classe
– effectuer la moyenne par classe
Pour effectuer la moyenne par élève et par classe:

db.grades.aggregate( [ 
{  
    $group: { _id : { "studentId" : "$student_id", "classId": "$class_id" }, "average" : { $avg : "$score" }} 
} 
])

Pour effectuer la 2ème moyenne en utilisant les résultats de la première requête:

db.grades.aggregate( [ 
{  
    $group: { _id : { "studentId" : "$student_id", "classId": "$class_id" }, "average" : { $avg : "$score" }} 
}, 
{ 
    $group: { _id : "$_id.class_id" , "average" : { $avg : "$average" }} 
} 
] )

Précision sur les fonctions "$first" et "$last"

Exemple:

db.cities.aggregate( [  
{ 
    $group : { _id : { state: "$state", city : "$city" } } 
    { population : { $sum: "$pop" } } 
}
{ 
    $sort : { "_id.state" : 1, "population" : -1 } 
} 
{ 
    $group : { 
        _id : "$_id.state", 
        city : { $first : "$_id.city"}, 
        population : { $first : "$population" } 
} 
} 
] )

Étape $project

Cette fonction permet de :
– supprimer des clés,
– ajouter de nouvelles clés,
– utiliser des fonctions simples comme $toLower, $toUpper, $add ou $multiply.
La cardinalité de cette fonction est 1:1.

Par exemple:

db.prices.aggregate( [ 
{ 
    $project : { 
        _id: 0, 
        "maker" : { $toLower : "$manufacturer" }, 
        "details" : { "category" : "$category" , "price" : { $multiply : [ "$price" : 10 ] }, 
        "item" : "$name", 
        "name" : 1 
    } 
} 
] )

"_id: 0" permet d’indiquer qu’on ne souhaite pas afficher le champ "_id".
Les champs "maker", "details" et “item” définissent les champs qui seront présents dans les documents du résultat mais ils ne sont pas présents dans les documents d’origine.

"name" :1 permet d’indiquer qu’on veut le champ "name" sans modifications.

Étape "$match"

Cette fonction permet de filtrer une collection dans la fonction d’agrégation. La cardinalité est n:1.

Par exemple, si on souhaite filtrer une collection contenant des adresses en ne gardant que les adresses dans l’état de New York:

db.addresses.aggregate( [  
    { $match : { state : "NY" } } 
] )

Pour avoir les codes postaux dont la population est supérieure à 100000:

db.zips.aggregate( [  
    { $match : { pop : { $gt:100000} } } 
] )

Étape "$sort"

Permet d’effectuer un tri. On peut l’utiliser avant ou après l’utilisation de "$group".

ATTENTION

Le tri s’effectue intégralement en mémoire

Exemple:

db.cities.aggregate( [  
{ 
    $group : 
    { _id : { state: "$state", city : "$city" } } 
    { population : { $sum: "$pop" } } 
}, 
{ 
    $sort : { "_id.state" : 1, "population" : -1 } } 
] )

"_id.state" ne comporte pas de "$" puisqu’on ne veut pas une valuation du champ.
"1" permet d’indiquer qu’on souhaite le tri dans le sens croissant. Pour avoir le sens décroissant, on utilise "-1".

Étapes "$skip" et "$limit"

On peut utiliser les 2 fonctions séparément mais la plupart du temps elles sont utilisées toutes les 2 avec la fonction "$sort" et dans l’ordre "$skip" puis "$limit". Sachant que ce sont des fonctions séparées, ce sont des étapes distinctes dans le pipeline.

Par exemple:

db.addresses.aggregate( [  
    { $sort : { state : 1} } 
    { $skip: 10 } 
    { $limit: 5 } 
] )

"$skip" permet de sauter 10 valeurs et "$limit" permet de limiter le nombre de résultat à 5.

Étape "$unwind"

Permet de convertir les éléments dans un tableau en plusieurs documents en reprenant les mêmes valeurs pour les autres champs.
Ainsi si on a un document:

{ a:1, b:1, c: [ "c1", "c2", "c3" ] }

Le résultat sera:

{ a:1, b:1, c: "c1" } 
{ a:1, b:1, c: "c2" } 
{ a:1, b:1, c: "c3" } 

Exemple:

db.posts.aggregate( [ 
    { $unwind: "tags" } 
] )

Double unwind

Si on utilise 2 étapes "$unwind", on effectue le produit cartésien entre toutes les valeurs des 2 tableaux.

Ainsi si on a: { a:1, b:1, c: [ "c1", "c2" ], d: [ "d1", "d2" ] }

Alors:

db.posts.aggregate( [ 
    { $unwind: "c" } 
    { $unwind: "d" } 
] )

On aura:

{ a:1, b:1, c: "c1", d: "d1" } 
{ a:1, b:1, c: "c1", d: "d2" } 
{ a:1, b:1, c: "c2", d: "d1" } 
{ a:1, b:1, c: "c2", d: "d2" } 

Limitations de la fonction "aggregate"

– La limite par étape est de 100 MB. il est possible d’utiliser une option "allowDiskUse" par dépasser cette limite.
– Il existe une limite de 16 MB par document si on renvoie tous les résultats dans un document.
– Dans le cas du "sharding", si on effectue une requête utilisant "group by" ou "sort", tous les documents sont récupérés de tous les “shard” vers le premier “shard” pour être traités. Il existe une fonction “map/reduce” (non recommandé) pour aider dans ce cas.
Il est aussi possible d’utiliser "hadoop".

Agrégations avec le driver .NET

Si on souhaite effectuer la requète:

db.zips.aggregate( [ 
    { $group: { _id: "state", totalPop: { $sum: "$pop" } } }, 
    { $match: { totalPop: { $gte: 10*1000*1000 } } } 
]}

En .NET:
Avec des BsonDocument:

var list = await col.aggregate() 
    .Group(new BsonDocument("_id", "$state").Add("totalPop", new BsonDocument("$sum", "$pop"))) 
    .Match (new BsonDocument("totalPop", new BsonDocument("$gte", 10 * 1000 * 1000))) 
    .ToListAsync(); 

Avec des chaines de caractères directement:

var list = await col.Aggregate() 
    .Group("{ _id: '$state', totalPop: { $sum: '$pop'} }") 
    .Match("{totalPop: {$gte: 10000000}}") 
    .ToListAsync(); 

Avec POCO:

var list = col.Aggregate() 
    .Group(x => x.State, g => new { State = g.Key, TotalPop = g.Sum(x => x.Population) }) 
    .Match(x => x.TotalPop > 10*1000*1000);

Formation MongoDB M101N: semaine 3 – Conception

Quelques caractéristiques:
– “Rich documents”: on peut stocker davantages que de simples valeurs,
– “Prejoin” en utilisant des documents intégrés (embedded documents),
– Pas de jointures en MongoDB,
– Pas de contraintes,
– Pas de transactions,
– Pas de schéma conceptuel.

Le principe général d’une base MongoDB est d’avoir des schémas de données proches de l’application.

Les bases relationnelles sont conçues pour:
– Interdire de modifier la base en introduisant des anomalies,
– Minimise le redesign de la base quand le schéma conceptuel grossit,
– Il permet d’éviter d’être influencer par certains patterns d’accès.

MongoDB ne pousse pas à utiliser un pattern par rapport à un autre. Ensuite il faut éviter d’introduire des anomalies à la création de données.

Exemple d’articles d’un blog

Par exemple, pour afficher les articles d’un blog, on peut stocker tous les éléments de l’article:
– titre,
– auteur,
– contenu,
– date,
– mots clé.

Tous les commentaires:
– auteur du commentaire,
– email,
– contenu du commentaire.

Tous ces éléments peuvent être stockés dans la même collection en utilisant des documents intégrés (embedded document).

Si on utilise un schéma proche de celui qu’on aurait utilisé dans une base relationnelle, on aurait:
– une collection pour les articles avec un ID, titre, corps, auteur et date,
– une collection pour les commentaires avec un ID, un ID d’article (clé étrangère), auteur, adresse email et l’ordre d’affichage du commentaire,
– une collection pour les tags avec un ID, un tag et un ID d’article (clé étrangère).

Ce type de schéma impose de
– maintenir une relation entre les 3 collections en utilisant des clés étrangères,
– maintenir l’ordre dans la collection des commentaires,
– d’utiliser 3 fichiers sur le disque, 1 fichier par collection.
– d’accèder à 3 collections pour afficher un article.

Cette organisation est plus lourde que de considérer une collection pour stocker tous les éléments.

Contraintes

Un des avantages de la base relationnelle est de garantir d’avoir des données consistantes en utilisant des clés étrangères. MongoDB ne garantit pas d’avoir des clés étrangères consistantes.
Le fait d’utiliser des documents intégrés garantit que les documents sont liés entre eux: si tous les commentaires sont directement dans la collection d’articles, par construction ils appartiennent à l’article.

Transactions

Pas de transactions en MongoDB mais les opérations sont atomiques.
Quand on insère des documents dans une collection, ils ne seront visibles que si le document est ajouté entièrement. En revanche, l’ajout de plusieurs documents n’est pas atomiques.
Le fait d’utiliser des documents intégrés permet de garantir la consistance des données.

En utilisant MongoDB, il faut:
– être restrictif en utilisant des documents intégrés ce qui permet d’insérer des données de façon consistante.
– implémenter des “locks” au niveau software
– tolérer une inconsistance dans les données.

Relations entre les objets logiques

Relations un vers un

Par exemple un employé et un CV, un employé n’a qu’un CV.
On peut stocker les infos dans 2 collections:

Employee:
- _ID,
- Name,
- Resume_ID
Resume 
- _ID,
- Jobs: [] (array)
- Education: []

Ou alors on inclut le CV directement dans la collection "Employee" sous forme de document intégré.

Pas de restrictions pour les relations un vers un, il faut juste avoir en tête:
La fréquence d’accès aux données: si on accède très souvent au CV d’un employé ou si on accède seulement aux informations de l’employé.
La taille des éléments: les informations du CV seront peut être plus volumineuse que celles de l’employé lui-même.
La limite de 16mb d’un document intégré: un document intégré ne peut dépasser 16mb.

Relations un vers plusieurs

Par exemple, une ville et une personne. Une ville contient plusieurs personnes.

Plusieurs schémas sont possibles:
1ere solution:

City:
- name,
- area;
- people: [] (array)

Ce schéma n’est pas possible car il y a beaucoup trop de personnes pour une ville, le tableau serait trop grand.

2e solution:

People:
- name,
- city : { name; area; }

Le problème avec ce schéma est de multiplier les données relatives à la ville dans tous les documents des personnes.

3e solution: 2 collections:

People:
- name;
- city: "NYC"
City:
- _ID: "NYC" 
- area

La 3e solution est la meilleure puisqu’on ne duplique pas les informations.

La 1ère solution aurait pu être choisi si il n’y avait pas beaucoup de personnes.

Pour les relations un vers plusieurs, l’important est de savoir l’ordre de grandeur du plusieurs:
dans le cas d’un grand nombre: il faut considérer une collection différente
dans le cas d’un nombre pas très élevé: il est préférable de privilégier un document intégré.

Plusieurs vers plusieurs

Par exemple, des livres et des auteurs.
Dans ce cas, il y a peu de livres pour un auteur et inversement, il a peu d’auteurs pour un livre. Donc il peut considérer 2 collections:

Books:
- _ID, 
- title, 
- authors: [ author_ID ]
Author: 
- _ID 
- Author_name, 
- books: [ books_ID ]

On peut inclure des références vers les auteurs dans la collection livre dans un tableau.

On peut aussi inclure directement les livres dans la collection auteur mais la contrainte est pour les livres qui ont plusieurs auteurs.

Dans le cas d’étudiants et de professeurs:

Un professeur possède beaucoup d’étudiants, on peut utiliser des documents intégrés en incluant un tableau de professeur dans la collection d’étudiants. Ce schéma peut poser problème si des étudiants sont aussi des professeurs.

Index multi-clé

Permet d’utiliser des index pour des relations plusieurs vers plusieurs.

Si on considère 2 collections telles que:

Students: 
- _ID, 
- name, 
- teachers: [ teacher_ID ] (tableau de teacher ID)
Teachers: 
- _ID 
- name,

Si on veut lister les professeurs d’un étudiant, on peut le faire directement. En revanche, si on veut les étudiants d’un professeur, on aura besoin d’un index multi-clé.

db.students.ensureIndex({ 'teachers':1 })

Pour avoir tous les étudiants d’un professeur:

db.students.find({ 'teachers': { $all: [ 0, 1 ] } })

Pour savoir comment l’index est utilisé:

db.students.ensureIndex({ 'teachers':1 }).explain()

Avantages d’utiliser des documents intégrés

– Permet d’améliorer les performances en lecture
– Effectuer un seul aller-retour avec la base de données
La base utilise des disques durs dont le temps d’accès est long mais qui ont une bande passante élevée: on met du temps à attendre l’information mais on peut charger rapidement beaucoup d’information en même temps. Il est donc préférable d’avoir des informations rapprochées pour les charger d’un coup.

Arbres

Par exemple, si on considère des produits et des catégories. Les catégories appartiennent à d’autres categories.
On peut proposer ce schéma:

Products: 
- Category, 
- Product_name
Category: 
- _ID 
- Category_name 
- ancestors: [ category_ID ]

Ainsi tous les ascendants de la categorie se trouvent dans le tableau “ancestors”.

Si on veut les descendants d’un categorie, il suffit de faire:

db.category.find({ ancestors: 35 })