Nadeszła pora na cykl publikacji: „Historia pewnej refaktoryzacji”. Część 8.
Oto nadszedł moment, aby zająć się refaktoryzacją samego importu danych. Sprawa się jednak komplikuje, albowiem fragmenty kodu odpowiedzialnego za odczyt i interpretację zawartości nie są we wszystkich metodach takie same. Ale dwie z metod są praktycznie identyczne – co stwierdziliśmy przygotowując listę funkcjonalności realizowanych przez metody. Zaczniemy więc od tych metod, być może po ich zunifikowaniu do jednej i w konsekwencji zredukowaniu liczby wszystkich metod do trzech, uda się pomiędzy nimi wychwycić jakieś podobieństwo, albo przynajmniej dojrzeć szansę na tego podobieństwa uzyskanie.
Pozostaje jeszcze jedynie kwestia stwierdzenia, jaki fragment kodu jest fragmentem dokonującym odczytu pliku i interpretacji zawartości? Przyjrzyjmy się wnętrzu jednej z metod:
openFileDialog.Filter = "Wartości separowane przecinkiem (*.csv)|*.csv"; if (openFileDialog.ShowDialog() != DialogResult.OK) return; FileInfo fileInfo = new FileInfo(openFileDialog.FileName); progressBar.Maximum = (int)fileInfo.Length; string xml = ""; using (StreamReader reader = new StreamReader(openFileDialog.FileName)) { string line; string[] values; progressBar.Value = 0; while (!reader.EndOfStream) { line = reader.ReadLine(); values = line.Split(','); if (values.Length == 2) xml += "<r s=\"" + values[0] + "\" q=\"" + values[1] + "\"/>"; progressBar.Value = progressBar.Value + line.Length + 2/*znak powrotu do początku linii + znak przejścia do nowej linii*/; } } if (xml.Length == 0) { MessageBox.Show("Nie udało się przetworzyć pliku!"); return; } xml = "<data>" + xml + "</data>"; store(xml);
i przypomnijmy sobie sporządzoną niedawno listę funkcjonalności:
- wybieranie pliku,
- sprawdzenie czy plik wybrano,
- odczytywanie pliku,
- pokazywanie postępu przetwarzania,
- generowanie xml,
- sprawdzenie czy xml dał się wygenerować,
- przekazywanie xml gdzieś dalej.
Postarajmy się teraz usunąć na podstawie tej listy wszystkie fragmenty kodu odpowiadające punktom powyżej i poniżej punktu odczytywanie pliku.
FileInfo fileInfo = new FileInfo(openFileDialog.FileName); progressBar.Maximum = (int)fileInfo.Length; string xml = ""; using (StreamReader reader = new StreamReader(openFileDialog.FileName)) { string line; string[] values; progressBar.Value = 0; while (!reader.EndOfStream) { line = reader.ReadLine(); values = line.Split(','); if (values.Length == 2) xml += "<r s=\"" + values[0] + "\" q=\"" + values[1] + "\"/>"; progressBar.Value = progressBar.Value + line.Length + 2/*znak powrotu do początku linii + znak przejścia do nowej linii*/; } }
Kod który pozostał po usunięciu, sprowadza się do następującej listy funkcjonalności:
- odczytywanie pliku,
- pokazywanie postępu przetwarzania,
- generowanie xml,
Jak widać nie udało nam się wyrugować kodu odpowiadającemu dwóm punktom. Niemniej spróbujmy zastosować do uzyskanego kodu wcześniej zdefiniowane interfejsy. Ale, ale – właściwie interfejs IFileSelector nie jest tu w ogóle potrzebny, nazwę pliku można przekazać do metody jako parametr. Potrzebny będzie jedynie interfejs IProgressNotifier, który również do metody trafi poprzez parametr. Przeróbmy zatem powyższy kod na metodę:
public void ReadCSVFile(string fileName, IProgressNotifier progressNotifier) { FileInfo fileInfo = new FileInfo(fileName); progressNotifier.Expected = (int)fileInfo.Length; string xml = ""; using (StreamReader reader = new StreamReader(fileName)) { string line; string[] values; int completed = 0; progressNotifier.Completed = completed; while (!reader.EndOfStream) { line = reader.ReadLine(); values = line.Split(','); if (values.Length == 2) xml += "<r s=\"" + values[0] + "\" q=\"" + values[1] + "\"/>"; completed += line.Length + 2/*znak powrotu do początku linii + znak przejścia do nowej linii*/; progressNotifier.Completed = completed; } } }
Metoda wygląda już o wiele lepiej, nadal jednak zawiera w sobie dwie dodatkowe funkcjonalności. Czy to źle? I tak, i nie. Nie, albowiem pokazywanie postępu przetwarzania jest nierozerwalnie związane z samym procesem przetwarzania – to ten proces powinien informować o postępie. Tak, albowiem budowanie XML-a w tym miejscu usztywnia metodę, bo uzależnia ją od konkretnego formatu wyjściowego. Lepiej gdyby otrzymane z pliku wartości pozostały w postaci bardziej elastycznej, uniwersalnej. Najlepiej gdyby pozostały … wartościami ;). Z tymi można robić cokolwiek się zechce. Czyż nie lepiej gdyby metoda wyglądała tak:
public void ReadCSVFile(string fileName, IProgressNotifier progressNotifier) { FileInfo fileInfo = new FileInfo(fileName); progressNotifier.Expected = (int)fileInfo.Length; using (StreamReader reader = new StreamReader(fileName)) { string line; string[] values; int completed = 0; progressNotifier.Completed = completed; while (!reader.EndOfStream) { line = reader.ReadLine(); values = line.Split(','); Transfer(values); completed += line.Length + 2/*znak powrotu do początku linii + znak przejścia do nowej linii*/; progressNotifier.Completed = completed; } } }
Rzeczywiście – wygląda to o wiele lepiej. Co to jednak za metoda Transfer()? To metoda, która będzie odpowiednio interpretowała daną porcję wartości (porcję odpowiadającą jednej linii przetworzonego pliku). Ale jak ma interpretować? Właściwie to nie powinno obchodzić metody odczytującej plik. A skoro tak, to powinna ona trafiać do metody jako parametr. W takim razie potrzebne są następujące zmiany:
public delegate void Transfer(params string[] values); public void ReadCSVFile(string fileName, Transfer transfer, IProgressNotifier progressNotifier) { FileInfo fileInfo = new FileInfo(fileName); progressNotifier.Expected = (int)fileInfo.Length; using (StreamReader reader = new StreamReader(fileName)) { string line; string[] values; int completed = 0; progressNotifier.Completed = completed; while (!reader.EndOfStream) { line = reader.ReadLine(); values = line.Split(','); transfer(values); completed += line.Length + 2/*znak powrotu do początku linii + znak przejścia do nowej linii*/; progressNotifier.Completed = completed; } } }
W ten sposób pozbyliśmy się ostatniej funkcjonalności, która nie była związana w żaden sposób z odczytem pliku.
Tu warto na chwilę pochylić się zarówno nad procesem redukcji funkcjonalności, jak i procesem otrzymania ich listy. Jak inaczej można nazwać taką listę funkcjonalności? Każda z nich odpowiada za inne działanie. Hm, odpowiada? Otóż to – tworząc tę listę de facto stworzyliśmy zakres odpowiedzialności procesu importu pliku. Część tych odpowiedzialności przydzieliliśmy do interfejsów (każdemu po jednej, proszę zauważyć, że pierwszy i drugi punkt to de facto jedna odpowiedzialność), kolejną właśnie przydzielamy, resztę zaś pominęliśmy, za chwilę się nimi zajmiemy i przydzielimy do właściwych bytów.
No dobrze, wszystko pięknie, ładnie, ale przecież jest jeszcze druga metoda ImportTabSeparated(). Czym jednak różni się ona od ImportCSV()? A dokładnie czym różnią się te fragmenty kodu, z których powstała metoda ReadCSVFile()? Jedyna różnica to:
values = line.Split(','); //ImportCSV
values = line.Split('\t'); //ImportTabSeparated
Różnią się one więc jedynie używanym separatorem. W takim razie zróbmy z niego parametr i wówczas:
public void ReadSeparatedFile(string fileName, char separator, Transfer transfer, IProgressNotifier progressNotifier) { FileInfo fileInfo = new FileInfo(fileName); progressNotifier.Expected = (int)fileInfo.Length; using (StreamReader reader = new StreamReader(fileName)) { string line; string[] values; int completed = 0; progressNotifier.Completed = completed; while (!reader.EndOfStream) { line = reader.ReadLine(); values = line.Split(separator); transfer(values); completed += line.Length + 2/*znak powrotu do początku linii + znak przejścia do nowej linii*/; progressNotifier.Completed = completed; } } }
Otrzymaliśmy metodę uniwersalną, która potrafi obsłużyć oba typy importu – dla plików separowanych przecinkiem oraz tabulacją. Zaraz, zaraz! Mamy metodę która potrafi obsłużyć import pliku separowanego dowolnym znakiem! Oto bonus za naszą dotychczasową refaktoryzację. Czy to zatem koniec unifikacji obu metod? Jeszcze nie. Zasada pojedynczej odpowiedzialności mówi o klasach, nie metodach, zresztą – przytoczę jej definicję za Agile. Programowanie zwinne: zasady, wzorce i praktyki zwinnego wytwarzania oprogramowania w C#:
Żadna klasa nie może być modyfikowana z więcej niż jednego powodu.
Nie jest to może zbyt szczęśliwa definicja, bo nie nakierowuje od razu na sens zasady, a jest nim to, że klasa powinna realizować tylko jedna funkcjonalność. Tu warto dodać, że musi to być funkcjonalność na danym poziomie abstrakcji. Nie będę się w tej chwili podejmował jej szczegółowego wyjaśnienia – ważne, że mówi ona o klasie i dlatego też, my naszą odpowiedzialność, którą jest odczyt plików zawierających wartości rozdzielone dowolnym separatorem, także umieścimy w klasie.
public delegate void Transfer(params string[] values); class SeparatedFileReader { #region konstruktory public SeparatedFileReader(string fileName, char separator, Transfer transfer, IProgressNotifier progressNotifier) { this.fileName = fileName; this.separator = separator; this.progressNotifier = progressNotifier; this.transfer = transfer; } #endregion #region metody public void Read() { string line; string[] values; int completed = 0; progressNotifier.Completed = completed; using (StreamReader reader = new StreamReader(fileName)) { while (!reader.EndOfStream) { line = reader.ReadLine(); values = line.Split(separator); transfer(values); completed += line.Length + EndOfLineSize; progressNotifier.Completed = completed; } } } #endregion #region pola private string fileName; private char separator; private IProgressNotifier progressNotifier; private Transfer transfer; #endregion #region stałe private const int EndOfLineSize = 2; #endregion }
A skoro mamy nową klasę i realizuje ona zakładaną przez nas funkcjonalność, to w następnej części cyklu przystąpimy do przetestowania czy rzeczywiście robi ona to, czego oczekujemy.