外觀和程式完全分離的框架 -- Lift Web Framework 模版簡介。

上一篇稍微簡介了一下如何用 Lift 寫出不用 JavaScript 的 Ajax 網頁,不過聽說 ZK、GWT 這些東西也做得到,是我太大驚小怪了(誰叫我幾百年沒寫網頁程式了嘛)。

回到正題,上一篇有提到要了解 Lift 的話,要從 MVC 和物件導向的想法跳脫出來,特別是 Lift 針對網站外觀模版的部份,和其他的框架可以說是相當的不同--他是真正的強制你外觀裡面不能包含任何的邏輯,而且他的模版是標準的 XHTML 文件!而正是這個不同,讓我注意到這個框架。

簡單地來講,Lift 針對網站的架構和外觀,以下面這三點為核心原則:

  • View-first 的概念,而不是傳統的 MVC 模式
  • 強制在控制外觀的模版裡,不能有任何一丁點的程式邏輯在
  • 引入 Functional Programming 那種以『轉換』為主體的想法

昨天已經看過了,我們『文學少女點心箱』首頁的 Lift 模版實際上就是一個標準並且單純的 XHTML 靜態網頁,但裡面有一些東西上一篇沒有講得很清楚,所以這一篇就來介紹一下 Lift 神奇的模版功能吧!程式碼一樣在 GitHub 上可以找到。

首先,我們從 src/main/scala/bootstrap/Boot.scala 下手,替我們的網站加上一頁 CSS Selector 的頁面,這個頁面對應到的是 /test 這個網址。

class Boot {
    def boot {

        // 要找相關的程式的話,到 code 這個 package 找
        LiftRules.addToPackages("code")

        // 整個網站只有 Home 一頁,這頁會顯示在左邊的 Menu 上
        val entries = Menu.i("Home") / "index" ::
                      Menu.i("CSS Selector") / "test" :: Nil

        LiftRules.setSiteMap(SiteMap(entries:_*))

        // 設定 Ajax 執行時的通知圖示
        LiftRules.ajaxStart = Full(() => LiftRules.jsArtifacts.show("ajax-loader").cmd)
        LiftRules.ajaxEnd = Full(() => LiftRules.jsArtifacts.hide("ajax-loader").cmd)

        // 強制所有 Request 的編碼是 UTF-8
        // 可有可無,加上可以防止被系服器預設的 JVM 編碼擾亂
        LiftRules.early.append(_.setCharacterEncoding("UTF-8"))
    }
}

接著,我們建立 src/main/webapps/test.html 這個網頁,內容如下:

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" 
          "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
  <head>
    <meta http-equiv="Content-Type" content="text/html;charset=utf-8" />
    <title>遠子學姐的點心箱</title>
  </head>
  <body class="lift:content_id=main">
    <div>你看不到我、你看不到我、你看不到我……這是特異功能的最高境界!</div>
    <div id="main" class="lift:surround?with=default;at=content">
        <div class="lift:SimpleSnippet.currentTime">
            心葉,現在已經 <span id="timeInHour">4</span> 點了耶,快點寫些什麼給我吃嘛!
        </div>

        <table class="lift:CSSSelectorExample.renderTable?sort=acc">
            <tr>
                <th>標題</th>
                <th>分數</th>
            </tr>
            <tr id="dataRow">
                <td><i name="title">這是測試</i></td>
                <td><i name="score">100</i></td>
            </tr>
        </table>

        <ul class="lift:CSSSelectorExample.renderList">
            <li>這是第一個故事</li>
            <li class="clearable">這是第二個故事</li>
            <li class="clearable">這是第三個故事</li>
            <li class="clearable">這是第四個故事</li>
        </ul>
    </div>
  </body>
</html>

你可以發現,這個檔案也是個標準的 HTML 網頁,唯一特別的地方就是某些 HTML 標籤裡有看起來很奇怪的 class 屬性宣告,接著我們來看一下實際上執行的結果會是什麼樣子。

點下左邊選單的 CSS Selector 之後,你應該會看到和首頁一樣的來自遠子學姐的招呼語,還有一個三題故事以及分數的表格,最後是一個 HTML 的 <ul> 列表,內容是所有三題故事的標題。

當然,上面的招呼語、表格和 ul 列表,都是可以在 test.html 這個檔案裡看到的,雖然說內容有一點點不同(像是檔案裡固定是 4 點,表格只有一列、ul 列表裡的東西和網頁上顯示的不一樣),但至少可以看出來這部份應該是從 test.html 這個檔案來的。

但好玩的問題就接著來了--首先,我們的檔案裡明明有一串是『你看不到我、你看不到我、你看不到我』的 <div> 標籤,為什麼沒有顯示出了呢?

答案就藏在 <body> 這個標籤的 class 宣告,當 Lift 看到你的 <body> 的 class 屬性是 lift:content_id="main" 的時候,他會知道這個模版實際上要顯示的東西,只有 HTML 標籤的 id 是 main 的節點而已。

透過這個方式,美術就可以做出完全符合 XHTML 格式的模版,但又不會把重覆的 HTML 標籤顯示給使用者。

但問題還有一個--我們現在知道這頁顯示的時候,實際上只顯示第十行的 <div> 標籤和他的子節點,那我們剛剛從伺服器上看到的 HTML 標頭宣告、左邊的選單、正上方的『遠子學姐的點心箱』的標題,還有最下面的版權宣告頁尾又是在哪裡呢?我們的 test.html 檔案裡沒有這樣的東西啊?!

答案一樣藏在第十行的 <div> 標籤上的 class 屬性,你會發現他是 "lift:surround?with=default;at=content",這個屬性代表的意思是這樣的:把我這個節點的內容,移花接木到 /src/webapps/template-hidden/default.html 這個檔案裡面,id=content 的 HTML 節點。

所以如果你打開 /src/webapps/template-hidden/default.html 就可以看到這些頁首頁尾還有選單的部份。

你會發現在這個檔案的最開頭,定義了我們網站的 CSS 樣式表,中間有一個 <div> 區塊裡面有個 class="lift:Menu.builder" 的 span 標籤,看起來就像是說來產生網站的選單,然後最下面有網站的 copyright 標註。

好啦!解密完啦,似乎沒想像中的難,而且好像也滿合理的--大部份的網站,選單和頁首頁尾都是固定的,實際上會變的只有內容的部份,我們當然希望如果要改頁首頁尾的話,只需要改一個檔案就好了啊。

接著我們來看一下遠子學姐的招呼語的部份,這個部份他會顯示現在是幾點鐘的訊息,這又是怎麼做到的呢?很簡單,這叫 Snippet,你看到的那些奇怪的 class 屬性,實際上就是在告訴 Lift 要去找哪個 Snippet 來用。

所以,在招呼叫的 <div> 標籤上我們加上了 class="lift:SimpleSnippet.currentTime" 的屬性,就是告訴 Lift 去找 SimpleSnippet 這個類別(或 Singleton 物件)的 currentTime 這個 Snippet 函式來用。

那到底什麼是 Snippet 函式呢?回到一開頭的 Lift 哲學第三點--Snippet 實際上就是『轉換』的函式,型態是 NodeSeq => NodeSeq,也就是把一串的 XML 轉成另一串的 XML。

再加上我們已經知道了 Lift 的模版本身就是符合標準的 XHTML 文件,然後 XHTML 又是合法的 XML 文件,所以我們當然就可以把 <div> 的子節點丟到這個函式裡,得出另一串 XHTML 後把他塞回原本的模版裡。

那這個函式要怎麼寫呢?當然我們可以土法練鋼,自己寫一個 NodeSeq => NodeSeq 的函式,然後自己處理被傳進來的 XHTML 節點(例如直接忽略他,重新給一個亂七八糟的 XHTML 節點),像下面一樣:

class SimpleSnippet
{
    /**
     *  處理模版裡 <div class="lift:AjaxExample.currentTime"> 的部份
     */
    def currentTime(xhtml: NodeSeq): NodeSeq = {
        val dateFormatter = new SimpleDateFormat("h")
        dateFormatter.setTimeZone(TimeZone.getTimeZone("Asia/Taipei"))
        val timeInHour = dateFormatter.format(new Date)

        <h1>現在是 {timeInHour} 點囉!心葉快點寫些什麼給我嘛!</h1>
    }   
}

