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 accept
Methode 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!