Technique – Développement en boîte noire

Une vieille technique de développement en informatique[1], particulièrement utile en situation de développement par équipe, comme lorsqu'on construit des systèmes client/ serveur (SCS), est la technique dite de la boîte noire.

Qu'est-ce que cette technique?

Le développement en boîte noire est aussi ce que certains appellent de la planification top-down, c'est à dire de haut en bas, ou – plus exactement – du plus vague au plus précis. C'est une technique qui rend de fiers services lors des travaux volumineux, par exemple en situation de travail d'équipe.

Sur le plan de la programmation structurée typique, en procédant par boîtes noires, on procède à une subdivision fonctionnelle de l'algorithme propre au problème à résoudre, et donc à un découpage du problème qui nous est soumis en sous-problèmes plus simples, comme vous y êtes déjà habitués.

Sur le plan de la conception de SCS, on établit aussi tôt que possible le protocole ou les interfaces dans chaque relation CS, permettant ainsi aux gens chargés de concevoir chaque homologue de la relation de développer leur module en présumant que l'autre respectera le protocole défini ou l'interface choisie. Ceci permet le développement en parallèle de chaque paire d'homologues, et réduit les dépendances entre les équipes de travail pendant la période de développement.

Dans chaque cas, on accélère et on simplifie la phase de développement, rapportant l'essentiel des problèmes de connectivité aux périodes de design, où sont pensées les manières de connecter les homologues entre eux, et d'intégration, où la connexion effective est faite et où les irritants résultant d'erreurs ou d'ambiguïtés sont résolus.

Règle générale, on imagine chaque algorithme ou chaque module comme une boîte noire. Pour chacune de ces boîtes noires :

Ceci nous permettra d'assembler les différentes boîtes noires sans vraiment avoir eu besoin d'implanter l'une ou l'autre d'entre elles, mais apportera aussi un autre avantage non négligeable: sachant comment est supposé communiquer chaque homologue, on pourra le remplacer par une simulation simplifiée, ce qui diminuera la complexité de la tâche d'intégration et permettra de faire fonctionner le système dans son ensemble même si un module tarde à être livré.

Pourquoi ne l'enseigne-t-on pas plus tôt?

Cette technique demande d'avoir un peu d'expérience de programmation, puisqu'elle demande une compréhension suffisante des techniques de résolution de problèmes pour être capable d'arriver à bien découper un problème en « sous-problèmes » qui, pris individuellement, sont solubles.

C'est pourquoi on ne l'enseigne pas, généralement, aux débutant(e)s. Un découpage qui manque de clarté devient un obstacle plutôt qu'une aide additionnelle.

Le cycle de développement par boîte noire peut être vu comme la séquence d'opérations suivantes: découpage en modules, simulation du fonctionnement des modules; et intégration des modules.

Quels en sont les avantages?

On y trouve plusieurs avantages. Par exemple :

Des exemples...

En fait, si on se limite au monde des fonctions, vous procédez depuis longtemps à ce genre de découpage. à chaque fois que vous faites usage d'une fonction ou d'un objet rendu disponible par le langage ou venant d'une bibliothèque sans savoir comment cet objet ou cette fonction ont été implantés, vous procédez – en quelque sorte – à une forme de développement en boîte noire.

Tri d'un fichier (exemple structuré)

Examinons le cas du tri des données dans un fichier.

Étape: le problème

Lire le contenu d'un fichier contenant, sur chaque ligne, un enregistrement constitué d'un nom de famille et d'un prénom, séparés d'une virgule; puis produire un nouveau fichier contenant tous ces enregistrements mais triés par ordre alphabétique de nom de famille.

Étape: schéma de solution

On peut dire que l'algorithme suivant est une solution valable au problème :

Algo Solution1 (E: Fichier en entrée, S: Fichier en sortie)
  Lire le contenu de E dans un tableau T
  Trier le tableau T
  écrire le contenu du tableau T dans le fichier S
Fin Solution1

On aurait alors besoin de passer à l'algorithme Solution1(E,S) deux fichiers (disons deux noms de fichiers, puisqu'on veut définir immédiatement les interfaces requises).

