Phoenix Framework 测试与回收

栏目: 编程语言 · 发布时间: 7年前

内容简介:如果你看过然而我照猫画虎,在给我的 Phoenix 控制器代码写测试时,发现一个奇怪问题:同一个也就是说,

如果你看过 Programming Phoenix 一书 ,你可能已经知道,书里在测试时,为了通过 Auth 模块的检查,推荐使用 conn = assign(conn, :current_user, user) 来模拟已登录状态。

然而我照猫画虎,在给我的 Phoenix 控制器代码写测试时,发现一个奇怪问题:同一个 test 块,前后两行代码, conn.assigns 值却是不一致的:

defp log_in_user(%{conn: conn, log_in_as: email}) do
    role = role_fixture()
    user = user_fixture(role, email: email)
    conn = assign(conn, :current_user, user) # <- 模拟登录
    {:ok, conn: conn, user: user}
  end
  describe "delete site" do
    setup [:log_in_user, :create_site]

    @tag log_in_as: "sam"
    test "deletes chosen site", %{conn: conn, site: site} do
      conn = delete(conn, Routes.site_path(conn, :delete, site))
      assert redirected_to(conn) == Routes.site_path(conn, :index)

      assert_error_sent 404, fn ->
         # <- 此时 conn.assigns.current_user 还有 %User{} 值
        get(conn, Routes.site_path(conn, :show, site)) # <- 这里 302 了,而不是预期的 404,经检查发现 current_user 值丢失
      end
    end
  end

也就是说, get(conn, Routes.site_path(conn, :show, site)) 这一行代码里, conn.assigns 值变了。

站在一个普通用户角度说,这让人费解,这一行代码并非登出,为什么会导致 conn.assigns 变化?

类似的困惑我们可以在很多地方看到:

  1. https://elixirforum.com/t/troubleshooting-a-failed-test-302-redirect-instead-of-200/8779/4
  2. https://stackoverflow.com/questions/50110449/phoenix-controller-test-case-loses-current-user
  3. https://stackoverflow.com/questions/46363292/losing-conn-assigns-in-the-middle-of-a-test

翻开源代码,我们来看看那一行 get 代码内部究竟发生了什么。

首先来看 get 方法 的定义:

@http_methods [:get, :post, :put, :patch, :delete, :options, :connect, :trace, :head]

for method <- @http_methods do
  @doc """
  Dispatches to the current endpoint.
  See `dispatch/5` for more information.
  """
  defmacro unquote(method)(conn, path_or_action, params_or_body \\ nil) do
    method = unquote(method)
    quote do
      Phoenix.ConnTest.dispatch(unquote(conn), @endpoint, unquote(method),
                                unquote(path_or_action), unquote(params_or_body))
    end
  end
end

这是一段 Elixir 宏 。不熟悉或者不知道都没关系,我们看见它在调用 Phoenix.ConnTest.dispatch 就行:

def dispatch(conn, endpoint, method, path_or_action, params_or_body \\ nil)
def dispatch(%Plug.Conn{} = conn, endpoint, method, path_or_action, params_or_body) do
  if is_nil(endpoint) do
    raise "no @endpoint set in test case"
  end

  if is_binary(params_or_body) and is_nil(List.keyfind(conn.req_headers, "content-type", 0)) do
    raise ArgumentError, "a content-type header is required when setting " <>
                          "a binary body in a test connection"
  end

  conn
  |> ensure_recycled()
  |> dispatch_endpoint(endpoint, method, path_or_action, params_or_body)
  |> Conn.put_private(:phoenix_recycled, false)
  |> from_set_to_sent()
end
def dispatch(conn, _endpoint, method, _path_or_action, _params_or_body) do
  raise ArgumentError, "expected first argument to #{method} to be a " <>
                        "%Plug.Conn{}, got #{inspect conn}"
end

dispatch 里则调用了 ensure_recycled

def ensure_recycled(conn) do
    if conn.private[:phoenix_recycled] do
      conn
    else
      recycle(conn)
    end
  end

追踪 recycle 方法:

