构建 OTP 应用程序

为什么我想要这样?

A construction sign with a squid holdin a shovel, rather than a man doing so

在看到我们整个应用程序的监督树在一个简单的函数调用下同时启动后,我们可能会想知道为什么我们要把事情变得比现在更复杂。监督树背后的概念有点复杂,我可以想象自己只是在系统首次设置时使用脚本手动启动所有这些树和子树。然后,我可以自由地出去,在余下的下午时间里寻找看起来像动物的云。

这完全是正确的,是的。这是一种可接受的方式(尤其是关于云的部分,因为现在一切都与云计算有关)。然而,对于程序员和工程师所做的大多数抽象来说,OTP 应用程序是许多特定系统被概括化和清理的结果。如果你要制作一系列脚本和命令来启动你上面描述的监督树,而你合作的其他开发人员也有自己的脚本和命令,你很快就会遇到巨大的问题。然后有人会问诸如“如果每个人都使用同一种系统启动一切,那不是很好吗?如果他们都拥有相同的应用程序结构,那不是更棒吗?”

OTP 应用程序试图解决这种确切类型的问题。它们提供目录结构、处理配置的方法、处理依赖项的方法、创建环境变量和配置、启动和停止应用程序的方法,以及在检测冲突和处理实时升级时进行大量安全控制,而无需关闭你的应用程序。

所以,除非你不想使用这些方面(以及它们带来的便利,例如一致的结构和为此开发的工具),否则本章应该让你感兴趣。

我的另一辆车是游泳池

我们将重用上一章为它编写的 ppool 应用程序,并将其变成一个真正的 OTP 应用程序。

第一步是将所有与 ppool 相关的文件复制到一个整洁的目录结构中

ebin/
include/
priv/
src/
 - ppool.erl
 - ppool_sup.erl
 - ppool_supersup.erl
 - ppool_worker_sup.erl
 - ppool_serv.erl
 - ppool_nagger.erl
test/
 - ppool_tests.erl

目前大多数目录将保持为空。正如 设计并发应用程序 一章中所解释的,ebin/ 目录将保存编译后的文件,include/ 目录将包含 Erlang 头文件 (.hrl) 文件,priv/ 将保存可执行文件、其他程序以及应用程序正常工作所需的其他特定文件,src/ 将保存你需要的 Erlang 源文件。

A pool with wheels and an exhaust pipe

你会注意到,我添加了一个 test/ 目录,专门用于我之前使用的测试文件。这样做的原因是测试比较常见,但你不一定希望将它们作为应用程序的一部分进行分发——你只需要在开发代码时和向经理证明自己时(“测试通过,我不明白为什么应用程序杀死了人”)使用它们。其他类似的目录会根据需要添加,具体取决于情况。一个例子是 doc/ 目录,在你想将 EDoc 文档添加到应用程序时添加。

需要具有的四个基本目录是 ebin/include/priv/src/,它们几乎会在你遇到的每个 OTP 应用程序中都很常见,不过当真正的 OTP 系统部署时,只有 ebin/priv/ 会被导出。

应用程序资源文件

我们该从哪里开始呢?首先要做的就是添加一个应用程序文件。这个文件将告诉 Erlang VM 应用程序是什么,从哪里开始,在哪里结束。这个文件与所有编译后的模块一起存在于 ebin/ 目录中。

这个文件通常命名为 <yourapp>.app(在我们的例子中是 ppool.app),并包含一堆 Erlang 项,这些项以 VM 可以理解的方式定义了应用程序(VM 在猜测方面很糟糕!)

注意:有些人更喜欢将此文件保留在 ebin/ 之外,而是将一个名为 <myapp>.app.src 的文件作为 src/ 的一部分。无论他们使用什么构建系统,都会将此文件复制到 ebin/ 中,甚至生成一个文件以保持一切干净。

应用程序文件的基本结构很简单

{application, ApplicationName, Properties}.

