seraphyの日記

日記というよりは過去を振り返るときのための単なる備忘録

EL式(EL3.0)をアプリケーションから活用する

概要

先の記事でEL2.2でのアプリケーションからの利用方法について調べたが、
ついでにEL3.0についても調べてみることにした。


利用方法も更に簡単になっており、便利で興味深い機能も増えている。

EL3の特徴

EL3.0では、EL2.2から文法を大幅に拡張している。

互換性は維持されているので、引き続きEL2.2と同じ使い方は可能である。

  • 複数の式をセミコロンで区切って書ける。
  • 文字列結合演算子(+=)が使える。
  • ローカル変数への代入ができる
  • リスト、マップ、セットのリテラル表現が使える。
  • クラスのStaticメソッド/フィールドのアクセスができる。
    • その対象となるクラスをインポートする機能が追加されている。
    • java.lang.*のパッケージはインポート済みなので、Math.max()などが標準で使える
  • ラムダとストリームを使った繰り返し処理ができる。
  • ELProcessorというアプリ側からEL評価環境を簡単に作成できるコンビニエンスクラスが追加されている。
互換性

EL3では文法が大幅に拡張されているが、API的には2.2の延長にあり、仕組みは変わっていない。

先の記事で示したELResolverまわりが引き続き"肝"になる部分であることに変わりない。


ELProcessorという、あらたにELを独自に利用する場合に便利なクラスが用意されているが、
これは、EL2.2までは、独自にELContextを準備し、ELResolverを設定するなどの一連の定型的な作業が必要だったところを、
EL3.0では、これを一発で行ってくれる"便利クラス"として用意されたにすぎない。


※ ただし、Staticフィールドのリゾルバや、ローカル変数への代入などを可能とするための新しいリゾルバが用意されているので、
もし、EL3.0の機能を使う場合にはEL2.2で使用していたリゾルバに加えて、これらも設定する必要がある。


既存のシステムはEL3.0にライブラリを差し替えたしても、そのまま動くことになっている。

準備するライブラリ

mavenリポジトリから取得するのが手早いと思う。

mavenのpomで示す。

    <dependency>
      <groupId>javax.el</groupId>
      <artifactId>javax.el-api</artifactId>
      <version>3.0.0</version>
      <scope>provided</scope>
    </dependency>
    <dependency>
      <groupId>org.glassfish</groupId>
      <artifactId>javax.el</artifactId>
      <version>3.0.0</version>
      <scope>runtime</scope>
    </dependency>

なお、先のEL2.2の実験例は、上記のEL3.0のライブラリに差し替えても、
問題なくコンパイルも実行もできた。

ELProcessorを使ってみる。

EL3.0評価環境の一発作成

EL3.0で用意された、コンビニエンスクラス"ELProcessor"を使ってみる。

    ELProcessor elProc = new ELProcessor();

これだけでOK.

これだけでEL3.0に対応したELContextや各種ELResolverやMapperなどの設定を行ってくれる。

ローカル変数の定義

ローカル変数を設定するのも簡単になっている。

    elProc.defineBean("foo", new BigDecimal("123"));
    elProc.defineBean("bar", "brabrabra");

ただし、これはEL2.2までのVariableMapperへの"変数"の設定とは意味が異なり、
EL3.0のStandardELContext内部が保持している"ローカル変数"への設定となる。


EL3.0のローカル変数はBeanNameELResolverという新しいリゾルバを用いた仕組みで、
つまり、EL2.2まででは暗黙の変数やスコープ変数用に独自にELResolverで作成していたものを
自動的に用意してくれているものである。

VariableMapperの変数の定義

VariableMapperによる変数定義もサポートされている。

    // 変数の定義
    elProc.setVariable("v1", "foo * 2");

引数のEL式を評価する"ValueExpression型"としてVariableMapperに設定される.

Variableはローカル変数よりも優先され、setValueやEL式の代入では更新できずエラーとなる.

evalによる評価
    Number ret = (Number)elProc.eval("foo + 1");
    assertEquals(124, ret.intValue());

ELProcessor#eval()によるEL式の評価も簡単になっている。


ただし、EL式は${}で囲んではならない

ELProcessorのgetValue, setValue, evalの各メソッドは渡された式を強制的に「${}」で囲むため、
引数として「${}」を含めると内部的には「${${}}」と二重となり文法エラーになってしまう。

