Open/Closed Principle – zastosowanie OCP

Open/Closed Principle – zastosowanie OCP

Zastosujemy teraz zasadę otwarte/zamknięte do naszej przykładowej aplikacji myjni samochodowej.

Wyobraźmy sobie teraz hipotetycznie, że przychodzi nasz klient dla którego robimy aplikację i pojawia się nowy wymóg, który zmienia sposób działania naszej aplikacji w niektórych przypadkach. I to jest częsta, bardzo życiowa sytuacja która zdarza się nieustannie. I tak się składa, że firma postanowiła dodać nowy typ mycia Premium i potrzebujemy mechanizmu do obsługi tego nowego typu mycia.

Obecnie nasz klasa CarWash (myjnia samochodowa) obsługuje trzy typy mycia, wszystkie w instrukcji switch. Obecnie są to typy Standard, StandardPlus, Waxing (woskowanie). Jeśli chcemy dodać kolejny typ mycia Premium, będziemy musieli dodać kolejny przypadek do tej instrukcji switch. Będziemy musieli to zrobić ponownie dla wszystkich dodatkowych rodzajów mycia które mogą się pojawić w przyszłości, więc korzystamy z okazji, aby zastosować zasadę otwarte/zamknięte.

I zróbmy to tak, abyśmy mogli znaleźć sposób na dodanie dodatkowych zachowań do metody Pricing (wycena) bez konieczności ciągłej jej modyfikacji.

I pierwszy krok, który zamierzamy zrobić polega na przejęciu logiki z każdego z tych przypadków mycia i przeniesienie jej do własnego typu czyli utworzymy nowe klasy dla każdego typu mycia.

public class StandardDetailsPricing
{
   private readonly CarWash _carWash;
   private ConsoleLogger _logger;

   public StandardDetailsPricing(CarWash carWash, ConsoleLogger logger)
   {
      _carWash = carWash;	     
      _logger = logger;	    
   }

   public void Pricing(Details details)
   {
      _logger.Log("Valuation for a standartd program.");
      _logger.Log("Valuation rules.");

      if (String.IsNullOrEmpty(details.Make))
      {
         _logger.Log("Car make must be stated.");
         return;
      }

      decimal baseWashingCost = 20;

      if (details.Make == "Ferrari")
      {
         baseWashingCost = baseWashingCost * 3;
      }

      baseWashingCost += details.Rinsing;
      baseWashingCost += details.Drying;
      _carWash.WashingCost = baseWashingCost;
   }
}

Ta klasa przyjmuje odwołanie do CarWash, abyśmy mogli ustawić właściwość WashingCost (koszt mycia) i odwołanie do ConsoleLogger, który jest używany do wyświetlania danych na konsoli. I utworzyłem metodę Pricing (wycena), gdzie przeniosłem logikę znajdującą się w instrukcji case dla tego typu mycia.

Zrobimy teraz dokładnie to samo dla pozostałych dwóch typów mycia.

public class StandardPlusDetailsPricing
{
   private readonly CarWash _carWash;
   private ConsoleLogger _logger;

   public StandardPlusDetailsPricing(CarWash carWash, ConsoleLogger logger)
   {
      _carWash = carWash;
      _logger = logger;
   }

   public void Pricing(Details details)
   {
      _logger.Log("Valuation for a standartd plus program.");
      _logger.Log("Valuation rules.");

      if (String.IsNullOrEmpty(details.Make))
      {
         _logger.Log("Car make must be stated");
         return;
      }

      if (details.VacuumingInside == 0 || details.WashingInside == 0)
      {
         _logger.Log("Standard Plus must specify Vacuuming Inside and Washing Inside.");
         return;
      }

      decimal baseWashingCost = 25;
      
      if (details.Make == "Ferrari")
      {
         baseWashingCost = baseWashingCost * 3;
      }

      if (details.Make == "Ford")
      {
         baseWashingCost = baseWashingCost * 1.5m;
      }

      baseWashingCost += details.VacuumingInside;
      baseWashingCost += details.WashingInside;
      _carWash.WashingCost = baseWashingCost;
   }
}
public class WaxingDetailsPricing
{
   private readonly CarWash _carWash;
   private ConsoleLogger _logger;

