Transformations en séquence avec les design patterns Chaîne de Responsabilité et Pipeline

Introduction

La chaîne de responsabilité et le Pipeline sont deux design patterns issus de la programmation orientée objet qui sont similaires en apparence, mais qui servent des finalités différentes. Abordons-les au travers d’un exemple simplifié, basé sur l’authentification d’un utilisateur.

Chaîne de responsabilité

Dans de nombreuses circonstances, un objet métier est amené à être soumis à différents « filtres ». Lorsqu’il est exposé à un objet, chaque filtre a le choix entre réagir en produisant une réponse, ou bien ne pas réagir et « laisser filer ». Par exemple:

  • Lorsque l’utilisateur entre un mot de passe, votre application doit vérifier si celui-ci respecte un ensemble de règles: longueur minimale, au moins un chiffre, au moins une majuscule, etc.
  • Vous gérez un ensemble de services, et bien qu’ils soient globalement indépendants, certains d’entre eux doivent être démarrés avant d’autres. On souhaite établir une relation d’ordre partiel définie par un ensemble de petites règles de type « le service A dépend du service B ».
  • Votre serveur doit supporter plusieurs backends d’authentification. Lorsqu’un utilisateur soumet un login et un mot de passe, une base interne doit être interrogée. Si elle ne permet pas l’authentification, un service LDAP est consulté. En cas de réponse négative, on utilise un PAM pour trancher.

Dans chacun des exemples, les filtres ont plusieurs points communs. Tout d’abord, ils sont relativement « petits »: leur rôle est fortement cadré, il se limite à une fonction.

Ils sont également complémentaires, et on imagine bien que d’autres du même type pourraient venir s’ajouter au cours du temps. Il faut donc faciliter cette extension.

Enfin, on veut pouvoir les combiner, les réordonner, les paramétrer selon des règles métiers et les circonstances. Il faut pouvoir supporter ce besoin de flexibilité.

Chaque filtre se défini comme une fonction acceptant une entrée A, et pouvant fournir une sortie B.

type Filter[A,B] = A => Option[B]

Pour reprendre l’exemple des différents mécanismes d’authentification, ou pourrait imaginer que le filtre génère un User représentant un utilisateur authentifié.

// Définition des concepts métier
case class Login(user: String, password: String)
case class User(name: String)
// Raffinage du type de filtre
type Authenticator = Filter[Login, User]
// Définition de trois filtres
def DBAuthenticator(conn: Connection): Authenticator = login =>
if(db.exists(user=login.user, password=login.password)) Some(User(login.user))
else None
def LDAPAuthenticator(ldap: LDAPBackend): Authenticator = ???
val PAMAuthenticator: Authenticator = ???

La chaîne de responsabilité, formalisée par le Gang of Four (les informaticiens, par le groupe post-punk britannique), est elle-même un filtre du même type que celui des filtres qui la constituent: il s’agit d’une composition. Elle soumet une entrée à chacun de ses composants, successivement, jusqu’à ce que l’un deux fournisse une réponse positive.

La chaîne peut être créée dynamiquement, ce qui offre une grande souplesse d’utilisation. Vous pouvez modifier l’ordre de vos filtres selon vos règles métier, et également activer ou non certains d’entre eux selon les circonstances.

val authenticators = List(
DBAuthenticator(myConn),
LDAPAuthenticator(myLDAP),
PAMAuthenticator
)
def authenticate(authenticators: List[Authenticator]): Authenticator = login =>
authenticators
.view
.flatMap(auth => auth(login))
.headOption
val chain: Authenticator = authenticate(authenticators)(_)

Notez que nous avons curryfié la fonction authenticate afin de la spécialiser pour nos propres besoins. Il ne nous reste plus qu’à exploiter cette chaîne de responsabilité:

val response: Option[User] = chain(Login("bob", "password"))

En résumé

La chaîne de responsabilité ne casse pas trois pattes à un canard. Elle repose sur les idées suivantes.

  • On définit un ensemble d’éléments, chacun pouvant générer ou non une réponse lorsqu’on leur présente une entrée.
  • On organise les éléments en chaîne: si le premier ne se prononce pas, on passe au suivant, etc.
  • Les éléments, mais aussi la chaîne, peuvent être créés dynamiquement.

Pipeline

Dans une chaîne de responsabilité, chaque élément a le choix entre « passer » et retourner une valeur décisive (un utilisateur dans par exemple). Dans un pipeline, c’est un peu différent. Les éléments sont toujours assemblés en une chaîne, mais chacun d’entre eux est responsable de la transformation systématique de son entrée en une sorte de même type. Le résultat de la première transformation est soumise en entrée de la seconde transformation, et ainsi de suite jusqu’à ce que toutes les transformations de la chaîne aient été réalisées séquentiellement.

Reprenons l’exemple de notre mot de passe.

Plutôt que de contenir directement le mot de passe, plusieurs indirections peuvent être utilisées afin d’exposer moins directement nos secrets.

  • Dans certaines applications, on préfixer la valeur d’une propriété de « ENV: », ce qui signifie que ce qui se trouve après se préfixe est en réalité le nom de la variable d’environnement contenant la véritable valeur de la propriété.
  • De manière similaire, une valeur commençant par « vault:/ » peut faire référence à un secret stocké dans Hashicorp Vault.
  • Une règle métier précise que tout mot de passe doit être suffixé par « -pwd », pour indiquer clairement sa nature. On souhaite ajouter ce suffixe s’il n’est pas déjà présent.

Ces différentes caractéristiques peuvent être combinées. Par exemple, une propriété dans un fichier de configuration peut

  1. faire référence à une variable d’environnement
  2. qui elle-même fait référence à un secret Vault
  3. si le résultat ne se termine pas par le suffixe « -pwd », celui-ci est ajouté.

Il est donc nécessaire de prendre en charge ces différentes transformations.

Prise en charge individuelle

Commençons par prendre en charge chacun des encodages, individuellement. On crée autant de fonctions que d’encodages supportés. Chaque fonction effectue le même type de tâche:

  1. Elle reçoit en entrée une chaîne de caractères.
  2. Elle détermine si elle reconnaît l’encodage dont elle a la charge.
  3. Elle produit en sortie soit la valeur décodée, si elle supportait effectivement l’encodage, soit la valeur telle qu’elle était fournie en entrée.

En Java, on écrirait ces fonctions comme ceci:

interface Resolver {
String resolve(String input);
}
class EnvResolver implements Resolver {
@Overrides
public String resolve(String input) {
if(input.startsWith("ENV:")) {
return System.getenv(input.substring(4))
} else {
return input;
}
}
}

Dans les langages fonctionnels, où le typage est plus expressif, on peut à utiliser la monade Either. (Either, membre de la trinité monadique, a déjà été utilisé pour réaliser des combinateurs de parseurs) Le fait que la valeur ait été effectivement transformée ou non par la fonction est explicite: un objet de type Right est retourné dans le premier cas, et un objet de type Left est retourné dans le second.

type Resolver[T] = T => Either[T,T]
def resolveEnv: Resolver[String] = input: String => {
if(input.startsWith("ENV:")) Right(System.getenv(input.substring(4)))
else Left(input)
}

Combinaison d’éléments de la pipeline

Il reste à utiliser consécutivement chacun des filtres.

def pipeline[T](resolvers: List[Resolver[T]]): Resolver[T] = input =>
resolvers.foldLeft[Either[T, T]](Left(input)) {
case (Left(current), resolver) => resolver(current)
case (resolved @ Right(_), _) => resolved
}

Si chaque Resolver ne fait que transmettre son entrée telle quelle lorsqu’elle n’est pas reconnue, on peut se contenter d’un Option qui retourne un None dans ce cas.

type Resolver[T] = T => Option[T]
def resolveEnv: Resolver[String] = input: String => {
if(input.startsWith("ENV:")) Some(System.getenv(input.substring(4)))
else None
}
def pipeline[T](resolvers: List[Resolver[T]]): Resolver[T] = input =>
resolvers.iterator.flatMap(_(input)).nextOption()

Point fixe

Une limite possible de la pipeline est que chaque resolver n’est invoqué qu’une seule fois. De plus, les résolveurs ne sont invoqués que dans l’ordre défini. Cela peut s’avérer problématique. Par exemple:

  • Le résultat d’un resolveEnv peut commencer par « ENV: », ce qui indiquerait que ce résultat fait lui-même référence à une variable d’environnement. Il faudrait donc appliquer resolveEnv une seconde fois, mais cela n’est plus possible.
  • Une valeur stockée dans Vault pourrait commencer par « ENV: », ce qui signifierait qu’il faudrait substituer cette valeur par le contenu d’une variable d’environnement en invoquant resolveEnv. Mais cela n’est plus possible, car ce Resolver est placé avant celui dédié à Vault dans la pipeline.

Pour ce genre de cas, il faut être capable d’exécuter la pipeline éventuellement à plusieurs reprises. Le résultat d’une exécution de la pipeline sera retraité par la pipeline jusqu’à ce que le résultat soit identique à l’entrée, ce qui signifie que la pipeline n’a effectué aucune transformation et qu’on peut donc s’arrêter. Cette situation terminale est appelée un point fixe.

def fixedPoint[T](pipeline: Resolver[T])(input: T): Option[T] =
@tailrec
def loop(current: T): Option[T] =
pipeline(current) match {
case None => None // jamais transformé depuis l'entrée
case Some(v) if v == current => Some(v) // stabilisé après au moins une transformation
case Some(v) => loop(v)
}
loop(input)

Notez que la méthode fixedPoint, appliquée à une pipeline quelconque, est elle-même un Resolver. On peut donc écrire, par exemple:

val myPipeline: Resolver[String] = pipeline(List(
resolveEnv,
fixedPoint(pipeline(List(resolveAlias, resolveDefault))),
resolveSecret
))

Découpage d’une pipeline en étapes

Dans certains cas, un phasage de la pipeline est nécessaire. Par exemple:

  • La substitution d’une variable d’environnement par sa valeur ne peut se faire qu’une seule fois, et seulement au début du traitement du mot de passe.
  • L’ajout d’un suffixe « -pwd » ne peut se faire qu’une seule fois, et seulement au terme du traitement du mot de passe.

Il faut alors raffiner la pipeline en différentes étapes, chaque étape pouvant ou non être exécutée plusieurs fois. L’ordre entre deux étapes est garanti, pour autant que les deux étapes en question ne sont pas comprises dans une boucle. On pourrait par exemple avoir:

def staged[T](resolvers: List[Resolver[T]]): Resolver[T] = input => {
val result = resolvers.foldLeft(input) { (current, resolver) =>
resolver(current).getOrElse(current) // None = pas de transformation, on passe tel quel
}
Option.when(result != input)(result) // None si rien n'a changé en fin de compte
}
val stagedPipeline: Resolver[String] = staged(List(
envResolver,
fixedPoint(vaultResolver),
suffixAppender
))

En résumé

La pipeline permet de réaliser plusieurs transformations sur une entrée. Afin de s’affranchir des limitées dues à l’ordre dans lequel les transformations sont exprimées, il est possible de décrire une transformation qui itère sur une pipeline jusqu’à l’atteinte d’un point fixe. Enfin, lorsque l’ordre entre certaines transformations est réellement importante, ou lorsque seules certaines transformations doivent être réalisées à plusieurs reprises, la pipeline peut être découpées en phases, chaque phase disposant de ses propres règles d’ordonnancement et de répétition.

La généricité que nous nous sommes efforcés d’adopter à ici plusieurs avantages. Premièrement, elle permet de considérer les pipelines complexes (avec répétition, avec découpage) comme des pipelines ordinaires, leur complexité étant cachée derrière la signature des types employés. Ensuite, cela permet une composition des pipelines: une pipeline relativement complexe peut devenir un simple élément d’une pipeline encore plus complexe.

Tout comme pour la chaîne de responsabilité, la pipeline permet de définir les éléments de transformation de manière dynamique.

Conclusion

La chaîne de responsabilité et la pipeline répondent à un besoin commun : organiser des traitements indépendants en une structure cohérente, extensible, et reconfigurable dynamiquement. Là où la chaîne de responsabilité cherche qui peut répondre à une entrée, la pipeline cherche comment la transformer progressivement.

Ce qui rend ces deux patterns particulièrement puissants dans un langage à typage expressif, c’est l’uniformité qu’on peut leur imposer. En définissant Resolver[T] comme un type unique, on s’assure que chaque élément — qu’il s’agisse d’un resolver atomique, d’une pipeline composée, d’une boucle jusqu’au point fixe, ou d’une pipeline découpée en phases — respecte le même contrat. Cette uniformité n’est pas une contrainte : c’est précisément ce qui autorise la composition.

Au travers de l’usage de ces deux design patterns, ressort un principe général : bien choisir ses types, c’est déjà concevoir son architecture.

Laisser un commentaire