Introduction to event-based programmingの翻訳です。
2022年11月8日
イベントベースプログラミング入門
この記事では、ゲストブロガーのGigi Sayfanがイベント駆動型プログラミングについて説明します。また、いくつかの楽しい例も紹介します。
イベント駆動型プログラミングは、複雑なシステムを構築するのに最適なアプローチだ。分割統治原則を体現しながら、同期呼び出しのような他のアプローチを使い続けることができます。
イベント・ベースのシステムを議論するとき、いくつかの異なる用語が同じコンセプトを指すことがよくある。ここではわかりやすくするため、主に以下の太字の用語を使用する:
- イベント**、メッセージ、通知
- プロデューサー**、パブリッシャー、センダー、イベントソース
- コンシューマー**、レシーバー、サブスクライバー、ハンドラー、イベントシンク
- メッセージ・キュー**、イベント・キュー
イベント駆動型プログラミングとは?
イベント駆動型プログラミング(イベント指向プログラミング)とは、エンティティ(オブジェクトやサービスなど)が仲介者を介して互いにメッセージを送信することで間接的に通信を行うパラダイムである。メッセージは通常、コンシューマーが処理する前にキューに格納される。
直接呼び出しを使う場合とは異なり、イベント駆動型プログラミングでは、プロデューサーとコンシューマーが完全に切り離されるため、注目すべき利点がいくつかある。例えば、複数のプロデューサーと複数のコンシューマーが協力して、入ってくるリクエストを処理することができる。失敗した処理の再試行やイベント履歴の管理も簡素化される。イベント駆動型プログラミングは、コンシューマーを追加するだけで容量を追加できるため、大規模システムのスケールも容易になる。
イベントベース・システムのアクターである、イベント、プロデューサー、コンシューマー、メッセージキューを見てみよう。
イベントとは何か?
イベントとは、プロデューサによって送信され、最終的にコンシューマによって消費されるデータの断片です。
例えば、マウスダウンイベントは通常以下の情報を含む:
- マウスポインタの座標
- どのマウスボタンが押されたか
イベントは通常、構造化されているが、コンシューマーが解析して処理する方法を知っていなければならないデータの塊(JSONの塊など)であることもある。
プロデューサーとコンシューマーとは?
プロデューサーはイベントを生成し、メッセージキューに送信するエンティティです。コンシューマーは、新しいイベントを受信するためにサブスクライブするか、キューから定期的にポーリングします。イベント・ドリブン・パラダイムの鍵はこの点にある:**プロデューサーとコンシューマーは互いに意識することなく、メッセージキューのみを通じて相互作用する。
構築して学ぶ
Pythonでインタラクティブなノートブックを作りながら、Apache KafkaのProducersとConsumerの基本を学びます。
メッセージキューとは?
**メッセージキューはメッセージ(イベント)の保管庫である。メッセージキューは通常、トピックに分割されます。プロデューサとコンシューマは、特定のトピックのメッセージを別々に送受信します。
**メッセージ・ブローカーは、キューに送信されたメッセージが、サブスクライブされたすべてのコンシューマーに配信されることを確認する役割を担っている。**現在よく使われているメッセージ・ブローカーには、RabbitMQ、Redis、Apache Kafkaなどがある。
コンシューマは、プルモードまたはプッシュモードを使用してメッセージを消費する。プル・モードでは、メッセージ・キューはすべてのコンシューマーのためにすべてのメッセージを保持する。メッセージがすべてのコンシューマによって受信されると、そのメッセージはキューから削除される。メッセージキューの中には、コンシューマが過去のメッセージをプルできるものもあります。
**プッシュ・モードでは、メッセージ・キューは新しいメッセージを現在のすべての加入者にプッシュします。
イベント駆動型プログラミングの特徴は?
イベントドリブンプログラミングパラダイムが持つ特性のいくつかを探り、何がクールで、何がやっかいなのかを見てみよう。ここでは、プッシュ型のシステムに焦点を当てます。
密結合ワークフローと疎結合ワークフロー
従来のコールベースのワークフローは密結合である。オブジェクト A がオブジェクト B に関連する情報 X を持っている場合、オブジェクト A がこの情報を B に正常に渡すには、以下の要件を満たす必要があります:
1.BがXに関心を持っていることを知る必要がある。
2.Bへの参照が必要
3.Bのインターフェース(例えばsetX()メソッド)を知る必要がある。
4.Bを呼び出してXを渡す必要がある
5.5.何か問題が発生した場合、(リトライやraise errorなど)何をすべきかを知る必要がある。
たくさんの要件がある。
CとDもXに興味があり、Xを受信するための異なるインターフェースを持っているかもしれない。Aはまた、部分的に失敗した場合(例えば、BとCはXを正常に受信したが、Dは失敗した場合)にどうするかも考慮する必要がある。
ここで、疎結合のイベントベースシステムにおけるオブジェクトAへの要求を見てみよう:
1.AはイベントXをイベントキューに送る。
これで完了だ。これで、B、C、D はイベント X を受信するためにサブスクライブすることができる。キューを管理するイベントフレームワークは、エラー処理と再試行についてのポリシーを持つことができ、これらはしばしばコンシューマによって指定されることができる。
しかし、疎結合ワークフローの欠点は、これらの利害関係者が、コードやコールグラフやワークフローの中で見えにくいことである。
リクエスト-レスポンス対パブリッシュ-サブスクライブ
リクエスト・レスポンス方式のコミュニケーションは、伝統的なウェブ開発の典型です。例えば、ユーザーが新しい URL に移動し、ブラウザがウェブページをリクエストし、サーバーが情報を応答する。これはリモート・プロシージャ・コール (RPC) システムの典型的なパターンです。
パブリッシュ・サブスクライブ・アプローチ(略してPubSub)は、異なるユースケースに取り組んでいます:fire and forgetです。パブリッシャーは確認や応答を必要とせず、単にイベントをキューに発行します。
どちらのアプローチにも適しています。後ほど説明しますが、イベントを使って非同期のリクエストとレスポンスを 実装することは可能ですが、それは面倒です。
同期呼び出しと非同期呼び出し
同期呼び出しは、一般的な関数呼び出しです。いくつかの引数で関数を呼び出し、同じスレッドで結果を取得します。スレッドは、関数呼び出しが戻るまでブロックされます。
非同期呼び出しはすぐに戻り、計算の完了を待つことはありません。結果は後で別のスレッドに届きます。PubSubに依存するイベントベースのシステムでは、多くの場合、イベントを受信するために非同期呼び出しが使用されます。コンシューマは特定のトピックやイベントを購読し、イベントは別のスレッドで処理されます。
リトライとリプレイ
他のオブジェクトやサービスを呼び出す場合、常にエラーが発生する可能性があります。一般的に、呼び出し元はいくつかのアクションのいずれかを取ることができます:
1.失敗してエラーを返す。
2.失敗を記録するが、動作は継続する
3.何らかの代替手段にフォールバックする
4.再試行
多くの場合、イベントフレームワークは、コンシューマーへのイベント送信を再試行するように設定することができ、再試行アクションを簡単に実装することができる。
しかし、コンシューマがイベントを受信し、その処理中に失敗した場合、その失敗をプロデューサに伝える直接的な方法はない。イベント・ドリブン・アーキテクチャーとプログラミングでは、プロデューサーはコンシューマーのことを知らないので、これは問題ではない。
キューを管理するイベントフレームワークは、失敗が断続的である場合に備えて、何度か再試行することができる。失敗が持続する場合、イベントは後の処理のために保存することができ、適切なエラーメッセージを記録することができる。
全能のキューの深さ
イベント・ベースのプログラミングでは、システムの状態とパフォーマンスを決定するのは非常に簡単だ。キューの深さとも呼ばれるキューのイベント数が増えれば、より多くのコンシューマーが必要になります。もしコンシューマーがアイドル状態でイベント処理を待っているのであれば、プロデューサはイベントを送信していないことになる。
**どのキューが制御不能に成長し、どのキューが飢餓状態に陥っているかをチェックすることで、問題を発見することができる。
イベント配信オプション
PubSubシステムでは、主に3つの異なる配信モデルがあります: ちょうど1回、多くても1回、少なくとも1回です。
正確に一度
この配信モデルでは、すべてのコンシューマは、すべてのイベントのコピーを正確に1回受け取ることが保証される。実際、このモデルを実現するのは非常に難しい。コンシューマが失敗したり、イベントキューから切断したりすると、そのコンシューマがイ ベントを受信したのか(しかし確認に失敗したのか)、あるいはイベントをまったく受信し ていないのか、不明なことが多い。
せいぜい一度
ここで、一部のコンシューマーはすべてのイベントを取得しないかもしれないが、重複することはない。このモデルは実装が最も簡単である。イベントはすべてのコンシューマーに一度だけ送られる。コンシューマーはイベントを受け取るか受け取らないかのどちらかである。リトライはない。
最低1回
この最も一般的なモデルでは、すべてのコンシューマがすべてのイベントを受け取ることが保証されますが、いくつかのコンシューマは重複を受け取るかもしれません。イベントフレームワークは、コンシューマからの確認を得るまで、何度か再試行します。重複イベントが発生する可能性があるため、コンシューマは冪等であるべきです。
プル型システムでは、コンシューマはキューからメッセージを引き出す責任を負います。もしキューが過去のメッセージを保持していれば、コンシューマは何度でもメッセージを引き出すことができます。
イベントベースのプログラミングはどのように機能するのか?
複数のプロデューサーと複数のコンシューマーを持つイベントキューを見てみよう。
イベントの生成
プロデューサーはイベントを生成し、特定のトピックに送信する。図では、2つのプロデューサーがいることがわかります。プロデューサー 1 はイベント A をトピック 1 に送る。プロデューサー2は、イベントBとCをトピック2と3に送る。
イベントへのサブスクライブ
各トピックはサブスクライバ(コンシューマ)のグループを持つことができます。これは主にPubSubシステムに関連します。コンシューマ1とコンシューマ2はトピック1を購読し、購読者リストを表すグループ1とグループ2を作成します。コンシューマ2はトピック2を購読し、グループ3を作成します。
プロデューサ 1 がイベント A をトピック 1 に送信すると、コンシューマ 1 と 2 は、それぞれのサブスクライバ・グループを通してそれを受信する。プロデューサ 2 がイベント B をトピック 2 に送信すると、コンシューマ 2 はそれをサブスクライバ・グループ 3 を通じて受信する。
プロデューサー2がイベントCを送るとき、どのコンシューマーも それをサブスクライブしていないので、どのコンシューマーもそれを受 信しない。イベントは破棄されるかもしれないし、コンシューマがサブスクライブ するのを待ってトピック3に保管されるかもしれない。
構成可能な保持
キューによっては、すべての購読者に配信されたイベントを直ちに破棄するものもあります。他のキューはイベントを保持し続けるかもしれません。そのようなキューでは、どの程度の期間イベントを保持するかを設定することが有用です。これは、イベントエイジ、キュー内のイベント数、あるいはキューに入れられたイベントの合計サイズに基づいて行うことができる。
イベントベースのパターン
イベントベースのプログラミングは、いくつかの興味深く有用なパターンへの扉を開く。
シングル・プロデューサー/シングル・コンシューマー
イベントのための最も単純な構成は、単一のプロデューサーと単一のコンシューマーで ある。このパターンでは、 プロデューサーとコンシューマーは互いに分離されており、独立して自由に動作します。
単一のプロデューサー/複数のコンシューマー
**複数のコンシューマーを持つ単一のプロデューサーは、イベントを 処理 するのに、イベントを 生成 するよりも時間がかかる場合に有用である。ロードバランサーはこのパターンの良い例である。
デッドレターキュー(DLQ)
あるイベントが何度再試行しても処理に失敗した場合、メイン・キューから削除し、専用の DLQ にポストするのが一般的です。エンジニアはこれらのイベントを後で分析し、根本的な原因を特定することができる。
生存時間 (TTL)
多くのイベントを発生させるシステムでは、履歴を残したいと思うかもしれないが、永遠ではない。一般的に言って、データの価値は時間とともに低下する。例えば、本番環境で発生した問題をデバッグする場合、最後のデプロイメント以降のイベントのストリー ムを見たいかもしれませんが、おそらく5年前のイベントは見たくないでしょう。一般的なパターンとして、各イベントのストリームや各トピックにTTLを割り当て、TTLが切れるとそれらのイベントは破棄される。
イベントによる非同期リクエスト・レスポンス
イベントハンドラは通常、結果を返しません。場合によっては、クエリやアクションを非同期で実行し、後で結果を取得するのが便利です。このような状況ではポーリングが一般的ですが、多くのオーバーヘッドが発生します。
イベントベースの代替手段は、リクエスト・トピックとレスポンス・トピックを確立することである。呼び出し元はリクエストをリクエストトピックに送り、レスポンストピックをサブスクライブする。応答の準備ができたら、元のリクエストのハンドラは応答を 応答トピックに送る。
イベントの分割
イベント分割は、同じイベントを複数のエンティティで処理する必要がある場合に便利で ある。これは、1つのコンシューマーが各イベントを処理する、単一プロ デューサー/複数コンシューマーのパターンとは異なる。例えば、保存のために処理されるだけでなく、後の分析のために集約される必要があるイベントを考える。あるコンシューマーが適切なデータストアにイベントを保存し、別のコンシューマーが集計を行います。この責任の分担が疎結合システムの特徴である。ストレージのコードは、同じデータを操作しているにもかかわらず、アグリゲーションのコードを意識することはない。
例
以下のイベント駆動型プログラミングの例は、私が息子たちと開発したブロック・パズル・ゲームのものです。このプロジェクトのソースコードはhttps://github.com/the-gigi/blocktserにあります。
Blocktserはブラウザ上で動作するシンプルなゲームです。TypeScript、Canvas API、phaser.ioフレームワークで実装されている。Blocktserでは、下部のステージング・エリアから図形をドラッグして画面内を移動させ、最終的にメイン・エリアにドロップします。行または列を完成させるとポイントがもらえる。図形を配置できなければゲームオーバー。
スクリーンショットはこちら:
GameObjectイベントの購読
図形はPhaser Gameオブジェクトです。便利なことに、Phaserはすでに生のCanvas APIマウスイベントをより高いレベルのイベントに変換しています。
イベントを受信して処理するには、いくつかのアクションを実行する必要があります。まず、コンテナオブジェクトをインタラクティブ(マウスカーソルの設定)かつドラッグ可能に設定することで、シェイプをドラッグ可能にします。
`this._container.setInteractive({ cursor: 'pointer' })
scene.input.setDraggable(this._container);`クリップボードにコピーする
次に、シーン入力オブジェクトのonメソッドを呼び出して、ドラッグ、ドラッグスタート、ドラッグエンドのイベントをサブスクライブします。関連イベントをトリガーする(生成する)と、提供されたイベント処理関数(コンシューマー)が呼び出されます。
`scene.input.on('drag', function(pointer, gameObject, dragX, dragY) { }.
self.onDragging(self, pointer, gameObject, dragX, dragY)
})
scene.input.on('ドラッグスタート', function (ポインタ, gameObject) { )
self.onDragStart(self, pointer, gameObject)
})
scene.input.on('dragend', function (pointer, gameObject) { )
self.onDragEnd(self, pointer, gameObject)
クリップボードにコピーする
イベントの処理
サブスクリプションイベントハンドラは、実際のロジックを Shape クラスの onDragging、onDragStart、onDragEnd メソッドに委譲します。
ShapeのonStartDragメソッドは、他のオブジェクトのドラッグ開始イベントを無視します。そして、シェイプを拡大縮小して更新するロジックを実行します。(最後に、dragHandlerのリストに対してonDragStartを呼び出します。これにより、先ほど説明したイベント分割パターンが実現されます。
onDragStart(シェイプ, ポインタ, gameObject) { { if (shape._container !== gameObject)
if (shape._container !== gameObject) { { {.
return
}
shape._unit *= shape.dragScale
shape.updateShape(true)
shape.dragHandlers.forEach((h) => h.onDragStart(shape))
}` クリップボードにコピー
ShapeのonDraggingメソッドは、ドラッグ操作中にマウスが動くたびに呼び出されます。ここでのイベントハンドラは、最後の位置からの差分に応じてシェイプの位置を更新します。そして、それぞれの dragHandlers メンバの onDragging メソッドを呼び出します。
`onDragging(shape, pointer, gameObject, dragX, dragY) { もし
if (shape._container !== gameObject) { { {.
return
}
const dx = dragX - gameObject.x
const dy = dragY - gameObject.y
shape._container.x += dx
shape._container.y += dy
shape.dragHandlers.forEach((h) => h.onDragging(shape))
}` クリップボードにコピー
最後に、ShapeのonDragEndメソッドです。ここでは、イベントが別のオブジェクトから来ている場合は無視し、シェイプのサイズを変更してドラッグハンドラを呼び出すというおなじみの構造になっています。
if (shape. \_container !== gameObject) { `if (shape. \_container !== gameObject)
return
}
shape._unit /= shape.dragScale
shape.updateShape(false)
shape.dragHandlers.forEach((h) => h.onDragEnd(shape))`クリップボードにコピー
イベントを高レベルのアプリケーションイベントに変換する
前のセクションで見てきたイベントハンドラはPhaserフレームワークによって定義されたもので、Phaserゲームオブジェクトとそのコンテナという観点で定義されていました。Blocktserでは、Blocktserのシェイプという観点で操作する、もう1つの抽象化レイヤーがあります。interfaces.tsファイルでは、以下のインターフェースが定義されています:
`export default interface ShapeDragHandler { 次のインターフェイスを定義します。
onDragStart: (shape: Shape) => void
onDragEnd: (shape: Shape) => void
onDrag: (shape: Shape) => void
}
export default interface MainEventHandler { 次のようにします。
onDrop: (shape: Shape, ok: boolean) => void
クリップボードにコピー
onDropイベントに注目してみましょう。これはShapeがメインエリア上でのドラッグを終了したときに発生します。まず、onDragEndメソッドが呼ばれます:
onDragEnd(シェイプ: シェイプ) { this.settleShape(shape)
this.settleShape(shape)
this.destroyPhantom()
}` クリップボードにコピー
onDragEndメソッドはsettleShapeメソッドを呼び出し、Shapeが正しくドロップされたかどうかを判断します。その後、onDrop メソッドを true
または false
で呼び出します。
`settleShape(shape: Shape) { // グリッド上にない場合はベイルアウトする。
// グリッド上にない場合はベールアウト
if (!this.isOnGrid(shape)) { // グリッド上にない場合はベールアウトする。
this.shapeEventHandler.onDrop(shape, false)
リターン
}
// shapeが占有セルと交差する場合はベールアウトする
const [row, col] = this.findGridLocation(shape)
if (!this.canShapeSettle(shape, row, col)) { if (!this.shapeHandler.onDrop(shape, false))
this.shapeEventHandler.onDrop(shape, false)
リターン
}
// シェイプの画像をセルに入れる
...
this.shapeEventHandler.onDrop(shape, true)
クリップボードにコピー
onDropハンドラは、ゲームのメインエリアとステージングエリアの両方を含む、シーンのより高い抽象化レベルで動作します。これは、シェイプがドロップされた意味を理解します。シェイプがメインエリアに正しくドロップされた場合、いくつかのアクションを取ります:
- サウンドを再生する。
- 必要に応じてステージングエリアを更新する
- 完了した行や列をクリアする
- スコアの更新
- ゲームオーバーの確認
図形が不適切に落とされた場合は、単にステージングエリアに戻る。
OSレベルからBlocktserのシェイプドロップイベントまでの一連の流れを追ってみよう:
- OSはマウスダウン、マウスアップ、マウス移動イベントのストリームを生成する。
- ブラウザはこれらのイベントをインターセプトし、マウスイベントとしてCanvasに反映します。
- Phaserフレームワークがこれらのイベントを処理し、GameObjectのドラッグイベントに変換します。
- Blocktser はこれらのイベントを処理し、イベント分割パターンを使用して、独自のイ ベントインターフェイスを通じて複数のコンシューマーに送信します。
- 最後に、Blocktser は dragEnd イベントをより上位の shape drop イベントに変換します。
結論
イベント・ベース・プログラミングは、シングル・プロセス・システムのUIから大規模分散システムのサービス間通信まで、幅広いユースケースを持つ、非常に便利なパラダイムである。イベント・ベース・プログラミングの疎結合の性質は多くの利点をもたらしますが、オブジェクト間通信についての考え方を変える必要があるかもしれません。
ソフトウェア開発のトレンドがモノリス・アプリケーションから分散システムやデカップリングされたマイクロサービスへと移行する中、アーキテクチャやプログラミングのためのイベント駆動型パラダイムは確実にここにとどまるでしょう。Kafkaであろうと他のメッセージ・ブローカーであろうと、どのイベント・キュー・ソリューションを使うかは賢明な検討が必要だ。少なくとも、あなたのマイクロサービス群がイベントドリブン開発へシフトすることで活気づくかどうかを検討する時だ。
データ・ストリーミング・ソリューションをお探しですか?
Aivenのフルマネージド・ホスティングApache Kafka®を、30日間の無料トライアルと300ドルのクレジットでお試しください。
無料トライアルはこちら
ジジ・セイファンについて
Gigi Sayfanは、バイオインフォマティクスとゲノミクスの新興企業であるHelixのDevOpsチームマネージャーであり、複数の書籍と数百の技術論文の著者である。
インスタント・メッセージング、モーフィング、ゲーム機向けマルチメディア・アプリケーション、脳を刺激する機械学習、カスタム・ブラウザー開発、3D分散ゲーム・プラットフォーム向けウェブ・サービス、IoTセンサー、バーチャル・リアリティなど、多様な領域で20年以上にわたって専門的にソフトウェアを開発してきた。
--
まだAivenのサービスをご利用になっていませんか?https://console.aiven.io/signupから無料トライアルにお申し込みください!
また、changelogやblogのRSSフィード、またはLinkedInやTwitterのアカウントをフォローして、製品や機能関連の最新情報をご確認ください。