Nous dirons ici que les paramètres E et S de l'algorithme Solution1() sont des paramètres en entrée, ou des intrants. Nous chercherons à définir les intrants et les extrants (paramètres en sortie) de chacun de nos modules, de façon à clarifier ce que chacun doit aux autres, et sous quelle forme: de façon à connaître à l'avance chaque interface de module à module.

Notre subdivision courante résulte en trois modules, qui sont :

L'examen les intrants et des extrants de cette subdivision nous donne :

Étape: subdivision du travail

On peut donc dores et déjà définir les interfaces des trois modules, et subdiviser le travail entre coéquipiers :

Nos interfaces ne sont pas encore complètes, par contre: il faudra s'entendre entre coéquipiers à savoir comment seront structurés les paramètres, et en particulier ici comment sera représenté un tableau d'enregistrements. Par exemple, en langage C, il faudra passer la taille du tableau en paramètre avec le tableau à chaque fois, puisque la taille d'un tableau n'est pas encodée dans le tableau lui-même.

On pourrait représenter un enregistrement comme :

...et on pourrait trouver d'autres représentations, toutes aussi valides.

Cas (0) Cas (1) Cas (2)
using enreg = char*;
// ou
using enreg = std::string;

enreg T[MAX];
struct enreg
{
   std::string nom_;
   std::string prenom_;
   enreg();
   enreg(const std::string&, const std::string&);
};

enreg T[MAX];
class enreg
{
   // ...
   std::string nom_;
   std::string prenom_;
public:
   enreg();
   enreg(const std::string&, const std::string&);
   // ...
};

enreg T[MAX];

Chacune de ces options a du bon; l'important est de s'entendre dans l'immédiat, pour que tous puissent commencer à travailler, et pour que la jonction des différentes boîtes soit, en bout de ligne, facile.

On vous a raconté plusieurs fois que l'un des bénéfices de la programmation orientée objet est la technique de l'encapsulation, selon laquelle on cherchera à envelopper les structures de données de manière à en cacher les détails au monde extérieur.

La définition d'interfaces entres les systèmes se prête bien à l'application de cette technique: en prétendant qu'on a un type nommé enregistrement (ou, dans le tableau plus haut, enreg), on peut se dire a priori qu'on utilisera comme interface entre les systèmes des tableaux d'enregistrements.

Ainsi, chacun peut travailler à ses algorithmes et à leur implantation, sans avoir à se soucier outre mesure des détails. Lorsque le moment s'y prêtera (cela peut se faire dès la subdivision initiale du travail comme cela peut se faire peu avant l'intégration des différents modules), l'équipe se réunira pour définir ce qu'on attend précisément du type représentant un enregistrement, et les ajustements aux implantations individuelles pourront être apportés avant de procéder à l'intégration officielle de ses différentes composants.

Étape: développement en parallèle

Chaque équipier peut alors développer son (ou ses) module(s) en simulant le bon fonctionnement des autres.

Par exemple, on peut écrire le module Trier(TE,TS) en supposant se faire donner toujours le même tableau TE, qu'on pourra simuler par une constante :

const int MAX = 5;
//
// Lire(E,T,taille) simulera la lecture d'un fichier
// contenant exactement le contenu d'un fichier à
// l'aide du tableau FichierSimule[]
// Entrée: la chaîne de caractères E (nom du fichier; inutilisé)
// Sortie: le tableau d'enregistrements T[] (rempli)
// Sortie: le nombre d'enregistrements taille (à jour)
//
void Lire (const std::string &E, enreg T[], int &taille)
{
   const enreg FichierSimule[MAX] =
   {
      enreg{"Tremblay","Roger"},
      enreg{"Casanova","Fidel"},
      enreg{"Cadorette","Jean"},
      enreg{"Biquette","Sheila"},
      enreg{"Fernandel","Gontrane"}
   };
   int i;
   for (i = 0; i < MAX; ++i)
      T[i] = TE[i]; // ou opération équivalente
   taille = i;
}
//
// Trier(TE,taille,TS) simulera la lecture d'un fichier
// contenant exactement le contenu d'un fichier à
// l'aide du tableau FichierSimule[]
// Entrée: le tableau d'enregistrements TE[] (rempli)
// Entrée: le nombre d'enregistrements taille dans TE[]
// Sortie: le tableau d'enregistrements TS[] (trié)
//
void Trier (enreg TE[], int taille, enreg TS[])
{
  // votre code pour le module Trier(TE,taille,TS)
}
int main()
{
   enreg T[MAX];
   int nbElements;
   Lire("NomQuelconque.dat", T, nbElements); // simulé
   Trier(T, resultat, T); // notre code
   // petite boucle pour vérifier le résultat
}

En simulant le bon fonctionnement de la fonction devant produire le tableau d'enregistrements requis pour le module Trier(TE,taille,TS), on pourra ainsi faire comme si ce module était présent et opérationnel. Il se trouve que dans notre simulation, ce module produit toujours le même résultat. Et puis? Dans la mesure où la véritable tâche est simulée, notre module pourra être testé de manière véritable.

Chacun des membres de l'équipe de développement peut ainsi procéder à la production et à la mise au point de son/ses composant(s) en faisant comme si les composants des autres étaient disponibles et opérationnels.

Étape: intégration

L'intégration des composants pourra alors se faire. Si tous les membres de l'équipe ont respecté à la lettre les interfaces définies au tout début, cette phase d'intégration devrait se limiter à remplacer les modules simulés par les modules véritables, et à recompiler.

En pratique, ce n'est jamais si simple. Une phase d'intégration demande pratiquement toujours de légers ajustements, allant du cosmétique (une majuscule au lieu d'une minuscule dans le nom d'un type de données ou de module) au sérieux (un module procédant à une manipulation imprudente de pointeurs et qui fait s'écraser les autres modules, sans qu'on ne parvienne à détecter aisément lequel des modules est fautif).

En général, toutefois, une interface bien définie et respectée au mieux des capacités de chacun diminue singulièrement le temps et la difficulté d'intégration des modules.

Accès à une BD (exemple réparti)

Examinons maintenant le cas d'un accès à une BD par un client distant.

Étape: le problème

On désire permettre à un client l'accès à une BD distante, mais sans lui donner la possibilité de soumettre directement des requêtes SQL car cela représenterait une brèche considérable de sécurité.

Étape: schéma de solution

Il faut examiner les types de requêtes permissibles au client. Imaginons pour faire simple qu'on ne veuille lui accorder d'autre permission que de connaître la liste des noms et des prénoms des employés d'une compagnie.

Si le choix de modèle transactionnel est un choix de bas niveau (par exemple, le transfert de données par sockets bruts), il faudra déterminer le format à utiliser pour:

Un protocole possible pourrait suivre un format XML et inclure...

Élément Exemple
  • Une requête composée d'un identificateur de commande (ListeEmployés) et d'un identificateur de séquence
<requete>
   <cmd>
      ListeEmployés
   </cmd>
   <id>
      3
   </id>
</requete>
  • Une réponse normale composée d'un séquence de paires nom,prénom
<reponse>
   <id>3</id>
   <employe>
      <nom>Tromblon</nom>
      <prenom>Roger</prenom>
   </employe>
   <employe>
      <nom>Cadorette</nom>
      <prenom>Gontran</prenom>
   </employe>
</reponse>
  • Dans le cas d'une erreur, un code permettant d'en déduire la nature
<reponse>
   <id>3</id>
   <erreur>404</erreur>
</reponse>

Ce modèle fonctionne avec des sockets puisque la réponse apparaît comme faisant partie d'un flot continu de données. La réception de chaque employé peut être faite par entreposage dans une variable scalaire, puis cette variable peut être ajoutée dans un vecteur dotn la tailel est dynamique. Cela permet un flot de taille arbitrairement grande et inconnue a priori sans poser, en contrepartie, de problèmes de programmation sérieux.

#define CAPACITE_NOM    64
#define CAPACITE_PRENOM 64

cpp_quote("const int CAPACITE_NOM = 64;")
cpp_quote("const int CAPACITE_PRENOM = 64;")

typedef struct
{
   char nom_[CAPACITE_NOM];
   char prenom_[CAPACITE_PRENOM];
}
employe;

interface IEmployes
{
   HRESULT GetNbEmployes([out] int *NbEmployes);
   HRESULT GetListeEmployes
      ([in] int Capacite,
       [out, size_is (Capacite)] employe T[],
       [out] int *NbObtenus);
};

