:: Enseignements :: ESIPE :: E4INFO :: 2026-2027 :: Java Avancé ::
[LOGO]

My little LogAnalyzer


Programmation fonctionnelle, interface fonctionnelle, lambda, stream et collector.
Le but de ce TP est d'implanter une petite application en ligne de commande, LogAnalyzer, qui lit un fichier de logs et qui permet de filtrer les lignes qui nous intéressent (les erreurs, les avertissements, les lignes liées à l'authentification, ou une combinaison de tout ça), puis de choisir ce que l'on veut faire du résultat (les afficher, ou calculer des statistiques dessus).
Ce qui va nous intéresser tout du long, c'est de voir comment le design du code évolue quand on utilise des lambdas et la programmation fonctionnelle plutôt que des classes mutables et des chaînes de if/else.

Exercice 1 - Maven

Nous allons utiliser Maven avec la configuration, le pom.xml, suivante
<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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>fr.uge.loganalyzer</groupId>
    <artifactId>loganalyzer</artifactId>
    <version>0.0.1-SNAPSHOT</version>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-api</artifactId>
            <version>5.14.4</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.16.0</version>
                <configuration>
                    <release>27</release>
                </configuration>
            </plugin>

            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>3.6.0</version>
            </plugin>
        </plugins>
    </build>
</project>
            
Créer un projet Maven (toujours pas un projet Java). Pour ce TP, le groupId est fr.uge.loganalyzer, l'artefactId est loganalyzer et la version est 0.0.1-SNAPSHOT.

Exercice 2 - Let's hope that my little LogAnalyzer filters like a pro

Notre programme s'appelle en ligne de commande ainsi
                java LogAnalyzer <file> <filter-flags> [action]
            
