Python计算机视觉:第八章 图像类容分类
第八章 圖像類容分類
8.1 K最近鄰
K最近鄰是分類中最簡單且常用的方法之一。
8.1.1 一個簡單的二維例子
# -*- 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.]]第七章已經實現了注冊新用戶的功能,本章我們要為已注冊的用戶提供登錄和退出功能。實現登錄功能之后,就可以根據登錄狀態和當前用戶的身份定制網站的內容了。例如,本章我們會更新網站的頭部,顯示“登錄”或“退出”鏈接,以及到個人資料頁面的鏈接;在第十章中,會根據當前登錄用戶的 id 創建關聯到這個用戶的微博;在第十一章,我們會實現當前登錄用戶關注其他用戶的功能,實現之后,在首頁就可以顯示被關注用戶發表的微博了。
實現登錄功能之后,還可以實現一種安全機制,即根據用戶的身份限制可以訪問的頁面,例如,在第九章中會介紹如何實現只有登入的用戶才能訪問編輯用戶資料的頁面。登錄系統還可以賦予管理員級別的用戶特別的權限,例如刪除用戶(也會在第九章中實現)等。
實現驗證系統的核心功能之后,我們會簡要的介紹一下 Cucumber 這個流行的行為驅動開發(Behavior-driven Development, BDD)系統,使用 Cucumber 重新實現之前的一些 RSpec 集成測試,看一下這兩種方式有何不同。
和之前的章節一樣,我們會在一個新的從分支中工作,本章結束后再將其合并到主分支中:
$ git checkout -b sign-in-out8.1 session 和登錄失敗
[session](http://en.wikipedia.org/wiki/Session(computerscience)) 是兩臺電腦(例如運行有網頁瀏覽器的客戶端電腦和運行 Rails 的服務器)之間的半永久性連接,我們就是利用它來實現“登錄”這一功能的。網絡中常見的 session 處理方式有好幾種:可以在用戶關閉瀏覽器后清除 session;也可以提供一個“記住我”單選框讓用戶選擇永遠保存,直到用戶退出后 session 才會失效。1?在示例程序中我們選擇使用第二種處理方式,即用戶登錄后,會永久的記住登錄狀態,直到用戶點擊“退出”鏈接之后才清除 session。(在?8.2.1 節中會介紹“永久”到底有多久。)
很顯然,我們可以把 session 視作一個符合 REST 架構的資源,在登錄頁面中準備一個新的 session,登錄后創建這個 session,退出則會銷毀 session。不過 session 和 Users 資源有所不同,Users 資源使用數據庫(通過 User 模型)持久的存儲數據,而 Sessions 資源是利用?cookie?來存儲數據的。cookie 是存儲在瀏覽器中的簡單文本。實現登錄功能基本上就是在實現基于 cookie 的驗證機制。在本節及接下來的一節中,我們會構建 Sessions 控制器,創建登錄表單,還會實現控制器中相關的動作。在?8.2 節中會加入處理 cookie 所需的代碼。
8.1.1 Sessions 控制器
登錄和退出功能其實是由 Sessions 控制器中相應的動作處理的,登錄表單在?new?動作中處理(本節的內容),登錄的過程就是向?create?動作發送?POST?請求(8.1 節和?8.2 節),退出則是向?destroy?動作發送?DELETE?請求(8.2.6 節)。(HTTP 請求和 REST 動作之間的對應關系可以查看表格 7.1。)首先,我們要生成 Sessions 控制器,以及驗證系統所需的集成測試:
$ rails generate controller Sessions --no-test-framework $ rails generate integration_test authentication_pages參照?7.2 節中的“注冊”頁面,我們要創建一個登錄表單,用來生成新的 session。注冊表單的構思圖如圖 8.1 所示。
“登錄”頁面的地址由?signin_path(稍后定義)獲取,和之前一樣,我們要先編寫相應的測試,如代碼 8.1 所示。(可以和代碼 7.6 中對“注冊”頁面的測試比較一下。)
圖 8.1:注冊表單的構思圖
代碼 8.1?對?new?動作和對應視圖的測試
spec/requests/authentication_pages_spec.rb
現在測試是失敗的:
$ bundle exec rspec spec/要讓代碼 8.1 中的測試通過,首先,我們要為 Sessions 資源設置路由,還要修改“登錄”頁面具名路由的名稱,將其映射到 Sessions 控制器的?new?動作上。和 Users 資源一樣,我們可以使用?resources?方法設置標準的 REST 動作:
resources :sessions, only: [:new, :create, :destroy]因為我們沒必要顯示或編輯 session,所以我們對動作的種類做了限制,為?resources?方法指定了?:only?選項,只創建new、create?和?destroy?動作。最終的結果,包括登錄和退出具名路由的設置,如代碼 8.2 所示。
代碼 8.2?設置 session 相關的路由
config/routes.rb
注意,設置退出路由那行使用了?via :delete,這個參數指明?destroy?動作要使用?DELETE?請求。
代碼 8.2 中的路由設置會生成類似表格 7.1?所示的URI 地址和動作的對應關系,如表格 8.1?所示。注意,我們修改了登錄和退出具名路由,而創建 session 的路由還是使用默認值。
| GET | /signin | signin_path | new | 創建新 session 的頁面(登錄) |
| POST | /sessions | sessions_path | create | 創建 session |
| DELETE | /signout | signout_path | destroy | 刪除 session(退出) |
表格 8.1:代碼 8.2 中的設置生成的符合 REST 架構的路由關系
為了讓代碼 8.1 中的測試通過,我們還要在 Sessions 控制器中加入?new?動作,相應的代碼如代碼 8.3 所示(同時也定義了create?和?destroy?動作)。
代碼 8.3?沒什么內容的 Sessions 控制器
app/controllers/sessions_controller.rb
接下來還要創建“登錄”頁面的視圖,因為“登錄”頁面的目的是創建新 session,所以創建的視圖位于app/views/sessions/new.html.erb。在視圖中我們要顯示網頁的標題和一個一級標頭,如代碼 8.4 所示。
代碼 8.4?“登錄”頁面的視圖
app/views/sessions/new.html.erb
現在代碼 8.1 中的測試應該可以通過了,接下來我們要編寫登錄表單。
$ bundle exec rspec spec/8.1.2 測試登錄功能
對比圖 8.1 和圖 7.11 之后,我們發現登錄表單和注冊表單外觀上差不多,只是少了兩個字段,只有 Email 地址和密碼字段。和注冊表單一樣,我們可以使用 Capybara 填寫表單,再點擊按鈕進行測試。
在測試的過程中,我們不得不向程序中加入相應的功能,這也正是 TDD 帶來的好處之一。我們先來測試填寫不合法數據的登錄過程,構思圖如圖 8.2 所示。
圖 8.2:注冊失敗頁面的構思圖
從圖 8.2 我們可以看出,如果提交的數據不正確,我們會重新渲染“注冊”頁面,還會顯示一個錯誤提示消息。這個錯誤提示是 Flash 消息,我們可以通過下面的測試驗證:
it { should have_selector('div.alert.alert-error', text: 'Invalid') }(在第七章練習中的代碼 7.32 中出現過類似的代碼。)我們要查找的元素是:
div.alert.alert-error前面介紹過,這里的點號代表 CSS 中的 class(參見?5.1.2 節),你也許猜到了,這里我們要查找的是同時具有?alert?和alert-error?class 的?div?元素。而且我們還檢測了錯誤提示消息中是否包含了?"Invalid"?這個詞。所以,上述測試是檢測頁面中是否有下面這個元素的:
<div class="alert alert-error">Invalid...</div>代碼 8.5 是針對標題和 Flash 消息的測試。我們可以看出,這些代碼缺少了一個很重要的部分,會在?8.1.5 節中說明。
代碼 8.5?登錄失敗時的測試
spec/requests/authentication_pages_spec.rb
測試了登錄失敗的情況,下面我們要測試登錄成功的情況了。我們要測試登錄成功后是否轉向了用戶資料頁面(從頁面的標題判斷,標題中應該包含用戶的名字),還要測試網站的導航中是否有以下三個變化:
(對“設置(Settings)”鏈接的測試會在?9.1 節中實現,對“所有用戶(Users)”鏈接的測試會在?9.3 節中實現。)如上變化的構思圖如圖 8.3 所示。2注意,“退出”和“個人資料”鏈接位于“賬戶(Account)”下拉菜單中。在?8.2.4 節中會介紹如何通過 Bootstrap 實現這種下拉菜單。
圖 8.3:登錄成功后顯示的用戶資料頁面構思圖
對登錄成功時的測試如代碼 8.6 所示。
代碼 8.6?登錄成功時的測試
spec/requests/authentication_pages_spec.rb
在代碼 8.6 中用到了?have_link?方法,它的第一參數是鏈接文本,第二個參數是可選的?:href,指定鏈接的地址,因此如下的代碼
it { should have_link('Profile', href: user_path(user)) }確保了頁面中有一個?a?元素,鏈接到指定的 URI 地址。這里我們要檢測的是一個指向用戶資料頁面的鏈接。
8.1.3 登錄表單
寫完測試之后,我們就可以創建登錄表單了。在代碼 7.17 中,注冊表單使用了?form_for?幫助函數,并指定其參數為?@user變量:
<%= form_for(@user) do |f| %> . . . <% end %>注冊表單和登錄表單的區別在于,程序中沒有 Session 模型,因此也就沒有類似?@user?的變量。也就是說,在構建登錄表單時,我們要給?form_for?提供更多的信息。一般來說,如下的代碼
form_for(@user)Rails 會自動向 /users 地址發送?POST?請求。對于登錄表單,我們則要明確的指定資源的名稱以及相應的 URI 地址:
form_for(:session, url: sessions_path)(創建表單還有另一種方法,不用?form_for,而用?form_tag。form_tag?也是 Rails 程序常用的方法,不過換用?form_tag?之后就和注冊表單有很多不同之處了,我現在是想使用相似的代碼構建登錄表單。使用?form_tag?構建登錄表單會留作練習(參見?8.5 節)。)
使用上述這種?form_for?形式,參照代碼 7.17 中的注冊表單,很容易的就能編寫一個符合圖 8.1 的登錄表單,如代碼 8.7 所示。
代碼 8.7?注冊表單的代碼
app/views/sessions/new.html.erb
注意,為了訪客的便利,我們還加入了到“注冊”頁面的鏈接。代碼 8.7 中的登錄表單效果如圖 8.4 所示。
圖 8.4:登錄表單(/signup)
用的多了你就不會老是查看 Rails 生成的 HTML(你會完全信任所用的幫助函數可以正確的完成任務),不過現在還是來看一下登錄表單的 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。你可能已經猜到了,提交登錄表單后會生成一個?params?Hash,其中params[:session][:email]?和?params[:session][:password]?分別對應了 Email 和密碼字段。
8.1.4 分析表單提交
和創建用戶類似,創建 session 時先要處理提交不合法數據的情況。我們已經編寫了對提交不合法數據的測試(參見代碼 8.5),也添加了有幾處難理解但還算簡單的代碼讓測試通過了。下面我們就來分析一下表單提交的過程,然后為登錄失敗添加失敗提示信息(如圖 8.2)。最后,以此為基礎,驗證提交的 Email 和密碼,處理登錄成功的情況(參見?8.2 節)。
首先,我們來編寫 Sessions 控制器的?create?動作,如代碼 8.9 所示,現在只是直接渲染登錄頁面。在瀏覽器中訪問 /sessions/new,然后提交空表單,顯示的頁面如圖 8.5 所示。
代碼 8.9?Sessions 控制器中?create?動作的初始版本
app/controllers/sessions_controller.rb
圖 8.5:代碼 8.9 中的?create?動作顯示的登錄失敗后的頁面
仔細看一下圖 8.5 中顯示的調試信息,你會發現,如在?8.1.3 節末尾說過的,表單提交后會生成?params?Hash,Email 和密碼都在?:session?鍵中:
--- session:email: ''password: '' commit: Sign in action: create controller: sessions和注冊表單類似,這些參數是一個嵌套的 Hash,在代碼 4.6 中見過。params?包含了如下的嵌套 Hash:
{ session: { password: "", email: "" } }也就是說
params[:session]本身就是一個 Hash:
{ password: "", email: "" }所以,
params[:session][:email]就是提交的 Email 地址,而
params[:session][:password]就是提交的密碼。
也就是說,在?create?動作中,params?包含了使用 Email 和密碼驗證用戶身份所需的全部數據。幸運的是,我們已經定義了身份驗證過程中所需的兩個方法,即由 Active Record 提供的?User.find_by_email(參見?6.1.4 節),以及由has_secure_password?提供的?authenticate?方法(參見?6.3.3 節)。我們之前介紹過,如果提交的數據不合法,authenticate?方法會返回?false。基于以上的分析,我們計劃按照如下的方式實現用戶登錄功能:
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 地址從數據庫中取出相應的用戶。第二行是 Ruby 中經常使用的語句形式:
user && user.authenticate(params[:session][:password])我們使用?&&(邏輯與)檢測獲取的用戶是否合法。因為除了?nil?和?false?之外的所有對象都被視作?true,上面這個語句可能出現的結果如表格 8.2所示。我們可以從表格 8.2 中看出,當且僅當數據庫中存在提交的 Email 并提交了對應的密碼時,這個語句才會返回?true。
| 不存在 | 任意值 | nil && [anything] == false |
| 存在 | 錯誤的密碼 | true && false == false |
| 存在 | 正確的密碼 | true && true == true |
表格 8.2:user && user.authenticate(...)?可能出現的結果
8.1.5 顯示 Flash 消息
在?7.3.2 節中,我們使用 User 模型的數據驗證信息來顯示注冊失敗時的提示信息。這些錯誤提示信息是關聯在某個 Active Record 對象上的,不過這種方式不可以用在 session 上,因為 session 不是 Active Record 模型。我們要采取的方法是,在登錄失敗時,把錯誤提示信息賦值給 Flash 消息。代碼 8.10 顯示的是我們首次嘗試實現這種方法所用的代碼,其中有個小小的錯誤。
代碼 8.10?嘗試處理登錄失敗(有個小小的錯誤)
app/controllers/sessions_controller.rb
布局中已經加入了顯示 Flash 消息的局部視圖,所以無需其他修改,上述 Flash 錯誤提示消息就會顯示出來,而且因為使用了 Bootstrap,這個錯誤消息的樣式也很美觀(如圖 8.6)。
圖 8.6:登錄失敗后顯示的 Flash 消息
不過,就像代碼 8.10 中的注釋所說,這些代碼還有問題。顯示的頁面看起來很正常啊,那么,問題出現在哪兒呢?問題的關鍵在于,Flash 消息在一個請求的生命周期內是持續存在的,而重新渲染頁面(使用?render?方法)和代碼 7.27 中的轉向不同,它不算新的請求,你會發現這個 Flash 消息存在的時間比設想的要長很多。例如,我們提交了不合法的登錄信息,Flash 消息生成了,然后在登錄頁面中顯示出來(如圖 8.6),這時如果我們點擊鏈接轉到其他頁面(例如“首頁”),這只算是表單提交后的第一次請求,所以頁面中還是會顯示 Flash 消息(如圖 8.7)。
圖 8.7:仍然顯示有 Flash 消息的頁面
Flash 消息沒有按預期消失算是程序的一個 bug,在修正之前,我們最好編寫一個測試來捕獲這個錯誤。現在,登錄失敗時的測試是可以通過的:
$ bundle exec rspec spec/requests/authentication_pages_spec.rb \ > -e "signin with invalid information"不過程序中有錯誤,測試應該是失敗的,所以我們要編寫一個能夠捕獲這種錯誤的測試。幸好,捕獲這種錯誤正是集成測試的拿手好戲,所用的代碼如下:
describe "after visiting another page" dobefore { click_link "Home" }it { should_not have_selector('div.alert.alert-error') } end提交不合法的登錄信息之后,這個測試用例會點擊網站中的“首頁”鏈接,期望顯示的頁面中沒有 Flash 錯誤消息。添加上述測試用例的測試文件如代碼 8.11 所示。
代碼 8.11?登錄失敗時的合理測試
spec/requests/authentication_pages_spec.rb
新添加的測試和預期一致,是失敗的:
$ bundle exec rspec spec/requests/authentication_pages_spec.rb \ > -e "signin with invalid information"要讓這個測試通過,我們要用?flash.now?替換?flash。flash.now?就是專門用來在重新渲染的頁面中顯示 Flash 消息的,在發送新的請求之后,Flash 消息便會消失。正確的?create?動作代碼如代碼 8.12 所示。
代碼 8.12?處理登錄失敗所需的正確代碼
app/controllers/sessions_controller.rb
現在登錄失敗時的所有測試應該都可以通過了:
$ bundle exec rspec spec/requests/authentication_pages_spec.rb \ > -e "with invalid information"8.2 登錄成功
上一節處理了登錄失敗的情況,這一節我們要處理登錄成功的情況了。實現用戶登錄的過程是本書目前為止最考驗 Ruby 編程能力的部分,你要堅持讀完本節,做好心理準備,付出大量的腦力勞動。幸好,第一步還算是簡單的,完成 Sessions 控制器的?create?動作沒什么難的,不過還是需要一點小技巧。
我們需要把代碼 8.12 中處理登錄成功分支中的注釋換成具體的代碼,使用?sign_in?方法實現登錄操作,然后轉向用戶的資料頁面,如代碼 8.13 所示。這就是我們使用的技巧,使用還沒定義的方法?sign_in。本節后面的內容會定義這個方法。
代碼 8.13?完整的?create?動作代碼(還不能正常使用)
app/controllers/sessions_controller.rb
8.2.1 “記住我”
現在我們要開始實現登錄功能了,第一步是實現“記住我”這個功能,即用戶登錄的狀態會被“永遠”記住,直到用戶點擊“退出”鏈接為止。實現登錄功能用到的函數已經超越了傳統的 MVC 架構,其中一些函數要同時在控制器和視圖中使用。在4.2.5 節中介紹過,Ruby 支持模塊(module)功能,打包一系列函數,在不同的地方引入。我們會利用模塊來打包用戶身份驗證相關的函數。我們當然可以創建一個新的模塊,不過 Sessions 控制器已經提供了一個名為?SessionsHelper?的模塊,而且這個模塊中的幫助方法會自動引入 Rails 程序的視圖中。所以,我們就直接使用這個現成的模塊,然后在 Application 控制器中引入,如代碼 8.14 所示。
代碼 8.14?在 Application 控制器中引入 Sessions 控制器的幫助方法模塊
app/controllers/application_controller.rb
默認情況下幫助函數只可以在視圖中使用,不能在控制器中使用,而我們需要同時在控制器和視圖中使用幫助函數,所以我們就手動引入幫助函數所在的模塊。
因為 HTTP 是無狀態的協議,所以如果應用程序需要實現登錄功能的話,就要找到一種方法記住用戶的狀態。維持用戶登錄狀態的方法之一,是使用常規的 Rails session(通過?session?函數),把用戶的 id 保存在“記憶權標(remember token)”中:
session[:remember_token] = user.idsession?對象把用戶 id 保存在瀏覽器的 cookie 中,這樣在網站的所有頁面就都可以使用了。瀏覽器關閉后,cookie 也隨之失效。在網站中的任何頁面,只需調用?User.find(session[:remember_token])?就可以取回用戶對象了。Rails 在處理 session 時,會確保安全性。倘若用戶企圖偽造用戶 id,Rails 可以通過每個 session 的 session id 檢測到。
根據示例程序的設計目標,我們計劃要實現的是持久保存的 session,即使瀏覽器關閉了,登錄狀態依舊存在,所以,登入的用戶要有一個持久保存的標識符才行。為此,我們要為每個用戶生成一個唯一而安全的記憶權標,長期存儲,不會隨著瀏覽器的關閉而消失。
記憶權標要附屬到特定的用戶對象上,而且要保存起來以待后用,所以我們就可以把它設為 User 模型的屬性(如圖 8.8)。我們先來編寫 User 模型的測試,如代碼 8.15 所示。
圖 8.8:User 模型,添加了?remember_token?屬性
代碼 8.15?記憶權標的第一個測試
spec/models/user_spec.rb
要讓這個測試通過,我們要生成記憶權標屬性,執行如下命令:
$ rails generate migration add_remember_token_to_users然后按照代碼 8.16 修改生成的遷移文件。注意,因為我們要使用記憶權標取回用戶,所以我們為?remember_token?列加了索引(參見?旁注 6.2)。
代碼 8.16?為?users?表添加?remember_token?列的遷移
db/migrate/[timestamp]_add_remember_token_to_users.rb
然后,還要更新“開發數據庫”和“測試數據庫”:
$ bundle exec rake db:migrate $ bundle exec rake db:test:prepare現在,User 模型的測試應該可以通過了:
$ bundle exec rspec spec/models/user_spec.rb接下來我們要考慮記憶權標要保存什么數據,這有很多種選擇,其實任何足夠長的隨機字符串都是可以的。因為用戶的密碼是經過加密處理的,所以原則上我們可以直接把用戶的?password_hash?值拿來用,不過這么做可能會把用戶的密碼暴露給潛在的攻擊者。以防萬一,我們還是用 Ruby 標準庫中?SecureRandom?模塊提供的?urlsafe_base64?方法來生成隨機字符串吧。urlsafe_base64?方法生成的是 Base64 字符串,可以放心的在 URI 中使用(因此也可以放心的在 cookie 中使用)。3寫作本書時,SecureRandom.urlsafe_base64?創建的字符串長度為 16,由 A-Z、a-z、0-9、下劃線(_)和連字符(-)組成,每一位字符都有 64 種可能的情況,所以兩個記憶權標相等的概率就是 1/6416=2-96≈10-29,完全可以忽略。
我們會使用回調函數來創建記憶權標,回調函數在?6.2.5 節?中實現 Email 屬性的唯一性驗證時介紹過。和?6.2.5 節?中的用法一樣,我們還是要使用?before_save?回調函數,在保存用戶之前創建?remember_token?的值。4要測試這個過程,我們可以先保存測試所需的用戶對象,然后檢查?remember_token?是否為非空值。這樣做,如果以后需要改變記憶權標的生成方式,也無需修改測試。測試代碼如代碼 8.17 所示。
代碼 8.17?測試合法的(非空)記憶權標值
spec/models/user_spec.rb
代碼 8.17 中用到了?its?方法,它和?it?很像,不過測試對象是參數中指定的屬性而不是整個測試的對象。也就是說,如下的代碼:
its(:remember_token) { should_not be_blank }等同于
it { @user.remember_token.should_not be_blank }程序所需的代碼會涉及到一些新的知識。其一,我們添加了一個回調函數來生成記憶權標:
before_save :create_remember_token當 Rails 執行到這行代碼時,會尋找一個名為?create_remember_token?的方法,在保存用戶之前執行。其二,create_remember_token?只會在 User 模型內部使用,所以沒必要把它開放給用戶之外的對象。在 Ruby 中,我們可以使用?private?關鍵字(譯者注:其實?private?是方法而不是關鍵字,請參閱《Ruby 編程語言》P233)限制方法的可見性:
privatedef create_remember_token# Create the token.end在類中,private?之后定義的方法都會被設為私有方法,所以,如果執行下面的操作
$ rails console >> User.first.create_remember_token就會拋出?NoMethodError?異常。
其三,在?create_remember_token?方法中,要給用戶的屬性賦值,需要在?remember_token?前加上?self?關鍵字:
def create_remember_tokenself.remember_token = SecureRandom.urlsafe_base64 end(提示:如果你使用的是 Ruby 1.8.7,就要把?SecureRandom.urlsafe_base64?換成?SecureRandom_hex。)
Active Record 是把模型的屬性和數據庫表中的列對應的,如果不指定?self?的話,我們就只是創建了一個名為remember_token?的局部變量而已,這可不是我們期望得到的結果。加上?self?之后,賦值操作就會把值賦值給用戶的remember_token?屬性,保存用戶時,隨著其他的屬性一起存入數據庫。
把上述的分析結合起來,最終得到的 User 模型文件如代碼 8.18 所示。
代碼 8.18?生成記憶權標的?before_save?回調函數
app/models/user.rb
順便說一下,我們為?create_remember_token?方法增加了一層縮進,這樣可以更好的突出這些方法是在?private?之后定義的。
譯者注:如果按照 bbatsov 的《Ruby 編程風格指南》(中譯版)來編寫 Ruby 代碼的話,就沒必要多加一層縮進。
因為?SecureRandom.urlsafe_base64?方法創建的字符串不可能為空值,所以對 User 模型的測試現在應該可以通過了:
$ bundle exec rspec spec/models/user_spec.rb8.2.2 定義?sign_in?方法
本小節我們要開始實現登錄功能了,首先來定義?sign_in?方法。上一小節已經說明了,我們計劃實現的身份驗證方式是,在用戶的瀏覽器中存儲記憶權標,在網站的頁面與頁面之間通過這個記憶權標獲取數據庫中的用戶記錄(會在?8.2.3 節實現)。實現這一設想所需的代碼如代碼 8.19 所示,這段代碼使用了兩個新內容:cookies?Hash 和?current_user?方法。
代碼 8.19?完整但還不能正常使用的?sign_in?方法
app/helpers/sessions_helper.rb
上述代碼中用到的?cookies?方法是由 Rails 提供的,我們可以把它看成 Hash,其中每個元素又都是一個 Hash,包含兩個元素,value?指定 cookie 的文本,expires?指定 cookie 的失效日期。例如,我們可以使用下述代碼實現登錄功能,把 cookie 的值設為用戶的記憶權標,失效日期設為 20 年之后:
cookies[:remember_token] = { value: user.remember_token,expires: 20.years.from_now.utc }(這里使用了 Rails 提供的時間幫助方法,詳情參見旁注 8.1。)
旁注 8.1 cookie 在?20.years.from_now?之后失效
在?4.4.2 節中介紹過,你可以向任何的 Ruby 類,甚至是內置的類中添加自定義的方法,我們就向?String?類添加了palindrome??方法(而且還發現了?"deified"?是回文)。我們還介紹過,Rails 為?Object?類添加了?blank??方法(所以,"".blank?、" ".blank??和?nil.blank??的返回值都是?true)。代碼 8.19 中處理 cookie 的代碼又是一例,使用了 Rails 提供的時間幫助方法,這些方法是添加到 `Fixnum` 類(數字的基類)中的。
$ 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 還添加了其他的幫助函數,如:
>> 1.kilobyte=> 1024>> 5.megabytes=> 5242880這幾個幫助函數可用于限制上傳文件的大小,例如,圖片最大不超過?5.megabytes。
這種為內置類添加方法的特性很靈便,可以擴展 Ruby 的功能,不過使用時要小心一些。其實 Rails 的很多優雅之處正式基于 Ruby 語言的這一特性。
因為開發者經常要把 cookie 的失效日期設為 20 年后,所以 Rails 特別提供了?permanent?方法,前面處理 cookie 的代碼可以改寫成:
cookies.permanent[:remember_token] = user.remember_tokenRails 的?permanent?方法會自動把 cookie 的失效日期設為 20 年后。
設定了 cookie 之后,在網頁中我們就可以使用下面的代碼取回用戶:
User.find_by_remember_token(cookies[:remember_token])其實瀏覽器中保存的 cookie 并不是 Hash,賦值給?cookies?只是把值以文本的形式保存在瀏覽器中。這正體現了 Rails 的智能,我們無需關心具體的處理細節,專注地實現應用程序的功能。
你可能聽說過,存儲在用戶瀏覽器中的驗證 cookie 在和服務器通訊時可能會導致程序被會話劫持,攻擊者只需復制記憶權標就可以偽造成相應的用戶登錄網站了。Firesheep 這個 Firefox 擴展可以查看會話劫持,你會發現很多著名的大網站(包括 Facebook 和 Twitter)都存在這種漏洞。避免這個漏洞的方法就是整站開啟 SSL,詳情參見?7.4.4 節。
8.2.3 獲取當前用戶
上一小節已經介紹了如何在 cookie 中存儲記憶權標以待后用,這一小節我們要看一下如何取回用戶。我們先回顧一下sign_in?方法:
module SessionsHelperdef sign_in(user)cookies.permanent[:remember_token] = user.remember_tokenself.current_user = userend end現在我們關注的是方法定義體中的第二行代碼:
self.current_user = user這行代碼創建了?current_user?方法,可以在控制器和視圖中使用,所以你既可以這樣用:
<%= current_user.name %>也可以這樣用:
redirect_to current_user這行代碼中的?self?也是必須的,原因在分析代碼 8.18 時已經說過,如果沒有?self,Ruby 只是定義了一個名為current_user?的局部變量。
在開始編寫?current_user?方法的代碼之前,請仔細看這行代碼:
self.current_user = user這是一個賦值操作,我們必須先定義相應的方法才能這么用。Ruby 為這種賦值操作提供了一種特別的定義方式,如代碼 8.20 所示。
代碼 8.20?實現?current_user?方法對應的賦值操作
app/helpers/sessions_helper.rb
這段代碼看起來很奇怪,因為大多數的編程語言并不允許在方法名中使用等號。其實這段代碼定義的?current_user=?方法是用來處理?current_user?賦值操作的。也就是說,如下的代碼
self.current_user = ...會自動轉換成下面這種形式
current_user=(...)就是直接調用?current_user=?方法,接受的參數是賦值語句右側的值,本例中是要登錄的用戶對象。current_user=?方法定義體內只有一行代碼,即設定實例變量?@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上面的做法其實就是實現了?attr_accessor?方法的功能(4.4.5 節介紹過)。5如果按照代碼 8.21 來定義?current_user?方法,會出現一個問題:程序不會記住用戶的登錄狀態。一旦用戶轉到其他的頁面,session 就失效了,會自動退出。若要避免這個問題,我們要使用代碼 8.19 中生成的記憶權標查找用戶,如代碼 8.22 所示。
代碼 8.22?通過記憶權標查找當前用戶
app/helpers/sessions_helper.rb
代碼 8.22 中使用了一個常見但不是很容易理解的?||=(“or equals”)操作符(旁注 8.2中有詳細介紹)。使用這個操作符之后,當且僅當?@current_user?未定義時才會把通過記憶權標獲取的用戶賦值給實例變量?@current_user。6也就是說,如下的代碼
@current_user ||= User.find_by_remember_token(cookies[:remember_token])只在第一次調用?current_user?方法時調用?find_by_remember_token?方法,如果后續再調用的話就直接返回?@current_user的值,而不必再查詢數據庫。7這種方式的優點只有當在一個請求中多次調用?current_user?方法時才能顯現。不管怎樣,只要用戶訪問了相應的頁面,find_by_remember_token?方法都至少會執行一次。
旁注 8.2?||=?操作符簡介
||=?操作符非常能夠體現 Ruby 的特性,如果你打算長期進行 Ruby 編程的話就要好好學習它的用法。初學時會覺得||=?很神秘,不過通過和其他操作符類比之后,你會發現也不是很難理解。
我們先來看一下改變已經定義的變量時經常使用的結構。在很多程序中都會把變量自增一,如下所示
x = x + 1大多數語言都為這種操作提供了簡化的操作符,在 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 中還經常會遇到這種情況,如果變量的值為 `nil` 則賦予其他的值,否則就不改變這個變量的值。[4.2.3 節](chapter4.html#sec-4-2-3) 中介紹過?||?或操作符,所以這種情況可以用如下的代碼表示:
>> @user=> nil>> @user = @user || "the user"=> "the user">> @user = @user || "another user"=> "the user"因為?nil?表示的布爾值是?false,所以第一個賦值操作等同于?nil || "the user",這個語句的計算結果是?"the user";類似的,第二個賦值操作等同于?"the user" || "another user",這個語句的計算結果還是?"the user",因為?"the user"?表示的布爾值是?true,這個或操作在執行了第一個表達式之后就終止了。(或操作的執行順序是從左至右,只要出現真值就會終止語句的執行,這種方式稱作“短路計算(short-circuit evaluation)”。)
和上面的控制臺會話對比之后,我們可以發現?@user = @user || value?符合?x = x O y?的形式,只需把?O?換成?||,所以就得到了下面這種簡寫形式:
>> @user ||= "the user"=> "the user"不難理解吧!
譯者注:這里對?||=?的分析和 Peter Cooper 的分析有點差異,我推薦你看以下 Ruby Inside 中的《What Ruby’s ||= (Double Pipe / Or Equals) Really Does》一文。
8.2.4 改變導航鏈接
本小節我們要完成的是實現登錄、退出功能的最后一步,根據登錄狀態改變布局中的導航鏈接。如圖 8.3 所示,我們要在登錄和退出后顯示不同的導航,要添加指向列出所有用戶頁面的鏈接、到用戶設置頁面的鏈接(第九章加入),還有到當前登錄用戶資料頁面的鏈接。加入這些鏈接之后,代碼 8.6 中的測試就可以通過了,這是本章目前為止測試首次變綠通過。
在網站的布局中改變導航鏈接需要用到 ERb 的 if-else 分支結構:
<% if signed_in? %> # Links for signed-in users <% else %> # Links for non-signed-in-users <% end %>若要上述代碼起作用,先要用?signed_in??方法。我們現在就來定義。
如果 session 中存有當前用戶的話,就可以說用戶已經登錄了。我們要判斷?current_user?的值是不是?nil,這里需要用到取反操作符,用感嘆號 ! 表示,一般讀作“bang”。只要?current_user?的值不是?nil,就說明用戶登錄了,如代碼 8.23 所示。
代碼 8.23?定義?signed_in??幫助方法
app/helpers/sessions_helper.rb
定義了?signed_in??方法后就可以著手修改布局中的導航了。我們要添加四個新鏈接,其中兩個鏈接的地址先不填(第九章再填):
<%= link_to "Users", '#' %> <%= link_to "Settings", '#' %>退出鏈接的地址使用代碼 8.2 中定義的?signout_path:
<%= link_to "Sign out", signout_path, method: "delete" %>(注意,我們還為退出鏈接指定了類型為 Hash 的參數,指明點擊鏈接后發送的是 HTTP?DELETE?請求。8)最后,我們還要添加一個到資料頁面的鏈接:
<%= link_to "Profile", current_user %>這個鏈接我們本可以寫成
<%= link_to "Profile", user_path(current_user) %>不過我們可以直接把鏈接地址設為?current_user,Rails 會自動將其轉換成?user_path(current_user)。
在添加導航鏈接的過程中,我們還要使用 Bootstrap 實現下拉菜單的效果,具體的實現方式可以參閱 Bootstrap 的文檔。添加導航鏈接所需的代碼如代碼 8.24 所示。注意其中和 Bootstrap 下拉菜單有關的 CSS id 和 class。
代碼 8.24?根據登錄狀態改變導航鏈接
app/views/layouts/_header.html.erb
實現下拉菜單還要用到 Bootstrap 中的 JavaScript 代碼,我們可以編輯應用程序的 JavaScript 文件,通過 asset pipeline 引入所需的文件,如代碼 8.25 所示。
代碼 8.25?把 Bootstrap 的 JavaScript 代碼加入?application.js
app/assets/javascripts/application.js
引入文件的功能是由 Sprockets 實現的,而文件本身是由?5.1.2 節中添加的?bootstrap-sass?gem 提供的。
添加了代碼 8.24 之后,所有的測試應該都可以通過了:
$ bundle exec rspec spec/不過,如果你在瀏覽器中查看的話,網站還不能正常使用。這是因為“記住我”這個功能要求用戶記錄的記憶權標屬性不為空,而現在這個用戶是在?7.4.3 節中創建的,遠在實現生成記憶權標的回調函數之前,所以記憶權標還沒有值。為了解決這個問題,我們要再次保存用戶,觸發代碼 8.18 中的?before_save?回調函數,生成用戶的記憶權標:
$ rails console >> User.first.remember_token => nil >> User.all.each { |user| user.save(validate: false) } >> User.first.remember_token => "Im9P0kWtZvD0RdyiK9UHtg"我們遍歷了數據庫中的所有用戶,以防之前創建了多個用戶。注意,我們向?save?方法傳入了一個參數。如果不指定這個參數的話,就無法保存,因為我們沒有指定密碼及密碼確認的值。在實際的網站中,我們根本就無法獲知用戶的密碼,但是我們還是要執行保存操作,這時就要指定?validate: false?參數跳過 Active Record 的數據驗證(更多內容請閱讀 Rails API 中關于 save 的文檔)。
做了上述修正之后,登錄的用戶就可以看到代碼 8.24 中添加的新鏈接和下拉菜單了,如圖 8.9 所示。
圖 8.9:登錄后顯示了新鏈接和下拉菜單
現在你可以驗證一下是否可以登錄,然后關閉瀏覽器,再打開看一下是否還是登入的狀態。如果需要,你還可以直接查看瀏覽器的 cookies,如圖 8.10 所示。
圖 8.10:查看瀏覽器中的記憶權標 cookie
8.2.5 注冊后直接登錄
雖然現在基本完成了用戶身份驗證功能,但是新注冊的用戶可能還是會困惑,為什么注冊后沒有登錄呢。在實現退出功能之前,我們還要實現注冊后直接登錄的功能。我們要先編寫測試,在身份驗證的測試中加入一行代碼,如代碼 8.26 所示。這段代碼要用到第七章一個練習中的“after saving the user”?describe?塊(參見代碼 7.32),如果之前你沒有做這個練習的話,現在請添加相應的測試代碼。
代碼 8.26?測試剛注冊的用戶是否會自動登錄
spec/requests/user_pages_spec.rb
我們檢測頁面中有沒有退出鏈接,來驗證用戶注冊后是否登錄了。
有了?8.2 節中定義的?sign_in?方法,要讓這個測試通過就很簡單了:在用戶保存到數據庫中之后加上?sign_in @user?就可以了,如代碼 8.27 所示。
代碼 8.27?用戶注冊后直接登錄
app/controllers/users_controller.rb
8.2.6 退出
在?8.1 節中介紹過,我們要實現的身份驗證機制會記住用戶的登錄狀態,直到用戶點擊退出鏈接為止。本小節,我們就要實現退出功能。
目前為止,Sessions 控制器的動作完全遵從了 REST 架構,new?動作用于登錄頁面,create?動作實現登錄的過程。我們還要添加一個?destroy?動作,刪除 session,實現退出功能。針對退出功能的測試,我們可以檢測點擊退出鏈接后,頁面中是否有登錄鏈接,如代碼 8.28 所示。
代碼 8.28?測試用戶退出
spec/requests/authentication_pages_spec.rb
登錄功能是由?sign_in?方法實現的,對應的,我們會使用?sign_out?方法實現退出功能,如代碼 8.29 所示。
代碼 8.29?銷毀 session,實現退出功能
app/controllers/sessions_controller.rb
和其他身份驗證相關的方法一樣,我們會在 Sessions 控制器的幫助方法模塊中定義?sign_out?方法。方法本身的實現很簡單,我們先把當前用戶設為?nil,然后在 cookies 上調用?delete?方法從 session 中刪除記憶權標,如代碼 8.30 所示。(其實這里沒必要把當前用戶設為?nil,因為在?destroy?動作中我們加入了轉向操作。這里我們之所以這么做是為了兼容不轉向的退出操作。)
代碼 8.30?Sessions 幫助方法模塊中定義的?sign_out?方法
app/helpers/sessions_helper.rb
現在,注冊、登錄和退出三個功能都實現了,測試也應該可以通過了:
$ bundle exec rspec spec/有一點需要注意,我們的測試覆蓋了身份驗證機制的大多數功能,但不是全部。例如,我們沒有測試“記住我”到底記住了多久,也沒測試是否設置了記憶權標。我們當然可以加入這些測試,不過經驗告訴我們,直接測試 cookie 的值不可靠,而且要依賴具體的實現細節,而實現的方法在不同的 Rails 版本中可能會有所不同,即便應用程序可以使用,測試卻會失敗。所以我們只關注抽象的功能(驗證用戶是否可以登錄,是否可以保持登錄狀態,以及是否可以退出),編寫的測試沒必要針對實現的細節。
8.3 Cucumber 簡介(選讀)
前面兩節基本完成了示例程序的身份驗證系統,這一節我們將介紹如何使用 Cucumber 編寫登錄測試。Cucumber 是一個流行的行為驅動開發(Behavior-driven Development, BDD)工具,在 Ruby 社區中占據著一定的地位。本節的內容是選讀的,你可以直接跳過,不會影響后續內容。
Cucumber 使用純文本的故事(story)描述應用程序的行為,很多 Rails 開發者發現使用 Cucumber 處理客戶案例時十分方便,因為非技術人員也能讀懂這些行為描述,Cucumber 測試可以用于和客戶溝通,甚至經常是由客戶來編寫的。當然,使用不是純 Ruby 代碼組成的測試框架有它的局限性,而且我還發現純文本的故事很啰嗦。不管怎樣,Cucumber 在 Ruby 測試工具中還是有其存在意義的,我特別欣賞它對抽象行為的關注,而不是死盯底層的具體實現。
因為本書著重介紹的是 RSpec 和 Capybara,所以本節對 Cucumber 的介紹很淺顯,也不完整,很多內容都沒做詳細說明,我只是想讓你體驗一下如何使用 Cucumber,如果你感覺不錯,可以閱讀專門介紹 Cucumber 的書籍深入學習。(一般我會推薦你閱讀 David Chelimsky 的《The RSpec Book》,Ryan Bigg 和 Yehuda Katz 的《Rails 3 in Action》,以及 Matt Wynne 和 Aslak Helles?y 的《The Cucumber Book》。)
8.3.1 安裝和設置
若要安裝 Cucumber,需要在?Gemfile?的?:test?組中加入?cucumber-rails?和?database_cleaner?這兩個 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 . . .然后和之前一樣運行一下命令安裝:
$ bundle install如果要在程序中使用 Cucumber,我們先要生成一些所需的文件和文件夾:
$ rails generate cucumber:install這個命令會在根目錄中創建?features?文件夾,Cucumber 相關的文件都會存在這個文件夾中。
8.3.2 功能和步驟定義
Cucumber 中的“功能(feature)”就是希望應用程序實現的行為,使用一種名為 Gherkin 的純文本語言編寫。使用 Gherkin 編寫的測試和寫的很好的 RSpec 測試用例差不多,不過因為 Gherkin 是純文本,所以特別適合那些不是很懂 Ruby 代碼而可以理解英語的人使用。
下面我們要編寫一些 Cucumber 功能,實現代碼 8.5 和代碼 8.6 中針對登錄功能的部分測試用例。首先,我們在?features文件夾中新建名為?signing_in.feature?的文件。
Cucumber 的功能由一個簡短的描述文本開始,如下所示:
Feature: Signing in然后再添加一定數量相對獨立的場景(scenario)。例如,要測試登錄失敗的情況,我們可以按照如下的方式編寫場景:
Scenario: Unsuccessful signin Given a user visits the signin page When he submits invalid signin information Then he should see an error message類似的,測試登錄成功時,我們可以加入如下的場景:
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?命令運行這個功能:
$ bundle exec cucumber features/上述命令和執行 RSpec 測試的命令類似:
$ bundle exec rspec spec/提示一下,Cucumber 和 RSpec 一樣,可以通過 Rake 命令執行:
$ bundle exec rake cucumber(鑒于某些原因,我經常使用的命令是?rake cucumber:ok。)
我們只是寫了一些純文本,所以毫不意外,Cucumber 場景現在不會通過。若要讓測試通過,我們要新建一個步驟定義文件,把場景中的純文本和 Ruby 代碼對應起來。步驟定義文件存放在?features/step_definition?文件夾中,我們要將其命名為?authentication_steps.rb。
以?Feature?和?Scenario?開頭的行基本上只被視作文檔,其他的行則都要和 Ruby 代碼對應。例如,功能文件中下面這行
Given a user visits the signin page對應到步驟定義中的
Given /?a user visits the signin page$/ dovisit signin_path end在功能文件中,Given?只是普通的字符串,而在步驟定義中?Given?則是一個方法,可以接受一個正則表達式作為參數,后面還可以跟著一個塊。Given?方法的正則表達式參數是用來匹配功能文件中某個特定行的,塊中的代碼則是實現描述的行為所需的 Ruby 代碼。本例中的“a user visits the signin page”是由下面這行代碼實現的:
visit signin_path你可能覺得這行代碼很眼熟,不錯,這就是前面用過的 Capybara 提供的方法,Cucumber 的步驟定義文件會自動引入 Capybara。接下來的兩行代碼實現也同樣眼熟。如下的場景步驟:
When he submits invalid signin information Then he should see an error message對應到步驟定義文件中的
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,第二步則結合了 Capybara 的?page?和 RSpec。很明顯,之前我們使用 RSpec 和 Capybara 編寫的測試,在 Cucumber 中也是有用武之地的。
場景中接下來的步驟也可以做類似的處理。最終的步驟定義文件如代碼 8.33 所示。你可以一次只添加一個步驟,然后執行下面的代碼,直到測試都通過為止:
$ bundle exec cucumber features/代碼 8.33?使登錄功能通過的步驟定義
features/step_definitions/authentication_steps.rb
添加了代碼 8.33,Cucumber 測試應該就可以通過了:
$ bundle exec cucumber features/8.3.3 小技巧:自定義 RSpec 匹配器
編寫了一些簡單的 Cucumber 場景之后,我們來和相應的 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 和集成測試各自的優缺點了。Cucumber 功能可讀性很好,但是卻和測試代碼分隔開了,同時削弱了功能和測試代碼的作用。我覺得 Cucumber 測試讀起來很順口,但是寫起來怪怪的;而集成測試讀起來不太順口,但是很容易編寫。
Cucumber 把功能描述和步驟定義分開,可以很好的實現抽象層面的行為。例如,下面這個描述
Then he should see an error message表達的意思是,期望看到一個錯誤提示信息。如下的步驟定義則檢測了能否實現這個期望:
Then /?he should see an error message$/ dopage.should have_selector('div.alert.alert-error', text: 'Invalid') endCucumber 這種分離方式特別便捷的地方在于,只有步驟定義是依賴具體實現的,所以假如我們修改了錯誤提示信息所用的 CSS class,功能描述文件是不需要修改的。
那么,如果你只是想檢測頁面中是否顯示有錯誤提示信息,就不想在多個地方重復的編寫下面的測試:
should have_selector('div.alert.alert-error', text: 'Invalid')如果你真的這么做了,就把測試和具體的實現綁死了,一旦改變了實現方式,就要到處修改測試。在 RSpec 中,可以自定義匹配器來解決這個問題,我們可以直接這么寫:
should have_error_message('Invalid')我們可以在?5.3.4 節?中定義?full_title?測試幫助方法的文件中定義這個匹配器,代碼如下:
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 節中的代碼 5.37 和代碼 5.38 合并了)。我覺得這種方法比 Cucumber 的步驟定義還要靈活,特別是當匹配器和幫助方法可以接受一個參數時,例如?valid_signin(user)。我們也可以用步驟定義中的正則表達式匹配來實現這種功能,不過太過繁雜。
代碼 8.34?添加一個幫助函數和一個 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) }...還有很多測試用例把測試和具體的實現綁縛在一起了,我們會在?8.5 節的練習中徹底的搜查現有的測試組件,使用自定義匹配器和幫助方法解耦測試和具體實現。
8.4 小結
本章我們介紹了很多基礎知識,也為稍顯簡陋的應用程序實現了注冊和登錄功能。實現了用戶身份驗證功能后,我們就可以根據登錄狀態和用戶的身份限制對特定頁面的訪問權限。在實現限制訪問的過程中,我們會為用戶添加編輯個人信息的功能,還會為管理員添加刪除用戶的功能。這些是第九章的主要內容。
在繼續閱讀之前,先把本章的改動合并到主分支吧:
$ git add . $ git commit -m "Finish sign in" $ git checkout master $ git merge sign-in-out然后再推送到 GitHub 和 Heroku “生產環境”服務器:
$ git push $ git push heroku $ heroku run rake db:migrate如果之前你在生產服務器中注冊過用戶,我建議你按照?8.2.4 節中介紹的方法,為各用戶生成記憶權標,不能用本地的控制臺,而要用 Heroku 的控制臺:
$ heroku run console >> User.all.each { |user| user.save(validate: false) }8.5 練習
總結
以上是生活随笔為你收集整理的Python计算机视觉:第八章 图像类容分类的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Python计算机视觉:第六章 图像聚类
- 下一篇: Python计算机视觉:第九章 图像分割