🙌

ストラテジーパターンを効果的に使おう

2024/06/02に公開

ストラテジーパターン

GoF(Gang of Four)デザインパターンの一つになります。
「同じ処理を複数のアルゴリズムで定義・定義したアルゴリズムを交換して効率的にしよう」というデザインパターンです。
「良いコード/悪いコードで学ぶ設計入門―保守しやすい 成長し続けるコードの書き方」という書籍を読んでいる際に出来たデザインパターンですが、効率的に使えばコードが読みやすくなるなと感じ記事にして一人でも多く知って頂ければと思いました

↓「良いコード/悪いコードで学ぶ設計入門―保守しやすい 成長し続けるコードの書き方」を読みたい方は

https://amzn.asia/d/hUlSJd1

GoF(Gang of Four)デザインパターンとは

GoF(Gang of Four)は、「Design Patterns: Elements of Reusable Object-Oriented Software(日本語題:オブジェクト指向における再利用のためのデザインパターン)」という書籍の著者4名のこと(そこまで重要じゃない)。
この4名が設計したデザインパターンの総称になります。

↓もっと詳しく知りたい方は

https://amzn.asia/d/14weeu5

実装

文章で説明するよりコードで書いた方が分かりやすくするためnode.js, express, typescriptで実装しています。

https://nodejs.org/en/

https://expressjs.com/

https://www.typescriptlang.org/

ディレクトリ構成

.
└── src
    ├── Strategies
    │   └── Bill
    │       ├── BillStrategyInterface.ts
    │       ├── NewFiveThousandYenStrategy.ts
    │       ├── NewTenThousandYenStrategy.ts
    │       ├── NewThousandYenStrategy.ts
    │       ├── OldFiveThousandYenStrategy.ts
    │       ├── OldTenThousandYenStrategy.ts
    │       ├── OldThousandYenStrategy.ts
    │       └── index.ts
    ├── enums
    │   └── Bill
    │       └── index.ts
    ├── routes
    │   └── bills.ts
    ├── index.ts
    ├── nodemon.json
    ├── package-lock.json
    ├── package.json
    └── tsconfig.json

環境構築

shell
mkdir strategy
cd strategy
npm init -y
npm install typescript nodemon ts-node @types/node --save-dev
npm i -D tsconfig-paths
npx tsc --init

tsconfig.jsonを編集していきます。

tsconfig.json
{
  "compilerOptions": {
    "target": "ES6",
    "module": "commonjs",
    "esModuleInterop": true,
    "strict": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "baseUrl": ".",
    "paths": {
      "@/*": [
        "src/*"
      ]
    }
  },
  "include": [
    "src/**/*.ts"
  ],
  "exclude": [
    "node_modules"
  ]
}

ホットリロードして開発出来るようにnodemonを導入しています。
何も設定せずにnodemonを使用するとaliasが機能しなかったのでnodemon.jsonを作成して機能するようにしてあげます。

nodemon.json
{
  "watch": [
    "src"
  ],
  "ext": "ts",
  "exec": "ts-node -r tsconfig-paths/register src/index.ts"
}

npmスクリプトを設定していきます。

package.json
{
  ...
  "scripts": {
+    "dev": "nodemon",
    "build": "tsc",
    "start": "node dist/index.js",
  },
  ...
}

初期設定は完了しまいたので正常に起動するか確認していきます。
src/index.tsを作成・編集していきます。

shell
mkdir src
touch src/index.ts
src/index.ts
import express from "express";

const app = express();

app.use(express.json());
app.use(express.urlencoded({ extended: true }));

app.use("/", (req, res) => {
  res.json("Hello World");
});

const PORT = 3000;
app.listen(PORT, () => {
  console.log(`Server is running on http://localhost:${PORT}`);
});

http://localhost:3000/へアクセス・添付画像のように表示されていれば初期設定完了です。
次からは実際にStrategyを実装していきます。

Enumを実装

Enumは非推奨なのでtypeを使用して実装していきます。

