CoffeeScript で学ぶ Observer パターンの基礎
前置き
CoffeeScript を導入したことによってクラス化が比較的易しくなり、導入前よりもずっと見通しの良いコードが書けるようになってきました。クラス化することによって関連する機能を一箇所に集約することができ、後から機能を追加する際も関連するクラス内に迷わず追記することができるので、コードがあちこちに散らばることがなくなります。そして各クラスは、それぞれが与えられた役目だけに徹する(関連機能が集約されているから)ので、他のクラスのことなど知ったこっちゃないと言わんばかりに意識しなくなり、自然と疎結合なコードになっていきます。
と、いうのが理想なわけですが、実際そうも言ってられなくなったりします。ひとつ以下の様なケースで考えてみます。
出版社(Publisher)と読者(Reader)という2つの登場人物がいます。読者は出版社が近日発売予定のとある書籍を購入したいと考えていますが、出版社内で編集が二転三転しているのかいつ発売されるのか未定のままです。読者はマメに書店に通うものの、いつも空振り。読者は書籍が発売されたら自分に連絡してくれるように出版社に頼んでみました。しかし出版社としては、ひとりの読者のためにそこまでするのは躊躇してしまいます。何か上手い解決方法は無いでしょうか?
出版社が直接読者に連絡するということは、出版社クラスが読者クラスの書籍を買いに書店に行くという処理を直接呼び出すのと変わらない訳です。つまり読者クラスは出版社クラスに依存しているということになりますね。
そもそも Observer パターンって何?
まず2つのキーワードを頭に叩き込んでおく必要があります。
- Observer: 監視する人という意味を持ちます。
- Subject: 〜を行うという意味を持ちますが、ここでは通知する人という意味がしっくりきます。
先のケースを例に説明します。読者はお目当ての書籍が発売されたことを出版社から知らせてほしいと考えています。出版社は特定の読者に直接連絡したくありません。知らせる対象が複数人となるとそれだけ手間がかかるうえ、他の書籍を発売する際にも同じことをすることになってしまうからです。そこで出版社は読者に対して自分を監視するように頼みます。出版社は書籍を発売する度に周囲に対して通知(告知)します。特定の誰かに対して、などと意識することなく純粋に通知だけをします。読者は出版社を監視しているので出版社が何かを通知したらすぐに分かります。もしそれがお目当ての書籍の発売であったらなら、すぐに書店に買いに行くというアクションを起こすことが出来ます。
このお話をプログラミングに置き換えてみましょう。監視する人という意味の Observer は読者、通知する人という意味の Subject は出版社です。Observer は Subject が書籍を発売するかどうかを監視します。この監視というのがいわゆるAddEventListener に相当するものであり、お目当ての書籍を発売するというのがEventNameになります。Subject は書籍を発売したらそのことを周囲に通知します。これが Event の発火というやつにあたります。Observer は Event をリッスンしているので、発火されたタイミングで書店に買いに行くというアクションを実行します。これが EventHandler です。
少々長くなりましたが、これをプログラミングで実現するための仕組みを Observer パターンといいます。何が優れているかというと、出版社は書籍を発売したらそのことを通知するだけで役目を終えます。その後読者が書店に買いに行くなどということを出版社が意識することはありません。読者も自分から出版社を監視して通知された内容が目的のものであれば自分から書店に買いに行くようになります。出版社から書店に買いに行ってくださいと直接呼び出される(依存する)ことはありません。出版社というクラスが読者というクラスの書店に買いに行くという処理を直接呼び出さなくてよくなります。つまりクラスの疎結合というわけです。
CoffeeScriptで作ってみよう
CoffeeScript はクラスの継承が非常に簡単に実現できるので、まず EventObserver というこの仕組の部分だけのクラスを作り、それを出版社クラスに継承させるという構成にするとしましょう。
EventObserver クラス
EventObserver.coffee
class window.EventObserver # 登録 on: (name, listener, context)-> @listeners = {} unless @listeners? @listeners[name] = [] unless @listeners[name]? @listeners[name].push [listener, context] @ # 削除 off: (name, listener)-> return @ unless @listeners[name] for listeners, i in @listeners[name] if listeners[0] == listener then @listeners[name].splice(i, 1) @ # 実行 trigger: (name)-> list = @listeners?[name] return @ unless list e = {} e.target = null e.context = null e.target = @ for listeners in list e.context = listeners[1] listeners[0](e) @
listeners というインスタンスプロパティにイベント名とリスナー関数、呼び出し元となるコンテキストをどんどんぶち込んでいきます。これが on() で行っている処理の実態です。off() はその逆ですね。登録済みのイベントを削除します。trigger() で指定されたイベント名(name)に紐付けられたリスナー関数(listeners[0])を実行します。これがイベント発火の実態です。
Reader クラスとPublisher クラス
まずは Reader クラスから。
Reader.coffee
class window.Reader constructor: (name)-> @name = name onNewBook: (event)-> console.log "I will go to buy the CoffeeScript cookbook to bookstore."
onNewBook というリスナー関数を定義しました。中身はコンソールログを出力するだけですが、書籍発売のイベントが発火したタイミングでこの関数を呼び出します。
Publisher.coffee
PublisherクラスにはEventObserverクラスを継承させます。
class window.Publisher extends EventObserver constructor: (name)-> @name = name notifyNewItemReleased: (item)-> @.trigger item
notifyNewItemReleased に引数を渡して呼び出すとその引数名のイベントを発火(publish)するという仕組みです。
イベントを発火させてみよう
各クラスが出来たので、イベントを発火させてリスナー関数を実行させてみます。
oreilly = new Publisher() wakamsha = new Reader() oreilly.on 'coffeescriptCookbook', wakamsha.onNewBook oreilly.notifyNewItemReleased 'coffeescriptCookbook'
oreillyインスタンスのon関数でcoffeescriptCookbook というイベントを監視します。そしてnotifyNewItemReleased関数にcoffeescriptCookbookというイベント名を引数に呼び出すとcoffeescriptCookbookイベントが発火します。これによってwakamshaインスタンスのonNewBook関数が実行されるはずです。
I will go to buy the CoffeeScript cookbook to bookstore.
リスナー関数に引数を渡せるようにしたい
今のままではイベント発火時に呼び出すリスナー関数にはeというイベントオブジェクトだけですが、さらに追加で引数を渡すことができれば利便性が向上します。
最も簡単なのは trigger 関数に第二引数、第三引数と定義すれば良いのですが、これでは渡せる引数の数に上限が出来てしまいます。より柔軟性を求めるならば、trigger の呼び出し側は引数の数を意識することなく自由に決められるべきであり、渡された引数が全て等しく処理されるべきです。このような仕組みは可変長引数と呼ばれるのですが、CoffeeScript では以下のように記述することで簡単に実現することができます。
EventObserver.coffee
trigger: (name, prams...)-> list = @listeners?[name] return @ unless list e = {} e.target = null e.context = null e.target = @ for listeners in list e.context = listeners[1] listeners[0](e, params) @
第二引数のparamsに...(スプラット)をつけると、第二引数以降に渡されたすべての引数が配列として渡ってきます。これなら引数の数を意識することなく好きなだけ渡すことが出来ます。
例: 書籍名を引数として渡してみる
Reader.coffee
class window.Reader constructor: (name)-> @name = name onNewBook: (event, items)-> titles = '' for item in items title += "\"#{item}\" " console.log "I will go to buy #{titles} to bookstore."
Publisher.coffee
class window.Publisher extends EventObserver constructor: (name)-> @name = name notifyNewItemReleased: (item, title)-> @.trigger item, title
application.coffee
oreilly.notifyNewItemReleased 'coffeescriptCookbook', 'CoffeeScript Cookbook'
コンソールに以下のようなログが出力されるはずです。
I will go to buy "CoffeScript Cookbook" to bookstore.
おわりに
JavaScript による非同期通信が増えてくるにつれて、各クラスや処理の疎結合が重要になってきます。クラス化によって処理の実態を一箇所に集約できても、それらの処理を外部から呼び出しまくっては台無しです。僕自身、直近の案件の最中で Observer パターンを学習しましたが、随分と助けられました。
以前より書籍などで目にはしていましたが、どれも抽象的な説明ばかりで全然頭に入ってこなかったため、この記事のように自分なりの具体例に置き換えることで理解することが出来ました。少しでも皆様の理解の助けになればと思います。