Un système simple et efficace de sélecteurs en Scala

Contexte

À plusieurs reprises, au cours de ma carrière, j’ai été confronté au besoin de supporter une variété de comportements, et de ne pouvoir décider que lors de l’exécution de notre application du comportement que nous devons adopter. Par exemple:

  • Un client mail reçoit une pièce jointe avec un code MIME. Sur base de ce code, il doit choisir la bonne manière de traiter la pièce jointe.
  • Un programme consomme les messages provenant d’un topic Kafka. Selon les préférences de l’utilisateur et le type d’environnement d’exécution, ces messages peuvent être encodés en JSON, en XML ou en Protobuf.
  • On demande à un service de créer la miniature d’une image, sur base de son URI. La manière de récupérer l’image originale diffère selon que le schéma de cet URI indique que la ressource soit accessible en HTTP, en FTP ou via un système de fichier local.
  • Une application doit traiter des fichiers PDF qui peuvent éventuellement être chiffrés avec GPG. Rien n’indique a priori si un fichier est chiffré ou non, si bien que le moyen le plus simple de le déterminer consiste à lire le fichier en supposant qu’il n’est pas chiffré et, en cas d’erreur, tenter une lecture en supposant qu’il est effectivement chiffré.

Ces exemples ont pour point commun que le développeur est face à l’inconnu: le comportement de son application dépend au mieux d’un indicateur reçu lors de l’exécution (tel que le code MIME ou le schéma de l’URI), et au pire il ne peut déterminer si un traitement est approprié qu’en tentant de le réaliser (comme dans le cas des fichiers PDF qui peuvent être chiffrés ou non).

Du point de vue applicatif, cette variation du traitement devrait n’être qu’un détail. En tout cas, elle devrait être encapsulée, délimitée pour que le reste du code n’ait pas à s’en préoccuper. En tant que développeur, comment gérer au mieux cette multiplicité de situations?

Représentation des sélecteurs

Vue de loin, chaque situation est assez similaire aux autres: nous devons traiter un élément d’une manière qui dépend d’un contexte. En programmation orientée objet, chaque contexte peut être représenté par une classe, que j’appelle un sélecteur. Selon le cas d’utilisation, un terme plus approprié sera privilégié. Pour reprendre l’exemple de la consommation de messages dans un topic Kafka, nous aurions trois sélecteurs qui prennent le nom de décodeurs:

sealed trait Decoder {
  def decode(msg: ByteArray): Message
}

object JSONDecoder extends Decoder {
  def decode(msg: ByteArray): Message = ???
}

object XMLDecoder extends Decoder {
  def decode(msg: ByteArray): Message = ???
}

object PBDecoder extends Decoder {
  def decode(msg: ByteArray): Message = ???
}

Il s’agit d’une implémentation du patron de conception Stratégie. Cependant, lors de la mise en œuvre de ce patron, nous connaissons généralement d’avance la stratégie que nous souhaitons appliquer. Dans le cas présent, nous considérons que la manière dont le message a été encodé est inconnu a priori. Nous ne sommes donc pas encore tiré d’affaire.

Choix dynamique d’un sélecteur

Lorsqu’un message nous parvient, il faut choisir un sélecteur. Parfois, une méta-donnée du message facilite ce choix. Par exemple, dans le cas du serveur de création de miniatures, le schéma de l’URI d’une image indiquait sans ambiguïté le protocole utilisé et donc le sélecteur à appliquer. Il est alors possible de distinguer la détermination de la pertinence du sélection de son traitement proprement dit. Par exemple, une méthode canProcess peut indiquer si le sélecteur accepte l’objet à traiter, tandis qu’une méthode process réalise le traitement proprement dit:

sealed trait Decoder {
  def canProcess(msg: ByteArray): Boolean
  def process(msg: ByteArray): Message
}

Cependant, je vois trois inconvénients à cette approche:

Premièrement, il y a un risque que la méthode process ne puisse en réalité pas traiter un objet pour lequel la méthode canProcess a pourtant donné une réponse positive. Cela vient du fait que les méthodes ont typiquement une partie de leur comportement en commun. Par exemple, canProcess peut se baser sur les premiers octets du message pour déterminer si le décodeur peut effectivement le décoder, et process lira également les mêmes premiers octets pour détermine comment le décodeur peut réaliser ce travail. Les problèmes commencent lorsqu’il y a un décalage entre les deux implémentations de cette partie commune du travail.