   public WaxingDetailsPricing(CarWash carWash, ConsoleLogger logger)
   {
      _carWash = carWash;
      _logger = logger;
   }

   public void Pricing(Details details)
   {
      _logger.Log("Valuation for a waxing program.");
      _logger.Log("Valuation rules.");
      
      decimal baseWashingCost = 40;
            
      if (details.Double)
      {
         baseWashingCost = baseWashingCost * 3;
      }

      _carWash.WashingCost = baseWashingCost;
   }
}

Gdy już to zrobiliśmy, teraz będziemy mogli utworzyć wystąpienie odpowiedniego typu mycia w klasie CarWash. I w miarę dodawania dodatkowych rodzajów mycia, które będziemy chcieli obsługiwać, będziemy musieli dodać tylko nowe klasy.

To zmodyfikujmy teraz tego switcha.

switch (details.WashingType)
{
   case WashingType.Standard:
      var standard = new StandardDetailsPricing(this, this.Logger);
      standard.Pricing(details);
      break;

   case WashingType.StandardPlus:
      var standardPlus = new StandardPlusDetailsPricing(this, this.Logger);
      standardPlus.Pricing(details);
      break;

   case WashingType.Waxing:
      var waxing = new WaxingDetailsPricing(this, this.Logger);
      waxing.Pricing(details);
      break;

   default:
      Logger.Log("Unknown type of Washing.");
      break;
}

Jeśli spojrzymy teraz na klasę CarWash, widzimy, że stała się znacznie krótsza i musi się tylko martwić o te trzy różne typy mycia. Jednak, jeszcze nie skończyliśmy, ponieważ nadal musielibyśmy zmodyfikować tę instrukcję switch, aby dodać obsługę typu mycia Premium.

Spójrzmy więc, jak możemy stworzyć fabrykę, która wyeliminuje potrzebę korzystania z instrukcja switch wewnątrz klasy CarWash. Korzystając ze wzorca projektowego fabryki, tworzymy nową klasę o nazwie DetailsPricingFactory (szczegóły wyceny fabryka).

public class DetailsPricingFactory
{
   public DetailsPricing Create(Details details, CarWash carWash)
   {
      switch (details.WashingType)
      {
         case WashingType.Standard:
            return new StandardDetailsPricing(carWash, carWash.Logger);

         case WashingType.StandardPlus:
            return new StandardPlusDetailsPricing(carWash, carWash.Logger);

         case WashingType.Waxing:
            return new WaxingDetailsPricing(carWash, carWash.Logger);

         default:
            return null;
      }
   }
}

Nasza fabryka ma metodę Create(), która dla danego typu mycia przyjmie wszystko, czego potrzebuje, czyli w tym przypadku jest to Details i CarWash , i zwrócić odpowiedni typ mycia DetailsPricing (szczegóły wyceny) którego jeszcze nie mamy musimy go utworzyć i to będzie klasa abstrakcyjna po której będą dziedziczyć wszystkie typy mycia.

public abstract class DetailsPricing
{
   protected readonly CarWash _carWash;
   protected readonly ConsoleLogger _logger;

   public DetailsPricing(CarWash carWash, ConsoleLogger logger)
   {
      _carWash = carWash;
      _logger = logger;
   }

   public abstract void Pricing(Details details);
}

Teraz możemy zmodyfikować nasze klasy dla naszych 3 typów mycia aby dziedziczyły po klasie abstrakcyjnej i przesłaniały metodę pricing.

public class StandardDetailsPricing : DetailsPricing
{
   public StandardDetailsPricing(CarWash carWash, ConsoleLogger logger) : base(carWash, logger)
   {}

   public override void Pricing(Details details)
   {
      ...
   }
}

