Elixir: робимо код розширюваною за допомогою Behaviour


Отже, визначимо диспозицію… Ви написали шматочок коду, який ви хочете використовувати з великою кількістю різних "речей" — звучить не дуже науково, але все ж. Ці різні речі об'єднує якесь загальне властивість, через яке вони досягають однакового результату на високому рівні абстракції; тільки ось шляху досягнення результату можуть бути абсолютно різними.
Часто ваш код повинен використовувати тільки одну таку річ за один раз, але ви ж не хочете робити ваш код настільки вузьким? Це просто огидно. Хіба не чудово, коли інші люди зможуть створити нові "речі" і розширити ваш код, в той час як ви навіть не кассаетесь клавіатури?
Але хіба я не можу вибрати конкретну реалізацію і використовувати її? Мені більше нічого й не треба....
звичайно, Ви можете. Але що трапиться, якщо ви поміняєте свою думку про "речі", яку ви використовуєте. Раптом ваша цукерочка без обгортки виявиться не тим, чим здається? А раптом ще гірше — вашу дивну "штучку" перестануть підтримувати? В отаких жахливих умовах було б круто, якщо б ви могли швидко поміняти одне на інше, не змінюючи при цьому взагалі все, що ми написали. Прально?
Вистачить товкти воду в ступі...
Якщо ви дочитали до сюди, я думаю, ви розумієте про що я. "Речі" являють собою мільйони варіацій, але давайте перейдемо до яких-небудь адекватним прикладів з реального світу:
  • Додаток-месенджер, яке повинно розсилати email декількома різними варіантами: SMTP, Mandrill, Sendgrid, Postmark, %ВашБудущийЅааЅПродукт% і так далі. У цьому прикладі "речі" — методи доставки повідомлень. вони всі доставляють пошту, але різними способами.
  • Генератор резюме, який бере дані з 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_renderer)
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. У разі виникнення будь-яких питань пишіть в коммьюніті.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.