データの一覧をグループ毎に表示するMayaaプロセッサ

タイトルのものを作ってみた。

背景

DBから取得した値を一覧表示するようなページで、デザイン変更があってデータをグループ毎に表示しなくてはいけなくなることが時々ある。(これの下二つのような感じ)


そういう時は、アクションなどでループを回してデータを加工し、加工したデータをrequestなりアクションフォームなりにセットし、あわせてHTMLテンプレートを作成したりする。でもこれって結構面倒だ。


そこで、HTMLテンプレート上だけでグループ表示ができるようにtaglib(じゃなくてMayaaのプロセッサ)を作ってみた。


中身は、リストとグループ化の為の関数を渡してやると、その関数の戻り値に従ってリストをグループ化(同じ戻り値=同じグループ)し、タグの中身に対してグループ毎のリストを渡してグループの個数分繰り返すというもの。forEachの拡張版みたいな感じ。


ソート順が指定出来ない(出現順に表示されてしまう)など、あまり実用的ではない仕様なので今回はあくまで実証実験的に。

ソースコード

まずJavaファイルの方。ForEachProcessorの拡張で作ろうと思ったけど、内部クラスなどを上手く使い回せなかったのでコピペして実装。

package sample.mayaa.processor;

import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Stack;

import org.seasar.mayaa.cycle.scope.AttributeScope;
import org.seasar.mayaa.engine.Page;
import org.seasar.mayaa.engine.processor.IterationProcessor;
import org.seasar.mayaa.engine.processor.ProcessStatus;
import org.seasar.mayaa.engine.processor.ProcessorProperty;
import org.seasar.mayaa.impl.cycle.CycleUtil;
import org.seasar.mayaa.impl.cycle.DefaultCycleLocalInstantiator;
import org.seasar.mayaa.impl.engine.processor.TemplateProcessorSupport;
import org.seasar.mayaa.impl.provider.ProviderUtil;
import org.seasar.mayaa.impl.util.IteratorUtil;

/**
 * グループ毎に一覧表示するためのProcessor
 */
