ElixirおよびOTPでのプロセス指向プログラミングのガイド

公開: 2022-03-11

人々はプログラミング言語をパラダイムに分類するのが好きです。 オブジェクト指向(OO)言語、命令型言語、関数型言語などがあります。これは、どの言語が同様の問題を解決し、どのタイプの問題を解決しようとしているのかを理解するのに役立ちます。

いずれの場合も、パラダイムには通常、その言語族の原動力となる1つの「主要な」焦点と手法があります。

  • オブジェクト指向言語では、状態(メソッド)を操作して状態(データ)をカプセル化する方法としてのクラスまたはオブジェクトです。

  • 関数型言語では、関数自体の操作、または関数から関数に渡される不変のデータの場合があります。

Elixir(およびその前のErlang)は、関数型言語に共通する不変のデータを示すため、関数型言語として分類されることがよくありますが、多くの関数型言語とは別のパラダイムを表すものとして提出します。 それらは存在し、OTPの存在のために採用されているので、私はそれらをプロセス指向言語として分類します。

この投稿では、これらの言語を使用する場合のプロセス指向プログラミングの意味を把握し、他のパラダイムとの相違点と類似点を探り、トレーニングと採用の両方への影響を確認し、プロセス指向プログラミングの短い例で終わります。

プロセス指向プログラミングとは何ですか?

定義から始めましょう。プロセス指向プログラミングは、1977年のTony Hoareの論文に基づいた、Communicating Sequential Processesに基づくパラダイムです。これは、一般に同時実行のアクターモデルとも呼ばれます。 このオリジナルの作品に何らかの関係がある他の言語には、Occam、Limbo、およびGoが含まれます。 正式な論文は同期通信のみを扱っています。 ほとんどのアクターモデル(OTPを含む)も非同期通信を使用します。 非同期通信の上に同期通信を構築することは常に可能であり、OTPは両方の形式をサポートします。

この歴史に基づいて、OTPは、シーケンシャルプロセスを通信することにより、フォールトトレラントコンピューティングのためのシステムを作成しました。 フォールトトレラント機能は、スーパーバイザーの形で確実なエラー回復を行い、アクターモデルによって可能になる分散処理を使用する「失敗させる」アプローチから生まれます。 「失敗させる」は「失敗を防ぐ」とは対照的です。前者は対応がはるかに簡単であり、OTPでは後者よりもはるかに信頼性が高いことが証明されています。 その理由は、障害を防ぐために必要なプログラミング作業(Javaチェック例外モデルに示されている)がはるかに複雑で要求が厳しいためです。

したがって、プロセス指向プログラミングは、プロセス構造とシステムのプロセス間の通信が主要な関心事であるパラダイムとして定義できます。

オブジェクト指向プログラミングとプロセス指向プログラミング

オブジェクト指向プログラミングでは、データと関数の静的構造が主な関心事です。 同封のデータを操作するために必要なメソッドと、オブジェクトまたはクラス間の接続はどうあるべきか。 したがって、図1に示すように、UMLのクラス図はこの焦点の代表的な例です。

プロセス指向プログラミング:サンプルUMLクラス図

オブジェクト指向プログラミングに対する一般的な批判は、目に見える制御フローがないことであることに注意してください。 システムは個別に定義された多数のクラス/オブジェクトで構成されているため、経験の浅い人がシステムの制御フローを視覚化するのは難しい場合があります。 これは、抽象インターフェイスを使用するか、強い型付けがない、継承の多いシステムに特に当てはまります。 ほとんどの場合、開発者は、システム構造を大量に記憶して効果を上げることが重要になります(どのクラスにどのメソッドがあり、どのクラスがどのように使用されるか)。

オブジェクト指向開発アプローチの強みは、新しいオブジェクトタイプが既存のコードの期待に準拠している限り、既存のコードへの影響を制限して新しいタイプのオブジェクトをサポートするようにシステムを拡張できることです。

機能指向プログラミングとプロセス指向プログラミング

多くの関数型プログラミング言語はさまざまな方法で並行性に対処しますが、それらの主な焦点は、関数間で受け渡される不変のデータ、または他の関数(関数を生成する高階関数)からの関数の作成です。 ほとんどの場合、言語の焦点は依然として単一のアドレス空間または実行可能ファイルであり、そのような実行可能ファイル間の通信はオペレーティングシステム固有の方法で処理されます。

