String-Templates mit Java

Veröffentlicht am 15. December 2010

Wenn man in Java mit komplexeren Strings arbeiten will, benötigt man oft Funktionen, um das Ergebnis aus einer Vorlage zu erzeugen, indem verschiedene Variablenwerte eingesetzt werden.
Vor allem, um Textdateien in einem bestimmten Format (z.B. CSV oder HTML) zu erzeugen, bei dem die Grundstruktur irgendwo entworfen und abgespeichert werden soll, macht ein Templatesystem für Strings Sinn.

Für die Web-Ausgabe gibt es JSP, um Inhalte in ein Template einzufügen und auszugeben.
Aber für lokale Anwendungen ist mir bisher kein solches System bekannt.
Einfache Sachen lassen sich ja in Java hartkodieren oder mit Suchen-und-Ersetzen-Methoden generieren, aber je komplizierter die Struktur wird, desto mehr Arbeit macht auch die Implementierung.

Um dieses Problem ein für alle Mal zu lösen, habe ich eine Template-Klasse geschrieben, die String-Templates mit folgenden Funktionen repräsentieren:

  • Ersetzen von Variablen
  • Auswählen von If-Then-Else-Verzweigungen
  • Iterieren über Collections (mit Code-Duplikation)

Ersetzen von Variablen

Diese einfache Funktion lässt sich normalerweise auch über String.replace() oder String.replaceAll() bewältigen.
Die Idee ist einfach, dass ich in meinem Template irgendwo den Ausdruck ${name} durch Metax ersetzen möchte.

Template tmpl = new Template("${name} ist toll! ${name} rockt!");
tmpl.replaceVariable("name", "Metax");
System.out.println(tmpl.toString()); // Metax ist toll! Metax rockt!

Natürlich soll das alles mit unterschiedlichen Variablen funktionieren und es sollen alle Auftreten der entspechenden Variablen ersetzt werden.

Auswählen von If-Then-Else-Verzweigungen

Die Idee hinter dieser Anforderung ist es, ein gewisses Maß an Logik in die Templates legen zu können.
Es sollen mit Namen versehene If-Then-Else-Blöcke in das Template eingefügt werden, die dann später vom Programm zu “wahr” oder “falsch” ausgewertet werden können. Der jeweilige Block wird angezeigt, der andere wird irgnoriert.
Der Else-Block soll bei Bedarf auch weggelassen werden können.

Template zahl1 = new Template("Die Zahl ${zahl} hat das Quadrat ${quad} und "
  + "ist ${If:gerade}gerade${Else:gerade}ungerade${EndIf:gerade}.");
Template zahl2 = zahl1.copy();
Template zahl3 = zahl1.copy();

zahl1.replaceVariable("zahl", 2);
zahl1.replaceVariable("quadrat", 4);
zahl1.replaceIf("gerade", true);

zahl2.replaceVariable("zahl", 4);
zahl2.replaceVariable("quadrat", 16);
zahl2.replaceIf("gerade", true);

zahl3.replaceVariable("zahl", 5);
zahl3.replaceVariable("quadrat", 25);
zahl3.replaceIf("gerade", false);

System.out.println(zahl1.toString()); // Die Zahl 2 hat das Quadrat 4 und ist gerade.
System.out.println(zahl2.toString()); // Die Zahl 4 hat das Quadrat 16 und ist gerade.
System.out.println(zahl3.toString()); // Die Zahl 5 hat das Quadrat 25 und ist ungerade.

Besonders sinnvoll wird diese Ersetzung natürlich, wenn größere Blöcke von dem If-The-Else umfasst werden und diese insbesondere selbst noch Variablen enthalten.

Iterieren über Collections

Dies Anforderungen haben dieses Template-System für mich besonders nützlich gemacht.
Es soll im Template ein Loop-Block definiert werden, der mit einem Namen versehen wird.
Im Programm soll eine beliebige iterierbare Datenmenge (also eigentlich eine Collection) durchlaufen werden können und für jedes Element dieser Collection soll eine Instanz Loop-Inhaltes erzeugt und mit korrekten Werten ersetzt werden.
Diese einzelnen Blöcke werden dann am Schluss zusammengesetzt und sind die Ersetzung für den ganzen Loop-Block.

Dadurch ist es möglich, viele Strukturen auf das Template abzubilden, die im Java-Programm dann mit den korrekten Inhalten gefüllt werden.

Da in der inneren Schleife aber für jedes Element eine andere Ersetzung möglich sein soll, muss eine Art Callback-Mechanismus in Java implementiert werden.
Da Java keine Funktionspointer kennt, muss ein Interface geschaffen werden, das die Ersetzung definiert.
Dieses Interface muss dann für eine konkrete Ersetzung implementiert werden (z.B. in einer anonymen Klasse).

Ich habe folgendes Interface gewählt:

public static interface LoopCallback<T> {
	public String replaceIteration(T obj, Template innerContent,
		int iteration, int max_iteration);
}