Po skończeniu, jak wrócimy do naszej fabryki widać mamy tutaj typy mycia Standard, StandardPlus i Waxing które są tworzone przez naszą fabrykę. Instrukcja switch nadal istnieje, ale instrukcja switch została teraz przeniesiona z wnętrza CarWash do fabryki.

To również nam pomaga postępować zgodnie z zasadą pojedynczej odpowiedzialności, którą omówiliśmy wcześniej, ponieważ teraz CarWash nie jest już odpowiedzialny za określanie, który typ mycia i która logika powinna być używana na podstawie tego typu. Teraz ustalenie typu jest obowiązkiem, który spoczywa tylko na fabryce. I tworzenie wystąpienia odpowiedniego typu mycia, który właśnie stworzyliśmy, jest teraz również obowiązkiem fabryki.

Kiedy już mamy tę fabrykę, możemy wrócić do CarWash i podłączyć ją tutaj zamiast switcha.

var details = DetailsSerializer.GetDetailsFromJsonString(detailsJson);

var factory = new DetailsPricingFactory();
var pricing = factory.Create(details, this);
pricing.Pricing(details);

Logger.Log("Pricing completed.");

Po zmianie na korzystanie z fabryki, metoda Pricing stała się znacznie prostsza.

I teraz, kiedy potrzebujemy faktycznie zastosować logikę typu mycia, po prostu tworzymy fabrykę i wywołujemy metode create() która tworzy odpowiedni typ mycia i wywołujemy metodę Pricing() aby zrobić wycenę mycia.

Ostatni krokiem jest wdrożenie typu mycia Premium. To dodajemy nową klasę.

public class PremiumDetailsPricing : DetailsPricing
{
   public PremiumDetailsPricing(CarWash carWash, ConsoleLogger logger) : base(carWash, logger)
   {
   }

   public override void Pricing(Details details)
   {
      _logger.Log("Valuation for a premium program.");
      _logger.Log("Valuation rules.");

      if (String.IsNullOrEmpty(details.Make))
      {
         _logger.Log("Car make must be stated.");
         return;
      }

      decimal baseWashingCost = 40;
      baseWashingCost += details.Rinsing * 2;
      baseWashingCost += details.Drying * 2;
      baseWashingCost += details.Coffee;

      if (details.Make == "Ferrari")
      {
         baseWashingCost = baseWashingCost * 2;
      }

      _carWash.WashingCost = baseWashingCost;
   }
}

Tutaj możemy zobaczyć, że dodanie niezbędnej logiki dla PremiumDetailsPricing wymaga po prostu, abyśmy utworzyli nowy podtyp klasy abstrakcyjnej DetailsPricing i wewnątrz niej wdrożyli metodę Pricing(). Możemy zastosować dowolną logikę biznesową, jaką chcemy, aby to zapewnić. My tutaj dodajemy taka logikę…

A kiedy już to zrobimy, jedyną inną zmianą, jaką musimy wprowadzić, jest dodanie jej do fabryki. Wewnątrz fabryki dodamy teraz przypadek dla PremiumDetailsPricing

case WashingType.Premium:
   return new PremiumDetailsPricing(carWash, carWash.Logger);

Musimy ten nowy typ dodać również do naszego wyliczenia.

public enum WashingType
{
   Standard = 0,
   ...
   Premium = 3
}

Musimy jeszcze dodać do klasy Details nowy typ mycia.

#region Premium
public decimal Coffee { get; set; }
#endregion

I aby można było przetestować działanie nowego typu mycia musimy zmodyfikować jeszcze plik detail.json

{
   "WashingType": "Premium",
   "Make": "Ferrari",
   "Rinsing": 7,
   "Drying": 10,
   "Coffee": 15
}

Teraz możemy przetestować działanie naszej aplikacji po zastosowaniu OCP i nowego typu mycia. I jeśli uruchomimy aplikację, to widzimy, że wycena została wykonana prawidłowo.

I teraz kiedy dodaliśmy ten nowy typ mycia, nie ma możliwości, że przypadkowo zepsuliśmy klasy naszych już istniejących typów mycia: Standard, StandardPlus, Waxing ponieważ są one w osobnych typach i w ogóle ich nie dotykamy.

Dzięki temu, mam nadzieję, że to widzimy, że metoda Pricing w klasie CarWash jest teraz otwarta na rozszerzanie różnych rodzajów typów mycia, ale zamknięta przed modyfikacją. Nie musimy zmieniać metody Pricing(), aby dodać obsługę nowego typu mycia.

Możemy zastosować zasadę open/closed również w fabryce, eliminując potrzebę posiadania switcha za pomocą refleksji.

public DetailsPricing Create(Details details, CarWash carWash)
{
   try
   {
      var detailsPricing = (DetailsPricing)Activator.CreateInstance(
                    Type.GetType($"ExampleSolid.{details.WashingType}DetailsPricing"),
                          new object[] { carWash, carWash.Logger });
      return detailsPricing;
   }
   catch (System.Exception)
   {
      return null;
   }
}

W tym przypadku używamy konwencji nazewnictwa, aby umożliwić nam utworzenie instancji odpowiedniej instancji klasy na podstawie nazwy podanej w typie mycia w dokumencie JSON. W przypadku, gdy nie znajdziemy pasującej klasy, po prostu zwrócimy wartość null z fabryki. Oznacza to, że gdy wrócimy do klasy CarWash, plik oceniający z fabryki będzie musiał również sprawdzić, czy nie jest nullem.

pricing?.Pricing(details);

Możemy to łatwo zrobić, używając po prostu rozszerzenia operator znaku zapytania na samym pricing, w takim przypadku metoda Pricing nie zostanie wywołana, jeśli pricing ma wartość null.

Możemy teraz sprawdzić czy nasza aplikacja działa. I jeśli uruchomimy aplikację, to widzimy, że wycena została wykonana prawidłowo.

Jeśli zmienimy na taki typ który nie istnieje i uruchomimy aplikację ponownie, dostajemy wyjątek metody GetDetailsFromJsonString naszego JsonDetailsSerializer a to dlatego że teraz klasa Details jako typ mycia akceptuje tylko te typy mycia które mamy zdefiniowane, aby to zmienić musimy WashingType zmienić na string

public string WashingType { get; set; }

I uruchomimy aplikację ponownie, widzimy, że wycena mycia nie jest tworzona, co jest oczekiwanym zachowaniem.

Najważniejsze wnioski to:

Zasada otwarte/zamknięte, opisuje, kiedy i jak powinniśmy używać abstrakcji, aby stworzyć nasze projekty bardziej rozszerzalne.

Zasady otwarte/zamknięte to przede wszystkim rozwiązanie problemu za pomocą prostego, konkretnego kodu.

Nie próbuj otwierać swojego kodu na rozszerzenie w każdym możliwym kierunku, ponieważ uczyni go to zbyt abstrakcyjnym.

Zidentyfikuj rodzaje zmiany, których aplikacja prawdopodobnie będzie nadal potrzebować w przyszłości. Spróbuj uniknąć przedwczesnych spekulacji bez rzeczywistych wymagań.

Zmodyfikuj kod, aby był rozszerzalny względem potencjalnych zmian, którą zidentyfikowałeś.

Mam nadzieję, że podobało Wam się zastosowanie zasady OCP i jesteście gotowi, aby zrobić kolejny krok w nauce zasad SOLID i zastosować trzecią zasadę LSP.

Cały kod na githubie (pod tym adresem kod do tego wpisu)

https://github.com/mariuszjurczenko/ExampleSolid/tree/883e01e997027cfc8c536baa27da3a95ace083b3

3 comments

  1. Cześć,
    Bardzo dobre wyjaśnienie zasady OCP, prosto i przystępnie na realnym przykładzie, trzymaj tak dalej, dobra robota.

  2. Pingback: Open/Closed Principle – OCP – DEV – HOBBY

Dodaj komentarz

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