JSF + WebSocket で実装した IMAP Web メール・クライアント
2013年12月11日 at 2:55 午前 2件のコメント
このエントリは 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 レルム」のエントリをご参照ください。
ここで、ご紹介した内容の内、ご参考になる部分があれば幸いです。
Entry filed under: Application Server/GlassFish, GlassFish, Java. Tags: Concurrency, Java EE 7, Java Mail, JSF, WebSocket.
1. メソッドバリデーションのユニットテスト | nagaseyasuhito Daily works. | 2013年12月12日 11:06 午前
[…] この記事はJavaEE Advent Calendar 2013の12日目として書かれたものです。昨日は@yoshioteradaさんの「JSF + WebSocket で実装した IMAP Web メール・クライアント」でした。 […]
2. Java EE 7 WebSocket アンチパターン | 寺田 佳央 - Yoshio Terada | 2013年12月20日 8:46 午前
[…] JSF + WebSocket で実装した IMAP Web メール・クライアント […]