mike-neckのブログ

Java or Groovy or Swift or Golang

JSFのConverterで見た闇の話

Converterのところで変なバグが出てて、1時間半くらいハマってたので、その記録。

先に結論を書くと、これ。


まず、選択肢用にenumを用意します。

package sample;

import javax.faces.model.SelectItem;
import java.util.NoSuchElementException;
import java.util.stream.Stream;

public enum Event {

    Track100m(1, "100m", true),
    Track400m(2, "400m", true),
    Track1500m(4, "1500m", true),
    Track5000m(5, "5000m", true),
    Hurdle400m(6, "400mH", true),
    HighJump(7, "HJ", false),
    LongJump(8, "LJ", false),
    ShotPut(9, "SP", false),
    DiscusThrow(10, "DT", false);

    private final int id;
    private final String displayName;
    private final boolean track;

    Event(int id, String display, boolean track) {
        this.id = id;
        this.displayName = display;
        this.track = track;
    }

    public int getId() {
        return id;
    }

    public String getName() {
        return displayName;
    }

    public boolean isTrack() {
        return track;
    }

    public boolean isField() {
        return !track;
    }

    private SelectItem toSelectItem() {
        return new SelectItem(this, displayName);
    }

    public static SelectItem[] tracks() {
        return Stream.of(values())
                .filter(Event::isTrack)
                .map(Event::toSelectItem)
                .toArray(SelectItem[]::new);
    }

    public static SelectItem[] fields() {
        return Stream.of(values())
                .filter(Event::isField)
                .map(Event::toSelectItem)
                .toArray(SelectItem[]::new);
    }

    public static Event of(int id) {
        for (Event e : values()) {
            if (e.id == id) return e;
        }
        throw new NoSuchElementException();
    }
}

もう一個、選択肢を準備します。

package sample;

import javax.faces.model.SelectItem;
import java.util.NoSuchElementException;

public enum Sex {

    男子(0), 女子(1);

    private final int id;

    Sex(int id) {
        this.id = id;
    }

    public int getId() {
        return id;
    }

    public static SelectItem[] asSelectItems() {
        Sex[] sexes = values();
        SelectItem[] items = new SelectItem[sexes.length];
        for (int i = 0; i < sexes.length; i++) {
            items[i] = new SelectItem(sexes[i], sexes[i].toString());
        }
        return items;
    }

    public static Sex of(int id) {
        if (id < 0 || id > 1) throw new NoSuchElementException();
        for (Sex sex : values()) {
            if(sex.id == id) return sex;
        }
        throw new NoSuchElementException();
    }
}

これらを選択させる画面を作ります。

    <h:outputText value="性別"/>
    <h:selectOneRadio label="性別" id="sex"
                      required="true" value="#{entry.sex}">
        <f:selectItems value="#{entry.sexes}"/>
        <f:converter converterId="eventConverter"/>
    </h:selectOneRadio>

    <h:outputText value="種目"/>
    <h:selectManyCheckbox label="種目" id="events"
                          required="true" value="#{entry.events}">
        <f:selectItems value="#{entry.trackEvents}"/>
        <f:selectItems value="#{entry.fieldsEvents}"/>
        <f:converter converterId="eventConverter"/>
    </h:selectManyCheckbox>

あとコンバーターも準備します。

package sample.converter;

import sample.Event;

import javax.faces.component.UIComponent;
import javax.faces.context.FacesContext;
import javax.faces.convert.Converter;
import javax.faces.convert.ConverterException;
import javax.faces.convert.FacesConverter;
import java.util.Objects;
import java.util.regex.Pattern;

@FacesConverter(value = "eventConverter", forClass = Event.class)
public class EventConverter implements Converter {

    @Override
    public Object getAsObject(FacesContext context, UIComponent component, String value) {
        Objects.requireNonNull(context);
        Objects.requireNonNull(component);
        String v = Objects.requireNonNull(value);
        if (!Pattern.compile("^\\d+$").matcher(v.trim()).matches()) {
            throw new ConverterException(v + " is not integer");
        }
        return Event.of(Integer.parseInt(v));
    }

    @Override
    public String getAsString(FacesContext context, UIComponent component, Object value) {
        Objects.requireNonNull(context);
        Objects.requireNonNull(component);
        Objects.requireNonNull(value);
        if (!(value instanceof Event)) throw new IllegalArgumentException(value + " is not Event. [class : " + value.getClass().getName() + "]");
        Event e = (Event) value;
        return String.format("%d", e.getId());
    }
}

まず最初に、Converterに注目します。

Converter<T>ではなく、Converterなので、型がわかりません。しかたなくgetAsStringにはEventではなくObjectの形で渡されます。また、逆方向のgetAsObjectでも? extends Objectが返されればよいので、forClass = Event.classとしているけど、Stringでも、List<String>でもはたまたintでも返せます。

型がわからないということは何が起こるかというと、実行時に例外が発生します。Javaは型安全ではなかったんや!


次にnullが渡される問題。

@Named("entry")でアノテートされたオブジェクトのインスタンス化された後は、その保持しているEventインスタンスnullです。したがって、JSFのEL式value="#{entry.events}"nullなので、Converter#getAsStringにはnullが渡されます。

さて、不勉強な僕はnullが渡されるなんてこれっぽっちも思わず(どれっぽっち?)、陽気にObjects.requireNonNull(value)なんてやっています。ドキュメントには

Model object to be converted (may be null)

って書いてありますけどね。発生するのはNullPointerExceptionです。画面にアクセスしてから落ちます。ドキュメントにはこうも書いてあります。

a zero-length String if value is null, otherwise the result of the conversion

いや、それならワンクッション入れてくれ。そのように不勉強な僕は思うわけです。特定のデータに対して特定の戻り値を要求するなら、そのような実装になるようにプログラムを制限してくれ、頼むから。つまりnullは氏ね。


最後にConverterの指定が型安全ではない問題。

IntelliJ IDEA様もxmlの属性値についてコード補完してくれますけど、残念ながらConverterは何に対するConverterか型情報を持たないので、f:converterに対して型推論を元にした補完ができません。したがって、不勉強な僕は型Sexに対してEventConverterを適用しているわけです。当然ながら実行時例外(ClassCastExceptionなど)が発生します。せっかくJava5で導入されたジェネリクスがもったいないです。


以上、闇の現場からの報告でした