【Phoenix】插件(Plug)
插件是Phoenix的HTTP层的核心。我们在请求生命周期的每一步中都在和插件打交道,而且,Phoenix的核心组件,如endpoint,路由器和控制器本质上都是插件。让我们一起来看看是什么让Plug如此特别。
插件决定着网络应用中模块的组合。同时也是一个不同网络服务的连接适配抽象层。插件的基本思想是统一“连接”这一概念。这与其他HTTP中间件层(如Rack)不同,在Rack中,请求和响应在中间件栈中是分开的。
Rack 是对 Ruby 的 Net::HTTP 库的封装为一个 Ruby 包,这个包能够让开发者方便易用 Net::HTTP。
插件有两种风格:函数插件和模块插件。
函数插件
让一个函数成为插件需要满足以下两点:
- 接受两个参数,第一个是连接结构体(
%Plug.Conn{}),第二个是连接选项; - 返回一个连接结构体。
下面是一个例子。
def introspect(conn, _opts) do IO.puts """ Verb: #{inspect(conn.method)} Host: #{inspect(conn.host)}Headers: #{inspect(conn.req_headers)} """ conn
end
该函数做了以下几件事:
- 接收一个连接和选项(没有用到)
- 向终端输出一些连接信息
- 返回一个连接
很简单吧,我们将它添加到 lib/hello_web/endpoint.ex 中看看效果。我们可以将它插入任何地方,因此让我们在将请求分发到路由器之前插入 plug :introspect :
defmodule HelloWeb.Endpoint do ...plug :introspect plug HelloWeb.Routerdef introspect(conn, _opts) do IO.puts """ Verb: #{inspect(conn.method)} Host: #{inspect(conn.host)}Headers: #{inspect(conn.req_headers)} """ connend
end
通过将函数名作为原子传递来插入函数插件,回到浏览器访问 http://localhost:4000 ,你应该能在终端看到类似下面的输出:
Verb: "GET"
Host: "localhost"
Headers: [...]
我们的插件只是打印来自连接的信息。虽然我们的第一个插件非常简单,但是你可以在里面做任何你想做的事情。
模块插件
模块做为插件只需要实现两个函数:
init/1初始化传递给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)enddef call(conn, default) do assign(conn, :locale, default)end
end
将模块插件添加到路由器,在 lib/hello_web/router.ex 中的 :browser 管道中添加 plug HelloWeb.Plugs.Locale, "en" :
defmodule HelloWeb.Router do use HelloWeb, :routerpipeline :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/3 是 Plug.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提供国际化的网络应用。
以上就是插件的全部内容,Phoenix从上到下都采用了这种可拔插设计,来看几个例子。
插入位置
endpoint,路由器和控制器都接受插件。
Endpoint插件
Endpoint管理着应用到所有请求的公共插件,并在请求分发到路由器之前应用。通过以下方式向endpoint添加插件:
defmodule HelloWeb.Endpoint do ...plug :introspect plug HelloWeb.Router
Endpoint中的默认插件做了很多工作:
Plug.Static- 静态资源服务。因为该插件在日志插件之前,所以静态资源请求没有记录日志。Phoenix.LiveDashboard.RequestLogger- 为Phoenix的动态看板设置请求日志,this will allow you to have the option to either pass a query parameter to stream requests logs or to enable/disable a cookie that streams requests logs from your dashboard.Plug.RequestId- 为每个请求生成一个唯一的请求ID。Plug.Telemetry- 添加检测点让Phoenix可以记录请求路径,状态码和请求时间。Plug.Parsers- 解析请求体,如果有可用的解析器的话。默认情况下,插件可以处理URL编码参数,multiparty和JSON(使用Jason)。如果请求的content-type不能被解析,请求体不会发生变化。Plug.MethodOverride- 根据_method参数将POST请求的方法改为PUT,PATCH或DELETE。Plug.Head- 将HEAD请求转换成GET并去掉请求体。Plug.Session- 设置session管理器。注意,使用session之前任然需要先调用fetch_session/2,因为该插件只是设置了session的获取方式。
在endpoint的中间,有一个条件语句块:
if code_reloading? do socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socketplug Phoenix.LiveReloader plug Phoenix.CodeReloader plug Phoenix.Ecto.CheckRepoStatus, otp_app: :hello
end
上面的代码只在开发环境运行,它提供了:
- 热重载 - 如果你修改了CSS文件,浏览器会自动更新而不需要刷新网页;
- 代码重载 - 修改代码不需要重启服务器;
- 检查存储库状态 - 保证数据库是最新的,否则抛出错误。
路由器插件
在路由器中,我们可以在pipeline内定义插件:
defmodule HelloWeb.Router do use HelloWeb, :routerpipeline :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"endscope "/", HelloWeb do pipe_through :browserget "/", PageController, :index end
路由定义在scope内,scope可以有多个pipeline。一旦有路由匹配,Phoenix就会调用路由关联的所有pipeline中的所有插件。例如,访问“/”会执行 :browser pipeline,然后调用它的全部插件。
后面我们会看到,pipeline本身也是插件。
控制器插件
最后,控制器也是插件,因此我们可以:
defmodule HelloWeb.PageController do use HelloWeb, :controllerplug HelloWeb.Plugs.Locale, "en"
不同的是,控制器插件允许我们针对某个特定的函数执行插件。例如:
defmodule HelloWeb.PageController do use HelloWeb, :controllerplug HelloWeb.Plugs.Locale, "en" when action in [:index]
这样插件只会针对 index 函数执行。
插件组合
根据插件使用公约,我们将应用请求转化成一些列显示转换。不止于此,为了展示插件设计的真正威力,让我们假设一个场景,我们需要检查一系列条件,在条件失败时重定向或停止。不用插件,代码如下:
defmodule HelloWeb.MessageController do use HelloWeb, :controllerdef 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"/") endend
end
认证和授权几个简单的步骤竟需要如此复杂的嵌套,让我们通过插件来改进它。
defmodule HelloWeb.MessageController do use HelloWeb, :controllerplug :authenticate plug :fetch_message plug :authorize_messagedef show(conn, params) do render(conn, :show, page: conn.assigns[:message])enddefp 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() endenddefp 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 enddefp authorize_message(conn, _) do if Authorizer.can_access?(conn.assigns[:user], conn.assigns[:message]) do connelse conn |> put_flash(:info, "You can't access that page") |> redirect(to: ~p"/") |> halt() endend
end
当进入失败分支时,我们使用 halt(conn) 来告诉 plug 不再调用下一个插件。
最终,通过用一系列扁平的插件转换替换了嵌套的代码块,以一种更加灵活、清晰和可重用的方式实现了相同的功能。
