Unterschiede finden mit dem Java Annotation Processor (Teil 3)

In den ersten und zweiten Beitrag dieser Reihe wurde ein Annotation Processor implementiert, der bei der Feststellung von Unterschieden zwischen zwei Java Beans unterstützt. In diesem Beitrag soll die Implementierung für Collection Klassen und Bean Strukturen erweitert werden.

Die bisherige Implementierung unterscheidet zwei Collections nur anhand ihrer Ungleichheit. Für eine bessere Unterscheidung sollen die Elemente aufgeführt werden, die hinzugefügt oder entfernt wurden.

Die Attribute von den Typen List und Set werden bislang wie alle anderen Attribute von der Methode addPropertyDifference verarbeitet.

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

Statt diese Methode zu erweitern, um darin den Typ der Parameter zur Laufzeit zu prüfen, werden vom Annotation Processor gesonderte Methoden während der Kompilierung erstellt.

protected <T> void addSetPropertyDifference(List<PropertyDifference<?>> changes, Set<T> previous, Set<T> next, String property) {
  Set<T> remove = new HashSet<>(previous);
  Set<T> add = new HashSet<>(next);
  remove.removeAll(next);
  add.removeAll(previous);
  if (!add.isEmpty() || !remove.isEmpty()) {
    changes.add(new PropertyDifference<>(property, add, remove));
  }
}

Die Methode addSetPropertyDifference erwartet zwei Parameter vom Typ Set und berechnet ihren Unterschied in hinzugefügten und entfernten Elementen. Da für ein Set die Reihenfolge der Elemente egal ist und keine identischen Elemente enthalten sein können, ist der Algorithmus recht primitiv. Die Elemente eines Set werden von einer Kopie des anderen Set abgezogen. So bleiben in der Kopie des früheren Set alle Elemente, die entfernt wurden und in der Kopie des späteren Set alle Elemente, die hinzugefügt wurden.

protected <T> void addListPropertyDifference(List<PropertyDifference<?>> changes, List<T> previous, List<T> next, String property) {
  List<T> add = new ArrayList<>(next);
  List<T> remove = new ArrayList<>(previous);
  previous.stream().filter(add::contains).forEach(add::remove);
  next.stream().filter(remove::contains).forEach(remove::remove);
  if (!add.isEmpty() || !remove.isEmpty()) {
    changes.add(new PropertyDifference<>(property, add, remove));
  }
}

Bei der addListPropertyDifference Methode für den Typ List ist die Verarbeitung etwas komplizierter, weil Elemente mehrfach vorkommen können und nur für ein einmaliges Hinzufügen oder Entfernen betrachtet werden. Sollen auch veränderte Positionen in der Liste festgestellt werden, dann reicht dieser einfache Algorithmus nicht aus. Dann muss ein Edit-Distance Algorithmus (z.B. Myers Diff Algorithmus) auf Listen angepasst werden.

Um die jeweils richtige Methode zu erzeugen, enthält das Template für die Differ Klasse eine switch Anweisung. Basierend auf dem Typ des Attributes wird entweder die bisherige addPropertyDifference oder eine der beiden neuen Methoden aufgerufen.

<#list method.beanAttributes as attribute>
  <#switch attribute.type.name()>
    <#case "SET">
    addSetPropertyDifference(changes, previousWrapper.${attribute.wrapperMethodName}(), nextWrapper.${attribute.wrapperMethodName}(), "${attribute.name}");
    <#break>
    <#case "LIST">
    addListPropertyDifference(changes, previousWrapper.${attribute.wrapperMethodName}(), nextWrapper.${attribute.wrapperMethodName}(), "${attribute.name}");
    <#break>
    <#case "DEFAULT">
    <#default>
    addPropertyDifference(changes, previousWrapper.${attribute.wrapperMethodName}(), nextWrapper.${attribute.wrapperMethodName}(), "${attribute.name}");
  </#switch>
</#list>

Für Bean Strukturen gelten ähnliche Anforderungen, wie für die Collections. Leider zeigen die Java Beans an dieser Stelle einen gewissen Nachteil gegenüber den Collections. Existieren bei den Collections die beiden Basis-Typen Set und List, tummeln sich bei den Java Beans Heerscharen von Java Klassen.

Ein leicht erweitertes Beispiel erklärt die Probleme.

@Data
class PersonBean {
  private String name;
  private int age;
  private CarBean car;
  private BikeBean bike;
}

@Data
class BikeBean {
  private int size;
  private String color;
}

@Data
class CarBean {
  private String type;
  private String color;
}

@Diff
interface PersonDiffer {
  Difference<PersonBean> diff(PersonBean previous, PersonBean next);
}

Die generierte PersonDiffer Implementierung prüft die beiden PersonBean Instanzen und vergleicht dabei auch die Attribute car und bike, die auch Java Beans sind. Die PersonDiffer Implementierung kann für diese beiden Java Beans zusätzliche Logik enthalten, um diese auch zu prüfen. Es können aber weitere Java Beans in die Klasse PersonBean oder in die Klassen CarBean und BikeBean eingefügt werden. Damit wird die Anzahl der Bean Klassen innerhalb einer Implementierung immer größer. Andererseits kann es auch andere Differ Implementierungen geben, die für gemeinsame Bean Klassen ihren eigenen Code generieren. Zusätzlich besteht das Problem zu unterscheiden, welche Klasse als Bean geprüft werden soll und welche nicht.

Eine beliebte Lösung, um aus diesem Dilemma herauszukommen, ist das Delegieren. Der PersonDiffer prüft ein Attribut als Bean nur dann, wenn ihm ein Differ für dieses Bean bekannt ist.

