システム開発で思うところ

Javaで主にシステム開発をしながら思うところをツラツラを綴る。主に自分向けのメモ。EE関連の情報が少なく自分自身がそういう情報があったら良いなぁということで他の人の参考になれば幸い

Rest(WebAPI)のメモ

API 設計ガイダンス - Best practices for cloud applications | Microsoft Docs

Zalando RESTful API と イベントスキーマのガイドライン

翻訳: WebAPI 設計のベストプラクティス - Qiita

RFC 7239 - Forwarded HTTP Extension

JSFのConversationScopedをフォルダでBegin・End

vermeer.hatenablog.jp

この記事で、やろうと思っていたことの お試し実装。

やりたいこと

  • あるページから遷移したときに、遷移前と遷移後のフォルダが異なる場合、ConversationをBeginする。
    フォルダから離脱するときにConversationをEndする。

  • Conversationが開始ページを必ずindex.xhtmlに強制する。

  • そして、その作法について極力意識しないような仕組みを設けたい。

使用方法

xhtml

会話スコープの制御対象となるページのxhtmlに以下の情報を追記します。

<f:metadata>
    <f:viewAction action="#{conversationLifecycleManager.startAndForwardIndexPage()}" />
</f:metadata>

Controller

アクションに関する実装です。

ManagedBeanだと要素と含めて1つにすることが多いと思いますが、私はアクションとFormを分けています。

@Controller@Actionを記述します。

@Typed(CaseOneAction.class)
@Controller
@Action
public class CaseOneAction implements Serializable {

    private static final long serialVersionUID = 1L;

    private final CaseOneForm form;

    @Inject
    public CaseOneAction(CaseOneForm form) {
        this.form = form;
    }

    public void countUp() {
        this.form.countUp();
    }

    public String fwSecond() {
        return "second.xhtml";
    }

    public String fwIndex() {
        return "index.xhtml";
    }

    public String fwRootIndex() {
        return "/index.xhtml";
    }

    public String fwOtherCase() {
        return "/case2/index.xhtml";
    }

    public String fwOtherCaseNotIndex() {
        return "/case2/second.xhtml";
    }

