Chapter 6. 对话以及工作区管理

现在该更详细地了解一下Seam的对话模型了。

从历史上看,Seam的“对话Conversation”概念是由三个不同的概念合并而成的。

通过统一以上这些概念并提供底层框架的支持,我们就有了一个强大的构造能力,它使我们能够用比以前更少的代码构建出功能更加丰富且更加高效的应用程序。

6.1. Seam的对话模型

我们目前为止所看到的例子仅仅使用非常简单的对话模型,它遵循以下这些规则:

  • 在应用JSF请求值、处理验证、更新模型值、调用应用程序,以及渲染JSF请求生命周期的响应阶段期间,始终都有一个激活的会话上下文。

  • 在JSF请求生命周期恢复视图阶段的最后,Seam将会试图恢复之前长时间运行的任何对话上下文。 如果这种上下文不存在,Seam将会创建一个新的临时对话上下文。

  • 当遇到 @Begin 方法时,临时对话上下文会被提升为“长时间运行”的对话。

  • 当遇到 @End 方法时,任何“长时间运行”对话上下文都将会被降级为临时对话。

  • 在JSF请求生命周期渲染阶段的最后,Seam会保存“长时间运行”对话的内容,或者销毁临时对话上下文的内容。

  • 任何“faces request”(一种JSF postback)都会传播对话上下文。 在默认情况下,非“faces request”(例如GET请求)都不会传播对话上下文,欲知详情,请看下面分解。

  • 如果JSF请求生命周期被一个重定向redirect命令中止,Seam将会透明地保存并恢复当前的对话上下文— 除非该对话已经通过 @End(beforeRedirect=true) 中止。

Seam透明地在JSF postback以及重定向redirect时传递对话上下文。 如果你不需要做任何特殊的事情,使用 “non-faces request” (例如GET请求)就不会传递对话上下文,并且它会在一个新的临时对话中被处理。这通常(但并非总是)是我们期望的一种行为。

如果你希望在“non-faces request”中传递Seam对话,就需要显式地将Seam的 conversation id 编写为一个request参数:

<a href="main.jsf?conversationId=#{conversation.id}">Continue</a>

或者更JSF的做法是:

<h:outputLink value="main.jsf">
    <f:param name="conversationId" value="#{conversation.id}"/>
    <h:outputText value="Continue"/>
</h:outputLink>

如果你使用Seam标签库,就等同于:

<h:outputLink value="main.jsf">
    <s:conversationId/>
    <h:outputText value="Continue"/>
</h:outputLink>

如果你不想给一个postback传播会话上下文,可以使用一个类似的小窍门:

<h:commandLink action="main" value="Exit">
    <f:param name="conversationPropagation" value="none"/>
</h:commandLink>

如果你使用Seam标签库,则等同于:

<h:commandLink action="main" value="Exit">
    <s:conversationPropagation type="none"/>
</h:commandLink>

注意不使用对话上下文传播与结束对话绝对不是同一回事。

请求参数 conversationPropagation,或者 <s:conversationPropagation> 标签甚至都可以用来开始和结束对话,或者开始一个嵌套对话。

<h:commandLink action="main" value="Exit">
    <s:conversationPropagation type="end"/>
</h:commandLink>
<h:commandLink action="main" value="Select Child">
    <s:conversationPropagation type="nested"/>
</h:commandLink>
<h:commandLink action="main" value="Select Hotel">
    <s:conversationPropagation type="begin"/>
</h:commandLink>
<h:commandLink action="main" value="Select Hotel">
    <s:conversationPropagation type="join"/>
</h:commandLink>

这种会话模型可以非常容易地创建基于多窗口操作的应用系统。对于许多应用程序来说,这已经足够了。 但是另外一些复杂的应用程序还会需要以下额外需求中的一点或两点。

  • 一个对话范围跨越多个更小的用户交互单元,这些小单元逐个或者同步地执行。 更小的 嵌套对话 拥有它们自己的一套独立的对话状态,并且也可以访问外部对话的状态。

  • 用户能够在同一个浏览窗口中的多个对话之间进行切换。这种功能称做 工作区管理

