seraphyの日記

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

EL式(EL2.2)をアプリケーションへ組み込む方法

概要

JavaJSP/JSFでは「EL式」という簡易な式言語を用いてオブジェクトの連鎖を評価することができる。


たとえば、"${foo[bar].baz}"というEL式では、fooという配列またはリストから、添え字を示すbarの位置のあるオブジェクトのbazというプロパティを評価することを表す。


こうゆう仕組みはJSPの中にとどまらず、設定ファイルなどでも変数展開のように使えると便利だと思うときがたびたびある。

また、ちょっとした数式を"eval"したい、という場合もあるだろう。


そこで、EL式を自分のアプリケーションから利用する方法について調べてみた。

EL式のアプリケーションへの組み込み

ELのバージョン

EL式にはいくつかバージョンがあり、

  1. JSP2.0のEL式 (${}による即時評価)
  2. JSF1.0のEL式 (#{}による遅延評価)
  3. JSP2.1とJSF1.1の、EL式を統合した、Unified EL(EL2.1)。JavaEE5以降、Tomcat6等
  4. JSP2.2と、ビーンのメソッド呼び出しなどをサポートするようになったEL2.2(→jsr245)。JavaEE6以降、Tomcat7等 ← 現在のメインストリーム?
  5. ラムダや複数式、代入をサポートするように大幅に言語拡張されたEL3.0(→jsr341) (JavaEE7, 2013/6リリース)


最新のEL3.0では、JSP/JSFだけでなくスタンドアロンでの利用を想定した"ELProcessor(→Javadoc)"のようなクラスも追加されており、自アプリケーションに組み込むのであれば、かなり容易くなっている。


反面、ウェブアプリ内でEL式を使う場合には、環境がまだJavaEE6やTomcat7だったりすると、新しいライブラリを利用するのは難しいかもしれない。

また、EL3はJava8の登場を待たず先行してラムダやコレクションをストリームで扱う仕組みを取り入れているなどの尖った部分が目立ち、今後のJava8との整合性も気になるところである。


とりあえず、まずは現行で広く使われているEL2.2を利用できるようにすることを考えてみる。

ライブラリの準備 (EL2.2のインタフェースおよび実装クラス)

EL式はJavaEE5以降でjavax.el.*パッケージ(→Javadoc)として標準化されている。

EL2.2のインタフェースであれば、"el-api-2.2.jar(→mvn)"などをクラスパスを通すことで、EL2.2を利用したソースコードコンパイルできるようになる。


実際に動作させるにはELの実装クラスも必要だが、Tomcat7やGlassfish-v3(JavaEE6)系では、これらのEL2.2の実装クラスも組み込まれている。


ウェブアプリケーションではなく、スタンドアロンなアプリケーションでEL2.2を利用したい場合は、Glassfishが提供している"el-impl-2-2.jar(→mvn)"のような実装クラスを参照に加える。


Mavenのpomで示せば、以下のような感じとなる。

    <dependency>
      <groupId>javax.el</groupId>
      <artifactId>el-api</artifactId>
      <version>2.2</version>
      <scope>provided</scope>
    </dependency>
    <dependency>
      <groupId>org.glassfish.web</groupId>
      <artifactId>el-impl</artifactId>
      <version>2.2</version>
      <scope>runtime</scope>
    </dependency>
ライセンス

なお、"el-impl-2-2.jar"は、Glassfishの成果物なのでライセンスはCDDLまたはクラスパス例外のGPLv2であり、
よって自アプリからライブラリとして参照するだけならば、自アプリのライセンスに影響を与えない。

準備するクラス

EL2.2を利用するには以下のクラスの準備が必要である。

  • ELContext (必須)
  • FunctionMapper (△必須、手抜き可能)
  • VariableMapper (△必須、手抜き可能)
  • ELResolver (必要ならば)
ELContextの実装

ELContextは抽象クラスであり、
FunctionMapper, VariableMapper, ELResolverの3つの要素を返すメソッドを定義する必要がある。

FunctionMapperの実装

FunctionMapperは、EL式で式の呼び出しがあった場合に、それを実際のメソッドと結びつけるためのものである。
EL式の中で「式」を使わないのであれば、nullを返すだけの簡単な実装でも良い。


あるいは、el-impl.jarの中に含まれる「FunctionMapperImpl」というクラスを借用しても良い。

VariableMapperの実装


VariableMapperは、EL式中で参照される変数を事前に定義しておくもので、変数名から変数を結びつけるためのものである。

それも単純なHashMapにつなげるだけの実装で問題ない。


あるいは、さらに手抜きをしてel-impl.jarの中に含まれる「VariableMapperImpl」というクラスを借用しても良い。

ELResolverの実装


ELResolverは、EL式の"肝"となる部分である。


EL式は、"${foo[bar].baz}"の文法を解釈し、foo, bar, bazの3要素に分割する。


このあと、

  • もしfooがマップであればbarをキーとして、
  • もしfooがリストであればbarを添え字として、
  • もしfooがリソースバンドルであればbarをリソースキーとして、

というように、オブジェクトの種類に応じて値を取得する方法が異なる点を吸収する必要がある。

この「マップなら〜、リストなら〜、リソースバンドルなら〜」といった解釈の違いをサポートするのがELResolverである。


これらのELResolverをまとめるためのCompositeELResolverというELResolverもある。


必要最低限のELResolverとしては、

    CompositeELResolver resolver = new CompositeELResolver();
    resolver.add(new ResourceBundleELResolver()); // リソースバンドルの解決用
    resolver.add(new MapELResolver()); // Mapの解決用
    resolver.add(new ListELResolver()); // Listの解決用
    resolver.add(new ArrayELResolver()); // 配列の解決用
    resolver.add(new BeanELResolver()); // Beanのsetter/getterの解決用

とすれば、一般的なEL式の解釈はできるようになる。


上記のELResolverは、上から順に評価され解決した時点で終了するので、addする順序は重要である。


また、JSPJSFなどでは、"暗黙の変数"のような仮想的な変数のようなものをサポートするためにさらにいくつかのELResolverを追加している。

たとえばJSP上のEL式で使えるrequest変数等は、実際にはVariableMapperに格納されている変数ではなく、ELResolverでブリッジ(もしくは射影)されている"仮想の変数"といえる。


自アプリケーションでも独自の暗黙の変数のようなものをサポートするのであればELResolverの拡張を行うことができる。

最低限の利用例

最低限、上記の準備を実装したものが以下の例となる。

    /**
     * [テスト] 最低限のEL式評価方法のテスト
     */
    public void testMinimum() {
        final CompositeELResolver resolver = new CompositeELResolver();
        resolver.add(new ResourceBundleELResolver()); // リソースバンドルの解決用
        resolver.add(new MapELResolver()); // Map, Propertiesの解決用
        resolver.add(new ListELResolver()); // Listの解決用
        resolver.add(new ArrayELResolver()); // 配列の解決用
        resolver.add(new BeanELResolver()); // Beanのsetter/getterの解決用

        final VariableMapper varMapper = new VariableMapperImpl(); // 借用
        final FunctionMapper funcMapper = new FunctionMapperImpl(); // 借用

        ELContext elContext = new ELContext() {
            @Override
            public ELResolver getELResolver() {
                return resolver;
            }
            @Override
            public FunctionMapper getFunctionMapper() {
                return funcMapper;
            }
            @Override
            public VariableMapper getVariableMapper() {
                return varMapper;
            }
        };

        // EL式の評価ファクトリ
        ExpressionFactory ef = ExpressionFactory.newInstance();

        // EL式で評価するための変数を設定する.
        varMapper.setVariable("foo", ef.createValueExpression("FOO", String.class));
        
        MyBean myBean = new MyBean();
        myBean.setX(1);
        myBean.setY(2);
        varMapper.setVariable("bar", ef.createValueExpression(myBean, myBean.getClass()));

        // EL式を評価する.
        String expression = "hello, ${foo}! ${bar.x + 234}";
        ValueExpression ve = ef.createValueExpression(elContext, expression, String.class);
        String ret = (String) ve.getValue(elContext);

        System.out.println("result=" + ret);
        // result=hello, FOO! 235
    }

変数"foo"に使っているビーンは以下の定義とする。

    public static final class MyBean {
        
        private int x;
        
        private int y;
        
        public int getX() {
            return x;
        }
        
        public void setX(int x) {
            this.x = x;
        }
        
        public int getY() {
            return y;
        }
        
        public void setY(int y) {
            this.y = y;
        }
	
	public void mes(String mes) {
	    System.out.println("mes=" + mes);
	}
        
        @Override
        public String toString() {
            return "(x: " + x + ", y:" + y + ")";
        }
    }

これで一応、"hello, ${foo}! ${bar.x + 234}"というEL式を解析し、
結果として「hello, FOO! 235」という値を得ることができるようになっている。

変数の準備方法

EL式の中にある変数は、VariableMapperから取得される。

(もしなければ仮想変数のようなものとしてELResolverのチェーン内を検索する。)


EL式はcreateValueExpressionメソッドで、ValueExpressionを作成した時点のELContextの中にある変数の状態を記憶するため、事前に変数を設定しておく必要がある。
(つまり、式を作成した時点で見えている変数の状態が記憶されている。)


変数も"ValueExpression"という形式で作成しなければならないが、
これはExpressionFactory#createValueExpression(Object, Class)というヘルパメソッドによって、
オブジェクトをそのまま返すだけのラッパーとして容易に作成することができる。


したがって、

    varMapper.setVariable("foo", ef.createValueExpression("FOO", String.class));

のように"foo"という変数名で、"FOO"という文字列を格納することができる。


なお、第二引数は、このValueExpressionが返すオブジェクトの型を表す。

EL式のValueExpressionの評価結果は、戻り値となるべき型に自動的に"型変換"される。

EL式の解析(読み込み)

EL式には、${}による即時評価と、#{}による遅延評価の二種類があるが、
EL2.2ではAPI的には${}も#{}も区別なく、どちらも同じ扱いとなっている。

(唯一、区別しているのは、1つのEL式の中で${}と#{}を混在させられないことぐらいである。)


たとえば、"${bar.x}"というEL式を解析する場合、

    String expression = "${bar.x}";
    ValueExpression ve = ef.createValueExpression(elContext, expression, Object.class);

このように「ValueExpression」という形で取得することになる。


つまり、${}でも#{}でもAPI的には、遅延評価型の扱いになっているわけである。


実際に変数の値を取得するには、ValueExpression#getValue()メソッドを呼び出す必要がある。

EL式の解析(書き込み)

遅延評価型なので、変数を更新したい場合も同じように使うことができ、たとえば以下のように記述することができる。

    String expression2 = "${bar.x}";
    ValueExpression ve2 = ef.createValueExpression(elContext, expression2, Object.class);
    ve2.setValue(elContext, 123);
    System.out.println("myBean.x=" + myBean.getX());
    // myBean.x=123

変数として参照されているmyBeanインスタンスの値が変更されていることが確認できる。


もちろん更新できるのはEL式が指すものがオブジェクトの場合のみである。


以下のようにリテラルを混在したEL式は読み込み専用である。

    String expression = "Hello, ${foo}!";
    ValueExpression ve = ef.createValueExpression(elContext, expression, Object.class);
    ve.setValue(elContext, 123); // ここで例外が発生する

書き込もうとすると、結果は"javax.el.PropertyNotWritableException: Illegal Syntax for Set Operation"と例外が発生する。

EL式の解析(アクションの呼び出し)

EL2.2ではEL式中でオブジェクトのメソッドが呼び出せる。

    String expression = "${bar.mes('hello')}";
    MethodExpression me = ef.createMethodExpression(elContext, expression, Object.class, new Class[0]);
    me.invoke(elContext, new Object[0]);
    // コンソールに「mes=hello」と出る。

メソッドmesの引数をEL式の中で与えているので、MethodExpressionにせずとも、
ValueExpression#getValue()でも同じ結果となる。


MethodExpressionのメリットは、EL式ではメソッドまでを指定し、引数はプログラム側から与えられる点であろう。

    String expression = "#{bar.mes}";
    MethodExpression me = ef.createMethodExpression(
        elContext, expression, Object.class, new Class[] {String.class});
    me.invoke(elContext, new Object[] {"こんにちは、世界!!"});
    // mes=こんにちは、世界!!

イベントに対する「#{アクション}イベントハンドラ」など、コールバックを受けるメソッドをEL式から示すような用途が考えられる。

暗黙の変数をサポートする独自のELResolverの作成

これまでの方法で、任意の変数を設定してEL式で評価することは可能となっている。

これに加えて、jspのEL式などで使われているように「暗黙の変数」をサポートする場合はELResolverを拡張する必要がある。


「暗黙の変数」は、変数名から何らかのオブジェクトにブリッジさせるものであり、変数とは異なりValueExpressionの中には状態が保持されておらず、実際の評価時に「その時の値が使用される」という特徴がある。


ELResolverは、EL式の「${first.second}」の式を解析した結果、

  1. firstに相当するものを解釈する
  2. firstに対するオブジェクトがわかっている場合にsecondに相当するものを解釈する

という2つの動きを行う。

ELResolverは受け取ったものを自分が認識できない場合は何もせず、次のELResolverに渡す、という動きになっている。


「暗黙の変数」を処理する場合は、firstの部分に「暗黙の変数名」が来た場合に何らかのオブジェクトを返すようにすればいい。

この場合、secondについては考慮しなくてよい。

なぜならば暗黙の変数が何らかのbeanやMapを返したとすると、このオブジェクトは既存のMapELResolverやBeanELResolverによって解釈することができるためである。


また、暗黙の変数は一般的に代入不可なので、代入処理は考慮しなくてよい。


そうすると、以下のようなELResolverの実装を行えばよい。

    /**
     * 暗黙の"implicit"コンテキストの型
     */
    private static class ImplicitContext extends HashMap<String, Object> {}

    /**
     * 暗黙の変数"implicit"をELContextから取得するためのELResolverの実装例.
     */
    private static class MyImplicitELResolver extends ELResolver {

        @Override
        public Class<?> getCommonPropertyType(ELContext context, Object base) {
            if (base == null) {
                // baseがnull、つまり${first.xxxx}のfirstの場合は、
                // 文字列として変数名を受け取ることを示す.
                return String.class;
            }
            return null;
        }

        @Override
        public Iterator<FeatureDescriptor> getFeatureDescriptors(
                ELContext context, Object base) {
            if (base != null) {
                // とりあえず空を返しておく.(手抜き、なくてもEL式は動く)
                return Collections.<FeatureDescriptor>emptyList().iterator();
            }
            return null;
        }

        @Override
        public Class<?> getType(ELContext context, Object base, Object property) {
            if (base == null && property != null) {
                String name = property.toString();
                if ("implicit".equals(name)) {
                    // ${first.second}の、firstの場合、
                    // それが"implicit"という名前であれば、ImplicitContextクラスを返す.
                    context.setPropertyResolved(true); // 解釈済みであることを示す
                    return ImplicitContext.class;
                }
            }
            return null; // 不明な場合は次のELResolverへ
        }

        @Override
        public Object getValue(ELContext context, Object base, Object property) {
            if (base == null && property != null) {
                String name = property.toString();
                if ("implicit".equals(name)) {
                    // ${first.second}の、firstの場合、
                    // それが"implicit"という名前であれば、ELContextに設定されている
                    // ImplicitContextコンテキストを返す.
                    // ※ ELContext#setContext()で事前に設定しておくこと.
                    context.setPropertyResolved(true); // 解釈済みであることを示す
                    return context.getContext(ImplicitContext.class);
                }
            }
            return null; // 不明な場合は次のELResolverへ
        }

        @Override
        public boolean isReadOnly(ELContext context, Object base,
                Object property) {
            return true;
        }

        @Override
        public void setValue(ELContext context, Object base, Object property,
                Object value) {
            throw new PropertyNotWritableException("代入はサポートされていません/base="
                    + base + "/property=" + property);
        }
    }

このMyImplicitELResolverは、"implicit"という仮想の変数名を解釈し、ELContextに設定されている
ImplicitContextクラスのコンテキストを返すようにしている。


ImplicitContextは、実体はただのマップだが、ELContextのコンテキストとしてクラスをキーしてアクセスするため、
ImplicitContextという型にして利用しやすいようにしている。


これを利用するコードは以下のような使い方ができる。

    // 文字列リストの変数を作成する.
    List<String> lst = Arrays.asList("aaa", "bbb", "ccc", "ddd");
    varMapper.setVariable("list", ef.createValueExpression(lst, List.class));

    // 暗黙の変数をもつEL式
    String expression = "${list[implicit.idx]}";
    ValueExpression ve = ef.createValueExpression(elContext, expression, String.class);

    // "implicit"変数の値となるオブジェクト
    ImplicitContext implicitContext = new ImplicitContext();
    elContext.putContext(ImplicitContext.class, implicitContext);

    for (int idx = 0; idx < lst.size(); idx++) {
        implicitContext.put("idx", Integer.valueOf(idx));
        String ret = (String) ve.getValue(elContext);
        System.out.println("ret[" + idx + "]=" + ret);
    }

"${list[implicit.idx]}"というEL式を解析して、一旦、ValueExpressionを取得する。


この時点で変数listはValueExpressionに格納されている状態となるが、"implicit.idx"は実体がないので、変数としては格納されていない状態である。


このあと、forループで"implicit.idx"の値を変えながら、同じValueExpressionを繰り返し評価する。


すると、結果は以下のようになる。

ret[0]=aaa
ret[1]=bbb
ret[2]=ccc
ret[3]=ddd


このように、ELResolverによる暗黙の変数の拡張も比較的簡単にできることがわかる。

EL式の関数のサポート

ELでは独自の関数を定義することができる。


EL2.2からはオブジェクトのメソッドを呼び出すことが容易になっているため出番も少なくなっていると思われるが、
EL標準の演算子では足りない汎用的な演算のために、アプリケーション固有の関数を用意することもできる。


たとえば、java.lang.Mathクラスにあるユーテリティ関数のうち、第一引数がdoubleのものを利用可能とする場合、

    final FunctionMapper funcMapper = new FunctionMapper() {
        
        private HashMap<String, Method> methods = new HashMap<String, Method>();
        
        {
            for (Method method : Math.class.getMethods()) {
                if (Modifier.isStatic(method.getModifiers())) {
                    String name = method.getName();
                    Class<?>[] params = method.getParameterTypes();
                    boolean accept = true;
                    if (params.length > 0) {
                        if (!params[0].isAssignableFrom(double.class)) {
                            // 第一引数がある場合、double型でなければ不可とする.
			    // ※ 同名の型違いの関数を避けるため
                            accept = false;
                        }
                    }
                    if (accept) {
                        methods.put(name, method);
                    }
                }
            }
        }
        
        @Override
        public Method resolveFunction(String prefix, String localName) {
            if ("math".equals(prefix)) {
                return methods.get(localName);
            }
            return null; // nullの場合は関数が未登録であることを示す
        }
    };

このようにFunctionMapperで関数名が与えられたら、その関数のメソッドを返すだけである。

あとは、そのように実装したfuncMapperをELContextで返すようにすれば良い。


上記コードでは、プリフィックスとして「math」を用いているため、EL式としては、以下のように使うことになる。

    varMapper.setVariable("a", ef.createValueExpression(10, Integer.class));
    varMapper.setVariable("b", ef.createValueExpression(20, Integer.class));

    String expression = "max=${math:max(a,b)}, min=${math:min(a,b)}";
    ValueExpression ve = ef.createValueExpression(elContext,
            expression, String.class);

    String ret = (String) ve.getValue(elContext);
    System.out.println("ret=" + ret);
    // 結果は、"ret=max=20.0, min=10.0"

変数a, bはInteger型だが、EL式は自動的に型変換をサポートしているため、double型として計算されている。

(仮に変数が文字列型の"10", "20"であっても、正しくdouble型に変換されて計算される。)

雑多なユーテリティ

システムプロパティへのアクセス

EL式の中でシステムプロパティを参照できるようにするには、"System.getPriperties()"の結果をマップに詰めなおして変数に設定する。

MapELResolverが設定されていれば、あとは通常のMapと同様に処理される。

    Properties sysProps = System.getProperties();
    HashMap<String, Object> sysMap = new HashMap<String, Object>();
    for (String name : sysProps.stringPropertyNames()) {
        sysMap.put(name, sysProps.getProperty(name));
    }
    varMapper.setVariable("sys", ef.createValueExpression(sysMap, Map.class));

    String expression = "${sys['java.version']}";

    ValueExpression ve = ef.createValueExpression(elContext,
        expression, String.class);
    String ret = (String) ve.getValue(elContext);

※ PropertiesクラスはMapインタフェースをもっているので上記のように詰め替えなくてもマップとしてアクセスは可能であるが、Propertiesには親となるPropertiesをカスケードチェインにできる機能があり、その場合、Map#get()は、そのチェインをさかのぼることができない問題があるため、厳密にいえば、詰め替え処理が必要となる。

ResourceBundleへのアクセス

ResourceBundleELResolverが設定されていれば、変数としてリソースバンドルを設定するだけで、あとは名前でアクセスできる。

    // リソースバンドル
    ResourceBundle res = ResourceBundle.getBundle(getClass().getCanonicalName());
    varMapper.setVariable("res", ef.createValueExpression(res, ResourceBundle.class));

    String expression = "${res['foo.bar.baz']}";

    ValueExpression ve = ef.createValueExpression(elContext, expression, String.class);
    String ret = (String) ve.getValue(elContext);

結論

独自にEL式を評価することは思った以上に簡単だった。


EL式は、若干の定型的なコードを書くだけで、労せずにしてリッチな変数展開の仕組みを利用できる、大変有益なものであった。

いままで、この仕組みを調べていなかったことを後悔したくなるほどである。


EL3.0からは、いっそうスタンドアロン的にEL式を使うための仕組みも拡充されているようなので、
今後はもっと積極的に使っていっても良い手法だと思われる。