    public String fwReadOnly() {
        return "/readonly/index.xhtml";
    }

}
@Stereotype
@Named
@RequestScoped
@Target(value = {ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@InterceptorBinding
public @interface Controller {
}

スコープとNamedを まとめただけのアノテーションです。 @InterceptorBindingと記述していますが、今回は使用していません。
今回は使用していませんが、例えばログ出力などをAOPで実現したいときに使用するイメージです。
スコープはRequestScopedで、状態を持たないインスタンスとしています。

@Dependent
@Target(value = {ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@InterceptorBinding
public @interface Action {
}

アクションに関するメソッドのインターセプターのためのアノテーションです。
このアノテーションをConversation制御に使用します。

Form

Formに関する実装です。

ManagedBeanだと要素と含めて1つにすることが多いと思いますが、私はアクションとFormを分けています。

@ViewForm
public class CaseOneForm implements Serializable {

    private static final long serialVersionUID = 1L;

    private Integer count;

    @PostConstruct
    void init() {
        this.count = 0;
        System.out.println(this.getClass().toString() + "::生成");
    }

    @PreDestroy
    void tearDown() {
        System.out.println(this.getClass().toString() + "::破棄");
    }

    void countUp() {
        this.count++;
    }

    public Integer getCount() {
        return count;
    }

    public void setCount(Integer count) {
        this.count = count;
    }

}
@Stereotype
@Named
@ConversationScoped
@Target(value = {ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@InterceptorBinding
public @interface ViewForm {

}

スコープとNamedを まとめただけのアノテーションです。 @InterceptorBindingと記述していますが、今回は使用していません。
今回は使用していませんが、例えばログ出力などをAOPで実現したいときに使用するイメージです。
スコープはConversationScopedで、状態を持つインスタンスとしています。
会話スコープ内の異なる画面で共通して使用したい情報がある場合、状態を保持しているので DIするだけ使用できます。

実行結果

全てではありませんが、いくつかの実行結果を示すことで意図した実装になっていることを確認したいと思います。

会話スコープの開始と終わり

開始画面。
なにもインスタンス化されていません。

f:id:vermeer-1977:20180503083345p:plain

会話スコープありの画面に遷移したところで、Formがインスタンス化されています。

f:id:vermeer-1977:20180503084005p:plain

f:id:vermeer-1977:20180503084039p:plain

CountUpをしても、インスタンスは破棄されません。

f:id:vermeer-1977:20180503084201p:plain

f:id:vermeer-1977:20180503084039p:plain

CaseTwo画面に遷移すると、CaseOneのFormインスタンスが破棄されて CaseTwoのFormインスタンスが生成されます。
CaseOneの会話スコープも終了しています。

f:id:vermeer-1977:20180503101232p:plain

f:id:vermeer-1977:20180503084255p:plain

会話スコープで情報共有

CountUpを連打して、カウンターの数値が増えることを確認します。

f:id:vermeer-1977:20180503084616p:plain

「to Second」で次の画面に遷移すると、値を引き継いでいることが確認できます。

f:id:vermeer-1977:20180503084800p:plain

Second画面で、さらにCountUpを連打します。

f:id:vermeer-1977:20180503084944p:plain

始めの画面に戻っても値を保持していることが確認できます。

f:id:vermeer-1977:20180503085039p:plain

この間、会話スコープもFormも維持されたままです。

f:id:vermeer-1977:20180503085108p:plain

index.xhtmlへの強制

会話スコープが開始していないフォルダのxhtmlに遷移しようとして、しかも対象がindex.xhtml以外である場合は、遷移先のフォルダのindex.xhtmlへ強制的に遷移して会話スコープも開始します。

f:id:vermeer-1977:20180503092634p:plain

「to Case Two Not Index」を押下して、index.xhtml以外の画面に遷移しようとしても、Case2のindex.xhtmlに遷移します。

f:id:vermeer-1977:20180503093011p:plain

f:id:vermeer-1977:20180503093026p:plain

非会話スコープ(読み取り専用)

xhtmlに以下を追記していなければ、その画面は読み取り専用(RequestScope)になります。

<f:metadata>
    <f:viewAction action="#{conversationLifecycleManager.startAndForwardIndexPage()}" />
</f:metadata>

遷移前

f:id:vermeer-1977:20180503090244p:plain

「to ReadOnly」を押下して、読み取り専用画面へ遷移します。
CaseOneのFormインスタンスは破棄されますし、ReadOnlyFormインスタンスも画面表示後には破棄されます。
会話スコープも開始されていないことも確認できます。

f:id:vermeer-1977:20180503090348p:plain

f:id:vermeer-1977:20180503090417p:plain

CountUpを連打すると、現在の画面情報として表示される「1」だけが残って、サーバー側のFormインスタンスはCountUp連打都度 生成と破棄を繰り返します。

f:id:vermeer-1977:20180503090641p:plain

f:id:vermeer-1977:20180503090713p:plain

実装と仕組み

Conversation Start

開始させるだけだったら

this.conversation.begin();

をロジック上で実行すれば可能です。

ただ会話スコープのアクション全てに それを実装するのは 正直面倒です。

できれば、@Transactionのように@Actionで全ての制御が出来れば良かったのですが、Conversationはクライアント側に保持している情報を使用するため どうしても xhtmlにトリガーを実装せざるを得ませんでした。

ということで、会話スコープ対象となるxhtmlに以下を追記しておくことで、会話スコープの開始制御を追加します。

<f:metadata>
    <f:viewAction action="#{conversationLifecycleManager.startAndForwardIndexPage()}" />
</f:metadata>

f:viewActionが画面初期表示時に実行する処理を記述しています。

そして、実際の開始メソッドが以下です。

public String startAndForwardIndexPage() {
    String currentViewId = FacesContext.getCurrentInstance().getViewRoot().getViewId();
    if (this.conversation.isTransient() == false) {
        return currentViewId;
    }

    System.out.println("Conversation :::::start:::::");

    this.conversation.begin();
    this.conversation.setTimeout(300000);

    if (currentViewId.contains("index.xhtml") == false) {
        return "index.xhtml";
    }
    return currentViewId;
}

戻り値は遷移先の画面IDだということを踏まえた制御と会話スコープを開始しているだけです。

会話スコープを開始させるだけだったら、voidでも良いと思います。

Conversation End

会話スコープを終わらせることで色々と面倒なのは どこで終了をするのか?というところです。

ということで、私は「xhtmlを会話スコープ毎に まとめて フォルダが変わったところで会話スコープを終わらせる」という整理をしました。

会話に関する視認性も良いと思いますし、ルールとして単純だと思います。

  • 終了メソッド
public void endConversation(String currentViewId, String resultViewId) {
    if (shouldConversationEnd(currentViewId, resultViewId)) {
        this.conversation.end();
        System.out.println("Conversation ::::end::::");
    }
}

public String conversationId() {
    return this.conversation.getId();
}

//
boolean shouldConversationEnd(String currentViewId, String resultViewId) {
    if (this.conversation.isTransient()) {
        return false;
    }
    String startViewFolder = uriFolderPath(currentViewId);
    String resultViewFolder = uriFolderPath(resultViewId);

    if (Objects.equals(startViewFolder, resultViewFolder)) {
        return false;
    }
    return Objects.equals(resultViewFolder, "") == false;
}

//
String uriFolderPath(String viewId) {
    String[] urlPaths = viewId.split("/");
    String viewItem = urlPaths[urlPaths.length - 1];
    int folderPathSize = viewId.length() - viewItem.length();
    return viewId.substring(0, folderPathSize);
}
  • 呼び元であるInterceptorの実装
@AroundInvoke
public Object invoke(InvocationContext ic) throws Exception {
    String currentViewId = FacesContext.getCurrentInstance().getViewRoot().getViewId();
    Object resultViewId = ic.proceed();
    this.conversationLifecycleManager.endConversation(currentViewId, (String) resultViewId);
    return resultViewId;
}

処理の実行前後の画面IDを参照するところがポイントでしょうか。

試してみて上手く行かなかったメモ

同じようなことをしたり、未来の自分が「こういうやり方だったら いけるかもしれない」と思ったときのためのメモ

覚えている限り

InterceptorでBegin

会話が未開始の画面から遷移したときには、良い感じにBeginしてくれます。
問題は会話中の画面から別のフォルダに遷移したときです。
Interceptor内でEndして、Startすると、、Conversatinが同一IDになってしまいます。

おそらくですが、ConversationIdはクライアントとの往復をもって実現しているため、同一フェーズでOff・Onしても「終わったこと」になっていなくて、新しいConversationIdを採番しないためだと考えられます。

ということで、望むタイミングでConversationをBeginするためには、xhtmlの初期表示タイミングでクライアントから明示的に実行するしかないと思います。

Filterで実現

ちょっと忘れてしまったところもあるけれど、いずれにしても上述のInterceptorと同じように、OnとOffの制御が思ったように出来なかったです。

BeginかEndのどちらかだけだったら、出来なくは無かったけど*1、 結局、index.xhtmlへの開始ページ強制をやろうとしたらFacesContext.getCurrentInstance().getViewRoot().getViewId()を使用したいということもあってFilterだと都合が悪かったりしたので止めました。

諦めたこと

  • ConversationScopedの開始位置の制限
    index.xhtmlを基本として、できれば任意のページを開始位置として指定できるようにもしたいかも。
    ただ現状 フォルダ内の任意のページを、しかも複数のページを開始位置とするケースが思いつかないので、index.xhtmlだけにしています。

  • ブラウザの戻る または 閉じる*2
    これは不可能というか、サーバー側で検知させるための実装が独自に必要ということで諦めます。
    ブラウザのHistoryに対して サーバー側と同等*3の判定を行えば、ある程度 ハンドリングできそうな気もしなくは無いですが、現時点で その知見を収集もしていないので 少なくともConversationのTimeoutもしくは、セッション終了時に破棄してくれるということで諦めました。

Code

Bitbucket

さいごに

「これならできるかな?」「あれならどうだろう?」と色々とやっていたので、最終結果に至るまでに紆余曲折。

パッケージ構成については、見直しの余地があるけれど、とりあえず目的としている実装の目途はついた気がします。

Controllerについては、私の実装はアクション指向風にしています。 JSFのManagedBeanだとコンポーネント指向ってことで、要素と振る舞いを1つにしたクラスで実装する事例が多いと思いますが、Controller(Action)という振る舞いにステートフルスコープを持たせるのは どうかなぁと。
私の設計思想としては状態を保持する主体(ここだとForm)がステートフルかステートレスか を管理すべきかな、と そんな風に考えました。
なお、DIしているから実質同じじゃない?という指摘は ごもっともだと思います。

後から気が付いた事

会話終了を任意で指定*4

必ずフォルダを離脱しないとスコープが終わらない、という仕様だと 同じフローを繰り返し行いたいときに困ることが分かりました。例えば@EndConversationを付与したメソッドは実行時に会話を終了させる、というようなことをすると良さそうです。

会話開始の遷移先でリダイレクトをしていない*5

これはバグです。

リダイレクトで遷移をしていないと、初回表示時点で会話が始まっていません。サーバー側のログだけ見て開始されていると思い込んでいましたが、ブラウザのアドレスを見たらパラメータが出力されていないので、意図した挙動になっていませんでした。

出ていないけど大丈夫だろう、と勝手に思い込んでいましたが 偶然 F5(更新)をしたときに、ログを見ていて 新たにFormのインスタンスが生成されていることで気が付きました。

startAndForwardIndexPageの遷移先を指定しているところは、
index.xhtmlではなくてindex.xhtml?faces-redirect=true
return currentViewIdではなくてreturn currentViewId + "?faces-redirect=true";
にすべきでした。

viewActionを使うやり方は良くない*6

vermeer.hatenablog.jp

*1:もうお試し実装を無くしてしまった

*2:2018/5/12 追記

*3:もしくは それ以上の

*4:2018/5/12 追記

*5:2018/5/12 追記

*6:2018/12/9 追記

JSFで自動Redirect

JSFをちょっと便利にする仕組み

はじめに

JSFで遷移先を指定するときに、return xxx.xhtml と記述します。

リダイレクトしたい場合は

return xxx.xhtml?faces-redirect=true

と記述します。

というか、リダイレクトしないことは皆無に近しいので、必ず記述します。

毎回追記するのも面倒だし、typoした場合、実行時例外になるので 正直面倒です。

定数にして、必ず追記するように、というのも方法としてありますが、とにかく「約束事の記述」は そもそも減らしたいです。

対応方法

Interceptorを使って、アクションの戻り値(つまり遷移先のViewID)に強制的に付与します。

Annotation

@Stereotype
@Named
@RequestScoped
@Target(value = {ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@InterceptorBinding
public @interface Action {

}

Interceptor

@Priorityの値を大きく(つまり優先度を低く)しているのは、各Interceptorの実行後で一番初めに処理してほしいからです。
つまり遷移後の状態を、他のInterceptorの実行前にリダイレクトとなるように処置しておくことで 画面遷移周りの挙動の安定を図ろうという算段です。 *1

工夫したところとしては、遷移後の画面IDに単純に?faces-redirect=trueを付与するだけだと、 自画面遷移として戻り値をvoidにしたときにNullPointerExceptionになってしまうので 遷移前の画面IDを取得するようにしているところです。

@Interceptor
@Dependent
@Priority(Interceptor.Priority.APPLICATION + 100)
@Action
public class ForceRedirectInterceptor {

    @AroundInvoke
    public Object invoke(InvocationContext ic) throws Exception {
        String currentViewId = FacesContext.getCurrentInstance().getViewRoot().getViewId();

        Object resultViewId = ic.proceed();

        if (resultViewId == null) {
            resultViewId = currentViewId;
        }
        resultViewId += "?faces-redirect=true";
        return resultViewId;
    }
}

使用実装

リダイレクトの記述をせずとも@Actionがあれば自動でリダイレクトされます。

ちょっと、手を抜きますが、実際に動かすと きちんとURLがアクションにあわせて
「今 有効となっている画面のURL」になっていることが確認できると思います。

@Action
public class IndexAction {

    public String fwCaseOne() {
        return "case1/index.xhtml";
    }
}

Code

今回は提示なしです。 Conversationとの組み合わせと合わせて公開します。

さいごに

多くのWebフレームワークではリダイレクトをしないことがデフォルトで、「リダイレクトするときには、こうします」みたいな実装例が示されるように感じています。

でも 個人的に不思議なのが、Webシステムでの URLの表記や、2度押しなどのリスクを鑑みた場合に「リダイレクトをしない」ケースの方が稀な気がするのです。

少なくともWebアプリレベルで リダイレクトを する か しない か は決まると思うので、起動パラメータやConfigでON・OFFできるようにしたら良い気がするのですが、実際そんなフレームワークを知らないので需要は無いのかな?

リダイレクトするとき と しないとき の場合分けを仕組みとして持たせたい要望があれば、System Property とかで指定できるようにしようかなぁと思いますが、 自分がそのケースに遭遇する気がしないので、私は「リダイレクトは常にする」を強制するという、今回のInterceptorを標準的に適用しようと思っています。

*1:これは、今後書く予定のConversationScopedの自動endに関連するので、その時に生きてきますが、それはまた後日ということで

Payara と Java EE 8 で Hello

EEなんだから、やっぱりPayaraだよね。

ということで、この記事のPayara版

vermeer.hatenablog.jp

Hello しかしていないから、本当にこれが正しいのか全く分からないけれど。。

PayaraはEサーバーなので、pomでの依存解決や、web.xmlなどの記述量が減りますね。

依存関係

pom.xml

    <dependencies>
        <dependency>
            <groupId>javax</groupId>
            <artifactId>javaee-web-api</artifactId>
            <version>8.0</version>
            <scope>provided</scope>
        </dependency>
    </dependencies>

これだけ。

設定

WEB-INF/beans.xml

CDIのバージョンを2.0にしておく。
しないといけないか、分からないけど理屈上はしておくべきだと思うので。

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://xmlns.jcp.org/xml/ns/javaee"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/beans_2_0.xsd"
       bean-discovery-mode="annotated">
</beans>

WEB-INF/web.xml

Servletのバージョンを4.0にしておく。
しないといけないか、分からないけど理屈上はしておくべきだと思うので。

<?xml version="1.0" encoding="UTF-8"?>
<web-app
    xmlns="http://xmlns.jcp.org/xml/ns/javaee"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
    version="4.0">

    <servlet>
        <servlet-name>Faces Servlet</servlet-name>
        <servlet-class>javax.faces.webapp.FacesServlet</servlet-class>
        <load-on-startup>1</load-on-startup>
    </servlet>
    <servlet-mapping>
        <servlet-name>Faces Servlet</servlet-name>
        <url-pattern>*.xhtml</url-pattern>
    </servlet-mapping>

    <context-param>
        <param-name>javax.faces.validator.DISABLE_DEFAULT_BEAN_VALIDATOR</param-name>
        <param-value>true</param-value>
    </context-param>

    <context-param>
        <param-name>javax.faces.SEPARATOR_CHAR</param-name>
        <param-value>-</param-value>
    </context-param>

    <welcome-file-list>
        <welcome-file>index.xhtml</welcome-file>
    </welcome-file-list>

    <session-config>
        <session-timeout>
            30
        </session-timeout>
    </session-config>

</web-app>

Code

Bitbucket

さいごに

まだ手探り。

とりあえずEEで色々するのは Tomcatじゃなくて、ちゃんとEEサーバーで色々と試していこうかな。

というわけで Hello Java EE 8

2018/4/25 追記
NetBeans8.2で開発をするんだったら、とりあえずJava EE 7までにしておいた方が良さそうなので、この雛型は使わない。

Tomcat と Java EE 7(JSF2.2) で Hello

余分なものを除外した雛型みたいなもの。

画面遷移も なーんにもない、本当に「Hello」としか出力しないもの。

以前、作った時のpomと今回のpomは微妙に違ったりも。

しかも、Java EE 8 がリリースされているのに、Java EE 7(笑)*1

とにかく、これでJSFで実験するための土台は出来たかな。

依存関係

pom.xml

<dependencies>

    <dependency>
        <groupId>javax</groupId>
        <artifactId>javaee-web-api</artifactId>
        <version>7.0</version>
        <scope>provided</scope>
    </dependency>

    <dependency>
        <groupId>javax.faces</groupId>
        <artifactId>javax.faces-api</artifactId>
        <version>2.2</version>
        <scope>provided</scope>
    </dependency>

    <dependency>
        <groupId>org.glassfish</groupId>
        <artifactId>javax.faces</artifactId>
        <version>2.2.17</version>
        <scope>runtime</scope>
    </dependency>

    <dependency>
        <groupId>javax.enterprise</groupId>
        <artifactId>cdi-api</artifactId>
        <version>1.2</version>
        <scope>provided</scope>
    </dependency>

    <dependency>
        <groupId>org.jboss.weld.servlet</groupId>
        <artifactId>weld-servlet</artifactId>
        <version>2.4.7.Final</version>
        <scope>runtime</scope>
    </dependency>

</dependencies>

設定

META-INF/context.xml

<?xml version="1.0" encoding="UTF-8"?>
<Context antiJARLocking="true" antiResourceLocking="true" path="/jsf-base-tomcat">
    <Resource auth="Container" factory="org.jboss.weld.resources.ManagerObjectFactory" name="BeanManager" type="javax.enterprise.inject.spi.BeanManager"/>
</Context>

WEB-INF/beans.xml

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://xmlns.jcp.org/xml/ns/javaee"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/beans_1_2.xsd"
       bean-discovery-mode="annotated">
</beans>

WEB-INF/faces-config.xml

<?xml version='1.0' encoding='UTF-8'?>
<faces-config version="2.2"
              xmlns="http://java.sun.com/xml/ns/javaee"
              xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
              xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-facesconfig_2_2.xsd">
</faces-config>

WEB-INF/web.xml

雛型と言いつつ、web.xmlは記載多め。

<?xml version="1.0" encoding="UTF-8"?>
<web-app version="3.1" xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd">
    <servlet>
        <servlet-name>Faces Servlet</servlet-name>
        <servlet-class>javax.faces.webapp.FacesServlet</servlet-class>
        <load-on-startup>1</load-on-startup>
    </servlet>
    <servlet-mapping>
        <servlet-name>Faces Servlet</servlet-name>
        <url-pattern>*.xhtml</url-pattern>
    </servlet-mapping>

    <context-param>
        <param-name>javax.faces.validator.DISABLE_DEFAULT_BEAN_VALIDATOR</param-name>
        <param-value>true</param-value>
    </context-param>

    <context-param>
        <param-name>javax.faces.SEPARATOR_CHAR</param-name>
        <param-value>-</param-value>
    </context-param>

    <filter>
        <filter-name>SetCharacterEncoding</filter-name>
        <filter-class>org.apache.catalina.filters.SetCharacterEncodingFilter</filter-class>
        <init-param>
            <param-name>encoding</param-name>
            <param-value>UTF-8</param-value>
        </init-param>
        <init-param>
            <param-name>ignore</param-name>
            <param-value>false</param-value>
        </init-param>
    </filter>
    <filter-mapping>
        <filter-name>SetCharacterEncoding</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>

    <filter>
        <filter-name>Conversation</filter-name>
        <filter-class>org.jboss.weld.servlet.ConversationFilter</filter-class>
    </filter>
    <filter-mapping>
        <filter-name>Conversation</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>

    <listener>
        <listener-class>org.jboss.weld.environment.servlet.Listener</listener-class>
    </listener>

    <resource-env-ref>
        <resource-env-ref-name>BeanManager</resource-env-ref-name>
        <resource-env-ref-type>javax.enterprise.inject.spi.BeanManager</resource-env-ref-type>
    </resource-env-ref>

    <welcome-file-list>
        <welcome-file>index.xhtml</welcome-file>
    </welcome-file-list>

    <session-config>
        <session-timeout>
            30
        </session-timeout>
    </session-config>

</web-app>

Code

Bitbucket

Java EE 8版
Bitbucket

*1:2018/4/19 Java EE 8版も作ってみました。Codeにも追記

BeanValidationのメッセージ遅延変換

過去記事の

vermeer.hatenablog.jp

vermeer.hatenablog.jp

vermeer.hatenablog.jp

と、ResourceBundleの扱いを追加した

vermeer.hatenablog.jp

を まとめ直してライブラリを作成しました。

過去記事との違い

ライブラリにするにあたって、過去の記事の実装との相違点は以下

  • 参照リソースを複数指定できるようにする

  • メッセージ変換都度 Resourceのパスを渡すようにせず、Factoryで指定する

  • LocaleとResourceのパスを渡すタイミングを分ける

  • FormのValidationは適時実装するものとしてライブラリからは一旦除外する。

メッセージ変換の使い方(参考)

ざっくり示すと以下のような感じ。

Validator validator = Validation.buildDefaultValidatorFactory().getValidator();

Set<ConstraintViolation<TargetView>> results = validator.validate(view, FormValidation.class);


// メッセージ変換に使用するResourceBundleを指定します
MessageInterpolatorFactory interpolatorFactory
              = MessageInterpolatorFactory.of("Messages", "FormMessages", "FormLabels");

// 複数のリソースを指定したい場合
//        MessageInterpolatorFactory interpolatorFactory
//                                  = MessageInterpolatorFactory.of(
//                        new String[]{"Messages", "Messages2"},
//                        new String[]{"FormMessages", "FormMessages2"},
//                        new String[]{"FormLabels", "FormLabels2"});

// 変換インスタンスを生成します(ロケールは未指定)
MessageInterpolator interpolator = interpolatorFactory.create();

// 変換インスタンスを生成します(ロケールを指定)
// MessageInterpolator interpolator = interpolatorFactory.create(Locale.JAPAN);

for (ConstraintViolation<TargetView> result : results) {
    // 検証結果からメッセージを変換します.
    String convertedMessage = interpolator.toMessage(result);
    System.out.println(convertedMessage);
}

その他、実際のAnnotationとの組み合わせについては、テストコードを参照していただければと思います。

Code

Bitbucket

さいごに

これだけだと実際のところ不十分で、JSFなどWebシステムでメッセージを出力するという具体的な使用感を掴みながら改修をしていくことになると思います。

複数のResourceBundleを参照できるようにする

はじめに

そもそもResourceBundle#getBundleに対して複数のリソースを読み込ませたいという要求自体があまりないのかもしれません。

もしそれをしたいのであれば、ListなりにResourceBundleを保持しておいて検索すれば事足りると思います。

ですが、BeanValidationでメッセージ出力をしているときに遭遇したのはResourceBundleをインターフェースとしたクラスを引数として渡さないといけないというケースでした。

色々なやり方があるとは思いますが、私はnewBundleを拡張するやり方でやってみました。

具体的には

vermeer.hatenablog.jp

で自作したライブラリの拡張です。

実装

方式としては、参照資産を全て1つのstreamに結合して読み込ませる、というだけです。

今回の実験で得た知識はSequenceInputStreamです。

なんと、JDK1.0からのクラスです。知りませんでした。

それをリソースファイル分、再帰処理で結合していきました(#concateInputStrem)。

ちょっとした工夫としては、リソースファイルが改行で終わっていないケースを考慮して 結合時に改行情報を差し込むようにしたところでしょうか。

protected ResourceBundle newBundleProperties(String baseName, Locale locale, ClassLoader loader, boolean reload)
        throws IllegalAccessException, InstantiationException, IOException {
    InputStream stream = readBaseNames(baseName, locale, loader, reload);
    ResourceBundle bundle = null;
    if (stream != null) {
        try {
            if (this.charCode != null) {
                bundle = new PropertyResourceBundle(new InputStreamReader(stream, this.charCode));
            } else {
                bundle = new PropertyResourceBundle(stream);
            }
        } finally {
            stream.close();
        }
    }
    return bundle;
}

InputStream readBaseNames(String baseName, Locale locale, ClassLoader loader, boolean reload) throws IOException {
    String[] _baseNames = baseName.split(",");
    List<InputStream> propertiesInputStreams = new ArrayList<>();
    for (String _baseName : _baseNames) {
        InputStream propertiesInputStream = readProperties(_baseName.trim(), locale, loader, reload);
        /* propertiesの結合時に使用する改行文字 */
        InputStream lineSeparator = new ByteArrayInputStream(System.lineSeparator().getBytes(StandardCharsets.UTF_8));
        if (propertiesInputStream != null) {
            InputStream inputStreamWithLineSeparator = new SequenceInputStream(propertiesInputStream, lineSeparator);
            propertiesInputStreams.add(inputStreamWithLineSeparator);
        }
    }

    if (propertiesInputStreams.isEmpty()) {
        return null;
    }

    Iterator<InputStream> inputStreamsIterator = propertiesInputStreams.iterator();
    InputStream inputStream = inputStreamsIterator.next();
    if (propertiesInputStreams.size() == 1) {
        return inputStream;
    }
    return concateInputStrem(inputStreamsIterator, inputStream);
}

InputStream readProperties(String baseName, Locale locale, ClassLoader loader, boolean reload) throws IOException {
    String bundleName = toBundleName(baseName, locale);
    final String resourceName = (bundleName.contains("://"))
                                ? null
                                : toResourceName(bundleName, "properties");
    if (resourceName == null) {
        return null;
    }
    final ClassLoader classLoader = loader;
    final boolean reloadFlag = reload;
    InputStream stream = null;
    try {
        stream = AccessController.doPrivileged((PrivilegedExceptionAction<InputStream>) () -> {
            InputStream is = null;
            if (reloadFlag) {
                URL url = classLoader.getResource(resourceName);
                if (url != null) {
                    URLConnection connection = url.openConnection();
                    if (connection != null) {
                        // Disable caches to get fresh data for reloading.
                        connection.setUseCaches(false);
                        is = connection.getInputStream();
                    }
                }
            } else {
                is = classLoader.getResourceAsStream(resourceName);
            }
            return is;
        });
    } catch (PrivilegedActionException e) {
        throw (IOException) e.getException();
    }
    return stream;
}

/**
 * InputStreamを結合して返却します.
 *
 * @param inputStreamsIterator 参照資産のイテレーター
 * @param concatedInputStream 結合先となるInputStream
 * @return 結合したInputStream
 */
InputStream concateInputStrem(Iterator<InputStream> inputStreamsIterator, InputStream concatedInputStream) throws IOException {
    if (inputStreamsIterator.hasNext() == false) {
        return concatedInputStream;
    }
    InputStream inputStream = inputStreamsIterator.next();
    SequenceInputStream sequenceInputStream = new SequenceInputStream(concatedInputStream, inputStream);
    return concateInputStrem(inputStreamsIterator, sequenceInputStream);
}

Code

Bitbucket

さいごに

ResoueceBundleそのものを拡張する、というやり方も試したりしたのですが、思ったようにできませんでした。

このやり方であれば特殊なことはしていないので(単純に読み込むstreamを結合しただけ)、コードのボリュームの割には単純なやり方なように思います。

ちょっと迷ったのは、Controlに追加したいPropertyFileを指定する、というやり方です。 こちらの案だったら、引数の型を配列なり、リストなりにして「複数指定していますよ」ということをメソッドで表現できます。

最終的に本方式にしたのは「ファイルの指定箇所が分散すると3日後の自分が忘れそうだ」というのが大きく、もう1つはControlをResourceBundleの生成都度置き換えるという実装思想が、しっくり来なかったからです。