Plug

Если вы знакомы с Ruby, то считайте, что Plug это Rack с лёгким налётом Sinatra. Он предоставляет спецификацию для компонентов веб-приложений и адаптеров для веб-серверов. Хотя Plug и не является частью ядра Elixir, это официальный проект от той же команды.

В этом уроке мы создадим простой HTTP-сервер с нуля, используя для этого библиотеку Elixir PlugCowboy. Cowboy - это простой HTTP-сервер для Erlang, и Plug предоставит нам интерфейс подключения для этого веб-сервера.

После того, как мы настроим наше простенькое рабочее веб-приложение, мы узнаем о маршрутизаторе Plug и о том, как использовать несколько модулей Plug в одном веб-приложении.

Перед установкой

Чтобы следовать инструкциям этого урока, вам понадобятся установленный Elixir версии 1.5 или выше и mix.

Мы начнем с создания нового OTP-проекта с деревом супервизора.

$ mix new example --sup
$ cd example

Нам нужно, чтобы наше приложение Elixir включало дерево супервизора, потому что мы будем использовать супервизор для запуска нашего сервера Cowboy2.

Зависимости

Добавлять новые зависимости при помощи mix невероятно легко. Чтобы использовать Plug в качестве интерфейса подключения для веб-сервера Cowboy2, нам необходимо установить пакет PlugCowboy:

Добавьте в файл mix.exs следующее:

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

Выполните следующую команду в терминале, чтобы mix скачал и установил новые зависимости:

$ mix deps.get

Спецификация

Чтобы создавать собственные Plug модули, нужно придерживаться спецификации. К счастью, необходимо реализовать всего две функции: init/1 и call/2.

Вот пример простого Plug модуля, который возвращает “Привет Мир!”:

defmodule Example.HelloWorldPlug do
  import Plug.Conn

  def init(options), do: options

  def call(conn, _opts) do
    conn
    |> put_resp_content_type("text/plain")
    |> send_resp(200, "Привет Мир!\n")
  end
end

Сохраним файл как lib/example/hello_world_plug.ex.

Функция init/1 используется для инициализации параметров нашего модуля Plug. Она вызывается супервизором, это будет объяснено в следующей секции. Пока что в качестве параметров будет пустой список.

Значение, возвращаемое init/1, передается в качестве второго аргумента в функцию call/2.

Функция call/2 вызывается для каждого нового запроса, приходящего от веб-сервера Cowboy. Она получает структуру %Plug.Conn{} в качества первого аргумента, и ожидается, что она также вернёт соединение (структуру того типа %Plug.Conn{}).

Настройка Application-модуля приложения

Мы должны дать команду “запуск” нашему приложению и контролировать веб-сервер Cowboy, когда приложение начнёт работать.

Мы реализуем это при помощи функции Plug.Cowboy.child_spec/1 .

Эта функция принимает три аргумента:

В файле lib/example/application.ex следует реализовать описание дочернего процесса при помощи его функции start/2.

defmodule Example.Application do
  use Application
  require Logger

  def start(_type, _args) do
    children = [
      {Plug.Cowboy, scheme: :http, plug: Example.HelloWorldPlug, options: [port: 8080]}
    ]
    opts = [strategy: :one_for_one, name: Example.Supervisor]

    Logger.info("Starting application...")

    Supervisor.start_link(children, opts)
  end
end

Примечание: Здесь нам не нужно вызывать child_spec, эта функция будет вызвана супервизором, запускающим этот процесс. Здесь мы просто передаём кортеж: модуль, который мы хотим использовать для дочернего процесса, и три обязательных аргумента.

Это запустит веб-сервер Cowboy2 под супервизором. В свою очередь, Cowboy начнёт работу по протоколу HTTP (можно также указать HTTPS) с использованием указанного порта (8080) и с модулем Plug (Example.HelloWorldPlug) в качестве интерфейса для входящих веб-запросов.

Теперь всё готово к запуску нашего первого веб-приложения, созданного на базе Plug. Учтите, что из-за того, что мы создали OTP-проект с деревом супервизора (передав аргумент --sup), наше приложение Example запустится автоматически из-за функции application.

В файле mix.exs должны быть следующие строчки:

def application do
  [
    extra_applications: [:logger],
    mod: {Example.Application, []}
  ]
end

Теперь мы готовы запустить наше простенькое рабочее веб-приложение. В командной строке выполним:

$ mix run --no-halt

Как только все скомпилируется и выведется сообщение [info] Starting application..., откройте в браузере http://127.0.0.1:8080. Там должно появиться следующее:

Привет Мир!

Использование Plug.Router

Для большинства приложений, таких как веб-сайты и REST API, понадобится что-то, что будет перенаправлять запросы к определенным ресурсам на соответствующие обработчики в коде. Специально для этого в Plug существует маршрутизатор (или роутер). Как мы сейчас увидим, фреймворк типа Sinatra в Elixir не требуется, так как мы получаем его возможности вместе с Plug.

Для начала создадим файл lib/example/router.ex и скопируем в него следующий код:

defmodule Example.Router do
  use Plug.Router

  plug :match
  plug :dispatch

  get "/" do
    send_resp(conn, 200, "Добро пожаловать")
  end

  match _ do
    send_resp(conn, 404, "Ой!")
  end
end

Это самая простая реализация модуля Router, её код довольно очевиден. Мы подключили необходимые макросы с помощью инструкции use Plug.Router и задействовали встроенные модули Plug: :match и :dispatch. В коде задано два предопределённых пути маршрутизации: один, для обработки GET-запросов к родительскому узлу '/', и второй, для обработки всех остальных запросов, возвращающий сообщение об ошибке 404.

Вернемся теперь к lib/example.ex и добавим Example.Router к дочерним процессам веб-сервера. Поменяем Example.HelloWorldPlug на наш новый роутер:

def start(_type, _args) do
  children = [
    {Plug.Cowboy, scheme: :http, plug: Example.Router, options: [port: 8080]}
  ]
  opts = [strategy: :one_for_one, name: Example.Supervisor]

  Logger.info("Starting application...")

  Supervisor.start_link(children, opts)
end

Запустим веб-сервер (в случае, если предыдущий сервер еще работает, его можно остановить, дважды нажав Ctrl+C).

Теперь откроем http://127.0.0.1:8080 в браузере. Мы должны увидеть сообщение Добро пожаловать. Попробуем открыть http://127.0.0.1:8080/waldo или любой другой ресурс. Должна появиться 404 ошибка с текстом Ой!.

Создание еще одного модуля Plug

Считается обычной практикой использовать в одном веб-приложении несколько модулей Plug, каждый из которых занимается своим делом. Например, у нас может быть был модуль, отвечающий за маршрутизацию; модуль, отвечающий за валидацию входящих веб-запросов; модуль, отвечающий за аутентификацию входящих запросов и так далее. В данном разделе мы создадим модуль Plug, который будет проверять параметры входящих запросов (модуль-валидатор) и мы научим наше веб-приложение использовать оба наших модуля Plug – маршрутизатор и валидатор.

Нам нужно создать модуль Plug, который проверяет, соответствует ли запрос какому-то заданному набору параметров. Только валидные (соответствующие всему набору параметров) запросы будут переданы нашему приложению, если мы реализуем проверку валидности в модуле Plug. Этот модуль Plug должен быть инициализирован с двумя опциями: :paths и :fields. Эти опции задают пути, у которых будет проверяться логика, и поля, которые будут необходимы для проверки.

Примечание: В веб-приложении модули Plug применяются ко всем запросом, из-за чего мы будем фильтровать запросы и применять нашу логику валидации только к некоторым из этих запросов. Чтобы проигнорировать запрос, мы просто пробрасываем соединение дальше.

Сначала мы покажем реализацию такого модуля Plug, а потом разберём его работу. Создаём модуль в файле lib/example/plug/verify_request.ex:

defmodule Example.Plug.VerifyRequest do
  defmodule IncompleteRequestError do
    @moduledoc """
    Если у запроса отсутствует один из требуемых параметров - возникает исключение.
    """

    defexception message: ""
  end

  def init(options), do: options

  def call(%Plug.Conn{request_path: path} = conn, opts) do
    if path in opts[:paths], do: verify_request!(conn.params, opts[:fields])
    conn
  end

  defp verify_request!(params, fields) do
    verified =
      params
      |> Map.keys()
      |> contains_fields?(fields)

    unless verified, do: raise(IncompleteRequestError)
  end

  defp contains_fields?(keys, fields), do: Enum.all?(fields, &(&1 in keys))
end

В первую очередь, нужно обратить внимание на то, что здесь мы задали новое исключение IncompleteRequestError. Оно вызывается в случае получения невалидного запроса.

Вторая часть модуля это функция call/2. В этой функции мы определяем, нужно или нет применять нашу логику валидации. Мы вызываем verify_request!/2 только в случае, если путь запроса содержится в :paths.

