Unterschiede finden mit dem Java Annotation Prozessor

“Wer der Unterscheidung fähig ist, wird von jeglicher Unsicherheit befreit”

Patanjali

Ein traditionelles Problem in der Software Entwicklung ist die Bestimmung von Unterschieden zwischen zwei Instanzen einer Klasse. Um das Problem zu lösen gibt es eine ganze Reihe von Möglichkeiten.

  1. Es werden Methoden geschrieben, die explizit alle Attribute miteinander vergleichen.
  2. Es wird Reflection verwendet, um die Attribute generisch zu vergleichen.
  3. Die Instanzen werden in ein anders Format (JSON, XML, Plain Text) konvertiert, für das es entsprechende Tools gibt.
  4. Es wird ein Framework wie Javers verwendet, das entsprechende Funktionalität besitzt.

Der erste Ansatz ist der sauberste, da tatsächlich alle Attribute behandelt werden, ohne von generischen Annahmen geleitet zu werden. Leider ist er auch der aufwändigste Ansatz, da bei vielen Klassen und Änderungen, viel Vergleichs-Code erstellt und angepasst werden muss.

In diesem Beitrag wird dieser Ansatz mit der Verwendung einer AnnotationProcessor Implementierung verbunden, um den Vergleichs-Code automatisch erstellen zu lassen. Der AnnotationProcessor wurde schon in den Beiträgen zum Hamcrest Matcher Generator vorgestellt und wird auch im MapStruct Framework verwendet, der für diesen Beitrag die Idee liefert.

Die Vergleiche werden hier exemplarisch an Instanzen der Klasse ExampleBean vorgenommen.

@Data
public class ExampleBean {
  private String name;
  private int age;
  private final boolean hidden;
}

Eine selbstgeschriebene Vergleichsklasse für die Klasse ExampleBean könnte wie folgt aussehen.

public class ExampleDiffer {
  public Difference<ExampleBean> diff(ExampleBean previous, ExampleBean next) {
    if (previous == null && next == null) {
      return new ClassDifference<>(ExampleBean.class, null, null, emptyList());
    }
    Wrapper previousWrapper = new Wrapper(previous);
    Wrapper nextWrapper = new Wrapper(next);
    return new ClassDifference<>(ExampleBean.class, previousWrapper.getHash(), nextWrapper.getHash(),
        getChange(previousWrapper, nextWrapper));
  }

  private List<PropertyDifference<?>> getChange(Wrapper previous, Wrapper next) {
    List<PropertyDifference<?>> changes = new ArrayList<>();
    addPropertyDifference(changes, previous.getName(), next.getName(), "name");
    addPropertyDifference(changes, previous.getAge(), next.getAge(), "age");
    addPropertyDifference(changes, previous.getHidden(), next.getHidden(), "hidden");
    return changes;
  }

  private <T> void addPropertyDifference(List<PropertyDifference<?>> changes, T previous, T next, String property) {
    if (!Objects.equals(previous, next)) {
      changes.add(new PropertyDifference<>(property, previous, next));
    }
  }

  private static class Wrapper {
    private final ExampleBean wrapped;

    private Wrapper(ExampleBean wrapped) { this.wrapped = wrapped; }

    private String getName() { return wrapped == null ? null : wrapped.getName(); }
    private Integer getAge() { return wrapped == null ? null : wrapped.getAge(); }
    private Boolean getHidden() { return wrapped == null ? null : wrapped.isHidden(); }
    private List<String> getNames() { return wrapped == null ? null : wrapped.getNames(); }
    private Integer getHash() { return wrapped == null ? null : wrapped.hashCode(); }
  }
}

Erwähnenswert ist ein Aspekte in der Implementierung. Es werden alle Vergleiche für die Attribute identisch über eine private Wrapper Klasse ausgeführt. Auf dem ersten Blick eine unnötige Indirektion, da zwei Instanzen der Wrapper Klasse benötigt werden und primitive Typen in ihre Objekt Varianten gewrapped werden müssen.

private List<PropertyDifference<?>> getChange(Wrapper previous, Wrapper next) {
  List<PropertyDifference<?>> changes = new ArrayList<>();
  addPropertyDifference(changes, previous.getName(), next.getName(), "name");
  addPropertyDifference(changes, previous.getAge(), next.getAge(), "age");
  addPropertyDifference(changes, previous.getHidden(), next.getHidden(), "hidden");
  return changes;
}

