Pipe And Filter

Über „Pipe-and-Filter“ wollte ich eigentlich schon ne ganze Zeit lang was schreiben. Bei meinem Vortrag auf den Delphi-Tagen hab ich das Pattern auch wieder erwähnt und so will ich jetzt mal ein bisschen was darüber erzählen.

Manche Programme scheinen bestimmte Herangehensweisen, ja sogar Programmierparadigmen zu bevorzugen. Bei meinem Vortrag habe ich eine Software zum Verwalten von Büchern (erfassen, ausleihen, zurückgeben, etc.) erwähnt. Das Problem scheint geradezu nach Objektorientierung zu verlangen, weil Bücher eben ziemlich eindeutig auch wirklich Objekte sind. Und Leser. Und Ausleihvorgänge.

Daneben gibt es aber auch Programme, die zuerst einmal gar nicht nach Objektorientierung aussehen. Einfach weil die Problemstellung mehr prozedural oder funktional erscheint. Ein Beispiel wäre ein Programm, das eine Datentransformation vornimmt.

Ich habe z.B. mal ein Programm geschrieben, das Daten von einem Oszilloskop aus einer Textdatei ausliest und über mehrere Schritte auswertet. Zuerst müssen fallende und steigende Flanken des Signals ermittelt werden, dann bestimmte Zeitintervalle bestimmt und daraus schließlich ein Endwert berechnet werden.

Die Problemstellung scheint typisch funktional oder prozedural zu sein. Trotzdem kann man das auch sehr schön objektorientiert lösen. Eben mit dem Pipe-and-Filter-Pattern.

Das Prinzip

Pipe-and-Filter-Pattern
Pipe-and-Filter-Pattern

Das generelle Prinzip des Pipe-and-Filter-Patterns ist recht einfach: Es gibt prinzipiell zwei Komponenten: Pipes und Filter. Diese werden genutzt um einen Datenstrom zu verarbeiten. Ein Datenstrom ist dabei eine zeitliche Abfolge von Daten. Dabei wird ein Datenstrom immer als konzeptionell unendlich lang betrachtet. Die Daten haben also eine zeitliche Reihenfolge, jedoch keine absolute Position, wie dies in einem Array der Fall wäre.

Ein Filter kann nun diesen Datenstrom verändern. Er kann einzelne Daten herausfiltern, neue Informationen hinzufügen, die Daten transformieren und sogar neue Daten erzeugen. Datenquellen und Datensenken sind dabei spezielle Filter. Eine Datenquelle hat keinen Eingangs-Datenstrom, sondern erzeugt den Datenstrom erst. Eine Datensenke hingegen hat keinen Ausgabedatenstrom, sondern bildet das Ende der Verarbeitung. Eine Datenquelle kann beispielsweise eine Datei sein und eine Datensenke die Ausgabe auf dem Bildschirm. Oder auch eine Datei. Oder ein Netzwerksocket.

Die Pipes sind die Komponenten, die die einzelnen Filter verbinden und somit die Kommunikation ermöglichen. Man kann sich das wie bei einem Fließband vorstellen. Die Pipes entsprechen dem Fließband. Sie transportieren die Daten. Einzelne Maschinen/Roboter/Arbeiter bzw. Filter erledigen dann je einen Arbeitsschritt.

Was dieses Muster so flexibel macht, ist nun die Tatsache, dass man Filter beliebig kombinieren kann. Man kann leicht neue Filter einfügen, alte ersetzen, ganz herausnehmen und ganz neue Ketten aus Pipes und Filtern (so genannte Pipelines) zusammenbauen.

Pipes in Unix

Das Prinzip von Pipe-and-Filter ist unter Unix schon seit Ewigkeiten bekannt und macht die Unix-Shells erst zu dem mächtigen Werkzeug, das sie darstellen. Einzelne kleine Unix-Programme, die für sich selbst genommen nur wenig können, lassen sich über so genannte Pipes so kombinieren, dass sie komplexe Aufgaben erledigen. Ein einfaches Beispiel:

1
cat | sort| uniq | nl

cat liest Strings von der Standardeingabe
sort sortiert dann das, was der User eingegeben hat alphabetisch
uniq entfernt Duplikate
und nl fügt Zeilennummern hinzu

Die einzelnen Programme sind über Pipes verbunden. Dabei steht „Pipe“ zum einen für das Zeichen „|“ und zum anderen für den Kommunikationsmechanismus, der benutzt wird. Eine Pipe ist gewissermaßen eine FIFO-Datenstruktur in die der eine Prozess Daten (ein Strom von Zeichenketten) einfüttert und der nächste diese Daten wieder ausliest.

