8

传送门!

传送门是这样一款游戏:通过往不同地点传送玩家人物或简单物品来解迷。玩家使用传送枪往类似地板或墙的平面上射击,制造出可以进入的传送门:

portal-drop.jpeg

本教程将会使用Elixir来实现这样的传送门:我们将使用不同的颜色来创造门,并在它们之间传送数据!甚至还将学习如何通过网络在不同的机器上建造门。

portal-list.jpeg

我们将学到:

  • 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

一个匿名函数由fnend包围,并且用->箭头来分离参数与函数体。我们使用匿名函数来初始化,获取和更新代理状态:

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,以获取leftright门的文本表示,也就是获取门内数据的表示。最终,我们返回了一个带有对齐好了的传送门表示的字符串。

启动另一个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@jvroom1是我们给节点的名称,jv是节点所在的计算机的网络名。在这里,我的机器名称是jv,而你会有不同的结果。在后面,我们将看到room1@COMPUTER-NAMEroom2@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_leftpush_right间的代码重复?

  • 学习更多关于Elixir的测试框架,ExUnit的内容,并为我们刚才创建的功能编写测试。记住我们在test目录中已经有了一个默认框架。

  • 使用ExDoc为你的项目生成HTML文档。

  • 将你的代码上传到类似Github的网站,并使用Hex包管理工具发布包。

欢迎到我们的网站阅读更多关于Elixir的教程。

最后,感谢 Augie De Blieck Jr. 的插图。

回见!


Ljzn
399 声望102 粉丝

网络安全;函数式编程;数字货币;人工智能


引用和评论

0 条评论