6.2. 嵌套对话

嵌套对话是通过在一个现有对话的范围内调用一个名为 @Begin(nested=true) 的方法进行创建的。 嵌套对话有它自己的对话上下文,还可以只读地访问外部对话的上下文(它可以读取外部对话的上下文变量,但是不可以写)。 随后当遇到 @End 时,嵌套对话会被销毁,并且外部对话会弹出会话堆栈继续运行。 理论上,对话可以嵌套到任意层深。

某个用户活动(工作区管理,或返回按钮)可以在内部对话结束之前就恢复外部对话。 在这种情况下,一个外部对话就有可能同时拥有多个嵌套对话。 如果外部对话在嵌套对话之前就被结束,Seam将会把嵌套对话和外部对话一起销毁掉。

对话可以被认为是一个 连续的状态 。 嵌套对话允许应用程序在不同的用户交互点捕捉一致连续的状态,因此必须确保在返回按钮以及工作区管理的面上有真正的正确行为。

TODO:说明当你点击返回按钮时嵌套对话如何防止错误发生的一个例子。

通常,如果一个组件存在于当前嵌套对话的父对话中,嵌套对话会使用同一个实例。 少数情况下,在每个嵌套对话中都使用不同的实例会很有用,以便存在于父对话中的组件实例对其子对话是不可见的。 你可以通过给这个组件注解 @PerNestedConversation 来实现。

6.3. 使用GET请求来开始一个对话

JSF并没有定义任何类型的action监听器,这种监听器会在通过非JSF请求“non-faces request”访问页面的时候被触发(例如,一个HTTP GET请求)。 这种触发会发生在当用户用书签保存了这个页面,或者在我们通过 <h:outputLink> 访问页面的时候。

有时候,我们希望在访问页面的时候立即开始一个对话。 由于没有JSF action方法,我们不能以寻常的通过用 @Begin 标注action的方式来解决这个问题,

当页面需要把一些状态抓取到上下文变量中时,另一个问题也就随之产生了。我们已经看到有两种方法可以解决这个问题。 如果这个状态是Seam组件所持有的,我们就可以通过 @Create 方法来抓取。 如果不是,我们就可以为这个上下文变量定义一个 @Factory 方法。

如果以上两种办法都不适合你,Seam还允许你在 pages.xml 文件中定义一个 page action

<pages>
    <page view-id="/messageList.jsp" action="#{messageManager.list}"/>
    ...
</pages>

这个action方法在开始渲染响应阶段的时候被调用,即在页面就要被渲染的任何时候。 如果页面action返回一个非空的值,Seam将会根据Seam导航规则处理任何合适的JSF,可能导致渲染另外一个完全不同的页面。

如果你在渲染页面之前想要做的 仅仅 是开始一个对话,那你可以使用一个内建的action方法,它正好具备这种功能:

<pages>
    <page view-id="/messageList.jsp" action="#{conversation.begin}"/>
    ...
</pages>

注意你也可以从JSF控制器中调用内建的action来开始一个对话,同样地,你可以使用 #{conversation.end} 来结束一个对话。

如果你想要更多的控制,以加入现有的对话或开始一个嵌套对话,开始一个页面流或者开始一个原子的对话,你应该使用 <begin-conversation> 元素。

<pages>
    <page view-id="/messageList.jsp">
       <begin-conversation nested="true" pageflow="AddItem"/>
    <page>
    ...
</pages>

<end-conversation>元素也可以结束一个对话。

<pages>
    <page view-id="/home.jsp">
       <end-conversation/>
    <page>
    ...
</pages>

为了解决第一个问题,我们现在有五种选择:

  • @Begin 注解 @Create 方法

  • @Begin 注解 @Factory 方法

  • @Begin 注解Seam页面action

  • pages.xml 中使用 <begin-conversation>

  • 利用 #{conversation.begin} 作为Seam页面action方法

