Elixir 入门文档中文(简体)翻译计划

2 用Mix编写OPT应用

在Elixir里,我们如何保存状态?

我们的软件需要在运行时系统里保存状态,配置,数据。在之前的章节里我们已学会了如何用进程/Actor保持状态,在一个循环中如何接受以及回复消息,但这种方式似乎不够可靠。如果我们的进程被一个错误退出了怎么办?难道我们真的需要仅仅为一个配置而去创建一个新的进程?

在这一章,我们将用OTP的方式来回答这些问题。在实践中,我们不必使用Mix来编写这样的应用,然而借此机会正好让我们了解一些Mix提供的一些方便之处。

2.1 Stacker服务器

我们的应用将会是一个运行我们推进/推出的简单堆栈。我们管它叫stacker:

$ mix new stacker --bare

我们的应用将包含一个堆栈,允许同一时间被许多的进程访问。为此,我们将创建一个服务器来负责管理这个堆栈。客户端随时可以向服务器发送消息来从服务器推进或取出某物。

因为在Erlang和Elixir里,创建这样的一个服务器是常见的一个范式,在OTP里有一个被称为GenServer的行为封装了这些常见的服务器功能。让我们创建一个文件lib/stacker/server.ex, 这就是我们的第一个服务器:

defmodule Stacker.Server do
  use GenServer.Behaviour

  def init(stack) do
    { :ok, stack }
  end

  def handle_call(:pop, _from, [h|stack]) do
    { :reply, h, stack }
  end

  def handle_cast({ :push, new }, stack) do
    { :noreply, [new|stack] }
  end
end

我们的服务器定义了三个函数: init/1handle_call/3handle_cast/2。我们不会直接调用这些函数,它们是当我们请求服务器的时候由OTP来使用的函数。我们很快会了解详情,现在我们无需关心更多。为此,在你的命令行里运行iex -S mix来开始用mix来启动iex,输入下面的指令:

# Let's start the server using Erlang's :gen_server module.
# It expects 3 arguments: the server module, the initial
# stack and some options (if desired):
iex> { :ok, pid } = :gen_server.start_link(Stacker.Server, [], [])
{:ok,<...>}

# Now let's push something onto the stack
iex> :gen_server.cast(pid, { :push, 13 })
:ok

# Now let's get it out from the stack
# Notice we are using *call* instead of *cast*
iex> :gen_server.call(pid, :pop)
13

非常好,我们的服务器工作正常!然而在幕后其实发生了很多的事情,然我们来一一探究。

首先,我们用OTP中的:gen_server模块发动服务器。注意我们使用了start_link, 它启动了服务器并且把当前的进程与之相连。在这种情况下,如果服务器死了,它将会向我们的当前进程发送一个退出消息,使它也退出。我们将在后面看到这个行为。函数start_link返回的是新创建的进程的识别符(pid)。

之后,我们向服务器发送了一个cast消息。消息的内容是{ :push, 13 },与我们之前定义在Stacker.Server中的回调函数handle_cast/2里的一致。无论何时我们发送一个cast消息,函数handle_cast/2会被调用来处理这个消息。

接着,我们最终用发送call消息地方时,看到了堆栈里的情况,它触发了回调handle_call/3。那么,castcall到底有什么不同呢?

cast消息是异步的:我们向服务器发送一个消息,然而并不期待回复。这也是为什么我们的handle_cast/2回调返回的是{ :noreply, [new|stack] }的缘故。这个元组中的第一个元素表明了无需回复,而第二个元素是包含了新物件的经过升级的堆栈。

相反,call消息是同步的。当我们发送一个call消息,客户端期待一个回复。在这个例子中,回调handle_call/3返回了{ :reply, h, stack },其中第二个元素就是用来返回的内容,而第三个是我们不包含头的堆栈。因为call能够向客户端返还消息,所以它的也多了一个有关客户端情况的参数(_from)。

2.1.1 深入学习回调函数

在GenServer的例子中,类似handle_callhandle_cast的函数可能返回8种不同的数值:

{ :reply, reply, new_state }
{ :reply, reply, new_state, timeout }
{ :reply, reply, new_state, :hibernate }
{ :noreply, new_state }
{ :noreply, new_state, timeout }
{ :noreply, new_state, :hibernate }
{ :stop, reason, new_state }
{ :stop, reason, reply, new_state }

一个GenServer的实现必须实现6种不同的回调函数。模块GenServer.Behaviour自动定义了这些函数,但又允许我们根据需要来修改。下面是这些函数的列表:

  • init(args) - 当服务器启动时调用
  • handle_call(msg, from, state) - 被调用来处理call消息
  • handle_cast(msg, state) - 被调用来处理cast消息
  • handle_info(msg, state) - 被调用来处理进程所收到的其他消息
  • terminate(reason, state) - 在服务器当机之前被调用,对清理很有用
  • code_change(old_vsn, state, extra) - 在应用代码热升级的时候被调用

