2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

TypeScriptでデザインパターンを考えてみる(Command編)

Last updated at Posted at 2023-08-04

この記事は?

オライリー社から出版されている「HeadFirst デザインパターン」を読んだ内容を
理解の整理のためTypeScriptで実装してみる内容です。(定期で載せれる様がんばる)

対象となるデザインパターン

Commandパターン

Commandパターンはオブジェクト指向設計の原則でいうところの「カプセル化」にフォーカスを当てたデザインパターンです。

参考書では、以下の前提を想定のストーリーとし、サンプルとして 「照明(Light)」、「シーリングファン(CeilingFan)」の二つのデバイスを操作できるリモコンを実装していく流れで説明されています。

家電のON/OFFとUNDOを操作できるリモコンを実装する。
家電には複数の種類がある。

アーキテクチャの理解

Commandパターンは以下の登場人物が登場します。

  • Client
    • 処理を依頼するオブジェクト。今回のケースで言うとこの「リモコンを操作する人間」。
  • Invorker
    • Clientからの依頼を受け、コマンドを実行するクラス。コマンドの処理をカプセル化し、Clientが理解できる形にする。今回のケースで言うとこの「リモコン」。
  • Command
    • Receiverの実装をカプセル化し、リモコンが実行できる形にする。今回のケースで言うとこの「リモコンのボタンに設定された機能」
  • Receiver
    • 最終的に実行されるオブジェクト。今回のケースで言うとこの「家電(照明、シーリングファン)」。

スクリーンショット 2023-08-03 8.18.58.png

各登場人物に今回の役名を被せてみるとこんな感じ。

スクリーンショット 2023-08-03 8.56.45.png

以上のイメージで、実際にコードをみていきましょう。

コードで書いてみる

カプセル化のメリットを理解するため、実装は以下の順序で行なっていきます。

  1. Commandパターンなし(カプセル化なし)でコーディング
  2. カプセル化をしない場合の課題の確認
  3. Commandパターン(カプセル化あり)を使用してコーディング

Device(Light)のコーディング

devices.ts
export class Light {
  on() {
    console.log("Light on.");
  }
  off() {
    console.log("Light off.");
  }
}

シンプルですね。これをユーザ側の実装で動かしてみるとこんな感じです。

user.ts
import { Light } from "./devices"

// 照明を直接操作する
const ligth = new Light();
ligth.on(); // => Light on.
ligth.off(); // => Light off.

ここまではそのままです。何も問題なし。

Device(CeilingFan)のコーディング

この状態で新しいデバイス(CeilingFan)を追加してみましょう。
シーリングファンはちょっと複雑な家電で、ON/OFF機能以外に、モード変更機能が搭載されています。

devices.ts
// Lightの実装の下に追加

export class CeilingFan {
  // モード一覧
  static readonly OFF = 0;
  static readonly LOW = 1;
  static readonly MEDIUM = 2;
  static readonly HIGH = 3;

  // 現在のモード状態
  mode:
    | typeof CeilingFan.OFF
    | typeof CeilingFan.LOW
    | typeof CeilingFan.MEDIUM
    | typeof CeilingFan.HIGH;

  constructor() {
    this.mode = CeilingFan.OFF;
  }

  // どんなモードがあるか一覧を返す
  getModeList(): number[] {
    return [CeilingFan.OFF, CeilingFan.LOW, CeilingFan.MEDIUM, CeilingFan.HIGH];
  }

  // 現在のモード状態を返す
  getCurrentMode(): typeof this.mode {
    return this.mode;
  }

  // 現在がOFF状態かどうか返す
  isOff(): boolean {
    return this.mode === CeilingFan.OFF;
  }

  // モードをhighにする
  private high(): void {
    this.mode = CeilingFan.HIGH;
    console.log("High mode.");
  }
  // モードをmediumにする
  private medium(): void {
    this.mode = CeilingFan.MEDIUM;
    console.log("Medium mode.");
  }
  // モードをlowにする
  private low(): void {
    this.mode = CeilingFan.LOW;
    console.log("Low mode.");
  }

  // モードをoffにする
  off(): void {
    this.mode = CeilingFan.OFF;
    console.log("Off mode.");
  }

  // 電源をつける
  on(): void {
    if (this.isOff()) {
      this.low();
    }
  }

  // モードを指定して変更する
  changeMode(mode: typeof this.mode): void {
    switch (mode) {
      case CeilingFan.OFF:
        return this.off();
      case CeilingFan.LOW:
        return this.low();
      case CeilingFan.MEDIUM:
        return this.medium();
      case CeilingFan.HIGH:
        return this.high();
      default:
        console.log("Not implemented mode.");
        return;
    }
  }
}

ぱっと見複雑ですが、やっていることはシンプルです。
このデバイスの操作をユーザ側に追加してみましょう。

user.ts
import { Light, CeilingFan } from "./devices"

// 照明を直接操作する
const ligth = new Light();
ligth.on(); // => Light on.
ligth.off(); // => Light off.

// シーリングファンを直接操作する
const fan = new CeilingFan();
fan.on(); // => Low mode.
fan.high(); // => High mode.
fan.off(); // => Off mode.

ぱっとみ良さそうですね。

カプセル化をしない場合の課題の確認

ここで突然ですが、問題です。

  • Q1: Mediumモードに切り替えたい時、fan のどのメソッドを使用すれば良いでしょうか?
  • Q2: changeMode(5) 実行時に出る表示はなんでしょうか?

...
...
...
答えは、以下になります。

  • A1: fan.changeMode(2)
  • A2: Not implemented mode.

正解でしたでしょうか?不正解でしたでしょうか?
どちらにしろ、回答された方はCeilingFanの仕様を理解するため、コードをかなり読み直す必要があったと思います。
ここで確認したかったのは、ユーザが正しくデバイスのAPIを使用するには、
APIがどのような仕様になっているのかを 完璧に理解しておく必要がある ということです。

これってキツくないですか?
私はキツいです。

開発現場を想定して考えてみましょう。
自分がユーザ処理の実装を担当する開発チームにいたとします。
その時、デバイス開発チームがデバイスの仕様を実装すると思いますが、このデバイス側の仕様を完全に理解していないと、ユーザチームは開発することができない、と言うことになってしまいます。

これって結局のところ、ユーザチームはデバイスチームの仕事 + ユーザ開発をしている、と言い換えても過言ではないと思います。

話をカプセル化に戻します。
CommandパターンはDevice(Receiver)の仕様をカプセル化し、UserがDeviceの仕様を把握してなくても実装を行うことができる手法になります。

では実際にどのように実装していくのか実際のコードを見ていきましょう。

Commandのコーディング(Light用)

 このデザインパターンの名前から読み取れるように、このパターンにおいてもっとも根幹の役割をおこなうのがこのCommandクラスになります。
 Commandクラスは複雑な仕様をもつReciever(Device)クラスの依存を、Client(User)から切り離す(カプセル化する)為に存在します。その為、複雑性がCommandに集約されがちで、比較的他の役割より実装が難しくなります。

まずはシンプルなLightクラス用Commandの実装から進めていきます。
Commandクラスを実装する前に、Deviceのインターフェースを定義してしまいましょう。

devices.ts
// DeviceクラスのIFを追加
export interface Device {
  on(): void;
  off(): void;
}

// 照明クラス。Device IFをimplementsする
// それ以外は先ほどと同じ。
export class Light implements Device {
  on() {
    console.log("Light on.");
  }
  off() {
    console.log("Light off.");
  }
}

Deviceインターフェースができたので、それを使用してCommandクラスを実装していきます。

commands.ts
import { Device } from "./devices";

// CommandもIFを定義しておく
export interface Command {
  execute(): void;
  undo(): void;
}

// OnCommandクラス
export class OnCommand implements Command {
  device: Device;

  constructor(device: Device) {
    this.device = device;
  }

  execute() {
    this.device.on();
  }

  undo() {
    this.device.off();
  }
}

// OffCommandクラス
export class OffCommand implements Command {
  device: Device;

  constructor(device: Device) {
    this.device = device;
  }

  execute() {
    this.device.off();
  }

  undo() {
    this.device.on();
  }
}

Commandクラスだけ実装しても、それをセットするInvorker(RemoteController)がいないと動作しません。
続いてRemoteControllerクラスを実装していきましょう。

RemoteController(Invorker)のコーディング

Invorkerクラスはユーザからのリクエストを受け付けるUIのクラスだと考えるとわかりやすいと思います。
Commandへの複雑な依頼を、Invorkerが担ってくれるので、ユーザは簡単な命令を依頼するだけで、Commandを実行することができます。

今回はInvorkerは家電を操作するリモコン(RemoteControl)としています。
前提として、リモコンには以下の機能があるとします。

- 7つのデバイス(slot)を登録可能
- 1デバイスごとに以下の2つのボタンが存在する
  - ONボタン
  - OFFボタン
- Undo(取り消し)ボタンを1つもっている

