TypeScript で学ぶ Observer パターン

wakamsha
51

一つ前に公開したエントリ『RxJS を学ぼう #1 – これからはじめる人のための導入編』にて『Rx は Observer パターンを基本に据えています』とご説明しました。Observer パターンとは、プログラム内のオブジェクトの状態を観察 ( 英: observe ) するようなプログラムで使われるデザインパターンの一種であり、出版 - 購読型モデルとも呼ばれます。

Rx を学ぶ前にその基本にある Observer パターンについておさえておくとします。

前置き

理解の補助線として以下のような具体例をもとに進めていくとしましょう。

新刊の発売を心待ちにしている読者

読者 ( Reader )出版社 ( Publisher ) という2つの登場人物がいます。読者は出版社より近日発売予定のとある新刊を購入したいと考えていますが、出版社内で編集が二転三転しているのかいつ発売されるのか未定のままです。読者はマメに出版社の公式サイトを訪問するものの、いつも空振り。読者は新刊が発売されたら自分に連絡してくれるように出版社に頼んでみました。しかし出版社としては、ひとりの読者のためにそこまでするのは躊躇してしまいます。何か上手い解決方法は無いでしょうか?

出版社が直接読者に連絡するということは、出版社クラスが読者クラスの『新刊を購入する』という処理を直接呼び出すのと同じ意味を持ちます。つまり読者クラスは出版社クラスに依存しているということになりますね。

Observer パターンのしくみ

まずは2つのキーワードをしっかり把握しておく必要があります。

Observer 監視する人という意味を持ちます。
Subject
( Observable )
〜を行うという意味を持ちますが、ここでは通知する人という意味がしっくりきます。
監視対象という意味で『Observable』と表記することもあります。

読者はお目当ての新刊が発売されたことを出版社から知らせてほしいと考えています。しかし出版社は特定の読者に直接連絡したくありません。知らせる対象が複数人となるとそれだけ手間がかかるうえ、他の新刊を発売する際にも同じことをすることになってしまうからです。そこで出版社は読者に対して自分を監視するように頼みます。出版社は書籍を発売する度に周囲に対して通知します。特定の誰かに対して、などと意識することなく純粋に通知するだけです。読者は出版社を監視しているので何かが通知されたらすぐに分かります。もしそれがお目当ての新刊の発売であったらなら、すぐに Amazon などから購入するというアクションを起こすことが出来ます。

このお話をプログラミングに置き換えてみましょう。監視する人という意味の Observer は読者、通知する人という意味の Subject ( もしくは Observable ) は出版社です。Observer は Observable が新刊を発売するかどうかを監視します。この監視がいわゆるAddEventListener に相当するものであり、お目当ての新刊を発売するというのがEventNameになります。Observable は新刊を発売したらそのことを周囲に通知します。これが Event の発火というやつにあたります。Observer は Event をリッスンしているので、発火されたタイミングで購入というアクションを実行します。これが EventHandler です。

長くなりましたが、これをプログラミングで実現するための仕組みを Observer パターンといいます。特徴は出版社は新刊を発売したらそのことを不特定多数に通知するだけで役目を終えます。その後読者が Amazon から購入するということを出版社が意識することはありません。読者も自分から出版社を監視して通知された内容が目的のものであれば自分から購入します。出版社から購入してくださいと直接呼び出される ( 依存する ) こともありません。出版社というクラスが読者というクラスが持つ購入するという処理を直接呼び出さなくてよくなります。

[ Step.1 ] TypeScript で書いてみよう

まずは Observer と Observable のインターフェースだけをそれぞれ定義します。

Interfaces

interface Observer {
  onNewBook(): void;  // 通知されたら実行する処理
}
interface Observable {
  on(reader: Observer): void;   // 通知対象のオブザーバを追加
  off(reader: Observer): void;  // オブザーバを通知対象から除外
  notify(): void;               // 通知する
}

次にこれらの実装クラスである Reader ( 読者 ) と Publisher ( 出版社 ) をそれぞれ作成します。

Reader クラスと Publisher クラス

まずは Reader クラスから。

class Reader implements Observer {

  constructor(private name: string) {}

  public onNewBook() {
    console.log(`${this.name} : I will go to buy the book to bookstore`);
  }
}

onNewBook というリスナー関数を定義しました。中身はコンソールにログを出力するだけですが、新刊発売のイベントが発火したタイミングでこの関数を呼び出します。

class Publisher implements Observable {

  private readers: Observer[];

  constructor(public name: string)  {
      this.readers = [];
  }

  public on(reader: Reader) {
    this.readers.push(reader);
  }

  public off(reader: Reader) {
    this.readers.splice(this.readers.indexOf(reader), 1);
  }

  public notify() {
    this.readers.forEach((reader: Reader) => reader.onNewBook());
  }
}

readers というインスタンスプロパティにオブザーバである読者オブジェクトをどんどんぶち込んでいきます。これが on() で行っている処理の実態です。off() はその逆ですね。登録済みのオブザーバを削除します。nofity() で指定された登録されたオブザーバの onNewBook()を呼び出します。

オブザーバに通知してみよう

各クラスが出来たので、オブザーバに通知してみます。

const oreilly = new Publisher('oreilly');

const john = new Reader('john');
const paul = new Reader('paul');

oreilly.on(john);
oreilly.on(paul);

oreilly.notify();

結果はこちら。

// "john : I will go to buy the book to bookstore"
// "paul : I will go to buy the book to bookstore"

こちらから実際の動きをご覧いただけます。

See the Pen Observer Pattern with TypeScript #1 by wakamsha (@wakamsha) on CodePen.

[ Step.2 ] 通知を受け取る条件を指定したい

