Python计算机视觉:第八章 图像类容分类
第八章 圖像類容分類
8.1 K最近鄰
K最近鄰是分類中最簡單且常用的方法之一。
8.1.1 一個(gè)簡單的二維例子
# -*- coding: utf-8 -*- from numpy.random import randn import pickle from pylab import *# create sample data of 2D points n = 200 # two normal distributions class_1 = 0.6 * randn(n,2) class_2 = 1.2 * randn(n,2) + array([5,1]) labels = hstack((ones(n),-ones(n))) # save with Pickle #with open('points_normal.pkl', 'w') as f: with open('points_normal_test.pkl', 'w') as f:pickle.dump(class_1,f)pickle.dump(class_2,f)pickle.dump(labels,f) # normal distribution and ring around it class_1 = 0.6 * randn(n,2) r = 0.8 * randn(n,1) + 5 angle = 2*pi * randn(n,1) class_2 = hstack((r*cos(angle),r*sin(angle))) labels = hstack((ones(n),-ones(n))) # save with Pickle #with open('points_ring.pkl', 'w') as f: with open('points_ring_test.pkl', 'w') as f:pickle.dump(class_1,f)pickle.dump(class_2,f)pickle.dump(labels,f) # -*- coding: utf-8 -*- import pickle from pylab import * from PCV.classifiers import knn from PCV.tools import imtoolspklist=['points_normal.pkl','points_ring.pkl']figure()# load 2D points using Pickle for i, pklfile in enumerate(pklist):with open(pklfile, 'r') as f:class_1 = pickle.load(f)class_2 = pickle.load(f)labels = pickle.load(f)# load test data using Picklewith open(pklfile[:-4]+'_test.pkl', 'r') as f:class_1 = pickle.load(f)class_2 = pickle.load(f)labels = pickle.load(f)model = knn.KnnClassifier(labels,vstack((class_1,class_2)))# test on the first pointprint model.classify(class_1[0])#define function for plottingdef classify(x,y,model=model):return array([model.classify([xx,yy]) for (xx,yy) in zip(x,y)])# lot the classification boundarysubplot(1,2,i+1)imtools.plot_2D_boundary([-6,6,-6,6],[class_1,class_2],classify,[1,-1])titlename=pklfile[:-4]title(titlename) show()8.1.2 圖像稠密(dense)sift特征)
# -*- coding: utf-8 -*- from PCV.localdescriptors import sift, dsift from pylab import * from PIL import Imagedsift.process_image_dsift('../data/empire.jpg','empire.dsift',90,40,True) l,d = sift.read_features_from_file('empire.dsift') im = array(Image.open('../data/empire.jpg')) sift.plot_features(im,l,True) title('dense SIFT') show()8.1.3 圖像分類——手勢識別
# -*- coding: utf-8 -*- import os from PCV.localdescriptors import sift, dsift from pylab import * from PIL import Imageimlist=['../data/gesture/train/A-uniform01.ppm','../data/gesture/train/B-uniform01.ppm','../data/gesture/train/C-uniform01.ppm','../data/gesture/train/Five-uniform01.ppm','../data/gesture/train/Point-uniform01.ppm','../data/gesture/train/V-uniform01.ppm']figure() for i, im in enumerate(imlist):dsift.process_image_dsift(im,im[:-3]+'.dsift',90,40,True)l,d = sift.read_features_from_file(im[:-3]+'dsift')dirpath, filename=os.path.split(im)im = array(Image.open(im))#顯示手勢含義titletitlename=filename[:-14]subplot(2,3,i+1)sift.plot_features(im,l,True)title(titlename) show() # -*- coding: utf-8 -*- from PCV.localdescriptors import dsift import os from PCV.localdescriptors import sift from pylab import * from PCV.classifiers import knndef get_imagelist(path):""" Returns a list of filenames for all jpg images in a directory. """return [os.path.join(path,f) for f in os.listdir(path) if f.endswith('.ppm')]def read_gesture_features_labels(path):# create list of all files ending in .dsiftfeatlist = [os.path.join(path,f) for f in os.listdir(path) if f.endswith('.dsift')]# read the featuresfeatures = []for featfile in featlist:l,d = sift.read_features_from_file(featfile)features.append(d.flatten())features = array(features)# create labelslabels = [featfile.split('/')[-1][0] for featfile in featlist]return features,array(labels)def print_confusion(res,labels,classnames):n = len(classnames)# confusion matrixclass_ind = dict([(classnames[i],i) for i in range(n)])confuse = zeros((n,n))for i in range(len(test_labels)):confuse[class_ind[res[i]],class_ind[test_labels[i]]] += 1print 'Confusion matrix for'print classnamesprint confusefilelist_train = get_imagelist('../data/gesture/train') filelist_test = get_imagelist('../data/gesture/test') imlist=filelist_train+filelist_test# process images at fixed size (50,50) for filename in imlist:featfile = filename[:-3]+'dsift'dsift.process_image_dsift(filename,featfile,10,5,resize=(50,50))features,labels = read_gesture_features_labels('../data/gesture/train/') test_features,test_labels = read_gesture_features_labels('../data/gesture/test/') classnames = unique(labels)# test kNN k = 1 knn_classifier = knn.KnnClassifier(labels,features) res = array([knn_classifier.classify(test_features[i],k) for i in range(len(test_labels))]) # accuracy acc = sum(1.0*(res==test_labels)) / len(test_labels) print 'Accuracy:', accprint_confusion(res,test_labels,classnames) Accuracy: 0.813471502591 Confusion matrix for ['A' 'B' 'C' 'F' 'P' 'V'] [[ 26. 0. 2. 0. 1. 1.][ 0. 26. 0. 1. 1. 1.][ 0. 0. 26. 0. 0. 1.][ 0. 3. 0. 37. 0. 0.][ 0. 1. 2. 0. 17. 1.][ 3. 1. 3. 0. 14. 25.]]第七章已經(jīng)實(shí)現(xiàn)了注冊新用戶的功能,本章我們要為已注冊的用戶提供登錄和退出功能。實(shí)現(xiàn)登錄功能之后,就可以根據(jù)登錄狀態(tài)和當(dāng)前用戶的身份定制網(wǎng)站的內(nèi)容了。例如,本章我們會更新網(wǎng)站的頭部,顯示“登錄”或“退出”鏈接,以及到個(gè)人資料頁面的鏈接;在第十章中,會根據(jù)當(dāng)前登錄用戶的 id 創(chuàng)建關(guān)聯(lián)到這個(gè)用戶的微博;在第十一章,我們會實(shí)現(xiàn)當(dāng)前登錄用戶關(guān)注其他用戶的功能,實(shí)現(xiàn)之后,在首頁就可以顯示被關(guān)注用戶發(fā)表的微博了。
實(shí)現(xiàn)登錄功能之后,還可以實(shí)現(xiàn)一種安全機(jī)制,即根據(jù)用戶的身份限制可以訪問的頁面,例如,在第九章中會介紹如何實(shí)現(xiàn)只有登入的用戶才能訪問編輯用戶資料的頁面。登錄系統(tǒng)還可以賦予管理員級別的用戶特別的權(quán)限,例如刪除用戶(也會在第九章中實(shí)現(xiàn))等。
實(shí)現(xiàn)驗(yàn)證系統(tǒng)的核心功能之后,我們會簡要的介紹一下 Cucumber 這個(gè)流行的行為驅(qū)動開發(fā)(Behavior-driven Development, BDD)系統(tǒng),使用 Cucumber 重新實(shí)現(xiàn)之前的一些 RSpec 集成測試,看一下這兩種方式有何不同。
和之前的章節(jié)一樣,我們會在一個(gè)新的從分支中工作,本章結(jié)束后再將其合并到主分支中:
$ git checkout -b sign-in-out8.1 session 和登錄失敗
[session](http://en.wikipedia.org/wiki/Session(computerscience)) 是兩臺電腦(例如運(yùn)行有網(wǎng)頁瀏覽器的客戶端電腦和運(yùn)行 Rails 的服務(wù)器)之間的半永久性連接,我們就是利用它來實(shí)現(xiàn)“登錄”這一功能的。網(wǎng)絡(luò)中常見的 session 處理方式有好幾種:可以在用戶關(guān)閉瀏覽器后清除 session;也可以提供一個(gè)“記住我”單選框讓用戶選擇永遠(yuǎn)保存,直到用戶退出后 session 才會失效。1?在示例程序中我們選擇使用第二種處理方式,即用戶登錄后,會永久的記住登錄狀態(tài),直到用戶點(diǎn)擊“退出”鏈接之后才清除 session。(在?8.2.1 節(jié)中會介紹“永久”到底有多久。)
很顯然,我們可以把 session 視作一個(gè)符合 REST 架構(gòu)的資源,在登錄頁面中準(zhǔn)備一個(gè)新的 session,登錄后創(chuàng)建這個(gè) session,退出則會銷毀 session。不過 session 和 Users 資源有所不同,Users 資源使用數(shù)據(jù)庫(通過 User 模型)持久的存儲數(shù)據(jù),而 Sessions 資源是利用?cookie?來存儲數(shù)據(jù)的。cookie 是存儲在瀏覽器中的簡單文本。實(shí)現(xiàn)登錄功能基本上就是在實(shí)現(xiàn)基于 cookie 的驗(yàn)證機(jī)制。在本節(jié)及接下來的一節(jié)中,我們會構(gòu)建 Sessions 控制器,創(chuàng)建登錄表單,還會實(shí)現(xiàn)控制器中相關(guān)的動作。在?8.2 節(jié)中會加入處理 cookie 所需的代碼。
8.1.1 Sessions 控制器
登錄和退出功能其實(shí)是由 Sessions 控制器中相應(yīng)的動作處理的,登錄表單在?new?動作中處理(本節(jié)的內(nèi)容),登錄的過程就是向?create?動作發(fā)送?POST?請求(8.1 節(jié)和?8.2 節(jié)),退出則是向?destroy?動作發(fā)送?DELETE?請求(8.2.6 節(jié))。(HTTP 請求和 REST 動作之間的對應(yīng)關(guān)系可以查看表格 7.1。)首先,我們要生成 Sessions 控制器,以及驗(yàn)證系統(tǒng)所需的集成測試:
$ rails generate controller Sessions --no-test-framework $ rails generate integration_test authentication_pages參照?7.2 節(jié)中的“注冊”頁面,我們要創(chuàng)建一個(gè)登錄表單,用來生成新的 session。注冊表單的構(gòu)思圖如圖 8.1 所示。
“登錄”頁面的地址由?signin_path(稍后定義)獲取,和之前一樣,我們要先編寫相應(yīng)的測試,如代碼 8.1 所示。(可以和代碼 7.6 中對“注冊”頁面的測試比較一下。)
圖 8.1:注冊表單的構(gòu)思圖
代碼 8.1?對?new?動作和對應(yīng)視圖的測試
spec/requests/authentication_pages_spec.rb
現(xiàn)在測試是失敗的:
$ bundle exec rspec spec/要讓代碼 8.1 中的測試通過,首先,我們要為 Sessions 資源設(shè)置路由,還要修改“登錄”頁面具名路由的名稱,將其映射到 Sessions 控制器的?new?動作上。和 Users 資源一樣,我們可以使用?resources?方法設(shè)置標(biāo)準(zhǔn)的 REST 動作:
resources :sessions, only: [:new, :create, :destroy]因?yàn)槲覀儧]必要顯示或編輯 session,所以我們對動作的種類做了限制,為?resources?方法指定了?:only?選項(xiàng),只創(chuàng)建new、create?和?destroy?動作。最終的結(jié)果,包括登錄和退出具名路由的設(shè)置,如代碼 8.2 所示。
代碼 8.2?設(shè)置 session 相關(guān)的路由
config/routes.rb
注意,設(shè)置退出路由那行使用了?via :delete,這個(gè)參數(shù)指明?destroy?動作要使用?DELETE?請求。
代碼 8.2 中的路由設(shè)置會生成類似表格 7.1?所示的URI 地址和動作的對應(yīng)關(guān)系,如表格 8.1?所示。注意,我們修改了登錄和退出具名路由,而創(chuàng)建 session 的路由還是使用默認(rèn)值。
| GET | /signin | signin_path | new | 創(chuàng)建新 session 的頁面(登錄) |
| POST | /sessions | sessions_path | create | 創(chuàng)建 session |
| DELETE | /signout | signout_path | destroy | 刪除 session(退出) |
表格 8.1:代碼 8.2 中的設(shè)置生成的符合 REST 架構(gòu)的路由關(guān)系
為了讓代碼 8.1 中的測試通過,我們還要在 Sessions 控制器中加入?new?動作,相應(yīng)的代碼如代碼 8.3 所示(同時(shí)也定義了create?和?destroy?動作)。
代碼 8.3?沒什么內(nèi)容的 Sessions 控制器
app/controllers/sessions_controller.rb
接下來還要創(chuàng)建“登錄”頁面的視圖,因?yàn)椤暗卿洝表撁娴哪康氖莿?chuàng)建新 session,所以創(chuàng)建的視圖位于app/views/sessions/new.html.erb。在視圖中我們要顯示網(wǎng)頁的標(biāo)題和一個(gè)一級標(biāo)頭,如代碼 8.4 所示。
代碼 8.4?“登錄”頁面的視圖
app/views/sessions/new.html.erb
現(xiàn)在代碼 8.1 中的測試應(yīng)該可以通過了,接下來我們要編寫登錄表單。
$ bundle exec rspec spec/8.1.2 測試登錄功能
對比圖 8.1 和圖 7.11 之后,我們發(fā)現(xiàn)登錄表單和注冊表單外觀上差不多,只是少了兩個(gè)字段,只有 Email 地址和密碼字段。和注冊表單一樣,我們可以使用 Capybara 填寫表單,再點(diǎn)擊按鈕進(jìn)行測試。
在測試的過程中,我們不得不向程序中加入相應(yīng)的功能,這也正是 TDD 帶來的好處之一。我們先來測試填寫不合法數(shù)據(jù)的登錄過程,構(gòu)思圖如圖 8.2 所示。
圖 8.2:注冊失敗頁面的構(gòu)思圖
從圖 8.2 我們可以看出,如果提交的數(shù)據(jù)不正確,我們會重新渲染“注冊”頁面,還會顯示一個(gè)錯(cuò)誤提示消息。這個(gè)錯(cuò)誤提示是 Flash 消息,我們可以通過下面的測試驗(yàn)證:
it { should have_selector('div.alert.alert-error', text: 'Invalid') }(在第七章練習(xí)中的代碼 7.32 中出現(xiàn)過類似的代碼。)我們要查找的元素是:
div.alert.alert-error前面介紹過,這里的點(diǎn)號代表 CSS 中的 class(參見?5.1.2 節(jié)),你也許猜到了,這里我們要查找的是同時(shí)具有?alert?和alert-error?class 的?div?元素。而且我們還檢測了錯(cuò)誤提示消息中是否包含了?"Invalid"?這個(gè)詞。所以,上述測試是檢測頁面中是否有下面這個(gè)元素的:
<div class="alert alert-error">Invalid...</div>代碼 8.5 是針對標(biāo)題和 Flash 消息的測試。我們可以看出,這些代碼缺少了一個(gè)很重要的部分,會在?8.1.5 節(jié)中說明。
代碼 8.5?登錄失敗時(shí)的測試
spec/requests/authentication_pages_spec.rb
測試了登錄失敗的情況,下面我們要測試登錄成功的情況了。我們要測試登錄成功后是否轉(zhuǎn)向了用戶資料頁面(從頁面的標(biāo)題判斷,標(biāo)題中應(yīng)該包含用戶的名字),還要測試網(wǎng)站的導(dǎo)航中是否有以下三個(gè)變化:
(對“設(shè)置(Settings)”鏈接的測試會在?9.1 節(jié)中實(shí)現(xiàn),對“所有用戶(Users)”鏈接的測試會在?9.3 節(jié)中實(shí)現(xiàn)。)如上變化的構(gòu)思圖如圖 8.3 所示。2注意,“退出”和“個(gè)人資料”鏈接位于“賬戶(Account)”下拉菜單中。在?8.2.4 節(jié)中會介紹如何通過 Bootstrap 實(shí)現(xiàn)這種下拉菜單。
圖 8.3:登錄成功后顯示的用戶資料頁面構(gòu)思圖
對登錄成功時(shí)的測試如代碼 8.6 所示。
代碼 8.6?登錄成功時(shí)的測試
spec/requests/authentication_pages_spec.rb
在代碼 8.6 中用到了?have_link?方法,它的第一參數(shù)是鏈接文本,第二個(gè)參數(shù)是可選的?:href,指定鏈接的地址,因此如下的代碼
it { should have_link('Profile', href: user_path(user)) }確保了頁面中有一個(gè)?a?元素,鏈接到指定的 URI 地址。這里我們要檢測的是一個(gè)指向用戶資料頁面的鏈接。
8.1.3 登錄表單
寫完測試之后,我們就可以創(chuàng)建登錄表單了。在代碼 7.17 中,注冊表單使用了?form_for?幫助函數(shù),并指定其參數(shù)為?@user變量:
<%= form_for(@user) do |f| %> . . . <% end %>注冊表單和登錄表單的區(qū)別在于,程序中沒有 Session 模型,因此也就沒有類似?@user?的變量。也就是說,在構(gòu)建登錄表單時(shí),我們要給?form_for?提供更多的信息。一般來說,如下的代碼
form_for(@user)Rails 會自動向 /users 地址發(fā)送?POST?請求。對于登錄表單,我們則要明確的指定資源的名稱以及相應(yīng)的 URI 地址:
form_for(:session, url: sessions_path)(創(chuàng)建表單還有另一種方法,不用?form_for,而用?form_tag。form_tag?也是 Rails 程序常用的方法,不過換用?form_tag?之后就和注冊表單有很多不同之處了,我現(xiàn)在是想使用相似的代碼構(gòu)建登錄表單。使用?form_tag?構(gòu)建登錄表單會留作練習(xí)(參見?8.5 節(jié))。)
使用上述這種?form_for?形式,參照代碼 7.17 中的注冊表單,很容易的就能編寫一個(gè)符合圖 8.1 的登錄表單,如代碼 8.7 所示。
代碼 8.7?注冊表單的代碼
app/views/sessions/new.html.erb
注意,為了訪客的便利,我們還加入了到“注冊”頁面的鏈接。代碼 8.7 中的登錄表單效果如圖 8.4 所示。
圖 8.4:登錄表單(/signup)
用的多了你就不會老是查看 Rails 生成的 HTML(你會完全信任所用的幫助函數(shù)可以正確的完成任務(wù)),不過現(xiàn)在還是來看一下登錄表單的 HTML 吧(如代碼 8.8 所示)。
代碼 8.8?代碼 8.7 中登錄表單生成的 HTML
<form accept-charset="UTF-8" action="/sessions" method="post"><div><label for="session_email">Email</label><input id="session_email" name="session[email]" size="30" type="text" /></div><div><label for="session_password">Password</label><input id="session_password" name="session[password]" size="30"type="password" /></div><input class="btn btn-large btn-primary" name="commit" type="submit"value="Sign in" /> </form>你可以對比一下代碼 8.8 和代碼 7.20。你可能已經(jīng)猜到了,提交登錄表單后會生成一個(gè)?params?Hash,其中params[:session][:email]?和?params[:session][:password]?分別對應(yīng)了 Email 和密碼字段。
8.1.4 分析表單提交
和創(chuàng)建用戶類似,創(chuàng)建 session 時(shí)先要處理提交不合法數(shù)據(jù)的情況。我們已經(jīng)編寫了對提交不合法數(shù)據(jù)的測試(參見代碼 8.5),也添加了有幾處難理解但還算簡單的代碼讓測試通過了。下面我們就來分析一下表單提交的過程,然后為登錄失敗添加失敗提示信息(如圖 8.2)。最后,以此為基礎(chǔ),驗(yàn)證提交的 Email 和密碼,處理登錄成功的情況(參見?8.2 節(jié))。
首先,我們來編寫 Sessions 控制器的?create?動作,如代碼 8.9 所示,現(xiàn)在只是直接渲染登錄頁面。在瀏覽器中訪問 /sessions/new,然后提交空表單,顯示的頁面如圖 8.5 所示。
代碼 8.9?Sessions 控制器中?create?動作的初始版本
app/controllers/sessions_controller.rb
圖 8.5:代碼 8.9 中的?create?動作顯示的登錄失敗后的頁面
仔細(xì)看一下圖 8.5 中顯示的調(diào)試信息,你會發(fā)現(xiàn),如在?8.1.3 節(jié)末尾說過的,表單提交后會生成?params?Hash,Email 和密碼都在?:session?鍵中:
--- session:email: ''password: '' commit: Sign in action: create controller: sessions和注冊表單類似,這些參數(shù)是一個(gè)嵌套的 Hash,在代碼 4.6 中見過。params?包含了如下的嵌套 Hash:
{ session: { password: "", email: "" } }也就是說
params[:session]本身就是一個(gè) Hash:
{ password: "", email: "" }所以,
params[:session][:email]就是提交的 Email 地址,而
params[:session][:password]就是提交的密碼。
也就是說,在?create?動作中,params?包含了使用 Email 和密碼驗(yàn)證用戶身份所需的全部數(shù)據(jù)。幸運(yùn)的是,我們已經(jīng)定義了身份驗(yàn)證過程中所需的兩個(gè)方法,即由 Active Record 提供的?User.find_by_email(參見?6.1.4 節(jié)),以及由has_secure_password?提供的?authenticate?方法(參見?6.3.3 節(jié))。我們之前介紹過,如果提交的數(shù)據(jù)不合法,authenticate?方法會返回?false。基于以上的分析,我們計(jì)劃按照如下的方式實(shí)現(xiàn)用戶登錄功能:
def createuser = User.find_by_email(params[:session][:email].downcase)if user && user.authenticate(params[:session][:password])# Sign the user in and redirect to the user's show page.else# Create an error message and re-render the signin form.end endcreate?動作的第一行,使用提交的 Email 地址從數(shù)據(jù)庫中取出相應(yīng)的用戶。第二行是 Ruby 中經(jīng)常使用的語句形式:
user && user.authenticate(params[:session][:password])我們使用?&&(邏輯與)檢測獲取的用戶是否合法。因?yàn)槌?nil?和?false?之外的所有對象都被視作?true,上面這個(gè)語句可能出現(xiàn)的結(jié)果如表格 8.2所示。我們可以從表格 8.2 中看出,當(dāng)且僅當(dāng)數(shù)據(jù)庫中存在提交的 Email 并提交了對應(yīng)的密碼時(shí),這個(gè)語句才會返回?true。
| 不存在 | 任意值 | nil && [anything] == false |
| 存在 | 錯(cuò)誤的密碼 | true && false == false |
| 存在 | 正確的密碼 | true && true == true |
表格 8.2:user && user.authenticate(...)?可能出現(xiàn)的結(jié)果
8.1.5 顯示 Flash 消息
在?7.3.2 節(jié)中,我們使用 User 模型的數(shù)據(jù)驗(yàn)證信息來顯示注冊失敗時(shí)的提示信息。這些錯(cuò)誤提示信息是關(guān)聯(lián)在某個(gè) Active Record 對象上的,不過這種方式不可以用在 session 上,因?yàn)?session 不是 Active Record 模型。我們要采取的方法是,在登錄失敗時(shí),把錯(cuò)誤提示信息賦值給 Flash 消息。代碼 8.10 顯示的是我們首次嘗試實(shí)現(xiàn)這種方法所用的代碼,其中有個(gè)小小的錯(cuò)誤。
代碼 8.10?嘗試處理登錄失敗(有個(gè)小小的錯(cuò)誤)
app/controllers/sessions_controller.rb
布局中已經(jīng)加入了顯示 Flash 消息的局部視圖,所以無需其他修改,上述 Flash 錯(cuò)誤提示消息就會顯示出來,而且因?yàn)槭褂昧?Bootstrap,這個(gè)錯(cuò)誤消息的樣式也很美觀(如圖 8.6)。
圖 8.6:登錄失敗后顯示的 Flash 消息
不過,就像代碼 8.10 中的注釋所說,這些代碼還有問題。顯示的頁面看起來很正常啊,那么,問題出現(xiàn)在哪兒呢?問題的關(guān)鍵在于,Flash 消息在一個(gè)請求的生命周期內(nèi)是持續(xù)存在的,而重新渲染頁面(使用?render?方法)和代碼 7.27 中的轉(zhuǎn)向不同,它不算新的請求,你會發(fā)現(xiàn)這個(gè) Flash 消息存在的時(shí)間比設(shè)想的要長很多。例如,我們提交了不合法的登錄信息,Flash 消息生成了,然后在登錄頁面中顯示出來(如圖 8.6),這時(shí)如果我們點(diǎn)擊鏈接轉(zhuǎn)到其他頁面(例如“首頁”),這只算是表單提交后的第一次請求,所以頁面中還是會顯示 Flash 消息(如圖 8.7)。
圖 8.7:仍然顯示有 Flash 消息的頁面
Flash 消息沒有按預(yù)期消失算是程序的一個(gè) bug,在修正之前,我們最好編寫一個(gè)測試來捕獲這個(gè)錯(cuò)誤。現(xiàn)在,登錄失敗時(shí)的測試是可以通過的:
$ bundle exec rspec spec/requests/authentication_pages_spec.rb \ > -e "signin with invalid information"不過程序中有錯(cuò)誤,測試應(yīng)該是失敗的,所以我們要編寫一個(gè)能夠捕獲這種錯(cuò)誤的測試。幸好,捕獲這種錯(cuò)誤正是集成測試的拿手好戲,所用的代碼如下:
describe "after visiting another page" dobefore { click_link "Home" }it { should_not have_selector('div.alert.alert-error') } end提交不合法的登錄信息之后,這個(gè)測試用例會點(diǎn)擊網(wǎng)站中的“首頁”鏈接,期望顯示的頁面中沒有 Flash 錯(cuò)誤消息。添加上述測試用例的測試文件如代碼 8.11 所示。
代碼 8.11?登錄失敗時(shí)的合理測試
spec/requests/authentication_pages_spec.rb
新添加的測試和預(yù)期一致,是失敗的:
$ bundle exec rspec spec/requests/authentication_pages_spec.rb \ > -e "signin with invalid information"要讓這個(gè)測試通過,我們要用?flash.now?替換?flash。flash.now?就是專門用來在重新渲染的頁面中顯示 Flash 消息的,在發(fā)送新的請求之后,Flash 消息便會消失。正確的?create?動作代碼如代碼 8.12 所示。
代碼 8.12?處理登錄失敗所需的正確代碼
app/controllers/sessions_controller.rb
現(xiàn)在登錄失敗時(shí)的所有測試應(yīng)該都可以通過了:
$ bundle exec rspec spec/requests/authentication_pages_spec.rb \ > -e "with invalid information"8.2 登錄成功
上一節(jié)處理了登錄失敗的情況,這一節(jié)我們要處理登錄成功的情況了。實(shí)現(xiàn)用戶登錄的過程是本書目前為止最考驗(yàn) Ruby 編程能力的部分,你要堅(jiān)持讀完本節(jié),做好心理準(zhǔn)備,付出大量的腦力勞動。幸好,第一步還算是簡單的,完成 Sessions 控制器的?create?動作沒什么難的,不過還是需要一點(diǎn)小技巧。
我們需要把代碼 8.12 中處理登錄成功分支中的注釋換成具體的代碼,使用?sign_in?方法實(shí)現(xiàn)登錄操作,然后轉(zhuǎn)向用戶的資料頁面,如代碼 8.13 所示。這就是我們使用的技巧,使用還沒定義的方法?sign_in。本節(jié)后面的內(nèi)容會定義這個(gè)方法。
代碼 8.13?完整的?create?動作代碼(還不能正常使用)
app/controllers/sessions_controller.rb
8.2.1 “記住我”
現(xiàn)在我們要開始實(shí)現(xiàn)登錄功能了,第一步是實(shí)現(xiàn)“記住我”這個(gè)功能,即用戶登錄的狀態(tài)會被“永遠(yuǎn)”記住,直到用戶點(diǎn)擊“退出”鏈接為止。實(shí)現(xiàn)登錄功能用到的函數(shù)已經(jīng)超越了傳統(tǒng)的 MVC 架構(gòu),其中一些函數(shù)要同時(shí)在控制器和視圖中使用。在4.2.5 節(jié)中介紹過,Ruby 支持模塊(module)功能,打包一系列函數(shù),在不同的地方引入。我們會利用模塊來打包用戶身份驗(yàn)證相關(guān)的函數(shù)。我們當(dāng)然可以創(chuàng)建一個(gè)新的模塊,不過 Sessions 控制器已經(jīng)提供了一個(gè)名為?SessionsHelper?的模塊,而且這個(gè)模塊中的幫助方法會自動引入 Rails 程序的視圖中。所以,我們就直接使用這個(gè)現(xiàn)成的模塊,然后在 Application 控制器中引入,如代碼 8.14 所示。
代碼 8.14?在 Application 控制器中引入 Sessions 控制器的幫助方法模塊
app/controllers/application_controller.rb
默認(rèn)情況下幫助函數(shù)只可以在視圖中使用,不能在控制器中使用,而我們需要同時(shí)在控制器和視圖中使用幫助函數(shù),所以我們就手動引入幫助函數(shù)所在的模塊。
因?yàn)?HTTP 是無狀態(tài)的協(xié)議,所以如果應(yīng)用程序需要實(shí)現(xiàn)登錄功能的話,就要找到一種方法記住用戶的狀態(tài)。維持用戶登錄狀態(tài)的方法之一,是使用常規(guī)的 Rails session(通過?session?函數(shù)),把用戶的 id 保存在“記憶權(quán)標(biāo)(remember token)”中:
session[:remember_token] = user.idsession?對象把用戶 id 保存在瀏覽器的 cookie 中,這樣在網(wǎng)站的所有頁面就都可以使用了。瀏覽器關(guān)閉后,cookie 也隨之失效。在網(wǎng)站中的任何頁面,只需調(diào)用?User.find(session[:remember_token])?就可以取回用戶對象了。Rails 在處理 session 時(shí),會確保安全性。倘若用戶企圖偽造用戶 id,Rails 可以通過每個(gè) session 的 session id 檢測到。
根據(jù)示例程序的設(shè)計(jì)目標(biāo),我們計(jì)劃要實(shí)現(xiàn)的是持久保存的 session,即使瀏覽器關(guān)閉了,登錄狀態(tài)依舊存在,所以,登入的用戶要有一個(gè)持久保存的標(biāo)識符才行。為此,我們要為每個(gè)用戶生成一個(gè)唯一而安全的記憶權(quán)標(biāo),長期存儲,不會隨著瀏覽器的關(guān)閉而消失。
記憶權(quán)標(biāo)要附屬到特定的用戶對象上,而且要保存起來以待后用,所以我們就可以把它設(shè)為 User 模型的屬性(如圖 8.8)。我們先來編寫 User 模型的測試,如代碼 8.15 所示。
圖 8.8:User 模型,添加了?remember_token?屬性
代碼 8.15?記憶權(quán)標(biāo)的第一個(gè)測試
spec/models/user_spec.rb
要讓這個(gè)測試通過,我們要生成記憶權(quán)標(biāo)屬性,執(zhí)行如下命令:
$ rails generate migration add_remember_token_to_users然后按照代碼 8.16 修改生成的遷移文件。注意,因?yàn)槲覀円褂糜洃洐?quán)標(biāo)取回用戶,所以我們?yōu)?remember_token?列加了索引(參見?旁注 6.2)。
代碼 8.16?為?users?表添加?remember_token?列的遷移
db/migrate/[timestamp]_add_remember_token_to_users.rb
然后,還要更新“開發(fā)數(shù)據(jù)庫”和“測試數(shù)據(jù)庫”:
$ bundle exec rake db:migrate $ bundle exec rake db:test:prepare現(xiàn)在,User 模型的測試應(yīng)該可以通過了:
$ bundle exec rspec spec/models/user_spec.rb接下來我們要考慮記憶權(quán)標(biāo)要保存什么數(shù)據(jù),這有很多種選擇,其實(shí)任何足夠長的隨機(jī)字符串都是可以的。因?yàn)橛脩舻拿艽a是經(jīng)過加密處理的,所以原則上我們可以直接把用戶的?password_hash?值拿來用,不過這么做可能會把用戶的密碼暴露給潛在的攻擊者。以防萬一,我們還是用 Ruby 標(biāo)準(zhǔn)庫中?SecureRandom?模塊提供的?urlsafe_base64?方法來生成隨機(jī)字符串吧。urlsafe_base64?方法生成的是 Base64 字符串,可以放心的在 URI 中使用(因此也可以放心的在 cookie 中使用)。3寫作本書時(shí),SecureRandom.urlsafe_base64?創(chuàng)建的字符串長度為 16,由 A-Z、a-z、0-9、下劃線(_)和連字符(-)組成,每一位字符都有 64 種可能的情況,所以兩個(gè)記憶權(quán)標(biāo)相等的概率就是 1/6416=2-96≈10-29,完全可以忽略。
我們會使用回調(diào)函數(shù)來創(chuàng)建記憶權(quán)標(biāo),回調(diào)函數(shù)在?6.2.5 節(jié)?中實(shí)現(xiàn) Email 屬性的唯一性驗(yàn)證時(shí)介紹過。和?6.2.5 節(jié)?中的用法一樣,我們還是要使用?before_save?回調(diào)函數(shù),在保存用戶之前創(chuàng)建?remember_token?的值。4要測試這個(gè)過程,我們可以先保存測試所需的用戶對象,然后檢查?remember_token?是否為非空值。這樣做,如果以后需要改變記憶權(quán)標(biāo)的生成方式,也無需修改測試。測試代碼如代碼 8.17 所示。
代碼 8.17?測試合法的(非空)記憶權(quán)標(biāo)值
spec/models/user_spec.rb
代碼 8.17 中用到了?its?方法,它和?it?很像,不過測試對象是參數(shù)中指定的屬性而不是整個(gè)測試的對象。也就是說,如下的代碼:
its(:remember_token) { should_not be_blank }等同于
it { @user.remember_token.should_not be_blank }程序所需的代碼會涉及到一些新的知識。其一,我們添加了一個(gè)回調(diào)函數(shù)來生成記憶權(quán)標(biāo):
before_save :create_remember_token當(dāng) Rails 執(zhí)行到這行代碼時(shí),會尋找一個(gè)名為?create_remember_token?的方法,在保存用戶之前執(zhí)行。其二,create_remember_token?只會在 User 模型內(nèi)部使用,所以沒必要把它開放給用戶之外的對象。在 Ruby 中,我們可以使用?private?關(guān)鍵字(譯者注:其實(shí)?private?是方法而不是關(guān)鍵字,請參閱《Ruby 編程語言》P233)限制方法的可見性:
privatedef create_remember_token# Create the token.end在類中,private?之后定義的方法都會被設(shè)為私有方法,所以,如果執(zhí)行下面的操作
$ rails console >> User.first.create_remember_token就會拋出?NoMethodError?異常。
其三,在?create_remember_token?方法中,要給用戶的屬性賦值,需要在?remember_token?前加上?self?關(guān)鍵字:
def create_remember_tokenself.remember_token = SecureRandom.urlsafe_base64 end(提示:如果你使用的是 Ruby 1.8.7,就要把?SecureRandom.urlsafe_base64?換成?SecureRandom_hex。)
Active Record 是把模型的屬性和數(shù)據(jù)庫表中的列對應(yīng)的,如果不指定?self?的話,我們就只是創(chuàng)建了一個(gè)名為remember_token?的局部變量而已,這可不是我們期望得到的結(jié)果。加上?self?之后,賦值操作就會把值賦值給用戶的remember_token?屬性,保存用戶時(shí),隨著其他的屬性一起存入數(shù)據(jù)庫。
把上述的分析結(jié)合起來,最終得到的 User 模型文件如代碼 8.18 所示。
代碼 8.18?生成記憶權(quán)標(biāo)的?before_save?回調(diào)函數(shù)
app/models/user.rb
順便說一下,我們?yōu)?create_remember_token?方法增加了一層縮進(jìn),這樣可以更好的突出這些方法是在?private?之后定義的。
譯者注:如果按照 bbatsov 的《Ruby 編程風(fēng)格指南》(中譯版)來編寫 Ruby 代碼的話,就沒必要多加一層縮進(jìn)。
因?yàn)?SecureRandom.urlsafe_base64?方法創(chuàng)建的字符串不可能為空值,所以對 User 模型的測試現(xiàn)在應(yīng)該可以通過了:
$ bundle exec rspec spec/models/user_spec.rb8.2.2 定義?sign_in?方法
本小節(jié)我們要開始實(shí)現(xiàn)登錄功能了,首先來定義?sign_in?方法。上一小節(jié)已經(jīng)說明了,我們計(jì)劃實(shí)現(xiàn)的身份驗(yàn)證方式是,在用戶的瀏覽器中存儲記憶權(quán)標(biāo),在網(wǎng)站的頁面與頁面之間通過這個(gè)記憶權(quán)標(biāo)獲取數(shù)據(jù)庫中的用戶記錄(會在?8.2.3 節(jié)實(shí)現(xiàn))。實(shí)現(xiàn)這一設(shè)想所需的代碼如代碼 8.19 所示,這段代碼使用了兩個(gè)新內(nèi)容:cookies?Hash 和?current_user?方法。
代碼 8.19?完整但還不能正常使用的?sign_in?方法
app/helpers/sessions_helper.rb
上述代碼中用到的?cookies?方法是由 Rails 提供的,我們可以把它看成 Hash,其中每個(gè)元素又都是一個(gè) Hash,包含兩個(gè)元素,value?指定 cookie 的文本,expires?指定 cookie 的失效日期。例如,我們可以使用下述代碼實(shí)現(xiàn)登錄功能,把 cookie 的值設(shè)為用戶的記憶權(quán)標(biāo),失效日期設(shè)為 20 年之后:
cookies[:remember_token] = { value: user.remember_token,expires: 20.years.from_now.utc }(這里使用了 Rails 提供的時(shí)間幫助方法,詳情參見旁注 8.1。)
旁注 8.1 cookie 在?20.years.from_now?之后失效
在?4.4.2 節(jié)中介紹過,你可以向任何的 Ruby 類,甚至是內(nèi)置的類中添加自定義的方法,我們就向?String?類添加了palindrome??方法(而且還發(fā)現(xiàn)了?"deified"?是回文)。我們還介紹過,Rails 為?Object?類添加了?blank??方法(所以,"".blank?、" ".blank??和?nil.blank??的返回值都是?true)。代碼 8.19 中處理 cookie 的代碼又是一例,使用了 Rails 提供的時(shí)間幫助方法,這些方法是添加到 `Fixnum` 類(數(shù)字的基類)中的。
$ rails console>> 1.year.from_now=> Sun, 13 Mar 2011 03:38:55 UTC +00:00>> 10.weeks.ago=> Sat, 02 Jan 2010 03:39:14 UTC +00:00Rails 還添加了其他的幫助函數(shù),如:
>> 1.kilobyte=> 1024>> 5.megabytes=> 5242880這幾個(gè)幫助函數(shù)可用于限制上傳文件的大小,例如,圖片最大不超過?5.megabytes。
這種為內(nèi)置類添加方法的特性很靈便,可以擴(kuò)展 Ruby 的功能,不過使用時(shí)要小心一些。其實(shí) Rails 的很多優(yōu)雅之處正式基于 Ruby 語言的這一特性。
因?yàn)殚_發(fā)者經(jīng)常要把 cookie 的失效日期設(shè)為 20 年后,所以 Rails 特別提供了?permanent?方法,前面處理 cookie 的代碼可以改寫成:
cookies.permanent[:remember_token] = user.remember_tokenRails 的?permanent?方法會自動把 cookie 的失效日期設(shè)為 20 年后。
設(shè)定了 cookie 之后,在網(wǎng)頁中我們就可以使用下面的代碼取回用戶:
User.find_by_remember_token(cookies[:remember_token])其實(shí)瀏覽器中保存的 cookie 并不是 Hash,賦值給?cookies?只是把值以文本的形式保存在瀏覽器中。這正體現(xiàn)了 Rails 的智能,我們無需關(guān)心具體的處理細(xì)節(jié),專注地實(shí)現(xiàn)應(yīng)用程序的功能。
你可能聽說過,存儲在用戶瀏覽器中的驗(yàn)證 cookie 在和服務(wù)器通訊時(shí)可能會導(dǎo)致程序被會話劫持,攻擊者只需復(fù)制記憶權(quán)標(biāo)就可以偽造成相應(yīng)的用戶登錄網(wǎng)站了。Firesheep 這個(gè) Firefox 擴(kuò)展可以查看會話劫持,你會發(fā)現(xiàn)很多著名的大網(wǎng)站(包括 Facebook 和 Twitter)都存在這種漏洞。避免這個(gè)漏洞的方法就是整站開啟 SSL,詳情參見?7.4.4 節(jié)。
8.2.3 獲取當(dāng)前用戶
上一小節(jié)已經(jīng)介紹了如何在 cookie 中存儲記憶權(quán)標(biāo)以待后用,這一小節(jié)我們要看一下如何取回用戶。我們先回顧一下sign_in?方法:
module SessionsHelperdef sign_in(user)cookies.permanent[:remember_token] = user.remember_tokenself.current_user = userend end現(xiàn)在我們關(guān)注的是方法定義體中的第二行代碼:
self.current_user = user這行代碼創(chuàng)建了?current_user?方法,可以在控制器和視圖中使用,所以你既可以這樣用:
<%= current_user.name %>也可以這樣用:
redirect_to current_user這行代碼中的?self?也是必須的,原因在分析代碼 8.18 時(shí)已經(jīng)說過,如果沒有?self,Ruby 只是定義了一個(gè)名為current_user?的局部變量。
在開始編寫?current_user?方法的代碼之前,請仔細(xì)看這行代碼:
self.current_user = user這是一個(gè)賦值操作,我們必須先定義相應(yīng)的方法才能這么用。Ruby 為這種賦值操作提供了一種特別的定義方式,如代碼 8.20 所示。
代碼 8.20?實(shí)現(xiàn)?current_user?方法對應(yīng)的賦值操作
app/helpers/sessions_helper.rb
這段代碼看起來很奇怪,因?yàn)榇蠖鄶?shù)的編程語言并不允許在方法名中使用等號。其實(shí)這段代碼定義的?current_user=?方法是用來處理?current_user?賦值操作的。也就是說,如下的代碼
self.current_user = ...會自動轉(zhuǎn)換成下面這種形式
current_user=(...)就是直接調(diào)用?current_user=?方法,接受的參數(shù)是賦值語句右側(cè)的值,本例中是要登錄的用戶對象。current_user=?方法定義體內(nèi)只有一行代碼,即設(shè)定實(shí)例變量?@current_user?的值,以備后用。
在常見的 Ruby 代碼中,我們還會定義?current_user?方法,用來讀取?@current_user?的值,如代碼 8.21 所示。
代碼 8.21?嘗試定義?current_user?方法,不過我們不會使用這種方式
module SessionsHelperdef sign_in(user)...enddef current_user=(user)@current_user = userenddef current_user@current_user # Useless! Don't use this line.end end上面的做法其實(shí)就是實(shí)現(xiàn)了?attr_accessor?方法的功能(4.4.5 節(jié)介紹過)。5如果按照代碼 8.21 來定義?current_user?方法,會出現(xiàn)一個(gè)問題:程序不會記住用戶的登錄狀態(tài)。一旦用戶轉(zhuǎn)到其他的頁面,session 就失效了,會自動退出。若要避免這個(gè)問題,我們要使用代碼 8.19 中生成的記憶權(quán)標(biāo)查找用戶,如代碼 8.22 所示。
代碼 8.22?通過記憶權(quán)標(biāo)查找當(dāng)前用戶
app/helpers/sessions_helper.rb
代碼 8.22 中使用了一個(gè)常見但不是很容易理解的?||=(“or equals”)操作符(旁注 8.2中有詳細(xì)介紹)。使用這個(gè)操作符之后,當(dāng)且僅當(dāng)?@current_user?未定義時(shí)才會把通過記憶權(quán)標(biāo)獲取的用戶賦值給實(shí)例變量?@current_user。6也就是說,如下的代碼
@current_user ||= User.find_by_remember_token(cookies[:remember_token])只在第一次調(diào)用?current_user?方法時(shí)調(diào)用?find_by_remember_token?方法,如果后續(xù)再調(diào)用的話就直接返回?@current_user的值,而不必再查詢數(shù)據(jù)庫。7這種方式的優(yōu)點(diǎn)只有當(dāng)在一個(gè)請求中多次調(diào)用?current_user?方法時(shí)才能顯現(xiàn)。不管怎樣,只要用戶訪問了相應(yīng)的頁面,find_by_remember_token?方法都至少會執(zhí)行一次。
旁注 8.2?||=?操作符簡介
||=?操作符非常能夠體現(xiàn) Ruby 的特性,如果你打算長期進(jìn)行 Ruby 編程的話就要好好學(xué)習(xí)它的用法。初學(xué)時(shí)會覺得||=?很神秘,不過通過和其他操作符類比之后,你會發(fā)現(xiàn)也不是很難理解。
我們先來看一下改變已經(jīng)定義的變量時(shí)經(jīng)常使用的結(jié)構(gòu)。在很多程序中都會把變量自增一,如下所示
x = x + 1大多數(shù)語言都為這種操作提供了簡化的操作符,在 Ruby 中,可以按照下面的方式重寫(C、C++、Perl、Python、Java 等也如此):
x += 1其他操作符也有類似的簡化形式:
$ rails console>> x = 1=> 1>> x += 1=> 2>> x *= 3=> 6>> x -= 7=> -1上面的舉例可以概括為,x = x O y?和?x O=y?是等效的,其中?O?表示操作符。
在 Ruby 中還經(jīng)常會遇到這種情況,如果變量的值為 `nil` 則賦予其他的值,否則就不改變這個(gè)變量的值。[4.2.3 節(jié)](chapter4.html#sec-4-2-3) 中介紹過?||?或操作符,所以這種情況可以用如下的代碼表示:
>> @user=> nil>> @user = @user || "the user"=> "the user">> @user = @user || "another user"=> "the user"因?yàn)?nil?表示的布爾值是?false,所以第一個(gè)賦值操作等同于?nil || "the user",這個(gè)語句的計(jì)算結(jié)果是?"the user";類似的,第二個(gè)賦值操作等同于?"the user" || "another user",這個(gè)語句的計(jì)算結(jié)果還是?"the user",因?yàn)?"the user"?表示的布爾值是?true,這個(gè)或操作在執(zhí)行了第一個(gè)表達(dá)式之后就終止了。(或操作的執(zhí)行順序是從左至右,只要出現(xiàn)真值就會終止語句的執(zhí)行,這種方式稱作“短路計(jì)算(short-circuit evaluation)”。)
和上面的控制臺會話對比之后,我們可以發(fā)現(xiàn)?@user = @user || value?符合?x = x O y?的形式,只需把?O?換成?||,所以就得到了下面這種簡寫形式:
>> @user ||= "the user"=> "the user"不難理解吧!
譯者注:這里對?||=?的分析和 Peter Cooper 的分析有點(diǎn)差異,我推薦你看以下 Ruby Inside 中的《What Ruby’s ||= (Double Pipe / Or Equals) Really Does》一文。
8.2.4 改變導(dǎo)航鏈接
本小節(jié)我們要完成的是實(shí)現(xiàn)登錄、退出功能的最后一步,根據(jù)登錄狀態(tài)改變布局中的導(dǎo)航鏈接。如圖 8.3 所示,我們要在登錄和退出后顯示不同的導(dǎo)航,要添加指向列出所有用戶頁面的鏈接、到用戶設(shè)置頁面的鏈接(第九章加入),還有到當(dāng)前登錄用戶資料頁面的鏈接。加入這些鏈接之后,代碼 8.6 中的測試就可以通過了,這是本章目前為止測試首次變綠通過。
在網(wǎng)站的布局中改變導(dǎo)航鏈接需要用到 ERb 的 if-else 分支結(jié)構(gòu):
<% if signed_in? %> # Links for signed-in users <% else %> # Links for non-signed-in-users <% end %>若要上述代碼起作用,先要用?signed_in??方法。我們現(xiàn)在就來定義。
如果 session 中存有當(dāng)前用戶的話,就可以說用戶已經(jīng)登錄了。我們要判斷?current_user?的值是不是?nil,這里需要用到取反操作符,用感嘆號 ! 表示,一般讀作“bang”。只要?current_user?的值不是?nil,就說明用戶登錄了,如代碼 8.23 所示。
代碼 8.23?定義?signed_in??幫助方法
app/helpers/sessions_helper.rb
定義了?signed_in??方法后就可以著手修改布局中的導(dǎo)航了。我們要添加四個(gè)新鏈接,其中兩個(gè)鏈接的地址先不填(第九章再填):
<%= link_to "Users", '#' %> <%= link_to "Settings", '#' %>退出鏈接的地址使用代碼 8.2 中定義的?signout_path:
<%= link_to "Sign out", signout_path, method: "delete" %>(注意,我們還為退出鏈接指定了類型為 Hash 的參數(shù),指明點(diǎn)擊鏈接后發(fā)送的是 HTTP?DELETE?請求。8)最后,我們還要添加一個(gè)到資料頁面的鏈接:
<%= link_to "Profile", current_user %>這個(gè)鏈接我們本可以寫成
<%= link_to "Profile", user_path(current_user) %>不過我們可以直接把鏈接地址設(shè)為?current_user,Rails 會自動將其轉(zhuǎn)換成?user_path(current_user)。
在添加導(dǎo)航鏈接的過程中,我們還要使用 Bootstrap 實(shí)現(xiàn)下拉菜單的效果,具體的實(shí)現(xiàn)方式可以參閱 Bootstrap 的文檔。添加導(dǎo)航鏈接所需的代碼如代碼 8.24 所示。注意其中和 Bootstrap 下拉菜單有關(guān)的 CSS id 和 class。
代碼 8.24?根據(jù)登錄狀態(tài)改變導(dǎo)航鏈接
app/views/layouts/_header.html.erb
實(shí)現(xiàn)下拉菜單還要用到 Bootstrap 中的 JavaScript 代碼,我們可以編輯應(yīng)用程序的 JavaScript 文件,通過 asset pipeline 引入所需的文件,如代碼 8.25 所示。
代碼 8.25?把 Bootstrap 的 JavaScript 代碼加入?application.js
app/assets/javascripts/application.js
引入文件的功能是由 Sprockets 實(shí)現(xiàn)的,而文件本身是由?5.1.2 節(jié)中添加的?bootstrap-sass?gem 提供的。
添加了代碼 8.24 之后,所有的測試應(yīng)該都可以通過了:
$ bundle exec rspec spec/不過,如果你在瀏覽器中查看的話,網(wǎng)站還不能正常使用。這是因?yàn)椤坝涀∥摇边@個(gè)功能要求用戶記錄的記憶權(quán)標(biāo)屬性不為空,而現(xiàn)在這個(gè)用戶是在?7.4.3 節(jié)中創(chuàng)建的,遠(yuǎn)在實(shí)現(xiàn)生成記憶權(quán)標(biāo)的回調(diào)函數(shù)之前,所以記憶權(quán)標(biāo)還沒有值。為了解決這個(gè)問題,我們要再次保存用戶,觸發(fā)代碼 8.18 中的?before_save?回調(diào)函數(shù),生成用戶的記憶權(quán)標(biāo):
$ rails console >> User.first.remember_token => nil >> User.all.each { |user| user.save(validate: false) } >> User.first.remember_token => "Im9P0kWtZvD0RdyiK9UHtg"我們遍歷了數(shù)據(jù)庫中的所有用戶,以防之前創(chuàng)建了多個(gè)用戶。注意,我們向?save?方法傳入了一個(gè)參數(shù)。如果不指定這個(gè)參數(shù)的話,就無法保存,因?yàn)槲覀儧]有指定密碼及密碼確認(rèn)的值。在實(shí)際的網(wǎng)站中,我們根本就無法獲知用戶的密碼,但是我們還是要執(zhí)行保存操作,這時(shí)就要指定?validate: false?參數(shù)跳過 Active Record 的數(shù)據(jù)驗(yàn)證(更多內(nèi)容請閱讀 Rails API 中關(guān)于 save 的文檔)。
做了上述修正之后,登錄的用戶就可以看到代碼 8.24 中添加的新鏈接和下拉菜單了,如圖 8.9 所示。
圖 8.9:登錄后顯示了新鏈接和下拉菜單
現(xiàn)在你可以驗(yàn)證一下是否可以登錄,然后關(guān)閉瀏覽器,再打開看一下是否還是登入的狀態(tài)。如果需要,你還可以直接查看瀏覽器的 cookies,如圖 8.10 所示。
圖 8.10:查看瀏覽器中的記憶權(quán)標(biāo) cookie
8.2.5 注冊后直接登錄
雖然現(xiàn)在基本完成了用戶身份驗(yàn)證功能,但是新注冊的用戶可能還是會困惑,為什么注冊后沒有登錄呢。在實(shí)現(xiàn)退出功能之前,我們還要實(shí)現(xiàn)注冊后直接登錄的功能。我們要先編寫測試,在身份驗(yàn)證的測試中加入一行代碼,如代碼 8.26 所示。這段代碼要用到第七章一個(gè)練習(xí)中的“after saving the user”?describe?塊(參見代碼 7.32),如果之前你沒有做這個(gè)練習(xí)的話,現(xiàn)在請?zhí)砑酉鄳?yīng)的測試代碼。
代碼 8.26?測試剛注冊的用戶是否會自動登錄
spec/requests/user_pages_spec.rb
我們檢測頁面中有沒有退出鏈接,來驗(yàn)證用戶注冊后是否登錄了。
有了?8.2 節(jié)中定義的?sign_in?方法,要讓這個(gè)測試通過就很簡單了:在用戶保存到數(shù)據(jù)庫中之后加上?sign_in @user?就可以了,如代碼 8.27 所示。
代碼 8.27?用戶注冊后直接登錄
app/controllers/users_controller.rb
8.2.6 退出
在?8.1 節(jié)中介紹過,我們要實(shí)現(xiàn)的身份驗(yàn)證機(jī)制會記住用戶的登錄狀態(tài),直到用戶點(diǎn)擊退出鏈接為止。本小節(jié),我們就要實(shí)現(xiàn)退出功能。
目前為止,Sessions 控制器的動作完全遵從了 REST 架構(gòu),new?動作用于登錄頁面,create?動作實(shí)現(xiàn)登錄的過程。我們還要添加一個(gè)?destroy?動作,刪除 session,實(shí)現(xiàn)退出功能。針對退出功能的測試,我們可以檢測點(diǎn)擊退出鏈接后,頁面中是否有登錄鏈接,如代碼 8.28 所示。
代碼 8.28?測試用戶退出
spec/requests/authentication_pages_spec.rb
登錄功能是由?sign_in?方法實(shí)現(xiàn)的,對應(yīng)的,我們會使用?sign_out?方法實(shí)現(xiàn)退出功能,如代碼 8.29 所示。
代碼 8.29?銷毀 session,實(shí)現(xiàn)退出功能
app/controllers/sessions_controller.rb
和其他身份驗(yàn)證相關(guān)的方法一樣,我們會在 Sessions 控制器的幫助方法模塊中定義?sign_out?方法。方法本身的實(shí)現(xiàn)很簡單,我們先把當(dāng)前用戶設(shè)為?nil,然后在 cookies 上調(diào)用?delete?方法從 session 中刪除記憶權(quán)標(biāo),如代碼 8.30 所示。(其實(shí)這里沒必要把當(dāng)前用戶設(shè)為?nil,因?yàn)樵?destroy?動作中我們加入了轉(zhuǎn)向操作。這里我們之所以這么做是為了兼容不轉(zhuǎn)向的退出操作。)
代碼 8.30?Sessions 幫助方法模塊中定義的?sign_out?方法
app/helpers/sessions_helper.rb
現(xiàn)在,注冊、登錄和退出三個(gè)功能都實(shí)現(xiàn)了,測試也應(yīng)該可以通過了:
$ bundle exec rspec spec/有一點(diǎn)需要注意,我們的測試覆蓋了身份驗(yàn)證機(jī)制的大多數(shù)功能,但不是全部。例如,我們沒有測試“記住我”到底記住了多久,也沒測試是否設(shè)置了記憶權(quán)標(biāo)。我們當(dāng)然可以加入這些測試,不過經(jīng)驗(yàn)告訴我們,直接測試 cookie 的值不可靠,而且要依賴具體的實(shí)現(xiàn)細(xì)節(jié),而實(shí)現(xiàn)的方法在不同的 Rails 版本中可能會有所不同,即便應(yīng)用程序可以使用,測試卻會失敗。所以我們只關(guān)注抽象的功能(驗(yàn)證用戶是否可以登錄,是否可以保持登錄狀態(tài),以及是否可以退出),編寫的測試沒必要針對實(shí)現(xiàn)的細(xì)節(jié)。
8.3 Cucumber 簡介(選讀)
前面兩節(jié)基本完成了示例程序的身份驗(yàn)證系統(tǒng),這一節(jié)我們將介紹如何使用 Cucumber 編寫登錄測試。Cucumber 是一個(gè)流行的行為驅(qū)動開發(fā)(Behavior-driven Development, BDD)工具,在 Ruby 社區(qū)中占據(jù)著一定的地位。本節(jié)的內(nèi)容是選讀的,你可以直接跳過,不會影響后續(xù)內(nèi)容。
Cucumber 使用純文本的故事(story)描述應(yīng)用程序的行為,很多 Rails 開發(fā)者發(fā)現(xiàn)使用 Cucumber 處理客戶案例時(shí)十分方便,因?yàn)榉羌夹g(shù)人員也能讀懂這些行為描述,Cucumber 測試可以用于和客戶溝通,甚至經(jīng)常是由客戶來編寫的。當(dāng)然,使用不是純 Ruby 代碼組成的測試框架有它的局限性,而且我還發(fā)現(xiàn)純文本的故事很啰嗦。不管怎樣,Cucumber 在 Ruby 測試工具中還是有其存在意義的,我特別欣賞它對抽象行為的關(guān)注,而不是死盯底層的具體實(shí)現(xiàn)。
因?yàn)楸緯亟榻B的是 RSpec 和 Capybara,所以本節(jié)對 Cucumber 的介紹很淺顯,也不完整,很多內(nèi)容都沒做詳細(xì)說明,我只是想讓你體驗(yàn)一下如何使用 Cucumber,如果你感覺不錯(cuò),可以閱讀專門介紹 Cucumber 的書籍深入學(xué)習(xí)。(一般我會推薦你閱讀 David Chelimsky 的《The RSpec Book》,Ryan Bigg 和 Yehuda Katz 的《Rails 3 in Action》,以及 Matt Wynne 和 Aslak Helles?y 的《The Cucumber Book》。)
8.3.1 安裝和設(shè)置
若要安裝 Cucumber,需要在?Gemfile?的?:test?組中加入?cucumber-rails?和?database_cleaner?這兩個(gè) gem,如代碼 8.31 所示。
代碼 8.31?在?Gemfile?中加入?cucumber-rails
. . . group :test do...gem 'cucumber-rails', '1.2.1', require: falsegem 'database_cleaner', '0.7.0' end . . .然后和之前一樣運(yùn)行一下命令安裝:
$ bundle install如果要在程序中使用 Cucumber,我們先要生成一些所需的文件和文件夾:
$ rails generate cucumber:install這個(gè)命令會在根目錄中創(chuàng)建?features?文件夾,Cucumber 相關(guān)的文件都會存在這個(gè)文件夾中。
8.3.2 功能和步驟定義
Cucumber 中的“功能(feature)”就是希望應(yīng)用程序?qū)崿F(xiàn)的行為,使用一種名為 Gherkin 的純文本語言編寫。使用 Gherkin 編寫的測試和寫的很好的 RSpec 測試用例差不多,不過因?yàn)?Gherkin 是純文本,所以特別適合那些不是很懂 Ruby 代碼而可以理解英語的人使用。
下面我們要編寫一些 Cucumber 功能,實(shí)現(xiàn)代碼 8.5 和代碼 8.6 中針對登錄功能的部分測試用例。首先,我們在?features文件夾中新建名為?signing_in.feature?的文件。
Cucumber 的功能由一個(gè)簡短的描述文本開始,如下所示:
Feature: Signing in然后再添加一定數(shù)量相對獨(dú)立的場景(scenario)。例如,要測試登錄失敗的情況,我們可以按照如下的方式編寫場景:
Scenario: Unsuccessful signin Given a user visits the signin page When he submits invalid signin information Then he should see an error message類似的,測試登錄成功時(shí),我們可以加入如下的場景:
Scenario: Successful signin Given a user visits the signin page And the user has an account And the user submits valid signin information Then he should see his profile page And he should see a signout link把上述的文本放在一起,就組成了代碼 8.32 所示的 Cucumber 功能文件。
代碼 8.32?測試用戶登錄功能
features/signing_in.feature
然后使用?cucumber?命令運(yùn)行這個(gè)功能:
$ bundle exec cucumber features/上述命令和執(zhí)行 RSpec 測試的命令類似:
$ bundle exec rspec spec/提示一下,Cucumber 和 RSpec 一樣,可以通過 Rake 命令執(zhí)行:
$ bundle exec rake cucumber(鑒于某些原因,我經(jīng)常使用的命令是?rake cucumber:ok。)
我們只是寫了一些純文本,所以毫不意外,Cucumber 場景現(xiàn)在不會通過。若要讓測試通過,我們要新建一個(gè)步驟定義文件,把場景中的純文本和 Ruby 代碼對應(yīng)起來。步驟定義文件存放在?features/step_definition?文件夾中,我們要將其命名為?authentication_steps.rb。
以?Feature?和?Scenario?開頭的行基本上只被視作文檔,其他的行則都要和 Ruby 代碼對應(yīng)。例如,功能文件中下面這行
Given a user visits the signin page對應(yīng)到步驟定義中的
Given /?a user visits the signin page$/ dovisit signin_path end在功能文件中,Given?只是普通的字符串,而在步驟定義中?Given?則是一個(gè)方法,可以接受一個(gè)正則表達(dá)式作為參數(shù),后面還可以跟著一個(gè)塊。Given?方法的正則表達(dá)式參數(shù)是用來匹配功能文件中某個(gè)特定行的,塊中的代碼則是實(shí)現(xiàn)描述的行為所需的 Ruby 代碼。本例中的“a user visits the signin page”是由下面這行代碼實(shí)現(xiàn)的:
visit signin_path你可能覺得這行代碼很眼熟,不錯(cuò),這就是前面用過的 Capybara 提供的方法,Cucumber 的步驟定義文件會自動引入 Capybara。接下來的兩行代碼實(shí)現(xiàn)也同樣眼熟。如下的場景步驟:
When he submits invalid signin information Then he should see an error message對應(yīng)到步驟定義文件中的
When /?he submits invalid signin information$/ doclick_button "Sign in" endThen /?he should see an error message$/ dopage.should have_selector('div.alert.alert-error') end上面這段代碼的第一步還是用了 Capybara,第二步則結(jié)合了 Capybara 的?page?和 RSpec。很明顯,之前我們使用 RSpec 和 Capybara 編寫的測試,在 Cucumber 中也是有用武之地的。
場景中接下來的步驟也可以做類似的處理。最終的步驟定義文件如代碼 8.33 所示。你可以一次只添加一個(gè)步驟,然后執(zhí)行下面的代碼,直到測試都通過為止:
$ bundle exec cucumber features/代碼 8.33?使登錄功能通過的步驟定義
features/step_definitions/authentication_steps.rb
添加了代碼 8.33,Cucumber 測試應(yīng)該就可以通過了:
$ bundle exec cucumber features/8.3.3 小技巧:自定義 RSpec 匹配器
編寫了一些簡單的 Cucumber 場景之后,我們來和相應(yīng)的 RSpec 測試用例對比一下。先看一下代碼 8.32 中的 Cucumber 功能和代碼 8.33 中的步驟定義,然后再看一下如下的 RSpec 集成測試:
describe "Authentication" dosubject { page }describe "signin" dobefore { visit signin_path }describe "with invalid information" dobefore { click_button "Sign in" }it { should have_selector('title', text: 'Sign in') }it { should have_selector('div.alert.alert-error', text: 'Invalid') }enddescribe "with valid information" dolet(:user) { FactoryGirl.create(:user) }before dofill_in "Email", with: user.emailfill_in "Password", with: user.passwordclick_button "Sign in"endit { should have_selector('title', text: user.name) }it { should have_selector('a', 'Sign out', href: signout_path) }endend end由此你大概就可以看出 Cucumber 和集成測試各自的優(yōu)缺點(diǎn)了。Cucumber 功能可讀性很好,但是卻和測試代碼分隔開了,同時(shí)削弱了功能和測試代碼的作用。我覺得 Cucumber 測試讀起來很順口,但是寫起來怪怪的;而集成測試讀起來不太順口,但是很容易編寫。
Cucumber 把功能描述和步驟定義分開,可以很好的實(shí)現(xiàn)抽象層面的行為。例如,下面這個(gè)描述
Then he should see an error message表達(dá)的意思是,期望看到一個(gè)錯(cuò)誤提示信息。如下的步驟定義則檢測了能否實(shí)現(xiàn)這個(gè)期望:
Then /?he should see an error message$/ dopage.should have_selector('div.alert.alert-error', text: 'Invalid') endCucumber 這種分離方式特別便捷的地方在于,只有步驟定義是依賴具體實(shí)現(xiàn)的,所以假如我們修改了錯(cuò)誤提示信息所用的 CSS class,功能描述文件是不需要修改的。
那么,如果你只是想檢測頁面中是否顯示有錯(cuò)誤提示信息,就不想在多個(gè)地方重復(fù)的編寫下面的測試:
should have_selector('div.alert.alert-error', text: 'Invalid')如果你真的這么做了,就把測試和具體的實(shí)現(xiàn)綁死了,一旦改變了實(shí)現(xiàn)方式,就要到處修改測試。在 RSpec 中,可以自定義匹配器來解決這個(gè)問題,我們可以直接這么寫:
should have_error_message('Invalid')我們可以在?5.3.4 節(jié)?中定義?full_title?測試幫助方法的文件中定義這個(gè)匹配器,代碼如下:
RSpec::Matchers.define :have_error_message do |message|match do |page|page.should have_selector('div.alert.alert-error', text: message)end end我們還可以為一些常用的操作定義幫助方法,例如:
def valid_signin(user)fill_in "Email", with: user.emailfill_in "Password", with: user.passwordclick_button "Sign in" end最終的文件如代碼 8.34 所示(把?5.6 節(jié)中的代碼 5.37 和代碼 5.38 合并了)。我覺得這種方法比 Cucumber 的步驟定義還要靈活,特別是當(dāng)匹配器和幫助方法可以接受一個(gè)參數(shù)時(shí),例如?valid_signin(user)。我們也可以用步驟定義中的正則表達(dá)式匹配來實(shí)現(xiàn)這種功能,不過太過繁雜。
代碼 8.34?添加一個(gè)幫助函數(shù)和一個(gè) RSpec 自定義匹配器
spec/support/utilities.rb
添加了代碼 8.34 之后,我們就可以直接寫
it { should have_error_message('Invalid') }和
describe "with valid information" dolet(:user) { FactoryGirl.create(:user) }before { valid_signin(user) }...還有很多測試用例把測試和具體的實(shí)現(xiàn)綁縛在一起了,我們會在?8.5 節(jié)的練習(xí)中徹底的搜查現(xiàn)有的測試組件,使用自定義匹配器和幫助方法解耦測試和具體實(shí)現(xiàn)。
8.4 小結(jié)
本章我們介紹了很多基礎(chǔ)知識,也為稍顯簡陋的應(yīng)用程序?qū)崿F(xiàn)了注冊和登錄功能。實(shí)現(xiàn)了用戶身份驗(yàn)證功能后,我們就可以根據(jù)登錄狀態(tài)和用戶的身份限制對特定頁面的訪問權(quán)限。在實(shí)現(xiàn)限制訪問的過程中,我們會為用戶添加編輯個(gè)人信息的功能,還會為管理員添加刪除用戶的功能。這些是第九章的主要內(nèi)容。
在繼續(xù)閱讀之前,先把本章的改動合并到主分支吧:
$ git add . $ git commit -m "Finish sign in" $ git checkout master $ git merge sign-in-out然后再推送到 GitHub 和 Heroku “生產(chǎn)環(huán)境”服務(wù)器:
$ git push $ git push heroku $ heroku run rake db:migrate如果之前你在生產(chǎn)服務(wù)器中注冊過用戶,我建議你按照?8.2.4 節(jié)中介紹的方法,為各用戶生成記憶權(quán)標(biāo),不能用本地的控制臺,而要用 Heroku 的控制臺:
$ heroku run console >> User.all.each { |user| user.save(validate: false) }8.5 練習(xí)
總結(jié)
以上是生活随笔為你收集整理的Python计算机视觉:第八章 图像类容分类的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Python计算机视觉:第六章 图像聚类
- 下一篇: Python计算机视觉:第九章 图像分割