Utiliser les directives de compilations pour ne pas perdre ses données de production en développant !

Beaucoup de développeurs codent des programmes pour eux-mêmes et se retrouvent ainsi avec des versions de travail et de production sur le même ordinateur.

Pour les programmes eux-mêmes, il est facile de ne pas se planter : il suffit d'utiliser un programme d'installation propre pour avoir la version de production avec les autres applications de l'ordinateur et les programmes en test ou développement dans une arborescence à part. Le problème se pose en revanche sur les données et en particulier les données de configuration ou bases de données locales (SQLite, IBLite, IBToGo, MS-Access, Paradox, ...).

Combien d'entre nous se sont fait avoir un jour en modifiant en production des données de test ou en débogage sur des données de production ?

Quand on programme pour du web il est facile d'écraser le fichier qui contient les paramètres de la base de données lors d'un upload FTP ou d'une synchronisation vers son système de gestion de versions (dans lequel il ne faut d'ailleurs jamais stocker de mots de passes).

Quand on fait des programmes pour PC ou Mac il en est de même. Quoiqu'avec les APPX sous Windows 10 on soit aussi théoriquement dans un environnement verrouillé, mais passons.

Pour les applications mobiles le cas ne se pose pas puisque la plupart du temps les programmes ne peuvent accéder qu'aux fichiers qu'ils ont eux-mêmes créés dans leur propre sous arborescence. Vive le sandboxing !

Heureusement la plupart des langages permettent de gérer des directives de compilation et de conditionner des bouts de code en fonction de leur environnement d'exécution ou de compilation. Delphi en bénéficient également. Les utilisez-vous ?

Le problème classique est le nom de fichier de paramètre ou de base de données en dur, sur une arborescence système en dur...

Prenez par exemple la classe de stockage de paramètres que je vous ai proposée il y a quelques jours.

Dans un but de simplification du code et de paramétrage, j'y ai mis une routine qui permet à la classe de déterminer le nom du fichier dans lequel elle doit stocker ses informations.

function getParamsFileName: string;
var
  folder: string;
  filename: string;
  app_name: string;
begin
  app_name := TPath.GetFileNameWithoutExtension(paramstr(0));
  folder := TPath.Combine(TPath.GetDocumentsPath, app_name);
  if not tdirectory.Exists(folder) then
    tdirectory.CreateDirectory(folder);
  filename := app_name + '.par';
  result := TPath.Combine(folder, filename);
end;

Ce fichier est créé dans un dossier du même nom que le programme exécutable lui-même créé dans "Mes Documents". Pour un projet qui se nommerait au hasard "Project1", le fichier des paramètres serait donc dans "Mes Documents/Project1/Project1.par".

Pourquoi cet exemple "au hasard" ? Tout simplement pour vous montrer l'ampleur des dégâts possibles en cas d'inattention de la part du développeur.

Project1 est le nom des projets par défaut sous Delphi lorsqu'ils ne sont pas sauvegardés sous un autre nom. Par conséquent tout programme non rebaptisé, utilisant cette classe de stockage de paramètres, partage le même fichier de configuration que les autres programmes du même nom.

Si les noms des paramètres ne sont pas les mêmes, ce n'est pas si grave de partager le même fichier de configuration. {mode troll on}Microsoft appelle ça une base de registres.{mode troll off} En revanche c'est aussi le cas lors d'une exécution du programme avec des données de production et lorsqu'on travaille dessus et qu'on l'exécute pour déboguer. Voyez-vous où je veux en venir ?

En cas de plantage sur la version de débogage, on risque de perdre aussi les données de production. Et là se pose la question de la sauvegarde des données sur lesquelles on travaille au quotidien, mais ça ne rentre pas vraiment dans le cadre de ce blog.

Il y a une solution très simple contre ce problème : utiliser des directives de compilation pour changer le nom des fichiers en dur en fonction de ce que l'on fait.

