このチュートリアルでは、ホテル予約をするという特定の機能に焦点を絞って説明します。 ユーザーの視点から見ると、 ホテルの検索、 選択、 予約そして確認までは 1 つの連続した作業単位、 つまり 対話 です。 しかし、 開発者側から見ると、 検索は独立していることが重要で、 これによりユーザーが同じ検索結果ページから複数のホテルを選択し、 別々のブラウザタブにそれぞれ異なる対話を開くことができます。
ほとんどの Web アプリケーションのアーキテクチャは、対話を表すための優れた構造を持っていません。これは対話の状態を管理するために重大な問題となります。通常、Java Web アプリケーションはいくつかの技術を組み合わせて使用します。 ある状態は URL に変換されますが、 ここで変換できない状態は各要求の前後でデータベースに記録されるか、 HttpSession に追加されます。
データベースは最もスケーラビリティに乏しい層なので、 これが極端にスケーラビリティを低減させています。 データベースを行き来する転送量の増加によっても待ち時間が増加します。 冗長な転送量を減少させるために、 Java アプリケーションでは要求間でよくアクセスされるデータを保管するデータキャッシュを導入することがよくあります。 しかし、データが無効かどうかの判断はユーザーがデータの操作を終了したかどうかではなく LRU ポリシーを基にして行うため、このキャッシュは効率的ではありません。 また、 キャッシュは同時トランザクション間で共有されるので、 キャッシュされた状態がデータベースの状態と一貫性を保持することに関連するさらなる問題も取り入れてしまうことになります。
HttpSession に保管された状態にも同様の問題が見られます。 HttpSession は実際のセッションデータ (ユーザーとアプリケーション間の全要求に共通となるデータ) の保存については問題ありませんが、個別要求のシリーズに関連するデータの場合は問題となります。 ここで保存される対話は複数のウィンドウや戻るボタンをクリックした場合にすぐに分解してしまいます。 プログラミングに注意しないと HttpSession にあるデータも増大し、セッションのクラスタ化を困難にします。 こうした手法から生じる問題に対応するためのメカニズムを開発するのは簡単ではありません (異なる同時対話に関連するセッション状態を分離して、 ひとつの対話が中断したら対話状態が必ず破棄されるようフェイルセーフを組み込みます)。
Seam は優れた構造として 対話コンテキスト を導入することにより状況を大幅に改善します。対話状態はこのコンテキストで安全に保管され、 明確に定義されたライフサイクルを持ちます。さらに良いことに、 対話コンテキストはユーザーが現在作業しているデータの自然なキャッシュとなるため、 アプリケーションサーバーとデータベース間でデータを継続的にプッシュする必要がありません。
次のアプリケーションではステートフルセッション Bean の保存に対話コンテキストが使用されています。 これらはスケーラビリティという観点からは弊害をもたらすとみなされることがあり、 また過去にはそうだったかもしれません。 ただし、 最近のアプリケーションサーバーはステートフルセッション Bean の複製に関して高度に優れたメカニズムを備えています。 JBoss AS は微細な複製機能で、変更された Bean の属性値のみを複製することが可能です。 ステートフルセッション Bean を正しく使用すればスケーラビリティに関する問題を引き起こすことはありません。 ただし、 ステートフルセッション Bean に不慣れな場合や使用したくない場合は POJO を使用することもできます。
この予約サンプルでは、 複雑な動作を実現するために異なるスコープを持つステートフルコンポーネントを連携させることができる一例を示しています。 予約アプリケーションのメインページではユーザーによるホテル検索が可能です。 検索結果は Seam セッションスコープに保管されます。 ユーザーがこれらのホテルの 1 つに移動すると、 対話が開始され、 対話スコープのコンポーネントがセッションスコープのコンポーネントから選択したホテルを読み出します。
予約サンプルは、 手書きの JavaScript を使用することなくリッチクライアントの動作を実装する場合の RichFaces Ajax の使い方も示しています。
検索機能はセッションスコープのステートフルセッション Bean を使用して実装されます。 メッセージ一覧サンプルに使用されているものと同様です。
例1.28 HotelSearchingAction.java
@Stateful
@Name("hotelSearch")
@Scope(ScopeType.SESSION)
@Restrict("#{identity.loggedIn}")
public class HotelSearchingAction implements HotelSearching
{
@PersistenceContext
private EntityManager em;
private String searchString;
private int pageSize = 10;
private int page;
@DataModel
private List<Hotel> hotels;
public void find()
{
page = 0;
queryHotels();
}
public void nextPage()
{
page++;
queryHotels();
}
private void queryHotels()
{
hotels =
em.createQuery("select h from Hotel h where lower(h.name) like #{pattern} " +
"or lower(h.city) like #{pattern} " +
"or lower(h.zip) like #{pattern} " +
"or lower(h.address) like #{pattern}")
.setMaxResults(pageSize)
.setFirstResult( page * pageSize )
.getResultList();
}
public boolean isNextPageAvailable()
{
return hotels!=null && hotels.size()==pageSize;
}
public int getPageSize() {
return pageSize;
}
public void setPageSize(int pageSize) {
this.pageSize = pageSize;
}
@Factory(value="pattern", scope=ScopeType.EVENT)
public String getSearchPattern()
{
return searchString==null ?
"%" : '%' + searchString.toLowerCase().replace('*', '%') + '%';
}
public String getSearchString()
{
return searchString;
}
public void setSearchString(String searchString)
{
this.searchString = searchString;
}
@Remove
public void destroy() {}
}
@Stateful
@Name("hotelSearch")
@Scope(ScopeType.SESSION)
@Restrict("#{identity.loggedIn}")
public class HotelSearchingAction implements HotelSearching
{
@PersistenceContext
private EntityManager em;
private String searchString;
private int pageSize = 10;
private int page;
@DataModel
private List<Hotel> hotels;
public void find()
{
page = 0;
queryHotels();
}
public void nextPage()
{
page++;
queryHotels();
}
private void queryHotels()
{
hotels =
em.createQuery("select h from Hotel h where lower(h.name) like #{pattern} " +
"or lower(h.city) like #{pattern} " +
"or lower(h.zip) like #{pattern} " +
"or lower(h.address) like #{pattern}")
.setMaxResults(pageSize)
.setFirstResult( page * pageSize )
.getResultList();
}
public boolean isNextPageAvailable()
{
return hotels!=null && hotels.size()==pageSize;
}
public int getPageSize() {
return pageSize;
}
public void setPageSize(int pageSize) {
this.pageSize = pageSize;
}
@Factory(value="pattern", scope=ScopeType.EVENT)
public String getSearchPattern()
{
return searchString==null ?
"%" : '%' + searchString.toLowerCase().replace('*', '%') + '%';
}
public String getSearchString()
{
return searchString;
}
public void setSearchString(String searchString)
{
this.searchString = searchString;
}
@Remove
public void destroy() {}
}
Copy to Clipboard
Copied!
Toggle word wrap
Toggle overflow

