Wzorce projektowe Strategia (Strategy)

We wpisie postaram się wyjaśnić i zastosować w praktyce jeden z najpopularniejszych wzorców projektowych Startegia (Strategy )  czasami także znana pod nazwą Policy. Wzorzec strategia ze względu na wersalność jest używany w większości projektów nad którymi pracowałem. W telegraficznym skrócie wzorzec umożliwia nam wykorzystanie różnych algorytmów do wykonania tej samej rzeczy w zależności od kontekstu w jakim będzie wykorzystany.


Poniżej kilka podpunktów definiujących nam wzorzec strategia (Strategy):

  • Pozwala nam zdefiniować grupę algorytmów, w której w każdym z elementów możemy zhermetyzować indywidualną implementację algorytmu i wybrać odpowiedni dynamicznie
  • Wzorzec pozwala nam na wykonanie różnych algorytmów rozwiązujących ten sam problem niezależnie od klienta, który z niej korzysta.
  • Strategia przechowuje abstrakcję wykonania algorytmu w interfejsie, a klasy implementujące ją szczegóły jego realizacji

W skrócie wzorzec strategia (Strategy) umożliwia klientowi rozwiązanie problemu poprzez wybranie sposobu jego rozwiązania ad-hoc bez znajomości szczegółów rozwiązania.

Jak już jesteśmy na bardzo formalnym etapie poniżej diagramik UML obrazujący wzorzec:

Wzorzec strategia (Strategy)
Diagram UML wzorzec strategia (Strategy)

Co tutaj mamy:

  • Client wykonawca algorytmu
  • Interfejs IStrategy, abstrakcja naszego algorytmu, klient wie tylko o jego istnieniu, i tylko z niego korzysta do wykonania algorytmu,
  • Klasy StrategyA i StrategyB, które implementują powyższy interfejs, są to konkretne wersje naszego algorytmu

Kiedy z niej najlepiej skorzystać z wzorca strategia (Strategy)?

  • Wtedy, gdy mamy w powiązanych ze sobą klasach niewielkie różnice w logice,
  • Potrzebujemy kilka wersji algorytmu,
  • Wykorzystywane algorytmy potrzebują dostępu do danych, których nie należy udostępniać klientowi,
  • Chcemy aby zachowanie naszego kodu było zdefiniowane podczas jego użytkowania,
  • Mamy dużo instrukcji warunkowych Case lub If

Przykład wykorzystania:

Obliczanie podatku od wynagrodzenia

Obecnie w przypadku umowy o pracę mamy dwa sposoby rozliczenia z podatku dochodowego. Poprzez skalę i poprzez podatek liniowy. Co z tym idzie mamy drobne różnice w sposobie implementacji algorytmu do obliczenia podatku dochodowego. Klient korzystający z naszej funkcjonalności chce mieć tylko możliwość wyboru sposobu opodatkowania w trakcie działania programu. Nic prostszego, powyższy kontekst idealnie pasuje nam do wzorca strategii (Strategy). Potrzebujemy jedynie interfejsu, dwóch implementacji algorytmu i wola gotowe. To zabieramy się do kodu.

Zacznijmy od zdefiniowania abstrakcji naszego algorytmu (na diagramie UML interfejsu IStrategy)
using SF.Domain.DTO;

namespace SF.Domain.TaxCalculators
{
    public interface ITaxCalculator
    {
        decimal Calculate(TaxCalculationContext context);
    }
}

Zdefiniowaliśmy sobie interfejs ITaxCalculators który posiada tylko jedną metodę Calculate wykorzystywaną do obliczenia podatku. To z tego interfejsu będzie korzystał nasz klient, i to będzie tylko jedno miejsce jego styku z naszymi algorytmami liczącymi podatek dochodowy.

Zabieramy się do implementacji naszych algorytmów, zgodnie z wymaganiami potrzebujemy tylko dwóch. Jednego do obliczeń podatków ze skali podatkowej i drugiego liniowego.