Les directives de compilation sous Delphi sont nombreuses et permettent de gérer dans le code les options du projet mais pas seulement. Elles permettent surtout de conditionner des blocs entiers de code source.

Pour repérer les directives de compilation, chercher {$...} dans les fichiers sources fournis avec Delphi.

Voici un exemple de conditionnement de code:

{$IFDEF toto}
  ShowMessage('Toto is in the place !');
{$ELSE}
  ShowMessage('Where is Toto ?');
{$ENDIF}

Ici on va afficher "Toto is in the place !" si effectivement le symbole "toto" est défini. Dans le cas contraire on affichera "Where is Toto ?".

{$IFDEF XXX} et {$IFNDEF XXX} permettent ainsi de conditionner du code si le symbole XXX est défini ou ne l'est pas.
Il arrive que l'on veuille tester plusieurs symboles à la fois et dans ce cas il faudra écrire {$IF Defined(XXX) AND Defined(YYY)} pour exécuter ce qui suit lorsque XXX et YYY sont définis. On peut aussi utiliser l'opérateur NOT, OR, ...

{$ELSE} permet d'exécuter le code suivant si la condition n'est pas réalisée.

{$ENDIF} termine le bloc conditionné. Dans la doc ou certains sources vous trouverez aussi {$IFEND} mais c'est désormais en DEPRECATED, donc à réécrire pour éviter des alertes à la compilation.

Il est aussi possible d'imbriquer des IF / IFDEF / IFNDEF comme on le ferait avec n'importe quelle zone de code.

Ces conditions et directives sont traitées par le compilateur et non à l'exécution comme ce serait le cas en PHP qui se sert de fonctions pour définir et tester des symboles ou pseudo constantes. Sous Delphi le code conditionné n'apparaît donc pas dans l'exécutable final s'il n'est pas valide.

Et si vous vous demandez comment définir un symbole, vous avez probablement vu juste. Il suffit d'utiliser {$DEFINE XXX} où vous voulez dans vos fichiers.

Dans l'exemple précédent pour que le premier message apparaisse, je peux donc écrire ceci pour un onClick sur un TButton d'une fiche vierge :

implementation

{$R *.fmx}

{$DEFINE toto}

procedure TForm1.Button1Click(Sender: TObject);
begin
{$IFDEF toto}
  ShowMessage('Toto is in the place !');
{$ELSE}
  ShowMessage('Where is Toto ?');
{$ENDIF}
{$IF Defined(toto)}
  ShowMessage('Toto is in the place ! (oui, je sais, je bégaie)');
{$ENDIF}
end;

end.

Alors comment cela peut-il nous sauver dans la problématique du présent article ?

Il suffit tout simplement d'utiliser les symboles gérés par l'environnement de développement en fonction de la configuration de construction et de la plateforme cible.

Dans le gestionnaire de projet de Delphi on peut spécifier par défaut deux configurations de construction : Debug et Release. On peut en ajouter d'autres si on le désire, cela permet d'avoir des configurations de projets différentes.

Par défaut, donc, la configuration de construction "Debug" définit le symbole DEBUG. La configuration de construction "Release" quant à elle définit RELEASE. Vous pouvez le vérifier dans le menu "Projets" / "Options" puis "Compilateur Delphi" et "Définitions conditionnelles". Vous pouvez également en ajouter pour votre propre usage. Elles seront globales au projet au lieu d'être locales au fichier dans lequel vous les indiquez par {$DEFINE XXX}.

Il est donc facile dans nos programmes de distinguer la version du programme et donc de conditionner les noms de fichiers en dur qu'on y utilise.

Dans le cas de ma classe tParams, la fonction getParamsFileName devient donc :

function getParamsFileName: string;
var
  folder: string;
  filename: string;
  app_name: string;
