Single Responsibility Principle – zastosowanie SRP

Single Responsibility Principle – zastosowanie SRP

Ile obowiązków znaleźliście w CarWash?

Przedstawie teraz wszystkie które mogliście zauważyć.

1) Wszędzie tam, gdzie to robimy, Console.WriteLines(„coś”);
jest przykładem tego, jak chcemy logować wszystko to co zachodzi w naszej aplikacji.

 Console.WriteLine("Starting pricing.");

2) Sposób, w jaki czytamy dane z pliku json, jest odpowiedzialnością za sposób przechowywania danych i jest to system plików, ale moglibyśmy korzystać z innego źródła danych np. Bazy danych. I jeśli zmienilibyśmy naszą decyzję dotyczącą tego, w jaki sposób chcielibyśmy zachować tę logikę, musielibyśmy zmienić ten szczegół implementacji i zmienić nasz kod.

string detailsJson = File.ReadAllText("details.json");

3) Zakodowaliśmy również na stałe zależność od formatu JSON. Jeśli później będziemy chcieli użyć plików, XML lub INI lub jakiś inny niestandardowy format binarny, ponownie będziemy musieli zmienić tę odpowiedzialność, zmienić nasz kod.

var details = JsonConvert.DeserializeObject<Details>(detailsJson, new StringEnumConverter());

4) Być może zauważyłeś, że w tej klasie znajduje się również kilka różnych reguł biznesowych. Niektóre z nich są reprezentowane przez różne typy mycia, które są zawarte w instrukcji switch, które posłużyły do uzyskania wyceny mycia.

case WashingType.Standard:

5) Na pewno zauważyłeś również ze mamy kilka rodzajów walidacji, które zwykle odbywają się w celu zapewnienia określonych właściwości niezbędnych do przeprowadzenia wyceny dla określonego rodzaju mycia.

 if (String.IsNullOrEmpty(details.Make))

6) I wreszcie złożona logika biznesowa 🙂 dla ustalenia ceny mycia.

decimal baseWashingCost = 20 ;
baseWashingCost += details.Rinsing;
baseWashingCost += details.Drying;
WashingCost = baseWashingCost;  

Obowiązki te mają bezpośredni związek z testowaniem naszego kodu. Kiedy klasy mają wiele odpowiedzialności, trudniej jest je przetestować. Szczególnie, gdy pojedyncze metody wykonują wiele różnych rzeczy. Napisanie dla nich testów, w szczególności testów jednostkowych, może być bardzo trudne.

Ogólnie rzecz biorąc, testowanie klasy z wieloma obowiązkami skutkują dłuższymi i bardziej złożonymi testami. Często te testy są kruche, ponieważ są powiązane z implementacją a zmiana jakiejkolwiek odpowiedzialności może zepsuć testy dla każdej innej odpowiedzialności należącą do tej samej metody lub klasy.

Jeśli spojrzymy na testy jednostkowe dla klasy CarWash które napisaliśmy w poprzednim wpisie. Zauważymy, że musi on nadpisać plik JSON na każde uruchomienie testu, uniemożliwiając tym samym równoległe uruchamianie testów i prawdopodobnie powodując w tym momencie problemy z blokowaniem i rywalizacją, które mogą skutkować nieoczekiwanymi błędami testów…

Dobrze. Rozważmy teraz ponownie klasę CarWash, mając na uwadze SRP.

Po pierwsze, możemy utworzyć nową klasę tylko do logowania, która ma tylko jedna odpowiedzialność.

public class ConsoleLogger
{
   public void Log(string message)
   {
      Console.WriteLine(message);
   }
}

Teraz Możemy dodać właściwość do klasy CarWash za pomocą tego nowego typu ConsolLoger. Nazwijmy ją Logger.

public ConsoleLogger Logger { get; set; } = new ConsoleLogger();

Następnie znajdujemy wszędzie wewnątrz CarWash wywołania Console.WriteLine i zastępujemy je wywołaniem naszej nowej metody Logger.Log.