Implementacja dla podatku liniowego 😉
public class LinearCalculatorStrategy : ITaxCalculatorStrategy

    {
        private readonly ITaxPercentagesService _taxPercentagesService;

        public LinearCalculatorStrategy(ITaxPercentagesService taxPercentagesService)
        {
            _taxPercentagesService = taxPercentagesService;
        }


        public decimal Calculate(TaxCalculationContext context)
        {
            if (context == null) throw new ArgumentNullException(nameof(context));

            var percentage = GetTaxPercentage();
            return context.CurrentIncome * percentage;
        }

        private decimal GetTaxPercentage()
        {
            IncomeTaxThreshold incomeTaxThreshold = _taxPercentagesService.GetLinearRate() ?? throw new DomainException("Tax percentages not found");

            return incomeTaxThreshold.Percentage;
        }
    }

Co tutaj mamy. Nasza klasa LinearCalculatorStrategy implementuje naszą abstrakcję algorytmu w tym przypadku ITaxCalculatorStrategy. W konstruktorze wstrzykujemy serwis, który zwróci nam aktualne oprocentowanie w przypadku podatku liniowego. No kwintesencje algorytmu sposób obliczenia naszego podatku 😉

Pozostał drugi algorytm dla skali podatkowej:
public class GeneralCalculatorStrategy : ITaxCalculatorStrategy
    {
        private readonly ITaxPercentagesService _taxPercentagesService;

        public GeneralCalculatorStrategy(ITaxPercentagesService taxPercentagesService)
        {
            _taxPercentagesService = taxPercentagesService ?? throw new ArgumentNullException(nameof(taxPercentagesService));
        }
        public decimal Calculate(TaxCalculationContext context)
        {
            if (context == null) throw new ArgumentNullException(nameof(context));
            decimal taxValue = 0;
            List incomeTaxThresholds = GetIncomeTaxThreshold();

            var currentValueIncomeTaxThreshold =
                GetCurrentValueIncomeTaxThreshold(context, incomeTaxThresholds);

            if (IsLimitValueMonth(currentValueIncomeTaxThreshold, context))
            {
                IncomeTaxThreshold nextThrashold = GetNextIncomeTaxThreshold(incomeTaxThresholds, currentValueIncomeTaxThreshold);
                taxValue = CalculateForBorderMonth(context, currentValueIncomeTaxThreshold, nextThrashold);
            }
            else
            {
                taxValue = CalculateForFullMonth(currentValueIncomeTaxThreshold, context);
            }

            return taxValue;
        }

        private decimal CalculateForFullMonth(IncomeTaxThreshold currentValueIncomeTaxThreshold, TaxCalculationContext context)
        {
            return context.CurrentIncome * currentValueIncomeTaxThreshold.Percentage;
        }

        private decimal CalculateForBorderMonth(TaxCalculationContext context, IncomeTaxThreshold currentValueIncomeTaxThreshold, IncomeTaxThreshold nextThrashold)
        {
            decimal taxValue = (currentValueIncomeTaxThreshold.ToAmount - context.TotalIncomes) * currentValueIncomeTaxThreshold.Percentage;
            var remainingValueForNextThreshold =((context.TotalIncomes + context.CurrentIncome) - nextThrashold.FromAmount) * nextThrashold.Percentage;

            return taxValue + remainingValueForNextThreshold;
        }

        private IncomeTaxThreshold GetNextIncomeTaxThreshold(List incomeTaxThresholds,
            IncomeTaxThreshold currenTaxThreshold)
        {
            return incomeTaxThresholds.FirstOrDefault(x => x.ThresholdNumber == (currenTaxThreshold.ThresholdNumber + 1));
        }

        private bool IsLimitValueMonth(IncomeTaxThreshold currentValueIncomeTaxThreshold, TaxCalculationContext context)
        {
            var currentIncomesSum = context.TotalIncomes + context.CurrentIncome;
            return currentIncomesSum > currentValueIncomeTaxThreshold.ToAmount
                   && context.TotalIncomes > currentValueIncomeTaxThreshold.FromAmount;
        }

        private List GetIncomeTaxThreshold()
        {
            return _taxPercentagesService.GetGeneralIncomeTaxThresholds();
        }

        private IncomeTaxThreshold GetCurrentValueIncomeTaxThreshold(TaxCalculationContext context, List incomeTaxThresholds)
        {
            var value = (context.TotalIncomes + context.CurrentIncome);
            var retval = incomeTaxThresholds.FirstOrDefault(x => x.FromAmount <= context.TotalIncomes && x.ToAmount >= value || x.ToAmount >= context.TotalIncomes);
            return retval;
        }


    }

