Ruby on Rails Tutorial
Изучение Rails на Примерах
Майкл Хартл
Содержание
- Предисловие к русскому изданию
- Глава 1 От нуля к развертыванию
- Глава 2 demo app
- Глава 3 В основном статические страницы
- Глава 4 Rails-приправленный Ruby
- Глава 5 Заполнение шаблона
- Глава 6 Моделирование пользователей
- Chapter 7 Регистрация
- Глава 8 Войти, выйти
- Глава 9 Обновление, демонстрация и удаление пользователей
- Глава 10 Микросообщения пользователей
- Глава 11 Слежение за сообщениями пользователей
Предисловие
Моя компания (CD Baby) была одной из первых громко перешедших на Ruby on Rails, а затем еще громче вернувшейся обратно на PHP (Google расскажет вам об этой драме). Эту книгу, написанную Майклом Хартлом так высоко рекомендовали, что я должен был попробовать её, и Ruby on Rails Tutorial это всё, что я использовал, чтобы вернуться к Rails.
Хотя я уже прошел через много книг по Rails, это одна из немногих, что, наконец, зацепила меня. Было много написано книг типа «Путь Rails» — после которых я чувствовал себя неестественно, но после этой книги я наконец почувствовал себя естественно. Это также единственная книга по Rails, которая соблюдает методику «разработка через тестирование» на всем своем протяжении, этот подход строго рекомендуется специалистами, но он никогда не был так чётко продемонстрирован ранее. Наконец, Git, GitHub и Heroku присутствуют в демо-примерах, автор действительно дает вам почувствовать, что он хотел сделать реальный проект. Учебный код примеров не изолирован.
Линейное повествование — отличный формат. Лично я прошел Rails Tutorial в течении трёх долгих дней, делая все примеры и задачи в конце каждой главы. Делайте всё от начала до конца, не прыгая, и вы получите максимальную пользу.
Наслаждайтесь!
Derek Sivers (sivers.org)
Основатель CD Baby
Благодарности
Ruby On Rails Учебник во многом обязан моей предыдущей книге по Rails, RailsSpace и, следовательно, моему соавтору Aurelius Prochazka. Я хотел бы поблагодарить Aure как за работу, которую он проделал над прошлой книгой, так и за поддержку этой. Я также хотел бы поблагодарить Debra Williams Cauley, редактора обеих книг RailsSpace и Rails Tutorial; до тех пор, пока она не прекратит брать меня на бейсбол, я буду продолжать писать книги для нее.
Я хотел бы поблагодарить огромное количество Рубистов учивших и вдохновлявших меня на протяжении многих лет: David Heinemeier Hansson, Yehuda Katz, Carl Lerche, Jeremy Kemper, Xavier Noria, Ryan Bates, Geoffrey Grosenbach, Peter Cooper, Matt Aimonetti, Gregg Pollack, Wayne E. Seguin, Amy Hoy, Dave Chelimsky, Pat Maddox, Tom Preston-Werner, Chris Wanstrath, Chad Fowler, Josh Susser, Obie Fernandez, Ian McFarland, Steven Bristol, Pratik Naik, Sarah Mei, Sarah Allen, Wolfram Arnold, Alex Chaffee, Giles Bowkett, Evan Dorn, Long Nguyen, James Lindenbaum, Adam Wiggins, Tikhon Bernstam, Ron Evans, Wyatt Greene, Miles Forrest, хороших людей из Pivotal Labs, команду Heroku, thoughtbot ребят, и команду GitHub. Наконец, многих, многих читателей - слишком много чтобы перечислять их здесь - внёсших большое количество предложений по улучшению и сообщивших об ошибках во время написания этой книги, и я с благодарностью признаю их помощь в написании ее настолько хорошей, насколько это было возможно.
Об авторе
Майкл Хартл – автор Ruby on Rails Tutorial, лидирующего введения в веб разработку на Ruby on Rails. Его предыдущий опыт включает в себя написание и разработку RailsSpace - чрезвычайно устаревшего учебника по Rails и разработку Insoshi - некогда популярной, а ныне устаревшей платформы для социальных сетей написанной на Ruby on Rails. В 2011, Майкл получил Ruby Hero Award за его вклад в Ruby сообщество. Он закончил Harvard College, имеет степень Кандидата Физических Наук присвоенную в Caltech и является выпускником предпринимательских курсов Y Combinator.
Копирайт и лицензия
Ruby on Rails Tutorial: Learn Web Development with Rails. Copyright © 2012 by Michael Hartl. Весь исходный код в Ruby on Rails Tutorial доступен под MIT License и Beerware License.
Лицензия MIT
Copyright (c) 2013 Michael Hartl
Данная лицензия разрешает лицам, получившим копию данного программного
обеспечения и сопутствующей документации (в дальнейшем именуемыми
«Программное Обеспечение»), безвозмездно использовать Программное
Обеспечение без ограничений, включая неограниченное право на использование,
копирование, изменение, добавление, публикацию, распространение,
сублицензирование и/или продажу копий Программного Обеспечения, также
как и лицам, которым предоставляется данное Программное Обеспечение,
при соблюдении следующих условий:
Указанное выше уведомление об авторском праве и данные условия должны быть
включены во все копии или значимые части данного Программного Обеспечения.
ДАННОЕ ПРОГРАММНОЕ ОБЕСПЕЧЕНИЕ ПРЕДОСТАВЛЯЕТСЯ «КАК ЕСТЬ», БЕЗ КАКИХ-ЛИБО
ГАРАНТИЙ, ЯВНО ВЫРАЖЕННЫХ ИЛИ ПОДРАЗУМЕВАЕМЫХ, ВКЛЮЧАЯ, НО НЕ ОГРАНИЧИВАЯСЬ
ГАРАНТИЯМИ ТОВАРНОЙ ПРИГОДНОСТИ, СООТВЕТСТВИЯ ПО ЕГО КОНКРЕТНОМУ НАЗНАЧЕНИЮ И
ОТСУТСТВИЯ НАРУШЕНИЙ ПРАВ. НИ В КАКОМ СЛУЧАЕ АВТОРЫ ИЛИ ПРАВООБЛАДАТЕЛИ НЕ
НЕСУТ ОТВЕТСТВЕННОСТИ ПО ИСКАМ О ВОЗМЕЩЕНИИ УЩЕРБА, УБЫТКОВ ИЛИ ДРУГИХ
ТРЕБОВАНИЙ ПО ДЕЙСТВУЮЩИМ КОНТРАКТАМ, ДЕЛИКТАМ ИЛИ ИНОМУ, ВОЗНИКШИМ ИЗ,
ИМЕЮЩИМ ПРИЧИНОЙ ИЛИ СВЯЗАННЫМ С ПРОГРАММНЫМ ОБЕСПЕЧЕНИЕМ ИЛИ ИСПОЛЬЗОВАНИЕМ
ПРОГРАММНОГО ОБЕСПЕЧЕНИЯ ИЛИ ИНЫМИ ДЕЙСТВИЯМИ С ПРОГРАММНЫМ ОБЕСПЕЧЕНИЕМ.
/*
* ----------------------------------------------------------------------------
* "ПИВНАЯ ЛИЦЕНЗИЯ" (Ревизия 42):
* Весь код написан Майклом Хартлом. До тех пор пока вы осознаете это,
* вы можете делать с ним все что захотите. Если мы когда нибудь
* встретимся, и если это того стоило, вы можете купить мне
* пиво в ответ.
* ----------------------------------------------------------------------------
*/
Глава 8 Войти, выйти
Теперь, когда новые пользователи могут регистрироваться на нашем сайте (Глава 7), пришло время дать зарегистрированным пользователям возможность входить на сайт и выходить из него. Это позволит нам добавить настройки, зависящие от регистрационного статуса и личности текущего пользователя. Например, в этой главе мы добавим в header сайта ссылки войти/выйти и ссылку на профиль пользователя. В Главе 10 мы будем использовать идентификацию вошедшего в систему пользователя для создания микросообщений, связанных с этим пользователем и в Главе 11 мы позволим текущему пользователю следовать за другими пользователями приложения (тем самым получать поток (feed) их микросообщений).
Наличие функции входа пользователей в систему также позволит нам реализовать модель безопасности, ограничивающую доступ к определенным страницам, основываясь на идентификации вошедшего в систему пользователя. Например, как мы увидим в Главе 9, только вошедшие пользователи смогут получить доступ к странице, используемой для редактирования информации о пользователе. Система входа/выхода также позволит реализовать особые привилегии для пользователей с правами администратора, такие как возможность (также в Главе 9) удалять пользователей из базы данных.
После реализации ядра аутентификационного механизма мы немного отвлечемся от основной темы для того чтобы познакомиться с Cucumber - популярной системой предназначенной для разработки через поведение (Раздел 8.3). В частности, мы перепишем пару интеграционных RSpec тестов на Cucumber для сравнения этих двух методик.
Как и в предыдущих главах, мы будем делать нашу работу в новой ветке и объединим изменения в конце:
$ git checkout -b sign-in-out
8.1 Сессии и провальный вход
Сессия это полупостоянное соединение между двумя компьютерами, такими как клиентский компьютер с запущенным веб-браузером и сервер с запущенными на нем Rails. Есть несколько моделей поведения сессий, принятых в сети: “забывание” сессии при закрытии браузера, опциональное использование “запомнить меня” флажка для постоянных сессий, и запоминание сессий до явных признаков выхода пользователя из системы.1 Мы выберем последнюю из этих опций: когда пользователь войдет, мы запомним его статус вошедшего “навсегда” и очистим сесию только после явного выхода пользователя из системы. (Мы увидим в Разделе 8.2.1 насколько продолжительно это самое “навсегда”.)
Удобно моделировать сессии как RESTful ресурс: у нас будет страница входа для новых сессий, вход будет создавать сессию и выход будет уничтожать ее. В отличие от ресурса Users который использует базу данных (через модель User) для сохранения данных, ресурс Sessions будет использовать куки, которые представляют собой небольшой фрагмент текста, помещаемого в браузер пользователя. Большая часть сложностей в разработке системы входа связана с построением этого, опирающегося на куки, аутентификационного механизма. В этом и последующих разделах мы будем заниматься подготовительной работой - создадим контроллер Sessions , форму входа и соответствующие действия контроллера. Затем мы завершим вход пользователей написав необходимый для манипуляций с куки код в Разделе 8.2.
8.1.1 Sessions контроллер
Элементы системы входа и выхода соответствуют определенным REST действиям Sessions контроллера: форма входа обрабатывается new
действием (рассматривается в этом разделе), сам вход обрабатывается отправкой запроса POST к действию create
(Раздел 8.1 и Раздел 8.2) и выход обрабатывается отправкой запроса DELETE к действию destroy
(Раздел 8.2.6). (Вспомним о соответствии между глаголами HTTP и REST действиями из Таблицы 7.1.) Для начала мы сгенерируем контроллер Sessions и интеграционный тест для механизма аутентификации:
$ rails generate controller Sessions --no-test-framework
$ rails generate integration_test authentication_pages
Следуя модели из Раздела 7.2 для страницы регистрации, мы создадим форму входа для создания новых сессий (Рис. 8.1).
Страница входа живет по URL предоставленному signin_path
(уже определенному) и, как обычно, мы начнем с минималистичного теста как это показано в Листинге 8.1. (Сравните его с аналогичным кодом для страницы регистрации из Листинга 7.6.)
new
session действия и представления. spec/requests/authentication_pages_spec.rb
require 'spec_helper'
describe "Authentication" do
subject { page }
describe "signin page" do
before { visit signin_path }
it { should have_content('Sign in') }
it { should have_title('Sign in') }
end
end
Изначально тест провальный, как и требуется:
$ bundle exec rspec spec/
Для того чтобы получить прохождение тестов из Листинга 8.1, в первую очередь нам необходимо определить маршруты для ресурса Sessions, совместно с кастомным именованным маршрутом для страницы входа (который мы направим к действию new
контроллера Session). Как и с ресурсом Users, мы можем использовать метод resources
для определения стандартных RESTful маршрутов:
resources :sessions, only: [:new, :create, :destroy]
Поскольку нам нет надобности показывать или редактировать сессии, мы ограничимся действиями new
, create
и destroy
с помощью опции :only
принимаемой resources
. Конечный результат, включающий именованные маршруты для входа и выхода, представлен в Листинге 8.2.
config/routes.rb
SampleApp::Application.routes.draw do
resources :users
resources :sessions, only: [:new, :create, :destroy]
root 'static_pages#home'
match '/signup', to: 'users#new', via: 'get'
match '/signin', to: 'sessions#new', via: 'get'
match '/signout', to: 'sessions#destroy', via: 'delete'
.
.
.
end
Обратите внимание на использование via: ’delete’
для маршрута выхода, указывающее на то, что он должен быть вызван с помощью HTTP запроса DELETE.
Ресурсы, определенные в Листинге 8.2 обеспечивают URL-адреса и действия, аналогичные ресурсу Users (Таблица 7.1), как видно в Таблице 8.1. Обратите внимание на то что маршруты для входа и выхода являются кастомными, но маршрут для создания сессии остался дефолтным (т.е., [resource name]_path
).
HTTP запрос | URL | Именованный маршрут | Действие | Цель (назначение) |
---|---|---|---|---|
GET | /signin | signin_path | new | страница для новой сессии (вход) |
POST | /sessions | sessions_path | create | создание новой сессии |
DELETE | /signout | signout_path | destroy | удаление сессии (выход) |
Кстати, для генерации полного списка маршрутов вашего приложения вы можете использовать команду rake routes
:
$ rake routes
Prefix Verb URL Pattern Controller#Action
users GET /users(.:format) users#index
POST /users(.:format) users#create
new_user GET /users/new(.:format) users#new
edit_user GET /users/:id/edit(.:format) users#edit
user GET /users/:id(.:format) users#show
PATCH /users/:id(.:format) users#update
PUT /users/:id(.:format) users#update
DELETE /users/:id(.:format) users#destroy
sessions POST /sessions(.:format) sessions#create
new_session GET /sessions/new(.:format) sessions#new
session DELETE /sessions/:id(.:format) sessions#destroy
root GET / static_pages#home
signup GET /signup(.:format) users#new
signin GET /signin(.:format) sessions#new
signout DELETE /signout(.:format) sessions#destroy
help GET /help(.:format) static_pages#help
about GET /about(.:format) static_pages#about
contact GET /contact(.:format) static_pages#contact
Следующим шагом необходимым для прохождения тестов из Листинга 8.1 является добавление new
действия к контроллеру Sessions, как это показано в Листинге 8.3 (который также определяет create
и destroy
действия для использования в будущем).
app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
def new
end
def create
end
def destroy
end
end
Последний шаг это определение начальной версии страницы входа. Обратите внимание, что, поскольку это страница для новой сесии, она живет в файле app/views/sessions/new.html.erb
, который мы должны создать. Содержимое, которое в настоящий момент определяет только заголовок страницы и заголовок первого уровня, представлено в Листинге 8.4.
app/views/sessions/new.html.erb
<% provide(:title, "Sign in") %>
<h1>Sign in</h1>
Теперь тесты из Листинга 8.1 должны пройти и мы готовы к созданию самой формы входа.
$ bundle exec rspec spec/
8.1.2 Тестирование входа
Сравнивая Рис. 8.1 с Рис. 7.11, мы видим, что форма входа (или, что эквивалентно, форма новой сессии) выглядит аналогично форме регистрации, за исключением того что в ней два поля (email и пароль) вместо четырех. Как и с формой регистрации, мы можем протестировать форму входа используя Capybara для заполнения формы данными и последующего клика по кнопке.
В процессе написания тестов мы вынуждены обращать внимание на аспекты дизайна нашего приложения, что является одним из приятных побочных эффектов разработки через тестирование. Мы начнем с провального входа, как это показано на наброске в Рис. 8.2.
Как видно на Рис. 8.2, при предоставлении невалидной информации мы хотим вновь отрендерить страницу входа и вывести на экран сообщение об ошибке. Мы будем рендерить ошибку как флэш сообщение, что мы можем протестировать следующим образом:
it { should have_selector('div.alert.alert-error') }
Здесь используется предоставляемый Capybara метод have_selector
который мы видели ранее в решениях для двух упражнений, Листинг 5.38 и Листинг 7.32. Метод have_selector
проверяет наличие конкретного селектора (т.е. HTML тега, однако в Capybara 2.0 это работает только для видимых элементов). В данном случае мы ищем
div.alert.alert-error
который проверяет тег div
. В частности, вспомнив что в CSS точка обозначает “класс” (Раздел 5.1.2), вы возможно догадались что это тест на наличие тега div
с классами "alert"
и "alert-error"
, вроде этого:
<div class="alert alert-error">Invalid...</div>
Комбинация тестов заголовка и флэша приводит нас к коду в Листинге 8.5. Как мы увидим, эти тесты упускают одну важную деталь, которой мы займемся в Разделе 8.1.5.
spec/requests/authentication_pages_spec.rb
require 'spec_helper'
describe "Authentication" do
.
.
.
describe "signin" do
before { visit signin_path }
describe "with invalid information" do
before { click_button "Sign in" }
it { should have_title('Sign in') }
it { should have_selector('div.alert.alert-error') }
end
end
end
Теперь, написав тесты для провального входа, мы возвращаемся к успешному входу. Изменениями которые мы хотим протестировать являются рендеринг страницы профиля пользователя (что предопределено заголовком страницы, который должен быть именем пользователя), совместно с тремя запланированными изменениями в навигации сайта:
- Появление ссылки на страницу профиля пользователя
- Появление ссылки “Sign out”
- Исчезновение ссылки “Sign in”
(Мы отложим тесты для ссылки “Settings” до Раздела 9.1 и для ссылки “Users” до Раздела 9.3.) Набросок этих изменений представлен на Рис. 8.3.2 Обратите внимание на то, что ссылки на выход и на профиль пользователя появляются в выпадающем меню “Account”; в Разделе 8.2.4, мы увидим как сделать такое меню с помощью Bootstrap.
Код тестов для успешного входа представлен в Листинге 8.6.
spec/requests/authentication_pages_spec.rb
require 'spec_helper'
describe "Authentication" do
.
.
.
describe "signin" do
before { visit signin_path }
.
.
.
describe "with valid information" do
let(:user) { FactoryGirl.create(:user) }
before do
fill_in "Email", with: user.email.upcase
fill_in "Password", with: user.password
click_button "Sign in"
end
it { should have_title(user.name) }
it { should have_link('Profile', href: user_path(user)) }
it { should have_link('Sign out', href: signout_path) }
it { should_not have_link('Sign in', href: signin_path) }
end
end
end
Здесь мы использовали метод have_link
. Он принимает в качестве аргументов текст ссылки и необязательный параметр :href
, таким образом
it { should have_link('Profile', href: user_path(user)) }
убеждается в том что якорный тег a
имеет правильный атрибут href
(URL) — в данном случае, ссылку на страницу профиля пользователя. Обратите также внимание на то что мы позаботились upcase
email адрес пользователя для того чтобы быть уверенными в том что наша способность находить пользователя в базе данных не зависит от регистра.
8.1.3 Форма для входа
После написания тестов мы готовы приступить к разработке формы для входа. Вспомним из Листинга 7.17 что форма регистрации использует вспомогательный метод form_for
, принимающий в качестве аргумента переменную экземпляра @user
:
<%= form_for(@user) do |f| %>
.
.
.
<% end %>
Основное отличие между этим и формой для входа заключается в том что у нас нет модели Session, и, следовательно, нет аналога для переменной @user
. Это означает, что при конструировании формы для новой сессии нам необходимо предоставить методу form_for
чуть больше информации; в частности, тогда как
form_for(@user)
позволяет Rails сделать вывод о том, что действием
формы должно быть POST к URL /users, в случае с сессиями мы должны явно указать имя ресурса и соответствующий URL:
form_for(:session, url: sessions_path)
(Вторым возможным способом является использование form_tag
вместо form_for
; это было бы даже более идеоматически корректным решением с точки зрения Rails, но оно бы имело мало общего с формой регистрации, а на этом этапе я хочу подчеркнуть параллельность структуры. Создание рабочей формы с помощью form_tag
оставлено в качестве упражнения (Раздел 8.5).)
Имея на руках правильный form_for
легко сделать форму для входа соответствующую наброску на Рис. 8.1 используя форму регистрации (Листинг 7.17) в качестве модели, как это показано в Листинге 8.7.
app/views/sessions/new.html.erb
<% provide(:title, "Sign in") %>
<h1>Sign in</h1>
<div class="row">
<div class="span6 offset3">
<%= form_for(:session, url: sessions_path) do |f| %>
<%= f.label :email %>
<%= f.text_field :email %>
<%= f.label :password %>
<%= f.password_field :password %>
<%= f.submit "Sign in", class: "btn btn-large btn-primary" %>
<% end %>
<p>New user? <%= link_to "Sign up now!", signup_path %></p>
</div>
</div>
Обратите внимание на то, что мы для удобства добавили ссылку на страницу входа. С кодом в Листинге 8.7, форма для входа выглядит как на Рис. 8.4.
Несмотря на то, что вы вскоре избавитесь от привычки смотреть на HTML генерируемый Rails (вместо этого доверив хелперам, делать свою работу), пока все же давайте взглянем на него (Листинг 8.8).
<form accept-charset="UTF-8" action="/sessions" method="post">
<div>
<label for="session_email">Email</label>
<input id="session_email" name="session[email]" type="text" />
</div>
<div>
<label for="session_password">Password</label>
<input id="session_password" name="session[password]"
type="password" />
</div>
<input class="btn btn-large btn-primary" name="commit" type="submit"
value="Sign in" />
</form>
Сравнивая Листинг 8.8 с Листингом 7.20, вы, возможно, догадались, что отправка этой формы приведет к хэшу params
, где params[:session][:email]
и params[:session][:password]
соответствуют email и password полям.
8.1.4 Обзор отправки формы
Как и в случае создания пользователей (регистрации), первый шаг в создании сессий (вход) состоит в обработке неверного ввода. У нас уже есть тесты для провальной регистрации (Листинг 8.5) и код приложения довольно прост за исключением пары тонкостей. Мы начнем с разбора того что происходит при отправке формы, а затем прикрутим полезное сообщение об ошибке появляющееся в случае провального входа (как это показано на наброске из Рис. 8.2.) Затем мы заложим основу для успешного входа (Раздел 8.2) научив наше приложение оценивать каждую попытку входа, опираясь на валидность предоставленной комбинации email/password.
Давайте начнем с определения минималистичного действия create
для контроллера Sessions (Листинг 8.9), которое пока не будет делать ничего кроме рендеринга представления new
. После чего, отправка формы /sessions/new с пустыми полями, будет приводить к результату показанному на Рис. 8.5.
create
действия. app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
.
.
.
def create
render 'new'
end
.
.
.
end
Тщательное изучение отладочной информации на Рис. 8.5 показывает, что, как намекалось в конце Раздела 8.1.3, отправка формы приводит к хэшу params
содержащему email и password под ключом :session
:
---
session:
email: ''
password: ''
commit: Sign in
action: create
controller: sessions
Как и в случае регистрации пользователя (Рис. 7.15) эти параметры образуют вложенный хэш, как тот, что мы видели в Листинге 4.6. В частности, params
содержит вложенный хэш формы
{ session: { password: "", email: "" } }
Это означает что
params[:session]
само является хэшем:
{ password: "", email: "" }
Как результат,
params[:session][:email]
является предоставленным адресом электронной почты и
params[:session][:password]
является предоставленным паролем.
Иными словами, внутри create
действия хэш params
имеет всю информацию, необходимую для аутентификации пользователей по электронной почте и паролю. Совершенно не случайно у нас уже как раз есть необходимый нам метод: User.find_by_email
предоставленный Active Record (Раздел 6.1.4) и метод authenticate
предоставляемый has_secure_password
(Раздел 6.3.3). Вспомните что authenticate
возвращает false
для невалидной аутентификации, наша стратегия для входа пользователя может быть резюмирована следующим образом:
def create
user = 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
end
Здесь первая строка вытягивает пользователя из базы данных с помощью предоставленного адреса электронной почты. (Вспомните из Раздела 6.2.5 что email адреса сохраняются в нижнем регистре, поэтому здесь мы используем метод downcase
для обеспечения соответствия когда предоставленный адрес валиден.) Следующая строка может немного смутить, но она довольна распространена в идеоматическом Rails программировании:
user && user.authenticate(params[:session][:password])
Здесь используется &&
(логическое и) для определения валидности полученного пользователя. Принимая в расчет что любой объект кроме nil
и самой false
является true
в булевом контексте (Раздел 4.2.3), возможные результаты выглядят как Таблица 8.2. Мы видим в Таблице 8.2 что выражение if
является true
только если пользователь с данным адресом электронной почты и существует в базе данных и имеет данный пароль, что нам и было необходимо.
Пользователь | Пароль | a && b |
---|---|---|
не существует | что-нибудь | nil && [anything] == false |
валидный пользователь | неправильный пароль | true && false == false |
валидный пользователь | правильный пароль | true && true == true |
8.1.5 Рендеринг с флэш сообщением
Напомним из Раздела 7.3.3, что мы отображали ошибки регистрации используя сообщения об ошибках модели User. Эти ошибки связаны с конкретным объектом Active Record, но эта стратегия здесь не сработает, поскольку сессии не являются моделью Active Record. Вместо этого, мы поместим сообщение во флеш так чтобы оно отображалось при провальном входе. Первая, немного некорректная попытка представлена в Листинге 8.10.
app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
def new
end
def create
user = 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
flash[:error] = 'Invalid email/password combination' # Not quite right!
render 'new'
end
end
def destroy
end
end
Поскольку сообщение об ошибке отображается в шаблоне сайта (Листинг 7.27), сообщение flash[:error]
будет автоматически отображено; благодаря Bootstrap CSS, оно, к тому же, будет иметь приятный стиль (Рис. 8.6).
К сожалению, как было отмечено в тексте и в комментарии к Листингу 8.10, этот код не совсем верный. Однако страница выглядит нормально, так в чем же подвох? Проблема заключается в том, что содержимое флэша существует в течение одного запроса, но, в отличие от редиректа (перенаправления) который мы использовали в Листинге 7.28 — повторный рендеринг шаблона с render
не считается запросом. В результате флэш сообщение существует на один запрос дольше чем мы хотим. Например, если мы отправим невалидную информацию, флэш сообщение будет установлено и отображено на странице входа (Рис. 8.6); если мы кликнем на другую страницу, такую как Home, что будет первым запросом после отправки формы, то флэш сообщение будет вновь отображено (Рис. 8.7).
Это постоянство флэша является багом нашего приложения и прежде чем приступить к его исправлению, было бы неплохо написать тест отлавливающий эту ошибку. В частности, тест провального входа в данный момент проходит:
$ bundle exec rspec spec/requests/authentication_pages_spec.rb \
> -e "signin with invalid information"
Но тесты ни в коем случае не должны проходить при наличии известной ошибки приложения, так что мы должны добавить провальный тест для ее отлова. К счастью, работа с проблемами вроде неисчезающего флэша, это одна из тех областей, где интеграционные тесты блестяще справляются с поставленной задачей; они позволяют нам сказать именно то, что мы имеем в виду:
describe "after visiting another page" do
before { click_link "Home" }
it { should_not have_selector('div.alert.alert-error') }
end
После отправки невалидных данных, этот тест переходит по Home ссылке, а затем требует отсутствия флэш сообщения об ошибке. Обновленный код с модифицированным тестом флэша показан в Листинге 8.11.
spec/requests/authentication_pages_spec.rb
require 'spec_helper'
describe "Authentication" do
.
.
.
describe "signin" do
before { visit signin_path }
describe "with invalid information" do
before { click_button "Sign in" }
it { should have_title('Sign in') }
it { should have_selector('div.alert.alert-error') }
describe "after visiting another page" do
before { click_link "Home" }
it { should_not have_selector('div.alert.alert-error') }
end
end
.
.
.
end
end
Новый тест не проходит, как и требуется:
$ bundle exec rspec spec/requests/authentication_pages_spec.rb \
> -e "signin with invalid information"
Для того чтобы получить прохождение этого провального теста, мы заменим flash
на flash.now
, который специально создан для отображения флэш сообщения на отрендеренных страницах; в отличие от содержимого flash
, его содержимое исчезает сразу после дополнительного запроса. Исправленный код приложения представлен в Листинге 8.12.
app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
def new
end
def create
user = 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
flash.now[:error] = 'Invalid email/password combination'
render 'new'
end
end
def destroy
end
end
Теперь набор тестов для пользователей предоставивших невалидные данные для входа должен быть зеленым:
$ bundle exec rspec spec/requests/authentication_pages_spec.rb \
> -e "with invalid information"
8.2 Успешный вход
Получив обработку неудачного входа, теперь нам нужно на самом деле впустить пользователя. Получение этого результата потребует самого сложного Ruby программирования, которое мы когда либо встречали в этом учебнике, так что держитесь до конца и будьте готовы к небольшому количеству тяжелой работы. К счастью, первый шаг прост — завершение create
действия контроллера Sessions — простая задача. К сожалению, эта легкость обманчива.
Заполнить область, занятую в настоящее время комментарием (Листинг 8.12) легко: после успешного входа, мы впускаем пользователя, используя функцию sign_in
, а затем перенаправляем его на страницу профиля (Листинг 8.13). Мы видим теперь, почему это обманчивая легкость: увы, sign_in
в настоящее время не существует. Написание этой функции займет оставшуюся часть этого раздела.
create
контроллера Sessions (пока не рабочее). app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
.
.
.
def create
user = User.find_by(email: params[:session][:email].downcase)
if user && user.authenticate(params[:session][:password])
sign_in user
redirect_to user
else
flash.now[:error] = 'Invalid email/password combination'
render 'new'
end
end
.
.
.
end
8.2.1 Запомнить меня
Мы теперь в состоянии приступить к реализации нашей модели входа, а именно, запоминанию статуса вошедшего пользователя “навсегда” и очистке сессии только тогда, когда пользователь явно покинет наш сайт. Сами функции входа, в конечном итоге, пересекают традиционное Модель-Представление-Контроллер; в частности, несколько функций входа должны быть доступны и в контроллерах и в представлениях. Вы можете вспомнить из Раздела 4.2.5, что Ruby предоставляет модули для упаковки функций вместе и включения их в нескольких местах и это наш план для функций аутентификации. Мы могли бы сделать совершенно новый модуль для аутентификации, но контроллер Sessions уже оснащен модулем, а именно, SessionsHelper
. Кроме того, помощники автоматически включаются в Rails представления, так что все что мы должны сделать для того чтобы использовать функции Sessions хелпера в контроллерах, это включить соответствующий модуль в Application контроллер (Листинг 8.14).
app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
protect_from_forgery with: :exception
include SessionsHelper
end
По умолчанию, все помощники доступны во views
, но не в контроллерах. Нам нужны методы Sessions хелпера в обоих местах, поэтому мы должны явно включить его.
Поскольку HTTP является протоколом, не сохраняющим своего состояния, веб-приложения, требующие входа пользователей, должны реализовывать способ, позволяющий отслеживать прогресс каждого пользователя от страницы к странице. Один из методов для поддержания статуса вошедшего пользователя, является использование традиционных Rails сессий (с помощью специальной session
функции) для хранения remember token, равного пользовательскому id:
session[:remember_token] = user.id
Этот session
объект делает идентификатор пользователя доступным от страницы к странице, сохраняя его в cookie, которые истекают при закрытии браузера. На каждой странице приложения можно просто вызвать
User.find(session[:remember_token])
для получения пользователя. Из-за способа, которым Rails обрабатывает сессии, этот процесс является безопасным, если злоумышленник попытается подменить идентификатор пользователя, Rails обнаружит несоответствие, основываясь на специальном session id, генерируемом для каждой сессии.
Для выбранного нами способа, который подразумевает постоянные сессии — то есть статус вошедшего пользователя, сохраняющийся даже после того, как браузер закрыт — нам необходимо использовать постоянный идентификатор для вошедшего пользователя. Для того чтобы достигнуть этого, мы будем генерировать уникальный, безопасный remember token для каждого пользователя и мы будем хранить его в качестве постоянной куки отличающейся от обычной тем, что она не истекает при закрытии браузера.
Remember token должен быть связан с пользователем и должен сохраняться для последующего использования, поэтому мы добавим его в качестве атрибута модели User, как это показано на Рис. 8.8.
Мы начнем с небольшого дополнения к спекам модели User (Листинг 8.15).
spec/models/user_spec.rb
require 'spec_helper'
describe User do
.
.
.
it { should respond_to(:password_confirmation) }
it { should respond_to(:remember_token) }
it { should respond_to(:authenticate) }
.
.
.
end
Мы можем получить прохождение этого теста сгенерировав remember token в командной строке:
$ rails generate migration add_remember_token_to_users
Затем мы заполняем получившуюся миграциюю кодом из Листинга 8.16. Это дает нам код показанный в Листинге 8.16. Обратите внимание, что, посколку мы планируем искать пользователей в базе данных по remember token, мы должны добавить индекс (Блок 6.2) к столбцу remember_token
.
remember_token
к таблице users
. db/migrate/[ts]_add_remember_token_to_users.rb
class AddRememberTokenToUsers < ActiveRecord::Migration
def change
add_column :users, :remember_token, :string
add_index :users, :remember_token
end
end
Затем мы, как обычно, обновляем тестовую и рабочую базы данных:
$ bundle exec rake db:migrate
$ bundle exec rake test:prepare
В этой точке спеки модели User должны проходить:
$ bundle exec rspec spec/models/user_spec.rb
Теперь мы должны выбрать, что именно использовать в качестве remember token. Существует множество, в основном эквивалентных способов, по сути, подойдет любая длинная случайная строка если она будет уникальной. Метод urlsafe_base64
из модуля SecureRandom
стандартной библиотеки Ruby вполне соответствует нашим требованиям:3 он возвращает случайную строку длиной в 16 символов составленную из знаков A–Z, a–z, 0–9, “-” и “_” (в общей сложности 64 возможности, т.е. “base64”). Это означает, что вероятность того, что два remember токена совпадут пренебрежительно мала: $1/64^{16} = 2^{-96} \approx 10^{-29}$.
Мы планируем хранить сам base64 токен в браузере, а его зашифрованную версию - в базе данных приложения. После чего мы сможем осуществлять логин пользователей вытягивая токен из куки, шифруя его, а затем ища ему соответствие в зашифрованных токенах хранимых в базе данных. Причина по которой мы храним только зашифрованные токены заключается в том, что, даже если вся наша база данных будет скомроментирована, атакер все равно не сможет использовать токены для входа. Для того чтобы сделать наш токен еще более безопасными, мы планируем менять его каждый раз когда пользователь создает новую сессию, а это означает что любые похищенные сессии—когда атакер использует украденные куки для входа от лица определенного пользователя—истекут при следующем входе пользователя. (Похищение сессий получило широкую огласку с помощью приложения Firesheep, которое показывало что токены на множестве знаменитых сайтов были видимы при подключении к публичным Wi-Fi сетям. Решение заключается в использовании SSL повсеместно на сайте, как это было описано в Разделе 7.4.4.)
Хотя в реальном приложении мы будем немедленно 'входить' вновь созданного пользователя (тем самым создавая новый токен в качестве побочного эффекта), мы не будем полагаться на такое поведение; более надежная практика заключается в обеспечении каждого пользователя валидным токеном с самого начала. Для того чтобы достигнуть этого мы будем создавать начальный токен с помощью функции обратного вызова - техники впервые представленной в Разделе 6.2.5 в контексте уникальности адресов электронной почты. В том разделе мы использовали коллбэк before_save
; в этот раз для создания remember_token
непосредственно перед сохранением пользователя мы будем использовать очень похожий коллбэк before_create
.4
Для того чтобы протестировать remember token, мы вначале сохраним тестового пользователя, а затем проверим, что атрибут пользовательского remember_token
не является пустым. Что позволит нам при необходимости изменять случайную строку, если это нам когда-либо потребуется. Результат представлен в Листинге 8.17.
spec/models/user_spec.rb
require 'spec_helper'
describe User do
before do
@user = User.new(name: "Example User", email: "[email protected]",
password: "foobar", password_confirmation: "foobar")
end
subject { @user }
.
.
.
describe "remember token" do
before { @user.save }
its(:remember_token) { should_not be_blank }
end
end
Листинг 8.17 вводит метод its
, который похож на it
но относит следующий за ним тест к данному атрибуту, а не к субъекту теста. Другими словами,
its(:remember_token) { should_not be_blank }
является эквивалентом
it { expect(@user.remember_token).not_to be_blank }
Код приложения вводит несколько новых элементов в модель User (app/models/user.rb
). Во-первых, мы добавили метод обратного вызова для создания remember token непосредственно перед созданием нового пользователя в базе данных:
before_create :create_remember_token
Этот код, называемый method reference понужает Rails искать метод с названием create_remember_token
и выполнять его перед сохранением пользователя. (В Листинге 6.20 мы явно передавали блок в before_save
, но техника ссылки на метод более предпочтительна в общем случае.) Во-вторых, сам метод используется тольку внутри модели User, так что нам нет необходимости выставлять его на показ сторонним пользователям. Как мы видели в Разделе 7.3.2, Ruby предлагает использовать для этих целей ключевое слово private
:
private
def create_remember_token
# Create the token.
end
Все методы, определенные в классе после private
автоматически становятся скрытыми, таким образом
$ rails console
>> User.first.create_remember_token
вызовет исключение NoMethodError
.
Наконец, метод create_remember_token
необходимо присвоить одному из атрибутов пользователей и в этом контексте необходимо использовать ключевое слово self
перед remember_token
:
def User.new_remember_token
SecureRandom.urlsafe_base64
end
def User.encrypt(token)
Digest::SHA1.hexdigest(token.to_s)
end
private
def create_remember_token
self.remember_token = User.encrypt(User.new_remember_token)
end
Из-за способа которым Ruby обрабатывает назначения внутри объектов, без self
назначение создаст локальную переменную с именем remember_token
, а это совсем не то что нам нужно. Использование self
обеспечивает установку назначением пользовательского remember_token
таким образом, что он будет записан в базу данных вместе с другими атрибутами при сохранении пользователя. (Теперь вы знаете почему остальные before_save
коллбэки из Листинга 6.20 используют self.email
вместо просто email
.)
Обратите внимание: мы шифровали токен с помощью SHA1 - хэширующего алгоритма который намного быстрее чем алгоритм Bcrypt используемый нами для шифрования паролей пользователей в Разделе 6.3.1, что важно, поскольку (как мы увидим в Разделе 8.2.2) для вошедших пользователей он будет выполняться на каждой странице. SHA1 является менее безопасным чем Bcrypt, но в данном случае его более чем достаточно так как шифруемый токен уже является 16-значной случайной строкой; SHA1 hexdigest такой строки по сути является невзламываемым. (Вызов to_s нужен для того чтобы мы имели возможность работать с nil
токенами - этого не должно происходить в браузерах, но иногда может случаться в тестах.)
Методы encrypt
и new_remember_token
прикреплены к классу User
так как для работы им не нужен инстанс пользователя5 и они являются публичными методами (выше строки private
) поскольку в Разделе 8.2.3 мы будем их использовать за пределами модели User.
Собрав все это воедино мы приходим модели User показанной в Листинге 8.18.
before_create
для создания remember_token
. app/models/user.rb
class User < ActiveRecord::Base
before_save { self.email = email.downcase }
before_create :create_remember_token
.
.
.
def User.new_remember_token
SecureRandom.urlsafe_base64
end
def User.encrypt(token)
Digest::SHA1.hexdigest(token.to_s)
end
private
def create_remember_token
self.remember_token = User.encrypt(User.new_remember_token)
end
end
Кстати, дополнительный уровень отступа на create_remember_token
сделан для того, чтобы визуально отделить методы определенные после private
. (Практика показала что это мудрая практика.)
Поскольку шифрованная строка SecureRandom.urlsafe_base64
определенно не пустая, тесты для модели User теперь должны пройти:
$ bundle exec rspec spec/models/user_spec.rb
8.2.2 Рабочий метод sign_in
Теперь мы готовы к написанию первого элемента входа - самой sign_in
функции. Как было отмечено выше, выбранный нами метод аутентификации заключается в помещении (вновь созданного) remember token в качестве куки в браузер пользователя и последующем использовании токена для поиска записи пользователя в базе данных при перемещении пользователя от страницы к странице (реализовано в Разделе 8.2.3). Результирующий Листинг 8.19 вводит новый для нас метод current_user
который мы будем реализовывать в Разделе 8.2.3.
sign_in
. app/helpers/sessions_helper.rb
module SessionsHelper
def sign_in(user)
remember_token = User.new_remember_token
cookies.permanent[:remember_token] = remember_token
user.update_attribute(:remember_token, User.encrypt(remember_token))
self.current_user = user
end
end
Здесь мы следуем избранной тактике: во-первых, создаем новый токен; во-вторых, помещаем зашифрованный токен в куки браузера; в-третьих, сохраняем зашифрованный токен в базе данных; в-четвертых, устанавливаем текущего пользователя равным данному пользователю (Раздел 8.2.3). Как мы увидим в Разделе 8.2.3, установка текущего пользователя равным user
в данный момент не нужна из-за незамедлительного редиректа в create
действии (Листинг 8.13), но все же это хорошая идея - на тот случай если мы когда-нибудь захотим использовать sign_in
без редиректа.
В Листинге 8.19 обратите внимание на использование update_attribute
для сохранения токена. Как вкратце упоминалось в Разделе 6.1.5), этот метод позволяет обновлять один атрибут в обход валидаций — в данном случае это необходимо так как у нас нет пароля пользователя. Листинг 8.19 также вводит утилиту cookies
которая позволяет нам манипулировать куками браузера как если бы они были хэшем; каждый элемент в куки представляет из себя хэш из двух элементов: value
и (необязательный) expires
дата (# дата истечения). Например, мы могли бы осуществить вход пользователя путем размещения куки со значением, равным пользовательскому токену, которая истекает через 20 лет:
cookies[:remember_token] = { value: remember_token,
expires: 20.years.from_now.utc }
(Этот код использует один из удобных Rails помощников, о чем говорится в Блоке 8.1.)
Вы можете вспомнить из Раздела 4.4.2, что Ruby позволяет добавлять методы к любому, даже встроенному классу. В том разделе мы добавляли palindrome?
метод к String
классу (и в результате обнаружили, что "deified"
является палиндромом), и мы также видели, как Rails добавляет blank?
метод к классу Object
(таким образом, "".blank?
, " ".blank?
, и nil.blank?
все являются true
). Код куки в Листинге 8.19 (который внутренне устанавливает срок действия cookie в 20.years.from_now
) дает еще один пример из этой практики, посредством одного из 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:00
Rails добавляет и другие помощники:
>> 1.kilobyte => 1024 >> 5.megabytes => 5242880
Они полезны для валидации загрузки, что позволяет легко ограничить, например, загрузку изображений размером в 5.megabytes.
Хотя она должна использоваться с осторожностью, возможность добавлять методы к встроенным классам позволяет создавать черезвычайно естественные добавления к обычному Ruby. Действительно, большая часть элегантности Rails в конечном счете, является производной от податливости лежащего в его основе языка Ruby.
Паттерн установки куки истекающей через 20 лет стал настолько общепринятым, что Rails добавил специальный метод permanent
для его реализации, так что мы можем просто написать
cookies.permanent[:remember_token] = remember_token
Под капотом, применение permanent
приводит к автоматической установке даты истечения куки через 20 лет (20.years.from_now
).
После того как куки установлены, на последующих представлениях страниц мы можем извлекать пользователя с кодом вроде
User.find_by(remember_token: remember_token)
(Как мы увидим в Листинге 8.22, на самом деле мы вначале должны захэшировать токен.) Конечно, cookies
это на самом деле не хэш, поскольку назначение cookies
действительно сохраняет кусочек текста в браузере, но частью красоты Rails является то, что он позволяет вам забыть о деталях и сконцентрироваться на написании приложения.
8.2.3 Текущий пользователь
Обсудив способ хранения пользовательского remember token в куки для последующего использования, теперь нам необходимо узнать как извлекать пользователя при последующем просмотре страниц. Давайте еще раз взглянем на функцию sign_in
для того чтобы понять где мы находимся:
module SessionsHelper
def sign_in(user)
remember_token = User.new_remember_token
cookies.permanent[:remember_token] = remember_token
user.update_attribute(:remember_token, User.encrypt(remember_token))
self.current_user = user
end
end
Единственный участок кода который в данный момент не работает это:
self.current_user = user
Как было отмечено сразу после Листинга 8.19, этот код никогда не будет использоваться в данном приложении из-за немедленного редиректа в Листинге 8.13, но для метода sign_in
было бы опасным полагаться на это.
Целью 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.
current_user
. app/helpers/sessions_helper.rb
module SessionsHelper
def sign_in(user)
.
.
.
end
def current_user=(user)
@current_user = user
end
end
Это может выглядеть сбивающим с толку — большинство языков не позволит вам использовать знак равенства в определении метода, но это просто определение метода current_user=
специально разработанного для обработки назначения current_user
. Другими словами, код
self.current_user = ...
автоматически конвертируется в
current_user=(...)
тем самым вызывая метод current_user=
. Его единственный аргумент это то, что находится справа от назначения, в данном случае - пользователь который войдет. Однострочный метод в теле просто устанавливает переменную экземпляра @current_user
, эффективно хранящую пользователя для дальнейшего использования.
В обычном Ruby, мы могли бы определить второй метод, current_user
, предназначенный для возвращения значения @current_user
, как это показано в Листинге 8.21.
current_user
.
module SessionsHelper
def sign_in(user)
.
.
.
end
def current_user=(user)
@current_user = user
end
def current_user
@current_user # Useless! Don't use this line.
end
end
Если бы мы сделали это, мы бы фактически повторили функциональность attr_accessor
, который мы видели в Разделе 4.4.5.6 Проблема в том, что он совершенно не в состоянии решить наши проблемы: с кодом в Листинге 8.21, статус вошедшего пользователя будет забыт: как только пользователь перейдет на другую страницу — poof! — сессия закончится и пользователь автоматически выйдет.
Это связано с тем что в HTTP отсутствует сохранение промежуточного состояния между парами «запрос-ответ» (Раздел 8.2.1) — когда пользователь делает второй запрос, все переменные устанавливаются к своим дефолтным значениям, в случае переменных экземпляра вроде @current_user
это nil
. Таким образом, когда пользователь обратится к еще одной странице, даже находясь в том же приложении, Rails установит @current_user
равным nil
и код в Листинге 8.21 не сделает то чего вы от него ожидали.
Для того чтобы избежать этой проблемы мы можем искать пользователя соответствующего remember token созданному кодом в Листинге 8.19, как это показано в Листинге 8.22. Обратите внимание: поскольку токен хранимый в базе данных зашифрован, нам нужно зашифровать токен полученный из куки прежде чем использовать его для поиска пользователя в базе данных. Мы достигним этого с помощью метода User.encrypt
определенного в Листинге 8.18.
remember_token
. app/helpers/sessions_helper.rb
module SessionsHelper
.
.
.
def current_user=(user)
@current_user = user
end
def current_user
remember_token = User.encrypt(cookies[:remember_token])
@current_user ||= User.find_by(remember_token: remember_token)
end
end
Листинг 8.22 использует общепринятый, но изначально обескураживающий ||=
(“или равно”) оператор присваиваивания (Блок 8.2). Его эффект заключается в установке переменной экземпляра @current_user
пользователю, соответствующему remember token, но только если @current_user
не определен.7 Иными словами, конструкция
@current_user ||= User.find_by(remember_token: remember_token)
вызывает метод find_by
при первом вызове которого вызывается current_user
, но при последующих вызовах возвращается @current_user
без обращения к базе данных.8 Это полезно лишь в случае если current_user
используется чаще чем один раз для запроса отдельно взятого пользователя; в любом случае, find_by
будет вызван по крайней мере один раз при каждом посещении страницы на этом сайте.
Конструкция ||= - очень Рубишная — то есть, она очень характерна для языка 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
но в противном случае оставляя ее в покое. Вспоминая or оператор ||
из Раздела 4.2.3, мы можем записать это следующим образом:
>> @user => nil >> @user = @user || "the user" => "the user" >> @user = @user || "another user" => "the user"
Поскольку nil
ложно в булевом контексте, первое присвоение это nil || "the user"
, что оценивается как "the user"
; аналогично, второе присвоение является "the user" || "another user"
, которое также оценивается как "the user"
— так как строки true
в булевом контексте, серия ||
выражений прекращается после оценки первого выражения. (Эта практика оценки выражений ||
слева направо и остановки на первом истинном значении, известна как оценка короткого замыкания (short-circuit evaluation).)
Сравнивая в консольной сессии различные операторы, мы видим, что @user = @user || value
следует x = x O y
паттерну с ||
вместо O
, что позволяет предположить следующую эквивалентную конструкцию:
>> @user ||= "the user" => "the user"
Вуаля!
8.2.4 Изменение ссылок шаблона
Мы подходим, наконец, к практическому применению всей нашей войти/выйти работы: мы сделаем ссылки в шаблоне меняющимися в зависимости от статуса пользователя. В частности, как показано на Рис. 8.3, мы организуем изменение ссылок при входе и выходе пользователей из системы, а также мы добавим ссылки на список всех пользователей, на страницу настроек пользователя (будет закончена в Главе 9) и одну для профиля текущего пользователя. При этом, мы получим прохождение тестов из Листинга 8.6, а это означает, что наш набор тестов станет польностью зеленым впервые с начала этой главы.
Смена ссылок в шаблоне сайта подразумевает использование если-иначе ветвящихся структур внутри Embedded Ruby:
<% if signed_in? %>
# Ссылки для вошедших пользователей
<% else %>
# Ссылки для не вошедших пользователей
<% end %>
Такой код требует наличия булевого метода signed_in?
, который мы сейчас и реализуем.
Пользователь является вошедшим если в сессии существует текущий пользователь, т.e., если current_user
не является nil
. Это требует использования оператора “not”, написанного с помощью восклицательного знака !
и обычно читаемого как “bang”. В данном контексте пользователь является вошедшим если current_user
является не nil
, как это показано в Листинге 8.23.
signed_in?
. app/helpers/sessions_helper.rb
module SessionsHelper
def sign_in(user)
remember_token = User.new_remember_token
cookies.permanent[:remember_token] = remember_token
user.update_attribute(:remember_token, User.encrypt(remember_token))
self.current_user = user
end
def signed_in?
!current_user.nil?
end
.
.
.
end
Имея на руках метод signed_in?
, мы готовы закончить ссылки шаблона. Это будут четыре новых ссылки, две из которых пока останутся заглушками (мы их доработаем в Главе 9):
<%= link_to "Users", '#' %>
<%= link_to "Settings", '#' %>
Ссылка на выход, между прочим, использует путь выхода, определенный в Листинге 8.2:
<%= link_to "Sign out", signout_path, method: "delete" %>
(Обратите внимание на то, что ссылка на выход передает хэш аргументов указывающий на то, что она должна отправить HTTP запрос DELETE.9) Наконец, мы добавим ссылку на профиль следующим образом:
<%= link_to "Profile", current_user %>
Здесь мы могли бы написать
<%= link_to "Profile", user_path(current_user) %>
но Rails позволяет нам ссылаться непосредственно на пользователя и в этом контексте current_user
будет автоматически конвертирован в user_path(current_user)
.
В процессе размещения новых ссылок в шаблон, мы воспользуемся случаем и создадим выпадающее меню с помощью Bootstrap, вы можете более подробно почитать об этом на странице компонентов Bootstrap. Полный результат представлен в Листинге 8.24. В частности, обратите внимание на CSS id и классы связанные с выпадающим меню Bootstrap.
app/views/layouts/_header.html.erb
<header class="navbar navbar-fixed-top navbar-inverse">
<div class="navbar-inner">
<div class="container">
<%= link_to "sample app", root_path, id: "logo" %>
<nav>
<ul class="nav pull-right">
<li><%= link_to "Home", root_path %></li>
<li><%= link_to "Help", help_path %></li>
<% if signed_in? %>
<li><%= link_to "Users", '#' %></li>
<li id="fat-menu" class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">
Account <b class="caret"></b>
</a>
<ul class="dropdown-menu">
<li><%= link_to "Profile", current_user %></li>
<li><%= link_to "Settings", '#' %></li>
<li class="divider"></li>
<li>
<%= link_to "Sign out", signout_path, method: "delete" %>
</li>
</ul>
</li>
<% else %>
<li><%= link_to "Sign in", signin_path %></li>
<% end %>
</ul>
</nav>
</div>
</div>
</header>
Выпадающее меню требует применения JavaScript библиотеки Bootstrap, которую мы можем включить с помощью Рельсового файлопровода, отредактировав файл application.js, как это показано в Листинге 8.25.
application.js
. app/assets/javascripts/application.js
//= require jquery
//= require jquery_ujs
//= require bootstrap
//= require turbolinks
//= require_tree .
Здесь используется библиотека Sprockets для включения Bootstrap JavaScript, которая в свою очередь доступна благодаря гему bootstrap-sass из Раздела 5.1.2.
С кодом в Листинге 8.24 все тесты должны пройти:
$ bundle exec rspec spec/
Вошедший пользователь теперь видит новые ссылки и выпадающее меню определенное Листингом 8.24, as shown in Рис. 8.9.
В этой точке вам следует проверить, что вы можете войти, закрыть браузер и быть по-прежнему вошедшими при повторном посещении примера приложения. Если хотите, вы можете даже проверить куки браузера для того чтобы посмотреть на результат непосредственно (Рис. 8.10).
8.2.5 Вход после регистрации
В принципе, хотя мы закончили с аутентификацией, вновь зарегистрированные пользователи могут оказаться сбитыми с толку, так как они не вошли в систему по умолчанию. Реализация этого - последний штрих который мы добавим прежде чем позволим пользователям входить на наш сайт. Мы начнем с добавления строки к тестам аутентификации (Листинг 8.26). Это включает “after saving the user” describe
блок из Листинга 7.32 (Раздел 7.6), который вы должны добавить в тест, если вы не сделали этого в соответствующем упражнении.
spec/requests/user_pages_spec.rb
require 'spec_helper'
describe "User pages" do
.
.
.
describe "with valid information" do
.
.
.
describe "after saving the user" do
before { click_button submit }
let(:user) { User.find_by(email: '[email protected]') }
it { should have_link('Sign out') }
it { should have_title(user.name) }
it { should have_selector('div.alert.alert-success', text: 'Welcome') }
end
end
end
end
Здесь мы протестировали появление ссылки на выход для того чтобы убедиться что пользователь успешно вошел после регистрации.
С методом sign_in
из Раздела 8.2, получение прохождения этого теста фактически впустив пользователя в систему легко: просто добавим sign_in @user
сразу после сохранения пользователя в базе данных (Листинг 8.27).
app/controllers/users_controller.rb
class UsersController < ApplicationController
.
.
.
def create
@user = User.new(user_params)
if @user.save
sign_in @user
flash[:success] = "Welcome to the Sample App!"
redirect_to @user
else
render 'new'
end
end
.
.
.
end
8.2.6 Выход
Как обсуждалось в Разделе 8.1, наша аутентификационная модель предполагает сохранение пользователей вошедшими до тех пор, пока они явно не выйдут из системы. В этом разделе мы добавим эту, необходимую нам и пользователям, возможность выхода.
До сих пор действия контроллера Sessions следовали RESTful конвенции, используя new
для страницы входа и create
для его завершения. Мы продолжим эту тему используя действие destroy
для удаления сессий, т.е., для выхода. Для того чтобы протестировать это, мы кликнем по ссылке “Sign out” а затем попробуем найти вновь появившуюся ссылку на вход (Листинг 8.28).
spec/requests/authentication_pages_spec.rb
require 'spec_helper'
describe "Authentication" do
.
.
.
describe "signin" do
.
.
.
describe "with valid information" do
.
.
.
describe "followed by signout" do
before { click_link "Sign out" }
it { should have_link('Sign in') }
end
end
end
end
Как и со входом пользователя, основанном на функции sign_in
, выход пользователя просто перекладывает работу на функцию sign_out
(Листинг 8.29).
app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
.
.
.
def destroy
sign_out
redirect_to root_url
end
end
Как и другие элементы аутентификации, мы поместим sign_out
в вспомогательный модуль Sessions. Листинг 8.30 показывает шаги: мы вначале меняем remember token пользователя в базе данных (на тот случай если куки были украдены, поскольку в этом случае они могут быть использованы для авторизации пользователя), затем мы вызываем метод delete
на куках для удаления remember token из сессии; в качестве необязательного шага, мы устанавливаем текущего пользователя равным nil
. (Как и назначение в методе sign_in
(Листинг 8.19), установка текущего пользователя равным nil
в настоящий момент не является строгой необходимостью из-за незамедлительного редиректа в действии destroy
, но все же это хорошая идея - на случай если мы когда-либо захотим использовать sign_out
без редиректа.)
sign_out
в модуле Sessions хелпер. app/helpers/sessions_helper.rb
module SessionsHelper
def sign_in(user)
remember_token = User.new_remember_token
cookies.permanent[:remember_token] = remember_token
user.update_attribute(:remember_token, User.encrypt(remember_token))
self.current_user = user
end
.
.
.
def sign_out
current_user.update_attribute(:remember_token,
User.encrypt(User.new_remember_token))
cookies.delete(:remember_token)
self.current_user = nil
end
end
Это завершает триумвират регистрация/вход/выход и набор тестов должен пройти:
$ bundle exec rspec spec/
Стоит отметить, что наш набор тестов покрывает большую часть механизма аутентификации, но не все же не полностью. Например, мы не тестируем то как долго живет “remember me” куки и даже не тестируем устанавливается ли она вообще. Это возможно сделать, но практика показывает, что непосредственное тестирование значения куки является хрупким и имеет тенденцию зависеть от деталей реализации которые иногда меняются от одного релиза Rails к другому. Результатом служат рухнувшие тесты вполне себе рабочего кода. Фокусируясь не функционале верхнего уровня - проверяя что пользователи могут войти, оставаться вошедшими при переходе от страницы к странице и могут выйти - мы тестируем ядро кода приложения не заморачиваясь менее важными деталями.
8.3 Введение в Cucumber (опционально)
Закончив основу системы аутентификации примера приложения, мы собираемся воспользоваться подвернувшейся возможностью для того чтобы показать как написать тесты входа с помощью Cucumber - популярного инструмента для разработки через поведение, который пользуется значительной популярностью в сообществе Ruby. Этот раздел необязателен и может быть пропущен без потери целостности повествования.
Cucumber позволяет определять текстовые истории описывающие поведение приложения. Множество Rails программистов находят Cucumber особенно полезным при работе над клиентскими проектами; поскольку они могут быть прочитаны даже технически не подкованными пользователями, тесты на Cucumber могут быть прочитаны (а иногда даже написаны) клиентом. Конечно же, применение тестового фреймворка, который не является чистым Ruby, имеет и оборотную сторону, и я считаю что текстовые истории зачастую могут быть излишне многословными. Тем не менее, Cucumber занял прочные позиции в Ruby-инструментарии тестирования и мне особенно нравится его акцент на поведении верхнего уровня, а не на деталях реализации.
Поскольку акценты в этой книге смещены в сторону RSpec и Capybara, последующая презентация совершенно не претендует на полноту и исчерпывающее раскрытие темы. Ее цель - просто дать вам возможность ощутить вкус Cucumber-а (несомненно свежий и сочный) — если он поразит ваше воображение, существуют целые книги на эту тему готовые удовлетворить ваш аппетит. (Я особенно рекомендую The RSpec Book (David Chelimsky) и Rails 3 in Action (Ryan Bigg и Yehuda Katz), и The Cucumber Book (Matt Wynne и Aslak Hellesøy).)
8.3.1 Установка и настройка
Для того чтобы установить Cucumber, во-первых, добавьте гем cucumber-rails и служебный гем database_cleaner в группу :test
в Gemfile
(Листинг 8.31).
Gemfile.
.
.
.
group :test do
.
.
.
gem 'cucumber-rails', '1.4.0', :require => false
gem 'database_cleaner', github: 'bmabey/database_cleaner'
end
.
.
.
Затем установите как обычно:
$ bundle install
Для того чтобы настроить приложение для использования Cucumber, мы затем генерируем несколько необходимых, поддерживающих его работу файлов и директорий:
$ rails generate cucumber:install
Это создает директорию features/
где будут жить файлы связанные с Cucumber.
8.3.2 Фичи и шаги
Огурцовые фичи это описания ожидаемого поведения с помощью plain-text языка называемого Gherkin. Gherkin тесты читаются во многом как хорошо написанные примеры RSpec, но, поскольку они написаны простым текстом, они более доступны для тех, кому комфортнее читать английский, а не код Руби.
Наши Огурцовые фичи будут реализовывать небольшое количество примеров входа в Листинге 8.5 и Листинге 8.6. Для того чтобы начать, мы создадим файл signing_in.feature
в директории features/
.
Огурцовые фичи начинаются с короткого описания функционала:
Feature: Signing in
Затем они добавляют индивидуальные сценарии. Например, для того, чтобы протестировать провальный вход, мы можем написать следующий сценарий:
Scenario: Unsuccessful signin
Given a user visits the signin page
When they submit invalid signin information
Then they should see an error message
Аналогично, для того, чтобы протестировать успешный вход, мы можем добавить следующее:
Scenario: Successful signin
Given a user visits the signin page
And the user has an account
When the user submits valid signin information
Then they should see their profile page
And they should see a signout link
Собрав все это вместе мы приходим к файлу Огурцовой фичи показанному в Листинге 8.32.
features/signing_in.feature
Feature: Signing in
Scenario: Unsuccessful signin
Given a user visits the signin page
When they submit invalid signin information
Then they should see an error message
Scenario: Successful signin
Given a user visits the signin page
And the user has an account
When the user submits valid signin information
Then they should see their profile page
And they should see a signout link
Для запуска фич мы используем исполняемую команду cucumber
:
$ bundle exec cucumber features/
Сравните это с
$ bundle exec rspec spec/
В данном контексте стоит отметить, что, как и RSpec, Cucumber может быть вызван с помощью Rake-задачи:
$ bundle exec rake cucumber
(По непонятным для меня причинам, это иногда пишут как rake cucumber:ok
.)
Все что мы пока сделали, это лишь написали немного простого текста, так что не думаю что для вас стало сюрпризом что Огурцовые сценарии пока не проходят. Для того чтобы получить зеленый набор тестов, нам необходима добавить файл step, который свяжет строки простого текста с Руби-кодом. Файл отправляется в директорию features/step_definitions
; мы назовем его authentication_steps.rb
.
Строки Feature
и Scenario
нужны в основном для документации, но каждой последующей строке нужен соответствующий Ruby. Например, строка
Given a user visits the signin page
в файле фич будет обработана соответствующим определением шага
Given /^a user visits the signin page$/ do
visit signin_path
end
В фиче, Given
это просто строка, но в файле с шагами Given
является методом который принимает регулярное выражение и блок. Регулярное выражение соответствует тексту строки в сценарии, а содержимое блока является чистым Руби кодом, необходимым для реализации шага. В данном случае, “a user visits the signin page” реализуется посредством
visit signin_path
Если это выглядит знакомым, все правильно: это просто Capybara, которая включена по умолчанию в файлы с Огурцовыми шагами. Следующие две строки тоже должны выглядеть знакомо; шаги сценария
When they submit invalid signin information
Then they should see an error message
в файле фич обрабатываются следующими шагами:
When /^they submit invalid signin information$/ do
click_button "Sign in"
end
Then /^they should see an error message$/ do
expect(page).to have_selector('div.alert.alert-error')
end
Первый шаг также использует Capybara, при этом второй использует объект Capybara page
вместе с RSpec. Очевидно, вся работа с тестами, которую мы проделали с RSpec и Capybara также полезна с Cucumber.
Остальные шаги обрабатываются аналогично. Конечный файл определения шагов преставлен в Листинге 8.33. Попробуйте добавлять шаги по одному, запуская
$ bundle exec cucumber features/
каждый раз до тех пор пока все тесты не пройдут.
features/step_definitions/authentication_steps.rb
Given /^a user visits the signin page$/ do
visit signin_path
end
When /^they submit invalid signin information$/ do
click_button "Sign in"
end
Then /^they should see an error message$/ do
expect(page).to have_selector('div.alert.alert-error')
end
Given /^the user has an account$/ do
@user = User.create(name: "Example User", email: "[email protected]",
password: "foobar", password_confirmation: "foobar")
end
When /^the user submits valid signin information$/ do
fill_in "Email", with: @user.email
fill_in "Password", with: @user.password
click_button "Sign in"
end
Then /^they should see their profile page$/ do
expect(page).to have_title(@user.name)
end
Then /^they should see a signout link$/ do
expect(page).to have_link('Sign out', href: signout_path)
end
С кодом в Листинге 8.33, Огурцовые тесты должны пройти:
$ bundle exec cucumber features/
8.3.3 Контрапункт: кастомные проверки RSpec
Написав несколько простых Огурцовых сценариев, стоит сравнить результат с эквивалентным примером на RSpec. Для начала, взглянем на Огурцовую фичу в Листинг 8.32 и соответствующее определение шагов в Листинге 8.33. Затем взглянем на RSpec request specs (интеграционные тесты):
describe "Authentication" do
subject { page }
describe "signin" do
before { visit signin_path }
describe "with invalid information" do
before { click_button "Sign in" }
it { should have_title('Sign in') }
it { should have_selector('div.alert.alert-error') }
end
describe "with valid information" do
let(:user) { FactoryGirl.create(:user) }
before do
fill_in "Email", with: user.email.upcase
fill_in "Password", with: user.password
click_button "Sign in"
end
it { should have_title(user.name) }
it { should have_link('Profile', href: user_path(user)) }
it { should have_link('Sign out', href: signout_path) }
it { should_not have_link('Sign in', href: signin_path) }
end
end
end
Вы можете видеть, что дело может быть сделано как с помощью Огурца, так и с помощью интеграционных тестов. Огурцовые фичи легкочитаемы, но они полностью отделены от кода который их реализует, а это палка о двух концах. Я считаю что Огурец легок в чтении, но сложен в написании, в то время как интеграционные тесты (для программистов) немного более сложны в чтении и намного более просты в написании.
Один из замечательных эффектов Огурцового разделения является то, что он работает на более высоком уровне абстракции. Например, мы пишем
Then they should see an error message
для того, чтобы выразить ожидание увидеть сообщение об ошибке, и
Then /^they should see an error message$/ do
expect(page).to have_selector('div.alert.alert-error')
end
для того чтобы реализовать тест. Что особенно удобно в этом, так это то, что только второй элемент (шаг) зависит от реализации, таким образом, если мы изменим, например, класс CSS используемый для сообщений об ошибках, файл фич останется неизменным.
В этом случае можно запечалиться переписывая
should have_selector('div.alert.alert-error')
в куче мест, в то время как вы на самом деле хотели лишь указать на то, что на странице должно присутствовать сообщение об ошибке. Эта практика тесно связывает тест с реализацией и мы должны были бы изменить это повсеместно при изменении реализации. В контексте чистого RSpec для этого есть решение, которое заключается в использовании кастомных проверок, позволяющих нам писать вместо этого следующее:
should have_error_message('Invalid')
Мы можем определить такие проверки в том же вспомогательном файле, в который мы поместили тестовый хелпер full_title
в Разделе 5.3.4. Сам код выглядит примерно следующим образом:
RSpec::Matchers.define :have_error_message do |message|
match do |page|
expect(page).to have_selector('div.alert.alert-error', text: message)
end
end
Мы можем также определить вспомогательные функции для общепринятых операций:
def valid_signin(user)
fill_in "Email", with: user.email
fill_in "Password", with: user.password
click_button "Sign in"
end
Получившийся в результате вспомогательный код показан в Листинге 8.34 (который включает в себя результаты Листинга 5.41 и Листинга 5.42 из Раздела 5.6). Я нахожу этот подход более гибким, нежели Огурцовые определения шагов, в особенности когда проверки или помощники долженствования натурально принимают аргумент, такой как valid_signin(user)
. Определения шагов может повторить эту функциональность с помощью проверок регулярных выражений, но я считаю такой подход гораздо более громоздким (# в оригинале - (cu)cumbersome).
spec/support/utilities.rb
include ApplicationHelper
def valid_signin(user)
fill_in "Email", with: user.email
fill_in "Password", with: user.password
click_button "Sign in"
end
RSpec::Matchers.define :have_error_message do |message|
match do |page|
expect(page).to have_selector('div.alert.alert-error', text: message)
end
end
С кодом из Листинга 8.34, мы можем написать
it { should have_error_message('Invalid') }
и
describe "with valid information" do
let(: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.5 Упражнения
- Реорганизуйте форму входа для использования
form_tag
вместоform_for
. Убедитесь что набор тестов по-прежнему проходит. Подсказка: см. RailsCast on authentication in Rails 3.1, особенно обратите внимание на изменения в структуре хэшаparams
. - Следуя примеру в Разделе 8.3.3, пройдитесь по интеграционным тестам пользователя и аутентификации (т.e., по файлам в директории
spec/requests
) и добавьте методы вspec/support/utilities.rb
для отделения тестов от реализации. Факультативно: Организуйте служебный код в отдельные файлы и модули, и заставьте все работать, правильно включив модули в файле spec_helper.rb.
- Другой распространенной моделью является завершение сессии после истечения определенного количества времени. Это особенно уместно на сайтах, содержащих конфиденциальную информацию, такую как банковские и финансово-торговые операции. ↑
- Изображение взято с http://www.flickr.com/photos/hermanusbackpackers/3343254977/. ↑
- Этот выбор опирается на RailsCast on remember me. ↑
- Более подробно о видах коллбэков, поддерживаемых библиотекой Active Record см. в обсуждении коллбэков в Rails Guides (# см. перевод на rusrails.ru). ↑
- Если методу не нужен экземпляр объекта, он должен быть методом класса. ↑
- На самом деле, эти двое абсолютно эквивалентны;
attr_accessor
это просто удобный способ создавать такие getter/setter методы автоматически. ↑ - Как правило, это означает присвоение переменных, которые изначально
nil
, но обратите внимание - ложные (false
) значения также будут переписаны оператором||=
. ↑ - Это является примером мемоизации, которая обсуждалась ранее в Блоке 6.3. ↑
- Веб браузеры на самом деле не могут выдавать запрос DELETE; Rails подделывает его с помошью JavaScript. ↑