Erlang/Elixir 中的 OTP 程式設計介紹

紳士喵發表於2018-06-28

前言

接觸 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 模組

借用官網的一張圖:

Erlang/Elixir 中的 OTP 程式設計介紹

其中方框表示監督程式,圓圈表示工作程式。監督程式又可以監督下一級的監督程式,每一個工作程式又被自己的監督程式監督,像極了企業中老闆、管理層和普通員工的關係。每一個監督者都可以定義被監督程式的重啟策略,每一個工人又可以定義自己的重啟時機。複雜可以配置一套高度定製化的容錯機制,簡單可以進行“永久執行”保證。

對了,上面實現的 hello_otp 應用是最簡單的根監督程式 + 一個工作程式的結構,但是如果我們將工作程式殺死,Hello! 不會再輸出了。嗯…… 好像哪裡不對的樣子 (⊙?⊙) 按理說不應該會立即重啟然後繼續輸出嗎?監督程式不就是幹這種事的麼?有關為什麼 hello_otp 應用的工作程式被殺死卻不重啟的原因這裡暫且不提,看了下一篇就會明白了:)

二、通用伺服器

在日常開發中,如果要實現一個基礎伺服器,需要涉及到狀態維護、程式建立、持續接收和響應訊息以及程式退出等功能。而 OTP 的通用伺服器(GenServer 模組)就是對 客戶端 - 服務端 模型的封裝,通用伺服器不僅可以簡單可靠的作為 C/S 模型依賴,其本身也是實現其它部分 OTP 行為模式的基礎。

最簡單的 C/S 模型示意圖(摘自官網文件):

Erlang/Elixir 中的 OTP 程式設計介紹

通用伺服器也是體現 OTP 核心目的的最典型例子,即:提取通用的程式碼/元件進行抽象,並儘可能的重用它們。

三、狀態機

狀態機(gen_statem)跟通用伺服器(gen_server) 一樣是 OTP 標準行為模式之一,對狀態機業務流程模擬的典型例子就是“開門/關門”:

Erlang/Elixir 中的 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

相關文章