Si dans un projet, vous vous retrouvez à gérer plusieurs services, il peut être intéressant de centraliser la configuration (ou du moins la configuration par défaut) de ces services sur des paramètres comme le port d'écoute HTTP, les endpoints d'API Rest ou, pour des applications Kafka, le nom des topics communicants entre les services. L'idée est d'éviter des collisions (comme pour le port d'écoute HTTP) ou de taper à côté (comme pour les topics Kafka) et de s’éviter des heures de debugging sur des erreurs futiles s’étendant à un seul caractère. Si vous avez en plus un moyen à la fois concis et homogène de configurer vos services et mettre en avant leur interdépendance, vous pouvez rapidement analyser la structure du projet.

Dans ce cas, pourquoi ne pas utiliser l'outil de build pour mettre en place cette centralisation. Nous allons voir ça avec SBT.

Quelques mots sur SBT

SBT, le Maven du monde Scala, est un outil qui n'est pas facile à maîtriser. Il a cependant des capacités de personnalisation qui sont assez importantes.

En effet, la gestion d'un projet sous SBT ne se base pas sur un fichier de description (XML, JSON, YAML...), mais sur des fichiers utilisant le langage Scala. Ceci permet d’utiliser toute la capacité du langage pour mettre en place un DSL (Domain Specific Language) et obtenir un style de déclaration de projet adapté à la définition de nos services.

Déclarer des services Kafka Streams

L’idée est d’avoir l’approche la plus déclarative pour décrire nos services. Ce qui veut dire que notre fichier de build doit permettre de comprendre très rapidement quelle est la structure du projet, quels sont les services, comment est-ce qu’ils interagissent entre eux et comment les monitorer.

Pour ça, nous allons créer dans le répertoire project/ le fichier BuildHelper.scala et allons y créer une fonction qui permet de déclarer des sous-modules représentant des services.

Voici la signature de la fonction serviceProject pour déclarer des services Kafka Streams avec une API Rest de monitoring, destinés à composer notre pipeline de traitement.

import sbt._
import sbt.Keys

object BuildHelper {

  def serviceProject(
      serviceName: String,
      port: Int,
      inputStream: Option[String] = None,
      outputStream: Option[String] = None
  ): Project = ???

}

La structure du projet est ainsi plus visible. Avec une section déclarant en un seul lieu les topics disponibles, nous évitons des erreurs de nom ,par exemple , le nom des topics a changé plusieurs fois et qu’on a oublié de propager la modification dans certains services. Nous pouvons aussi utiliser des variables pour mettre en valeur la dépendance en terme de topic entre les services. Sur un autre aspect, les collisions de port d’écoute HTTP sont plus aisés à détecter.

Ainsi, les services sont plus homogènes et leur déclaration est plus simple à écrire et à lire.

import BuildHelper._

lazy val root =
  (project in file("."))
  .aggregate(serviceCommon, serviceSerde, serviceIngest, serviceProcess, serviceExport)

// ---- TOPIC NAMES ----------------

val streams = new {
  val data        = "data-stream"
  val event       = "event-stream"
  val information = "information-stream"
}

// ---- SERVICE DECLARATION ----------------

lazy val serviceIngest =
  serviceProject(
    serviceName  = "service-ingest",
    port         = 10001,
    inputStream  = Some(streams.data),
    outputStream = Some(streams.event)
  )
  .settings(commonSettings)
  .dependsOn(serviceCommon, serviceSerde)

lazy val serviceProcess =
  serviceProject(
    serviceName  = "service-process",
    port         = 10002,
    inputStream  = Some(streams.event),
    outputStream = Some(streams.information)
  )
  .settings(commonSettings)
  .dependsOn(serviceCommon, serviceSerde)

lazy val serviceExport =
  serviceProject(
    serviceName  = "service-export",
    port         = 10003,
    inputStream  = Some(streams.information),
    outputStream = None
  )
  .settings(commonSettings)
  .dependsOn(serviceCommon, serviceSerde)

// ---- ADDITIONAL SUBMODULES ----------------

lazy val serviceCommon =
  (project in file("service-common")) // ...

lazy val serviceSerde =
  (project in file("service-serde")) // ...

// ---- COMMONS ----------------

// settings necessary for the current pipeline
lazy val commonSettings =
  Def.settings(/* ... */)

Ce qui correspond au diagramme suivant.

SBT permet la génération de fichier comme tout bon outil de build et ça vaut pour les fichiers de configuration. En se basant sur la déclaration vue précédemment, on peut générer automatiquement une configuration par défaut. En utilisant un format de fichier de configuration comme HOCON, il est possible de surcharger cette configuration par défaut. Voici un exemple de configuration générée automatiquement à partir de la déclaration vue avant.

service {
  name = "service-ingest"
  http {
    port = 10001
  }
  kafka {
    bootstrapServers  = "localhost:9092"
    schemaRegistryUrl = "localhost:8081"
  }
}
topics {
  inputStream  = "data-stream"
  outputStream = "event-stream"
}

Par contre, contrairement à la génération automatique de code source qui se déclenche lors de l'étape de compilation, la génération automatique de fichier de ressource se déclenche lors du run du service ou à l'étape package.

Pour intégrer cette configuration et éventuellement la surcharger, à supposer que le fichier se trouve dans target/scala-*/resource_managed/main/service-generated/application-generated.conf, votre application.conf ressemblera à

include classpath("service-generated/application-generated.conf")

Implémentation

Nous allons tout d'abord inclure le plugin sbt-native-packager, afin d'avoir un exemple avec un plugin à configurer pour chaque service. On peut aussi faire de même avec d'autres plugins comme sbt-buildinfo.

addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.7.2")

Ci-dessous, nous avons l'implémentation de serviceProject. Cette fonction assure différentes tâches :

  • La déclaration du sous-module
  • La déclaration du répertoire du sous-module
  • La déclaration de l'identifiant du sous-module (utilisé pour la navigation entre sous-modules dans le CLI de SBT)
  • La déclaration du nom du projet
  • La déclaration de la main class
  • L'ajout d'une tâche dans la génération automatique des fichiers de ressources pour générer la configuration par défaut du service, en suivant l'approche recommandée dans SBT
  def serviceProject(
      serviceName: String,
      port: Int,
      inputStream: Option[String] = None,
      outputStream: Option[String] = None
  ): Project = {
    val nameParts        = serviceName.split("-")
    val projectId        = nameParts.head + nameParts.tail.map(_.capitalize).mkString
    val packageName      = s"io.univalence.service.${nameParts.last}"
    def serviceMainClass = s"Service${nameParts.last.capitalize}Main"

    Project(projectId, new File(serviceName))
      .enablePlugins(JavaAppPackaging)
      // basic settings required by all services
      // (set anything like scalac options, dependencies, test configuration...)
      .settings(serviceSettings)
      .settings(
        name                 := serviceName,
        mainClass in Compile := Some(s"$packageName.$serviceMainClass")
      )
      .settings(
        // add resource generation task for the default config of the service
        Compile / resourceGenerators += Def.task {
          // get path of the generated file
          val confFile = (Compile / resourceManaged).value / "service-generated" / "application-generated.conf"

          val files =
            generateServiceConfig(
              confFile     = confFile,
              serviceName  = serviceName,
              defaultPort  = port,
              inputStream  = inputStream,
              outputStream = outputStream
            )

          streams.value.log.info(s"generated $files")

          files
        }.taskValue
      )
  }

La génération de la configuration par défaut va se baser sur une fonction qui utilise un template. J'utilise ensuite la fonction IO.write pour écrire dans le fichier. La fonction doit renvoyer un Seq[File].

  def generateServiceConfig(
      confFile: File,
      serviceName: String,
      defaultPort: Int,
      inputStream: Option[String],
      outputStream: Option[String]
  ): Seq[File] = {
    val isParam = inputStream.map(is => s"""inputStream  = "$is"""").getOrElse("")
    val osParam = outputStream.map(os => s"""outputStream = "$os"""").getOrElse("")

    val content =
      s"""service {
       |  name = "$serviceName"
       |  http {
       |    port = $defaultPort
       |  }
       |  kafka {
       |    bootstrapServers  = "localhost:9092"
       |    schemaRegistryUrl = "localhost:8081"
       |  }
       |}
       |topics {
       |  $isParam
       |  $osParam
       |}
       |""".stripMargin

    IO.write(confFile, content)

    Seq(confFile)
  }

Conclusion

SBT permet d’utiliser les capacités du langage Scala pour mettre en place un DSL adapté à la déclaration des services qui composent notre projet. Un tel DSL doit permettre de comprendre rapidement la structure du projet et de détecter les dépendances entre ses composants en les centralisant. L’implémentation d’un tel DSL n’est vraiment compliqué mais nécessite de connaître le fonctionnement de SBT. La documentation de SBT est à ce titre nécessaire et assez bien fournie.

Photographie par Steve Harvey sur Unsplash