src/enums/Bill/index.ts
export const BILL_TYPE = {
  OLD_TEN_THOUSAND_YEN: 0,
  OLD_FIVE_THOUSAND_YEN: 1,
  OLD_THOUSAND_YEN: 2,
  NEW_TEN_THOUSAND_YEN: 3,
  NEW_FIVE_THOUSAND_YEN: 4,
  NEW_THOUSAND_YEN: 5,
} as const;

export type BILL_TYPE = (typeof BILL_TYPE)[keyof typeof BILL_TYPE];

Interfaceを実装

Strategyで使用する共通の操作を定義します。

src/Strategies/Bill/BillStrategyInterface.ts
export interface BillStrategyInterface {
  getName(): string;
  getBehind(): string;
  getPrice(): number;
  isNew(): boolean;
}

Strategyの大元になるclassを作成

constructorにて渡されたStrategyを元にアルゴリズムだけを切り出ししていきます。

src/Strategies/Bill/index.ts
import { BillStrategyInterface } from "./BillStrategyInterface";

/**
 * @export
 * @class bill
 */
export class Bill {
  /**
   * @private
   * @type {BillStrategyInterface}
   * @memberof bill
   */
  private _strategy: BillStrategyInterface;

  /**
   * Creates an instance of bill.
   * @param {BillStrategyInterface} strategy
   * @memberof bill
   */
  constructor(strategy: BillStrategyInterface) {
    this._strategy = strategy;
  }

  /**
   * @memberof bill
   * @returns string
   */
  getName = (): string => this._strategy.getName();
  /**
   * @memberof bill
   * @returns string
   */
  getBehind = (): string => this._strategy.getBehind();
  /**
   * @memberof bill
   * @returns number
   */
  getPrice = (): number => this._strategy.getPrice();
  /**
   * @memberof bill
   * @returns boolean
   */
  isNew = (): boolean => this._strategy.isNew();
}

各Strategyの実装

先ほど実装したInterfaceを元に各Strategyを定義してあげます。

OldTenThousandYenStrategy

src/Strategies/Bill/OldTenThousandYenStrategy.ts
import { BillStrategyInterface } from "./BillStrategyInterface";

export class OldTenThousandYenStrategy implements BillStrategyInterface {
  getName = () => "福沢諭吉";
  getBehind = () => "平等院鳳凰像";
  getPrice = () => 10000;
  isNew = () => false;
}

OldFiveThousandYenStrategy

src/Strategies/Bill/OldFiveThousandYenStrategy.ts
import { BillStrategyInterface } from "./BillStrategyInterface";

export class OldFiveThousandYenStrategy implements BillStrategyInterface {
  getName = () => "樋口一葉";
  getBehind = () => "燕子花図";
  getPrice = () => 5000;
  isNew = () => false;
}

OldThousandYenStrategy

src/Strategies/Bill/OldThousandYenStrategy.ts
import { BillStrategyInterface } from "./BillStrategyInterface";

export class OldThousandYenStrategy implements BillStrategyInterface {
  getName = () => "野口英世";
  getBehind = () => "富士山と桜";
  getPrice = () => 1000;
  isNew = () => false;
}

NewTenThousandYenStrategy

src/Strategies/Bill/NewTenThousandYenStrategy.ts
import { BillStrategyInterface } from "./BillStrategyInterface";

export class NewTenThousandYenStrategy implements BillStrategyInterface {
  getName = () => "渋沢栄一";
  getBehind = () => "東京駅";
  getPrice = () => 10000;
  isNew = () => true;
}

NewFiveThousandYenStrategy

src/Strategies/Bill/NewFiveThousandYenStrategy.ts
import { BillStrategyInterface } from "./BillStrategyInterface";

export class NewFiveThousandYenStrategy implements BillStrategyInterface {
  getName = () => "津田梅子";
  getBehind = () => "藤";
  getPrice = () => 5000;
  isNew = () => true;
}

NewThousandYenStrategy

