Il y a des bibliothèques comme ça, qui ne sont pas forcément au niveau de popularité de Spring, mais qui font l’unanimité quand elles sont proposées sur un projet. Lightbend Config, anciennement “Typesafe Config”, en fait partie.

Et pour cause ! Vous allez voir que la flexibilité et la robustesse apportées par cette bibliothèque en font un choix idéal pour la configuration des applications pour JVM.

Démons de l’ancien monde

Comment faisait-on avant Lightbend Config ? J’ai du mal à m’en souvenir. Et c’est bien normal. Si j’ai rencontré différentes stacks techniques au cours de mes missions, si les choix de mes clients n’étaient pas toujours ouverts à la remise en cause, je n’ai jamais eu d’opposition avec cette bibliothèque. À chaque fois, l’essayer c’était l’adopter.

Historiquement, Java proposait les fameux fichiers .properties. Supportés par la classe éponyme, ces fichiers ne sont que des dictionnaires clé/valeur plats. De plus, il a fallu attendre Java 9 pour que ces fichiers soient lisibles autrement qu’en ISO-8859-1 ! Imaginez toutes les valeurs i18n pour les langues non-latines rentrées à coup de séquence d’échappement Unicode…

Si le monde de l’entreprise a massivement utilisé le XML pour remplacer les properties, les projets open-source ont parfois tenté une approche “properties structurées” à la Log4J. Les clés du fichier log4j.properties peuvent contenir des points qui constituent un chemin dans la structure de la config qui elle, est objet. La version properties reflète ainsi la structure de la version XML.

# Root logger option
log4j.rootLogger=INFO, stdout

# Direct log messages to stdout
log4j.appender.stdout=org.apache.log4j.ConsoleAppender
log4j.appender.stdout.Target=System.out
log4j.appender.stdout.layout=org.apache.log4j.PatternLayout
log4j.appender.stdout.layout.ConversionPattern=%d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L - %m%n
<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
   <Appenders>
       <Console name="stdout" target="System.out">
           <PatternLayout pattern="%d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L - %m%n"/>
       </Console>
   </Appenders>
   <Loggers>
       <Root level="INFO">
           <AppenderRef ref="stdout"/>
       </Root>
   </Loggers>
</Configuration>

Le manque de souplesse de XML ainsi que la popularisation du JSON ont poussé de nombreux projets à migrer vers ce dernier format qui permet de communiquer plus facilement avec les projets frontend. La lingua franca du web fonctionne bien comme format de configuration car elle permet de la structurer proprement et lisiblement (plus qu’avec le XML) et dispose d’un support logiciel universel. Certains langages, comme Javascript, profitent d’une facilité de manipulation accrue. Le XML, avec sa structure et son typage, reste majoritairement utilisé comme format d’échange.

Enfin, properties, XML et JSON ne sont que des formats de fichiers. La flexibilité de configuration externe de votre application dépend du code que vous avez produit pour les utiliser. Par défaut, un ops ne pourra poser la configuration qu’à l’endroit prévu par le code, ou dans un dossier à ajouter au CLASSPATH pour ceux qui auront des collègues développeurs attentionnés.

Fuyons ces démons de l’ancien monde…

Une configuration pour les lier toutes

Par opposition avec les formats précités, Lightbend Config permet une configuration quasi-agnostique de son emplacement et fusionnable.

Fusionnable avec quoi ?

C’est simple : vous avez une JVM, donc en plus des nombreux fichiers de configuration de votre application et de vos dépendances (dans des formats variés), vous avez les Java system properties. Vous savez, celles que vous récupérez via System.getProperty(“some.key”) et que vous pouvez surcharger sur la ligne de commande java au moyen des drapeaux -D. Et comme vous avez un process qui tourne sur un OS, vous avez les variables d’environnement système.

Au moment du build et du déploiement, ça en fait des endroits pour modifier le comportement du code…

Lightbend Config propose de regrouper ce qui est défini dans les fichiers de configuration, les propriétés système et les variables d’environnement sous un même namespace où tout est fusionné et surchargeable. Cerise sur le gâteau, si vos propriétés système sont préfixées par une série de mots dont le séparateur est le point, vous injectez une valeur directement dans la structure de votre configuration (qui peut être vu comme un document au sens JSON, ou un objet).

Par exemple, avec la configuration :

{
  "proxy": {
    "https": {
      "port": 8080,
      "host": "proxy.intranet.company"
    }
  }
}

