Elixir 和 OTP 中面向過程的編程指南

已發表: 2022-03-11

人們喜歡將編程語言分類為範式。 有面向對象 (OO) 語言、命令式語言、函數式語言等。這有助於確定哪些語言解決了類似的問題,以及一種語言打算解決哪些類型的問題。

在每種情況下,範式通常都有一個“主要”焦點和技術,這是該語言家族的驅動力:

  • 在 OO 語言中,它是類或對象,作為封裝狀態(數據)和操作該狀態(方法)的一種方式。

  • 在函數式語言中,它可以是函數本身的操作,也可以是從函數傳遞到函數的不可變數據

雖然 Elixir(和之前的 Erlang)通常被歸類為函數式語言,因為它們展示了函數式語言共有的不可變數據,但我認為它們代表了與許多函數式語言不同的範式。 它們之所以存在並被採用是因為 OTP 的存在,所以我將它們歸類為面向過程的語言

在這篇文章中,我們將了解使用這些語言時面向過程編程的含義,探索與其他範例的異同,了解培訓和採用的含義,並以一個簡短的面向過程編程示例結束。

什麼是面向過程的編程?

讓我們從一個定義開始:面向過程的編程是一種基於通信順序過程的範式,最初來自於 Tony Hoare 在 1977 年的一篇論文。這也通常稱為並發的參與者模型。 與此原始作品有一定關係的其他語言包括 Occam、Limbo 和 Go。 正式論文只涉及同步通信; 大多數參與者模型(包括 OTP)也使用異步通信。 總是可以在異步通信之上構建同步通信,而 OTP 支持這兩種形式。

在這段歷史上,OTP 通過通信順序進程創建了一個容錯計算系統。 容錯設施來自“讓它失敗”的方法,以監督者的形式進行可靠的錯誤恢復,並使用參與者模型啟用的分佈式處理。 “讓它失敗”可以與“防止它失敗”形成對比,因為前者更容易適應,並且在 OTP 中被證明比後者更可靠。 原因是防止失敗所需的編程工作(如 Java 檢查異常模型中所示)涉及更多且要求更高。

因此,面向過程的編程可以定義為一種範式,其中系統的進程結構和進程之間的通信是主要關注點

面向對象與面向過程的編程

在面向對象編程中,數據和函數的靜態結構是主要關注點。 操作封閉的數據需要什麼方法,對像或類之間的連接應該是什麼。 因此,UML 的類圖是這個焦點的一個主要例子,如圖 1 所示。

面向過程的編程:示例 UML 類圖

可以注意到,對面向對象編程的一個常見批評是沒有可見的控制流。 因為系統是由大量單獨定義的類/對象組成的,所以經驗不足的人可能難以可視化系統的控制流。 對於具有大量繼承、使用抽象接口或沒有強類型的系統尤其如此。 在大多數情況下,對於開發人員來說,記住大量的系統結構以使其有效變得很重要(哪些類有哪些方法,哪些以什麼方式使用)。

面向對象的開發方法的優勢在於,只要新的對像類型符合現有代碼的期望,系統就可以擴展以支持對現有代碼影響有限的新類型的對象。

功能與面向過程的編程

許多函數式編程語言確實以各種方式解決並發問題,但它們的主要關注點是函數之間的不可變數據傳遞,或者從其他函數(生成函數的高階函數)創建函數。 在大多數情況下,該語言的重點仍然是單個地址空間或可執行文件,並且此類可執行文件之間的通信以操作系統特定的方式處理。

例如,Scala 是一種建立在 Java 虛擬機上的函數式語言。 雖然它可以訪問 Java 工具進行通信,但它不是該語言的固有部分。 雖然它是 Spark 編程中使用的一種通用語言,但它又是一個與該語言結合使用的庫。

功能範式的優勢在於能夠在給定頂級功能的情況下可視化系統的控制流。 控制流是明確的,每個函數調用其他函數,並將所有數據從一個函數傳遞到下一個函數。 在功能範式中沒有副作用,這使得問題確定更容易。 純功能係統面臨的挑戰是“副作用”需要具有持久狀態。 在架構良好的系統中,狀態的持久化在控制流的頂層處理,允許大多數係統沒有副作用。

Elixir/OTP 和麵向過程的編程

在 Elixir/Erlang 和 OTP 中,通信原語是執行語言的虛擬機的一部分。 進程之間和機器之間的通信能力是語言系統的內置和核心。 這強調了在這種範式和這些語言系統中交流的重要性。

雖然 Elixir 語言在語言中表達的邏輯方面主要是功能性的,但它的使用是面向過程的

面向過程意味著什麼?

這篇文章中定義的面向流程是首先以存在哪些流程以及它們如何通信的形式設計一個系統。 主要問題之一是哪些進程是靜態的,哪些是動態的,哪些是根據請求產生的,哪些服務於長期運行的目的,哪些持有系統的共享狀態或部分共享狀態,哪些特徵系統本質上是並發的。 正如 OO 有對像類型,功能有函數類型一樣,面向過程的編程也有過程類型。