|
EJB 標準 @Stateful アノテーションは、 このクラスがステートフルセッション Bean であることを識別しています。 ステートフルセッション Bean は、 デフォルトで対話コンテキストのスコープを持ちます。
|

|
@Restrict アノテーションはコンポーネントにセキュリティ制限を適用します。 コンポーネントへのアクセスを制限し、 ログインしているユーザーにのみアクセスを許可します。「セキュリティ」の章では Seam におけるセキュリティについてさらに詳細に説明します
|

|
@DataModel アノテーションは JSF ListDataModel として List を公開します。これにより検索画面でのクリック可能な一覧の実装が容易になります。このサンプルでは、ホテル一覧が hotels という名前の対話変数で ListDataModel としてページに公開されています。
|

|
EJB 標準の @Remove アノテーションはアノテーションが付けられたメソッドが呼び出された後ステートフルセッション Bean が取り除かれてその状態が破棄されることを規定しています。 Seam では、 すべてのステートフルセッション Bean はパラメータなしの @Remove メソッドを定義しなければなりません。Seam がセッションコンテキストを破棄するとこのメソッドが呼び出されます。
|
アプリケーションのメインページは Facelets ページです。 ホテル検索に関連する部分を見てみましょう。
例1.29 main.xhtml
<div class="section">
<span class="errors">
<h:messages globalOnly="true"/>
</span>
<h1>Search Hotels</h1>
<h:form id="searchCriteria">
<fieldset>
<h:inputText id="searchString" value="#{hotelSearch.searchString}"
style="width: 165px;">
<a:support event="onkeyup" actionListener="#{hotelSearch.find}"
reRender="searchResults" />
</h:inputText>
 
