用 Groovy 服务器页面(GSP)改变视图

本系列的前两篇文章介绍了 Grails Web 框架的基本构建块。我曾反复强调过 —Grails 基于模型-视图-控制器(Model-View-Controller,MVC)架构模式(请参阅 参考资料),Grails 利用约定优于配置 将框架的各个部分组合在一起。Grails 用命名直观的文件和目录代替了更容易出错的在外部配置文件中手工对这些链接进行归类的老方法。例如,在 第一篇文章 可以看到控制器拥有 Controller 后缀,存储在 grails-app/controller 目录。在 第二篇文章 了解到可以从 grails-app/domain 目录找到域模型。

在 本月的文章中,我将通过讨论 Grails 视图进一步介绍 MVC。视图(正如您所料)存储在 grails-app/views 目录内。但是视图远不止直观的目录名称这么简单。本文将讨论 Groovy 服务器页面(GSP)并介绍许多替代的视图选项。在本文中将学习标准的 Grails 标记库(TagLibs),并了解到创建自定义 TagLib 有多么容易。还会看到如何将 GSP 的常用片断提取出来放在自己的片段模板(partial template)内,从而遵循 DRY(Don't Repeate Yourself,不要重复自己)(请参阅 参考资料)原则。最后,将学习如何为搭建的视图调整默认模板,从而在方便地自动创建视图和跳出 Grail 应用程序默认外观之间进行平衡。

查看 Grails 应用程序

Grails 使用 GSP 作为表示层。Groovy 服务器页面中的 Groovy 不仅代表底层技术,还代表可以快速编写一两个 scriptlet 的语言。从这方面来说,GSP 非常类似于 Java™ 服务器页面(JSP)技术,JSP 允许在 Web 页面上混合使用一些 Java 代码,也和 RHTML(Ruby on Rails 的核心视图技术)非常相像,RHTML 允许在 HTML 标记之间插入一些 Ruby 代码。

当然,Java 社区长期以来都不欣赏小脚本。scriptlet 会导致最低形式的技术重用 —复制与粘贴 — 以及其他一些在技术方面为人所不齿的恶行(因为你能因为你应该 之间有巨大区别)。GSP 中的 G 对优秀、正直的 Java 人员来说只应该表示一种实现语言而不是其他。Groovy TagLibs 和片段模板提供了在 Web 页面之间共享代码和行为的一种更成熟的方式。

GSP 是 Grails 以页面为中心的 MVC 观点的基础。页面是基本衡量单位。列表页面提供了到 Show 页面的链接。Show 页面支持单击到编辑页面,诸如此类。不论是熟练的 Struts 开发人员还是最近的 Rails 爱好者,都熟悉这种 Web 生命周期。

之所以提到这点,是因为近几年出现了大量不以页面为中心的视图技术(请参阅 参考资料)。 面向组件的 Web 框架(例如 JavaServer Faces (JSF) 和 Tapestry 越来越受到青睐。Ajax 革命派生出大量基于 JavaScript 的解决方案,例如 Dojo 和 Yahoo! UI (YUI) 库。富 Internet 应用程序(RIA)平台,例如 Adobe Flash 和 Google Web Toolkit (GWT) 承诺能够实现方便的 Web 部署,并提供更加丰富、与桌面类似的用户体验。幸运的是,Grails 能够轻松地处理所有这些视图技术。

MVC 关注点隔离的整体要点在于:它能够使您轻松地用自己喜欢的任何视图作为 Web 应用程序的外观。Grails 流行的插件基础设施意味着许多 GSP 替代物不过是 grails 安装的插件(请参阅 参考资料 获得可用插件的完整列表的链接,或者在命令行下输入 grails list-plugins)。许多插件都是由社区驱动的,是那些希望将 Grail 与他们喜欢的表示层技术一起使用的人们的努力结果。

虽 然 Grails 没有内置 JSF 的自动挂勾(hook),但是仍然可以结合使用这两种技术。Grails 应用程序是标准的 Java EE 应用程序,因此可以将相应的 JAR 放在 lib 目录内,将需要的设置放在 WEB-INF/web.xml 配置文件内,并像平常一样编写应用程序。Grails 应用程序部署在标准的 servlet 容器内,所以 Grails 对 JSP 的支持同对 GSP 的支持一样好。Grails 有针对 Echo2 和 Wicket 的插件(两者都是面向组件的 Web 框架),所以在使用 JSF 或 Tapestry 插件方面没有任何障碍。

类似地,向 Grails 添加 Ajax 框架(例如 Dojo 和 YUI)的步骤也没有什么特别之处:只要将它们的 JavaScript 库复制到 web-app/js 目录即可。Prototype 和 Scriptaculous 是 Grails 的默认安装。RichUI 插件则从各种 Ajax 库选择 UI 部件。

如果查看插件列表,那么就会看到对 RIA 客户机的支持 —— 例如 Flex、OpenLazlo、GWT 和 ZK。显然,Grails 应用程序并不缺少备选的视图解决方案。但是在这里我们还是采用 Grail 直接支持的视图技术 — GSP。








GSP 101

可以使用多种方法查找 GSP 页面。文件扩展名 .gsp 就是一种很明显的方法,就好像很多以 <g: 开头的标记一样。事实上,GSP 不过是标准 HTML 加上一些提供动态内容的 Grails 标记而已。在前一节提到的某些备选的视图技术是一层不透明的抽象层,完全将 HTML、CSS 和 JavaScript 的细节隐藏在 Java、ActionScript 或其他编程语言层之后。GSP 是在标准 HTML 上的薄薄的一层 Groovy 层,因此在必要时,可以轻松地将它从框架中取出来,并使用原生的 Web 技术。

但是要在目前 的 Trip Planner 应用程序中查找 GSP 的话,则会比较费力(这个系列的前两篇文章开始构建 Trip Planner 程序。如果没有跟上进度,那么现在是赶上来的好时机)。现在视图正在使用动态搭建(scaffold),所以 trip-planner/grails-app/views 目录还是空的。请在文本编辑器打开如清单 1 所示的 grails-app/controller/TripController.groovy,查看用来启用动态搭建的命令:


清单 1. TripController
                
class TripController{
def scaffold = Trip
}

def scaffold = Trip 行告诉 Grails 在运行的时候动态地生成 GSP。这非常适合自动保持视图与域模型修改同步,但是如果正在学习该框架,那么它没有提供太多可学习的东西。

请在 trip-planner 根目录下输入 grails generate-all Trip。当询问是否覆盖现有控制器时,回答 y(也可以回答 a 表示全部,这将覆盖所有内容,这样就不会反复出现提示)。现在应该看到完整的 TripController 类,带有名为 createeditlistshow 闭包(以及其他闭包)。还应该看到 grails-app/views/trip 目录下有四个 GSP:create.gsp, edit.gsp, list.gsp, and show.gsp.

在这里起作用的是 “约定优于配置”。当访问 http://localhost:9090/trip-planner/trip/list 时,就是要求 TripController 填充域模型对象 Trip 的列表,并将列表传递给 trip/list.gsp 视图。请在文本编辑器中查看 TripController.groovy,如清单 2 所示:


清单 2. 完全填充的 TripController
                
class TripController{
...
def list = {
if(!params.max) params.max = 10
[ tripList: Trip.list( params ) ]
}
...
}

这个简单的闭包从数据库中检索到 10 条 Trip 记录,将它们转换为 POGO,并将它们保存在名为 tripListArrayList 内。list.gsp 页面随后将遍历列表,逐行构建 HTML 表格。

下一节研究许多流行的 Grails 标记,包括用来在 Web 页面上显示每条 Trip<g:each> 标记。







Grails 标记

<g:each> 是常用的 Grails 标记。它将遍历列表中的每个 项。要查看它的效果,请在文本编辑器中打开 grails-app/views/trip/list.gsp(如清单 3 所示):


清单 3.list.gsp 视图
                
<g:each in="${tripList}" status="i" var="trip">
<tr class="${(i % 2) == 0 ? 'even' : 'odd'}">
<td><link action="show" id="${trip.id}">${trip.id?.encodeAsHTML()}</g:link></td>
<td>${trip.airline?.encodeAsHTML()}</td>
<td>${trip.name?.encodeAsHTML()}</td>
<td>${trip.city?.encodeAsHTML()}</td>
<td>${trip.startDate?.encodeAsHTML()}</td>
<td>${trip.endDate?.encodeAsHTML()}</td>
</tr>
</g:each>

<g:each> 标记的 status 属性是个简单的计数器字段(请注意这个值用在下一行的 ternary 语句中,用来将 CSS 样式设为 evenodd)。var 属性允许命名用来保存当前项的变量。如果将名称改为 foo,那么需要将后面的行改为 ${foo.airline?.encodeAsHTML()},依次类推( ?. 操作符是 Groovy 用来避免 NullPointerException 的方法。它可以快捷地表示 “只有在 airline 不为 null 时才调用 encodeAsHTML() 方法,否则返回空字符串”)。

另一个常用 Grails 标记是 <g:link>。顾名思义,它的作用是构建一个 HTML <a href> 链接。当然也可以直接使用 <a href> 标记,但是这个方便的标记还接受属性,例如 actionidcontroller。如果只考虑 href 的值而不考虑它前后的 anchor 标记,那么可以改用 <g:createLink> 。在 list.gsp 顶部能看到返回链接的第三个标记: <g:createLinkTo>。这个标记接受 dirfile 属性而不是 controlleractionid 属性。清单 4 显示了 linkcreateLinkTo 的作用:


清单 4. link 标记 vs. createLinkTo 标记
                
<div class="nav">
<span class="menuButton"><a class="home" href="${createLinkTo(dir:'')}">Home</a></span>
<span class="menuButton"><link class="create" action="create">New Trip</g:link></span>
</div>

注意,在清单 4 中,可以交替使用两种不同的形式调用 Grails 标记 — 一种是在尖括号内包含标记,一种是仿效方法调用在大括号内包含标记。当在另一个标记的属性中调用方法时,大括号表示法(正式名称为表达式语言或 EL 语法)更合适。

在 list.gsp 下面的几行,能够看到另一个流行的 Grails 标记:<g:if>,如清单 5 所示。在这个示例中,它的意思是 “如果 flash.message 属性不为 null,就显示它。”


清单 5. <g:if> 标记
                
<h1>Trip List</h1>
<if test="${flash.message}">
<div class="message">${flash.message}</div>
</g:if>

在浏览生成的视图时,还会看到其他许多 Grails 标记。<g:paginate> 标记在数据库包含的 Trip 比当前显示的 10 条记录多时,显示 “前一个” 和 “下一个” 链接。<g:sortable> 标记使列标题变为可单击,从而支持排序。看看其他 GSP 页面中与 HTML 表单有关的标记,例如 <g:form><g:submit>。Grails 的联机文档中列出了所有可用的 Grails 标记,并提供了它们的用法示例(请参阅 参考资料)。








自定义标记库

虽 然标准 Grails 标记很有帮助,但是最终会遇到需要自定义标记的情况。许多资深 Java 开发人员(包括我自己)公开表示,“自定义 TagLib 是合适的架构性解决方案。”,然后却偷偷地编写 scriptlet,以为别人看不到。编写自定义 JSP TagLib 需要的工作太多,所以难以抵抗 scriptlet 的诱惑。scriptlet 并不是正确的方法,但不幸的是,它是一种容易实现的方法。

Scriptlet 破坏了 HTML 基于标记的范式,将原始代码直接引入视图。错误的并不是代码本身,而是它们缺少封装和重用的潜力。重用 Scriptlet 的惟一方式就是 “复制-粘贴”。这导致 bug、代码膨胀,并严重违背了 DRY 原则。更不用说 Scriptlet 在可测试性方面的匮乏了。

这就是说,我必须坦白,随着开发期限越来越紧迫,我写的 JSP scriptlet 的比例也相当大。JPS 标准标记库(JSP Standard Tag Library,JSTL)在这方面帮助了我很多,但是编写我自己的自定义 JSP 标记则完全是另一回事。在我用 Java 代码编写自定义 JSP 标记、编译标记,并将大量时间浪费在将标记库描述符(Tag Library Descriptor,TLD)设置为正确的格式和位置时,我已经完全忘记了当初编写这个标记的理由是什么。编写测试来验证我的新 JSP 标记是否正确也同样麻烦 — 只能说我的出发点是好的。

对比之下,用 Grails 编写自定义 TagLibs 简直就是举手之劳。Grails 框架使得做正确的事(包括编写测试)变得很容易。例如,我经常需要在 Web 页面底部加上标准的版权声明。版权声明应该是这样的: © 2002 - 2008, FakeCo Inc. All Rights Reserved.。问题在于,我希望第二个年份总是当前的年份。清单 6 显示了用 scriptlet 如何完成这个任务:


清单 6. 用 scriptlet 完成的版权声明
                
<div id="copyright">
&copy; 2002 - ${Calendar.getInstance().get(Calendar.YEAR)},
FakeCo Inc. All Rights Reserved.
</div>

既然知道了如何处理当前年份,那么下面就要创建一个执行同样任务的自定义标记。请输入 grails create-tag-lib Date, 这会创建两个文件:grails-app/taglib/DateTagLib.groovy(TagLib)和 grails-app/test/integration/DateTagLibTests.groovy(测试)。将清单 7 中的代码添加到 DateTagLib.groovy:


清单 7.简单的自定义 Grails 标记
                
class DateTagLib {
def thisYear = {
out << Calendar.getInstance().get(Calendar.YEAR)
}
}

清单 7 创建一个 <g:thisYear> 标记。可以看到,年份直接写入输出流。清单 8 显示了新标记的效用:


清单 8.使用自定义标记的版权声明
                
<div id="copyright">
&copy; 2002 - <thisYear />, FakeCo Inc. All Rights Reserved.
</div>

您可能以为这就完成了。非常抱歉,这只完成了一半。







测试 TagLibs

即使现在看起来一切正常,还是应该编写一个测试,确保这个标记日后不会出错。Working Effectively with Legacy Code 的作者 Michael Feathers 说过,任何没有测试的代码都是遗留代码。为了防止 Feathers 先生大发雷霆,请将清单 9 的代码添加到 DateTagLibTests.groovy:


清单 9.自定义标记的测试
                
class DateTagLibTests extends GroovyTestCase {
def dateTagLib

void setUp(){
dateTagLib = new DateTagLib()
}

void testThisYear() {
String expected = Calendar.getInstance().get(Calendar.YEAR)
assertEquals("the years don't match", expected, dateTagLib.thisYear())
}
}

GroovyTestCase 是在 JUnit 3.x TestCase 之上一层薄薄的 Groovy 层。为只有一行代码的标记编写测试看起来似乎有些过分,但是很多时候问题的源头正是这一行代码。编写测试并不难,而且保证安全要比说抱歉更好。请输入 grails test-app 运行测试。如果一切正常,应该看到如清单 10 所示的信息:


清单 10.在 Grails 中通过测试
                
-------------------------------------------------------
Running 2 Integration Tests...
Running test DateTagLibTests...
testThisYear...SUCCESS
Running test TripTests...
testSomething...SUCCESS
Integration Tests Completed in 506ms
-------------------------------------------------------

如果 TripTests 的样子让您感到惊讶,请不要担心。在输入 grails create-domain-class Trip 时,将会为您生成一个测试。实际上,每个 Grails create 命令都会生成对应的测试。确实,测试在现代软件开发中如此 之重要。如果以前没有编写测试的习惯,那么 Grails 将优雅地将您带到正确的方向上来,您肯定不会后悔。

grails test-app 命令除了运行测试之外,还会创建很好的 HTML 报告。请在浏览器中打开 test/reports/html/index.html,查看标准的 JUnit 测试报告,如图 1 所示。


图 1.单元测试报告
Unit test report

编写并测试过简单的自定义标记之后,现在要构建一个略微复杂一些的标记。








高级自定义标记

更复杂的标记中可以处理属性和标记体。例如,现在的版权标记还需要许多复制/粘贴工作才能满足需求。我想像下面这样将当前的行为放在真正可重用的标记内: <g:copyright startYear="2002">FakeCo Inc.</g:copyright>。 清单 11 显示了代码:


清单 11.处理属性和标记体的 Grails 标记
                
class DateTagLib {
def thisYear = {
out << Calendar.getInstance().get(Calendar.YEAR)
}

def copyright = { attrs, body ->
out << "<div id='copyright'>"
out << "&copy; ${attrs['startYear']} - ${thisYear()}, ${body()}"
out << " All Rights Reserved."
out << "</div>"
}
}

请注意:attrs 是标记属性的 HashMap。在这里用它提取 startYear 属性。我将以闭包形式调用 thisYear 标记(这与我用大括号时从 GSP 页面所做的闭包调用相同)。类似地,body 也以闭包的形式传递给标记,所以调用它的方式与调用其他标记的方式相同。这样确保了我的自定义标记可以按照任意深度嵌套到 GSP 中。

您可能注意到,自定义 TagLibs 使用与标准 Grails TagLibs 相同的名称空间 g:。如果需要将自己的 TagLibs 放在自定义名称空间内,请向 DateTagLib.groovy 中添加 static namespace = 'trip'。在 GSP 内,TagLib 现在应该是 <trip:copyright startYear="2002">FakeCo Inc.</trip:copyright>







片断模板

自定义标记是重用简短代码的好方法,从而避免成为只能复制/粘贴的 scriptlet。但是对于更大块的 GSP 标记来说,可以使用片断模板

片断模板在 Grails 文档中的官方称谓是模板。惟一的问题是模板 这个词用得太多了,在 Grails 中有许多不同的意义。下一节就会看到,将安装改变搭建视图的默认模板。对这些模板的修改也包括本节要讨论的片断模板。为了减少混淆,我从 Rails 社区借用了一个术语,将要表达的内容称为片断模板,或者就称为片断

片 断模板是一大块能够在多个 Web 页面之间共享的 GSP 代码。例如,假设我要在所有页面底部使用一个标准的页脚。为了实现这一目的,我要创建一个名为 _footer.gsp 的代码片断。前面的下划线是对框架的提示(对开发人员也是个明显的提示),告诉框架这不是个完整的格式良好的 GSP。如果我在 grails-app/views/trip 目录中创建这个文件,那么只有 Trip 视图才会看到它。我要将它保存在 grails-app/views 目录内,这样就能供所有页面全局共享。清单 12 显示了全局共享页脚的片断模板:


清单 12. Grails 片断模板
                
<div id="footer">
<copyright startYear='2002'>FakeCo, Inc.</g:copyright>

<div id="powered-by">
<img src="${createLinkTo(dir:'images', file:'grails-powered.jpg')}" />
</div>
</div>

可以看到,片断模板支持用 HTML/GSP 语法进行表达。对比之下,自定义 TagLib 是用 Groovy 编写的。简要来说,TagLibs 一般情况下用来封闭小行为更合适,而片断模板更适于重用布局元素。

为了让这个示例能正常工作,还需要将 “Powered by Grails” 按钮下载到 grails-app/web-app/images 目录(请参阅 参考资料)。在下载页面上会看到其他许多附属内容,从高分辨率的 logo 到 16x16 大小的 favicons(浏览网站时在浏览器地址栏前显示的图标)。

清单 13 显示了如何在 list.gsp 页面底部包含新建的页脚:


清单 13.呈现片断模板
                
<html><body>
...
<render template="/footer" />



</body></html>

请注意,在呈现模板时,要去掉下划线。如果在 trip 目录下保存 _footer.gsp,那么前面的斜杠也要省略。可以这样认为:grails-app/views 目录是视图层次结构的根。








自定义默认搭建

有了一些良好的、可测试的、可重用的组件之后,可以将它们做为默认搭建的一部分。这部分内容是在将 def scaffold = Foo 放入控制器之后动态生成的。默认搭建也是输入 grails generate-views Tripgrails generate-all Trip 时生成 GSP 的来源。

要定制默认搭建,请输入 grails install-templates。这样会在项目中加入新的 grails-app/src/templates 目录。应该看到三个目录,名为 artifacts、scaffolding 和 war。

artifacts 目录容纳各种 Groovy 类的模板: ControllerDomainClassTagLib,等等。例如,如果想让所有控制器都扩展一个抽象父类,那么可以在这里进行修改。全部新控制器都将基于修改过的模板代码(有些人会加入 def scaffold = @[email protected],这样动态搭建就会成为所有控制器的默认行为)。

war 目录包含所有 Java EE 开发人员都熟悉的 web.xml 文件。如果需要添加自己的参数、过滤器或 servlet,请在这里进行操作(JSF 爱好者们:注意到了吗?)在输入 grails war 时,这里的 web.xml 文件就会被包含到生成的 WAR 内。

scaffolding 目录包含动态生成的视图的原始内容。请打开 list.gsp 并将 <render template="/footer" /> 添加到文件底部。因为这些模板是所有视图共享的,所以一定要使用全局片断模板。

调整了列表视图之后,现在需要验证修改是否生效。对默认模板的修改是少数需要重新启动服务器的操作之一。Grails 重新启动之后,请用浏览器访问 http://localhost:9090/trip-planner/airline/list。如果正在使用 AirlineController 的默认搭建,那么在页面底部就应该出现新的页脚。







结束语

本期文章总结了 精通 Grails 的另一篇文章。现在您对 GSP 以及 Grails 可以使用的其他视图技术应该有了进一步的了解,并更好地理解了在生成的众多页面中使用的默认标记。下次您再编写 scriptlet 时,肯定会觉得有点 “不舒服”,因为通过编写自定义 Taglib 可以更轻松地完成正确的事情。您看到了如何创建片断模板,还看到了将它们添加到默认搭建视图有多么容易。

下个 月的 Grails Web 框架之旅的重点是 Ajax。不用重新加载整个页面就能发送 “微型” HTTP 请求,这一能力是 Google Maps、Flickr 以及其他许多流行 Web 站点背后的诀窍。下个月您将在 Grails 中体会到相同的魔力。具体来讲,将创建一个多对多关系,并通过 Ajax 使用户体验变得自然而有趣。

现在要说再见了,希望您喜欢 “精通 Grails”系列。