...vous pourriez surcharger le port du proxy http avec la propriété système de la commande suivante :

java -Dproxy.https.port=9090 -jar MonApp.jar

Sur la cerise du gâteau, ajoutons un peu de crème : tous les fichiers peuvent être fusionnés par directive d’inclusion, et leur structure l’est également. Cela permet un système de valeurs par défaut multi-niveau avec la possibilité de redéfinir des valeurs au déploiement !

Vous ne passerez pas !

On notera que Lightbend Config dompte les démons de l’ancien monde : l’API lit les fichiers properties. Et si les clés sont des “mots séparés par des points” (un chemin en somme) comme proxy.http.port, les valeurs sont injectées dans la structure objet.

Un langage non-elfique humain

Vous trouverez certainement JSON moins verbeux que XML, mais on peut encore mieux faire. Lightbend Config permet d’écrire la config dans un superset du JSON, le HOCON (Human-Optimized Config Object Notation). Autrement dit, un JSON valide est compris, mais on peut aller plus loin en écrivant en HOCON.

Vous trouverez les détails du format sur la doc officielle du HOCON, mais voici un résumé des possibilités.

Imaginons une configuration en JSON:

{
  "fr": {
     "wescale": {
        "configdemo": {
           "app-name": "ConfigDemo",
           "group-number": 12,
           "kafka": {
              "consumer": {
                 "bootstrap-servers": [
                    {
                       "host": "broker1",
                       "port": 9092
                    },
                    {
                       "host": "broker2",
                       "port": 9092
                    }
                 ],
                 "group-id": "ConfigDemo-consumer-group-12",
                 "session-timeout": 10000
              }
           }
        }
     }
  }
}

En HOCON, cela pourrait donner:

fr.wescale.configdemo {
  app-name: "ConfigDemo"
  instance-number: 0
  kafka {
    consumer {
      bootstrap-servers: [{host: "broker1", port: 9092}, {host: "broker2", port: 9092}]
      group-id: ${fr.wescale.configdemo.app-name}"-consumer-group-"${fr.wescale.configdemo.instance-number}
      session-timeout: 10s
    }
  }
}
fr.wescale.configdemo.kafka.consumer.session-timeout=${?KAFKA_CONSUMER_SESSION_TIMEOUT}

Le fichier ci-dessus montre :

  • la possibilité d’abréger les imbrications : fr.wescale.configdemo, notre namespace, n'alourdit pas la configuration. Avoir un namespace permet d’éviter toute collision avec une dépendance qui utiliserait aussi Lightbend Config.
  • la possibilité d’omettre le “:” devant l’accolade de début d’un objet
  • la possibilité d’omettre les double-quotes autour des noms de clés
  • la possibilité de référencer d’autres valeurs de la configuration. Cela marche avec toute variable qui serait dans un autre fichier visible après le chargement par le code applicatif, ainsi que toute propriété système (Java system property) ou toute variable d’environnement. Exemples : ${file.separator}, ${HOME}...
  • la possibilité de définir des temps de manière “lisible”. Ici, 10s remplace l’habituel nombre exprimé en millisecondes. Mais nous aurions pu mettre 10000ms par exemple.
  • la possibilité de redéfinir une variable. Contrairement au JSON qui ne supporte pas d’avoir deux clés de même nom au même niveau, HOCON le supporte et applique les valeurs dans le sens de la lecture. Notre session-timeout dans le kafka consumer ne vaudra donc pas forcément 10 secondes, car la dernière ligne peut l’écraser si une variable (ici d’environnement) KAFKA_CONSUMER_SESSION_TIMOUT est présente.
  • On vient de le voir, la définition conditionnelle ${nomVariable} assigne systématiquement la valeur, ${?nomVariable} ne le fait que si nomVariable existe.

Les directives d’inclusion de fichiers, que je vous laisse découvrir dans la documentation d’origine, sont également interprétées dans l’ordre où elles sont rencontrées. Selon moi, dans une configuration propre et Cloud Native, elle ne sert pas à inclure des fichiers à un emplacement fixe (cela reste possible), mais à découper une grosse configuration en sections. Le Fichier principal n’aura que des inclusions par exemple.

Séquence de chargement normalisée

