Logi aplikacji to jedna z ważniejszych rzeczy w aplikacji, które nie przekładają się bezpośrednio na funkcjonalność — w przypadku awarii, czy też po prostu badając ‘zdrowie’ aplikacji jest to pierwsze miejsce, do którego najprawdopodobniej sięgniesz po informacje. Na rynku jest jednak kilka rozwiązań — dzisiaj pomożemy Ci świadomie wybrać jedno z nich.
Jak działa logger
Podstawową rolą biblioteki do logowania jest przekazanie komunikatu wygenerowanego w kodzie (np. informacji, że jakaś operacja się powiodła) do odpowiedniego miejsca (np. standardowe wyjście konsoli, plik, serwer zdalny), dodając informacje o czasie, poziomie i ew kilka innych pomocniczych. To z pozoru banalne zadanie ma pewien ‘haczyk’ — najczęściej logi trafiają do plików. Zapis do plików (i odczyt z nich) jest jednak ‘najdroższą’ operacją, jaką można wykonać w ramach jednego komputera. Z tego powodu logowanie potencjalnie (a czasem realnie) znacząco spowalnia naszą aplikację, o ile biblioteka nie obsługuje tego w ‘sprytny’ sposób. Ten sprytny sposób to tzw asynchroniczność — sam zapis do pliku jest realizowany w innym wątku, niż właściwe zdarzenie, dzięki czemu nie blokuje go i aplikacja może działać wydajnie. To z kolei generuje dodatkowe problemy (np. poprawna obsługa kolejności logowanych danych, zbieranie ich z różnych obszarów aplikacji itp). Na szczęście większość bibliotek radzi sobie z tym problemem w sposób ‘przezroczysty’ dla użytkowników (programistów), co pozwala nam skupić się na właściwej aplikacji :)
Nazwa loggera i hierarchia loggerów
Wszystkie loggery mają swoją nazwę, którą nadajemy w momencie tworzenia obiektów — często jest to nazwa klasy (pełna, czyli razem z nazwą pakietu). Wynika to z faktu, że nazwa jest w loggerach znacząca i określa hierarchię, wg której są one konfigurowane. Dla przykładu, logger o nazwie ‘pl.kobietydokodu.koty.KotService’ należy do hierarchii ‘pl’, ‘pl.kobietydokodu’, ale nie do ‘org.springframework’. Ponieważ dodatkowo dla każdej hierarchii możemy stosować osobną konfigurację, pozwala nam to na bardzo dużą elastyczność.
Możemy np. przekierować do pliku tylko logi, ktore generuje nasza aplikacja — dzięki temu nie będzie tam ‘śmieci’ z różnych frameworków. Możemy też przekierować logi z różnych warstw aplikacji do różnych plików, część zignorować, logować je na różnych poziomach z różną ilością detali — taka struktura daje po prostu ogrom możliwości w zakresie konfiguracji. Nie zmienia to faktu że najczęściej loguje się wszystko w jedno miejsce — zaawansowana konfiguracja jest przydatna w aplikacjach z ogromnym ruchem, które generują np. wiele gigabajtów logów dziennie.
Hierarchia wynikająca z nazwy loggera to główny powód, dla którego loggery są tworzone w konkretnych obiektach osobno, a nie np. wstrzykiwane za pomocą kontenera IoC (np. Springa).
Poziomy logowania
Właściwie każda biblioteka oferuje różne tzw. poziomy logowania. Poziom to nic innego jak ‘ważność’ danej wiadomości, często używanym schematem są opcje: FATAL, ERROR, WARNING, INFO, DEBUG, TRACE w różnych wariantach (np. brak poziomu FATAL czy TRACE). Niektóre biblioteki pozwalają na definiowanie własnych poziomów (np. Log4J). W tym zakresie wyróżnia się pakiet java.util.logging (omówiony poniżej), który definiuje zupełnie odmienne poziomy: SEVERE, WARNING, INFO, CONFIG, FINE, FINER, FINEST.
Poza informacją w logu na temat poziomu (opcjonalnie, w zależności od konfiguracji), używany jest on także w konfiguracji — np. na środowisku produkcyjnym wyłączając wszystkie logi ‘niższe’ niż INFO. Dzięki temu nie trzeba dodawać warunków i tymczasowych logów na czas rozwijania lub debugowania konkretnego fragmentu kodu, wystarczy skonfigurować odpowiednio loggery i poprawnie używać poziomów.
Porównywanie
Ponieważ biblioteki te realizują bardzo specyficzną i względnie prostą funkcję, ciężko jest porównywać je ze względu na dostępne funkcje. Dlatego ograniczymy się pokazania, jak utworzyć odpowiednie obiekty w każdej z tych bibliotek oraz wypisania problemów, z jakimi możesz się spotkać pracując z konkretnymi bibliotekami.
Wybierając rozwiązanie do Twojego projektu, zwróć uwagę przede wszystkim na to, czy będziesz musiała dokonywać wielu zmian w aplikacji (oraz czy ew możesz je rozwiązać za pomocą Slf4J), czy któryś z problemów może być przeszkodą w Twoim systemie oraz to, która jest najpopularniejsza (co przekłada się na najlepszy support społeczności) — w podsumowaniu znajdziesz też naszą rekomendację w tym zakresie :)
Java Logging API (java.util.logging; zwana też JUL)
Pakiet java.util.logging jest częścią Javy SE, aby go użyć nie jest konieczne dodawanie żadnych dependency do projektu. Pakiet ten zapewnia bardzo prosty ‘szkielet’, w którym większość specyficznych elementów (np. inne formatowanie, integracje z systemem zbierania logów itp) musimy samodzielnie zaprogramować. W kwestii różnic pomiędzy tą biblioteką a innymi, JUL loguje domyślnie na standardowe wyjście błędów, podczas gdy częściej logi zbieramy ze standardowego wyjścia (zwykłego). Posiada także niespotykane w innych implementacjach poziomy logowania (np. ‘CONFIG’, ‘FINE’, ‘FINER’, które zastępują znane nam ‘DEBUG’).
Inicjowanie loggera wygląda następująco:
protected final Logger log = Logger.getLogger(getClass().getName()); //java.util.logging.Logger
Z kolei jego użycie:
log.log(Level.INFO, "Przetwarzam {0} elementów", lista.size());
Zaletą jest niewątpliwie możliwość parametryzacji treści loga oraz fakt, że jest wbudowany w Javę. Jest to jednak framework bardzo prosty, czasem okreąlany wręcz naiwnym w kwestii implementacji, i może przysporzyć problemów w większych projektach, które intensywnie korzystają z loga. Jest też mało wygodny w użyciu — każdy log musimy poprzedzić enumem z poziomem logowania — inne biblioteki zapewniają osobne metody, co jest czytelniejsze. Raczej niezalecany w produkcyjnych aplikacjach.
Log4J (strona projektu)
Log4J mimo upływu czasu pozostaje jedną z najpopularniejszych bibliotek do logowania — całkiem słusznie, ponieważ jest naprawdę solidną implementacją z wieloma możliwościami. Obecnie projekt nie jest rozwijany, do wszelkich nowych zastosowań zaleca się użycie Logback — biblioteki tworzonej przez te same osoby, które pracowały przy Log4J.
Inicjalizacja loggera wygląda następująco:
protected final Logger log = Logger.getLogger(getClass()); //org.apache.log4j.Logger;
Przykład jego użycia w kodzie:
log.error("Coś poszło nie tak : " + kodBledu);
Log4J nie zadziała bez konfiguracji (tzn. będzie można z niego korzystać, ale żadne informacje nie pojawią się na konsoli lub w pliku). Konfiguracji można dokonywać programowo, za pomocą pliku XML oraz za pomocą pliku .properties, co jest najpopularniejszą metodą. Przykładowy plik konfiguracji (wiadomości na poziomie INFO, wypisywane tylko na konsolę) wygląda następująco:
log4j.rootLogger=INFO, stdout
log4j.appender.stdout=org.apache.log4j.ConsoleAppender
log4j.appender.stdout.Target=System.out
log4j.appender.stdout.layout=org.apache.log4j.PatternLayout
log4j.appender.stdout.layout.ConversionPattern=%d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L - %m%n
Sama biblioteka w obecnej wersji nie ma znanych problemów, które byłyby istotnym przeciwwskazaniem do jej stosowania. Następca, Logback, używa nieco innej architektury i nowszych rozwiązań z API Javy, przez co jest wydajniejszy i bardziej elastyczny. Migracja z Log4J do Logback nie musi być jednak priorytetem w projekcie.
Logback (strona projektu)
Logback to następca Log4J, rozwijany przez z grubsza tą samą grupę osób. Zdecydowano się na nową nazwę z uwagi na zmianę koncepcji — Logback został napisany od nowa, w zupełnie inny sposób niż Log4J, przez co jest minimalnie szybszy i bardziej elastyczny (szczegóły dlaczego warto go używać znajdziesz w dokumentacji Logback). Jedną ze zmian jest także fakt, że bez żadnej konfiguracji przyjmowana jest konfiguracja domyślna — czyli wypisywanie wszystkich logów na standardowe wyjście konsoli.
Inicjowanie logera wygląda następująco:
protected final Logger log = LoggerFactory.getLogger(getClass());
Uwaga! W powyższym fragmencie interfejsy i klasy pochodzą z pakietu org.slf4j ! Wynika to z faktu, że Logback bazuje na Slf4j i nie definiuje własnych, dodatkowych iterfejsów!
Z kolei jego wywołanie:
log.debug("Wykonuje operację ABCDE");
Niestandardową konfigurację można zdefiniować w plikach logback.xml oraz logback-test.xml (oba mają identyczną strukturę), przykładowa zawartość takiego pliku (zawartość pobrana z dokumentacji logback):
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<!-- encoders are assigned the type
ch.qos.logback.classic.encoder.PatternLayoutEncoder by default -->
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="debug">
<appender-ref ref="STDOUT" />
</root>
</configuration>
Logback pozwala także na przekazanie ścieżki do pliku konfiguracyjnego jako zmienną systemową logback.configurationFile, co może być przydatne, jeśli chcemy np. na serwerach produkcyjnych logować w określony sposób, niezależnie od ustawień aplikacji.
tinylog (strona projektu)
Tinylog to taka opcja ‘minimum’ jeśli chodzi o logowanie — ideą twórców było to, że skoro większość projektów nie korzysta z zaawansowanych funkcji loggerów, ich hierarchii itp, to lepiej stworzyć bibliotekę, która będzie ich pozbawiona, a przez to będzie lżejsza. Na stronie znajdziemy też porównanie wydajności z innymi bibliotekami — jest ono oczywiście prawdziwe, ale należy mieć świadomość, że wzrost wydajności jest okupiony funkcjonalnością i elastycznością. Biblioteka ta nie wymaga tworzenia loggera, wszystkie jej metody są statyczne, przykładowe użycie wygląda następująco:
Logger.info("Witaj świecie!");
Bibliotekę można konfigurować za pomocą zmiennych systemowych lub programowo, domyślnie wypisuje ona wszystkie logi na poziomie info i wyższych na standardowe wyjście konsoli.
Z naszej strony nie polecamy za bardzo takiego podejścia — elastyczność, jaką dają inne narzędzia jest zdecydowanie warta ich potencjalnego kosztu w kwestii wydajności, szczególnie w przypadku projektów, które się rozwijają lub musza być utrzymywane. Teoretycznie posiada ona integrację z slf4j, ale korzystając z niej w ten sposób tracimy ‘minimalizm’, w który ta biblioteka celuje (i równie dobrze można wykorzystać Logback, który także zadziała w podobny sposób bez konfiguracji)
W niektórych zastosowaniach jednak będzie ona miała sens, w szczególności w systemach wbudowanych czy innych z bardzo ograniczonymi zasobami, dlatego zamieszczamy ją w tym porównaniu — bardziej jako ciekawostkę, z dużym ostrzeżeniem.
Abstrakcje
W tej kategorii mamy trochę mniej reprezentantów — biblioteka te nie zajmują się właściwym logowaniem, a dostarczeniem wspólnego interfejsu dla różnych bibliotek logujących. Dzięki temu zabiegowi, można właściwie dowolnie ‘wymieniać’ biblioteki logujące bez zmiany linijki kodu. Slf4j ma też kilka asów w rękawie, o czym powiemy za chwilę.
Slf4j (strona projektu)
Slf4j to najpopularniejsza obecnie abstrakcja, która jest używana w 40–70% projektów (w zależności od źródła, w które spojrzymy). Powstała jako bezpośrednia odpowiedź na problemy z Apache Commons Logging i jest rozwijana przez developerów związanych z projektem Logback (stanowi ona też podstawę tej biblioteki). Z uwagi na prostą i wygodną obsługę, licencję pozwalającą na dowolne wykorzystanie, właściwie bezproblemowe działanie i pełną dowolność w zakresie biblioteki używanej ‘pod spodem’ Slf4J właściwie nie ma obecnie alternatyw — nie istnieje powszechny problem z tą biblioteką, który nowa biblioteka miałaby rozwiązać ;)
Inicjowanie loggera wygląda następująco:
protected final Logger log = LoggerFactory.getLogger(getClass());
A jego użycie:
log.info("Witaj świecie!");
Gorąco rekomendujemy używanie Slf4j jako fasady do logowania w Twojej aplikacji — poniżej znajdziesz kilka przykładów jak można ją wykorzystać w pełni.
Korzyści
Szablony
Aby rozwiązać częsty problem (patrz poniżej, w sekcji o antywzorcu) Slf4J wprowadził szablony — innymi słowy zamiast samemu budować wiadomość (która potencjalnie może nie zostać nigdzie wyświetlona, a przy okazji tworzenia wywołuje metody toString() różnych obiektów, które z kolei mogą być kosztowne do wywołania), mówimy Slf4J jak ma to zrobić, i robi to za nas! Przykładowo:
log.debug("Duży i skomplikowany obiekt: {} wysłany przez użytkownika {}", duzyObiekt, obiektUzytkownika)
zamieni ‘{}’ na kolejne argumenty przekazane do metody, ale zrobi to tylko jeśli będzie to faktycznie potrzebne (tzn. jeśli wiadomości na tym poziomie są faktycznie logowane). Dzięki temunie musimy się martwić o dodatkowe warunki i wydajność — biblioteka załatwi to za nas.
Adaptery do innych API
Kolejną bardzo przydatną funkcją są adaptery do innych API. Jeśli Twoja aplikacja korzysta już np. z Apache Commons Logging, nie musisz przepisywać całego kodu! Slf4J dostarcza moduł, który ‘udaje’ inną bibliotekę (w tym przypadkuy będzie to Apache Commons Logging) i przekierowuje wszystkie wywołania do Slf4J — dzięki temu bez zmiany linijki kodu możesz wykorzystać wszystkie zalety w potencjalnie dowolnej aplikacji!
Inne
Slf4J ma wielu zwolenników, z których każdy poda kilka kolejnych powodów, dla którego warto użyć właśnie tej biblioteki. Zainteresowanych odsyłamy np. do dyskusji na stackoverflow.
JCL (Jakarta Commons Logging / Apache Commons Logging; strona projektu)
Apache Commons Logging to pierwsza większa próba stworzenia wspólnej fasady dla różnych bibliotek. Z uwagi na sposób implementacji występowały z nią notoryczne problemy (patrz poniżej), przez co została w większości projektów porzucona na korzyść Slf4J.
Inicjowanie loggera wygląda następująco:
protected final Log log = LogFactory.getLog(getClass());
A jego użycie:
log.info("Witaj świecie!");
JCL sam w sobie nie oferował żadnych dodatkowych funkcjonalności, co także przyczyniło się do spadku jego popularności na korzyść Slf4J.
Problemy
Głównym problemem trapiącym JCL jest sposób ‘ładowania’ bibliotek, z których korzysta do faktycznego logowania. W implementacji zdecydowano się na sposób z użyciem classloaderów, co w większych projektach, z dynamicznymi modułami, zaczęło generować problemy i negatywnie wpływało na wydajność. Zainteresowanych odsyłamy do szerszego i bardziej technicznego opisu na stronie http://articles.qos.ch/classloader.html, a my ograniczymy się do powiedzenia, że Slf4J korzysta z innych sposobów, dzięki czemu opisywane problemy go nie dotyczą.
Loggery jako pola statyczne vs pola obiektu
Większość bibliotek pozwala używać klasy, jako nazwy tworzonego loggera. Częstą praktyką jest tworzenie pola statycznego, w którym nazwą loggera jest nazwa klasy.
Powoduje to pewien problem przy dziedziczeniu — klasy dziedziczące po danej klasie, o ile nie zadeklarują własnego loggera, będą korzystały z loggera utworzonego w klasie nadrzędnej, a więc także o nazwie takiej, jak klasa nadrzędna. Z tego powodu ciężko będzie odróżnić jedne wpisy od drugich.
Rozwiązaniem jest tworzenie loggerów jako pól obiektu, a nie statycznych, oraz używanie getClass() zamiast sztywno wpisanej nazwy klasy. Dzięki temu loggery będą zawsze wskazywały prawidłową nazwę (podejście to jest stosowane w przykładach powyżej).
Krótką dyskusję znajdziesz w tym wątku na Stack Overflow. Ogólnie ujmując — użycie pól obiekty (a nie statycznych) jest bardziej intuicyjne w przypadku dziedziczenia, choć pole statyczne bardziej odpowiada idei loggerów (jeden wspólny, współdzielony przez wiele instancji).
Logowanie wybranych informacji, a nie całych obiektów
Dość częstą praktyką jest logowanie całych obiektów zamiast wybranych informacji — przykładowo tworzenie użytkownika powoduje zapisanie informacji do logu ze wszystkimi szczegółami (wywołując metodę toString() obiektu). Ma to dwie potencjalne konsekwencje: po pierwsze, objętość logu rośnie dramatycznie, szczególnie w przypadku kompleksowych obiektów z wieloma poziomami zagnieżdżenia; po drugie rodzi to potencjalne ryzyko wycieku poufnych informacji (np. skrótu hasła, adresu zamieszkania itp) — z założenia logi aplikacji są gorzej chronione niż np. baza danych. W rzeczywistości w logu wystarczająca jest informacja, że został utworzony użytkownik o loginie xyz oraz id 123 — w razie potrzeby wszystkie dodatkowe informacje można pobrać z bazy danych. Czytelność tego typu logu jest także znacząco większa, co przyspiesza analizę problemów i ich rozwiązywanie.
Wytyczne dotyczące polityki logowania w projekcie
Podobnie jak w wielu innych aspektach projektu, jednym z najważniejszych celów zespołu powinna być spójność — w tym wypadku logowanie tego samego rodzaju informacji w ten sam sposób (na tym samym poziomie) w tym samym miejscu (module). Polityka logowania może być wspólna dla całej firmy lub specyficzna dla danego projektu czy nawet modułu projektu ( o ile jest on samodzielną aplikacją / systemem), i powinna zawierać wytyczne:
- co będzie logowane i na każdym poziomie (jakie zdarzenia są na tyle interesujące, aby je logować na danym poziomie)
- jakie informacje mogą trafić do logu (najczęściej jest to ID oraz nazwa wyświetlana; ważne, żeby informacje te pozwalały odnaleźć komplet informacji w bazie danych, ale jednocześnie nie stanowiły ryzyka wycieku wrażliwych informacji)
- jak będą nazywane loggery (czy od nazwy klas, czy może w inny sposób)
- gdzie będą przechowywane logi i przez jaki okres czasu
- gdzie będą emitowane logi (w której warstwie aplikacji)
Zaniedbanie polityki logowania może prowadzić do sytuacji, w której pewne zdarzenia będą logowane wielokrotnie, podczas gdy inne będą pomijane; może dojść do wycieku danych poprzez przejęcie logów lub kluczowe dla rozwiązania problemu informacje mogły zostać przedwcześnie skasowane lub nie wiadomo, gdzie ich szukać. Polityka logowania może przynieść korzyści w każdego rozmiaru projekcie i nie musi to być coś, co wymaga długich dokumentów — najprostsza wersja będzie zawierała tylko kilka punktów, np:
- wszystkie logi nie będące opisem błędu/wyjątku są emitowane w warstwie serwisów
- każdy log może zawierać tylko id obiektu oraz jego nazwę wyświetlaną (o ile nie zawiera ona danych wrażliwych)
- poszczególne poziomy logowania i ich znaczenie:
- fatal — błędy krytyczne, nieobsłużone i blokujące działanie systemu / procesu
- error — błędy, które są obsługiwane i blokują tylko kreśloną część procesu/wycinek funkcjonalności
- warn — błędy, które są obsłużone, a ich wpływ na proces został zniwelowany (np. poprzez ponowienie czynności)
- info — informacje do audytu, zakończenie realizacji ważnego procesu
- debug — informacje developerskie, np. pełne obiekty zamiast samych id (patrz: sekcja o podwójnych logach)
Te proste reguły wystarczą, aby znacznie polepszyć czytelność logów w projekcie i usprawnić ich analizę.
Podwójne logi, na różnych poziomach
Logi aplikacji powinny być zarazem pełne (informować o wszystkich ważnych zdarzeniach, pozwalając na ich odtworzenie), jak i czytelne (możliwie zwięzłe, zachowując możliwość manualnej i programowej analizy). Niełatwe jest logowanie w zgodzie z obiema tymi zasadami, ale jest na to rada — podwójne logi.
Idea jest dość prosta — logujemy tylko najważniejsze informacje na poziomie info (np. “Zamówienie 1234 zostało utworzone”), a na poziomie debug jednocześnie logujemy wszystkie detale. Logi te mogą trafiać w jedno miejsce (np. na konsole, z założeniem, że są one używane tylko podczas rozwoju aplikacji) lub możemy mieć np. dwa pliki logów — jeden logujący ‘wszystko’ (wraz z komunikatami na poziomie debug), oraz drugi logujący tylko ‘ważne’ rzeczy (np. na poziomie info lub warning). Analize można wtedy rozpocząć od tego ‘którszego’ logu, po czym znajdując interesujące nas elementy odnaleźć odpowiadające im fragmenty w ‘pełnym’ logu wraz z kompletem informacji.
To podejście wymaga jednak uwagi — należy zwrócić szczególną uwagę, aby do logów nie trafiły dane wrażliwe — grozi to wyciekiem danych, jeśli na środowisku produkcyjnym przypadkowo będziemy zapisywali także te logi z pełnymi informacjami.
Antywzorzec if ( logger.isXXXEnabled() )
W projektach korzystających bezpośrednio z log4j czy JUL często możemy spotkać poniższe ‘koszmarki’:
if (log.isDebugEnabled()) {
log.debug("Jakieś info o obiekcie: " + obiekt);
}
Wynikają one z prostego problemu — nawet, jeśli poziom debug jest nieaktywny, bez warunku if dookoła wywołana zostanie metoda toString() obiektu, która może być kosztowna (pod względem czasowym lub pamięciowym). Poza faktem, że kod ten wygląda tragicznie i jest prawie nietestowalny, często programiści bez zastanowienia wstawiają te konstrukcje nawet, jeśli logowany jest prosty ciąg znaków (z niewiedzy lub lenistwa i tumiwisizmu).
Lepszym podejściem jest użycie abstrakcji, np. Slf4J i wykorzystanie szablonów — dzięki temu metoda toString zostanie wywołana tylko i wyłącznie kiedy będzie taka potrzeba. Powyższy kod z użyciem Slf4J wyglądałby następująco:
log.debug("Jakieś info o obiekcie: {}", obiekt);
Lombok
Pamiętaj, że każdy z tych loggerów ma odpowiednią adnotację w Lomboku (jeśli nie wiesz co to jest, zachęcamy do zapoznania się z naszym krótkim tutorialem). Dzięki temu nie będziesz musiała pamiętać, jak inicjować dany logger!
Podsumowanie
Pisząc aplikację staraj się nie uzależniać kodu od konkretnej implementacji / wybranej biblioteki. Właśnie dlatego zachęcamy do korzystania z abstrakcji takich jak Slf4j — zapewniają one spójne API dla różnych bibliotek, zarówno obecnych, jak i potencjalnie tych, które powstaną w przyszłości. Dzięki temu w sytuacji, kiedy będziesz potrzebowała funkcji z innej niż używana dotychczas biblioteki do logowania, Twój kod się nie zmieni — zmieni się jedynie konfiguracja (a najprawdopodobniej tylko zależność).