~/home of geeks

In Ketten gelegt

· 2092 Wörter · 10 Minute(n) Lesedauer

Dieser Artikel ist Teil der Artikel-Serie "Chain of Responsibility".

patterns in nature

Eines der praktischsten und von mir in der Praxis häufiger verwendeten Entwurfsmuster ist die Chain of Responsibility (Zuständigkeitskette). Wie einfach man mit diesem Muster mehrere, unabhängige Arbeitsschritte modularisieren kann, zeige ich hier in einem Beispiel aus meiner Praxis.

In einem System sollten regelmäßig mehrere XML-Dokumente importiert werden. Als Beispiel benutze ich ein XML-Dokument, das Kundendaten enthält.

<kunde>
  <kundennr>123</kundennr>
  <vorname>Heinz</vorname>
  <nachname>Mustermann</nachname>
  <adresse>
    <strasse>Musterstrasse 23</stasse>
    <ort>Köln</ort>
    <plz>50667</plz>
  </adresse>
  <email>h.mustermann@gmail.de</email>
  <guthaben>42000</guthaben>
  <gueltigBis>01.04.2012</gueltigBis>
  <einkaeufe>
    <einkauf>
      <kaufdatum>01.02.2012</kaufdatum>
      <betrag>120</betrag>
      <verkaeufer>
        <kundennr>789</kundennr>
        <vorname>Heinz</vorname>
        <nachname>Mustermann</nachname>
        <adresse>
          <strasse>Musterstrasse 23</stasse>
          <ort>Köln</ort>
          <plz>50667</plz>
        </adresse>
        <email>h.mustermann@gmail.de</email>
      </verkaeufer>
    </einkauf>
    <einkauf>
      <kaufdatum>05.03.2012</kaufdatum>
      <betrag>2120</betrag>
    </einkauf>
  </einkaeufe>
</kunde>

Zu diesem Dokument gibt es entsprechende Jaxb-Klassen, wodurch das ganze zu einer Objekt-Verarbeitung wird.

Nun gibt es mehrere Regeln, welche vor dem Import das Benutzerobjekt (oder -dokument) prüfen und/oder ändern müssen.

Eine Regel besagt, dass pro Importiervorgang ein Kunde nur einmal importiert werden darf. Sollte ein Dokument mehrfach vorkommen, ist dies ein Indiz dafür, dass beim Export des Quellsystems etwas schiefgelaufen ist. In solchen Fällen muss der Import mit Fehlermeldung abgebrochen werden.

Eine weitere Regel verlangt, dass das “Gültig Bis”-Feld nicht in der Vergangenheit liegen darf. Dieses soll beim Import auf einen Wert einige Jahre weiter in der Zukunft gesetzt werden.

Zusätzlich dürfen Kundendaten, deren Kundennummer kleiner ist als 1000 nicht auf dem Produktivsystem importiert werden, auf dem Testsystem schon.

Ich könnte mir noch eine Reihe weiterer Regeln ausdenken, die zu berücksichtigen sind, aber die genannten Regeln sollten vorerst reichen.

Initial könnte man sagen, man schreibt entsprechende IF-Statements, um die Bedingungen zu erfüllen:

