Archive for 12月, 2013
Java EE 7 WebSocket Mail の実装
本エントリは、Java Advent Calendar 2013 の 20日目のエントリです。
昨日、19日目は 23 才のお誕生日を迎えた、 ひらおかゆみさん (id:yumix_h) の
「JavaMailを手軽に使うライブラリ」でした。 yumix_h さんお誕生日おめでとうございます!!
明日は、@nagaseyasuhito さんです。
本エントリは、1つ前のエントリで投稿した「JSF + WebSocket で実装した IMAP Web メール・クライアント」の続きのエントリで、WebSocket の実装部についてご紹介します。
ただし、残念ながら今回はアンチパターンとしてのご紹介になります。
ポイントは、WebSocket のサーバ・エンドポイントの実装で別スレッドを立てて監視などを行ってはいけないという点です。
追記:2014 年 5 月 9 日 : Java Mail 1.5.2 のリリースによりアンチパターンではなくなりました。詳細は本エントリ最下部をご参照
WebSocket はリアルタイム通知を行うために多くの開発者に興味を持たれています。実際、このデモでは、IMPAの受信箱(INBOX)に入ってきたメッセージをユーザにいち早く、リアルタイムでお知らせするために、WebSocket を使用して通知しています。WebSocket を使う事でこのようなアプリケーションを作成する事もできます。
実際には、下記のように実装しています。まず、クライアント側の View の実装ですが、JSF の XHTML と JavaScript でそれぞれ実装します。
JSF のページ内で <h:outputScript> を指定し WebSocket のクライアント・エンドポイントの実装 JavaScript(ws-client-endpoint.js) を読み込んでいます。次に、PrimeFaces で用意されている、「Notification Bar」を使用します。「Notification Bar」は、下記 URL のサンプルでご参照頂ける通り、動的にパネルを表示させる事ができる、JSF のコンポーネントです。
http://www.primefaces.org/showcase/ui/notificationBar.jsf
このコンポーネントを使用して、WebSocket のサーバ・エンドポイントからメッセージを受信した際に、 JavaScript から PF(‘bar’).show() を実行してこのコンポーネントを表示させます。一方で、このコンポーネントを非表示にするためには、JavaScript で PF(‘bar’).hide()を呼び出す事で非表示にできます。
実際に、WebSocket のサーバに接続するためには、「リアルタイムチェック開始」ボタンを押下して接続します。「リアルタイムチェック開始」ボタンを押下すると JavaScript の connectServerEndpoint() が呼び出され、WebSocket のサーバ・エンドポイントに接続します。
<h:head> <title>JSF-WebSocket WebMail</title> <f:event type="preRenderView" listener="#{messageReceiveMgdBean.onPageLoad}"/> <h:outputScript library="javascripts" name="ws-client-endpoint.js"/> </h:head> <h:body> <h:form id="form"> <p:notificationBar position="top" effect="slide" widgetVar="bar" styleClass="top" style="background-color : #F8F8FF ; width: fit-content;"> <h:panelGrid columns="2" columnClasses="column" cellpadding="0"> <h:outputText value="新着メッセージ :" style="color: red;font-size:12px;" /> <p:commandButton value="閉じる" onclick="PF('bar').hide()" type="button" style="font-size:10px;"/> <h:outputText value="Subject :" style="font-size:10px;" /><h:outputText id="wssubject" value="" style="font-size:10px;" /> <h:outputText value="From :" style="font-size:10px;" /><h:outputText id="wsfrom" value="" style="font-size:10px;" /> <h:outputText value="メッセージ・サマリー :" style="font-size:10px;" /><h:outputText id="wssummary" value="" style="font-size:10px;" /> </h:panelGrid> </p:notificationBar> …中略 <input id="connect" type="button" value="リアルタイムチェック開始" style="font-size:10px;" onClick="connectServerEndpoint();"/> <input id="close" type="button" value="リアルタイムチェック中止" style="font-size:10px;" onClick="closeServerEndpoint();"/>
JavaScript の実装は下記の通りです。まず、ページのロード時には、「リアルタイムチェック中止」のボタンを非表示にし「リアルタイムチェック開始」ボタンのみ表示させています。ボタンを押下すると「connectServerEndpoint()」を呼び出しますが、ここでは、WebSocket のサーバ・エンドポイントに接続し、「リアルタイムチェック開始」、「リアルタイムチェック中止」ボタンそれぞれの表示、非表示を切り替えます。
次に、サーバ・エンドポイントからメッセージを受信した場合、onMessage() 経由で writeToScreen() を呼び出しJSON のデータを展開した後、JSF のコンポーネントに対して値を代入し、 PF(‘bar’).show()で通知バー(<p:notificationBar>)を表示させます。
var websocket = null; function init() { document.getElementById("close").style.display = "none"; } function closeServerEndpoint() { websocket.close(4001, "Close connection from client"); document.getElementById("connect").style.display = "block"; document.getElementById("close").style.display = "none"; } function connectServerEndpoint() { var wsUri = "ws://localhost:8080/JSF-WebSocket-Mailer/inbox-check"; if ("WebSocket" in window) { websocket = new WebSocket(wsUri); } else if ("MozWebSocket" in window) { websocket = new MozWebSocket(wsUri); } websocket.onopen = function(evt) { onOpen(evt); }; websocket.onmessage = function(evt) { onMessage(evt); }; websocket.onerror = function(evt) { onError(evt); }; websocket.onclose = function(evt) { closeServerEndpoint(); }; document.getElementById("connect").style.display = "none"; document.getElementById("close").style.display = "block"; } function onOpen(evt) { ; } function onMessage(evt) { writeToScreen(evt.data); } function onError(evt) { writeToScreen("ERROR: " + evt.data); } function writeToScreen(messages) { if (window.JSON) { var obj = JSON.parse(messages); var subject = obj.subject; var from = obj.address; var summary = obj.summary; document.getElementById('form:wssubject').innerHTML = subject; document.getElementById('form:wsfrom').innerHTML = from; document.getElementById('form:wssummary').innerHTML = summary; } PF('bar').show(); } window.addEventListener("load", init, false);
次に、サーバ・エンドポイント側の実装ですが、サーバ側の実装は下記の3クラスから構成されています。
InboxCheck : WebSocket のサーバ・エンドポイントの実装
MessageEncoder : MessageからJSONを生成するエンコーダ
InboxCheckRunnableTask : INBOX を監視する並列処理用タスク
まず、InboxCheck ですが、このクラスが WebSocket のサーバ・エンドポイントの重要なクラスです。このクラスでは、クライアント・エンドポイントから接続された際に、IMAP サーバへ接続するためのユーザ名、パスワードを受け取り、IMAP サーバへ接続を行っています。
※ 今回、JSF のログインページで入力された、ユーザ名、パスワードをWebSocket 側でも扱えるように Session Scope に値を代入し、取り出していますが、本来 Session Scope に代入すべきではないので、別の方法 (Flash等でできれば) でユーザ名、パスワードを渡す方法を検討すべきです。今回は簡単のため、Session で扱わせて頂きました。
IMAPサーバへ接続した後、checkNewMessage() で新着メッセージを監視します。
追記:checkNewMessage() メソッドの実装は、下記の実装ではなく、Java Mail 1.5.2 でリリースされたIdleManagerクラスを使用して実装してください。詳細は本エントリの最下部に記載しています。
具体的には、下記の実装箇所で、IMAP の INBOX Folder に対して、メッセージが追加された際の処理を実装しています。
folder.addMessageCountListener(new MessageCountAdapter() {});
この内部実装では、メッセージが追加(新着メッセージが来た)された際に、最新のメッセージを取得し、その情報を WebSocket のクライアント・エンドポイントに対して配信しています。
次に、実際にメッセージが追加された事を検知するための実装を行います。これは、IMAP の IDLE (RFC 2177) 機能を使って、フォルダの変更をリアルタイムで検知します。このリアルタイム監視は、InboxCheckRunnableTask で実装し、別のスレッドで監視を行うようにします。
package jp.co.oracle.samples.websockets; import java.io.IOException; import java.util.Properties; import java.util.logging.Level; import java.util.logging.Logger; import javax.annotation.Resource; import javax.annotation.security.RolesAllowed; import javax.enterprise.concurrent.ManagedThreadFactory; import javax.inject.Inject; import javax.mail.Folder; import javax.mail.Message; import javax.mail.MessagingException; import javax.mail.Session; import javax.mail.Store; import javax.mail.event.MessageCountAdapter; import javax.mail.event.MessageCountEvent; import javax.websocket.EncodeException; import javax.websocket.OnClose; import javax.websocket.OnError; import javax.websocket.OnOpen; import javax.websocket.server.ServerEndpoint; import jp.co.oracle.samples.mgdBean.IndexLoginMgdBean; import jp.co.oracle.samples.tasks.InboxCheckRunnableTask; /** * * @author Yoshio Terada */ @ServerEndpoint(value = "/inbox-check", encoders = {MessageEncoder.class}) public class InboxCheck { private Store store; private static final Logger logger = Logger.getLogger(InboxCheck.class.getPackage().getName()); @Resource ManagedThreadFactory threadFactory; InboxCheckRunnableTask invokeCheck = null; @Inject IndexLoginMgdBean login; @OnOpen public void onOpen(javax.websocket.Session session) { try { initStore(login.getImapServer(), login.getUsername(), login.getPassword()); checkNewMessage(session); } catch (MessagingException mes) { try { logger.log(Level.SEVERE, "Exception occured on monitoring INBOX ", mes); session.close(); } catch (IOException ex) { logger.log(Level.SEVERE, "Failed to close session", ex); } } } @OnClose public void onClose(javax.websocket.Session session) { if (invokeCheck != null) { invokeCheck.terminateRealTimeCheck(); } } @OnError public void onError(Throwable t) { logger.log(Level.SEVERE, "Error Occured", t); } /* 本エントリの最下部に説明した IdleManager クラスを使用して実装してください private void checkNewMessage(final javax.websocket.Session session) throws MessagingException { // INBOX のフォルダを対象 Folder folder = store.getFolder("INBOX"); if (!folder.isOpen()) { folder.open(javax.mail.Folder.READ_WRITE); } // フォルダのメッセージ・カウント数を監視 folder.addMessageCountListener(new MessageCountAdapter() { @Override public void messagesAdded(MessageCountEvent e) { Message[] msgs = e.getMessages(); Message msg = msgs[msgs.length - 1]; try { // WebSocket のクライアント・エンドポイントに送信 session.getBasicRemote().sendObject(msg); } catch (IOException | EncodeException ioencx) { logger.log(Level.SEVERE, "Failed to Send Message ", ioencx); } } }); // 別スレッドでメッセージの到着を監視 newInboxCheckThreadWithRetryCount(folder); } private void newInboxCheckThreadWithRetryCount(Folder folder) { invokeCheck = new InboxCheckRunnableTask(folder); Thread runTask = threadFactory.newThread(invokeCheck); runTask.start(); } */ // Store の初期化(ページのロード時) private void initStore(String imapServer, String username, String password) throws MessagingException { Properties props = System.getProperties(); props.setProperty("mail.store.protocol", "imaps"); Session session = Session.getDefaultInstance(props, null); javax.mail.Store initStore = session.getStore("imaps"); initStore.connect(imapServer, username, password); this.store = initStore; } }
InboxCheckRunnableTask では、IDLE 機能が有効か無効かをチェックし、IDLE 機能が有効な場合、idle()で新着メッセージの到着を待ち受けます。idle() はメッセージが1通到着すると処理が終了しますので、スレッドの終了メソッドが呼び出されるか、何らかの例外が発生するまで、無限ループで繰り返し呼び出します。
一方で、IDLE 機能が無効なサーバに接続する場合は(Yahooなど)、idle() で待ち受けできませんので、自身で定期的にポーリングを行っています。
package jp.co.oracle.samples.tasks; import com.sun.mail.imap.IMAPFolder; import java.util.logging.Level; import java.util.logging.Logger; import javax.mail.Folder; import javax.mail.MessagingException; /** * * @author Yoshio Terada */ /* 本エントリの最後に記載した IdleManager で実装してください。 public class InboxCheckRunnableTask implements Runnable { private final static int MAIL_CHECK_IDLE_TIME = 20000; private Folder folder; private static final Logger logger = Logger.getLogger(InboxCheckRunnableTask.class.getPackage().getName()); volatile boolean isRunnable = true; public InboxCheckRunnableTask(Folder folder) { this.folder = folder; } private void executeCheckForIdleDisable() throws InterruptedException, MessagingException { Thread.sleep(MAIL_CHECK_IDLE_TIME); int count = folder.getMessageCount(); } public void terminateRealTimeCheck() { isRunnable = false; } @Override public void run() { boolean idleIsAvailable = true; while (isRunnable) { // IMAPFolder のインスタンスで isIdleEnable が true の時実行 if (folder instanceof IMAPFolder) { IMAPFolder ifolder = (IMAPFolder) folder; if (idleIsAvailable) { try { ifolder.idle(); } catch (javax.mail.FolderClosedException fce) { logger.log(Level.SEVERE, "IMAP Folder closed:", fce); isRunnable = false; } catch (MessagingException ex) { if (ex.getMessage().contains("IDLE not supported")) { idleIsAvailable = false; } else { logger.log(Level.SEVERE, "IMAP Folder & Something error occured;", ex); isRunnable = false; } } } else { try { executeCheckForIdleDisable(); } catch (InterruptedException | MessagingException ime) { logger.log(Level.SEVERE, "Some error occured on executeCheckForIdleDisable() : ", ime); isRunnable = false; } } } else { logger.log(Level.SEVERE, "THis is not IMAP Folder."); isRunnable = false; } } } */ }
最後に、JavaMail の Message から JSON の文字列を生成するエンコーダを作成します。このエンコーダを作成する事で、Message オブジェクトから、JSON に変換してクライアント・エンドポイントに対してメッセージを送信できるようになります。ここでは、Java EE 7 の標準に含まれる JSON-P を使用して実装しています。
session.getBasicRemote().sendObject(msg);
package jp.co.oracle.samples.websockets; import java.io.IOException; import java.io.StringWriter; import java.util.logging.Level; import java.util.logging.Logger; import javax.json.Json; import javax.json.JsonArrayBuilder; import javax.json.JsonObject; import javax.json.JsonWriter; import javax.mail.Address; import javax.mail.Message; import javax.mail.MessagingException; import javax.mail.internet.InternetAddress; import javax.websocket.EncodeException; import javax.websocket.Encoder; import javax.websocket.EndpointConfig; import jp.co.oracle.samples.msgutil.MessageDumpUtil; /** * * @author Yoshio Terada */ public class MessageEncoder implements Encoder.Text<Message> { private final static int SUMMARY_SIZE = 80; private static final Logger logger = Logger.getLogger(MessageEncoder.class.getPackage().getName()); @Override public String encode(Message msg) throws EncodeException { try { // Address[] から JSon 配列を作成 Address[] addresses = msg.getFrom(); JsonArrayBuilder array = Json.createArrayBuilder(); for (Address adres : addresses) { InternetAddress iAddress = (InternetAddress) adres; array.add(iAddress.toUnicodeString()); } // メッセージ・サマリを取得・作成 MessageDumpUtil dumpUtil = new MessageDumpUtil(); String msgSummary = dumpUtil.getText(msg); if (!msgSummary.isEmpty() && msgSummary.length() > SUMMARY_SIZE) { String tmp = msgSummary.replaceAll("(\r|\n)", ""); msgSummary = tmp.substring(0, SUMMARY_SIZE); msgSummary = msgSummary + " ......"; } //JSon オブジェクトを生成 JsonObject model = Json.createObjectBuilder() .add("subject", msg.getSubject()) .add("address", array) .add("summary", msgSummary) .build(); //JSon オブジェクトから JSon 文字列を生成 StringWriter stWriter = new StringWriter(); try (JsonWriter jsonWriter = Json.createWriter(stWriter)) { jsonWriter.writeObject(model); } return stWriter.toString(); } catch (MessagingException | IOException ex) { EncodeException ee = new EncodeException(msg, "Encode failed ", ex); logger.log(Level.SEVERE, null, ex); throw ee; } } @Override public void init(EndpointConfig config) { } @Override public void destroy() { } }
上記のようにして、IMAP サーバの受信箱(INBOX)に新着メッセージが来た時点で通知を行う事ができます。しかし、このようなアプリケーションを一般公開する大規模なサービスとして実装すべきではありません。元々、WebSocket をサポートするサーバ・エンジンは大量のアクセスに対し少ないスレッドで処理を行うように、NIO で実装されている事が多いかと想定します。
GlassFish (Grizzly) の場合:
Grizzlyの概要 : C10K問題に対応するGlassFish(Grizzly)
Grizzlyの概要 2 : Java New I/Oで実装されたサーバその他、Jetty でも古くからNIOに対応しています。
今回作成した私の WebSocket で通知するアプリは、監視を行うために、新しく1つのスレッドを作成し、スレッド内で変更を監視するように実装しています。つまり、IMAP の受信箱(INBOX) の監視を行うために、ユーザ毎に新しくスレッドを生成しています。監視を行う為に別のスレッドを起こして無限ループ内で特定のイベントを監視するプログラムはよくあるかと想定しますが、そのようなアプリケーションに WebSocket は向きません。仮に ManagedExecutorService に変更し、コネクション・プールを使用しても問題は同じです。スレッド・プール数の最大までスレッドが生成されると終了です。
せっかく、サーバ・エンジンが NIO で実装して大量のアクセスに対して、少数のスレッドで処理を裁くことができるように実装されていても、個々のユーザ、もしくはリクエスト毎に、アプリケーション側でスレッドを生成すると、アプリケーション・レベルでC10K 問題が発生します。具体的には、100 人サーバに接続してきた場合に、100 スレッドが生成され、1000 人接続してきた場合、1000 スレッドが必要になります。
アクセス数が限られる環境では、こうした実装もありかもしれませんが、大規模に展開するサービスでは、リアルタイムで何らかの監視を行うために、個々にスレッドを生成するような実装はやめた方がよいと思います。WebSocket はリアルタイム監視などにもでき、簡単に実装できますと私も説明をしていますが、サーバ側の実装は十分にご検討頂いた後、作成してください。
追記:2014 年 5 月 9 日
上記の実装後、Java Mail のスペックリードに上記を解決するために、NIO を使って実装できる API を新たに作成して欲しい旨要望を出しておりました。その結果、Java Mail 1.5.2 より実験的に、IdleManager クラスが導入されました。これにより上記で実装した idle() メソッドの代わりに IdleManager で実装する事で、多くのスレッドを生成しなくてもよくなりサーバ側での C10K 問題を解決できます。詳しくは下記 IdleManager の API とそこに記載されているサンプルをご覧ください。
https://javamail.java.net/nonav/docs/api/com/sun/mail/imap/IdleManager.html
実際には微調整が必要ですが下記のような実装になります。
@Resource ManagedExecutorService es; private void checkNewMessage(final javax.websocket.Session session) throws MessagingException, IOException { Properties props = session.getProperties(); props.put("mail.event.scope", "session"); // or "application" props.put("mail.event.executor", es); //javax.mail.Session Session mailSession = Session.getInstance(props, null); // シングルトン EJB より IdleManager を取得 final IdleManager idleManager = imapIdleManager.getSingleIdleManager(mailSession, es); //final IdleManager idleManager = new IdleManager(mailSession, es); // IMAP Server へ接続 javax.mail.Store initStore = mailSession.getStore("imaps"); initStore.connect("imap-server.yoshio3.com", "USERNAME", "PASSWORD"); Folder folder = store.getFolder("INBOX"); folder.open(Folder.READ_WRITE); folder.addMessageCountListener(new MessageCountAdapter() { public void messagesAdded(MessageCountEvent ev) { Folder folder = (Folder)ev.getSource(); Message[] msgs = ev.getMessages(); System.out.println("Folder: " + folder + " got " + msgs.length + " new messages"); // process new messages idleManager.watch(folder); // keep watching for new messages } }); idleManager.watch(folder); }
※ JJUG CCC の HoL で実施した WebSocket のハンズオンは、JMS を使用して単一のMDB でキューやトピックを監視しメッセージ配信を行っているため、上記のような問題はありません。
JSF + WebSocket で実装した IMAP Web メール・クライアント
このエントリは Java EE Advent Calendar 2013 の 11 日目のエントリです。昨日はsk44_ さんの 「JSF で日本語ファイル名のファイルダウンロード?」のご紹介 でした。
明日は @nagaseyasuhito さんです。
エントリを始める前に、昨日 12/10 は Java EE 6/GlassFish v3 が正式リリースされて丁度 4 年目にあたる日でした。2009 年のブログを確認すると 昨日の日本時間の夜 11 時頃からダウンロードできていたようです。
Happy Birth Day 4th Anniversary of Java EE 6 & GlassFish v3 !!
今回私は、JavaServer Faces (JSF) + WebSocket + Java Mail API を使用して、IMAP のメールクライアントを作成しました。本アプリケーションは、Ajax を使用していますが、Ajax 部分では一切 JavaScript を使用していません。JSF のデフォルトで用意されている Ajax ライブラリを使用し動的な画面更新を実現しています。また、今回実装したコードはコード量も比較的少なくある程度かんたんに動かす所までの実装で 3-4 日程度で実装できています。是非ご覧ください。
このアプリケーションのデモ動画はコチラ
今回作成した JSF のアプリケーションは並列処理 (Concurrency Utilities for Java EE)も利用しています。例えば長時間処理が必要な処理を実行しなければならない場合、バックエンドの処理をシーケンシャルに処理していては大量の時間を要してしまいます。これを並列処理 (Concurrency Utilities) を利用する事で描画までの時間を短縮する事もできます。
ここでご紹介する JSF(PrimeFaces) で実装したサンプル・アプリケーションを通じて Java EE 7 のテクノロジーを使ってどのような事ができるのか、どのようにして実装できるのかをご理解いただければ幸いです。
特に JSF (PrimeFaces)で ツリーやテーブルを扱う部分、さらには Ajax を実現する部分はご注目ください。
※ このアプリケーションの実装では INBOX を監視し新規メッセージを受信した際、WebSocket で通知を行なう部分も実装しています。しかし WebSocket の実装部分は次回エントリで記載する予定です。
今回作成したアプリの全ソースコードは下記の URL にアップしました。
https://github.com/yoshioterada/JSF-WebSocket-Mailer
本アプリケーションで使用する、Java EE 7 の技術を紹介します。
● JavaServer Faces 2.2 (PrimeFaces 4.0)
● Java Mail 1.5
● Contexts and Dependency Injection 1.1
● Concurrency Utilities 1.0
まず、本アプリケーションの完成予想イメージを示します。
本アプリケーションは、ログイン画面の「IMAP Server 名」で指定した IMAP サーバに対して、「ログイン名」、「パスワード」を入力しIMAPサーバとの認証を行い、認証に成功した場合、下記の画面が表示されます。
上記の画面は、主に3つのコンポーネントから構成されています。
● フォルダ一覧表示部(画面左部)
● フォルダ内のサブジェクト一覧表示部(画面右上部)
● メッセージ表示部(画面右下部)
フォルダ一覧表示部(画面左部)
画面左側に IMAP サーバ上で作成されているフォルダの一覧を表示しています。
フォルダに子のフォルダが存在する場合、「▶」のマークが表示され、展開する事によって子のフォルダ一覧を取得できます。「▶」を押下すると Ajax でサーバに問い合わせを行い、子のフォルダを一覧を取得します。1度子フォルダを取得した後は「▶」を押下しても Ajax 通信を行なわず、開いたり閉じたりできるようになります。
特定のフォルダを選択すると、選択したフォルダ内に存在するメッセージを Ajax で行い、取得後「フォルダ内のサブジェクト一覧表示部」と「メッセージ表示部」を更新します。
また、「受信数:」のフィールドにデフォルトで「10」と表示されています。ここで扱う数字は、画面右部の「フォルダ内のサブジェクト一覧表示部」で扱うメッセージの件数を変更できます。デフォルトでは、テーブル内で表示されているメッセージは5件です。テーブル下部に存在するボタン「2」を押下する事で次の5件を表示できるようになります。
本エントリでは詳細は説明しませんが、「リアルタイム・チェック開始」、「リアルタイム・チェック中止」ボタンを押下する事で、それぞれ WebSocket 通信の開始、中止を行なうことが、INBOX (受信箱)にメッセージが届くと WebSocket でリアルタイムに通知を受け取ることができるようになります。
フォルダ内のサブジェクト一覧表示部(画面右上部)
画面右上部では、アプリケーション起動直後はデフォルトで INBOX(受信箱)に存在するメッセージの内、最新5 件のメッセージの「サブジェクト」、「送信者アドレス」、「送信日付」、「サイズ」を取得し表示しています。
また、デフォルトでフォルダに存在する最新のメッセージが選択された状態になります。また、「サブジェクト」、「日付」、メッセージの「サイズ」に応じてソートができるようになっていますので、各項目でソートをしたい場合、各項目の上下の ↑↓ 部分を押下することでソートができます。
また、サーバ側ではデフォルトで受信数 10 件を管理していますが、1 画面中では、5 件のメッセージを表示できます。テーブル下部に存在する「2」のボタンを押下する事で次の5件を取得できます。このデフォルトの受信数を変更したい場合は、「フォルダ一覧表示部(画面左部)」の下に存在する「受信数」のフィールドの数字を変更し「適用」ボタンを押下する事で受信数を変更でき、参照可能な件数が代わります。例えば、「受信数」を 20 に変更した場合、テーブル下部に「1」、「2」、「3」、「4」のボタンが追加されます。また、テーブル内に存在する、メッセージ(サブジェクト等が表示されている)を選択すると対応するメッセージを Ajax で取得し「メッセージ表示部」に対象のメッセージを表示します。
メッセージ表示部
最後に、右画面の下部を下記に示します。デフォルトで INBOX(受信箱)に存在する最新のメッセージを表示していますが、「フォルダ内のサブジェクト一覧表示部(画面右上部)」で特定のメッセージをマウスでクリックし選択すると、対応するメッセージがここで表示されます。
本アプリケーションの実装方法の詳細
この JSF アプリケーションの主要な機能は View の実装として、JSF のFacelets を使用し「folders-show.xhtml」ファイルに画面デザインを実装しています。また、この画面のバックエンドの処理を行なったり、画面上に存在する各種コンポーネントとのバインディングを行なうために、JSF 管理対象 Bean を「MessageReceiveMgdBean.java」として実装しています。つまりこの2つのファイルを確認する事で、本アプリケーションの詳細な振る舞いを把握する事ができます。
メッセージを表示するために実装した Facelets の全ソースコードを下記に示します。ある程度、複雑な画面構成になっているにも関わらず、記載しているコード量が 88行程度と、とても少ない事をご確認いただけるのではないかと思います。
<?xml version='1.0' encoding='UTF-8' ?> <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> <html xmlns="http://www.w3.org/1999/xhtml" xmlns:h="http://xmlns.jcp.org/jsf/html" xmlns:p="http://primefaces.org/ui" xmlns:f="http://xmlns.jcp.org/jsf/core" xmlns:ui="http://xmlns.jcp.org/jsf/facelets"> <h:head> <title>JSF-WebSocket WebMail</title> <f:event type="preRenderView" listener="#{messageReceiveMgdBean.onPageLoad}"/> <h:outputScript library="javascripts" name="ws-client-endpoint.js"/> </h:head> <h:body> <h:form id="form"> <p:notificationBar position="top" effect="slide" widgetVar="bar" styleClass="top" style="background-color : #F8F8FF ; width: fit-content;"> <h:panelGrid columns="2" columnClasses="column" cellpadding="0"> <h:outputText value="新着メッセージ :" style="color: red;font-size:12px;" /> <p:commandButton value="閉じる" onclick="PF('bar').hide()" type="button" style="font-size:10px;"/> <h:outputText value="Subject :" style="font-size:10px;" /><h:outputText id="wssubject" value="" style="font-size:10px;" /> <h:outputText value="From :" style="font-size:10px;" /><h:outputText id="wsfrom" value="" style="font-size:10px;" /> <h:outputText value="メッセージ・サマリー :" style="font-size:10px;" /><h:outputText id="wssummary" value="" style="font-size:10px;" /> </h:panelGrid> </p:notificationBar> <p:layout fullPage="true"> <p:layoutUnit position="west" size="200" header="フォルダ一覧" resizable="true" closable="true" collapsible="true" style="font-size:14px;"> <p:tree id="docTree" value="#{messageReceiveMgdBean.root}" var="doc" selectionMode="single" dynamic="true" selection="#{messageReceiveMgdBean.selectedNode}"> <p:ajax event="select" listener="#{messageReceiveMgdBean.onNodeSelect}" update=":form:mailheader :form:specifiedMsg"/> <p:treeNode> <h:outputText value="#{doc.name}" style="font-size:14px;"/> </p:treeNode> </p:tree> <p:outputLabel value="受信数:" style="font-size:10px;"/> <p:inputText autocomplete="false" id="numberOfMsg" value="#{messageReceiveMgdBean.numberOfMessages}" style="font-size:10px;"> </p:inputText> <h:commandButton id="upBtn" value="適用" style="font-size:10px;"> <f:ajax event="click" render="mailheader" execute="numberOfMsg" listener="#{messageReceiveMgdBean.updateMessageCount}"/> </h:commandButton> <input id="connect" type="button" value="リアルタイムチェック開始" style="font-size:10px;" onClick="connectServerEndpoint();"/> <input id="close" type="button" value="リアルタイムチェック中止" style="font-size:10px;" onClick="closeServerEndpoint();"/> </p:layoutUnit> <p:layoutUnit position="center"> <p:dataTable id="mailheader" var="mheader" paginator="true" paginatorPosition="bottom" value="#{messageReceiveMgdBean.mailHeaderModel}" rows="5" rowKey=" #{mheader.messageCount}" selection="#{messageReceiveMgdBean.selectedMailHeader}" selectionMode="single" style="width:800px;font-size:10px;" > <p:ajax event="rowSelect" listener="#{messageReceiveMgdBean.onMessageSelect}" update=":form:specifiedMsg" global="false"/> <p:column id="msubject" headerText="サブジェクト" style="font-size:10px;" sortBy="subject" width="50%"> #{mheader.subject} </p:column> <p:column id="maddress" headerText="アドレス" style="font-size:10px;" width="30%"> <ui:repeat value="#{mheader.fromAddress}" var="fromEmail"> #{fromEmail.toUnicodeString()} </ui:repeat> </p:column> <p:column id="mdate" headerText="日付" style="font-size:10px;" sortBy="sendDate" width="10%"> <h:outputLabel value="#{mheader.sendDate}"> <f:convertDateTime pattern="yyyy年MM月dd日 HH:mm:ss"/> </h:outputLabel> </p:column> <p:column id="msize" headerText="サイズ" style="font-size:10px;" sortBy="size" width="10%"> #{mheader.size} </p:column> </p:dataTable> <p:scrollPanel style="width:800px;height:400px" mode="native"> <h:outputText id="specifiedMsg" value="#{messageReceiveMgdBean.specifiedMessage}" escape="false"/> </p:scrollPanel> </p:layoutUnit> </p:layout> </h:form> <p:ajaxStatus onstart="PF('statusDialog').show();" onsuccess="PF('statusDialog').hide();"/> <p:dialog modal="true" widgetVar="statusDialog" header="処理中" draggable="false" closable="false"> <p:graphicImage value="/resources/imgs/ajaxloadingbar.gif" /> </p:dialog> </h:body> </html>
次に上記 Facelets のバックエンド処理を実装する、MessageReceiveMgdBean クラスを下記に示します。
package jp.co.oracle.samples.mgdBean; import java.io.Serializable; import java.util.List; import java.util.Properties; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import java.util.logging.Level; import java.util.logging.Logger; import javax.annotation.Resource; import javax.enterprise.concurrent.ManagedExecutorService; import javax.inject.Named; import javax.faces.view.ViewScoped; import javax.inject.Inject; import javax.mail.MessagingException; import javax.mail.Session; import javax.mail.Store; import jp.co.oracle.samples.tasks.AllFolderHandlerTask; import jp.co.oracle.samples.beans.FolderName; import jp.co.oracle.samples.beans.MailHeader; import jp.co.oracle.samples.beans.MailHeaderModel; import jp.co.oracle.samples.tasks.SpecifiedMessageHandlerTask; import jp.co.oracle.samples.tasks.SpecifiedNodeMailHeaderHandleTask; import org.primefaces.event.NodeSelectEvent; import org.primefaces.event.SelectEvent; import org.primefaces.model.TreeNode; /** * * @author Yoshio Terada */ @Named(value = "messageReceiveMgdBean") @ViewScoped public class MessageReceiveMgdBean implements Serializable { private Store store; private TreeNode root; private TreeNode selectedNode; private MailHeader selectedMailHeader; private MailHeaderModel mailHeaderModel; private String folderFullName; private String specifiedMessage; private int numberOfMessages = DEFAULT_NUMBER_OF_MESSAGE; private final static int DEFAULT_NUMBER_OF_MESSAGE = 10; private static final Logger logger = Logger.getLogger(MessageReceiveMgdBean.class.getPackage().getName()); @Inject IndexLoginMgdBean login; @Resource ManagedExecutorService execService; /** * コンストラクタ */ public MessageReceiveMgdBean() { } /** * ページのロード時の処理を実装 * 並列で各タスクを実行し結果表示速度を少し改善 */ public void onPageLoad() { String imapServer = login.getImapServer(); String username = login.getUsername(); String password = login.getPassword(); initStore(imapServer, username, password); if (getRoot() == null) { //全フォルダリストの取得 Future<TreeNode> folderHandlesubmit = execService.submit(new AllFolderHandlerTask(store)); int num = getNumberOfMessages(); if (num == 0) { num = DEFAULT_NUMBER_OF_MESSAGE; } // デフォルトで INBOX のメッセージの取得 folderFullName = "INBOX"; Future<MailHeaderModel> headerHandlerSubmit = execService.submit(new SpecifiedNodeMailHeaderHandleTask(store, folderFullName, num)); Future<String> messageHandlerSubmit = null; try { // デフォルトで INBOX の最新のメッセージ取得 messageHandlerSubmit = execService.submit(new SpecifiedMessageHandlerTask(store, folderFullName, store.getFolder(folderFullName).getMessageCount())); } catch (MessagingException ex) { logger.log(Level.SEVERE, null, ex); } try { //左ペインのツリーの一覧を設定 root = folderHandlesubmit.get(); //右ペインのテーブルの設定 MailHeaderModel mailmodel = headerHandlerSubmit.get(); setMailHeaderModel(mailmodel); List<MailHeader> headers = mailmodel.getAllHeader(); //デフォルトで最新のメッセージを選択された状態に設定 if (headers != null && !headers.isEmpty()) { MailHeader latestMailHeader = headers.get(0); selectedMailHeader = latestMailHeader; } if (messageHandlerSubmit != null) { specifiedMessage = messageHandlerSubmit.get(); } } catch (InterruptedException | ExecutionException ex) { logger.log(Level.SEVERE, null, ex); } } } // ツリーが選択された際に呼び出されるイベント public void onNodeSelect(NodeSelectEvent event) { folderFullName = ((FolderName) selectedNode.getData()).getFullName(); int num = getNumberOfMessages(); if (num == 0) { num = DEFAULT_NUMBER_OF_MESSAGE; } // 選択したフォルダのメールヘッダを更新 Future<MailHeaderModel> headerHandlerSubmit = execService.submit(new SpecifiedNodeMailHeaderHandleTask(store, folderFullName, num)); // 選択したフォルダの最新メッセージを取得 try { MailHeaderModel mailmodel = headerHandlerSubmit.get(); //メールヘッダの更新 setMailHeaderModel(mailmodel); // 最新のメッセージ取得 Future<String> messageHandlerSubmit = execService.submit(new SpecifiedMessageHandlerTask(store, folderFullName, store.getFolder(folderFullName).getMessageCount())); specifiedMessage = messageHandlerSubmit.get(); List<MailHeader> headers = mailmodel.getAllHeader(); //デフォルトで最新のメッセージを選択された状態に設定 if (headers != null && !headers.isEmpty()) { MailHeader latestMailHeader = headers.get(0); selectedMailHeader = latestMailHeader; } } catch (MessagingException | InterruptedException | ExecutionException ex) { logger.log(Level.SEVERE, null, ex); } } // メッセージが選択された際に呼び出されるイベント public void onMessageSelect(SelectEvent event) { int msgCount = ((MailHeader) event.getObject()).getMessageCount(); try { Future<String> messageHandlerSubmit = execService.submit(new SpecifiedMessageHandlerTask(store, folderFullName, msgCount)); specifiedMessage = messageHandlerSubmit.get(); } catch (InterruptedException | ExecutionException ex) { logger.log(Level.SEVERE, null, ex); } } // メッセージのカウンタが更新された際の処理 // 10 よりしたの値が入力された場合、何もしない。 public void updateMessageCount() { String folderName = folderFullName; if (folderName.isEmpty()) { folderName = "INBOX"; } int num = getNumberOfMessages(); if (num > 10) { Future<MailHeaderModel> headerHandlerSubmit = execService.submit(new SpecifiedNodeMailHeaderHandleTask(store, folderName, num)); try { setMailHeaderModel(headerHandlerSubmit.get()); } catch (InterruptedException | ExecutionException ex) { logger.log(Level.SEVERE, null, ex); } } } // Store の初期化(ページのロード時) private void initStore(String imapServer, String username, String password) { Properties props = System.getProperties(); props.setProperty("mail.store.protocol", "imaps"); javax.mail.Store initStore; try { Session session = Session.getDefaultInstance(props, null); initStore = session.getStore("imaps"); initStore.connect(imapServer, username, password); this.store = initStore; } catch (MessagingException ex) { logger.log(Level.SEVERE, null, ex); } } /** * @return the selectedNode */ public TreeNode getSelectedNode() { return selectedNode; } /** * @param selectedNode the selectedNode to set */ public void setSelectedNode(TreeNode selectedNode) { this.selectedNode = selectedNode; } /** * @return the selectedMailHeader */ public MailHeader getSelectedMailHeader() { return selectedMailHeader; } /** * @param selectedMailHeader the selectedMailHeader to set */ public void setSelectedMailHeader(MailHeader selectedMailHeader) { this.selectedMailHeader = selectedMailHeader; } /** * @return the mailHeaderModel */ public MailHeaderModel getMailHeaderModel() { return mailHeaderModel; } /** * @param mailHeaderModel the mailHeaderModel to set */ public void setMailHeaderModel(MailHeaderModel mailHeaderModel) { this.mailHeaderModel = mailHeaderModel; } /** * @return the specifiedMessage */ public String getSpecifiedMessage() { return specifiedMessage; } /** * @param specifiedMessage the specifiedMessage to set */ public void setSpecifiedMessage(String specifiedMessage) { this.specifiedMessage = specifiedMessage; } /** * @return the numberOfMessages */ public int getNumberOfMessages() { return numberOfMessages; } /** * @param numberOfMessages the numberOfMessages to set */ public void setNumberOfMessages(int numberOfMessages) { this.numberOfMessages = numberOfMessages; } /** * @return the root */ public TreeNode getRoot() { return root; } }
本アプリケーションの実装において、特筆すべき点として JSF を利用する事で Ajax がとても簡単に実装できる点です。実際、今回のアプリケーションでは WebSocket の実装部以外で一切 JavaScript を記述しておらず、JSF の標準に含まれる <f:ajax> タグを拡張した PrimeFaces の <p:ajax> タグを使用して Ajax 通信を実現しています。
それでは、上記のコードを分割して、各コンポーネントの実装について詳しくご紹介していきます。
画面描画前に実行するコードの実装
この IMAP のメッセージを表示する画面は、画面にリクエストが発生した際に、各種画面のコンポーネントを初期化し、デフォルトで表示する全データを取得した後、描画を行ないます。これを実現するために、JSF では画面の描画前に処理を実行するために <f:event> タグを利用できます。
<h:head> <title>JSF-WebSocket WebMail</title> <f:event type="preRenderView" listener="#{messageReceiveMgdBean.onPageLoad}"/> </h:head>
ここで type=”preRenderView” 属性を追加しレンダリングされる前にイベントを発生する事を指定し、実行したい処理は listener で指定します。ここでは listener で「#{messageReceiveMgdBean.onPageLoad}”」を定義し、CDI (JSF の管理対象 Bean) として実装した MessageReceiveMgdBean クラスの onPageLoad() メソッドを呼び出しています。onPageLoad()メソッドでは最初のアクセスの際にデフォルトで描画する全コンポーネント(ツリーの構築部や、テーブルの構築部、メッセージの表示部)のデータを取得し組み立てます。
この際、「ツリーの構築部」、「テーブルの構築部」、「メッセージの表示部」を構成するための処理を、それぞれ並列処理タスクとして実装しました。 仮に並列処理で実装しない場合、画面を構築するためにかかる所要時間は、「フォルダ一覧表示部(画面左部)」+「フォルダ内のサブジェクト一覧表示部」+「メッセージ表示部」の合計時間になります。この時間を少しでも軽減するために、上記をそれぞれ別のタスクとして実装して並列に取得できるように実装します。
フォルダ一覧表示部(画面左部)の実装
下記に、画面左部の実装を説明します。
画面左部の「フォルダ一覧表示部」の部分のコードは下記です。下記は、大きく3つのコンポーネントから構成されています。
● フォルダの一覧を表示するツリー
● 受信数を変更するテキスト・フィールド
● WebSocket によるリアルタイム監視機能の 開始/中止 ボタン
<p:layoutUnit position="west" size="200" header="フォルダ一覧" resizable="true" closable="true" collapsible="true" style="font-size:14px;"> <p:tree id="docTree" value="#{messageReceiveMgdBean.root}" var="doc" selectionMode="single" dynamic="true" selection="#{messageReceiveMgdBean.selectedNode}"> <p:ajax event="select" listener="#{messageReceiveMgdBean.onNodeSelect}" update=":form:mailheader :form:specifiedMsg"/> <p:treeNode> <h:outputText value="#{doc.name}" style="font-size:14px;"/> </p:treeNode> </p:tree> <p:outputLabel value="受信数:" style="font-size:10px;"/> <p:inputText autocomplete="false" id="numberOfMsg" value="#{messageReceiveMgdBean.numberOfMessages}" style="font-size:10px;"> </p:inputText> <h:commandButton id="upBtn" value="適用" style="font-size:10px;"> <f:ajax event="click" render="mailheader" execute="numberOfMsg" listener="#{messageReceiveMgdBean.updateMessageCount}"/> </h:commandButton> <input id="connect" type="button" value="リアルタイムチェック開始" style="font-size:10px;" onClick="connectServerEndpoint();"/> <input id="close" type="button" value="リアルタイムチェック中止" style="font-size:10px;" onClick="closeServerEndpoint();"/> </p:layoutUnit>
ここで、特に「フォルダ一覧表示部」のツリーは下記のコードで実現しています。
… 前略 <p:tree id="docTree" value="#{messageReceiveMgdBean.root}" var="doc" selectionMode="single" dynamic="true" selection="#{messageReceiveMgdBean.selectedNode}"> <p:ajax event="select" listener="#{messageReceiveMgdBean.onNodeSelect}" update=":form:mailheader :form:specifiedMsg"/> <p:treeNode> <h:outputText value="#{doc.name}" style="font-size:14px;"/> </p:treeNode> </p:tree> … 後略
HTML の中でツリーを実現するために、PrimeFaces の <p:tree> タグを使用します。 また、<p:tree> には下記の属性を指定しています。
● id= コンポーネントの識別子
● value=ツリーを描画するための TreeNode オブジェクト
● var= TreeNode 内に含まれる各ノード・オブジェクト (FolderName) に対する変数
● selectionMode=選択モード
● dynamic=動的モード
● selection=選択されたノードを表すオブジェクト
次に、上記のツリーを描画するために必要な実装コード (MessageReceiveMgdBean クラス) を下記に抜粋します。
@Named(value = "messageReceiveMgdBean") @ViewScoped public class MessageReceiveMgdBean implements Serializable { private TreeNode root; private TreeNode selectedNode; @Resource ManagedExecutorService execService; public void onPageLoad() { // ログイン画面で入力されたデータの取得 String imapServer = login.getImapServer(); String username = login.getUsername(); String password = login.getPassword(); initStore(imapServer, username, password); if (getRoot() == null) { //全フォルダリストの取得 Future<TreeNode> folderHandlesubmit = execService.submit(new AllFolderHandlerTask(store)); try { root = folderHandlesubmit.get(); } catch (InterruptedException | ExecutionException ex) { logger.log(Level.SEVERE, null, ex); } } // Store の初期化(ページのロード時) private void initStore(String imapServer, String username, String password) { Properties props = System.getProperties(); props.setProperty("mail.store.protocol", "imaps"); javax.mail.Store initStore; try { Session session = Session.getDefaultInstance(props, null); initStore = session.getStore("imaps"); initStore.connect(imapServer, username, password); this.store = initStore; } catch (MessagingException ex) { logger.log(Level.SEVERE, null, ex); } } }
まず、initStore() メソッドを実行し IMAP サーバに接続します。接続に問題がない場合、18 行目〜26行目で並列タスクを実行し、実行結果よりツリーを取得しています。
ここで、ツリーを構成するための並列処理タスクは下記「AllFolderHandlerTask」クラスです。Callable インタフェースを実装したこのタスクは Runnable インタフェースを実装したタスクと違い、返り値 (TreeNode) を取得することができます。
タスクが ManagedExecutorService のインスタンス execService#submit() によって実行されると、AllFolderHandlerTask クラスの call() メソッドが呼び出されます。このメソッドは IMAP サーバに存在する全フォルダ一覧を再帰的に取得し、TreeNode を構築していきます。
より詳しく説明すると、TreeNode rootのインスタンスを生成し、IMAP サーバに存在するデフォルトのフォルダの一覧を取得します。そしてフォルダの一覧を root に付け加えていきます。次に取得したフォルダの中で子のフォルダを持つフォルダの場合、再帰的に子のフォルダ一覧を取得しツリーのノードに付け加えていきます。全てのフォルダ一覧を取得した後、全フォルダ一覧を含む TreeNode のオブジェクトを返します。
#getAllIMAPFolders()の再帰の実装正直いけてないですが、
#TreeNode に追加する方法上こう実装しました。
AllFolderHandlerTask#call() メソッドの処理が完了後、onPageLoad() メソッドに処理が戻り、root = folderHandlesubmit.get() で返り値を取得できます。そして取得した結果を root に代入しています。
public class AllFolderHandlerTask implements Callable<TreeNode> { private final Store store; private static final Logger logger = Logger.getLogger(AllFolderHandlerTask.class.getPackage().getName()); public AllFolderHandlerTask(Store store) { this.store = store; } @Override public TreeNode call() throws Exception { TreeNode root = new DefaultTreeNode("root", null); Folder[] folders; if (store == null) { return null; } try { folders = store.getDefaultFolder().list(); getAllIMAPFolders(root, folders); } catch (MessagingException ex) { logger.log(Level.SEVERE, null, ex); } return root; } private TreeNode getAllIMAPFolders(TreeNode root, Folder[] folders) { TreeNode child = null; try { for (Folder folder : folders) { String folName = folder.getName(); String folFullName = folder.getFullName(); if (hasChildFolder(folder) == true) { child = new DefaultTreeNode(new FolderName(folName, folFullName), root); getAllIMAPFolders(child, folder.list()); } else { child = new DefaultTreeNode(new FolderName(folName, folFullName), root); } } } catch (MessagingException ex) { logger.log(Level.SEVERE, null, ex); } return child; } //フォルダに子のフォルダがあるか否か private boolean hasChildFolder(Folder folder) throws MessagingException { boolean hasFolder = false; if (folder.list().length > 0) { hasFolder = true; } return hasFolder; } }
onPageLoad() メソッドに処理が戻った後、root に代入する事で、View からフォルダ一覧がツリーとして参照できるようになります。具体的には、Facelets の <p:tree> タグに記述している value=”#{messageReceiveMgdBean.root}” で参照できるようになります。
また、<p:tree> タグに追加した属性 var=”doc” によって、ツリー中に含まれる各フォルダは、変数 doc として EL 式中で扱う事ができるようになります。具体的に doc は FolderName クラスのインスタンスを表します。例えば、#{doc.name} は FolderName インスタンスの getName() メソッドを呼び出し、フォルダ名を取得することができます。
<p:treeNode>タグ中に <h:outputText> タグを記載しています。このタグはテキストを表示するためのコンポーネントですが、属性 value=”#{doc.name}” で各フォルダの名前を出力しています。
<p:tree id="docTree" value="#{messageReceiveMgdBean.root}" var="doc" selectionMode="single" dynamic="true" selection="#{messageReceiveMgdBean.selectedNode}"> <p:ajax event="select" listener="# {messageReceiveMgdBean.onNodeSelect}" update=":form:mailheader :form:specifiedMsg"/> <p:treeNode> <h:outputText value="#{doc.name}" style="font-size:14px;"/> </p:treeNode> </p:tree>
次に、ツリー中のフォルダが選択された際の処理を説明します。ツリー中のフォルダを選択した際、選択されたツリーのノードは、<p:tree> タグで指定した属性、selection (<p:tree selection=”#{messageReceiveMgdBean.selectedNode}”>)によって MessageReceiveMgdBeanクラスの selectedNode に代入されます。
ここで、<p:tree> タグの直下に、<p:ajax>タグを記載していますが、この<p:ajax>タグは、ツリー内で特定のフォルダが選択されると、同タグ中の listener=”#{messageReceiveMgdBean.onNodeSelect}”
を呼び出します。これは、内部的に MessageReceiveMgdBean クラスの onNodeSelect(NodeSelectEvent event)を呼び出します。
onNodeSelect()のメソッドでは、選択されたフォルダに存在する「フォルダ内のサブジェクト一覧表示部」と「メッセージ表示部」を取得する処理が行なわれます。
呼び出した結果、<p:ajax> タグの update 属性の設定に従い、 <p:ajax update=”:form:mailheader :form:specifiedMsg”/>「:form:mailheader」と「:form:specifiedMsg」で指定するコンポーネント、つまり「フォルダ内のサブジェクト一覧表示部」と「メッセージ表示部」を更新します。
「フォルダ内のサブジェクト一覧表示部(画面右上部)の実装
つづいて、「フォルダ内のサブジェクト一覧表示部」の実装を説明します。
テーブルを実現している Facelets の実装コードは下記です。
<p:dataTable id="mailheader" var="mheader" paginator="true" paginatorPosition="bottom" value="#{messageReceiveMgdBean.mailHeaderModel}" rows="5" rowKey=" #{mheader.messageCount}" selection="#{messageReceiveMgdBean.selectedMailHeader}" selectionMode="single" style="width:800px;font-size:10px;" > <p:ajax event="rowSelect" listener="#{messageReceiveMgdBean.onMessageSelect}" update=":form:specifiedMsg" global="false"/> <p:column id="msubject" headerText="サブジェクト" style="font-size:10px;" sortBy="subject" width="50%"> #{mheader.subject} </p:column> <p:column id="maddress" headerText="アドレス" style="font-size:10px;" width="30%"> <ui:repeat value="#{mheader.fromAddress}" var="fromEmail"> <h:outputLabel value="#{fromEmail.toUnicodeString()}"/> </ui:repeat> </p:column> <p:column id="mdate" headerText="日付" style="font-size:10px;" sortBy="sendDate" width="10%"> <h:outputLabel value="#{mheader.sendDate}"> <f:convertDateTime pattern="yyyy年MM月dd日 HH:mm:ss"/> </h:outputLabel> </p:column> <p:column id="msize" headerText="サイズ" style="font-size:10px;" sortBy="size" width="10%"> #{mheader.size} </p:column> </p:dataTable>
ちょっと補足:
web.xml の最後に下記の <context-param>を追加してください。
上記 JSF の Facelets でメッセージ送信日付を、f:convertDateTime で変換し描画しています。この変換の際デフォルトのタイムゾーンをシステムのタイムゾーンに変更します。
<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"> … 前略 <context-param> <param-name>javax.faces.DATETIMECONVERTER_DEFAULT_TIMEZONE_IS_SYSTEM_TIMEZONE</param-name> <param-value>true</param-value> </context-param> </web-app>
画面が描画前に呼び出される処理は onPageLoad() メソッドに実装されている事は上記ですでに紹介しました。onPageLoad() メソッドの中、つまり最初のアクセスの際に、テーブルは「INBOX(受信箱)」に存在するメッセージを並列タスクとして取得し描画します。
HTML のテーブルを扱うために PrimeFaces の <p:dataTable>を使用します。
<p:dataTable id=”mailheader” var=”mheader”
paginator=”true”
paginatorPosition=”bottom”
value=”#{messageReceiveMgdBean.mailHeaderModel}”
rows=”5″ rowKey=” #{mheader.messageCount}”
selection=”#{messageReceiveMgdBean.selectedMailHeader}”
selectionMode=”single” style=”width:800px;font-size:10px;” >
<p:dataTable> タグでは下記の属性を指定しています。
● paginator : テーブル内にボタンを表示しページ移動を可能にする属性
● paginatorPosition : ページ移動用のボタンの配置場所を指定
● value: テーブル内で扱うデータ・モデル
● rows: テーブルで描画する行数
● rowKey: 行を選択した際、他の行と区別するためのキー
● selection: 選択された行のデータ
● selectionMode: 選択モード
この中で、特に重要なのが value で指定するデータ・モデルです。今回、このテーブルを扱うためのモデルとして、MailHeaderModel クラスを実装し、value にはこのクラスのインスタンスを代入します。MailHeaderModel クラスは MailHeader オブジェクト(行を表すオブジェクト)を List で管理しています。これらのクラスのインスタンスは並列処理タスク SpecifiedNodeMailHeaderHandleTask の中で生成されます。下記に MailHeaderModel、MailHeader クラスの実装をそれぞれ示します。
まず、テーブル内に表示するデータを保持する MailHeader クラスを作成します。今回テーブルには「サブジェクト」、「From:」、「送信日」、「メッセージのサイズ」の4項目を表示させますので、4つのフィールド(subject, fromAddress, sendDate, size)を定義しています。
またmessageCount はフォルダ内に存在するメッセージの内、特定のメッセージを識別するためのキーをメッセージのカウントIDとして指定します。実際には、SpecifiedNodeMailHeaderHandleTask の中でMailHeaderのインスタンスを生成する際、Java Mail API の Message#getMessageNumber() をここに代入します。
@ViewScoped public class MailHeader { private String subject; private Address[] fromAddress; private Date sendDate; private int size; private Integer messageCount; public MailHeader(String subject, Address[] fromAddress, Date sendDate, int size, Integer messageCount) { this.subject = subject; this.fromAddress = fromAddress; this.sendDate = sendDate; this.size = size ; this.messageCount = messageCount; } 別途、Setter, Getter メソッドを実装してください。 }
次に、上記 MailHeader クラスを管理するクラスを MailHeaderModel に実装します。このクラスはインスタンスが生成された際に、コンストラクタ内で MailHeader の List を最新日付順(最新のメッセージのカウント ID 順)でソートします。そして各行を識別するためのキーとして、メッセージのカウントID を使用します。
@ViewScoped public class MailHeaderModel extends ListDataModel<MailHeader> implements SelectableDataModel<MailHeader> { public MailHeaderModel() { } public MailHeaderModel(List<MailHeader> header) { super(header); Collections.sort(header, new Comparator<MailHeader>() { @Override public int compare(MailHeader m1, MailHeader m2) { return m2.getMessageCount() - m1.getMessageCount(); } }); } @Override public Object getRowKey(MailHeader header) { return header.getMessageCount(); } @Override public MailHeader getRowData(String rowKey) { List<MailHeader> headers = (List<MailHeader>) getWrappedData(); for (MailHeader header : headers) { if (header.getMessageCount().toString().equals(rowKey)) { return header; } } return null; } }
ここで、画面ロード時に呼び出される MessageReceiveMgdBean#onPageLoad() メソッドの中から、テーブルを初期化する部分のコードを下記に抜粋し示します。画面ロード時にはデフォルトで INBOX のメッセージを取得します。INBOX のフォルダを SpecifiedNodeMailHeaderHandleTask に指定し並列タスクとして実行します。
public void onPageLoad() { // デフォルトで INBOX のメッセージの取得 folderFullName = "INBOX"; Future<MailHeaderModel> headerHandlerSubmit = execService.submit( new SpecifiedNodeMailHeaderHandleTask(store, folderFullName, num)); try { MailHeaderModel mailmodel = headerHandlerSubmit.get(); setMailHeaderModel(mailmodel); // テーブル中の最新のメッセージ(MailHeader)をデフォルトで // 選択 (マウスがクリックされた) 状態にする。 List<MailHeader> headers = mailmodel.getAllHeader(); if (headers != null && !headers.isEmpty()) { MailHeader latestMailHeader = headers.get(0); selectedMailHeader = latestMailHeader; } } catch (InterruptedException | ExecutionException ex) { logger.log(Level.SEVERE, null, ex); } }
execService#submit によりSpecifiedNodeMailHeaderHandleTask(store, folderFullName, num) の並列タスクを実行します。このタスクは、指定された IMAP のフォルダの中から、num で指定した数だけ最新メッセージを取得し、取得したメッセージ(MailHeader) を含む MailHeaderModel のインスタンスを返します。
並列処理タスクの処理が完了すると、onPageLoad() に処理が戻り、headerHandlerSubmit.get() でMailHeaderModel を取得できますので、取得した MailHeaderModel を setMailHeaderModel() で置き換えて更新します。更新した後、最新のメッセージを選択状態にするため、最新のメッセージを取得し、selectedMailHeader に代入しています。
public class SpecifiedNodeMailHeaderHandleTask implements Callable<MailHeaderModel> { private final Store store; private final String folderFullName; private final int numberOfMessage; private static final Logger logger = Logger.getLogger(SpecifiedNodeMailHeaderHandleTask.class.getPackage().getName()); public SpecifiedNodeMailHeaderHandleTask(Store store,String folderFullName,int numberOfMessage){ this.store = store; this.folderFullName = folderFullName; this.numberOfMessage = numberOfMessage; } @Override public MailHeaderModel call() throws Exception { MailHeaderModel model = null; if (store != null) { try { Folder folder = store.getFolder(folderFullName); if (!folder.isOpen()) { folder.open(javax.mail.Folder.READ_WRITE); } int endMsgs = folder.getMessageCount(); int startMsgs = endMsgs - (numberOfMessage - 1); Message[] msgs = folder.getMessages(startMsgs, endMsgs); List<MailHeader> data = new ArrayList<>(); for (Message msg : msgs) { MailHeader msgModel = new MailHeader(msg.getSubject(), msg.getFrom(), msg.getSentDate(), msg.getSize(), msg.getMessageNumber()); data.add(msgModel); } model = new MailHeaderModel(data); } catch (MessagingException ex) { logger.log(Level.SEVERE, null, ex); } } return model; } }
次に、テーブル内で行が選択された場合の実装、振る舞いを紹介します。テーブル内で行を選択すると、選択した行を表す MailHeader オブジェクトが、<p:dataTable> タグの selection 属性で指定した、
<p:dataTable selection=”#{messageReceiveMgdBean.selectedMailHeader}” >
に代入されます。また、行を選択した際のキーは MailHeaderModel クラス内の getRowKey() メソッドの返り値、つまり MailHeader の getMessageCount() をキーとなります。
また、<p:dataTable> タグの直後に<p:ajax>タグを記述しています。これにより、実際に行が選択されると <p:ajax> の属性 listener で示すメソッド onMessageSelect() メソッドが呼び出されます。
// メッセージが選択された際に呼び出されるイベント public void onMessageSelect(SelectEvent event) { int msgCount = ((MailHeader) event.getObject()).getMessageCount(); try { Future<String> messageHandlerSubmit = execService.submit(new SpecifiedMessageHandlerTask(store, folderFullName, msgCount)); specifiedMessage = messageHandlerSubmit.get(); } catch (InterruptedException | ExecutionException ex) { logger.log(Level.SEVERE, null, ex); } }
onMessageSelect() メソッドは選択された該当のメッセージを、SpecifiedMessageHandlerTask で実装された並列タスクで取得し、取得したメッセージの文字列を specifiedMessage に代入します。
この Ajax リクエストは処理が完了後、メッセージを<p:ajax> タグで設定されている update 属性の内容に従い、”:form:specifiedMsg” つまり「メッセージの表示部」に更新します。
メッセージ表示部(画面右下部)の実装
最後にメッセージ表示部の実装部分について説明します。
![]() |
specifiedMessage は View(Facelets) の中で下記のコードで参照・表示されます。
<p:scrollPanel style="width:800px;height:400px" mode="native"> <h:outputText id="specifiedMsg" value="#{messageReceiveMgdBean.specifiedMessage}" escape="false"/> </p:scrollPanel>
<p:scrollPalnel> タグは、スクロールが可能なパネルで長文のメッセージを表示する際に、スクロールしながら参照が可能なパネルです。このスクロール可能なパネルの中で、IMAP の特定メッセージを <h:outputText> タグ内で表示します。ここで、<h:outputText> タグ内で excape=”false” と指定していますが、これは HTML メールを参照する場合、JSF では
デフォルトで < や > 等をエスケープ&lt;、&gt;しますので、HTMLメールをそのまま表示させるために、この部分だけエスケープしないように設定しています。
実際に、指定されたメッセージのカウントID を持つメッセージを取得するための並列タスク SpecifiedMessageHandlerTask クラスを下記に示します。
public class SpecifiedMessageHandlerTask implements Callable<String> { private final Store store; private final String folderFullName; private final int msgCount; private static final Logger logger = Logger.getLogger(SpecifiedMessageHandlerTask.class.getPackage().getName()); public SpecifiedMessageHandlerTask(Store store, String folderFullName, int msgCount) { this.store = store; this.folderFullName = folderFullName; this.msgCount = msgCount; } @Override public String call() throws Exception { String returnMsg =""; if (store != null) { Folder folder; try { folder = store.getFolder(folderFullName); if (!folder.isOpen()) { folder.open(javax.mail.Folder.READ_WRITE); } Message msg = folder.getMessage(msgCount); MessageDumpUtil dumpUtil = new MessageDumpUtil(); returnMsg = dumpUtil.getText(msg); } catch (MessagingException | IOException e) { logger.log(Level.SEVERE, null, e); } } return returnMsg; } }
Message を取得するためには、Java Mail の API を使用して folder.getMessage(msgCount) で取得します。ただし Message は単純なプレイン・テキストだけではなく、マルチパート、html 形式様々な形の
メッセージが存在します。そこで、メッセージのタイプに応じて表示用の文字列を取得する必要があります。
今回の実装では、Base 64 への未対応や、マルチパートファイルのダウンロードなどは実装しておらず、テキストと HTML 表示のみ対応しています。HTML ならばその文字列をそのまま返し、プレイン・テキストの場合、<:PRE>を付加してメッセージの形式をそのままの形で表示して返しています。Message の解析を行ない表示用の文字列を取得するためのユーティリティ・クラスを MessageDumpUtil として実装しました。MessageDumpUtil クラスを下記に示します。
public class MessageDumpUtil { private boolean textIsHtml = false; public String getText(Part p) throws MessagingException, IOException { if (p.isMimeType("text/*")) { textIsHtml = p.isMimeType("text/html"); if (true == textIsHtml) { return (String) p.getContent(); } else { return getPreString((String) p.getContent()); } } if (p.isMimeType("multipart/alternative")) { // prefer html text over plain text Multipart mp = (Multipart) p.getContent(); String text = null; for (int i = 0; i < mp.getCount(); i++) { Part bp = mp.getBodyPart(i); if (bp.getContent() instanceof BASE64DecoderStream) { return "現在 Base 64 のコンテンツには現在未対応です。"; } else if (bp.isMimeType("text/plain")) { if (text == null) { text = getPreString(getText(bp)); } } else if (bp.isMimeType("text/html")) { String s = getText(bp); if (s != null) { return s; } } else { return getPreString(getText(bp)); } } return text; } else if (p.isMimeType("multipart/*")) { Multipart mp = (Multipart) p.getContent(); for (int i = 0; i < mp.getCount(); i++) { String s = getText(mp.getBodyPart(i)); if (s != null) { return s; } } } return null; } private String getPreString(String data) { StringBuilder sb = new StringBuilder(); sb.append("<PRE style=\"font-size:12px;\">"); sb.append(data); sb.append("</PRE>"); String s = sb.toString(); return s; } }
このユーティリティ・クラスでは getText(Part p) メソッドが実際の解析を行なっています。解析を行なった後、HTML は HTML として、テキストはテキストとして文字列を取りだし、最後に表示用の文字列を String で返しています。ユーティリティ・クラスから文字列が返ってくると、その値をそのまま並列タスクの戻り値として返します。
並列タスクの処理が完了すると <h:outputText value=”#{messageReceiveMgdBean.specifiedMessage}” > でその文字列を描画するために、戻り値を specifiedMessage に代入します。
以上により、大まかな実装の概要説明は終了です。
Ajax 通信の際のダイアログ表示方法
最後に、長時間の Ajax 通信時にステータス・ウィンドウを表示させる方法を紹介します。
Ajax リクエストを行なう際に、上記のステータスを表示するダイアログを表示させています。これは Ajax で長時間処理を要するような処理を行なう際にとても有用です。特に今回は IMAP サーバに直接接続を行いフォルダ一覧を取得したりメッセージを取得しているため、通常の DB アクセスよりもさらに時間を要すような処理を Ajax として実装しました。仮に上記のようなダイアログを使用しない場合、本当に Ajax のリクエストが実行されているのか否かわからなくなります。そこで、このような長時間処理用に、今回 PrimeFaces の下記のタグを使用して実装しました。
<p:ajaxStatus onstart="PF('statusDialog').show();" onsuccess="PF('statusDialog').hide();"/> <p:dialog modal="true" widgetVar="statusDialog" header="処理中" draggable="false" closable="false"> <p:graphicImage value="/resources/imgs/ajaxloadingbar.gif" /> </p:dialog>
基本的には、上記のコードを記述する事で全ての Ajax 通信時にステータス・ウィンドウが表示されるようになります。しかし、全ての処理で上記ダイアログを表示させたくない場合もあります。そのような場合、<p:ajax> タグの属性 global を false に設定する事で、その Ajax リクエストではダイアログを非表示にする事もできます。実際、私の場合は、テーブル中で特定のメッセージを選択した際、その対象メッセージをスクロール・パネルに表示させますが、その Ajax リクエストではダイアログを非表示にしています。
<p:dataTable id="mailheader" var="mheader" paginator="true" paginatorPosition="bottom" value="#{messageReceiveMgdBean.mailHeaderModel}" rows="5" rowKey=" #{mheader.messageCount}" selection="#{messageReceiveMgdBean.selectedMailHeader}" selectionMode="single" style="width:800px;font-size:10px;" > <p:ajax event="rowSelect" listener="#{messageReceiveMgdBean.onMessageSelect}" update=":form:specifiedMsg" global="false"/>
※ご注意:全てのコンポーネントで global=false が利用できるわけではないようです。
このようにして、PrimeFaces のようなリッチな JSF コンポーネントを使用する事で、標準の JSF でもある程度簡単に、シングル・ページ・アプリケーションを構築する事ができます。如何でしょうか?是非この開発生産性の高い技術をお試しください。
最後に、
本アプリケーションは短時間で実装し、あくまでも JSF, WebSocket, JavaMail のサンプル・アプリケーションとして作成しました。そのため、実務レベルで使用するためには細かい部分で実装が足りていません。
例えばログイン時に毎回 IMAP サーバに接続し全情報を取得しますので、アプリケーションの動作としてとても遅いと感じるかもしれません。それはJSF ではなく、毎回 IMAP サーバに接続しに行っているため遅くなっています。また、一般的な IMAP のメールクライアントが実装しているような、ローカル・キャッシュを実装していません。毎回直接 IMAP サーバに参照に行っています。起動時に全画面の描画を高速にするためには、ローカル・キャッシュのデータを参照するなどが必要です。また各処理を並列処理で行なっていますが、タイミングに応じて異なるメッセージが選択される可能性もあります(注意点は GitHub のソースに記述しています)。また、セッション・タイムアウトに対する実装も今回は実装していません。ログイン認証も簡易実装しています。Java EE におけるログイン認証は「たかがレルムされどレルム GlassFish で始める詳細 JDBC レルム」のエントリをご参照ください。
ここで、ご紹介した内容の内、ご参考になる部分があれば幸いです。
たかがレルムされどレルム GlassFish で始める詳細 JDBC レルム
この記事は「 GlassFish Advent Calendar 2013」 の10日目として新たに書き下ろしたものです。昨日は蓮沼さんによる「GlassFish付属のJava DBについて」でした。
幸運にも、昨日 12/09 に蓮沼さんが GlassFish に付属している Java DB について詳しくご紹介してくださったので、今日はその流れ?!というわけではありませんが、GlassFish に付属の Java DB(他のDBにも利用できます) を使用して、Java EE 標準の認証・認可方法をご紹介します。
Realm (レルム) は古くから Java 標準の認証・認可を行なうための機能として多くの環境で利用されてきましたが、やや環境構築や設定が大変という事もあって、レルムを使用せず独自の認証システムを構築される事もいらっしゃるようです。しかし、Java EE コンテナが提供する認証・認可機能を正しく理解し設定やプログラミングを行なえば、より安全で柔軟なプログラムを簡単に実現できるようになります。
また、この認証・認可の機能は Java EE に含まれるフレーム・ワーク全体に対して適用が可能なため、今回の資料では動作確認に JavaServer Faces を利用してを行ないますが、JSFに限らず、JAX-RS でも EJB でも、その他のフレームワークでも全ての Java EE フレームワークに適用できますのでとても重要です。
非常に古くからある機能のため、既にご存知の方やご適用頂いている方も多いかと思いますが、「たかがレルムされどレルム」で Java EE の中でも重要な概念の一つと考えています。最近、Java EE を始めた方や、これから Java EE を始める方に、本ブログ・エントリが有用な情報になれば誠に幸いです。Web アプリケーションに対してどこからどのように認証・認可などの設定を進めればよいか分からない方はどうぞご覧ください。
本ブログエントリは、環境構築手順をできるだけ詳細にご紹介し、これさえ見れば JDBC レルムの環境構築がある程度できるという資料になるようにエントリを書いてみました。当初はブログのエントリ中に JDBC レルムに関する説明を書こうと考えましたが、非常に長い内容になりましたので、途中でブログで記載するのはあきらめ PDF で資料を作りなおしました。PDF であればダウンロードしプリント・アウトした後にゆっくりご確認頂けるので、よりよいかと想定しています。是非、本資料を有効活用をして頂ければ幸いです。
限られた時間の中で急ピッチで作成しましたので、手順を端折って実装した所もあります(例えばユーザ登録、削除の確認画面など)。しかし、全体的な設定手順や実装方法の概要は掴めるかと思いますので、ご参考ください。
本資料でご紹介したコードは下記にアップしております。コードだけを参照されたい場合は、下記へアクセスしてください。
https://github.com/yoshioterada/JDBC-Realm-Sample
※ 下記に公開する PDF 資料中のプロジェクト名、パッケージ名は適切ではありません。プロジェクト名、パッケージ名等は適宜ご修正ください。元々、次のブログ・エントリで記載する内容とマージして一つの資料としてまとめたかったのですが、時間の関係上とボリュームの関係で、本 PDF には JDBC レルムの部分までしか記載できませんでした。
そこで、JDBC Realm のハンズオン・ラボの資料としてはプロジェクト名、パッケージ名などが適切ではない名前が記載されています。上記 GitHub ではプロジェクト名、パッケージ名をリファクタリングした後のコードをアップしておりますので、併せてご参照ください。
最後に、認証や認可用の設定やコードを行なうと、jUnit 等のテスト連携が困難になると予想される方もいらっしゃるかもしれません。
EJB を実装されている場合には jUnit 等の単体テストで、組み込み可能 EJB コンテナを利用できます。
この組み込み可能な EJB コンテナで認証・認可まで含めたテストケースを実施したい場合、GlassFish では下記のクラスを利用する事で、EJB プログラムに対するログイン認証・認可の権限チェックを行なうことができるようになります。
GlassFish v3.x の場合:
$GLASSFISH_HOME\glassfish\modules
com.sun.appserv.security.ProgrammaticLogin
GlassFish v4.0 の場合 (パッケージ名が代わりました。)
$GLASSFISH_HOME\glassfish\modules\security-ee.jar
com.sun.enterprise.security.ee.auth.login.ProgrammaticLogin
例えば、jUnit のコード中で下記のようなコードを書く事ができます。
char[] passwd = “password”.toCharArray();
ProgrammaticLogin progLogin = new ProgrammaticLogin();
progLogin.login(“admin”, passwd , “jdbc-realm”, true);
最後に、今回は DBを使用しましたが、LDAPでも、SSO を実現するような製品群でもレルムは利用可能です。また少ない設定変更でレルムの切り替えももできますので、是非レルムをご理解いただきご利用ください。