Dabei kann das Ganze parallel passieren. Jeder Prozess arbeitet für sich getrennt und kann schon arbeiten, wenn der vorangegangene Prozess nur einen Teil der Daten produziert hat. Im obigen Beispiel kann nl schon Nummern hinzufügen, während uniq noch nach weiteren Duplikaten sucht.

Das ist das Merkmal von „echten“ Pipes. Sie erlauben parallele Verarbeitung. DOS hingegen hatte beispielsweise nur „Pseudo-Pipes“. Da DOS kein Multitasking kannte, mussten bei solchen Konstruktionen temporäre Dateien erzeugt werden. Das ist bei „echten“ Pipes nicht nötig.

Streams

Das Prinzip ist aber nicht auf die Unix-Shell begrenzt, sondern findet sich auch in ganz anderen zusammenhängen. Ein Beispiel sind die in vielen Programmiersprachen bzw. Frameworks vorhandenen Stream-Klassen. Der Bezeichner „Stream“ zeigt schon, dass es sich um Datenströme handelt, die verarbeitet werden.

Ein Stream-Objekt ist dabei meist Pipe und Filter in einem. Es liest Daten aus einem anderen Stream (außer es ist Datenquelle), macht ggf. eine Verarbeitung und stellt dann den Ausgangsdatenstrom für die Weiterverarbeitung zur Verfügung.

Pipe-and-Filter in der Architektur

Stream-Objekte nutzen das Pipe-and-Filter-Pattern auf einer vergleichsweise niedrigen Abstraktionsebene. Meist stellen sie nur einen kleinen Teil des Programms dar. Hier handelt es sich also um eine Verwendung als Entwurfsmuster im Gegensatz zur Verwendung als Architekturmuster.

Setzt man das Pipe-and-Filter-Pattern als Architekturmuster ein, bestimmt es das gesamte (Sub-)System. Architekturmuster bestimmen nicht nur die Struktur der Software, sondern auch die Denkweise. Also wie man über das System denkt. Häufig wird beispielsweise das Layers-Muster verwendet. In diesem Fall denkt man also von dem System in Abstraktionsschichten. Jede Klasse wird i.d.R. genau einer Schicht zugeordnet, etc. Beim Pipe-and-Filter-Pattern denkt man nun vom System nicht in Schichten, sondern in Verarbeitungsschritten. Jeder Schritt entspricht einem Filter.

Die technische Realisierung kann dabei sehr unterschiedlich ausfallen. Beispielsweise können die Filter die Funkton der Pipes mit übernehmen, wie das bei den Stream-Klassen der Fall ist. Das ist jedoch weniger flexibel und widerspricht eigentlich dem Single-Responsibility-Principle. Deshalb werden Pipes und Filter meist getrennt.

Pipes können also eigene separate Klassen sein, können aber auch durch bereits vorhandene Mechanismen realisiert werden. So kann man Pipes beispielsweise durch Named-Pipes oder durch TCP-Sockets realisieren.

Bei der Realisierung der Filter hat man im Grunde genommen drei Möglichkeiten: Push-Filter, Pull-Filter und aktive Filter.

Aktive Filter besitzen im Gegensatz zu dem passiven Push- und Pull-Filtern einen eigenen Thread. Die Pipes werden dann hierbei zur Synchronisation verwendet. Das Lesen aus einer Pipe ist blockierend. Sind also noch keine Daten in der Pipe vorhanden, pausiert der lesende Filter automatisch. Werden Daten in die Pipe geschrieben, gehts wieder weiter.

Push-Filter arbeiten von der Datenquelle aus. Man schiebt quasi am Anfang Daten in die Pipeline und wartet, dass hinten etwas herausfällt. Man kann sich das vorstellen wie ein Fleischwolf. Man schiebt vorne bzw. oben Fleisch rein. Innen drin geschieht „etwas Magisches“ und am anderen Ende kommt Hackfleisch raus. Das „Reinschieben“ der Daten ist meist ein einfacher Prozedur- bzw. Methodenaufruf.

Pull-Filter arbeiten anderes herum. Hier liegt die Kontrolle bei der Datensenke, die aus der Pipeline quasi Daten heraussaugt. Das wird dann meist über einen Funktions-Methodenaufruf gemacht. Man fragt bei der Datensenke einen Wert ab und diese holt sich daraufhin so viele Werte aus der Pipeline, wie sie braucht, um den gewünschten Wert zu berechnen.

Variationen