2.1.2 干掉一个服务器

Of what use is a server if we cannot crash it?

实际上要使一个服务器当机并不难。我们的回调handle_call/3只有当堆栈不是空的时候才工作正常(想起来了吗,[h|t]不能匹配空列表)。让我们在堆栈为空的情况下发送一个消息看看:

# Start another server, but with an initial :hello item
iex> { :ok, pid } = :gen_server.start_link(Stacker.Server, [:hello], [])
{:ok,<...>}

# Let's get our initial item:
iex> :gen_server.call(pid, :pop)
:hello

# And now let's call pop again
iex> :gen_server.call(pid, :pop)

=ERROR REPORT==== 6-Dec-2012::19:15:33 ===
...
** (exit)
...

你可以看到,这里有两个错误报告。第一个因为当机由服务器产生。因为服务器是同我们的进程相连的,它也发送了一个退出的消息,IEx as ** (exit) ....

因为我们的服务器总会崩溃,我们需要监控它们,这也是下面的内容。GenServer.Behaviour不仅仅包含我们已经学到的这些。请查看GenServer.Behaviour的文档来发现更多。

2.2 监控服务器

当在Eralng和Elixir里编写应用,一个常用到的哲学是fail first。也许是因为资源不可得,也许是服务之间的超时,或许是其他的什么原因。这也是为什么有能力对这些崩溃作出反应并回复过来,是非常重要的。把这些牢记在心,我们为我们的服务器编写一个supervisor。

用下面的内容创建一个文件,lib/stacker/supervisor.ex

defmodule Stacker.Supervisor do
  use Supervisor.Behaviour

  # A convenience to start the supervisor
  def start_link(stack) do
    :supervisor.start_link(__MODULE__, stack)
  end

  # The callback invoked when the supervisor starts
  def init(stack) do
    children = [ worker(Stacker.Server, [stack]) ]
    supervise children, strategy: :one_for_one
  end
end

在监控中,唯一需要实现的回调函数是init(args)。这个回调必须返回监工的规格,在上面的例子中实际调用了帮助函数supervise/2

我们的监工非常简单:它必须监控我们的工人Stacker.Server, 工人的启动需要一个参数,在这是默认的堆栈。完成定义的工人被用:one_for_one的策略监控,这意味着每一次工人死亡之后都会被重启。

由于我们的工人由Stacker.Server模块指定,并且需要传递stack作为参数,在默认下,监工将调用函数Stacker.Server.start_link(stack)来启动工人,所以让我们来实现它:

defmodule Stacker.Server do
  use GenServer.Behaviour

  def start_link(stack) do
    :gen_server.start_link({ :local, :stacker }, __MODULE__, stack, [])
  end

  def init(stack) do
    { :ok, stack }
  end

  def handle_call(:pop, _from, [h|stack]) do
    { :reply, h, stack }
  end

  def handle_cast({ :push, new }, stack) do
    { :noreply, [new|stack] }
  end
end

函数start_link同我们之前启动服务器的方式很相似,除了在这里我们需要多传递一个参数{ :local, :stacker }。这个参数在本地节点上注册我们的服务器,运行用一个名字(在这里,是:stacker)来调用它,而无需直接使用pid

不借助监工,让我们运行iex -S mix来再一次打开控制台,这也会再一次编译我们的文件:

# Now we will start the supervisor with a
# default stack containing :hello
iex> Stacker.Supervisor.start_link([:hello])
{:ok,<...>}

# And we will access the server by name since
# we registered it
iex> :gen_server.call(:stacker, :pop)
:hello

注意监工自动为我们启动了服务器,现在我们可以用名字:stacker向服务器发送消息了。如果我们使得服务器当机,会发送甚什么?

iex> :gen_server.call(:stacker, :pop)

=ERROR REPORT==== 6-Dec-2012::19:15:33 ===
...
** (exit)
...

iex> :gen_server.call(:stacker, :pop)
:hello

它和前面一样当机了,当监工立刻用默认的堆栈重启了它,使得我们可以再一次接受到:hello。太棒了!

默认下,监工运行一个工人在5秒内最多当机5次。如果工人当机的频率超过了这个限制,监工就会放弃它,不在重启。让我们尝试接连发送一个未知的消息看看(要快!):

iex> :gen_server.call(:stacker, :unknown)
... 5 times ...

iex> :gen_server.call(:stacker, :unknown)
** (exit) {:noproc,{:gen_server,:call,[:stacker,:unknown]}}
    gen_server.erl:180: :gen_server.call/2