其中 ApplicationName 是一个原子,Properties 是一个 {Key, Value} 元组列表,用于描述应用程序。它们被 OTP 用于弄清楚你的应用程序做了什么等等,它们都是可选的,但可能始终有用,并且对于某些工具来说是必要的。事实上,我们现在只关注其中的一部分,并在需要时介绍其他部分

{description, "Some description of your application"}

这为系统提供了应用程序的简短描述。该字段是可选的,默认为空字符串。我建议始终定义一个描述,即使只是因为它使事情更容易阅读。

{vsn, "1.2.3"}

告诉应用程序的版本是什么。该字符串可以采用任何你想要的格式。通常最好坚持使用 <major>.<minor>.<patch> 或类似格式的方案。当我们开始使用工具来帮助进行升级和降级时,该字符串将用于标识应用程序的版本。

{modules, ModuleList}

包含应用程序向系统引入的所有模块的列表。一个模块最多属于一个应用程序,并且不能同时出现在两个应用程序的 app 文件中。此列表允许系统和工具查看应用程序的依赖项,确保所有内容都位于应在的位置,并且你的应用程序与系统中已加载的其他应用程序之间没有冲突。如果你使用的是标准 OTP 结构并使用 rebar3 这样的构建工具,则会为你处理。

{registered, AtomList}

包含应用程序注册的所有名称的列表。这使 OTP 知道当你尝试将一堆应用程序捆绑在一起时何时会发生名称冲突,但这完全基于对开发人员提供良好数据的信任。我们都知道情况并非总是如此,因此在这种情况下不应该盲目信任。

{env, [{Key, Val}]}

这是一个键值对列表,可用作应用程序的配置。可以通过调用 application:get_env(Key)application:get_env(AppName, Key) 在运行时获取它们。第一个将在调用时尝试在当前所在的应用程序的应用程序文件中找到该值,第二个允许你指定特定应用程序。这些内容可以根据需要被覆盖(在引导时或使用 application:set_env/3-4)。

总而言之,这是一个存储配置数据的非常好的地方,而不是使用一堆以各种格式读取的配置文件,而不确定在哪里存储它们等等。人们通常倾向于自己构建系统,因为不是每个人都喜欢在配置文件中使用 Erlang 语法。

{maxT, Milliseconds}

这是应用程序可以运行的最大时间,之后它将被关闭。这是一个很少使用的项目,Milliseconds 默认值为 infinity,因此你通常根本不需要理会它。

{applications, AtomList}

你的应用程序依赖的应用程序列表。Erlang 的应用程序系统将确保它们在允许你的应用程序这样做之前被加载和/或启动。所有应用程序至少依赖于 kernelstdlib,但是如果你的应用程序依赖于 ppool 的启动,则应将 ppool 添加到列表中。

注意:是的,标准库和 VM 的内核本身就是应用程序,这意味着 Erlang 是一种用于构建 OTP 的语言,但其运行时环境依赖于 OTP 才能运行。这是一个循环。这让你了解为什么这种语言正式被称为“Erlang/OTP”。

{mod, {CallbackMod, Args}}

定义应用程序的回调模块,使用应用程序行为(我们将在 下一节 中看到)。这告诉 OTP 在启动你的应用程序时,应调用 CallbackMod:start(normal, Args)。当 OTP 在停止应用程序时调用 CallbackMod:stop(StartReturn) 时,此函数的返回值将被使用。人们往往会根据他们的应用程序命名 CallbackMod

这涵盖了我们现在可能需要的大部分内容(以及你可能编写的绝大多数应用程序)。

转换池

我们试着实践一下吧?我们将把上一章中的一组 ppool 进程变成一个基本的 OTP 应用程序。第一步是将所有内容重新分发到正确的目录结构下。只需创建五个目录,并将文件分发如下

ebin/
include/
priv/
src/
	- ppool.erl
	- ppool_serv.erl
	- ppool_sup.erl
	- ppool_supersup.erl
	- ppool_worker_sup.erl
test/
	- ppool_tests.erl
	- ppool_nagger.erl

你会注意到,我将 ppool_nagger 移动到了测试目录中。这是有充分理由的——它只是一个演示案例,与我们的应用程序无关,但对于测试来说仍然是必要的。我们实际上可以在应用程序打包好之后再尝试它,以确保一切仍然有效,但目前它有点没用。

