使用 Plug 在 Elixir 中进行 Web 开发

2021-08-18
·
11 min read
a purple water drop in the center, representing the Elixir programming language logo

What's Plug?

Plug 是 Elixir 最重量级的 Web 框架 Phoneix Framework 的中间层,根据官方文档的描述,Plug 是

  1. A specification for composable modules between web applications
  2. Connection adapters for different web servers in the Erlang VM

看完上面的介绍可能反而更加懵了,Plug 好像跟一般 Java Python 之类的 Web 框架里的存在的组件都不大一样。

根据 Elixir 的作者和 Plug 的贡献者 José Valim 自己的回答来看,Plug 是一个介于 Web Server 和 Web Framework 中间的一个通用抽象层。

Plug 封装了一个 Plug.Conn 的结构体,里面包含了与 Web Server 交互所需要的信息,包括 HTTP 请求和相应当中的 METHOD STATUS PATH BODY 等等信息,通过修改一个 Plug.Conn 当中的信息,将其传递给 Web Server 后,再次返回得到一个新的 Plug.Conn

所以根据上面的描述,Plug 是一个 Web Server 之上的抽象适配层,其下面可以是不同的 Web Server 实现,但实际上 Elixir 官方目前只实现了对 Cowboy 这个基于 Erlang VM 的 Web Server。

Get Started

首先用 Mix 来创建一个新的带有 Supervisor 支持的项目,执行 mix new <project_name> --sup,项目初始化完成后修改 mix.exs 添加 Plug 相关依赖,plug_cowboy 包括了 Cowboy Web Server 及其 Plug 的绑定。

defp deps do
  [
    {:plug_cowboy, "~> 2.0"}
  ]
end

之后执行 mix deps.get 拉取相关依赖。

A Glance at Plug.Conn

在正式编写我们的 Plug 项目的之前,先来看看 Plug.Conn 这个结构体到底包含了哪些东西。

Plug.Conn 代表一个 Connection,里面同时包含了 HTTP 的请求和相应的信息。

与请求相关的字段包括 host method path_info req_headers 等信息,与相应相关的字段包括 resp_body resp_headers status 等等。

还有一些特殊的字段比如 cookies body_params query_params 等等,这些字段需要手动调用特定函数后才会获取,例如调用 fetch_query_params/2 后才会获取到 query_params 字段。

另外在实际开发当中,我们还可以给 Plug.Conn 添加一些自己的额外信息,以 map 的形式存在 assigns 字段里面。

%Plug.Conn{
  adapter: {Plug.Cowboy.Conn, :...},
  assigns: %{},
  body_params: %Plug.Conn.Unfetched{aspect: :body_params},
  cookies: %Plug.Conn.Unfetched{aspect: :cookies},
  halted: false,
  host: "localhost",
  method: "GET",
  owner: #PID<0.363.0>,
  params: %Plug.Conn.Unfetched{aspect: :params},
  path_info: ["haha"],
  path_params: %{},
  port: 4001,
  private: %{},
  query_params: %Plug.Conn.Unfetched{aspect: :query_params},
  query_string: "",
  remote_ip: {127, 0, 0, 1},
  req_cookies: %Plug.Conn.Unfetched{aspect: :cookies},
  req_headers: [
    {"accept", "*/*"},
    {"host", "localhost:4001"},
    {"user-agent", "curl/7.78.0"}
  ],
  request_path: "/haha",
  resp_body: nil,
  resp_cookies: %{},
  resp_headers: [{"cache-control", "max-age=0, private, must-revalidate"}],
  scheme: :http,
  script_name: [],
  secret_key_base: nil,
  state: :unset,
  status: nil
}

My First Plug

简单来说,Plug 是一个特殊的类型为 (Plug.Conn.t, Plug.opts) :: Plug.Conn.t 函数,即接收一个 Connection 和一串 options,在函数内对 Connection 进行修改后再返回的一个函数。

除了这种函数式的 Plug,还有模块式的 Plug,但其核心还是上述的函数,接下来我们写一个简单的 Plug。

首先通过 import Plug.Conn 引入 Plug.Conn 模块下的所以函数和宏,之后我们需要实现规定好的两个回调函数。

init/1 函数用于初始化 options,将在 call/2 函数当中被调用。call/2 函数接收并返回一个 Plug.Conn 结构体。

call/2 函数内,我们可以使用之前 import 进来的函数来修改 Plug.Conn,根据函数名很容易能看出来下面的代码做了什么。

defmodule PlugTest.MyPlug do
  import Plug.Conn

  def init(opts), do: opts

  def call(conn, _opts) do
    conn
    |> put_resp_content_type("text/plain")
    |> send_resp(200, "Hello, World!")
  end
end

Launch the Application

之后我们要通过 Supervisor 来启动我们的 Web Server,修改 application.ex,在 children 列表中配置启动 Cowboy Server,同时设置初始化启动我们刚刚编写的 Plug,并将服务器开放在 4001 端口上。

defmodule PlugTest.Application do
  @moduledoc false

  use Application

  @impl true
  def start(_type, _args) do
    children = [
      {Plug.Cowboy, scheme: :http, plug: PlugTest.MyPlug, options: [port: 4001]}
    ]

    opts = [strategy: :one_for_one, name: PlugTest.Supervisor]
    Supervisor.start_link(children, opts)
  end