getValueによる評価
    String ret = (String)elProc.getValue("bar += '☆' += foo", String.class);
    assertEquals("brabrabra☆123", ret);

※ 式全体が${}で必ず囲まれるので、従来のように"${foo}${bar}"のようには書けない。
※ そのかわりEL3でサポートされた"文字列結合演算子 += "を使うと良い。

setValueによるローカル変数への代入
    elProc.setValue("foo", "1234");
    elProc.setValue("baz", "1234"); // ※ 存在しない変数への代入も可

"baz"というローカル変数は事前定義していないが、代入が可能である。

EL式からのローカル変数の代入

EL3.0からEL式でローカル変数を代入できるようになった。

    // EL式からのローカル変数への代入例
    elProc.eval("qux = [1,2,3,4]"); // リストも定義可能
    elProc.eval("map = {'a': 111, 'b': 222}"); // マップも定義可能

    System.out.println("qux=" + elProc.getValue("qux", Object.class));
    // qux=[1, 2, 3, 4]
    System.out.println("map=" + elProc.getValue("map", Object.class));
    // map={b=222, a=111}

EL3.0では、通常の数値や文字列といったリテラルの他、リスト(List)、マップ(Map)、セット(Set)を、宣言可能である。


また、存在しない変数への代入も可能である。

VariableMapperの変数の利用

VariableMapperの"変数"はEL式からは参照としてのみ利用できる。

また、同名のローカル変数がある場合はVariableMapperのほうが優先される。


※ これはローカル変数がELResolverによる仕組みであり、VariableMapperはELResolverでオブジェクトの解決を行う前に変数名が解決される為であろう。

    // ローカル変数とVariableMapper変数の混在
    Integer v = (Integer) elProc.getValue("v1 + foo", Integer.class);
    assertEquals(Integer.valueOf(123 + 123 * 2), v);

参照する分にはローカル変数もVariableMapperの変数も違いはない。


ただし、setValueメソッドや、EL式での代入によるVariableMapper変数の更新はできない。

    // VariableMapper変数の更新はできない.
    try {
        elProc.setValue("v1", 4567);
        assertTrue(false);

    } catch (PropertyNotWritableException ex) {
        System.out.println(ex.toString());
        // javax.el.PropertyNotWritableException: Illegal Syntax for Set Operation
    }

    // VariableMapper変数はEL式からも代入できない.
    try {
        elProc.eval("v1 = 4567");
        assertTrue(false);

    } catch (PropertyNotWritableException ex) {
        System.out.println(ex.toString());
        // javax.el.PropertyNotWritableException: Illegal Syntax for Set Operation
    }
複数式の実行

EL3.0では1つのEL式の中にセミコロンで区切って複数の式を書ける。

式の結果は最後の式の評価結果となる。

    Integer ret = (Integer) elProc.getValue("x=1;y=2;z=x+y", Integer.class);
    assertEquals(Integer.valueOf(3), ret);

※ なお、文法上「複文」ではなく、「セミコロン演算子」という扱いである。CやJavaScriptでいうところのカンマ演算子みたいなもの。

Staticフィールドへのアクセス

EL3.0ではクラスのスタティックフィールドやメソッドへのアクセスが容易になった。

    elProc.defineBean("Color", new ELClass(java.awt.Color.class));

    // スタティックフィールドのアクセス
    Color color = (Color) elProc.eval("Color.red");
    assertEquals(java.awt.Color.red, color);

    // スタティックメソッドの呼び出しも可
    Color color2 = (Color) elProc.eval("Color.decode('#ffffff')");
    assertEquals(java.awt.Color.white, color2);

ELClassにラップされたクラスに対してフィールドまたはメソッドを評価することができる。


※ EL3.0で新設されたStaticFieldELResolverによって解決される。


また、EL3.0ではデフォルトで「java.lang.*」のパッケージをインポートしているため、
java.lang.Mathや、java.lang.Integerなどのクラスのスタティックメソッドを自由に呼び出せる。

    Integer ret = (Integer) elProc.getValue("x=10;y=20;Math.max(x,y)", Integer.class);
    assertEquals(Integer.valueOf(20), ret);


