Javaで最強の非HTMLテンプレートエンジンを考えてみる
モチベーション
プレーンテキスト用のテンプレートエンジンがほしい!!というのがきっかけです。
(JSライブラリのejsのようなもの。)
具体的には以下のような機能を兼ね備えたプレーンテキスト用のテンプレートエンジンが必要でした。
- コンテキストで設定したオブジェクトを、テンプレートエンジンで参照可能
- コンテキストで設定した引数あり関数を、テンプレート内で実行可能。
- テンプレート内で四則演算可能
- テンプレート内で三項演算子が実行可能
JavaにはThymeleafやJSPなど、HTML用のテンプレートエンジンは優秀なものが多いのですが、プレーンテキスト用となると、今回の条件に合致するものがありません。
それならJSP内で使われているEL式を、プレーンテキスト用テンプレートエンジンとして使いたい!ということで調べてみました。
テンプレートエンジンとしてEL式を使う方法
まず、依存関係としてjakartaのel式APIを追加します。
- jakarta.el:jakarta.el-api:4.0.0
- org.glassfish:jakarta.el:4.0.2
Mavenの場合
<dependency>
<groupId>jakarta.el</groupId>
<artifactId>jakarta.el-api</artifactId>
<version>4.0.0</version>
</dependency>
<dependency>
<groupId>org.glassfish</groupId>
<artifactId>jakarta.el</artifactId>
<version>4.0.2</version>
</dependency>
以下の通り、実装します。
public static String eval(String template, Map<String, Object> contextData) {
// ELコンテキストとファクトリーの作成
ELProcessor elProcessor = new ELProcessor();
contextData.forEach(elProcessor::setValue);
StringBuilder result = new StringBuilder();
int start;
int end = 0;
// ${...} の箇所を見つけて評価するのを繰り返す
while ((start = template.indexOf("${", end)) != -1) {
result.append(template, end, start); // 式の前のテキストを追加
end = template.indexOf("}", start);
if (end == -1) {
break;
}
// ${...} 内の式を評価
String expression = template.substring(start + 2, end);
Object value = elProcessor.eval(expression.trim());
result.append(value != null ? value.toString() : "");
end++;
}
result.append(template, end, template.length());
return result.toString();
}
evalの第1引数にテンプレートを、第2引数にコンテキストを設定すればテンプレートエンジンとして利用可能です。
中身はEL式なので当たり前ですが、関数呼び出しや四則演算、3項演算子などの利用も可能です。
public static void main(String[] args) {
Map<String, Object> context = new HashMap<>();
context.put("name", "Namiken");
context.put("condition", "hoge");
// コンテキストにアクセス
System.out.println(eval("こんにちわ。私の名前は${name}です。", context));
// 演算
System.out.println(eval("1+1は${1+1}. 2*2は${2*2}", context));
// 関数呼び出しと3項演算子
System.out.println(eval("${condition.equals('hoge') ? 'hoge!!' :'no hoge'}", context));
// 以下でも同じ挙動になる
System.out.println(eval("${condition eq 'hoge' ? 'hoge!!' :'no hoge'}", context));
}
その他、EL式で使える以下の機能も利用可能です。
- 配列へのアクセス
- コンテキストにDTOを指定
- getterを連想配列としてアクセス(
parson['name']
で、parson.getName()
を実行。) - not/empty/eqなどのEL式独自演算子
EL式内で使える独自関数を定義したい場合
EL式内で関数呼び出しを行う場合のいちばん簡単な方法は、コンテキストとして関数を含むクラスのインスタンスを渡すことです。
Map<String, Object> context = new HashMap<>();
context.put("util", new Util());
eval("${util.someFn()}", context);
しかし場合によってはeval("${someFn()}", context);
といった形で直接関数を呼びたい場合があります。
そのような場合は、カスタムResolverを作成する必要があります。
以下に、eval("${now('<日付フォーマット>')}", context);
とした場合、現在の日付を指定されたフォーマットに変換し表示するような例を記載します。
①now()が指定された場合に、現在の日付を返却するjakarta.el.LambdaExpression
を実装します。
public class NowLambdaExpression extends LambdaExpression {
public NowLambdaExpression() {
super(null, null);
}
@Override
public Object invoke(ELContext elContext, Object... args) throws ELException {
if (args.length != 1 || !(args[0] instanceof String)) {
throw new ELException("この関数を実行するためにはString型の引数が1つだけ必要です。");
}
return LocalDateTime.now().format(DateTimeFormatter.ofPattern((String) args[0]));
}
}
②ELResolverを拡張したResolverを作成します。この時、nowが指定された場合はNowLambdaExpressionを返却するようにします。
getValueはLambdaExpressionを返却した場合はLambdaExpression#invokeを実行し、それ以外はそのまま値として利用します。
class CustomELResolver extends ELResolver {
@Override
public Object getValue(ELContext context, Object base, Object property) {
if (base == null && "now".equals(property)) {
context.setPropertyResolved(true);
return new NowLambdaExpression();
}
return null;
}
@Override
public Class<?> getType(ELContext context, Object base, Object property) {
return null;
}
@Override
public void setValue(ELContext context, Object base, Object property, Object value) {
}
@Override
public boolean isReadOnly(ELContext context, Object base, Object property) {
return true;
}
@Override
public Iterator<FeatureDescriptor> getFeatureDescriptors(ELContext context, Object base) {
return null;
}
@Override
public Class<?> getCommonPropertyType(ELContext context, Object base) {
return null;
}
@Override
public Object invoke(ELContext context, Object base, Object method, Class<?>[] paramTypes, Object[] params) {
return super.invoke(context, base, method, paramTypes, params);
}
}
③ELProcessorのインスタンス作成後にCustomELResolverを設定します。
// ELコンテキストとファクトリーの作成
ELProcessor elProcessor = new ELProcessor();
contextData.forEach(elProcessor::setValue);
// CustomELResolverを登録
elProcessor.getELManager().addELResolver(new CustomELResolver());
~省略~
こうすることで、以下のように直接now関数を呼び出せます。
eval("今日は${now('uuuu/MM/dd')}です。");
[エラー解消]ExpressionFactoryImpl not foundの対応方法
独自のClassLoaderを使っているような環境ではCaused by: jakarta.el.ELException: Provider com.sun.el.ExpressionFactoryImpl not found
というエラーが発生することがあります。
結論からいうと、ELProcessorのインスタンス作成前に以下を実行することで回避可能です。
Thread.currentThread().setContextClassLoader(new ClassLoader() {
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
if ("com.sun.el.ExpressionFactoryImpl".equals(name)) { return com.sun.el.ExpressionFactoryImpl.class; }
return super.findClass(name);
}
});
ELProcessorのインスタンスが作成される際、jakarta.el.ExpressionFactory
の実装クラスがServiceLoaderによってロードされます。
しかし、独自のClassLoaderを使っている場合など特殊な状況下では実装クラスが見つからず本エラーが発生します。
(実際にMinecraftのBukkitプラグインでは上記エラーが発生しました。)
そのため一時的にClassLoaderを改ざんすることで回避しています。
[参考]テンプレートエンジン比較
参考にJavaのテンプレートエンジンを比較してみます。
※全て実際に触ったわけではないため、間違っていたらすみません。。。。
テンプレートエンジン名 | 四則演算可能 | 引数なし関数 | 引数あり関数 | プレーンテキスト |
---|---|---|---|---|
Velocity | ✕ | ◯ | ◯ | ◯ |
Mustache | ✕ | ◯ | ✕ | ◯ |
FreeMarker | ◯ | ◯ | ✕ | ◯ |
JSP | ◯ | ◯ | ◯ | ✕ |
Thymeleaf | ◯ | ◯ | ◯ | ✕ |
EL式以外を使っているテンプレートエンジンでは、「四則演算」や「引数あり関数の呼び出し」などの機能が制限されてしまいます。
2025/02/02 追記
@tokuhiromさんからpebbleというライブラリがあるという情報をもらいました。
これを使えば「四則演算」「引数あり関数呼び出し」「三項演算子」「プレーンテキスト対応」など可能です。
限りなく独自実装を減らすことができるので、どうしてもEL式を使いたいなどの特別な理由がなければ、pebbleを使うほうがよいでしょう。
Discussion
freemarker は四則演算できますよ。
あと、この手の用途だと pebble が結構いいんじゃ無いかと思います。
コメントありがとうございます
freemarkerは勉強不足だったのと、pebbleは知りませんでした
pebble使えばEL式なくてもよさそうですねw
一部本文修正しておきます。