第六个消息不在产生错误报告,因为我们的服务器不在自动重启了。Elixir返回:noprocno process的简写形式),意味着那里已经没有一个被称为:stacker的进程了。重启的次数和时间间隔,可以通过向函数supervise传递参数进行修改。除了上面例子中的:one_for_one, 监工也能选用不同的重启策略。如果向知道还有那些支持的策略,检查Supervisor.Behaviour文档

2.3 谁来监控监工?

我们已经编写了我们的监工,但有一个问题:谁来监控监工?为了回答这个问题,OTP有一个概念,应用(application)。应用可以被作为一个整体启动或关闭,当这些发生的时候,它们通常和一个监工相连。

在之前的章节中,我们已经看到了Mix如何用文件mix.exs中的application函数中包含的信息来自动地产生一个.app文件,每次编译我们的项目。

这个.app文件被称为应用规格,它必须包含我们的应用的依赖,它定义的模块,注册名和其他的许多。其中的一些信息由Mix来自动完成,但另外的一些数据需要手动添加。

在我们的例子里面,我们的应用有一个监工,而且它用名字:stacker注册了监工。也就是说,为了避免冲突,我们需要在应用规格里加入所有的注册名。如果两个应用注册了同一个名字,我们能更快地找到冲突。所以,让我们打开文件`,用下面的内容编辑application`函数:

def application do
  [ registered: [:stacker],
    mod: { Stacker, [:hello] } ]
end

:registered键中我们指定了我们的应用注册的所有名字。而:mod键的用途是,一旦应用启动,它必须去调用应用模块回调函数(appliation module callback)。在我们的例子里面,应用模块回调函数是Stack模块,而且它将会接受到默认的参数,堆栈[:hello]。这个回调函数必须返回同应用相关的监工的pid

有了这些在心里,让我们打开文件lib/stacker.ex,添加以下的内容:

defmodule Stacker do
  use Application.Behaviour

  def start(_type, stack) do
    Stacker.Supervisor.start_link(stack)
  end
end

Application.Behaviour期待两个回调,start(type, args)stop(state)。我们需要来实现start(type, args), 而stop(state)可以先放在一边不管。

在添加了上面的应用行为之后,你只需要在一次启动iex -S mix。我们的文件将被再一次重编译,监工(包括我们的服务器)会自动启动:

iex> :gen_server.call(:stacker, :pop)
:hello

太棒了,它能性!也许你已经注意到了,应用回调函数start/2接受了一个类型参数,虽然我们忽略了它。这个类型控制当我们的监工,自然也包括应用,崩溃的时候,虚拟机应该如何应对。你可以通过阅读Application.Behaviour文档学到更多。

最后,注意mix new支持一个--sup选项,它告诉Mix产生一个包含应用模块回调的监工,自动地完成了一些上面的工作。你一定要试试!

2.4 启动应用

在任何时候,我们都不必自己去启动我们定义的应用。那是因为默认下Mix会启动所有的应用,包括所依赖的应用。我们能通过调用OTP提供的:application模块中的函数来手动地启动应用:

iex> :application.start(:stacker)
{ :error, { :already_started, :stacker } }

在上面的例子中,因为应用已经事先启动了,它返回了一个错误信息。

Mix不仅启动你的应用,而且包括所有的你的应用的依赖。请注意你的项目的依赖(我们在前面几章中讨论过的,定义在键deps里的)和应用依赖是不同的。

项目依赖也许会包含你的测试框架或者一个编译时的依赖。应用依赖是运行时你的应用依赖的一切。一个应用依赖需要明确地被加入appliation函数里:

def application do
  [ registered: [:stacker],
    applications: [:some_dep],
    mod: { Stacker, [:hello] } ]
end

当在Mix里运行任务,它将确保应用以及应用的依赖都会被启动。

2.5 配置应用

除了我们已经看到的键:registered:applications:mod,应用也支持被读取和设置的配置数值。

在命令行里,尝试:

iex> :application.get_env(:stacker, :foo)
:undefined
iex> :application.set_env(:stacker, :foo, :bar)
:ok
iex> :application.get_env(:stacker, :foo)
{ :ok, :bar }

这个机制非常有用,它无需创建一整个监工链就能为你的应用提供配置数值。应用的默认的配置数值可以通过如下的方式在文件mix.exs中定义:

def application do
  [ registered: [:stacker],
    mod: { Stacker, [:hello] },
    env: [foo: :bar] ]
end

现在,从控制台里退出,然后用iex -S mix重启它:

iex> :application.get_env(:stacker, :foo)
{ :ok, :bar }

例如,IEx和ExUnit是两个Elixir包含的应用,它们的mix.exs文件就是使用了这样的配置,文件IExExUnit。这样的应用于是提供了提供了对读写这些数值的封装

到这里,我们结束了这一章。我们已经学习了如何创建服务器,监控它们,把它们和我们的应用整合和提供简单的配置选项。在下一章,我们将学习如何创建一个Mix中的定制任务。