Durch den Typparameter T kann über Collections von beliebigen Typen iteriert werden.

Die Funktion replaceIteration() wird für jedes Element der Collection aufgerufen und über das Template innerContent können Variablenersetzungen vorgenommen werden (natürlich auch If- oder sogar weitere Loop-Ersetzungen).

Um das zu demonstrieren, hier ein Beispiel:

Template tmpl = new Template("Hier eine Zahlenreihe:\n"
	+ "${Loop:zahlen} ${i}) Die Zahl ${zahl} hat das Quadrat ${quad}"
	+ " und ist ${If:gerade}gerade${Else:gerade}ungerade${EndIf:gerade}.\n"
	+ "${EndLoop:zahlen}");

List<Integer> zahlen = Arrays.asList(1, 2, 3, 4, 5, 10, 15, 20, 100);

tmpl.replaceLoop("zahlen", zahlen, new LoopCallback<Integer>() {
	@Override
	public String replaceIteration(Integer zahl, Template innerContent,
			int iteration, int max_iteration) {
		
		innerContent.replaceVariable("i", iteration + 1);
		innerContent.replaceVariable("zahl", zahl);
		innerContent.replaceVariable("quad", zahl*zahl);
		innerContent.replaceIf("gerade", zahl % 2 == 0);
		return innerContent.toString();
	}
});

System.out.println(tmpl.toString());

Ausgabe:

Hier eine Zahlenreihe:
 1) Die Zahl 1 hat das Quadrat 1 und ist ungerade.
 2) Die Zahl 2 hat das Quadrat 4 und ist gerade.
 3) Die Zahl 3 hat das Quadrat 9 und ist ungerade.
 4) Die Zahl 4 hat das Quadrat 16 und ist gerade.
 5) Die Zahl 5 hat das Quadrat 25 und ist ungerade.
 6) Die Zahl 10 hat das Quadrat 100 und ist gerade.
 7) Die Zahl 15 hat das Quadrat 225 und ist ungerade.
 8) Die Zahl 20 hat das Quadrat 400 und ist gerade.
 9) Die Zahl 100 hat das Quadrat 10000 und ist gerade.

Die Klasse Template

Diese Anforderungen habe ich alle in der Klasse Template umgesetzt.
Ich habe mich entschieden, diese Klasse zur freien Verfügung unter der Lesser GPL zu veröffentlichen.

Ihr dürft meine Template-Klasse also gerne in eueren Java-Projekten benutzen.

Über Feedback würde ich mich freuen.

Template.java
package de.planet_metax.util;

