這一次,我們沒有 mix phx.gen.html
可以用,所以要一步一步寫了。
它的過程,跟Phoenix初體驗一章一樣。
但這里,我們要從測試寫起,運行它,看著它拋出錯誤,之后才填補代碼,保證每個測試通過。
Don't panic,錯誤是指引我們成功的路燈。
添加路由
首先在 test/tv_recipe_web/controllers
目錄下新建一個 session_controller_test.exs
文件:
defmodule TvRecipeWeb.SessionControllerTest do
use TvRecipeWeb.ConnCase
end
我們希望在用戶訪問 /sessions/new
網(wǎng)址時,返回一個登錄頁面。雖然目前我們還不清楚 Phoenix 下的測試代碼究竟是什么原理,但沒關系,我們可以參考 user_controller_test.exs
測試文件照貓畫虎:
test "renders form for new sessions", %{conn: conn} do
conn = get conn, Routes.session_path(conn, :new)
# 200 響應,頁面上帶有“登錄”
assert html_response(conn, 200) =~ "登錄"
end
運行測試,結果如下:
$ mix test test/tv_recipe_web/controllers/session_controller_test.exs
** (CompileError) test/tv_recipe_web/controllers/session_controller_test.exs:5: undefined function session_path/2
(stdlib) lists.erl:1338: :lists.foreach/2
(stdlib) erl_eval.erl:670: :erl_eval.do_apply/6
(elixir) lib/code.ex:370: Code.require_file/2
(elixir) lib/kernel/parallel_require.ex:57: anonymous fn/2 in Kernel.ParallelRequire.spawn_requires/5
session_path
函數(shù)未定義。要怎么定義,在哪定義?
實際上,在前面的章節(jié)里,我們已經(jīng)遭遇過 user_path
,但還沒有解釋過它從哪里來。
我們來看 Phoenix.Router 的文檔,其中 Helpers 一節(jié)有說明如下:
Phoenix automatically generates a module Helpers inside your router which contains named helpers to help developers generate and keep their routes up to date.
Helpers are automatically generated based on the controller name.
我們在 router.ex
文件中定義 TvRecipe.Router
模塊,而 Phoenix 會在該模塊下生成一個 TvRecipe.Router.Helpers
模塊,用于管理我們的路由。Helpers
下的內容,基于控制器的名稱生成。
比如我們有一個路由:
get "/", PageController, :index
則 Phoenix 會自動生成 TvRecipe.Router.Helpers.page_path
。
那么,前面章節(jié)里 user_path
出現(xiàn)時,是在控制器與模板中,并且是光禿禿的 user_path
,而不是 TvRecipe.Router.Helpers.user_path
這樣冗長寫法,它們究竟是怎樣引用的?
我們回頭去看控制器的代碼,會在開頭處看到這么一行:
use TvRecipeWeb, :controller
而 TvRecipeWeb
是定義在 tv_recipe_web/tv_recipe_web.ex
文件,其中會有這樣的內容:
def controller do
quote do
use Phoenix.Controller
alias TvRecipe.Repo
import Ecto
import Ecto.Query
import TvRecipe.Router.Helpers
import TvRecipe.Gettext
end
end
我們看到了 import TvRecipe.Router.Helpers
一行,這正是我們在控制器中可以直接使用 user_path
等函數(shù)的原因 - use TvRecipeWeb, :controller
做了準備工作。
現(xiàn)在,我們知道要怎么定義 session_path
了。
打開 router.ex
文件,添加一個新路由:
diff --git a/lib/tv_recipe_web/router.ex b/lib/tv_recipe_web/router.ex
index 4ddc1cc..aac327c 100644
--- a/lib/tv_recipe_web/router.ex
+++ b/lib/tv_recipe_web/router.ex
@@ -18,6 +18,7 @@ defmodule TvRecipe.Router do
get "/", PageController, :index
resources "/users", UserController
+ get "/sessions/new", SessionController, :new
end
運行測試:
mix test test/tv_recipe_web/controllers/session_controller_test.exs
Compiling 8 files (.ex)
1) test renders form for new sessions (TvRecipeWeb.SessionControllerTest)
test/tv_recipe_web/controllers/session_controller_test.exs:4
** (UndefinedFunctionError) function TvRecipeWeb.SessionController.init/1 is undefined (module TvRecipeWeb.SessionController
is not available)
stacktrace:
TvRecipeWeb.SessionController.init(:new)
(tv_recipe) lib/tv_recipe_web/router.ex:1: anonymous fn/1 in TvRecipe.Router.match_route/4
(tv_recipe) lib/phoenix/router.ex:261: TvRecipe.Router.dispatch/2
(tv_recipe) lib/tv_recipe_web/router.ex:1: TvRecipe.Router.do_call/2
(tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.phoenix_pipeline/1
(tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.call/2
(phoenix) lib/phoenix/test/conn_test.ex:224: Phoenix.ConnTest.dispatch/5
test/tv_recipe_web/controllers/session_controller_test.exs:5: (test)
Finished in 0.08 seconds
1 test, 1 failure
SessionController
未定義。
創(chuàng)建 SessionController
模塊
在 web/controllers
目錄下新建一個 session_controller.ex
文件,內容如下:
defmodule TvRecipeWeb.SessionController do
use TvRecipeWeb, :controller
def new(conn, _params) do
render conn, "new.html"
end
end
你可能在想,_params
是什么意思。在 Elixir 下,如果一個參數(shù)沒被用到,編譯時就會有提示,我們給這個未用到的參數(shù)加個 _
前綴,就能消除編譯時的提示。
現(xiàn)在運行測試:
mix test test/tv_recipe_web/controllers/session_controller_test.exs
Compiling 1 file (.ex)
Generated tv_recipe app
1) test renders form for new sessions (TvRecipeWeb.SessionControllerTest)
test/tv_recipe_web/controllers/session_controller_test.exs:4
** (UndefinedFunctionError) function TvRecipeWeb.SessionView.render/2 is undefined (module TvRecipeWeb.SessionView is not ava
ilable)
stacktrace:
TvRecipeWeb.SessionView.render("new.html", %{conn: %Plug.Conn{adapter: {Plug.Adapters.Test.Conn, :...}, assigns: %{layou
t: {TvRecipe.LayoutView, "app.html"}}, before_send: [#Function<0.101282891/1 in Plug.CSRFProtection.call/2>, #Function<4.111
648917/1 in Phoenix.Controller.fetch_flash/2>, #Function<0.61377594/1 in Plug.Session.before_send/2>, #Function<1.115972179/
1 in Plug.Logger.call/2>], body_params: %{}, cookies: %{}, halted: false, host: "www.example.com", method: "GET", owner: #PI
D<0.302.0>, params: %{}, path_info: ["sessions", "new"], path_params: %{}, peer: {{127, 0, 0, 1}, 111317}, port: 80, private
: %{TvRecipe.Router => {[], %{}}, :phoenix_action => :new, :phoenix_controller => TvRecipeWeb.SessionController, :phoenix_endpo
int => TvRecipe.Endpoint, :phoenix_flash => %{}, :phoenix_format => "html", :phoenix_layout => {TvRecipe.LayoutView, :app},
:phoenix_pipelines => [:browser], :phoenix_recycled => true, :phoenix_route => #Function<12.75217690/1 in TvRecipe.Router.ma
tch_route/4>, :phoenix_router => TvRecipe.Router, :phoenix_template => "new.html", :phoenix_view => TvRecipeWeb.SessionView, :p
lug_session => %{}, :plug_session_fetch => :done, :plug_skip_csrf_protection => true}, query_params: %{}, query_string: "",
remote_ip: {127, 0, 0, 1}, req_cookies: %{}, req_headers: [], request_path: "/sessions/new", resp_body: nil, resp_cookies: %
{}, resp_headers: [{"cache-control", "max-age=0, private, must-revalidate"}, {"x-request-id", "eedn739jkdct1hr8r3nod6nst95b2
qvu"}, {"x-frame-options", "SAMEORIGIN"}, {"x-xss-protection", "1; mode=block"}, {"x-content-type-options", "nosniff"}], sch
eme: :http, script_name: [], secret_key_base: "XfacEiZ/QVO87L4qirM0thXcedgcx5zYhLPAsmVPnL8AVu6qB/Et84yvJ6712aSn", state: :un
set, status: nil}, view_module: TvRecipeWeb.SessionView, view_template: "new.html"})
(tv_recipe) web/templates/layout/app.html.eex:29: TvRecipe.LayoutView."app.html"/1
(phoenix) lib/phoenix/view.ex:335: Phoenix.View.render_to_iodata/3
(phoenix) lib/phoenix/controller.ex:642: Phoenix.Controller.do_render/4
(tv_recipe) web/controllers/session_controller.ex:1: TvRecipeWeb.SessionController.action/2
(tv_recipe) web/controllers/session_controller.ex:1: TvRecipeWeb.SessionController.phoenix_controller_pipeline/2
(tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.instrument/4
(tv_recipe) lib/phoenix/router.ex:261: TvRecipe.Router.dispatch/2
(tv_recipe) lib/tv_recipe_web/router.ex:1: TvRecipe.Router.do_call/2
(tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.phoenix_pipeline/1
(tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.call/2
(phoenix) lib/phoenix/test/conn_test.ex:224: Phoenix.ConnTest.dispatch/5
test/tv_recipe_web/controllers/session_controller_test.exs:5: (test)
Finished in 0.1 seconds
1 test, 1 failure
測試失敗,因為 TvRecipeWeb.SessionView
未定義。
創(chuàng)建 SessionView
模塊
在 web/views
目錄下新建一個 session_view.ex
文件,內容如下:
defmodule TvRecipeWeb.SessionView do
use TvRecipeWeb, :view
end
在 Phoenix 下,View 與 templates 是分開的,其中 View 是模塊(module),而 templates 在編譯后,會變成 View 模塊中的函數(shù)。這也是為什么我們在定義模板之前,要先定義視圖的原因。
此時運行測試:
mix test test/tv_recipe_web/controllers/session_controller_test.exs
Compiling 1 file (.ex)
Generated tv_recipe app
1) test renders form for new sessions (TvRecipeWeb.SessionControllerTest)
test/tv_recipe_web/controllers/session_controller_test.exs:4
** (Phoenix.Template.UndefinedError) Could not render "new.html" for TvRecipeWeb.SessionView, please define a matching cla
use for render/2 or define a template at "web/templates/session". No templates were compiled for this module.
Assigns:
%{conn: %Plug.Conn{adapter: {Plug.Adapters.Test.Conn, :...}, assigns: %{layout: {TvRecipe.LayoutView, "app.html"}}, bef
ore_send: [#Function<0.101282891/1 in Plug.CSRFProtection.call/2>, #Function<4.111648917/1 in Phoenix.Controller.fetch_flash
/2>, #Function<0.61377594/1 in Plug.Session.before_send/2>, #Function<1.115972179/1 in Plug.Logger.call/2>], body_params: %{
}, cookies: %{}, halted: false, host: "www.example.com", method: "GET", owner: #PID<0.300.0>, params: %{}, path_info: ["sess
ions", "new"], path_params: %{}, peer: {{127, 0, 0, 1}, 111317}, port: 80, private: %{TvRecipe.Router => {[], %{}}, :phoenix
_action => :new, :phoenix_controller => TvRecipeWeb.SessionController, :phoenix_endpoint => TvRecipe.Endpoint, :phoenix_flash =
> %{}, :phoenix_format => "html", :phoenix_layout => {TvRecipe.LayoutView, :app}, :phoenix_pipelines => [:browser], :phoenix
_recycled => true, :phoenix_route => #Function<12.75217690/1 in TvRecipe.Router.match_route/4>, :phoenix_router => TvRecipe.
Router, :phoenix_template => "new.html", :phoenix_view => TvRecipeWeb.SessionView, :plug_session => %{}, :plug_session_fetch =>
:done, :plug_skip_csrf_protection => true}, query_params: %{}, query_string: "", remote_ip: {127, 0, 0, 1}, req_cookies: %{
}, req_headers: [], request_path: "/sessions/new", resp_body: nil, resp_cookies: %{}, resp_headers: [{"cache-control", "max-
age=0, private, must-revalidate"}, {"x-request-id", "vi7asqkbb9153m6ku8btf8r50p38rsqn"}, {"x-frame-options", "SAMEORIGIN"},
{"x-xss-protection", "1; mode=block"}, {"x-content-type-options", "nosniff"}], scheme: :http, script_name: [], secret_key_ba
se: "XfacEiZ/QVO87L4qirM0thXcedgcx5zYhLPAsmVPnL8AVu6qB/Et84yvJ6712aSn", state: :unset, status: nil}, template_not_found: TvR
ecipe.SessionView, view_module: TvRecipeWeb.SessionView, view_template: "new.html"}
stacktrace:
(phoenix) lib/phoenix/template.ex:364: Phoenix.Template.raise_template_not_found/3
(tv_recipe) web/templates/layout/app.html.eex:29: TvRecipe.LayoutView."app.html"/1
(phoenix) lib/phoenix/view.ex:335: Phoenix.View.render_to_iodata/3
(phoenix) lib/phoenix/controller.ex:642: Phoenix.Controller.do_render/4
(tv_recipe) web/controllers/session_controller.ex:1: TvRecipeWeb.SessionController.action/2
(tv_recipe) web/controllers/session_controller.ex:1: TvRecipeWeb.SessionController.phoenix_controller_pipeline/2
(tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.instrument/4
(tv_recipe) lib/phoenix/router.ex:261: TvRecipe.Router.dispatch/2
(tv_recipe) lib/tv_recipe_web/router.ex:1: TvRecipe.Router.do_call/2
(tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.phoenix_pipeline/1
(tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.call/2
(phoenix) lib/phoenix/test/conn_test.ex:224: Phoenix.ConnTest.dispatch/5
test/tv_recipe_web/controllers/session_controller_test.exs:5: (test)
Finished in 0.1 seconds
1 test, 1 failure
測試失敗,因為 new.html 模板不存在。
創(chuàng)建 new.html.eex
模板文件
在 web/templates/session
目錄中新建一個空白 new.html.eex
模板文件。
現(xiàn)在運行測試:
mix test test/tv_recipe_web/controllers/session_controller_test.exs
Compiling 1 file (.ex)
1) test renders form for new sessions (TvRecipeWeb.SessionControllerTest)
test/tv_recipe_web/controllers/session_controller_test.exs:4
Assertion with =~ failed
code: html_response(conn, 200) =~ "登錄"
left: "<!DOCTYPE html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\">\n <meta http-equiv=\"X-UA-Compat
ible\" content=\"IE=edge\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n <meta name=\"
description\" content=\"\">\n <meta name=\"author\" content=\"\">\n\n <title>Hello TvRecipe!</title>\n <link rel=\"
stylesheet\" href=\"/css/app.css\">\n </head>\n\n <body>\n <div class=\"container\">\n <header class=\"header\">\n
<nav role=\"navigation\">\n <ul class=\"nav nav-pills pull-right\">\n <li><a href=\"http://www.p
hoenixframework.org/docs\">Get Started</a></li>\n </ul>\n </nav>\n <span class=\"logo\"></span>\n
</header>\n\n <p class=\"alert alert-info\" role=\"alert\"></p>\n <p class=\"alert alert-danger\" role=\"alert\"
></p>\n\n <main role=\"main\">\n </main>\n\n </div> <!-- /container -->\n <script src=\"/js/app.js\"></scrip
t>\n </body>\n</html>\n"
right: "登錄"
stacktrace:
test/tv_recipe_web/controllers/session_controller_test.exs:7: (test)
Finished in 0.1 seconds
1 test, 1 failure
這是因為我們的頁面還是空白的,并沒有“登錄”的字眼。
那么,new.html.eex
文件內容要怎么寫?
首先,當然是加個“登錄”的標題,保證我們此前的測試正確:
<h2>登錄</h2>
接著是登錄表單。首先想到的,自然是參照 web/templates/user
目錄下的 form.eex.html
文件:
<%= form_for @changeset, @action, fn f -> %>
<%= if @changeset.action do %>
<div class="alert alert-danger">
<p>Oops, something went wrong! Please check the errors below.</p>
</div>
<% end %>
<div class="form-group <%= if f.errors[:email], do: "has-error" %>">
<%= label f, :email, class: "control-label" %>
<%= text_input f, :email, class: "form-control" %>
<%= error_tag f, :email %>
</div>
<div class="form-group <%= if f.errors[:password], do: "has-error" %>">
<%= label f, :password, class: "control-label" %>
<%= password_input f, :password, class: "form-control" %>
<%= error_tag f, :password %>
</div>
<div class="form-group">
<%= submit "Submit", class: "btn btn-primary" %>
</div>
<% end %>
但測試結果告訴我們:
mix test test/tv_recipe_web/controllers/session_controller_test.exs
Compiling 1 file (.ex)
1) test renders form for new sessions (TvRecipeWeb.SessionControllerTest)
test/tv_recipe_web/controllers/session_controller_test.exs:4
** (ArgumentError) assign @changeset not available in eex template.
Please make sure all proper assigns have been set. If this
is a child template, ensure assigns are given explicitly by
the parent template as they are not automatically forwarded.
Available assigns: [:conn, :view_module, :view_template]
報錯是必然的,我們前面草草寫就的 new
函數(shù)里,只是一行 render "new.html"
,并沒有傳遞 changeset
- 因為我們根本沒有 changeset
可以傳遞。
怎么辦?來看看 Phoenix.HTML.Form
的文檔描述的 form_for
的三種應用場景:
- with changeset data - when information to populate the form comes from a changeset
- with connection data - when a form is created based on the information in the connection (aka Plug.Conn)
- without form data - when the functions are used directly, outside of a form
我們沒有 changeset
,但是涉及表單數(shù)據(jù),適用第二種。
根據(jù) form_for
的用法,我們將 new.html.eex
做以下修改:
diff --git a/web/templates/session/new.html.eex b/web/templates/session/new.html.eex
index 9c1f842..1df67cc 100644
--- a/web/templates/session/new.html.eex
+++ b/web/templates/session/new.html.eex
@@ -1,11 +1,5 @@
<h2>登錄</h2>
-<%= form_for @changeset, @action, fn f -> %>
- <%= if @changeset.action do %>
- <div class="alert alert-danger">
- <p>Oops, something went wrong! Please check the errors below.</p>
- </div>
- <% end %>
-
+<%= form_for @conn, Routes.session_path(@conn, :create), [as: :session], fn f -> %>
<div class="form-group <%= if f.errors[:email], do: "has-error" %>">
<%= label f, :email, class: "control-label" %>
<%= text_input f, :email, class: "form-control" %>
Routes.session_path(@conn, :create)
是表單數(shù)據(jù)要提交的路徑,as: :session
則表示表單數(shù)據(jù)提交時,是保存在 session
的鍵名下的。
現(xiàn)在運行測試:
mix test test/tv_recipe_web/controllers/session_controller_test.exs
Compiling 10 files (.ex)
1) test renders form for new sessions (TvRecipeWeb.SessionControllerTest)
test/tv_recipe_web/controllers/session_controller_test.exs:4
** (ArgumentError) No helper clause for TvRecipe.Router.Helpers.session_path/2 defined for action :create.
The following session_path actions are defined under your router:
* :new
stacktrace:
(phoenix) lib/phoenix/router/helpers.ex:269: Phoenix.Router.Helpers.raise_route_error/5
(tv_recipe) web/templates/session/new.html.eex:2: TvRecipeWeb.SessionView."new.html"/1
(tv_recipe) web/templates/layout/app.html.eex:29: TvRecipe.LayoutView."app.html"/1
(phoenix) lib/phoenix/view.ex:335: Phoenix.View.render_to_iodata/3
(phoenix) lib/phoenix/controller.ex:642: Phoenix.Controller.do_render/4
(tv_recipe) web/controllers/session_controller.ex:1: TvRecipeWeb.SessionController.action/2
(tv_recipe) web/controllers/session_controller.ex:1: TvRecipeWeb.SessionController.phoenix_controller_pipeline/2
(tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.instrument/4
(tv_recipe) lib/phoenix/router.ex:261: TvRecipe.Router.dispatch/2
(tv_recipe) lib/tv_recipe_web/router.ex:1: TvRecipe.Router.do_call/2
(tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.phoenix_pipeline/1
(tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.call/2
(phoenix) lib/phoenix/test/conn_test.ex:224: Phoenix.ConnTest.dispatch/5
test/tv_recipe_web/controllers/session_controller_test.exs:5: (test)
Finished in 0.07 seconds
1 test, 1 failure
測試結果提示我們:No helper clause for TvRecipe.Router.Helpers.session_path/2 defined for action :create.。
我們需要在 router.ex
文件添加一個路由:
diff --git a/lib/tv_recipe_web/router.ex b/lib/tv_recipe_web/router.ex
index aac327c..e0406d2 100644
--- a/lib/tv_recipe_web/router.ex
+++ b/lib/tv_recipe_web/router.ex
@@ -19,6 +19,7 @@ defmodule TvRecipe.Router do
get "/", PageController, :index
resources "/users", UserController
get "/sessions/new", SessionController, :new
+ post "/sessions/new", SessionController, :create
end
很好,我們的測試終于通過了。
但我們才邁出了一小步。
create 動作
如果我們此時在瀏覽器里訪問 /sessions/new
頁面,并提交用戶登錄數(shù)據(jù),會怎樣?不不不,不要在瀏覽器里嘗試,我們用測試代碼:
diff --git a/test/tv_recipe_web/controllers/session_controller_test.exs b/test/tv_recipe_web/controllers/session_controller_test.exs
index 0372448..6835e40 100644
--- a/test/tv_recipe_web/controllers/session_controller_test.exs
+++ b/test/tv_recipe_web/controllers/session_controller_test.exs
@@ -1,9 +1,24 @@
defmodule TvRecipeWeb.SessionControllerTest do
use TvRecipe.ConnCase
+ alias TvRecipe.Repo
+ alias TvRecipe.Users.User
+ @valid_user_attrs %{email: "chenxsan@gmail.com", username: "chenxsan", password: String.duplicate("a", 6)}
+
test "renders form for new sessions", %{conn: conn} do
conn = get conn, Routes.session_path(conn, :new)
# 200 響應,頁面上帶有“登錄”
assert html_response(conn, 200) =~ "登錄"
end
+
+ test "login user and redirect to home page when data is valid", %{conn: conn} do
+ user_changeset = User.changeset(%User{}, @valid_user_attrs)
+ # 插入新用戶
+ Repo.insert! user_changeset
+ # 用戶登錄
+ conn = post conn, Routes.session_path(conn, :create), session: @valid_user_attrs
+ # 顯示“歡迎你”的消息
+ assert get_flash(conn, :info) == "歡迎你"
+ # 重定向到主頁
+ assert redirected_to(conn) == Routes.page_path(conn, :index)
+ end
end
我們的測試結果是:
$ mix test test/tv_recipe_web/controllers/session_controller_test.exs
Compiling 1 file (.ex)
warning: variable "user" is unused
test/tv_recipe_web/controllers/session_controller_test.exs:16
.
1) test login user and redirect to home page when data is valid (TvRecipeWeb.SessionControllerTest)
test/tv_recipe_web/controllers/session_controller_test.exs:13
** (UndefinedFunctionError) function TvRecipeWeb.SessionController.create/2 is undefined or private
```
`TvRecipeWeb.SessionController.create` 未定義。
打開 `session_controller.ex` 文件,添加 `create` 動作:
```elixir
diff --git a/web/controllers/session_controller.ex b/web/controllers/session_controller.ex
index 66a5304..40ad02f 100644
--- a/web/controllers/session_controller.ex
+++ b/web/controllers/session_controller.ex
@@ -1,7 +1,20 @@
defmodule TvRecipeWeb.SessionController do
use TvRecipeWeb, :controller
+ alias TvRecipe.Repo
+ alias TvRecipe.Users.User
def new(conn, _params) do
render conn, "new.html"
end
+
+ def create(conn, %{"session" => %{"email" => email, "password" => password}}) do
+ # 根據(jù)郵箱地址從數(shù)據(jù)庫中查找用戶
+ user = Repo.get_by(User, email: email)
+ cond do
+ # 用戶存在,且密碼正確
+ user && Comeonin.Bcrypt.checkpw(password, user.password_hash) ->
+ conn
+ |> put_flash(:info, "歡迎你")
+ |> redirect(to: Routes.page_path(conn, :index))
+ end
+ end
end
還記得模式匹配嗎?我們上面的代碼用它來抽取 session
鍵下的數(shù)據(jù),然后跟數(shù)據(jù)庫中存儲的用戶 password_hash
做比較,如果通過 Comeonin.Bcrypt.checkpw
的檢查,我們就顯示“歡迎你”,并重定向用戶到主頁。
此外,上面的代碼中有兩個新知識需要提一下:
alias TvRecipe.{Repo, User}
- alias 允許我們給模塊設置別名,這樣可以減少后期輸入,不必寫完整的TvRecipe.Repo
與TvRecipe.User
。cond do
- 條件判斷語句。
現(xiàn)在運行測試:
$ mix test test/tv_recipe_web/controllers/session_controller_test.exs
..
Finished in 0.2 seconds
2 tests, 0 failures
通過了。
但我們只處理了用戶郵箱存在且密碼正確的情況。還有兩種情況未處理:
- 郵箱存在,密碼不正確
- 郵箱不存在
同樣的,我們先寫測試:
diff --git a/test/tv_recipe_web/controllers/session_controller_test.exs b/test/tv_recipe_web/controllers/session_controller_test.exs
index cc35f0a..dd5bc02 100644
--- a/test/tv_recipe_web/controllers/session_controller_test.exs
+++ b/test/tv_recipe_web/controllers/session_controller_test.exs
@@ -21,4 +21,24 @@ defmodule TvRecipeWeb.SessionControllerTest do
# 重定向到主頁
assert redirected_to(conn) == Routes.page_path(conn, :index)
end
+
+ test "redirect to session new when email exists but with wrong password", %{conn: conn} do
+ user_changeset = User.changeset(%User{}, @valid_user_attrs)
+ # 插入新用戶
+ Repo.insert! user_changeset
+ # 用戶登錄
+ conn = post conn, Routes.session_path(conn, :create), session: %{@valid_user_attrs | password: ""}
+ # 顯示“用戶名或密碼錯誤”
+ assert get_flash(conn, :error) == "用戶名或密碼錯誤"
+ # 返回登錄頁
+ assert html_response(conn, 200) =~ "登錄"
+ end
+
+ test "redirect to session new when nobody login", %{conn: conn} do
+ conn = post conn, Routes.session_path(conn, :create), session: @valid_user_attrs
+ # 顯示“用戶名或密碼錯誤”
+ assert get_flash(conn, :error) == "用戶名或密碼錯誤"
+ # 返回登錄頁
+ assert html_response(conn, 200) =~ "登錄"
+ end
end
然后實現(xiàn)代碼:
diff --git a/web/controllers/session_controller.ex b/web/controllers/session_controller.ex
index 40ad02f..400a33c 100644
--- a/web/controllers/session_controller.ex
+++ b/web/controllers/session_controller.ex
@@ -15,6 +15,18 @@ defmodule TvRecipeWeb.SessionController do
conn
|> put_flash(:info, "歡迎你")
|> redirect(to: Routes.page_path(conn, :index))
+ # 用戶存在,但密碼錯誤
+ user ->
+ conn
+ |> put_flash(:error, "用戶名或密碼錯誤")
+ |> render("new.html")
+ # 其它
+ true ->
+ # 預防暴力破解
+ Comeonin.Bcrypt.dummy_checkpw()
+ conn
+ |> put_flash(:error, "用戶名或密碼錯誤")
+ |> render("new.html")
end
end
end
再次測試:
mix test test/tv_recipe_web/controllers/session_controller_test.exs
....
Finished in 0.2 seconds
4 tests, 0 failures
悉數(shù)通過。
到現(xiàn)在為止,我們還沒有打開瀏覽器測試過頁面,現(xiàn)在你可以試試。在瀏覽器上,我們會更容易發(fā)現(xiàn)一些可用性上的問題。
比如這個問題:登錄賬號后,刷新頁面,我們就不知道自己是否登錄了,因為頁面上沒有任何標識表明我們當前是登錄的狀態(tài)。
我們需要在頁面上顯示登錄后的用戶名。
登錄后的頁面顯示 username
我們來改造下我們的測試代碼:
diff --git a/test/tv_recipe_web/controllers/session_controller_test.exs b/test/tv_recipe_web/controllers/session_controller_test.exs
index dd5bc02..52e8801 100644
--- a/test/tv_recipe_web/controllers/session_controller_test.exs
+++ b/test/tv_recipe_web/controllers/session_controller_test.exs
@@ -13,13 +13,19 @@ defmodule TvRecipeWeb.SessionControllerTest do
test "login user and redirect to home page when data is valid", %{conn: conn} do
user_changeset = User.changeset(%User{}, @valid_user_attrs)
# 插入新用戶
- Repo.insert! user_changeset
+ user = Repo.insert! user_changeset
# 用戶登錄
conn = post conn, Routes.session_path(conn, :create), session: @valid_user_attrs
# 顯示“歡迎你”的消息
assert get_flash(conn, :info) == "歡迎你"
# 重定向到主頁
assert redirected_to(conn) == Routes.page_path(conn, :index)
+ # 讀取首頁,頁面上包含已登錄用戶的用戶名
+ conn = get conn, Routes.page_path(conn, :index)
+ assert html_response(conn, 200) =~ Map.get(@valid_user_attrs, :username)
+ # 讀取用戶頁,頁面上包含已登錄用戶的用戶名
+ conn = get conn, Routes.user_path(conn, :show, user)
+ assert html_response(conn, 200) =~ Map.get(@valid_user_attrs, :username)
end
我們在測試中確保新建的用戶登錄后,頁面上包含用戶名。
這里,我們有兩個問題需要解決:
- 我們要顯示的用戶名要寫在哪個模板文件
- 模板文件中的用戶名從何而來
Phoenix 創(chuàng)建時生成的頁面里,均有 Get Started 菜單,這正是我們的用戶名想要達到的效果。
查找一下 Get Started,我們就能定位到它在 web/templates/layout/app.html.eex
文件中,我們的 username
將加在 app.html.eex
文件中:
diff --git a/web/templates/layout/app.html.eex b/web/templates/layout/app.html.eex
index 82259d8..2d39904 100644
--- a/web/templates/layout/app.html.eex
+++ b/web/templates/layout/app.html.eex
@@ -17,6 +17,9 @@
<nav role="navigation">
<ul class="nav nav-pills pull-right">
<li><a href="http://www.phoenixframework.org/docs">Get Started</a></li>
+ <%= if @current_user do %>
+ <li><%= link @current_user.username, to: Routes.user_path(@conn, :show, @current_user) %></li>
+ <% end %>
</ul>
</nav>
<span class="logo"></span>
那么,用戶名從哪來。
我們之前說過,一個請求到響應結束的過程是這樣:
- 路由
- 控制器
- 視圖
- 模板
如果用代碼表示,則是這樣:
conn
|> router
|> controller
|> view
|> template
所以我們只要在模板的上游環(huán)節(jié)存儲用戶數(shù)據(jù)即可。但要如何保證,每個路由中都存儲了 :current_user
數(shù)據(jù)?
我們來看看上游的 router.ex
文件,其中的部分內容如下:
scope "/", TvRecipe do
pipe_through :browser # Use the default browser stack
get "/", PageController, :index
resources "/users", UserController
get "/sessions/new", SessionController, :new
post "/sessions/new", SessionController, :create
end
定義在 scope "/"
中的所有路由,在進入控制器之前,要經(jīng)過叫 :browser
的 pipeline:
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_flash
plug :protect_from_forgery
plug :put_secure_browser_headers
end
很明顯,pipeline
就只是一些 plug
的組合。
解決辦法已經(jīng)浮出水面了:我們在 plug
中準備 :current_user
,然后把這個 plug
放到 :browser
這個 pipeline
里,這樣,我們就在模板渲染前準備好了 :current_user
。
plug
有兩種,一種是函數(shù)式的(function plug),一種是模塊式的(module plug),我們這里將使用模塊式的 plug。
模塊式 plug 需要定義兩個函數(shù):
init/1
- 用于初始化參數(shù)或選項,然后傳遞給call
call/2
- 第一個參數(shù)為 Plug.Conn 結構體,第二個參數(shù)為init/1
的結果
請注意函數(shù)后的 /1
與 /2
,它們表示函數(shù)接收的參數(shù)的數(shù)量。
我們在 web/controllers
目錄下新增一個 auth.ex
文件:
diff --git a/web/controllers/auth.ex b/web/controllers/auth.ex
new file mode 100644
index 0000000..84b17f7
--- /dev/null
+++ b/web/controllers/auth.ex
@@ -0,0 +1,16 @@
+defmodule TvRecipeWeb.Auth do
+ import Plug.Conn
+
+ @doc """
+ 初始化選項
+
+ """
+ def init(opts) do
+ Keyword.fetch!(opts, :repo)
+ end
+
+ def call(conn, repo) do
+ assign(conn, :current_user, user)
+ end
+
+end
當然,我們的代碼在編譯時報錯了,因為 user
還沒有定義,那么 user
要從哪兒來?
我們回到 session_controller.ex
文件,其中有一段:
def create(conn, %{"session" => %{"email" => email, "password" => password}}) do
# 根據(jù)郵箱地址從數(shù)據(jù)庫中查找用戶
user = Repo.get_by(User, email: email)
cond do
# 用戶存在,且密碼正確
user && Comeonin.Bcrypt.checkpw(password, user.password_hash) ->
conn
|> put_flash(:info, "歡迎你")
|> redirect(to: Routes.page_path(conn, :index))
用戶登錄時,我們根據(jù)他們提供的郵箱取得數(shù)據(jù)庫中的用戶,然后比對密碼,如果密碼正確,我們就得到了 user
。
一處有 user
,一處需要 user
,怎么傳遞?
我們可以通過會話(session)。用戶第一次訪問網(wǎng)站時,服務端會分配一個唯一的 session id,這樣每次請求進來,服務端解析 session id 就能知道是誰。聽起來很復雜?不必擔心,因為 Phoenix 已經(jīng)幫我們打理好。我們只要關心 session 的存儲、讀取等就好。
讓我們在用戶登錄時,把用戶的 id 存儲在 session 中:
diff --git a/web/controllers/session_controller.ex b/web/controllers/session_controller.ex
index 400a33c..b5218f2 100644
--- a/web/controllers/session_controller.ex
+++ b/web/controllers/session_controller.ex
@@ -13,6 +13,7 @@ defmodule TvRecipeWeb.SessionController do
# 用戶存在,且密碼正確
user && Comeonin.Bcrypt.checkpw(password, user.password_hash) ->
conn
+ |> put_session(:user_id, user.id)
|> put_flash(:info, "歡迎你")
|> redirect(to: Routes.page_path(conn, :index))
然后我們就能在 auth.ex
文件中讀取 session 中的 :user_id
了:
diff --git a/web/controllers/auth.ex b/web/controllers/auth.ex
index 84b17f7..994112d 100644
--- a/web/controllers/auth.ex
+++ b/web/controllers/auth.ex
@@ -10,6 +10,8 @@ defmodule TvRecipe.Auth do
end
def call(conn, repo) do
+ user_id = get_session(conn, :user_id)
+ user = user_id && repo.get(TvRecipe.Users.User, user_id)
assign(conn, :current_user, user)
end
最后,將 Auth
plug 加入 :browser
pipeline 中:
diff --git a/lib/tv_recipe_web/router.ex b/lib/tv_recipe_web/router.ex
index e0406d2..1265c86 100644
--- a/lib/tv_recipe_web/router.ex
+++ b/lib/tv_recipe_web/router.ex
@@ -7,6 +7,7 @@ defmodule TvRecipeWeb.Router do
plug :fetch_flash
plug :protect_from_forgery
plug :put_secure_browser_headers
+ plug TvRecipeWeb.Auth, repo: TvRecipe.Repo
end
pipeline :api do
現(xiàn)在運行測試:
$ mix test
...................................
Finished in 0.4 seconds
35 tests, 0 failures
全部通過。
你可能會問,為什么在登錄時,不直接保存 user
數(shù)據(jù)到 session 中,而是保存了 user.id
的數(shù)據(jù)?假如我們保存了 user
數(shù)據(jù),而用戶又修改了個人信息,會導致 session 中的 user
數(shù)據(jù)與數(shù)據(jù)庫中不一致,所以我們只存了 id,然后根據(jù) id 從數(shù)據(jù)庫中讀取 user
數(shù)據(jù),保證了數(shù)據(jù)的有效性。
以上,我們完成用戶登錄的功能。
更多建議: