В мире Elixir, Plug представляет собой спецификацию, позволяющую различным фреймворкам общаться с различными web-серверами, работающими в Erlang VM.
Если вы знакомы с Ruby, то можете провести аналогию с Rack: Plug пытается решать те же проблемы, но только другим способом. Понимание основ работы Plug позволит лучше разобраться как с работой Phoenix, так и других web-фреймворков, созданных на языке Elixir.




Роль Plug


Вы можете думать о Plug как о кусочке кода, который получает структуру данных, осуществляет с ней какие то трансформации, и возвращает ту же структуру данных, но уже частично модифицированную. Та структура данных, с которой работает Plug обычно называется соединением (connection). В этой структуре храниться всё что требуется знать о запросе (пер: и об ответе тоже).


Так как любой Plug принимает и возвращает соединение, то можно выстроить цепочку из нескольких таких объектов, которые последовательно будут обрабатывать одно и то же соединение. Такая композиция называется Plug pipeline


Сама структура данных, представляющая соединение — обычная Elixir структура, называемая %Plug.Conn{} (документацию по ней можно найти здесь).



Два различных типа Plug


Существуют два различных типа Plug: Plug-функция и Plug-модуль.


Plug-функция — любая функция, которая в качестве аргумента принимает соединение (это тот самый %Plug.Conn{}, и набор опций, и возвращает соединение.


def my_plug(conn, opts) do
  conn
end

Plug-модуль — это в свою очередь любой модуль, который имеет следующий интерфейс: init/1 и call/2, реализуемый таким образом:


module MyPlug do
  def init(opts) do
    opts
  end

  def call(conn, opts) do
    conn
  end
end

Интерес вызывает тот факт, что функция init/1 вызывается на этапе компиляции, а функция call/2 — во время работы программы.


Простой пример


Перейдём от теории к практике и создадим простейшее приложение, использующее Plug для обработки http запроса.


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


$ mix new learning_plug
$ cd learning_plug

Отредактируем файл mix.exs, добавив в качестве зависимостей Plug и Cowboy (это web-сервер):


# ./mix.exs

defp deps do
  [{:plug, "~> 1.0"},
   {:cowboy, "~> 1.0"}]
end

Подтянем зависимости:


$ mix deps.get

и мы готовы начинать работу!


Наш первый Plug будет просто возвращать "Hello, World!":


defmodule LearningPlug do
  # The Plug.Conn module gives us the main functions
  # we will use to work with our connection, which is
  # a %Plug.Conn{} struct, also defined in this module.
  import Plug.Conn

  def init(opts) do
    # Here we just add a new entry in the opts map, that we can use
    # in the call/2 function
    Map.put(opts, :my_option, "Hello")
  end

  def call(conn, opts) do
    # And we send a response back, with a status code and a body
    send_resp(conn, 200, "#{opts[:my_option]}, World!")
  end
end

Для использования этого модуля, запустим iex с окружением проекта:


$ iex -S mix

и выполним следующие команды:


iex(1)> Plug.Adapters.Cowboy.http(LearningPlug, %{})
{:ok, #PID<0.150.0>}

Мы используем Cowboy в качестве web-сервера, указывая ему использовать наш Plug. Второй аргумент функции http/2 (в данном случае пустой Map %{}) — это тот самый набор опций, который передастся в качестве аргумента функции init/1 в наш Plug.
Web-сервер должен был стартовать на порту 4000, поэтому если вы откроете http://localhost:4000 в браузере, то увидите "Hello, World!". Очень просто!


Попробуем сделать наш Plug чуточку умнее. Пусть он анализирует URL, к которому мы делаем запрос на сервер, и если к примеру мы пытаемся получить доступ к http://localhost:4000/Name мы должны видеть “Hello, Name”.


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


def call(%Plug.Conn{request_path: "/" <> name} = conn, opts) do
  send_resp(conn, 200, "Hello, #{name}")
end

Вот она мощь функционального программирования! Мы сопоставляем только ту информацию, которая нам нужна (имя), а затем используем её для генерирования ответа.


Pipeline и как это работает


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


Phoenix фреймворк использует pipeline везде, и делает это очень умно. По умолчанию, для обработки обычного браузерного запроса, pipeline выглядит так:


pipeline :browser do
  plug :accepts, ["html"]
  plug :fetch_session
  plug :fetch_flash
  plug :protect_from_forgery
  plug :put_secure_browser_headers
end

Если к примеру, нам надо обработать запрос к API, большинство из этих функций нам не нужны. Тогда pipeline значительно упрощается:


pipeline :api do
  plug :accepts, ["json"]
end

Конечно, pipeline макрос из предыдущего примера встроен в Phoenix. однако и Plug сам по себе предоставляет возможность строить такую pipeline: Plug.Builder.


Вот пример его работы:


defmodule MyPipeline do
  # We use Plug.Builder to have access to the plug/2 macro.
  # This macro can receive a function or a module plug and an
  # optional parameter that will be passed unchanged to the 
  # given plug.
  use Plug.Builder

  plug Plug.Logger
  plug :extract_name
  plug :greet, %{my_option: "Hello"}

  def extract_name(%Plug.Conn{request_path: "/" <> name} = conn, opts) do
    assign(conn, :name, name)
  end

  def greet(conn, opts) do
    conn
    |> send_resp(200, "#{opts[:my_option]}, #{conn.assigns.name}")
  end
end

Тут мы сделали композицию трёх модулей PlugPlug.Logger, extract_name и greet.
extract_name использует assign/3 для того, чтобы поместить значение с определённым ключом в соединение. assign/3 возвращает модифицированную копию соединения, которое затем обрабатывается greet_plug, которое наоборот читает это значение, чтобы затем сгенерировать ответ, который нам нужен.


Plug.Logger поставляется вместе с Plug и, как вы догадались, используется для логирования http запросов. Прямо из коробки доступен определённый набор "батареек", список можно найти тут


Использовать такую pipeline так же просто как и Plug:


Plug.Adapters.Cowboy.http(MyPipeline, %{})

Следует не забывать о том, что модули используются в той же последовательности, в которой они определены в pipeline


Ещё одна фишка: те композиции, которые созданы с помощью Plug.Builder — также реализуют интерфейс Plug. Поэтому, к примеру, можно составить композицию из pipeline и Plug, и продолжать до бесконечности!


Подытожим


Основная идея в том, что и запрос, и ответ представлен в одной общей структуре %Plug.Conn{}, и эта структура передаётся "по цепочке" от функции к функции, частично изменяясь на каждом шагу (пер: изменяется фигурально — данные иммутабельны, поэтому дальше передаётся изменённая копия структуры), до тех пор пока не получится ответ, который будет послан назад. Plug — это спецификация, определяющая как это всё должно работать и создающая абстракции так, что различные фреймворки могут общаться с различными web-серверами до тех пор, пока они выполняют эту спецификацию.


В "батарейки" к Plug входят различные модули, облегчающие множество разных распространённых задач: создание pipeline, простой роутинг, куки, заголовки и так далее.


А в конце хочется заметить, что в основе Plug лежит сама идея функционального программирования — передача данных по цепочке функций, которые трансформируют эти данные до тех пор, пока не получится нужный результат. Просто в этом случае данные — это http запрос.

Поделиться с друзьями
-->

Комментарии (0)