たとえば、ScalaはJava仮想マシン上に構築された関数型言語です。 通信のためにJava機能にアクセスできますが、言語の固有の部分ではありません。 これはSparkプログラミングで使用される一般的な言語ですが、この言語と組み合わせて使用​​されるライブラリでもあります。

機能パラダイムの強みは、トップレベルの機能が与えられたシステムの制御フローを視覚化する機能です。 制御フローは、各関数が他の関数を呼び出し、すべてのデータを1つから次の関数に渡すという点で明示的です。 機能パラダイムには副作用がないため、問題の特定が容易になります。 純粋関数システムの課題は、永続的な状態を維持するために「副作用」が必要になることです。 適切に設計されたシステムでは、状態の永続化は制御フローの最上位で処理されるため、システムのほとんどで副作用が発生しません。

Elixir/OTPとプロセス指向プログラミング

Elixir / ErlangおよびOTPでは、通信プリミティブは言語を実行する仮想マシンの一部です。 プロセス間およびマシン間で通信する機能が組み込まれており、言語システムの中心にあります。 これは、このパラダイムとこれらの言語システムにおけるコミュニケーションの重要性を強調しています。

Elixir言語は、その言語で表現されるロジックの観点から主に機能しますが、その使用はプロセス指向です。

プロセス指向であるとはどういう意味ですか?

この投稿で定義されているようにプロセス指向であるということは、最初に、存在するプロセスとそれらがどのように通信するかという形でシステムを設計することです。 主な質問の1つは、静的なプロセスと動的なプロセス、要求に応じて生成されるプロセス、長期的な目的に役立つプロセス、システムの共有状態または共有状態の一部を保持するプロセス、およびシステムは本質的に同時です。 オブジェクト指向にはオブジェクトの種類があり、機能には関数の種類があるのと同じように、プロセス指向プログラミングにはプロセスの種類があります。

したがって、プロセス指向の設計とは、問題を解決したり、ニーズに対応したりするために必要な一連のプロセスタイプを識別することです

時間の側面は、設計と要件の取り組みにすぐに入ります。 システムのライフサイクルは何ですか? どのようなカスタムニーズが時折あり、どれが一定ですか? システムの負荷はどこにあり、予想される速度と体積はどれくらいですか? プロセス指向の設計が各プロセスの機能または実行されるロジックの定義を開始するのは、これらのタイプの考慮事項が理解されてからです。

トレーニングへの影響

この分類のトレーニングへの影響は、トレーニングは言語構文や「Hello World」の例ではなく、システムエンジニアリングの考え方とプロセス割り当てに焦点を当てた設計から開始する必要があるということです。

コーディングの懸念は、より高いレベルで最も適切に対処されるプロセスの設計と割り当ての二次的なものであり、ライフサイクル、QA、DevOps、および顧客のビジネス要件に関する部門横断的な考え方が含まれます。 ElixirまたはErlangのトレーニングコースにはOTPが含まれている必要があり(通常は含まれています)、「Elixirでコーディングできるようになったので、並行性を実行しましょう」タイプのアプローチではなく、最初からプロセス指向である必要があります。

採用への影響

採用の意味するところは、言語とシステムが、コミュニケーションやコンピューティングの分散を必要とする問題によりよく適用されるということです。 単一のコンピューター上の単一のワークロードである問題は、この分野ではあまり興味がなく、別の言語でより適切に対処できる可能性があります。 耐障害性がゼロから組み込まれているため、長寿命の連続処理システムがこの言語の主要なターゲットです。

ドキュメントや設計作業では、グラフィック表記を使用すると非常に役立ちます(オブジェクト指向言語の図1のように)。 ElixirとUMLからのプロセス指向プログラミングの提案は、プロセス間の時間的関係を示し、どのプロセスが要求の処理に関与しているかを識別するシーケンス図(図2の例)です。 ライフサイクルとプロセス構造をキャプチャするためのUMLダイアグラムタイプはありませんが、プロセスタイプとそれらの関係の単純なボックスと矢印のダイアグラムで表すことができます。 たとえば、図3:

プロセス指向プログラミングのサンプルUMLシーケンス図

プロセス指向プログラミングのサンプルプロセス構造図

プロセスオリエンテーションの例

最後に、問題にプロセスオリエンテーションを適用する簡単な例を見ていきます。 グローバル選挙をサポートするシステムを提供する任務があるとします。 この問題は、多くの個々のアクティビティがバーストで実行されるという点で選択されていますが、結果の集計または要約はリアルタイムで望ましく、大きな負荷がかかる可能性があります。