Secondement, rien n’impose aux clients de ce trait de n’invoquer la méthode process que si canProcess a donné une réponse positive. Cette contrainte est implicite, et donc source d’erreurs. Un problème similaire existe auprès des itérateurs en Java, pour qui la méthode next ne peut être invoquée que si hasNext le permet.

Troisièmement, dans certains cas, déterminer si un sélecteur est approprié pour une entrée donnée est pratiquement aussi difficile que de réaliser le traitement proprement dit. En procédant en deux temps, il y a donc une duplication partielle de l’effort.

Pour ces raisons, je préfère souvent adopter une approche selon laquelle un sélecteur n’a qu’une méthode process (ou équivalent), dont la réponse est soit le résultat du traitement, soit une indication spécifiant que le traitement n’a pu être réalisé. En Scala, un Option est le type approprié pour cela.

sealed trait Decoder {
  def decode(msg: ByteArray): Option[Message]
}

En plus d’éviter les trois inconvénients décrits ci-dessus, une approche basée sur une seule méthode a l’avantage de faire de chaque sélecteur une fonction, ce qui permet son exploitation dans les mécanismes basés sur des fonctions, justement: compositions, currying, fonctions d’ordre supérieur, etc. Pour pleinement profiter de cet avantage, en Scala, la classe doit étendre Function1 et la méthode du sélecteur doit se nommer apply:

sealed trait Decoder extends Function1[ByteArray, Option[Message]] {
   def apply(msg: ByteArray): Option[Message]
}

object JSONDecoder extends Decoder {
  def apply(msg: ByteArray): Option[Message] = ???
}

val msg: ByteArray = ???
val result: Option[Message] = JSONDecoder(msg)

Combinaison de sélecteurs

Il est aisé de combiner les sélecteurs potentiellement pertinents, en leur demandant à tour de rôle de traiter un message brut donné. Le premier à y parvenir générera le résultat final.

val decoders = List(JSONDecoder, XMLDecoder, PBDecoder)

def process(msg: ByteArray): Option[Message] = decoders
   .flatMap(_(msg))
   .headOption

Le traitement d’un message est potentiellement onéreux, notamment en termes de temps CPU. De plus, dans la plupart des cas, tous les sélecteurs échoueront à produire un résultat, à l’exception éventuelle d’un seul. Il est donc inutile d’obliger chaque sélecteur à tenter un décodage alors que le premier fait peut-être parfaitement l’affaire. Utilisons une liste paresseuse pour ne tenter un décodage que si cela est potentiellement intéressant.

val decoders = LazyList(JSONDecoder, XMLDecoder, PBDecoder)

def process(msg: ByteArray): Option[Message] = decoders
   .flatMap(_(msg))
   .headOption

Pour aller plus loin

Quelques raffinements sont envisageables.

Plutôt que de simplement retourner un None pour indiquer que le traitement n’a pas abouti, il pourrait être intéressant de retourner une représentation de l’erreur observée. Option doit alors être remplacé par Either:

sealed trait Decoder extends Function1[ByteArray, Option[Message]] {
   def apply(msg: ByteArray): Either[Error, Message]
}

def process(msg: ByteArray): Either[List[Error], Message] = {
  val results = decoders.map(_(msg))

 // Trouver le premier succès ou collecter toutes les erreurs
  results.collectFirst { case Right(message) => message } match {
    case Some(success) => Right(success)
    case None          => Left(results.collect { case Left(error) => error })
  }
}

Le fait que l’erreur soit à gauche du Either et la véritable réponse à sa droite est une pure convention, mais c’est celle qui est le plus souvent respectée en Scala.

Chaque sélecteur est typiquement indépendant des autres. Si on s’attend à ce que le traitement soit coûteux en temps, on peut donc facilement paralléliser leur évaluation:

def process(msg: ByteArray): Future[Option[Message]] = {
  val decodeFutures = decoders.map(Future(_(msg)))
  Future.sequence(decodeFutures).map(_.flatten.headOption)
}

Enfin, en pratique, les sélecteurs ne se présenteront pas directement sous la forme d’une liste, mais seront injectés, par exemple à partir d’une factory:

val decoders: List[Decoder] = DecoderFactory.loadFromConfig(config)

Conclusion

J’appelle sélecteur un composant logiciel qui peut transformer une entrée en une sortie. Nous avons vu plusieurs approches permettant de gérer l’incertitude concernant cette capacité, et avons mis l’accent sur une approche plus fonctionnelle.

Nous avons également vu comment combiner des sélecteurs afin de trouver celui qui est effectivement capable de produire un résultat.

Enfin, nous avons brièvement présenté quelques raffinements qui peuvent s’avérer utiles selon les circonstances.

Laisser un commentaire