Elixir元编程-第三章 编译时代码生成技术进阶
注:本章内容来自 Metaprogramming Elixir 一书,写的非常好,强烈推荐。内容不是原文照翻,部分文字采取意译,主要内容都基本保留,加上自己的一些理解描述。为更好理解,建议参考原文。
基于外部数据源生成函数
Elixir中如何处理Unicode
UnicodeData.txt 文件包含了27000行描述Unicode代码的映射,内容如下:
00C7;LATIN CAPITAL LETTER C WITH CEDILLA;Lu;0;L;0043 0327;...
00C8;LATIN CAPITAL LETTER E WITH GRAVE;Lu;0;L;0045 0300;...
00C9;LATIN CAPITAL LETTER E WITH ACUTE;Lu;0;L;0045 0301;...
00CA;LATIN CAPITAL LETTER E WITH CIRCUMFLEX;Lu;0;L;0045 0302;...
00CB;LATIN CAPITAL LETTER E WITH DIAERESIS;Lu;0;L;0045 0308;...
...
String.Unicode模块在编译时读取这个文件,将每一行描述转化成函数定义。最终将包含所有的大小写转换以及其他一些字符转换函数。
我们看下这个模块是如何处理大写转换的:
defmodule String.Unicode do
...
def upcase(string), do: do_upcase(string) |> IO.iodata_to_binary
...
defp do_upcase("é" <> rest) do
:binary.bin_to_list("É") ++ do_upcase(rest)
end
defp do_upcase(" ć " <> rest) do
:binary.bin_to_list(" Ć ") ++ do_upcase(rest)
end
defp do_upcase("ü" <> rest) do
:binary.bin_to_list("Ü") ++ do_upcase(rest)
end
...
defp do_upcase(char <> rest) do
:binary.bin_to_list(char) ++ do_upcase(rest)
end
...
end
编译后的 String.Unicode 模块将包含上万个这种函数定义。 当我们需要转换 "Thanks José!" 成大写时,通过模式匹配,调用函数 do_update("T" <> rest),然后递归调用 do_upcase(rest),直到最后一个字符。 整个架构简单干脆,我们接下来看看将这种技术用于 MIME 类型解析。
仅仅十行代码的 MIME-Type 转换
在 web 程序中,我们经常需要校验和转换 MIME 类型,然后将其对应到合适的文件扩展名。比如当我们请求 application/javascript 时,我们需要知道如何处理这种 MIME 类型,然后正确的渲染 .js 模板。大多数语言中我们采用的方案是存储所有的 MIME 数据映射,然后构建一个 MIME-type 转换的关键字存储。面对庞大的数据类型,我们不得不手工编写代码进行格式,这项工作索然乏味。在 Elixir 中这可简单多了。
使用现有的数据集
我们采用的方法非常简洁。我们获取公开的 MIME-type 数据集,然后自动生成转换函数。所有代码仅需十多行,代码快速易维护。
我们首先获取一份 MIMIE-type 的数据集,在网上很容易找到。内容如下:
文件:advanced_code_gen/mimes.txt
---------------------------
application/javascript .js
application/json .json
image/jpeg .jpeg, .jpg
video/jpeg .jpgv
完整的 mimes.txt 包含 685 行,内容都是 MIME 类型到文件名的映射。每一行两个字段,中间用 tab 分隔,如果对应多个扩展名,则再用逗号分隔。我们先创建一个 Mime 模块。
文件:advanced_code_gen/mime.exs
--------------------------------
defmodule Mime do
for line <- File.stream!(Path.join([__DIR__, "mimes.txt"]), [], :line) do
[type, rest] = line
|> String.split("\t")
|> Enum.map(&String.strip(&1))
extensions = String.split(rest, ~r/,\s?/)
def exts_from_type(unquote(type)), do: unquote(extensions)
def type_from_ext(ext) when ext in unquote(extensions), do: unquote(type)
end
def exts_from_type(_type), do: []
def type_from_ext(_ext), do: nil
def valid_type?(type),
do: exts_from_type(type)
|> Enum.any?
end
就通过这十多行代码,我们创建了一个完整的 MIME-type 转换跟校验模块。
首先逐行读取 mimes.txt 文件,将每行拆分成两部分,对于扩展名再用逗号继续拆分。然后基于解析定义两个函数,一个用于从 MIME 类型到扩展名映射,一个用于从扩展名到 MIME 类型映射。我们使用标准的 def 宏定义函数,使用 unquote 注入 MIME 跟扩展名。最后,我们还需要定义能捕获所有参数的两个函数 exts_from_type 、type_from_ext,这两个函数是最后的守门员,用来捕捉漏网之鱼。最后还有一个 valid_type? 函数,他简单的利用前面定义的函数进行校验。下面在 iex 测试一下:
iex> c "mime.exs" Line 1
[Mime]
iex> Mime.exts_from_type("image/jpeg")
[".jpeg", ".jpg"]
iex> Mime.type_from_ext(".jpg")
"image/jpeg"
iex> Mime.valid_type?("text/html")
true
iex> Mime.valid_type?("text/emoji")
false
功能完美。代码中需要注意的一点,你可能会很诧异我们为什么能在 quote 代码块外面调用 unquote。Elixir 支持 unquote 片段,使用 unquote 片段我们可以动态地定义函数,正如前面的代码所示:
def exts_from_type(unquote(type)), do: unquote(extensions)
def type_from_ext(ext) when ext in unquote(extensions), do: unquote(type)
我们使用 unquote 片段定义了 exts_from_type 和 type_from_ext 函数的多个子句,我们还可以动态定义函数名哦。示例如下:
iex> defmodule Fragments do
...> for {name, val} <- [one: 1, two: 2, three: 3] do
...> def unquote(name)(), do: unquote(val)
...> end
...> end
{:module, Fragments, ...
iex> Fragments.one
1
iex> Fragments.two
2
使用 unquote 片段,我们可以将任意 atom 传给 def,用它做函数名来动态的定义一个函数。在本章余下的内容中,我们还会大量的使用 unquote 片段。
很多美妙的解决方案往往让你一眼看不穿。使用大量的小块代码,我们可以更快速地构建任意 web 服务,代码维护性还很好。如要要增加更多的 MIME-type ,我们只需要简单的编辑一下 mimes.txt 文件。代码越短,bug越少。定义多个函数头,把繁重的工作丢给 VM 去匹配解析 ,我们偷着乐吧。
接下来,我们通过构建一个国际化语言的 lib 来进一步学习。 首先我们先交代一下另外一个背景知识。
当外部资源变化时,自动重编译模块
我们的 Mime 模块工作地很好,但是一旦 mimes.txt 文件发生变化,我们的模块是不会通过 mix 自动编译的。这是因为程序源码并没有发生变化。Elixir 提供了一个模块属性 @external_resource 用来处理这种情况,一旦资源变化,模块将自动编译。我们在 Mime 里面注册一个 @external_resource:
文件: advanced_code_gen/external_resource.exs
---------------------------------------
defmodule Mime do
@external_resource mimes_path = Path.join([__DIR__, "mimes.txt"])
for line <- File.stream!(mimes_path, [], :line) do
end
现在只要 mimes.txt 修改了,mix 会自动重新编译 Mime 模块。@external_resource 是一个累加属性(accumulated attribute),它会把多次调用的参数不断累积,都汇聚到一起。如果你的代码需要依赖一个非代码的资源文件,就在模块的body内使用他。这样一旦有需要代码就会自动重新编译,这会帮上大忙,节约我们很多的时间。
构建一个国际化语言的库
几乎所有用户友好的程序都需要支持语言国际化,为世界上不同国家的人提供不同的语言界面。让我们用比你想象中少的多的代码来实现这个功能。
第一步:规划你的 Macro API
我们要构建一个 Translator 程序,我们先琢磨下怎么设计 macro 的接口API。我们称之为说明书驱动开发。其实这有利于我们梳理目标,规划 macro 的实现。我们的目标是实现如下 API。文件存为 i18n.exs
文件:advanced_code_gen/i18n.exs
------------------------------
defmodule I18n do
use Translator
locale "en",
flash: [
hello: "Hello %{first} %{last}!",
bye: "Bye, %{name}!"
],
users: [
title: "Users",
]
locale "fr",
flash: [
hello: "Salut %{first} %{last}!",
bye: "Au revoir, %{name}!"
],
users: [
title: "Utilisateurs",
]
end
最终代码调用格式如下:
iex> I18n.t("en", "flash.hello", first: "Chris", last: "McCord")
"Hello Chris Mccord!"
iex> I18n.t("fr", "flash.hello", first: "Chris", last: "McCord")
"Salut Chris McCord!"
iex> I18n.t("en", "users.title")
"Users"
任何模块只要 use Translator
后,就可以包含一个翻译字典和一个 t/3 函数。那么我们肯定需要定义一个 __using__
宏,用来 import 模块,以及包含一些属性,然后还需要一个 locale 宏用来处理 locale 注册。回到键盘上,让我们开干。
第二步:利用元编程 Hooks 实现一个模块骨架
实现一个 Translator 骨架,需要定义 using,before_compile,和 locale 宏。这个库只是简单地设置编译时 hooks 以及注册模块属性,至于生成代码部分稍后再做。首先定义一个元编程骨架是一种很好的思维模式,我们先把复杂的代码生成丢到脑后,单纯地来思考模块组织。这有利于我们保持代码的清晰和可复用。
创建 translator.exs 文件,加入骨架 API:
advanced_code_gen/translator_step2.exs
----------------------------------------
defmodule Translator do
defmacro __using__(_options) do
quote
do
Module.register_attribute __MODULE__,
:locales,
accumulate: true,
persist: false
import unquote(__MODULE__), only: [locale: 2]
@before_compile unquote(__MODULE__)
end
end
defmacro __before_compile__(env) do
compile(Module.get_attribute(env.module, :locales))
end
defmacro locale(name, mappings) do
quote bind_quoted: [
name: name,
mappings: mappings
] do
@locales {name, mappings}
end
end
def compile(translations) do
# TBD: Return AST for all translation function definitions
end
end
在前面几章的 Assertion 模块中我们定义过一个累加属性 @tests,这里我们也定义了一个累加属性 @locales。然后我们在 Translator.using 宏中激活 before_compile hook。这里我暂时放个 compile 空函数占位,此函数将根据 locale 注册信息进行代码生成,内容随后填充。最后我们再定义 locale 宏,用来注册locale ,以及文本翻译对照表,这些东西随后在 before_compile hook 调用的compile 中会用到。
我们注册的累加属性激活后,我们就有了足够的信息来生成 t/3 函数的 AST 结构。如果你喜欢递归,哈哈正逢其时。不喜欢,那就要下点功夫,我们细细讲。
第三步:基于累加模块属性生成代码
我们开始填充 compile 函数,实现将 locale 注册信息转换成函数定义。我们最终要实现将所有的翻译条目映射到一堆庞大的 t/3 函数的 AST 中。我们需要添加一个 catch-all 子句作为守夜人,用来处理所有未被翻译收纳的内容,它将返回 {:error,:no_translation}。
修改 compile/1 函数内容如下:
advanced_code_gen/translator_step3.exs
----------------------------------------
def compile(translations) do
translations_ast = for {locale, mappings} <- translations do
deftranslations(locale, "", mappings)
end
quote do
def t(locale, path, bindings \\ [])
unquote(translations_ast)
def t(_locale, _path, _bindings), do: {:error, :no_translation}
end
end
defp deftranslations(locales, current_path, mappings) do
# TBD: Return an AST of the t/3 function defs for the given locale
end
compile 函数用来处理 locale 代码生成。使用 for 语句读取 locale,生成函数的 AST 定义,结果存入 translations_ast,此参数随后用于代码注入。这里我们先放个 deftranslations 占位,此函数用来实现 t/3 函数的定义。最后6-10行结合 translations_ast 参数,为调用者 生成AST,以及定义一个 catch-all 函数。
在最终实现 deftranslations 前,我们在iex 查看下:
iex> c "translator.exs"
[Translator]
iex> c "i18n.exs"
[I18n]
iex> I18n.t("en", "flash.hello", first: "Chris", last: "McCord")
{:error, :no_translation}
iex> I18n.t("en", "flash.hello")
{:error, :no_translation}
一切都如预期。任何 I18n.t 的调用都将返回 {:error, :no_translation},因为我们现在还没有为 locale 生成对应函数。我们只是验证了 catch-all t/3 定义是工作正常的。让我们开始实现 deftranslations ,递归遍历 locales,然后生成翻译函数。
修改 deftranslations 如下:
advanced_code_gen/translator_step4.exs
defp deftranslations(locale, current_path, mappings) do
for {key, val} <- mappings do
path = append_path(current_path, key)
if Keyword.keyword?(val) do
deftranslations(locale, path, val)
else
quote do
def t(unquote(locale), unquote(path), bindings) do
unquote(interpolate(val))
end
end
end
end
end
defp interpolate(string) do
string # TBD interpolate bindings within string
end
defp append_path("", next), do: to_string(next)
defp append_path(current, next), do: "#{current}.#{next}"
我们首先将 mappings 中的键值对取出,然后检查 value 是不是一个 keyword list。因为我们的翻译内容可能是一个嵌套的列表结构,正如我们在之前原始的高阶 API 设计中所见。
flash: [
hello: "Hello %{first} %{last}!",
bye: "Bye, %{name}!"
],
关键字 :flash 指向一个嵌套的 keyword list。处理办法,我们将 "flash" 追加到累加变量 current_path 里面,这个变量在最后两行的 append_path 辅助函数中会用到。然后我们继续递归调用 deftranslations, 直到最终解析到一个字符串文本。我们使用 quote 为每一个字符串生成 t/3 函数定义,然后使用 unquote 将对应的 current_path(比如"flash.hello") 注入到函数子句中。t/3 函数体调用一个占位函数 interpolate,这个函数随后实现。
代码只有寥寥数行,不过递归部分略微烧脑。我们可以在 iex 里做下调试:
iex> c "translator.exs"
[Translator]
iex> c "i18n.exs"
[I18n]
iex> I18n.t("en", "flash.hello", first: "Chris", last: "McCord")
"Hello %{first} %{last}!"
我们确实做到了。我们的 t/3 函数正确生成了,我们只不过做了些简单的变量插值就完成了这个库。你可能会琢磨我们程序生成的代码又如何跟踪呢,不用担心,Elixir为我们想好了。当你生成了一大堆代码时,一般来说我们只需要关心最终生成的代码,我们可以使用 Macro.to_string。
Macro.to_string:理解你生成的代码
Macro.to_string 读取 AST,然后生成 Elixir 源码文本。这个工具在调试生成的 AST 时非常的强大,尤其在创建大批量的函数头时非常有用,就像我们上面的 Translator 模块。让我们观察一下 compile 函数生成的代码。
修改 Translator 模块:
advanced_code_gen/macro_to_string.exs
def compile(translations) do
translations_ast = for {locale, mappings} <- translations do
deftranslations(locale, "", mappings)
end
final_ast = quote do
def t(locale, path, binding \\ [])
unquote(translations_ast)
def t(_locale, _path, _bindings), do: {:error, :no_translation}
end
IO.puts Macro.to_string(final_ast)
final_ast
end
第6行,我们将生成的结果 AST 存入 final_ast 绑定。第12行,使用 Macro.to_string 将 AST 展开成源码文本后输出。最后将 final_ast 返回。启用 iex 调试:
iex> c "translator.exs"
[Translator]
iex> c "i18n.exs"
(
def(t(locale, path, bindings \\ []))
[[[def(t("fr", "flash.hello", bindings)) do
"Salut %{first} %{last}!"
end, def(t("fr", "flash.bye", bindings)) do
"Au revoir, %{name}!"
end], [def(t("fr", "users.title", bindings)) do
"Utilisateurs"
end]], [[def(t("en", "flash.hello", bindings)) do
"Hello %{first} %{last}!"
end, def(t("en", "flash.bye", bindings)) do
"Bye, %{name}!"
end], [def(t("en", "users.title", bindings)) do
"Users"
end]]]
def(t(_locale, _path, _bindings)) do
{:error, :no_translation}
end
)
[I18n]
iex>
第一眼看上去,返回结果似乎没啥用,因为 t/3 定义包裹在一个嵌套列表中。我们看到 def 语句都嵌入在 list 中,因为前面我们用 for 语句返回了所有的 deftranslations AST。我们可以 flatten (扁平化)列表,然后将其切片,提取最终的 AST,但对于 Elixir 是无所谓的,因此我们保持原样,通过 unquote 列表片段引用代码就行了。
在你生成 AST 是最好时不时地使用 Macro.to_string 来调试。你可以看到最终展开的代码如何注入到 caller 中,可以检查生成的参数列表,是否符合模板匹配。当然编写测试代码也是必不可少的。
最后一步:目标编译时优化
最后一步工作是,让 Translator 模块实现对占位符进行插值替换,比如 %{name}。当然我们可以在运行时生成正则表达式进行求值,这里我们换个思路,尝试一下进行编译时优化。我们可以生成一个函数定义,用来在进行插值替换时完成字符拼接功能。这样在运行时性能会急剧提升。我们实现一个 interpolate 函数,它用来生成一段 AST 注入到 t/3中 ,当 t/3 函数需要插值替换时进行引用。
advanced_code_gen/translator_final.exs
defp deftranslations(locale, current_path, mappings) do
for {key, val} <- mappings do
path = append_path(current_path, key)
if Keyword.keyword?(val) do
deftranslations(locale, path, val)
else
quote do
def t(unquote(locale), unquote(path), bindings) do
unquote(interpolate(val))
end
end
end
end
end
defp interpolate(string) do
~r/(?)%{[^}]+}(?)/
|> Regex.split(string, on: [:head, :tail])
|> Enum.reduce "", fn
<<"%{" <> rest>>, acc ->
key = String.to_atom(String.rstrip(rest, ?}))
quote do
unquote(acc) <> to_string(Dict.fetch!(bindings, unquote(key)))
end
segment, acc -> quote do: (unquote(acc) <> unquote(segment))
end
end
从16行开始,我们使用 %{varname} 模板来拆分翻译字符串。%{开头就意味着碰到了一个 segment,我们搜索字符串,不断的变量替换,不断的缩减引用,最后 Regex.split 被转换成一个简单的字符串拼接的 AST。我们使用 Dict.fetch!来处理绑定变量,以确保 caller 提供所有的内插值。对于普通字符串部分,我们就直接将其追加到这个不断累加的 AST 中。我们使用 Macro.to_string 来调试下:
iex> c "translator.exs"
[Translator]
iex> c "i18n.exs"
(
def(t(locale, path, binding \\ []))
[[[def(t("fr", "flash.hello", bindings)) do
(((("" <> "Salut ") <> to_string(Dict.fetch!(bindings, :first))) <> " ") <>
to_string(Dict.fetch!(bindings, :last))) <> "!"
end, def(t("fr", "flash.bye", bindings)) do
(("" <> "Au revoir, ") <> to_string(Dict.fetch!(bindings, :name))) <> "!"
end], [def(t("fr", "users.title", bindings)) do
"" <> "Utilisateurs"
end]], [[def(t("en", "flash.hello", bindings)) do
(((("" <> "Hello ") <> to_string(Dict.fetch!(bindings, :first))) <> " ") <>
to_string(Dict.fetch!(bindings, :last))) <> "!"
end, def(t("en", "flash.bye", bindings)) do
(("" <> "Bye, ") <> to_string(Dict.fetch!(bindings, :name))) <> "!"
end], [def(t("en", "users.title", bindings)) do
"" <> "Users"
end]]]
def(t(_locale, _path, _bindings)) do
{:error, :no_translation}
end
)
[I18n]
iex> I18n.t("en", "flash.hello", first: "Chris", last: "McCord")
"Hello Chris Mccord!"
iex> I18n.t("fr", "flash.hello", first: "Chris", last: "McCord")
"Salut Chris McCord!"
iex> I18n.t("en", "users.title")
"Users"
Macro.to_string 观察了编译时优化的 t/3 函数内部。我们看到所有的内插 AST 都正确的展开成简单的字符串拼接操作。这种方式的性能优化是绝大多数语言做不到的,相比于运行时的正则表达式匹配,性能提升那是相当大的。
你可能会好奇我们是如何在不使用 var! 宏的情况下直接在插值时引用绑定变量的。这里我们完全不用考虑宏卫生的问题,因为所有 quote block 都是位于同一个模块当中,因此他们共享同一个个上下文。让我们暂时存疑,好好欣赏下我们完成的工作吧。
最终版本的 Translator 模块
让我好好看看完整版本的程序,看下各部分是如何有机地结合在一起的。浏览代码时,思考下元编程中的每一步决定,我们是如何让说明文档驱动我们的实现的。
defmodule Translator do
defmacro __using__(_options) do
quote do
Module.register_attribute __MODULE__, :locales, accumulate: true,
persist: false
import unquote(__MODULE__), only: [locale: 2]
@before_compile unquote(__MODULE__)
end
end
defmacro __before_compile__(env) do
compile(Module.get_attribute(env.module, :locales))
end
defmacro locale(name, mappings) do
quote bind_quoted: [name: name, mappings: mappings] do
@locales {name, mappings}
end
end
def compile(translations) do
translations_ast = for {locale, source} <- translations do
deftranslations(locale, "", source)
end
quote do
def t(locale, path, binding \\ [])
unquote(translations_ast)
def t(_locale, _path, _bindings), do: {:error, :no_translation}
end
end
defp deftranslations(locale, current_path, translations) do
for {key, val} <- translations do
path = append_path(current_path, key)
if Keyword.keyword?(val) do
deftranslations(locale, path, val)
else
quote do
def t(unquote(locale), unquote(path), bindings) do
unquote(interpolate(val))
end
end
end
end
end
defp interpolate(string) do
~r/(?)%{[^}]+}(?)/
|> Regex.split(string, on: [:head, :tail])
|> Enum.reduce "", fn
<<"%{" <> rest>>, acc ->
key = String.to_atom(String.rstrip(rest, ?}))
quote do
unquote(acc) <> to_string(Dict.fetch!(bindings, unquote(key)))
end
segment, acc -> quote do: (unquote(acc) <> unquote(segment))
end
end
defp append_path("", next), do: to_string(next)
defp append_path(current, next), do: "#{current}.#{next}"
end
仅65行代码,我们就编写了一个鲁棒性非常强的国际化语言库,而且做了编译时性能优化。为每一个翻译条目映射生成函数头,也确保了 VM 能够快速检索。有了更多的翻译内容,也只需要简单的更新 locales就行了。
通过远程 API 生成代码
通过这一系列的联系你的元编程技能有进阶了,Elixir武器库又多了几样宝贝。现在让我们尝试在真实生产环境下探索 Elixir 的扩展性。前面我们没有限制是根据纯文本还是 Elixir 数据结构来构建代码。让我们创建一个 Hub mix project,通过 GitHub 的公开 API 来定义我们的模块功能。我们会生成一个模块,包含我们的 public repositories 的嵌入信息,要能够函数调用启动一个 web 浏览器直接跳转到我们的 project。
Mix Project 设置
创建一个工程项目
$ mix new hub --bare
$ cd hub
添加 Poison 和 HTTPotion 到项目依赖,一个用于 JSON 编码,一个用于处理 HTTP 请求。
编辑 hub/mix.exs
defmodule Hub.Mixfile do
use Mix.Project
def project do
[app: :hub,
version: "0.0.1",
elixir: "~> 1.0.0",
deps: deps]
end
def application do
[applications: [:logger]]
end
defp deps do
[{:ibrowse, github: "cmullaparthi/ibrowse", tag: "v4.1.0"},
{:poison, "~> 1.3.0"},
{:httpotion, "~> 1.0.0"}]
end
end
下载依赖包
$ mix deps.get
远程代码生成
编辑主模块 hub.ex ,从远程 API 生成代码。我们会访问 GitHub 的公开 API,提取我们 GitHub 账号下所有的 repositories,然后将返回的 JSON 数据中的 body 解码后存入一个 Elixir map。然后基于每条结果记录生成一个函数,函数名就是 repository 名,函数体就是该 repository 下的 GitHub proects 的所有相关数据。最后定义一个 go 函数,接受 repository name 作为参数,启动一个 web 浏览器跳转到该 URL。
编辑 lib/hub.ex 文件,输入下列代码。如果你有自己的 GitHub 账号,那么把 "chrismccord" 改成你自己的账号。
defmodule Hub do
HTTPotion.start
@username "chrismccord"
"https://api.github.com/users/#{@username}/repos"
|> HTTPotion.get(["User-Agent": "Elixir"])
|> Map.get(:body)
|> Poison.decode!
|> Enum.each fn repo ->
def unquote(String.to_atom(repo["name"]))() do
unquote(Macro.escape(repo))
end
end
def go(repo) do
url = apply(__MODULE__, repo, [])["html_url"]
IO.puts "Launching browser to #{url}..."
System.cmd("open", [url])
end
end
在第5行,我们使用管道用来将 JSON URL 转化成一系列的函数定义。我们获取原始的 response body,解码成 JSON,然后将每一个 JSON repository 映射成函数定义。基于每个 repository 生成一个函数,函数名就是 repo name;函数体只是简单的包含 repo 信息。第15行,定义了一个 go 函数,可以快速启动一个浏览器,跳转到给定 repository 的 URL。在 iex 测试下:
$ iex -S mix
iex> Hub.
atlas/0 bclose.vim/0
calliope/0 chrismccord.com/0
dot_vim/0 elixir/0
elixir_express/0 ex_copter/0
genserver_stack_example/0 gitit/0
go/1 haml-coffee/0
historian/0 jazz/0
jellybeans.vim/0 labrador/0
linguist/0 phoenix_chat_example/0
plug/0 phoenix_haml/0
phoenix_render_example/0 phoenix_vs_rails_showdown/0
iex > Hub.linguist
%{"description" => "Elixir Internationalization library",
"full_name" => "chrismccord/linguist",
"git_url" => "git://github.com/chrismccord/linguist.git",
"open_issues" => 4,
"open_issues_count" => 4,
"pushed_at" => "2014-08-04T13:28:30Z",
"watchers" => 33,
...
}
iex> Hub.linguist["description"]
"Elixir Internationalization library"
iex> Hub.linguist["watchers"]
33
iex> Hub.go :linguist
Launching browser to https://github.com/chrismccord/linguist...
仅20行代码,让我们陶醉下。我们在互联网上发出一个 JSON API 调用,然后直接将返回数据转换成模块函数。只有模块编译时产生了一次 API 调用。在运行时,我们相当于已经直接将 GitHub 数据缓存为函数调用了。这个例子只是为了好玩,它向我们展示了 Elixir 是如何的易于扩展。这里我们第一次接触了 Macro.escape 。
Macro.escape
Macro.escape 用来将一个 Elixir 字面量递归地(因为有嵌套的数据结构)转义成 AST 表达式(译注:因为 Elixir 的字面语法并非是 AST语法,所以需要转义。似乎只有 Lisp 系列语言才是直接操纵 AST 的)。
它主要用在当你需要将一个 Elixir value(而这个 value 是 Elixir 字面量语法,不是 AST 字面量语法) 插入到一个已经 quoted 的表达式中。
对于 Hub 模块,我们需要将 JSON map 注入到函数体重,但是 def 宏已经 quote 了接收到的代码块(译注:def 是个宏,因此其参数会自动 quoted,而 def func,do: block 的格式中,block 不过是个参数而已)。因此我们需要对 repo escape 转义,然后在 quoted block 中,才能通过 unquote 对其引用。
在 iex 中我们做些演示:
iex> Macro.escape(123)
123
iex> Macro.escape([1, 2, 3])
[1, 2, 3]
# %{watchers: 33, name: "linguist"} 是Elixir字面量表示
# {:%{}, [], [name: "linguist", watchers: 33]}是 AST 字面量表示
iex> Macro.escape(%{watchers: 33, name: "linguist"})
{:%{}, [], [name: "linguist", watchers: 33]}
iex> defmodule MyModule do
...> map = %{name: "Elixir"} # map 是 Elixir 字面量
...> def value do
...> unquote(map) # 所以这里引用 Elixir 字面量是有问题的
...> end
...> end
** (CompileError) iex: invalid quoted expression: %{name: "Elixir"}
iex> defmodule MyModule do
...> map = Macro.escape %{name: "Elixir"} # 转换成 AST 字面量
...> def value do
...> unquote(map)
...> end
...> end
{:module, MyModule, ...}
iex> MyModule.value
%{name: "Elixir"}
在这个 MyModule 例子当中,CompileError 报错是因为 map 不是一个 quoted 过的表达式。我们使用 Macro.escape 将其转义为一个可注入的 AST,就解决问题了。无论何时只要你遇到一个 invalid quoted expression 错误,停下来好好想一想,你是要把 values 注入到一个 quoted 表达式。如果表达式已经 quoted 成了一个 AST,那你就需要 Macro.escape 了。
继续探索
我们已经把生成代码带到了一个新高度,我们的代码高度可维护,性能也很棒,完全可以用于生产服务中。我们已经见识过了高阶代码生成技术的优点,也感受到了从远程 API 派生代码的乐趣。如果你试图探索这些技术的更多可能性,以后再说吧。我们这先思考下这些问题,扩充下你的脑洞。
- 在 Mime 模块中添加 using ,以便于其他模块可以 use Mime,并允许其添加自己的 MIME 定义,例如:
defmodule MimeMapper do
use Mime, "text/emoji": [".emj"],
"text/elixir": [".exs"]
end
iex> MimeMapper.exts_for_type("text/elixir")
[".exs"]
iex> MimeMapper.exts_for_type("text/html")
[".html"]
- 给 Translator 添加多元支持(老外麻烦事多,中文就没有单复数),如:
iex> I18n.t("en", "title.users", count: 1)
"user"
iex> I18n.t("en", "title.users", count: 2)
"users"
- 基于你常用的 web service 的 public API 生成些代码。