Последняя часть описываемого модуля Plug — закрытая функция verify_request!/2, которая проверяет наличие у запроса всех требуемых параметров из аргумента :fields. В случае отсутствия любого из параметров, вызывается исключение IncompleteRequestError.

Мы настроили наш модуль Plug так, чтобы проверять, что все запросы к пути /upload содержат параметры "content" и "mimetype". Только в случае прохождения этой проверки может быть выполнен код маршрутизатора, связанный с такими запросами.

Теперь нужно сообщить маршрутизатору о новом Plug-модуле. Отредактируем lib/example/router.ex следующим образом:

defmodule Example.Router do
  use Plug.Router

  alias Example.Plug.VerifyRequest

  plug Plug.Parsers, parsers: [:urlencoded, :multipart]
  plug VerifyRequest, fields: ["content", "mimetype"], paths: ["/upload"]
  plug :match
  plug :dispatch

  get "/" do
    send_resp(conn, 200, "Добро пожаловать")
  end

  get "/upload" do
    send_resp(conn, 201, "Загружено")
  end

  match _ do
    send_resp(conn, 404, "Ой!")
  end
end

В этом участке кода мы указываем нашему веб-приложению перенаправлять входящие запросы через модуль Plug VerifyRequest до того, как будет выполнен код в модуле-маршрутизаторе. Это осуществляется вызовом следующей функции:

plug VerifyRequest, fields: ["content", "mimetype"], paths: ["/upload"]

Здесь автоматически вызывается VerifyRequest.init(fields: ["content", "mimetype"], paths: ["/upload"]). Заданные опции передаются в функцию VerifyRequest.call(conn, opts).

Пришло время посмотреть на работу нашего модуля Plug. Остановите свой локальный сервер (для тех, кто забыл: нажмите ctrl + c два раза). Затем перезагрузите сервер (mix run --no-halt). Перейдите по адресу http://127.0.0.1:8080/upload и вы увидите, что эта страница просто не отображается. Вам будет показана стандартная страница с ошибкой.

Теперь давайте передадим требуемые параметры через запрос http://127.0.0.1:8080/upload?content=thing1&mimetype=thing2. Сейчас мы должны увидеть наше сообщение ‘Загружено’.

То, что мы вообще не получаем никакую страницу в случае получения ошибочного запроса, нехорошо. Мы разберёмся, как обрабатывать подобные ошибки, несколько позже.

Делаем HTTP порт конфигурируемым

Когда мы создавали наше приложение Example, HTTP порт был “зашит” в коде. Считается хорошим тоном делать порт конфигурируемым при помощи файлов настроек.

Мы установим переменную среды приложения в config/config.exs

use Mix.Config

config :example, cowboy_port: 8080

Дальше нам нужно обновить lib/example/application.ex, получить номер порта и передать это значение веб-серверу Cowboy. Здесь мы зададим приватную функцию cowboy_port, которая будет выполнять эту работу.

defmodule Example.Application do
  use Application
  require Logger

  def start(_type, _args) do
    children = [
      {Plug.Cowboy, scheme: :http, plug: Example.Router, options: [port: cowboy_port()]}
    ]
    opts = [strategy: :one_for_one, name: Example.Supervisor]

    Logger.info("Starting application...")

    Supervisor.start_link(children, opts)
  end

  defp cowboy_port, do: Application.get_env(:example, :cowboy_port, 8080)
end

Третий аргумент в Application.get_env — это порт по умолчанию на случай, если настройка не объявлена.

Теперь для запуска приложения можно использовать команду:

$ mix run --no-halt

Тестирование модуля Plug

Тестировать модули Plug легко благодаря наличию Plug.Test. Этот модуль предоставляет множество функций для упрощения тестирования.

Напишем следующий тест в test/example/router_test.exs:

defmodule Example.RouterTest do
  use ExUnit.Case
  use Plug.Test

  alias Example.Router

  @content "<html><body>Привет!</body></html>"
  @mimetype "text/html"

  @opts Router.init([])

  test "returns Добро пожаловать" do
    conn =
      :get
      |> conn("/", "")
      |> Router.call(@opts)

    assert conn.state == :sent
    assert conn.status == 200
  end

  test "returns Загружено" do
    conn =
      :get
      |> conn("/upload?content=#{@content}&mimetype=#{@mimetype}")
      |> Router.call(@opts)

    assert conn.state == :sent
    assert conn.status == 201
  end

  test "returns 404" do
    conn =
      :get
      |> conn("/missing", "")
      |> Router.call(@opts)

    assert conn.state == :sent
    assert conn.status == 404
  end
