Erlang/Elixir 中的 OTP 编程介绍

前言

接触 Elixir 也有一定的时间了(接近一个月了),这是一门我非常看好的语言,它有令人舒服的语法和友好的编程方式以及强大优雅的 Erlang 并发设计。

其实在最初我没想过要接触 Erlang,我之所以选择 Elixir 而不是 Erlang 也是因为“道听途书”自己给 Erlang 扣上了莫虚乌有的“语法怪异”的帽子。语法怪异就会产生更多的担心:是不是写起代码来很啰嗦、很别捏?
并且在我大概用两天时间学完 Elixir 的基础内容,比较充分的体会到 Elixir 的优雅之后就便更加担心 Erlang 是不是设计落后所以才有了 Elixir?

直到终于因为我无法理解 Elixir 官方指南中的 OTP 编程,我才明白不学 Erlang 就企图彻底搞懂 OTP 设计是一种“妄想”。基于这个原因才导致我正式接触了 Erlang 以及原生的 TOP,也正是这个决定让我理解了什么是 OTP 编程的同时还避免因为“误解”而错过 Erlang 这么优秀的语言。

所以我最初决定接触 Erlang 的理由,便是这篇文章的主题:Elixir 中的 OTP 编程是什么?我会尽可能的以 Elixir 角度来剖析,并带入 Erlang 中的设计原则。毕竟不是每一个 Elixir 开发者都必须是 Erlang 的用户,这是加分项但不是必选项。

OTP 概念

OTP 是 Open Telecom Platform(开放电信平台)的缩写。这个命名的由来可能跟 Erlang 最初服务的业务相关,毕竟 Erlang 曾经是通信行业巨头爱立信所有的私有软件。实际上后来 OTP 的设计和功能已经脱离了它名称的本意,所以 OTP 应该被看作一种名意无关的概念。

在 Erlang/Elixir 中也许你已经可以利用语言内置的功能来实现一些常见的并发场景,但是假设每个人每个项目都要这么做一遍或者多遍那就显得太多余了。作为一个有足够编码经验的你一定能想到可以将它们组合起来抽象成为适用一定场景或尽可能通用的“框架”,这便是 OTP。使用 OTP 只需要实现 OTP 的行为模式并基于行为模式的 API 设计作为通信细节,便可以涵盖到各种场景下,让开发者更专注业务的实现,不必为并发和容错而担忧。

OTP 应用

与大多数程序以及编程语言相反,OTP 应用本身不具备一个阻塞程序执行的主执行流(线程/进程之类的并行单元)。准确的说是 OTP 应用自身的进程并不阻塞应用,Erlang 的面向进程编程便是这个的前提。

对于 OTP 应用而言,应用本身是由多个进程组成的,一般来讲是一种监督树结构,这些进程会出现不同的分工但不会具备任何特权。与之相对的,例如常规程序是由一个启动应用的线程阻塞来维持运行的,如果这个线程结束了那么程序就结束了(通常所有的后台线程会被释放)。但是 OTP 应用是由 ERTS(Erlang 运行时系统) 来加载启动的,每一个进程都是平等的,你会发现其实每一个 OTP 应用都类似于由多个微服务(进程)组成的系统,面向进程编程就是在这个系统上开发出一个个的“微服务”,具备这个原则设计的程序便是 OTP 应用。

我们用实际代码来举例,首先我们创建一个 hello_main 项目:

mix new hello_main

修改 lib/hello_main.ex 文件,添加一个用作启动的入口函数(main/0),逻辑为调用一个无限递归的输出 Hello! 字符串的函数(loop_echo/1):

defmodule HelloMain do

  def main do
    loop_echo("Hello!")
  end

  def loop_echo(text) do
    IO.puts(text)
    :timer.sleep(1000)
    loop_echo(text)
  end
end

执行(启动)这个程序:

iex -S mix run -e HelloMain.main

我们会看到如下输出:

Erlang/OTP 21 [erts-10.0] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] [hipe]

Compiling 1 file (.ex)
Generated hello_main app
Hello!
Hello!
Hello!
# ……

注意了,这时候我们的 iex 终端被 main 中执行的进程阻塞了,且该进程完全不受任何管理。

(注意,你完全也可以通过 escript 来模拟这个程序,会更加直观。只不过 Elixir 的 escrit 需要 mix 支持,反而不直观)

接着我们再实现一个相同功能的程序,但是以 OTP 的原则来进行组织。创建 hello_otp 项目:

mix new hello_otp

修改 mix.exs 添加回调模块:

def application do
  [
    mod: {HelloOtp, []},
    # ……
  ]
end

给 lib/hello_otp.ex 添加相同的 loop_echo 函数,并实现 Application 行为模式:

