TypeScriptで理解するStrategy パターン
Strategyパターンとは
Strategyパターンとは、アルゴリズム(戦略)を表すインターフェースを定義して、
そのインターフェースを実装したクラスを作成し、
具体的な処理を簡単に切り替えることができる形で、
クラスを設計するデザインパターンです。
Strategy パターンの登場人物
-
Strategy(戦略)
Strategyは、戦略を表すためのインターフェースです。
このインターフェースに基づいて、具体的な戦略を実装することで、Strategyを呼び出すクライアント側では簡単に、異なるアルゴリズムを切り替えることができます。 -
ConcreteStrategy(具体的な戦略)
ConcreteStrategyは、Strategyインターフェースを実装したクラスであり、具体的な戦略を表します。
アルゴリズムの具体的な振る舞いを追加/変更したい場合は、新たなConcreteStrategyを追加/変更します。 -
Context(文脈)
Contextは、Strategyインターフェースを利用して、具体的な戦略を利用するクラスです。
Strategyインターフェースを通して、具体処理を実行するため、ConcreteStrategyの詳細を知る必要がありません。
Strategyパターンのクラス図とサンプルコード
interface Strategy {
strategyMethod(): void;
}
class ConcreteStrategyA implements Strategy {
strategyMethod(): void {
console.log('ConcreteStrategyA');
}
}
class ConcreteStrategyB implements Strategy {
strategyMethod(): void {
console.log('ConcreteStrategyB');
}
}
class Context {
private strategy: Strategy;
constructor(strategy: Strategy) {
this.strategy = strategy;
}
public contextMethod(): void {
this.strategy.strategyMethod();
}
}
実行例
const contextA = new Context(new ConcreteStrategyA());
contextA.contextMethod();
// => 'ConcreteStrategyA'
const contextB = new Context(new ConcreteStrategyB());
contextB.contextMethod();
// => 'ConcreteStrategyB'
Strategyパターンの使用例
ECサイトの料金計算の例を考えてみます。
ECサイトでは、会員の種類によって、料金の割引が存在したり、送料が異なるケースがあります。
今回は次のような料金計算の例を考えてみます。
- 通常会員
- 送料: 買い物の合計金額が10,000円未満の場合、500円、10,000円以上の場合、無料
- 割引: なし
- プレミアム会員
- 送料: 無料
- 割引: 買い物料金の5%
まずは、Strategyパターンを利用しない場合の実装を見てみます。
type Plan = "Regular" | "Premium";
class Member {
private SHIPPING_FEE_FOR_REGULAR = 500;
private SHIPPING_FREE_THRESHOLD_FOR_REGULAR = 10000;
private DISCOUNT_RATE_FOR_PREMIUM = 0.05;
private plan: Plan
constructor(plan: Plan) {
this.plan = plan;
}
calculateFee(basePrice: number): number {
switch (this.plan) {
case "Regular":
return this.calculateRegularFee(basePrice);
case "Premium":
return this.calculatePremiumFee(basePrice);
}
}
private calculateRegularFee(basePrice: number): number {
const priceAfterDiscount = this.applyDiscountForRegular(basePrice);
const finalPrice = priceAfterDiscount + this.calculateShippingFeeForRegular(priceAfterDiscount);
return finalPrice;
}
private applyDiscountForRegular(basePrice: number): number {
return basePrice;
}
private calculateShippingFeeForRegular(basePrice: number): number {
return basePrice < this.SHIPPING_FREE_THRESHOLD_FOR_REGULAR ? this.SHIPPING_FEE_FOR_REGULAR : 0;
}
private calculatePremiumFee(basePrice: number): number {
const priceAfterDiscount = this.applyDiscountForPremium(basePrice);
const finalPrice = priceAfterDiscount + this.calculateShippingFeeForPremium();
return finalPrice;
}
private applyDiscountForPremium(basePrice: number): number {
return basePrice * (1 - this.DISCOUNT_RATE_FOR_PREMIUM);
}
private calculateShippingFeeForPremium(): number {
return 0;
}
}
// 使用例
// 通常会員が9000円の買い物をした時
const regularMember = new Member("Regular");
console.log(regularMember.calculateFee(9000));
// => 9500
// プレミアム会員が9000円の買い物をした時
const premiumMember = new Member("Premium");
console.log(premiumMember.calculateFee(9000));
// => 8550
calculateFee()メソッドの中で、会員の種類に応じて、料金計算の処理を分岐しています。
今回はその中身を別メソッドとして切り出しています。
このような形だと、新しい会員が追加されるたびにswitch~case文を修正する必要があります。
今はプランが2つだけですが、それだけでも冗長なコードになっています。
上記はかなり極端な例ですが、今度は、プランごとにクラスを分けてみます。
class RegularMember {
private SHIPPING_FEE = 500;
private SHIPPING_FREE_THRESHOLD = 10000;
calculateFee(basePrice: number): number {
const priceAfterDiscount = this.applyDiscount(basePrice);
const finalPrice = priceAfterDiscount + this.calculateShippingFee(priceAfterDiscount);
return finalPrice;
}
private calculateShippingFee(basePrice: number): number {
return basePrice < this.SHIPPING_FREE_THRESHOLD ? this.SHIPPING_FEE : 0;
}
private applyDiscount(basePrice: number): number {
return basePrice;
}
}
class PremiumMember {
private DISCOUNT_RATE = 0.05;
calculateFee(basePrice: number): number {
const priceAfterDiscount = this.applyDiscount(basePrice);
const finalPrice = priceAfterDiscount + this.calculateShippingFee(priceAfterDiscount);
return finalPrice;
}
private calculateShippingFee(basePrice: number): number {
return 0;
}
private applyDiscount(basePrice: number): number {
return basePrice * (1 - this.DISCOUNT_RATE);
}
}
// 使用例
// 通常会員が9000円の買い物をした時
const regularMember = new RegularMember();
console.log(regularMember.calculateFee(9000));
// => 9500
// プレミアム会員が9000円の買い物をした時
const premiumMember = new PremiumMember();
console.log(premiumMember.calculateFee(9000));
// => 8550
通常会員用の料金計算を行うRegularMemberクラスと、プレミアム会員用の料金計算を行うPremiumMemberクラスを実装しています。
calculateFee()メソッドの中身はどちらのクラスも同じように実装しています。
先ほどの例よりはだいぶ整理されたと思いますが、
新たな会員プランが追加された場合や、料金計算の仕様が変更された場合、実装や修正のコストが高くなりますし、
最悪対応漏れが発生する可能性もあります。
具体的に、下記のような用件が追加されるとしましょう。
- プレミアムプラス会員であれば、送料無料で、かつ、買い物料金の10%の割引を適用する。
- 期間限定で、プランに関わらず割引後料金にさらに20%の割引を適用する。
このような場合先ほどのコードを下記のように修正することになります。
class RegularMember {
private SHIPPING_FEE = 500;
private SHIPPING_FREE_THRESHOLD = 10000;
private CAMPAIGN_DISCOUNT_RATE = 0.1;
calculateFee(basePrice: number): number {
let priceAfterDiscount = this.applyDiscount(basePrice);
if (this.isCampaign()) priceAfterDiscount = this.campaignDiscount(priceAfterDiscount);
const finalPrice = priceAfterDiscount + this.calculateShippingFee(priceAfterDiscount);
return finalPrice;
}
private calculateShippingFee(basePrice: number): number {
return basePrice < this.SHIPPING_FREE_THRESHOLD ? this.SHIPPING_FEE : 0;
}
private applyDiscount(basePrice: number): number {
return basePrice;
}
private isCampaign(): boolean {
return Date.now() >= new Date("2023-10-01 00:00:00").getTime() && Date.now() <= new Date("2023-11-30 23:59:59").getTime()
}
private campaignDiscount(basePrice: number): number {
return basePrice * (1 - this.CAMPAIGN_DISCOUNT_RATE);
}
}
class PremiumMember {
private DISCOUNT_RATE = 0.05;
private CAMPAIGN_DISCOUNT_RATE = 0.1;
calculateFee(basePrice: number): number {
let priceAfterDiscount = this.applyDiscount(basePrice);
if (this.isCampaign()) priceAfterDiscount = this.campaignDiscount(priceAfterDiscount);
const finalPrice = priceAfterDiscount + this.calculateShippingFee(priceAfterDiscount);
return finalPrice;
}
private calculateShippingFee(basePrice: number): number {
return 0;
}
private applyDiscount(basePrice: number): number {
return basePrice * (1 - this.DISCOUNT_RATE);
}
private isCampaign(): boolean {
return Date.now() >= new Date("2023-10-01 00:00:00").getTime() && Date.now() <= new Date("2023-11-30 23:59:59").getTime()
}
private campaignDiscount(basePrice: number): number {
return basePrice * (1 - this.CAMPAIGN_DISCOUNT_RATE);
}
}
class PremiumPlusMember {
private DISCOUNT_RATE = 0.1;
private CAMPAIGN_DISCOUNT_RATE = 0.1;
calculateFee(basePrice: number): number {
let priceAfterDiscount = this.applyDiscount(basePrice);
if (this.isCampaign()) priceAfterDiscount = this.campaignDiscount(priceAfterDiscount);
const finalPrice = priceAfterDiscount + this.calculateShippingFee(priceAfterDiscount);
return finalPrice;
}
private calculateShippingFee(basePrice: number): number {
return 0;
}
private isCampaign(): boolean {
return Date.now() >= new Date("2023-10-01 00:00:00").getTime() && Date.now() <= new Date("2023-11-30 23:59:59").getTime()
}
private applyDiscount(basePrice: number): number {
return basePrice * (1 - this.DISCOUNT_RATE);
}
private campaignDiscount(basePrice: number): number {
return basePrice * (1 - this.CAMPAIGN_DISCOUNT_RATE);
}
}
// 使用例
// 通常会員が9000円の買い物をした時
const regularMember = new RegularMember();
console.log(regularMember.calculateFee(9000));
// => 8600
// プレミアム会員が9000円の買い物をした時
const premiumMember = new PremiumMember();
console.log(premiumMember.calculateFee(9000));
// => 7695
// プレミアムプラス会員が9000円の買い物をした時
const premiumPlusMember = new PremiumPlusMember();
console.log(premiumPlusMember.calculateFee(9000));
// => 7290
一気に冗長さが増したと思います。
料金算出フローに、キャンペーン期間中だった場合に、割引を適用する処理を追加しています。
クラス数が増えるほど、料金フローの変更も困難になります。
複数キャンペーン適用時の料金計算を実装する場合は、さらに複雑になります。
このような料金計算をStrategyパターンを用いて実装してみます。
まず、最初の使用を満たしたコードは下記のようになります。
interface MembershipFeeStrategy {
calculateShippingFee: (basePrice: number) => number;
applyDiscount: (basePrice: number) => number;
}
class RegularMemberFee implements MembershipFeeStrategy {
private SHIPPING_FEE = 500;
private SHIPPING_FREE_THRESHOLD = 10000;
calculateShippingFee(basePrice: number): number {
return basePrice < this.SHIPPING_FREE_THRESHOLD ? this.SHIPPING_FEE : 0;
}
applyDiscount(basePrice: number): number {
return basePrice;
}
}
class PremiumMemberFee implements MembershipFeeStrategy {
private DISCOUNT_RATE = 0.05;
calculateShippingFee(basePrice: number): number {
return 0;
}
applyDiscount(basePrice: number): number {
return basePrice * (1 - this.DISCOUNT_RATE);
}
}
class FeeContext {
private strategy: MembershipFeeStrategy;
constructor(strategy: MembershipFeeStrategy) {
this.strategy = strategy;
}
calculateFee(basePrice: number): number {
const priceAfterDiscount = this.strategy.applyDiscount(basePrice);
const finalPrice = priceAfterDiscount + this.strategy.calculateShippingFee(priceAfterDiscount);
return finalPrice;
}
}
// 使用例
// 通常会員が9000円の買い物をした時
const regularContext = new FeeContext(new RegularMemberFee());
console.log(regularContext.calculateFee(9000));
// => 9500
// プレミアム会員が9000円の買い物をした時
const premiumContext = new FeeContext(new PremiumMemberFee());
console.log(premiumContext.calculateFee(9000));
// => 8550
MembershipFeeStrategyは、会員の種類によって異なる料金計算の戦略を表すインターフェースです。
これがStrategyパターンのStrategyに相当します。
このインターフェースをもとに、具体的な戦略を実装しているのがRegularMemberFeeとPremiumMemberFeeです。
これらがStrategyパターンのConcreteStrategyに相当します。
処理の中身は複雑なものではないので説明が不要だと思いますが、前述の仕様を満たすように実装しています。
料金計算の戦略を利用するクラスがFeeContextです。
これがStrategyパターンのContextに相当します。
このクラスの中では、初期化時に料金計算の戦略を受け取り、calculateFee()メソッドを呼び出すことで、料金計算を行います。
calculateFee()メソッドの中では、料金計算の戦略に従って、割引を適用した後、送料を計算しています。
使用例では、FeeContextの初期化時に、対応する会員の種類に応じた料金計算の戦略を渡しています。
こうすることで、会員の種類に応じて、異なる料金計算の戦略を利用することができます。
さらに、プレミアムプラス会員の追加やキャンペーン期間の料金計算を実装してみます。
class PremiumPlusMemberFee implements MembershipFeeStrategy {
private DISCOUNT_RATE = 0.1;
calculateShippingFee(basePrice: number): number {
return 0;
}
applyDiscount(basePrice: number): number {
return basePrice * (1 - this.DISCOUNT_RATE);
}
}
class CampaignFeeContext {
private strategy: MembershipFeeStrategy;
private CAMPAIGN_DISCOUNT_RATE = 0.1;
constructor(strategy: MembershipFeeStrategy) {
this.strategy = strategy;
}
calculateFee(basePrice: number): number {
let priceAfterDiscount = this.strategy.applyDiscount(basePrice);
if (this.isCampaign()) priceAfterDiscount = this.campaignDiscount(priceAfterDiscount);
const finalPrice = priceAfterDiscount + this.strategy.calculateShippingFee(priceAfterDiscount);
return finalPrice;
}
private campaignDiscount(basePrice: number): number {
return basePrice * (1 - this.CAMPAIGN_DISCOUNT_RATE);
}
private isCampaign(): boolean {
return Date.now() >= new Date("2023-10-01 00:00:00").getTime() && Date.now() <= new Date("2023-11-30 23:59:59").getTime()
}
}
// 使用例
const campaignRegularContext = new CampaignFeeContext(new RegularMemberFee());
console.log(campaignRegularContext.calculateFee(9000));
// => 8600
const campaignPremiumContext = new CampaignFeeContext(new PremiumMemberFee());
console.log(campaignPremiumContext.calculateFee(9000));
// => 7695
const campaignPremiumPlusContext = new CampaignFeeContext(new PremiumPlusMemberFee());
console.log(campaignPremiumPlusContext.calculateFee(9000));
// => 7290
このようにStrategyはインターフェースを通じて実装することで簡単に追加することが可能であり、
Contextに関しても柔軟に対応することができます。
何より、既存の処理を変更することなく、新たな処理を追加することができるため、たとえばキャンペーンが複数重なった場合でも、
既存の処理を変更することなく、新たな処理を追加することができますし、
完了して不要になれば、追加したContextを削除さえすれば既存の処理には影響を与えません。
Strategyパターンが有効な場合/有効でない場合
Strategyパターンは、以下のような場合に有効です。
- 複数のアルゴリズムを用意しておき、必要に応じて切り替えたい場合
- アルゴリズムの切り替えを、クライアント側ではなく、Context側で行いたい場合
- アルゴリズムの切り替えを、実行時に行いたい場合
逆に下記のような場合には、Strategyパターンは有効ではない場合があります。
- 共通の関数やアルゴリズムを持っており、その具体的な振る舞いのみを変えたい場合
このようなケースでは前回記事で紹介したTemplate Methodパターンが有効です。
Discussion