1. 截止到目前,我们讨论的都是无状态的客户端-服务器交互,grails认为应用程
序中的action都是简单的单步处理,不需要知道用户当前操作的状态和前后步骤。
但是在某些情况下,应用程序的处理是和前后步骤相关的,比如典型的购物车,用
户必须确认购买的物品和价格、输入送货地址、验证信用卡支付才能确认订单并显
示收据,用户直接跳到确认订单的步骤是不合法的。
在无状态的应用框架下,程序员需要自己保存当前状态(比如利用session或者是
cookie),然后在controller里通过大量编码来确保步骤的顺序进行,并且自己提
供各种边界条件的判断和处理逻辑。在度过大量不眠之夜终于完成这个功能后,你
偶然在一个某大师的桌上发现了这本书,随意翻到第9章 “Creating Web Flows”,
你会突然发现人生是如此灰暗,天底下最悲惨的事情莫过于此。。。
当然,也有另一种无状态框架的做法,就是采用Ajax把所有的状态放在客户端来管
理(正如我们上一章里的例子),在流程里的每个步骤对应了HTML里根据当前状态
变成“出现“或“隐藏“的元素,这样就不需要每次刷新浏览器了。但是大部分grails
达人,还是会坚持认为流程控制应该放在服务器端而不是客户端来进行。
2. 来看看grails提供的插件能做什么?利用grails的Spring Web Flow插件,定义
一系列的状态,从启示状态到终结状态,并且根据流程的定义控制用户在不同状态
之间的转移。Spring Web Flow实质上是一个状态机,在客户端和服务器之间传递
flowExecuteKey和eventID(通常是作为request参数,),从而实现用户在不同状
态之间的转移。前面一句话是不是很难看懂?其实,我也没看懂,书上在写了这样
一段话以后,又接着说了一句:“您不需要花太多精力去研究这些机制,grails已
经帮您处理好了大部分的客户端-服务器通讯。“ 既然如此,那就先跳过吧。
3. Web Flow的定义:只需在controller中定义以"Flow"结尾的action,就定义了
一个Web Flow,如:
class StoreController {
def shoppingCartFlow = {
....
}
}
每个流程应该有一个唯一的id,就是def后面流程名去掉“Flow“,例如这里是
shoppingCart。这样好像看上去和一般的action没啥不同,但是实际上是大不相
同。首先,在流程的closure里并没有任何逻辑语句,而是顺序定义了一组流程状
态,这些流程状态表达为以一个闭包作为参数的方法调用,如:
def shoppingCartFlow = {
showCart {
on("checkout").to "enterAddress"
on("continueShopping").to "displayCatalogue"
}
}
on("checkout").to "enterAddress" 是Spring Web Flow提供的流程定义DSL,这
样的流程定义非常简单易读。
4. 状态定义:一个流程的起始状态永远是流程闭包里的第一个状态定义,结束状
态有两种情况:一是无参数的(也就是闭包内为空),一是redirect到流程外的
action或者另一个流程的,如:
def shoppingCartFlow = {
showCart { //起始状态
.....
}
displayInvoice() //第一种终结状态
cancelTransaction{ //第二种终结状态
redirect(controller: “store")
}
}
5. 对应的view:根据grails的convention,在每个含有流程定义的controller的
view目录下,需要为每个定义的流程建立一个与流程同名的子目录,然后在子目录
下根据流程内部状态的定义,为起始、中间和无参数结束的状态建立一个与状态名
同名的.gsp。对应上面的例子,应该在 grails-app/views/store下建立
shoppingCart目录,在其中添加 showCart.gsp,displayInvoice.gsp等,而
cancelTransaction不需要对应的.gsp,因为它作为一个终结状态,并且会
redirect到其他controller。
6. action状态和view状态:在起始和终结状态之间,一般还会存在若干其他的状
态,这些状态可以分为action状态和view状态。简而言之,view状态就是能暂停流
程并渲染一个view的状态,它不定义action或redirect。如前面提到,view名等同
于状态名,但是也可以通过render方法显示指定为其他的.gsp。例如:
showCart{
render(view:"basket")
}
就让showCart状态对应到basket.gsp进行渲染。
action状态不停下来等待用户的输入,二是直接执行一段代码来确定流程应该转移
到哪个状态。例如:
listAlbums {
action {
[albumList:Album.list(max:10, sort:'dateCreated', order:'desc')]
}
on("success").to "showCatalogue"
on(Exception).to "handleError"
}
listAlbums状态定义了一个action,取得最新的10个唱片记录,把这个list包装成
一个map,map的key是albumList,然后它作为model被返回并自动保存到flow
scope里,可以在整个流程中被访问,如果action执行没有产生错误,则在完成后
触发success事件,使流程自动转移到 showCatalogue状态;否则转到handleError
状态,也可以更加具体地描述Exception并转到更清晰的状态,如:
on(StoreNotAvailableException).to "maintenancePage"
action状态也可以从action触发客户化的事件,配合状态的转移DSL,进行动态的
判断和转移。例如:
isGift {
action {
params.isGift ? yes() : no()
}
on("yes").to "wrappingOptions"
on("no").to "enterShippingAddress"
}
7. Flow Scope:除了常规的request, session这些scope,我们前面还提到了
flash(controller中的),此外还有flow和conversation。Scope本质上就是容
器,和map一样,flash、flow、和conversation的区别在于:
flash:保存的对象只对当前和下一个request有效,和controller中的flash非常
相似,主要区别是在要保存在flow的flash scope中的对象必须实现
java.io.Serializable接口;
flow:保存的对象在整个流程里有效,当流程到达终结状态后被清除。这是在流程
中最常用的scope;
conversation:保存的对象在对话中有效,包括根流程和所有嵌套的子流程。
8. Flow,串行化和Flow存储:
在前面的例子里可以看到,从action状态中返回的model会被自动保存到flow
scope里。使用flow有一个很重要的问题需要注意:存放在flow scope里的对象必
须实现java.io.Serializable接口。这又是为什么呢?很简单,因为flow不像
session和request这些常规scope,它的状态是在服务器以串行化的压缩表形式保
存的。有好学的同志可能会提出一个问题:为啥一定要保存在服务器端呢?
嗯,grails也允许你把它保存在客户端,具体的做法是在Config.groovy里设置
grails.webflow.flow.storage属性:
grails.webflow.flow.storage = “client"
这样服务器端就是无状态的,Web Flow通过接收从客户端传递到服务器的
flowExecutionKey来获取状态。看到这里我不禁产生了一个疑问:只有
flowExecutionKey,那eventID哪里去了?想来想去,想必是因为状态被保存在客
户端,eventID就不需要给无状态的服务器了?有的同志又要说了:这样不是也很
好吗?可能在性能上还会有明显的改进呢!但是同志们,采用客户端存储的状态有
两个需要注意的问题:
只能使用HTTP POST request来触发事件,因为flowExecutionKey太大,无法包含
在URL里;
该方法是不安全的,因为这种方式需要把敏感数据以串行化的方式在服务器和客户
端之间传输,不过如果你的应用不需要考虑安全,或者是已经在HTTPS下运行,则
是可行的;
不管是否采用这种方法,实现java.io.Serializable是必须的。就如在Java中,如
果你有任何不愿意串行化的属性,你必须标记它为 transient。这包括你定义的所
有闭包,因为Groovy的闭包没有实现Serializable接口,如:
transient onLoad = { }
9. 从view中触发事件:
前面我们提到view状态里会暂停流程,渲染一个view来接收用户输入。那么用户输
入又是怎么使流程继续执行下去的呢?主要有两种方式:链接或表单提交。
链接是通过<g:link>标签实现,在第4章提到了用链接来关联到特定的controller
和action,既然流程也是通过 controller和action来实现,链接触发流程继续执
行也就是很正常的了。例如:
<g:link controller="store" action="shoppingCart">My Cart</g:link>
有仔细的同学看到这里可能会发现上面的例子有问题:”开始说的是通过链接触发
在view状态里被暂停的流程继续执行,可是这个例子说的是通过链接来启动一个流
程,shoppingCart是一个action,明明是对应了一个流程的名字嘛!如果要继续执
行流程,必须要对应到 on("aEventName").to "newStateName"才是正确的。“
咳咳,这位同学说的很对,这里再举一个符合初始问题的例子:
<g:link controller="store" action="shoppingCart"
event="checkout">Checkout</g:link>
这样就使Checkout链接在被点击的时候产生checkout事件传递到
store.shoppingCart这个流程里,对应的代码是:
showCart{
on("checkout").to "enterPersonalDetails"
......
}
于是,在Checkout链接被点击后,流程被转移到enterPersonalDetails状态,可能
是通过 enterPersonalDetails.gsp来让用户输入个人资料。
表单提交略有不同,主要是grails通过提交按钮<g:submitButton>标签里的name属
性来确定产生的事件。例如:
<g:form name="shoppingForm" url="[controller:'store', action:'shoppingCart']"
....
<g:submitButton name="checkout" value="Checkout“/>
......
</g:form>
10. 转移action和表单验证:表单提交的数据验证如何进行?一种方式是提交到一
个action状态,在改状态内部可以执行特定的一块代码,这非常有用。不过用
transistion action进行表单验证是更好的实践。那么transition action是什么
呢?基本上它是一个在特定事件被触发的情况下执行的action。有意思的是,如果
transition action由于错误而失败,transition就会被中止,当前状态会被回滚
到原始状态。例如:
on("submit") {
flow.person = new Person(params)
flow.person.validate() ? success() : error()
}.to "enterShipping"
on("return").to "showCart"
在on("submit")后面跟的一个闭包就是transition action,通过validate()方
法,返回的是自动根据validation结果产生的success和error事件。
11. 子流程和对话(conversation)scope:子流程就是流程内部定义的嵌套流
程,通过在流程内部的一个状态下用 subflow(referenceToSubFlowDefinition)方
法产生,如:
先定义subflow:
def chooseGiftWrapFlow = {
....
confirmSelection {
on('confirm') {
def giftWrap = new GiftWrap(params)
if(!giftWrap.validate()) return error()
else {
conversation.giftWrap = giftWrap //把结果保存到conversation scope,使上级flow可以访问到
}
}. to 'giftWrapChosen'
on('cancel').to 'cancelGiftWrap'
}
cancelGiftWrap() //subflow的终结状态
giftWrapChosen() //subflow的终结状态
}
然后在主流程中引用subflow:
def shoppingCartFlow = {
.......
wrappingOptions {
subflw(chooseGiftWrapFlow)
on('giftWarpChosen') { //event 对应subflow里的终结状态
flow.giftWrap = conversation.giftWrap //从conversation中取出结果,保存到flow scope里
}
on('cancelGiftWrap'). to 'enterShippingAddress' //event 对应subflow里的终结状态
}
}
12. 在一个view里混用Ajax和普通request:request对象中有一个xhr属性,如果
该request是一个Ajax请求则是true,否则为false,可以用于判断处理。例如:
if (request.xhr) {
render(template:"album",model:[artist:artist, album:album])
}
else {
render(view:"show",model:[artist:artist, album:album])
}
else {
response.sendError 404
}
13. siteMesh layout的g:layoutBody和g:applyLayout标签:利用这两个标签,可
以实现layout的复用和嵌套,并通过 pageScope.variables表达式把当前页面的
model传递到被渲染的template页面。
14. Groovy表达式动态解析:看这个例子:
if(!flow.albumPayments.album.find{it?id == album.id} )
flow.albumPayments实际上是一个java.util.List对象,怎么它会有一个属性叫
album(一个Album类的实例)呢?这就是GPath的魔力,Groovy会自动解析其中含
有flow.albumPayments这个List中每个元素中的album属性,并把这些属性包装成
一个List返回。还有find{it?id == album.id}部分,是Groovy Truth的体现,在
Java中,只有布尔值可以用来表示true和false,而在Groovy中包含了更完整的表
示法,例如null在if语句中为 false。
15. 条件查询和基于字符串的查询:基于字符串的查询如SQL或HQL容易出现错误,
因为在书写查询语句的时候没有IDE或parser的自动检查,而且这种方式丢失了被
查询对象的大部分类型信息;而条件查询则受益于Groovy的运行时间查询构建器,
能够安全和优雅地实现查询。例如使用 withCriteria方法:
flow.genreRecommandations = Album.withCriteria {
inList 'genre', genres
not {
inList 'id', albums.id
}
maxResults 4
order 'dateCreated', 'desc'
}
还可以通过在条件的闭包内引用以关联表名命名的方法,实现对关联表的查询,如:
def otherAlbumPayments = AlbumPayment.withCriteria {
user { //关联到AlbumPayment表的user属性
purchasedAlbums { //关联到User表的purchasedAlbums属性
inList 'id', albums.id
}
}
}
16. 复用closure:如果有一段代码经常被重复使用,出于DRY的原则,并且提高代
码的一致性和可维护性,应该把这部分代码放在一个closure里,赋值给一个
private 对象进行保存,在该段代码反复出现的地方可以直接引用该对象,达到完
全相同的效果。这是为什么呢?因为everything in Groovy is an object,在
Groovy里一切都是对象,所以一个closure作为一个对象,也可以保存在一个对象
里,引用这个对象的时候,就能访问到这个 closure里的代码块。如:
private addAlbumToCartAction = {
if(!flow.albumPayments) flow.albumPayments = [ ]
def album = Album.get(params.id)
if(!flow.albumPayments.album.find {it?.id == album.id}) {
flow.lastAlbum = new AlbumPayment(album.album)
flow.albumPayments << flow.lastAlbum
}
}
然后在流程中引用就可以非常简化:
def buyFlow = {
start {
....
}
....
showRecommendations {
on('addAlbum', addAlbumToCartAction). to 'requireHardCopy'
.......
}
}
否则showRecommendations状态内部要写成:
on('addAlbum') {
if(!flow.albumPayments) flow.albumPayments = [ ]
def album = Album.get(params.id)
if(!flow.albumPayments.album.find {it?.id == album.id}) {
flow.lastAlbum = new AlbumPayment(album.album)
flow.albumPayments << flow.lastAlbum
}
}.to 'requreHardCopy'
17. 动态的状态转移:如果需要在view状态中动态地确定转移到哪个状态,可以
在.to方法的参数中放置一个closure来动态返回一个目标状态名称。如:
on('back').to {
def view
if(flow.genreRecommendations || flow.userRecommendations)
view = "showRecommendations"
else if(flow.lastAlbum.shippingAddress) {
view = 'enterShipping'
}
else {
view = 'requireHardCopy'
}
return view
}
简单的例子也可以这么看:
on('back').to ‘enterShipping' //static String
on('back').to {'enterShipping'} //closure执行,隐含返回
on('back').to { return 'enterShipping'} //closure执行,显式返回
三个语句的执行结果是一样的。
18. 在转移到关键性的状态之前,可以通过一系列的assert关键字进行状态的
validate,如:
p.addToAlbumPayments(ap)
assert p.save()
19. 测试flow:利用grails.test.WebFlowTestCase类,在集成测试环境中进行测
试。有人问,为什么不做flow的单元测试呢?这个问题就问的没有水平了,flow涉
及到那么多的domain类、controller、template GSP、甚至还有command对象什么
的,单元测试就不适用了。言归正传,在测试类中需要实现抽象方法getFlow(),
返回一个代表被测试flow 的closure,在测试中可以使用的方法如下:
startFlow():启动被测试的flow
assertFlowExecutionEnded():断言流程执行已终结
assertFlowExecutionOutcomeEquals(): 断言流程执行的结果
assertFlowExecutionActive():断言流程未终结
assertCurrentStateEquals():断言当前的状态
signalEvent():触发一个事件
setCurrentState():设定流程的当前状态