public void Pricing()
{       
   Logger.Log("Starting pricing.");
   Logger.Log("Loading details.");
   ...
   switch (details.WashingType)
   {
      case WashingType.Standard:        
         Logger.Log("Valuation for a standartd program.");
         Logger.Log("Valuation rules.");

  ...

Po drugie, podobnie możemy wykonać te same kroki dla przechowywania danych. Najpierw utwórzmy nową klasę, która robi tylko jedną rzecz, w tym przypadku czytanie z pliku JSON.

public class FileDetailsSource
{
   public string GetDetailsFromSource() 
   {
      return File.ReadAllText("details.json");
   }
}

Następnie dodajemy właściwość tego typu do CarWash i używamy jej zamiast pracować bezpośrednio z niskopoziomowymi bibliotekami we/wy plików.

public FileDetailsSource DetailsSource { get; set; } = new FileDetailsSource();

Po trzecie, podobnie możemy wykonać te same kroki dla deserializacji. Tworzymy nową klasę.

public class JsonDetailsSerializer 
{
   public Details GetDetailsFromJsonString(string jsonString)
   {
      return JsonConvert.DeserializeObject<Details>(jsonString, new StringEnumConverter());
   }
}

Następnie dodajemy właściwość tego typu do CarWash i używamy jej.

public JsonDetailsSerializer DetailsSerializer { get; set; } = new JsonDetailsSerializer();

I oto klasa CarWash po modyfikacji i zastosowaniu SRP.

public class CarWash
{
   public decimal WashingCost { get; set; }
   public ConsoleLogger Logger { get; set; } = new ConsoleLogger();
   public FileDetailsSource DetailsSource { get; set; } = new FileDetailsSource();
   public JsonDetailsSerializer DetailsSerializer { get; set; } = new JsonDetailsSerializer();

   public void Pricing()
   {
      Logger.Log("Starting pricing.");
      Logger.Log("Loading details.");

      string detailsJson = DetailsSource.GetDetailsFromSource();

      var details = DetailsSerializer.GetDetailsFromJsonString(detailsJson);

      switch (details.WashingType)
      {
         case WashingType.Standard:
            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;
            WashingCost = baseWashingCost;                   
            break;

         case WashingType.StandardPlus:
            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;
            }
            baseWashingCost = 25;
            if (details.Make == "Ferrari")
            {
               baseWashingCost = baseWashingCost * 3;
            }
            if (details.Make == "Ford")
            {
               baseWashingCost = baseWashingCost * 1.5m;
            }
            baseWashingCost += details.VacuumingInside;
            baseWashingCost += details.WashingInside;
            WashingCost = baseWashingCost;
            break;

         case WashingType.Waxing:
            Logger.Log("Valuation for a waxing program.");
            Logger.Log("Valuation rules.");
            baseWashingCost = 40;
            if (details.Double)
            {
               baseWashingCost = baseWashingCost * 3;
            }
            WashingCost = baseWashingCost;
            break;

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

      Logger.Log("Pricing completed.");
   }
}

Teraz klasa CarWash nadal wykonuje tę samą pracę, ale teraz nie określa, jak jest wykonywana. Teraz deleguje szczegóły na inne klasy. A każda z tych innych klas robi tylko jedną rzecz, więc są niezwykle proste w obsłudze i zrozumieniu. Ma to również tę zaletę, że bardzo łatwo możemy nazwać te nowe klasy i ich metody, ponieważ kiedy robisz tylko jedną rzecz, łatwo jest wymyślić nazwę, która opisuje jedna rzecz, która jest wykonywana.

Każdą z tych trzech nowych klas można łatwo przetestować. Przykład testu jednostkowego, który wcześniej nie był łatwy do napisania. Teraz można sprawdzić w bardzo prostym teście, czy deserializacja działa zgodnie z oczekiwaniami i jest to wykonane bez polegania na CarWash lub rzeczywistych plikach json.

public class JsonDetailsSerializerGetDetailsFromJsonString
{
   [Test]
   public void ReturnsDefaultDetailsFromEmptyJsonString() 
   {
      var inputJson = "{}";
      var serializer = new JsonDetailsSerializer();

      var result = serializer.GetDetailsFromJsonString(inputJson);

      var details = new Details();
      AssertDetailsEqual(result, details);
   }

   [Test]
   public void ReturnsSimpleDetailsFromValidJsonString() 
   {
      var inputJson = @"{""WashingType"": ""Standard"",""Make"": ""Ferrari"",""Rinsing"": 7,""Drying"": 10}";
      var serializer = new JsonDetailsSerializer();

      var result = serializer.GetDetailsFromJsonString(inputJson);

      var details = new Details { WashingType = WashingType.Standard, Make = "Ferrari", Rinsing = 7, Drying = 10 };
      AssertDetailsEqual(result, details);
   }

   private static void AssertDetailsEqual(Details result, Details details)
   {
      Assert.AreEqual(details.Double, result.Double);
      Assert.AreEqual(details.Drying, result.Drying);
      Assert.AreEqual(details.Make, result.Make);
      Assert.AreEqual(details.Rinsing, result.Rinsing);
      Assert.AreEqual(details.VacuumingInside, result.VacuumingInside);
      Assert.AreEqual(details.WashingInside, result.WashingInside);
      Assert.AreEqual(details.WashingType, result.WashingType);
   }
}

Możemy określić dowolny ciąg wejściowy, który chcemy w każdym indywidualnym teście jednostkowym i sprawdzić, czy zwracany jest oczekiwany wynik z procesu deserializacji, którego używamy.

W tym kursie pokazuje, jak zastosować zasady SOLID zmieniając i ulepszając przykładowy kod. W tym momencie już wiecie jak zasada pojedynczej odpowiedzialności może pomóc ulepszyć projekt Waszej Aplikacji. W następnym rozdziale przyjrzymy się zasadzie otwarte – zamknięte (ang. Open/Closed Principle – OCP).

Najważniejsze wnioski z tego rozdziału to:

Po pierwsze, nie próbuj od razu stosować każdej zasady SOLID. Użyj ich, aby wyeliminować ból podczas ulepszanie projektu po napisaniu już działającego oprogramowania. Najpierw powinniśmy napisać nasz kod przy użyciu najprostszej techniki jaką znamy.

SRP stwierdza, że każda klasa powinna mieć jedna odpowiedzialność, co oznacza, że powinna mieć jeden powód do zmiany.

SRP pomaga również osiągnąć wysoki poziom spójność w swoich klasach i wspomnieliśmy, że chcemy dążyć do luźnego powiązania. Niektóre inne zasady pomogą nam w tym.

Wreszcie, utrzymujmy nasze klasy małe i skoncentrowane, co generalnie będzie czynić je również bardziej testowalnymi.

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

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

https://github.com/mariuszjurczenko/ExampleSolid/commit/53f27adfedce7bf2028867d6de52f2212d4d25f3

11 comments

  1. bardzo dobry przykład, czy to będzie też w wersji video kursu, 🙂

    1. dziękuje, jest przygotowywana również wersja video kursu, na który serdecznie zapraszam.

  2. Merytoryczny artykuł, dobrze opracowany, szacun. Smuci mnie jednak fakt, że wielu programistów, nawet tych doświadczonych bagatelizuje zasady SOLID.

  3. trafiłem na tą stronę, szukając informacji na temat S.O.L.I.D., ale czuję że zostanę tutaj dłużej niż tylko to zagadnienie 😉

    1. dziękuje, za miłe słowa, cieszę się że zostaniesz na dłużej… pozdrawiam

  4. Bardzo dobry mateirał a wiedza na temat wzorców projektowych, to jest to czego brakuje studentom po studiach.

  5. Pingback: Single Responsibility Principle – SRP – aplikacja – DEV – HOBBY

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *