Масштабируемое приложение на Эликсире: от зонтичного проекта к распределённой системе

Абстракции OTP Эликсира и Эрланга буквально вынуждают разработчиков разбивать программы на независимые части. Серверы GenServer инкапсулируют элементы бизнес-логики на микроуровне, в то время как приложения являются более общей (сервисной) составляющей системы. Сложные программы, написанные на Эликсире, всегда представляют собой набор взаимодействующих OTP-приложений.

Основной вопрос, возникающий в процессе создания таких программ, — это вопрос о разделении сложной системы на составные части. Но ещё более важной проблемой является организация связи между частями.

В данной статье приведены некоторые принципы проектирования при разработке более или менее сложных проектов на Эликсире. Мы поговорим о том, как разбивать проект на более мелкие поддерживаемые микросервисы (приложения) и как создавать внутри них модули, используя «контексты».

Но всё же основное внимание будет уделено проектированию гибких интерфейсов между приложениями на Эликсире. Вы увидите, как они будут меняться в процессе перехода от простого зонтичного проекта к распределённой системе. Будут рассмотрены такие подходы, как удалённый вызов процедуры Эрланга, распределённые задачи и HTTP-протокол. И в качестве бонуса вы узнаете, как ограничить конкурентный доступ к микросервисам.

Зонтичный проект

Зонтичный проект

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

Вот этот демо-проект сегодня послужит нам в качестве примера. Проект называется ml_tools, а в полном варианте «Machine Learning Tools». Он позволяет пользователям применять к своим данным различные модели предсказания и выбирать из них наиболее подходящую. Им также предоставляется возможность выбора различных алгоритмов и визуализации результатов.

Принципы разделения проекта на несколько приложений достаточно очевидны и определяются из требований:

  • datasets —  приложение, отвечающее за операции с данными: создание, чтение и обновление.
  • utils —  набор вспомогательных сервисов для предварительной обработки и визуализации данных.
  • models  — сервис, реализующий различные алгоритмы предиктивного моделирования: линейная модель, «случайный лес», метод опорных векторов.
  • main  — приложение верхнего уровня, задействующее остальные приложения и API верхнего уровня.

Каждое приложение запускается под контролем отдельного супервизора, а следовательно, является независимым сервисом.

apps/
  datasets/
    lib/
      datasets/
        fetchers/
          fetchers.ex
          aws.ex
          kaggle.ex
        collections/
          ...
        interfaces/
          fetchers.ex 
          collections.ex
  models/
  utils/
  main/
...

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

К примеру, приложение datasets отвечает за хранение наборов данных в своей базе данных и выборку данных из других источников. Значит, приложение будет иметь две папки в директории lib/datasets: «collections» и «fetchers». В каждой из них будет присутствовать файл с расширением .ex, реализующий интерфейс контекста и другие вспомогательные модули.

Заглянем в папку lib/datasets/fetchers. В ней находится модуль Datasets.Fetchers, реализующий интерфейс для fetchers-контекста — функции, возвращающие данные с платформ AWS и Kaggle. Помимо этого, есть Datasets.Fetchers.Aws и Datasets.Fetchers.Kaggle, реализующие доступ к соответствующим источникам данных.

Такое же разделение по контекстному признаку можно осуществить и в остальных приложениях. Приложение models разбить по алгоритмам: Models.Lm (линейная модель), Models.Rf (случайный лес). Приложение utils отвечает за предварительную обработку данных (Utils.PreProcessing) и визуализацию (Utils.Visualization).

И осталось последнее приложение верхнего уровня (main), использующее все остальные микросервисы. Оно имеет несколько контекстов: Модуль Main.Zillow для конкурса от Zillow и Main.Screening для задачи по улучшению алгоритма досмотра пассажиров.

Все приложения подключены к приложению Main в списке зависимостей в Main.Mixfile:

defp deps do
  [
    {:datasets, in_umbrella: true},
    {:models, in_umbrella: true},
    {:utils, in_umbrella: true}
  ]
end

Таким образом, главному приложению доступны модули всех других.

Итак, в общем случае существуют три уровня организации кода в проектах, написанных на Эликсире:

  • Уровень сервиса —  наиболее очевидный способ разбиения сложных систем на отдельные приложения (datasets, models, utils).

  • Уровень контекста — разделение ответственности внутри того или иного сервиса с помощью реализации модулей контекста (Datasets.Fetchers, Datasets.Collections).

  • Уровень реализации —  создание нескольких модулей, определяющих функции и структуры данных (Datasets.Fetchers.Aws, Datasets.Fetchers.Kaggle).

Плюсы и минусы зонтичного проекта

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

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

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

Однако пора бы подумать и об инкапсуляции.

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

Эликсиру не хватает конструкций для должной реализации инкапсуляции. Все, что у него есть, — это модули и функции (публичные и приватные). Если указать другой проект в зависимостях, то станут доступны все его модули, а значит, и все публичные функции. Реализация подбора данных для Zillow в главном приложении будет выглядеть так:

defmodule Main.Zillow do
  def rf_fit do
    Datasets.Fetchers.zillow_data
    |> Utils.PreProcessing.normalize_data
    |> Models.Rf.fit_model
  end
end

Datasets.Fetchers, Utils.PreProcessing и Models.Rf — модули других приложений. Такое беспечное использование модулей из других приложений приведёт к объединению сервисов и превращению системы обратно в монолит.

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

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

Модули интерфейса

Интерфейсы

Задача: переместить все публичные функции приложения (функции, которые могут быть вызваны другим приложением) в отдельные модули. Например, в приложении datasets имеется специальный модуль интерфейса для функций приложения Fetchers:

defmodule Datasets.Interfaces.Fetchers do
  alias Datasets.Fetchers

  defdelegate zillow_data, to: Fetchers
  defdelegate landsat_data, to: Fetchers
end

С помощью такой простой реализации можно делегировать вызовы функций соответствующему модулю. А в будущем, если будет решено перенести запущенное приложение datasets на другую ноду, этот модуль будет содержать основную часть логики взаимодействия.

В других приложениях можно сделать то же самое, просто переписав модуль Main.Zillow:

def rf_fit do
  Datasets.Interfaces.Fetchers.zillow_data
  |> Utils.Interfaces.PreProcessing.normalize_data
  |> Models.Interfaces.Rf.fit_model
end

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

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

Переход к распределённой системе

Интерфейсные приложения

Предположим, обработка данных стала занимать слишком много времени, и было решено запускать приложение models на отдельной ноде. Получается, необходимо удалить {:models, in_umbrella: true} из списка зависимостей и запустить это приложение на другой ноде.

Запустив консоль (iex -S mix) из папки с главным приложением (main), можно увидеть, что доступ к модулям приложения models закрыт:

iex(1)> Models.Interfaces.Rf.fit_model(“data”)
** (UndefinedFunctionError) function Models.Interfaces.Rf.fit_model/1 is undefined (module Models.Interfaces.Rf is not available)

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

Но, как известно, виртуальная машина BEAM была создана для распределённых приложений, поэтому существует множество способов получить доступ к коду, запущенному на другой машине.

Модуль :rpc

Сравнительно просто запустить функцию на удалённой ноде с помощью модуля :rpc Эрланга. Для взаимодействия между нодами :rpc использует Erlang Distribution Protocol.

Можно даже провести небольшой эксперимент: запустить приложение main с помощью опции --sname main в одной вкладке терминала

iex --sname main -S mix

а проект models — в другой:

iex --sname models -S mix

Теперь можно что-нибудь посчитать:

iex(main@ip-192–168–1–150)1> :rpc.call(:”models@ip-192–168–1–150", Models.Interfaces.Rf, :fit_model, [“data”])
%{__struct__: Models.Rf.Coefficient, a: 1, b: 2, data: “data”}

Как же изменить текущий проект так, чтобы можно было воспользоваться приведённым выше способом?

Идея очень проста: нужно добавить в проект ещё одно приложение, реализующее логику взаимодействия, — models_interface.

models_interface/ 
  config/
  lib/
    models_interface/
      models_interface.ex
        lm.ex
        rf.ex
    mix.ex

Вот такая небольшая правка предоставляет main доступ к функциям Models.Interface. Пару небольших модулей просто дублируют функции модулей Interfaces:

defmodule ModelsInterface.Rf do
  def fit_model(data) do
    ModelsInterface.remote_call(Models.Interfaces.Rf, :fit_model, [data])
  end
end

Данный модуль просто вызывает функцию Models.Interfaces.Rf.fit_model/1, а реализация функции remote_call уже находится в модуле ModelsInterface:

defmodule ModelsInterface do
  def remote_call(module, fun, args, env \\ Mix.env) do
    do_remote_call({module, fun, args}, env)
  end

  def remote_node do
    Application.get_env(:models_interface, :node)
  end

  defp do_remote_call({module, fun, args}, :test) do
    apply(module, fun, args)
  end
  
  defp do_remote_call({module, fun, args}, _) do
    :rpc.call(remote_node(), module, fun, args)
  end
end

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

И последнее: нужно лишь заменить Models.Interfaces на ModelsInterface и всё готово! Только не забудьте добавить models_interface в список зависимостей главного приложения.

defp deps do
  [
    {:datasets, in_umbrella: true},
    {:models, in_umbrella: true, only: [:test]},
    {:models_interface, in_umbrella: true},
    {:utils, in_umbrella: true},
    {:espec, "1.4.6", only: :test}
  ]
end

Приложение models указано в зависимостях, но только в тестовом окружении. Это позволяет осуществлять прямые вызовы приложения в тестовом окружении.

Вот и всё. Теперь можно получить доступ к models через консоль iex:

iex(main@ip-192–168–1–150)1> ModelsInterface.Rf.fit_model(“data”)
%{__struct__: Models.Rf.Coefficient, a: 1, b: 2, data: “data”}

Итак, подведём итоги. Единственное изменение, которое необходимо было сделать, — это создание нового интерфейсного приложения. Весь код по-прежнему находится в одном месте, и все тесты пройдены.

##Распределённые задачи

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

Для динамического контроля задач в Эликсире используется специальный модуль Task.Supervisor. Он запускает супервизор внутри удалённого приложения и осуществляет надзор за задачами, исполняющими код. Воспользуемся распределёнными задачами, чтобы получить доступ к приложению datasets.

Прежде всего, добавим Task.Supervisor к списку потомков супервизора приложения datasets:

defmodule Datasets.Application do
  @moduledoc false

  use Application
  import Supervisor.Spec

  def start(_type, _args) do
    children = [
      supervisor(Task.Supervisor, 
        [[name: Datasets.Task.Supervisor]],
        [restart: :temporary, shutdown: 10000])
    ]

    opts = [strategy: :one_for_one, name: Datasets.Supervisor]
    Supervisor.start_link(children, opts)
  end
end

Модуль DatasetsInterface (отдельное интерфейсное приложение) выглядит так:

defmodule DatasetsInterface do
  def spawn_task(module, fun, args, env \\ Mix.env) do
    do_spawn_task({module, fun, args}, env)
  end

  defp do_spawn_task({module, fun, args}, :test) do
    apply(module, fun, args)
  end

  defp do_spawn_task({module, fun, args}, _) do
    Task.Supervisor.async(remote_supervisor(), module, fun, args)
    |> Task.await
  end

  defp remote_supervisor do
    {
      Application.get_env(:datasets_interface, :task_supervisor),
      Application.get_env(:datasets_interface, :node)
    }
  end
end

Будем использовать паттерн async/await. Отличие заключается в том, что задачи порождаются на удалённой ноде и контролируются удалённым супервизором, имя и расположение которого указаны в файле конфигурации:

config :datasets_interface,
       task_supervisor: Datasets.Task.Supervisor,
       node: :"models@ip-192-168-1-150"

И снова тот же трюк с тестовым окружением!

Другие протоколы

RPC и распределённые задачи — встроенные в Эрланг и Эликсир абстракции, позволяющие приложениям на Эликсире взаимодействовать без сериализации и десериализации. Но если требуется наладить связь с приложением, написанным на другом языке, то поможет более распространённый подход, такой как протокол HTTP.

В качестве примера реализуем простенький интерфейс HTTP для приложения utils. Как и в прошлый раз, сначала создаём новое приложение utils_interface:

Модуль UtilsInterface имеет похожую на ModelsInterface структуру, однако функция do_remote_call/2 выглядит так:

defp do_remote_call({module, fun, args}, _) do
  {:ok, resp} = HTTPoison.post(remote_url(),
                               serialize({module, fun, args}))
  deserialize(resp.body)
end

В данном примере будем использовать элементарную сериализацию Эрланга term_to_binary и binary_to_term:

defp serialize(term), do: :erlang.term_to_binary(term)
defp deserialize(data), do: :erlang.binary_to_term(data)

Проекту utils понадобится HTTP-сервер для прослушивания входящих запросов. Для этого воспользуемся cowboy и plug:

defp deps do
  [
    {:cowboy, "~> 1.0.0"},
    {:plug, "~> 1.0"},
    {:espec, "1.4.6", only: :test}
  ]
end

Так выглядит плаг, отвечающий за обработку запросов:

defmodule Utils.Interfaces.Plug do
  use Plug.Router

  plug :match
  plug :dispatch

  post "/remote" do
    {:ok, body, conn} = Plug.Conn.read_body(conn)
    {module, fun, args} = deserialize(body)
    result = apply(module, fun, args)
    send_resp(conn, 200, serialize(result))
  end
end

Он попросту десериализует кортеж {module, fun, args}, вызывает функцию и отправляет результат обратно клиенту.

Не забудьте запустить плаг чепез сервер cowboy в приложении utils:

children = [
  Plug.Adapters.Cowboy.child_spec(:http,  
       Utils.Interfaces.Plug, [], [port: 4001])
]

Обратите внимание, что вызывать функции напрямую из десериализованных данных не самый лучший ход. Здесь это сделано только в целях упрощения примера, на практике потребуется более продуманный метод.

Ограниченная конкурентность с poolboy

И последнее, о чём мы поговорим сегодня, поможет защитить приложение и его ресурсы от перегрузки. Представим, например, что приложение models использует слишком много памяти для подбора моделей. Соответственно, необходимо уменьшить количество клиентов, желающих получить доступ к этому приложению. Для этого на уровне интерфейса создадим ограниченный пул процессов-воркеров, используя библиотеку poolboy.

poolboy нужно запустить через супервизор приложения:

defmodule Models.Application do
  use Application

  def start(_type, _args) do
    pool_options = [
      name: {:local, Models.Interface},
      worker_module: Models.Interfaces.Worker,
      size: 5, max_overflow: 5]

    children = [
      :poolboy.child_spec(Models.Interface, pool_options, []),
    ]

    opts = [strategy: :one_for_one, name: Models.Supervisor]
    Supervisor.start_link(children, opts)
  end
end

Видно, что у poolboy есть несколько опций: имя супервизора, модуль-воркер, размер пула и максимальная загрузка.

Модуль-воркер — простой GenServer, вызывающий соответствующую функцию:

defmodule Models.Interfaces.Worker do
  use GenServer

  def start_link(_opts) do
    GenServer.start_link(__MODULE__, :ok, [])
  end

  def init(:ok), do: {:ok, %{}}

  def handle_call({module, fun, args}, _from, state) do
    result = apply(module, fun, args)
    {:reply, result, state}
  end
end

И последнее изменение касается модуля Models.Interfaces.Rf: вместо делегации функций, он будет порождать в пуле процесс-воркер:

defmodule Models.Interfaces.Rf do
  def fit_model(data) do
    with_poolboy({Models.Rf, :fit_model, [data]})
  end

  def with_poolboy(args) do
    worker = :poolboy.checkout(Models.Interface)
    result = GenServer.call(worker, args, :infinity)
    :poolboy.checkin(Models.Interface, worker)
    result
  end
end

Теперь беспокоиться не о чем: приложение models сможет обработать только ограниченное число запросов.

Заключение

И в заключение пара рекомендаций:

  • С самого начала разработки думайте в сторону микросервисов. С зонтичным проектом на Эликсире это проще, чем вы думаете.

  • Для организации логики внутри приложений создавайте модули контекста и реализации.

  • Тщательно продумывайте интерфейсы приложений. Никаких прямых вызовов реализаций функций быть не должно.

  • Масштабируя проект в распределённую систему, помещайте логику взаимодействия в отдельное приложение. Для взаимодействия между приложениями, запущенными на BEAM, используйте Erlang Distribution Protocol.

Надеемся, подходы и абстракции, описанные в данной статье, помогут вам более грамотно работать на Эликсире.

© 2020 / Россия Любые мысли и вопросы пишите на elixir@wunsh.ru.