本节书摘来自异步社区《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")
[:br]
(label "pass1" "retype password")
(password-field "pass1")
[:br]
(submit-button "create account"))))`
看得出来,函数内部的表达方式有点累赘,每一个输入需要一个标签,然后还得添加一个换行。好在Hiccup使用标准Clojure数据结构表述,我们可以提取重复元素,抽象并构造一个辅助函数:
`(defn control [field name text]
(list (label name text)
(field name)
[:br]))`
`(defn registration-page []
(layout/common
(form-to [:post "/register"]
(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
(GET "/register" _))`
上面的函数形参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中运行着站点,那么你需要重启,让新的路由生效。
网站重启之后,则需要导航至http://localhost:3000/register确认页面能否正确加载。如果一切顺利,你现在就可以为注册页面添加处理了。
在成功注册之后,处理会将用户重定向到home页。重定向是个简单的map,包含状态、头、消息体:{:status 302, :headers {"Location" "/"}, :body ""}
Ring在ring.util.response命名空间中提供了重定向功能。由于我们已经启用了lib-noir,使用noir.response/redirect取代之。lib-noir允许使用操作关键字表达重定向状态码。默认是:found,对应的重定向状态码是302。
我们需要引用这个命名空间才能访问它,将其添加到auth命名空间的:require表中。
`(ns guestbook.routes.auth
(:require ...
[noir.response :refer [redirect]]))`
现在我们可以在auth-routes定义中添加我们的handler。此刻,我们对输入密码做简单匹配检查判定,成功则重定向到home页,否则,我们刷新此页。
`(defroutes auth-routes
(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来说明。首先在每个中间件和存储处理都要声明引用此命名空间。
`(ns guestbook.handler
...
(:require ...
[noir.session :as session]
[ring.middleware.session.memory
:refer [memory-store]]))`
下一步,我们将使用会话中间件封装我们的应用。wrap-noir-session中间件接受一个包含:store键的map参数。我们绑定此键到memory-store:
`(def app
(->
(handler/site
(routes auth-routes
home-routes
app-routes))
(session/wrap-noir-session
{:store (memory-store)})))`
现在我们看到的内容涉及创建登录页面并将用户添加到会话。我们打开auth命名空间,将如下函数添加入内:
`(defn login-page []
(layout/common
(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。
`(ns guestbook.routes.auth
(:require ...
[noir.session :as session]))`
...
`(defroutes auth-routes
(GET "/register" [](registration-page))
(POST "/register" [id pass pass1]
(if (= pass pass1)
(redirect "/")
(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 [& [name message error]]
(layout/common
[: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)]`
(submit-button "comment"))))
下一步,我们在创建注销页面时调用noir.session/clear!。当用户单击退出按钮,接下来将会清除此用户在会话中积累的一切信息。
`(defroutes auth-routes
(GET "/register" [](registration-page))
(POST "/register" [id pass pass1]
(if (= pass pass1)
(redirect "/")
(registration-page)))`
`(GET "/login" [](login-page))
(POST "/login" [id pass]
(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 [& [error]]
(layout/common
(if error [:div.error "Login error: " error])
(form-to [:post "/login"]
(control text-field :id "screen name")
(control password-field :pass "Password")
(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命名空间引用它,见识一下它如何改善我们的验证处理。
`(ns guestbook.routes.auth
(:require ...
[noir.validation
:refer [rule errors? has-value? on-error]])`
对于使用验证函数,我们一样需要将handler封装到wrap- noir-validation中间件。这里需要引用noir.validation:
`(ns guestbook.handler
...
(:require ...
[noir.validation
:refer [wrap-noir-validation]]))`
guestbook-with-auth/src/guestbook/handler.clj
`(def app
(->
(handler/site
(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页面。
`(defn handle-login [id pass]
(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
(session/put! :user id)
(redirect "/"))))`
我们按如下格式创建规则:(rule validator [:field-name "error message"])
验证器可以表达为任何形式,只要最终返回布尔值即可。也可以为每个键设置多重错误,这些错误会被汇集到一个vector。当验证器返回false,将生成错误。
例如,我们写下(= id "foo"),id的值只要不是foo,就会生成错误。
我们这里为每一个项分别提供一个错误处理。其实可以创建一个辅助函数,用于将它们汇集起来,并统一为展示错误内容做进一步处理。guestbook-with-auth/src/guestbook/routes/auth.clj
`(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)
(field name)
[:br]))`
由于我们不再需要将错误定向到login-page,我们更新对应内容。guestbook-with-auth/src/guestbook/routes/auth.clj
`(defn login-page []
(layout/common
(form-to [:post "/login"]
(control text-field :id "screen name")
(control password-field :pass "Password")
(submit-button "login"))))`
总而言之,我们可以在需要验证的任何地方创建规则。每个规则会考察、判定此处是否合法。如果此处验证失败,就会生成错误内容并通过on-error辅助函数呈现给用户。
我们之所以可以这样做,是因为验证错误一定是当前的请求带来的。由于调用的这个函数为当前的请求负责处理和展现结果,所以它也应当处理对应的错误。
添加安全机制
Lib-noir同样提供便捷途径去处理hash,并使用noir.util.crypt验证密码。这个命名空间提供两个名为encrypt 和compare的函数。前者用于密码加密、加盐(salts),后者用于对比明文密码和由前者生成的hash字符串。实际上,内部具体使用的是流行的jBCrypt库21处理的加密。
使用compare函数去验证看起来是这样:(compare raw encrypted)
encrypt函数允许指定加盐,也生成并提供一个不加盐的版本。
`(encrypt salt raw)
(encrypt raw)`
我们之所以对密码加盐,是为了对抗彩虹表 (rainbow-table)22的攻击。彩虹表其实是预先将很多常见密码通过哈希计算生成的字典。此表是通过优化提高哈希查找效率,并且允许攻击者容易通过给定的哈希值来获取密码原文。而加盐操作是为密码追加随机内容再进行哈希,最终生成的哈希便不再容易被破解。
这里,我们同样需要在auth命名空间中添加引用:
`(ns guestbook.routes.auth
(:require ...
[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-connection db
(sql/with-query-results
res "select * from users where id = ?" id)))`
完成这些之后,我们需要重新加载db命名空间,使得新的函数生效,然后在REPL控制台运行(create-user-table)。
我们现在可以切换到auth命名空间,开始编写handle-registration函数。记住,我们一样也要在db命名空间声明引用。
`(ns guestbook.routes.auth
(:require ... [guestbook.models.db :as db]))`
`guestbook-with-auth/src/guestbook/routes/auth.clj
(defn handle-registration [id pass pass1]
(rule (= pass pass1)
[:pass "password was not retyped correctly"])
(if (errors? :pass)
(registration-page)
(do
(db/add-user-record {:id id :pass (crypt/encrypt pass)})
(redirect "/login"))))
更新POST /register 路由,这些功能在被调用时将会生效。
(POST "/register" [id pass pass1]
(handle-registration id pass pass1))`
接下来,当一个用户试图登录时,我们会在登录处理函数中检查其授权。
`guestbook-with-auth/src/guestbook/routes/auth.clj
(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)
(do
(session/put! :user id)
(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章 相册”来讨论这些内容。