def recycle(conn) do
    build_conn()
    |> Map.put(:host, conn.host)
    |> Plug.Test.recycle_cookies(conn)
    |> Plug.Test.put_peer_data(Plug.Conn.get_peer_data(conn))
    |> copy_headers(conn.req_headers, ~w(accept authorization))
  end

再看看 build_conn 的定义:

def build_conn(method, path, params_or_body \\ nil) do
    Plug.Adapters.Test.Conn.conn(%Conn{}, method, path, params_or_body)
    |> Conn.put_private(:plug_skip_csrf_protection, true)
    |> Conn.put_private(:phoenix_recycled, true)
  end

这样,我们就弄明白,测试代码里执行一个 get 发生了什么:一个全新的 %Plug.Conn{} 结构被创建出来,并且拷入旧 conn 的 cookie 及 acceptauthorization 两个请求头 - 换句话说,旧 conn 只有部分数据被保留, conn.assigns 不保留 - 因此 conn.assigns.current_usernil

Phoenix 把上面这种行为叫做 回收 ?可是,为什么要有这个回收机制?

这是因为 Phoenix 在尝试模拟浏览器的一个机制:一个请求的响应里若设定 cookie,则下一次请求应自动携带 cookie。

了解工作原理后,我们的问题就有了解决办法:手动回收 conn ,并拷贝旧的 conn.assigns 数据到新的 conn 上:

conn =
  conn
  |> cycle()
  |> Map.put(:assigns, conn.assigns)

当然,这个方案不直观,让人觉得莫名其妙。

我们还有一种方案,不采用 Programming Phoenix 一书建议的模拟登录方案,直接调用登录接口:

@tag log_in_as: "sam"
test "deletes chosen site", %{conn: conn, site: site, user: user} do
  # 此处执行登录
  # 有两种登录方式,可以二选一
  # 第一种,直接请求 session_path
  conn =
    post(conn, Routes.session_path(conn, :create),
      session: %{email: user.email, password: "123456"}
    )
  # 第二种,我个人认为比第一种更优雅
  # conn = Plug.Test.init_test_session(conn, user_id: user.id)

  conn = delete(conn, Routes.site_path(conn, :delete, site))
  assert redirected_to(conn) == Routes.site_path(conn, :index)

  # 注释掉手动回收代码,因为我们不再需要了
  # conn =
  #   conn
  #   |> recycle()
  #   |> Map.put(:assigns, conn.assigns)

  assert_error_sent 404, fn ->
    get(conn, Routes.site_path(conn, :show, site))
  end
end

可是你说,这一种方案里,Phoenix 同样要回收 conn ,为什么没有出现文章一开始提到的那种问题?这是因为,我们在 session 里存储了 user_id ,而 Phoenix 的 session 默认是通过 cookie 实现 - 还记得吗,Phoenix 的回收保留了 cookie,这样 Auth 就匹配到另一条路径:

def call(conn, _opts) do
    user_id = get_session(conn, :user_id)

    cond do
      conn.assigns[:current_user] -> # <- Programming Phoenix 书里推荐的方案匹配的路径
        conn

      user = user_id && Accounts.get_user(user_id) -> # <- post Routes.session_path 方案匹配的路径
        assign(conn, :current_user, user)

      true ->
        assign(conn, :current_user, nil)
    end
  end

至于为什么 Programming Phoenix 没有推荐后面这种方案?大概是因为开销更大,毕竟有一个 Accounts.get_user 要执行。

相关链接

  1. Why Phoenix recycling

以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

Data Structures and Algorithm Analysis in Java

Data Structures and Algorithm Analysis in Java

Mark A. Weiss / Pearson / 2011-11-18 / GBP 129.99

Data Structures and Algorithm Analysis in Java is an “advanced algorithms” book that fits between traditional CS2 and Algorithms Analysis courses. In the old ACM Curriculum Guidelines, this course wa......一起来看看 《Data Structures and Algorithm Analysis in Java》 这本书的介绍吧!

URL 编码/解码
URL 编码/解码

URL 编码/解码

XML 在线格式化
XML 在线格式化

在线 XML 格式化压缩工具

UNIX 时间戳转换
UNIX 时间戳转换

UNIX 时间戳转换