《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")
       [: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章 相册”来讨论这些内容。

相关文章
|
1天前
|
前端开发 JavaScript BI
Django教程第5章 | Web开发实战-数据统计图表(echarts、highchart)
使用echarts和highcharts图表库实现折线图、柱状图、饼图和数据集图
71 2
|
6月前
|
JSON Cloud Native Go
GO 语言 Web 开发实战一
GO 语言 Web 开发实战一
|
1天前
|
存储 前端开发 JavaScript
Django教程第4章 | Web开发实战-三种验证码实现
手动生成验证码,自动生成验证码,滑动验证码。【2月更文挑战第24天】
48 0
Django教程第4章 | Web开发实战-三种验证码实现
|
1天前
|
存储 中间件 数据安全/隐私保护
Django教程第3章 | Web开发实战-登录
登录案例、Djiango中间件【2月更文挑战第23天】
63 2
Django教程第3章 | Web开发实战-登录
|
1天前
|
JavaScript 关系型数据库 MySQL
Django教程第2章| Web开发实战-用户管理
基于Django实现用户管理:增删改查,搜索,分页。【2月更文挑战第22天】
63 0
Django教程第2章| Web开发实战-用户管理
|
1天前
|
设计模式 开发框架 数据库
Python Web开发主要常用的框架
【5月更文挑战第12天】Python Web开发框架包括Django、Flask、Tornado和Pyramid。Django适用于复杂应用,提供ORM、模板引擎等全套功能;Flask轻量级,易于扩展,适合小型至中型项目;Tornado擅长处理高并发,支持异步和WebSockets;Pyramid灵活强大,可适配多种数据库和模板引擎,适用于各种规模项目。选择框架需依据项目需求和技术栈。
116 2
|
1天前
|
关系型数据库 MySQL
web简易开发(二){html5+php实现文件上传及通过关键字搜索已上传图片)}
web简易开发(二){html5+php实现文件上传及通过关键字搜索已上传图片)}
|
1天前
|
JSON JavaScript API
使用 Node.js 开发一个简单的 web 服务器响应 HTTP post 请求
使用 Node.js 开发一个简单的 web 服务器响应 HTTP post 请求
6 1
|
1天前
|
JSON JavaScript 中间件
使用 Node.js 开发一个简单的 web 服务器响应 HTTP get 请求
使用 Node.js 开发一个简单的 web 服务器响应 HTTP get 请求
9 2
|
1天前
|
JavaScript 前端开发 API
在Node.js上使用dojo库进行面向对象web应用开发
请注意,虽然这个例子在Node.js环境中使用了Dojo,但Dojo的许多功能(例如DOM操作和AJAX请求)在Node.js环境中可能无法正常工作。因此,如果你打算在Node.js环境中使用Dojo,你可能需要查找一些适用于服务器端JavaScript的替代方案。
7 0