因此,面向過程的設計是對解決問題或滿足需求所需的一組過程類型的標識

時間方面很快進入了設計和需求工作。 系統的生命週期是什麼? 哪些定制需求是偶爾的,哪些是不變的? 系統中的負載在哪裡,預期的速度和體積是多少? 只有在理解了這些類型的考慮之後,面向流程的設計才開始定義每個流程的功能或要執行的邏輯。

培訓意義

這種分類對培訓的含義是,培訓不應從語言語法或“Hello World”示例開始,而應從系統工程思維和對流程分配的設計重點開始

編碼問題次要於流程設計和分配,最好在更高級別解決,並且涉及對生命週期、QA、DevOps 和客戶業務需求的跨職能思考。 Elixir 或 Erlang 的任何培訓課程都必須(並且通常確實)包括 OTP,並且應該從一開始就面向過程,而不是像“現在你可以在 Elixir 中編碼,所以讓我們做並發”類型的方法。

收養影響

採用的含義是語言和系統更好地應用於需要通信和/或計算分佈的問題。 單台計算機上的單一工作負載問題在這個領域不太有趣,可能用另一種語言更好地解決。 長壽命的連續處理系統是這種語言的主要目標,因為它從頭開始就內置了容錯能力。

對於文檔和設計工作,使用圖形表示法會非常有幫助(如圖 1 中的 OO 語言)。 來自 UML 的 Elixir 和麵向流程編程的建議將是序列圖(圖 2 中的示例),以顯示流程之間的時間關係並確定哪些流程參與服務請求。 沒有用於捕獲生命週期和流程結構的 UML 圖類型,但可以用簡單的方框圖和箭頭圖表示流程類型及其關係。 例如,圖 3:

面向過程的編程示例UML序列圖

面向過程的編程示例流程結構圖

過程導向的一個例子

最後,我們將通過一個簡短的示例將過程導向應用於問題。 假設我們的任務是提供一個支持全球選舉的系統。 之所以選擇此問題,是因為許多單獨的活動是突發執行的,但結果的聚合或匯總是實時的,並且可能會看到大量負載。

初始流程設計和分配

我們最初可以看到,每個人的投票是來自許多離散輸入的系統流量的爆發,沒有時間順序,並且可能具有高負載。 為了支持這項活動,我們需要大量的流程都收集這些輸入並將它們轉發到更中心的製表流程。 這些過程可以位於每個國家/地區將產生選票的人口附近,從而提供低延遲。 他們將保留本地結果,立即記錄他們的輸入,並將它們分批轉發以供製表,以減少帶寬和開銷。

我們最初可以看到,需要有流程來跟踪每個司法管轄區的投票情況,必須在其中提交結果。 讓我們假設對於這個例子,我們需要跟踪每個國家的結果,並按省/州在每個國家內跟踪結果。 為了支持這項活動,我們希望每個國家/地區至少有一個進程執行計算,並保留當前的總數,並為每個國家/地區的每個州/省設置另一組。 這假設我們需要能夠實時或低延遲地回答國家和州/省的總數。 如果可以從數據庫系統獲得結果,我們可能會選擇不同的進程分配,其中總數由瞬態進程更新。 為這些計算使用專用進程的優點是結果以內存的速度發生並且可以以低延遲獲得。

最後,我們可以看到有很多人會查看結果。 這些過程可以以多種方式進行劃分。 我們可能希望通過在每個負責該國家/地區結果的國家/地區放置流程來分配負載。 進程可以緩存來自計算進程的結果以減少計算進程的查詢負載,和/或計算進程可以定期將其結果推送到適當的結果進程,當結果發生顯著變化時,或計算過程變得空閒表明變化率變慢。

在所有三種流程類型中,我們可以相互獨立地擴展流程,在地理上分佈它們,並通過主動確認流程之間的數據傳輸來確保結果永遠不會丟失。

如前所述,我們以獨立於每個流程中的業務邏輯的流程設計開始示例。 如果業務邏輯對數據聚合或地理有特定要求,可能會反复影響流程分配。 到目前為止,我們的流程設計如圖 4 所示。

面向流程的開發示例:初始流程設計

使用單獨的進程來接收選票允許每個選票獨立於任何其他選票被接收,在收到時記錄,並批處理到下一組進程,從而顯著減少這些系統的負載。 對於消耗大量數據的系統,通過使用進程層來減少數據量是一種常見且有用的模式。

通過在一組隔離的進程中執行計算,我們可以管理這些進程的負載並確保它們的穩定性和資源需求。

通過將結果表示放在一組隔離的進程中,我們既可以控制系統其餘部分的負載,又可以動態擴展進程集以適應負載。

其他要求