final Set<Long> importierteKundennummern = new HashSet<>();
for (File xmlFile : xmlFiles) {
  final JAXBContext kundeJaxbContext = JAXBContext.newInstance("de.gigaco.documentchain");
  final Unmarshaller kundeUnmarshaller = kundeJaxbContext.createUnmarshaller();
  Kunde kunde = kundeUnmarshaller.unmarshal(new FileReader(xmlFile));

  if (importierteKundennummern.contains(kunde.getKundennr())) {
    throw new ImportException("Kundennr " + kunde.getKundennr() + " aus Datei " + xmlFile + " wurde bereits einmal importiert.");
  }
  importierteKundennummern.add(kunde.getKundennr());

  if (kunde.getKundennr() <= 1000L && currentSystem.equals("produktion")){
    throw new ImportException("Kundennr " + kunde.getKundennr() + " aus Datei " + xmlFile + " ist kleiner als 1000 und darf daher auf diesem Produktivsystem nicht importiert werden.");
  }

  final Date now = new Date();
  if (kunde.getGueltigBis().before(now)){
    Calendar cal = Calendar.getInstance();
    cal.add(Calendar.YEAR, 3);
    kunde.setGueltigBis(cal.getTime());
  }

  importKunde(kunde);

Bei den drei einfachen Regeln ist das noch vertretbar, bei einem Dutzend komplexerer Regeln wird das ganze schnell zu Spaghetticode und damit unübersichtlich und schlecht wartbar. Hinzu kommt dann, dass regelmäßig neue Regeln hinzugefügt werden sollen (Erweiterbarkeit).

Kabelsalat - materiell gewordener Spaghetticode
Kabelsalat - materiell gewordener Spaghetticode
Computer museum in Montana" by BrianMulawka via Imgur.com

Da die einzelnen Prüfungen oder Manipulationen weitestgehend voneinander unabhängig sind, bietet sich die Chain of Responsibility an. Sie würde sich auch bei voneinander abhängigen Prüfungen anbieten, wenn man die Abhängigkeiten gut genug kapselt. Im Prinzip erschafft man sich so eine Art Workflow.

Schnittstelle zuerst #

Ich persönlich fange gerne mit einem einfachen Interface an.

/**
 * Interface für Filter, die Kunden verarbeiten.<br/>
 */
public interface IKundenFilter {

    /**
     * Filtert das XML eines Dokuments.
     * 
     * @return das gefilterte Dokument
     * @throws ImportFailedException
     * @throws DocumentFilterException
     */
    public void doFilter(final Kunde kunde) throws Exception;
}

An dieser Stelle kann man das Interface auch auf verschiedene andere Weisen aufbauen. Bei echten Filter, die nicht manipulieren, sondern lediglich entscheiden, ob ein Dokument weiter verarbeitet werden soll oder nicht, würde man, analog zu den java.io.FilenameFilter, auf eine Methode public boolean accept(Object o) zurück greifen, bei der jeder Filter ein Veto einlegen kann.

Chain #

Meine erste Implementierung des Filter-Interfaces ist stets eine Variante, die mehrere Filter hintereinander ausführt, also die Chain erzeugt. Der doFilter-Aufruf wird an die enthaltenen Filter weiter delegiert.

/**
 * Filterkette. Sammlung von mehreren Filtern, die mit der Methode {@link #doFilter(Kunde)} durchlaufen werden.<br/>
 */
public class ChainedFilter implements IKundenFilter {
    private final Collection<IKundenFilter> filters = new ArrayList<>();

    public ChainedFilter() { /* NOOP */ }

    public ChainedFilter(final Collection<IKundenFilter> filters) {
        this.filters.addAll(filters);
    }

    public ChainedFilter addAll(final Collection<IKundenFilter> filters) {
        this.filters.addAll(filters);
        return this;
    }

    public ChainedFilter add(final IKundenFilter filter) {
        this.filters.add(filter);
        return this;
    }

    @Override
    public void doFilter(final Kunde kunde) throws Exception {
        for (final IKundenFilter filter : filters) {
            filter.doFilter(kunde);
        }
    }
}

Auch hier kann man sich Variationen vorstellen. So werden in der Java-Servlet Spezifikation zwei separate Objekte für solch eine Chain benutzt: Filter und FilterChain void doFilter(ServletRequest request, ServletResponse response, FilterChain chain).

Für meine einfachen Fälle reicht es, die Chain als eine Implementierung des gleichen Interfaces zu realisieren.

Erster Filter #

Nun könnte ich den ersten Filter implementieren, der z. B. das “Gültig Bis” Datum prüft und gegebenenfalls anpasst:

/**
 * Prüft das Gültig-Bis Datum und setzt es in die Zukunft, wenn es abgelaufen ist.
 */
public class CheckGueltigBisFilter implements IKundenFilter {
  /**
   * Statische Singleton-Instanz.
   */
  public static final CheckGueltigBisFilter INSTANCE = new CheckGueltigBisFilter();

  @Override
  public final void doFilter(final Kunde kunde) {
    final Date now = new Date();
    if (kunde.getGueltigBis().before(now)){
      Calendar cal = Calendar.getInstance();
      cal.add(Calendar.YEAR, 3);
      kunde.setGueltigBis(cal.getTime());
    }
  }
}

Da diese Filterimplementierung keine Zustände verwaltet, also Zustandslos ist, habe ich eine statische Instanz erzeugt, die wieder verwendet werden kann.

Diesen kann ich anschließend mit anderen Filtern in die ChainedFilter einsetzen, um sie alle sequentiell auszuführen:

public IKundenFilter buildChain(){
  return new ChainedFilter(Arrays.asList(CheckGueltigBisFilter.INSTANCE, filter2, filter3, ...));
}

So kann man die Reihenfolge der Regeln sehr einfach ändern, neue Regeln hinzufügen und alte Regeln entfernen. Schön, nicht wahr?

Schablonen #

In meinem konkreten Fall war das zu verarbeitende Objekt sehr verschachtelt und hatte viele Attributslisten, wie ich es mit der Verschachtelung der “Einkäufe” mit weiteren “Personen”-Objekten, wie dem “Verkäufer” angedeutet habe.

Wenn man nun eine Regel hat, bei welcher der Betrag aller Einkäufe auf ein Transaktionslimit hin geprüft werden muss, muss man iterieren und schreibt Code wie den folgenden:

/**
 * Prüft das Transaktionslimit für jeden Einkauf und erzeugt eine Exception, wenn diese nicht passen.
 */
public class TransaktionslimitFilter implements IKundenFilter {
  
  /**
   * Limit pro einzelnen Einkauf.
   */
  public static final double TRANSAKTIONSLIMIT = 10000;

  /**
   * Statische Singleton-Instanz.
   */
  public static final TransaktionslimitFilter INSTANCE = new TransaktionslimitFilter();

  @Override
  public final void doFilter(final Kunde kunde) throws Exception{
    if (kunde.getEinkaeufe() != null) {
      for (final Einkauf einkauf : kunde.getEinkaeufe()) {
        filter(einkauf);
      }
    }
  }

  private void filter(final Einkauf einkauf)  throws Exception {
    if (einkauf.getBetrag() > TRANSAKTIONSLIMIT){
      throw new ImportException("Transaktion (" + einkauf.getBetrag() + ") überschreitet Limit.") getätigt.");
    }
  }
}

Das Augenmerk sollte dabei darauf gelegt werden, dass bei Attributen, die aus Collections bestehen, stets ein Overhead aus Prüfen auf null und iterieren entsteht.

Hier empfiehlt sich dann das Muster Template Method. Hierzu wird eine Basisklasse erstellt, die das Durchiterieren durch den Objektbaum übernimmt und nur noch einzelne Aufrufe weiter delegiert:

/**
 * Abstrakte Template-Klasse für Filter, die nur bestimmte Attribute im Kunden prüfen / verändern wollen.<br/>
 * Hierfür kann eine Subklasse eine der {@code process(...)}-Methoden überschreiben und Aktionen durchführen.
 * Die Struktur, d. h. das rekursive Iterieren durch das Objekt, wird von dieser Klasse erledigt.<br/>
 */
public abstract class AbstractKundenProcessingFilter implements IKundenFilter {

    public AbstractKundenProcessingFilter() {
        super();
    }

    @Override
    public final void doFilter(final Kunde kunde) throws Exception {
        process(kunde);
        filterEinkaufe(kunde.getEinkaufe());
    }

    /**
     * Überschreiben für Aktion.
     * 
     * @param kunde
     */
    public void process(final Kunde kunde) {
      // NOOP
    }

    public final void filterEinkaufe(final Collection<Einkauf> einkaufe) throws Exception {
        if (einkaufe != null) {
            for (final Einkauf einkauf : einkaeufe) {
              filterEinkauf(einkauf);
            }
        }
    }

    public final void filterEinkauf(final Einkauf einkauf) throws Exception {
        process(einkauf);
        filter(einkauf.getWaren());
    }

    /**
     * Überschreiben für Aktion.
     * 
     * @param kunde
     */
    public void process(final Einkauf einkauf) {
      // NOOP
    }

    [...]

Ich habe hier die doFilter-Methode final gemacht, damit erbende Klassen diese nicht mehr überschreiben können, was bei Schablonen sehr wichtig ist, denn die Elternklasse bestimmt das Vorgehen und ruft Methoden der Implementierenden Klasse auf (Hollywood-Prinzip). Dies gilt auch für alle filterXXX-Methoden, welche den Overhead für uns übernehmen sollen. Dafür gibt es nun process-Methoden, welche von konkreten Klassen überschrieben werden können. Eine leere Default-Implementierung ist schon vorhanden, damit nicht jede erbende Klasse alle Methoden implementieren muss.

Nun reduziert sich die Implementierung des TransaktionslimitFilter auf das folgende wesentliche:

/**
 * Prüft das Transaktionslimit für jeden Einkauf und erzeugt eine Exception, wenn diese nicht passen.
 */
public class TransaktionslimitFilter extends AbstractKundenProcessingFilter {
  
  /**
   * Limit pro einzelnen Einkauf.
   */
  public static final double TRANSAKTIONSLIMIT = 10000;

  /**
   * Statische Singleton-Instanz.
   */
  public static final TransaktionslimitFilter INSTANCE = new TransaktionslimitFilter();

  @Override
  public final void process(final Einkauf einkauf) throws Exception {
    if (einkauf.getBetrag() > TRANSAKTIONSLIMIT){
      throw new ImportException("Transaktion (" + einkauf.getBetrag() + ") überschreitet Limit.");
    }
  }
}

Zustände #

Komplizierter werden die Dinge, wenn man noch Zustände in den Filtern benötigt. Bei der Regel, dass pro Import eine Kundennummer nur einmal vorkommen darf, benötigt man beispielsweise eine Sammlung aller bereits importierten Kundennummern.

Hierfür erhält der DuplicateImportFilter bei Initialisierung einer Set, in welcher die importierten Kundennummern verwaltet werden können. Der Grund, warum diese Set von außen gegeben wird ist der, dass in meinem Fall diese Daten noch verwertet werden.

/**
 * Prüft, ob eine Kundennummer bereits importiert wurde
 * und erzeugt eine Exception, falls das der Fall ist.<br/>
 * Dieser Filter soll verhindern, dass ein Kundendatensatz unter verschiedenen Dateinamen 
 * importiert und sich dabei die Daten gegenseitig überschreiben. Auch könnte ein Exportfehler des zuliefernden Systems vorliegen.
 */
public class DuplicateImportFilter implements IKundenFilter {
    private final Set<Long> importierteKundennummern;

    public DuplicateImportFilter(final Set<Long> importierteKundennummern) {
        if (importierteKundennummern == null) {
            this.importierteKundennummern = new HashSet<Long>();
        } else {
            this.importierteKundennummern = importierteKundennummern;
        }
    }

    @Override
    public final void doFilter(final Kunde kunde) throws Exception {
      if (importierteKundennummern.contains(kunde.getKundennr())) {
        throw new ImportException("Der Kunde mit der Kundennummer \"" + kunde.getKundennr()
              + "\" wurde bereits importiert. Doppelt exportierter Datensatz?");
      }
      importierteKundennummern.add(kunde.getKundennr());
    }
}

Richtig schwierig wird es, wenn der Filter pro Aufruf eine neue Instanz benötigt. Das kann man dann per Reflection-API realisieren, oder per Factory-Muster, oder man ändert die Schnittstelle derart, dass nicht mehr Kunden-Objekte übergeben werden, sondern ein abstrahiertes Kontext-Objekt, das zum Kunden-Objekt auch Zustände weiterleiten kann.

Access Filter #

Für die Regel, dass Kundendaten, deren Kundennummer kleiner ist als 1000 nicht auf dem Produktivsystem importiert werden dürfen, auf anderen Systemen schon, habe ich einen kleinen Access-Filter geschrieben, der das ganze so generisch löst, dass man mit einfachen Umstellungen die gleiche Bedingung auch für andere Regeln anwenden kann, also z. B. doppelte Kundennummern nur auf dem Abnahme-System prüfen.

/**
 * Filterklasse, die andere Filterklassen wrappt und diese
 * nur dann aufruft, wenn eines der erlaubten (angegebenen)
 * Environments vorliegt.<br/>
 * Hierzu wird ein Filter wie folgt definiert:<br/>
 * 
 * new EnvironmentDependentDocumentTypeFilterDelegator(
 *   new String[] { &quot;test&quot;, &quot;abnahme&quot; }, 
 *   new DuplicateImportFilter(new HashSet<Long>()),
 *   myActualEnvironment)
 */
public class EnvironmentDependentFilter implements IKundenFilter {
    private static final Logger LOG = LoggerFactory.getLogger(EnvironmentDependentFilter.class);

    private final Set<String> environments = new HashSet<String>();
    private final String actualEnvironment;
    private final IKundenFilter delegate;
    private final Restrict restrictPolicy;

    /**
     * Zugriff erlauben.
     */
    public static final Restrict Allow = new Allow();

    /**
     * Zugriff ablehnen (ignorieren).
     */
    public static final Restrict Deny = new Deny();

    /**
     * Hilfsklasse zum Kapseln der Abfrage für contains oder !contains.<br/>
     * Diese Klasse verhindert IF-Abfragen bei jeder Ausführung. <br>
     * 
     * @author Serhat Cinar
     */
    private interface Restrict {
        public boolean isActive(final Collection<String> environments, final String actualEnvironment);
    }

    /**
     * Implementierung "Zugriff erlauben".
     * Zugriff wird nur erlaubt, wenn das actual Environment in der übergebenen Liste enthalten ist.
     */
    private static class Allow implements Restrict {
        @Override
        public boolean isActive(final Collection<String> environments, final String actualEnvironment) {
            return environments.contains(actualEnvironment);
        }

        @Override
        public String toString() {
            return "Allow";
        }
    }

    /**
     * Implementierung "Zugriff ignorieren".
     * Zugriff wird ignoriert, wenn das actual Environment NICHT in der übergebenen Liste enthalten ist.
     */
    private static class Deny implements Restrict {
        @Override
        public boolean isActive(final Collection<String> environments, final String actualEnvironment) {
            return !environments.contains(actualEnvironment);
        }

        @Override
        public String toString() {
            return "Deny";
        }
    }

    public EnvironmentDependentFilter(final Collection<String> environments, final IKundenFilter delegate, final String actualEnvironment,
        final Restrict restrictPolicy) {
        this.environments.addAll(environments);
        this.actualEnvironment = actualEnvironment;
        this.delegate = delegate;
        this.restrictPolicy = restrictPolicy;
    }

    public EnvironmentDependentDocumentFilter(final String[] environments, final IKundenFilter delegate, final String actualEnvironment,
        final Restrict restrictPolicy) {
        this(Arrays.asList(environments), delegate, actualEnvironment, restrictPolicy);
    }

    /**
     * Ist dieser Filter aktiv?<br/>
     * Hängt von den erlaubten Environments und dem aktuellen Environment ab.
     * 
     * @return
     */
    private boolean isActive() {
        final boolean result = restrictPolicy.isActive(environments, actualEnvironment);
        if (LOG.isDebugEnabled()) {
            LOG.debug("isActive(policy=" + restrictPolicy.toString() + ", envs=" + environments + ", act env=" + actualEnvironment + ") = " + result);
        }
        return result;
    }

    @Override
    public final void doFilter(final Kunde kunde) throws Exception {
        if (isActive()) {
            delegate.doFilter(kunde);
        }
    }
}

Was insgesamt wegen der anonymen Klassen etwas kompliziert aussieht (ich habe mir noch nicht die Mühe gemacht, diese auszugliedern), erleichtert das Festlegen von Regeln für verschiedene Systeme. So wird im Folgenden der Filter MyFilter nur auf dem Test-System ausgeführt:

new EnvironmentDependentFilter(
  Arrays.asList("test"), 
  MyFilter.INSTANCE, actualEnvironment, EnvironmentDependentFilter.Allow);

Der folgende Filter MyFilter hingegen wird nur auf dem Abnahmesystem NICHT ausgeführt:

new EnvironmentDependentFilter(
  Arrays.asList("abnahme"), 
  MyFilter.INSTANCE, actualEnvironment, EnvironmentDependentFilter.Deny);