begin
  app_name := TPath.GetFileNameWithoutExtension(paramstr(0));
  folder := TPath.Combine(TPath.GetDocumentsPath, app_name);
  if not tdirectory.Exists(folder) then
    tdirectory.CreateDirectory(folder);
{$IFDEF DEBUG}
  filename := app_name + '-debug.par';
{$ELSE}
  filename := app_name + '.par';
{$ENDIF}
  result := TPath.Combine(folder, filename);
end;

Simple comme Hello World, n'est-il pas ?

Ainsi, tant qu'on travaille sur le programme en mode DEBUG, on touche à des fichiers liés à l'environnement de test. Le mode Release ne servant quant à lui qu'à la création du programme qui sera ensuite passé dans la toolchain de mise en production (signature du programme, génération d'un setup.exe, puis lancement de celui-ci pour installer le programme sur les ordinateurs devant l'utiliser).

On peut ainsi conditionner les noms des fichiers créés par les programmes de tests en y ajoutant un "-debug" (ou n'importe quoi d'autre) afin de les distinguer des fichiers de production et ne jamais se tromper. Il faut cependant y penser lors de la phase de programmation !

Et pendant que j'y suis, il y a d'autres symboles prédéfinis bien pratiques : ceux qui permettent à un programme de savoir sur quelle plateforme et quel processeur il se compile. On peut ainsi choisir de mettre du code utilisant les API d'Android uniquement lors de la compilation pour la plateforme Android, mais dans le même source que pour les autres plateformes. Il suffit d'utiliser ce type de bloc de condition :

{$IF Defined(ANDROID)}
  // source lié à Android
{$ELSEIF Defined(MSWINDOWS)}
  // source lié à Windows
{$ELSEIF Defined(IOS)}
  // source lié à iOS et au simulateur iOS
{$ELSEIF Defined(MACOS)}
  // source lié à macOS
{$ELSEIF Defined(LINUX)}
  // source lié à Linux
{$ENDIF}

Et dans ce cas faites bien attention à tester IOS avant MACOS car MACOS est aussi défini pour la compilation vers IOS puisque la compilation se fait sur Mac.

Vous retrouverez un exemple pratique de ce conditionnement en fonction de la plateforme cible avec l'unité u_urlOpen qui permet d'ouvrir une URL dans le navigateur par défaut de l'appareil sur lequel s'exécute le programme.

Il y a bien d'autres symboles prédéfinis. Je ne m'en sers que très rarement. Vous les retrouverez dans la documentation officielle à partir de cette page.

La recherche de ces directives n'étant pas aisée avec le moteur de recherche intégré au docwiki et encore moins avec les fichiers d'aide sous Delphi, je me garde toujours la liste en favori sur mon navigateur habituel. Je vous recommande de faire de même si vous les utilisez et ne voulez pas apprendre par coeur tous les symboles prédéfinis susceptibles de vous intéresser.

Notez une dernière chose : les conditionnements sont aussi utilisés par l'EDI lorsque vous éditez vos sources. Les vérifications de syntaxe et assistants de saisie ne se déclenchent que pour les blocs de codes liés à des symboles valides en fonction de la configuration actuelle de l'environnement. Si vous déboguez pour un smartphone sous Android, vos modifications du code lié à Windows seront enregistrées mais l'EDI ne vous proposera pas de correctifs si vous faites une faute de saisie ou de syntaxe. Ne vous étonnez donc pas de tomber sur des erreurs alros que vous avez réussi à compiler sur une autre cible juste avant.
Ce fonctionnement est tout à fait normal car l'EDI se sert du compilateur pour tester en temps réel la saisie de l'utilisateur et lui afficher ses éventuelles erreurs et suggestions. Il est donc normal qu'il ne traite que le code qui serait compilé en cas d'exécution du programme.

Il y a aussi un effet de bord dont il faut se méfier lorsqu'on partage des fichiers entre plusieurs programmes avec nos propres directives de compilations globales. J'y reviendrai une prochaine fois.


Mug Pascal case in AlexandrieMug Toucan DX dans la baie de Rio