Architektura programów na przykładzie filtrów tekstowych
Cześć!
Jak każdy z nas wie, bardzo ważne jest to aby pisać programy szybko, pisać je poprawnie, przewidywać różne use case’y, korzystać ze wzorców projektowych, utrzymywać porządek w kodzie itp.
Równie ważne jest rozplanowanie pisania programu. Przy małych programach, (tak do kilkudziesięciu klas) to, w jaki sposób go napiszemy, nie będzie miało większego znaczenia. Oczywiście można się silić na jakiś genialny pomysł, ale zazwyczaj zyskamy tylko większą złożoność kodu i zdecydowanie większą jego ilość.
Ale co się dzieje gdy nasz projekt zapowiada się projektem na co najmniej 100 klas? Wtedy warto spojrzeć na projekt od strony bardziej koncepcyjnej – znaleźć jakieś powiązanie, które może stać się fundamentem do zbudowania zestawu reguł koncepcyjnych – architekturą.
Mamy już wiele modeli architektur, np:
- Klient – Serwer – intuicyjna architektura, gdzie jedna strona wysyła żądania, druga je przetwarza i wysyła odpowiedź.
- MVC ( Model-View-Controller ) – taki podział kodu, aby oddzielić logikę biznesową od widoku. Czyli najprościej mówiąc – mamy model – miejsce gdzie nasza logika biznesową implementujemy, controller, czyli sposób routingu żądań oraz widok – miejsce gdzie, dane przetworzone w modelu, które przeszły przez controller, możemy wyświetlić. Gęsto i często stosowana w aplikacjach webowych.
- Warstwowa – podział kodu na warstwy. Np warstwa dostępu do danych, warstwa przetwarzania, warstwa zbierania statystyk, warstwa prezentacji. Chodzi o to żeby informacje, kiedy przechodzą przez warstwy zostały przez każdą z nich przetworzone (np. wzbogacone, zapisane, przekonwertowane). Dane nie mogą pomijać warstw.
- Potokowy – trochę podobna do warstwowej, ale nieco inaczej wizualizowana. Tutaj mamy do czynienia z czymś ala potok, rura. Czyli mamy jakieś wejście do rury i wyjście z niej. Rury możemy łączyć, co powoduje że wyjście jednej rury jest wejściem kolejnej. Każda rura oferuje pojedyńczą usługę, filtr. Łącząc pojedyncze, samodzielne części, tworzymy większy efekt.
Oczywiście nic nie staje na przeszkodzie żeby jakoś łączyć ze sobą te architektury – wszystko zależy od kontekstu.
Przejdźmy do przykładu.
Polecenie:
Napisz program, który będzie oferował możliwość nałożenia filtrów na pliki tekstowe. Program musi zakładać:
- Zliczanie znaków, słów i wierszy
- Numerowanie wierszy pliku
- Usuwanie pustych wierszy
- Zamiana małych liter na duże
- Zamiana dużych liter na małe
- Zamiana znaków tabulacji na określoną liczbę spacji
- Zliczanie krotności wystąpienia podanego ciągu znaków
- Zamiana podanego ciągu znaków na inny podany ciąg
- Usunięcie z pliku podanego ciągu znaków, wypisanie wierszy (oraz ich numerów) zawierających podany ciąg znaków
- Wypisanie n początkowych wierszy pliku
- Wypisanie n końcowych wierszy pliku
Ok, więc pierwsze co musimy określić to język programowania w jakim napiszemy to. Pliki mogą być duże i uruchamiane na różnych systemach, więc możemy użyć np. C++.
Popatrzmy na wymagania. Mamy jakieś zliczanie, jakieś zmiany w pliku, jakieś wypisanie części pliku. Spróbujmy je kategoryzować.
Stworzymy 3 grupy:
- Filters
- Counters
- Views
Filters – filtrami nazwiemy te wymagania, które będa zmieniały coś w pliku.
Counters – wszystko co coś zlicza.
Views – wszystko co daje nam inne spojrzenie na plik.
Teraz stwórzmy dla każdej grupy jednolite API.
Niech klasy konkretnych wymagań rozszerzają klasy abstrakcyjne, implementujac ich metody abstrakcyjne. A więc:
View:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | #ifndef VIEW_H #define VIEW_H #include <string> using namespace std; class View { public: View(); virtual string getView(ifstream& source)=0; virtual ~View(); protected: private: }; #endif // VIEW_H |
Filter:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | #ifndef FILTER_H #define FILTER_H #include <string> using namespace std; class Filter { public: Filter(); virtual void run(ifstream& source )=0; virtual void apply( )=0; ~Filter(); protected: private: }; #endif // FILTER_H |
i Counter:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | #ifndef COUNTER_H #define COUNTER_H #include <string> using namespace std; class Counter { public: Counter(); virtual int getCount (ifstream& source)=0; virtual ~Counter(); protected: private: }; #endif // COUNTER_H |
Ok, w ten sposób zyskaliśmy jednolite API, inne dla każdego typu. Teraz powinniśmy zbudować jakiś interfejs, który pozwoli ładnie budować konkretne typy, wykorzystamy polimorfizm.
Dla każdego typu będzie stworzona prosta fabryka.
Teraz posegregujmy wymagania do grup, tak żeby wiedzieć co fabryki maja produkować:
Views:
- Wypisanie n początkowych wierszy pliku
- Wypisanie n końcowych wierszy pliku
Filters:
- Numerowanie wierszy pliku
- Usuwanie pustych wierszy
- Zamiana małych liter na duże
- Zamiana dużych liter na małe
- Zamiana znaków tabulacji na określoną liczbę spacji
- Zamiana podanego ciągu znaków na inny podany ciąg
- Usunięcie z pliku podanego ciągu znaków, wypisanie wierszy (oraz ich numerów)zawierających podany ciąg znaków
Counters:
- Zliczanie znaków, słów i wierszy
- Zliczanie krotności wystąpienia podanego ciągu znaków
A więc zobaczmy jak będzie wyglądać fabryka dla Filtrow:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | #ifndef FILTERFACTORY_H #define FILTERFACTORY_H #include "filter.h" #include <string> using namespace std; class FilterFactory { public: FilterFactory(); Filter* getFilter(string name); virtual ~FilterFactory(); protected: private: }; #endif // FILTERFACTORY_H |
Korzystamy z tego że każda nasza klasa będzie dziedziczyć albo po Filter, albo Counter albo View. Co daje nam możliwość przypisania instancji takiej do typu abstrakcyjnego. Zobaczmy na implementację FilterFactory:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | #include "../include/filterfactory.h" #include "../include/removeblanlinesfilter.h" #include "../include/addlinesnumerationfilter.h" #include "../include/changelettersizefilter.h" #include "../include/changetabtospacefilter.h" #include "../include/removestringfilter.h" #include "../include/replacestringfilter.h" FilterFactory::FilterFactory() { //ctor } Filter* FilterFactory::getFilter(string name){ if(name=="blank_lines_remove") return new RemoveBlanLinesFilter(); else if (name=="add_lines_numeration") return new addLinesNumerationFilter(); else if (name=="change_letter_size") return new ChangeLetterSizeFilter(); else if (name=="change_tab_to_spaces") return new ChangeTabToSpaceFilter(); else if (name=="remove_string") return new RemoveStringFilter(); else if (name=="replace_string") return new ReplaceStringFilter(); } FilterFactory::~FilterFactory() { //dtor } |
Pokażmy jeszcze jak będzie wyglądać przykładowy filtr:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | #ifndef ADDLINESNUMERATIONFILTER_H #define ADDLINESNUMERATIONFILTER_H #include "filter.h" #include <fstream> class addLinesNumerationFilter : public Filter { public: addLinesNumerationFilter(); void run(ifstream& source ); void apply( ); virtual ~addLinesNumerationFilter(); protected: private: ofstream output; string lines; string itos(int i); }; #endif // ADDLINESNUMERATIONFILTER_H |
i implementacja:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | #include "../include/addlinesnumerationfilter.h" #include <iostream> #include <sstream> addLinesNumerationFilter::addLinesNumerationFilter() { //ctor } string addLinesNumerationFilter::itos(int i) // convert int to string { stringstream s; s << i; return s.str(); } void addLinesNumerationFilter::run(ifstream& source){ string line; int i=1; while ( getline( source, line ) ) { this->lines +=( this->itos(i)+' '+line + '\n'); i++; } } void addLinesNumerationFilter::apply(){ cout << "lines: " << lines; (this->output).open("out.txt"); (this->output) << lines; (this->output).close(); } addLinesNumerationFilter::~addLinesNumerationFilter() { //dtor } |
No i teraz możemy pokazać jak użyć filtru:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | #include <iostream> #include <fstream> #include <string> #define FILE "file.txt" #include "include\filterfactory.h" #include "include\counterfactory.h" #include "include\viewfactory.h" using namespace std; int main() { ifstream source; source.open(FILE); FilterFactory* factory = new FilterFactory(); Filter* f1 = factory->getFilter("add_lines_numeration"); f1->run(source); f1->apply(); source.close(); return 0; } |
No dobra, napisaliśmy się jak głupi, co dostaliśmy w zamian? Solidne, łatwe i jednolite API dla każdej grupy i prosty sposób organizacji instancji dla każdej grupy. Architektura lekko potokowa – każdy z tych wymagań to osobna klasa która dziedziczy po klasie abstrakcyjnej odpowiedniej dla typu polecenia, tzn jeśli jest Counter’em to dziedziczy po klasie Counter, jeśli filtrem to po klasie Filter i jeśli widokiem to po klasie View. Klasy dziedziczące mają konwencję nazw wg szablonu: nazwapoleceniaTyp, czyli np: linescounter removestringfilter firstlinesview. Aby nałożyć wiecej niż jeden filtr, wyjście z pierwszego filtru musi być wejściem do kolejnego.
Bardzo łatwo dodać kolejny filtr – wystarczy napisac klasę, dziedziczącą po odpowiednim typie i dodać ją w fabryce :)
A więc np. żeby nałożyć dwa filtry możemy zrobić to tak:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | #include <iostream> #include <fstream> #include <string> #define FILE "file.txt" #include "include\filterfactory.h" #include "include\counterfactory.h" #include "include\viewfactory.h" using namespace std; int main() { ifstream source; source.open(FILE); FilterFactory* factory = new FilterFactory(); Filter* f1 = factory->getFilter("blank_lines_remove"); Filter* f2 = factory->getFilter("add_lines_numeration"); f1->run(source); f1->apply(); source.close(); source.open("out.txt"); f2->run(source); f2->apply(); return 0; } |
W taki sposób z tekstu:
1 2 3 4 5 6 7 8 9 10 | fsdfjdfkljdfdsf fdsfds fds fds fds fdsfdsfdsaf dsf dsf |
dostaniemy:
1 2 3 4 5 6 7 8 | 1 fsdfjdfkljdfdsf 2 fdsfds 3 fds 4 fds 5 fds 6 fdsfdsfdsaf 7 dsf 8 dsf |
Projekt ten był pisany dla kogoś na zaliczenie jakiegoś przedmiotu na uczelni, ale zrezygnował z juz napisanego projektu (polecam brać z góry pieniądze). A więc wrzuciłem tutaj w celach edukacyjnych ten kod. Patrząc na kod przykładu, widzisz że mamy jakiś plik źródłowy i potem wszystko idzie do innego pliku. Ale ten drugi plik dla każdego kolejnego filtru będzie otwierany ponownie. Czy umiałbyś zmienić to by wyglądało to trochę ładniej?
Zapraszam Cię do klonowania / forkowania repozytorium z tym projektem – GitHub
Pozdrawiam!
Mateusz Mazurek
Odnośnie includów. Możesz je zastąpić bezpośrednim odniesieniem do nazwy pliku dodając ścieżkę do folderu z nagłówkami jako argument kompilatora.
Dzięki za info, będę miał to na uwadze :)
Dlaczego do wyboru case`a używasz ifów? Zmieniejsza to zdecydowanie czytelność kodu, a poruszasz tutaj temat dużych projektów, to jak to ma być, co? : )
W sumie ciężko się nie zgodzić :D konstrukcja switch-case była by odpowiedniejsza.. ;)