Spec-Zone.ru › Elixir 1.17

Источник Языки, специфичные для предметной области (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.

← Предыдущая страница Макросы
Следующая страница → Введение в Mix

Скачать версию ePub

Создано с помощью ExDoc (v0.34.1) для языка программирования Elixir

© 2012-2024 The Elixir Team
Licensed under the Apache License, Version 2.0.
https://hexdocs.pm/elixir/1.17.2/domain-specific-languages.html

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API