Jak zastąpić rozbudowany switch w aplikacji

Wprowadzenie

Dzisiejszy wpis jest zainspirowany kilkoma ostatnimi przypadkami, w których widziałem bardzo rozbudowane switche w aplikacji.  Utrzymanie tego kodu zaczynało sprawiać programistom spore problemy. Zapewne wielokrotnie widziałeś lub widziałaś metody, w których znajdował się jeden wielki switch, gdzie każdy case zawierał następnie zupełnie inny kod niż pozostałe przypadki. Utrzymanie tego, a w szczególności dodanie nowych przypadków zaczyna być wyzwaniem i często powoduje, że nasz mały potworek staje się coraz większy. W tym wpisie pokażę Ci, jak można to zmienić, zachowując przy okazji literkę O z SOLID, czyli Open/Closed Principle.

Problem

Wyobraźmy sobie, że tworzymy aplikację, w której użytkownik może wygenerować sobie raport. W formularzu wybiera parametry raportu, w tym między innymi format pliku, w jakim raport ma zostać zapisany – na przykład xml, csv czy pdf.

W takiej sytuacji programista prawdopodobnie po stronie kodu utworzyłby enuma, w którym znajdowałaby się lista dostępnych formaterów:

public enum ExportFormat
{
Xml,
Csv
}

Natomiast sam raport zawierałby właściwość, w której znajdowałaby się informacja dotycząca tego, jakiego formatera użyć:

public class Report
{
public string Name { get; set; }
public ExportFormat ExportFormat { get; set; }
}
view raw Report.cs hosted with ❤ by GitHub

Następnie dla każdego formatu powstałaby klasa generująca odpowiedni plik. W testowym przykładzie byłoby to na przykład (same klasy nie zawierają właściwej implementacji, tylko ją symulują poprzez wypisanie napisu na konsoli):

public class XmlFormatter : IXmlFormatter
{
public void Export(Report report)
{
Console.WriteLine($"XmlFormatter.Export: {report.Name}");
}
}
public interface IXmlFormatter
{
}
public class CsvFormatter : ICsvFormatter
{
public void Export(Report report)
{
Console.WriteLine($"CsvFormatter.Export: {report.Name}");
}
}
public interface ICsvFormatter
{
}

Na końcu sam procesor odpowiedzialny za wygenerowanie raportu może wyglądać tak:

public class ReportProcessorWithSwitch : IReportProcessor
{
private IXmlFormatter _xmlFormatter;
private ICsvFormatter _csvFormatter;
public ReportProcessorWithSwitch(IXmlFormatter xmlFormatter,
ICsvFormatter csvFormatter)
{
_xmlFormatter = xmlFormatter;
_csvFormatter = csvFormatter;
}
public void Process(Report report)
{
switch (report.ExportFormat)
{
case ExportFormat.Xml:
_xmlFormatter.Export(report);
break;
case ExportFormat.Csv:
_csvFormatter.Export(report);
break;
default:
throw new ArgumentOutOfRangeException();
}
}
}

Metoda Process zawiera switch, który na podstawie wartości właściwości ExportFormat raportu decyduje o tym, którego formatera użyć.

W przykładzie kod nie wygląda jakoś megastrasznie. Głównie przez to, że poszczególne sposoby formatowania raportu są zapisane w dedykowanych klasach. Niestety często można spotkać switche, w których cały kod znajduje się w poszczególnych case’ach zamiast w dedykowanych klasach. Wtedy taki switch wygląda dużo gorzej.

Darmowy kurs Visual Studio

Pracując z setkami programistów, zauważyłem, że większość osób nie pracuje efektywnie w Visual Studio. W skrajnych przypadkach korzystali z kopiowania z wykorzystaniem menu Edit. Wiem, że to dziwne, ale naprawdę niektórzy tak pracują. Dlatego postanowiłem stworzyć kurs Visual Studio – aby pomóc koleżankom i kolegom w efektywniejszej pracy.

Przygotowałem 30 lekcji e-mail, w których pokażę Ci, w jaki sposób pracować efektywniej i szybciej w Visual Studio. Poznasz dodatki, bez których nie wyobrażam sobie pracy w tym IDE.

Po więcej informacji zapraszam na dedykowaną stronę kursu: Darmowy Kurs Visual Studio.

Quiz C#

Ostatnio przygotowałem również quiz C#, w którym możesz sprawdzić swoją wiedzę. Podejmiesz wyzwanie?

A co z dodaniem nowego formatera?

Działając tak, jak w powyższym przykładzie, gdy chcemy dodać nowy format zapisu raportu, musimy wykonać kilka kroków i zmodyfikować już istniejący kod:

  • Dodać nową wartość do enuma
  • Dołączyć klasę implementującą nowy format zapisu
  • Dodać nową zależność do procesora
  • Dołączyć nowego case’a do switcha
  • W przypadku gdy mamy testy jednostkowe, musimy poza testami do nowej klasy dodać też nowe testy do procesora, które pokryją nowego case’a.

Jak widać, kroków jest sporo. Co gorsza, z racji tego, że złamaliśmy regułę Otwarte/Zamknięte, może się okazać, że przy okazji modyfikacji procesora coś przy okazji popsujemy w istniejącym kodzie. Pół biedy, gdy mamy do wszystkiego testy, ale gdy testujemy wszystko ręcznie, wtedy może okazać się to bardzo kosztowne.

Rozwiązanie