6.4. 利用<s:link>以及<s:button>

JSF命令链始终通过JavaScript来执行一个表单提交,它打破了浏览器的“在新窗口中打开”或者“在新标签中打开”这种特点。 在普通的JSF中,如果你需要这项功能,就需要使用 <h:outputLink>。 但是 <h:outputLink> 标签有两大限制。

  • JSF没有提供将action监听器附加给 <h:outputLink> 的方法。

  • 由于实际上没有提交表单,JSF并没有传播 DataModel 中的选中行。

Seam提供了一个 page action 的概念来帮助解决第一个问题,但是这对于第二个问题却无能为力。 我们 可以 利用REST的方法传递请求参数以及重新查询服务端的选中对象来解决这个问题。 在某些情况下——例如Seam博客上范例应用程序那样——这实际上最好的方法。REST风格支持书签,因为它不需要服务器端的状态。 在其他那些我们不需要关心书签的情况下,使用 @DataModel 以及 @DataModelSelection 就很方便也很透明!

为了填补这项缺失的功能,也为了使对话传播的管理变得更加简单,Seam提供 <s:link> 这样一个JSF标签。

这个连接可以仅指定JSF视图的id:

<s:link view="/login.xhtml" value="Login"/>

或者,它可以指定一个action方法(在这种情况下action的输出将会决定结果页面):

<s:link action="#{login.logout}" value="Logout"/>

如果你把JSF视图id和action方法这 两者 都指定的话,“视图”将会被使用, 除非action方法返回一个非空的结果:

<s:link view="/loggedOut.xhtml"  action="#{login.logout}" value="Logout"/>

这个连接自动地利用内部的 <h:dataTable> 传播 DataModel 的所选行。

<s:link view="/hotel.xhtml" action="#{hotelSearch.selectHotel}" value="#{hotel.name}"/>

你可以不指定现有对话的范围:

<s:link view="/main.xhtml" propagation="none"/>

你可以开始、结束或者嵌套对话:

<s:link action="#{issueEditor.viewComment}" propagation="nest"/>

如果一个连接开始了一个对话,你甚至可以指定一个要使用的页面流:

<s:link action="#{documentEditor.getDocument}" propagation="begin"
        pageflow="EditDocument"/>

如果使用jBPM任务列表,那么你可以使用 taskInstance 属性:

<s:link action="#{documentApproval.approveOrReject}" taskInstance="#{task}"/>

(请见DVD Store演示的应用程序中针对以上用法的范例。)

最后,如果你希望“链接”被渲染成为一个按钮,就使用 <s:button>

<s:button action="#{login.logout}" value="Logout"/>

6.5. 成功信息

给用户显示一条action执行成功或者失败的消息是相当常见的功能。为此使用JSF的 FacesMessage 是非常方便的。 不幸的是,成功的action通常需要一个浏览器重定向。这使得在普通的JSF中显示成功信息变得相当困难。

内建的会话范围的Seam组件 facesMessages 解决了这个问题。 (你必须安装Seam重定向过滤器。)

@Name("editDocumentAction")
@Stateless
public class EditDocumentBean implements EditDocument {
    @In EntityManager em;
    @In Document document;
    @In FacesMessages facesMessages;

    public String update() {
        em.merge(document);
        facesMessages.add("Document updated");
    }
}

对于当前的会话来说,任何加入到 facesMessages 的消息都正好用在下一个渲染阶段中。 甚至当没有“长时间运行”对话的时候也会奏效,因为Seam甚至在重定向过程中保留了临时对话。

你甚至可以在faces message概述中包含JSF EL表达式:

facesMessages.add("Document #{document.title} was updated");

你可以按照通常的方式显示消息,例如:

<h:messages globalOnly="true"/>

6.6. 使用“显式”的对话id

通常情况下,Seam会给每个对话产生一个无意义且唯一的id。你可以在你开始一个对话的时候定制id的值。

这个特性可以用来定制会话id生成算法,像这样:

@Begin(id="#{myConversationIdGenerator.nextId}")
public void editHotel() { ... }

