Observerについて

一般的にActiveModelでなにかモデルの状態を変更した場合はafter_saveや最近追加されたafter_save_commitなどのコールバックを使う。 デザインパターンの世界ではObserverというパターンが存在するようだ。

まずはChatGPTに質問してみた:

Observer パターンは、ソフトウェアデザインパターンの一つで、オブジェクトの状態の変化を他のオブジェクトに通知するためのものです。このパターンでは、あるオブジェクト(被観察者またはサブジェクトと呼ばれることもあります)がその状態が変化したときに、それに興味を持っている他のオブジェクト(観察者)に変更を通知します。これにより、被観察者と観察者の間で強い結合を避け、柔軟性を高めることができます。

Observer パターンの主な要素は次の通りです:

  1. 被観察者(Subject): 状態の変化を通知する対象となるオブジェクト。被観察者は観察者を管理し、状態が変化した際にそれらに通知します。
  2. 観察者(Observer): 被観察者の状態変化に興味を持っているオブジェクト。これらのオブジェクトは被観察者に登録され、状態の変更があると通知を受けることができます。

Observer パターンの基本的な動作は以下のようになります:

  • 被観察者には観察者を登録するためのメソッド(通常は addObserver)が用意されています。
  • 観察者は被観察者に対して自分を登録します。
  • 被観察者の状態が変化すると、登録された観察者全員に通知が行われます。通知メソッド(通常は notifyObservers)を呼び出すことによって実現されます。
  • 観察者は通知を受け取り、適切な処理を行います。

Observer パターンは、イベント処理、ユーザーインターフェイス、モデル-ビュー-コントローラ(MVC)アーキテクチャなどでよく使用されます。被観察者と観察者の分離により、システムの変更が簡単に行え、柔軟性が向上します。

例えばあるFooというモデルがあり、Foo#barを実行したあとにBazMailerがメールを送信するとしよう、こういった場合FooBazMailerに依存する必要はないし、後からQuxNotifierというクラスを追加してもよいだろう。 ただ私もよく理解できなかったのだけれどもObserverはなんとなく関心の分離という意味のクラスではあるものの、コールバックとの違いがよく理解できなかった。

そこで続けてChatGPTに質問してみた:

ObserverとCallbackは、ソフトウェア開発において異なる概念を指しますが、共通して特定のイベントや状態の変化に対する反応を定義する手段です。

Observer パターン:

  • 定義: Observer パターンは、オブジェクトの状態の変化を監視し、変化があった際に他のオブジェクトに通知するためのデザインパターンです。
  • 構成要素: 被観察者(Subject)と観察者(Observer)があります。被観察者は状態の変化を通知し、それに興味を持っている観察者がその通知を受け取り、適切な処理を行います。
  • 利点: オブジェクト間の疎結合を提供し、変更が容易で柔軟な設計を可能にします。例えば、GUIフレームワークのイベントハンドリングやMVC(Model-View-Controller)アーキテクチャでよく使用されます。

Callback:

  • 定義: Callbackは、ある関数やメソッドを引数として渡し、別の関数やメソッドの実行中に特定のポイントで呼び出すことを指します。これは、非同期処理、イベント処理、またはコールバック関数として知られています。
  • 構成要素: 通常は関数またはメソッドです。関数が別の関数を引数として受け取り、後者の関数が実行される際に、前者の関数が呼び出されます。
  • 利点: コールバックは非同期処理やイベント駆動の場面で有用で、特定のイベント発生時に実行されるカスタムロジックを提供します。JavaScriptやRubyなどの言語でよく使用されます。

違い:

  1. パターン vs. 機能:
    • Observerはデザインパターンで、オブジェクト間の状態の変化を通知するためのものです。
    • Callbackは特定のイベントや条件が発生したときに、指定されたコード(関数やメソッド)を実行する手段です。
  2. 構成要素:
    • Observerは被観察者と観察者という明示的な構成要素があります。
    • Callbackは通常、関数やメソッドとして具現化されます。
  3. 使用シナリオ:
    • Observerはオブジェクト間の状態変化に関連して使用され、疎結合な設計を促進します。
    • Callbackは非同期処理やイベント駆動プログラミングでよく使用され、特定のイベントが発生したときに対応する処理を提供します。

簡単に言えば、Observerはオブジェクト間の関係性を扱うデザインパターンであり、Callbackはある処理が完了した際に他の処理を実行する手段を指します。

この回答を得た時点ではまだ納得行かなかったのだけれども、ここで重要なのは状態が変化するということにつきる。 イメージとしてはReactのsetStateが実行されたときに再描画が行われるようなイメージに近いと思う。

