Tuyaux et filtres. Exemple d'application et d'implémentation à l'aide de Spring

Cet article explique l'utilisation du modèle Pipes & Filters.


Tout d'abord, nous analyserons un exemple de fonction, que nous réécrirons plus tard en utilisant le modèle mentionné ci-dessus. Les changements dans le code se produiront progressivement et chaque fois que nous créerons une version utilisable jusqu'à ce que nous nous attardions sur la solution en utilisant DI (dans cet exemple Spring).


Ainsi, nous créerons plusieurs solutions, offrant la possibilité d'en utiliser une.
Au final, nous comparons les implémentations initiales et finales, examinons des exemples d'application dans des projets réels et résumons.


Défi


Supposons que nous ayons un tas de vêtements que nous obtenons du séchage et que nous devons maintenant déplacer dans le placard. Il s'avère que les données (vêtements) proviennent d'un service distinct et la tâche consiste à fournir ces données au client sous la bonne forme (dans un placard à partir duquel il peut obtenir des vêtements).


Dans la plupart des cas, vous ne pouvez pas utiliser les données reçues sous la forme dans laquelle elles nous parviennent. Ces données doivent être vérifiées, transformées, triées, etc.
Supposons qu'un client exige que les vêtements soient repassés s'ils sont neufs.


Ensuite, pour la première fois, nous créons un Modifier , dans lequel nous prescrivons les changements:


  public class Modifier { public List<> modify(List<> ){ (); return ; } private void (List<> ) { .stream() .filter(::) .forEach(o -> { // }); } } 

À ce stade, tout est simple et clair. Écrivons un test qui vérifie que tous les vêtements froissés ont été repassés.


Mais au fil du temps, de nouvelles exigences apparaissent et chaque fois que la fonctionnalité de la classe Modifier développe:


  • Ne mettez pas de linge sale dans le placard.
  • Les chemises, vestes et pantalons doivent être accrochés aux épaules.
  • Les chaussettes qui fuient doivent être cousues en premier
  • ...

La séquence des changements est également importante. Par exemple, vous ne pouvez pas d'abord suspendre des vêtements sur leurs épaules, puis repasser.


Ainsi, à un moment donné, Modifier peut prendre la forme suivante:


 public class Modifier { private static final Predicate<> ___ = ((Predicate<>).class::isInstance) .or(.class::isInstance) .or(.class::isInstance) ; public List<> modify(List<> ){ (); (); (); (); //   return ; } private void (List<> ) { .stream() .filter(.class::isInstance) .map(.class::cast) .filter(::) .forEach(o -> { // }); } private void (List<> ) { .stream() .filter(___) .forEach(o -> { //   }); } private void (List<> ) { .removeIf(::); } private void (List<> ) { .stream() .filter(::) .forEach(o -> { // }); } //  } 

En conséquence, les tests sont devenus plus compliqués, qui doivent maintenant au moins vérifier chaque étape individuellement.


Et lorsqu'une nouvelle exigence arrive, en regardant le code, nous décidons que le moment est venu pour la refactorisation.


Refactoring


La première chose qui attire votre attention est la casse fréquente de tous les vêtements. Donc, la première étape, nous déplaçons tout en un cycle, et transférons également le contrôle de propreté au début du cycle:


 public class Modifier { private static final Predicate<> ___ = ((Predicate<>).class::isInstance) .or(.class::isInstance) .or(.class::isInstance) ; public List<> modify(List<> ){ List<> result = new ArrayList<>(); for(var o : ){ if(o.()){ continue; } result.add(o); (o); (o); (o); //   } return result; } private void ( ) { if( instanceof ){ // ()  } } private void ( ) { if(___.test()){ //   } } private void ( ) { if(.()){ // } } //  } 

Maintenant, le temps de traitement des vêtements est réduit, mais le code est encore trop long pour une classe et pour le corps du cycle. Essayons d'abord de raccourcir le corps du cycle.


  • Après avoir vérifié la propreté, vous pouvez effectuer tous les appels dans une méthode de modify( ) distincte modify( ) :


     public List<> modify(List<> ){ List<> result = new ArrayList<>(); for(var o : ){ if(o.()){ continue; } result.add(o); modify(o); } return result; } private void modify( o) { (o); (o); (o); //   } 

  • Vous pouvez combiner tous les appels en un seul Consumer :


     private Consumer<> modification = ((Consumer<>) this::) .andThen(this::) .andThen(this::); //   public List<> modify(List<> ){ return .stream() .filter(o -> !o.()) .peek(modification) .collect(Collectors.toList()); } 

    Blunt: coup d'oeil
    J'ai utilisé peek pour faire court. Sonar dira qu'un tel code ne devrait pas être fait, car Javadoc indique à Peek que la méthode existe principalement pour le débogage. Mais si vous le réécrivez sur la carte: .map (o -> {modification.accept (o); return o;}), alors IDEA dira qu'il vaut mieux utiliser peek


Trébuchement: Consommateur
Un exemple avec Consumer (et suivant avec Function) est donné pour montrer les capacités du langage.

Maintenant, le corps du cycle est devenu plus court, mais jusqu'à présent, la classe elle-même est encore trop grande et contient trop d'informations (connaissance de toutes les étapes).


Essayons de résoudre ce problème en utilisant des modèles de programmation déjà établis. Dans ce cas, nous utiliserons des Pipes & Filters .


Tuyaux et filtres


Le modèle de canal et de filtre décrit une approche dans laquelle les données entrantes passent par plusieurs étapes de traitement.


Essayons d'appliquer cette approche à notre code.


Étape 1


En fait, notre code est déjà proche de ce modèle. Les données obtenues passent par plusieurs étapes indépendantes. Jusqu'à présent, chaque méthode est un filtre, et la modify elle-même décrit le canal, filtrant d'abord tous les vêtements sales.


Maintenant, nous allons transférer chaque étape dans une classe distincte et voir ce que nous obtenons:


 public class Modifier { private final  ; private final  ; private final  ; //  public Modifier( ,  ,   //  ) { this. = ; this. = ; this. = ; //  } public List<> modify(List<> ) { return .stream() .filter(o -> !o.()) .peek(o -> { .(o); .(o); .(o); //  }) .collect(Collectors.toList()); } } 

Ainsi, nous avons placé le code dans des classes distinctes, simplifiant les tests de transformations individuelles (et créant la possibilité de réutiliser les étapes). L'ordre des appels détermine la séquence des étapes.


Mais la classe elle-même connaît toujours toutes les étapes individuelles, contrôle l'ordre et dispose ainsi d'une énorme liste de dépendances. En plus d'ajouter une nouvelle étape, nous serons obligés non seulement d'écrire une nouvelle classe, mais aussi de l'ajouter à Modfier .


Étape 2


Simplifiez le code à l'aide de Spring.
Créez d'abord une interface pour chaque étape:


 interface Modification { void modify( ); } 

Modifier lui-même sera désormais beaucoup plus court:


 public class Modifier { private final List<Modification> steps; @Autowired public Modifier(List<Modification> steps) { this.steps = steps; } public List<> modify(List<> ) { return .stream() .filter(o -> !o.()) .peek(o -> { steps.forEach(m -> m.modify(o)); }) .collect(Collectors.toList()); } } 

Maintenant, pour ajouter une nouvelle étape, il vous suffit d'écrire une nouvelle classe qui implémente l'interface de Modification et de mettre @Component au-dessus. Spring le trouvera et l'ajoutera à la liste.


Modifer lui Modifer même Modifer sait rien des étapes individuelles, ce qui crée une «connexion faible» entre les composants.


La seule difficulté est de régler la séquence. Pour ce faire, Spring a une annotation @Order dans laquelle vous pouvez passer une valeur int. La liste est triée par ordre croissant.
Ainsi, il peut arriver qu'en ajoutant une nouvelle étape au milieu de la liste, vous deviez modifier les valeurs de tri des étapes existantes.


Spring aurait pu être supprimé si toutes les implémentations connues avaient été transmises manuellement au constructeur Modifier. Cela aidera à résoudre le problème de tri, mais compliquera encore une fois l'ajout de nouvelles étapes.

Étape 3


Nous passons maintenant le test de propreté dans une étape distincte. Pour ce faire, nous réécrivons notre interface afin qu'elle renvoie toujours une valeur:


 interface Modification {  modify( ); } 

Vérifiez la propreté:


 @Component @Order(Ordered.HIGHEST_PRECEDENCE) class CleanFilter implements Modification {  modify( ) { if(.()){ return null; } return ; } } 

Modifier.modify - Modifier.modify :


  public List<> modify(List<> ) { return .stream() .map(o -> { var modified = o; for(var step : steps){ modified = step.modify(o); if(modified == null){ return null; } } return modified; }) .filter(Objects::nonNull) .collect(Collectors.toList()); } 

Dans cette version, Modifier ne dispose d'aucune information sur les données. Il les transmet simplement à chaque étape connue et recueille les résultats.


Si l'une des étapes renvoie null, le traitement de ce vêtement est interrompu.


Un principe similaire est utilisé dans Spring pour les HandlerInterceptors. Avant et après l'appel du contrôleur, tous les intercepteurs appropriés pour cette URL sont appelés. Dans le même temps, il renvoie vrai ou faux dans la méthode preHandle pour indiquer si le traitement et l'appel des intercepteurs suivants peuvent continuer


Étape n


L'étape suivante consiste à ajouter la méthode des matches à l'interface de Modification , dans laquelle les étapes d'un attribut distinct des vêtements sont vérifiées:


 interface Modification {  modify( ); default matches( ) {return true;} } 

Pour cette raison, vous pouvez légèrement simplifier la logique des méthodes de modify en déplaçant les vérifications des classes et des propriétés dans une méthode distincte.


Une approche similaire est utilisée dans le filtre Spring (Request), mais la principale différence est que chaque filtre est un wrapper autour du suivant et appelle explicitement FilterChain.doFilter pour continuer le traitement.


Total


Le résultat final est très différent de la version initiale. En les comparant, nous pouvons tirer les conclusions suivantes:


  • L'implémentation basée sur Pipes & Filters simplifie la classe Modifier elle-même.
  • Des responsabilités mieux réparties et des connexions «faibles» entre les composants.
  • Plus facile de tester des étapes individuelles.
  • Ajoutez et supprimez des étapes plus facilement.
  • Un peu plus difficile de tester toute une chaîne de filtres. Nous avons déjà besoin de IntegrationTests.
  • Plus de cours

En fin de compte, une option plus pratique et flexible que l'original.


De plus, vous pouvez simplement paralléliser le traitement des données en utilisant le même parallelStream.


Ce que cet exemple ne résout pas


  1. La description du modèle indique que les filtres individuels peuvent être réutilisés en créant une autre chaîne de filtres (canal).
    • D'une part, cela est facile à faire en utilisant @Qualifier .
    • En revanche, définir un ordre différent avec @Order échouera.
  2. Pour des exemples plus complexes, vous devrez utiliser plusieurs chaînes, utiliser des chaînes imbriquées et toujours modifier l'implémentation existante.
    • Ainsi, par exemple, la tâche: "pour chaque chaussette, recherchez une paire et mettez-les dans une instance de <? Extends Clothing>" ne rentrera pas bien dans l'implémentation décrite, car Maintenant, pour chaque orteil, vous devez trier tout le linge et modifier la liste de données initiale.
    • Pour résoudre ce problème, vous pouvez écrire une nouvelle interface qui accepte et renvoie une liste <Clothing> et transfère vers une nouvelle chaîne. Mais vous devez faire attention à la séquence des appels des chaînes elles-mêmes, si les chaussettes ne peuvent être cousues que par l'hôtel.

Merci de votre attention.

Source: https://habr.com/ru/post/fr479464/


All Articles