Elixir中的元编程-领域特定语言

  1. 前言
  2. 构建我们自己的测试用例
  3. test宏
  4. 用属性存储信息

前言

域特定语言 (DSL) 允许开发人员剪裁它们的应用到特定领域。你不需要为了有一个DSL而需要宏:在你的模块里定义的每个数据结构和每个函数是你的领域特定语言的一部分。

例如,假设我们想实现一个校验器模块,它提供一个数据校验领域特定语言。我们可以使用数据结构、函数或宏来实现它。让我们看看这些不同的DSL的样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 1. 数据结构
import Validator
validate user, name: [length: 1..100],
email: [matches: ~r/@/]

# 2. 函数
import Validator
user
|> validate_length(:name, 1..100)
|> validate_matches(:email, ~r/@/)

# 3. 宏 + 模块
defmodule MyValidator do
use Validator
validate_length :name, 1..100
validate_matches :email, ~r/@/
end

MyValidator.validate(user)

在上面所有的方法中,第一个绝对是最灵活的。如果我们的领域规则可以用数据结构进行编码,则它们是迄今为止最容易的撰写和实施,因为Elixir的标准库中充满了操纵不同数据类型的函数。

第二种方法使用函数调用,它更适合更复杂的API(例如,如果你需要传递很多选项)和Elixir里用管道运算符来更好地读。

第三种方法,使用宏,是迄今为止最复杂的。它将花更多行代码来实现,也难于测试(是和测试简单函数相比),它限制用户肯能如何使用库,因为所有校验需要被定义在一个模块里。

若要驱动该点,请假设只有在给定条件满足时才要验证某个属性。我们可以很容易地用第一种方案实现,通过操作相应结构的数据,或用第二种方案在调用函数之前用条件句(if/else)。但是,用宏的方法是不可能的,除非它的DSL被增强。

换句话说:

1
data > functions > macros

也就是说,仍然有场景使用宏和模块来构建特定于域的语言。既然我们在入门指南已经探讨了数据结构和函数定义,那么本章将探讨如何使用宏和模块的属性来处理更复杂的DSL。

构建我们自己的测试用例

本章的目标是构建一个名为 TestCase 的模块,它允许我们按如下内容来写:

1
2
3
4
5
6
7
8
9
10
11
12
13
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宏的模块开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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 打开它,并且定义我们第一个测试用例:

1
2
3
4
5
6
7
iex> defmodule MyTest do
...> use TestCase
...>
...> test "hello" do
...> "hello" = "world"
...> end
...> end

现在我们没有一种机制来运行测试,但是我们知道一个名为 “test hello” 的函数被定义在后台。当我们调用它,它应该失败:

1
2
iex> MyTest."test hello"()
** (MatchError) no match of right hand side value: "world"

用属性存储信息

为了完成我们的 TestCase 实现,我们需要能够访问所有被定义的测试用例。在运行时通过 __MODULE__.__info__(:functions) 获取测试用例来做到这点,这个函数将返回给定模块的所有函数组成的列表。但是,考虑到我们可能想要存储除了每一个测试用例名字外更多的信息,一个更灵活的方法是我们需要的。

在前面章节讨论模块属性时,我们提到了如何将它们用作临时存储。这正是我们将在本节中使用的属性。

在 __using__/1 的实现里,我们将初始化名为 @tests 的模块属性为一个空列表,然后存储每一个定义好的测试用例的名字到这个属性里,所以测试用例可以在run函数里被调用。

下面是 TestCase 模块修改后的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
defmodule TestCase do
@doc false
defmacro __using__(_opts) do
quote do
import TestCase

# 初始化 @tests 为一个空列表
@tests []

# 模块被编译前调用 TestCase.__before_compile__/1
@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
# 插入新定义的测试到测试列表
@tests [unquote(function_name) | @tests]
def unquote(function_name)(), do: unquote(block)
end
end

# 这将在目标模块被编译前被立即调用
# 给我们完美的机会注入'run/0'函数
@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,我们现在可以定义我们的测试用例并运行它们:

1
2
3
4
5
6
7
8
9
10
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 module 和 Macro.Env 文档里找到关于宏和编译环境的有用信息。

原文链接: http://elixir-lang.org/getting-started/meta/domain-specific-languages.html