Эта статья изначально была опубликована в моем личном блоге на сайте ieftimov.com.
Глядя на любой язык программирования, вы (надеюсь!) найдете богатую и полезную стандартную библиотеку. Я начал свою профессиональную карьеру в качестве разработчика программного обеспечения с Ruby, который имеет довольно простую в использовании и хорошо документированную стандартную библиотеку с множеством модулей и классов для использования. Лично я нахожу модуль Enumerable
в Ruby со всеми его замечательными методами просто блестящим.
Возможно, вы пришли из другого языка, но я уверен, что любой серьезный язык программирования имеет набор классов, модулей, методов и функций, облегчающих вашу жизнь (и работу).
Итак, что насчет Elixirs stdlib?
Неудивительно и то, что у Elixir есть хорошо документированная и простая в использовании стандартная библиотека. Но поскольку он работает поверх виртуальной машины BEAM и многое унаследовал от богатой истории Erlang, у него есть и кое-что еще — что-то под названием OTP.
Встречайте ОТП 👋
OTP — это набор полезного промежуточного программного обеспечения, библиотек и инструментов, написанных на языке программирования Erlang. Это неотъемлемая часть дистрибутива Erlang с открытым исходным кодом. Название OTP изначально было аббревиатурой от Open Telecom Platform, которая была попыткой брендинга до того, как Ericsson выпустила Erlang/OTP с открытым исходным кодом. Однако ни Erlang, ни OTP не являются специфическими для телекоммуникационных приложений.
В продолжении говорится:
Он (ОТП) содержит:
- интерпретатор Erlang (называемый BEAM);
- компилятор Erlang;
- протокол связи между серверами (узлами);
- посредник запросов объектов CORBA;
- инструмент статического анализа под названием Dialyzer;
- сервер распределенной базы данных (Mnesia); и
- многие другие библиотеки.
Хотя я не считаю себя экспертом в Elixir, Erlang, BEAM или OTP ни в коем случае, я хотел бы предложить вам путешествие к одному из самых полезных и известных поведений ОТП — GenServer
.
В дальнейшем мы будем использовать BEAM-процессы, поэтому, если вы не знакомы с созданием новых процессов, отправкой и получением сообщений от них, то лучше сразу перейти к разделу Понимание основ модели параллелизма в Elixir и дать это быстрое чтение. Это поможет вам понять процессы и параллелизм в Elixir, чтобы вы могли применить знания в проекте, над которым мы работаем в этой статье. Обещаю.
Сокращение ссылки ✂️
Давайте напишем модуль сокращения URL-адресов, который будет работать в процессе BEAM и может получать несколько команд:
shorten
— берет ссылку, сокращает ее и возвращает короткую ссылку в качестве ответаget
— взять короткую ссылку и вернуть исходнуюflush
— стереть память сокращателя URLstop
— остановить процесс
defmodule URLShortener do
def start do
spawn(__MODULE__, :loop, [%{}])
end
def loop(state) do
receive do
{:stop, caller} ->
send caller, "Shutting down."
{:shorten, url, caller} ->
url_md5 = md5(url)
new_state = Map.put(state, url_md5, url)
send caller, url_md5
loop(new_state)
{:get, md5, caller} ->
send caller, Map.fetch(state, md5)
loop(state)
:flush ->
loop(%{})
_ ->
loop(state)
end
end
defp md5(url) do
:crypto.hash(:md5, url)
|> Base.encode16(case: :lower)
end
end
Что делает модуль, так это то, что при запуске процесса он будет рекурсивно вызывать функцию URLShortener.loop/1
, пока не получит сообщение {:stop, caller}
.
Если мы увеличим масштаб {:shorten, url, caller}
, мы заметим, что мы генерируем дайджест MD5
из URL-адреса, а затем обновляем карту state
, которая создает новую карту (называемую new_state
). Как только мы получим дайджест, мы сохраним его на карте с ключом, являющимся MD5, и значением, являющимся фактическим URL-адресом. Карта state
будет выглядеть так:
%{
"99999ebcfdb78df077ad2727fd00969f" => "https://google.com",
"76100d6f27db53fddb6c8fce320f5d21" => "https://elixir-lang.org",
"3097fca9b1ec8942c4305e550ef1b50a" => "https://github.com",
...
}
Затем мы отправляем значение MD5 обратно вызывающей стороне. Очевидно, что это не то, как работают bit.ly или лайки, поскольку их ссылки намного короче. (Кому интересно, вот интересное обсуждение темы). Однако для целей этой статьи мы будем придерживаться простого MD5-дайджеста URL.
Две другие команды, get
и flush
, довольно просты. get
возвращает только одно значение из карты state
, тогда как flush
вызывает loop/1
с пустой картой, эффективно удаляя все укороченные ссылки из состояния процесса (памяти).
Давайте запустим наш сокращатель в сеансе IEx
:
iex(22)> shortener = URLShortener.start
#PID<0.141.0>
iex(23)> send shortener, {:shorten, "https://ieftimov.com", self()}
{:shorten, "https://ieftimov.com", #PID<0.102.0>}
iex(24)> send shortener, {:shorten, "https://google.com", self()}
{:shorten, "https://google.com", #PID<0.102.0>}
iex(25)> send shortener, {:shorten, "https://github.com", self()}
{:shorten, "https://github.com", #PID<0.102.0>}
iex(26)> flush
"8c4c7fbc57b08d379da5b1312690be04"
"99999ebcfdb78df077ad2727fd00969f"
"3097fca9b1ec8942c4305e550ef1b50a"
:ok
iex(27)> send shortener, {:get, "99999ebcfdb78df077ad2727fd00969f", self()}
{:get, "99999ebcfdb78df077ad2727fd00969f", #PID<0.102.0>}
iex(28)> flush
"https://google.com"
:ok
iex(29)> send shortener, {:get, "8c4c7fbc57b08d379da5b1312690be04", self()}
{:get, "8c4c7fbc57b08d379da5b1312690be04", #PID<0.102.0>}
iex(30)> flush
"https://ieftimov.com"
:ok
iex(31)> send shortener, {:get, "3097fca9b1ec8942c4305e550ef1b50a", self()}
{:get, "3097fca9b1ec8942c4305e550ef1b50a", #PID<0.102.0>}
iex(32)> flush
"https://github.com"
:ok
Работает, как и ожидалось — мы отправляем три разных URL-адреса для сокращения, мы получаем их дайджесты MD5 обратно в почтовый ящик процесса, и когда мы запрашиваем их, мы получаем каждый из них обратно.
Хотя наш модуль URLShortener
теперь работает довольно аккуратно, на самом деле ему не хватает функциональности. Конечно, он действительно хорошо справляется со «счастливым путем», но когда дело доходит до обработки ошибок, трассировки или отчетов об ошибках, он действительно не справляется. Кроме того, у него нет стандартного интерфейса для добавления дополнительных функций в процесс — мы как бы придумали его по ходу дела.
Прочитав все это, вы, вероятно, подумали, что есть лучший способ сделать это. И вы были бы правы, думая так — давайте узнаем больше о GenServer
s.
Войдите в GenServer 🚪
GenServer
– это поведение одноразового пароля. Поведение в этом контексте относится к трем вещам:
- интерфейс, представляющий собой набор функций;
- реализация, которая представляет собой код, специфичный для приложения, и
- контейнер, который является процессом BEAM
Это означает, что модуль может реализовать определенную группу функций (интерфейс или сигнатуры), которые под капотом реализуют некоторые функции обратного вызова (специфические для поведения, над которым вы работаете), которые выполняются в процессе BEAM.
Например, GenServer
— это общееповедение сервера — оно ожидает для каждой из функций, определенных в его интерфейсе, набор обратных вызовов, которые будут обрабатывать запросы к серверу. Это означает, что функции интерфейса будут использоваться клиентами универсального сервера, также известного как клиентский API, в то время как определенные обратные вызовы, по сути, будут внутренними компонентами сервера («бэкэнд»).
Итак, как работает GenServer
? Что ж, как вы понимаете, мы не можем слишком углубляться в GenServer
, но нам нужно хорошо понять некоторые основы:
- Запуск и состояние сервера
- Асинхронные сообщения
- Синхронные сообщения
Запуск и состояние сервера
Так же, как и с нашим URLShortener
, который мы реализовали, каждый GenServer
может сохранять состояние. На самом деле GenServer
s должен реализовать функцию init/1
, которая будет устанавливать начальное состояние сервера (подробнее см. документацию init/1
здесь).
Чтобы запустить сервер, мы можем запустить:
GenServer.start_link(__MODULE__, :ok, [])
GenServer.start_link/3
вызовет функцию init/1
__MODULE__
, передав :ok
в качестве аргумента init/1
. Вызов этой функции будет заблокирован до тех пор, пока не вернется init/1
, поэтому обычно в этой функции мы делаем все необходимые настройки серверного процесса (которые могут понадобиться). Например, в нашем случае, чтобы перестроить URLShortener
с использованием поведения GenServer
, нам понадобится функция init/1
для установки начального состояния (пустая карта) сервера:
def init(:ok) do
{:ok, %{}}
end
Это все. start_link/3
вызовет init/1
с аргументом :ok
, который вернет :ok
и установит состояние процесса в пустую карту.
Синхронные и асинхронные сообщения 📨
Как и большинство серверов, GenServer
s также может принимать запросы и отвечать на них (при необходимости). Как следует из заголовка, существует два типа запросов, которые GenServers
обрабатывает: одни ожидают ответа (call
), а другие — нет (cast
). Поэтому GenServer
определяют две функции обратного вызова — handle_call/3
и handle_cast/2
.
Мы рассмотрим эти функции более подробно чуть позже.
Повторная реализация URLShortener
с использованием GenServer
♻️
Давайте посмотрим, как мы можем перевернуть реализацию, чтобы использовать GenServer
.
Во-первых, давайте добавим оболочку модуля, функцию start_link/1
и функцию init/1
, которую будет вызывать start_link/1
:
defmodule URLShortener do
use GenServer
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, :ok, opts)
end
def init(:ok) do
{:ok, %{}}
end
end
Заметными изменениями здесь являются use
поведения GenServer
в модуле, функция start_link/1
, которая вызывает GenServer.start_link/3
, которая фактически вызывает функцию init/1
с атомом :ok
в качестве аргумента. Также стоит отметить, что пустая карта, которую функция init/1
возвращает в кортеже, является фактическим начальным состоянием процесса URLShortener
.
Давайте попробуем в IEx
:
iex(1)> {:ok, pid} = URLShortener.start_link
{:ok, #PID<0.108.0>}
Это все, что мы можем сделать в данный момент. Разница здесь в том, что функция GenServer.start_link/3
вернет кортеж с атомом (:ok
) и PID сервера.
Остановка сервера ✋
Добавим команду stop
:
defmodule URLShortener do
use GenServer
# Client API
def start_link(opts \\ []), do: GenServer.start_link(__MODULE__, :ok, opts)
def stop(pid) do
GenServer.cast(pid, :stop)
end
# GenServer callbacks
def init(:ok), do: {:ok, %{}}
def handle_cast(:stop, state) do
{:stop, :normal, state}
end
end
Да, я знаю, что сказал, что мы добавим одну команду, но в итоге добавил две функции: stop/1
и handle_cast/2
. Потерпите меня сейчас:
Поскольку мы не хотим получать ответ на команду stop
, мы будем использовать GenServer.cast/2
в функции stop/1
. Это означает, что когда эта команда вызывается клиентом (пользователем) сервера, на сервере будет запущен обратный вызов handle_cast/2
. В нашем случае функция handle_cast/2
вернет кортеж из трех элементов — {:stop, :normal, state}
.
Возврат этого кортежа останавливает цикл и вызывается другой обратный вызов с именем terminate/2
(который определен в поведении, но не реализован URLShortener
) с причиной :normal
и состоянием state
. Процесс завершится с причиной :normal
.
Этот способ работы с GenServer
позволяет нам определять только обратные вызовы, а поведение GenServer
будет знать, как обрабатывать все остальное. Единственная сложность заключается в том, что нам нужно понимать и знать большинство типов возвратов, которые могут иметь функции обратного вызова.
Еще одна вещь, на которую стоит обратить внимание, это то, что каждая функция, которая будет использоваться клиентом, будет принимать PID
в качестве первого аргумента. Это позволит нам отправлять сообщения правильному процессу GenServer
. В дальнейшем мы не будем признавать присутствие PID
— мы признаем, что для работы нашего URLShortener
это обязательно. Позже мы рассмотрим, как можно пропустить передачу PID
в качестве аргументов.
Давайте вернемся к IEx
и запустим и остановим сервер URLShortener
:
iex(1)> {:ok, pid} = URLShortener.start_link
{:ok, #PID<0.109.0>}
iex(2)> Process.alive?(pid)
true
iex(3)> URLShortener.stop(pid)
:ok
iex(4)> Process.alive?(pid)
false
Это начинается и останавливается во всей своей красе.
Сокращение URL-адреса
Еще одна вещь, которую мы хотели, чтобы наш сервер имел возможность сокращать URL-адреса, используя их дайджест MD5 в качестве короткого варианта URL-адреса. Давайте сделаем это, используя GenServer
:
defmodule URLShortener do
use GenServer
# Client API
def start_link(opts \\ []), do: GenServer.start_link(__MODULE__, :ok, opts)
def stop(pid), do: GenServer.cast(pid, :stop)
def shorten(pid, url) do
GenServer.call(pid, {:shorten, url})
end
# GenServer callbacks
def init(:ok), do: {:ok, %{}}
def handle_cast(:stop, state), do: {:stop, :normal, state}
def handle_call({:shorten, url}, _from, state) do
short = md5(url)
{:reply, short, Map.put(state, short, url)}
end
defp md5(url) do
:crypto.hash(:md5, url)
|> Base.encode16(case: :lower)
end
end
На этот раз три функции, но, по крайней мере, md5/1
является копией того, что у нас было раньше. Итак, давайте посмотрим на два других.
Возможно, вы видите шаблон — у нас есть функция, которая будет использоваться клиентом (shorten/2
), и обратный вызов, который будет вызываться на сервере (handle_call/3
). На этот раз есть небольшая разница в используемых функциях и именах: в shorten/2
мы вызываем GenServer.call/2
вместо cast/2
, а имя обратного вызова — handle_call/3
вместо handle_cast/2
.
Почему? Ну, разница заключается в ответе — handle_call/3
отправит ответ обратно клиенту (отсюда атом :reply
в кортеже ответа), а handle_cast/2
этого не делает. По сути, cast
ing — это асинхронный вызов, при котором клиент не ожидает ответа, а call
ing — это синхронный вызов, при котором ответ ожидается.
Итак, давайте посмотрим на структуру обратного вызова handle_call/3
.
Он принимает три аргумента: запрос от клиента (в нашем случае кортеж), кортеж, описывающий клиента запроса (который мы игнорируем), и состояние сервера (в нашем случае карта).
В качестве ответа он возвращает кортеж с :reply
, указывающий, что будет ответ на запрос, сам ответ (в нашем случае ссылка short
ened) и state
, которое является состоянием, переносимым в следующий цикл сервера.
Конечно, у handle_call/3
есть немного больше тонкостей, которые мы рассмотрим позже, но вы всегда можете проверить его документацию, чтобы узнать больше.
Получение сокращенного URL 🔗
Давайте реализуем команду get
, которая при наличии версии ссылки short
будет возвращать полный URL-адрес:
defmodule URLShortener do
use GenServer
# Client API
# ...
def get(pid, short_link) do
GenServer.call(pid, {:get, short_link})
end
# GenServer callbacks
# ...
def handle_call({:get, short_link}, _from, state) do
{:reply, Map.get(state, short), state}
end
end
Снова паттерн входа двойной функции — мы добавляем URLShortener.get/2
и еще один заголовок функции URLShortener.handle_call/3
.
URLShortener.get/2
вызовет GenServer.call/2
под капотом, что при выполнении приведет к срабатыванию обратного вызова handle_call/3
.
На этот раз URLShortener.handle_call/3
примет команду (:get
) и short_link
в качестве первого аргумента. Заглянув внутрь, мы видим, что это опять-таки короткая функция — она возвращает только кортеж с :reply
(в котором говорится, что вызов будет иметь ответ), вызов Map.get/2
, чей возврат будет фактическим ответом на вызов, и state
, поэтому процесс GenServer
поддерживает состояние в следующем цикле.
На данный момент мы можем с уверенностью сказать, что у нас есть хорошее представление об основах написания функциональности для модуля, реализующего поведение GenServer
. Как вы могли подумать, здесь есть что исследовать, но эти основы позволят вам создавать GenServer
и экспериментировать.
Прежде чем продолжить, попробуйте реализовать еще две команды:
flush
— асинхронный вызов, который сотрет состояние сервераcount
— вызов синхронизации, возвращающий количество ссылок в состоянии сервера
Больше конфигураций 🎛
Если мы вернемся к URLShortener.start_link/1
и его внутренним элементам (а именно к вызову GenServer.start_link/3
), мы также заметим, что можем передавать параметры (opts
) в функцию GenServer.start_link/3
, которые по умолчанию представляют собой пустой список ([]
).
Какие опции мы можем добавить сюда? Изучив документацию GenServer.start_link/3
, вы заметите несколько интересных опций:
:name
- используется для регистрации имени. Это означает, что вместо того, чтобы идентифицироватьGenServer
поPID
, мы можем дать ему имя.:timeout
- устанавливает время ожидания запуска сервера (в миллисекундах):debug
— включает отладку, вызывая соответствующую функцию в модуле:sys
:hibernate_after
— устанавливает время (в миллисекундах), по истечении которого серверный процесс автоматически переходит в спящий режим до поступления нового запроса. Это делается с помощью:proc_lib.hibernate/3
:spawn_opt
— позволяет передавать больше параметров базовому процессу
Большинство из них являются продвинутыми и выходят за рамки нашего варианта использования. Однако есть одна конфигурация, которую мы могли бы использовать прямо сейчас: :name
.
Именование сервера 📢
Давайте изменим наш URLShortener
, чтобы взять name
в его функции start_link/1
и протестировать его в IEx
. Кроме того, поскольку у каждого процесса URLShortener
будет имя, мы можем ссылаться на процесс по имени вместо PID
— давайте посмотрим, как это будет работать в коде:
defmodule URLShortener do
use GenServer
# Client API
def start_link(name, opts \\ []) do
GenServer.start_link(__MODULE__, :ok, opts ++ [name: name])
end
def stop(name), do: GenServer.cast(name, :stop)
def shorten(name, url), do: GenServer.call(name, {:shorten, url})
def get(name, short_link) do
GenServer.call(name, {:get, short_link})
end
# GenServer callbacks
def init(:ok), do: {:ok, %{}}
def handle_cast(:stop, state), do: {:stop, :normal, state}
def handle_call({:shorten, url}, _from, state), do: {:reply, md5(url), Map.put(state, md5(url), url)}
def handle_call({:get, short_link}, _from, state) do
{:reply, Map.get(state, short_link), state}
end
defp md5(url), do: :crypto.hash(:md5, url) |> Base.encode16(case: :lower)
end
Это все. Мы добавили новый аргумент к URLShortener.start_link/2
и отказались от использования PID
и заменили его на name
.
Давайте проверим это в IEx
:
iex(1)> {:ok, pid} = URLShortener.start_link(:foo)
{:ok, #PID<0.109.0>}
iex(2)> URLShortener.shorten(:foo, "https://google.com")
"99999ebcfdb78df077ad2727fd00969f"
iex(3)> URLShortener.get(:foo, "99999ebcfdb78df077ad2727fd00969f")
"https://google.com"
iex(4)> URLShortener.stop(:foo)
:ok
iex(5)> Process.alive?(pid)
false
Вы можете видеть, что это довольно круто — вместо использования PID
мы добавили к процессу имя :foo
, что позволило нам обращаться к нему, используя имя вместо PID
. Очевидно, вы можете видеть, что для проверки процесса BEAM нам по-прежнему понадобится PID
, но для клиента name
делает свое дело.
Эта комбинация имени и PID
позволяет нам иметь ссылку на процесс BEAM, одновременно повышая простоту использования для клиента.
Если мы хотим еще больше упростить ситуацию, мы можем превратить URLShortener
в одноэлементный сервер. Прежде чем вы сойдете с ума, у него нет ни одного из недостатков печально известного в объектно-ориентированном программировании одиночного шаблона. Мы просто заявляем, что можем изменить URLShortener
так, чтобы один и только один процесс выполнялся в определенное время, установив для него статическое имя:
defmodule URLShortener do
use GenServer
@name :url_shortener_server
# Client API
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, :ok, opts ++ [name: @name])
end
def stop, do: GenServer.cast(@name, :stop)
def shorten(url), do: GenServer.call(@name, {:shorten, url})
def get(short_link) do
GenServer.call(@name, {:get, short_link})
end
# GenServer callbacks
# ...
end
Вы можете заметить, что мы добавили атрибут модуля @name
, который содержит имя процесса. Во всех функциях клиентского API мы убрали name
из списков аргументов и просто используем @name
как ссылку на процесс. Это означает, что для URLShortener
будет только один процесс с именем :url_shortener_server
.
Давайте проверим это в IEx
:
iex(1)> {:ok, pid} = URLShortener.start_link
{:ok, #PID<0.108.0>}
iex(2)> URLShortener.shorten("https://google.com")
"99999ebcfdb78df077ad2727fd00969f"
iex(3)> URLShortener.shorten("https://yahoo.com")
"c88f320dec138ba5ab0a5f990ff082ba"
iex(4)> URLShortener.get("99999ebcfdb78df077ad2727fd00969f")
"https://google.com"
iex(5)> URLShortener.stop
:ok
iex(6)> Process.alive?(pid)
false
Вы можете заметить, что хотя мы и захватили PID
в первой строке, он нам вообще не нужен — всю работу за нас делает URLShortener
.
В этом разделе вы увидели, как можно использовать имена для упрощения работы с процессами. Давайте рассмотрим нашу полную реализацию модуля URLShortener
.
Аутро 🖖
Прежде чем мы закончим этот длинный урок, давайте в последний раз взглянем на наш новый модуль URLShortener
, включая функции count/1
и flush/1
:
defmodule URLShortener do
use GenServer
# Client API
def start_link(name, opts \\ []) do
GenServer.start_link(__MODULE__, :ok, opts ++ [name: name])
end
def shorten(name, url) do
GenServer.call(name, {:shorten, url})
end
def get(name, short) do
GenServer.call(name, {:get, short})
end
def flush(name) do
GenServer.cast(name, :flush)
end
def stop(name) do
GenServer.cast(name, :stop)
end
def count(name) do
GenServer.call(name, :count)
end
# Callbacks
def init(:ok) do
{:ok, %{}}
end
def handle_cast(:flush, _state) do
{:noreply, %{}}
end
def handle_cast(:stop, state) do
{:stop, :normal, state}
end
def handle_call({:shorten, url}, _from, state) do
shortened = md5(url)
new_state = Map.put(state, shortened, url)
{:reply, shortened, new_state}
end
def handle_call({:get, short}, _from, state) do
{:reply, Map.get(state, short), state}
end
def handle_call(:count, _from, state) do
count = Map.keys(state) |> Enum.count
{:reply, count, state}
end
defp md5(url) do
:crypto.hash(:md5, url)
|> Base.encode16(case: :lower)
end
end
Два обратных вызова очень просты — flush
просто отправит noreply
и установит состояние на пустую карту. count
, с другой стороны, будет иметь reply
с количеством элементов карты, которое представляет собой просто количество ключей на карте state
. Это все.
Пока вы добрались до конца статьи, ваше путешествие с GenServer
на этом не заканчивается. На самом деле, это только началось. GenServer
s и OTP — это очень мощные инструменты, которые вы можете использовать для создания универсальных серверов, которые могут работать в небольших процессах BEAM и имеют очень общий подход к построению функциональности (вызовы и обратные вызовы).
Хотя мы рассмотрели здесь много вопросов, мы не коснулись того, почему мы назвали начальную функцию start_link
, а не просто start
(подсказка: соглашение супервайзеров), или как мы подходим к тестированию таких GenServer
, как URLShortener
.
В каких сценариях вы использовали GenServers
? Или, если у вас нет опыта с этим, где вы видите себя использовать его в будущем?