src/Strategies/Bill/NewThousandYenStrategy.ts
import { BillStrategyInterface } from "./BillStrategyInterface";

export class NewThousandYenStrategy implements BillStrategyInterface {
  getName = () => "	北里柴三郎";
  getBehind = () => "富嶽三十六景";
  getPrice = () => 1000;
  isNew = () => true;
}

それぞれのStrategyを作成出来たら呼び出しを一箇所にまとめて行きます。

src/Strategies/Bill/index.ts
import { BillStrategyInterface } from "./BillStrategyInterface";

+ export * from "./NewFiveThousandYenStrategy";
+ export * from "./NewTenThousandYenStrategy";
+ export * from "./NewThousandYenStrategy";
+ export * from "./OldFiveThousandYenStrategy";
+ export * from "./OldTenThousandYenStrategy";
+ export * from "./OldThousandYenStrategy";

...
}

各Strategyの呼び出し実装

enumからStrategyを取得する形でコードを書いていきます。
if文やswitch文を使用することなく実装出来るためコードがスッキリして読みやすいコードになります。

src/routes/bills.ts
import {
  Bill,
  NewFiveThousandYenStrategy,
  NewTenThousandYenStrategy,
  NewThousandYenStrategy,
  OldFiveThousandYenStrategy,
  OldTenThousandYenStrategy,
  OldThousandYenStrategy,
} from "@/Strategies/Bill";
import { BILL_TYPE } from "@/enums/Bill";
import { Router } from "express";

type CreateBillsDomType<T> = (bills: [T, ...T[]]) => string;

const router = Router();

const BillStrategies: Record<BILL_TYPE, Bill> = {
  [BILL_TYPE.OLD_TEN_THOUSAND_YEN]: new Bill(new OldTenThousandYenStrategy()),
  [BILL_TYPE.OLD_FIVE_THOUSAND_YEN]: new Bill(new OldFiveThousandYenStrategy()),
  [BILL_TYPE.OLD_THOUSAND_YEN]: new Bill(new OldThousandYenStrategy()),
  [BILL_TYPE.NEW_TEN_THOUSAND_YEN]: new Bill(new NewTenThousandYenStrategy()),
  [BILL_TYPE.NEW_FIVE_THOUSAND_YEN]: new Bill(new NewFiveThousandYenStrategy()),
  [BILL_TYPE.NEW_THOUSAND_YEN]: new Bill(new NewThousandYenStrategy()),
};

const createBillsDom: CreateBillsDomType<Bill> = (bills) => {
  return bills
    .map((bill) => {
      return `
        <div>
          <h1 style="margin: 0;">${bill.getName()}</h1>
          <h2 style="margin: 0;">裏面:${bill.getBehind()}</h2>
          <p style="margin: 0;">${bill.getPrice().toLocaleString()}円</p>
          <p style="margin: 0;">${bill.isNew() ? "新" : "旧"}お札</p>
        </div>
      `;
    })
    .join("----------------------------");
};

router.get("/", (req, res) => {
  res.send("Hello bills");
});

router.get("/new", (req, res) => {
  const tenThousandYen = BillStrategies[BILL_TYPE.NEW_TEN_THOUSAND_YEN];
  const fiveThousandYen = BillStrategies[BILL_TYPE.NEW_FIVE_THOUSAND_YEN];
  const thousandYen = BillStrategies[BILL_TYPE.NEW_THOUSAND_YEN];

  const dom = createBillsDom([tenThousandYen, fiveThousandYen, thousandYen]);
  res.send(dom);
});

router.get("/old", (req, res) => {
  const tenThousandYen = BillStrategies[BILL_TYPE.OLD_TEN_THOUSAND_YEN];
  const fiveThousandYen = BillStrategies[BILL_TYPE.OLD_FIVE_THOUSAND_YEN];
  const thousandYen = BillStrategies[BILL_TYPE.OLD_THOUSAND_YEN];

  const dom = createBillsDom([tenThousandYen, fiveThousandYen, thousandYen]);
  res.send(dom);
});

