“Any problem in computer science can be solved with another layer of indirection.”
David Wheeler
Kürzlich las ich bei Martin Fowler einen interessanten Artikel zu einem Design-Pattern von Pete Hodgson. Dazu schossen mir sogleich zwei Ideen durch den Kopf. Zuerst die Frage, warum erst jetzt diese Pattern formuliert wird und gleich darauf der Gedanke, eine meiner Klassen schleunigst umzuschreiben.
Viele Funktionalitäten, die in einer Klasse beschrieben werden, sind in ihrer Formulieren knapp und präzise gehalten. Im folgenden Beispiel wollen wir eine Person in unsere Datenbank einfügen, falls eine passende Person in der Datenbank gefunden wird, werden die Informationen zusammengeführt. Werden mehrere passende Einträge gefunden, dann wird die Person nicht eingefügt.
public Optional<Person> merge(Person person) { try { List<Person> similiarPerson = gedcom.findByNameAndBirth(person); if (similiarPerson.isEmpty()) { gedcom.add(person); return Optional.empty(); } if (similiarPerson.size() == 1) { Person mergedPerson = merge(similiarPerson.get(0), person); gedcom.update(mergedPerson); return Optional.of(mergedPerson); } return Optional.empty(); } catch (RuntimeException e) { return Optional.empty(); } }
Dazu gesellen sich aber allzu oft weitere Funktionalitäten, das Protokollieren in eine Log-Datei, das Aktualisieren der Metriken, Zeitmessungen und was die aktuellen Projektvorgaben sonst noch hergeben.
public Optional<Person> merge(Person person) { try { LOGGER.info("merge person {}", person.getName()); List<Person> similiarPersons = gedcom.findByNameAndBirth(person); if (similiarPersons.isEmpty()) { gedcom.add(person); personAddedCounter.increment(); LOGGER.debug("person added"); return Optional.empty(); } if (similiarPerson.size() == 1) { Person familyMember = similiarPersons.get(0); LOGGER.debug("merge person with given family member {}", familyMember.getUrn()); Person mergedPerson = merge(familyMember, person); gedcom.update(mergedPerson); personUpdatedCounter.increment(); LOGGER.debug("person updated"); return Optional.of(mergedPerson); } LOGGER.debug("cannot decide between {} person", similiarPersons.size()); toManyChoicesCounter.increment(); } catch (RuntimeException e) { LOGGER.warn("error on merge " + e.getMessage(), e); errorCounter.increment(); } return Optional.empty(); }
Von dem anfangs kompakt beschrieben Algorithmus ist nicht mehr viel übrig geblieben. Das ist nicht nur hässlich, sondern sorgt auch für viel Arbeit beim Testen.
Abhilfe kann hier das Domain Probe Patterns schaffen. Die Idee ist dabei recht einfach. Alle Zusatzaufgaben werden domänenspezifisch ausgelagert. Dafür erstellen wir eine Klasse PersonMergeInstrumentation und fügen passende Methoden ein.
public class PersonMergeInstrumentation { private Counter personAddedCounter; private Counter personUpdatedCounter; private Counter errorCounter; public PersonMergeInstrumentation(MeterRegistry registry) { ... } public void mergingPerson(Person person) { LOGGER.info("merge person {}", person.getName()); } public void addedPerson(Person person) { personAddedCounter.increment(); LOGGER.debug("person added"); } public void similiarPersonFound(Person person) { LOGGER.debug("merge person with given family member {}", person.getUrn()); } public void updatedPerson(Person person) { personUpdatedCounter.increment(); LOGGER.debug("person updated"); } public void toManyChoices(List<Person> choices) { LOGGER.debug("cannot decide between {} person", choices.size()); toManyChoicesCounter.increment(); } public void mergeFailed(Exception e) { LOGGER.warn("error on merge " + e.getMessage(), e); errorCounter.increment(); } }
Diese Methoden ersetzen den Zusatzcode in der Methode merge und wir bekommen wieder einen übersichtlichen Source Code. Statt unzähliger Aufrufe technischer Komponenten in unserem Source Code, haben wir nun zu den Prozessschritten konforme Aufrufe unserer Instrumentation Klasse. Zusätzlich extrahieren wir noch die einzelnen Prozessschritte als eigene Methoden.
private void add(Person person) { gedcom.add(person); instrumentation.addedPerson(person); } private Person update(Person familyMember, Person person) { Person mergedPersons = merge(familyMember, person); instrumentation.similiarPersonFound(mergedPersons); gedcom.update(mergedPersons); instrumentation.updatedPerson(mergedPersons); return mergedPersons; } public Optional<Person> merge(Person person) { try { instrumentation.mergingPerson(person); List<Person> similiarPersons = gedcom.findByNameAndBirth(person); if (similiarPersons.isEmpty()) { add(person); return Optional.empty(); } if (similiarPerson.size() == 1) { Person mergedPerson = update(similiarPersons.get(0), person); return Optional.of(mergedPerson); } instrumentation.toManyChoices(similiarPersons); } catch (RuntimeException e) { instrumentation.mergeFailed(e); } return Optional.empty(); }
Die Trennung dieser Verwaltungsaufgaben von den eigentlichen Funktionalitäten verbessert nicht nur die Lesbarkeit. Durch die lose Kopplung können beide Aspekte in Unit Tests voneinander
getrennt getestet werden. Möchten wir das Zusammenführen von Personen testen, dann können wir unsere Instrumentation Klasse durch einen Mock ersetzen, da uns Log-Ausgaben und die Werte der Zähler nicht interessieren. Andererseits können wir unsere Instrumentation Klasse unabhängig vom Zusammenführen von Personen testen, indem wir ihre Methoden aufrufen und die Zähler prüfen.
@Mock private Gedcom gedcom; @Mock private PersonMergeInstrumentation instrumentation; @Test void merger() { Person person = ... PersonMerger merger = new PersonMerger(gedcom, instrumentation); assertTrue(merger.merge(person).isPresent()); } @Test void instrumentation() { MeterRegistry registry = ... PersonMergeInstrumentation instrumentation = PersonMergeInstrumentation(registry); instrumentation.addedPerson(person); assertEquals(1, registry.counter("personAddedCounter").value()); }
Das Design Probe Pattern ist eine gute Möglichkeit, technische Aspekte zum Loggen und Messen sauber in die eigene Software einzubauen ohne auf Vertreter der Aspektorientierten Programmierung zurückgreifen zu müssen.