传送门!
传送门是这样一款游戏:通过往不同地点传送玩家人物或简单物品来解迷。玩家使用传送枪往类似地板或墙的平面上射击,制造出可以进入的传送门:
本教程将会使用Elixir来实现这样的传送门:我们将使用不同的颜色来创造门,并在它们之间传送数据!甚至还将学习如何通过网络在不同的机器上建造门。
我们将学到:
Elixir的shell交互
创建新的Elixir应用
模式匹配
为状态使用代理
自定义结构体
使用协议来扩展语言
监督树和应用
分布式Elixir节点
让我们开始吧!
安装
Elixir官网上有详细的安装教程,只需要跟随其中的步骤就能完成安装。安装好之后,你的终端里就会多了一些命令。iex
就是其中之一。输入iex
即可运行:
$ iex
Interactive Elixir - press Ctrl+C to exit (type h() ENTER for help)
iex
代表Elixir交互,你可以输入任何表达式并得到结果:
iex> 40 + 2
42
iex> "hello" <> " world"
"hello world"
iex> # This is a code comment
nil
你还可以使用如下数据类型:
iex> :atom # An identifier (known as Symbols in other languages)
:atom
iex> [1, 2, "three"] # Lists (typically hold a dynamic amount of items)
[1, 2, "three"]
iex> {:ok, "value"} # Tuples (typically hold a fixed amount of items)
{:ok, "value"}
在完成了我们的传送门应用后,就可以在iex
中输入如下命令:
# Shoot two doors: one orange, another blue
iex(1)> Portal.shoot(:orange)
{:ok, #PID<0.72.0>}
iex(2)> Portal.shoot(:blue)
{:ok, #PID<0.74.0>}
# Start transferring the list [1, 2, 3, 4] from orange to blue
iex(3)> portal = Portal.transfer(:orange, :blue, [1, 2, 3, 4])
#Portal<
:orange <=> :blue
[1, 2, 3, 4] <=> []
>
# Now every time we call push_right, data goes to blue
iex(4)> Portal.push_right(portal)
#Portal<
:orange <=> :blue
[1, 2, 3] <=> [4]
>
看上去不错!
我们的第一个项目
Elixir搭载了一个叫做Mix的工具。Mix可用于创建,编译和测试新项目。让我们使用mix
来创建一个名为portal
的项目。在创建时我们加上--sup
选项,这样会一同创建一个监督树。在后面的部分我们将讲解监督树的作用。现在只需要输入:
$ mix new portal --sup
上面的命令创造了一个名为portal
的新目录,以及其中的一些文件。将工作目录移动到portal
并运行mix test
来启动项目测试:
$ cd portal
$ mix test
很好,现在我们已经有了一个工作项目和一套测试。
让我们使用编辑器打开项目。我一般使用Sublime Text 3,你也可以选择你喜欢的编辑器,只要它支持Elixir。
在编辑器中查看以下文件:
_build
- Mix在此存放编译后的文件config
- 项目和依赖的配置文件lib
- 存放代码mix.exs
- 定义项目名称,版本和依赖test
- 定义测试
现在我们可以在项目中启动一个iex
会话。只需要输入:
$ iex -S mix
模式匹配
在实现我们的应用之前,先来聊聊模式匹配。Elixir中的=
号和其它语言中的有所不同:
iex> x = 1
1
iex> x
1
还不错,如果我们翻转表达式,会发生什么?
iex> 1 = x
1
成功了!这是因为Elixir试图将右边的匹配到左边。由于两边都是1
,所以能够运作。让我们试试其它的:
iex> 2 = x
** (MatchError) no match of right hand side value: 1
现在不匹配了,所以得到了一个错误。在Elixir中我们也对数据结构进行模式匹配。例如,我们可以使用[head|tail]
来获取一个列表的头部(第一个元素)和尾部(其余的部分)。
iex> [head|tail] = [1, 2, 3]
[1, 2, 3]
iex> head
1
iex> tail
[2, 3]
使用[head|tail]
匹配空列表会造成匹配错误:
iex> [head|tail] = []
** (MatchError) no match of right hand side value: []
最后,我们也可以使用[head|tail]
表达式来向列表的头部添加元素:
iex> list = [1, 2, 3]
[1, 2, 3]
iex> [0|list]
[0, 1, 2, 3]
使用代理构建传送门
Elixir数据结构是不可变的。在上面的例子中,我们没有改变列表。我们可以打碎一个列表或往头部添加元素,但原始的列表是不变的。
也就是说,当我们需要保持某种状态,例如通过传送门传送数据,我们必须使用某种抽象来存储这个状态。Elixir中这样的抽象之一叫做代理。在使用代理之前,我们需要简短地聊聊匿名函数:
iex> adder = fn a, b -> a + b end
#Function<12.90072148/2 in :erl_eval.expr/5>
iex> adder.(1, 2)
3
一个匿名函数由fn
和end
包围,并且用->
箭头来分离参数与函数体。我们使用匿名函数来初始化,获取和更新代理状态:
iex> {:ok, agent} = Agent.start_link(fn -> [] end)
{:ok, #PID<0.61.0>}
iex> Agent.get(agent, fn list -> list end)
[]
iex> Agent.update(agent, fn list -> [0|list] end)
:ok
iex> Agent.get(agent, fn list -> list end)
[0]
注意:你可能会得到与例子中不同的
#PID<...>
,这是正常的!
上述例子中,我们创建了一个新的代理,传送了一个用于返回初始状态空列表的函数。这个代理反悔了{:ok, #PID<0.61.0>}
。
Elixir中的花括号代表元组;上面的元组包含了原子:ok
和一个进程辨识符(PID)。我们使用原子作为标签。上述例子中,我们将代理标记为成功启动。
#PID<...>
是代理的进程辨识符。当我们谈论Elixir中的进程时,我们不是在说操作系统的进程,Elixir的进程是轻量且独立的,允许我们在同一台机器上运行数十万的进程。
我们将代理的PID存放在变量agent
中,这样我们就能通过发送信息来获取和更新代理的状态。
我们将使用代理来实现我们的传送门。使用如下内容创建一个名为lib/portal/door.ex
的文件:
defmodule Portal.Door do
@doc """
Starts a door with the given `color`.
The color is given as a name so we can identify
the door by color name instead of using a PID.
"""
def start_link(color) do
Agent.start_link(fn -> [] end, name: color)
end
@doc """
Get the data currently in the `door`.
"""
def get(door) do
Agent.get(door, fn list -> list end)
end
@doc """
Pushes `value` into the door.
"""
def push(door, value) do
Agent.update(door, fn list -> [value|list] end)
end
@doc """
Pops a value from the `door`.
Returns `{:ok, value}` if there is a value
or `:error` if the hole is currently empty.
"""
def pop(door) do
Agent.get_and_update(door, fn
[] -> {:error, []}
[h|t] -> {{:ok, h}, t}
end)
end
end
在Elixir中,我们把代码定义在模块里,它是一个基本的函数群。我们定义了四个函数,并且都写好了文档。
让我们来测试一下。用iex -S mix
开启新会话。启动时我们的新文件会自动被编译,所以我们可以直接使用:
iex> Portal.Door.start_link(:pink)
{:ok, #PID<0.68.0>}
iex> Portal.Door.get(:pink)
[]
iex> Portal.Door.push(:pink, 1)
:ok
iex> Portal.Door.get(:pink)
[1]
iex> Portal.Door.pop(:pink)
{:ok, 1}
iex> Portal.Door.get(:pink)
[]
iex> Portal.Door.pop(:pink)
:error
很好!
Elixir中一个很有趣的地方就是文档被当做一等公民。由于我们已经为Portal.Door
代码写好了文档,我们可以从终端中轻松地获取到文档。试试:
iex> h Portal.Door.start_link
传送
是时候为我们的传送门开启传送了!为了存储传送数据,我们将创造一个名为Portal
的就够。让我们现在IEx中尝试一下结构:
iex> defmodule User do
...> defstruct [:name, :age]
...> end
iex> user = %User{name: "john doe", age: 27}
%User{name: "john doe", age: 27}
iex> user.name
"john doe"
iex> %User{age: age} = user
%User{name: "john doe", age: 27}
iex> age
27
结构在模块中定义,并有着和模块相同的名字。在结构被定义后,我们可以使用%User{...}
形式来定义新的结构或是匹配它们。
让我们打开lib/portal.ex
并添加以下代码到Portal
模块。注意当前的Portal
模块已经有一个名为start/2
的函数。不要删除这个函数,我们将在后面讨论它,现在你只需要添加新的内容到Portal
模块:
defstruct [:left, :right]
@doc """
Starts transfering `data` from `left` to `right`.
"""
def transfer(left, right, data) do
# First add all data to the portal on the left
for item <- data do
Portal.Door.push(left, item)
end
# Returns a portal struct we will use next
%Portal{left: left, right: right}
end
@doc """
Pushes data to the right in the given `portal`.
"""
def push_right(portal) do
# See if we can pop data from left. If so, push the
# popped data to the right. Otherwise, do nothing.
case Portal.Door.pop(portal.left) do
:error -> :ok
{:ok, h} -> Portal.Door.push(portal.right, h)
end
# Let's return the portal itself
portal
end
我们已经定义了Portal
结构和一个Portal.transfer/3
函数(/3
表明该函数需要三个参数)。让我们来试试。用iex -S mix
启动一个新会话,这样我们的改动就会被编译,然后输入:
# Start doors
iex> Portal.Door.start_link(:orange)
{:ok, #PID<0.59.0>}
iex> Portal.Door.start_link(:blue)
{:ok, #PID<0.61.0>}
# Start transfer
iex> portal = Portal.transfer(:orange, :blue, [1, 2, 3])
%Portal{left: :orange, right: :blue}
# Check there is data on the orange/left door
iex> Portal.Door.get(:orange)
[3, 2, 1]
# Push right once
iex> Portal.push_right(portal)
%Portal{left: :orange, right: :blue}
# See changes
iex> Portal.Door.get(:orange)
[2, 1]
iex> Portal.Door.get(:blue)
[3]
一对传送门似乎可以运作了。注意在左边/橙色门中的数据是反向的。这正是我们所预期的,因为我们需要让列表的末尾(这里是数字3)成为第一个进入右边/蓝色门的数据。
还有一点与教程开头我们所看到的不同,那就是我们的传送现在是以结构的形式展现%Portal{left: :orange, right: :blue}
。如果我们需要打印传送过程,这能帮助我们查看传送的进程。
那就是我们接下来要做的。
使用协议来查看传送
我们已经知道数据可以被打印在iex
中。当我们在iex
中输入1 + 2
,我们得到了3
。那么,我们是否可以自定义返回的格式呢?
是的,我们可以!Elixir提供了协议,它能为任何数据类型扩展并实现某种行为。
例如,每当有iex
上打印一些东西,Elixir都使用了Inspect
协议。由于协议可以在任何时候扩展到任何的数据类型,这就意味着我们可以为Portal
实现它。打开lib/portal.ex
,在文件的末尾,在Portal
模块之外,添加:
defimpl Inspect, for: Portal do
def inspect(%Portal{left: left, right: right}, _) do
left_door = inspect(left)
right_door = inspect(right)
left_data = inspect(Enum.reverse(Portal.Door.get(left)))
right_data = inspect(Portal.Door.get(right))
max = max(String.length(left_door), String.length(left_data))
"""
#Portal<
#{String.pad_leading(left_door, max)} <=> #{right_door}
#{String.pad_leading(left_data, max)} <=> #{right_data}
>
"""
end
end
我们为Portal
结构实现了Inspect
协议。该协议只实现了一个名为inspect
的函数。函数需要两个参数,第一个是Portal
结构本身,第二个是选项,我们暂时不用管它。
然后我们多次调用inspect
,以获取left
和right
门的文本表示,也就是获取门内数据的表示。最终,我们返回了一个带有对齐好了的传送门表示的字符串。
启动另一个iex
会话,来查看我们新的表示:
iex> Portal.Door.start_link(:orange)
{:ok, #PID<0.59.0>}
iex> Portal.Door.start_link(:blue)
{:ok, #PID<0.61.0>}
iex> portal = Portal.transfer(:orange, :blue, [1, 2, 3])
#Portal<
:orange <=> :blue
[1, 2, 3] <=> []
>
创造被监督的门
我们经常听到Erlang VM,也就是Elixir所运行于的虚拟机,以及Erlang生态系统很善于构建容错率高的应用。其中的一个原因就是监督树机制。
我们的代码还没有被监督。让我们来看看当我们关闭了一个门的代理时会发生什么:
# Start doors and transfer
iex> Portal.Door.start_link(:orange)
{:ok, #PID<0.59.0>}
iex> Portal.Door.start_link(:blue)
{:ok, #PID<0.61.0>}
iex> portal = Portal.transfer(:orange, :blue, [1, 2, 3])
# First unlink the door from the shell to avoid the shell from crashing
iex> Process.unlink(Process.whereis(:blue))
true
# Send a shutdown exit signal to the blue agent
iex> Process.exit(Process.whereis(:blue), :shutdown)
true
# Try to move data
iex> Portal.push_right(portal)
** (exit) exited in: :gen_server.call(:blue, ..., 5000)
** (EXIT) no process
(stdlib) gen_server.erl:190: :gen_server.call/3
(portal) lib/portal.ex:25: Portal.push_right/1
我们得到了一个错误,因为这里没有:blue
门。你可以在我们的函数调用之后看到一个** (EXIT) no process
信息。为了解决这个问题,我们需要设置一个能在传送门崩溃之后自动重启它们的监督者。
还记得我们在创建项目时设附带的--sup
标志吗?我们附带了这个标志,是因为监督者通常运行在监督树中,而监督树通常作为应用的一部分启动。--sup
的默认作用就是创建一个被监督的结构,我们可以再Portal
模块中看到:
defmodule Portal do
use Application
# See http://elixir-lang.org/docs/stable/elixir/Application.html
# for more information on OTP Applications
def start(_type, _args) do
import Supervisor.Spec, warn: false
children = [
# Define workers and child supervisors to be supervised
# worker(Portal.Worker, [arg1, arg2, arg3])
]
# See http://elixir-lang.org/docs/stable/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: Portal.Supervisor]
Supervisor.start_link(children, opts)
end
# ... functions we have added ...
end
上述代码将Portal
模块变成了一个应用回调。应用回调必须提供一个名为start/2
的函数,该函数必须启动一个代表监督树根部的监督者。现在我们的监督者还没有一个子进程,而稍后我们将改变它。
将start/2
函数替换为:
def start(_type, _args) do
import Supervisor.Spec, warn: false
children = [
worker(Portal.Door, [])
]
opts = [strategy: :simple_one_for_one, name: Portal.Supervisor]
Supervisor.start_link(children, opts)
end
我们做了两处改动:
我们为监督者添加了一个子进程,类型为
worker
,由Portal.Door
模块来表达。我们没有传送任何参数给工人,只是一个空列表[]
,因为门的颜色会在稍后被指定。我们将策略由
:one_for_one
改为了:simple_one_for_one
。监督者提供了不同的策略,当我们想用不同的参数,动态地创造子进程时,:simple_one_for_one
是最合适的。而我们正想用不同的颜色来生成不同的门。
最后我们要添加一个名为shoot/1
的函数到Portal
模块中,它会接收一个颜色并生成一个新的门作为监督树的一部分:
@doc """
Shoots a new door with the given `color`.
"""
def shoot(color) do
Supervisor.start_child(Portal.Supervisor, [color])
end
上述函数访问了名为Portal.Supervisor
的监督者,并请求启动一个新的子进程。Portal.Supervisor
是我们在statr/2
中所定义的监督者的名字,而子进程会是我们在那个监督者中所指定的工人Portal.Door
。
在内部,为了启动子进程,监督者会调用Portal.Door.start_link(color)
,颜色正是我们传送给start_child/2
的参数。如果我们调用了Supervisor.start_child(Portal.Supervisor, [foo, bar, baz])
,监督者将会试图启动一个Portal.Door.start_link(foo, bar, baz)
作为子进程。
让我们试用一下。启动新的iex -S mix
会话,并输入:
iex> Portal.shoot(:orange)
{:ok, #PID<0.72.0>}
iex> Portal.shoot(:blue)
{:ok, #PID<0.74.0>}
iex> portal = Portal.transfer(:orange, :blue, [1, 2, 3, 4])
#Portal<
:orange <=> :blue
[1, 2, 3, 4] <=> []
>
iex> Portal.push_right(portal)
#Portal<
:orange <=> :blue
[1, 2, 3] <=> [4]
>
如果我们停止:blue
进程会发生什么?
iex> Process.unlink(Process.whereis(:blue))
true
iex> Process.exit(Process.whereis(:blue), :shutdown)
true
iex> Portal.push_right(portal)
#Portal<
:orange <=> :blue
[1, 2] <=> [3]
>
注意到这一次push_right/1
操作成功了,因为监督者自动启动了另一个:blue
传送门。不幸的是蓝色门中的数据丢失了。
在实践中,我们可以选择其它的监督策略,包括在崩溃时保留数据。
很好!
分布式传送
我们的传送门已经可以工作,准备好尝试一下分布式传送了。如果你在同一个网络中的两台机器上运行这些代码,结果会非常酷。如果你手头没有另一台机器,它也可以运行。
我们可以在启动iex
会话时传送--sname
选项使其变为网络中的一个节点。来试试:
$ iex --sname room1 --cookie secret -S mix
Interactive Elixir - press Ctrl+C to exit (type h() ENTER for help)
iex(room1@jv)1>
你会发现这个iex
与之前的有所不同。现在你会看到提示符中的room1@jv
,room1
是我们给节点的名称,jv
是节点所在的计算机的网络名。在这里,我的机器名称是jv
,而你会有不同的结果。在后面,我们将看到room1@COMPUTER-NAME
和room2@COMPUTER-NAME
,而你必须将COMPUTER-NAME
替换为你自己的电脑名。
在room1
会话中,让我们射出一个:blue
门:
iex(room1@COMPUTER-NAME)> Portal.shoot(:blue)
{:ok, #PID<0.65.0>}
启动另一个iex
会话,名为room2
:
$ iex --sname room2 --cookie secret -S mix
注意:两台电脑上的cookie必须相同,这样Elixir节点才可以沟通。
代理API允许我们进行跨节点请求。当调用Portal.Door
时,我们只需要提供想要连接到的代理所运行于的节点名。例如,让我们从room2
访问蓝色门:
iex(room2@COMPUTER-NAME)> Portal.Door.get({:blue, :"room1@COMPUTER-NAME"})
[]
这意味着我们已经可以简单地使用节点名来分布式传送了。在room2
中继续输入:
iex(room2@COMPUTER-NAME)> Portal.shoot(:orange)
{:ok, #PID<0.71.0>}
iex(room2@COMPUTER-NAME)> orange = {:orange, :"room2@COMPUTER-NAME"}
{:orange, :"room2@COMPUTER-NAME"}
iex(room2@COMPUTER-NAME)> blue = {:blue, :"room1@COMPUTER-NAME"}
{:blue, :"room1@COMPUTER-NAME"}
iex(room2@COMPUTER-NAME)> portal = Portal.transfer(orange, blue, [1, 2, 3, 4])
#Portal<
{:orange, :room2@COMPUTER-NAME} <=> {:blue, :room1@COMPUTER-NAME}
[1, 2, 3, 4] <=> []
>
iex(room2@COMPUTER-NAME)> Portal.push_right(portal)
#Portal<
{:orange, :room2@COMPUTER-NAME} <=> {:blue, :room1@COMPUTER-NAME}
[1, 2, 3] <=> [4]
>
太棒了。我们没有修改一行基础代码就实现了分布式的传送!
尽管room2
在管理传送,我们也可以从room1
中查看到传送:
iex(room1@COMPUTER-NAME)> orange = {:orange, :"room2@COMPUTER-NAME"}
{:orange, :"room2@COMPUTER-NAME"}
iex(room1@COMPUTER-NAME)> blue = {:blue, :"room1@COMPUTER-NAME"}
{:blue, :"room1@COMPUTER-NAME"}
iex(room1@COMPUTER-NAME)> Portal.Door.get(orange)
[3, 2, 1]
iex(room1@COMPUTER-NAME)> Portal.Door.get(blue)
[4]
我们的分布式传送门之所以能够工作,是因为门只是进程,而对门访问/推送数据,都是由代理API向这些进程发送信息来完成的。我们说在Elixir中发送信息是位置透明的:我们可以对任何PID发送信息,无论是否和发送者在同一个节点。
包装
我们已经完成了这个“如何开始使用Elixir”的教程!这是一次有趣的经历,我们从创建门进程,讲到了到高容错性的门和分布式的传送!
完成以下挑战,你的传送门应用可以更进一步:
添加一个
Portal.push_left/1
函数,反方向传送数据。你将如何处理push_left
和push_right
间的代码重复?学习更多关于Elixir的测试框架,ExUnit的内容,并为我们刚才创建的功能编写测试。记住我们在
test
目录中已经有了一个默认框架。使用ExDoc为你的项目生成HTML文档。
将你的代码上传到类似Github的网站,并使用Hex包管理工具发布包。
欢迎到我们的网站阅读更多关于Elixir的教程。
最后,感谢 Augie De Blieck Jr. 的插图。
回见!
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。