public class ForEachGroupProcessor extends TemplateProcessorSupport
        implements IterationProcessor {
    private static final long serialVersionUID = -8295121752406858179L;

    private static final String PROCESS_TIME_INFO_KEY =
            ForEachGroupProcessor.class.getName() + "#processTimeInfo";
    static {
        CycleUtil.registVariableFactory(PROCESS_TIME_INFO_KEY,
                new DefaultCycleLocalInstantiator() {
            public Object create(Object owner, Object[] params) {
                ForEachGroupProcessor processor = (ForEachGroupProcessor) owner;
                return processor.new GroupIteratorStack();
            }
        });
    }
    
    /** 元になるリスト */
    protected ProcessorProperty _items;
    /** 要素からグループキーを生成する関数 */
    protected ProcessorProperty _groupBy;
    /** グループのリストを設定する為の文字列 */
    private String _groupName;

    /** グルーピングに使用したキーを設定する為の文字列 */
    private String _groupKeyName;
    /** グループの順番を設定する為の文字列 */
    protected String _indexName;

    // MLD property, required
    public void setGroupName(String groupName) {
        _groupName = groupName;
    }
    // MLD property, required
    public void setGroupBy(ProcessorProperty groupBy) {
        _groupBy = groupBy;
    }
    // MLD property, required=true, expectedClass=void
    public void setItems(ProcessorProperty items) {
        if (items == null) {
            throw new IllegalArgumentException();
        }
        _items = items;
    }

    // MLD property
    public void setIndexName(String indexName) {
        _indexName = indexName;
    }
    // MLD property
    public void setGroupKeyName(String groupKeyName) {
        _groupKeyName = groupKeyName;
    }

    public boolean isIteration() {
        return true;
    }

    /**
     * グループのリストに次の要素があるかどうか判定します。
     * また、次の要素がある場合はそれを現在のスコープのgroupにセットします。
     * このとき必要ならばindexもセットします。
     * また、必要ならばグルーピングに使用されたキーもセットします
     *
     * @return 次の要素があるならtrue
     */
    protected boolean prepareEvalBody() {
        GroupIteratorStack stack = (GroupIteratorStack) CycleUtil.getLocalVariable(
                PROCESS_TIME_INFO_KEY, this, null);
        GroupIterator iterator = stack.peek();

        if (iterator.hasNext() == false) {
            return false;
        }

        AttributeScope currentScope = CycleUtil.getCurrentPageScope();
        GroupEntry entry = iterator.next();
        currentScope.setAttribute(_groupName, entry.groupItem);
        if (_indexName != null) {
            currentScope.setAttribute(_indexName, iterator.getNextIndex());
        }
        if (_groupKeyName != null) {
            currentScope.setAttribute(_groupKeyName, entry.key);
        }
        return true;
    }

    public ProcessStatus doStartProcess(Page topLevelPage) {
        if (_items == null || _groupBy == null || _groupName == null) {
            throw new IllegalStateException();
        }
        GroupIteratorStack stack = (GroupIteratorStack) CycleUtil.getLocalVariable(
                PROCESS_TIME_INFO_KEY, this, null);
        stack.pushOne();

        if (prepareEvalBody() == false) {
            stack.pop();
            return ProcessStatus.SKIP_BODY;
        }
        return ProcessStatus.EVAL_BODY_INCLUDE;
    }

    public ProcessStatus doAfterChildProcess() {
        if (prepareEvalBody() == false) {
            GroupIteratorStack stack = (GroupIteratorStack) CycleUtil.getLocalVariable(
                    PROCESS_TIME_INFO_KEY, this, null);
            stack.pop();
            return ProcessStatus.SKIP_BODY;
        }
        return ProcessStatus.EVAL_BODY_AGAIN;
    }

    // for serialize

    private void readObject(java.io.ObjectInputStream in)
            throws java.io.IOException, ClassNotFoundException {
        in.defaultReadObject();
    }

    // support class


    /** グループを保持する値クラス */
    private class GroupEntry {
        /** グルーピングに使用したキー */
        private Object key;
        /** グループ */
        private List groupItem;
    }

    /** 繰り返し処理する値から、グループのリストを生成する */
    private Iterator<GroupEntry> makeGroupEntries(Object listObject) {
        LinkedHashMap<Object, GroupEntry> groups = new LinkedHashMap<Object, GroupEntry>();
        
        Iterator iterator = IteratorUtil.toIterator(listObject);
        while (iterator.hasNext()) {
            Object item = iterator.next();
            Object keyForItem =
                ProviderUtil.getScriptEnvironment().convertFromScriptObject(
                        _groupBy.getValue().execute(new Object[] {item}));
            GroupEntry entry = groups.get(keyForItem);
            if (entry == null) {
                entry = new GroupEntry();
                entry.key = keyForItem;
                entry.groupItem = new ArrayList();
                groups.put(keyForItem, entry);
            }
            entry.groupItem.add(item);
        }
        
        return groups.values().iterator();
    }
    
    /**
     * ForEachProcessor内のIndexIteratorStackと同じもの
     * (pushするクラスのみ異なる)
     */
    private class GroupIteratorStack {
        private Stack<GroupIterator> _stack;

        public GroupIteratorStack() {
            _stack = new Stack<GroupIterator>();
        }
        public void pushOne() {
            _stack.push(new GroupIterator());
        }
        public GroupIterator pop() {
            return (GroupIterator) _stack.pop();
        }
        public GroupIterator peek() {
            return (GroupIterator) _stack.peek();
        }
    }

    /**
     * nextの回数を取得できるIterator。
     * ForEachProcessorのIndexIteratorとほぼ同じだがlistから要素のiteratorを作るのではなく、
     * 与えられた条件でグループ化し、グループのiteratorを作って使用する
     */
    private class GroupIterator {
        private int _index;
        private Iterator<GroupEntry> _iterator;

        public GroupIterator() {
            if (_indexName != null) {
                _index = -1;
            }
            Object obj =
                ProviderUtil.getScriptEnvironment().convertFromScriptObject(
                        _items.getValue().execute(null));
            _iterator = makeGroupEntries(obj);
        }
        public Integer getNextIndex() {
            return new Integer(++_index);
        }
        public boolean hasNext() {
            return _iterator.hasNext();
        }
        public GroupEntry next() {
            return _iterator.next();
        }
   }
}