@Diff
interface CarDiffer {
  Difference<CarBean> diff(CarBean previous, CarBean next);
}

@Diff
interface BikeDiffer {
  Difference<BikeBean> diff(BikeBean previous, BikeBean next);
}

@Diff(uses={CarDiffer.class, BikeDiffer.class})
interface PersonDiffer {
  Difference<PersonBean> diff(PersonBean previous, PersonBean next);
}

In diesem Beispiel werden die Klassen CarBean und BikeBean als Bean geprüft, weil dem PersonDiffer über das Attribute uses passende Differ Implementierungen zur Verfügung stehen.

Um diese Funktionalität umzusetzen, werden zu den Diff-Methoden aus der PersonDiffer Klasse auch die Diff-Methoden aus den Klassen CarBean und BikeBean benötigt.

public DiffTyp create(TypeElement differInterfaceType) {
  List<DiffMethod> methods = extractDiffMethods(differInterfaceType);
  if (methods.isEmpty()) {
    throw new NoSuchElementException("no diff methods found " + differInterfaceType);
  }

  Set<TypeMirror> usedDiffers = getUsedDiffers(differInterfaceType);
  processingEnv.getMessager().printMessage(Kind.NOTE, "used: " + usedDiffers);

  Map<TypeMirror, List<DiffMethod>> differPropertyMethods = usedDiffers.stream()
      .map(tm -> tm.accept(declaredTypeVisitor, null)).map(DeclaredType::asElement).map(TypeElement.class::cast)
      .filter(e -> e.getAnnotation(Diff.class) != null)
      .collect(toMap(Element::asType, this::extractDiffMethods));

  return new DiffTyp(differInterfaceType, methods, usedDiffers, differPropertyMethods);
}

Dazu wird die Methode extractDiffMethods nicht nur auf das aktuelle Interface (Zeile 2), sondern auch auf alle Interfaces, die im uses Attribute aufgelistet sind (Zeile 13).

Mit den Informationen aus der differPropertyMethods Map wird vor der Codegenerierung geprüft, ob an einen anderen Differ delegiert werden soll. Um diesen zusätzlichen Fall zu behandeln wird die switch Anweisung im Template um den Fall DIFFER erweitert.

<#switch attribute.type.name()>
<#case "SET">
    addSetPropertyDifference(changes, previousWrapper.${attribute.wrapperMethodName}(), nextWrapper.${attribute.wrapperMethodName}(), "${attribute.name}");
    <#break>
<#case "LIST">
    addListPropertyDifference(changes, previousWrapper.${attribute.wrapperMethodName}(), nextWrapper.${attribute.wrapperMethodName}(), "${attribute.name}");
    <#break>
<#case "DIFFER">
    addPropertyDifference(changes, ${attribute.differCall}(previousWrapper.${attribute.wrapperMethodName}(), nextWrapper.${attribute.wrapperMethodName}()), "${attribute.name}");
    <#break>
<#case "DEFAULT">
<#default>
    addPropertyDifference(changes, previousWrapper.${attribute.wrapperMethodName}(), nextWrapper.${attribute.wrapperMethodName}(), "${attribute.name}");
</#switch>

Der geänderte Annotation Processor produziert für den PersonDiffer die folgende Implementierung.

public final class PersonDifferImpl extends AbstractDiffer implements PersonDiffer {
  private final BikeDiffer bikeDiffer;
  private final CarDiffer carDiffer;

  public PersonDifferImpl(
    BikeDiffer bikeDiffer,
    CarDiffer carDiffer
  ) {
    this.bikeDiffer = bikeDiffer;
    this.carDiffer = carDiffer;
  }

@Override
public Difference<PersonBean> diff(PersonBean previous, PersonBean next) {
    if (previous == next) {
      return new ClassDifference<>(PersonBean.class, null, null, Collections.emptyList());
    }
    PersonBeanWrapper previousWrapper = new PersonBeanWrapper(previous);
    PersonBeanWrapper nextWrapper = new PersonBeanWrapper(next);
    List <PropertyDifference<?>> changes = new ArrayList<>();
    addPropertyDifference(changes, previousWrapper.getName(), nextWrapper.getName(), "name");
    addPropertyDifference(changes, previousWrapper.getAge(), nextWrapper.getAge(), "age");
    addPropertyDifference(changes, carDiffer.diff(previousWrapper.getCar(), nextWrapper.getCar()), "car");
    addPropertyDifference(changes, bikeDiffer.diff(previousWrapper.getBike(), nextWrapper.getBike()), "bike");
    return new ClassDifference<>(PersonBean.class, previousWrapper.hash(), nextWrapper.hash(), changes);
  }

  private static class PersonBeanWrapper extends AbstractWrapper<PersonBean> {
    private PersonBeanWrapper(PersonBean wrapped) {
      super(wrapped);
    }

    private CarBean getCar() {
      return wrapped == null ? null : wrapped.getCar();
    }

    private String getName() {
      return wrapped == null ? null : wrapped.getName();
    }

    private Integer getAge() {
      return wrapped == null ? null : wrapped.getAge();
    }

    private BikeBean getBike() {
      return wrapped == null ? null : wrapped.getBike();
    }
  }
}

In den Zeilen 23 und 24 delegiert die Klasse PersonDifferImpl an die, im Konstruktor übergebenen BikeDiffer und CarDiffer, um Unterschiede in den Attributen zu prüfen. Mit diesen Ergänzungen kann der Annotation Processor produktiv in eigenen Projekten verwendet werden.