Que se passe-t-il quand on charge la configuration avec un appel à ConfigFactory.load() ?

  1. Chargement des configurations de référence

    Tous les fichiers nommés reference.conf trouvés dans le CLASSPATH sont chargés, parsés et leurs variables sont résolues.

  2. Chargement des configurations applicatives.

    Les fichiers application.properties, application.json et application.conf sont chargés et fusionnés dans cet ordre. Les variables (même celles qui étaient dans reference.conf) sont résolues. Il y a donc deux phases de résolution pour certaines variables.

  3. Fusion des system properties

Les propriétés système Java (dont les arguments passés avec le drapeau -D) sont fusionnées dans la configuration. Elles ont donc le plus haut niveau de priorité, ce qui permet de changer la configuration d’une application à la volée au lancement.

On notera que les variables d’environnement du processus ne sont visibles que si elles sont explicitement référencées pour être incluses dans une valeur. Exemple : my.app.user=${USER}

Chargement personnalisé

On peut remplacer le chargement des fichiers application.(conf|json|properties) par un fichier dont l’emplacement est spécifié par un chemin, une URL ou une ressource du CLASSPATH. Il suffit de le préciser sur la ligne de commande java respectivement avec le drapeau -Dconfig.file, -Dconfig.url ou -Dconfig.resource.

Autre moyen de fixer l’origine de la configuration à charger, dans le code applicatif :

  • en passant à ConfigFactory.load(resource) un chemin de ressource du CLASSPATH ou une URL
  • en passant un chemin de fichier à la méthode ConfigFactory.parse* qui convient.

Dans ce dernier cas, la configuration est simplement parsée, il faudra donc appeler explicitement resolve() pour résoudre les variables ${}.

On pourra également fusionner explicitement des configurations dans le code en leur donnant des priorités :

var someconfig = ConfigFactory.load(“someconfig.conf”).withFallback(otherConfig)

Ci-dessus, on doit avoir chargé au préalable otherConfig. La ressource someconfig.conf sera fusionnée dans otherConfig de manière à la surcharger. Si et seulement si une clé n’est pas trouvée dans someconfig.conf, alors elle sera recherchée dans otherConfig.

Résumé des possibilités de configuration lors du déploiement

Avec ce que nous avons vu, il est possible d’avoir un tableau des possibilités de configuration.

  1. Embarquer des valeurs par défaut dans le Jar de mon application
  2. Redéfinir des valeurs spécifiques à un environnement ou à un lancement via une option sur la ligne de commande
  3. Prévoir la surcharge de certaines valeurs par variables d’environnement

En fixant le chemin de la configuration par -Dconfig.file sur la ligne de commande java, on peut donc pointer vers un endroit spécifique qui n’existe pas dans le conteneur applicatif jusqu’à ce que le montage d’un volume se fasse. On peut donc changer la configuration d’une application conteneurisée sans reconstruire son image. Remarque: pour tout ce qui ne peut être vu comme un système de fichier local, -Dconfig.url peut être utilisée.

On pourra aussi utiliser des variables d’environnement qui seront affectées dans les descripteurs de lancement utilisés par les orchestrateurs de conteneurs, ou même dans le descripteur d’un application serverless (AWS Lambda / Google Cloud Function, etc.).

Bonnes pratiques

Organisation des fichiers de configuration

Si vous faites une bibliothèque, prévoyez toujours une config de référence. Elle doit se trouver à la racine du CLASSPATH (et donc du fichier .jar) et s’appeler reference.conf.

Comme elle peut servir à valider la structure de la configuration finale via la méthode checkValid de Config, il vaut mieux éviter d’utiliser la configuration de référence pour renseigner les quelques variables par défaut d’une application qui aurait par ailleurs une configuration plus complexe. Dans ce cas, jouez sur la fusion de ressources :

  • Utilisez le chargement par défaut pour constituer une configuration de fallback
  • Récupérez une propriété système personnalisée (telle -Dmy.company.app.config=/path/to/configdir/myapp.conf) pour savoir quel est le fichier contenant la configuration complète ou qui contient tous les éléments obligatoires
  • Dans le cas d’un conteneur, prévoyez bien de faire pointer cette fameuse option vers un dossier vide qui sera rempli au montage des volumes (exemple : ici /path/to/configdir sera un volume)

Pour une lambda, vous devriez avoir une configuration légère, embarquée dans le code, avec les valeurs qui peuvent varier, le contexte de la lambda pointant vers des variables d’environnement qui seront précisées dans le descripteur de déploiement de cette dernière.

Points d’attention au build

