Records für FreshMarker

Seit Java 14 existieren Records als zusätzliches Klassenkonstrukt im Sprachumfang. Daher wäre es schön, diese kompakte Variante von immutablen Klassen auch in der Template-Engine FreshMarker nutzen zu können. Wie einfach die Unterstützung für Records implementiert werden kann, soll dieser Beitrag veranschaulichen.

Die neuen Records haben gegenüber klassischen POJOS als reine Datenklassen einen erheblichen Vorteil.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
public class Ancestor {
private final String firstName;
private final String lastName;
private final LocalDate dateOfBirth;
private final LocalDate dateOfDeath;
public Ancestor(String firstName, String lastName, LocalDate dateOfBirth, LocalDate dateOfDeath) {
this.firstName = firstName;
this.lastName = lastName;
this.dateOfBirth = dateOfBirth;
this.dateOfDeath = dateOfDeath;
}
public String getFirstName() {
return firstName;
}
public String getLastName() {
return lastName;
}
public LocalDate getDateOfBirth() {
return firstName;
}
public LocalDate getDateOfDeath() {
return dateOfDeath;
}
public boolean equals(Object o) {
// ...
}
public int hashCode() {
// ...
}
public String toString() {
// ...
}
}
public class Ancestor { private final String firstName; private final String lastName; private final LocalDate dateOfBirth; private final LocalDate dateOfDeath; public Ancestor(String firstName, String lastName, LocalDate dateOfBirth, LocalDate dateOfDeath) { this.firstName = firstName; this.lastName = lastName; this.dateOfBirth = dateOfBirth; this.dateOfDeath = dateOfDeath; } public String getFirstName() { return firstName; } public String getLastName() { return lastName; } public LocalDate getDateOfBirth() { return firstName; } public LocalDate getDateOfDeath() { return dateOfDeath; } public boolean equals(Object o) { // ... } public int hashCode() { // ... } public String toString() { // ... } }
public class Ancestor {
  private final String firstName;
  private final String lastName;
  private final LocalDate dateOfBirth;
  private final LocalDate dateOfDeath;

  public Ancestor(String firstName, String lastName, LocalDate dateOfBirth, LocalDate dateOfDeath) {
    this.firstName = firstName;
    this.lastName = lastName;
    this.dateOfBirth = dateOfBirth;
    this.dateOfDeath = dateOfDeath;
  }

  public String getFirstName() {
    return firstName;
  }

  public String getLastName() {
    return lastName;
  }

  public LocalDate getDateOfBirth() {
    return firstName;
  }

  public LocalDate getDateOfDeath() {
    return dateOfDeath;
  }

  public boolean equals(Object o) {
    // ...
  }

  public int hashCode() {
    // ...
  }

