Strategyパターンとは
設計パターンの一種。1つのinterfaceを定義して、これをimplementsしたクラスを複数用意するやり方。
共通のinterfaceをimplementsしているため、実装したクラスは簡単に交換可能である。
用語
コード例は以下の記事で紹介されていたPythonをTypeScriptに修正したものを使わせていただいた。
https://qiita.com/hankehly/items/1848f2fd8e09812d1aaf
Context
Strategy(interface)を実装したオブジェクトを持ち、処理の一部をStrategyに委譲するクラス。
// Job.ts
import type { StorageStrategy } from "./StorageStrategy";
export class Job {
private inputStorageStrategy: StorageStrategy;
private inputPath: string;
private outputStorageStrategy: StorageStrategy;
private outputPath: string;
constructor(
inputStorageStrategy: StorageStrategy,
inputPath: string,
outputStorageStrategy: StorageStrategy,
outputPath: string
) {
// Strategyをコンストラクターで渡して、インスタンス変数にする
this.inputStorageStrategy = inputStorageStrategy;
this.inputPath = inputPath;
this.outputStorageStrategy = outputStorageStrategy;
this.outputPath = outputPath;
}
async run(): Promise<void> {
const data = await this.getData();
await this.saveData(data);
}
private async getData(): Promise<Uint8Array> {
// ロジックをStrategyに委託する
return await this.inputStorageStrategy.get(this.inputPath);
}
private async saveData(data: Uint8Array): Promise<void> {
// ロジックをStrategyに委託する
await this.outputStorageStrategy.save(data, this.outputPath);
}
}
Strategy
interface。これをimplementsしたクラスは、interfaceに書かれているものを必ず実装しなければならない。
// StorageStrategy.ts
export interface StorageStrategy {
get(path: string): Promise<Uint8Array> | Uint8Array;
save(data: Uint8Array, path: string): Promise<void> | void;
}
Concrete Strategy
Strategyをimplementsしたクラス。各メソッドの内容はテキトーです。
// LocalStorageStrategy.ts
import { readFile, writeFile } from "fs/promises";
import type { StorageStrategy } from "./StorageStrategy";
export class LocalStorageStrategy implements StorageStrategy {
async get(path: string): Promise<Uint8Array> {
return await readFile(path);
}
async save(data: Uint8Array, path: string): Promise<void> {
await writeFile(path, data);
}
}
// S3StorageStrategy.ts
import type { StorageStrategy } from "./StorageStrategy";
type S3ClientLike = {
getObject(key: string): Promise<Uint8Array>;
putObject(key: string, data: Uint8Array): Promise<void>;
};
export class S3StorageStrategy implements StorageStrategy {
constructor(private s3: S3ClientLike) {}
async get(path: string): Promise<Uint8Array> {
return await this.s3.getObject(path);
}
async save(data: Uint8Array, path: string): Promise<void> {
await this.s3.putObject(path, data);
}
}
使用例
localStorageを使う場合の使用例。
import { Job } from "./Job";
import { LocalStorageStrategy } from "./LocalStorageStrategy";
const local = new LocalStorageStrategy();
const job = new Job(
local,
"./input.bin",
local,
"./output.bin"
);
await job.run();
S3を使う場合の使用例。
import { Job } from "./Job";
import { S3StorageStrategy } from "./S3StorageStrategy";
// getS3Client()の実装は省略
const s3Client = getS3Client();
const s3 = new S3StorageStrategy(s3Client);
const job = new Job(
s3,
"s3://my-bucket/input.bin",
s3,
"s3://my-bucket/output.bin"
);
await job.run();
メリット
コードを拡張しやすくなる。仕様変更が起きても、少ない労力で対応できる。
Jobクラスが持っておかないといけない情報が減る
Strategyパターンを採用しない場合、Jobクラスは、どのストレージサービスを使う可能性があるのか、という情報を持っておく必要があった。
class Job {
run() {
if (this.type === "s3") {
// S3取得
} else if (this.type === "local") {
// ローカル取得
}
// 今後はGoogle Cloud Storageも使うかも...?
// もしそうなら、以下にどんどんコードが増えることになる
}
}
strategyパターンを採用することで、Jobクラスのrun()は以下のようになった。
export class Job {
async run(): Promise<void> {
const data = await this.getData();
await this.saveData(data);
}
}
とてもスッキリしているし、今後扱いたいストレージサービスが増えたとしても、ConcreteStrategyを追加で実装すればOK。Jobクラスは変わらない。
このメリットは、可読性や保守性、拡張性に大きく貢献している。
Jobクラスから排除された分岐はContextを呼び出す前などで行わなければならない。
const strategy =
storageType === "s3"
? new S3StorageStrategy()
: new LocalStorageStrategy();
const job = new Job(strategy, ...);
テストがしやすくなる
Jobクラスの役割が明確になったことで、Jobクラスのテストがしやすくなる。
なぜなら、他のストレージサービスを使いたくなったとしても、Jobクラスは変わらないからである。
(新たに追加するConcreteStrategyのテストは追加実装が必要)
デメリット
クラス/ファイル数が増える
新たにinterfaceやConcreteStrategyとしてクラスを実装する必要があるので、その分ファイル数は増える。
処理が追いにくくなる(かもしれない)
ファイル数が増えることで、処理を追いにくくなることがあるかもしれない。
(個人的には、1つのファイルにやたらめったら書くよりは、ファイル数が増える方がまし派)
使いどき
似たような機能ではあるが、差異がある機能を実装したいとき。
例えば、S3とファイルをやり取りしていたが、Google Cloud Storageともやりとりする必要が出てきたとき、Strategyパターンが有効。
putとgetについてのinterfaceを実装し、これをimplementsしたclass S3とclass GoogleCloudStorageを実装。
このようにすれば、もしS3は不要になった時や、Azureともやり取りしたくなった時に、影響範囲を小さく抑えることができる。