Meta de la configuration, illustré avec Elixir

Publié le 22 février 2018 par Cédric Brancourt | architecture

Cet article est publié sous licence CC BY-NC-SA

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)


L’équipe Synbioz,
Libres d’être ensemble.