或者它可以用来分配一个有意义的对话id:

@Begin(id="hotel#{hotel.id}")
public String editHotel() { ... }
@Begin(id="hotel#{hotelsDataModel.rowData.id}")
public String selectHotel() { ... }
@Begin(id="entry#{params['blogId']}")
public String viewBlogEntry() { ... }
@BeginTask(id="task#{taskInstance.id}")
public String approveDocument() { ... }

毫无疑问,每当选中一家特殊的酒店、一篇特殊的博客或者一项特殊的任务时,这些例子都会产生一个相同的对话id。 那么,如果一相新对话开始时已经存在一个包含相同对话id的对话时,会发生什么情况呢? 嗯,Seam竟然会发现现有的对话,并重定向到该对话,而不去再次运行 @Begin 方法。 这个特性会帮助我们控制在使用工作区管理时创建的多个工作区。

6.7. 工作区管理

工作区管理指的是可以在一个单独的窗口中"切换"多个对话的能力。 Seam在Java代码级别完全透明地管理工作区。为了启用工作区管理,你所需要做的全部事情如下:

  • 为每个视图id(在使用JSF或Seam导航规则时)或者页面节点(在使用JPDL页面流时)提供一个 描述 文本。 这个描述文本通过工作区切换器显示给用户。

  • 在你的页面中包含一个或多个标准JSP或facelets片断的工作区转换器。 标准片断支持通过下拉菜单、对话列表或者导航控件来管理工作区。

6.7.1. 工作区管理及JSF导航

当你使用JSF或者Seam导航规则的时候,Seam会通过恢复对话的当前 view-id 切换到该对话。 工作区的描述文本在一个名为 pages.xml 的文件中定义, Seam希望在 WEB-INF 目录中找到它,这个文件就放在 faces-config.xml 旁边。

<pages>
    <page view-id="/main.xhtml">Search hotels: #{hotelBooking.searchString}</page>
    <page view-id="/hotel.xhtml">View hotel: #{hotel.name}</page>
    <page view-id="/book.xhtml">Book hotel: #{hotel.name}</page>
    <page view-id="/confirm.xhtml">Confirm: #{booking.description}</page>
</pages>

注意,如果找不到这个文件,Seam应用程序会继续正常地运行!只不过会失去切换工作区的功能。

6.7.2. 工作区管理和jPDL页面流

当你使用jPDL页面流程定义的时候,Seam通过恢复当前jBPM流程状态切换到一个对话。 这是一个更加灵活的模型,因为它允许同一个 view-id 根据当前的 <页面> 节点而拥有不同的描述。 这个描述文本通过 <page> 节点来定义。

<pageflow-definition name="shopping">

   <start-state name="start">
      <transition to="browse"/>
   </start-state>

   <page name="browse" view-id="/browse.xhtml">
      <description>DVD Search: #{search.searchPattern}</description>
      <transition to="browse"/>
      <transition name="checkout" to="checkout"/>
   </page>

   <page name="checkout" view-id="/checkout.xhtml">
      <description>Purchase: $#{cart.total}</description>
      <transition to="checkout"/>
      <transition name="complete" to="complete"/>
   </page>

   <page name="complete" view-id="/complete.xhtml">
      <end-conversation />
   </page>

</pageflow-definition>

6.7.3. 对话转换器

在你的JSP或facelets页面中包含以下代码片断,以获得使你可以转换到任何当前对话或者应用程序的任何其他页面的一个下拉菜单:

<h:selectOneMenu value="#{switcher.conversationIdOrOutcome}">
    <f:selectItem itemLabel="Find Issues" itemValue="findIssue"/>
    <f:selectItem itemLabel="Create Issue" itemValue="editIssue"/>
    <f:selectItems value="#{switcher.selectItems}"/>
</h:selectOneMenu>
<h:commandButton action="#{switcher.select}" value="Switch"/>