任意のクラスをインポートすることで、EL式から利用することも可能となる。

    elProc.getELManager().importClass("java.sql.Types");
    Integer ret = (Integer) elProc.getValue("Types.BLOB", Integer.class);
    assertEquals(Integer.valueOf(java.sql.Types.BLOB), ret);
EL式用の関数の定義

EL式で使用される独自の関数の宣言も従来よりも簡単になっている。

    // EL3の独自関数の定義方法
    Method method;
    try {
        method = getClass().getMethod("strJoin",
                new Class[] {String.class, List.class});
        elProc.defineFunction("myFn", "join", method); // staticメソッドでないとダメ

    } catch (NoSuchMethodException ex) {
        throw new RuntimeException(ex);
    }

呼び出される関数は以下のように定義されているものとする。

    /**
     * ELから呼び出される関数
     * @param 区切り文字
     * @param args 文字列として連結する要素が格納されたリスト
     * @return 文字列とした連結された結果
     */
    public static String strJoin(String sep, List<Object> args) {
        StringBuilder buf = new StringBuilder();
        if (args != null) {
            for (Object arg : args) {
                if (buf.length() > 0) {
                    buf.append(sep);
                }
                buf.append(arg != null ? arg.toString() : "");
            }
        }
        return buf.toString();
    }

以下のように呼び出すことができる。

    Object ret = elProc.eval("myFn:join(',', ['aaa', 'bbb', 'ccc', 123])");
    assertEquals("aaa,bbb,ccc,123", ret);

EL3.0からはリストのリテラルを表現できるようになったので、
可変長引数のように複数個のデータを関数に渡すことも容易になった。

EL3のラムダとストリームの利用例

EL3.0ではラムダ式やコレクションのストリームが使えるようになっている。

    // EL3のラムダ式の利用例
    List<Integer> list = Arrays.asList(1, 2, 3, 4, 5, 6);
    elProc.defineBean("list", list);
    
    // EL3の中間変数を使ったラムダ式とforEachによる繰り返し演算例
    String lambda1 = "sum=0;list.stream().forEach(x->(sum=sum+x));sum";
    Number ret1 = (Number) elProc.eval(lambda1); 
    assertEquals(1 + 2 + 3 + 4 + 5 + 6, ret1.intValue());

Listや配列に対してstream()して列挙させ、forEachにより全要素をチェックする。


これと同等なものはsumやaverage, max, min, countなどの集合演算があるので、もっとコンパクトにかける。

    // EL3のストリームと集合演算
    String lambda1b = "list.stream().sum()";
    Number ret1b = (Number) elProc.eval(lambda1b); 
    assertEquals(1 + 2 + 3 + 4 + 5 + 6, ret1b.intValue());


汎用的にはreduceを使う方法もある。

    // EL3のストリームと全体演算(reduce)、初期値0なのでストリームが空でも0となる。
    String lambda1c = "list.stream().reduce(0,(a,b)->a+b)";
    Number ret1c = (Number) elProc.eval(lambda1c);
    assertEquals(1 + 2 + 3 + 4 + 5 + 6, ret1c.intValue());


複数式やリストの構築が可能なので、以下のようなことも可能なようだ。

    // EL3のラムダ式を使ったリストのフィルタリングと型変換例
    String lambda2 ="lst=[];list.stream().forEach(x->((x % 2 == 0)?" +
            "lst.add(Integer.toString(x)):null));lst";
    @SuppressWarnings("unchecked")
    List<String> ret2 = (List<String>) elProc.eval(lambda2);
    assertEquals(Arrays.asList("2", "4", "6"), ret2);

※ 文字列に変換しているのはEL式内の型変換で数値がLongやBigDecimalに変換がされているかもしれないので。


もちろん、"filter"を使えば、もっとコンパクトにかける。

    // EL3のラムダ式を使ったリストのフィルタリングと型変換例(filter+map版)
    String lambda2b = "list.stream().filter(x->(x % 2 == 0)).map(" +
            "x->Integer.toString(x)).toList()";
    @SuppressWarnings("unchecked")
    List<String> ret2b = (List<String>) elProc.eval(lambda2b);
    assertEquals(Arrays.asList("2", "4", "6"), ret2b);


