Przejdź do głównej zawartości

Pułapki logowania w Javie

Czy spotkałeś kiedyś się z kodem takim jak poniższy?
if (logger.isDebugEnabled()) {
    logger.debug("Request: " + requestObject);
}

Ja tak. Jakiś czas temu napotkałem na mnóstwo takich fragmentów w kodzie, który analizowałem. Zastanowiło mnie po co została wykorzystana tutaj instrukcja warunkowa - a jako, że if-y uwazam za zło konieczne postanowiłem to zbadać. Czy nie do tego właśnie służą biblioteki do logowania i udostępniane przez nie metody jak debug żeby właśnie sterowane konfiguracją decydowały co zalogować a co nie? Co więcej, okazało się, że instrukcję warunkową wprowadził ktoś w ramach większej akcji. I jak się okazuje, prawdopodobnie znacznie zwiększył dzieki temu wydajność aplikacji.

W dzisiejszym wpisie postaram się wyjaśnić jak korzystać z loggera aby nie zaszkodziło to wydajności.

Po co if-y?

Na początek wyjaśnić trzeba czemu ten if tak na prawdę służy. Korzystamy z biblioteki do logowania i spodziewamy się, że wykorzystujac wybraną metodę, zostanie wykonana ona wyłącznie gdy wybrany poziom logowania jest aktualnie włączony. Czyli dla podanego na początku przykładu byłyby to poziomy DEBUG lub TRACE. I tak się dzieje. Można jednak zapomnieć o tym co dzieje się zanim zostanie wywołana metoda. A dzieje się coś co możemy nazwać ewaluacją parametrów. W javie operator + wymaga aby oba jego argumenty były tego samego typu. Jeśli zatem jeden jest literałem typu String to JVM podejmie próbę niejawnego rzutowania zmiennej, która jest drugim argumentem. Najczęściej będzie sie to sprowadzać do tego, że zostanie wywołana metoda toString za każdym razem - nawet jeśli dany poziom logowania jest nieaktywny. Jeśli obiekt jest stosunkowo duży, zawiera wiele pól, a metoda toString ma domyślną implementację to koszt jej wykonania może być już zauważalny. I to rozwiązaniem właśnie tego problemu jest wykorzystanie instrukcji warunkowej. Czy można zrobić to lepiej? Owszem.

Korzystajmy z API prawidłowo

Czy można się obyć bez instrukcji warunkowych, których nadmierne stosowanie psuje czytelność i estetykę kodu pisanego w sposób imperatywny (lista instrukcji do wykonania krok po kroku)? W wielu wypadkach można. Wystarczy zapoznać się z API biblioteki z której korzystamy. To jest akurat częsty błąd, który popełniamy - my programiści. Zdarza się nam korzystać z różnych bibliotek zgodnie z intuicją, na zasadzie prób i błędów, bez wcześniejszego zapoznania się z jej przeznaczeniem i interfejsem.

