《Clojure Web开发实战》——第2章,第2.3节应用架构
本節書摘來自異步社區《Clojure Web開發實戰》一書中的第2章,第2.3節應用架構,作者[美]Dmitri Sotnikov,更多章節內容可以訪問云棲社區“異步社區”公眾號查看
2.3 應用架構
典型的Compojure開發Web程序方式可能不同于你之前使用的方式。多數框架偏好使用模型-視圖-控制器(MVC,model-view-controller)模式使用邏輯分離思想將視圖、控制、模式嚴格分開。這里,Compojure并沒有明確分離視圖和控制。
相反,我們為程序中每個路由創建了獨立的handler,這些handler用于處理來自客戶端的HTTP請求,Compojure正是以這種思路來分派任務的。handler驅動模型負責處理域邏輯。這種方法提供了一個徹底的域邏輯分離模式,并不牽涉應用程序的表示層,也沒有任何不必要的聯系。
盡管如此,Clojure的Web棧設計得還是比較靈活,它甚至允許你以任何喜好的方式來組織,如果你非要在程序中使用傳統MVC風格,也不會有什么麻煩。
僅通過幾個邏輯部件就能一覽典型應用(這是指我們前面做的那個留言簿程序的結構)。那我們再看看別的一些特性,多數應用被拆分為如下幾個方面。
? handler——此命名空間負責處理請求、響應。
? routes——路由涵蓋我們程序的核心內容,譬如維護讀取頁面和處理客戶端請求的邏輯關系。
? model——此命名空間保留給數據模型和持久化層。
? views——此命名空間包含通用邏輯以構成應用層。
程序的handler
handler是功能入口,它通常用于定義handler命名空間。它負責將程序的所有路由匯聚起來,并且定義所有的處理過程,用于封裝必要的中間件。
handler命名空間也為程序定義一些基礎路由,但不用于任何特定的工作流。我們留言簿程序中的那個handler,有兩條路由:一條用于處理靜態資源;還有一條用于捕獲其他所有路由都未定義的URI請求。
`(defroutes app-routes
(route/resources "/")
(route/not-found "Not Found"))`
路由里具體的工作流,比如在留言簿里發布和瀏覽消息的路由處理,都組織在與它們功能相關的特定命名空間里。每一條都供routes命名空間訪問。
handler命名空間也提供init和destroy方法,它們在程序起停時被調用。任何需要在始末階段調用的代碼,都要分別放在這兩個函數里面執行。
舉個例子說明吧,我們在留言簿程序里就用上了,init函數用來檢查數據庫連接是否可用。
`(defn init []
(println "guestbook is starting")
(if-not (.exists (java.io.File. "./db.sq3"))
(db/create-guestbook-table)))`
接下來,我們定義入口點,在調用app函數時,程序將開始處理所有路由請求。
(def app (handler/site (routes home-routes app-routes)))
這段代碼,compojure.handler/site函數用于生成Ring handler,用中間件支撐一個典型網站。
site函數僅僅創建一個handler,并將其封裝進一些通用中間件,來支持通用網站。中間件由如下封裝器構成。
? wrap-session。
? wrap-flash。
? wrap-cookies。
? wrap-multipart-params。
? wrap-params。
? wrap-nested-params。
? wrap-keyword-params。
在project.clj里,程序的handler、init函數、destroy函數,都綁定在:ring鍵下面,具體參見我們的留言簿程序(“第1章起步”)。
`:ring {:handler guestbook.handler/app
:init guestbook.handler/init
:destroy guestbook.handler/destroy}`
以上描述用于引導程序核心部分。接下來,我們一起看看怎樣添加一些別的路由,來滿足應用程序的具體功能。
路由請求
此前我們討論過,程序路由表現為URI,由客戶端請求,由服務端執行。客戶端請求的URI由路由程序對應的處理函數做相應回應。
現實當中沒有哪個應用只有一條路由。比如,在我們的留言簿程序中,有兩個獨立路由,各自執行不同的操作:
`guestbook/src/guestbook/routes/home.clj
(defroutes home-routes
(GET "/" [](home))
(POST "/" name message))`
第一條路由被綁定于/,用于從數據庫檢索消息,并用此消息創建一張表單,最終呈現整幅頁面給客戶端。
第二條路由會處理用戶輸入。如果輸入驗證通過,接下來這條消息就會被存入數據庫;否則,頁面將呈現錯誤描述。
其實這兩條路由功能有交集:存儲和顯示用戶信息,它們也算是同一工作流的兩個部分。
當你發現程序的工作流有明確所屬,那么可以將此工作流的邏輯關系合并,放在一起處理。程序中的routes包之下的命名空間正是為這種特殊工作流預留的。
由于我們的留言簿應用很小。除了在guestbook.routes.home命名空間里有幾個輔助函數,定義一套路由就夠用了。
當程序包含多個頁面,為便于維護代碼,我們會創建額外的命名空間。接下來我們用Compojure提供的routes宏,在每個獨立的命名空間下創建獨立的路由,并將處理放在handler命名空間。
routes宏可以將多個路由合并,最終創建handler。有一點要注意,路由之間存在覆蓋關系。由于我們的app-routes調用了(route/not-found "Not Found"),務必把它置為最后一條,否則在not-found路由后面的所有路由將被覆蓋。
應用模型
稍稍復雜一些的應用,都需要建立在某種模型之上。模型用于描述應用程序如何存儲數據、單個數據元素之間的內在關系。我們的留言簿程序模型由用戶表和消息表構成。
處理模型和持久層的所有命名空間,慣例上屬于models包。我們在下一章會用大篇幅重點講述。
應用視圖
views包用于為頁面提供可視布局和其他的通用控件,其下有預設的layout命名空間。這個命名空間為我們包含了common布局聲明,用于生成基礎頁面模板。
common布局用于填充頁面頭、填寫標題標簽、打包資源(如CSS)及添加負載內容。由于內容使用html5宏封裝,common布局被調用之后,將自動創建HTML文本串,這個處理直接將結果反饋給客戶端。
這種方式常用于創建通用布局,以及提供基本頁面結構,也使用它定義個別頁面。亦可創建通用頁面元素,比如頁眉、頁腳、菜單,并會得到統一維護。我們每次創建的頁面,都需要使用定義的布局簡單將內容包裹起來。
定義頁面
創建路由的同時也就定義了頁面,通過接受請求參數來生成各種特殊的響應,比如用來返回HTML元素,執行服務端操作,重定向到另一個頁面;或者返回特殊類型的數據,比如數據交換格式(JSON,JavaScript Object Notation)字符串或文件。
通常,一張頁面由多條路由組成。其中有一條接受GET請求,并返回HTML供瀏覽器渲染的路由。還有其他情況,比如在客戶端用戶與頁面交互時,生成并提交了表單,這時會有其他路由來處理此請求。
無論我們選擇如何處理,都能創建頁面,Compojure并不關心我們使用的具體方法,這恰好為選擇模板庫留有余地。可選的方案不少,這里介紹幾個流行的庫:Hiccup14、Enlive15、Selmer16、Stencil17。
Hiccup能使用原生Clojure數據結構,通過它定義表情并生成相適應的HTML;Enlive反其道而行,使用純HTML定義頁面而不用特殊處理標簽。適配器將特定模型和域變換為HTML模板。
與Hiccup和Enlive不一樣,Stencil和Selmer都是基于外部模板系統,而不是基于Clojure。Stencil是實現了Mustache(這是個流行的無邏輯模板系統),Selmer是模仿Django模板系統在Python上的實現。
本書重點關注并使用Hiccup,因為它不需要額外學習任何語法,直接使用Clojure函數即可。此外,我們在后面還會學習用Selmer模板來取代Hiccup創建的應用。
別的選擇徹底沒有考慮使用服務端模板,你需要在客戶端處理模板來接管這些工作,挑個流行的JavaScript庫,并使用Ajax與服務通訊。當然,這樣也能勝任。好處是這可以讓客戶端服務端的界限明確、清晰,有助于擴充其他形式的客戶端,比如移動應用接口。在編寫單頁應用18時,這還是通行手段。
無論你喜歡何種模板策略,最佳實踐都不會去聚合域邏輯和視圖。通過合理構架的程序,是可以輕松替換模板引擎的。
Hiccup處理模板化頁面
現在開始介紹一些Hiccup使用基礎,以及通過它如何生成適當的頁面元素。
剛才提到,用原生Clojure就能編寫Hiccup模板,所以你就不需要去學習特定領域語言(DLS,domain-specific language)就能駕馭它。
Hiccup用Clojure vector(向量表)表示HTML元素,其屬性使用map描述,這種結構表達方式與生成的HTML標簽在結構上比較吻合,示例如下。
`[:tag-name {:attribute-key "attribute value"} tag body]
attribute-key="attribute value">tag body`
如果我們想要創建一個包含圖片的div標簽,可以創建一個vector,第一個元素為:div關鍵字,緊隨其后是一個map(包含div ID和div的class)。余下部分是以vector表示圖片的內容構成。
[:div {:id "hello", :class "content"} [:p "Hello world!"]]
我們使用hiccup.core/html宏將vector轉換為HTML文本:
(html [:div {:id "hello", :class "content"} [:p "Hello world!"]])
Hello world!
由于Hiccup允許你通過map設置元素屬性,如有必要,你還可以使用元素內聯樣式。盡管如此,你還是應該抵御這種誘惑,使用CSS樣式化元素取代之,這可以確保結構和描述分離。
由于對元素設置ID和設置class是常用操作,Hiccup還提供便捷的CSS樣式化處理。我們可以如下簡化編寫我們的div,取代之前的代碼:
[:div#hello.content [:p "Hello world!"]]
Hiccup同樣提供一些輔助函數,用來定義常用元素,比如表單、鏈接、圖像。所有這些函數輸出的vector,由Hiccup預先定義的格式描述。
當一個函數在使用中并不能滿足需求時,你當然可以寫下元素的文本描述,還可以調整輸出來滿足需要。描述HTML元素的函數可以配置,其第一個參數可以接受可選屬性的map。我們再了解一些常用的Hiccup輔助函數,來改善使用體驗。
首先,我們來看看怎么用link-to輔助函數創建一個標簽:
(link-to {:align "left"} "http://google.com" "google")
這段代碼將生成以下vector:
[:a {:align "left", :href #http://google.com>} ("google")]
我們已有一個關鍵字:a作為第一項,緊隨其后的map表示屬性,以及表示內容的list。
還是如此,將link-to函數封裝在html宏里面,我們可以基于此vector輸出HTML:
(html (link-to {:align "left"} "http://google.com" "google"))
還有一個常用的函數form-to,用來生成HTML表單,我們用此函數實現上一章創建的表單,并將信息提交給服務端。
`(form-to [:post "/"]
[:p "Name:" (text-field "name")]
[:p "Message:" (text-area {:rows 10 :cols 40} "message")]
(submit-button "comment"))`
這個輔助函數接受一個vector,第一個元素是HTTP請求類型的關鍵字,第二個元素是URL字符串。余下參數也為vector,通過求值可以表示為HTML元素。當調用html宏后,前面的代碼會被轉化為以下HTML:
`
Name:
Message:
還有一個實用的輔助宏defhtml。我們在定義一個函數同時,通過參數內容悄悄生成HTML。這意味著在構造頁面時,我們不需要用html宏作用每一個獨立元素。
`(defhtml page [& body]
[:html
[:head
[:title "Welcome"]]
[:body body]])`
同樣,在hiccup.page命名空間里,Hiccup提供若干生成特定HTML變體的宏,比如HTML4、HTML5和XHTML。看,我們在留言簿程序里使用的就是html5宏。
`(defn common [& body]
(html5
[:head
[:title "Welcome to guestbook"]
(include-css "/css/screen.css")]
[:body body]))`
添加資源
現實中,大型網站的頁面必然涉及加載JavaScript和CSS。在hiccup.page 命名空間里,Hiccup提供幾個實用函數來達到這個目的。你可以使用include-css去引用任何CSS文件,include-js來加載JavaScript資源。這里有個在常用布局中包含CSS 和JavaScript資源的例子:
`(defn common [& content]
(html5
[:head
[:title "My App"]
(include-css "/css/mobile.css"
"/css/screen.css")
(include-js "//code.jquery.com/jquery-1.10.1.min.js"
"/js/uielements.js")]
[:body content]))`
如你所見,include-css和include-js都能接受多個字符串,每個參數指定一個URI資源。它們的輸出必然是一個Hiccupvector,最終會被轉換為HTML。
;;output of include-css
([:link
{:type "text/css", :href #, :rel "stylesheet"}]
[:link
{:type "text/css", :href #, :rel "stylesheet"}])
;;output of include-js
([:script
{:type "text/javascript",
:src
#}]
[:script {:type "text/javascript", :src #}])
同樣,在hiccup.element命名空間,Hiccup提供一個名為image的輔助函數去加載圖片:
(image "/img/test.jpg")
[:img {:src #}]
(image "/img/test.jpg" "alt text")
[:img {:src #, :alt "alt text"}]
Hiccup API一覽
你已經見識了一些常用的函數,其實還有一些更有用的。大多數輔助函數可以在element和form命名空間里找到。這些函數用于定義元素,比如圖像、鏈接、腳本標簽、復選框、下拉工具欄以及輸入欄。
如你所見,Hiccup提供一套簡明API去生成HTML模板,此外還有字面量vector表達式。既然你已經領悟到了Hiccup的精髓,那我們回過來對此前的留言簿程序進行更深入的剖析。
回顧留言簿程序
我們現在換個角度去看待那些定義在home命名空間的函數。當你試著運行程序,并來回瀏覽時,順便查閱頁面的HTML輸出和在代碼里的定義。
首先,我們用show-guests函數去生成一個無序清單。它遍歷數據庫的消息,然后為每一個消息創建一個列表項。
(defn show-guests []
[:ul.guests
(for [{:keys [message name timestamp]} (db/read-guests)]
[:li
[:blockquote message]
[:p "-" [:cite name]]
[:time (format-time timestamp)]])])
這里有個輔助函數,可以用于顯示格式化時間戳。此函數使用java.text.SimpleDate Format將日期對象轉化為格式化字符串。我們使用流化(->)宏去執行格式化器去格式化文本,接下來使用此方法處理從數據庫獲取的時間戳。
(defn format-time [timestamp]
(-> "dd/MM/yyyy"
(java.text.SimpleDateFormat.)
(.format timestamp)))
你可能已經發現目前的home函數編寫得有點復雜,因為它還有一些用來指導用戶提交表單的額外描述。
這里有一點值得一提:錯誤處理行的代碼用于顯示錯誤鍵值,由控制器填充,最終交由show-guests函數去呈現內容。
home函數使用layout/common封裝內容,為頁面生成HTML。
(defn home [& [name message error]]
(layout/common
[:h1 "Guestbook"]
[:p "Welcome to my guestbook"]
[:p error]
(show-guests)
[:hr]
(form-to [:post "/"]
[:p "Name:" (text-field "name" name)]
[:p "Message:" (text-area {:rows 10 :cols 40} "message" message)]
(submit-button "comment"))))
如你所見,僅需少許代碼,就能使用Hiccup創建頁面模板,同時也便于通過關聯模板定義生成輸出元素。
我們就此完成了路由定義,Compojure路由得以完善。
(defroutes home-routes
(GET "/" name message error)
(POST "/" name message))
到目前為止,我們已完成創建路由并由此呈現頁面,還能處理來自客戶端的請求表單。正如我們先前提到的,除了由Ring和Compojure提供的,真實的應用還需要添加一些別的元素。接下來,讓我們看看如何為我們的應用添加更多功能。
總結
以上是生活随笔為你收集整理的《Clojure Web开发实战》——第2章,第2.3节应用架构的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 组合(Combination)
- 下一篇: 运行时设置数据库连接字符串