関数型のテンプレートエンジンを作ってみる(0.3) - 条件分岐・繰り返しの導入

前回追加したdefineタグ、insertタグに加えて、今回は条件分岐タグ(ifタグ)と繰り返しタグ(foreachタグ)を導入する。


ifタグは以下のように、条件が満たされた時にその内部がレンダリングされる。

// 会員のみメッセージを表示
{if condition="customer.isMember"}
ようこそ、会員{insert value="customer.firstName"/}
{/if}

foreachタグは以下のように、itemsで渡されたCollectionのそれぞれについて、varで指定したシンボルに要素を設定し、内部をレンダリングする。

// customersのfirstNameをカンマ区切りで出力
{foreach items="customers" var=customer}
{insert value="customer.firstName"/},
{/foreach}

タグ属性のExpression(「"」で囲われた部分)には、ELを書けるものとする。ELには、今回MVELを使ってみた。*1

前回からの変更点: Contextに値を入れる

まず大きな変更点としては、今までTemplateしか入れられなかったContextに、リストやboolean値など一般的な値を入れられるようにする。これはforeachでコレクションを取ったり、ifでboolean値を取ったりするのに必要。


値を直に入れるのではなく、Valueという型で包むようにする。また、ValueとTemplateの共通の親としてAssociableというインタフェースを定義する。

public interface Associable<V, T extends Template<V, T>> {
    /** @return Templateならtrueを、Valueならfalseを戻す。*/
    boolean isTemplate();
    /** @return テンプレートとしての値を戻す */
    T asTemplate();
    /** @return Valueとしての値を戻す */
    Value<V, T> asValue();
}
public interface Value<V, T extends Template<V, T>> extends Associable<V, T> {
	V value();
}

TemplateインタフェースもAssociableをextendsするように変更する。

public interface Template<V, T extends Template<V, T>> extends Associable<V, T> {
    TemplatePair<V, T> apply(Context<V, T> context);
	boolean isReducible();
}


Contextのget()は、今までのようにTemplateを返すのではなく、Associableを返すようにする。

public interface Context<V, T extends Template<V, T>> {
    Associable<V, T> get(Symbol name);
}

これに伴いContextに関するユーティリティクラスの実装も変わるけど省略。


ついでに、Template、Valueの実装クラスを用意する。
Valueの実装クラスはSimpleValueとする。単にObject型の値を保持する。
Templateとして評価された場合はSimpleLiteralを戻す。

public class SimpleValue implements Value<Object, SimpleTemplate> {
    private final Object value;

    public SimpleValue(Object value) {
        this.value = value;
    }
    @Override
    public Object value() {
        return value;
    }
    @Override
    public boolean isTemplate() {
        return false;
    }
    @Override
    public Value<Object, SimpleTemplate> asValue() {
        return this;
    }
    @Override
    public SimpleTemplate asTemplate() {
        return new SimpleLiteral(value);
    }
}

Templateの実装クラスはAbstractSimpleTemplateとし、他のSimpleXX系のTemplate実装クラスはこれを継承する。
Valueとして評価された場合はthisをSimpleValueで包んで戻す。

public abstract class AbstractSimpleTemplate implements SimpleTemplate {
    @Override
    public boolean isTemplate() {
        return true;
    }
    @Override
    public Value<Object, SimpleTemplate> asValue() {
        return new SimpleValue(this);
    }
    @Override
    public SimpleTemplate asTemplate() {
        return this;
    }
}

asTemplate()/asValue()は単にキャストではなくて、相互に値の変換になっている。


前回SimpleSourceインタフェース(タグの属性値の評価方法を切り替える為のインタフェース)は属性値の内容を取得するのにgetTemplate()というメソッドを定義していたが、これをgetAssociable()という名前に変更し、戻り値もTemplateではなくAssociableに変更する。

public interface SimpleSource {
    SimpleTemplate getTemplate(Context<Object, SimpleTemplate> context);
}

↓↓↓

public interface SimpleSource {
    Associable<Object, SimpleTemplate> getAssociable(Context<Object, SimpleTemplate> context);
}

SimpleSourceの実装クラスや使用しているテンプレートのクラスも変わるけど省略。

前回からの変更点: ELを扱えるようにする。

前回は、タグの属性値で""を使った場合、SimpleExpressionというSimpleSourceの実装クラスを生成していた。
このSimpleExpressionの内容取得メソッドを変更し、「"で囲われた部分をリテラルTemplateとして取り出す」から「"で囲われた部分をELとして評価し、その値を取り出す」に変更する。

public class SimpleExpression implements SimpleSource {
    private final String expression;

    public SimpleExpression(String expression) {
        this.text = expression;
    }
    @Override
    public SimpleTemplate getTemplate(Context<Object, SimpleTemplate> context) {
        return new SimpleLiteral(expression);
    }
}

↓↓↓

public class SimpleExpression implements SimpleSource {
    private final String expression;

    public SimpleExpression(String expression) {
        this.expression = expression;
    }
    // 内容をcontextコンテキストでMVELとして評価し、値がAssociable型でなければSimpleValueで包んで戻す。
    // 内容が評価できない場合はnullを戻す。
    @Override
    public Associable<Object, SimpleTemplate> getAssociable(Context<Object, SimpleTemplate> context) {
        try {
            Object value = evaluate(context);
            if (!(value instanceof Associable)) {
                value = new SimpleValue(value);
            }
            return (Associable<Object, SimpleTemplate>) value;
        } catch (PropertyAccessException e) {
            return null;
        }
    }
    private Object evaluate(Context<Object, SimpleTemplate> context) {
        return MVEL.eval(expression, new HotplateVariableResolverFactory(context));
    }

HotplateVariableResolverFactoryはContextをMVELのコンテキストに読み替える為のクラスとして用意した。
実装方法が合ってるかはあまり良くわからないけど、とりあえずgetVariableResolver(String)でコンテキストの中身を戻すようにした。

    public static class HotplateVariableResolverFactory extends BaseVariableResolverFactory {
        private static final long serialVersionUID = 1L;
        private Context<Object, SimpleTemplate> context;

        public HotplateVariableResolverFactory(Context<Object, SimpleTemplate> context) {
            this.context = context;
        }
        @Override
        public VariableResolver getVariableResolver(String name) {
            Associable<Object, SimpleTemplate> value = context.get(Symbol.of(name));
            if (value != null) {
                return new SimpleValueResolver(value.asValue().value());
            } else {
                return null;
            }
        }
        @Override
        public boolean isTarget(String name) {
            return context.get(Symbol.of(name)) != null;
        }
        @Override
        public boolean isResolveable(String name) {
            return context.get(Symbol.of(name)) != null;
        }
        @Override
        public VariableResolver createVariable(String name, Object value) {
            throw new IllegalStateException();
        }
        @Override
        public VariableResolver createVariable(String name, Object value,
                Class<?> type) {
            throw new IllegalStateException();
        }
    }
}


この変更に伴って、既存のタグの仕様が少し変わる。
Expressionで文字列リテラルを表したい場合には、""で囲むだけではなく、中でさらに''で囲む必要がある

{define name=firstName value="花子" /}

↓↓↓

{define name=firstName value="'花子'" /}

Ifタグの定義

実行時にconditionタグの値を評価し、trueならタグの中身を評価して戻すようなタグを追加する。trueで無ければSimpleNopを戻す。
パース時のTagHandler、ProcessorPrototypeと実行時のProcessorを定義する。

public class IfTagHandler implements TagHandler {
    @Override
    public String[] tagNames() {
        return new String[] {"if"};
    }
    @Override
    public boolean requireSingleTag(String tagName) {
        return false; // シングルタグは取らない
    }
    @Override
    public boolean requireContainerTag(String tagName) {
        return true;
    }
    @Override
    public SimpleTemplatePrototype handleSingleTag(String tagName,
            List<Attribute> attributes) {
        throw new IllegalStateException("If cannot use as a single tag.");
    }

    @Override
    public SimpleTemplatePrototype handleContainerTag(String tagName,
            List<Attribute> attributes, List<SimpleTemplatePrototype> elements) {
        // conditionタグの値を取得
        Attribute conditionAttribute = Attribute.findAttribute("condition", attributes);
        return new SimpleIfProcessorPrototype(
                TagHandlerUtils.makeSource(conditionAttribute),
                new SimpleContainerPrototype(elements));
    }
}
public class SimpleIfProcessorPrototype implements SimpleTemplatePrototype {
    private final SimpleSource condition;
    private final SimpleTemplatePrototype contents;
    public SimpleIfProcessorPrototype(SimpleSource condition, SimpleTemplatePrototype contents) {
        super();
        this.condition = condition;
        this.contents = contents;
    }
    public SimpleTemplate instantiate(Context<Object, SimpleTemplate> lexicalContext) {
         return new SimpleIfProcessor(
                 lexicalContext, condition, 
                 contents.instantiate(lexicalContext));
    }
}
public class SimpleIfProcessor extends AbstractSimpleTemplate implements SimpleTemplate {
    private final Context<Object, SimpleTemplate> lexicalContext;
    private final SimpleSource condition;
    private final SimpleTemplate content;

    public SimpleIfProcessor(Context<Object, SimpleTemplate>lexicalContext,
            SimpleSource condition, SimpleTemplate contents) {
        super();
        this.lexicalContext = lexicalContext;
        this.condition = condition;
        this.content = content;
    }

    @Override
    public TemplatePair<Object, SimpleTemplate> apply(final Context<Object, SimpleTemplate> context) {
        Context<Object, SimpleTemplate> merged = ContextUtils.merge(context, lexicalContext);
        // conditionの値を取得
        Associable<Object, SimpleTemplate> conditionAssociable = this.condition.getAssociable(merged);

        if (conditionAssociable == null) {
           // 定義が見つからない場合は自分自身を戻す=未評価
           return TemplatePair.<Object, SimpleTemplate>pairOf(this);
        }

        Object conditionValue = conditionAssociable.asValue().value();
        if (conditionValue != null && conditionValue.equals(true)) {
            // conditionValueがtrueならばcontentを評価して戻す。
            return TemplatePair.pairOf(content.apply(context).template());
        } else {
            // conditionValueがfalseならばSimpleNopを戻す。contentは捨てる。
            return TemplatePair.<Object, SimpleTemplate>pairOf(new SimpleNop());
        }
    }
    @Override
    public boolean isReducible() {
        return true;
    }
    @Override
    public String getString() {
        throw new IllegalStateException("Unevaluated if:" + this);
    }
}

Foreachタグの定義

実行時にitemsタグの値を評価し、コレクションならその要素の一つずつについて、varで指定されたシンボルで要素をcontextに追加してタグの内容を評価し、評価結果を連結して戻すようなタグを追加する。
パース時のTagHandler、ProcessorPrototypeと実行時のProcessorを定義する。

public class ForeachTagHandler implements TagHandler{
    @Override
    public String[] tagNames() {
        return new String[] {"foreach"};
    }
    @Override
    public boolean requireSingleTag(String tagName) {
        return false; // シングルタグは取らない
    }
    @Override
    public boolean requireContainerTag(String tagName) {
        return true;
    }
    @Override
    public SimpleTemplatePrototype handleSingleTag(String tagName,
            List<Attribute> attributes) {
        throw new IllegalStateException("Foreach cannot use as a single tag.");
    }

    @Override
    public SimpleTemplatePrototype handleContainerTag(String tagName,
            List<Attribute> attributes, List<SimpleTemplatePrototype> elements) {
        // itemsタグの値を取得
        Attribute itemsAttribute = Attribute.findAttribute("items", attributes);
        // varタグの値を取得
        Attribute varAttribute = Attribute.findAttribute("var", attributes);
        // itemsタグの値はSimpleSourceとして、varの値はSymbolとして渡す
        return new SimpleForeachProcessorPrototype(
                TagHandlerUtils.makeSource(itemsAttribute), 
                varAttribute.getValue().getSymbol(), 
                new SimpleContainerPrototype(elements));
    }
}
public class SimpleForeachProcessorPrototype implements SimpleTemplatePrototype {
    private final SimpleSource items;
    private final Symbol var;
    private final SimpleTemplatePrototype contents;
    public SimpleForeachProcessorPrototype(SimpleSource items, Symbol var, SimpleTemplatePrototype contents) {
        super();
        this.items = items;
        this.var = var;
        this.contents = contents;
    }
    public SimpleTemplate instantiate(Context<Object, SimpleTemplate> lexicalContext) {
         return new SimpleForeachProcessor(
                 lexicalContext, items, var, 
                 contents.instantiate(lexicalContext));
    }
}
public class SimpleForeachProcessor extends AbstractSimpleTemplate implements SimpleTemplate {
    private final Context<Object, SimpleTemplate> lexicalContext;
    private final SimpleSource items;
    private final Symbol var;
    private final SimpleTemplate content;

    public SimpleForeachProcessor(Context<Object, SimpleTemplate>lexicalContext,
            SimpleSource items, Symbol var, SimpleTemplate content) {
        super();
        this.lexicalContext = lexicalContext;
        this.items = items;
        this.var = var;
        this.content = content;
    }

    @SuppressWarnings("unchecked")
    @Override
    public TemplatePair<Object, SimpleTemplate> apply(final Context<Object, SimpleTemplate> context) {
        Context<Object, SimpleTemplate> merged = ContextUtils.merge(context, lexicalContext);

        // itemsの値を取得
        Associable<Object, SimpleTemplate> itemsAssociable = this.items.getAssociable(merged);

        if (itemsAssociable == null) {
           // itemsが見つからない場合は自分自身を戻す=未評価
            return TemplatePair.<Object, SimpleTemplate>pairOf(this);
        }

        Object itemsValue = itemsAssociable.asValue().value();
        if (itemsValue == null || !(itemsValue instanceof Collection)) {
            // Collectionでなければエラー
            throw new IllegalStateException("'items' is not collection");
        }
        List<SimpleTemplate> elements = new ArrayList<SimpleTemplate>();
        for (Object item : (Collection<Object>) itemsValue) {
            // itemの値をvarとしてセットし、タグ内を評価する。
            Context<Object, SimpleTemplate> innerContext = ContextUtils.put(context, var, new SimpleValue(item));
            elements.add(content.apply(innerContext).template());
        }
        
        // タグ内のすべての評価結果をSimpleContainerとして戻す。
        return TemplatePair.<Object, SimpleTemplate>pairOf(new SimpleContainer(elements));
    }
    @Override
    public boolean isReducible() {
        return true;
    }
    @Override
    public String getString() {
        throw new IllegalStateException("Unevaluated foreach:" + this);
    }
}

パーサーにTagHandler

忘れずに追加

public final class ParserFactory {
...
    private void registerHandlers() {
        registerHandler(new DefineTagHandler());
        registerHandler(new InsertTagHandler());
        registerHandler(new IfTagHandler());      // NEW!!
        registerHandler(new ForeachTagHandler()); // NEW!!
    }
...

実行サンプル(Ifタグ)

会員(isMember==true)の場合のみ、「ようこそ、会員<会員のファーストネーム>」と表示する。

   public static class Customer {
       public String firstName;
       public String lastName;
       public boolean isMember;
   }
   @Test
   public void testIf() {
       Customer customer = new Customer();
       customer.firstName = "トン吉";
       customer.isMember = true;
       
       Context<Object, SimpleTemplate> context =
           new ContextBuilder<Object, SimpleTemplate>()
           .put(Symbol.of("customer"), new SimpleValue(customer))
           .context();

       SimpleTranslator translator = new SimpleTranslator();
       SimpleTemplate template = translator.toTemplate("" +
           "{if condition=\"customer.isMember\"}" +
           "ようこそ、会員{insert value=\"customer.firstName\"/}" + 
           "{/if}" +
       "");
       String output = 
           translator.fromTemplate(template.apply(context).template());
       System.out.println(output);
       String expected = "ようこそ、会員トン吉";
       assertEquals(expected, output);
   }

実行サンプル(Foreachタグ)

顧客情報のListを元に、ファーストネームを「、」区切りで表示する。

   @Test
   public void testForeach() {
       List<Customer> customers = new ArrayList<Customer>();
       customers.add(new Customer(){{firstName="Boo";}});
       customers.add(new Customer(){{firstName="Hoo";}});
       customers.add(new Customer(){{firstName="Woo";}});
       
       Context<Object, SimpleTemplate> context =
           new ContextBuilder<Object, SimpleTemplate>()
           .put(Symbol.of("customers"), new SimpleValue(customers))
           .context();

       SimpleTranslator translator = new SimpleTranslator();
       SimpleTemplate template = translator.toTemplate("" +
           "{foreach items=\"customers\" var=customer}" +
           "{insert value=\"customer.firstName\"/}、" + 
           "{/foreach}" +
       "");
       String output = 
           translator.fromTemplate(template.apply(context).template());
       System.out.println(output);
       String expected = "Boo、Hoo、Woo、";
       assertEquals(expected, output);
   }


これでdefine/insert/if/foreachタグが出来たので、次回は「関数型テンプレートエンジンに可能なこと」を実例を元に挙げていく。

*1:MVELは使ったことが無かったので試しに使ってみた。ELの役割は非常に重要なのでそのうち自家製のものに換える。