“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.
- Es werden Methoden geschrieben, die explizit alle Attribute miteinander vergleichen.
- Es wird Reflection verwendet, um die Attribute generisch zu vergleichen.
- Die Instanzen werden in ein anders Format (JSON, XML, Plain Text) konvertiert, für das es entsprechende Tools gibt.
- 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
machen dies manchmal nötig. Die AnnotationProcessor
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
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
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
dient zur Initialisierung des init
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
Methode über die Get-Methoden, entsprechend der Java Bean Specification, bestimmt. Die Informationen werden an die extractBeanAttributes
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 thoughts on “Unterschiede finden mit dem Java Annotation Prozessor”
Comments are closed.