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; } | |
} |
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.
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); | |
} |
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?
Swietny artykul. Dzieki bardzo Daniel! 🙂
Super, że się podoba 🙂
Można też trzymać stałą liste z formaterami
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
Dzięki za spostrzeżęnia!