W pierwszej kolejności możemy dodać wspólny interfejs dla wszystkich formaterów. Dzięki temu w kontenerze (w przykładzie będzie to Autofac) możemy zarejestrować wszystkie formatery pod jednym interfejsem, aby później procesor zamiast poszczególnych formaterów otrzymał listę wszystkich dostępnych. Interfejs może wyglądać tak:

public interface IFormatter
{
ExportFormat Format { get; }
void Export(Report report);
}
view raw IFormatter.cs hosted with ❤ by GitHub

Interfejs poza metodą Export zawiera jeszcze właściwość Format, która jest typu naszego enuma z typem formatu. Właściwość ta będzie nam później potrzebna, aby z listy dostępnych formaterów wybrać właściwą klasę, która wygeneruje raport.

Zmienione formatery będą wyglądać tak:

public class XmlFormatter : IXmlFormatter
{
public ExportFormat Format { get; } = ExportFormat.Xml;
public void Export(Report report)
{
Console.WriteLine($"XmlFormatter.Export: {report.Name}");
}
}
public interface IXmlFormatter : IFormatter
{
}
public class CsvFormatter : ICsvFormatter
{
public ExportFormat Format { get; } = ExportFormat.Csv;
public void Export(Report report)
{
Console.WriteLine($"CsvFormatter.Export: {report.Name}");
}
}
public interface ICsvFormatter : IFormatter
{
}

Teraz interfejs poszczególnych formaterów dziedziczy po IFormatter oraz dodatkowo każdy z formaterów ustawia odpowiednią wartość właściwości Format.

Po tych zmianach sam procesor jest prostszy:

public class ReportProcessorWithoutSwitch : IReportProcessor
{
private IEnumerable<IFormatter> _formatters;
public ReportProcessorWithoutSwitch(IEnumerable<IFormatter> formatters)
{
_formatters = formatters;
}
public void Process(Report report)
{
var formatter = _formatters.FirstOrDefault(f => f.Format == report.ExportFormat);
if (formatter == null)
{
throw new Exception("Not supported report formatter.");
}
formatter.Export(report);
}
}

W konstruktorze wstrzykujemy listę wszystkich formaterów zarejestrowanych w kontenerze, natomiast w metodzia Process z listy wybieramy formater na podstawie właściwości Format.

W tym przypadku dodanie nowego formatera jest dużo prostsze i sprowadza się właściwie tylko do dodawania nowego kodu:

  • Dodajemy nową wartość do enuma
  • Dołączamy nowy formater
  • Dodajemy testy do formatera.

Nie musimy niczego zmieniać w istniejącym procesorze. Przez to nie popsujemy nic przy okazji oraz nie musimy dodawać dodatkowych testów jednostkowych dla procesora.

Co dalej?

W tym momencie nie udało się nam do końca rozwiązać problemu ze złamaniem reguły Otwarte/Zamknięte. Niestety podczas dodawania nowego formatera musimy dodać nową wartość do enuma.

Jeśli jednak się zastanowimy, to tak naprawdę w tym momencie za bardzo nam ten enum nie jest potrzebny. Możemy właściwość Format zamienić na typ string, w którym będzie znajdować się nazwa naszego formatera. Podczas wyświetlania widoku do generowania raportu możemy pobrać listę formaterów i wyświetlić w interfejsie wszystkie wartości z właściwości Format. Dzięki temu możemy się pozbyć enuma i spełnić regułę Otwarte/Zamknięte w naszym kodzie.

Ten krok zostawiam już dla Ciebie jako pracę domową. 🙂

Przykład

Na githubie (https://github.com/danielplawgo/ReplaceSwitch) znajduje się przykład do tego wpisu. Do jego uruchomienia nie jest potrzebna żadna dodatkowa konfiguracja. Wystarczy uruchomić aplikację konsolową.

Podsumowanie

Na powyższym przykładzie widać, że bardzo często używanie switcha w aplikacji może prowadzić do złamania reguły Otwarte/Zamknięte. Sam bardzo często staram się unikać w swoim kodzie rozbudowanych switchy – o ile oczywiście ilość dodatkowej pracy nie jest zbyt duża i ma szansę się zwrócić z czasem.

A Ty jak sobie radzisz z takimi switchami w Twoim kodzie?

.NET 5 Web App w Azure

Szkolenie .NET 5 Web App w Azure

Zainteresował Ciebie ten temat? A może chcesz więcej? Jak tak to zapraszam na moje autorskie szkolenie o Web API działającym w Azure.

7 thoughts on “Jak zastąpić rozbudowany switch w aplikacji

  • Pingback: dotnetomaniak.pl
    • Można, tylko, że takie rozwiązanie na dłuższą metę jest mniej elastyczne. Szczególnie, gdy lista obiektów zależy od jakieś logiki (np. uprawnień użytkownika lub za co zapłacić klient).

  • Nie jedna osoba straciła zdrowie przy mega-switchu:) Dwa spostrzeżenia do arykułu:

    1) zamiast listy, która ma liniowy czas przeszukiwania O(n) (użycie FirstOrDefault) warto rozważyć użycie słownika Dictionary, który ma (zazwyczaj) stały czas przeszukiwania O(1), Szczególnie, jeśli switch jest bardzo rozbudowany i mamy duże n 🙂

    2) switch, jest brzydki, ale generuje tzw. tablicę skoków („jump table” albo „branch table”), która może być znacznie wydajniejsza niż wyszukiwanie na liście a potem wykonywanie metod interfejsu (dispatch metody wirtualnej, itp.). Przy kodzie, który jest zorientowany na wydajność, zachęcałbym do napisania benchmarku w BenchmarkDotNet. Oczywiście, dla tradycyjnego kodu biznesowego, te zyski będą nieznaczące

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany.