特定于域的语言 | Domain-specific languages
特定于域的语言
前言
特定于域的语言(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
在一个named文件中定义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
。