Uff troche kodu jest. Ale spokojnie niczym nie różni się od poprzedniego 😉 Przecież oblicza tylko podatek dochodowy… Jak porzednio mamy konkretny algorytm GeneralCalculatorStrategy implementujący naszą abstrakcję algorytmu ITaxCalculatorStrategy. No i jedną metodę obliczającą podatek dochodowy (plus kilka pomocniczych 🙂 nie będę tu wnikał w szczegóły implementacji sposobu obliczenia podatku dla skali).

Ok ale brakuje nam jeszcze naszego sławnego klienta.
public class TaxCalculator : ITaxCalculator
    {
        private readonly IComponentContext _componentContext;

        public TaxCalculator(IComponentContext componentContext)
        {
            _componentContext = componentContext ?? throw new ArgumentNullException(nameof(componentContext));
        }

        decimal ITaxCalculator.Calculate(TaxCalculationContext context)
        {
            ITaxCalculatorStrategy calculator = null;
            calculator = _componentContext.ResolveKeyed(context.TaxationForm);

            if (calculator == null) throw new DomainException($"Tax calculator for {context.TaxationForm} not found");

            return calculator.Calculate(context);
        }
    }

Mamy tu klasę TaxCalculator, która reprezentuje naszego klienta oraz jego abstrakcję ITaxCalculator.  Dzięki takiemu zabiegowi operujemy na abstrakcjach zgodnie z założeniami Dependency Injection. Jeszcze jedna rzecz, warta wyjaśnienia to wykorzystanie IComponentContext. Jest to interfejs dostarczony nam przez kontener dependency injection Autofac. To za  pomocą niego możemy wybrać implementację naszego algorytmu w trakcie działania programu.

Jeszcze dla dopełnienie  przykładu rejestracja naszych elementów w autofac:
            builder.RegisterType()
                .As()
                .InstancePerLifetimeScope();

            builder.RegisterType()
                .As()
                .InstancePerLifetimeScope();

            builder.RegisterType()
                .Keyed(TaxationForm.GENERAL)
                .InstancePerLifetimeScope();

            builder.RegisterType()
                .Keyed(TaxationForm.LINEAR)
                .InstancePerLifetimeScope();

I to wszystko. Nic skomplikowanego prawda?

A teraz dodatek.

Dzięki wykorzystaniu wzorca strategii w naszym zadaniu spełniliśmy kilka zasad SOLID 😉

  1. Single Rsponsibiliti Principle: każda zaimplementowana przez nas klasa odpowiada za wykonanie tylko jednej rzeczy. Najlepszy przykład to nasze algorytmy.
  2. Open/Close Principle: Zawsze możemy dodać nowa implementację algorytmu, lub w łatwy sposób zastąpić istniejący bez impaktu na pozostałe elementy naszej aplikacji.
  3. Dependency inversion Principle: Nasze komponenty wyższego rzędu jak TaxCalculator nie są zależne od komponentów niższego rzędu oraz wszystko zależnie jest od abstrakcji a nie od konkretnej implementacji.

Pełną implementację przykładu można zobaczyć w repo : https://github.com/erloon/SF.git

No comment yet, add your voice below!


Add a Comment

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *