Rohre & Filter. Beispielanwendung und Implementierung mit Spring

In diesem Artikel wird die Verwendung des Pipes & Filters-Musters erläutert.


Zunächst analysieren wir ein Beispiel einer Funktion, die wir später anhand des oben genannten Musters umschreiben werden. Änderungen im Code werden schrittweise vorgenommen und jedes Mal werden wir eine funktionsfähige Version erstellen, bis wir uns mit der Lösung unter Verwendung von DI befassen (in diesem Spring-Beispiel).


Aus diesem Grund werden wir verschiedene Lösungen entwickeln, die die Möglichkeit bieten, beliebige Lösungen zu verwenden.
Am Ende vergleichen wir die Erst- und Endimplementierungen, betrachten Anwendungsbeispiele in realen Projekten und fassen zusammen.


Herausforderung


Angenommen, wir haben ein paar Kleidungsstücke, die wir durch Trocknen bekommen und die wir jetzt in den Schrank bringen müssen. Es stellt sich heraus, dass die Daten (Kleidung) von einem separaten Dienst stammen und die Aufgabe darin besteht, dem Kunden diese Daten in der richtigen Form (in einem Schrank, von dem er Kleidung erhalten kann) zur Verfügung zu stellen.


In den meisten Fällen können Sie die erhaltenen Daten nicht in der Form verwenden, in der sie bei uns eingehen. Diese Daten müssen überprüft, transformiert, sortiert usw. werden.
Angenommen, ein Kunde verlangt, dass Kleidung gebügelt wird, wenn sie neu ist.


Dann erstellen wir zum ersten Mal einen Modifier , in dem wir die Änderungen vorschreiben:


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

In dieser Phase ist alles einfach und klar. Lassen Sie uns einen Test schreiben, der prüft, ob alle zerknitterten Kleidungsstücke gebügelt wurden.


Mit der Zeit treten jedoch neue Anforderungen auf und die Funktionalität der Modifier Klasse wird jedes Mal erweitert:


  • Stellen Sie keine schmutzige Wäsche in den Schrank.
  • Hemden, Jacken und Hosen sollten an den Schultern hängen.
  • Undichte Socken müssen zuerst vernäht werden
  • ...

Die Reihenfolge der Änderungen ist ebenfalls wichtig. Zum Beispiel können Sie nicht zuerst Kleidung auf die Schultern hängen und dann bügeln.


Daher kann Modifier irgendwann die folgende Form annehmen:


 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 -> { // }); } //  } 

Dementsprechend sind die Tests komplizierter geworden, die nun zumindest jeden Schritt einzeln prüfen müssen.


Und wenn eine neue Anforderung eingeht, sehen wir uns den Code an und entscheiden, dass der Zeitpunkt für das Refactoring gekommen ist.


Refactoring


Das erste, was auffällt, ist das häufige Zerschlagen aller Kleidungsstücke. Im ersten Schritt bewegen wir also alles in einem Zyklus und übertragen die Sauberkeitsprüfung auch auf den Beginn des Zyklus:


 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(.()){ // } } //  } 

Jetzt ist die Bearbeitungszeit für Kleidung verkürzt, aber der Code ist immer noch zu lang für eine Klasse und für den gesamten Zyklus. Versuchen wir zunächst, den Zyklus zu verkürzen.


  • Nachdem Sie die Sauberkeit überprüft haben, können Sie alle Anrufe in einer separaten modify( ) tätigen 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); //   } 

  • Sie können alle Anrufe zu einem Consumer :


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

    Stumpf: Blick
    Ich habe einen kurzen Blick verwendet. Sonar wird sagen, dass ein solcher Code nicht gemacht werden sollte, weil Javadoc teilt Peek mit, dass die Methode hauptsächlich für das Debug existiert. Aber wenn Sie es auf der Karte neu schreiben: .map (o -> {modification.accept (o); return o;}), dann sagt IDEA, dass es besser ist, peek zu verwenden


Stolpern: Verbraucher
Ein Beispiel mit Consumer (und anschließend mit Function) zeigt die Fähigkeiten der Sprache.

Jetzt ist der Kreislauf kürzer geworden, aber die Klasse selbst ist noch zu groß und enthält zu viele Informationen (Kenntnis aller Schritte).


Versuchen wir, dieses Problem mit bereits festgelegten Programmiermustern zu lösen. In diesem Fall verwenden wir Pipes & Filters .


Rohre & Filter


Die Kanal- und Filtervorlage beschreibt einen Ansatz, bei dem eingehende Daten mehrere Verarbeitungsschritte durchlaufen.


Versuchen wir, diesen Ansatz auf unseren Code anzuwenden.


Schritt 1


In der Tat ist unser Code bereits in der Nähe dieses Musters. Die erhaltenen Daten durchlaufen mehrere unabhängige Schritte. Bisher ist jede Methode ein Filter und modify selbst den Kanal, wobei zunächst alle schmutzigen Kleidungsstücke herausgefiltert werden.


Jetzt werden wir jeden Schritt in eine separate Klasse übertragen und sehen, was wir bekommen:


 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()); } } 

Daher haben wir den Code in separate Klassen eingeteilt, um die Tests für einzelne Transformationen zu vereinfachen (und die Möglichkeit zu schaffen, Schritte wiederzuverwenden). Die Reihenfolge der Aufrufe bestimmt die Reihenfolge der Schritte.


