関数型のテンプレートエンジンで何が出来るか
まず関数型のテンプレートエンジンの定義なんだけど、とりあえず以下を満たすもので考える。
- 各テンプレートが独立した静的スコープを持つ
- テンプレート(の一部)がファーストクラスのオブジェクトとして扱える。
- 変数に代入したり、引数で渡せる。
折角なんで実際作ったものを使って説明したいと思う。Vers_0.3をベースに実例を交えて説明する。
おしながき:
- レイアウトなどのテンプレートの合成
- ページャなどの部品化
- ネストした構造を持ったデータの表示
- 表示構造と表示要素の分離
- 計算
- その他の可能性
レイアウトなどのテンプレートの合成
HTMLの構成は、ヘッダ・フッタ・メニューなどに囲われて、実際の主たる関心ごと(内容)が表示されることが多い。前者を「レイアウト」、後者を「内容ページ」とする。「内容ページ」を「レイアウト」のextendであるというように継承関係として考えるより、コンポジションで考える方が自然なように思う。
メニューなどの構成は「内容ページ」によって一意に決まることが多いし、また、タイトルやキーワードMETAタグ等を動的に変えたいという場合も、内容ページで表示しているまさにそのデータに連動する場合が多いため、レイアウトのコントロールは「内容ページ」の側を軸に実装した方がまとまりが良い。
そこで、「レイアウトページ」に対して、引数として「内容ページ」を渡し、全体をレンダリングするようなイメージでページを設計する。
簡単な実例を挙げていくよ。
レイアウト側:
<html> <head><title>{insert value=title/}</title></head> <body class="{insert value=bodyClass/}"> <h1>{insert value=title/}</h1> <hr> {insert value=contentBody/} <hr> Copyright(c) terazzo 2011 All Rights Reserved. </body> </html>
内容ページ側(「アンケート入力ページ」):
{insert value=layout} {define name=title}アンケートフォーム{/define} {define name=bodyClass value="'enquete'"/} {define name=contentBody} <!-- ここから内容 --> ご意見がありましたらどうぞ。 <form action="{insert value=actionUrl/}"> お名前:<input type="text" name="name"/><br> メッセージ:<br> <textarea name=""> </textarea> <input type="submit" value="送信"/><br> </form> <!-- ここまで内容 --> {/define} {/insert}
プログラム:
public String renderEnquete() throws IOException { // レイアウト側の読み込み SimpleTemplate layout = loadTemplate("components/layout.html"); // パラメータの準備 Context<Object, SimpleTemplate> context = new ContextBuilder<Object, SimpleTemplate>() .put(Symbol.of("layout"), layout) .put(Symbol.of("actionUrl"), new SimpleValue("http://example.com/post.cgi")) .context(); // 内容ページ側の準備 SimpleTemplate page = loadTemplate("templates/page.html"); // レンダリング String output = translator.fromTemplate(page.apply(context).template()); return output; }
出力結果:
<html> <head><title>アンケートフォーム</title></head> <body class="enquete"> <h1>アンケートフォーム</h1> <hr> <!-- ここから内容 --> ご意見がありましたらどうぞ。 <form action="http://example.com/post.cgi"> お名前:<input type="text" name="name"/><br> メッセージ:<br> <textarea name=""> </textarea> <input type="submit" value="送信"/><br> </form> <!-- ここまで内容 --> <hr> Copyright(c) terazzo 2011 All Rights Reserved. </body> </html>
内容ページ側からレイアウトページをinsertし、その際にタイトル、bodyタグのクラス、レンダリングする内容をパラメータとして渡している。
この発想は、ページレイアウトなどページ全体についてだけではなく、HTMLの一部を既存のテンプレートで修飾するような場合にも使用できる。
過去の関連エントリ:「レイアウト側の属性を制御する - terazzoの日記」
ページャなどの部品化
一覧ページでデータの件数が多すぎる場合に、ページには20件など限られた件数のみを表示し、次の10件などをナビゲーションして表示することが多い。
ページャを汎用の部品として作成し、該当ページで表示する部分をパラメータとして渡してレンダリングすることで、部品を再利用して手間を減らすことが出来る。
以下のサンプルはデータの全体、1ページあたりの件数、現在のページで表示する内容をパラメータとして渡している。
ページャー部品:
{define name=listSize value="list.size()"/} {define name=maxpage value="listSize/size"/} {define name=paging} <div> | {foreach items="($ in [0,1,2,3,4,5,6,7,8,9] if $ < maxpage)" var=p} {if condition="p==page"} {insert value=p/} {/if} {if condition="p!=page"} <a href="{insert value="baseUrl + 'page=' + p"/}">{insert value=p/}</a> {/if} | {/foreach} </div> {/define} <!-- ページ送り用のリンクを表示(上部) --> {insert value=paging/} <!-- 現在の表示ページ --> {define name=from value="Math.min(listSize, size*page)"/} {define name=to value="Math.min(listSize, size*(1+page))"/} {insert value=content} {define name=displayList value="list.subList(from, to)" comment="現在のページで表示するリスト"/} {define name=from value="from + 1" comment="何件目からか(1〜)"/} {define name=to value="to" comment="何件目までか(1〜)"/} {/insert} <!-- ページ送り用のリンクを表示(下部) --> {insert value=paging/}
呼び出し側ページ:
結果一覧: {insert value=pager} {define name=list value=branches comment="全件を含むリスト"/} {define name=size value="5" comment="一ページあたりの件数"/} {define name=page value=page comment="現在のページ(0〜)"/} {define name=baseUrl value="'http://example.com/list.cgi?'" comment="リンクURL"/} {define name=content comment="中身"} {insert value=from/}〜{insert value=to/}件目を表示 <table> <tr> <th>ID</th> <th>支店名</th> <th>登録日</th> </tr> {foreach items=displayList var=record} <tr> <td>{insert value="record.id"/}</td> <td>{insert value="record.name"/}</td> <td>{insert value="record.registeredDate"/}</td> </tr> {/foreach} </table> {/define} {/insert}
プログラム:
public String renderWithPager(int currentPage) throws IOException { // ページャ部品の読み込み SimpleTemplate pager = loadTemplate("components/pager.html"); // 内容ページの読み込み SimpleTemplate listWithPager = loadTemplate("templates/list.html"); // パラメータを準備 Context<Object, SimpleTemplate> context = new ContextBuilder<Object, SimpleTemplate>() .put(Symbol.of("pager"), pager) .put(Symbol.of("branches"), new SimpleValue(branches)) .put(Symbol.of("page"), new SimpleValue(currentPage)) .context(); // レンダリング String output = translator.fromTemplate(listWithPager.apply(context).template()); return output.replaceAll("\\n[\\s\\t]*\\n", "\n"); } // テストデータ public static class Branch { public final int id; public final String name; public final String registeredDate; public Branch(int id, String name, String registeredDate) { super(); this.id = id; this.name = name; this.registeredDate = registeredDate; } } List<Branch> branches = Arrays.asList( new Branch(1, "本社", "2001-1-1"), new Branch(2, "埼玉支店", "2004-3-10"), new Branch(3, "神奈川支店", "2005-4-2"), new Branch(4, "千葉支店", "2005-7-15"), new Branch(5, "茨城支店", "2007-1-5"), new Branch(6, "東北支店", "2010-5-8"), new Branch(7, "静岡支店", "2010-9-1"), new Branch(8, "長野支店", "2010-11-15"), new Branch(9, "山梨支店", "2011-2-25"), new Branch(10, "名古屋支店", "2011-4-2"), new Branch(11, "新潟支店", "2011-4-2") );
出力結果(currentPage=1として出力):
結果一覧: <!-- ページ送り用のリンクを表示(上部) --> <div> | <a href="http://example.com/list.cgi?page=0">0</a> | 1 | <a href="http://example.com/list.cgi?page=2">2</a> | </div> <!-- 現在の表示ページ --> 6〜10件目を表示 <table> <tr> <th>ID</th> <th>支店名</th> <th>登録日</th> </tr> <tr> <td>6</td> <td>東北支店</td> <td>2010-5-8</td> </tr> <tr> <td>7</td> <td>静岡支店</td> <td>2010-9-1</td> </tr> <tr> <td>8</td> <td>長野支店</td> <td>2010-11-15</td> </tr> <tr> <td>9</td> <td>山梨支店</td> <td>2011-2-25</td> </tr> <tr> <td>10</td> <td>名古屋支店</td> <td>2011-4-2</td> </tr> </table> <!-- ページ送り用のリンクを表示(下部) --> <div> | <a href="http://example.com/list.cgi?page=0">0</a> | 1 | <a href="http://example.com/list.cgi?page=2">2</a> | </div>
pagerテンプレートに引数contentで渡したテンプレートが、pager側から呼び出され、その際にfrom(何件目からか(1〜))、to(何件目までか(1〜))、displayList(現在のページで表示するリスト)が渡されてくるので、それを使ってレンダリングをおこなう。引数contentで渡すテンプレートが、高階関数への関数引数のような振る舞いになっている。
ページの内容は表示するデータ等によって異なるが、ページングの部分はわりと共通なので再利用が可能である。
今回の例は10ページ目までしか表示出来ないし、また「あらかじめ全件を取得する」という今時あんまり無そうなシチュエーションだけどアイデア的にはこれで伝わるかと思う。
ネストした構造を持ったデータの表示
テンプレートを再帰的に呼び出すことでツリー状のデータを表示することが出来る。
まず固定データの表示用の例。
ツリー側:
<li> {insert value="node.name"/} {if condition="!node.advancedClasses.empty"} <ul> {foreach items="node.advancedClasses" var=advancedClass} {insert value=treeView} {define name=node value=advancedClass/} {define name=treeView value=treeView/} {/insert} {/foreach} </ul> {/if} </li>
呼び出し側:
<ul id="treeroot"> {insert value=treeView} {define name=node value=node/} {define name=treeView value=treeView/} {/insert} </ul>
プログラム:
public String renderTree() throws IOException { // 部品の読み込み SimpleTemplate treeView = loadTemplate("components/tree.html"); // 内容ページの読み込み SimpleTemplate page = loadTemplate("templates/treeMain.html"); // パラメータを準備 Context<Object, SimpleTemplate> context = new ContextBuilder<Object, SimpleTemplate>() .put(Symbol.of("node"), new SimpleValue(UnitClass.AMAZON_FAMILY)) .put(Symbol.of("treeView"), treeView) .context(); // レンダリング String output = translator.fromTemplate(listWithPager.apply(context).template()); return output.replaceAll("\\n[\\s\\t]*\\n", "\n"); } // テストデータ // ツリー状のデータのノードとなるクラス public static class UnitClass { public final String name; // 名称 public final String requiredParameter; // クラスチェンジに必要なパラメータ public final UnitType unitType; // 移動タイプ public final List<UnitClass> advancedClasses; // 上位クラス public UnitClass(String name, UnitType unitType, String requiredParameter, UnitClass... advancedClasses) { this.name = name; this.requiredParameter = requiredParameter; this.unitType = unitType; this.advancedClasses = Collections.unmodifiableList(Arrays.asList(advancedClasses)); } public static enum UnitType { HIGH_SKY("おおぞら"), LOW_SKY("ていくう"), PLAINS("そうげん"), FOREST("しんりん"), MOUNTAIN("さんがく"), SNOWFIELD("せつげん"), MARSH("しっち"), SHOALS("あさせ"), OCEAN("しんかい"), COFFIN("どんそく"); public final String label; UnitType(String label) { this.label = label; } } // ツリー状のデータの定義 public static final UnitClass AMAZON_FAMILY = new UnitClass("アマゾネス", UnitType.FOREST, "", new UnitClass("ヴァルキリー", UnitType.FOREST, "Level:5,Cha:50+,Ali:35+", new UnitClass("フレイア", UnitType.SNOWFIELD, "Level:15,Cha:60+,Ali:70+") ), new UnitClass("クレリック", UnitType.PLAINS, "Level:4,Cha:50+,Ali:50+", new UnitClass("プリースト", UnitType.PLAINS, "Level:10,Cha:65+,Ali:65+", new UnitClass("ビショップ", UnitType.PLAINS, "Level:18,Cha:73+,Ali:73+") ) ), new UnitClass("ウィッチ", UnitType.PLAINS, "Level:5,Cha:50+,Ali:0-65"), new UnitClass("プリンセス", UnitType.SNOWFIELD, "Item:Royal Crown") ); }
結果(インデントは見やすく変えてあります):
<ul id="treeroot"> <li> アマゾネス <ul> <li> ヴァルキリー <ul> <li> フレイア </li> </ul> </li> <li> クレリック <ul> <li> プリースト <ul> <li> ビショップ </li> </ul> </li> </ul> </li> <li> ウィッチ </li> <li> プリンセス </li> </ul> </li> </ul>
ツリー側では、node.advancedClassesが空でなければforeachでループして各要素について「自分自身」をinsertしている。今回の例では「自分自身」を呼び出し側から渡しているが、ページ内でdefineしても良い。
同一のテンプレートを再帰的に呼び出しても、同名の変数だからといって上書きされてスコープが破壊されたりせずに、ちゃんと再帰処理出来ている。
過去の関連エントリ:「HTMLテンプレートの再帰でツリー型のデータを表示する - terazzoの日記」
表示構造と表示要素の分離
上の例ではツリー状にnode.nameを表示しているが、nameプロパティを持つデータについてその内容を表示するという機能しか無く、再利用可能ではない。そこで、「ツリー構造を繰り返し表示する」部分と、「データの内容を表示する」部分を分離する。
準備として、ツリー構造のノードをインタフェース定義しデータのクラスに実装する。
public interface Node<T extends Node<T>> { List<T> getChildren(); } public static class UnitClass implements Node<UnitClass> { @Override public List<UnitClass> getChildren() { return advancedClasses; } ...(あとは上の例と同じ)
汎用のツリー部品:
<li> {insert value="nodeView"} {define name=node value=node/} {/insert} {if condition="!node.children.empty"} <ul> {foreach items="node.children" var=child} {insert value=treeView} {define name=node value=child/} {define name=nodeView value=nodeView/} {define name=treeView value=treeView/} {/insert} {/foreach} </ul> {/if} </li>
呼び出し側(情報少ない版):
<ul id="simpletree"> {insert value="treeView"} {define name=node value=node/} {define name=nodeView} {insert value="node.name"/} {/define} {define name=treeView value=treeView/} {/insert} </ul>
プログラム
public String renderGenericTree() throws IOException { // 部品の読み込み SimpleTemplate treeView = loadTemplate("components/genericTree.html"); // 内容ページの読み込み SimpleTemplate page = loadTemplate("templates/genericTreeMain.html"); // パラメータを準備 Context<Object, SimpleTemplate> context = new ContextBuilder<Object, SimpleTemplate>() .put(Symbol.of("node"), new SimpleValue(UnitClass.AMAZON_FAMILY)) .put(Symbol.of("treeView"), treeView) .context(); // レンダリング String output = translator.fromTemplate(page.apply(context).template()); return output.replaceAll("\\n[\\s\\t]*\\n", "\n"); } // テストデータは上と同じ
出力結果(インデントは見やすく変えてあります):
<ul id="simpletree"> <li> アマゾネス <ul> <li> ヴァルキリー <ul> <li> フレイア </li> </ul> </li> <li> クレリック <ul> <li> プリースト <ul> <li> ビショップ </li> </ul> </li> </ul> </li> <li> ウィッチ </li> <li> プリンセス </li> </ul> </li> </ul>
ここで、もう少し詳しい情報を表示したい場合でも、部品側は変更せず、呼び出し側の変更で対応出来る。
<ul id="richtree"> {insert value="treeView"} {define name=node value=node/} {define name=nodeView} <div class="unitClass"> <div class="unitClassName">{insert value="node.name"/}</div> <div class="unitType">移動: {insert value="node.unitType.label"/} タイプ</div> <div class="requirement">必要パラメータ: {insert value="node.requiredParameter"/}</div> </div> {/define} {define name=treeView value=treeView/} {/insert} </ul>
出力結果(インデントは見やすく変えてあります):
<ul id="richtree"> <li> <div class="unitClass"> <div class="unitClassName">アマゾネス</div> <div class="unitType">移動: しんりん タイプ</div> <div class="requirement">必要パラメータ: </div> </div> <ul> <li> <div class="unitClass"> <div class="unitClassName">ヴァルキリー</div> <div class="unitType">移動: しんりん タイプ</div> <div class="requirement">必要パラメータ: Level:5,Cha:50+,Ali:35+</div> </div> <ul> <li> <div class="unitClass"> <div class="unitClassName">フレイア</div> <div class="unitType">移動: せつげん タイプ</div> <div class="requirement">必要パラメータ: Level:15,Cha:60+,Ali:70+</div> </div> </li> </ul> </li> <li> <div class="unitClass"> <div class="unitClassName">クレリック</div> <div class="unitType">移動: そうげん タイプ</div> <div class="requirement">必要パラメータ: Level:4,Cha:50+,Ali:50+</div> </div> <ul> <li> <div class="unitClass"> <div class="unitClassName">プリースト</div> <div class="unitType">移動: そうげん タイプ</div> <div class="requirement">必要パラメータ: Level:10,Cha:65+,Ali:65+</div> </div> <ul> <li> <div class="unitClass"> <div class="unitClassName">ビショップ</div> <div class="unitType">移動: そうげん タイプ</div> <div class="requirement">必要パラメータ: Level:18,Cha:73+,Ali:73+</div> </div> </li> </ul> </li> </ul> </li> <li> <div class="unitClass"> <div class="unitClassName">ウィッチ</div> <div class="unitType">移動: そうげん タイプ</div> <div class="requirement">必要パラメータ: Level:5,Cha:50+,Ali:0-65</div> </div> </li> <li> <div class="unitClass"> <div class="unitClassName">プリンセス</div> <div class="unitType">移動: せつげん タイプ</div> <div class="requirement">必要パラメータ: Item:Royal Crown</div> </div> </li> </ul> </li> </ul>
表示構造と表示要素の分離(その2)
ページング、ツリー構造以外にも、例えばマトリックス表示等で同じアイデアが使える。
この例では、カラム数(cols)と個々の内容の表示テンプレート(itemView)を渡して、テーブル表示を行っている。
部品側ではtableタグをレンダリングしつつ、個々の要素をitemというパラーメータにセットしつつitemViewを呼び出している。
部品側(10x10までのマトリックスが表示可能):
{define name=listSize value="list.size()"/} {define name=rows value="Math.ceil(listSize/cols)"/} <table> {foreach items="($ in [0,1,2,3,4,5,6,7,8,9] if $ < rows)" var=row} <tr> {foreach items="($ in [0,1,2,3,4,5,6,7,8,9] if $ < cols)" var=col} {if condition="cols*row + col < listSize"} <td>{insert value=itemView}{define name=item value="list.get(cols*row + col)"/}{/insert}</td> {/if} {if condition="cols*row + col >= listSize"} <td> </td> {/if} {/foreach} </tr> {/foreach} </table>
呼び出し側(各データが1行の3列表示と各データが2行の6列表示):
<h2>3列表示</h2> {insert value=matrixView} {define name=list value=branches/} {define name=cols value="3"/} {define name=itemView} <div> <span class="branchId">{insert value="item.id"/}</span>: <span class="branchName">{insert value="item.name"/}</span> </div> {/define} {/insert} <h2>6列表示</h2> {insert value=matrixView} {define name=list value=branches/} {define name=cols value="6"/} {define name=itemView} <div> <div class="branchId">ID: {insert value="item.id"/}</div> <div class="branchName">支店名: {insert value="item.name"/}</div> </div> {/define} {/insert}
プログラム:
public String renderMatrix() throws IOException { // 部品の読み込み SimpleTemplate matrixView = loadTemplate("components/matrix.html"); // 内容ページの読み込み SimpleTemplate page = loadTemplate("templates/matrixMain.html"); // パラメータを準備 Context<Object, SimpleTemplate> context = new ContextBuilder<Object, SimpleTemplate>() .put(Symbol.of("matrixView"), matrixView) .put(Symbol.of("branches"), new SimpleValue(branches)) .context(); // レンダリング String output = translator.fromTemplate(page.apply(context).template()); System.out.println(output); return output.replaceAll("\\n[\\s\\t]*\\n", "\n"); } // テストデータはページャの時のものと同じ
計算
フィボナッチ数を計算してみた。
{define name=fib} {if condition="i==0"}{/if} {if condition="i==1"}{insert value=mark/}{/if} {if condition="i>=2"} {insert value=fib} {define name=i value="i-1"/} {define name=fib value=fib/} {/insert} {insert value=fib} {define name=i value="i-2"/} {define name=fib value=fib/} {/insert} {/if} {/define} {insert value=fib} {define name=i value=arg/} {define name=fib value=fib/} {/insert}
プログラム:
public int fib(int i) throws IOException { char mark = '*'; SimpleTemplate page = loadTemplate("templates/fib.html"); Context<Object, SimpleTemplate> context = new ContextBuilder<Object, SimpleTemplate>() .put(Symbol.of("arg"), new SimpleValue(i)) .put(Symbol.of("mark"), new SimpleValue(mark)) .context(); String output = translator.fromTemplate(page.apply(context).template()); return output.replaceAll("[^"+mark+"]", "").length(); } public void testFib() throws IOException { int i = 10; System.out.printf("fib(%d) = %d\n", i, fib(i); }
結果:
fib(10) = 55
まあ25ぐらいで帰ってこなくなるけど。
過去の関連エントリ:「HTMLコンポーネントにHTMLの断片を引数的に渡す - terazzoの日記」
その他の可能性
まあここからはVaporな話なんだけど、可能性としていろいろ考えられる。
- 最適化
- 一応EL(MVEL)部分以外は副作用が無いはずなのでメモ化などが可能のはず
- 適用順序を変えて部分適用が出来るようにする。(今の実装はなるべく遅延させている)
- マスタ系のあまり変わらない情報を先に適用してキャッシュする等
- レンダリング以外での評価方法
- 評価器を切り替えて、結果を出力する代わりに使用するデータ構造の事前スキャンをおこなうなど
- 今はASTと評価器が分かれていないので要変更
- ELも独自に実装が必要かも
- 型の導入
- 型を導入することで、プロパティの有無のチェックや入力補完などが出来る
- ELも独自に実装が必要
ということでやはり次はELの実装が肝になりそうな感じ。
上の例でも分かるようにMVELはちょっと強力すぎるので、(自分が作るELでは)もう少し出来ることは限定した方が良いかもしれない。但し再帰include&テンプレートのパラメータ渡しは十分強力なので、プロパティアクセス&存在チェックぐらい出来れば正規表現のクラスは超えてしまうかも。
個人的にはParr先生ほどストイックではなくて、現場の人がごりごりロジック埋めちゃってもそれはそれで良いと思っている。現場で有り合わせの素材で必要なシステムをブリコラージュしてもらうことで、「野生のシステム設計」みたいなものを引き出せるのは悪くないんじゃないかと思う。それでWikiの良さみたいな感じのものが出せないかなあ。