In this example, we have a menu that includes an item for each conversation, together with two additional items that let the user begin a new conversation. 在这个例子中,我们有一菜单,它为每一个对话都包含了一个条目,另外还有两个让用户开始新对话的条目。

Only conversations with a description will be included in the drop-down menu.

6.7.4. 对话列表

除了对话列表会显示成为一个表格以外,它与对话转换器非常相似:

<h:dataTable value="#{conversationList}" var="entry"
        rendered="#{not empty conversationList}">
    <h:column>
        <f:facet name="header">Workspace</f:facet>
        <h:commandLink action="#{entry.select}" value="#{entry.description}"/>
        <h:outputText value="[current]" rendered="#{entry.current}"/>
    </h:column>
    <h:column>
        <f:facet name="header">Activity</f:facet>
        <h:outputText value="#{entry.startDatetime}">
            <f:convertDateTime type="time" pattern="hh:mm a"/>
        </h:outputText>
        <h:outputText value=" - "/>
        <h:outputText value="#{entry.lastDatetime}">
            <f:convertDateTime type="time" pattern="hh:mm a"/>
        </h:outputText>
    </h:column>
    <h:column>
        <f:facet name="header">Action</f:facet>
        <h:commandButton action="#{entry.select}" value="#{msg.Switch}"/>
        <h:commandButton action="#{entry.destroy}" value="#{msg.Destroy}"/>
    </h:column>
</h:dataTable>

我们设想你想要根据你自己的应用程序去定制这个。

只有带有描述的对话才会被包含在列表中。

注意对话列表允许用户销毁工作区。

6.7.5. 导航控件

导航控件在使用嵌套对话模式的应用程序中很有用。导航控件是当前对话堆栈中对话链接的一个列表。

<ui:repeat value="#{conversationStack}" var="entry">
    <h:outputText value=" | "/>
    <h:commandLink value="#{entry.description}" action="#{entry.select}"/>
</ui:repeat

6.8. 对话组件和JSF组件绑定

对话组件有一个小小的限制:它们不能够被用来保存对JSF组件的绑定。 (除非绝对必要,否则我们通常不喜欢使用JSF的这个特性,因为它创建了从应用程序逻辑到视图的强依赖关系。) 在一个postback请求中,组件绑定会在视图恢复阶段中且在Seam对话上下文恢复之前被更新。

为了解决这个问题,使用一个事件范围的组件来保存组件绑定,并将它注入到需要它的对话范围组件中。

@Name("grid")
@Scope(ScopeType.EVENT)
public class Grid
{
    private HtmlPanelGrid htmlPanelGrid;

    // getters and setters
    ...
}
@Name("gridEditor")
@Scope(ScopeType.CONVERSATION)
public class GridEditor
{
    @In(required=false)
    private Grid grid;

    ...
}

另外一种选择是,你可以通过隐式的 uiComponent 句柄来访问JSF组件树。 下面这个例子访问在迭代中支持数据表的 UIData 组件的 getRowIndex() ,它打印当前的行数:

<h:dataTable id="lineItemTable" var="lineItem" value="#{orderHome.lineItems}">
   <h:column>
      Row: #{uiComponent['lineItemTable'].rowIndex}
   </h:column>
   ...
</h:dataTable>

在这个map中,可以得到JSF UI组件和它们的客户标识符。

6.9. 对话组件的并发调用

在 ??? 中可以找到Seam组件并发调用的全部讨论。 在这里,我们要讨论是常见的一种并发情形,在这种情形下,你会遇到从AJAX请求中访问对话组件的并发—。 我们就要讨论一个Ajax客户端库应该提供用来控制源自客户端的事件的选项—,并看看RickFaces为你提供的选项。

对话组件实际上并不允许真正的并发访问,因此Seam会给每一个请求排一个队列并依次处理它们。这样允许每个请求都以一种特定的方式被执行。 但是,简单的队列不是那么强大—首先,由于某些原因,如果一个方法需要非常长的时间来完成,并且每次生成一个错误的请求时就一次又一次的反复运行(潜在拒绝服务攻击的可能)。 其次,AJAX经常被用于给用户提供快速的状态更新,因此持续地长时间运行该动作并没有什么用处。

