Эта статья изначально была опубликована в моем личном блоге на сайте 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 — стереть память сокращателя URL
  • stop — остановить процесс
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 теперь работает довольно аккуратно, на самом деле ему не хватает функциональности. Конечно, он действительно хорошо справляется со «счастливым путем», но когда дело доходит до обработки ошибок, трассировки или отчетов об ошибках, он действительно не справляется. Кроме того, у него нет стандартного интерфейса для добавления дополнительных функций в процесс — мы как бы придумали его по ходу дела.

Прочитав все это, вы, вероятно, подумали, что есть лучший способ сделать это. И вы были бы правы, думая так — давайте узнаем больше о GenServers.

Войдите в GenServer 🚪

GenServer – это поведение одноразового пароля. Поведение в этом контексте относится к трем вещам:

  • интерфейс, представляющий собой набор функций;
  • реализация, которая представляет собой код, специфичный для приложения, и
  • контейнер, который является процессом BEAM

Это означает, что модуль может реализовать определенную группу функций (интерфейс или сигнатуры), которые под капотом реализуют некоторые функции обратного вызова (специфические для поведения, над которым вы работаете), которые выполняются в процессе BEAM.

Например, GenServer — это общееповедение сервера — оно ожидает для каждой из функций, определенных в его интерфейсе, набор обратных вызовов, которые будут обрабатывать запросы к серверу. Это означает, что функции интерфейса будут использоваться клиентами универсального сервера, также известного как клиентский API, в то время как определенные обратные вызовы, по сути, будут внутренними компонентами сервера («бэкэнд»).

Итак, как работает GenServer? Что ж, как вы понимаете, мы не можем слишком углубляться в GenServer, но нам нужно хорошо понять некоторые основы:

  1. Запуск и состояние сервера
  2. Асинхронные сообщения
  3. Синхронные сообщения

Запуск и состояние сервера

Так же, как и с нашим URLShortener, который мы реализовали, каждый GenServer может сохранять состояние. На самом деле GenServers должен реализовать функцию 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 и установит состояние процесса в пустую карту.

Синхронные и асинхронные сообщения 📨

Как и большинство серверов, GenServers также может принимать запросы и отвечать на них (при необходимости). Как следует из заголовка, существует два типа запросов, которые 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 этого не делает. По сути, casting — это асинхронный вызов, при котором клиент не ожидает ответа, а calling — это синхронный вызов, при котором ответ ожидается.

Итак, давайте посмотрим на структуру обратного вызова handle_call/3.

Он принимает три аргумента: запрос от клиента (в нашем случае кортеж), кортеж, описывающий клиента запроса (который мы игнорируем), и состояние сервера (в нашем случае карта).

В качестве ответа он возвращает кортеж с :reply, указывающий, что будет ответ на запрос, сам ответ (в нашем случае ссылка shortened) и 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 на этом не заканчивается. На самом деле, это только началось. GenServers и OTP — это очень мощные инструменты, которые вы можете использовать для создания универсальных серверов, которые могут работать в небольших процессах BEAM и имеют очень общий подход к построению функциональности (вызовы и обратные вызовы).

Хотя мы рассмотрели здесь много вопросов, мы не коснулись того, почему мы назвали начальную функцию start_link, а не просто start (подсказка: соглашение супервайзеров), или как мы подходим к тестированию таких GenServer, как URLShortener.

В каких сценариях вы использовали GenServers? Или, если у вас нет опыта с этим, где вы видите себя использовать его в будущем?