Article écrit par Cédric Brancourt
La configuration des applications est un (non)sujet qui semble simple à première vue, mais qui n’est pas si bien maîtrisé par les développeurs juniors ou seniors.
Build configuration vs Run configuration
Attaquons par le fond du problème, sans tourner autour du pot. Il existe deux types de configuration dont les rôles et la mise en œuvre sont totalement orthogonaux.
Si votre expérience tourne autour de langages compilés, la distinction entre ces types de configuration vous est souvent imposée par l’outillage.
Si votre expérience tourne autour d’un langage interprété, comme Ruby, il est plus que probable que vous n’ayez jamais fait de distinction entre ces deux types de configuration.
Pourtant qu’il s’agisse de langage compilé ou interprété, ignorer cette distinction se traduit souvent par des applications rigides, difficiles à déployer, peu réutilisables ou par une explosion de la complexité de configuration.
Une confusion générale dont l’écho se fait entendre et se propage jusque dans les meilleures recommandations.
Build configuration
Il s’agit de faire varier l’assemblage de l’application pour satisfaire des besoins ou contraintes en fonction du stade dans lequel on exécute l’application.
Par assemblage on entend configuration des dépendances et liens entre les modules de l’application.
Dans la grande majorité des cas, il y a trois stades ou environnements qui justifient de faire varier l’assemblage de l’application : développement, test et production.
Tous les environnements doivent être une variation minimale de celui de production. Une trop grande divergence produirait une application différente de celle développée ou testée et des comportements incohérents et non reproductibles.
L’environnement de développement varie souvent en incluant un debugger, un backend de persistance allégé, le hot reloading du code, la verbosité des logs, ou une toute autre manière de servir l’application.
Prenons l’exemple d’une application JavaScript à destination du navigateur : l’environnement de développement ne sert pas de fichiers « minifiés », ni assemblés, il inclut parfois des outils d’assistance au debugging, linting, etc.
Dans le cas d’une application Ruby il peut inclure un debugger, un analyseur de code statique, une console améliorée …
L’environnement de test diffère souvent de l’environnement de production par la présence d’un framework de test, et surtout si le code est correctement architecturé, par l’utilisation de dépendances de substitution (adapters) pour les I/O.
En règle générale, ces configurations de build sont référencées dans un fichier car elles sont (presque) toujours identiques. Que ce soit sur le poste de Victor, Hugo ou Quentin, lorsque nous jouons les tests unitaires, ils s’exécutent tous avec le même adapter de persistance.
Par exemple dans une application Rails vous trouverez des fichiers d’environnement pour le développement, test, et production, ainsi que des bundles différents pour chaque environnement.
Si vous trouvez des fichiers config/environnement/staging.rb
, config/environnement/qa.rb
, config/environnement/feature.rb
… C’est une erreur.
Ce sont des configurations qui varient en fonction du stack dans lequel on fait tourner l’application, et donc des run configurations.
Sur une plate-forme de « Staging », « QA » ou le « cluster-prod-2 », c’est toujours le build de production qui tourne. Sinon comment assurer le résultat en production avec un build différent ?
Dans l’exemple d’une application JavaScript qui est build avec Webpack, vous trouverez aussi des fichiers destinés à assembler l’application pour les différents environnements couvrant toujours les mêmes besoins, à savoir faire varier les dépendances incluses dans le build, et l’assemblage des modules. Si vous trouvez l’URL du backend de l’application dans ces fichiers, c’est également un problème de conception, car celle-ci doit varier au run et non au build.
Enfin prenons l’exemple d’une application Elixir : Mix est utilisé pour faire varier la configuration au build. Toujours pour les mêmes raisons. Et vous trouverez parfois les même erreurs, à savoir des environnements qui n’en sont pas. Et des configurations de run qui n’ont rien à y faire.
Run configuration
Ces configurations n’ont pas pour but de faire varier le comportement de l’application ni de modifier ses dépendances. Elles sont utilisées pour adapter les variables du stack courant : les URLs des services en relation, les credentials de la DB, le port sur lequel on écoute … Elles ne nécessitent pas de recompiler, réassembler ou redéployer l’application pour varier. En général un simple redémarrage suffit.
En règle générale les variables d’environnement sont utilisées pour faire varier la configuration. Mais dans le cadre d’une application JavaScript exécutée dans un document HTML, c’est ce dernier qui porte la configuration et qui la fournit en argument du script d’initialisation de l’application.
Côté bonnes pratiques, il est recommandé d’utiliser les variables d’environnement. Mais attention à ne pas les disséminer partout dans la codebase. Un fichier fournissant un dictionnaire des clés/valeurs dans lequel on interpole les variables d’environnement fera des miracles en cas de changement de noms de variables et en termes de lisibilité.
Les mécanismes fournis par les plate-formes d’exécution et les frameworks servent principalement à faire varier les build configurations. Il vaut mieux construire votre module de configuration (ou en utiliser un tout fait), pour encapsuler celles-ci, gérer les valeurs par défaut, etc, plutôt que d’utiliser le mécanisme de build, qui n’est pas vraiment fait pour.
Exemple d’une app Ruby et/ou Rails
N’ajoutez pas vos configurations dans les fichiers d’environnement du framework ! Tout simplement ajoutez un fichier config/my_app.yml
qui contiendra les variables de votre application. Ensuite chargez-le dans un initializer et interpolez les variables d’environnement grâce à ERB. Que ce soit dans une application rails ou dans une bibliothèque Ruby la technique est identique.
module MyApp extend self attr_writer :config def config @config ||= MyApp::Config.new() end def configure yield(config) end def load_config_file(file) config = YAML.load(ERB.new(File.read(file))) end end
module MyApp class Config attr_accessor :url_example def initialize(opts) url_example = opts.fetch(:url_example) end end end
# config.yml url_example: <%= ENV.fetch("URL_EXAMPLE", "http://default.value") %>
De cette manière vous pouvez gérer la configuration de l’application comme bon vous semble :
MyApp.load_config_file("config.yml") # Ou MyApp.configure do |config| config.url_example = "http://another.value" end # Ou MyApp.config = MyApp::Config.new(url_example: "http://another.value") # …
Elixir et les env vars
Si vous développez et déployez des applications Elixir à des fins de production, il y a de fortes chances pour que vous vous soyez cassé les dents sur le build d’une release, comme on peut le lire un peu partout sur le web (d’autant plus si vous avez été (dé)formés par RubyOnRails).
Les configurations gérées avec Mix sont destinées au build. Si vous utilisez System.get_env
dans la configuration Mix, celle-ci sera statique car évaluée à la compilation ; Et c’est bien normal ! Pour comprendre pourquoi, relisez l’article depuis le début, mais avec attention cette fois.
Il faut donc comme dit précédemment construire son propre module de configuration de l’application qui pourra servir de proxy aux configurations de Mix.
Un exemple
Pour illustrer le principe prenons l’exemple d’une application « iFriend », pour ceux qui n’ont pas d’amis, qui envoie un certain nombre de « bonjour » par SMS.
Build configuration
Lorsque le développeur travaille sur l’application il n’a pas intérêt à envoyer des SMS, parce que c’est long, coûteux, et difficile à vérifier ; Idem lorsque la suite de test s’exécute.
Il serait possible d’utiliser un bouchon pour la SMS gateway, mais ce n’est pas la piste que nous explorons afin de ne pas alourdir les dépendances de l’environnement de développement.
Et si en fonction de l’environnement de build les IO de l’application utilisent des adapters différents ? Dans le cas de l’environnement de production, nous utilisons un adapter qui utilise une SMS gateway au travers de HTTP. Dans l’environnement de dev, les SMS envoyés sont directement envoyés sur la sortie standard. Dans l’environnement de test, ils sont capturés pour être comptés et vérifiés.
Ainsi nos différents fichiers d’environnement de build ressembleront à ceci :
# config/config.exs use Mix.Config config :IFriend, :greet_medium_adapter, IFriend.GreetMedium.SMS case Mix.env do :prod -> :ok _ -> import_config "#{Mix.env}.exs" end
On définit l’adapter dans la configuration de base, qui est utilisé pour la production. Celui-ci est étendu pour les environnements de dev et test par les définitions ci-dessous.
# config/dev.exs use Mix.Config config :IFriend, :greet_medium_adapter, IFriend.GreetMedium.IO
Pour le développement on log dans la console
# config/test.exs use Mix.Config config :IFriend, :greet_medium_adapter, IFriend.GreetMedium.Memory
Pour les tests on garde les messages en mémoire. Ces configurations sont évaluées au moment du build, et sont donc figées dans la release qui est produite.
Elles sont utilisées par le code applicatif à l’aide de Application.get_env(:IFriend, :greet_medium_adapter)
.
Exemple :
def greet(max, count) do greet_medium().output(greeter()) greet(max, count + 1) end defp greet_medium do Application.get_env(:IFriend, :greet_medium_adapter) end
En prime il sera aisé de remplacer les SMS par des e-mails ou des messages Discord, puisque le code est découplé. Il suffira de créer un nouvel adapter.
Run configuration
Notre application pourra être configurée pour envoyer un message différent, suivant les préférences de la personne qui l’exécute, et faire varier la quantité de messages, ou encore modifier les credentials et l’URL de la SMS gateway.
Ainsi lors d’un déploiement en staging il sera possible d’utiliser un service alternatif ou un bouchon pour ne pas envoyer de SMS, tout en utilisant le build de production.
Comme je l’expliquais plus haut, Mix n’est pas destiné à configurer l’application, mais le build (c’est écrit #000 sur #FFF dans la documentation).
Pour configurer l’application je recommande d’utiliser un module qui sert de proxy à la configuration, et qui va nous permettre d’utiliser les valeurs des variables d’environnement présentes au run et non au build.
# lib/i_friend/config.ex defmodule IFriend.Config do # L'interface principale du module de config def get(app, key, default \ nil) when is_atom(app) and is_atom(key) do case read_config(Application.get_env(app, key)) do nil -> default val -> val end end # Dans le cas du tupple {:system, var } on lit la variable sur le systeme courrant defp read_config({:system, var_name}), do: System.get_env(var_name) defp read_config({:system, var_name, default}) do case System.get_env(var_name) do nil -> default val -> val end end # Si c'est une liste alors c'est une sous-clé de configuration defp read_config(list) when is_list(list) do Enum.reduce(list, [], fn(e, acc) -> [ read_config(e) | acc] end) end defp read_config({subconfig, val}) when is_atom(subconfig), do: {subconfig, read_config(val)} defp read_config(other), do: other # Si on attend un entier, autant faire le cast dans le module def get_cast(:integer, app, key, default \ nil) do case get(app, key, default) do val when is_integer(val) -> val val -> with {i, ""} <- Integer.parse(val) do i else err -> raise ArgumentError, message: ~s""" Error parsing Config value for #{app}, #{key} : #{err} does not seem to be valid integer """ end end end end
Notre fichier de configuration peut maintenant contenir des variables qui varient 🙂
# config/config.exs use Mix.Config config :IFriend, :greeter, {:system, "GREETER", "hello"} config :IFriend, :greeting, [max: {:system, "MAX"}] config :IFriend, :greet_medium_adapter, IFriend.GreetMedium.SMS case Mix.env do :prod -> :ok _ -> import_config "#{Mix.env}.exs" end
Et utiliser ces dernières dans notre application.
# extrait de lib/i_friend.ex def hello do greet(greet_count()) end defp greet(max, count \ 0) defp greet(max, count) when count > max , do: :ok defp greet(max, count) do greet_medium().output(greeter()) greet(max, count + 1) end defp greeter, do: Config.get(:IFriend, :greeter) defp greet_count, do: Config.get_cast(:interger, :IFriend, :greeting, :max) defp greet_medium, do: Config.get(:IFriend, :greet_medium_adapter)
En ajustant les variables d’environnement GREETER
et MAX
je peux à présent faire varier le message et le nombre de messages sans avoir à build une nouvelle version.
Les maux de la faim …
Distinguer les variables liées à l’assemblage de celles qui varient à l’exécution peut vite devenir indispensable, suivant la plate-forme employée. Sur une plate-forme interprétée c’est tout aussi payant pour ce qui est de la lisibilité et la modularité.
Ces connaissances théoriques peuvent être rapidement mises en pratique, quelle que soit votre techno de prédilection, et vous éviter des nœuds dans les synapses lorsque vous travaillez sur des systèmes multi-tiers ou distribués.
La bibliothèque de configuration Elixir présentée ici fera l’objet d’un package Hex très prochainement. Ce qui sera sûrement l’occasion de détailler les patterns utilisés dans le code.
Pour les plus curieux d’entre vous qui ont lu jusqu’au bout, l’application d’exemple est disponible sur GitHub. Vous y trouverez l’exemple concret de configuration, mais aussi la recette pour empaqueter les releases Elixir dans un conteneur docker avec Distillery. (ce qui fera peut-être l’objet d’un autre article)