現在,讓我們添加一些複雜的要求。 讓我們假設在每個司法管轄區(國家或州),投票製表可能會導致成比例的結果、贏家通吃的結果,或者如果相對於該司法管轄區的人口投票不足,則沒有結果。 每個司法管轄區都可以控制這些方面。 隨著這種變化,國家的結果不是原始投票結果的簡單聚合,而是州/省結果的聚合。 這改變了原來的流程分配,要求將州/省流程的結果輸入國家流程。 如果投票收集與州/省、省到國家進程之間使用的協議相同,則聚合邏輯可以復用,但需要不同的進程持有結果,並且它們的通信路徑不同,如圖5.

面向過程的開發示例:改進的過程設計

代碼

為了完成該示例,我們將回顧 Elixir OTP 中示例的實現。 為簡化起見,此示例假設使用像 Phoenix 這樣的 Web 服務器來處理實際的 Web 請求,並且這些 Web 服務向上述過程發出請求。 這樣做的好處是簡化了示例並將重點放在 Elixir/OTP 上。 在生產系統中,讓這些獨立的進程具有一些優點以及分離關注點、允許靈活部署、分配負載並減少延遲。 帶有測試的完整源代碼可以在 https://github.com/technomage/voting 找到。 為了便於閱讀,本文對源代碼進行了縮寫。 下面的每個進程都適合 OTP 監督樹,以確保進程在失敗時重新啟動。 有關示例的這方面的更多信息,請參閱源代碼。

投票記錄器

這個過程接收投票,將它們記錄到持久存儲中,並將結果批處理到聚合器。 VoteRecoder 模塊使用 Task.Supervisor 來管理短期任務以記錄每次投票。

 defmodule Voting.VoteRecorder do @moduledoc """ This module receives votes and sends them to the proper aggregator. This module uses supervised tasks to ensure that any failure is recovered from and the vote is not lost. """ @doc """ Start a task to track the submittal of a vote to an aggregator. This is a supervised task to ensure completion. """ def cast_vote where, who do Task.Supervisor.async_nolink(Voting.VoteTaskSupervisor, fn -> Voting.Aggregator.submit_vote where, who end) |> Task.await end end

投票聚合器

這個過程聚合一個轄區內的投票,計算該轄區的結果,並將投票摘要轉發給下一個更高的過程(更高級別的轄區或結果提交者)。

 defmodule Voting.Aggregator do use GenStage ... @doc """ Submit a single vote to an aggregator """ def submit_vote id, candidate do pid = __MODULE__.via_tuple(id) :ok = GenStage.call pid, {:submit_vote, candidate} end @doc """ Respond to requests """ def handle_call {:submit_vote, candidate}, _from, state do n = state.votes[candidate] || 0 state = %{state | votes: Map.put(state.votes, candidate, n+1)} {:reply, :ok, [%{state.id => state.votes}], state} end @doc """ Handle events from subordinate aggregators """ def handle_events events, _from, state do votes = Enum.reduce events, state.votes, fn e, votes -> Enum.reduce e, votes, fn {k,v}, votes -> Map.put(votes, k, v) # replace any entries for subordinates end end # Any jurisdiction specific policy would go here # Sum the votes by candidate for the published event merged = Enum.reduce votes, %{}, fn {j, jv}, votes -> # Each jourisdiction is summed for each candidate Enum.reduce jv, votes, fn {candidate, tot}, votes -> Logger.debug "@@@@ Votes in #{inspect j} for #{inspect candidate}: #{inspect tot}" n = votes[candidate] || 0 Map.put(votes, candidate, n + tot) end end # Return the published event and the state which retains # Votes by jourisdiction {:noreply, [%{state.id => merged}], %{state | votes: votes}} end end

結果演示者

此過程從聚合器接收投票並將這些結果緩存到服務請求以呈現結果。

 defmodule Voting.ResultPresenter do use GenStage … @doc """ Handle requests for results """ def handle_call :get_votes, _from, state do {:reply, {:ok, state.votes}, [], state} end @doc """ Obtain the results from this presenter """ def get_votes id do pid = Voting.ResultPresenter.via_tuple(id) {:ok, votes} = GenStage.call pid, :get_votes votes end @doc """ Receive votes from aggregator """ def handle_events events, _from, state do Logger.debug "@@@@ Presenter received: #{inspect events}" votes = Enum.reduce events, state.votes, fn v, votes -> Enum.reduce v, votes, fn {k,v}, votes -> Map.put(votes, k, v) end end {:noreply, [], %{state | votes: votes}} end end

帶走

這篇文章探討了 Elixir/OTP 作為面向過程語言的潛力,將其與面向對象和功能範式進行了比較,並回顧了這對培訓和採用的影響。

該帖子還包括一個簡短的示例,將這種方向應用於示例問題。 如果您想查看所有代碼,這裡是指向我們在 GitHub 上的示例的鏈接,這樣您就不必向後滾動查找它。

關鍵要點是將系統視為通信過程的集合。 首先從流程設計的角度規劃系統,然後再從邏輯編碼的角度規劃系統。