💎一站式轻松地调用各大LLM模型接口,支持GPT4、智谱、星火、月之暗面及文生图 广告
5-监督者和应用程序 ====================== 到目前为止,我们的程序已经实现了注册表(registry)来对成百上千的bucket进程进行监视。 你是不是觉得这个还不错?没有软件是bug-free的,挂掉那是必定会发生滴。 当有东西挂了,我们的第一反应是:“快拯救这些错误”。但是,像在《入门》中学到的那样, 不同于其它多数语言,Elixir不太做“防御性编程”。 相反,我们说“要挂快点挂”,或是“就让它挂”。 如果有bug要让我们的注册表进程挂掉,啥也别怕,因为我们即将实现用监督者来启动新的注册表进程副本。 本章我们将学习监督者(supervisor),还会讲到些有关应用程序的知识。 一个不够,我们要创建两个监督者,用它们监督我们的进程。 ## 5.1-第一个监督者 创建一个监督者跟创建通用服务器差不多。我们将定义一个名为```KV.Supervisor```的模块, 使用[Supervisor](http://elixir-lang.org/docs/stable/elixir/Supervisor.html)行为。 代码文件```lib/kv/supervisor.ex```内容如下: ```elixir defmodule KV.Supervisor do use Supervisor def start_link do Supervisor.start_link(__MODULE__, :ok) end def init(:ok) do children = [ worker(KV.Registry, [KV.Registry]) ] supervise(children, strategy: :one_for_one) end end ``` 我们的监督者目前只有一个孩子:注册表进程。一个形式如 ```elixir worker(KV.Registry, [KV.Registry]) ``` 的worker,在调用: ```elixir KV.Registry.start_link(KV.Registry) ``` 时将启动一个进程。 我们传给```start_link```的参数是进程的名称。给监督机制下得进程命名是常见的做法, 这样别的进程就可以通过名称访问它们,而不需要知道它们的进程ID。 这很有用,因为当被监督的某进程挂掉被重启后,它的进程ID可能会改变。但是用名称就不一样了。 我们可以保证一个挂掉新启的进程,还会用同样的名称注册进来。而不用显式地先获取之前的进程ID。 另外,通常会用定义的模块名称作为进程的名字,在将来对系统进行debug时非常直观。 最后,我们调用了```supervisor/2```,给它传递了一个孩子列表以及策略:```:one_for_one```。 监督者的策略指明了当一个孩子进程挂了会发生什么。```:one_for_one```意思是如果一个孩子进程挂了, 只有一个“复制品”会启动来替代它。我们现在要的就是这个策略, 因为我们只有一个孩子。```Supervisor```支持许多不同的策略,我们在本章中将会陆续讨论。 因为```KV.Registry.start_link/1```现在期待一个参数,需要修改我们的实现来接受这一个参数。 打开文件```lib/kv/registry.ex```,覆盖原来的```start_link/0```定义: ```elixir @doc """ Starts the registry with the given `name`. """ def start_link(name) do GenServer.start_link(__MODULE__, :ok, name: name) end ``` 我们还要修改测试,在注册表进程启动时给个名字。在文件```test/kv/registry_test.exs```中覆盖 原```setup```函数代码: ```elixir setup context do {:ok, registry} = KV.Registry.start_link(context.test) {:ok, registry: registry} end ``` 类似```test/3```,函数```setup/2```也接受测试上下文(context)。不管我们给setup代码中添加了啥, 上下文中包含着几个关键变量:比如```:case```,```:test```,```:file```和```:line```。 上面代码中,我们用了```context.test```作为捷径取得当前运行着的测试名称,生成一个注册表进程。 现在,随着测试通过,可以拉我们的监督者出去溜溜了。如果在工程中启动命令行对话```iex -S mix```, 我们可以手动启动监督者: ```elixir iex> KV.Supervisor.start_link {:ok, #PID<0.66.0>} iex> KV.Registry.create(KV.Registry, "shopping") :ok iex> KV.Registry.lookup(KV.Registry, "shopping") {:ok, #PID<0.70.0>} ``` 当我们启动监督者,注册表worker会自动启动,允许我们创建bucket而不需要手动启动它们。 但是,在实战中我们很少手动启动应用程序的监督者。启动监督者是应用程序回调过程的一部分。 ## 5.2-理解应用程序 起始我们已经把所有时间都花在这个应用程序上了。每次修改了一个文件,执行```mix compile```, 我们都能看到```Generated kv app```消息在编译信息中打印出来。 我们可以在```_build/dev/lib/kv/ebin/kv.app```找到```.app```文件。来看一下它的内容: ```elixir {application,kv, [{registered,[]}, {description,"kv"}, {applications,[kernel,stdlib,elixir,logger]}, {vsn,"0.0.1"}, {modules,['Elixir.KV','Elixir.KV.Bucket', 'Elixir.KV.Registry','Elixir.KV.Supervisor']}]}. ``` 该文件包含Erlang的语句(使用Erlang的语法写的)。但即使我们不熟悉Erlang, 也能很容易地猜到这个文件保存的是我们应用程序的定义。 它包括应用程序的版本,定义的所有模块,还有它依赖的应用程序列表, 如Erlang的Kernel,elixir本身,logger(我们在```mix.exs```里添加的)。 要是每次我们添加一个新的模块就要手动修改这个文件,是很讨厌的。 这也是为啥把它交给mix来自动维护的原因。 我们还可以通过修改```mix.exs```工程文件中,函数```application/0```的返回值, 来配置生成的```.app```文件。我们将很快做第一次自定义配置。 ### 5.2.1-启动应用程序 定义了```.app```文件(里面是应用程序的定义),我们就可以将应用程序视作一个整体形式来启动和停止。 到目前为止我们还没有考虑过这个问题,这是因为: 1. Mix为我们自动启动了应用程序 2. 即使Mix没有自动启动我们的程序,该程序启动后也没做啥特别的事儿 总之,让我们看看Mix如何为我们启动应用程序。先在工程下启动命令行,然后试着执行: ```elixir iex> Application.start(:kv) {:error, {:already_started, :kv}} ``` 擦,已经启动了?Mix通常会启动文件```mix.exs```中定义的整个应用程序结构。 遇到依赖的程序也会如此一并启动。 我们可以给mix一个选项,让它不要启动我们的应用程序。 执行命令:```iex -S mix run --no-start```启动命令行,然后执行: ```elixir iex> Application.start(:kv) :ok ``` 我们可以停止```:kv```程序和```:logger```程序,后者是Elixir默认情况下自动启动的: ```elixir iex> Application.stop(:kv) :ok iex> Application.stop(:logger) :ok ``` 然后再次启动我们的程序: ```elixir iex> Application.start(:kv) {:error, {:not_started, :logger}} ``` 错误是由于```:kv```所依赖的应用程序(这里是```:logger```)没有启动导致的。 Mix一般会根据工程中的```mix.exs```启动整个应用程序结构; 对其依赖的每个应用程序来说也是这样(如果它们还依赖于其它应用程序)。 但是这次我们用了```--no-start```标志,因此我们需要手动 _按顺序_ 启动所有应用程序, 或者像这样调用```Application.ensure_all_started```: ```elixir iex> Application.ensure_all_started(:kv) {:ok, [:logger, :kv]} ``` 没什么激动人心的,这些只是演示了如何控制我们的应用程序。 >当你运行```iex -S mix```,它相当于执行```iex -S mix run```。 因此无论何时你启动iex会话,传递参数给```mix run```,实际上是传递给```run```命令。 你可以在命令行中执行```mix help run```获取关于```run```的更多信息。 ### 5.2.2-应用程序的回调(callback) 因为我们几乎都在讲应用程序如何启动和停止,你能猜到肯定有办法能在启动的当儿做点有意义的事情。 没错,有的! 我们可以定义应用程序的回调函数。在应用程序启动时,该函数将被调用。 这个函数必须返回```{:ok, pid}```,其中```pid```是其内部监督者进程的标识符。 我们分两步来定义这个回调函数。首先,打开```mix.exs```文件,修改```def application```部分: ```elixir def application do [applications: [:logger], mod: {KV, []}] end ``` 选项```:mod```指出了“应用程序回调函数的模块”,后面跟着该传递给它的参数。 这个回调函数的模块可以是任意模块,只要它实现了[Application](http://elixir-lang.org/docs/stable/elixir/Application.html)行为。 在这里,我们要让```KV```作为它回调函数的模块。因此在文件```lib/kv.ex```中做一些修改: ```elixir defmodule KV do use Application def start(_type, _args) do KV.Supervisor.start_link end end ``` 当我们声明```use Application```,(类似声明了```GenServer```、```Supervisor```) 我们需要定义几个函数。这里我们只需定义```start/2```函数。 如果我们想在应用程序停止时定义一个自定义的行为,我们也可以定义一个```stop/1```函数。 现在我们再次用```iex -S mix```启动我们的工程对话。 我们将看到一个名为```KV.Registry```的进程已经在运行: ```elixir iex> KV.Registry.create(KV.Registry, "shopping") :ok iex> KV.Registry.lookup(KV.Registry, "shopping") {:ok, #PID<0.88.0>} ``` 好牛逼! ### 5.2.3-工程还是应用程序? Mix是区分工程(projects)和应用程序(applications)的。 基于目前的```mix.exs```,我们可以说,我们有一个Mix __工程__,该工程定义了```:kv```应用程序。 在后面章节我们会看到,有些工程一个应用程序也没定义。 当我们讲“工程”时,你应该想到Mix。Mix是管理工程的工具。 它知道如何去编译、测试你的工程,等等。它还知道如何编译和启动你的工程的相关应用程序。 当我们讲“应用程序”时,我们讨论的是OTP。应用程序是一个实体,它作为一个整体启动或者停止。 你可以在[应用程序模块文档](http://elixir-lang.org/docs/stable/elixir/Application.html) 阅读更多关于应用程序的知识。或者执行```mix help compile.app``` 来学习```def application```中支持的更多选项。 ## 5.3 简单的一对一监督者 我们已经成功定义了我们的监督者,它作为我们应用程序生命周期的一部分自动启动(和停止)。 回顾一下,我们的```KV.Registry```在```handle_cast/2```回调中,链接并且监视bucket进程: ```elixir {:ok, pid} = KV.Bucket.start_link() ref = Process.monitor(pid) ``` 链接是双向的,意味着一个bucket进程挂了会导致注册表进程挂掉。 尽管现在我们有了监督者,它能保证一旦注册表进程挂了还可以重启。 但是注册表挂掉仍然意味着我们会丢失用来匹配bucket名称到其相应进程的数据。 换句话说,我们希望即使bucket进程挂了,注册表进程也能够保持运行。写成测试就是: ```elixir test "removes bucket on crash", %{registry: registry} do KV.Registry.create(registry, "shopping") {:ok, bucket} = KV.Registry.lookup(registry, "shopping") # Stop the bucket with non-normal reason Process.exit(bucket, :shutdown) # Wait until the bucket is dead ref = Process.monitor(bucket) assert_receive {:DOWN, ^ref, _, _, _} assert KV.Registry.lookup(registry, "shopping") == :error end ``` 这个测试很像之前的“退出时移除bucket”, 只是我们的做法更加残暴(用```:shutdown```代替了```:normal```)。 不像```Agent.stop/1```,```Process.exit/2```是一个异步的操作。 因此我们不能简单地在刚发了退出信号之后就执行查询```KV.Registry.lookup/2```, 那个时候也许bucket进程还没有结束(也就不会造成系统问题)。 为了解决这个问题,我们仍然要在测试期间监视bucket进程,然后在确保其已经结束时再去查询注册表进程, 避免竞争状态。 因为bucket是链接注册表进程的,而注册表进程是链接着测试进程。让bucket挂掉会导致测试进程挂掉: ``` 1) test removes bucket on crash (KV.RegistryTest) test/kv/registry_test.exs:52 ** (EXIT from #PID<0.94.0>) shutdown ``` 一个可行的解决方法是提供```KV.Bucket.start/0```,让它执行```Agent.start/1```。 在注册表进程中使用这个方法启动bucket,从而避免它们之间的链接。 但是这不是个好办法,因为这样bucket进程就链接不到任何进程。 这意味着所有bucket进程即使在有人停止了```:kv```程序也一直活着。 不光如此,它的进程会变得不可触及。而一个不可触及的进程是难以在运行时内省的。 我们将定义一个新的监督者来解决这个问题。这个新监督者会派生和监督所有的bucket。 有一个简单的一对一监督策略,叫做```:simple_one_for_one```,对于此情况是非常适用的: 他允许指定一个工人模板,而后监督基于那个模板创建的多个孩子。 在这个策略下,工人进程不会在监督者初始化时启动。而是每次调用了```start_child/2```函数后, 才会创建一个新的工人进程。 让我们在文件```lib/kv/bucket/supervisor.ex```中定义```KV.Bucket.Supervisor```: ```elixir defmodule KV.Bucket.Supervisor do use Supervisor # A simple module attribute that stores the supervisor name @name KV.Bucket.Supervisor def start_link() do Supervisor.start_link(__MODULE__, :ok, name: @name) end def start_bucket do Supervisor.start_child(@name, []) end def init(:ok) do children = [ worker(KV.Bucket, [], restart: :temporary) ] supervise(children, strategy: :simple_one_for_one) end end ``` 比起我们第一个监督者,这个监督者有三点改变。 相较于之前接受所注册进程的名字作为参数,我们这里只简单地将其命名为```KV.Bucket.Supervisor``` (代码中用```__MODULE__```),因为我们不需要派生这个进程的多个版本。 我们还定义了函数```start_bucket/0```来启动每个bucket, 作为这个名为```KV.Bucket.Supervisor```的监督者的孩子。 函数```start_bucket/0```代替了注册表进程中直接调用的```KV.Bucket.start_link```。 最后,在```init/1```回调中,我们将工人进程标记为```:temporary```。 意思是如果bucket进程即使挂了也不回重启。因为我们创建这个监督者,只是用来作为将bucket进程圈成组这么一种机制。 bucket进程的创建还应该通过注册表进程。 执行```iex -S mix```来试用下这个新监督者: ```elixir iex> {:ok, _} = KV.Bucket.Supervisor.start_link {:ok, #PID<0.70.0>} iex> {:ok, bucket} = KV.Bucket.Supervisor.start_bucket() {:ok, #PID<0.72.0>} iex> KV.Bucket.put(bucket, "eggs", 3) :ok iex> KV.Bucket.get(bucket, "eggs") 3 ``` 修改注册表进程中启动bucket的部分,来与bucket的监督者协同工作: ```elixir def handle_cast({:create, name}, {names, refs}) do if Map.has_key?(names, name) do {:noreply, {names, refs}} else {:ok, pid} = KV.Bucket.Supervisor.start_bucket() ref = Process.monitor(pid) refs = Map.put(refs, ref, name) names = Map.put(names, name, pid) {:noreply, {names, refs}} end end ``` 在做了这些修改之后,我们的测试还是会fail。因为bucket的监督者还没有启动。 但是我们将不会在每次测试启动时启动bucket的监督者,而是让其作为我们主监督者树的一部分自动启动。 ## 5.4-监督树 为了在应用程序中使用bucket的监督者,我们要把它作为一个孩子加到```KV.Supervisor```中去。 注意,我们已经开始用一个监督者去监督另一个监督者了---正式的称呼是“监督树”。 打开```lib/kv/supervisor.ex```,添加一个新的模块属性存储bucket监督者的名字, 并且修改```init/1```: ```elixir def init(:ok) do children = [ worker(KV.Registry, [KV.Registry]), supervisor(KV.Bucket.Supervisor, []) ] supervise(children, strategy: :one_for_one) end ``` 这里我们添加了一个监督者作为孩子(没有传递启动参数)。重新运行测试,测试将可以通过。 记住,声明各个孩子的顺序是很重要的。因为注册表进程依赖于bucket监督者, 所以bucket监督者需要在孩子列表中排得靠前一些。 因为我们已为监督者添加了多个孩子,现在就需要考虑使用```:one_for_one```这个策略还是否正确。 一个显现的问题就是注册表进程和bucket监督者之间的关系。 如果注册表进程挂了,bucket监督者也必须挂。 因为一旦注册表进程挂了,所有关联bucket名字和其进程的信息也就丢失了。 此时若bucket的监督者还活着,它掌管的众多bucket将根本访问不到,变成垃圾。 我们可以考虑使用其他的策略,如```:one_for_all```或```:rest_for_one```。 策略```:one_for_all```在任何时候,只要有一个孩子挂,它就会停止并且重启所有孩子进程。 这个貌似符合现在的需求,但是有些简单粗暴。因为如果bucket监督者进程挂了, 是没必要同时挂掉注册表进程的。因为注册表进程本身就监控这每个bucket进程的状态, 它会自己清理不需要的信息(挂掉的bucket)。因此,策略```:rest_for_one```是比较合适的。 它会单独重启挂掉的孩子进程,而不影响其它的。因此我们做如下修改: ```elixir def init(:ok) do children = [ worker(KV.Registry, [KV.Registry]), supervisor(KV.Bucket.Supervisor, []) ] supervise(children, strategy: :rest_for_one) end ``` 如果注册表进程挂了,那么它和bucket监督者都会被重启; 而如果只是bucket监督者进程挂了,那么只有它自己被重启。 还有其它几个策略或选项可以传递给```worker/2```,```supervisor/2```和```supervise/2```函数, 所以可别忘记阅读[监督者](http://elixir-lang.org/docs/stable/elixir/Supervisor.html) 及[监督者.spec](http://elixir-lang.org/docs/stable/elixir/Supervisor.Spec.html)的文档。 ## 5.5 观察者(Observer) 现在我们定义好了监督者树,这是介绍观察者工具(Observer tool)的最佳时机。 该工具和Erlang一同推出。使用```iex -S mix```启动你的应用程序,输入: ```elixir iex> :observer.start ``` 一个GUI窗口将弹出,里面包含了关于我们系统的各种信息:从总体统计信息到负载图表, 还有运行中的所有进程和应用程序。 在“应用程序”Tab页上,可以看到系统中运行的所有应用程序以及它们的监督者树信息。 可以选择```kv```查看它的详细信息: ![观察者窗口](http://elixir-lang.org/images/contents/kv-observer.png) 不但如此,如果你再命令行中创建新的bucket: ```elixir iex> KV.Registry.create KV.Registry, "shopping" :ok ``` 你可以在观察者工具中看到从监督者树种派生出了新的进程。 观察者工具就留给读者自行探索。你可以双击进程查看其详细信息, 还可以右击发送停止信号(模拟进程失败的完美方法)等等。 在每天辛苦工作快要结束的时候,一个像观察者这样的工具绝对是你还想着在监督者树里创建几条进程的主要原因之一。 即使创建的都是临时的,你也可以看看整个工程里各个进程还是不是可触及或是可内省的。 ## 5.6 测试里共享的状态 目前为止,我们是在每个测试中启动一个注册表进程,以确保它们是独立的: ```elixir setup context do {:ok, registry} = KV.Registry.start_link(context.test) {:ok, registry: registry} end ``` 因为我们已经将注册表进程改成使用```KV.Bucket.Supervisor```了,而它是在全局注册的, 因此现在我们的测试依赖于这个共享的、全局的监督者,即使每个测试仍使用自己的注册表进程。 那么问题来了:我们是否应该这么做? It depends。只要仅依赖于某一状态的非共享部分,那么也还ok啦。比如,每次用一个名字注册进程, 都是注册在一个共享的注册表中。尽管如此,只要确保每个名字用于不同的测试, 比如在创建时使用上下文参数```context.test```,就不会再测试间出现并行或者数据依赖的问题。 对我们的bucket监督者来说也是同样的道理。尽管多个注册表进程会在共享的bucket监督者上启动bucket, 但这些bucket和注册表进程之间是相互隔离的。我们唯一会遇到并发问题,是我们想要调用 函数```Supervisor.count_children(KV.Bucket.Supervisor)```的时候。 它统计所有注册表进程下的所有bucket。当测试并行执行并调用它的时候,返回的结果可能不一样。 因此,目前由于我们的测试依赖于共享的监督者中的非共享部分,我们不用担心并发问题。 假如它成为问题了,我们可以给每个测试启动一个监督者,并将其作为参数传递给注册表进程的```start_link```函数。 至此,我们的应用程序已经被监督者监督着,而且也已测试通过。之后我们要想办法提升一些性能。