本節書摘來自異步社群《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函數時,程式将開始處理所有路由請求。
<code>(def app (handler/site (routes home-routes app-routes)))</code>
這段代碼,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))
第一條路由被綁定于/,用于從資料庫檢索消息,并用此消息建立一張表單,最終呈現整幅頁面給用戶端。
第二條路由會處理使用者輸入。如果輸入驗證通過,接下來這條消息就會被存入資料庫;否則,頁面将呈現錯誤描述。
其實這兩條路由功能有交集:存儲和顯示使用者資訊,它們也算是同一工作流的兩個部分。
當你發現程式的工作流有明确所屬,那麼可以将此工作流的邏輯關系合并,放在一起處理。程式中的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表示圖檔的内容構成。
<code>[:div {:id "hello", :class "content"} [:p "hello world!"]]</code>
我們使用hiccup.core/html宏将vector轉換為html文本:
<code>(html [:div {:id "hello", :class "content"} [:p "hello world!"]])</code>
hello world!
由于hiccup允許你通過map設定元素屬性,如有必要,你還可以使用元素内聯樣式。盡管如此,你還是應該抵禦這種誘惑,使用css樣式化元素取代之,這可以確定結構和描述分離。
由于對元素設定id和設定class是常用操作,hiccup還提供便捷的css樣式化處理。我們可以如下簡化編寫我們的div,取代之前的代碼:
<code>[:div#hello.content [:p "hello world!"]]</code>
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"))
<a href="http://google.com">google</a>
還有一個常用的函數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]
[: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路由得以完善。
到目前為止,我們已完成建立路由并由此呈現頁面,還能處理來自用戶端的請求表單。正如我們先前提到的,除了由ring和compojure提供的,真實的應用還需要添加一些别的元素。接下來,讓我們看看如何為我們的應用添加更多功能。