Elixir: начинаем работу с Plug
В мире 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
Тут мы сделали композицию трёх модулей Plug
— Plug.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
запрос.