Dieser Code ist aber sehr einfach automatisch zu generieren, weil seine Struktur für alle Typen identisch ist.

Aufgerufen wird die Vergleichsklasse mit der Methode diff(ExampleBean previous, ExampleBean next), deren beiden Parameter die zu vergleichenden Instanzen sind.

ExampleBean previous = new ExampleBean("test", 42, true);
ExampleBean next = new ExampleBean("tset", 24, false);
ExampleDiffer differ = new ExampleDiffer();
Difference<ExampleBean> diff = differ.diff(previous, next));

Das Ergebnis vom Typ Difference beinhaltet Informationen zur Klasse und den Attributen mit unterschiedlichen Werten und die Implementierung hier nicht interessant. Eine Ausgabe im Textformat für den obigen Vergleich wäre z.B.

--- de.schegge.example.ExampleBean@123f34f
+++ de.schegge.example.ExampleBean@5acf58a
@@ name @@
< test
> tset
@@ age @@
< 42
> 24
@@ hidden @@
< true
> false

Um die Generierung einer solchen Vergleichsklasse zu ermöglichen, benötigen wir mindestens den Namen der zu vergleichenden Klasse. Der Vorteil der Code-Generierung durch den AnnotationProcessor ist der Umstand, dass ein annotiertes Java Konstrukt als Anker benötigt wird. Zwei augenscheinliche Möglichkeiten existieren dazu. Zum einen die zu vergleichende Klasse oder ein Interface, dass den Vergleich beschreibt. Um Klasse und Vergleich nicht direkt zu koppeln, wird das Interface verwendet.

public interface Differ<T> {
  Difference<T> diff(T previous, T next);
}

Jede generierte Vergleichsklasse muss eine Implementierung eines Interfaces sein, dass vom Interface Differ abgeleitet ist. Dabei wird der generische Typ T, durch die zu vergleichende Klasse ersetzt. Damit der AnnotationProcessor weiß, dass Code generiert werden soll, muss das Interface mit der Annotation @Diff versehen werden. Mit diesen Anforderungen ergibt sich folgendes Vergleich-Interface ExampleDiffer für die ExampleBean Klasse.

@Diff
public interface ExampleDiffer extends Differ<ExampleBean> {
}

Normalerweise muss eine Methode nicht im abgeleiteten Interface wiederholt werden, aber ein Feature des eigenen AnnotationProcessor machen dies manchmal nötig. Die diff Methode kann mit einer @Ignore Annotation versehen werden, um Attribute beim Vergleich zu ignorieren.

@Diff
public interface ExampleDiffer extends Differ<ExampleBean> {
  @Override
  @Ignore("hidden")
  public Difference<ExampleBean> diff(ExampleBean previous, ExampleBean next);
}    

In diesem Fall wird also das Attribute hidden bei den Vergleichen ignoriert.

Bevor hier die Implementierung des eigenen AnnotationProcessor beleuchtet wird, gibt es erst ein paar Details zur Arbeitsweise der AnnotationProcessor. Die AnnotationProcessor Implementierungen werden während de der Compilierung des Source Codes vom Java Compiler aufgerufen. Dabei werden sie zu Beginn initialisiert und danach in mehreren Runden vom Compiler aufgerufen. Ein Aufruf erfolgt, wenn der Compiler in einer Compilierung Annotation gefunden hat, die von dem jeweiligen AnnotationProcessor unterstützt werden.

@SupportedAnnotationTypes("de.schegge.diff.annotations.Diff")
@SupportedSourceVersion(SourceVersion.RELEASE_11)
@AutoService(Processor.class)
public class DiffEvaluatorProcessor extends AbstractProcessor {
  // ...
  @Override
  public synchronized void init(ProcessingEnvironment processingEnv) {
    super.init(processingEnv);
    // ...
  }

  @Override
  public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
    return annotations.stream().map(roundEnv::getElementsAnnotatedWith).flatMap(Set::stream)
        .map(TypeElement.class::cast).allMatch(this::processDifferInterface);
  }
  // ...
}