因此Seam给action事件排队等待一段时间(并发的请求超时); 如果不能及时处理事件,它就会创建一个临时的对话并且给用户打印一条信息,让他们了解情况。 所以不要给服务器发送泛滥的AJAX事件,这是非常重要的。

我们可以在components.xml文件中给并发的请求超时设置一个合理的超时默认值(ms)。

<core:manager concurrent-request-timeout="500" />

到目前为止,我们已经讨论了同步的AJAX请求—— 客户端告诉服务器发生了一个事件,然后根据返回值来重新渲染局部页面。 当AJAX请求是轻量级时,采用这种方法就相当好(这些方法的调用也简单,如:计算一列数字的总和)。 但是当我们需要做复杂的计算时我们应该怎么做呢?

对于大量的计算我们应当使用真正的异步(基于轮询“Poll”的)方法 — 客户端发送一个AJAX请求到服务器,这使得一个action在服务器端被异步地执行(因此立即就会响应客户端);然后客户端会在服务器中查询更新。 当你运行一个长时间运行的操作时它很有用,因为每一个action都可以执行是非常重要的(你不会希望某些action由于重复或者超时而被丢弃)。

我们应如何来设计对话的AJAX应用程序呢?

首先,你需要决定是否想使用更简单的“同步”请方式,或者是否想要利用“Poll风格”的方法。

如果你要使用“同步”请求的方法,那么你需要评估一下你的AJAX请求需要多长时间才可以结束——它是不是比并发请求超时值要短? 如果不是,你也许要修改这个方法的并发请求超时时间(如上所述)。 接下来你或许需要在客户端给请求排一个队,防止请求全部涌入服务器。 如果这是一个经常发生的事件(例如,按钮按下和输入域的onblur),并且立即更新客户端又不是优先需要考虑的情况,你就应该在客户端设置一个请求延时。 当消耗完你的请求延时的时候,该事件中的操作也会在服务器段排成一个队列。

最后,客户端库可能会提供一个选项,它可以放弃最近未完成的重复请求。 对这个选项你需要很谨慎,因为在服务端没能放弃未完成的请求时,这个选项可能导致请求全部涌入服务器端。

使用“Poll风格”的设计比较不需要细调。 你只要给你的action方法 @Asynchronous 进行标注,并确定一个查询时间间隔就可以了:

int total;

// This method is called when an event occurs on the client
// It takes a really long time to execute
@Asynchronous
public void calculateTotal() {
   total = someReallyComplicatedCalculation();
}

// This method is called as the result of the poll
// It's very quick to execute
public int getTotal() {
   return total;
}

6.9.1. RichFaces Ajax

RichFaces AJAX是最常与Seam一起使用的Ajax库,它提供了上面讨论过的所有控制:

  • eventsQueue — 提供一个放置事件的队列。所有的事件都排成队列,并且请求被依次发送给服务器端。 当服务器没有被拒绝服务攻击时,若一个请求在服务器上需要花费一些时间来执行时,这个是很有用的(比如:大量的计算,从慢的数据源里获取信息)。

  • 如果最近在队列中已经有‘相似的’请求, ignoreDupResponses — 就会忽略由该请求产生的响应。 ignoreDupResponses="true" 不会取消 请求在服务端的处理 — 它只是在客户端防止不必要的更新。

    这个选项与Seam对话一起使用时应该很小心,因为它允许创建多个并发请求。

  • requestDelay — 定义请求存在于队列中的时间(ms)。 如果这个请求在这个时间内没有被处理,它就会被发送(不管是否接收到response)或者丢弃(如果队列中有更近的相似事件)。

    这个选项与Seam的对话一起使用应该很小心,因为它允许创建多个并发请求。 你要确定你所设置的延时时间(结合并发请求超时的时间)要长于action的执行时间。

  • <a:poll reRender="total" interval="1000" /> — 请求服务器端,并重新渲染一个需要的区域。