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.

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, hashCode und 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.

public record Ancestor(String firstName, String lastName, LocalDate dateOfBirth, LocalDate dateOfDeath) {
}

Dieser Record besitzt wie die Java Bean die Methoden equals, hashCode und toString. Außerdem vier Accessor Methoden firstName(), lastName(), dateOfBirth() und 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.

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 hält den ursprünglichen Ancestor Record und das berechnete Alter der entsprechenden Person. Dann wird der Stream nach dem Alter sortiert und die 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 nur Map<String, Object> Implementierungen und Java Beans unterstützt.

Beide Varianten werden durch die Klasse TemplateBean realisiert, die eine 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 zurückgeliefert. Für Map<String, Object> Instanzen ist es eben diese Instanz, für Java Beans ist es eine eigene Subklasse BaseReflectionsMap der AbstractMap, die via Reflections die Attribute mit dem entsprechenden Namen ausliest. Die entsprechenden Methoden der Java Bean erhält die Map vom java.beans.Introspector. Der hier dargestellte TemplateBeanProvider kümmert sich um die Auswertung der Java Bean und cached das Ergebnis für weitere Anfragen.

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 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 kann auf die Namen und Methoden der einzelnen Record Komponenten zugegriffen werden.

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 sammelt wie sein Java Beans Gegenpart die Methode in eine Map und fügt sie in eine BaseReflectionsMap ein.

Nun fehlt nur noch die Verwendung des TemplateRecordProvider und die Unterstützung von Records in FreshMarker ist fertiggestellt.

private final List<TemplateObjectProvider> providers = new ArrayList<>(
      List.of(mappingTemplateObjectProvider, new RecordTemplateObjectProvider(), new CompoundTemplateObjectProvider(),
          new BeanTemplateObjectProvider()));

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

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

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.

<dependency>
  <groupId>de.schegge</groupId>
  <artifactId>freshmarker</artifactId>
  <version>0.2.3</version>
</dependency>

Schreibe einen Kommentar