我们将添加一个 Emakefile(恰当地命名为 Emakefile,放置在应用程序的根目录中)来帮助我们以后编译和运行东西

{"src/*", [debug_info, {i,"include/"}, {outdir, "ebin/"}]}.
{"test/*", [debug_info, {i,"include/"}, {outdir, "ebin/"}]}.

这只是告诉编译器为 src/test/ 中的所有文件包含 debug_info,告诉它去 include/ 目录中查找(如果需要的话),然后将文件塞到它的 ebin/ 目录中。

说到这里,让我们在 ebin/ 目录中添加 app 文件

{application, ppool,
 [{vsn, "1.0.0"},
  {modules, [ppool, ppool_serv, ppool_sup, ppool_supersup, ppool_worker_sup]},
  {registered, [ppool]},
  {mod, {ppool, []}}
 ]}.

这个文件只包含我们认为必要的字段;envmaxTapplications 没有使用。我们现在需要更改回调模块 (ppool) 的工作方式。我们究竟该如何做呢?

首先,让我们看看应用程序行为。

注意:即使所有应用程序都依赖于 kernelstdlib 应用程序,我也没有将它们包含在内。ppool 仍然可以正常工作,因为启动 Erlang VM 会自动启动这些应用程序。你可能会想将它们添加进去以示明确,但现在还没有必要这样做。

Parody of Indiana Jones' scene where he substitutes a treasure for a fake weight. The piece of gold has 'generic' written on it, and the fake weight has 'specific' on it

应用程序行为

就像我们已经看到的多数 OTP 抽象一样,我们想要的是一个预构建的实现。Erlang 程序员不满意将设计模式作为一种约定,他们希望为它们提供一个可靠的抽象。这为我们提供了应用程序的行为。请记住,行为总是关于将通用代码与特定代码分离。它们表示你的特定代码放弃自己的执行流程,并将自己插入作为一系列回调,供通用代码使用。用更简单的话说,行为处理无聊的部分,而你负责连接这些部分。在应用程序的情况下,这部分通用代码非常复杂,并不像其他行为那样简单。

每当 VM 首次启动时,一个名为应用程序控制器的进程就会启动(名称为application_controller)。它启动所有其他应用程序,并位于大多数应用程序之上。事实上,你可以说应用程序控制器有点像所有应用程序的监督者。我们将在从混沌到应用程序部分看到有哪些监督策略。

注意:应用程序控制器在技术上并不位于所有应用程序之上。一个例外是内核应用程序,它本身启动一个名为user的进程。实际上,user进程充当应用程序控制器的组长,因此内核应用程序需要一些特殊处理。我们不必关心这一点,但我觉得为了精确起见应该包含它。

在 Erlang 中,IO 系统依赖于一个名为组长的概念。组长代表标准输入和输出,并被所有进程继承。有一个隐藏的IO 协议,组长和任何调用 IO 函数的进程都与之通信。然后,组长负责将这些消息转发到任何存在的输入/输出通道,并在我们本文的范围内做一些我们不关心的魔法。

无论如何,当有人决定要启动一个应用程序时,应用程序控制器(在 OTP 行话中通常简称为AC)会启动一个应用程序管理器。应用程序管理器实际上是两个负责每个独立应用程序的进程:它们设置应用程序并充当应用程序顶级监督者和应用程序控制器之间的中间人。OTP 是一个官僚机构,我们有很多层级的中层管理人员!我不会详细说明在那里发生了什么,因为大多数 Erlang 开发人员实际上永远不需要关心这些,而且很少有文档存在(代码就是文档)。只要知道应用程序管理器有点像应用程序的保姆(好吧,一个非常疯狂的保姆)。它监督着它的孩子和孙子,当事情出错时,它就会发疯并终止整个家族树。残忍地杀死孩子是 Erlang 程序员之间的一个常见话题。

具有多个应用程序的 Erlang VM 可能看起来有点像这样

The Application controller stands over three application masters (in this graphic, in real life it has many more), which each stand on top of a supervisor process

到目前为止,我们仍在查看行为的通用部分,但具体的东西呢?毕竟,这才是我们实际需要编程的。好吧,应用程序回调模块只需要很少的函数才能正常工作:start/2stop/1

第一个函数的形式为YourMod:start(Type, Args)。目前,Type 将始终为normal(接受的其他可能性与分布式应用程序有关,我们将在以后看到)。Args 是来自你的应用程序文件的。该函数初始化你的应用程序的所有内容,只需要以以下两种形式之一返回应用程序顶级监督者的 Pid:{ok, Pid}{ok, Pid, SomeState}。如果你没有返回SomeState,它将简单地默认为[]

stop/1 函数将start/2 返回的状态作为参数。它在应用程序运行完毕后运行,只做必要的清理工作。

就是这样。一个庞大的通用部分,一个微小的特定部分。对此表示感谢,因为你不想经常编写其他东西(如果你想,看看源代码!)。还有一些你可以选择使用的其他函数,以获得对应用程序的更多控制,但我们现在不需要它们。这意味着我们可以继续我们的ppool应用程序!

从混沌到应用程序

我们有了应用程序文件,以及对应用程序如何工作的一般了解。两个简单的回调。打开ppool.erl,我们更改以下几行

-export([start_link/0, stop/0, start_pool/3,
         run/2, sync_queue/2, async_queue/2, stop_pool/1]).

start_link() ->
    ppool_supersup:start_link().

stop() ->
    ppool_supersup:stop().

改为以下几行

-behaviour(application).
-export([start/2, stop/1, start_pool/3,
         run/2, sync_queue/2, async_queue/2, stop_pool/1]).

start(normal, _Args) ->
    ppool_supersup:start_link().

stop(_State) ->
    ok.

然后我们可以确保测试仍然有效。选择旧的ppool_tests.erl 文件(我为上一章编写了它,现在把它拿回来),将对ppool:start_link/0 的单个调用替换为application:start(ppool),如下所示

find_unique_name() ->
    application:start(ppool),
    Name = list_to_atom(lists:flatten(io_lib:format("~p",[now()]))),
    ?assertEqual(undefined, whereis(Name)),
    Name.

你也可以花点时间从ppool_supersup 中删除stop/0(并删除导出),因为 OTP 应用程序工具将为我们处理这些事。

我们终于可以重新编译代码并运行所有测试,以确保一切仍然正常(我们将在以后看到eunit 的工作方式,别担心)

$ erl -make
Recompile: src/ppool_worker_sup
Recompile: src/ppool_supersup
...
$ erl -pa ebin/
...
1> make:all([load]).
Recompile: src/ppool_worker_sup
Recompile: src/ppool_supersup
Recompile: src/ppool_sup
Recompile: src/ppool_serv
Recompile: src/ppool
Recompile: test/ppool_tests
Recompile: test/ppool_nagger
up_to_date
2> eunit:test(ppool_tests).
  All 14 tests passed.
ok

由于timer:sleep(X) 被用于在几个地方同步所有内容,因此测试需要一段时间才能运行,但它应该告诉你一切正常,如上所示。好消息是,我们的应用程序很健康。

我们现在可以使用新的强大回调来研究 OTP 应用程序的奇迹