Wróćmy do loggera. Najpopularniejszym API do logowania w świecie javy jest Slf4j. Jest ono implementowane przez co najmniej kilka różnych bibliotek z logback-iem na czele. Oferuje ono możliwość późnej ewaluacji parametrów. Tym samym przedstawiony wcześniej fragment kodu możemy zamienić na:
logger.debug("Request: {}", requestObject);
Jak się okazuje efekt będzie ten sam. Metoda toString nie zostanie wywołana dopóki poziom logowania DEBUG nie będzie aktywny. Warto zauważyć, że nie zawsze można skorzystać z tego rozwiąnia. Chodzi tutaj m.in. o przypadki kiedy parametr wyliczany jest za pomocą metody wywoływanej jawnie. Wtedy prawdopodobnie ciężko będzie uniknąć sprawdzania poziomu logowania. Można również próbować korzystać z różnych rozszerzeń takich jak np. fluent API dostępne w slf4j od wersji 2.0.0, która nie jest jeszcze wersją stabilną (http://www.slf4j.org/manual.html#fluent) lub slf4j-fluent. Nie są to jednak rozwiązania uznawane obecnie za standard.

Na problemy z wczesną ewaluacją parametrów możemy spotkać również w innych wypadkach, niezwiązanych z logowaniem. Kto korzysta ze statycznej analizy kodu za pomocą narzędzi takich jak np Sonar to kojarzy pewnie rulkę o identyfikatorze squid:S2629. Ona często wskaże nam miejsce, które wymaga poprawy.

Test

W celu zobrazowania różnic w czasie wykonania kodu przygotowałem test obrazujący czas wykonania w przypadku prawidłowego wykorzystania api, wczesnej ewaluacji oraz logowania warunkowego. Poniżej kod:

pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>dev.maczkowski</groupId>
    <artifactId>log-level-checking</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <maven.compiler.source>11</maven.compiler.source>
        <maven.compiler.target>11</maven.compiler.target>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.16</version>
        </dependency>
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-api</artifactId>
            <version>1.7.30</version>
        </dependency>
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-simple</artifactId>
            <version>1.7.30</version>
        </dependency>
        <dependency>
            <groupId>org.fissore</groupId>
            <artifactId>slf4j-fluent</artifactId>
            <version>0.13.3</version>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
            <version>3.11</version>
        </dependency>
    </dependencies>
</project>
simplelogger.properties
org.slf4j.simpleLogger.showDateTime=true
org.slf4j.simpleLogger.dateTimeFormat=yyyy-MM-dd HH:mm:ss:SSS Z
org.slf4j.simpleLogger.defaultLogLevel=info
Main.java
import static org.fissore.slf4j.Util.lazy;

import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Random;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import org.apache.commons.lang3.time.DurationFormatUtils;
import org.fissore.slf4j.FluentLogger;
import org.fissore.slf4j.FluentLoggerFactory;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
public class Main {

    private static final FluentLogger FLUENT_LOGGER = FluentLoggerFactory.getLogger(Main.class);

    public static void main(String[] args) {
        List<ExampleData> randomData = Stream.generate(() -> ExampleData.builder()
                .a(new Random().nextDouble())
                .b(new Random().nextDouble())
                .c(new Random().nextDouble())
                .d(new Random().nextDouble())
                .e(new Random().nextDouble())
                .f(new Random().nextDouble())
                .g(new Random().nextDouble())
                .build())
                .limit(5000000)
                .collect(Collectors.toList());

        log.info("Warm up");
        randomData.forEach(data -> log.debug("Log entry: " + data));

        log.info("Start test (eager evaluation, without loglevel checking)");
        LocalDateTime start1 = LocalDateTime.now();
        randomData.forEach(data -> log.debug("Log entry: " + data));
        LocalDateTime stop1 = LocalDateTime.now();
        log.info("Stop test");


        log.info("Start test (eager evaluation, with loglevel checking)");
        LocalDateTime start2 = LocalDateTime.now();
        randomData.forEach(data -> {
            if (log.isDebugEnabled()) {
                log.debug("Log entry: " + data);
            }
        });
        LocalDateTime stop2 = LocalDateTime.now();
        log.info("Stop test");

        log.info("Start test (lazy evaluation)");
        LocalDateTime start3 = LocalDateTime.now();
        randomData.forEach(data -> log.debug("Log entry: {}", data));
        LocalDateTime stop3 = LocalDateTime.now();
        log.info("Stop test");


        FLUENT_LOGGER.info().log("Time1: {}", lazy(() -> formatDuration(start1, stop1)));
        FLUENT_LOGGER.info().log("Time2: {}", lazy(() -> formatDuration(start2, stop2)));
        FLUENT_LOGGER.info().log("Time3: {}", lazy(() -> formatDuration(start3, stop3)));
    }

    private static String formatDuration(LocalDateTime a, LocalDateTime b) {
        return DurationFormatUtils.formatDuration(Duration.between(a, b).toMillis(), "**H:mm:ss:SSS**", true);
    }
}

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
class ExampleData {
    private Double a;
    private Double b;
    private Double c;
    private Double d;
    private Double e;
    private Double f;
    private Double g;
}
Dla leniwych rezultaty z wykonania powyższego kodu:
2021-05-19 20:00:04:864 +0200 [main] INFO Main - Warm up
2021-05-19 20:00:18:652 +0200 [main] INFO Main - Start test (eager evaluation, without loglevel checking)
2021-05-19 20:00:32:791 +0200 [main] INFO Main - Stop test
2021-05-19 20:00:32:791 +0200 [main] INFO Main - Start test (eager evaluation, with loglevel checking)
2021-05-19 20:00:32:885 +0200 [main] INFO Main - Stop test
2021-05-19 20:00:32:886 +0200 [main] INFO Main - Start test (lazy evaluation)
2021-05-19 20:00:33:003 +0200 [main] INFO Main - Stop test
2021-05-19 20:00:33:042 +0200 [main] INFO Main - Time1: **0:00:14:100**
2021-05-19 20:00:33:042 +0200 [main] INFO Main - Time2: **0:00:00:093**
2021-05-19 20:00:33:043 +0200 [main] INFO Main - Time3: **0:00:00:116**
Analizę rezultatu pozostawiam czytelnikowi. Dla jednego zysk może się wydawać niewielki, a dla kogoś innego znaczący.

Komentarze

  1. Fajny wpis, acz polecam przepisać benchmark na JMH. Logowanie przed i po foreachu może wpływać na wyniki.
    Foreach również jest nieco śliski.
    Warmup nie uwzględnia metody isDebugEnabled().

    Przeniesienie na JMH z pewnością rozwieje tutaj wątpliwości, a porównanie benchmarku manualnego z benchmarkiem frameworkowym może być też ciekawym doświadczeniem ;)

    OdpowiedzUsuń
  2. a czy to nie jest tak, że jak metoda będzie dostatecznie często wykorzystywana to zostanie przez JIT zoptymalizowana i wtedy właściwie już różnic w wykonaniu nie będzie?

    OdpowiedzUsuń
    Odpowiedzi
    1. Ciężko mi się wypowiadać na temat JIT-a bo po prostu nie wiem jak on dokładnie działa. Jednak nawet jeśli jest on w stanie zoptymalizować wykonanie metody to nadal trzeba ją wykonać. Przypuszczam, że różnie będzie to się zachowywać w zależności od implementacji danej metody - jeśli opiera ona swoje wyliczenia na wielu zmiennych parametrach to trudno mi sobie wyobrazić sensowna optymalizację.

      Usuń