ChatGPTは親切なのでデザインパターンといえばJavaということで、Javaのコード例を提示してくれているのだがここでは割愛する。 Rubyでも同様にJavaをなぞるような簡易的なクラスの実装はできなくもないのだが、ObserverというまさにこのObserverパターンの実装を行うためのモジュールが存在する。

class Ticker
  def run
    if price != last_price
      changed # notify observers
      last_price = price
      notify_observers(Time.now, price)
    end
  end
end

コードを抜粋してみたがここではchangedが重要みたいだ。 Observerのコード自体はこの投稿の時点でも230行程度で、その大半がRDocのものなので実装を読み解くことはそこまで難しくなさそうである。

続けてnotify_observersのコードも掲載してみよう:

def notify_observers(*arg)
  if defined? @observer_state and @observer_state
    if defined? @observer_peers
      @observer_peers.each do |k, v|
        k.__send__(v, *arg)
      end
    end
    @observer_state = false
  end
end

ちょうどdefined? @observer_state and @observer_statechanged?にあたる部分だと思う。 つまり状態が変更されなければSubjectに対して通知は行われないということだ。 これが明確にコールバックとは異なる部分だろう。

ちなみにchanged?というメソッドはActiveModelにも存在する。 ということは、Observerをインクルードしてobserverを登録しておけばよさそうだ。

RubyのObservableモジュールはObserverパターンを実装するためのもので、オブジェクトの状態変化を監視し、変化があった際に登録されたObserverに通知することができます。しかし、ObservableモジュールはRuby 2.5.0から非推奨とされ、推奨される代替手段は提供されていません。

代わりに、通常のオブジェクト指向の原則を使ってObserverパターンを実装することが一般的です。

本当はこのあたりまでChatGPTにも踏み込んでほしかったのだけれども、なぜかChatGPTの世界線ではObservableは非推奨になっている。

class Foo < ApplicationRecord
  include Observable

  after_save -> { notify_observers(self) }

  def bar
    self.update(value: rand(100))
  end
end

class Baz
  def initialize(foo)
    foo.add_observer(self)
  end

  def update(foo) # メソッドはupdateである必要がある
    puts "value is #{foo.value}"
  end
end

foo = Foo.create
Baz.new(foo)
foo.bar

理想はこのようなコードを書きたかった。 このコードであれば単にafter_save -> { Baz.new.update(self) }でもよいのだが、あくまでFooBazというクラスの存在に依存しないのがポイントである。 ただしObservableをインクルードしてしまうともともとRails側で定義されるはずのchanged?がまるごと上書きされてしまう。 またnotify_observerschanged?ではなくdefined? @observer_state and @observer_stateであるのでやはりActiveRecordの恩恵をうけるには自分でクラスを用意するほうがよさそうである。

NOTE: これを見越してChatGPTが非推奨と言ったのかは定かではない。

module NotifyObservable
  extend ActiveSupport::Concern

  included do
    after_save_commit :notify_observers
  end

  def add_observer(observer)
    unless observer.respond_to?(:update)
      raise NoMethodError, "observer does not respond to `update'"
    end
    observers << observer
  end

  private

  def observers
    @observers ||= []
  end

  def notify_observers
    observers.each { _1.update(self) } if observable?
  end

  def observable?
    observers.present? && saved_changes?
  end
end

なかなかよい感じである。 本当はObservableが使いたかったのだけれども、既に定義されているモジュールを上書きするわけにもいかない。

changed?after_saveの時点でfalseを返すのでタイミング的には少々異なる。 DBに更新がcommitされたタイミングが好ましいのでsaved_changes?にした。 この変更に伴いFooは単純にNotifyObservableをインクルードするだけでよくなった。

class Foo < ApplicationRecord
  include NotifyObservable
end

では実際に動かしてみよう:

$ rails c
Loading development environment (Rails 7.0.8)
=> #<Foo:0x00007f4841d43c18 id: 15, value: nil, created_at: Fri, 15 Dec 2023 02:13:22.984689000 JST +09:00, updated_at: Fri, 15 Dec 2023 02:13:22.984689000 JST +09:00>
>> foo.bar
=> true
>> Baz.new(foo)
=> #<Baz:0x00007f5c23c4d958>
>> foo.bar
value is 42
=> true

ここでのポイントはFoo#barを2度実行しているが、あくまでObserverの有無に関係ないところだ。 あとはBazをどのタイミングで呼び出すかであるけれども、ActiveJob関連のようにすでにコールバックが用意されているものであればこのパターンを使う恩恵はない。 つまりServiceクラスであったり、自分で定義した単純なクラスであればこの恩恵を手にすることができそうである。