defmodule HelloOtp do
  use Application

  def start(_type, _args) do
    children = [{Task, fn -> loop_echo("Hello!") end}]
    Supervisor.start_link(children, strategy: :one_for_one)
  end

  # loop_echo/1 defined here ……
end

启动应用(注意因为我们实现了 Application 并定义了回调模块,不需要手动指定入口函数):

iex -S mix

在这里,我们使用了监督进程来启动并管理调用 loop_echo 函数的进程。并且由于监督进程并不会阻塞 iex 终端进程输入,所以在输出 Hello! 的同时还能正常使用 iex 的功能。

两个程序的不同之处在于,hello_main 的整个执行周期都不会将入口函数 main 执行完毕,因为这是一个不可能返回的函数逻辑,哪怕强行终止程序。而 hello_opt 的 start/2 函数在启动监督进程以后就立即结束了,返回了相应的结果。所以此时监督进程和被监督的进程都是后台运行状态,并且进程之间被正确的组织起来管理。

PS:实际上 hello_otp 的监督进程和 iex 终端进程是平级的关系。

hello_otp 的特点是正确的实现了 Application 行为模式(返回了结果),整个应用是由一个或多个进程组成,每个进程都在后台运行,进程和 ERTS 中内置的应用进程一样被正确组织起来。
而 hello_main 更接近于我们所见到的常规程序,第一个启动的进程阻塞执行,它结束应用便结束。相信看到这里,你也应该大概能明白对 OTP 应用的定义了。

OTP 应用本质

如果你接触过 Erlang 并组织过 OTP 应用,那你应该知道每一个应用都存在一份“规范”,这份规范会被 Application 模块载入(对于上述的 hello_otp 应用而言在载入之后还会被回调指定函数)。

我们脱离 mix 手动调用模块来重现这一点,不过前提是确保你的程序已经经过编译:

mix compile

直接运行 iex(或者 erl):

iex -pa _build/dev/lib/hello_otp/ebin/

(-pa 参数是将手动指定的路径添加到模块搜索路径的列表中,这样子才可以在载入时找到我们自己的模块)

跟之前不同的是,这个时候 iex 的控制台并没有输出 Hello!,因为应用没有被载入更不会被启动,我们要手动做这一步:

Application.start :hello_otp # 如果是 erl 则使用 application:start(hello_otp)

控制台会打印一个 :ok(Application.start/1 函数返回值),然后不断的输出 Hello!,跟使用 mix 启动的效果是一样的,只是这些步骤被 mix run 做了而已。

别忘了上面提过,每一个 OTP 应用都有一份“规范”文件,Application.start/1 函数首先做的就是寻找这份规范文件,然后根据解析结果载入模块。我们可以从 _build/dev/lib/hello_otp/ebin 目录中看到一个名为 hello_otp.app 的文件,这便是所谓的“规范”文件。它的格式是一个 Erlang 元组,其中 mod 定义了入口模块(也就是之前 mix.exs 中添加过的),之所以执行 Application.start(:hello_otp) 会回调 HelloOtp.start/2 函数也是这个原因。

这让我们明白了,OTP 应用其实就是被 ERTS 载入的一系列模块,应用启动的进程由实现 Application 行为模式的入口模块在回调函数的执行过程中产生。

那么,不产生进程的但符合 OTP 应用结构的模块被载入以后,它算不算 OTP 应用呢?答案是:算。不产生进程的 OTP 应用很常见,那就是“库”应用。实际上我们也能将 hello_otp 作为库应用载入(重新进入 iex):

Application.load :hello_otp

调用 Application.load/1 函数发现同样返回了 :ok,不过没有任何 Hello! 产生,因为并没有回调 HelloOtp.start/2 函数。此时你可以手动调用 HelloOtp.start/2 或者 HelloOtp.loop_echo/1 函数,聪明的你一定意识到了,这时候的 hello_otp 便成为了一个“库应用”。如果你想让这个库产生进程,即启动 hello_otp 程序,只需要:

HelloOtp.start(:normal, [])

手动调用 HelloOtp.start/2 函数即可。也就是说对于 Erlang/Elixir 而言,更加是对于 OTP 原则组织的模块而言,库和具有入口的程序区别不大,它们都被称之为“应用”。

所以写到这里有必要推翻上面说过的 OTP 应用启动会产生一个或多个后台进程,这并不是必须的。如果要明确的定义某个程序是否属于 OTP 应用,只需要从它的模块组织上来看就行了,其运行过程并不重要。但是即便模块组织上符合规范,仍然可能存在有问题的 OTP 应用:例如不正确的实现行为模式。如果我在 start/2 函数中不启动监督进程,而是直接调用 loop_echo/1,这样的做法会导致前台进程阻塞,start/2 回调函数永远无法返回,和 hello_main 也没多少区别了。

