檢視原始碼 特定領域語言 (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 的文件檔中找到有關巨集和編譯環境的有用資訊。