Variationen dieses Musters gibt es viele. Hier eine (nicht notwendigerweise vollständige) Liste:

  • Pipe und Filter in einem: Wie schon erwähnt können Filter auch die Funktion der Pipes gleich mit übernehmen. Dazu halten sie intern ggf. eine Queue oder etwas Ähnliches als Puffer. Insbesondere passive Filter können aber auch oft ganz ohne Puffer auskommen.
  • Gemischte Pull-Push-Pipeline: Man kann bei einer Pipeline auch in der Mitte anfangen. Der Teil Richtung Datenquelle funktioniert dann im Pull- und der andere im Push-Verfahren.
  • Nicht-Lineare Pipelines: Filter können auch so gestaltet werden, dass sie einen Datenstrom in zwei (oder mehr) gleiche oder unterschiedliche Ströme aufteilen. Ebenso können mehrere Datenströme zu einem zusammengefasst werden. Damit lassen sich sogar Feedback-Schleifen erzeugen. Sodass das Ergebnis eines Filters der Input für einen anderen Filter weiter vorne in der Pipeline darstellt. Das kann aber sehr kompliziert werden und man muss in so einem Fall aufpassen, dass das Programm auch wirklich noch in allen Fällen terminiert.
  • Unterschiedliche Datentypen: Eine Pipeline kann in jedem Schritt gleichartige Daten verarbeiten. Beispielsweise Zeichenketten oder Integers oder Bitmaps. Es ist aber auch möglich, dass ein Filter den Typ des Datenstroms ändert. Dadurch sind unterschiedliche Pipes notwendig (was man ggf. über Generics lösen kann) und man büßt ein bisschen Flexibilität ein, jedoch hat man so mehr Typsicherheit und ggf. geringere Performanceverluste durch unnötige Konvertierungen.
  • Die Kopplung der Pipes und Filter kann über Methodenaufrufe, aber auch über Events oder ähnliche Mechanismen stattfinden.

Nachteile

Nichts hat nur Vorteile und so ist es natürlich auch mit dem Pipe-and-Filter-Pattern.

  • Das Error-Handling kann sich je nach Art der Realisierung als schwierig herausstellen.
  • Wenn man (aus welchen Gründen auch immer) globale Zustände braucht, ist das Muster eher ungeeignet, da sich solche mitunter schwer realisieren lassen. Das Muster will ja gerade darauf verzichten.
  • Wenn nicht nur die Reihenfolge, sondern auch die absolute Position der Daten von Bedeutung ist, sind Datenströme und damit das Pipe-and-Filter-Pattern eher ungeeignet.
  • In Sprachen ohne Garbage Collector muss man sich, wenn die Daten Objekte sind, Gedanken darüber machen, wo die Daten nun wieder freigegeben werden.

Weitere Informationen zu diesem Muster finden sich in POSA1:53.

Ein Beispiel in Delphi

Hier mal ein Beispiel einer Push-Pipeline mit separaten Pipes und Eventkopplung in Delphi. In dieser Form setze ich sie in einem Programm ein.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
type
  TSomeDate = class(Persistent) // die Daten für den Datenstrom
    ...
  end;

  TSomePipe = class
  private
    ...
  public
    property OnNewData: TNotifyEvent read FOnNewData write FOnNewData;
    ...
    procedure Write(AData: TSomeData); virtual;
    procedure Read(AData: TSomeData); virtual;
    procedure Clear; virtual;
  end;

  TSomeFilter = class
  private
    FIn: TSomePipe; // hier kommt der Input her; wird über OnNewData erkannt
    FOut: TSomePipe; // Ausgabe-Datenstrom
    ...
  public
    property Out: TSomePipe read FOut; // auf Out darf man lesend zugreifen, damit man aus der Pipe lesen kann
    constructor Create(AIn: TSomePipe); // der Konstruktor legt den Eingabedatenstrom fest
    procedure Reset; virtual; // das setzt den Filter (der kann ja zustandsbehaftet sein) wieder in den Ausgangszustand zurück
    ...
  end;

// und so baut man die einzelnen Filter zusammen:
dataSource := TDataSourceFilter.Create;
filter1 := TSomeFilter.Create(dataSource.Out);
filter2 := TSomeFilter.Create(filter1.Out);
dataSink := TSomeDataSink.Create(filter2.Out);

Interessant ist jetzt noch, wie die Daten wieder freigegeben werden. Möglichkeiten gibts hier viele. Ich habe mich dafür entschieden, dass die Pipe die Daten freigibt:

1
2
3
4
5
6
procedure TSomePipe.Read(AData: TSomeData);
begin
  ...
  AData.Assign(data); // Assign kopiert die Daten in das übergebene Objekt
  data.Free; // eigenes Objekt freigeben
end;

Jedes Filter-OutputPipe-Paar verwaltet also seine eigenen Daten-Objekte. Das hat kleinere Performance-Nachteile ist aber einfacher zu benutzen. Man wird nicht so schnell in Versuchung geführt, Speicherlöcher zu produzieren. Die Signatur der Read-Methode zwingt den Benutzer der Pipeline fast automatisch zur richtigen Benutzung.

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.