本系列的前兩篇文章介紹了 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 類,帶有名為 create、edit、list 和 show 閉包(以及其他閉包)。還應該看到 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,並將它們保存在名為 tripList 的 ArrayList 內。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 樣式設為 even 或 odd)。var 屬性允許命名用來保存當前項的變量。如果將名稱 改為 foo,那麼需要將後面的行改為 ${foo.airline?.encodeAsHTML()},依次類推( ?. 操作符是 Groovy 用來避免 NullPointerException 的方法。它可以快捷地表示 “只有在 airline 不為 null 時 才調用 encodeAsHTML() 方法,否則返回空字符串”)。
另一個常用 Grails 標記是 <g:link>。顧名思義,它的作用是構建一個 HTML <a href> 鏈接。當然也可以直接使用 <a href> 標記,但是這個方便的標記還接受屬性,例如 action、id 和 controller。如果只考慮 href 的值而不考慮它前後的 anchor 標記,那麼可以改用 <g:createLink> 。在 list.gsp 頂部能看到返回鏈接的第三個標記: <g:createLinkTo>。 這個標記接受 dir 和 file 屬性而不是 controller、action 和 id 屬性。清單 4 顯示了 link 和 createLinkTo 的作用:
清單 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 標記很有幫助,但是最終會遇到需要自定義標記的情況。許多資深 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">
© 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">
© 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.單元測試報告
編寫並測試過簡單的自定義標記之後,現在要構建一個略微復雜一些的標記。
高級自定義標記
更復雜的標記中可以處理屬性和標記體。例如,現在的版權標記還需要許多復制/粘貼工作才能滿足需 求。我想像下面這樣將當前的行為放在真正可重用的標記內: <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 << "© ${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 Trip 或 grails generate-all Trip 時生成 GSP 的來源。
要定制默認搭建,請輸入 grails install-templates。這樣會在項目中加入新的 grails- app/src/templates 目錄。應該看到三個目錄,名為 artifacts、scaffolding 和 war。
artifacts 目錄容納各種 Groovy 類的模板: Controller、DomainClass、 TagLib,等等。例如,如 果想讓所有控制器都擴展一個抽象父類,那麼可以在這裡進行修改。全部新控制器都將基於修改過的模板 代碼(有些人會加入 def scaffold = @artifact.name@,這樣動態搭建就會成為所有控制器的默認行為 )。
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 使用戶體驗變得自然而有趣。