router.get("/:id", (req, res) => {
  const id = Number(req.params.id) as BILL_TYPE;

  if (!Object.values(BILL_TYPE).includes(id))
    return res.status(400).send("Invalid BILL_TYPE id");

  const bill = BillStrategies[id];

  const dom = createBillsDom([bill]);
  res.send(dom);
});

export default router;

コードの説明

const BillStrategies: Record<BILL_TYPE, Bill> = {
  [BILL_TYPE.OLD_TEN_THOUSAND_YEN]: new Bill(new OldTenThousandYenStrategy()),
  [BILL_TYPE.OLD_FIVE_THOUSAND_YEN]: new Bill(new OldFiveThousandYenStrategy()),
  [BILL_TYPE.OLD_THOUSAND_YEN]: new Bill(new OldThousandYenStrategy()),
  [BILL_TYPE.NEW_TEN_THOUSAND_YEN]: new Bill(new NewTenThousandYenStrategy()),
  [BILL_TYPE.NEW_FIVE_THOUSAND_YEN]: new Bill(new NewFiveThousandYenStrategy()),
  [BILL_TYPE.NEW_THOUSAND_YEN]: new Bill(new NewThousandYenStrategy()),
};

このコードでそれぞれのStrategyを登録していきます。
Enumをkeyとして登録しているのでEnum又はidからStrategyを呼び出すことが可能になります。

const bill = BillStrategies[BILL_TYPE.NEW_TEN_THOUSAND_YEN];

ルーティング呼び出し

今回はルーティングを分けての実装になっています。
責務の分離をしています。

src/index.ts
import billRouter from "@/routes/bills";
import express from "express";

const app = express();

app.use(express.json());
app.use(express.urlencoded({ extended: true }));

app.use("/bills", billRouter);

app.use("/", (req, res) => {
  res.json("Hello World");
});

const PORT = 3000;
app.listen(PORT, () => {
  console.log(`Server is running on http://localhost:${PORT}`);
});

表示

トップページ

こちらはStrategyと関係ないので説明を飛ばします。
http://localhost:3000/bills

新お札ページ

ページに移動すると新お札3つが表示されています。
新お札に関係あるStrategyを取得する処理を追加しているため3つのみ表示されています。
http://localhost:3000/bills/new

旧お札ページ

ページに移動すると旧お札3つが表示されています。
旧お札に関係あるStrategyを取得する処理を追加しているため3つのみ表示されています。
http://localhost:3000/bills/old

id別お札ページ

idを指定してそれぞれのお札が表示されています。
keyとidが一致したStrategyが取得・表示されるようになります。
enumにないidを指定するとエラーが表示されるようになっています。
http://localhost:3000/bills/1

どこで使用するか

今回は簡単な実装を行ったため恩恵を感じづらいかもしれません。

例として「何千行にもなっているコードを読んである箇所の機能を修正をしてほしい(それもコード上ではif文やswitch文を多様して実装している)」というタスクが回って来たとします。
多分皆さんは苦虫を噛み潰したような顔になるかと思います。(自分ならなります)
ですが、ストラテジーパターンを使って実装されている場合、修正を行うコードは数百行に収めることが出来るため、対応するストラテジだけを修正をすることで作業完了になります。(テストとかはしっかりやっていね)
ストラテジーパターンを実装しているかしていないかで修正を行うコード箇所の特定の時短・タスクの工数削減をすることが出来ます。

もしストラテジーパターンで書かれていなかったらコードを読み解くだけでも相当な工数を取られることになります。
修正をした結果、あらぬところでバグを残していまう可能性もあり、ユーザーにサービスを継続的に提供することが出来なくなります。

まとめ

ストラテジーパターンを実装していきましたが、使い方次第ではとても読みやすいチームがハッピーになるコードを書くことが出来ます。
if文やswitch文を多様して実装している場合、ストラテジーパターンが使えないか考えてみるもの良いかと思います。

Discussion