Comme Lightbend Config est utilisé par un certain nombre de bibliothèques de l’écosystème Java/Scala, vous risquez des conflits de ressources lors de la création d’un fat jar (alias jar with dependencies). Il conviendra alors de préciser au plugin de shading ou à l’assembly de votre descripteur de build d’appliquer une règle de filtrage spécifique : les fichiers *.conf doivent être concaténés. Vous obtiendrez ainsi un fichier reference.conf qui contiendra la configuration de référence de toutes vos dépendances.

Sans cette manipulation, seule une ressource sera retenue pour chaque conflit, aboutissant à une configuration incomplète qui ne manquera pas de faire crasher votre application au démarrage.

Exemple de configuration pour le maven-shade-plugin :

   <project>
      ...
      <build>
        <plugins>
          <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-shade-plugin</artifactId>
            <version>3.2.4</version>
            <executions>
              <execution>
                <goals>
                  <goal>shade</goal>
                </goals>
                <configuration>
                  <transformers>
                    <transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
                      <resource>reference.conf</resource>
                    </transformer>
                    <transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
                      <resource>application.conf</resource>
                    </transformer>
                  </transformers>
                </configuration>
              </execution>
            </executions>
          </plugin>
        </plugins>
      </build>
      ...
    </project>

Exemple de configuration pour le plugin sbt-assembly :

assemblyMergeStrategy in assembly := {
  case "application.conf" => MergeStrategy.concat
  case "reference.conf"   => MergeStrategy.concat
  case x =>
    val oldStrategy = ( assemblyMergeStrategy in assembly).value
    oldStrategy(x)
}

Utiliser un namespace

Comme nous venons de le voir, les ressources de configuration vont être concaténées. Nous avons également vu plus haut que les différents fichiers chargés ainsi que les propriétés systèmes vont être fusionnés dans une unique configuration en mémoire.

Pour ces raisons, il est recommandé de mettre votre configuration applicative dans un namespace qui vous est propre. Dans l’exemple de configuration HOCON vu au début, nous avons utilisé le namespace fr.wescale.configdemo. Si vous suivez la même convention que les packages Java, vous serez prémunis de tout conflit.

Validation et mapping objet

En explorant l’API Config, vous constaterez que des méthodes spécifiques comme getInt, getString, getStringList, etc. existent pour chaque type que peut prendre une variable de configuration. Ces méthodes jettent une exception si le type n’est pas celui attendu ou si la clé demandée n’existe pas. Il existe également une méthode pour vérifier si une clé est présente (hasPath).

Ce constat nous amène à une observation : nous n’allons évidemment pas, à chaque fois que nous aurons besoin d’une valeur dans le code, mettre un if plus un try-catch. Nous allons donc valider notre configuration au chargement de l’application et la mapper sur un objet (un POJO ou case class). Passée cette validation, nous aurons donc accès à la configuration de manière sûre dans un simple objet !

Si votre configuration est trop complexe, la documentation officielle vous donne une liste d’outils permettant de vous simplifier la vie.

En résumé, les projets Java utiliseront un outil qui génère des POJO immuables à partir d’une configuration fournie. Kotlin et Scala disposent de bibliothèques permettant le mapping automatique ou semi-automatique sur un modèle que vous aurez écrit. Pour Scala, vous avez même un sacré choix… Personnellement, je mets toujours en place Lightbend Config dans les projets Scala avec Pureconfig.

Conclusion

Si la flexibilité et la structuration permises par Lightbend Config la rendent très appréciable pour les développeurs, c’est toute la pratique devops qui est favorisée. Ainsi, mon code est catapultable n’importe où, et sa configuration est surchargeable en fonction de l’environnement : VM, conteneur, lambda, framework de calcul distribué.

Si on regarde les fameux “12 facteurs”, le gestionnaire de configuration qu’est Lightbend Config coche la case 3. Il aide également au 5ème facteur en permettant des modifications de configuration à l’assemblage et au lancement. Enfin, il contribue au facteur 10 en rapprochant dev et ops autour d’un modèle commun.

Sa compatibilité avec le format properties (encore souvent utilisé), ou bien avec le JSON en fait un outil sur lequel on migre de manière facile. Concernant les pratiques de développement, il favorise également les bonnes habitudes avec sa vision d’une configuration qui n’est plus un simple dictionnaire fourre-tout, mais un graphe d’objets validés, typés et immuables.

C’est finalement pour moi la configuration Cloud Native incarnée.

La documentation complète est disponible sur le Github de Lightbend Config.