Une interface possible (présentée ici sous forme IDL selon les standards du modèle COM) pourrait dévoiler les services suivants :

Ce modèle est classique, et permet l'obtention de l'information en deux temps : un premier appel est requis pour savoir combien d'espace allouer, côté client, pour entreposer la liste d'employés, et un second appel est ensuite nécessaire pour indiquer où entreposer la liste (un tableau d'employés), combien d'employés peuvent être entreposés dans ce tableau (sa capacité) et pour que le serveur puisse indiquer combien d'employés y auront vraiment été entreposés (au cas où le nombre serait moindre que la capacité, entre autres).

Il ne faut pas oublier que, dans un SCS, les deux homologues sont généralement des entités dynamiques. Ainsi, entre deux appels à des services d'une même interface sur un même serveur, le nombre d'employés peut avoir changé. Utiliser GetNbEmployes() est utile, mais demeure une procédure à nature heuristique.

Étape: subdivision du travail

La subdivision la plus simple et la plus probable ici est de confier à une équipe la tâche de rédiger le module client, qui utilisera l'interface IEmployes ou qui soumettra des requêtes via le protocole ayant été défini, et de confier à une autre équipe la tâche de rédiger le module qui implantera le traitement côté serveur associé au protocole, ou qui implantera les méthodes de l'interface.

Étape: développement en parallèle

Suite à la subdivision, l'équipe oeuvrant sur le serveur mettra au point ce module dans le respect de l'interface ou du protocole, alors que l'équipe oeuvrant sur le client fera de même pour son côté de la transaction.

Chaque équipe pourra aisément simuler le module de l'autre pour que son propre module soit aussi proche que possible de ce dont il aura l'air dans sa version finale.

Étape: intégration

Ce qui restera à faire sera de tester le client réel en connexion avec le serveur réel, et de valider rigoureusement que la connectivité est complète et conforme entre les deux.

Développement par boîte noire et réutilisation du code

Un avantage « caché » du développement par boîte noire est qu'il augmente de beaucoup les probabilités de réutilisation du code. En effet, si un module a été pensé en terme d'intégration éventuelle avec d'autres modules, avec des interfaces claires dès le début, on obtiendra un module qui aura en général tendance à bien s'intégrer avec d'autres modules.

Réutiliser des modules permet de diminuer le temps passé à «réinventer la roue», et permet de commercialiser plus rapidement le fruit de nos efforts. Les anglophones ont un nom pour ce genre de développement basé sur des composants faciles à assembler entre elles: ils appellent cela le Component-Based Programming (ou programmation par composants). On voit en pratique que cette manière de faire les choses permet de mettre sur pied des programmes efficaces en un tournemain.

Développement par boîte noire et documentation

Autre avantage significatif du développement par boîte noire: ayant défini dès le début les modules et leurs interfaces respectives, on obtient pratiquement un système « pré-documenté », ou du moins dont la documentation est grandement simplifiée. On a dès le tout début du développement :

Cela permet de produire rapidement et efficacement, pour les clients comme pour l'employeur, une documentation cohérente et utile du système informatique en développement. Les entreprises manquant trop souvent de temps à allouer à la rédaction et à la mise au point de la documentation des différents systèmes, être en mesure de la produire systématiquement est un avantage compétitif indéniable.

Développement par boîte noire et tests

De même, connaître dès le début les détails de l'interface entre chaque paire de modules dans un système permet d'en tester efficacement le fonctionnement.

En effet, en offrant à un module une banque d'intrants conçue en conséquence et en vérifiant de manière systématique les extrants correspondants, on peut valider efficacement le bon fonctionnement du module, et on peut produire une liste d'intrants à risque, ce qui facilite, pour l'équipe de développement, la tâche de maintenance et de mise au point des modules (en sachant lesquels des intrants produisent des résultats incorrects, les informaticien(ne)s sont en mesure de vérifier pourquoi le module ne fonctionne pas correctement pour ces intrants bien précis).


[1] ...elle-même tout droit inspirée de bonnes vieilles techniques mathématiques que vous avez tous déjà survolées au secondaire...


Valid XHTML 1.0 Transitional

CSS Valide !