Besucher in alten Gemäuern

Die Java Software Entwicklung kennt zwei große Gruppen von Mechanismen, die häufig in der Implementierung verwendet werden. Dies sind die klassischen Schönheiten und die Taschenspielertricks.

Die klassischen Schönheiten sind die Design Pattern, Problemlösungen, die direkt auf den objektorientierten Prinzipien basieren. Dies sind Memento, Command, Visitor, Singleton, Facade, Bridge und viele mehr.

Auf der anderen Seite gibt es die Taschenspielertricks, deren Funktionsweise vielen Entwicklern immer noch wie Magie vorkommt. Tatsächlich ist es keine Zauberei, sondern auch hier wird, wie in einem billigen Varieté, mit Drähten, geheimen Türen und versteckten Helfern gearbeitet. Bekannte Vertreter sind Spring Dependency Injection, Hibernate, Mockito und Lombok. Ihre Tricks sind Reflections und Byte Code Manipulationen.

In diesem Beitrag geht es um das Visitor-Pattern, eines meiner liebsten Design Pattern. In den Beiträgen Zu Besuch bei den Enums und Noch mehr Besucher habe ich die Anwendung des Visitor-Pattern auf Enum Typen beleuchtet. In diesem Beitrag wenden wir uns der Legacy Software zu.

Das Prinzip hinter dem Pattern ist recht einfach. Eine spezielle Querschnittsfunktion ist für diverse Klassen in einer Visitor Implementierung zusammengefasst. Der jeweils für eine spezielle Klasse benötigte Code wird ausgeführt, indem die Instanz die passende Visitor Methode aufruft.

@Getter
class AncestorReportVisitor {
  private int maleCount;
  private int femaleCount;

  void visit(Person person) {
   maleCount += person.isMale() ? 1 : 0
   femaleCount += person.isFemale() ? 1 : 0
   person.getFamily().ifPresent(family -> family.accept(this));
  }

  void visit(Family family) {
    family.children().forEach(person -> person.accept(this));
  }
}

Der obige Visitor bestimmt die Anzahl von Männern und Frauen in einem Stammbaum. Je nach Geschlecht einer Person wird ein Zähler erhöht. Der GEDCOM Standard der Kirche Jesu Christi der Heiligen der Letzten Tage sieht bislang nur ein binäres Geschlecht vor, daher existieren hier auch nur zwei Zähler. Besitzt eine Person eine eigene Familie (ist also Vater oder Mutter), dann wird die Methode Family#accept aufgerufen. Für eine Familie wird für alle Kinder dieser Familie Person#accept aufgerufen.

Die Verbindung zwischen dem Visitor und den besuchten Klassen wird über die accept Methode realisiert. Die Methode erhält den Visitor als Parameter und die besuchte Klasse ruft dann die passende Methode auf dem Visitor auf.

class Person {
  ...
  void accept(AncestorReportVisitor visitor) {
    visitor.visit(this);
  }
}
class Family {
  ...
  void accept(AncestorReportVisitor visitor) {
    visitor.visit(this);
  }
}

Gestartet wird der Mechanismus, in dem auf einer Start-Instanz die accept Methode aufgerufen wird.

AncestorReportVisitor visitor = new AncestorReportVisitor();
gedcom.findByName("Diedrich /Kayser/").accept(visitor);
int femaleCount = visitor.getFemaleCount();

Indem vom AncestorReportVisitor generalisiert wird und die besuchten Klassen beispielsweise ein AncestorVisitor akzeptieren, können auch andere Implementierungen verwendet werden, z.B. ein TreePrintVisitor.

class AncestorReportVisitor implements AncestorReportVisitor  { ... }
class TreePrintVisitor implements AncestorReportVisitor  { ... }

Die Verwendung des Visitor-Pattern in Legacy Code ist, wie alle Veränderungen, mit Schwierigkeiten verbunden.

Bei Legacy Code haben es die Entwickler mit schwer wartbaren Code zu tun. Er ist meist nicht nur einfach hoffnungsvoll veraltet, oft gibt es eine unsaubere Architektur, schlechte modellierte Klassen-Hierarchien und Datenmodelle.

Die häufigste Schwierigkeit bei der Anwendung des Visitor-Pattern ist die Unmöglichkeit, die zu besuchenden Klassen mit einer accept Methode auszurüsten. Sie werden vielleicht aus einer Fremdbibliothek importiert oder generiert.

In diesem Fall stehen meist noch zwei Möglichkeiten zur Verfügung, das Visitor-Pattern in den eigenen Code zu integrieren. Entweder durch Wrapper-Klassen oder durch ein Acceptor-Adapter.

Wenn die Möglichkeit besteht, an zentraler Stelle, in die Erzeugung der Instanzen einzugreifen, dann kann man sie dort in Wrapper-Objekte einbetten. Für eine fiktive Legacy Klasse GasEngine erstellen wir einen Wrapper, der alle Aufrufe auf das Original umlenkt und zusatzlich eine acceptMethode bereitstellt.

