Spec-Zone.ru › Elixir 1.16

Source Языки, ориентированные на конкретную предметную область (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.

Вот обновлённый код для модуля %%%CODE_BLOCK_23%%:

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.32.2) для языка программирования Elixir

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

Spec-Zone.ru

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