„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.