降順ソートして上位3件まで絞り込むようなこともラムダ式とストリームを使えば可能になる。

    // 降順ソートの上位3件の取得 (sortedとlimit)
    String lambda = "list=[9,3,6,4];" +
        "cmp=(a,b)->-(a-b);" + // 比較関数をラムダ変数で定義
        "cnv=(x)->Integer.toString(x);" + // タイプ変換関数をラムダ変数で定義
        "list.stream().sorted(cmp).limit(3).map(cnv).toList()";
    assertEquals(Arrays.asList("9", "6", "4"), elProc.eval(lambda));

ラムダ式は変数にできるので、コードを見やすくしたり、同じ関数を繰り返し使うようなことも可能である。


集合演算ではコレクションが空の場合は計算していない = 「値がない」という状態になるため、
そのような状態がありえる関数では、値がないかもしれないことを表すOption型として返される。

たとえば先頭1行を取得する場合、もし空の場合にどうするか決定するためorElse関数を使う。

    // 最初のアイテムを求める。コレクションが空ならば空文字にする。 (findFirstとorElse)
    assertEquals("", elProc.eval("list=[];list.stream().findFirst().orElse('')"));
ラムダの引数・変数のスコープと、高階関数性の確認 (2014/02/09追加)

ラムダ式は値として引数にもできるし戻り値にもできる = 高階関数性がある。

    // ラムダ式を返すラムダ式の変数格納と、変数からのラムダ式の呼び出し
    Number ret = (Number) elProc.eval("fn=a->(b->a+b);fn2=(a,f)->f(a);fn2(12,fn(21))");
    assertEquals(33, ret.intValue());

なお、ラムダは"javax.el.LambdaExpression型"として作成されるので、プログラム側からもinvokeすることは可能である。


ラムダの引数は、ラムダを生成した時点の値が保持される。

    elProc.eval("fn=a->(b->a+b)");
    elProc.eval("arg=10;fn1=fn(arg);arg=20;fn2=fn(arg)");
    Number ret = (Number) elProc.eval("arg=50;fn1(arg)+fn2(arg)");
    assertEquals(10 + 50 + 20 + 50, ret.intValue());

ラムダ引数はラムダが生成されたときの値を保持しており互いに干渉していないことが確認できる.

このあたりの仕組みは、EL3のELContext.html#enterLambdaScope(java.util.Map), ELContext#getLambdaArgument(String arg), ELContext.html#exitLambdaScope()などの仕組みによるものと思われる。


しかし、ラムダ式の内部から引数でない変数を参照した場合は、このように動きにはならない。

    elProc.eval("a=1234;fn=x->(x+a)");
    Number ret = (Number) elProc.eval("a=5678;fn(2345)");
    assertEquals(5678 + 2345, ret.intValue());

上記のように(試した限りでは)一般的なクロージャと異なりEL3のラムダ内からの引数でない変数へのアクセスはレキシカルスコープではない


この場合、Objective-CC#C++などのクロージャやラムダであればラムダ式を生成した時点の外部変数の参照はキャプチャされるのが一般的であるが、EL3のラムダの場合は、そのような動きにはなっていない。


あくまでも、ラムダ式を実際に評価した時点の"変数aの現在の値"を参照しており、ラムダ式に変数のスコープは影響していない。


ラムダは変数のスコープに影響を与えないため、ラムダ中で変更した変数はラムダの外から普通にアクセスできる。

    Number ret = (Number) elProc.eval("a=1234;fn=()->(a=2345;b=3456);fn();a+b");
    assertEquals(2345 + 3456, ret.intValue());

この動きについては、仕様書からは読み取れなかったが、この場合の変数a, bはELResolverでローカル変数として解決されるELの原理からすると、おそらくEL3の仕様だと思う。

結論

EL3.0は基本的にEL2.2の仕組みを、そのまま拡張しているだけであり、互換性は高そうである。


また、ELProcessorによりアプリケーション側からのELの利用が、より簡単になっている。

おそらく独自のELResolverを作る必要性は、ほとんどないだろう。


ラムダなどの鋭利な機能は、たしかにJSTLのforEachタグでitemsの指定時にフィルタできそうなので、そうゆう利用方法はあるかもしれない。


ユーテリティクラスなどのStaticメソッドの利用が可能になっているなどの点は非常に便利だと思う。


とりあえず、仕組み的にも文法的にも上位互換性があるわけで、
改善されたEL2.2として、従来どおりの使い方から徐々に移行するような形でも良いと思う。