查看源代码 领域特定语言 (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

假设我们在名为 tests.exs 的文件中定义了 TestCase,我们可以通过运行 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 的文档中了解更多信息。你也可以在 MacroMacro.Env 的文档中找到有关宏和编译环境的有用信息。