où <file> est le chemin d'un fichier de logs, où <filter-flags> est une ou plusieurs des options --errors, --warnings, --auth ou --all. Les flags se combinent en "ou" : une ligne est affichée si elle correspond à au moins un des filtres demandés.
Une ligne est considérée comme une erreur (error) si elle contient "[ERROR]", comme un avertissement (warning) si elle contient "[WARN]", et comme liée à l'authentification si elle commence par "USER_" ou "AUTH_".
--all signifie "accepter toutes les lignes", y compris les lignes qui ne correspondent ni à une erreur, ni à un warning, ni à l'authentification.
De plus, on peut spécifier une [action] optionnelle print (par défaut) ou stats. L'action print affiche simplement les lignes retenues. L'action stats affiche, sous forme de dictionnaire trié alphabétiquement par clé, le nombre de lignes retenues pour chaque "niveau" (level) (le texte entre crochets en début de ligne, ou "OTHER" si la ligne n'a pas de niveau).
Voici un exemple de fichier de logs :
2026-08-23 10:15:22 [WARN] 192.168.1.45 DB_CONN - Database query response time > 500ms.
2026-08-23 10:16:01 [ERROR] 10.0.0.12 AUTH_FAIL - Invalid password attempt for user 'bob'.
2026-08-23 10:18:10 [ERROR] 10.0.0.12 AUTH_FAIL - User 'bob' account locked after 3 failed attempts.
2026-08-23 10:20:00 - 172.16.0.5 DISK_SPACE - Low disk space on /var/log (12% remaining).
2026-08-23 10:22:15 [ERROR] 192.168.1.45 DB_CONN - Connection to primary database lost.
            

Les tests JUnit 5 de cet exercice sont LogAnalyzerTest.java.
Attention : ces tests fonctionnent en appelant le main et en vérifiant la sortie, donc ils n'indiquent pas si votre implantation est belle et maintenable, mais uniquement si votre implantation répond au cahier des charges.
Note : comme pour le TP précédent, les tests des questions suivantes utilisent des classes ou méthodes qui n'existent pas encore, donc le code risque de ne pas compiler. Commentez les autres tests, et vous les dé-commenterez au fur et à mesure.

  1. Dans un premier temps, on va partir d'une version naïve de l'analyseur, pour bien voir le problème qu'on va résoudre par la suite.
    final class LogAnalyzer {
      static class Filter {
        boolean errors;
        boolean warnings;
        boolean auth;
      }
    
      private static void processLog(Path filePath, Filter filter, PrintStream out) {
        try(var input = Files.newBufferedReader(filePath)) {
          String line;
          while ((line = input.readLine()) != null) {
            if (filter.errors && line.contains("[ERROR]")) {
              out.println(line);
            }
            else if (filter.warnings && line.contains("[WARN]")) {
              out.println(line);
            }
            else if (filter.auth && (line.startsWith("USER_") || line.startsWith("AUTH_"))) {
              out.println(line);
            }
          }
        } catch (IOException e) {
          e.printStackTrace();
        }
      }
    
      static void main(String[] args) {
        if (args.length < 2) {
          System.out.print("""
              Usage: java LogAnalyzer <file> <filter-flags>
              Filters:  --all, --errors, --warnings, --auth
              """);
          System.exit(1);
          return;
        }
        var file = Path.of(args[0]);
        var filter = new Filter();
        for (var i = 1; i < args.length; i++) {
          var arg = args[i].toLowerCase();
          switch (arg) {
            case "--errors":   filter.errors = true; break;
            case "--warnings": filter.warnings = true; break;
            case "--auth":     filter.auth = true; break;
            case "--all":      filter.errors = filter.warnings = filter.auth = true; break;
            default: // TODO not a filter flag!
          }
        }
        processLog(file, filter, System.out);
      }
    }
                    

    Expliquer ce que font les méthodes processLog et main.
    Quel est le problème principal de ce code si on veut en assurer la maintenance ?
    Si vous pensez que c'est la gestion des exceptions (oui, il y a un vrai problème, mais ce n'est pas le plus gros problème).
    Si vous pensez que c'est le TODO (pareil, toujours pas le plus gros problème)
    Si vous pensez que c'est l'appel à String.toLowerCase() (pareil, toujours pas le plus gros problème).

  2. On va remplacer la classe mutable Filter par son équivalent fonctionnel, donc une interface fonctionnelle Filter (on peut garder le même nom) et corriger les autres problèmes par la même occasion.
    Un filtre reçoit une ligne et répond vrai ou faux. Quelle signature de méthode décrit naturellement cette opération ?
    Modifier Filter en conséquence.
    On va aussi extraire du main, une méthode createFilter(String arg), créé dans Filter, qui, à partir d'un argument comme "--errors", renvoie un Filter correspondant et qui gère correctement le cas où l'argument n'est pas reconnu. On va aussi en profiter pour moderniser le switch.
    Écrire la méthode createFilter(String arg).
    Modifier processLog pour simplifier la gestion des filtres. On va aussi en profiter pour gérer correctement l'IOException à la fois dans processLog et dans le main.
    Note : à ce stade, on va ne prendre en compte qu'un seul filtre (c'est déjà le cas dans le main fourni). On corrigera cela à la question suivante.
    Note 2 : n'oubliez pas d'ajouter l'annotation qui va bien à Filter.
    Vérifier que les tests marqués "Q2" passent.

  3. On veut maintenant pouvoir combiner plusieurs filtres avec un "ou" : --errors --warnings doit afficher les lignes qui sont des erreurs ou des avertissements.
    Écrire une méthode privée or(Filter filter1, Filter filter2) qui renvoie un nouveau Filter combinant les deux filtres.
    Modifier le main afin que, pour chaque argument, le filtre correspondant soit combiné avec le filtre déjà existant.
    Vérifier que les tests marqués "Q3" passent.

  4. Vous avons écrit Filter à la main, mais cela ressemble beaucoup à quelque chose qui existe déjà dans le JDK... Quelle interface fonctionnelle standard du package java.util.function a exactement la même forme ?
    Et oh magie, elle possède même déjà une méthode d'instance or !
    On va remplacer partout dans le code, notre Filter "maison" par l'interface fonctionnelle qui va bien. Avant cela, dupliquez votre code en mettez en commentaire l'ancienne version.
    C'est un pur refactoring : pas de nouveaux tests, mais ceux de "Q2" et "Q3" doivent continuer à passer.

  5. Un des gros intérêts d'utiliser des classes du JDK déjà existantes est que l'on peut profiter de la synergie avec le reste de l'écosystème (ici, une autre méthode du JDK).
    En effet, on peut maintenant simplifier le code de la méthode processLog en utilisant la méthode Files.lines(path) vue au TP précédent,
    Réécrire processLog pour utiliser Files.lines.
    Encore un pur refactoring : les tests de "Q2" et "Q3" doivent continuer à passer.

  6. Jusqu'ici, la seule chose que l'on sait faire des lignes filtrées, c'est les afficher une par une. On aimerait pouvoir généraliser : et si on voulait les regrouper en une seule chaîne, ou calculer des statistiques dessus ?
    On va paramétrer processLog non seulement par le filtre, mais aussi par ce que l'on fait du résultat : ajouter un paramètre Collector<? super String, ?, ?> collector, remplacer .forEach(out::println) par .collect(collector), et afficher le résultat obtenu une seule fois avec out.println(...).
    Note : nous verrons dans un cours suivant pourquoi le Collector doit être typé avec des ? super et des ?. Pour l'instant, ? super String se lit comme un super-type de String et ? comme n'importe quel type.
    Modifier le main pour appeler processLog avec un Collectors.joining comme collecteur, de façon à retrouver exactement le même comportement qu'avant (chaque ligne retenue, une par ligne).
    Toujours pas de nouveaux tests, mais "Q2" et "Q3" doivent continuer à passer.

  7. Enfin, pour les plus balèzes, on veut ajouter une seconde action, stats, qui calcule pour chaque "niveau" de log (le texte entre crochets en début de ligne, ou "OTHER" si la ligne n'en a pas) le nombre de lignes retenues, trié par niveau.
    Pour extraire le niveau (level) d'une ligne, il nous faut une expression régulière. Pour cela, déclarer une constante PATTERN de type java.util.Pattern Initialiser la constante avec Pattern.compile() avec une regex qui reconnaît un texte entre crochet et capture le texte entre les crochets. (cf cours numéro 2 de l'année dernière). Puis créer une méthode level(String line) qui renvoie la chaîne de caractères qui correspond au niveau (ERROR, WARN, etc ou OTHER) d'une ligne.
    Note : penser à "échapper" correctement la regex !
    Écrire createCollector(String arg) qui renvoie, selon arg : pour "print", Collectors.joining("\n") ; pour "stats", un collecteur qui associe à chaque ligne son niveau, puis regroupe (Collectors.groupingBy) dans une Map triée par niveau qui compte les occurrences de lignes de chaque niveau.
    Modifier le main pour distinguer, parmi les arguments après le fichier, ceux qui commencent par "--" (des filtres, combinés avec or) de ceux qui ne commencent pas par "--" (une action, qui remplace le collecteur courant via createCollector) ; le collecteur par défaut, si aucune action n'est précisée, reste le collecteur correspondant à print.
    Vérifier que les tests marqués "Q7" passent.
En conclusion, nous sommes partis d'un code impératif qui utilisait un objet mutable avec des booléens et des actions figées. Nous avons transformé ce code en utilisant des fonctions composables : la classe mutable a été remplacée par des fonctions, la cascade de if/else par un appel polymorphe, puis la boucle impérative et l'action terminale forEach ont été remplacées par un Stream et un Collector. Le code obtenu est plus déclaratif, et plus facilement extensible.