先ほどの例は Observable に追加された オブザーバは全て一様に通知を受け取ってリスナー関数が実行されていました。つまり全ての読者は関心のあるなしに関わらず出版社が新刊の通知をする度に購入するということになります。読者によっては雑誌だけを購入したかったりムック本だけを購入したい人もいることでしょう。そこで読者は出版社を監視する際にどんな新刊を欲しているのかを予め指定し、その通知が来たときだけ購入処理を実行するといった作りに変えてみるとします。

Interface

Observable を以下のように改修します。

interface Observable {
    on(state: string, reader: Observer): void;
    off(state: string, reader: Observer): void;
    notify(state: string): void;
}

各メソッドに state というパラメータを追加しました。これと読者オブジェクト ( reader ) の組み合わせを登録するようにし、通知する際は state を指定することでそれに紐付いた読者の onNewBook を呼び出すようにします。

Publisher クラスを改修

class Publisher implements Observable {

  private listeners: Listener[];
    
  constructor(public name: string)  {
    this.listeners = [];
  }
    
  public on(state: string, reader: Reader) {
    const listener = this.getListener(state);
    if (listener && listener.readers) {
      listener.readers.push(reader);
    } else {
      this.listeners.push({
        state,
        readers: [reader]
      });
    }
  }
    
  public off(state: string, reader: Reader) {
    const listener = this.getListener(state);
    listener && listener.readers.splice(listener.readers.indexOf(reader), 1);
  }
    
  public notify(state: string) {
    const listener = this.getListener(state);
    listener && listener.readers.forEach((reader: Reader) => reader.onNewBook());
  }
    
  private getListener(state: string): Listener {
    return this.listeners.find((listener) => listener.state === state);
  }
}

listeners というインスタンスプロパティを新規に用意しました。state とその対象となる reader の配列をもつオブジェクトを一覧管理するためのものです。以下のような型情報を持ちます。

type Listener = {
  state: string;
  readers: Observer[];
}

オブザーバに通知してみよう

const oreilly = new Publisher('oreilly');

const john = new Reader('john');
const paul = new Reader('paul');
const wakamsha = new Reader('wakamsha');

oreilly.on('release', john);
oreilly.on('release', paul);
oreilly.on('sale', wakamsha);

oreilly.notify('release');

console.log('-------- 中略 --------');

oreilly.off('release', john);
oreilly.notify('release');

結果はこちら。

// "john : I will go to buy the book to bookstore"
// "paul : I will go to buy the book to bookstore"
// "-------- 中略 --------"
// "paul : I will go to buy the book to bookstore"

こちらから実際の動きをご覧いただけます。

See the Pen Observer Pattern with TypeScript #2 by wakamsha (@wakamsha) on CodePen.

sale という state で読者 wakamsha を新たに追加しましたが、出版社は release という state を通知したので、john と paul の二人の読者だけが受け取ることになります。また、その後に john が監視をやめて通知対象から外れました。その後の release 通知は paul だけが受け取っているのがわかります。

[ Step.3 ]リスナー関数に引数を渡せるようにしたい

イベント発火時に呼び出し側から引数を渡すことができれば利便性が向上します。最も簡単なのは notify 関数に第二引数、第三引数と定義すれば良いのですが、これでは渡せる引数の数に上限が出来てしまいます。より柔軟性を求めるならば notify の呼び出し側は引数の数を意識することなく自由に渡せられるべきであり、それらが全て等しく処理されるべきです。このような仕組みを可変長引数 ( Spread operator ) と呼びます。

Interfaces

interface Observer {
    onNewBook(...params: any[]): void;
}
interface Observable {
    on(state: string, reader: Observer): void;
    off(state: string, reader: Observer): void;
    notify(state: string, ...params: any[]): void;
}

第二引数のparams...(スプラット)をつけると第二引数以降に渡されたすべての引数が配列として渡ってきます。これなら引数の数を意識することなく好きなだけ渡すことが出来ます。

例: 書籍名を引数として渡してみる

class Reader implements Observer {

    constructor(private name: string) {}

    public onNewBook(...params: any[]) {
        console.log(this.name, `I will go to buy the ${params} to bookstore`);
    }
}
class Publisher implements Observable {

    private listeners: Listener[];

    constructor(public name: string)  {
        this.listeners = [];
    }
    ︙
    public notify(state: string, ...params: any[]) {
        const listener = this.getListener(state);
        listener && listener.readers.forEach((reader: Reader) => reader.onNewBook(params));
    }
    ︙
}

各メソッドに...params: any[]というパラメータを追加しました。今度はオブザーバに通知するとともに書籍名を渡してみます。

oreilly.notify('release', '初めてのJavaScript', 'Reactビギナーズガイド');

結果はこちら。

// "john" "I will go to buy the 初めてのJavaScript,Reactビギナーズガイド to bookstore"
// "paul" "I will go to buy the 初めてのJavaScript,Reactビギナーズガイド to bookstore"

こちらから実際の動きをご覧いただけます。

See the Pen Observer Pattern with TypeScript #3 by wakamsha (@wakamsha) on CodePen.

締め

Observer パターンは、通称 GoF 本と呼ばれる『オブジェクト指向における再利用のためのデザインパターン』にて紹介されていることでも有名です。RxJS を使ううえで何となく Observables / Observer という用語に触れていますが、このように自分なりの具体例に置き換えてサンプルコードを書いてみることで理解を深めることが出来ました。こうしてみると document.addEventListener('click', (event) => {...}) と殆ど同じですね。

本エントリが少しでも皆さまの理解の手助けになれば幸いです。

本エントリは、以前僕が前職のブログに書いた内容を焼き直したものです。