end

И запустим командой:

$ mix test test/example/router_test.exs

Plug.ErrorHandler

Ранее мы заметили, что когда мы перешли на http://127.0.0.1:8080/upload без ожидаемых параметров, мы не получили удобную страницу с ошибкой или разумный статус HTTP - просто страницу с ошибкой c 500 Internal Server Error.

Давайте исправим это сейчас, добавив Plug.ErrorHandler.

Сначала откройте lib/example/router.ex, а затем напишите в этот файл следующее.

defmodule Example.Router do
  use Plug.Router
  use Plug.ErrorHandler

  alias Example.Plug.VerifyRequest

  plug Plug.Parsers, parsers: [:urlencoded, :multipart]
  plug VerifyRequest, fields: ["content", "mimetype"], paths: ["/upload"]
  plug :match
  plug :dispatch

  get "/" do
    send_resp(conn, 200, "Добро пожаловать")
  end

  get "/upload" do
    send_resp(conn, 201, "Загружено")
  end

  match _ do
    send_resp(conn, 404, "Ой!")
  end

  defp handle_errors(conn, %{kind: kind, reason: reason, stack: stack}) do
    IO.inspect(kind, label: :kind)
    IO.inspect(reason, label: :reason)
    IO.inspect(stack, label: :stack)
    send_resp(conn, conn.status, "Something went wrong")
  end
end

Обратите внимание, что в начале листинга мы указываем use Plug.ErrorHandler.

Этот модуль Plug обрабатывает все ошибки, а потом вызывает функцию handle_errors/2, которая займётся обработкой всех ошибок.

Функция handle_errors/2 принимает два параметра: conn и ассоциативный массив с тремя элементами (:kind, :reason и :stack).

Мы задали очень простую функцию handle_errors/2, чтобы были лучше видно происходящее в приложении. Теперь давайте остановим и перезагрузим наше веб-приложение, чтобы увидеть работу этой функции.

По переходу на адрес http://127.0.0.1:8080/upload вы увидите заданное вами сообщение об ошибке.

Если обратить внимание на терминал, в котором запущен сервер, то там можно разглядеть что-то наподобие:

kind: :error
reason: %Example.Plug.VerifyRequest.IncompleteRequestError{message: ""}
stack: [
  {Example.Plug.VerifyRequest, :verify_request!, 2,
   [file: 'lib/example/plug/verify_request.ex', line: 23]},
  {Example.Plug.VerifyRequest, :call, 2,
   [file: 'lib/example/plug/verify_request.ex', line: 13]},
  {Example.Router, :plug_builder_call, 2,
   [file: 'lib/example/router.ex', line: 1]},
  {Example.Router, :call, 2, [file: 'lib/plug/error_handler.ex', line: 64]},
  {Plug.Cowboy.Handler, :init, 2,
   [file: 'lib/plug/cowboy/handler.ex', line: 12]},
  {:cowboy_handler, :execute, 2,
   [
     file: '/path/to/project/example/deps/cowboy/src/cowboy_handler.erl',
     line: 41
   ]},
  {:cowboy_stream_h, :execute, 3,
   [
     file: '/path/to/project/example/deps/cowboy/src/cowboy_stream_h.erl',
     line: 293
   ]},
  {:cowboy_stream_h, :request_process, 3,
   [
     file: '/path/to/project/example/deps/cowboy/src/cowboy_stream_h.erl',
     line: 271
   ]}
]

В данный момент мы все еще возвращаем 500 Internal Server Error. Мы можем настроить код состояния HTTP, добавив поле : plug_status к нашему исключению. Откройте lib/example/plug/verify_request.ex и добавьте следующее:

defmodule IncompleteRequestError do
  defexception message: "", plug_status: 400
end

Перезагрузите сервер и обновите страницу, теперь вы получите ответ 400 Bad Request.

Этот модуль Plug облегчает работу разработчикам, позволяя им получать полезную информацию для исправлению ошибок, и в то же время этот модуль показывает нашим пользователям красивую страничку с ошибкой вместо стандартной “ничего не работает”.

Доступные модули Plug

Много модулей Plug доступно для использования сразу “из коробки”. Полный список можно найти в документации по Plug &mdash; здесь.

Caught a mistake or want to contribute to the lesson? Edit this lesson on GitHub!