查看源代码 领域特定语言 (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
的文档中了解更多信息。你也可以在 Macro
和 Macro.Env
的文档中找到有关宏和编译环境的有用信息。