查看源代码 插头

要求:本指南假设您已经阅读过入门指南,并成功创建了一个 Phoenix 应用程序运行起来.

要求:本指南假设您已经阅读过请求生命周期指南.

插头是 Phoenix HTTP 层的核心,Phoenix 将插头置于首位。我们在请求生命周期的每个步骤中都会与插头交互,而像端点、路由器和控制器这样的核心 Phoenix 组件内部都是插头。让我们深入了解一下,找出是什么让插头如此特别。

插头 是 web 应用程序之间可组合模块的规范。它也是不同 web 服务器连接适配器的抽象层。插头的基本思想是统一我们操作的“连接”概念。这与其他 HTTP 中间件层(如 Rack)不同,在 Rack 中,请求和响应在中间件堆栈中是分开的。

从最简单的层面上讲,插头规范有两种形式:函数插头模块插头

函数插头

为了作为一个插头,一个函数需要

  1. 接受一个连接结构体 (%Plug.Conn{}) 作为它的第一个参数,以及连接选项作为它的第二个参数;
  2. 返回一个连接结构体。

任何满足这两个条件的函数都可以。这是一个例子。

def introspect(conn, _opts) do
  IO.puts """
  Verb: #{inspect(conn.method)}
  Host: #{inspect(conn.host)}
  Headers: #{inspect(conn.req_headers)}
  """

  conn
end

此函数执行以下操作

  1. 它接收一个连接和选项(我们没有使用)
  2. 它将一些连接信息打印到终端
  3. 它返回连接

很简单,对吧?让我们通过将它添加到我们端点的 lib/hello_web/endpoint.ex 中,来观察此函数的实际操作。我们可以将它插入到任何位置,所以让我们在将请求委托给路由器之前插入 plug :introspect

defmodule HelloWeb.Endpoint do
  ...

  plug :introspect
  plug HelloWeb.Router

  def introspect(conn, _opts) do
    IO.puts """
    Verb: #{inspect(conn.method)}
    Host: #{inspect(conn.host)}
    Headers: #{inspect(conn.req_headers)}
    """

    conn
  end
end

函数插头通过将函数名作为原子传递来插入。要尝试使用插头,请返回到浏览器并获取 http://localhost:4000。您应该在 shell 终端中看到类似于此的内容

Verb: "GET"
Host: "localhost"
Headers: [...]

我们的插头只是从连接中打印信息。虽然我们的初始插头非常简单,但您可以在其中做几乎任何您想做的事情。要了解连接中所有可用的字段以及与其关联的所有功能,请参阅 有关 Plug.Conn 的文档.

现在让我们看看另一个插头变体,即模块插头。

模块插头

模块插头是另一种类型的插头,它允许我们在一个模块中定义连接转换。该模块只需要实现两个函数

  • init/1 它初始化要传递给 call/2 的任何参数或选项
  • call/2 它执行连接转换。 call/2 只不过是我们之前看到的函数插头

为了看到它的实际操作,让我们编写一个模块插头,它将 :locale 键和值放入连接中,以便在其他插头、控制器操作和我们的视图中使用。将下面的内容放入名为 lib/hello_web/plugs/locale.ex 的文件中

defmodule HelloWeb.Plugs.Locale do
  import Plug.Conn

  @locales ["en", "fr", "de"]

  def init(default), do: default

  def call(%Plug.Conn{params: %{"locale" => loc}} = conn, _default) when loc in @locales do
    assign(conn, :locale, loc)
  end

  def call(conn, default) do
    assign(conn, :locale, default)
  end
end

为了尝试一下,让我们将这个模块插头添加到我们的路由器中,通过将 plug HelloWeb.Plugs.Locale, "en" 附加到 lib/hello_web/router.ex 中的 :browser 管道

defmodule HelloWeb.Router do
  use HelloWeb, :router

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_flash
    plug :protect_from_forgery
    plug :put_secure_browser_headers
    plug HelloWeb.Plugs.Locale, "en"
  end
  ...

init/1 回调中,我们传递一个默认语言环境,如果参数中没有语言环境,则使用它。我们还使用模式匹配来定义多个 call/2 函数头以验证参数中的语言环境,如果匹配不上,则回退到 "en"assign/3Plug.Conn 模块的一部分,它允许我们在 conn 数据结构中存储值。

要查看分配的实际操作,请转到 lib/hello_web/controllers/page_html/home.html.heex 中的模板,并在 </h1> 标记的结束处添加以下代码

<p>Locale: <%= @locale %></p>

转到 http://localhost:4000/,您应该会看到显示的语言环境。访问 http://localhost:4000/?locale=fr,您应该会看到分配更改为 "fr"。有人可以使用这些信息以及 Gettext 来提供一个完全国际化的 web 应用程序。

这就是关于插头的一切。Phoenix 采用插头的可组合转换设计,贯穿整个堆栈。让我们看一些例子!

插头的位置

Phoenix 中的端点、路由器和控制器都接受插头。

端点插头

端点组织所有对每个请求通用的插头,并在使用其自定义管道将其调度到路由器之前应用它们。我们像这样在端点中添加了一个插头

defmodule HelloWeb.Endpoint do
  ...

  plug :introspect
  plug HelloWeb.Router

默认的端点插头做了很多工作。以下是它们的顺序

  • Plug.Static - 提供静态资产。由于此插头在记录器之前,因此对静态资产的请求不会被记录。

  • Phoenix.LiveDashboard.RequestLogger - 为 Phoenix LiveDashboard 设置请求记录器,这将允许您选择传递查询参数来流式传输请求日志,或者启用/禁用从仪表板流式传输请求日志的 cookie。

  • Plug.RequestId - 为每个请求生成一个唯一的请求 ID。

  • Plug.Telemetry - 添加检测点,以便 Phoenix 默认情况下可以记录请求路径、状态码和请求时间。

  • Plug.Parsers - 当可用的解析器已知时,解析请求正文。默认情况下,此插头可以处理 URL 编码、多部分和 JSON 内容(使用 Jason)。如果无法解析请求的 content-type,则请求正文将保持不变。

  • Plug.MethodOverride - 将请求方法转换为 PUT、PATCH 或 DELETE,用于带有有效 _method 参数的 POST 请求。

  • Plug.Head - 将 HEAD 请求转换为 GET 请求。

  • Plug.Session - 一个设置会话管理的插头。请注意,fetch_session/2 仍然需要在使用会话之前明确调用,因为此插头只是设置如何获取会话。

在端点的中间,还有一个条件块

  if code_reloading? do
    socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
    plug Phoenix.LiveReloader
    plug Phoenix.CodeReloader
    plug Phoenix.Ecto.CheckRepoStatus, otp_app: :hello
  end

此块仅在开发环境中执行。它启用了

  • 实时重载 - 如果您更改 CSS 文件,它们将在浏览器中更新,而无需刷新页面;
  • 代码重载 - 这样我们就可以看到对应用程序的更改,而无需重新启动服务器;
  • 检查仓库状态 - 这确保了我们的数据库是最新的,否则会引发一个易读且可操作的错误。

路由器插头

在路由器中,我们可以在管道内声明插头

defmodule HelloWeb.Router do
  use HelloWeb, :router

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, html: {HelloWeb.LayoutView, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
    plug HelloWeb.Plugs.Locale, "en"
  end

  scope "/", HelloWeb do
    pipe_through :browser

    get "/", PageController, :index
  end

路由在范围内部定义,范围可以经过多个管道。一旦路由匹配,Phoenix 就会调用与该路由关联的所有管道中定义的所有插头。例如,访问“/”将经过 :browser 管道,从而调用其所有插头。

正如我们在 路由指南 中将看到的,管道本身就是插头。在那里,我们还将讨论 :browser 管道中的所有插头。

控制器插头

最后,控制器也是插头,所以我们可以做

defmodule HelloWeb.PageController do
  use HelloWeb, :controller

  plug HelloWeb.Plugs.Locale, "en"

特别是,控制器插头提供了一个功能,允许我们仅在某些操作中执行插头。例如,您可以做

defmodule HelloWeb.PageController do
  use HelloWeb, :controller

  plug HelloWeb.Plugs.Locale, "en" when action in [:index]

插头将仅对 index 操作执行。

插头作为组合

通过遵守插头契约,我们将应用程序请求变成一系列显式转换。它不会止步于此。要真正看到插头设计的效果,让我们想象一个需要检查一系列条件,然后在条件失败时重定向或停止的场景。如果没有插头,我们会得到类似于下面的东西

defmodule HelloWeb.MessageController do
  use HelloWeb, :controller

  def show(conn, params) do
    case Authenticator.find_user(conn) do
      {:ok, user} ->
        case find_message(params["id"]) do
          nil ->
            conn |> put_flash(:info, "That message wasn't found") |> redirect(to: ~p"/")
          message ->
            if Authorizer.can_access?(user, message) do
              render(conn, :show, page: message)
            else
              conn |> put_flash(:info, "You can't access that page") |> redirect(to: ~p"/")
            end
        end
      :error ->
        conn |> put_flash(:info, "You must be logged in") |> redirect(to: ~p"/")
    end
  end
end

请注意,仅仅几个身份验证和授权步骤就需要复杂的嵌套和重复?让我们用几个插头来改进这一点。

defmodule HelloWeb.MessageController do
  use HelloWeb, :controller

  plug :authenticate
  plug :fetch_message
  plug :authorize_message

  def show(conn, params) do
    render(conn, :show, page: conn.assigns[:message])
  end

  defp authenticate(conn, _) do
    case Authenticator.find_user(conn) do
      {:ok, user} ->
        assign(conn, :user, user)
      :error ->
        conn |> put_flash(:info, "You must be logged in") |> redirect(to: ~p"/") |> halt()
    end
  end

  defp fetch_message(conn, _) do
    case find_message(conn.params["id"]) do
      nil ->
        conn |> put_flash(:info, "That message wasn't found") |> redirect(to: ~p"/") |> halt()
      message ->
        assign(conn, :message, message)
    end
  end

  defp authorize_message(conn, _) do
    if Authorizer.can_access?(conn.assigns[:user], conn.assigns[:message]) do
      conn
    else
      conn |> put_flash(:info, "You can't access that page") |> redirect(to: ~p"/") |> halt()
    end
  end
end

为了使这一切正常工作,我们转换了嵌套的代码块,并在到达失败路径时使用了 halt(conn)halt(conn) 功能在这里至关重要:它告诉插头不应该调用下一个插头。

最终,通过用扁平化的插头转换序列替换嵌套的代码块,我们能够以一种更加可组合、清晰和可重用的方式实现相同的功能。

要了解更多关于插头的知识,请参阅 插头项目 的文档,其中提供了许多内置插头和功能。