以下が実装内容です。

invorkers.ts
import { Command } from "./commands";

// RemoteControlクラス
export class RemoteControl {
  static MaxSlotNumber = 7; // リモコンのボタン数

  onCommands: Command[] = [];
  offCommands: Command[] = [];
  undoCommand: Command;

  constructor() {
    // 初期化時に全てのSlotにNoCommandをセット
    const execute = () => {
      console.log("No execute command.");
    };
    const undo = () => {
      console.log("No undo command.");
    };
    const noCommand = { execute, undo };

    for (let i = 0; i < RemoteControl.MaxSlotNumber; i++) {
      this.onCommands.push(noCommand);
      this.offCommands.push(noCommand);
    }
    this.undoCommand = noCommand;
  }

  // Commandセット
  setCommand(slot: number, onCommand: Command, offCommand: Command) {
    this.onCommands[slot] = onCommand;
    this.offCommands[slot] = offCommand;
  }

  // UndoCommandセット
  private setUndoCommand(command: Command) {
    this.undoCommand = command;
  }

  pressOn(slot: number) {
    this.onCommands[slot].execute();
    this.setUndoCommand(this.onCommands[slot]);
  }

  pressOff(slot: number) {
    this.offCommands[slot].execute();
    this.setUndoCommand(this.offCommands[slot]);
  }

  pressUndo() {
    this.undoCommand.undo();
  }
}

今は以下のようなクラスが存在している状態です。

スクリーンショット 2023-08-03 8.45.29.png

では、実際に動作するか試してみましょう。

動作検証(Light編)

user.ts
import { Light } from "./devices";
import {
  OnCommand,
  OffCommand,
} from "./commands";
import { RemoteControl } from "./invorkers";

// リモコンを用意します
const remoteControl = new RemoteControl();

// Light の Command をセットします
const light = new Light();
const lightOnCommand = new OnCommand(light);
const lightOffCommand = new OffCommand(light);
remoteControl.setCommand(1, lightOnCommand, lightOffCommand);

// ボタンを押してみる
remoteControl.pressOn(1); // => Light on. 
remoteControl.pressOff(1); // => Light off.
remoteControl.pressUndo(); // => Light on.

// Commandがセットされてないボタンの場合
remoteControl.pressOn(3); // => No execute command.
remoteControl.pressOff(3); // => No execute command.
remoteControl.pressUndo(); // => No undo command.

ここで重要なのが、ユーザはリモコンの中でどんな処理がされてるのかさっぱりわかっていないということです。
ユーザが把握しているのはあくまで、

- リモコンSlot{x}のONボタンを押せば、Slot{x}に登録された家電がONになる
- リモコンSlot{x}のOFFボタンを押せば、Slot{x}に登録された家電がOFFになる
- リモコンのUNDOボタンを押せば、事前に押した処理が取り消される

以上のみです。めっちゃ楽そうですね。
これがカプセル化の効果であり、Commandパターンの内容になります。

Lightクラスだけだと、ありがたみがちょっと分かりずらいと思うので、残りのCeilingFanも実装してみましょう。

CeilingFanの実装(Device, Command)

  • CeilingFanクラスの改修
devices.ts
// ...DeviceやらLightクラスの実装は省略

// Mode機能を持っているDevice用IF
export interface ModeDevice extends Device {
  changeMode(mode: number): void;
  getModeList(): number[]; // 値は1ずつ上がるものとする
  getCurrentMode(): number;
  isOff(): boolean;
}

// CeilingFanクラス。IFをimplementsする
export class CeilingFan implements ModeDevice {
    // 中は変更ないので割愛
}
  • Mode機能を持っているDevice用のCommandを実装
commands.ts
import { Device, ModeDevice } from "./devices";

// 既存の実装は省略

// Mode機能をもっているDevice向けのOnCommandクラス
export class ModeDeviceOnCommand implements Command {
  device: ModeDevice;
  prevMode: number; // undoに必要。変更前のモード
  maxMode: number; // モード遷移可能な最大値
  minMode: number; // モード遷移可能な最小値

  constructor(device: ModeDevice) {
    this.device = device;
    this.prevMode = device.getCurrentMode(); // Undo用に変更前のModeを保持
    this.maxMode = Math.max(...device.getModeList()); // Modeを「弱->中->強->弱...」といったループ遷移にするため必要
    this.minMode = Math.min(...device.getModeList()); // Modeを「弱->中->強->弱...」といったループ遷移にするため必要
  }

  // モードの変更
  private changeMode(): void {
    const currentMode = this.device.getCurrentMode();
    if (currentMode < this.maxMode) {
      return this.device.changeMode(currentMode + 1);
    }

    this.device.changeMode(this.minMode);
  }

  // ONボタンを押された時の処理
  execute() {
    this.prevMode = this.device.getCurrentMode(); // 変更前を保持

    if (this.device.isOff()) {
      // OFF状態の場合はONにする
      this.device.on();
    } else {
      // それ以外の時は、モードを遷移させる
      this.changeMode();
    }
  }

  // 取り消し操作
  undo(): void {
    const tempPrevMode = this.device.getCurrentMode();
    this.device.changeMode(this.prevMode);
    this.prevMode = tempPrevMode;
  }
}

// Mode機能をもっているDevice向けのOffCommandクラス
export class ModeDeviceOffCommand implements Command {
  device: ModeDevice;
  prevMode: number; // undoに必要。変更前のモード

  constructor(device: ModeDevice) {
    this.device = device;
    this.prevMode = device.getCurrentMode(); // Undo用に変更前のModeを保持
  }

  // OFFボタンを押された時の処理
  execute() {
    this.prevMode = this.device.getCurrentMode();

    this.device.off();
  }

  // 取り消し操作
  undo(): void {
    const tempPrevMode = this.device.getCurrentMode();
    this.device.changeMode(this.prevMode);
    this.prevMode = tempPrevMode;
  }
}

Modeを持っている場合、少し機能が複雑なため実装がややこしくなってますが、やっていることは基本的にLightクラスと同じです。
まとめると以下のような内容になります。

Commandクラスを実装する。
Deviceの特性 `execute(), undo()` にカプセル化する。

動作検証(CeilingFan編)

user.ts
import { CeilingFan } from "./devices";
import {
  ModeDeviceOnCommand,
  ModeDeviceOffCommand
} from "./commands";
import { RemoteControl } from "./invorkers";

// リモコンを定義
const remoteControl = new RemoteControl();

// CeilingFan の Commandをセット
const fan = new CeilingFan();
const fanOnCommand = new ModeDeviceOnCommand(fan);
const fanOffCommand = new ModeDeviceOffCommand(fan);
remoteControl.setCommand(1, fanOnCommand, fanOffCommand);

remoteControl.pressOn(1); // => Low mode.
remoteControl.pressOn(1); // => Medium mode.
remoteControl.pressOff(1); // => Off mode.
remoteControl.pressUndo(); // => Medium mode.

いい感じに動いてそうです。
もちろん上の検証コードにLight用のCommandを定義して、別スロットにセットしてあげても正常に動作します。

user.ts
const light = new Light();
const lightOnCommand = new OnCommand(light);
const lightOffCommand = new OffCommand(light);
remoteControl.setCommand(2, lightOnCommand, lightOffCommand);

remoteControl.pressOn(2); // => Light on.
remoteControl.pressOff(2); // => Light off.
remoteControl.pressUndo(); // => Light on.

改めてになりますが、ここで重要なのはユーザが以下だけを把握しており、他は関知していない、ということです。

- リモコンSlot{x}のONボタンを押せば、Slot{x}に登録された家電がONになる
- リモコンSlot{x}のOFFボタンを押せば、Slot{x}に登録された家電がOFFになる
- リモコンのUNDOボタンを押せば、事前に押した処理が取り消される

Commandパターンの使い所について

Wikiに一覧で記載されていましたのがわかりやすかったので、そちらを転載させていただきます。

まとめ

以上、Commandパターンでした。
今までのデザインパターンとことなり、複数のコンポーネントに跨ったパターンのため、どのように記事にまとめればわかりやすく書けるのか、悩みました。
最終的にわかりやすくなっているかはわからないですが、自身の整理と言う意味では、目的を果たせたんじゃないかな?と思ってます。(自己弁護&自己完結)

普段rubyを使ってるせいもありますが、デザインパターンは「便利そうだけど、使い所...はて」 といったものが多い印象でした。(個人の感想です)
それに比べCommandパターンは、今までのデザインパターンと違い、使えるシーンが結構思い当たるので、実用性が高そうな印象です。
私は普段Railsアプリケーションを扱うことが多いので、ActiveJob が一番身近なCommandパターンの例だと思いました。

デザインパターンを学ぶ意義として、「考え方を知る」ことが重要だと思っています。
引き続き基本的なデザインパターンを学んで、考え方の幅を広げていきたいと思います。
次は「Adaptarパターン&Facadeパターン」です。(記事分けるかも。)

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?