import java.io.Serializable;
import java.util.ArrayList;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * String Template: mutable String representation
 * which allows for some basic replacement operations.
 * <br><br>
 * To define variables in the templates, use the syntax:
 * <code>${VariableName}</code>, where VariableName denotes
 * the name (or id) of the variable (variable names are case
 * insensitive).<br>
 * If the variable name starts with <code>If:</code>,
 * <code>Else:</code>, <code>EndIf:</code>, <code>Loop:</code>
 * or <code>EndLoop:</code>, the variable serves as boundary mark
 * for some replacement funtions.<br><br>
 * Supported Operations:
 * <ul><li>Replace variables: {@link #replaceVariable(String, String)}</li>
 * <li>Conditional replacement: {@link #replaceIf(String, boolean)}</li>
 * <li>Looped replacement: {@link #replaceLoop(String, Iterable, LoopCallback)}</li>
 * <li>Inclusion of another template: {@link #replaceInclude(String, Template)}</li>
 * 
 * @author Christian Simon
 * @version 1.0
 */
public class Template implements Serializable {

	private static final long serialVersionUID = 5418676608433977081L;
	
	private String content;
	
	/**
	 * Create new template from string.
	 * @param initialContent the initial template content.
	 */
	public Template(String initialContent) {
		this.content = initialContent;
	}
	
	/**
	 * Get the template contents as {@link String}.
	 * @return contents
	 */
	public String getTemplateContent() {
		return this.content;
	}
	
	/**
	 * Create another template with the same content.
	 * @return template copy
	 */
	public Template copy() {
		return new Template(this.content);
	}
	
	/**
	 * Replace all instances of the variable <code>${var}</code>
	 * with the replacement.
	 * @param var the variable name to replace
	 * @param replacement the replacement
	 */
	public void replaceVariable(String var, String replacement) {
		if (replacement == null) {
			replaceVariable(var, "");
		}
		this.content = this.content.replaceAll("(?is)\\$\\{" + Pattern.quote(var)
			+ "\\}", Matcher.quoteReplacement(replacement));
	}
	
	/**
	 * @see #replaceVariable(String, String)
	 */
	public void replaceVariable(String var, int replacement) {
		replaceVariable(var, "" + replacement);
	}
	
	/**
	 * Replace all instances of the variable <code>${include}</code>
	 * with the contents of the replacement template.
	 * @param include the variable name
	 * @param replacement the template, which contains the replacement string
	 */
	public void replaceInclude(String include, Template replacement) {
		if (replacement == null) {
			replaceVariable(include, null);
			return;
		}
		replaceVariable(include, replacement.getTemplateContent());
	}
	
	/**
	 * Replace the inner content between the tokens <code>${If:var}</code>
	 * and <code>${EndIf:var}</code> depending of the condition parameter.
	 * <br>
	 * There might be a token <code>${Else:var}</code> in the inner content.
	 * If it is not, the else token is implicit assumed directly before the
	 * <code>${EndIf:var}</code> token.<br>
	 * When the condition is true, the inner content is replace with the text
	 * between <code>${If:var}</code> and <code>${Else:var}</code>.
	 * Otherwise it is replaced with the text between <code>${Else:var}</code>
	 * and <code>${EndIf:var}</code>.<br><br>
	 * Example:
	 * <table><tr><th>template</th><th>cond=true</th><th>cond=false</th></tr>
	 * <tr><td>${If:var}Foo${EndIf:var}</td><td>Foo</td><td></td></tr>
	 * <tr><td>${If:var}Foo${Else:var}Bar${EndIf:var}</td><td>Foo</td><td>Bar</td></tr>
	 * </table>
	 * @param var the variable name
	 * @param condition the condition to check
	 */
	public void replaceIf(String var, boolean condition) {
		Pattern pattern = Pattern.compile("\\$\\{If:" + Pattern.quote(var)
			+ "\\}(.*?)\\$\\{EndIf:" + Pattern.quote(var) + "\\}",
			Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
		
		Matcher m = pattern.matcher(this.content);
		m.reset();
		boolean found = m.find();
		if (found) {
			StringBuffer sb = new StringBuffer();
			do {
				String if_content;
				String else_content = "";
				String[] parts = m.group(1).split("(?is)\\$\\{Else:"
					+ Pattern.quote(var) + "\\}", 2);
				if_content = parts[0];
				if (parts.length == 2) {
					else_content = parts[1];
				}
				m.appendReplacement(sb, Matcher.quoteReplacement(condition ? if_content : else_content));
				found = m.find();
			} while (found);
			m.appendTail(sb);
			this.content = sb.toString();
		}
	}
	
	/**
	 * Replace the inner content between the tokens <code>${Loop:var}</code>
	 * and <code>${EndLoop:var}</code> with the concatenated result of the
	 * callback replacement performed for each element of the data parameter.
	 * @param <T> data type of the data payload
	 * @param var the variable name
	 * @param data iterable data object with payload type &lt;T&gt;
	 * @param callback a callback object
	 */
	public <T> void replaceLoop(String var, Iterable<T> data, LoopCallback<T> callback) {
		ArrayList<T> data_list = new ArrayList<T>();
		for (T t : data) {
			data_list.add(t);
		}
		final int max_iteration = data_list.size() - 1;
		
		Pattern pattern = Pattern.compile("\\$\\{Loop:" + Pattern.quote(var)
				+ "\\}(.*?)\\$\\{EndLoop:" + Pattern.quote(var) + "\\}",
				Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
			
		Matcher m = pattern.matcher(this.content);
		m.reset();
		boolean found = m.find();
		if (found) {
			StringBuffer sb = new StringBuffer();
			do {
				String innerContent = m.group(1);
				StringBuffer cumulativeContent = new StringBuffer();
				
				// Iterate over data elements
				for (int i = 0; i <= max_iteration; i++) {
					Template innerTemplate = new Template(innerContent);
					String replaced = callback.replaceIteration(data_list.get(i), innerTemplate, i, max_iteration);
					cumulativeContent.append(replaced);
				}
				
				m.appendReplacement(sb, Matcher.quoteReplacement(cumulativeContent.toString()));
				found = m.find();
			} while (found);
			m.appendTail(sb);
			this.content = sb.toString();
		}
		
	}
	
	@Override
	public String toString() {
		return this.content;
	}
	
	/**
	 * Interface to a handler to the template function
	 * {@link Template#replaceLoop(String, Iterable, LoopCallback)}.
	 * 
	 * @author Christian Simon
	 * @param <T> data type of the payload
	 */
	public static interface LoopCallback<T> {
		
		/**
		 * This method is invoked for every item in the data parameter
		 * of {@link Template#replaceLoop(String, Iterable, LoopCallback)}.  
		 * @param obj the current data object
		 * @param innerContent template with the inner content of the loop
		 * @param iteration the current iteration number
		 * @param max_iteration the maximum iteration number
		 * @return replacement for the inner content of the loop
		 */
		public String replaceIteration(T obj, Template innerContent, int iteration, int max_iteration);
		
	}

}

Update:
Es gibt mittlerweile deutlich ausgereiftere Template-Systeme für solche Anwendungen, wie besipielsweise mustache, so dass sich eine Eigenentwicklung hier nicht mehr besonders empfiehlt.