<a:commandButton id="findHotels" value="Find Hotels" action="#{hotelSearch.find}"
reRender="searchResults"/>
 
<a:status>
<f:facet name="start">
<h:graphicImage value="/img/spinner.gif"/>
</f:facet>
</a:status>
<br/>
<h:outputLabel for="pageSize">Maximum results:</h:outputLabel> 
<h:selectOneMenu value="#{hotelSearch.pageSize}" id="pageSize">
<f:selectItem itemLabel="5" itemValue="5"/>
<f:selectItem itemLabel="10" itemValue="10"/>
<f:selectItem itemLabel="20" itemValue="20"/>
</h:selectOneMenu>
</fieldset>
</h:form>
</div>
<a:outputPanel id="searchResults">
<div class="section">
<h:outputText value="No Hotels Found"
rendered="#{hotels != null and hotels.rowCount==0}"/>
<h:dataTable id="hotels" value="#{hotels}" var="hot"
rendered="#{hotels.rowCount>0}">
<h:column>
<f:facet name="header">Name</f:facet>
#{hot.name}
</h:column>
<h:column>
<f:facet name="header">Address</f:facet>
#{hot.address}
</h:column>
<h:column>
<f:facet name="header">City, State</f:facet>
#{hot.city}, #{hot.state}, #{hot.country}
</h:column>
<h:column>
<f:facet name="header">Zip</f:facet>
#{hot.zip}
</h:column>
<h:column>
<f:facet name="header">Action</f:facet>
<s:link id="viewHotel" value="View Hotel"
action="#{hotelBooking.selectHotel(hot)}"/>
</h:column>
</h:dataTable>
<s:link value="More results" action="#{hotelSearch.nextPage}"
rendered="#{hotelSearch.nextPageAvailable}"/>
</div>
</a:outputPanel>
<div class="section">
<span class="errors">
<h:messages globalOnly="true"/>
</span>
<h1>Search Hotels</h1>
<h:form id="searchCriteria">
<fieldset>
<h:inputText id="searchString" value="#{hotelSearch.searchString}"
style="width: 165px;">
<a:support event="onkeyup" actionListener="#{hotelSearch.find}"
reRender="searchResults" />
</h:inputText>
 
<a:commandButton id="findHotels" value="Find Hotels" action="#{hotelSearch.find}"
reRender="searchResults"/>
 
<a:status>
<f:facet name="start">
<h:graphicImage value="/img/spinner.gif"/>
</f:facet>
</a:status>
<br/>
<h:outputLabel for="pageSize">Maximum results:</h:outputLabel> 
<h:selectOneMenu value="#{hotelSearch.pageSize}" id="pageSize">
<f:selectItem itemLabel="5" itemValue="5"/>
<f:selectItem itemLabel="10" itemValue="10"/>
<f:selectItem itemLabel="20" itemValue="20"/>
</h:selectOneMenu>
</fieldset>
</h:form>
</div>
<a:outputPanel id="searchResults">
<div class="section">
<h:outputText value="No Hotels Found"
rendered="#{hotels != null and hotels.rowCount==0}"/>
<h:dataTable id="hotels" value="#{hotels}" var="hot"
rendered="#{hotels.rowCount>0}">
<h:column>
<f:facet name="header">Name</f:facet>
#{hot.name}
</h:column>
<h:column>
<f:facet name="header">Address</f:facet>
#{hot.address}
</h:column>
<h:column>
<f:facet name="header">City, State</f:facet>
#{hot.city}, #{hot.state}, #{hot.country}
</h:column>
<h:column>
<f:facet name="header">Zip</f:facet>
#{hot.zip}
</h:column>
<h:column>
<f:facet name="header">Action</f:facet>
<s:link id="viewHotel" value="View Hotel"
action="#{hotelBooking.selectHotel(hot)}"/>
</h:column>
</h:dataTable>
<s:link value="More results" action="#{hotelSearch.nextPage}"
rendered="#{hotelSearch.nextPageAvailable}"/>
</div>
</a:outputPanel>
Copy to Clipboard
Copied!
Toggle word wrap
Toggle overflow

|
RichFaces Ajax <a:support> タグを使用すると、onkeyup のような JavaScript イベントの発生時に、非同期の XMLHttpRequest により JSF アクションイベントリスナーが呼び出されます。さらに良いことには reRender 属性により非同期の応答を受け取ると JSF ページの一部を表示し、一部のページを更新することが可能です。
|

|
RichFaces Ajax <a:status> タグを使用すると、非同期の要求が返されるのを待つ間に動画イメージを表示させます。
|

|
RichFaces Ajax <a:outputPanel> タグは非同期要求によって再レンダリング可能なページの領域を定義します。
|

|
Seam <s:link> タグを使用すると、JSF アクションリスナーを普通の (非 JavaScript) HTML リンクにつなげることができます。標準 JSF <h:commandLink> と比べて有利な点は、「新しいウィンドウで開く」や「新しいタブで開く」という動作を維持することです。パラメータ #{hotelBooking.selectHotel(hot)} 付きのメソッドバインディングを使用している点に注目してください。これは標準 Unified EL では不可能ですが、Seam はすべてのメソッドバインディング式でパラメータを使用できるよう EL を拡張します。
ナビゲーションのルールは WEB-INF/pages.xml に記載されています。詳細は 「ナビゲーション」 で説明します。
|
このページはユーザーの入力に応じて検索結果を動的に表示して、選択したホテルを HotelBookingAction の selectHotel() メソッドに渡します。 ここで実際の作業が発生します。
次のコードでは予約サンプルアプリケーションがどのように対話スコープのステートフルセッション Bean を使用し、 対話関連の永続データの自然なキャッシュを実現しているのかを示しています。コードを対話の各種ステップを実装するスクリプト化された動作の一覧と考えると理解できます。
例1.30 HotelBookingAction.java
@Stateful
@Name("hotelBooking")
@Restrict("#{identity.loggedIn}")
public class HotelBookingAction implements HotelBooking
{
@PersistenceContext(type=EXTENDED)
private EntityManager em;
@In
private User user;
@In(required=false) @Out
private Hotel hotel;
@In(required=false)
@Out(required=false)
private Booking booking;
@In
private FacesMessages facesMessages;
@In
private Events events;
@Logger
private Log log;
private boolean bookingValid;
@Begin
public void selectHotel(Hotel selectedHotel)
{
hotel = em.merge(selectedHotel);
}
public void bookHotel()
{
booking = new Booking(hotel, user);
Calendar calendar = Calendar.getInstance();
booking.setCheckinDate( calendar.getTime() );
calendar.add(Calendar.DAY_OF_MONTH, 1);
booking.setCheckoutDate( calendar.getTime() );
}
public void setBookingDetails()
{
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.DAY_OF_MONTH, -1);
if ( booking.getCheckinDate().before( calendar.getTime() ) )
{
facesMessages.addToControl("checkinDate",
"Check in date must be a future date");
bookingValid=false;
}
else if ( !booking.getCheckinDate().before( booking.getCheckoutDate() ) )
{
facesMessages.addToControl("checkoutDate",
"Check out date must be later " +
"than check in date");
bookingValid=false;
}
else
{
bookingValid=true;
}
}
public boolean isBookingValid()
{
return bookingValid;
}
@End
public void confirm()
{
em.persist(booking);
facesMessages.add("Thank you, #{user.name}, your confimation number " +
" for #{hotel.name} is #{booki g.id}");
log.info("New booking: #{booking.id} for #{user.username}");
events.raiseTransactionSuccessEvent("bookingConfirmed");
}
@End
public void cancel() {}
@Remove
public void destroy() {}
}
@Stateful
@Name("hotelBooking")
@Restrict("#{identity.loggedIn}")
public class HotelBookingAction implements HotelBooking
{
@PersistenceContext(type=EXTENDED)
private EntityManager em;
@In
private User user;
@In(required=false) @Out
private Hotel hotel;
@In(required=false)
@Out(required=false)
private Booking booking;
@In
private FacesMessages facesMessages;
@In
private Events events;
@Logger
private Log log;
private boolean bookingValid;
@Begin
public void selectHotel(Hotel selectedHotel)
{
hotel = em.merge(selectedHotel);
}
public void bookHotel()
{
booking = new Booking(hotel, user);
Calendar calendar = Calendar.getInstance();
booking.setCheckinDate( calendar.getTime() );
calendar.add(Calendar.DAY_OF_MONTH, 1);
booking.setCheckoutDate( calendar.getTime() );
}
public void setBookingDetails()
{
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.DAY_OF_MONTH, -1);
if ( booking.getCheckinDate().before( calendar.getTime() ) )
{
facesMessages.addToControl("checkinDate",
"Check in date must be a future date");
bookingValid=false;
}
else if ( !booking.getCheckinDate().before( booking.getCheckoutDate() ) )
{
facesMessages.addToControl("checkoutDate",
"Check out date must be later " +
"than check in date");
bookingValid=false;
}
else
{
bookingValid=true;
}
}
public boolean isBookingValid()
{
return bookingValid;
}
@End
public void confirm()
{
em.persist(booking);
facesMessages.add("Thank you, #{user.name}, your confimation number " +
" for #{hotel.name} is #{booki g.id}");
log.info("New booking: #{booking.id} for #{user.username}");
events.raiseTransactionSuccessEvent("bookingConfirmed");
}
@End
public void cancel() {}
@Remove
public void destroy() {}
}
Copy to Clipboard
Copied!
Toggle word wrap
Toggle overflow

|
この Bean は EJB3 拡張永続コンテキスト を使用するため、 エンティティインスタンスはステートフルセッション Bean のライフサイクル全体に対して管理されたままとなります。
|

|
@Out アノテーションはメソッド呼び出しの後に属性値がコンテキスト変数に アウトジェクト されることを宣言します。このサンプルでは、アクションリスナーの呼び出しが完了するごとに、hotel という名前のコンテキスト変数が hotel インスタンス変数の値に設定されます。
|

|
@Begin アノテーションは、 アノテーション付きメソッドが 長期実行の対話 を開始することを指定するため、現在の対話コンテキストは要求の終わりに破棄されません。その代わりに、現在のウィンドウからのあらゆる要求に再度関連付けられ、非アクティブな対話によるタイムアウトまたは適合する @End メソッドにより破棄されます。
|

|
@End アノテーションはアノテーション付きメソッドが現在の長期実行の対話を終了することを指定します。したがって要求の終わりで現在の対話コンテキストは破棄されます。
|

|
この EJB remove メソッドは Seam が対話コンテキストを破棄すると呼び出されます。このメソッドを定義するのを忘れないようにしてください。
|
HotelBookingAction は選択、 予約、 予約確認を実装するすべてのアクションリスナーのメソッドを持っており、 この操作に関連する状態をそのインスタンス変数に保持しています。 このコードは HttpSession 属性の取得と設定に比べるとより明確でかつシンプルです。
さらに良いことに、 ユーザーはログインセッション毎に複数の分離された対話を持つことが可能です。 ログインして検索を試行したり、 複数のブラウザタブを開いて異なるホテルのページを表示させたりしてみてください。 同時に 2 つの異なるホテル予約の作成を行うことが可能です。 いずれかの対話を長時間放置すると Seam は最終的にはその対話をタイムアウトし状態を破棄します。 対話の終了後に、 その対話ページに戻るボタンを押して戻り何らかの操作を行おうとすると、 Seam によって対話が既に終了したことが検出され、検索ページにリダイレクトされます。