FreshMarker Feiertage und Enums

„Beauty is more important in computing than anywhere else in technology because software is so complicated. Beauty is the ultimate defence against complexity.“

David Gelernter

Zu den ersten Komponenten der FreshMarker Template-Engine gehörte ein einfaches Plug-In System. Durch diese Komponente wurde die Ergänzung um neue Built-Ins, Formatter und Mapper vereinfacht. Zwei neue Plug-Ins zeigen dabei Stärken und Schwächen des bisherigen Ansatzes.

Die Template-Engine unterstütze bislang keine Enum Typen. Um Enum Werte darzustellen müssen diese bisher in eine textuelle oder numerische Darstellung umgewandelt werden. Das folgende Enum besitzt vier Konstanten ALPHA, BETA, GAMMA und DELTA und für die Nutzung in einem Template drei Darstellungsformen. Den Namen der Konstanten über die name Methode, die Darstellung über die toString Methode und die Ordinalzahl über die ordinal Methode.

enum TestEnum {
  ALPHA("Α"),
  BETA("Β"),
  GAMMA("Γ"),
  DELTA("Δ");

  private final String name;

  TestEnum(String name) {
    this.name = name;
  }

  @Override
  public String toString() {
    return name;
  }
}

Um den Konventionen zu entsprechen wird für die Interpolation ${enum} die toString Methode des Enum verwendet. Die Interpolation ${enum?c} mit dem Built-In c für die computer language Darstellung verwendet die name Methode und die Interpolation ${enum?ordinal} mit dem Built-In ordinal stellt die Ordinalzahl des Enum dar.

Für die Enums wird ein eigener Datentyp benötigt, die beiden Built-Ins c und ordinal, sowie ein Mapper. Der eigene Datentyp ist zügig erstellt, weil er als Subklasse von TemplatePrimitive für Enum Klassen angelegt wird.

public class TemplateEnum<E extends Enum<E>> extends TemplatePrimitive<E> {
  public TemplateEnum(E value) {
    super(value);
  }
}

Die beiden Built-Ins werden im Plug-In als Built-In Methoden angelegt. Dabei erzeugt die Methode computerBuiltIn eine TemplateString Instanz und die Methode ordinal eine TemplateNumber Instanz mit den passenden Enum Methoden.

@BuiltInMethod("c")
public static TemplateString computerBuiltIn(TemplateEnum<?> value) {
  return new TemplateString(value.getValue().name());
}

@BuiltInMethod("ordinal")
public static TemplateNumber ordinal(TemplateEnum<?> value) {
  return new TemplateNumber(value.getValue().ordinal());
}

Obwohl der Einbau der Enum Unterstützung bislang recht einfach war, ergibt sich bei dem Mapper ein Problem. Bis auf zwei Ausnahmen wurden die Mapper bisher mit Hilfe einer Map realisiert.

Map<Class<?>, Function<Object, TemplateObject>> mapper = new HashMap<>();
mapper.put(String.class, o -> new TemplateString((String) o));
mapper.put(Long.class, o -> new TemplateNumber(new LongNumber((Long) o)));
mapper.put(Integer.class, o -> new TemplateNumber(new IntegerNumber((Integer) o)));
mapper.put(Short.class, o -> new TemplateNumber(new ShortNumber((Short) o)));
mapper.put(Byte.class, o -> new TemplateNumber(new ByteNumber((Byte) o)));
mapper.put(Double.class, o -> new TemplateNumber(new DoubleNumber((Double) o)));
mapper.put(Float.class, o -> new TemplateNumber(new FloatNumber((Float) o)));
mapper.put(Boolean.class, o -> Boolean.TRUE.equals(o) ? TemplateBoolean.TRUE : TemplateBoolean.FALSE);

Der Schlüsse in der Map ist die jeweilige Ursprungsklasse und der Wert ist eine Funktion, die den eine Instanz der Ursprungsklasse in ein TemplateObject wrapped. Bei den Enum Subklassen funktioniert das aber nicht, weil sie alle unbekannterweise in der Map aufgeführt sein müssten.

Die bereits erwähnten Ausnahmen haben das gleiche Problem wie die Enums. Sie sind die generischen Basisklassen für Listen und Maps. Auch hier kann nicht mit der Map gearbeitet werden. Damit Enums auch gemappt werden können muss die zentrale wrap Methode angepasst werden.