  public String toString() {
    // ...
  }
}

Bei dieser Java Bean für die Darstellung eines Ahnen wurden vier Attribute definiert, ein Konstruktor, vier Get Methoden und die üblichen

equals
equals,
hashCode
hashCode und
toString
toString Methoden. Bei den drei letzteren fehlt die Implementierung im Beispiel, weil dem Autor die Lust auf den üblichen Boilerplate Code beim Schreiben schon verging. Die Repräsentation einer gleich mächtigen Klasse als Java Record ist da schon erheblich kürzer.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
public record Ancestor(String firstName, String lastName, LocalDate dateOfBirth, LocalDate dateOfDeath) {
}
public record Ancestor(String firstName, String lastName, LocalDate dateOfBirth, LocalDate dateOfDeath) { }
public record Ancestor(String firstName, String lastName, LocalDate dateOfBirth, LocalDate dateOfDeath) {
}

Dieser Record besitzt wie die Java Bean die Methoden

equals
equals,
hashCode
hashCode und
toString
toString. Außerdem vier Accessor Methoden
firstName()
firstName(),
lastName()
lastName(),
dateOfBirth()
dateOfBirth() und
dateOfDeath()
dateOfDeath() und einen Konstruktor mit vier Parametern. Diese massive Reduktion des Aufwandes erkauft sich der Entwickler mit einigen Restriktionen der Records. Diese können zwar Interfaces implementieren, aber ebenso wie Enums nehmen sie an keiner Vererbung teil.

Eine interessante Möglichkeit für die Verwendung von Records, sind komplexe Stream Operationen.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
public List<Ancestor> sortByAge(List<Ancestor> list, LocalDate now) {
record AncestorWithAge(Ancestor ancestor, int age) {}
return list.stream()
.map(ancestor -> new AncestorWithAge(ancestor, computeAge(ancestor, now))
.sorted(Comparator.comparing(AncestorWithAge::age))
.map(ancestorWithAge::ancestor)
.collect(Collectors.toList());
}
public List<Ancestor> sortByAge(List<Ancestor> list, LocalDate now) { record AncestorWithAge(Ancestor ancestor, int age) {} return list.stream() .map(ancestor -> new AncestorWithAge(ancestor, computeAge(ancestor, now)) .sorted(Comparator.comparing(AncestorWithAge::age)) .map(ancestorWithAge::ancestor) .collect(Collectors.toList()); }
public List<Ancestor> sortByAge(List<Ancestor> list, LocalDate now) {
  record AncestorWithAge(Ancestor ancestor, int age) {}
  return list.stream()
      .map(ancestor -> new AncestorWithAge(ancestor, computeAge(ancestor, now))
      .sorted(Comparator.comparing(AncestorWithAge::age))
      .map(ancestorWithAge::ancestor)
      .collect(Collectors.toList());    
}

In der Beispiel Methode wird ein lokaler Record verwendet, um ein Zwischenergebnis während der Stream Verarbeitung zu speichern. Der Record

AncestorWithAge
AncestorWithAge hält den ursprünglichen
Ancestor
Ancestor Record und das berechnete Alter der entsprechenden Person. Dann wird der Stream nach dem Alter sortiert und die
Ancestor
Ancestor in eine neue Liste gesammelt.

In diesem Beitrag geht es aber um die Unterstützung von Records durch die Template-Engine FreshMarker. Bisher wurden im Datenmodel der Template-Engine als

TemplateMap
TemplateMap nur
Map<String, Object>
Map<String, Object> Implementierungen und Java Beans unterstützt.

Beide Varianten werden durch die Klasse

TemplateBean
TemplateBean realisiert, die eine
Map
Map enthält. Ist dort unter ein Wert für einen entsprechenden Namen enthalten dann wird dieser in einen Modeltypen gekapselt oder es wird
TemplateNull.NULL
TemplateNull.NULL zurückgeliefert. Für
Map<String, Object>
Map<String, Object> Instanzen ist es eben diese Instanz, für Java Beans ist es eine eigene Subklasse
BaseReflectionsMap
BaseReflectionsMap der
AbstractMap
AbstractMap, die via Reflections die Attribute mit dem entsprechenden Namen ausliest. Die entsprechenden Methoden der Java Bean erhält die
Map
Map vom
java.beans.Introspector
java.beans.Introspector. Der hier dargestellte
TemplateBeanProvider
TemplateBeanProvider kümmert sich um die Auswertung der Java Bean und cached das Ergebnis für weitere Anfragen.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
public class TemplateBeanProvider {
private final Map<Class<?>, Map<String, Method>> beans = new HashMap<>();
public Map<String, Object> provide(Object bean, Environment environment) {
final Map<String, Method> methods = beans.computeIfAbsent(bean.getClass(), b -> collectMethods(bean));
return new BaseReflectionsMap(methods, environment, bean);
}
private Map<String, Method> collectMethods(Object bean) {
try {
BeanInfo beanInfo = Introspector.getBeanInfo(bean.getClass(), Object.class);
return Stream.of(beanInfo.getPropertyDescriptors()).collect(toMap(
FeatureDescriptor::getName, PropertyDescriptor::getReadMethod));
} catch (IntrospectionException e) {
throw new ProcessException(e.getMessage(), e);
}
}
}
public class TemplateBeanProvider { private final Map<Class<?>, Map<String, Method>> beans = new HashMap<>(); public Map<String, Object> provide(Object bean, Environment environment) { final Map<String, Method> methods = beans.computeIfAbsent(bean.getClass(), b -> collectMethods(bean)); return new BaseReflectionsMap(methods, environment, bean); } private Map<String, Method> collectMethods(Object bean) { try { BeanInfo beanInfo = Introspector.getBeanInfo(bean.getClass(), Object.class); return Stream.of(beanInfo.getPropertyDescriptors()).collect(toMap( FeatureDescriptor::getName, PropertyDescriptor::getReadMethod)); } catch (IntrospectionException e) { throw new ProcessException(e.getMessage(), e); } } }
public class TemplateBeanProvider {

  private final Map<Class<?>, Map<String, Method>> beans = new HashMap<>();

  public Map<String, Object> provide(Object bean, Environment environment) {
    final Map<String, Method> methods = beans.computeIfAbsent(bean.getClass(), b -> collectMethods(bean));
    return new BaseReflectionsMap(methods, environment, bean);
  }

  private Map<String, Method> collectMethods(Object bean) {
    try {
      BeanInfo beanInfo = Introspector.getBeanInfo(bean.getClass(), Object.class);
      return Stream.of(beanInfo.getPropertyDescriptors()).collect(toMap(
          FeatureDescriptor::getName, PropertyDescriptor::getReadMethod));
    } catch (IntrospectionException e) {
      throw new ProcessException(e.getMessage(), e);
    }
  }
}

Leider unterscheidet sich der Record nicht nur oberflächlich von einer Java Beans, sondern auch die interne Implementierung verhält sich anders. Der

Introspector
Introspector ist auf Records nicht anwendbar, so dass hier etwas tiefer in Reflections eingetaucht werden muss. Erfreulicherweise sind Records keine besonders komplexen Zeitgenossen. Über die Methode
getRecordComponent
getRecordComponent kann auf die Namen und Methoden der einzelnen Record Komponenten zugegriffen werden.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
public class TemplateRecordProvider {
private final Map<Class<?>, Map<String, Method>> records = new HashMap<>();
public Map<String, Object> provide(Object recordObject, Environment environment) {
Map<String, Method> methods = records.computeIfAbsent(recordObject.getClass(), b -> collectMethods(recordObject));
return new BaseReflectionsMap(methods, environment, recordObject);
}
private Map<String, Method> collectMethods(Object recordObject) {
return Stream.of(recordObject.getClass().getRecordComponents())
.collect(toMap(RecordComponent::getName, RecordComponent::getAccessor));
}
}
public class TemplateRecordProvider { private final Map<Class<?>, Map<String, Method>> records = new HashMap<>(); public Map<String, Object> provide(Object recordObject, Environment environment) { Map<String, Method> methods = records.computeIfAbsent(recordObject.getClass(), b -> collectMethods(recordObject)); return new BaseReflectionsMap(methods, environment, recordObject); } private Map<String, Method> collectMethods(Object recordObject) { return Stream.of(recordObject.getClass().getRecordComponents()) .collect(toMap(RecordComponent::getName, RecordComponent::getAccessor)); } }
public class TemplateRecordProvider {

  private final Map<Class<?>, Map<String, Method>> records = new HashMap<>();

  public Map<String, Object> provide(Object recordObject, Environment environment) {
    Map<String, Method> methods = records.computeIfAbsent(recordObject.getClass(), b -> collectMethods(recordObject));
    return new BaseReflectionsMap(methods, environment, recordObject);
  }

  private Map<String, Method> collectMethods(Object recordObject) {
    return Stream.of(recordObject.getClass().getRecordComponents())
        .collect(toMap(RecordComponent::getName, RecordComponent::getAccessor));
  }
}

Der

TemplateRecordProvider
TemplateRecordProvider sammelt wie sein Java Beans Gegenpart die Methode in eine
Map
Map und fügt sie in eine
BaseReflectionsMap
BaseReflectionsMap ein.

Nun fehlt nur noch die Verwendung des

TemplateRecordProvider
TemplateRecordProvider und die Unterstützung von Records in FreshMarker ist fertiggestellt.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
private final List<TemplateObjectProvider> providers = new ArrayList<>(
List.of(mappingTemplateObjectProvider, new RecordTemplateObjectProvider(), new CompoundTemplateObjectProvider(),
new BeanTemplateObjectProvider()));
private final List<TemplateObjectProvider> providers = new ArrayList<>( List.of(mappingTemplateObjectProvider, new RecordTemplateObjectProvider(), new CompoundTemplateObjectProvider(), new BeanTemplateObjectProvider()));
private final List<TemplateObjectProvider> providers = new ArrayList<>(
      List.of(mappingTemplateObjectProvider, new RecordTemplateObjectProvider(), new CompoundTemplateObjectProvider(),
          new BeanTemplateObjectProvider()));

Der neue

RecordTemplateObjectProvider
RecordTemplateObjectProvider wird in den vorderen Bereich der Standard Provider eingefügt und bei der Suche nach einer passenden Implementierung direkt nach dem
MappingTemplateObjectProvider
MappingTemplateObjectProvider aufgerufen. Der
MappingTemplateObjectProvider
MappingTemplateObjectProvider prüft ob eine Klasse zu den von Haus aus unterstützten Typen (
String
String,
Boolean
Boolean,
Integer
Integer,
Long
Long,
Date
Date, …) oder zusätzlich durch Erweiterungen hinzugefügte Typen (
LocalDateTime
LocalDateTime,
LocalDate
LocalDate,
LocalTime
LocalTime,
Duration
Duration,
Period
Period,
File
File,
Path
Path, …) handelt.

Der neue

RecordTemplateObjectProvider
RecordTemplateObjectProvider prüft nur, ob es sich um einen Record Typen handelt und erzeugt dann ggf. eine
TemplateBean
TemplateBean Instanz.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
public class RecordTemplateObjectProvider implements TemplateObjectProvider {
private final TemplateRecordProvider recordProvider = new TemplateRecordProvider();
@Override
public TemplateObject provide(Environment environment, Object o) {
if (!o.getClass().isRecord()) {
return null;
}
return new TemplateBean(recordProvider.provide(o, environment));
}
}
public class RecordTemplateObjectProvider implements TemplateObjectProvider { private final TemplateRecordProvider recordProvider = new TemplateRecordProvider(); @Override public TemplateObject provide(Environment environment, Object o) { if (!o.getClass().isRecord()) { return null; } return new TemplateBean(recordProvider.provide(o, environment)); } }
public class RecordTemplateObjectProvider implements TemplateObjectProvider {

  private final TemplateRecordProvider recordProvider = new TemplateRecordProvider();

  @Override
  public TemplateObject provide(Environment environment, Object o) {
    if (!o.getClass().isRecord()) {
      return null;
    }
    return new TemplateBean(recordProvider.provide(o, environment));
  }
}

Wer Records mit FreshMarker ausprobieren möchte, findet die aktuelle Version auf Maven Central.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
<dependency>
<groupId>de.schegge</groupId>
<artifactId>freshmarker</artifactId>
<version>0.2.3</version>
</dependency>
<dependency> <groupId>de.schegge</groupId> <artifactId>freshmarker</artifactId> <version>0.2.3</version> </dependency>
<dependency>
  <groupId>de.schegge</groupId>
  <artifactId>freshmarker</artifactId>
  <version>0.2.3</version>
</dependency>

Leave a Comment