Источник Языки предметной области (DSL)
Языки предметной области (DSL) — это языки, настроенные для определенной области применения. Вам не нужны макросы, чтобы иметь DSL: каждая структура данных и каждая функция, которую вы определяете в своём модуле, являются частью вашего языка предметной области.
Например, предположим, что мы хотим реализовать модуль Validator, который предоставляет язык предметной области для валидации данных. Мы можем реализовать его, используя структуры данных, функции или макросы. Давайте посмотрим, как будут выглядеть эти различные DSL:
# 1. Data structures import Validator validate user, name: [length: 1..100], email: [matches: ~r/@/] # 2. Functions import Validator user |> validate_length(:name, 1..100) |> validate_matches(:email, ~r/@/) # 3. Macros + modules defmodule MyValidator do use Validator validate_length :name, 1..100 validate_matches :email, ~r/@/ end MyValidator.validate(user)
Из всех вышеперечисленных подходов первый, безусловно, самый гибкий. Если наши правила области могут быть закодированы со структурами данных, они намного проще в использовании и реализации, так как стандартная библиотека Elixir содержит функции для манипулирования различными типами данных.
Второй подход использует вызовы функций, что лучше подходит для более сложных API (например, если вам нужно передать много параметров) и приятно читается в Elixir благодаря оператору конвейера.
Третий подход использует макросы и является, безусловно, самым сложным. Для его реализации потребуется больше строк кода, его сложно и дорого тестировать (по сравнению с тестированием простых функций), и он ограничивает то, как пользователь может использовать библиотеку, так как все проверки должны быть определены внутри модуля.
Чтобы прояснить этот момент, представьте, что вы хотите проверить определённый атрибут только в том случае, если выполняется определённое условие. Мы легко можем добиться этого с помощью первого решения, манипулируя структурой данных соответствующим образом, или с помощью второго решения, используя условные операторы (if/else) перед вызовом функции. Однако сделать это с помощью подхода с макросами невозможно, если его DSL не дополнен.
Другими словами:
data > functions > macros
Тем не менее, есть случаи, когда использование макросов и модулей для построения языков предметной области полезно. Поскольку мы изучили структуры данных и определения функций в руководстве для начинающих, в этой главе мы рассмотрим, как использовать макросы и атрибуты модулей для решения более сложных DSL.
Создание собственного тестового случая
Цель этой главы — создать модуль с именем TestCase, который позволит нам писать следующее:
defmodule MyTest do
use TestCase
test "arithmetic operations" do
4 = 2 + 2
end
test "list operations" do
[1, 2, 3] = [1, 2] ++ [3]
end
end
MyTest.run()
В приведенном выше примере, используя TestCase, мы можем писать тесты с помощью макроса test, который определяет функцию с именем run для автоматического выполнения всех тестов за нас. Наш прототип будет опираться на оператор совпадения (=) в качестве механизма для утверждений.
Макрос test
Начнём с создания модуля, который определяет и импортирует макрос test при использовании:
defmodule TestCase do
# Callback invoked by `use`.
#
# For now it returns a quoted expression that
# imports the module itself into the user code.
@doc false
defmacro __using__(_opts) do
quote do
import TestCase
end
end
@doc """
Defines a test case with the given description.
## Examples
test "arithmetic operations" do
4 = 2 + 2
end
"""
defmacro test(description, do: block) do
function_name = String.to_atom("test " <> description)
quote do
def unquote(function_name)(), do: unquote(block)
end
end
end
Предполагая, что мы определили TestCase в файле с именем tests.exs, мы можем открыть его, выполнив iex tests.exs, и определить наши первые тесты:
iex> defmodule MyTest do ...> use TestCase ...> ...> test "hello" do ...> "hello" = "world" ...> end ...> end
Пока у нас нет механизма для запуска тестов, но мы знаем, что за кулисами была определена функция с именем test hello. При вызове она должна завершиться неудачно:
iex> MyTest."test hello"() ** (MatchError) no match of right hand side value: "world"
Хранение информации с помощью атрибутов
Для завершения нашей реализации TestCase нам необходимо иметь возможность получать доступ ко всем определённым тестовым случаям. Один из способов сделать это — получить тесты во время выполнения с помощью __MODULE__.__info__(:functions), который возвращает список всех функций в заданном модуле. Однако, учитывая, что мы можем захотеть хранить больше информации о каждом тесте, помимо имени теста, требуется более гибкий подход.
При обсуждении атрибутов модулей в предыдущих главах мы упомянули, как они могут использоваться в качестве временного хранилища. Именно это свойство мы применим в этом разделе.
В реализации __using__/1 мы инициализируем атрибут модуля с именем @tests пустым списком, затем сохраним имя каждого определённого теста в этом атрибуте, чтобы тесты можно было вызывать из функции run.
Вот обновлённый код для модуля TestCase:
defmodule TestCase do
@doc false
defmacro __using__(_opts) do
quote do
import TestCase
# Initialize @tests to an empty list
@tests []
# Invoke TestCase.__before_compile__/1 before the module is compiled
@before_compile TestCase
end
end
@doc """
Defines a test case with the given description.
## Examples
test "arithmetic operations" do
4 = 2 + 2
end
"""
defmacro test(description, do: block) do
function_name = String.to_atom("test " <> description)
quote do
# Prepend the newly defined test to the list of tests
@tests [unquote(function_name) | @tests]
def unquote(function_name)(), do: unquote(block)
end
end
# This will be invoked right before the target module is compiled
# giving us the perfect opportunity to inject the `run/0` function
@doc false
defmacro __before_compile__(_env) do
quote do
def run do
Enum.each(@tests, fn name ->
IO.puts("Running #{name}")
apply(__MODULE__, name, [])
end)
end
end
end
end
Запустив новую сессию IEx, мы теперь можем определить наши тесты и запустить их:
iex> defmodule MyTest do ...> use TestCase ...> ...> test "hello" do ...> "hello" = "world" ...> end ...> end iex> MyTest.run() Running test hello ** (MatchError) no match of right hand side value: "world"
Хотя мы пропустили некоторые детали, это основная идея создания языков предметной области в Elixir с помощью модулей и макросов. Макросы позволяют нам возвращать цитируемые выражения, которые выполняются в вызывающей стороне, что мы можем использовать для преобразования кода и сохранения соответствующей информации в целевом модуле с помощью атрибутов модуля. Наконец, такие обратные вызовы, как @before_compile, позволяют нам внедрять код в модуль после завершения его определения.
Помимо @before_compile, существуют другие полезные атрибуты модулей, такие как @on_definition и @after_compile, о которых вы можете узнать больше в документации для Module. Вы также можете найти полезную информацию о макросах и среде компиляции в документации для Macro и Macro.Env.
© 2012-2024 The Elixir Team
Licensed under the Apache License, Version 2.0.
https://hexdocs.pm/elixir/1.18.1/domain-specific-languages.html