传送门游戏(Portal is a game)由一系列谜题组成的游戏,必须通过将玩家的角色和简单物体从一个地方传送到另一个地方来解决。
为了传送,玩家使用Portal枪将门射到平面上,如地板或墙壁。进入其中一扇门将你传送到另一扇门:

本文使用 Elixir 抓取不同颜色的门并在它们之间传输数据来构建传送门,以及学习如何在网络上的不同机器分配门:

我们将学习的内容:
- Elixir交互
- Elixir项目的创建
- 模式匹配
- 使用状态代理
- 使用结构来定制数据结构
- 使用协议扩展语言
- 监督树和应用
- 分布式Elixir节点
让我们开始吧!
安装
Elixir 官方网站有安装指南,我更加推荐使用 asdf-vm/asdf 来安装。
安装完成后,可以在终端通过 iex
命令进行交互。
> $ iex
Erlang/OTP 21 [erts-10.0.4] [source] [64-bit] [smp:4:4] [ds:4:4:10] [async-threads:1] [hipe]
Interactive Elixir (1.7.1) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)>
在 iex
交互中,我们可以输入任何形式的表达式:
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 是 Elixir 开发人员用来创建,编译和测试新项目的东西。让我们通过 mix
创建一个名为 project
的项目。在创建项目时,我们还将传递 --sup
将创建监督树(supervision tree)的选项。我们将在后面的章节中探讨监督树的作用。现在,只需输入:
mix new portal --sup
上面的命令创建了一个新目录 portal ,其中包含一些文件。将您的工作目录更改为 * portal* 并运行 mix test
以运行项目测试:
cd portal
mix test
很好,我们有了一个带有测试套件的项目。
让我们通过文本编辑器来浏览项目。我个人并不太关注文本编辑器,我使用的是 Spacemacs ,但您可以在网站上找到Elixir对不同文本编辑器的支持。
> $ tree
.
├── _build
│ └── test
│ └── lib
│ └── portal
│ ├── consolidated
│ │ ├── Elixir.Collectable.beam
│ │ ├── Elixir.Enumerable.beam
│ │ ├── Elixir.IEx.Info.beam
│ │ ├── Elixir.Inspect.beam
│ │ ├── Elixir.List.Chars.beam
│ │ └── Elixir.String.Chars.beam
│ └── ebin
│ ├── Elixir.Portal.Application.beam
│ ├── Elixir.Portal.beam
│ └── portal.app
├── config
│ └── config.exs
├── lib
│ ├── portal
│ │ └── application.ex
│ └── portal.ex
├── mix.exs
├── README.md
└── test
├── portal_test.exs
└── test_helper.exs
10 directories, 16 files
打开编辑器,浏览以下目录:
-
_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)。我们使用Elixir中的原子作为标签。在上面的示例中,我们将代理标记为已成功启动。
这 #PID<...>
是代理的进程标识符。当我们说 Elixir 中的进程时,我们并不是指操作系统进程,而是 Elixir 进程,它们是轻量级的并且是独立的,允许我们在同一台机器上运行数十万个进程。
我们将代理的 PID 存储在 agent
变量中,这允许我们发送消息以获取和更新代理的状态。
我们将使用代理商来实现门户门。创建一个 lib/portal/door.ex
使用以下内容命名的新文件:
touch lib/portaldoor.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`
"""
def pop(door) do
Agent.get_and_update(door, fn
[] -> {:error, []}
[h | t] -> {{:ok, h}, t}
end)
end
end
在 Elixir 中,我们在模块内部定义代码,这些代码基本上是一组函数。我们已经定义了上面的四个函数,所有这些函
让我们尝试一下我们的实现。用它开始一个新的 shell iex -S mix
。启动shell时,我们的新文件将自动编译,因此我们可以直接使用它:
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(14)> h Portal.Door.start_link
def start_link(color)
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.
传送转移
我们的传送门已经准备好了,是时候开始传送转移了。为了存储传送数据,我们要创建一个名为 Portal
的结构。在此之前,我们先在 iex
中尝试结构(structs) :
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
模块已经有一个名为的函数 hello/0
。您可以删除此功能,然后在 Portal
模块中添加新内容:
defmodule Portal do
defstruct [:left, :right]
@moduledoc """
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
end
我们已经定义了我们的 Portal
结构和一个 Portal.transfer/3
函数( /3
表示函数需要三个参数)。让我们尝试一下这个转移,启动另一个shell,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}
。如果我们实际上有门户传输的打印表示,那就更好了,这样我们就可以在推送数据时看到门户流程。
这就是我们接下来要做的。
使用协议观察传送门
Inspecting portals with Protocols
我们已经知道可以打印数据了 iex
。毕竟,当我们输入 1 + 2
时 iex
,我们会 3
回来。但是,我们可以自定义我们自己的类型打印方式吗?
可以的!Elixir提供协议,允许在任何时候为任何数据类型(如我们的 Portal
结构)扩展和实现行为。
例如,每次在我们的 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.rjust(left_door, max)} <=> #{right_door}
#{String.rjust(left_data, max)} <=> #{right_data}
>
"""
end
end
在上面的代码片段中,我们已经为 Portal
struct 实现了 Inspect
协议。该协议只需要实现一个名为 inspect
的函数。该函数需要两个参数,第一个是 Portal
结构本身,第二个是一组选项,我们现在不关心它们。
然后我们可以多次调用 inspect
,以获得两者 left
和 right
门的文本表示,以及获得门内数据的表示。最后,我们返回一个包含门户网站演示文稿的字符串。
启动另一个 iex
会话:iex -S mix
,以查看我们正在使用的新展示:
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
我们的函数调用后面有一条消息。为了解决这个问题,我们将设置一个监督(supervisor),负责在门户崩溃时重新启动门户。
还记得我们 --sup
在创建 portal
项目时传递标志吗?我们通过了那个标志,因为监督通常在监督树内运行,监督树通常作为申请的一部分开始。所有 --sup
标志都是默认创建一个监督结构,我们可以Portal.Application
在 lib / portal / application.ex 中的模块中看到:
defmodule Portal.Application do
# See https://hexdocs.pm/elixir/Application.html
# for more information on OTP Applications
@moduledoc false
use Application
def start(_type, _args) do
import Supervisor.Spec, warn: false
# List all child processes to be supervised
children = [
# Starts a worker by calling: Portal.Worker.start_link(arg)
# {Portal.Worker, arg},
]
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: Portal.Supervisor]
Supervisor.start_link(children, opts)
end
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
是我们定义的主管的名字,start/2
而孩子将Portal.Door
被指定为该主管的工作人员。
在内部,为了启动子项,主管将调用 Portal.Door.start_link(color)
,其中 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
您各自的计算机名称。
在iex名为的会议中 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节点能够相互通信。
开箱即用的Agent 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]
我们的分布式门户传输是有效的,因为门只是进程,通过门访问/推送数据是通过Agent API向这些进程发送消息来完成的。我们说在Elixir中发送消息是位置透明的:我们可以向任何PID发送消息,无论它是与发送方位于同一节点还是位于同一网络的不同节点中。
包起来
所以我们已经到了本指南的最后,关于如何开始使用Elixir!这是一个有趣的旅程,我们很快就从手动启动门流程到拍摄容错门进行分布式门户传输!
我们通过将您的门户应用程序提升到新的水平,向您挑战继续学习和探索更多Elixir:
- 添加一个
Portal.push_left/1
以另一个方向传输数据的函数。如何避免push_left/1
和push_right/1
函数之间存在的代码重复? - 了解有关ExUnit,Elixir测试框架的更多信息,并针对我们迄今为止构建的功能编写测试。请记住,我们已经在
test
目录中列出了默认结构。 - 使用ExDoc为项目生成HTML文档。
- 将项目推送到外部源(如Github),然后使用Hex包管理器发布包。
网友评论