Der eigene DiffEvaluatorProcessor implementiert den AbstractProcessor und ist dadurch ein AnnotationProcessor. Er verarbeitet Annotationen vom Typ Diff und gibt dies dem Compiler lustigerweise über eine Annotation @SupportedAnnotationTypes bekannt. Die Methode init dient zur Initialisierung des DiffEvaluatorProcessor und in der process Methode, werden ihm Verwendungen der Annotation @Diff im Sourcecode übergeben.

Innerhalb der process Methode werden dann alle mit @Diff annotierten Interfaces behandelt, in dem zuerst die gefundenen annotierten Elemente aus dem RoundEnvironment entnommen werden, auf TypeElement gecastet werden und zuletzt der Methode processDifferInterfaces übergeben werden.

private boolean processDifferInterfaces(TypeElement differInterfaceType) {
  try {
    TypeMirror beanType = extractBeanType(differInterfaceType);
    List<String> ignoredAttributes = extractIgnoredAttributes(differInterfaceType, beanType);
    List<DiffAttribute> beanAttributes = extractBeanAttributes(beanType, ignoredAttributes);
    
    return createDifferClassFile(differInterfaceType, beanType, beanAttributes);
  } catch (IllegalArgumentException e) {
    processingEnv.getMessager().printMessage(Kind.ERROR, "cannot create implementation: " + e, differInterfaceType);
    return false;
  }
}

In der processDifferInterfaces Methode werden für jedes Interface alle notwendigen Information in der gesammelt. Dies ist der Type beanType der zu vergleichenden Klasse und die Attribute der Klasse, abzüglich der ignorierten Attribute. Die Attribute werden innerhalb der extractBeanAttributes Methode über die Get-Methoden, entsprechend der Java Bean Specification, bestimmt. Die Informationen werden an die createDifferClassFile Methode übergeben, die daraus eine Java Source Datei erzeugt.

private boolean createDifferClassFile(TypeElement differType, TypeElement beanType,
    List<DiffAttribute> beanAttributes) {
  try {
    String differTypeName = differType.getSimpleName().toString();
    String differImplTypeName = differTypeName + "Impl";

    JavaFileObject builderFile = processingEnv.getFiler().createSourceFile(differImplTypeName);
    if (builderFile.getLastModified() > 0) {
      return false;
    }
    PackageElement packageOf = elements.getPackageOf(differType);
    try (PrintWriter out = new PrintWriter(builderFile.openWriter())) {
      String packageName = packageOf.getQualifiedName().toString();

      Set<TypeMirror> baseTypes = beanAttributes.stream().map(DiffAttribute::getType).collect(toSet());

      TypeDictionary typeDictionary = createTypeDictionary(differType, beanType, baseTypes);

      List<DiffAttribute> beanAttributList = beanAttributes.stream().map(a -> new DiffAttribute(a.getMethod(),
          a.getType().accept(typeDictionary.getTypeNameGenerator(), null),
          a.getInternalName())).collect(toList());

      Map<String, Object> root = new HashMap<>();
      root.put("package", packageName);
      root.put("differImplTypeName", differImplTypeName);
      root.put("differTypeName", differTypeName);
      root.put("diffAttributes", beanAttributList);
      root.put("beanTypeName", beanType.getSimpleName());
      root.put("imports", typeDictionary.getImports());
      classGenerator.processTemplate(root, out);
    }
    return true;
  } catch (IOException e) {
    throw new IllegalArgumentException(e);
  }
}

Die Methode createDifferClassFile ist etwas aufwendiger, da hier alle Werte für das FreeMarker Template zusammengestellt werden müssen. Der Source Code der neuen Klasse wird nicht programmatisch erstellt, sondern mit Hilfe einer Template Engine. Die FreeMarker Engine wurde bereits in den Beiträgen Hamcrest Matcher Generator (Teil 2) und Trivial Pursuit – API MarkDown verwendet und vereinfacht auch hier die Generierung.

Interessant an dieser Methode ist die hier verwendete Klasse TypeDictionary. In ihr werden alle Typen gesammelt, die in der Klasse verwendet werden sollen. Das TypeDictionary sorgt dafür, dass soweit möglich, alle Klassen importiert werden und ohne qualifizierten Namen verwendet werden können. Das macht den generierten Text lesbarer für den Entwickler.