パラメータ名など以外は概ねMayaa標準のForEachProcessor(m:forEachの実装クラス)を真似している。


ポイントとしては、list属性で受け取った値をそのままイテレータ化せず、makeGroupEntries()というメソッドを呼んでグループ(GroupEntry)のイテレータを作成している所。GroupEntryはグループ化に使ったキーとグループに属する要素のリストを保持しており、ループ時に両者をパラメータで指定されたキーで現在のスコープにsetAttribute()している。


グループ用のキーの生成は、JavaScriptに要素を引数として渡し、戻って来た値毎のグループに要素を加えるようにしている。


mldファイル(sample.mld)を作成し、クラスパスの通っている所に配置(ファイル名やuriは適当に変えて下さい。)

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE library
    PUBLIC "-//The Seasar Foundation//DTD Mayaa Library Definition 1.0//EN"
    "http://mayaa.seasar.org/dtd/mld_1_0.dtd">
<library uri="http://terazzo.dyndns.org/mayaa/sample">
    <processor name="forEachGroup"
            class="sample.mayaa.processor.ForEachGroupProcessor">
        <property name="items" required="true"/>
        <property name="groupBy" required="true"/>
        <property name="group" implName="groupName" required="true" expectedClass="java.lang.String"/>
        <property name="groupKey" implName="groupKeyName" expectedClass="java.lang.String"/>
        <property name="index" implName="indexName" expectedClass="java.lang.String"/>
    </processor>
</library>

プロセッサの書き方などは公式サイトのML参照([mayaa-user:53] Re: 独自のプロセッサー。) Webにもあるとのことなので探せばもっと良いリファレンスがあるかも。

使用例

次のような表示用のサンプルデータを使う(Employeeは只の値クラス, BloodTypeは前に使ったEnum)

package sample.model;

import java.util.ArrayList;
import java.util.List;

public class SampleUtils {
    public static List getForEachGroupSample() {
        List list = new ArrayList();
        
        list.add(new Employee(){{id=1;name="青木";nameKana="アオキ";bloodType=O.getValue();}});
        list.add(new Employee(){{id=2;name="長嶋";nameKana="ナガシマ";bloodType=A.getValue();}});
        list.add(new Employee(){{id=3;name="長嶋";nameKana="ナガシマ";bloodType=A.getValue();}});
        list.add(new Employee(){{id=4;name="長嶋";nameKana="ナガシマ";bloodType=O.getValue();}});
        list.add(new Employee(){{id=5;name="長嶋";nameKana="ナガシマ";bloodType=AB.getValue();}});
        list.add(new Employee(){{id=6;name="山本";nameKana="ヤマモト";bloodType=B.getValue();}});
        list.add(new Employee(){{id=7;name="二ノ宮";nameKana="ニノミヤ";bloodType=AB.getValue();}});
        list.add(new Employee(){{id=8;name="鈴木";nameKana="スズキ";bloodType=O.getValue();}});
        list.add(new Employee(){{id=9;name="田中";nameKana="マイケル";bloodType=A.getValue();}});
        
        return list ;
    }
}


まずは通常のm:forEachを使ったループで一覧表示する例
index.html:

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN">
<html xmlns:m="http://mayaa.seasar.org">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=Shift_JIS">
<body>
    <h1>forEachGroup例</h1>
    <h2>通常のforEachの場合</h2>
    <table border="1">
      <tr>
        <th>社員番号</th><th>名前</th><th>血液型</th>
      </tr>
      <div m:id="repetition1">
      <tr>
        <td><span m:id="id1">-1</span></td>
        <td><span m:id="name1">社員太郎</span></td>
        <td><span m:id="bloodType1">X</span></td>
      </tr>
      </div>
    </table>
