天天看點

《Clojure Web開發實戰》——第2章,第2.4節Compojure和Ring之後

本節書摘來自異步社群《clojure web開發實戰》一書中的第2章,第2.4節compojure和ring之後,作者[美]dmitri sotnikov,更多章節内容可以通路雲栖社群“異步社群”公衆号檢視

2.4 compojure和ring之後

不少程式庫能有效應對各種處理任務,比如會話管理、輸入驗證、身份認證。你依舊可以随意挑揀适合你的部件。

我們選擇lib-noir19作為接下來的關注重點,因為應對web程式的絕大多數任務,它都能勝任。我們之前通過介紹hiccup的api,學習了它的一些特性及常見功能,同樣,我們也來看看lib-noir是如何用的。

首先,為了能啟用lib-noir,我們需将其添入項目描述檔案project.clj。具體是在依賴項的vector裡添加[lib-noir "0.7.6"]。

如果你的項目還正運作着,你務必先重新開機應用,讓依賴項生效。接下來,我們再看看如何使用lib-noir為應用添加功能。

處理重定向

有些情況下,在執行某些操作之後,我們需要刻意将頁面跳轉到别的頁面。比如,使用者在注冊頁面完成賬戶注冊之後,需要将使用者重定向到首頁。

既然要實作使用者注冊,我們就先添加一個注冊頁吧。第一步,建立一個命名空間,名為guestbook.routes.auth。與home命名空間的處理一樣,需要引用其他的命名空間:

`(ns guestbook.routes.auth

 (:require [compojure.core :refer [defroutes get post]]

      [guestbook.views.layout :as layout]

      [hiccup.form :refer

       [form-to label text-field password-field submit-button]]))`

這個函數用于為我們呈現頁面,并會為展示給使用者一個表單,用于引導使用者輸入id和密碼。

`(defn registration-page []

 (layout/common

  (form-to [:post "/register"]

       (label "id" "screen name")

       (text-field "id")

       [:br]

       (label "pass" "password")

       (password-field "pass")

       (label "pass1" "retype password")

       (password-field "pass1")

       (submit-button "create account"))))`

看得出來,函數内部的表達方式有點累贅,每一個輸入需要一個标簽,然後還得添加一個換行。好在hiccup使用标準clojure資料結構表述,我們可以提取重複元素,抽象并構造一個輔助函數:

`(defn control [field name text]

 (list (label name text)

    (field name)

    [:br]))`

   (control text-field :id "screen name")

   (control password-field :pass "password")

   (control password-field :pass1 "retype password")

   (submit-button "create account"))))`

平時,我們會用一個vector來直接表述,但這次建立的函數使用list函數來包裝。這是因為hiccup使用vector來表達html标簽,但是标簽内容并不能用vector來表達。

既然已經建立了新頁面,同時也要考慮為其增加一條對應的路由。這裡,将路由處理封裝到名為auth-routes的函數中:

`(defroutes auth-routes

上面的函數形參vector中使用了下劃線(_),用在被執行的函數不使用此參數時,這種表達方式是clojure約定俗成的用法。

由于我們已經建立了一條新路由,同樣,我們也需要去更新我們的程式處理。我們需要在handler命名空間中引用這個新命名空間,同時為我們的程式添加路由,具體如下:

`(ns guestbook.handler

 ...

 (:require ...

  [guestbook.routes.auth :refer [auth-routes]]))`

...

`(def app

 (handler/site

  (routes auth-routes home-routes app-routes)))`

注意,因為路由中使用了(route/not-found "not found"),這條路由會覆寫所有定義在此之後的其他路由,新路由應該添加在app-routes前段。

如果你已經在repl中運作着站點,那麼你需要重新開機,讓新的路由生效。

在成功注冊之後,處理會将使用者重定向到home頁。重定向是個簡單的map,包含狀态、頭、消息體:

<code>{:status 302, :headers {"location" "/"}, :body ""}</code>

ring在ring.util.response命名空間中提供了重定向功能。由于我們已經啟用了lib-noir,使用noir.response/redirect取代之。lib-noir允許使用操作關鍵字表達重定向狀态碼。預設是:found,對應的重定向狀态碼是302。

我們需要引用這個命名空間才能通路它,将其添加到auth命名空間的:require表中。

      [noir.response :refer [redirect]]))`

現在我們可以在auth-routes定義中添加我們的handler。此刻,我們對輸入密碼做簡單比對檢查判定,成功則重定向到home頁,否則,我們重新整理此頁。

 (get "/register" [](registration-page))

 (post "/register" [id pass pass1]

    (if (= pass pass1)

     (redirect "/")

     (registration-page))))`

管理會話

在使用者與程式互動過程中,我們需要以某種途徑去記錄使用者會話狀态。所幸lib-noir在noir.session命名空間已提供了一套管理會話的方法。将用戶端會話表示為一個map用于記錄,使用如下輔助函數來處理:

• clear! ——清除會話一切内容。

• flash-put ——将一個值儲存入檢索表。

• flash-get —— 取回一個值并清除之。

• get —— 從會話擷取一個值。

• put! —— 将一個值存入會話。

• remove! ——從會話删除一個值。

函數名字尾使用感歎号(!),說明此舉會改變會話狀态,這種通過在函數名上增加符号來表達操作的表示方式,是clojure約定俗成的。讓我們看個例子——實作login和logout頁面,每個動作将對會話做對應更新。

使用lib-noir會話的同時,我們會封裝app handler來通路會話中間件。由于标準處理并不關心會話,也并不在請求之間提供方法去持有狀态,是以這種處理是有必要的。

中間件要求我們自己提供儲存方式,這樣會話狀态将會得到持久化處理。可以使用redis20存于記憶體或備份至外部存儲。

在我們的應用中,我們簡單使用ring.middleware.session.memory/memory-store來說明。首先在每個中間件和存儲處理都要聲明引用此命名空間。

  [noir.session :as session]

  [ring.middleware.session.memory

   :refer [memory-store]]))`

下一步,我們将使用會話中間件封裝我們的應用。wrap-noir-session中間件接受一個包含:store鍵的map參數。我們綁定此鍵到memory-store:

 (-&gt;

  (handler/site

   (routes auth-routes

       home-routes

       app-routes))

  (session/wrap-noir-session

   {:store (memory-store)})))`

現在我們看到的内容涉及建立登入頁面并将使用者添加到會話。我們打開auth命名空間,将如下函數添加入内:

`(defn login-page []

  (form-to [:post "/login"]

   (control text-field :id "screen name")

   (control password-field :pass "password")

   (submit-button "login"))))`

此函數建立一個包含使用者id和密碼的登入表單,并使用通用布局封裝。當使用者點選送出按鈕,表單會将一個http發送給/login uri。

我們現在更新這個路由定義,為程式建立一個get和post的/login路由。為使其正常工作,我們同樣需要在路由頁面引用noir.session。

      [noir.session :as session]))`

     (registration-page)))

 (get "/login" [](login-page))

 (post "/login" [id pass]

     (session/put! :user id)

     (redirect "/")))`

get login路由簡單調用login-page函數去顯示頁面。在重定向到home頁面之前,post login路由使用noir.session/put!函數和:user鍵将使用者添加到會話。現在我們将浏覽器定位到/login頁面,試試新添加的功能。

對于會話中的那個使用者,在我們的home函數構造頁面的同時,可以調用(session/get :user)來檢視,這樣就能在更新home頁面的同時顯示使用者id。此舉須先在home命名空間聲明處放置noir.session的包含引用。

`(ns guestbook.routes.home

 (:require ... [noir.session :as session])`

`guestbook-with-auth/src/guestbook/routes/home.clj

(defn home [&amp; [name message error]]

  [:h1 "guestbook " (session/get :user)]

  [: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)]`

       <code>(submit-button "comment"))))</code>

下一步,我們在建立登出頁面時調用noir.session/clear!。當使用者單擊退出按鈕,接下來将會清除此使用者在會話中積累的一切資訊。

     (registration-page)))`

 `(get "/login" [](login-page))

    (session/put! :user id)

    (redirect "/"))

 (get "/logout" []

     (layout/common

      (form-to [:post "/logout"]

       (submit-button "logout"))))

 (post "/logout" []

    (session/clear!)

    (redirect "/")))`

切記,session命名空間必須在請求上下文時通路,這意味着不能在路由聲明之外使用。

處理輸入驗證

當建立表單時,我們需要某種途徑去檢查填寫正确與否,并且還需要通知使用者關于填寫遺漏或項缺失。到目前為止,我們僅簡單在參數中填充錯誤鍵并顯示在頁面上。

還是使用類似的辦法,我們使用cond實作決策處理:顯示有錯誤描述的登入頁面,或者将使用者添進會話并重定向頁面:

`(defn login-page [&amp; [error]]

  (if error [:div.error "login error: " error])

  (form-to [:post "/login"]

   (submit-button "login"))))`

`(defn handle-login [id pass]

 (cond

  (empty? id)

  (login-page "screen name is required")

  (empty? pass)

  (login-page "password is required")

  (and (= "foo" id) (= "bar" pass))

  (do

   (session/put! :user id)

   (redirect "/"))`

  `:else

  (login-page "authentication failed")))`

下一步,我們更新post /login路由,使用handle-login函數作為handler去處理。

`(post "/login" [id pass]

 (handle-login id pass))`

盡管這種方式簡單、可用,為了擴充更多規則,很快就會變得乏味。正好lib-noir提供了noir.validation命名空間,可以使用優雅的方式去處理輸入驗證。我們在auth命名空間引用它,見識一下它如何改善我們的驗證處理。

       [noir.validation

       :refer [rule errors? has-value? on-error]])`

對于使用驗證函數,我們一樣需要将handler封裝到wrap- noir-validation中間件。這裡需要引用noir.validation:

      [noir.validation

       :refer [wrap-noir-validation]]))`

<code>guestbook-with-auth/src/guestbook/handler.clj</code>

   (routes auth-routes

       home-routes

       app-routes))

   (wrap-base-url)

   (session/wrap-noir-session

    {:store (memory-store)})

   (wrap-noir-validation)))`

順便說一聲,如果你正運作着repl,現在你需要通過重新加載程式來重編譯路由。

這裡有個noir.validation/rule輔助函數,可以取代cond來實作決策。每個規則都對内容判定,檢查各自是否能通過。最後,函數會調用noir.validation/errors?去檢查規則中是否産生錯誤。如果有,我們就顯示登入頁面;否則我們将使用者記錄到會話,并重定向到home頁面。

 (rule (has-value? id)

    [:id "screen name is required"])

 (rule (= id "foo")

    [:id "unknown user"])

 (rule (has-value? pass)

    [:pass "password is required"])

 (rule (= pass "bar")

    [:pass "invalid password"])`

 `(if (errors? :id :pass)

  (login-page)`

  `(do

   (redirect "/"))))`

我們按如下格式建立規則:

<code>(rule validator [:field-name "error message"])</code>

驗證器可以表達為任何形式,隻要最終傳回布爾值即可。也可以為每個鍵設定多重錯誤,這些錯誤會被彙集到一個vector。當驗證器傳回false,将生成錯誤。

例如,我們寫下(= id "foo"),id的值隻要不是foo,就會生成錯誤。

我們這裡為每一個項分别提供一個錯誤處理。其實可以建立一個輔助函數,用于将它們彙集起來,并統一為展示錯誤内容做進一步處理。

<code>guestbook-with-auth/src/guestbook/routes/auth.clj</code>

`(defn format-error [[error]]

 [:p.error error])`

我們現在更新control函數,在調用on-error時,傳入控制名。這便實作了錯誤彙聚,對提供的鍵名使用format-error格式化。

`guestbook-with-auth/src/guestbook/routes/auth.clj

(defn control [field name text]

 (list (on-error name format-error)

    (label name text)

由于我們不再需要将錯誤定向到login-page,我們更新對應内容。

總而言之,我們可以在需要驗證的任何地方建立規則。每個規則會考察、判定此處是否合法。如果此處驗證失敗,就會生成錯誤内容并通過on-error輔助函數呈現給使用者。

我們之是以可以這樣做,是因為驗證錯誤一定是目前的請求帶來的。由于調用的這個函數為目前的請求負責處理和展現結果,是以它也應當處理對應的錯誤。

添加安全機制

lib-noir同樣提供便捷途徑去處理hash,并使用noir.util.crypt驗證密碼。這個命名空間提供兩個名為encrypt 和compare的函數。前者用于密碼加密、加鹽(salts),後者用于對比明文密碼和由前者生成的hash字元串。實際上,内部具體使用的是流行的jbcrypt庫21處理的加密。

使用compare函數去驗證看起來是這樣:

<code>(compare raw encrypted)</code>

encrypt函數允許指定加鹽,也生成并提供一個不加鹽的版本。

`(encrypt salt raw)

(encrypt raw)`

我們之是以對密碼加鹽,是為了對抗彩虹表 (rainbow-table)22的攻擊。彩虹表其實是預先将很多常見密碼通過哈希計算生成的字典。此表是通過優化提高哈希查找效率,并且允許攻擊者容易通過給定的哈希值來擷取密碼原文。而加鹽操作是為密碼追加随機内容再進行哈希,最終生成的哈希便不再容易被破解。

這裡,我們同樣需要在auth命名空間中添加引用:

      [noir.util.crypt :as crypt])`

至此,我們已經将使用者狀态儲存在會話記錄中。接下來,我們再看看當使用者注冊到站點時,如何固化使用者詳細資訊。首先,我們在db命名空間下添加幾個函數,用于通路資料庫:實作一個寫操作函數去添加使用者,一個讀操作函數檢索使用者。

`guestbook-with-auth/src/guestbook/models/db.clj

(defn create-user-table []

 (sql/with-connection

  db

  (sql/create-table

   :users

   [:id "varchar(20) primary key"]

   [:pass "varchar(100)"])))`

`(defn add-user-record [user]

 (sql/with-connection db

  (sql/insert-record :users user)))`

`(defn get-user [id]

  (sql/with-query-results

完成這些之後,我們需要重新加載db命名空間,使得新的函數生效,然後在repl控制台運作(create-user-table)。

我們現在可以切換到auth命名空間,開始編寫handle-registration函數。記住,我們一樣也要在db命名空間聲明引用。

 (:require ... [guestbook.models.db :as db]))`

(defn handle-registration [id pass pass1]

 (rule (= pass pass1)

    [:pass "password was not retyped correctly"])

 (if (errors? :pass)

  (registration-page)

   (db/add-user-record {:id id :pass (crypt/encrypt pass)})

   (redirect "/login"))))

更新post /register 路由,這些功能在被調用時将會生效。

(post "/register" [id pass pass1]

   (handle-registration id pass pass1))`

接下來,當一個使用者試圖登入時,我們會在登入處理函數中檢查其授權。

(defn handle-login [id pass]

 (let [user (db/get-user id)]

  (rule (has-value? id)

     [:id "screen name is required"])

  (rule (has-value? pass)

     [:pass "password is required"])

  (rule (and user (crypt/compare pass (:pass user)))

     [:pass "invalid password"])

  (if (errors? :id :pass)

  (login-page)

   (redirect "/")))))`

我們使用crypt/compare函數去比對此時提供的密碼和其在注冊中建立的哈希版本。

指定mime類型

出于一些原因,我們可能會希望明确指定負載内容的類型,比如純文字、json等。我們可以通過簡單封裝noir.response命名空間下的content-type函數實作。

`(get "/records" []

 (noir.response/content-type "text/plain" "some plain text"))`

noir.response命名空間下有用于處理json和xml的輔助函數。比如json響應,就是将内建資料結構自動轉換為json字元串。

`(get "/get-message" []

 (noir.response/json {:message "everything went better than expected!"})`

這個回應輔助函數非常實用,用于應對用戶端發起的ajax請求。

noir api一覽

我們已經說過了,lib-noir提供非常多的實用特性。

cookies命名空間提供的函數用于讀寫cookie;io命名空間提供的函數可用于通路靜态資源,并且也能處理檔案上傳;cache命名空間提供内容緩存的基礎件;middleware命名空間提供數個輔助函數去建立通用類型的程式handler和封裝;最後,route命名空間提供一個函數去建立受限路由。這有助于限制頁面通路,我們放在“第5章 相冊”來讨論這些内容。