:: Enseignements :: Master :: M1 :: 2026-2027 :: Java Avancé ::
![[LOGO]](http://monge.univ-eiffel.fr/ens/resources/mlv.png) |
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.
-
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).
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
© Université de Marne-la-Vallée