Elixir元编程-第五章 创建一个HTML DSL(领域专用语言)

Elixir元编程-第五章 创建一个HTML DSL

要最大限度发挥宏的威力,莫过于构建一个 DSL了(领域专用语言)。他可以让你针对应用的专用领域,为语言增加一个定制层。这可以让你的代码更易编写,对问题的解决之道也展示的更为清晰。使用 DSL,你可以直接将商业需求进行代码化,可以在一个抽象的共享层上与程序库的调用者进行互动。

我们进一步扩展前面所学,编写一个 HTML DSL。首先来看看 DSL 都要做什么。然后,我们构建一个完整的 HTML DSL,可以通过纯粹的 Elixir 代码生成模板。构建过程中,我们会学到一些宏的高阶特性以及运用。最后我们回顾下何时该用以及何时不该用 DSL,以及如何决策。

初探领域专用

在深入代码前,我们先探讨下何谓 DSL,以及元编程为何实现它如此容易。在 Elixir 中,DSL 就是通过定制宏扩展的语言定义。他们是用来解决特定领域问题,而在一门语言中扩展出来的语言。在我们的例子中,我们的领域就是做一个 HTML 生成器。

你可能在其他语言中尝试过 HTML 生成器,一般他们采用的方法是在标签中混和源代码的方式来生成 HTML 字符串,然后解析文件,计算结果。这个方法也可行,但你不得不使用一些完全不同的模板语法,没法使用纯粹的程序代码。不便之处在于你要学习另一套语法,而且要在不同语言的上下文中来回切换。

想象一下如果你无须解析一个外部文件,直接编写普通的 Elixir 代码就能表达 HTML。代码的运行结果就是生成一个完整的 HTML字符串。我们看看用宏定义的这种 HTML DSL 应该长啥样:

markup do
  div class: "row" do
    h1 do
      text title
    end
    article do
      p do: text "Welcome!"
    end
  end
  if logged_in? do
    a href: "edit.html" do
      text "Edit"
    end
  end
end
"

Domain Specific Languages

Welcome!

Edit
"

由于宏是一阶特性,因此我们可以设想为每个 HTML 标记定义一个宏,然后由此生成标签树对应的 HTML 字符串。这个例子程序就是一个彻头彻尾的 dsl。任何人只要扫一眼,就会马上明白这些代码所表达的 HTML。这个 lib 允许我们在 Elixir 语言相同的上下文中编写 HTML,可以集中精力解决感兴趣的问题。这也是我们将要设计的程序库。开始吧。

从最小可行的 API 定义开始

现在我们知道想要的 DSL 是什么样子了,我们需要决定如何设计 API。HTML 标准包含 117 个有效的标签,但我们构建 DSL 只需要很小一部分。很有可能你想立即打开编辑器,直接为所有标签编写 117 个独立的宏。但是有更好的方式。既然我们要创建 DSL,要用宏定义一门迷你语言,那么最好的方式何不为这门语言定义一个最小规模的宏定义的集合,这些宏定义又会为更大规模的宏定义提供基础。与其用宏定义所有的 HTML 规范,不如先定义一组超精简的宏,实现 HTML 的标准动作。

我们的 HTML 库最小的 API 包含一个 tag 宏,用来实现标签构造,一个 text 宏用来注入文本,一个 markup 宏用来包裹生成的所有的块。这三个宏聚焦基础,是我们应用的基石。有了这三个宏,我们就可以快速构造一个可用版本,然后不断增强。

重写前面的示例代码,这里我们只用这三个宏:

markup do
  tag :div, class: "row" do
    tag :h1 do
      text title
    end
    tag :article do
      tag :p, do: text "Welcome!"
    end
  end
  if logged_in? do
    tag :a, href: "edit.html" do
      text "Edit"
    end
  end
end
"

Domain Specific Languages

Welcome!

Edit
"

构建我们的HTML库,首先我要确保能够支持这些最精简的 API。这些最精简的 API 当然不像完整的 DSL 那样整洁,但我们还是能够充分表达 HTML 的意图。一旦这几个初始化的宏就绪,我们就能够用 tag 宏作为基础,支持定义全部的 117个 HTML 标签的宏了。现在我们知道如何开始了,开干吧。

让我们列出最小 HTML API 的功能需求。首先要能支持 markup,tag,text 宏。其次显而易见,在 markup 生成阶段,我们的程序库必须能够维护输出缓冲的状态。因为我们能够在 DSL 任意混合 Elixir 表达式,因此我们必须在程序运行时存储生成的 HTML 状态。

要理解我们的程序为什么需要可变状态(mutable state,注:函数式编程一般提倡的是不可变),我们想象一下,我们试图在每一次 tag 宏调用重新绑定缓冲变量时保持状态。下面生成的模拟代码,用 buff 变量来跟踪缓冲状态。

markup do # buff = ""
  div do # buff = buff <> "
" h1 do # buff = buff <> "

" text "hello" # buff = buff <> "hello" end # buff = buff <> "

" end # buff = buff <> "
" end # buff iex> buff "

hello

"

每一次 tag 或 text 调用时会重新绑定 buff,这种方式适用于基本处理。在我们介绍更简单的解决方案前,想象一下下面的代码,当我们加入一个 for 语句时会发生什么。

markup do # buff = ""
  tag :table do        # buff = buff <> "
    tag :tr do         # buff = buff <> ""
      for i <- 0..3 do # >------>------->----------->
        tag :td do     # | buff = buff <> "" |
      end              # <------<-------<-----------<
    end                # buff = buff <> ""
  end                  # buff = buff <> "
" | text "#{i}" # ^ buff = buff <> "#{i}" v end # | buff = buff <> "
" end # buff iex> buff "
"

执行 for 语句前一切正常,碰到 for 语句就完了。生成的状态并没有反映在缓冲数据中,所有的 td 标签都丢失了,因为变量作用域的原因,内层嵌套的绑定数据是无法释放到外部的上下文中。即便没有作用域的问题,这种变量的动态重绑定在 Elixir 的 for 语句里面也是不支持的。你可以自己试试下在 iex 里运行 for 语句,看看能不能重绑定一个变量:

iex> buff = ""
""
iex> for i <- 1..3 do
  ...> buff = buff <> "#{i}"
  ...> IO.inspect buff
  ...> end
"1"
"2"
"3"
["1", "2", "3"]
iex> buff
""

看到没,我们没法用变量重绑定来保持输出缓冲。我们必须另谋出路,解决每次 tag 或 text 调用时更新当前缓冲的问题。幸运的是,Elixir 里有个 Agent 模块,能够完美解决每次 tag 生成后刷新 buffer 的问题。

使用 Agent 保持状态

Elixir Agent 提供了一种简单的方式用于存储和获取数据状态。下面我们看下使用 Agent 进程管理状态有多简单。在 iex 里我们试下:

iex> {:ok, buffer} = Agent.start_link fn -> [] end
{:ok, #PID<0.130.0>}

iex> Agent.get(buffer, fn state -> state end)
[]

iex> Agent.update(buffer, &["

Hello

" | &1]) :ok iex> Agent.get(buffer, &(&1)) ["

Hello

"] iex> for i <- 1..3, do: Agent.update(buffer, &["<#{i}" | &1]) [:ok, :ok, :ok] iex> Agent.get(buffer, &(&1)) ["<3", "<2", "<1", "

Hello

"]

Agent模块API非常简单,聚焦于快速访问状态。上面的例子中,我们用初始状态[]启动一个 Agent。然后,往 buffer 列表中写入些字符串,然后结束更新状态。对于 HTML DSL 的输出缓冲我们采用类似存储方式。

学习了 Agent 新技能,我们编辑文件 html_step1.exs,定义 Html 模块,编写 API: html/lib/html_step1.exs

defmodule Html do

  defmacro markup(do: block) do 
    quote do
      {:ok, var!(buffer, Html)} = start_buffer([])
      unquote(block)
      result = render(var!(buffer, Html))
      :ok = stop_buffer(var!(buffer, Html))
      result
    end
  end

  def start_buffer(state), do: Agent.start_link(fn -> state end) 

  def stop_buffer(buff), do: Agent.stop(buff)

  def put_buffer(buff, content), do: Agent.update(buff, &[content | &1]) 

  def render(buff), do: Agent.get(buff, &(&1)) |> Enum.reverse |> Enum.join("") 

  defmacro tag(name, do: inner) do 
    quote do
      put_buffer var!(buffer, Html), "<#{unquote(name)}>"
      unquote(inner)
      put_buffer var!(buffer, Html), ""
    end
  end

  defmacro text(string) do 
    quote do: put_buffer(var!(buffer, Html), to_string(unquote(string)))
  end
end

第3行,我们定义了 markup 宏,他用来包裹完整的 HTML 生成块。markup 完成三个动作。首先,第13行的 start_buffer 函数开启一个 Agent。Agent 会保存一个包含所有 tag 和 text 输出的列表。然后我们注入来自调用者的代码块,代码块中包含所有的 tag 和 text 宏调用。最后调用 render 函数完成 markup 代码。第19行定义的 render 函数获取 Agent 状态然后将所有的 buffer 片段组合成最终的输出字符串。然后,我们要在结果返回前终止 Agent 进程,它的使命也完结了。

除了 markup 代码跟 Agent 函数,我们还定义了 tag 和 text 宏来完成主要的宏的功能。tag 使用 put_buffer 调用将调用者的 inner 代码块包裹起来,它会环绕 inner contents 形成一对开闭的 HTML 标签。下面我们看看一系列嵌套的 tag 如何工作的:

tag :div do
  tag: span do
    Logger.info "We can mix regular Elixir code here"
    text "Nested tags are no trouble for our buffer"
  end
end

编译时这些代码会转化成:

put_buffer(var!(buffer, Html), "
") put_buffer(var!(buffer, Html), "") Logger.info "We can mix regular Elixir code here" put_buffer(var!(buffer, Html), "Nested tags are no trouble for our buffer") put_buffer(var!(buffer, Html), "") put_buffer(var!(buffer, Html), "
")

并不复杂,对吧?有了 Agent 来保持状态, tag 宏只需要生成正确的 put_buffer 调用,确保任何嵌套的 block 都被一对开闭的标签包裹就可以了。类似的,text 宏只需要生成一个简单的 put_buffer 调用就行,它会将传入参数转化为字符串。

破坏宏卫生确实是万恶之源。使用一定要谨慎

重要提示,要认识到我们的模块从头到尾使用了 buffer 变量,这已经破坏了宏卫生。破坏宏卫生才能允许我们在每一个 quote balock 中引用派生出的 Agent 进程,因为我们直接使用了 var! 来访问外部的上下文。最重要的是,我们将 Html 作为第二个参数,因此 buffer 变量的上下文才能限制在我们的模块中。如果我们不包括 Html 参数,我们的 buffer 变量就需要暴露到调用者的上下文中,调用者也可以任意访问了。这个案例说明了是否破坏宏卫生需要权衡考虑。我们可以将状态存储隐藏到幕后,以避免调用者的 Html 上下文中定义的 buffer 变量引发的冲突。

开始测试

让我们快速构建一个 Temple 模块,来测试下这些 API 的功能。

编辑 html_step1_render.exs 文件,添加:

html/lib/html_step1_render.exs

defmodule Template do
  import Html

  def render do
    markup do
      tag :table do
        tag :tr do
          for i <- 0..5 do
            tag :td, do: text("Cell #{i}")
          end
        end
      end
      tag :div do
        text "Some Nested Content"
      end
    end
  end
end

第4行,我们随便定义了一个 render 函数,里面是 markup 生成器。在 iex 里面测试一下:

iex> c "html_step1.exs"
[Html]

iex> c "html_step1_render.exs"
[Template]

iex> Template.render
"
Cell 0Cell 1Cell 2Cell 3 Cell 4Cell 5
Some Nested Content
"

仅仅依靠 markup,tag,text 宏,我们已经生成了 HTML 字符串,幕后是使用 buffer Agent 进行状态存储(对我们都是透明的)。我们的 DSL 开口说了他的第一句话。接下来我们将支持完整的 HTML 规格,进一步优化它。

使用宏支持完整的 HTML 规格

开局完美,但我们的目标是创建一个一阶的 DSL。一个简单的 tag 宏还不足以完成。让我们支持全部有效的 117 个 HTML 标签。我们还需要手工编写上百个宏,但我们可以使用第三章的进阶技术,节约时间,简化工作。

老调重弹,我们还是到网上搜索下完整的 HTML 标签清单。将其拷贝粘贴到文本文件中,最后一个标签是行分隔符。下面摘抄文件部分内容:

html/lib/tags.txt

form
frame
frameset
h1
head
header

我们利用这个文件生成完整的 HTML 规格。将文件保存到 Html 模块的同一目录下,名字改为 tags.txt。现在修改 Html 源码,加入代码解析 tags.txt 生成宏定义。新的文件命名为 html_step2.exs。

html/lib/html_step2.exs

defmodule Html do

  @external_resource tags_path = Path.join([__DIR__, "tags.txt"])
  @tags (for line <- File.stream!(tags_path, [], :line) do
           line |> String.strip |> String.to_atom
         end)

  for tag <- @tags do
    defmacro unquote(tag)(do: inner) do
      tag = unquote(tag)
      quote do: tag(unquote(tag), do: unquote(inner))
    end
  end

  defmacro markup(do: block) do
    quote do
      import Kernel, except: [div: 2]
      {:ok, var!(buffer, Html)} = start_buffer([])
      unquote(block)
      result = render(var!(buffer, Html))
      :ok = stop_buffer(var!(buffer, Html))
      result
    end
  end
# ...
end

在第4行,我们逐行读取 tags.txt 文件,然后将其转化为 atom,存入到 @tags 属性中。然后在第8行我们使用 for 语句为每一个 tag 定义一个宏,tag 名称就是从文件中读取后转为 atom 的名称。每一个宏都只是简单的转发到 tag 宏。

还有一件重要的事就是在第17行,我们排除掉 Kernel.div,禁止 import 到我们的 markup 块中,因为这个名称同通用的

标签冲突了。屏蔽掉 Kernel.div 倒还问题不大,因为实在要引用可以加上模块名。我们再次使用了 @external_resource 以确保 Html 在 tags.txt 发生变化时可以自动重编译。

现在我们使用新的宏来渲染一些 HTML。创建一个新的 Template 模块,文件命名 html_step2_render.exs:

html/lib/html_step2_render.exs

defmodule Template do
  import Html

  def render do
    markup do
      table do
        tr do
          for i <- 0..5 do
            td do: text("Cell #{i}")
          end
        end
      end
      div do
        text "Some Nested Content"
      end
    end
  end
end

我们用新生成的宏替换掉所有的 tag 调用。在 iex 里面测试下:

iex> c "html_step2.exs"
[Html]

iex> c "html_step2_render.exs"
[Template]

iex> Template.render
"
Cell 0Cell 1Cell 2Cell 3 Cell 4Cell 5
Some Nested Content
"

工作良好,我们利用编译时的代码生成技术实现了全规格的 HTML DSL。从一个具有三个宏的 DSL ,我们扩展出一个包含上百个宏的 DSL,代码干净可维护。未来一旦 HTML 标签有了新增,我们只需编辑 tags.txt 文件就可支持最新的规格。

构建 DSL 我们已经走了很远,但工作尚未结束。让我们继续支持其他的 HTML 特性。

增强API,添加HTML属性支持

如果我们希望 HTML 库具有实用价值,我们还必须支持标签属性,比如 class 和 id。让我们扩展 DSL,以支持可选的关键字列表,在宏里它会被转化成标签属性。

示例,我们的 API 应该类似如下:

div id: "main" do
  h1 class: "title", do: text("Welcome!")
  div class: "row" do
    div class: "column" do
      p "Hello!"
    end
  end
  button onclick: "javascript: history.go(-1);" do
    text "Back"
  end
end

让我们研究下 Html 模块,添加标签属性的支持。将 tag/2 宏跟 for tag <- @tags 语句修改如下,文件存为 html_step3.exs。

html/lib/html_step3.exs

  for tag <- @tags do 
    defmacro unquote(tag)(attrs, do: inner) do
      tag = unquote(tag)
      quote do: tag(unquote(tag), unquote(attrs), do: unquote(inner))
    end
    defmacro unquote(tag)(attrs \\ []) do
      tag = unquote(tag)
      quote do: tag(unquote(tag), unquote(attrs))
    end
  end

  defmacro tag(name, attrs \\ []) do
    {inner, attrs} = Dict.pop(attrs, :do)
    quote do: tag(unquote(name), unquote(attrs), do: unquote(inner))
  end
  defmacro tag(name, attrs, do: inner) do
    quote do
      put_buffer var!(buffer, Html), open_tag(unquote_splicing([name, attrs])) 
      unquote(inner)
      put_buffer var!(buffer, Html), ""
    end
  end

  def open_tag(name, []), do: "<#{name}>" 
  def open_tag(name, attrs) do
    attr_html = for {key, val} <- attrs, into: "", do: " #{key}=\"#{val}\""
    "<#{name}#{attr_html}>"
  end

第1行我们修改 for 语句,为每个标签生成多个宏的 head。这样就可以把属性列表传递给宏。我们还额外添加了一个 tag 宏用于处理可选属性。在第24行,我们定义了一个 open_tag 函数用来处理带属性列表的 HTML 标签。会在原有的 tag 定义内分派到这个宏。这里我们也第一次使用了 unquote_splicing。

unquote_splicing 宏的行为类似于 unquote,但它不是注入单个值,而是注入一个参数列表到 AST 中。比如,下面代码是等价的:

quote do
  put_buffer var!(buffer), open_tag(unquote_splicing([name, attrs]))
end

quote do
  put_buffer var!(buffer), open_tag(unquote(name), unquote(attrs))
end

当你需要注入一个参数列表时使用 unquote_splicing 非常方便,特别是编译时这些参数长度不一的话。

我们已经可以支持标签属性,让我们在 iex 里测试下。更新 Template 模块,保存为 html_step3_render.exs。

html/lib/html_step3_render.exs

defmodule Template do
  import Html

  def render do
    markup do
      div id: "main" do
        h1 class: "title" do
          text "Welcome!"
        end
      end
      div class: "row" do
        div do
          p do: text "Hello!"
        end
      end
    end
  end
end

在 iex 里加载这些文件,渲染一下你刚刚创建的模板:

iex> c "html_step3.exs"
[Html]

iex> c "html_step3_render.exs"
[Template]

iex> Template.render
"

Welcome!

Hello!

"

很不错,我们现在有了一个稳健的 HTML DSL 了,读写都很容易。你可以用纯 Elixir 代码来编写整个 web 应用的模板了,我们的程序库还可以随着 HTML 规格变化而不断扩展。区区60余行代码,我们的 DSL 已有小成,甚至可以支持上百个宏。

但我们不会止步于此。接下来, you’ll find out ways Elixir lets us trim this footprint down even further.

遍历AST以生成更少的代码

我们的 Html 模块清晰优雅,但我们不得不生成上百个宏使其运作。有没有什么办法能够缩减代码,同时不损害 DSL 的表现力,而且拥有所有的标签宏调用呢?我们来创造奇迹吧。

你也许觉得不生成所有的 HTML 宏,DSL 根本没法用,思考下 Elixir 给了你全部 AST 的访问能力。设想下,我们打开 iex ,quote 任意一段 HTML DSL 表达式,我们看看结果。不要载入你的 Html 模块,我们就是要看看在脱离程序库的情况下原始的表达式会 quote 成啥样:

iex> ast = quote do
...> div do
...> h1 do
...> text "Hello"
...> end
...> end
...> end
{:div, [], [[do: {:h1, [], [[do: {:text, [], ["Hello"]}]]}]]}

看上去很简单,是不是?我们得到了 DSL 宏的 AST 表现形式。我们可以看到宏调用整齐的嵌入到一个三元组里面。想想看,我们不用生成所有的 HTML 标签宏,我们可以遍历 AST,一段段地将 AST 节点,比如 {:div, [] [[do: ...]]} 转化成 tag :div do ... 宏调用。事实上,Elixir已经有内建函数帮我们干这事了。

Elixir包含两个函数 Macro.prewalk/2 和 Macro.postwalk/2 可以让我们遍历 AST,一个是深度优先,一个是广度优先。让我们用 IO.inspect 监测当我们遍历 AST 时发生了什么。


iex> Macro.postwalk ast, fn segment -> IO.inspect(segment) end
:do
:do
"Hello"
{:text, [], ["Hello"]}
{:do, {:text, [], ["Hello"]}}
[do: {:text, [], ["Hello"]}]
{:h1, [], [[do: {:text, [], ["Hello"]}]]}
{:do, {:h1, [], [[do: {:text, [], ["Hello"]}]]}}
[do: {:h1, [], [[do: {:text, [], ["Hello"]}]]}]
{:div, [], [[do: {:h1, [], [[do: {:text, [], ["Hello"]}]]}]]}
{:div, [], [[do: {:h1, [], [[do: {:text, [], ["Hello"]}]]}]]}

iex> Macro.prewalk ast, fn segment -> IO.inspect(segment) end
{:div, [], [[do: {:h1, [], [[do: {:text, [], ["Hello"]}]]}]]}
[do: {:h1, [], [[do: {:text, [], ["Hello"]}]]}]
{:do, {:h1, [], [[do: {:text, [], ["Hello"]}]]}}
:do
{:h1, [], [[do: {:text, [], ["Hello"]}]]}
[do: {:text, [], ["Hello"]}]
{:do, {:text, [], ["Hello"]}}
:do
{:text, [], ["Hello"]}
"Hello"
{:div, [], [[do: {:h1, [], [[do: {:text, [], ["Hello"]}]]}]]}

如果我们看得够仔细,我们可以看到 Macro.postwalk 和 Macro.prewalk 遍历 AST 然后将每一个 segment 发送到后面的函数。我们也能清楚地看到在形如 {:text, [], ["Hello"]} 的 segments 中我们的宏调用。这些函数用来增强 AST,但这里我们只打印下内容,然后原封不动地返回结果。

让我们删除 Html 模块中的 117 个宏。我们生成遍历 AST 中看到的代码来替换它。如下更新 Html 模块,文件保存为 html_macro_walk.exs:

html/lib/html_macro_walk.exs

defmodule Html do

  @external_resource tags_path = Path.join([__DIR__, "tags.txt"])
  @tags (for line <- File.stream!(tags_path, [], :line) do
    line |> String.strip |> String.to_atom
  end)

  defmacro markup(do: block) do
    quote do
      {:ok, var!(buffer, Html)} = start_buffer([])
      unquote(Macro.postwalk(block, &postwalk/1)) 
      result = render(var!(buffer, Html))
      :ok = stop_buffer(var!(buffer, Html))
      result
    end
  end

  def postwalk({:text, _meta, [string]}) do 
    quote do: put_buffer(var!(buffer, Html), to_string(unquote(string)))
  end
  def postwalk({tag_name, _meta, [[do: inner]]}) when tag_name in @tags do 
    quote do: tag(unquote(tag_name), [], do: unquote(inner))
  end
  def postwalk({tag_name, _meta, [attrs, [do: inner]]}) when tag_name in @tags do 
    quote do: tag(unquote(tag_name), unquote(attrs), do: unquote(inner))
  end
  def postwalk(ast), do: ast 

  def start_buffer(state), do: Agent.start_link(fn -> state end)

  def stop_buffer(buff), do: Agent.stop(buff)

  def put_buffer(buff, content), do: Agent.update(buff, &[content | &1])

  def render(buff), do: Agent.get(buff, &(&1)) |> Enum.reverse |> Enum.join("")

  defmacro tag(name, attrs \\ [], do: inner) do
    quote do
      put_buffer var!(buffer, Html), open_tag(unquote_splicing([name, attrs]))
      unquote(postwalk(inner)) 
      put_buffer var!(buffer, Html), unquote("")
    end
  end

  def open_tag(name, []), do: "<#{name}>"
  def open_tag(name, attrs) do
    attr_html = for {key, val} <- attrs, into: "", do: " #{key}=\"#{val}\""
    "<#{name}#{attr_html}>"
  end
end

我们修改第11行的 markup 定义,调用 Macro.postwalk 来遍历调用者传入的代码块。18到27行,我们替换 for 语句,这个语句之前是用来产生 117 个标签宏的,现在只需调用四个 postwalk 函数。这四个函数使用基本的模式匹配,抽取 AST 片段,将其转换成正确的 HTML 标签。让我们分析下这些函数是怎么做的。

第18行的 postwalk 函数通过模板匹配到 text 宏调用的 AST 片段,返回一个 quoted 的 put_buffer 调用。参数被转化成字符串,正如我们之前步骤中的 text 宏定义。接下来,在第21行我们我们通过模板匹配 117 个 HTML 标签的 AST 片段。这里我们用了一个卫兵语句 when tag_name in @tags 来匹配 AST tuple 中的第一个元素。如果我们发现了匹配 HTML tag 的片段,我们将其转化成一个 tag 宏调用。最后,在第27行,我们添加了一个 catch-all postwalk 函数用来原样返回我们的 DSL 无需定义的部分。我们用前面学到的 Macro.to_string 来查看 postwalk 函数生成的代码。

打开 iex,载入 html_macro_walk.exs 文件,输入如下内容:

iex> c "html_macro_walk.exs"
[Html]

iex> import Html
nil

iex> ast = quote do
  ...> markup do
    ...> div do
      ...> h1 do
        ...> text "Some text"
        ...> end
      ...> end
    ...> end
  ...> end

iex> ast |> Macro.expand(__ENV__) |> Macro.to_string |> IO.puts
(
  {:ok, var!(buffer, Html)} = start_buffer([])
  tag(:div, []) do
    tag(:h1, []) do
      put_buffer(var!(buffer, Html), to_string("Some text"))
    end
  end
  result = render(var!(buffer, Html))
  :ok = stop_buffer(var!(buffer, Html))
  result
  )
:ok

我们 quoted 了一段 markup 代码块,然后使用 Macro.expand 和 Macro.to_string 来窥探下我们的 postwalk 转换生成的代码。我们可以看到 postwalk 函数正确地将 HTML 标签转换成了 tag 宏调用。全部使用特定模板匹配原始 AST,这是个非常高级的练习。工作原理可能一时会理解不了,也不要太担心。Macro.postwalk 遍历 AST,然后转换每一个片段,我们可以看到是如何匹配代码片段,如何对 117 个宏进行替换。你不会经常用到 Macro.postwalk 或者 Macro.prewalk,但这是两把利器,可以让我们无需定义 quoted 表达式中需要的每一个宏,我们只需要转换整个 AST 就行了。

现在你的 DSL 经验又升级了,我们再回顾下何时何地才需要 DSL。

用或不用 DSL?

DSL 确实很酷炫吧?会不会有种想用它解决所有问题的冲动,但是要小心,很多问题看似很适合用 DSL,但是用标准函数会解决得更好。无论何时我们试图用 DSL 解决问题,都要问自己几个问题:

  1. 解决这类问题用到的宏是否能很好的融入到 Elixir 语法中,就像 HTML 标签一样?
  2. 定义的 DSL 是有助于使用者聚焦于解决问题本身,还是正好相反?
  3. 我们程序库的使用者是否真的愿意将一大堆乱七八糟的代码注入到自己的程序中?

对这些问题没有统一的标准,很多时候都是模棱两可的。为进一步说明这些问题,我们假设要创建一个 Emailer 库。初看上去,一个 email 构成很简单,无非是 from,to,subject,send。因此我们会遇到上面的第一个问题,答案是这个问题可以很自然的用宏进行表达。顺着这个思路,我们构想程序库的 DSL 应该如下:

defmodule UserWelcomeEmail do
  use Emailer
  
  from "[email protected]"
  reply_to "[email protected]"
  subject "Welcome!"
  
  def deliver(to, body) do
    send_email to: to, body: body
  end
end

UserWelcomeEmail.deliver("[email protected]", "Hello there!")

还不赖,调用者使用 Emailer,然后围绕 email 头,如 from,reply_to 等等拥有了一套 DSL。代码可读性也不错,但现在要问第二个问题了。定义的 DSL 是有助于使用者聚焦于解决问题本身,还是正好相反?举个例子,用户这时候突然想添加一个自定义的头,比如说"X-SERVICE-ID"?因为 email 规格支持任意 header,要求很合理,可你的 DSL 立即陷入了被动。一个快速的解决方案是支持一个可选的 headers 函数,让调用者可以定制 headers:

defmodule UserWelcomeEmail do
  use Emailer
  
  from "[email protected]"
  reply_to "[email protected]"
  subject "Welcome!"
  
  def headers do
    %{"X-SERVICE-ID" => "myservice"}
  end
  
  def deliver(to, body) do
    send_email to: to, body: body
  end
end

这法子还行得通,可以现在调用者必须知道 DSL 都支持哪些 headers,何时需要自己再定义一个 headers map。现在我们看下没有 DSL 的解决方案。只需要调用者定义一个 headers 函数,然后返回一个需要的所有的 email headers 的 map 就行了。

defmodule UserWelcomeEmail do
  use Emailer
  
  def headers do
    %{"from" => "[email protected]",
      "reply-to" => "[email protected]",
      "subject" => "Welcome!",
      "X-SERVICE-ID" => "myservice"}
  end
  
  def deliver(to, body) do
    send_email to: to, body: body
  end
end

这个例子中,传统方案明显胜出。代码清晰,可读行良好,完全不需要 DSL。

第三个问题让我们思考调用者是否真的想要将一大堆代码注入到自己的模块中。有时答案很明确,确实需要,而有时为了实现一个小小的功能,比如发送邮件,就将一大堆宏跟代码注入到你的模块中,未免小题大做。这容易引发同用户的代码冲突,也会增加问题复杂性,这时候使用传统的函数会是更好的解决方案。

基于这些判断,Emailer 库不会是一个好的 DSL。直接了当的函数更易使用,为了发送个邮件信息,还要学套特定的 DSL 语法得不偿失。DSL 确实很强大,但你要好好考虑你的特定问题是否适合用它解决。很多时候 DSL 语法很简洁,但有时这就会变成一种限制。用不用它要具体分析,每次你想用 DSL 的时候都问下自己上面的三个问题,会让你头脑清醒的多。

进一步探索

使用 DSL 在语言当中定义一种语言,我们现在元编程技能暴涨。这种解决问题的方式,将问题化解成一堆非常自然的宏,会让你创建更富有表现力的程序库。你看到了某些领域,比如 HTML 生成,就完美地融入到 DSL 中,而另外一些就需要仔细权衡了。琢磨一下你可以给 HTML DSL 再扩展些什么。这有一些意见可供参考:

  • 扩展 Html ,提供格式化良好的输入:
iex> Template.render
"

Welcome!

Hello!

"
  • 去除所有的 text input 框,以防止跨域攻击:
defmodule Template do
  import Html
  def render do
    markup do
      div id: "main" do
        text "XSS Protection "
      end
    end
  end
end

iex> Template.render
"
XSS Protection <script>alert('vulnerable?');</script>
"

转载于:https://my.oschina.net/u/4130622/blog/3065664

你可能感兴趣的:(Elixir元编程-第五章 创建一个HTML DSL(领域专用语言))