Wzorzec projektowy Strategy (Strategia) to jeden z behawioralnych wzorców projektowych opisanych przez Gang of Four. W tym artykule poznasz zasadę działania tego wzorca oraz jego przykładowe zastosowania. Ten wpis jest kolejnym wpisem z serii o wzorcach projektowych. Jeśli chcesz poznać inne wzorce projektowe lub dowiedzieć się czym są wzorce projektowe, to koniecznie sprawdź mój wpis o wzorcach projektowych. Fragmenty kodu, które wykorzystuję w tym artykule, zostały przygotowane w TypeScript.
Opis wzorca Strategia
Strategia jest wzorcem, który jest dedykowany sytuacjom, gdy w zależności od warunków należy zastosować inne rozwiązania. Podobnie jak w przypadku innych wzorców, dla Strategii można znaleźć pasujące analogie pomagające zapamiętać ideę wzorca. Na przykład w czasie konfliktu, w zależności od sytuacji, dobrze będą się sprawdzać różne strategie. Podobnie sytuacja wygląda na przykład w negocjacjach. Z kolei w pracy programisty, wykorzystując wzorzec Strategii, w zależności od warunków, do osiągnięcia celu można wykorzystać różne algorytmy.
Warunkami mogą być na przykład określone wymagania dla określonych przypadków, ale też na przykład aspekty wydajnościowe. Przykładowo, niektóre algorytmy mogą działać lepiej lub gorzej dla wybranych rodzajów czy rozmiarów danych.
Praktycznym przykładem, gdzie wykorzystanie Strategii miałoby sens, jest wyznaczanie trasy w aplikacji Google Maps. Aplikacja pozwala na wybranie trasy, która może przebiegać inaczej w zależności od wybranego środka transportu.
Strategia w praktyce
Aby zobrazować działania wzorca Strategia w praktyce, przygotowałem dość typowy scenariusz biznesowy. W sklepie internetowym kwota do zapłaty za zamówienie budowana jest z następujących składowych:
- wartość produktów w koszyku. Wartość koszyka może zostać obniżona na podstawie kodu rabatowego. W przypadku pierwszego zamówienia klient ma mieć możliwość naliczenia rabatu w wysokości 15%. Klient może otrzymać tylko jeden rabat na wartość koszyka. Kod nie rabatuje kosztów dostawy.
- koszt dostawy. W przypadku wartości zamówienia (netto, bez kosztów wysyłki) o wartości powyżej 100, koszt wysyłki wynosi 0;
- wartość VAT obliczana na podstawie kraju zamawiającego.
Dla uproszczenia możemy założyć, że wszystkie kwoty są wyrażone w PLN. Dla każdego z wymagań wydzieliłem interfejs, na podstawie którego budowane są poszczególne strategie.
interface IShoppingCartStrategy {
calculatePurchasePrice( cartValue: number ): number;
}
interface IShippingCostStrategy {
calculateShippingCost(): number;
}
interface IVATStrategy {
calculateVAT( cartValue: number ): number;
}
Następnie, bazując na przygotowanych interfejsach, przygotowałem strategie implementujące poszczególne wymagania.
class FreeShippingStrategy implements IShippingCostStrategy {
calculateShippingCost(): number {
return 0;
}
}
class RegularShippingStrategy implements IShippingCostStrategy {
calculateShippingCost(): number {
return 9.99;
}
}
class RegularOrderStrategy implements IShoppingCartStrategy {
calculatePurchasePrice( cartValue: number ): number {
return cartValue;
}
}
class DiscountCodeOrderStrategy implements IShoppingCartStrategy {
public constructor( private readonly _discountValueInPercents: number ) {}
calculatePurchasePrice( cartValue: number ): number {
return cartValue * ( ( 100 - this._discountValueInPercents ) / 100 );
}
}
class FirstOrderDiscountStrategy implements IShoppingCartStrategy {
calculatePurchasePrice( cartValue: number ): number {
return cartValue * 0.85;
}
}
class PLVATStrategy implements IVATStrategy {
calculateVAT( cartValue: number ): number {
return cartValue * 0.23;
}
}
class DEVATStrategy implements IVATStrategy {
calculateVAT( cartValue: number ): number {
return cartValue * 0.19;
}
}
Strategie do klasy reprezentującej koszyk przekazuję, wstrzykując je do konstruktora. Aby uniknąć problemów z arytmetyką zmiennoprzecinkową, obliczenia wykonuję na groszach i przed zwróceniem wyniku zamieniam go na złotówki.
class ShoppingCart {
public constructor(
private readonly _shoppingCartStrategy: IShoppingCartStrategy,
private readonly _shippingCostStrategy: IShippingCostStrategy,
private readonly _vatStrategy?: IVATStrategy
) {}
public checkout( cartValue: number ): number {
const shippingCost = this._shippingCostStrategy.calculateShippingCost();
const purchasePrice = this._shoppingCartStrategy.calculatePurchasePrice( cartValue );
const vat = this._vatStrategy ? this._vatStrategy.calculateVAT( purchasePrice ) : 0;
return ( purchasePrice * 100 + vat * 100 + shippingCost * 100 ) / 100;
}
}
Konstrukcja koszyków dla różnych wariantów wygląda następująco.
const freeShippingStrategy = new FreeShippingStrategy();
const regularShippingStrategy = new RegularShippingStrategy();
const firstOrderDiscountStrategy = new FirstOrderDiscountStrategy();
const discountCodeOrderStrategy = new DiscountCodeOrderStrategy( 10 );
const regularOrderStrategy = new RegularOrderStrategy();
const plVATStrategy = new PLVATStrategy();
const deVATStrategy = new DEVATStrategy()
// First Order, Free Shipping, PL VAT
const cartWithFreeShippingPL = new ShoppingCart(
firstOrderDiscountStrategy,
freeShippingStrategy,
plVATStrategy
);
console.log( cartWithFreeShippingPL.checkout( 120 ) ); // 125.46
// Regular Order, Regular Shipping, DE VAT
const regularCartDE = new ShoppingCart(
regularOrderStrategy,
regularShippingStrategy,
deVATStrategy
);
console.log( regularCartDE.checkout( 120 ) ); // 152.79
// Regular Order, Free Shipping, without VAT
const regularCart = new ShoppingCart(
regularOrderStrategy,
freeShippingStrategy
);
console.log( regularCart.checkout( 120 ) ); // 120
// Discount, Free Shipping, PL VAT
const discountCartPL = new ShoppingCart(
discountCodeOrderStrategy,
freeShippingStrategy,
plVATStrategy
);
console.log( discountCartPL.checkout( 120 ) ); // 132.84
Tworzenie poszczególnych strategii można schować za dodatkową warstwą abstrakcji, wykorzystując np. wzorzec Fabryki.
Analogicznie do przedstawionych strategii, rozwiązanie można rozszerzyć o kolejne typy strategii, przykładowo o strategię odpowiedzialną za ustawienie waluty, w jakiej obliczona zostaje wartość koszyka czy z możliwością dodania wyboru ratalnej.
Istniejące typy strategii również można rozszerzyć o kolejne przypadki w zależności od potrzeb. Można dodać np. różne strategie dla poszczególnych form wysyłki, a także wparcie dla kolejnych krajów w kontekście VAT.
Zalety i wady wzorca Strategia
Do zalet wynikających z zastosowania wzorca Strategia z pewnością można zaliczyć to, że pomaga on pilnować Single Responsibility Principle oraz Open-Closed Principle. Każda implementacja poszczególnej strategii ma jedną odpowiedzialność, a przedstawiona implementacja umożliwia łatwe rozwijanie możliwości koszyka o nowe strategie, jak również o nowe typy strategii.
Wydzielenie obliczania ceny zamówienia, kosztów dostawy i VAT do osobnych strategii sprawia, że kluczowa logika odpowiedzialna za podliczanie całkowitej wartości koszyka pozostaje czytelna, a sama modyfikacja logiki enkapsulowanej w klasie ShoppingCart prawdopodobnie będzie występować rzadko. Częstszym przypadkiem będzie modyfikacja poszczególnych strategii lub dodawanie nowych. Innymi słowy, logika związana z wymaganiami biznesowymi schowane są w strategiach, a ifologię z nimi związaną można schować np. w dedykowanej fabryce. Koszyk nic nie wie np. o zastosowanych rabatach oraz warunkach ich uzyskania, wysokości podatku czy kosztach wysyłki. Wie tylko ja policzyć ostateczną wartość zamówienia i co się na nią składa.
Przedstawiona implementacja Strategii pozwala też na łatwe przetestowanie rozwiązania jednostkowo i integracyjnie. Testy jednostkowe poszczególnych strategii sprowadzają się wtedy do powołania obiektu danej klasy do życia, wywołania jednej metody i wykonania asercji. W ramach testów integracyjnych można przetestować logikę klasy ShoppingCart z kilkoma wybranymi strategiami, tak jak ma to miejsce na kodzie załączonym nieco wcześniej.
Główną wadę Strategii widać na załączonym przykładzie. Jest to wdrożenie sporego zestawu klas i interfejsów i skomplikowanie rozwiązania. Do obsługi prostego przypadku biznesowego konieczne było napisanie rozbudowanej abstrakcji, która jest tu absolutnie bezcelowa. Równie dobrze sprawdziłoby się tu wstrzyknięcie np. funkcji i pominięcie tworzenia klas czy interfejsów związanych ze strategiami.
class ShoppingCart {
public constructor(
private readonly _calculateShippingCost: () => number,
private readonly _calculatePurchasePrice: ( cartValue: number ) => number,
private readonly _calculateVAT?: ( purchasePrice: number ) => number
) {}
public checkout( cartValue: number ): number {
const shippingCost = this._calculateShippingCost();
const purchasePrice = this._calculatePurchasePrice( cartValue );
const vat = this._calculateVAT ? this._calculateVAT( purchasePrice ) : 0;
return ( purchasePrice * 100 + vat * 100 + shippingCost * 100 ) / 100;
}
}
Strategia w wielu przypadkach może być „armatą na muchę” (uwielbiam ten frazeologizm). Będąc SOLID, warto też pamiętać o innych „modnych” akronimach tzn. KISS i YAGNI.
Podsumowanie
W ramach podsumowania zachęcam do samodzielnego pobawienia się kodem z tego artykułu samodzielnie. Chętnie dowiem się, jakie są Twoje doświadczenia ze wzorcem Strategia i jeśli masz jakieś ciekawe przypadki wykorzystania Strategii w swoim projekcie, to koniecznie podziel się tym w komentarzu.
Jeśli po przeczytaniu artykułu czujesz niedosyt, to inny ciekawy przykład, gdzie przedstawiono implementację Strategii, znajdziesz na blogu Koddlo. Standardowo zachęcam do zajrzenia do źródeł i materiałów dodatkowych oraz poszerowania artykułu, jeśli był dla Ciebie przydatny.
Książkę Design Patterns: Elements of Reusable Object-Oriented Software możesz kupić klikając w TEN link. Kupując z tego linku, wspierasz rozwój bloga.
Źródła i materiały dodatkowe
- Design Patterns: Elements of Reusable Object-Oriented Software – Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides (1994) str. 349
- Why is 0.1 + 0.2 Not Equal to 0.3 in Most Programming Languages?
- Koddlo – Strategy (Strategia)
- Is the Strategy Pattern an ultimate solution for low coupling? – Oskar Dudycz
- Strategy – Refactoring Guru
- Strategy Design Pattern – Source Making
Dobry wkład w ten artykuł. Całkiem przypadkowo odkryłem Twojego bloga 🙂 Będę wpadać częściej.
Dzięki, mega mnie to cieszy! Do zobaczenia pod innymi artykułami! 🙂