Damit ist die Implementierung des DiffEvaluatorProcessors vollständig und einfache Vergleichsklassen können generiert werden.

package de.schegge;

import java.util.Objects;
import java.util.List;
import de.schegge.diff.PropertyDifference;
import de.schegge.diff.Differ;
import de.schegge.diff.ClassDifference;
import de.schegge.diff.Difference;
import java.util.Collections;
import de.schegge.example.ExampleBean;
import java.util.ArrayList;

public final class ExampleDifferImpl implements ExampleDiffer {
  @Override
  public Difference<ExampleBean> diff(ExampleBean previous, ExampleBean next) {
    if (previous == null && next == null) {
      return new ClassDifference<>(ExampleBean.class, null, null, Collections.emptyList());
    }
    Wrapper previousWrapper = new Wrapper(previous);
    Wrapper nextWrapper = new Wrapper(next);
    return new ClassDifference<>(ExampleBean.class, previousWrapper.getHash(), nextWrapper.getHash(),
        getChange(previousWrapper, nextWrapper));
  }

  private <T> void addPropertyDifference(List<PropertyDifference<?>> changes, T previous, T next, String property) {
    if (!Objects.equals(previous, next)) {
      changes.add(new PropertyDifference<>(property, previous, next));
    }
  }

  private List<PropertyDifference<?>> getChange(Wrapper previous, Wrapper next) {
    List <PropertyDifference<?>> changes = new ArrayList<>();
    addPropertyDifference(changes, previous.getName(), next.getName(), "name");
    addPropertyDifference(changes, previous.getAge(), next.getAge(), "age");
    addPropertyDifference(changes, previous.getHidden(), next.getHidden(), "hidden");
    addPropertyDifference(changes, previous.getNames(), next.getNames(), "names");
    addPropertyDifference(changes, previous.getInternalNames(), next.getInternalNames(), "internalNames");
    return changes;
  }

  private static class Wrapper {
    private final ExampleBean wrapped;

    private Wrapper(ExampleBean wrapped) { this.wrapped = wrapped; }

    private String getName() { return wrapped == null ? null : wrapped.getName(); }
    private Integer getAge() { return wrapped == null ? null : wrapped.getAge(); }
    private Boolean getHidden() { return wrapped == null ? null : wrapped.isHidden(); }
    private Integer getHash() { return wrapped == null ? null : wrapped.hashCode(); }
  }
}

Für den praktischen Einsatz fehlen dem DiffEvaluatorProcessor aber noch einige Features. Momentan ist die Generierung von Unterschieden auf die Attribute einer Klasse beschränkt. Dabei werden Unterschiede festgestellt, in dem die jeweiligen Attribute der beiden Instanzen auf Gleichheit getestet werden.

  • Bei eigenen Attributtypen wird erwartet, dass die Unterscheidung sich in diesen Typen fortsetzt. Bei einer Person, soll nicht nur geprüft werden, ob sich die Adressen unterscheiden. Auch innerhalb der Adressen sollen Unterschiede festgestellt werden.
  • Unterschiedliche Anforderungen an ignorierte Attribute bedeuten momentan immer ein neues Interface und eine neue Implementierung. Statt einer einzelnen festen Methode, soll der DiffEvaluatorProcessor alle Methoden implementieren, die valide Parameter- und Resultat-Typen besitzen.
  • Innerhalb von Listen und Sets sollen Unterschiede festgestellt werden. Es soll nicht nur geprüft werden, ob sich zwei Listen unterscheiden, sondern festgestellt werden, welche Elemente hinzugefügt wurden und welche gelöscht wurden.
  • Spezielle Attributen können unterschiedlich verglichen werden. String Attribute können case-sensitive oder case-insensitive verglichen werden, bei Fließkommazahlen und Zeitstempel können unterschiedliche Genauigkeiten verwendet werden.

Wie man all dies, in die bisherige Implementierung einbaut, ist Inhalt des nächsten Beitrags.

2 Gedanken zu „Unterschiede finden mit dem Java Annotation Prozessor“

Kommentare sind geschlossen.