不過很明顯的,這樣的做法很麻煩,而且雖然我們的模版裡沒有程式碼,但控制外觀的東西卻入侵到程式碼中了,似乎不是那麼地完美。

所以在 2.3 版之後,Lift 加入了一個叫做 CSS Selector 的東西來幫助我們,這東西的基本概念還是沒變的--將某串 XHTML 轉換成另一串的 XHTML,只是他可以幫助我們將轉換的規則以 CSS 的概念表達出來而已。

所以,現在我們可以將這個 Snippet 寫成像下面這樣:

class SimpleSnippet
{
    /**
     *  處理模版裡 <div class="lift:AjaxExample.currentTime"> 的部份
     */
    def currentTime = {
        val dateFormatter = new SimpleDateFormat("h")
        dateFormatter.setTimeZone(TimeZone.getTimeZone("Asia/Taipei"))
        val timeInHour = dateFormatter.format(new Date)

        // 把模版裡 id=timeInHour 的 XHTML 元素用 timeInHour 變數取代掉
        "id=timeInHour" #> timeInHour
    }
}

裡面的重點只有最後一行的 "id=timeInHour" #> timeInHour 而已,這是說把傳進來的 <div> 子節點裡面,節點的 id 屬性是 timeInHour 的節點,取代成後面的 timeInHour 變數,因為是把整個節點取代掉,所以如果你去看伺服器吐回來的 HTML 的話,你會發現 span 標籤消失了,只有數字而已。

看起來還不錯,不過上面是很單純地把某個節點替換成另一個節點,但很多時候事情沒那麼簡單啊,像是 JSTL 就提供了 c:ForEach 這個東西,讓你可以用迴圈來做表格。如果我們的模版沒辦法用這樣的東西的話,要怎麼做表格或是 <ul> 列表這種東西呢?

其實很簡單的,我們先來看一下 <ul> 的部份,在我們的 test.html模版裡第二十六行的部份定義了這個 <ul>,裡面有四個元素。

然後再來看一下我們的 CSSSelector.renderList 的定義:

object CSSSelectorExample
{
    case class ArticleScore(title: String, score: Int)

    val articles = List(
        ArticleScore("開學典禮、自我介紹、熊貓", 50),
        ArticleScore("窗戶、袋鼠、計算機", 100),
        ArticleScore("醬油丸子、廟會、洗碗機", 55)
    )


    def renderList = {
        ClearClearable &
        "li *" #> articles.map(_.title) 
    }
}

很簡單的程式,只有兩行。第一行的 ClearClearable 是 Lift 給我們的一個輔助函式,他可以幫你把 class 屬性是 clearable 的 HTML 標籤給移除,所以美術在做網頁外觀時可以放多個 li 來看顯示出來的效果,但這些東西又不會影響到實際上的呈現。

經過了 ClearClearable 這個規則後,我們實際上得到的東西就是 <li>這是第一個故事</li> 的標籤了,然後接下來我們再針對這個標籤做一些處理。

我們這裡的規則寫的是 "li *",代表找出所有的 li 標籤,並且把 li 標籤的子節點(這裡就是『這是第一個故事』這串字),用 #> 後面的東西取代掉。

那 #> 後面可以接什麼東西呢?有兩種選擇,如果是單純的 String 的話,那就是把『這是第一個故事』這串字用新的 String 取代掉,如果是一個 Iteratable[String] 的話,那他就會把這個 li 標籤重覆 N 次,並且每個 li 標籤的值對應到 Iteratable[String] 裡的每個元素。

所以上面的程式碼就很清楚了吧?我們先把用 map(_.title) 取出所有的故事標題,讓他變成 List("開學典禮、自我介紹、熊貓", "窗戶、袋鼠、計算機", "醬油丸子、廟會、洗碗機") 這東西。

然後把這一串丟給 "li *" 的話,Lift 就會再把他變成 List("<li>開學典禮、自我介紹、熊貓</li>", "<li>窗戶、袋鼠、計算機</li>", "<li>醬油丸子、廟會、洗碗機</li>") 這東西。

最後,再把這一串合起來後丟回到原來的 <ul> 標籤之下,成為我們現在看到的樣子。

看起來好像很複雜,但只要明白了這就是所謂的『轉換、轉換、再轉換』之後,就會發現其實是非常好懂的,而且你的模版裡根本就用不到所謂的迴圈,也能達成相同的功能。

接下來,我們再來看看另一種型式的迴圈和轉換,這次我們是要把三題故事和遠子學姐給的分數放在表格裡,表格的定義在 test.html 的第 15 行。

我們建了一個表格,他的 class 屬性是 lift:CSSSelectorExample.renderTable,現在大家應該知道這是說去找 renderTable 這個函式,並且把子節點(在這裡就是兩個 tr 標籤)丟給他,讓他做一些轉換後丟回來。

在這個表格裡,我們定義了一個 tr 標籤有著 id=dataRow 的屬性,然後這個 row 裡有兩個 <i> 的斜體標籤,他們的 name 屬性分別是 title 和 score。

然後我們的 renderTable 函式長得像下面這樣:

object CSSSelectorExample
{
    case class ArticleScore(title: String, score: Int)

    val articles = List(
        ArticleScore("開學典禮、自我介紹、熊貓", 50),
        ArticleScore("窗戶、袋鼠、計算機", 100),
        ArticleScore("醬油丸子、廟會、洗碗機", 55)
    )

    def renderTable = {

        val sortedArticles = articles.sortWith(_.score > _.score)

        // #dataRow 可以寫成 id=dataRow
        // @title 可以寫成 name=title
        // @score 可以寫成 name=score
        "#dataRow" #> sortedArticles.map { article =>
            "@title *" #> article.title &
            "@score" #> article.score
        }
    }
}

實際上的重點只有三行,第一行是將所有故事依照分數來排序,產生一個新的 List 物件,然後接下來顯示表格的時候,會用這個物件來顯示,這樣一來我們顯示出來的結果就會是分數由高到低。

現在我們就來看最後一個 sortedArticles.map() 函式在搞什麼鬼唄。

雖然看起來像外星文,其實沒有很難,這個函式是說:把 sortedArticles 裡的每一個元素對應到一個 CSS Selector 轉換規則,而我們這邊的轉換規則有以下兩條:

  • 把 name 屬性是 title 的節點的子節點(內容),設成 article.title
  • 把 name 屬性是 score 的節點,用 article.score 取代掉

所以這個步驟做完後,我們實際上得到的是一個有三個轉換規則(因為有三篇故事)的 List,然後剛剛說過了,如果 #> 收到的是一個 List,會再把他組合起來,所以我們看到的就會是完整的表格啦。

另外,你會看到故事標題的地方是斜體,這是因為在設定 title 的地方,我們使用了 "@title *",加上 * 號代表只取代內容,所以 <i> 標籤還是會保留,而設定 score 的地方,則是直接把 <i> 標籤用 score 取代掉了。

順道一提,這裡的 #dataRow 和 @title 還有 @score 是簡寫,你可以直接用 xxx=yyy 這樣的方式來指定你要選擇的模版裡的元素,例如你可以用 "title=mytext" 來選擇一個 <a title="mytext" /> 的超鏈結,然後設定他的內容這樣。

以上就是整個 Snippet 和 Lift 模版的基本概念。

我們先把原來的 List 用分數做排序,得到過一個排序過的 List,再把排序過後的 List 轉成一連串的 CSS Selector 規則,最後再交由 Lift 幫我們把一連串的 XHTML 節點經由這些規則轉成另外一串 XHTML 節點。

Volia!最後的結果就這樣生出來了,我們成功地把內部的資料結構轉成使用者看到的 HTML 表格,而且在過程中將屬於邏輯的東西保留在程式碼裡,而模版維持乾乾淨淨的 HTML 檔案。

這就是 Lift 的模版和 CSS Selector 系統,看起來很簡單,很不起眼,但說實在如果曾經手動刻過 PHP/JSP 這種 View 和程式邏輯大雜燴的人,應該會很感激 Lift 給了這種可以完全把外觀和程式碼分離的模版引擎啊!

回響