檢視原始碼 Plug

需求:這份指南假定您已閱讀過 入門指南,並且已經 建立並執行 Phoenix 應用程式。

需求:這份指南假定您已閱讀過 請求生命週期指南

Plug 是 Phoenix HTTP 層的核心,並且 Phoenix 將 Plug 擺在首要位置。我們在請求生命週期的每個步驟都會與 Plug 互動,而 Phoenix 的核心元件(例如端點、路由器和控制器)在內部都只是 Plug。讓我們深入了解 Plug 的特別之處。

Plug 是一種用於 Web 應用程式之間的可組合模組的規範。它同時也是不同 Web 伺服器連接適配器的抽象層。Plug 的基本概念是統一我們操作的「連線」概念。這不同於其他 HTTP 中介層,例如 Rack,在這些中介層堆疊中,請求和回應是分開的。

Plug 規範最基本的層級分成兩種:函式 Plug模組 Plug

函式 Plug

為了作為一個 Plug,函式需要

  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

函式 Plug 會將函式名稱作為一個原子插入。要試用這個 Plug,請回到瀏覽器並擷取 https://127.0.0.1:4000。您應該會在 shell 終端機中看到列印出類似以下的內容

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

我們的 Plug 會簡單地列印連線資訊。儘管我們的初始 Plug 非常簡單,但您幾乎可以在其中執行任何您想做的事。要深入了解連線中所有可用的欄位,以及所有與它相關的功能,請參閱 Plug.Conn 的文件

現在讓我們看看另一種 Plug 變體,模組 Plug。

模組外掛程式

模組外掛程式為另一種類型的外掛程式,讓你在模組中定義連線轉換。該模組只需實作兩個函式

  • 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>

跳至 https://127.0.0.1:4000/,你應該會看到顯示的區域設定。請拜訪 https://127.0.0.1:4000/?locale=fr,你應該會看到指定變更為 "fr"。任何人都可以使用這個資訊結合 Gettext 來提供完全國際化的網頁應用程式

Plug 的部分就介紹到這邊。Phoenix 完全採用 Plug 的設計來 COMPOSABLE 所有堆疊中上下的轉換。讓我們看一些範例!

插入位置

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)。如果無法剖析請求的內容類型,則請求本體將保持不變。

  • Plug.MethodOverride - 使用一個有效的 _method 參數,將請求方法轉換為 POST 要求的 PUT、PATCH 或 DELETE。

  • 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 動作中執行。

外掛作為組成

通過遵守外掛合約,我們將應用程式要求轉換為一系列明確的轉換。不只如此。為了真正了解 Plug 的設計有多麼有效,讓我們想像一個場景:我們需要檢查一系列條件,然後在條件失敗時重新導向或暫停。沒有 Plug 的話,我們最終會寫出類似這樣的東西

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) 功能至關重要:它告訴 Plug 不應該呼叫下一個外掛。

最後,通過用扁平的外掛轉換系列取代巢狀程式碼區塊,我們可以用更可組合、清晰且可重複利用的方式達成相同的功能。

如需瞭解更多有關外掛的資訊,請參閱 Plug 專案 的文件,其中提供了許多內建外掛和功能。