The Strategy Design Pattern

Strategy Design Pattern בא לעזרתנו כשיש לנו אסטרטגיה להשיג מטרה כלשהי ואנחנו רוצים את היכולת להחליף אותה כשיש צורך. אנחנו נרצה להחליף את האסטרטגיה בזמן ריצה, בלי לשנות את הקוד שמשתמש באלגוריתם.

מה זה Strategy Design Pattern?

נניח שאנחנו יוצרים משחק וידאו שהדמות הראשית שלו יכולה לתקוף אוייבים. לדמות יכולים להיות כלי נשק שונים לבחור מתוכם. למשל יכולים להיות לה חרב, חץ וקשת או לחשי קסם. לכל סוג של נשק יש דרך שונה שבה הוא תוקף את האוייב. החרב למשל, טובה להתקפה של אוייבים שנמצאים בטווח קצר, הקשת טובה לטווח ארוך ולחשי הקסם יכולים לתקוף או לרפא.

Strategy – האסטרטגיה – כל סוג של התקפה (שימוש בחרב, בקשת או בלחש קסמים) יכול להיות אסטרטגיה לניצחון על האויבים.

Context – ההקשר – הדמות שמשתמשת בנשק היא ההקשר, Context. ה-Context משתמש ב-Strategy להשיג את המטרה שלו, אבל ה-Strategy יכולה להשתנות.

Client – הלקוח – זה המשחק שלנו. הוא מנהל את הדמות ובוחר באיזה אסטרטגיה להשתמש בהתאם לנשק שהדמות משתמשת בו.

דוגמת קוד פשוטה – אסטרטגיית נשק

נראה דוגמת קוד ב-javascript.

הגדרת Strategies.

נגדיר 3 פונקציות שכל אחת מממשת אסטרטגיה אחרת.

const swordStrategy = () => {
    console.log("Attacking with sword!");
};

const bowStrategy = () => {
    console.log("Shooting an arrow!");
};

const magicStrategy = () => {
    console.log("Casting a spell!");
};

הגדרת Content.

נגדיר class בשם Character שיכול להשתמש באסטרטגיות ההתקפה שלנו. אנחנו נשנה את האסטרטגיה על ידי קריאה לפונקציה setAttackStrategy.

class Character {
    constructor() {
        this.attackStrategy = null;
    }

    setAttackStrategy(strategy) {
        this.attackStrategy = strategy;
    }

    attack() {
        if (this.attackStrategy) {
            this.attackStrategy();
        } else {
            console.log("No strategy set!");
        }
    }
}

שימוש ב-Strategies.

מה שנשאר זה ליצור את הדמות ולהשתמש באסטרטגיות.

const hero = new Character();

// No strategy set
hero.attack(); // Output: No strategy set!

// Player chooses a sword
hero.setAttackStrategy(swordStrategy);
hero.attack(); // Output: Attacking with sword!

// Player switches to a bow
hero.setAttackStrategy(bowStrategy);
hero.attack(); // Output: Shooting an arrow!

// Player uses a magic spell
hero.setAttackStrategy(magicStrategy);
hero.attack(); // Output: Casting a spell!

הפונקציות swordStrategy, bowStrategy, magicStrategy הן אסטרטגיות שונות והן תהינה מוחלפות בזמן הריצה.

ה-class של Character מייצג את ה-context. הוא משתמש באסטרטגיות כדי לתקוף, אסטרטגית התקיפה מתחלפת על ידי קריאה ל-setAttackStrategy.

הפונקציה setAttackStrategy(strategy) קובעת את האסטרטגיה שבה תשתמש הדמות כדי לתקוף .

הפונקציה attack תשתנה לפי אסטרטגיית התקיפה הנוכחית.

דוגמת קוד נוספת לאסטרטגיית הנחות

נניח שיש לנו חנות דיגיטלית. החנות נותנת הנחות לפי סוג הלקוח.

  • לקוח רגיל – ללא הנחה.
  • לקוח VIP – מקבל 20% הנחה.
  • עובד החברה – מקבל 50% הנחה.

נראה דוגמת קוד אנגולרית.

הגדרת Strategies.

קובץ discount.strategy.ts

export interface DiscountStrategy {
    applyDiscount(amount: number): number;
}

קובץ regular-customer.strategy.ts

import { DiscountStrategy } from './discount.strategy';

export class RegularCustomerStrategy implements DiscountStrategy {
    applyDiscount(amount: number): number {
        // No discount for regular customers.
        return amount;
    }
}

קובץ vip-customer.strategy.ts

import { DiscountStrategy } from './discount.strategy';

export class VipCustomerStrategy implements DiscountStrategy {
    applyDiscount(amount: number): number {
        // 20% discount for VIP customers.
        return amount * 0.8;
    }
}

קובץ employee.strategy.ts

import { DiscountStrategy } from './discount.strategy';

export class EmployeeStrategy implements DiscountStrategy {
    applyDiscount(amount: number): number {
        // 50% discount for employees.
        return amount * 0.5;
    }
}

הגדרת Content.

קובץ discount.service.ts

import { Injectable } from '@angular/core';
import { DiscountStrategy } from './discount.strategy';

@Injectable({
    providedIn: 'root',
})
export class DiscountService {
    private discountStrategy: DiscountStrategy;

    setDiscountStrategy(strategy: DiscountStrategy): void {
        this.discountStrategy = strategy;
    }

    applyDiscount(amount: number): number {
        return this.discountStrategy.applyDiscount(amount);
    }
}

שימוש ב-Strategies.

קובץ app.component.ts

import { Component } from '@angular/core';
import { DiscountService } from './discount.service';
import { RegularCustomerStrategy, 
    VipCustomerStrategy, EmployeeStrategy } from './customer-strategies';

@Component({
  selector: 'app-root',
  template: `
    <div class="app-container">
      <h1>Choose User Type</h1>
      <button (click)="selectUserType('regular')">Regular Customer</button>
      <button (click)="selectUserType('vip')">VIP Customer</button>
      <button (click)="selectUserType('employee')">Employee</button>
      <p>Original Price: ${{ price }}</p>
      <p>Discounted Price: ${{ discountedPrice }}</p>
    </div>
  `,
})
export class AppComponent {
    price = 100;
    discountedPrice: number;

    constructor(private discountService: DiscountService) {
        this.discountedPrice = this.price;
    }

    selectUserType(userType: string): void {
        switch(userType) {
            case 'regular':
                this.discountService.setDiscountStrategy(new 
                      RegularCustomerStrategy());
                break;
            case 'vip':
                this.discountService.setDiscountStrategy(new VipCustomerStrategy());
                break;
            case 'employee':
                this.discountService.setDiscountStrategy(new EmployeeStrategy());
                break;
        }
        this.discountedPrice = this.discountService.applyDiscount(this.price);
    }
}

אנחנו מפרידים בין ההגדרה של סוגי המשתמשים שלנו ובין הלוגיקה של נתינת ההנחה. אם יהיה לנו סוג משתמש נוסף, נבחר אותו בצורה פשוטה בקובץ השימוש, אבל כל שאר הלוגיקה תעבוד בלי הפרעה.

כשאנחנו נוסיף סוג משתנה נוסף למערכת ה-class של DiscountService לא ישתנה. הוא לא תלוי במימושים שלנו.

למה להשתמש ב-Strategy Design Pattern?

גמישות – אפשר בקלות להוסיף עוד אסטרטגיות או לשנות את אלה שקיימות בלי תלות ב-clases שמשתמשים בהם.

תחזוקה – קל לתחזק ולשנות קוד בגלל שכל שינוי לא משפיע על שאר החלקים של הקוד.

בדיקות – אפשר לבדוק את האסטרטגיות בלי תלות במי שמשתמש בהן.

חסרונות

הקוד הופך להיות יותר מסובך כי יש לנו פה שימוש במספר classes לא קטן.

ה-client צריך לדעת איזה מהאסטרטגיות לבחור ולשלוח לבקשת המימוש.

מקרים לדוגמא שבהם משתמשים ב-Strategy Design Pattern