Prześlij komentarz

Popularne posty z tego bloga

Wzorzec Open Session in View w Spring Boot

Dzisiejszy post będzie z cyklu: "Wtf? Dlaczego to działa?". A dotyczy on pewnego wzorca, którego implementacja jak się okazuje jest we frameworku Spring Boot domyślnie włączona, czego nie wszyscy programiści mogą się spodziewać. Geneza Zaczęło się od tego, że pisałem kolejny test integracyjny do kolejnego kontrolera. Test napisany, uruchamiam, zielono. No i przeglądam sobie jeszcze raz kod zanim wypchnę zmiany do review. I rzuciło mi się w oczy, że zapomniałem dodać adnotacji @Transactional (do tej pory uważałem, że to jest zawsze wymagane). I wtedy zadałem sobie przytoczone na wstępie pytanie. Dlaczego test przeszedł skoro mój serwis dociąga sobie obiekt oznaczony jako LAZY ? Dużo się nagimnastykowałem by wpisać odpowiednie query do Google'a i znaleźć odpowiedź: Open Session in View. Ale o tym za chwilę. Jak działa Hibernate Żeby lepiej zrozumieć opisywane zagadnienie warto przypomnieć jakie kroki w uproszczeniu musi wykonać Hibernate podczas komunikacji z bazą d...

Analiza podatności CVE-2021-22119

W tym wpisie chciałbym przyjrzeć się jednej z podatności bezpieczeństwa, którą odkryłem w jednym z projektów wykorzystując plugin do mavena skanujący zależności - org.owasp:dependency-check-maven . Narzędzie to pomaga wychwycić znane podatności, które dotyczą naszej aplikacji tylko dlatego, że korzystamy z konkretnej wersji zależności (biblioteki, frameworka), która zawiera błąd bezpieczeństwa. Błędy te zaliczają się do kategorii A06 (przed 09.2021 - A09) z listy OWASP TOP 10 ( https://owasp.org/www-project-top-ten/ ). Swoją drogą polecam każdemu przeskanowanie swoich projektów takim skanerem aby zobaczyć jak wiele niebezpieczeńsw pociąga za sobą beztroskie korzystanie z mnóstwa bibliotek. Względne bezpieczeństwo daje nam tylko przypadek spowodowany tym, że akurat nie korzystamy z jakiejś konkretnej funkcjonalności. Tak będzie i w moim przypadku :). CVE-2021-22119 Dokładny opis błędu można znaleźć m.in. tu https://nvd.nist.gov/vuln/detail/CVE-2021-22119 . Dlaczego skupiłem się n...

One-to-one i lazy loading

Hibernate jest frameworkiem, który nie przestaje zaskakiwać. Kryje się w nim mnóstwo tajemnic, pułapek i zagadek. Jedną taką pułapkę postaram się opisać w tym wpisie, a dotyczy ona nieoczywistego na pierwszy rzut oka zachowania adnotacji @OneToOne z parametrem fetchType=LAZY . One-to-one Adnotacja @OneToOne służy w JPA do oznaczania pól encji, które odnoszą się do obiektów będących z nią w relacji jeden-do-jeden. W znormalizowanym schemacie relacyjnej bazy danych oznacza to sytuację, w której mamy do czynienia z dwoma tabelami A oraz B , a jedna z nich posiada kolumnę, której wartości wskazują na klucz identyfikujący krotkę w drugiej z nich. Np: Lazy fetch Adnotacja @OneToOne zawiera również atrybut fetch , który może przyjąć wartości EAGER lub LAZY (domyślnie EAGER ). O ile ustawienie wartości EAGER oznacza, że Hibernate musi pobrać dodatkowy wiersz natychmiast (za pomocą klauzuli JOIN bądź dodatkowego zapytania) to zastosowanie LAZY może ale nie musi spowodowa...