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

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

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

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

初探领域专用

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

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

想象一下若是你无须解析一个外部文件,直接编写普通的 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
"<div class\"row\"><h1>Domain Specific Languages</h1><article><p>Welcome!</p>
</article><a href=\"edit.html\">Edit</a></div>"

因为宏是一阶特性,所以咱们能够设想为每一个 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
"<div class\"row\"><h1>Domain Specific Languages</h1><article><p>Welcome!</p>
</article><a href=\"edit.html\">Edit</a></div>"

构建咱们的HTML库,首先我要确保可以支持这些最精简的 API。这些最精简的 API 固然不像完整的 DSL 那样整洁,但咱们仍是可以充分表达 HTML 的意图。一旦这几个初始化的宏就绪,咱们就可以用 tag 宏做为基础,支持定义所有的 117个 HTML 标签的宏了。如今咱们知道如何开始了,开干吧。post

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

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

markup do # buff = ""
  div do # buff = buff <> "<div>"
    h1 do # buff = buff <> "<h1>"
      text "hello" # buff = buff <> "hello"
    end # buff = buff <> "</h1>"
  end # buff = buff <> "</div>"
end # buff

iex> buff
"<div><h1>hello</h1></div>"

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

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

iex> buff
"<table><tr></tr></table>"

执行 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, &["<h1>Hello</h1>" | &1])
:ok

iex> Agent.get(buffer, &(&1))
["<h1>Hello</h1>"]

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

iex> Agent.get(buffer, &(&1))
["<td><3</td>", "<td><2</td>", "<td><1</td>", "<h1>Hello</h1>"]

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), "</#{unquote(name)}>"
    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), "<div>")
put_buffer(var!(buffer, Html), "<span>")
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), "</span>")
put_buffer(var!(buffer, Html), "</div>")

并不复杂,对吧?有了 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
"<table><tr><td>Cell 0</td><td>Cell 1</td><td>Cell 2</td><td>Cell 3</td>
<td>Cell 4</td><td>Cell 5</td></tr></table><div>Some Nested Content</div>"

仅仅依靠 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 块中,由于这个名称同通用的 <div> 标签冲突了。屏蔽掉 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
"<table><tr><td>Cell 0</td><td>Cell 1</td><td>Cell 2</td><td>Cell 3</td>
<td>Cell 4</td><td>Cell 5</td></tr></table><div>Some Nested Content</div>"

工做良好,咱们利用编译时的代码生成技术实现了全规格的 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), "</#{unquote(name)}>"
    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
"<div id=\"main\"><h1 class=\"title\">Welcome!</h1>
</div><div class=\"row\"><div><p>Hello!</p></div></div>"

很不错,咱们如今有了一个稳健的 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("</#{name}>")
    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 "info@example.com"
  reply_to "info@example.com"
  subject "Welcome!"
  
  def deliver(to, body) do
    send_email to: to, body: body
  end
end

UserWelcomeEmail.deliver("user@example.com", "Hello there!")

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

defmodule UserWelcomeEmail do
  use Emailer
  
  from "info@example.com"
  reply_to "info@example.com"
  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" => "info@example.com",
      "reply-to" => "info@example.com",
      "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
"<div id=\"main\">
  <h1 class=\"title\">Welcome!</h1>
</div>
<div class=\"row\">
  <div>
    <p>Hello!</p>
  </div>
</div>"
  • 去除全部的 text input 框,以防止跨域攻击:
defmodule Template do
  import Html
  def render do
    markup do
      div id: "main" do
        text "XSS Protection <script>alert('vulnerable?');</script>"
      end
    end
  end
end

iex> Template.render
"<div id=\"main\">
XSS Protection &lt;script&gt;alert(&#39;vulnerable?&#39;);&lt;/script&gt;
</div>"
相关文章
相关标签/搜索