OTP 设计原则

终于讲到这里了,OTP 究竟是怎样设计的?它的设计分别落实到那些实体概念?要深入讲解 OTP 其实有很多细节需要描述,而这一节只是对 OTP 的设计做一个大体概括上的描述。具体的 OTP 实践讲解会新开一篇文章。

一、监督树

监督树对于 OTP 而言是非常重要的一个概念,也是 OTP 实现“高容错”保证的基石。简单来讲,监督树是一种组织进程的方式,因为进程整体是一个树结构,而根是又一个最顶级的监督进程,所以称作“监督树”。

PS:构建监督树需要 Supervisor 模块

借用官网的一张图:

其中方框表示监督进程,圆圈表示工作进程。监督进程又可以监督下一级的监督进程,每一个工作进程又被自己的监督进程监督,像极了企业中老板、管理层和普通员工的关系。每一个监督者都可以定义被监督进程的重启策略,每一个工人又可以定义自己的重启时机。复杂可以配置一套高度定制化的容错机制,简单可以进行“永久运行”保证。

对了,上面实现的 hello_otp 应用是最简单的根监督进程 + 一个工作进程的结构,但是如果我们将工作进程杀死,Hello! 不会再输出了。嗯…… 好像哪里不对的样子 (⊙?⊙) 按理说不应该会立即重启然后继续输出吗?监督进程不就是干这种事的么?有关为什么 hello_otp 应用的工作进程被杀死却不重启的原因这里暂且不提,看了下一篇就会明白了:)

二、通用服务器

在日常开发中,如果要实现一个基础服务器,需要涉及到状态维护、进程创建、持续接收和响应消息以及进程退出等功能。而 OTP 的通用服务器(GenServer 模块)就是对 客户端 - 服务端 模型的封装,通用服务器不仅可以简单可靠的作为 C/S 模型依赖,其本身也是实现其它部分 OTP 行为模式的基础。

最简单的 C/S 模型示意图(摘自官网文档):

通用服务器也是体现 OTP 核心目的的最典型例子,即:提取通用的代码/组件进行抽象,并尽可能的重用它们。

三、状态机

状态机(gen_statem)跟通用服务器(gen_server) 一样是 OTP 标准行为模式之一,对状态机业务流程模拟的典型例子就是“开门/关门”:

上图摘自一篇介绍 Drupal 工作流的文章(不是我懒得画图,而是有关状态机的概念描述已经够多了,我不必多次一举)。在这个例子中,门会根据输入转换为三种状态(开启、关闭和锁定),不过门需要从锁定状态(Locked)转换为关闭(Closed)状态以后才能打开(Opened),不能将一个上锁的门在不经过解锁的情况下直接打开,即从 Locked 直接转换为 Opened。

注意:Elixir 并没有对 Erlang 的 gen_statem 模块进行包装,另外 Erlang 19.0 之前提供的相关行模式为 gen_fsm。

gen_statem 模块跟 GenServer 模块的设计很相似,并且在一定程度上 GenServer 也能解决类似业务。在官方的建议中,如果业务流程足够简单,并且未来也不会遇到需要实现 gen_statem 行为模式才能完全适应你的问题的情形,那么仅使用 GenServer 即可。

四、事件管理器

事件管理器(gen_event)也是 OTP 标准行为模式,其 API 设计跟通用服务器(gen_server)相似,但是运作方式却不同。

事件管理器之所以叫“管理器”是因为它并不直接处理事件,而是管理事件“处理器”,而事件处理器才是实现 gen_event 行为模式的具体模块。事件管理器本质上是一个维护 {Module,State} 对的列表,Module 即事件处理器,State 是处理器的内部状态。
在监督树中,往往只用启动一个 gen_event 事件管理器,然后“热插拔”多个事件处理器。在需要的时候添加,不需要的时候删除。正是这种一对多的关系决定了它与通用服务器的运作方式的不同。

最后

Erlang 和 Elixir 都是非常不错的语言,OTP 这一套更是 Erlang/Elixir 坚强的后盾。使用 OTP 原则设计应用程序,能足够保证程序的健壮性,因为 OTP 经过严格而充分的测试。并且应用 OTP 的行为模式,能大大提高程序的可读性,毕竟它们是人尽皆知(步入 Erlang 的必经之路)的设计模式。有关 OTP 行为模式具体案例的讲解,我会再下一篇发出来,而本文也到此未知:)

最后欢迎小伙伴加 Telegram 群学习和交流(Erlang/Elixir):https://t.me/elixir_cn