Elixir: делаем код расширяемым с помощью Behaviour

в 1:32, , рубрики: Elixir, Elixir OTP, Erlang/OTP, Extensibility, functional programming, patterns, функциональное программирование

Elixir: делаем код расширяемым с помощью Behaviour - 1

Итак, определим диспозицию… Вы написали кусочек кода, который вы хотите использовать с большим количеством разных "вещей" — звучит не очень научно, но всё же. Эти разные вещи объединяет какое-то общее свойство, через которое они достигают одинакового результата на высоком уровне абстракции; только вот пути достижения результата могут быть совершенно разными.

Часто ваш код должен использовать только одну такую вещь за раз, но вы же не хотите делать ваш код настолько узким? Это просто отвратительно. Разве не замечательно, когда другие люди смогут создать новые "вещи" и расширить ваш код, в то время как вы даже не кассаетесь клавиатуры?

Но разве я не могу выбрать конкретную реализацию и использовать её? Мне больше ничего и не надо....

Вы конечно можете. Но что случится, если вы поменяете своё мнение о "вещи", которую вы используете. Вдруг ваша конфетка без обёртки окажется не тем, чем кажется? А вдруг ещё хуже — вашу дивную "штучку" перестанут поддерживать? В этаких ужасных условиях было бы круто, если бы вы могли быстро поменять одно на другое, не меняя при этом вообще всё что мы написали. Прально?

Хватит толочь воду в ступе...

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

  • Приложение-мессенджер, которое должно рассылать email несколькими различными вариантами: SMTP, Mandrill, Sendgrid, Postmark, %ВашБудущийSaaSПродукт% и так далее. В этом примере "вещи" — методы доставки сообщений. они все доставляют почту, но разными способами.

  • Генератор резюме, который берёт данные из web-формы, а рендерит, её в HTML, PDF, Markdown, LaTeX, %ВотТутНовыйФорматВашейБабушки% и так далее. "Вещи" здесь — разные форматы документы, они все на вход принимают одинаковые данные, но все делают разные вещи чтобы достичь результата в виде документа, которые может брать ваш пользователь.

  • Движок хранения данны, который принимает данные и их хранит в базе данных: PostgreSQL, MySQL, SQLite и так далее. В этом случае "вещь" — база данных, они все могут принимать запросы, но каждая из них по разному обрабатывает эти запросы. Я, кстати, только что описал Ecto.

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

Behaviour в Elixir

Подсказка в названии. Чтобы взаимодействовать с несколькими вещами, как если бы они были одно и то же: мы должны определить их общее поведение как абстракцию. И это как раз то, что делает Behaviour в Elixir: определение подобной абстракции. Всякий Behaviour существует как спецификация либо инструкция, позволяя другим модулям следовать этим инструкциям и таким образом поддерживать Behaviour. Это позволяет остальному коду заботится только об общем интерфейсе. Хотите поменять сервисы? На здоровье, вызывающий код ничего не заметит.

Но как это всё выглядит? Behaviour определён как обычный модуль, внутри которого вы можете обозначить группу спецификаций для функций, который должны быть реализованы в модуле, который поддерживает этот Behaviour. Какждая такая спецификация определена с помощью директивы @callback и сигнатуры typespec, которая позволяет задать что конкретно принимает и отдаёт каждая функция. Выглядит так:

defmodule Parser do
  @callback parse(String.t) :: any
  @callback extensions() :: [String.t]
end

Все модули, которые хотят поддержать это Behaviour должны:

  • Явно подтвержить своё желание директивой @behaviour Parser;

  • Реализовать метод parse/1, которй принимает строку и возвращает любой терм;

  • Реализовать метод extensions/0 который принимает ничего и возвращает список строк.

Использование Behaviour — явное, поэтому все модули, которые поддерживают Behaviour должны подтвердить это используя атрибут @behaviour SomeModule. Это очень удобно — вы можете положится на компилятор, он проверит, что ваши модули не соответствуют спецификации. Поэтому если вы обновите Behaviour, то вы можете быть уверены, что компилятор на вашей стороне — он удостоверится что все модули поддерживающие его должны быть обновлены тоже.

Ныряем глубже

Если вы ещё не совсем поняли что я имею ввиду — вам может помочь пример других языков. Если вы любитель Python, то вот этот пост — хорошее обьяснение паттерна в целом. Если вы из Ruby — почитайте его тоже — философия в общем-то такая же (унаследовать базовый адаптер и надеятся на лучшее, хехе). Ну а для любителей Go — много общего у этого дела с интерфейсами.

Должен сказать, что гораздо проще объяснить, как Behaviour может помочь писать расширяемый код, на живом примере, поэтому идти глубже мы будем с примером про email. Рассмотрим библиотеку Swoosh, которая использует Behaviour для определения стека методов по доставке писем, причём пользовать сам может добавить ещё один метод и использовать его.

Определение публичного договора

Мы так долго обсуждали зачем, поэтому давайте сразу посмотрим на библиотеку, а именно на Swoosh.Adapter

defmodule Swoosh.Adapter do
  @moduledoc ~S"""
  Specification of the email delivery adapter.
  """

  @type t :: module

  @type email :: Swoosh.Email.t

  @typep config :: Keyword.t

  @doc """
  Delivers an email with the given config.
  """
  @callback deliver(email, config) :: {:ok, term} | {:error, term}
end

Как вы можете видет, пример немножко длиннее чем в документации, потому что Swoosh определяет все используемые типы для дополнительного удобства чтения и прозрачности кода (config используется как ссылка на Keyword.t, просто потому что так более понятно). Но нам на типы в общем то всё равно, мы заботимся только о директиве @callback, которая как раз и определяет правила для одной и единственной функции в этой абстракции: доставить письмо. Определение deliver/2 рассказывает нам, что:

  • функция принимает два аргумента: структуру типа Swoosh.Email и конфигурацию в виде списка ключевых слов;

  • функция что-то делает;

  • возвращает значения в привычном для Elixir кортеже ok/error.

Поддерживаем Behaviour

Самое время определить делаем что-то. Мы возбмём и посмотрим на два адаптера, которые поддерживают behaviour, и входят в "батарейки" к Swoosh. Для начала посмотрим на простой — Local клиент, который доставляет письма прямиком в память.

defmodule Swoosh.Adapters.Local do

  @behaviour Swoosh.Adapter

  def deliver(%Swoosh.Email{} = email, _config) do
    %Swoosh.Email{headers: %{"Message-ID" => id}} = Swoosh.InMemoryMailbox.push(email)
    {:ok, %{id: id}}
  end
end

Тут в общем-то и обсуждать нечего. Для начала адаптер явно указывает, что поддерживает Swoosh.Adapter Behaviour. Затем, определяется функция deliver/2, которая имеет точно такую сигнатуру, которая определена в договоре. Вот такое вот явное определение позволяет компилятору делать за нас всю грязную работу. Если ребята, которые делают Swoosh решат добавить ещё одну функцию в спецификацию, то все поддерживающие модули так же должны будут измениться, а иначе приложение просто не скомпилируется. потрясающая безопастность!

Ещё один клиент, который посылает письма через Sendgrid — слишком большой чтобы ешо исходный код сюда копировать, но вы можете посмотреть его на GitHub. Вы заметите, что модуль гораздо сложнее, и определяет большое количество функций кроме той, которые должны быть обязательно: deliver/2. Это потому, что для Behaviour не важно, сколько там лишних функций — они не должны совпадать 1:1. Это позволяет более сложным модулям вызывать другие определённые функции в тех самых, определённых в контракте, что улучшает читабельность и чистоту кода.

Добавляем в код щепотку гибкости

Мы уже узнали, как определять "контракт" Behaviour, и даже как поддерживать его в модулях, но как это поможет нам, когда мы захотим использовать их в вызывающем коде? Есть несколько путей реализации этой задумки, все умеют разную сложность. Начнём с простого.

Dependency injection с помощью заголовка функции

Вернёмся к нашему примеру Parser прямиком из доков:


defmodule Document do
  @default_parser XMLParser

  defstruct body: ""

  def parse(%Document{} = document, opts // []) do
    {parser, opts} = Keyword.pop(opts, :parser, @default_parser)
    parser.parse(document.body)
  end 
end

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

Document.parse(document)

В коде выше мы передаём только один аргумент — без options. Это приводит к тому, что доступ к данным с ключом :parser в вызове Keyword.pop не сработвет, и вернёт нам простой @default_parser, который был определён в атрибуте модуля. После этого функция parse/1 просто вызовет этот же метод у нашего парсера, передавая туда строку body.

Супер, а как насчёт XMLParser? Вуаля!

Document.parse(document, parser: JSONParser)

Так как и XMLParser и JSONParser поддерживают Parser Behaviour, они оба имеют реализацию функции parse/1. И вызывая эту функцию в врапере, мы можем очень быстро и просто делать dependency injection нужного нам парсера.

Такой способ управления зависимостями очень мощный. Он даже позволяет разным частям приложения использовать, к примеру, разные парсеры. Однако есть и минусы. При использовании этого метода вы должны доверить пользователью знания о том, как и где происходит dependency injection, что потребует более развёрнутой документации. Более того, что если пользователь захочет использовать разныне зависимости в разных окружениях? Вашему коду каждый раз придётся решать в рантайме какой модуль использовать и когда. Не лучше ли задать это заранее и забыть о нём?

Dependency injection с помощью Mix Сonfig

Благодаря Mix — это даже не проблема. Посмотрим на пример Parser опять:

defmodule Document do
  @default_parser XMLParser

  defstruct body: ""

  def parse(%Document{} = document) do
    config = Application.get_env(:parser_app, __MODULE__, [])
    parser = config[:parser] || @default_parser
    parser.parse(document.body)
  end 
end

В этом примере parse/1 больше не принимает никаких опций. Зато тип парсера вычисляется прямиком из конфигурации OTP Application.

Например, конфигурационный файл может выглядеть так:

# config/config.exs
config :parser_app, Document,
  parser: JSONParser

наш Document.parse враппер будет знать, что надо использовать JSONParser для парсинга. Всё это служит для нас отличную службу, так как выбор адаптера больше не привязан к вызывающему коду, и поэтому может быть изменён в Mix config, либо конфиг, зависящий от окружения может выбрать наш парсер в будущем. Опять же, и такой подход имеет свои минусы: конфигурация сильно привязана к модулю Document, потому что использует MODULE (имя модуля) в конфиге. Это обозначает, что мы уходим от возможности использования нескольких парсеров, только потому что везде в коде мы используем захардкоженый модуль Document. Конечно, в большинстве случаев одного адаптера достаточно для всего проекта, но если вдруг понадобится больше? К примеру одна часть кода будет посылать письма через Sendgrid, а другая его часть будет требовать поддержки устаревшего SMTP сервера. Что же, вернёмся к Swoosh...

Достигаем преимущества обоих подходов

К счастью для на, Swoosh повторяет подход Ecto к этой проблеме. Вы, как программист, должны опредлить собственный модуль где-либо в коде, который будет потом использовать Swoosh через use Swoosh.Mailer. Ваш вызывающий код потом будет использовать этот модуль как враппер для импортированного Swoosh.Mailer. К сожалению, детали работы макросов вынесены за рамки статьи., но по простому: макрос use заставляет Elixir вызвать макрос с именем using в импортируемом модуле. Можете посмотреть как выполнен этот макрос Swoosh.Mailer.using непосредственно в репе.

По сути, это значит, что конфигурация Swoosh находится сразу в двух местах:

# In your config/config.exs file
config :sample, Sample.Mailer,
  adapter: Swoosh.Adapters.Sendgrid,
  api_key: "SG.x.x"

# In your application code
defmodule Sample.Mailer do
  use Swoosh.Mailer, otp_app: :sample
end

Структурируя код таким образом, мы можем достигнуть того, что каждый модуль имеет свой собственый участок настроек в Mix config. Каждый отдельный модуль должен use Swoosh.Mailer для того, чтобы они были настроены по разному.

… и всё! Теперь вы знаете как создать публично-расширяемый код. Но перед тем как закончить, ещё пара слов...

Больше примеров

Чтение существующего кода поможет усвоить получееные в статье знания. Начать можно с:

  • Plug — спецификация для расширяемых web приложений является сама по себе Behaviour. Когда кто-то создаёт plug, по сути они поддерживают в своём модуле Plug Behaviour, который очень прост: модуль должен поддерживать две функции: init/1 и call/2. Такой подход позволяет выстраивать цепочки из плагов, как в Phoenix.

  • Ecto — использует Behaviour в миллиарде мест, начиная от хранилищ и их адаптеров, заканчивая соединениями с БД, расширениями, миграциями и самих репозиториев.

Парочка вещей на посмотреть

Подводя итоги: преимущества такого подхода в том, что он позволяет писать слабосвязанный код, который подчиняется каким-либо публичным контрактам. Благодаря этому разработчики могут расширять существующую функциональность просто явно определяя расширение, которые появляются в ходе работы. Тот факт, что контракт определён явно, позволяет гораздо проще тестировать, без всяких там "mocking as a verb".

Как уже было сказано, такой подход работает далеко не для всех ситуаций. Определяя стандартный набор отношений между всеми плагинами вы как бы заставляете плагины иметь схожую функциональность, а это не всегда применимо: бывают ситуации когда код настолько разный, что под него нельзя подвести общий делитель. Для дальнейшего чтения я бы посоветовал код проекта Ecto, в особенности те участки, в которых подводится базис под доступ к различным базам данных, например DDL транзакции и как они сделаны в Behaviour.

Эпилог

Пост неожиданно получился просто огромным. В любом случае спасибо, что дошли до сюда, и может быть даже нажали на парочку ссылок. В любом случае, без стеснения пишите мне на email, или подпиывайтесь на мой Twitter, или каким нибудь другим способом распространяйте заразу любви к Elixir!.

Спасибо Baris за то что читал и проверял.

От переводчика

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

Автор: Virviil

Источник

Поделиться новостью

* - обязательные к заполнению поля