private TemplateObject wrap(Object o) {
  if (o == null) {
    return TemplateNull.NULL;
  }
  if (o instanceof TemplateObject) {
    return (TemplateObject) o;
  }
  if (o instanceof List) {
    List<Object> values = (List<Object>) o;
    return new TemplateListSequence(values);
  }
  if (o instanceof Map) {
    Map<String, Object> values = (Map<String, Object>) o;
    return new TemplateBean(values);
  }
  if (o instanceof Enum<?>) {
    return new TemplateEnum<>((Enum)o);
  }
  Function<Object, TemplateObject> mapping = mapper.get(o.getClass());
  if (mapping != null) {
    return mapping.apply(o);
  }
  if (!o.getClass().isPrimitive() && !o.getClass().getName().startsWith("java")) {
    return new TemplateBean(beanProvider.provide(o, this));
  }
  throw new UnsupportedDataTypeException("unsupported data type: " + o.getClass());
}

Es wird jetzt in der Methode auch geprüft, ob das Object vom Typ Enum ist, um dann ggf. eine TemplateEnum Instanz zu erzeugen.

Keine wirklich elegante Lösung, wenn der Anspruch besteht, eine erweiterbare Template-Engine zu entwickeln. Eine einfache Verbesserung ist das Verlagern der Logik in Implementierungen des Interface TemplateObjectProvider. Mit diesen Implementierungen reduziert sich die Logik in der wrap Methode und durch die Liste von TemplateObjectProvider ist die Möglichkeit für eine saubere Erweiterbarkeit gegeben.

private final List<TemplateObjectProvider> providers;

private TemplateObject wrap(Object o) {
  if (o == null) {
    return TemplateNull.NULL;
  }
  if (o instanceof TemplateObject) {
    return (TemplateObject) o;
  }
  return providers.stream().map(p -> p.provide(this, o)).filter(Objects::nonNull)
      .findFirst().orElseThrow(() -> new UnsupportedDataTypeException("unsupported data type: " + o.getClass()));
}
  }

Die Liste der TemplateObjectProvider wird in der Konfiguration bereitgestellt und durch Plug-Ins ergänzt. Der ursprüngliche Code wirkt primitiv und fehleranfällig.

   public void registerPlugin(PluginProvider provider) {
    logger.info("register plugin: {}", provider.getClass().getSimpleName());
    provider.registerBuildIn(builtIns);
    provider.registerFormatter(formatter);
    provider.registerMapper(mappingTemplateObjectProvider.getMapper());
    provider.registerTemplateObjectProvider(providers);
}

Die internen Strukturen der Konfiguration werden direkt an die Methoden des PluginProviders übergeben und dieser befüllt sie nach eigenem Gusto. Das Exponieren interner Strukturen ist keine Best Practice und sollte unterlassen werden. An dieser Stelle ergibt sich zusätzlich das Problem, dass am Ende der Liste immer der BeanTemplateObjectProvider stehen muss. Dieser TemplateObjectProvider ist die letzte Möglichkeit eine unbekannte Klasse als TemplateBean zur Verfügung zu stellen. Die TemplateObjectProvider die nach diesem eingefügt werden, erhalten kaum die Chance aufgerufen zu werden.

Aber auch diese einfache Schnittstelle lässt sich durch eine Indirektion sicherer und sauberer gestalten. Die folgende Version mit zwei zusätzlichen Zeilen sorgt auch die die Beachtung des BeanTemplateObjectProvider.

   public void registerPlugin(PluginProvider provider) {
    logger.info("register plugin: {}", provider.getClass().getSimpleName());
    provider.registerBuildIn(builtIns);
    provider.registerFormatter(formatter);
    provider.registerMapper(mappingTemplateObjectProvider.getMapper());
    List<TemplateObjectProvider> list = new ArrayList<>();
    provider.registerTemplateObjectProvider(list);
    providers.addAll(providers.size() - 2, list);
}

Statt die echte providers Liste zu übergeben, wird dem PluginProvider eine leere Liste übergeben. Nachdem die Liste befüllt wurde, fügt die Konfiguration die enthaltenen TemplateObjectProvider vor die letzte Position in der providers Liste ein. Auf diese Weise können auch die internen Konstrukte builtIns, formatter und mappingTemplateObjectProvider.getMapper() vor den PluginProvider verborgen werden.

<#list sequence as s>
${s?counter}. ${s} name=${s?c} ordinal=${s?ordinal}
</#list>

Mit dem EnumPluginProvider und dem obigen Template und dem Datenmodel Map.of("sequence", EnumSet.allOf(TestEnum.class))) produziert die Template-Engine die folgende Ausgabe.

1. Α name=ALPHA ordinal=0
2. Β name=BETA ordinal=1
3. Γ name=GAMMA ordinal=2
4. Δ name=DELTA ordinal=3

Eine weitere Ergänzung in Form eines Plug-Ins ist die Unterstützung der Holidays Bibliothek. Das Plug-In stellt zwei Built-Ins für LocalDate Interpolations bereit.

Das Built-In get_holiday liefert den Namen eines Feiertages. Die erste Variante ${date?get_holiday} liefert für den 25. Dezember den Text “1. Weihnachtstag” und die zweite Variante ${date?get_holiday('Kein Feiertag')} liefert für den 24. August den Text “Kein Feiertag“.

Das Built-In is_holiday liefert den boolean Wert TRUE wenn das entsprechende Datum ein Feiertag ist und anderenfalls den Wert FALSE.

@Override
public void registerBuildIn(Map<BuiltInKey, BuiltIn> builtIns) {
  builtIns.put(DATE_BUILDER.of("get_holiday"),
      new FunctionalBuiltIn((TemplateObject x, List<TemplateObject> y, ProcessContext c) -> {
        Locale locale = checkCountry(c.getEnvironment().getLocale());
        TemplateLocalDate value = x.evaluate(c, TemplateLocalDate.class);
        return new TemplateString(getHolidaysFromStore(c, locale).getHoliday(value.getValue()).orElseGet(() -> getOptionalFallback(y)));
      }));
  builtIns.put(DATE_BUILDER.of("is_holiday"),
      new FunctionalBuiltIn((TemplateObject x, List<TemplateObject> y, ProcessContext c) -> {
        Locale locale = checkCountry(c.getEnvironment().getLocale());
        TemplateLocalDate value = x.evaluate(c, TemplateLocalDate.class);
        return TemplateBoolean.from(getHolidaysFromStore(c, locale).isHoliday(value.getValue()));
      }));
}

Beide Built-Ins sind als FunctionalBuiltIn implementiert. Gemeinsam ist beiden Built-Ins, dass zuerst das aktuelle Locale auf eine Länderangabe geprüft wird. Die Holidays Bibliothek benötigt diese Angabe, damit eine landesspezifische Feiertag geprüft werden kann. Zusätzlich wird aus dem übergebenen TemplateObject ein LocalDate bestimmt. Danach wird eine Holidays Instanz für dieses Locale erzeugt und die entsprechende Methode darauf aufgerufen.

Damit nicht jeder Aufruf im Dokument ein neues Holidays Objekt erzeugt, werden diese im ProcessContext zwischengespeichert.

private Holidays getHolidaysFromStore(ProcessContext c, Locale locale) {
  return (Holidays) c.getStore("holidays").computeIfAbsent(locale, l -> Holidays.in(locale, locale));
}

Dafür erhält der ProcessContext eine zusätzliche Map auf die jederzeit über getStore zugegriffen werden kann. In dieser werden unter dem Schlüssel holidays die Holidays Instanzen pro Locale gespeichert.

<#list sequence as s>
${s?counter}. ${s} ${s?get_holiday}
</#list>

Mit dem HolidayPluginProvider und dem obigen Template und dem Datenmodel Map.of("sequence", Holidays.in(Locale.GERMANY).getHolidays(2022).keySet().stream().sorted().collected(toList())) produziert die Template-Engine die folgende Ausgabe.

1. 2022-01-01 Neujahr
2. 2022-04-15 Karfreitag
3. 2022-04-18 Ostermontag
4. 2022-05-01 Tag der Arbeit
5. 2022-05-26 Christi Himmelfahrt
6. 2022-06-06 Pfingstmontag
7. 2022-10-03 Tag der Deutschen Einheit
8. 2022-12-25 1. Weihnachtstag
9. 2022-12-26 2. Weihnachtstag

Damit sind zwei neue Plugins für Freshmarker erstellt und nebenbei die Template-Engine durch neue Anforderungen verbessert worden. Schwächen am Code, wie die Änderungen an der Konfiguration durch die Plugins, können häufig durch kleine umsichtige Änderungen verbessert werden.

Schreibe einen Kommentar