Dzisiaj pierwsze zadanie programistyczne — dla początkujących mamy sposób na przekonanie się do pewnych, czasem nieciekawych, czynności, a zaawansowanych skłaniamy do refleksji nad czymś, nad czym pewnie dawno się nie zastanawiałaś. Gotowa?
Wyzwanie dla początkujących
Testowanie kodu to PODSTAWA. Bez niej programowanie jest jak wróżenie z fusów — a nuż napisany przez nas kod zadziała. Choć ja bardziej trzymałabym się wersji, że póki nie ma testów, to kod nie działa ;) No ale, z różnych powodów nie każdy lubi, nie każdy umie, nie każdy w końcu je pisze. Jako początkujący powinieneś jednak wyrobić sobie nawyk: kod bez pokrycia testami to nie jest kod gotowy!
Jak to zrobić najłatwiej? Skorzystać z podejścia Test Driven Development (TDD) i pisanie swojego kodu zaczynać od testów. Taka praktyka ma jeszcze jeden super plus: w ten sposób możesz szybko i prosto rozbić skomplikowaną funkcjonalność na malutkie kroczki.
Więcej o tym, jak działa TDD przeczytasz w naszym wpisie na ten temat.
ZADANIE:
Korzystając z TDD zaimplementuj rozwiązanie następującego problemu: weźKanapkę (getSandwich)
Kanapka składa się z dwóch kawałków chleba i czegoś pomiędzy ;) Zwróć Stringa, który znajduje się pomiędzy pierwszym i ostatnim “chlebem” w danym Stringu, albo zwróć pusty String “” jeśli kanapka nie ma w sobie dwóch kawałków “chleba”.
Przyklady:
weźKanapkę("chlebjajkochleb") → zwraca "jajko"
weźKanapkę("xxchlebszynkachlebyy") → zwraca "szynka"
weźKanapkę("xxchlebyy") → zwraca ""
PS. Używanie polskich nazw metod i zmiennych nie jest dobrą praktyką, zostały one jednak przez nas zastosowane by ułatwić Ci zrozumienie przykładu. W rozwiązaniu prosimy, abyś trzymała się konwencji i używała angielskich nazw :)
Nasza odpowiedź
Rozwiązanie: https://gist.github.com/apietras/ce299f08c4d43e34f566e8d9741bc5df
Testy: https://gist.github.com/apietras/7df4437ab0b7da2fc13aaf5f57cb79de
Jak widzicie moje rozwiazanie to 3 metody, wykorzystujące różne możliwości Javy ;) Po pierwsze, Pattern, po drugie, indexOf(), a po trzecie split().
Jakie są korzyści poszczególnych rozwiązań?
Użycie klasy Pattern (czy ogólnie wyrażeń regularnych) to zdecydowanie najprostszy i najbardziej elegancki sposób. Kod jest krótki, czytelny i nie ma wątpliwości, co się w nim dzieje. Wadą tego rozwiązania jest to, że jest ono stosunkowo obciążające (analiza wyrażenia regularnego może być pracochłonna dla komputera — o czym boleśnie przekonał się kiedyś sam StackOverflow ;) )
Użycie indexOf to optymalny sposób, ale też bardzo mało elastyczny — nawet prosta zmiana warunków zadania mogłaby wymagać wielu zmian w kodzie. W kodzie zaczynają się pojawiać magiczne liczby — np. tutaj musimy odjąć długość ‘chleba’, tam dodać ‘1′ żeby uzyskać właściwe liczby… Rozwiązanie nadal poprawne, ale czytając je za kilka miesięcy możesz spędzić chwilę odszyfrowując, co znaczy ta ‘5′, a skąd jest ta ‘1’.
Skorzystanie z metody split() dodałam bardziej dla kompletności niż jako faktyczne rozwiązanie, które polecam ;) Teoretycznie jest to możliwe, ale kod nie jest zbyt czytelny i wymaga od nas kilku magicznych tricków — np. zapewnienie, że split() zwróci także puste stringi na początku i końcu, na czym oparta jest dalsza część implementacji. Dodatkowo ponieważ metoda split() przyjmuje wyrażenie regularne, a nie po prostu ciąg znaków, musimy uważać co jest naszym ‘chlebem’. Konieczne jest też ‘odbudowanie’ wyniku na końcu, co także nie poprawia czytelności. Co więcej — ‘pod spodem’ metoda ta korzysta z wyrażeń regularnych, więc nie będzie w żaden sposób szybsza od pierwszego rozwiązania.
Moje testy pokrywają nie tylko te 3 przypadki, które były pokazane jako example. Tworząc je starałam się myśleć: co jeszcze, może być argumentem dla tej metody. Stąd mamy testy dla pustego Stringa, kanapki z jednym chlebem.
Uwagi co do samego kodu. Przeglądając Wasze rozwiązania chcielibyśmy zwrócić uwagę na kilka kwestii związanych z Clean Code:
- stosujcie nazwy, które coś mówią! Wiemy, że to tylko ćwiczenie, ale czemu nie nazywać zmiennych czy metod/funkcji w sposób, gdzie jasno wiemy co będą robić (również mówimy tu o metodach testowych. Często spotykaną praktyką jest zaczynanie ich nazwy od słowa test lub should, a dalsza jej część powinna starać się tłumaczyć sens testu),
- dobrą praktyką jest nazywanie pliku z testami tak samo jak pliku, który jest nimi testowany z dopiskiem Test na końcu. czyli klasę Sandwich testujemy klasą SandwichTest
- oczekiwany wynik i zmienna jaką przyjmuje testowana metoda powinny być wyciągnięte do zmiennych lokalnych metody — dzięki temu w łatwy sposób zmodyfikujesz test, jeśli będzie taka potrzeba (oraz napiszesz kolejne, po prostu zmieniając te dwa parametry),
- warto korzystać ze stałych, u mnie wyciągnęłam w ten sposób „chleb”, ponownie ułatwia to utrzymanie kodu
- zazwyczaj nie ma sensu robić commita z zakomentowanym kodem — wprowadza to zamieszanie, a potem taki kod jest trochę cmentarzem — nikt nie chce go usunąć, bo a nuż to się kiedyś przyda i wisi sobie niewiadomo ile ;)
Wyzwanie dla praktyków
Logowanie to zagadnienie, które przewija się w praktycznie każdej aplikacji — od najmniejszej , pierwszej aplikacji webowej po największe aplikacje rozproszone. Choć zagadnienie wydaje się błahe, z całą pewnością takie nie jest — szczególnie w przypadku systemów wielowątkowych, współbieżnych lub obsługujących dużą ilość zapytań. Głównie z tego powodu nawet programiści szczycący się ‘pisaniem wszystkiego od zera’ korzystają ze sprawdzonych bibliotek — w przypadku Javy Log4J czy Logback to obecnie najpopularniejsze z nich (więcej o nich możesz przeczytać w jednym z naszych wpisów).
Twoim zadaniem jest napisanie własnej biblioteki do logowania. Oczywiście nie będzie ona tak zaawansowana, jak dostępne na rynku rozwiązania, ale spróbuj osiągnąć podstawowe funkcjonalności — Twoja ‘biblioteka’ powinna zapewniać:
- możliwość tworzenia osobnych loggerów (w przypadku języków obiektowych) lub przekazywania nazwy loggera (w przypadku pozostałych języków)
- możliwość logowania na kilku poziomach (INFO, WARN, ERROR)
- możliwość używania parametrów w logowanym tekście (w dowolny sposób — możesz wykorzystać istniejące w języku narzędzia)
- biblioteka ma wypisywać logi na konsolę, w formacie {czas} {nazwa} [{poziom}]: {wiadomość}
Po jej zaimplementowaniu zastanów się nad poniższymi kwestiami:
- Jak biblioteka zachowa się w aplikacji wielowątkowej (jeśli język, którego używasz, wspiera takie aplikacje)
- Co jest największą słabością tej biblioteki i jak można ją wyeliminować?
- Czy Twoja biblioteka w jakiś sposób wpłynie na działanie i wydajność aplikacji?
- W czym jest lepsza/gorsza od istniejących rozwiązań?
- Jakie zmiany byłyby potrzebne, jeśli zamiast do konsoli chciałbyś zapisywać logi do pliku?
- Jakie decyzje podjęłaś w trakcie implementacji? Dlaczego wybrałaś tą a nie inną drogę? Czy zmieniłabyś swoją decyzję?
- Jak przetestowałabyś swoją implementację?
Pamiętaj, aby ukończyć zadanie w mniej więcej 40–50 minut — nie chodzi o to, żeby spędzić nad nim cały dzień i stworzyć najlepszy loger świata, ale o to aby zauważyć i zastanowić się nad pewnymi problemami, które na codzień biblioteki rozwiązują za nas. Nie ma też problemu w tym, żeby implementacja była np. niewydajna itp — najważniejsze, żebyś zauważyła te problemy i zastanowił się nad ich przyczyną i możliwymi rozwiązaniami.
Pytanie, z jakim Cię dzisiaj zostawiamy to z jakiej biblioteki korzystasz najczęściej i nie wyobrażasz sobie życia bez niej? Czy wiesz jak ona działa? Jak Ty byś ją zaimplementowała?
Nasza odpowiedź
Moje rozwiązanie zadania znajdziesz pod adresem https://gist.github.com/jderda/4a8dba539d3471e1a5fec08128705c8a .
Jedno z głównych założeń, które przyjąłem podczas tworzenia tego rozwiązania było wsparcie dla aplikacji wielowątkowych (np. aplikacji webowych) oraz względna wydajność (szczególnie pod kątem pamięci). Obie te rzeczy wynikają z moich doświadczeń — kilkukrotnie w przeszłości pisząc na szybko jakiś PoC zamiast normalnego loggera lub zapisu do pliku wypisywałem tekst na konsoli. Jak to się dzieje z każdym PoC, po drobnych poprawkach na szybko robionych przez inną osobę trafiał on na produkcję, po czym okazywało się, że ‚logi’ są ze sobą poprzeplatane i zupełnie nieczytelne. Druga kwestia była problemem, z jakim się spotkałem w jednej z poprzednich prac — wąskim gardłem aplikacji były właśnie logi (zapisywane w zdecydowanie nadmiernej ilości, przy użyciu biblioteki ‚wynalezionej’ przez poprzednich programistów tej aplikacji).
Całość jest dość prosta — mamy kolejkę w pamięci (PriorityBlockingQueue), która przechowuje nam wpisy ‚do wypisania’ oraz wątek, który pobiera kolejne wpisy z tej kolejki i wypisuje je na konsole. Wybrałem tutaj najprostszą i najsensowniejszą drogę jeśli chodzi o wybór struktury danych:
- PriorityBlockingQueue jest bezpieczna w środowisku wielowątkowym (mówi o tym dokumentacja API)
- Nie potrzebuje logiki związanej ze sprawdzaniem, czy kolejka jest pusta i/lub czekaniem na kolejny element — ponieważ używamy BlockingQueue, wywołanie metody ‚pobierz element’ samo poczeka, jeśli taki element nie jest jeszcze dostępny
- Ponieważ jest to też kolejka priorytetowa, rozwiązuje nam z automatu wszelkie ewentualne sytuacje ‚wyścigu’ (ang. race condition) — gdybyśmy użyli zwykłej listy, teoretycznie możliwe jest, że wpisy nie wyświetlały by się w kolejności (oczywiście mówimy tutaj o skrajnych przypadkach i wpisach z praktycznie tego samego momentu)
Reszta kodu to właściwie tylko metody do uproszczonego tworzenia nowych obiektów loga — obsługa parametrów opiera się na Javowej składni metod ze zmienną ilością argumentów, a ich ‚dołaczanie’ do ciągu znaków na metodzie String.format (działa ona niemal identycznie jak metoda sprintf w językach C‑podobnych).
Odpowiedzmy sobie na pytania postawione w zadaniu:
Jak biblioteka zachowa się w aplikacji wielowątkowej (jeśli język, którego używasz, wspiera takie aplikacje)
Dzięki użyciu PriorityBlockingQueue, aplikacja może być używana w środowisku wielowątkowym. Dodawanie wpisów logu jest nieblokujące, dzięki czemu nie powinna wpłynąć negatywnie na wydajność aplikacji.
Co jest największą słabością tej biblioteki i jak można ją wyeliminować?
Obecnie biblioteka uniemożliwia automatyczne zamknięcie JVM (jako że posiada działający wątek) — konieczne byłoby dodanie kodu pozwalającego na obsługę ‚powiadomień’ o kończeniu działania aplikacji lub zrezygnowanie z zapisywania w tle za pomocą jednego wątku i używanie zadań do pojedynczych wpisów logu.
Aplikacja ta może być też obciążająca dla procesora — założeniem było to, że pamięć jest cenniejsza od mocy obliczeniowej. Aby zmniejszyć obciążenie procesora można zrezygnować z formatowania z użyciem metody String.format na rzecz prostego ‚podstawiania’ (znanego np. z Slf4j).
W przypadku dużej ilości logów, w pamięci pozostanie sporo obiektów LogItem, używanych tylko przez chwilę, powodując częste uruchamianie Garbage Collectora, w efekcie spowalniając aplikację. Ten problem można by wyeliminować korzystając z wzorca Pyłek (po prostu ponownie używając tych samych obiektów do przechowywania innych informacji)
Czy Twoja biblioteka w jakiś sposób wpłynie na działanie i wydajność aplikacji?
Biblioteka, w szczególności w przypadku logowania dużej ilości informacji, może zwiększyć wykorzystanie CPU. W takiej sytuacji problemem będzie też tworzenie dużej ilości obiektów w pamięci, powodując częste uruchamianie Garbage Collectora. Dla aplikacji o takim profilu będzie obciążająca i spowalniająca.
W czym jest lepsza/gorsza od istniejących rozwiązań?
Biblioteka ta nie ma przewag nad większością dostępnych implementacji. Jest minimalnie szybsza w specyficznych przypadkach od np. Log4J, ale wynika to z małej ilości funkcji i odpowiednio dobranych warunków testowych. Obszary, w których jest gorsza od dostępnych rozwiązań:
- możliwości konfiguracyjne (np. zapis do pliku, różne wzorce logów itp, konfiguracja poziomów logowania)
- optymalizacje (np. pomijanie niektórych czynności, jeśli dany poziom logowania jest ignorowany)
- wykorzystanie pamięci
- ogólna wydajność i wpływ na aplikację
Jakie zmiany byłyby potrzebne, jeśli zamiast do konsoli chciałbyś zapisywać logi do pliku?
Ponieważ kwestię współbieżności rozwiązaliśmy na wcześniejszym etapie, wystarczy zamiana wywołania System.out na zapis do pliku. Plik może pozostawać otwarty przez dłuższy czas z uwagi na to, że zawsze będzie tylko jeden wątek zapisujący logi w obecnym rozwiązaniu.
Jakie decyzje podjęłaś w trakcie implementacji? Dlaczego wybrałaś tą a nie inną drogę? Czy zmieniłabyś swoją decyzję?
Podczas implementacji podjąłem kilka istotnych decyzji:
- oparcie implementacji o istniejącą klasę PriorityBlockingQueue, co pozwoliło na znaczne uproszczenie całości — zdecydowanie nie zmieniłbym tej decyzji
- użycie statycznej kolejki z jednym wątkiem, który stale zapisuje/czeka na wpisy — wybrane rozwiązanie ma swoje zalety (nie musimy się martwić o różne instancje loggerów, sytuacje wyścigu itp), ale niesie za sobą też pewne konsekwencje (np. jeśli logger jest używany tylko w przypadku jednego obiektu, który żyje krótko w pamięci, wszystkie zasoby takie jak wątek, kolejka itp nie zostaną usunięte z pamięci aż do zamknięcia JVM) — najprawdopodobniej rozważyłbym zmianę tego rozwiązania na inne (np. pojedyncze zadania z wątkiem tworzonym tylko w razie potrzeby), porównał działanie różnych opcji i wybrał najlepszą z punktu widzenia projektu (lub stworzył konfigurację wokół tego?)
- statyczna metoda getLogger vs konstruktor — metoda statyczna pozwala na zmianę logiki i drobne optymalizacje w przyszłości (np. jeśli mamy już utworzony logger o danej nazwie, możemy go po prostu wykorzystać ponownie zamiast tworzyć nowy), użycie konstruktora powodowałoby, że każde stworzenie loggera powoduje utworzenie nowego obiektu (a trzeba mieć na uwadze, że użytkownicy najprawdopodobniej nie zawsze zastosują się do Twoich wskazówek i dokumentacji)
Jak przetestowałabyś swoją implementację?
Najważniejsza kwestia to ‚jak sprawdzić, czy na konsolę został wypisany odpowiedni tekst’? Na szczęście Java jako jeden z niewielu języków pozwala na ‚przekierowanie’ standardowego wyjścia w inne miejsce lub po prostu przejęcie go (zainteresowanych odsyłam do Stack Overflow, gdzie jest przykład jak można to osiągnąć). W przypadku innych języków pozostaje nam albo dołączenie logiki pozwalającej przekierować logi zamiast do konsoli to do zmiennej, albo osiągnięcie tego samego poprzez polimorfizm. W niektórych jezykach mamy też metody na ‚obejście’ tego problemu (np. w PHP ma to nazwę output buffering).
Druga kwestia to jakie przypadki testowe należy rozważyć, mi do głowy przyszły następujące:
- użycie loggera żeby zapisać 10 wiadomości
- użycie loggera żeby zapisać 10 tysięcy wiadomości
- użycie loggera współbieżnie w 2 wątkach (aby możliwie zasymulować sytuację wyścigu) — uwaga, ten test jest niedeterministyczny!
- stworzenie kilku loggerów o innych nazwach i użycie ich do zapisania określonej wiadomości
Pamiętaj, że nowe zadania będą się pojawiać codziennie o godzinie 11. Rozwiązania będziemy umieszczać pod zadaniami kolejnego dnia o godzinie 18. Nie zapomnij podzielić się swoimi odpowiedziami i przemyśleniami na wydarzeniu na facebooku, a jak masz ochotę to też w komentarzu ;)!
Linki do wszystkich zadań znajdziesz w innym wpisie na naszym blogu. Powodzenia!