public class GasEngineWrapper extends GasEngine {
  private final GasEngine wrapped;
  ...
  public void accept(EngineVisitor visitor) {
    visitor.visit(this);
  }
}

Da die accept Methode in der Wrapper Klasse definiert wurde, sollte der Visitor mit den Wrapper Instanzen arbeiten. Ansonsten muss jedem Aufruf der accept Methode ein Cast vorangehen.

Der obige GasEngineWrapper Code ist sehr kurz, weil alle Wrapper-Methoden ausgelassen wurden. Wer nicht alles selber schreiben will und sich hier ein wenig Magie wünscht, kann z.B. CGLIB aus der Trickkiste holen.

public class GasEngineWrapper extends GasEngine {
  public void accept(EngineVisitor visitor) {
    visitor.visit(this);
  }
  public static GasEngineWrapper of(GasEngine engine) {
    Enhancer enhancer = new Enhancer();
    enhancer.setSuperclass(GasEngineWrapper.class);
    enhancer.setCallback((MethodInterceptor) (obj, method, args, proxy) -> {
      if (method.getDeclaringClass() == GasEngineWrapper.class) {
        return proxy.invokeSuper(obj, args);
      }
      return proxy.invoke(engine, args);
    });
    return (GasEngineWrapper)enhancer.create();
  }
}

Die obige Factory-Methode erzeugt mit CGLIB einen Proxy für die GasEngineWrapper Klasse. Der MethodInterceptor leitet alle Methodenaufrufe für die GasEngine Klasse an die engine Instanz weiter. Die Methodenaufrufe der GasEngineWrapper Klasse werden an an die Originalmethode weitergeleitet. In diesem Fall ist dies einzig die accept Methode.

Wenn die ursprünglichen Entwickler des Legacy Codes auch den Weg über Wrapper Klassen versperrt haben, dann existiert noch der Weg über einen Acceptor-Adapter. Auch hier ist die Idee recht einfach. Können die besuchten Klassen nicht selber entscheiden, welche Visitor-Methode aufgerufen werden soll, dann wird diese Aufgabe an den Acceptor delegiert.

Eine einfache Variante ist im folgenden dargestellt. Der EngineAcceptor beinhaltet eine Map, die für verschiedene Klassen einen Methodenaufruf, als Consumer beinhaltet.

public class EngineAcceptor {
  public static final Consumer<Object> NULL_CONSUMER = unknown -> {};

  private Map<Class<?>, Consumer<Object>> accepts;

  EngineAcceptor(EngineVisitor visitor) {
    accepts = Map.ofEntries(
        Map.entry(Vehicle.class, person -> visitor.visit((Vehicle) person)),
        Map.entry(OilTurbine.class, person -> visitor.visit((OilTurbine) person)),
        Map.entry(FluxCompensator.class, person -> visitor.visit((FluxCompensator) person)),
        Map.entry(WarpDrive.class, person -> visitor.visit((WarpDrive) person))
    );
  }

  public void accept(Object object) {
    accepts.getOrDefault(object.getClass(), NULL_CONSUMER).accept(object);
  }
}

Wird für eine Klasse kein Eintrag in der Map gefunden gefunden, wird ein leerer Consumer zurückgegeben. Das übergebene Objekt wird im Consumer auf den nötigen Typ gecastet und dann die passende visit methode aufgerufen.

public class EngineVisitor {
  private final EngineAcceptor acceptor;
 
  public EngineVisitor() {
    this.acceptor= new EngineAcceptor(this);
  }
  
  public void visit(Vehicle vehicle) { 
    vehicle.getParts().stream().forEach(acceptor::accept);
  }

  public void visit(OilEngine engine) { ... }
  public void visit(WarpDrive engine) { ... }
  public void visit(FluxCompensator engine) { ... }
}

Der Acceptor muss im Visitor instanziiert werden, da er in den visit Methoden benötigt wird, um dort die accept Aufrufe zu ersetzen.

Im obigen Beispiel, werden alle Teile einer Vehicle Instanz an den Acceptor übergeben. Bei einem oder mehreren Teilen handelt es sich vermutlich um eine Engine. Bei einem DeLorean kann also die visit(FluxCompensator engine) Methode aufgerufen werden und bei der USS Voyager die visit(WarpDrive engine).

Der hier vorgestellte EngineAcceptor wertet nur den Typ des übergebenen Objektes aus, funktioniert also nicht bei visit Methoden für Interfaces oder Superklassen. Selbstverständlich könnte, mit ein bisschen faulen Zauber aus dem Reflektions-Arsenal, auch hier mehr gemacht werden.

Aber manchmal sollte man den alten Ratschlag zum Legacy Code beherzigen – Wegwerfen und neu machen!