初期プロセスの設計と割り当て

最初に、各個人による投票は、多くの個別の入力からシステムへのトラフィックのバーストであり、時間順に並べられておらず、負荷が高くなる可能性があることがわかります。 このアクティビティをサポートするには、多数のプロセスがすべてこれらの入力を収集し、それらをより中央のプロセスに転送して集計する必要があります。 これらのプロセスは、投票を生成する各国の人口の近くに配置できるため、待ち時間が短くなります。 ローカルの結果を保持し、入力をすぐにログに記録し、帯域幅とオーバーヘッドを削減するためにバッチで集計するために転送します。

最初に、結果を提示する必要がある各管轄区域の投票を追跡するプロセスが必要であることがわかります。 この例では、各国の結果を追跡する必要があり、各国内の州/州ごとに追跡する必要があると仮定します。 このアクティビティをサポートするには、国ごとに少なくとも1つのプロセスで計算を実行し、現在の合計を保持し、各国の州/県ごとに別のプロセスを設定する必要があります。 これは、国と州/県の合計にリアルタイムまたは低遅延で回答できる必要があることを前提としています。 データベースシステムから結果を取得できる場合は、一時的なプロセスによって合計が更新される別のプロセス割り当てを選択する場合があります。 これらの計算に専用のプロセスを使用する利点は、結果がメモリの速度で発生し、低レイテンシで取得できることです。

最後に、多くの人が結果を表示していることがわかります。 これらのプロセスは、さまざまな方法で分割できます。 その国の結果に責任を持つ各国にプロセスを配置することにより、負荷を分散させたい場合があります。 プロセスは、計算プロセスからの結果をキャッシュして、計算プロセスのクエリ負荷を軽減できます。また、計算プロセスは、結果が大幅に変化した場合、または結果が大幅に変化した場合に、定期的に結果を適切な結果プロセスにプッシュできます。計算プロセスがアイドル状態になり、変化の速度が遅いことを示します。

3つのプロセスタイプすべてで、プロセスを互いに独立してスケーリングし、地理的に分散し、プロセス間のデータ転送をアクティブに確認することで結果が失われないようにすることができます。

説明したように、各プロセスのビジネスロジックに依存しないプロセス設計で例を開始しました。 ビジネスロジックに、プロセスの割り当てに繰り返し影響を与える可能性のあるデータ集約または地理に関する特定の要件がある場合。 これまでのプロセス設計を図4に示します。

プロセス指向の開発例:初期プロセス設計

投票を受け取るために別々のプロセスを使用すると、各投票を他の投票とは独立して受け取り、受け取ったときにログに記録し、次の一連のプロセスにバッチ処理できるため、これらのシステムの負荷が大幅に軽減されます。 大量のデータを消費するシステムの場合、プロセスのレイヤーを使用してデータの量を減らすことは、一般的で有用なパターンです。

分離された一連のプロセスで計算を実行することにより、これらのプロセスの負荷を管理し、それらの安定性とリソース要件を確保できます。

結果の表示を分離された一連のプロセスに配置することで、システムの残りの部分への負荷を制御し、一連のプロセスを動的に負荷に合わせてスケーリングできるようにします。

追加要件

次に、いくつかの複雑な要件を追加しましょう。 各管轄区域(国または州)で、投票の集計により、その管轄区域の人口に対して不十分な票が投​​じられた場合、比例した結果、勝者がすべての結果、または結果が得られないとします。 各管轄区域は、これらの側面を管理します。 この変更により、国の結果は生の投票結果の単純な集計ではなく、州/県の結果の集計になります。 これにより、プロセスの割り当てが元のプロセスから変更され、州/県のプロセスからの結果が国のプロセスにフィードされる必要があります。 投票収集と州/州および州から国へのプロセス間で使用されるプロトコルが同じである場合、集計ロジックを再利用できますが、結果を保持する別個のプロセスが必要であり、それらの通信パスは図に示すように異なります。 5.5。

プロセス指向の開発例:変更されたプロセス設計

コード

例を完了するために、ElixirOTPでの例の実装を確認します。 簡単にするために、この例では、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の例へのリンクをもう一度示します。これにより、スクロールして戻ってコードを探す必要がなくなります。

重要なポイントは、システムを通信プロセスのコレクションと見なすことです。 最初にプロセス設計の観点からシステムを計画し、次に論理コーディングの観点からシステムを計画します。