Mayaaを普通のテンプレートエンジンとして使う

解説などは後ろで。

ソースコード

package sample.mayaa;

import java.io.OutputStream;
import java.util.Map;

import javax.servlet.ServletContext;

import org.seasar.mayaa.FactoryFactory;
import org.seasar.mayaa.engine.Engine;
import org.seasar.mayaa.impl.FactoryFactoryImpl;
import org.seasar.mayaa.impl.cycle.CycleUtil;
import org.seasar.mayaa.impl.cycle.web.MockHttpServletRequest;
import org.seasar.mayaa.impl.cycle.web.MockHttpServletResponse;
import org.seasar.mayaa.impl.cycle.web.MockServletContext;
import org.seasar.mayaa.impl.provider.ProviderUtil;

/**
 * Mayaaテンプレートを単体で使う為のクラス
 */
public class MayaaTemplate {

    private ServletContext servletContext;
    private String templatePath;

    /**
     * @param templatePath テンプレートファイルへの相対パス
     * @param servletContext ServletContext
     */
    public MayaaTemplate(String templatePath, ServletContext servletContext) {
        super();
        this.servletContext = servletContext;
        initializeFactoryFactory();
        
        this.templatePath = templatePath;
    }
    /**
     * テンプレートファイルへの相対パスを指定してコンストラクタを生成する。
     * ServletContextにはMockServletContextを生成して使用する。
     * @param templatePath テンプレートファイルへの相対パス
     */
    public MayaaTemplate(String templatePath) {
        this(templatePath, getMockServletContext());

    }
    private static ServletContext getMockServletContext() {
        String basePath = System.getProperty("user.dir");
        String contextPath = "";
        
        ServletContext servletContext = new MockServletContext(basePath, contextPath);
        return servletContext;
    }
    private void initializeFactoryFactory() {
        if (!FactoryFactory.isInitialized()) {
            FactoryFactory.setInstance(new FactoryFactoryImpl());
            FactoryFactory.setContext(this.servletContext);
        }
    }
    
    /**
     * テンプレートにbindingsを適用し、outに出力する
     * @param bindings バインドする変数
     * @param out 結果出力用OutputStream
     */
    public void apply(Map<String, Object> bindings, OutputStream out) {
        String servletPath = "";
        MockHttpServletRequest request = new MockHttpServletRequest(this.servletContext, servletPath);
        request.setPathInfo(this.templatePath);        

        MockHttpServletResponse response = new MockHttpServletResponse();
        response.setOnCommitOutputStream(out);

        CycleUtil.initialize(request, response);
        Engine engine = ProviderUtil.getEngine();
        engine.doService(bindings, true);
        CycleUtil.cycleFinalize();
    }
}

使い方サンプル

例として、オークションサイトの落札通知のメールをテンプレートで作成する感じで。


sample/mayaa/MayaaTemplateSample.java (呼び出し側のプログラムサンプル):

package sample.mayaa;

import java.io.ByteArrayOutputStream;
import java.io.UnsupportedEncodingException;
import java.util.HashMap;
import java.util.Map;


/**
 * MayaaTemplateの使い方サンプル
 */
public class MayaaTemplateSample {
    private static final String ENCODING = "Shift_JIS";
    public static void main(String args[]) {
        // 表示する変数を準備
        // 今回はサンプルなので適当に決め打ちで文字列を設定
        Map<String, Object> bindings = new HashMap<String, Object>();
        bindings.put("profile",
                new UserProfile("しなもん", "近藤"));
        bindings.put("goods",
                new Goods("『がちょうのアレキサンダー』"));

        // 出力対象となるOutputStream。
        // 今回はByteArrayOutputStreamを用いてメモリ中で文字列を得る
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        String contentString = null;

        /* [1] テンプレートパスを指定してMayaaTemplateオブジェクトを生成する */
        String templatePath = "/sample/mayaa/mailtempl.html";
        MayaaTemplate template = new MayaaTemplate(templatePath);
                
        /* [2] バインド変数とOutputStreamを渡して出力を得る */
        template.apply(bindings, out);
        
        
        // 出力されたOutputStreamの内容を確認
        try {
            contentString = new String(out.toByteArray(), ENCODING);
            System.err.println("contentString = " + contentString);
        } catch (UnsupportedEncodingException e) {
            e.printStackTrace();
        }
    }
    
    /** ユーザプロフィール情報 */
    public static class UserProfile {
        private String lastName;
        private String firstName;
        public UserProfile(String firstName, String lastName) {
            super();
            this.lastName = lastName;
            this.firstName = firstName;
        }
        public String getFirstName() {
            return firstName;
        }
        public String getLastName() {
            return lastName;
        }
    }
    /** 購入商品情報 */
    public static class Goods {
        private String name;
        public Goods(String name) {
            super();
            this.name = name;
        }
        public String getName() {
            return name;
        }
    }
     
    
}

sample/mayaa/mailtempl.html(テンプレートのHTML):

<meta id="encoding" http-equiv="Content-Type" content="text/html; charset=Shift_JIS" />
<div id="content">
こんにちは、<span id="lastName">苗字</span> <span id="firstName">名前</span>さん、

<span id="goodsName">商品名</span>を落札しました。おめでとうございます!

※このメールは今後必要になる場合がありますので、必ず印刷または保存
しておいてください。

<span id="spchars">タグ表示サンプル</span>

?ほげほげオークション事務局?
</div>

sample/mayaa/mailtempl.mayaa (テンプレートのmayaa):

<?xml version="1.0" encoding="Shift_JIS"?>
<m:mayaa xmlns:m="http://mayaa.seasar.org">
    <m:doRender m:id="content" replace="true" />
    <m:write m:id="firstName" value="${profile.firstName}" />
    <m:write m:id="lastName" value="${profile.lastName}" />
    <m:write m:id="goodsName" value="${goods.name}" />
    <m:write m:id="spchars" value="&lt;〜タグはこうだ!〜&gt;" escapeXml="false" />
    
    <m:null m:id="encoding" />
</m:mayaa>

実行結果:

contentString = 

こんにちは、近藤 しなもんさん、

『がちょうのアレキサンダー』を落札しました。おめでとうございます!

※このメールは今後必要になる場合がありますので、必ず印刷または保存
しておいてください。

<〜タグはこうだ!〜>

〜ほげほげオークション事務局〜

環境

  • J2SE 5.0
  • 以下のライブラリを使用(大体Mayaaについてくる奴)
    • commons-beanutils-core-1.7.0.jar
    • commons-collections-3.1.jar
    • commons-logging-1.0.4.jar
    • xercesImpl-2.7.1.jar
    • servlet-api.jar
    • rhino-1.6r5.jar
    • nekohtml-0.9.5.jar
    • mayaa-1.1.9.jar

なぜMayaaをメールテンプレートに?

巷のJavaプログラマは、レポート出力とか物販の購入完了メールの本文をどうやって生成しているんだろう。

  • プログラムで日本語直書き派
    • 文言変更の度に再コンパイル
    • レイアウトはプログラム次第で自由にできる
  • パーツ毎にpropertiesなどから読んできて合成する派
    • 出力位置とか順番とか変わっただけで再コンパイル
    • レイアウトはプログラム次第で自由にできる
  • テキストファイルに変数部分{0}とかで書いてMessageFormat使う派
    • 出力位置とか順番は変えられる
    • 商品一覧みたいな繰り返しとかは無理かも
    • placeholderは10個まで
  • propertiesとMessageFormat組み合わせ派
    • 自由度はまあまあ高い
    • 設定ファイル見ただけだと最終形が想像しにくい
    • ここまでするならテンプレートエンジン使うよね?
  • オレオレ形式のテンプレートエンジン
    • 車輪の再発明じゃね?
    • 運用/サポートの人が書式覚えるの大変でしょ

ということで、できるだけ既存のテンプレートエンジンを使って、しかも書式も一種類にしたいわけですよ。
WebページをMayaaで書いてるなら、メールのテンプレートにもMayaa、というのが理想です。

Web用のテンプレートエンジンをメールで使う場合の問題点

上記のように、テンプレートエンジン愛好家としては、レポートメールなどのレイアウトや文言にもテンプレートを使用したいところ。ところがFaceletsやMayaaServletコンテナ内でHTTPレスポンスに使うことが前提となっているようで、なかなかVelocityのような気ままな使い方はできないというのが実情ですよ。


でも、ServletContextやHttpServletResponseを前提としているということは、逆に言うとそれらを実装したクラスのオブジェクトを生成して渡してやれば良いという事でもある。有難いことにMayaaにはMock系のクラスが一通り揃っているので、Mockオブジェクトを利用してレスポンスを生成する方法をとった。これでコンテナ外でもテンプレートが使える。

MayaaTemplateクラスの説明

  • ServletContextが存在する場合は、コンストラクタで指定しても良い(但し動作確認は取ってません。)
  • パスについてはMayaaの検索パスからの相対パスを指定する。検索パスを追加したければ、設定ファイルに追加をおこなう(META-INF/org.seasar.mayaa.source.PageSourceFactory)
  • テンプレートの文字コードはmetaタグを含めることで指定可能。ストリームにはテンプレートと同じ文字コードで書き込まれる
  • 制御タグ部分が空行になるのでタグ内で改行するなど工夫が必要(outputTemplateWhitespaceの仕様がイマイチ(後述))
  • [内部の処理] apply()メソッド内で以下の流れで出力を得ている
    1. MockHttpServletRequest生成
    2. 1.にテンプレートのパスをセット
    3. MockHttpServletResponse生成
    4. 3.に出力ストリームをセット
    5. CycleUtil.initialize()呼び出して1.,3.をスレッドローカルなコンテキストにセット
    6. org.seasar.mayaa.engine.Engineを取得
    7. バインディングを指定し、engine.doService()を実施
    8. CycleUtil.cycleFinalize()呼び出し
  • Webアプリ内部でもちゃんと使えるらしい(2週間ぐらい前にオープンしたサイトで使っている)
  • 但しコンテキストがThreadLocalを利用しているため、Web用のMayaaテンプレート中から呼び出したメソッドでこのクラスを使うと副作用が出るかも。

余談

  • 一応Facelets版も似たようなの作ったんだけど、あっちはjavax.faces.application.Applicationとか必要なのでJSFでしか使えない感じ
  • タグオンリーの行が空行として開いてしまうので見苦しい。
    • HTMLだと問題ないけどメールなんかの場合見た目上も露骨に開いてしまう。
    • 改行しない or タグ内部で改行すれば出力はまともになるけどテンプレートが見にくい
    • XSLTのように「空白文字だけからなるテキストノードを除去」が理想?
      • それだと「(空白x2)[商品名] [数量]」のような場合に困る
      • 「タグ開始の直後とタグ終了の直前の改行は削除」あたりが現実的か
    • outputTemplateWhitespaceをfalseにすると今度は取れ過ぎ。
      • TemplateNodeHandler.javaのremoveIgnorableWhitespace()って@Overrideになってるけど、SpecificationNodeHandler.javaの方がprivateになってて呼ばれてないような?
  • 「<」とか「>」を含む場合にエスケープしないといけないのも辛い
  • 実はもっと簡単な方法が既に標準機能で入ってそうな気も……