Strategy Design Pattern יכול להיות יעיל במגוון מקרים. למשל:

  • אלגוריתמים של מיון. אפשר לאגד מספר אלגוריתמים שונים של מיון כאסטרטגיות שונות ולהעביר אליהם אובייקט שצריך מיון.
  • חוקי ולידציה. אפשר לאגד חוקי ולידציה ולשלוח אליהם את האובייקט שצריך את הפעולה.
  • עיצוב טקסט.
  • גישה ל-DB. אפשר לאגד אסטרטגיות של גישה ל-DB שונים.
  • תשלומים. איגוד צורות תשלום שונות.

איך להשתמש ב-Strategy Design Pattern

נראה מה השלבים ואיך ליישם את השימוש ב-Strategy Design Pattern.

  1. איתור האלגוריתם או ההתנהגות שצריך לאגד כדי שתהיה להם היכולת להתחלף האחד עם השני.
  2. הגדרת interface שמייצג את ההתנהגות הזאת, עם פונקציה אחת שהיא החתימה של ה-interface.
  3. מימוש ה-classes שמממשים את הפונקציה של ה-interface.
  4. הגדרת class שהוא ה-context, הוא משתמש ב-interface וקורא לפונקציה המתאימה.
  5. מתן אפשרות ל-context להחליף בין המימושים השונים.

Best Practices למימוש

יש כמה דברים שכדאי לחשוב עליהם כשמשתמשים ב-Strategy Design Pattern.

  1. לשמור על interface פשוט ולהתמקד בפעולה אחת שהוא אחראי עליה.
  2. לרכז את כל האסטרטגיות שלנו תחת ה-interface הזה ולא לבצע את הפעולות ב-context.
  3. להשתמש ב-dependency injection כדי להעביר את האסטרטגיות ולא ליצור מופעים של ה-class.
  4. להשתמש ב-factory או enum כדי שיהיה מקום אחד שמייצר ומנהל את האסטרטגיות.

בעיה ופתרון

נגדיר בעיה שאפשר לפתור על ידי שימוש ב-Strategy Design Pattern.

נניח שאנחנו בונים משחק כמו “Street Fighter”. נניח גם שלכל דמות יש 4 מהלכים שהיא יכולה לעשות: לבעוט, לתת אגרוף, להתגלגל ולקפוץ. כל אחת מהדמויות יש אפשרות לבעוט ולתת אגרוף, אבל רק חלקן יכולות להתגלגל ולקפוץ.

איך נבנה את המודל של הדמות על ידי classes? נתחיל עם class אב של "לוחם" וניתן לכל הדמויות לרשת מדמות הבסיס. לדמות הבסיס יהיו מימושים של ברירת מחדל לפעולות וכל דמות תוכל להגדיר מחדש את הפעולות על פי הנתונים שלה.

ה-class הראשי יהיה בעל כל התכונות.

והאחרים יממשו את מה שמתאים להם.

מה הבעיה פה?

מה אם יש דמות שלא יכולה לקפוץ? היא עדיין יורשת את פעולת הקפיצה מ-class האב. למרות שאפשר לדרוס את פעולת הקפיצה לכזאת שלא עושה שום פעולה, יתכן שנצטרך לעשות את זה להרבה מהדמויות שלנו ובכל פעם שנוסיף דמות שלא קופצת, נצטרך לטפל בזה. לכן ירושה פחות תתאים פה.

נראה מימוש אחר על ידי Interface.

כאן ברור יותר מה יש לכל דמות. הוצאנו פעולות שהן אופציונליות מחוץ ל-class הבסיס והפכנו אותם ל-interfaces. עכשיו רק דמויות שתומכות בפעולות האלה יממשו את הפעולה.

מה הבעיה עכשיו?

הבעיה העיקרית היא שתהיה לנו חזרה על קוד. אין לנו עכשיו מימוש ברירת מחדל לפעולות , אם יש כמה דמויות שיש להן את פעולת הקפיצה, נצטרך לממש את הפעולה עבור כל אחת מהן בנפרד.

מה הפתרון? Strategy Design Pattern.

במקום שכל דמות תממש את פעולת הקפיצה, הדמות מבקשת את המימוש משירות הקפיצות. בשירות הזה יש לנו אפשרות להשתמש במימושים שונים לפעולה. הקריאה לשירות לא תשתנה ולא משנה איזה מימוש הדמות צריכה.