Die Klasse selbst kennt aber noch alle Einzelschritte, kontrolliert die Reihenfolge und hat somit eine riesige Liste von Abhängigkeiten. Zusätzlich zum Hinzufügen eines neuen Schritts müssen wir nicht nur eine neue Klasse schreiben, sondern sie auch zu Modfier hinzufügen.


Schritt 2


Vereinfachen Sie den Code mit Spring.
Erstellen Sie zunächst für jeden einzelnen Schritt eine Schnittstelle:


 interface Modification { void modify( ); } 

Modifier selbst wird jetzt viel kürzer sein:


 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()); } } 

@Component nun einen neuen Schritt hinzuzufügen, müssen Sie lediglich eine neue Klasse schreiben, die die Modification implementiert, und @Component darüber setzen. Der Frühling findet es und fügt es der Liste hinzu.


Modifer selbst weiß nichts über die einzelnen Schritte, wodurch eine „schwache Verbindung“ zwischen den Komponenten entsteht.


Die einzige Schwierigkeit besteht darin, die Reihenfolge festzulegen. Zu diesem @Order verfügt Spring über eine @Order Annotation, in die Sie einen int-Wert übergeben können. Die Liste ist aufsteigend sortiert.
Daher kann es vorkommen, dass Sie durch Hinzufügen eines neuen Schritts in der Mitte der Liste die Sortierwerte für vorhandene Schritte ändern müssen.


Auf Spring hätte verzichtet werden können, wenn alle bekannten Implementierungen manuell an den Modifier-Konstruktor übergeben worden wären. Dies hilft, das Sortierproblem zu lösen, erschwert jedoch das Hinzufügen neuer Schritte.

Schritt 3


Jetzt bestehen wir die Sauberkeitsprüfung in einem separaten Schritt. Dazu schreiben wir unsere Schnittstelle so, dass sie immer einen Wert zurückgibt:


 interface Modification {  modify( ); } 

Auf Sauberkeit prüfen:


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

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()); } 

In dieser Version verfügt Modifier über keine Dateninformationen. Er gibt sie einfach an jeden bekannten Schritt weiter und sammelt die Ergebnisse.


Wenn einer der Schritte null zurückgibt, wird die Verarbeitung für dieses Kleidungsstück unterbrochen.


Ein ähnliches Prinzip wird im Frühjahr für HandlerInterceptors verwendet. Vor und nach dem Controller-Aufruf werden alle entsprechenden Interceptors für diese URL aufgerufen. Gleichzeitig wird in der preHandle-Methode true oder false zurückgegeben, um anzugeben, ob die Verarbeitung und der Aufruf nachfolgender Interceptors fortgesetzt werden können


Schritt n


Der nächste Schritt ist das Hinzufügen der matches zur Modification , in der die Schritte zu einem separaten Attribut der Kleidung überprüft werden:


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

Aus diesem Grund können Sie die Logik beim modify Methoden leicht vereinfachen, indem Sie die Prüfungen für Klassen und Eigenschaften in eine separate Methode verschieben.


Ein ähnlicher Ansatz wird im Spring-Filter (Anforderungsfilter) verwendet, der Hauptunterschied besteht jedoch darin, dass jeder Filter ein Wrapper für den nächsten Filter ist und FilterChain.doFilter explizit aufruft, um die Verarbeitung fortzusetzen.


Total


Das Endergebnis unterscheidet sich sehr von der ursprünglichen Version. Wenn wir sie vergleichen, können wir die folgenden Schlussfolgerungen ziehen:


  • Die auf Pipes & Filters basierende Implementierung vereinfacht die Modifier Klasse selbst.
  • Besser verteilte Verantwortlichkeiten und „schwache“ Verbindungen zwischen Komponenten.
  • Einfacher, einzelne Schritte zu testen.
  • Einfacheres Hinzufügen und Entfernen von Schritten.
  • Etwas schwieriger ist es, eine ganze Kette von Filtern zu testen. Wir brauchen schon IntegrationTests.
  • Weitere Klassen

Letztendlich eine bequemere und flexiblere Option als das Original.


Außerdem können Sie die Datenverarbeitung einfach mit demselben parallelStream parallelisieren.


Was dieses Beispiel nicht löst


  1. Die Beschreibung des Musters besagt, dass einzelne Filter wiederverwendet werden können, indem eine andere Filterkette (Kanal) erstellt wird.
    • Dies ist zum einen mit @Qualifier einfach zu @Qualifier .
    • Andererseits @Order das Festlegen einer anderen Reihenfolge mit @Order fehl.
  2. Für komplexere Beispiele müssen Sie mehrere Ketten verwenden, verschachtelte Ketten verwenden und dennoch die vorhandene Implementierung ändern.
    • Die Aufgabe "Suchen Sie für jede Socke nach einem Paar und fügen Sie es in eine Instanz von <? Extends Clothing> ein" passt daher nicht gut in die beschriebene Implementierung Jetzt müssen Sie für jeden Zeh die gesamte Wäsche sortieren und die ursprüngliche Datenliste ändern.
    • Um dies zu lösen, können Sie eine neue Schnittstelle schreiben, die eine Liste <Kleidung> akzeptiert und zurückgibt und diese an eine neue Kette überträgt. Aber Sie müssen mit der Reihenfolge der Aufrufe der Ketten selbst vorsichtig sein, wenn die Socken nur vom Hotel genäht werden können.

Vielen Dank für Ihre Aufmerksamkeit

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


All Articles