</body>
</html>

index.mayaa:

<?xml version="1.0" encoding="Shift_JIS"?>
<m:mayaa xmlns:m="http://mayaa.seasar.org"
    xmlns:t="http://terazzo.dyndns.org/mayaa/sample"
    xmlns:sample="http://terazzo.dyndns.org/tags-sample">
    <m:beforeRender>
        var employees = Packages.sample.model.SampleUtils.forEachGroupSample;
        var bloodTypes = Packages.sample.model.BloodType.values();
    </m:beforeRender>
    <m:forEach m:id="repetition1" items="${employees}" var="employee"/>
    <m:write m:id="id1" value="${employee.id}"/>
    <m:write m:id="name1" value="${employee.name}"/>
    <sample:labelCollection m:id="bloodType1"
          collectionName="bloodTypes" value="value" label="label"
          name="employee" property="bloodType"/>

</m:mayaa>

表示値の取得は(アクション書くの面倒だったので) m:beforeRenderでクラスから直接取得した。
sample:labelCollectionは前に使ったTaglibで、リストと値からラベルを特定して文字列表示するというもの(ここでは、1~4などの数字をABOに変換している。)


次に、forEachGroupを使って、上のデータで血液型ごとに表を作ってみる。
itemsに社員情報のリスト、groupByに個々の社員情報から血液型を取得する関数を指定すると、血液型の種類回ループしながら、それぞれの血液型の社員情報をgroupにセットしてくれる。


byblood.html:

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN">
<html xmlns:m="http://mayaa.seasar.org">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=Shift_JIS">
<body>
    <h1>forEachGroup例</h1>
    <h2>血液型毎にgroupBy</h2>
    <table border="1">
      <tr>
        <th>血液型</th><th>社員番号</th><th>名前</th>
      </tr>
      <!-- 血液型ごとにグループをつくる -->
      <div m:id="groupByBloodType">
      <!-- 個々の血液型ごとにその血液型に属する社員がリストで渡ってくるので -->
      <!-- さらにそのリストでループして個々の社員情報を表示 -->
      <div m:id="repetition2">
      <tr>
        <td m:id="showBloodType2"><span m:id="bloodType2">X</span></td>
        <td><span m:id="id2">-1</span></td>
        <td><span m:id="name2">社員太郎</span></td>
      </tr>
      </div>
      </div>
    </table>
</body>
</html>

byblood.mayaa:

<?xml version="1.0" encoding="Shift_JIS"?>
<m:mayaa xmlns:m="http://mayaa.seasar.org"
    xmlns:t="http://terazzo.dyndns.org/mayaa/sample"
    xmlns:sample="http://terazzo.dyndns.org/tags-sample">
    <m:beforeRender>
        var employees = Packages.sample.model.SampleUtils.forEachGroupSample;
        var bloodTypes = Packages.sample.model.BloodType.values();
        var host = {
            byBloodType: function(x) {
                return x.bloodType;
            }
        };
    </m:beforeRender>

    <!--
         employeesを血液型(bloodType)毎にグループ化し、グループ毎にループ。
         ループの際、employeesForEachBloodTypeに各グループの要素一覧を設定。
         同時にbloodTypeにそのグループの血液型を設定
      -->
    <t:forEachGroup m:id="groupByBloodType" items="${employees}"
        groupBy="${host.byBloodType}"
        group="employeesForEachBloodType"
        groupKey="bloodType"/>

    <!--
        各グループを、m:forEachでさらにループ
    -->
    <m:forEach m:id="repetition2" items="${employeesForEachBloodType}"
        var="employee" index="idx2"/>

    <!-- rowspanの為のトリック -->
    <m:if m:id="showBloodType2" test="${idx2 == 0}">
        <td rowspan="${employeesForEachBloodType.size()}">
        <m:doBody/>
        </td>
    </m:if>
    <sample:labelCollection m:id="bloodType2"
          collectionName="bloodTypes" value="value" label="label"
          name="bloodType"/>
    <m:write m:id="id2" value="${employee.id}"/>
    <m:write m:id="name2" value="${employee.name}"/>