end

之后执行 mix run --no-halt 启动服务器,或者执行 iex -S mix 同时附带启动 iex。用浏览器或者 curl 访问 http://localhost:4001,会看见输出 Hello, World! 的信息。

Lifecycle of a Plug

Plug.Conn 里有一个特别的字段 state,用来表明当前的 Conn 的状态,用下面一个例子来看看一个 Conn 的生命周期。

修改我们的上面写的第一个 Plug,在每一次操作之前都对它的状态进行一次审查。这里用到了最新版本 Elixir 1.12 才引入的 tap/2 函数。

defmodule PlugTest.MyPlug do
  import Plug.Conn

  def init(opts), do: opts

  def call(conn, _opts) do
    conn
    |> tap(&inspect_state/1)
    |> put_resp_content_type("text/plain")
    |> tap(&inspect_state/1)
    |> resp(200, "Hello, World!")
    |> tap(&inspect_state/1)
    |> send_resp()
    |> tap(&inspect_state/1)
  end

  defp inspect_state(%Plug.Conn{state: state}) do
    IO.inspect(state)
  end
end

之后运行启动服务器,并用浏览器访问,可以看到控制台有以下输出内容

:unset
:unset
:set
:sent

由此可以看到一个 Plug.Conn 的生命周期。在 Supervisor 的配置里,我们配置启动了 Plug.Cowboy, scheme: :http, plug: PlugTest.MyPlug, options: [port: 4001],我们也可以手动执行 Plug.Cowboy.http 函数来启动 Cowboy 服务器。

在启动服务器的同时我们就将我们写的 Plug 挂载了上去,在有客户端向服务器发送请求时,此时的 Conn 的状态为 unset,在调用 resp/3 函数设置了 Connstatus coderesponse body 之后,Conn 的状态变成了 set,最终使用 send_resp/1 函数之后,我们将响应发送给了客户端,最终 Conn 的状态变为 sent

Built-in Plugs

现在我们了解到,Plug 用于将接收到的 Conn 进行一系列操作之后,再将其返回或者是最终将响应发送给客户端,由此一系列的 Plug 可以形成 Pipeline。

Plug 项目官方提供了一些可以直接使用的 Plugs,首先我们来看看增加路由功能的 Plug.Router

Router Plug

另外新建一个模块来编写我们的 Router Plug,首先我们使用了 use Plug.Router,这会将在 Plug.Router 模块内定义好的一些代码注入到当前模块。

之后使用了 plug :matchplug :dispatchplug/2 是一个宏,用于调用一系列 Plug 形成 Pipeline,后面可以是模块式的 Plug,也可以是函数式的 Plug。所有的请求都会以此经由所定义的 Pipiline。

其中 :match 是用来匹配路径并传递给 :dispatch 将请求分发到对应的不同函数上,之后可以用 get post put patch delete match 等宏以 DSL 的形式来构建自己的路由规则。

光看代码将已经能很清楚的知道具体的功能了,之后记得将 Supervisor 配置里启动 Cowboy 时初始化挂载的 Plug 改成新写的这个。

defmodule PlugTest.MyRouter do
  use Plug.Router

  plug :match
  plug :dispatch

  get "/hello" do
    send_resp(conn, 200, "Hello, World!")
  end

  get "/hello/:name" do
    send_resp(conn, 200, "Hello, #{name}")
  end

  match _ do
    send_resp(conn, 400, "404 NOT FOUND :(")
  end
end

实际测试一下,可以按照预期正常工作

$ curl "http://localhost:4001/"
404 NOT FOUND :(
$ curl "http://localhost:4001/hello"
Hello, World!
$ curl "http://localhost:4001/hello/name"
Hello, name

Logger Plug

Plug.Logger 基于 Elixir 内建的 Logger 模块给我们的 Plug 提供了打印调试信息日志的功能,只需要在我们的 Router Plug 的 Pipeline 后再追加一个 plug 调用就能启用日志功能。

plug :match
plug :dispatch
plug Plug.Logger, log: :debug

实际输出效果

$ mix run --no-halt
Compiling 1 file (.ex)
13:01:39.497 [debug] GET /
13:01:43.158 [debug] GET /test

Basic Auth

Plug 还内建了一个登陆验证的 Plug,简单使用的示例如下,要注意 Pipeline 中各个 Plug 使用的顺序,首先要先经由验证后才进行后续的路由匹配,所以登陆验证的 Plug 要放在首位。

之后用浏览器访问会弹出对话框提示输入用户密码,如果验证失败则返回 401。

import Plug.BasicAuth

plug :basic_auth, username: "test", password: "secret"
plug :match
plug :dispatch
plug Plug.Logger, log: :debug

Wrap Up

相信看到这里已经对 Plug 的开发模型有了一个简单的认识,通过复用已有的 Plug 以及自己按照编写新的 Plug,然后调用一系列 Plug 形成 Pipeline,由此可以快速地开发出简易的 Web 网站来。

之后还会介绍基于 Plug 的 MVC 模式的 Web 开发框架 Phoenix Framework,让 CRUD 型的业务开发更加简便。

Reference