Phoenix 登錄

2023-12-18 14:38 更新

這一次,我們沒有 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 的三種應用場景:

  1. with changeset data - when information to populate the form comes from a changeset
  2. with connection data - when a form is created based on the information in the connection (aka Plug.Conn)
  3. 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 的檢查,我們就顯示“歡迎你”,并重定向用戶到主頁。

此外,上面的代碼中有兩個新知識需要提一下:

  1. alias TvRecipe.{Repo, User} - alias 允許我們給模塊設置別名,這樣可以減少后期輸入,不必寫完整的 TvRecipe.RepoTvRecipe.User
  2. cond do - 條件判斷語句。

現(xiàn)在運行測試:

$ mix test test/tv_recipe_web/controllers/session_controller_test.exs
..

Finished in 0.2 seconds
2 tests, 0 failures

通過了。

但我們只處理了用戶郵箱存在且密碼正確的情況。還有兩種情況未處理:

  1. 郵箱存在,密碼不正確
  2. 郵箱不存在

同樣的,我們先寫測試:

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

我們在測試中確保新建的用戶登錄后,頁面上包含用戶名。

這里,我們有兩個問題需要解決:

  1. 我們要顯示的用戶名要寫在哪個模板文件
  2. 模板文件中的用戶名從何而來

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>

那么,用戶名從哪來。

我們之前說過,一個請求到響應結束的過程是這樣:

  1. 路由
  2. 控制器
  3. 視圖
  4. 模板

如果用代碼表示,則是這樣:

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ù):

  1. init/1 - 用于初始化參數(shù)或選項,然后傳遞給 call
  2. 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ù)的有效性。

以上,我們完成用戶登錄的功能。

以上內容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號