</m:mayaa>

groupByBloodTypeでグループ毎にループし、さらにrepetition2で各グループの要素を表示している。
groupByに渡すスクリプトの内容を"${hoge.fuga}"にしておくと、execute時に引数を渡す事で、hogeオブジェクトのfuga関数として評価する事が出来る。上の例だとhost.byBloodTypeが呼ばれ、引数にはリストの要素が渡せる。


次はよみがなごとに表を作ってみる。中身はほぼ同じ
bynamekana.html:

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN">
<html xmlns:m="http://mayaa.seasar.org">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=Shift_JIS">
<body>
    <h1>forEachGroup例</h1>
    <h2>よみがな先頭文字毎にgroupBy</h2>
    <table border="1">
      <tr>
        <th>五十音</th><th>社員番号</th><th>名前</th>
      </tr>
      <div m:id="groupByNameKana">
      <div m:id="repetition3">
      <tr>
        <td m:id="showNameKana3"><span m:id="nameKana3">を</span></td>
        <td><span m:id="id3">-1</span></td>
        <td><span m:id="name3">社員太郎</span></td>
      </tr>
      </div>
      </div>
    </table>


</body>
</html>

bynamekana.mayaa:

<?xml version="1.0" encoding="Shift_JIS"?>
<m:mayaa xmlns:m="http://mayaa.seasar.org"
    xmlns:t="http://terazzo.dyndns.org/mayaa/sample"
    xmlns:sample="http://terazzo.dyndns.org/tags-sample">
    <m:beforeRender>
        var employees = Packages.sample.model.SampleUtils.forEachGroupSample;
        var bloodTypes = Packages.sample.model.BloodType.values();
        var host = {
            byNameKana: function(x){
                return x.nameKana.substr(0,1);
            }
        };
    </m:beforeRender>

    <t:forEachGroup m:id="groupByNameKana" items="${employees}"
        groupBy="${host.byNameKana}"
        group="employeesForEachNameKana"
        groupKey="kana"/>
    <m:forEach m:id="repetition3" items="${employeesForEachNameKana}"
        var="employee" index="idx3"/>
    <m:if m:id="showNameKana3" test="${idx3 == 0}">
        <td rowspan="${employeesForEachNameKana.size()}">
        <m:doBody/>
        </td>
    </m:if>
    <m:write m:id="nameKana3" value="${kana}"/>
    <m:write m:id="id3" value="${employee.id}"/>
    <m:write m:id="name3" value="${employee.name}"/>
</m:mayaa>

host.byBloodTypeがhost.byNameKanaになった以外はほぼ同じ内容。byNameKanaでは名前カナの先頭1文字を使ってグループ化するように指定している。


出力結果→表示


関数部分は普通にJavaScriptなので、年月日の年毎とかも楽勝で出来る。

動作環境

  • J2SE 5.0
  • Tomcat 6(たぶん5.5でも大丈夫)
  • Mayaa 1.1.9に付いてくるjar
  • Struts 1.3.8のstruts-blankサンプルに含まれるjar(使用例のsample:labelCollectionで使用)

感想その他

属性にgroupBy="${function(x){return x.bloodType;}"と直接書けないかと思ったけど、それはちょっと無理っぽかった。


今回は並び順まで制御しなかったけど、ソート方法を指示するようにも出来なくはない。もっとも実際のデータベースの値だと、リレーション先のマスタデータに優先度が設定されていてそれでソートしたりするので、そこまで行くと流石にHTMLテンプレート上だけでは難しい。


むしろ発展方向としては、listでIEnumerable的なものを取って、そこでgroup byもできるようになっていけばいいと思う。というかDBから値取得→HTMLテンプレート適用が両方関数化されて、関数の合成・分解問題として扱えれば良いと思う。