3> application:start(ppool).
ok
4> ppool:start_pool(nag, 2, {ppool_nagger, start_link, []}).
{ok,<0.142.0>}
5> ppool:run(nag, [make_ref(), 500, 10, self()]).
{ok,<0.146.0>}
6> ppool:run(nag, [make_ref(), 500, 10, self()]).
{ok,<0.148.0>}
7> ppool:run(nag, [make_ref(), 500, 10, self()]).
noalloc
9> flush().
Shell got {<0.146.0>,#Ref<0.0.0.625>}
Shell got {<0.148.0>,#Ref<0.0.0.632>}
...
received down msg
received down msg

这里的魔法命令是application:start(ppool)。它告诉应用程序控制器启动我们的 ppool 应用程序。它启动ppool_supersup 监督者,从那时起,所有内容都可以照常使用。我们可以通过调用application:which_applications() 查看当前运行的所有应用程序

10> application:which_applications().
[{ppool,[],"1.0.0"},
 {stdlib,"ERTS  CXC 138 10","1.17.4"},
 {kernel,"ERTS  CXC 138 10","2.14.4"}]

真是个惊喜,ppool 正在运行。如前所述,我们可以看到所有应用程序都依赖于kernelstdlib,它们都在运行。如果我们想关闭池

11> application:stop(ppool).

=INFO REPORT==== DD-MM-YYYY::23:14:50 ===
    application: ppool
    exited: stopped
    type: temporary
ok

就这样完成了。你应该注意到,我们现在得到了一个干净的关闭,并带有一个简短的提示报告,而不是上一章混乱的** exception exit: killed

注意:你有时会看到人们使用MyApp:start(...) 而不是application:start(MyApp)。虽然这在测试目的上有效,但它破坏了许多拥有应用程序的实际优势:它不再是 VM 监督树的一部分,无法访问其环境变量,启动之前不会检查依赖关系,等等。如果可能,尽量坚持使用application:start/1

看看这个!关于我们的应用程序是临时的,这到底是怎么回事?我们编写 Erlang 和 OTP 代码是因为它应该永远运行,而不仅仅是运行一段时间!VM 怎么敢这么说?秘密是我们可以给application:start 传递不同的参数。根据参数的不同,VM 对其应用程序之一的终止将做出不同的反应。在某些情况下,VM 将成为一个爱护孩子的野兽,准备为其孩子而死。在其他情况下,它更像一台冷酷无情、务实的机器,愿意容忍许多孩子为了其物种的生存而死亡。

使用以下命令启动应用程序:application:start(AppName, temporary)
正常结束:不会发生特殊情况,应用程序已停止。
异常结束:报告错误,应用程序终止,不会重启。
使用以下命令启动应用程序:application:start(AppName, transient)
正常结束:不会发生特殊情况,应用程序已停止。
异常结束:报告错误,所有其他应用程序都停止,VM 关闭。
使用以下命令启动应用程序:application:start(AppName, permanent)
正常结束:所有其他应用程序都终止,VM 关闭。
异常结束:相同;所有应用程序都终止,VM 关闭。

在应用程序的监督策略中,你可以看到一些新的东西。VM 将不再尝试拯救你。此时,必须发生了一些非常严重的问题,才会导致它上升到其重要应用程序之一的整个监督树,足以使其崩溃。当这种情况发生时,VM 对你的程序失去了所有希望。鉴于疯狂的定义是重复做同样的事情,却每次都期望不同的结果,因此 VM 宁愿理智地死去,放弃。当然,真正的原因是有一些需要修复的错误,但你明白我的意思。请注意,所有应用程序都可以通过调用application:stop(AppName) 来终止,而不会影响其他应用程序,就像发生崩溃一样。

库应用程序

当我们想要将平面模块封装到一个应用程序中,但我们没有进程要启动,因此不需要应用程序回调模块时会发生什么?

在拔掉头发并愤怒地哭泣了几分钟之后,剩下的唯一一件事就是从应用程序文件中删除元组{mod, {Module, Args}}。就是这样。这被称为库应用程序。如果你想要一个例子,Erlang 的stdlib(标准库)应用程序就是其中之一。

如果你有 Erlang 的源代码包,你可以转到otp_src_<release>/lib/stdlib/src/stdlib.app.src 并查看以下内容

{application, stdlib,
 [{description, "ERTS  CXC 138 10"},
  {vsn, "%VSN%"},
  {modules, [array,
	 ...
     gen_event,
     gen_fsm,
     gen_server,
     io,
	 ...
     lists,
	 ...
     zip]},
  {registered,[timer_server,rsh_starter,take_over_monitor,pool_master,
               dets]},
  {applications, [kernel]},
  {env, []}]}.

你可以看到它是一个相当标准的应用程序文件,但没有回调模块。一个库应用程序。

我们如何更深入地了解应用程序呢?