דוגמת קוד לאסטרטגיית יצירת דו"ח

נראה דוגמא אנגולרית נוספת. נניח שיש לנו אפליקציה שמייצרת דוחות בפורמטים שונים: PDF, CSV, or Excel בהתאם לבחירה של המשתמש. The Strategy Pattern מאפשרת לנו להחליף בין האפשרויות בצורה דינמית.

Strategy Interfaces and Concrete Strategies

קובץ report.strategy.ts

export interface ReportStrategy {
    generateReport(data: any): void;
}

קובץ pdf-report.strategy.ts

import { ReportStrategy } from './report.strategy';

export class PdfReportStrategy implements ReportStrategy {
    generateReport(data: any): void {
        console.log("Generating PDF report...");
        // Logic for generating PDF report...
    }
}

קובץ csv-report.strategy.ts

import { ReportStrategy } from './report.strategy';

export class CsvReportStrategy implements ReportStrategy {
    generateReport(data: any): void {
        console.log("Generating CSV report...");
        // Logic for generating CSV report...
    }
}

קובץ excel-report.strategy.ts

import { ReportStrategy } from './report.strategy';

export class ExcelReportStrategy implements ReportStrategy {
    generateReport(data: any): void {
        console.log("Generating Excel report...");
        // Logic for generating Excel report...
    }
}

Context Implementation

קובץ report.service.ts

import { Injectable } from '@angular/core';
import { ReportStrategy } from './report.strategy';

@Injectable({
    providedIn: 'root',
})
export class ReportService {
    private reportStrategy: ReportStrategy;

    setReportStrategy(strategy: ReportStrategy): void {
        this.reportStrategy = strategy;
    }

    generateReport(data: any): void {
        this.reportStrategy.generateReport(data);
    }
}

Component Utilizing the Strategies

קובץ app.component.ts

import { Component } from '@angular/core';
import { ReportService } from './report.service';
import { PdfReportStrategy, CsvReportStrategy, ExcelReportStrategy } from './report-strategies';

@Component({
  selector: 'app-root',
  template: `
    <div>
      <h1>Generate Report</h1>
      <button (click)="selectReportType('pdf')">Generate PDF Report</button>
      <button (click)="selectReportType('csv')">Generate CSV Report</button>
      <button (click)="selectReportType('excel')">Generate Excel Report</button>
    </div>
  `,
})
export class AppComponent {
    constructor(private reportService: ReportService) {}

    selectReportType(reportType: string): void {
        switch(reportType) {
            case 'pdf':
                this.reportService.setReportStrategy(new PdfReportStrategy());
                break;
            case 'csv':
                this.reportService.setReportStrategy(new CsvReportStrategy());
                break;
            case 'excel':
                this.reportService.setReportStrategy(new ExcelReportStrategy());
                break;
        }
        this.reportService.generateReport({/* some report data */});
    }
}

יש לנו פה גמישו להוספת דוחות נוספים בעתיד דרך אותו המנגנון.

מימושים נוספים

יש המון דוגמאות לשימוש ב-pattern הזה, נראה כמה רעיונות.

  1. אסטרטגיית משלוחים – חישוב עלות סל הקניות על פי שיטות משלוח שונות.
  2. אסטרטגיית מס – חישובי מס שונים על פי שיקולים שונים כמו איזור מגורים ומצב משפחתי.
  3. אסטרטגיית שירות לקוחות – בחירת הדרך לתקשר עם הלקוח, למשל sms, אימייל וכו'
  4. אסטרטגיית מועדון לקוחות – בחירת החישוב להוספת נקודות למועדון על פי היסטוריית רכישה, קידומי מכירות וכו'
  5. אסטרטגיית אבטחה – בחירת אסטרטגיית אבטחה לאימות זהות משתמש.

כל הדוגמאות כאן מדגימות שימוש נפוץ ב-Strategy Pattern על ידי אפליקציות שונות, והיתרון של כולם הוא שמתאפשר לנו לבצע שינויים עם התערבות מינימלית בקוד הקיים.