XV. Découvrir les problèmes▲
Avant que le C soit apprivoisé en C ANSI, nous avions une petite blague : « Mon code compile donc il doit s'exécuter ! » (Ah ah !).
Ce n'était drôle que si on comprenait le C parce qu'à cette époque, le compilateur C acceptait n'importe quoi. C était réellement un « langage d'assemblage portable » créé pour voir s'il était possible de développer un système d'exploitation portable (Unix) qui pouvait être déplacé de l'architecture d'une machine à une autre sans le réécrire totalement dans le langage d'assemblage de la nouvelle machine. Le C fut donc créé comme un effet secondaire de la construction d'Unix et non comme un langage de programmation à visée générale.
Comme le C était destiné à des programmeurs qui écrivaient des systèmes d'exploitation en langage d'assemblage, il était implicitement supposé que ces programmeurs savaient ce qu'ils faisaient et n'avaient pas besoin de filet de sécurité. Par exemple, ces programmeurs n'avaient pas besoin du compilateur pour vérifier le type et l'usage des arguments. S'ils décidaient d'utiliser un type de donnée d'une manière différente de ce qui avait été imaginé à l'origine, ils devaient certainement avoir de bonnes raisons de le faire et le compilateur ne se mettait pas en travers de leur route. Donc avoir un programme en C pré ANSI qui compilait n'était que le premier pas dans le long processus de développer un programme sans bug.
Le développement du C ANSI combiné à des règles plus strictes concernant ce que le compilateur pouvait accepter venait après que de nombreuses personnes aient utilisé le C pour des projets autres que le développement d'un système d'exploitation et après l'apparition du C++, qui lui-même augmentait les chances d'avoir un programme s'exécutant décemment après avoir compilé. Une grande part de cette amélioration vient de la vérification forte et statique du typage : « forte », car le compilateur empêche l'emploi d'un mauvais type et « statique », car le C ANSI et le C++ exécute cette vérification lors de la compilation.
L'amélioration fut si spectaculaire qu'il apparut à beaucoup de gens (y compris moi-même) que la vérification forte et statique du typage était la réponse à une large part de nos problèmes. En fait, une des motivations pour la création de Java fut que la vérification du typage en C++ n'est pas suffisamment forte (tout d'abord parce que le C++ doit être compatible descendant avec le C et est donc lié par ses limitations). Ainsi Java est allé encore plus loin pour bénéficier de la vérification du typage et puisque Java a des mécanismes de vérification au niveau langage qui existent à l'exécution (C++ n'en n'a pas ; ce qui est laissé à l'exécution est en fait le langage d'assemblage, très rapide, mais sans autocontrôle), il n'est pas seulement restreint à la vérification statique du typage. (86)
Malgré tout, il semble que les mécanismes de vérification de niveau langage ne peuvent pas nous amener plus loin dans notre quête de développement d'un programme marchant correctement. C++ nous donnait des programmes qui marchaient bien et en moins de temps que les programmes C, mais souvent ils contenaient toujours des problèmes tels que des fuites mémoires ou des bugs subtils et cachés. Java a accompli une longue route en direction de la résolution de ces problèmes, mais il est toujours possible d'écrire un programme Java contenant de vilains bugs. De plus, tous les filets de sécurité dans Java ajoutent un surcoût (en dépit des annonces de performances incroyables vantées par la D.C.A de Sun). Donc on tombe parfois dans le défi de parvenir à exécuter un programme suffisamment vite pour un besoin donné (bien qu'il soit habituellement plus important d'avoir un programme qui marche qu'un programme qui s'exécute à une vitesse donnée).
Ce chapitre présente des outils pour résoudre les problèmes que le compilateur ne peut pas résoudre. D'une certaine façon, nous admettons que le compilateur ne peut pas nous amener plus loin dans la création de programmes robustes donc nous dépassons la simple utilisation du compilateur en créant un système et un code qui en sait plus sur ce qu'un programme est supposé faire ou ne pas faire.
Un des plus grands pas en avant est l'incorporation de tests unitaires automatiques. Ceci signifie écrire des tests et les incorporer dans un système de construction qui compile le code et exécute les tests à chaque fois, comme si les tests faisaient partie du processus de compilation (on commence rapidement à se fier à eux comme s'ils en faisant vraiment partie). Pour ce livre, un système de test maison a été développé afin d'assurer la correction de la sortie d'un programme (et pour afficher directement la sortie dans le listing du code), mais JUnit le système de test standard de facto sera aussi utilisé lorsque cela sera approprié. Pour s'assurer que les tests sont automatiques, ils sont exécutés comme une partie du processus de construction en utilisant Ant, un outil open source qui est aussi devenu le standard de facto en développement Java et CVS, un autre outil open source qui maintient tout le code source pour un projet particulier dans un référentiel.
JDK 1.4 introduit un mécanisme d'assertion pour aider à la vérification du code lors de l'exécution. Un des usages les plus attractifs des assertions est la Conception par Contrat (CPC), une manière formelle de décrire le comportement correct d'une classe. Combiné aux tests automatiques, CPC peut être un outil puissant.
Parfois, les tests unitaires ne sont pas suffisants et l'on a besoin, dans un programme qui s'exécute d'une manière incorrecte, de tracer les problèmes. Dans le JDK 1.4, l'API de log a été introduite pour permettre de tracer une information à propos du programme. C'est une amélioration significative dans le but de tracer un problème, par rapport à l'ajout et la suppression d'instructions println( ) et cette section ira suffisamment dans le détail pour vous donner une base solide dans la compréhension de cette API. Ce chapitre fournira aussi une introduction au débogage, montrant les informations qu'un débogueur typique peut fournir pour vous aider dans la découverte de problèmes subtils. Finalement, vous apprendrez le profilage et comment découvrir les goulots d'étranglement qui sont la cause de l'exécution trop lente de vos programmes.
XV-A. Test unitaire▲
On a récemment réalisé la très grande valeur des tests unitaires dans la pratique de la programmation. Ce processus constitue à construire des tests intégrés à tout le code créé et à exécuter ces tests à chaque fois que l'on construit le programme. De cette manière, le processus de construction peut vérifier plus que les erreurs de syntaxe puisqu'on lui apprend aussi à vérifier les erreurs sémantiques. Les langages de programmation dans le style du C et particulièrement le C++ ont typiquement préféré la performance à la sécurité de programmation. La raison pour laquelle développer des programmes en Java est beaucoup plus rapide qu'en C++ (en gros deux fois plus rapide selon la plupart des estimations) est le filet de sécurité : des caractéristiques telles que le ramasse-miettes et la vérification améliorée du typage. En intégrant les tests unitaires dans le processus de construction, on élargit le filet de sécurité, avec pour résultat un développement plus rapide. On peut aussi être plus audacieux dans les changements effectués, retravailler plus facilement le code lorsque l'on découvre des défauts dans la conception ou l'implémentation et en général délivrer un meilleur produit de manière plus rapide.
L'effet des tests unitaires sur le développement est si significatif qu'ils sont utilisés à travers ce livre non seulement pour valider le code, mais aussi pour afficher le résultat espéré. Ma propre expérience des tests unitaires commença lorsque je réalisais que pour garantir la correction du code dans un livre, tout programme devait être automatiquement extrait et organisé dans un arbre des sources, combiné avec un système de construction approprié. L'outil de construction utilisé dans ce livre est Ant (décrit plus loin dans ce chapitre) et après que vous l'ayez installé, il suffit de taper ant pour construire l'intégralité du code du livre. L'effet d'une extraction et d'un processus de compilation automatiques sur la qualité du code fut si immédiat et important qu'il devient bientôt (dans mon esprit) un prérequis pour tout livre de programmation. Comment pourrait-on avoir confiance dans un code qui ne compile pas ? J'ai aussi découvert que si je voulais faire du nettoyage, je pouvais utiliser le rechercher / remplacer à travers tout le livre ou juste modifier du code autour. Je savais que si j'introduisais un défaut, l'extracteur de code et le système de construction le découvriraient.
Mais au fur et à mesure que les programmes devenaient plus complexes, je trouvais aussi qu'il y avait un sérieux manque dans mon système. Être capable de compiler avec succès les programmes est clairement un premier pas important et cela semble assez révolutionnaire pour un livre publié. Habituellement, à cause des pressions dues aux dates de publication, il est typique de pouvoir ouvrir un livre de programmation au hasard et de découvrir des erreurs de code. Mais je continuais de recevoir des messages de lecteurs signalant des problèmes sémantiques dans mon code, ces problèmes ne pouvant être découverts qu'en exécutant le code. Naturellement, j'avais compris ceci et entrepris quelques premiers jalons hésitants dans le but d'implémenter un système exécutant automatiquement les tests, mais j'avais été vaincu par le planning de publication tout en sachant qu'il y avait définitivement quelque chose de faux dans mon processus et que ceci réapparaîtrait sous la forme de bugs embarrassants. Dans le monde de l'open source(87), la gêne d'une personne à propos de la qualité de son propre code est un des premiers facteurs de motivation en vue d'une amélioration !
L'autre problème était que je manquais d'une structure pour le système de test. Finalement, j'ai commencé à entendre parler des tests unitaires et de JUnit, qui fournissait une base pour une structure de test. Je trouvais les versions initiales de JUnit inacceptables, car elles exigeaient que le programmeur écrive beaucoup trop de code même pour la plus simple suite de test. Les versions plus récentes ont réduit considérablement le code requis en utilisant la réflexion et sont donc beaucoup plus satisfaisantes.
Malgré tout, j'avais besoin de résoudre un autre problème qui était la validation des sorties d'un programme et comment montrer les sorties validées dans le livre. J'avais comme plaintes régulières que je ne montrais pas suffisamment les résultats des programmes dans mon livre. Ma position était que le lecteur se devait d'exécuter les programmes tout en lisant. Beaucoup de lecteurs font ceci et en retirent un grand bénéfice. Malgré tout, une raison cachée de mon attitude était que je n'avais pas moyen de tester que le résultat montré dans le livre était correct. Par expérience, je savais que tôt ou tard, quelque chose arriverait qui rendrait le résultat incorrect (et d'une manière telle que je ne m'en apercevrais pas de prime abord). Le framework de test simple que je montre ici ne capture pas seulement la sortie du programme de la console (beaucoup de programmes de ce livre produisent une sortie console), mais la compare aussi au résultat attendu qui est imprimé dans le livre comme une partie du code source. Ainsi les lecteurs peuvent voir le résultat, savoir qu'il a été vérifié par le processus de construction et qu'ils peuvent le vérifier eux-mêmes.
Je voulais voir si le système de test pouvait même être plus facile et plus simple à utiliser, appliquant comme point de départ le principe de l'Extreme Programming qui est de « faire la chose la plus simple possible pour que ça marche » et de faire ensuite évoluer le système au fur et à mesure des besoins d'utilisation. En plus, je souhaitais réduire le code de test et avoir ainsi le maximum de fonctionnalités pour le minimum de code en vue de présentations d'écrans. Le résultat (88) en est le framework de test décrit ci-après.
XV-A-1. Un Framework de Test Simple▲
Le but premier de ce framework (89) est de vérifier la sortie des exemples de ce livre. Vous avez déjà vu des lignes telles que
private
static
Test monitor =
new
Test
(
);
au début de la plupart des classes contenant une méthode main( ). La tâche de l'objet monitor est d'intercepter et de sauver une copie de la sortie standard et de la sortie d'erreur dans un fichier texte. Ce fichier est ensuite utilisé pour vérifier la sortie d'un programme d'exemple par comparaison du contenu de ce fichier avec le résultat attendu.
Nous commençons par définir les exceptions qui seront lancées par ce système de test. L'exception à portée générale pour cette librairie est la classe de base des autres exceptions. Remarquez qu'elle étend RuntimeException afin de ne pas être dans l'obligation de traiter l'exception :
//: com:bruceeckel:simpletest:SimpleTestException.java
package
com.bruceeckel.simpletest;
public
class
SimpleTestException extends
RuntimeException {
public
SimpleTestException
(
String msg) {
super
(
msg);
}
}
///:~
Un test basique est de vérifier que le nombre de lignes envoyé par le programme à la console est le même que le nombre de lignes attendu :
//: com:bruceeckel:simpletest:NumOfLinesException.java
package
com.bruceeckel.simpletest;
public
class
NumOfLinesException
extends
SimpleTestException {
public
NumOfLinesException
(
int
exp, int
out) {
super
(
"Number of lines of output and "
+
"expected output did not match.
\n
"
+
"expected: <"
+
exp +
">
\n
"
+
"output: <"
+
out +
"> lines)"
);
}
}
///:~
Un autre cas est lorsque le nombre de lignes est correct, mais au moins une ligne ne correspond pas :
//: com:bruceeckel:simpletest:LineMismatchException.java
package
com.bruceeckel.simpletest;
import
java.io.PrintStream;
public
class
LineMismatchException
extends
SimpleTestException {
public
LineMismatchException
(
int
lineNum, String expected, String output) {
super
(
"line "
+
lineNum +
" of output did not match expected output
\n
"
+
"expected: <"
+
expected +
">
\n
"
+
"output: <"
+
output +
">"
);
}
}
///:~
Ce système de test fonctionne par interception de la sortie console en utilisant la classe TestStream pour remplacer la sortie console standard et la sortie console des erreurs :
//: com:bruceeckel:simpletest:TestStream.java
// Simple utility for testing program output. Intercepts
// System.out to print both to the console and a buffer.
package
com.bruceeckel.simpletest;
import
java.io.*;
import
java.util.*;
import
java.util.regex.*;
public
class
TestStream extends
PrintStream {
protected
int
numOfLines;
private
PrintStream
console =
System.out,
err =
System.err,
fout;
// To store lines sent to System.out or err
private
InputStream stdin;
private
String className;
public
TestStream
(
String className) {
super
(
System.out, true
); // Autoflush
System.setOut
(
this
);
System.setErr
(
this
);
stdin =
System.in; // Save to restore in dispose()
// Replace the default version with one that
// automatically produces input on demand:
System.setIn
(
new
BufferedInputStream
(
new
InputStream
(
){
char
[] input =
(
"test
\n
"
).toCharArray
(
);
int
index =
0
;
public
int
read
(
) {
return
(
int
)input[index =
(
index +
1
) %
input.length];
}
}
));
this
.className =
className;
openOutputFile
(
);
}
// public PrintStream getConsole() { return console; }
public
void
dispose
(
) {
System.setOut
(
console);
System.setErr
(
err);
System.setIn
(
stdin);
}
// This will write over an old Output.txt file:
public
void
openOutputFile
(
) {
try
{
fout =
new
PrintStream
(
new
FileOutputStream
(
new
File
(
className +
"Output.txt"
)));
}
catch
(
FileNotFoundException e) {
throw
new
RuntimeException
(
e);
}
}
// Override all possible print/println methods to send
// intercepted console output to both the console and
// the Output.txt file:
public
void
print
(
boolean
x) {
console.print
(
x);
fout.print
(
x);
}
public
void
println
(
boolean
x) {
numOfLines++
;
console.println
(
x);
fout.println
(
x);
}
public
void
print
(
char
x) {
console.print
(
x);
fout.print
(
x);
}
public
void
println
(
char
x) {
numOfLines++
;
console.println
(
x);
fout.println
(
x);
}
public
void
print
(
int
x) {
console.print
(
x);
fout.print
(
x);
}
public
void
println
(
int
x) {
numOfLines++
;
console.println
(
x);
fout.println
(
x);
}
public
void
print
(
long
x) {
console.print
(
x);
fout.print
(
x);
}
public
void
println
(
long
x) {
numOfLines++
;
console.println
(
x);
fout.println
(
x);
}
public
void
print
(
float
x) {
console.print
(
x);
fout.print
(
x);
}
public
void
println
(
float
x) {
numOfLines++
;
console.println
(
x);
fout.println
(
x);
}
public
void
print
(
double
x) {
console.print
(
x);
fout.print
(
x);
}
public
void
println
(
double
x) {
numOfLines++
;
console.println
(
x);
fout.println
(
x);
}
public
void
print
(
char
[] x) {
console.print
(
x);
fout.print
(
x);
}
public
void
println
(
char
[] x) {
numOfLines++
;
console.println
(
x);
fout.println
(
x);
}
public
void
print
(
String x) {
console.print
(
x);
fout.print
(
x);
}
public
void
println
(
String x) {
numOfLines++
;
console.println
(
x);
fout.println
(
x);
}
public
void
print
(
Object x) {
console.print
(
x);
fout.print
(
x);
}
public
void
println
(
Object x) {
numOfLines++
;
console.println
(
x);
fout.println
(
x);
}
public
void
println
(
) {
if
(
false
) console.print
(
"println"
);
numOfLines++
;
console.println
(
);
fout.println
(
);
}
public
void
write
(
byte
[] buffer, int
offset, int
length) {
console.write
(
buffer, offset, length);
fout.write
(
buffer, offset, length);
}
public
void
write
(
int
b) {
console.write
(
b);
fout.write
(
b);
}
}
///:~
Le constructeur de TestStream, après avoir appelé le constructeur de la classe de base, sauve d'abord les références de la sortie standard et de la sortie d'erreur et redirige ensuite les deux flux vers l'objet TestStream. Les méthodes statiques setOut( ) et setErr( ) prennent toutes deux un argument PrintStream. Les références de System.out et System.err sont détachées de leur objet normal et attaché à l'objet TestStream donc TestStream doit aussi être un PrintStream (ou de manière équivalente, quelque chose héritant de PrintStream). La référence à la sortie standard originale PrintStream est capturée dans la référence console à l'intérieur de TestStream et à chaque fois que la sortie console est interceptée, elle est envoyée aussi bien à la console originale qu'au fichier de sortie. La méthode dispose( ) est utilisée pour rattacher les références des E/S standards à leurs objets d'origine, lorsque TestStream a fini de les traiter.
Pour tester automatiquement des exemples qui requièrent une entrée console de la part de l'utilisateur, le constructeur redirige l'entrée standard. L'entrée standard courante est sauvée dans une référence afin que dispose( ) puisse la restaurer dans son état d'origine. En utilisant System.setIn( ), une classe anonyme interne est affectée pour prendre en charge toute requête d'une entrée venant du programme testé. La méthode read( ) de la classe interne produit les lettres « test » suivies par une nouvelle ligne.
TestStream redéfinit les méthodes print( ) et println( ) de PrintStream pour chaque type. Chacune de ces méthodes écrit à la fois sur la sortie « standard » et sur la sortie fichier. La méthode expect( ) peut être ensuite utilisée pour tester si la sortie produite par un programme correspond à la sortie espérée fournie en argument à expect( ).
Ces outils sont utilisés dans la classe Test :
//: com:bruceeckel:simpletest:Test.java
// Simple utility for testing program output. Intercepts
// System.out to print both to the console and a buffer.
package
com.bruceeckel.simpletest;
import
java.io.*;
import
java.util.*;
import
java.util.regex.*;
public
class
Test {
// Bit-shifted so they can be added together:
public
static
final
int
EXACT =
1
<<
0
, // Lines must match exactly
AT_LEAST =
1
<<
1
, // Must be at least these lines
IGNORE_ORDER =
1
<<
2
, // Ignore line order
WAIT =
1
<<
3
; // Delay until all lines are output
private
String className;
private
TestStream testStream;
public
Test
(
) {
// Discover the name of the class this
// object was created within:
className =
new
Throwable
(
).getStackTrace
(
)[1
].getClassName
(
);
testStream =
new
TestStream
(
className);
}
public
static
List fileToList
(
String fname) {
ArrayList list =
new
ArrayList
(
);
try
{
BufferedReader in =
new
BufferedReader
(
new
FileReader
(
fname));
try
{
String line;
while
((
line =
in.readLine
(
)) !=
null
) {
if
(
fname.endsWith
(
".txt"
))
list.add
(
line);
else
list.add
(
new
TestExpression
(
line));
}
}
finally
{
in.close
(
);
}
}
catch
(
IOException e) {
throw
new
RuntimeException
(
e);
}
return
list;
}
public
static
List arrayToList
(
Object[] array) {
List l =
new
ArrayList
(
);
for
(
int
i =
0
; i <
array.length; i++
) {
if
(
array[i] instanceof
TestExpression) {
TestExpression re =
(
TestExpression)array[i];
for
(
int
j =
0
; j <
re.getNumber
(
); j++
)
l.add
(
re);
}
else
{
l.add
(
new
TestExpression
(
array[i].toString
(
)));
}
}
return
l;
}
public
void
expect
(
Object[] exp, int
flags) {
if
((
flags &
WAIT) !=
0
)
while
(
testStream.numOfLines <
exp.length) {
try
{
Thread.sleep
(
1000
);
}
catch
(
InterruptedException e) {
throw
new
RuntimeException
(
e);
}
}
List output =
fileToList
(
className +
"Output.txt"
);
if
((
flags &
IGNORE_ORDER) ==
IGNORE_ORDER)
OutputVerifier.verifyIgnoreOrder
(
output, exp);
else
if
((
flags &
AT_LEAST) ==
AT_LEAST)
OutputVerifier.verifyAtLeast
(
output,
arrayToList
(
exp));
else
OutputVerifier.verify
(
output, arrayToList
(
exp));
// Clean up the output file - see c06:Detergent.java
testStream.openOutputFile
(
);
}
public
void
expect
(
Object[] expected) {
expect
(
expected, EXACT);
}
public
void
expect
(
Object[] expectFirst,
String fname, int
flags) {
List expected =
fileToList
(
fname);
for
(
int
i =
0
; i <
expectFirst.length; i++
)
expected.add
(
i, expectFirst[i]);
expect
(
expected.toArray
(
), flags);
}
public
void
expect
(
Object[] expectFirst, String fname) {
expect
(
expectFirst, fname, EXACT);
}
public
void
expect
(
String fname) {
expect
(
new
Object[] {}
, fname, EXACT);
}
}
///:~
Il y a plusieurs versions surchargées de expect( ) fournies par commodité (ainsi le programmeur client peut par exemple fournir le nom du fichier contenant le résultat espéré au lieu d'un tableau de lignes des sorties attendues). Toutes ces méthodes surchargées appellent la méthode principale expect( ) qui prend comme argument un tableau d' Objects contenant les lignes de sorties attendues et un int contenant différents drapeaux. Les drapeaux sont implémentés en utilisant le changement de bit, avec chaque bit correspondant à un drapeau particulier comme défini au début de Test.java.
La méthode expect( ) inspecte d'abord l'argument drapeau pour voir s'il faut retarder le traitement pour permettre à un programme lent de finir. Elle appelle ensuite une méthode static fileToList( ) qui convertit le contenu du fichier de sortie produit par un programme en une List. La méthode fileToList( ) enrobe aussi chaque objet String dans un objet OutputLine. La raison de ceci deviendra claire plus tard. Finalement, la méthode expect( ) appelle la méthode verify( ) appropriée en se basant sur l'argument drapeau.
Il y a trois vérificateurs: verify( ), verifyIgnoreOrder( ) et verifyAtLeast( ) correspondant aux trois modes EXACT, IGNORE_ORDER et AT_LEAST respectivement.
//: com:bruceeckel:simpletest:OutputVerifier.java
package
com.bruceeckel.simpletest;
import
java.util.*;
import
java.io.PrintStream;
public
class
OutputVerifier {
private
static
void
verifyLength
(
int
output, int
expected, int
compare) {
if
((
compare ==
Test.EXACT &&
expected !=
output)
||
(
compare ==
Test.AT_LEAST &&
output <
expected))
throw
new
NumOfLinesException
(
expected, output);
}
public
static
void
verify
(
List output, List expected) {
verifyLength
(
output.size
(
),expected.size
(
),Test.EXACT);
if
(!
expected.equals
(
output)) {
//find the line of mismatch
ListIterator it1 =
expected.listIterator
(
);
ListIterator it2 =
output.listIterator
(
);
while
(
it1.hasNext
(
)
&&
it2.hasNext
(
)
&&
it1.next
(
).equals
(
it2.next
(
)));
throw
new
LineMismatchException
(
it1.nextIndex
(
), it1.previous
(
).toString
(
),
it2.previous
(
).toString
(
));
}
}
public
static
void
verifyIgnoreOrder
(
List output, Object[] expected) {
verifyLength
(
expected.length,output.size
(
),Test.EXACT);
if
(!(
expected instanceof
String[]))
throw
new
RuntimeException
(
"IGNORE_ORDER only works with String objects"
);
String[] out =
new
String[output.size
(
)];
Iterator it =
output.iterator
(
);
for
(
int
i =
0
; i <
out.length; i++
)
out[i] =
it.next
(
).toString
(
);
Arrays.sort
(
out);
Arrays.sort
(
expected);
int
i =
0
;
if
(!
Arrays.equals
(
expected, out)) {
while
(
expected[i].equals
(
out[i])) {
i++
;}
throw
new
SimpleTestException
(
((
String) out[i]).compareTo
(
expected[i]) <
0
? "output: <"
+
out[i] +
">"
:
"expected: <"
+
expected[i] +
">"
);
}
}
public
static
void
verifyAtLeast
(
List output, List expected) {
verifyLength
(
output.size
(
), expected.size
(
),
Test.AT_LEAST);
if
(!
output.containsAll
(
expected)) {
ListIterator it =
expected.listIterator
(
);
while
(
output.contains
(
it.next
(
))) {}
throw
new
SimpleTestException
(
"expected: <"
+
it.previous
(
).toString
(
) +
">"
);
}
}
}
///:~
Les méthodes « verify » testent si la sortie produite par un programme correspond à la sortie attendue comme spécifié par le mode particulier. Si ce n'est pas le cas, les méthodes « verify » lèvent une exception qui interrompt le processus de construction.
Chacune des méthodes « verify » utilise verifyLength( ) pour tester le nombre exact de lignes en sortie. Le mode EXACT requiert que les deux tableaux de la sortie et de la sortie attendue soient de la même taille et que chaque ligne de sortie soit identique à la ligne correspondante dans le tableau de sortie attendu. IGNORE_ORDER requiert aussi que les deux tableaux soient de même taille, mais l'ordre d'apparence des lignes n'est pas pris en compte (les deux tableaux de sortie peuvent être une permutation l'un de l'autre). Le mode IGNORE_ORDER est utilisé pour tester des exemples avec des threads, car la séquence des lignes de sortie produite par un programme ne peut être prédite à cause la planification non déterministe des threads par la JVM. Le mode AT_LEAST ne requiert pas que les deux tableaux soient de la même taille, mais chaque ligne de la sortie attendue doit être contenue dans la sortie produite effectivement par le programme, sans tenir compte de l'ordre. Cette caractéristique est particulièrement utile pour tester les programmes d'exemple contenant des lignes de sortie qui peuvent être ou ne pas être imprimées, ce qui est le cas avec la plupart des exemples traitant du nettoyage mémoire. Notez que les trois modes sont canoniques. C'est-à-dire que si un test passe en mode IGNORE_ORDER, alors il passera aussi en mode AT_LEAST et s'il passe en mode EXACT, il passera dans les deux autres modes.
Remarquez comme l'implémentation des méthodes « verify » est simple. Par exemple verify( ) appelle simplement la méthode equals( ) fournit par la classe List et verifyAtLeast( ) appelle List.containsAll( ). Souvenez-vous que les deux sorties List peuvent contenir à la fois des objets OutputLine et RegularExpression. La raison pour laquelle l'objet simple String est enrobé dans OutputLines devrait maintenant apparaître clairement. Cette approche nous permet de redéfinir la méthode equals( ), ce qui est nécessaire pour bénéficier de l'API Collections de Java.
Les objets dans le tableau expect( ) sont soit des Strings, soit des TestExpressions. Ils peuvent encapsuler une expression régulière (décrit au chapitre 12) ce qui est utile pour tester les exemples qui produisent une sortie aléatoire. La classe TestExpression encapsule une String représentant une expression régulière particulière.
//: com:bruceeckel:simpletest:TestExpression.java
// Regular expression for testing program output lines
package
com.bruceeckel.simpletest;
import
java.util.regex.*;
public
class
TestExpression implements
Comparable {
private
Pattern p;
private
String expression;
private
boolean
isRegEx;
// Default to only one instance of this expression:
private
int
duplicates =
1
;
public
TestExpression
(
String s) {
this
.expression =
s;
if
(
expression.startsWith
(
"%% "
)) {
this
.isRegEx =
true
;
expression =
expression.substring
(
3
);
this
.p =
Pattern.compile
(
expression);
}
}
// For duplicate instances:
public
TestExpression
(
String s, int
duplicates) {
this
(
s);
this
.duplicates =
duplicates;
}
public
String toString
(
) {
if
(
isRegEx) return
p.pattern
(
);
return
expression;
}
public
boolean
equals
(
Object obj) {
if
(
this
==
obj) return
true
;
if
(
isRegEx) return
(
compareTo
(
obj) ==
0
);
return
expression.equals
(
obj.toString
(
));
}
public
int
compareTo
(
Object obj) {
if
((
isRegEx) &&
(
p.matcher
(
obj.toString
(
)).matches
(
)))
return
0
;
return
expression.compareTo
(
obj.toString
(
));
}
public
int
getNumber
(
) {
return
duplicates; }
public
String getExpression
(
) {
return
expression;}
public
boolean
isRegEx
(
) {
return
isRegEx; }
}
///:~
TestExpression peut distinguer les patrons d'expressions régulières des littéraux String. Le deuxième constructeur permet à des lignes multiples d'expressions identiques d'être enrobées dans un seul objet par commodité.
Ce système de test a été raisonnablement utile et l'exercice de le créer et de le mettre en usage a été inestimable. Mais au bout du compte, je n'en suis pas satisfait et j'ai des idées qui seront probablement implémentées dans la prochaine édition de ce livre (ou peut-être plus tôt).
XV-A-2. JUnit▲
Bien que le framework de test juste décrit permette de vérifier simplement et facilement la sortie d'un programme, on souhaite dans certains cas effectuer des fonctions de test plus vastes. JUnit, disponible à www.junit.org, est un standard émergeant et rapide d'écriture de tests répétables pour les programmes Java qui permet des tests simples ou complexes.
La première version de Junit était de manière probable basée sur le JDK 1.0 et ne pouvait donc pas utiliser les facilités de la réflexion Java. Ceci avait pour conséquence que l'écriture des tests unitaires avec le vieux JUnit était une activité lourde et verbeuse et j'en trouvais de plus la conception désagréable. À cause de tout ceci, j'ai écrit mon propre framework de test pour Java, (90) qui va l'extrême opposé en « faisant la chose la plus simple qui puisse marcher ». (91) Depuis lors, Junit a été modifié et utilise la réflexion pour simplifier grandement le processus d'écriture du code des tests unitaires. Bien que l'on ait toujours l'option d'écrire le code « à l'ancienne » avec des suites de test et plein d'autres détails complexes, je crois que dans une grande majorité de cas, on peut suivre l'approche simple montrée ici (et rendre la vie plus plaisante).
Dans l'approche la plus simple d'utilisation de JUnit, on met tous les tests dans une sous-classe de TestCase. Chaque test doit être public, ne prendre aucun argument, retourner void et avoir un nom de méthode commençant par le mot « test ». La réflexion dans JUnit identifiera ces méthodes comme étant des tests individuels, les prépara et les exécutera chacun à leur tour, prenant les mesures nécessaires pour éviter les effets de bord entre tests.
Traditionnellement, la méthode setUp( ) crée et initialise un ensemble commun d'objets qui seront utilisés dans tous les tests. Mais il est aussi possible d'effectuer cette initialisation dans le constructeur de la classe de test. JUnit crée un objet pour chaque test afin de s'assurer qu'il n'y ait pas d'effet de bord lorsque les tests s'exécutent. Mais tous les objets pour tous les tests sont créés en une fois (plutôt que de créer l'objet juste avant le test). Donc la seule différence entre l'utilisation de setUp( ) et du constructeur est que setUp( ) est appelée directement avant le test. Dans la plupart des cas, cela ne sera pas un problème et on peut utiliser l'approche avec le constructeur pour sa simplicité.
Si l'on a besoin d'effectuer un nettoyage après chaque test (si on modifie quelque chose de statique qui doit être restauré, des fichiers ouverts qui doivent être fermés, des connexions réseau restées ouvertes, etc.), on écrit une méthode tearDown( ). Cela aussi est optionnel.
L'exemple suivant utilise cette approche simple pour créer des tests JUnit concernant la classe standard ArrayList de Java. Pour tracer comment JUnit crée et nettoie les objets de test, CountedList hérite de ArrayList et un système de trace de l'information y est ajouté :
//: c15:JUnitDemo.java
// Simple use of JUnit to test ArrayList
// {Depends: junit.jar}
import
java.util.*;
import
junit.framework.*;
// So we can see the list objects being created,
// and keep track of when they are cleaned up:
class
CountedList extends
ArrayList {
private
static
int
counter =
0
;
private
int
id =
counter++
;
public
CountedList
(
) {
System.out.println
(
"CountedList #"
+
id);
}
public
int
getId
(
) {
return
id; }
}
public
class
JUnitDemo extends
TestCase {
private
static
com.bruceeckel.simpletest.Test monitor =
new
com.bruceeckel.simpletest.Test
(
);
private
CountedList list =
new
CountedList
(
);
// You can use the constructor instead of setUp():
public
JUnitDemo
(
String name) {
super
(
name);
for
(
int
i =
0
; i <
3
; i++
)
list.add
(
""
+
i);
}
// Thus, setUp() is optional, but is run right
// before the test:
protected
void
setUp
(
) {
System.out.println
(
"Set up for "
+
list.getId
(
));
}
// tearDown() is also optional, and is called after
// each test. setUp() and tearDown() can be either
// protected or public:
public
void
tearDown
(
) {
System.out.println
(
"Tearing down "
+
list.getId
(
));
}
// All tests have method names beginning with "test":
public
void
testInsert
(
) {
System.out.println
(
"Running testInsert()"
);
assertEquals
(
list.size
(
), 3
);
list.add
(
1
, "Insert"
);
assertEquals
(
list.size
(
), 4
);
assertEquals
(
list.get
(
1
), "Insert"
);
}
public
void
testReplace
(
) {
System.out.println
(
"Running testReplace()"
);
assertEquals
(
list.size
(
), 3
);
list.set
(
1
, "Replace"
);
assertEquals
(
list.size
(
), 3
);
assertEquals
(
list.get
(
1
), "Replace"
);
}
// A "helper" method to reduce code duplication. As long
// as the name doesn't start with "test," it will not
// be automatically executed by JUnit.
private
void
compare
(
ArrayList lst, String[] strs) {
Object[] array =
lst.toArray
(
);
assertTrue
(
"Arrays not the same length"
,
array.length ==
strs.length);
for
(
int
i =
0
; i <
array.length; i++
)
assertEquals
(
strs[i], (
String)array[i]);
}
public
void
testOrder
(
) {
System.out.println
(
"Running testOrder()"
);
compare
(
list, new
String[] {
"0"
, "1"
, "2"
}
);
}
public
void
testRemove
(
) {
System.out.println
(
"Running testRemove()"
);
assertEquals
(
list.size
(
), 3
);
list.remove
(
1
);
assertEquals
(
list.size
(
), 2
);
compare
(
list, new
String[] {
"0"
, "2"
}
);
}
public
void
testAddAll
(
) {
System.out.println
(
"Running testAddAll()"
);
list.addAll
(
Arrays.asList
(
new
Object[] {
"An"
, "African"
, "Swallow"
}
));
assertEquals
(
list.size
(
), 6
);
compare
(
list, new
String[] {
"0"
, "1"
, "2"
,
"An"
, "African"
, "Swallow"
}
);
}
public
static
void
main
(
String[] args) {
// Invoke JUnit on the class:
junit.textui.TestRunner.run
(
JUnitDemo.class
);
monitor.expect
(
new
String[] {
"CountedList #0"
,
"CountedList #1"
,
"CountedList #2"
,
"CountedList #3"
,
"CountedList #4"
,
// '.' indicates the beginning of each test:
".Set up for 0"
,
"Running testInsert()"
,
"Tearing down 0"
,
".Set up for 1"
,
"Running testReplace()"
,
"Tearing down 1"
,
".Set up for 2"
,
"Running testOrder()"
,
"Tearing down 2"
,
".Set up for 3"
,
"Running testRemove()"
,
"Tearing down 3"
,
".Set up for 4"
,
"Running testAddAll()"
,
"Tearing down 4"
,
""
,
"%% Time: .*"
,
""
,
"OK (5 tests)"
,
""
,
}
);
}
}
///:~
Pour mettre en place les tests unitaires, on doit seulement importer junit.framework.* et étendre TestCase comme le fait JUnitDemo. En plus, on doit créer un constructeur qui prend un String en argument et qui le passe à son super constructeur.
Pour chaque test, un nouvel objet JunitDemo sera créé et tous les membres non static seront aussi créés. Cela signifie qu'un nouvel objet CountedList (list) sera créé et initialisé pour chaque test, car c'est un champ de JUnitDemo. De plus, le constructeur sera appelé pour chaque test donc list sera initialisé avec les chaînes « 0 », « 1 » et « 2 » avant l'exécution de chaque test.
Afin d'observer le comportement de setUp( ) et tearDown( ), des méthodes de même nom ont été créées pour afficher des informations sur la manière dont le test est initialisé et nettoyé. Remarquez que les méthodes de la classe de base sont protected, donc les méthodes redéfinies doivent être soit protected soit public.
testInsert( ) et testReplace( ) illustrent des méthodes de test typiques, car elles suivent la convention requise en matière de nommage et de signature. JUnit découvre ces méthodes en utilisant la réflexion et exécute chacune comme un test. À l'intérieur des méthodes, on exécute toutes les opérations désirées et on utilise les assertions JUnit (qui commencent par le mot « assert ») pour vérifier la correction des tests (la panoplie complète des « assert » peut être trouvée dans la javadoc JUnit de junit.framework.Assert). Si l'assertion échoue, l'expression et les valeurs qui causent l'échec seront affichées. Ceci est généralement suffisant, mais il est aussi possible d'utiliser la version surchargée de chaque assertion JUnit qui inclut un String qui sera affiché si l'assertion échoue.
Les assertions ne sont pas obligatoires, on peut juste exécuter le test sans assertions et considérer que c'est un succès si aucune exception n'est lancée.
La méthode compare( ) est un exemple d'une méthode « d'aide » qui n'est pas exécutée par JUnit, mais qui est utilisée par d'autres tests dans la classe. Tant que le nom d'une méthode ne commence pas par « test », JUnit ne l'exécute pas et ne s'attend pas pour ceci à ce qu'elle ait une signature particulière. Ici, compare( ) est private pour mettre en valeur le fait qu'elle est seulement utilisée à l'intérieur de la classe de test, mais elle aurait pu tout aussi bien être publique. Les méthodes de test restantes éliminent le code dupliqué en le factorisant par utilisation de la méthode compare( ).
Pour exécuter les tests Junit, la méthode statique TestRunner.run( ) est invoquée dans main( ). Cette méthode manipule la classe qui contient la collection de tests, est automatiquement initialisée et exécute tous les tests. Dans la sortie de expect( ), on peut voir que tous les objets nécessaires à l'exécution des tests sont créés en premier, dans un batch : c'est là que la construction se fait. (92) Avant chaque test, la méthode setUp( ) est appelée. Ensuite le test est exécuté, suivi par la méthode tearDown( ) JUnit sépare chaque test avec un ‘.'.
Bien qu'il soit probablement possible de survivre en utilisant la plus simple des approches de JUnit comme montrée dans l'exemple précédent, JUnit était conçu à l'origine avec une pléthore de structures compliquées. Si vous êtes curieux, vous pouvez facilement en apprendre plus sur celles-ci puisque le téléchargement de JUnit à partir de www.JUnit.org contient de la documentation et des tutoriaux.
XV-B. Améliorer la fiabilité avec les assertions▲
Les assertions, que vous avez vues utilisées dans des exemples précédents de ce livre, furent ajoutées à la version du JDK 1.4 de Java dans le but d'aider les programmeurs à améliorer la fiabilité de leurs programmes. Correctement utilisées, les assertions peuvent ajouter aux programmes de la robustesse en vérifiant que certaines conditions sont satisfaites durant l'exécution du programme. Par exemple, supposons que l'on ait un champ numérique dans un objet qui représente le mois du calendrier julien. On sait que cette valeur doit toujours être comprise entre 1 et 12. Une assertion peut être utilisée pour vérifier ceci et rapporter une erreur si d'une quelconque façon le nombre est en dehors de cette fourchette. Si l'on est à l'intérieur d'une méthode, on peut vérifier la validité d'un argument avec une assertion. Il existe des tests importants pour s'assurer de la correction d'un programme, mais ils ne peuvent pas être effectués lors de la compilation et ne tombent donc pas dans la catégorie des tests unitaires. Dans cette section, nous regardons le fonctionnement du mécanisme d'assertion et la manière par laquelle on peut l'utiliser pour implémenter partiellement le concept de conception par contrat.
XV-B-1. Syntaxe de l'assertion▲
Puisqu'il est possible de simuler l'effet des assertions en utilisant d'autres constructions programmatiques, on peut penser que l'intérêt principal d'ajouter les assertions à Java est leur facilité d'écriture. Les instructions d'assertion sont de deux formes :
assert boolean-expression;
assert boolean-expression: information-expression;
Ces deux instructions disent « Je prétends que l'expression booléenne produira une valeur vraie ». Si ce n'est pas le cas, l'assertion produira une exception de type AssertionError. C'est une sous-classe de Throwable et ainsi ne requiert pas une spécification de l'exception.
Malheureusement, la première forme d'assertion ne produit aucune information concernant l'expression booléenne dans l'exception produite par l'assertion en échec (contrairement à la plupart des autres mécanismes d'assertion présents dans d'autres langages). Voici un exemple montrant l'utilisation de la première forme :
//: c15:Assert1.java
// Non-informative style of assert
// Compile with: javac -source 1.4 Assert1.java
// {JVMArgs: -ea} // Must run with -ea
// {ThrowsException}
public
class
Assert1 {
public
static
void
main
(
String[] args) {
assert
false
;
}
}
///:~
Les assertions ne sont pas actives par défaut dans le JDK 1.4 (cela est ennuyeux, mais les concepteurs arrivèrent à se convaincre eux-mêmes que c'était une bonne idée). Pour empêcher les erreurs à la compilation, on doit compiler avec le drapeau :
-source 1.4
Si l'on n'utilise pas ce flag, on reçoit un message verbeux disant que assert est un mot clé dans le JDK 1.4 et qu'il ne peut plus être utilisé comme un identifiant.
En exécutant le programme d'une manière normale, sans flag spécial pour les assertions, rien ne se passera. On doit activer les assertions à l'exécution du programme. La façon la plus simple de le faire est d'utiliser le flag -ea mais on peut aussi l'écrire en toutes lettres : -enableassertions. Cela activera les instructions d'assertion à l'exécution du programme et on obtiendra :
Exception in thread "main" java.lang.AssertionError
at Assert1.main(Assert1.java:8)
On peut voir que la sortie ne contient pas beaucoup d'information utile. D'un autre côté, si on utilise la forme avec « information-expression », on produit un message d'aide lorsque l'assertion échoue.
Pour utiliser la seconde forme, on fournit une expression d'information qui sera affichée dans la stack trace de l'exception. L'expression d'information peut produire n'importe quel type de donnée. Mais l'expression d'information la plus utile sera typiquement une chaîne avec un texte utile au programmeur. Voici un exemple :
//: c15:Assert2.java
// Assert with an informative message
// {JVMArgs: -ea}
// {ThrowsException}
public
class
Assert2 {
public
static
void
main
(
String[] args) {
assert
false
: "Here's a message saying what happened"
;
}
}
///:~
Maintenant, la sortie est :
Exception in thread "main" java.lang.AssertionError: Here's a message saying what happened
at Assert2.main(Assert2.java:6)
Bien que ce que l'on voit ici soit un simple objet String, l'expression d'information peut produire n'importe quel type d'objet et donc on construira typiquement une chaîne plus complexe contenant par exemple les valeurs des objets impliqués dans l'échec de l'assertion.
Comme la seule manière d'avoir une information utile à partir d'une assertion en échec est d'utiliser l'expression d'information, c'est la forme qui sera toujours utilisée dans ce livre et la première forme sera considérée comme un mauvais choix.
On peut décider d'activer et de désactiver les assertions en fonction d'un nom de classe ou de package (c'est-à-dire que l'on peut activer ou désactiver les assertions pour le package en entier). Les détails peuvent être trouvés dans la documentation du JDK 1.4 dans la documentation sur les assertions. Cela peut être utile pour les grands projets utilisant les assertions et que l'on veuille en désactiver une partie. Malgré tout, tracer et déboguer (tous deux décrits dans ce chapitre ci-après) sont probablement de meilleurs outils pour capturer ce type d'information. Dans ce livre, on activera l'ensemble des assertions lorsque cela sera nécessaire et nous ignorerons donc la possibilité d'utiliser un contrôle plus fin des assertions.
Il existe une autre manière de contrôler les assertions de manière programmatique, en utilisant l'objet ClassLoader. Le JDK 1.4 a ajouté plusieurs nouvelles méthodes à ClassLoader qui permettent l'activation et la désactivation dynamique des assertions, incluant setDefaultAssertionStatus( ), qui définit le statut des assertions pour toutes les classes chargées après. On pourrait donc penser qu'il est presque possible d'activer silencieusement les assertions de cette manière :
//: c15:LoaderAssertions.java
// Using the class loader to enable assertions
// Compile with: javac -source 1.4 LoaderAssertions.java
// {ThrowsException}
public
class
LoaderAssertions {
public
static
void
main
(
String[] args) {
ClassLoader.getSystemClassLoader
(
)
.setDefaultAssertionStatus
(
true
);
new
Loaded
(
).go
(
);
}
}
class
Loaded {
public
void
go
(
) {
assert
false
: "Loaded.go()"
;
}
}
///:~
Bien que cela élimine le besoin d'utiliser le drapeau -ea de la ligne de commande lorsque le programme Java est exécuté, ce n'est pas une solution complète, car on doit toujours tout compiler avec le drapeau -source 1.4. Il est peut-être aussi direct d'activer les assertions en utilisant les arguments de la ligne de commande ; très probablement lors de la livraison d'un produit autonome, on a de toute manière à mettre au point un script d'exécution pour que l'utilisateur démarre le programme dans le but de configurer les autres paramètres de démarrage.
Malgré tout, il est sensé de décider que l'on souhaite que l'activation des assertions soit requise lorsque le programme est exécuté. On peut accomplir ceci avec la clause static suivante, placée dans la classe main du système :
static
{
boolean
assertionsEnabled =
false
;
// Note intentional side effect of assignment:
assert
assertionsEnabled =
true
;
if
(!
assertionsEnabled)
throw
new
RuntimeException
(
"Assertions disabled"
);
}
Si les assertions sont activées, alors l'instruction assert sera exécutée et assertionsEnabled aura pour valeur true. L'assertion ne sera jamais mise en échec, car la valeur de retour de l'assignation est la valeur assignée. Si les assertions sont désactivées, l'instruction assert ne sera pas exécutée et assertionsEnabled restera à false, avec pour résultat le déclenchement de l'exception.
XV-B-2. Utiliser les assertions pour la Conception par Contrat▲
La Conception par Contrat (CPC) est un concept développé par Bertrand Meyer, créateur du langage de programmation Eiffel, pour aider à la création de programmes robustes en garantissant que les objets respectent certaines règles qui ne peuvent pas être vérifiées lors de la phase de compilation. (93) Ces règles sont déterminées par la nature du problème à résoudre, ce qui est hors de portée de ce que le compilateur peut connaître et tester.
Bien que les assertions n'implémentent pas directement la CPC (comme le fait le langage Eiffel), elles peuvent être utilisées pour créer un style informel de programmation par CPC.
L'idée fondamentale de la CPC est qu'il existe un contrat clairement spécifié entre le fournisseur d'un service et le consommateur ou client de ce service. En programmation orientée objet, les services sont fournis habituellement par les objets, et la frontière de l'objet - la séparation entre le fournisseur et le consommateur - est l'interface de la classe de l'objet. Lorsque les clients appellent une méthode publique particulière, ils attendent un certain comportement de cet appel : un changement d'état dans l'objet et une valeur de retour prévisible. La thèse de Meyer est que :
- Ce comportement peut être clairement spécifié, comme si c'était un contrat.
- Ce comportement peut être garanti par l'implémentation de certaines vérifications lors de l'exécution qu'il appelle préconditions, postconditions et invariants.
Que vous soyez d'accord ou pas avec le point 1, il apparaît être vrai dans suffisamment de situations pour faire de la CPC une approche intéressante. (Je crois que, comme toute solution, il existe des limites à son utilité. Mais si l'on connaît ces limites, on sait quand essayer de l'appliquer.) En particulier, une partie très intéressante du processus de conception est l'expression des contraintes de la CPC pour une classe particulière ; si on est incapable de spécifier ces contraintes, on n'en sait probablement pas assez sur ce que l'on tente de construire.
XV-B-2-a. Vérifier les instructions▲
Avant d'aller en profondeur dans les moyens offerts par la CPC, considérons l'utilisation la plus simple pour les assertions, que Meyer appelle la vérification d'instruction. Une vérification d'instruction exprime votre conviction qu'une propriété particulière sera satisfaite à cet endroit dans votre code. L'idée de la vérification d'instruction est d'exprimer des conclusions non évidentes dans le code, et pas seulement pour tester, mais aussi pour documenter le code pour ses futurs lecteurs.
Par exemple, dans un processus chimique, on peut titrer un liquide clair avec un autre, et arriver à un certain point, tout devient bleu. Cela n'est pas évident partant de la couleur des deux liquides; cela fait partie d'une réaction complexe. Une vérification d'instruction utile à la fin du processus de titrage asserterait que le liquide résultant est bleu.
Un autre exemple est la méthode Thread.holdsLock( ) introduite dans le JDK 1.4. Elle est utilisée lors de situations de threads complexes (tel que l'itération dans une collection d'une manière thread-safe) où vous devez vous appuyer sur un programme client ou sur une autre classe dans votre système utilisant votre propre librairie, plutôt que le seul mot clé synchronized. Pour s'assurer que le code suit de manière propre les règles de la conception de votre librairie, vous pouvez asserter que le thread courant conserve de manière effective le lock :
assert
Thread.holdsLock
(
this
); // lock-status assertion
La vérification d'instructions est un ajout précieux au code. Comme les assertions peuvent être désactivées, la vérification d'instructions devrait être utilisée lorsque l'on a une connaissance non évidente à propos de l'état d'un objet ou du programme.
XV-B-2-b. Préconditions▲
Une précondition est un test qui s'assure que le client (le code appelant cette méthode) a rempli sa part du contrat. Cela signifie presque toujours la vérification des arguments au tout début de l'appel de la méthode (avant toute autre action dans la méthode) afin de s'assurer que les arguments sont appropriés pour leur utilisation dans la méthode. Puisqu'on ne sait jamais ce que le client va passer, la vérification des préconditions est toujours une bonne idée.
XV-B-2-c. Postconditions▲
Un test de postcondition vérifie les résultats de ce qui a été fait dans la méthode. Ce code est placé à la fin de l'appel de la méthode, avant l'instruction return, s'il y en a une. Pour les méthodes longues et complexes où le résultat des calculs doit être vérifié avant d'être retourné (c'est-à-dire, dans les situations où pour une raison quelconque on ne peut pas toujours avoir confiance dans le résultat), les vérifications de postcondition sont essentielles. À chaque fois que l'on peut décrire les contraintes sur le résultat d'une méthode, il est sage d'exprimer ces contraintes dans le code comme une postcondition. En Java, cela est codé avec des assertions, mais les instructions d'assertion varieront d'une méthode à une autre.
XV-B-2-d. Invariants▲
Un invariant donne des garanties quant au maintien de l'état d'un objet entre des appels de méthodes. Malgré tout, cela ne force pas une méthode à diverger de ces garanties lors de son exécution. Cela veut juste dire que l'état d'information de l'objet obéira toujours à ces règles :
- À l'entrée de la méthode.
- Avant de quitter la méthode.
De plus, l'invariant est une garantie de l'état de l'objet après construction.
Selon cette description, un invariant effectif serait défini comme une méthode, probablement appelée invariant( ), qui serait invoquée après la construction, et au début et à la fin de chaque méthode. Cette méthode pourrait être appelée de la manière suivante :
assert
invariant
(
);
De cette manière, si l'on choisit de désactiver les assertions pour des raisons de performance, il n'y aura aucun surcoût.
XV-B-2-e. Affaiblir la CPC▲
Bien qu'il mette en avant l'importance de pouvoir exprimer des préconditions, postconditions et invariants, et la valeur de les utiliser lors du développement, Meyer admet qu'il n'est pas toujours pratique d'inclure du code CPC dans un produit livrable. On peut affaiblir la vérification CPC sur la base du degré de confiance que l'on place dans le code à un point particulier. Voici l'ordre d'affaiblissement, du plus sûr au moins sûr :
- La vérification d'invariant au début de chaque méthode peut être désactivée en premier, puisque la vérification d'invariant à la fin de chaque méthode garantira que l'état de l'objet sera valide au début de chaque appel de méthode. Ainsi, on peut généralement avoir confiance dans le fait que l'état de l'objet ne changera pas entre les appels de méthode. Ceci est une hypothèse tellement sûre que l'on peut choisir d'écrire du code avec des vérifications d'invariant seulement à la fin.
- La vérification des postconditions peut être désactivée ensuite, si l'on possède des tests unitaires suffisamment raisonnables qui vérifient que les méthodes retournent des valeurs appropriées. Puisque la vérification d'invariant observe l'état de l'objet, la vérification des postconditions valide seulement les résultats de calculs dans une méthode, et ainsi peut être supprimée en faveur des tests unitaires. Les tests unitaires ne seront pas aussi sûrs qu'une vérification des posconditions à l'exécution, mais ils peuvent être suffisants, particulièrement si l'on a suffisamment confiance dans le code.
- La vérification d'invariant à la fin de l'appel d'une méthode peut être désactivée si l'on est suffisamment sûr que le corps d'une méthode ne met pas l'objet dans un état invalide. Il est possible de vérifier ceci avec des tests unitaires de type boite blanche (c'est-à-dire des tests unitaires qui ont accès aux champs privés et qui peuvent ainsi valider l'état de l'objet). Ainsi, bien que cela ne soit peut-être pas aussi robuste que des appels à invariant( ), il est possible de « migrer » la vérification d'invariant, de tests à l'exécution en tests à la compilation (grâce aux tests unitaires), de la même manière que les postconditions.
- Finalement, en dernier ressort, on peut désactiver les vérifications de préconditions. Ceci est le moins sûr et le moins recommandable, car bien que l'on connaisse et que l'on a le contrôle sur son propre code, on n'a aucun contrôle sur les arguments que le client peut passer à la méthode. Malgré tout, dans une situation où (a) le besoin de performance est désespéré et le profilage a montré que les vérifications de préconditions sont des goulots d'étranglement et (b) on possède des assurances suffisantes quant au fait que le client ne violera pas les préconditions (comme dans le cas où l'on écrit soi-même le code client), il peut être acceptable de désactiver les vérifications de préconditions.
On ne doit pas supprimer le code qui effectue les vérifications décrites ici lors de leurs désactivations. Si un bogue est découvert, on souhaite pouvoir activer facilement les vérifications afin de découvrir rapidement le problème.
XV-B-3. Exemple: CPC + test unitaire boîte blanche▲
L'exemple suivant montre la puissance de la combinaison des concepts venant de la Conception par Contrat et des tests unitaires. Il montre une classe pour une petite queue premier entré, premier sorti (FIFO) qui est implémentée par un tableau « circulaire » - c'est-à-dire, un tableau utilisé de manière circulaire. Lorsque la fin du tableau est atteinte, la classe retourne au début du tableau.
Nous pouvons faire un certain de nombre de définitions contractuelles pour cette queue:
- Précondition (pour un put( )): les éléments nuls ne peuvent pas être ajoutés à la queue.
- Précondition (pour un put( )): il est illégal de mettre des éléments dans une queue pleine.
- Précondition (pour un get( )): Il est illégal d'essayer d'obtenir des éléments d'une queue vide.
- Postcondition (pour un get( )): Des éléments nuls ne peuvent être obtenus à partir du tableau.
- Invariant: La région du tableau contenant des objets ne peut contenir aucun élément nul.
- Invariant: La région du tableau qui ne contient pas d'objets doit avoir seulement des valeurs nulles.
Voici une manière d'implémenter ces règles, en utilisant des appels explicites de méthode pour chaque type d'élément de la CPC :
//: c15:Queue.java
// Demonstration of Design by Contract (DBC) combined
// with white-box unit testing.
// {Depends: junit.jar}
import
junit.framework.*;
import
java.util.*;
public
class
Queue {
private
Object[] data;
private
int
in =
0
, // Next available storage space
out =
0
; // Next gettable object
// Has it wrapped around the circular queue?
private
boolean
wrapped =
false
;
public
static
class
QueueException extends
RuntimeException {
public
QueueException
(
String why) {
super
(
why); }
}
public
Queue
(
int
size) {
data =
new
Object[size];
assert
invariant
(
); // Must be true after construction
}
public
boolean
empty
(
) {
return
!
wrapped &&
in ==
out;
}
public
boolean
full
(
) {
return
wrapped &&
in ==
out;
}
public
void
put
(
Object item) {
precondition
(
item !=
null
, "put() null item"
);
precondition
(!
full
(
), "put() into full Queue"
);
assert
invariant
(
);
data[in++
] =
item;
if
(
in >=
data.length) {
in =
0
;
wrapped =
true
;
}
assert
invariant
(
);
}
public
Object get
(
) {
precondition
(!
empty
(
), "get() from empty Queue"
);
assert
invariant
(
);
Object returnVal =
data[out];
data[out] =
null
;
out++
;
if
(
out >=
data.length) {
out =
0
;
wrapped =
false
;
}
assert
postcondition
(
returnVal !=
null
, "Null item in Queue"
);
assert
invariant
(
);
return
returnVal;
}
// Design-by-contract support methods:
private
static
void
precondition
(
boolean
cond, String msg) {
if
(!
cond) throw
new
QueueException
(
msg);
}
private
static
boolean
postcondition
(
boolean
cond, String msg) {
if
(!
cond) throw
new
QueueException
(
msg);
return
true
;
}
private
boolean
invariant
(
) {
// Guarantee that no null values are in the
// region of 'data' that holds objects:
for
(
int
i =
out; i !=
in; i =
(
i +
1
) %
data.length)
if
(
data[i] ==
null
)
throw
new
QueueException
(
"null in queue"
);
// Guarantee that only null values are outside the
// region of 'data' that holds objects:
if
(
full
(
)) return
true
;
for
(
int
i =
in; i !=
out; i =
(
i +
1
) %
data.length)
if
(
data[i] !=
null
)
throw
new
QueueException
(
"non-null outside of queue range: "
+
dump
(
));
return
true
;
}
private
String dump
(
) {
return
"in = "
+
in +
", out = "
+
out +
", full() = "
+
full
(
) +
", empty() = "
+
empty
(
) +
", queue = "
+
Arrays.asList
(
data);
}
// JUnit testing.
// As an inner class, this has access to privates:
public
static
class
WhiteBoxTest extends
TestCase {
private
Queue queue =
new
Queue
(
10
);
private
int
i =
0
;
public
WhiteBoxTest
(
String name) {
super
(
name);
while
(
i <
5
) // Preload with some data
queue.put
(
""
+
i++
);
}
// Support methods:
private
void
showFullness
(
) {
assertTrue
(
queue.full
(
));
assertFalse
(
queue.empty
(
));
// Dump is private, white-box testing allows access:
System.out.println
(
queue.dump
(
));
}
private
void
showEmptiness
(
) {
assertFalse
(
queue.full
(
));
assertTrue
(
queue.empty
(
));
System.out.println
(
queue.dump
(
));
}
public
void
testFull
(
) {
System.out.println
(
"testFull"
);
System.out.println
(
queue.dump
(
));
System.out.println
(
queue.get
(
));
System.out.println
(
queue.get
(
));
while
(!
queue.full
(
))
queue.put
(
""
+
i++
);
String msg =
""
;
try
{
queue.put
(
""
);
}
catch
(
QueueException e) {
msg =
e.getMessage
(
);
System.out.println
(
msg);
}
assertEquals
(
msg, "put() into full Queue"
);
showFullness
(
);
}
public
void
testEmpty
(
) {
System.out.println
(
"testEmpty"
);
while
(!
queue.empty
(
))
System.out.println
(
queue.get
(
));
String msg =
""
;
try
{
queue.get
(
);
}
catch
(
QueueException e) {
msg =
e.getMessage
(
);
System.out.println
(
msg);
}
assertEquals
(
msg, "get() from empty Queue"
);
showEmptiness
(
);
}
public
void
testNullPut
(
) {
System.out.println
(
"testNullPut"
);
String msg =
""
;
try
{
queue.put
(
null
);
}
catch
(
QueueException e) {
msg =
e.getMessage
(
);
System.out.println
(
msg);
}
assertEquals
(
msg, "put() null item"
);
}
public
void
testCircularity
(
) {
System.out.println
(
"testCircularity"
);
while
(!
queue.full
(
))
queue.put
(
""
+
i++
);
showFullness
(
);
// White-box testing accesses private field:
assertTrue
(
queue.wrapped);
while
(!
queue.empty
(
))
System.out.println
(
queue.get
(
));
showEmptiness
(
);
while
(!
queue.full
(
))
queue.put
(
""
+
i++
);
showFullness
(
);
while
(!
queue.empty
(
))
System.out.println
(
queue.get
(
));
showEmptiness
(
);
}
}
public
static
void
main
(
String[] args) {
junit.textui.TestRunner.run
(
Queue.WhiteBoxTest.class
);
}
}
///:~
Le compteur in indique la place dans le tableau où ira le prochain objet, et le compteur out indique d'où le prochain objet viendra. Le drapeau wrapped montre que in a fait « un tour du cercle » et est maintenant en haut et derrière out. Lorsque in et out coïncident, la queue est vide (si wrapped est faux) ou pleine (si wrapped est vrai).
On peut voir que les méthodes put( ) et get( ) appellent les méthodes precondition( ), postcondition( ), et invariant( ), qui sont des méthodes private définies plus bas dans la classe. precondition( ) et postcondition( ) sont des méthodes utilitaires conçues pour clarifier le code. Remarquez que precondition( ) retourne void car elle n'est pas utilisée avec assert. Comme noté précédemment, on souhaite généralement garder les préconditions dans le code; malgré tout, en les enrobant dans un appel à la méthode precondition( ), on obtient de meilleures options si l'on est réduit au changement désastreux qui est de les désactiver.
postcondition( ) et invariant( ) retournent une valeur booléenne afin qu'elles puissent être utilisées dans des instructions assert. Ainsi, si les assertions sont désactivées pour des raisons de performance, il n'y aura aucun appel de méthodes.
invariant( ) exécute des vérifications internes concernant la validité des objets. On peut voir ceci comme une opération coûteuse à faire à la fois en début et en fin de tout appel de méthode, comme Meyer le suggère. Malgré tout, cela a une grande valeur d'avoir ceci présenté de manière claire dans le code et ceci m'a aidé à obtenir une implémentation qui soit correcte. De plus, si l'on fait un changement quelconque de l'implémentation, invariant( ) assurera que le code n'est pas abîmé. Mais on peut voir qu'il serait trivial de bouger les tests d'invariant des appels de méthodes vers les tests unitaires. Si les tests unitaires sont raisonnablement minutieux, on peut avoir un niveau suffisant de confiance dans le fait que les invariants seront respectés.
Notez que la méthode utilitaire dump( ) retourne une chaîne contenant toutes les données plutôt que d'imprimer les données directement. Cette approche permet beaucoup plus d'options quant à la manière d'utiliser l'information.
La sous-classe WhiteBoxTest de type TestCase est créée comme une classe interne afin d'accéder aux éléments private de Queue et est ainsi capable de valider l'implémentation sous-jacente et pas seulement le comportement de la classe comme dans un test boîte blanche. Le constructeur ajoute quelques données afin que Queue soit partiellement remplie pour chaque test. Les méthodes de support showFullness( ) et showEmptiness( ) sont conçues pour être appelées afin de vérifier que Queue soit respectivement vide ou plein. Chacune des quatre méthodes assure qu'un aspect différent des opérations de Queue fonctionne correctement.
Notez qu'en combinant la CPC avec les tests unitaires, on n'obtient pas seulement le meilleur des deux mondes, mais aussi une voie de migration - on peut transformer les tests CPC en test unitaires plutôt que de simplement les désactiver. Ainsi, on conserve un certain niveau de test.
XV-C. Construire avec Ant▲
J'ai commencé ma carrière en écrivant des programmes en assembleur qui contrôlaient des appareils temps réel. Ces programmes tenaient habituellement dans un seul fichier, donc quand me fut présenté l'utilitaire make , je n'en fus pas vraiment excité, car la chose la plus complexe que j'avais eu à faire était d'exécuter un compilateur assembleur ou C sur quelques fichiers de code. À l'époque, construire un projet n'était pas la plus difficile de mes tâches et il n'était pas trop difficile de tout exécuter à la main.
Le temps passa et deux évènements se produisirent. Premièrement, je me mis à créer des projets plus complexes contenant beaucoup plus de fichiers. Garder trace de quels fichiers avaient besoin d'être compilés devint au-dessus de ce à quoi j'étais capable (ou voulais) penser. Deuxièmement, à cause de cette complexité, je commençais à réaliser que quelle que soit la simplicité du processus de construction, si l'on répète quelque chose plus de deux ou trois fois, on devient négligent et le processus commence à se lézarder.
XV-C-1. Tout automatiser▲
J'en suis venu à réaliser que pour qu'un système soit construit de manière robuste et sûre, j'avais besoin d'automatiser l'ensemble de ce qui fait partie d'un processus de construction. Cela requiert de la concentration dès le début, comme écrire un programme requiert de la concentration, mais le gain est que l'on résout le problème une seule fois et que l'on s'appuie ensuite sur la configuration du processus de construction pour prendre en compte les nouveaux détails. C'est une variation de l'abstraction, principe fondamental de la programmation : on s'élève au-dessus des détails quotidiens en cachant ces détails à l'intérieur d'un processus et en donnant à ce processus un nom. Pendant de longues années, le nom de ce processus était make.
L'utilitaire make apparut avec le C comme un outil permettant de créer le système d'exploitation Unix. La fonction primaire de make est de comparer la date de deux fichiers et d'exécuter certaines opérations qui mettront à jour les deux fichiers l'un par rapport à l'autre. Les relations entre fichiers dans vos projets et les règles nécessaires permettant de les mettre à jour (la règle est d'habitude d'exécuter le compilateur C/C++ sur un fichier source) sont contenues dans un makefile. Le programmeur crée un makefile contenant la description de la manière dont est construit le système. Lorsque l'on veut mettre le système à jour, on tape simplement make sur la ligne de commande. À ce jour, installer des programmes Unix/Linux consiste à les désarchiver et à taper des commandes make.
XV-C-2. Les problèmes avec make▲
Le concept de make est clairement une bonne idée et cette idée proliféra avec beaucoup de version de make. Les vendeurs de compilateurs C et C++ inclurent typiquement leur propre variation de make avec leur compilateur - ces variations prenaient souvent des libertés avec ce que les gens considéraient comme étant les règles standards des makefile, donc les makefile résultants ne s'exécutaient pas comme souhaité. Le problème fut finalement résolu (comme c'est souvent le cas) par un make qui était, et est toujours, supérieur à tous les autres make, et qui est aussi gratuit, donc il n'y a aucune résistance à l'utiliser : le make GNU. (94) Cet outil a un ensemble significativement meilleur de caractéristiques que les autres versions de make et est disponible sur toutes les plateformes.
Dans les deux éditions précédentes de Thinking in Java, j'utilisais des makefile pour construire l'ensemble du code contenu dans l'arbre des sources du livre. Je générais ces makefile automatiquement - un par répertoire, et un makefile maître dans le répertoire racine qui appelait les autres - utilisant un outil que j'avais écris à l'origine en C++ (en deux semaines) pour Thinking in C++, et que j'ai réécrit plus tard en Python (en une demi-journée) appelé MakeBuilder.py. (95) J'ai travaillé à la fois pour Windows et pour Linux/Unix, mais je devais écrire du code supplémentaire pour pouvoir le faire, et je n'ai jamais essayé pour Macintosh. C'est ici que se situe le premier problème avec make : on peut le faire marcher sur de multiples plateformes, mais il n'est pas de manière inhérente transplateformes. Donc pour un langage qui est supposé être « écrit une seule fois, exécuté partout » (c'est-à-dire Java), on dépense beaucoup d'effort à obtenir le même comportement avec make pour le processus de construction.
Les problèmes restants avec make peuvent être probablement résumés en disant qu'il est semblable à beaucoup d'outils développés pour Unix ; la personne créant l'outil n'a pu résister à la tentation de créer sa propre syntaxe, avec pour résultat qu'Unix est rempli d'outils extrêmement différents et également incompréhensibles. C'est-à-dire que la syntaxe de make est difficile à comprendre dans son ensemble - j'ai continué à l'apprendre pendant des années - et contient beaucoup de choses embêtantes comme son insistance à utiliser les tabulations au lieu des espaces. (96)
Tout ceci étant dit, notez que je considère toujours le make GNU comme indispensable pour beaucoup de projets que je crée.
XV-C-3. Ant : le standard de facto▲
Tous ces problèmes avec make irritèrent suffisamment un programmeur Java appelé James Duncan Davidson pour lui faire créer Ant comme outil open source qui migra vers le projet Apache à http://jakarta.apache.org/ant. Ce site contient le téléchargement complet incluant l'exécutable et la documentation Ant. Ant a grandi et s'est amélioré jusqu'à être reconnu actuellement comme l'outil de construction standard de facto pour les projets Java.
Pour rendre Ant transplateformes, le format des fichiers de description des projets est XML (couvert dans Thinking in Enterprise Java). Au lieu d'un makefile, on crée un buildfile, qui est nommé par défaut build.xml (ceci permet de juste écrire ‘ant' sur la ligne de commande. Si le buildfile est appelé autrement, on doit spécifier son nom avec un drapeau sur la ligne de commande).
La seule exigence rigide pour le buildfile est qu'il soit un fichier XML valide. Ant compense lui-même les problèmes spécifiques à chaque plateforme comme les caractères de fin de ligne ou les séparateurs de répertoire dans un chemin. On peut utiliser au choix les tabulations ou les espaces dans les buildfile. En plus, la syntaxe et le nom des balises dans les buildfile donne un code lisible, compréhensible (et donc maintenable).
Pour couronner le tout, Ant est conçu pour être extensible, avec une interface standard qui permet d'écrire ses propres tâches si celles venant avec Ant ne sont pas suffisantes (même si elles le sont généralement, et l'arsenal s'étend régulièrement).
À la différence de make, la courbe d'apprentissage pour Ant est raisonnablement douce. On n'a pas besoin de savoir beaucoup de choses pour pouvoir créer un buildfile qui compile du code Java dans un répertoire. Voici pour l'exemple un fichier build.xml très basique, extrait du chapitre 2 de ce livre:
<?xml version="1.0"?>
<project
name
=
"Thinking in Java (c02)"
default
=
"c02.run"
basedir
=
"."
>
<!-- build all classes in this directory -->
<target
name
=
"c02.build"
>
<javac
srcdir
=
"${basedir}"
classpath
=
"${basedir}/.."
source
=
"1.4"
/>
</target>
<!-- run all classes in this directory -->
<target
name
=
"c02.run"
depends
=
"c02.build"
>
<antcall
target
=
"HelloDate.run"
/>
</target>
<target
name
=
"HelloDate.run"
>
<java
taskname
=
"HelloDate"
classname
=
"HelloDate"
classpath
=
"${basedir};${basedir}/.."
fork
=
"true"
failonerror
=
"true"
/>
</target>
<!-- delete all class files -->
<target
name
=
"clean"
>
<delete>
<fileset
dir
=
"${basedir}"
includes
=
"**/*.class"
/>
<fileset
dir
=
"${basedir}"
includes
=
"**/*Output.txt"
/>
</delete>
<echo
message
=
"clean successful"
/>
</target>
</project>
La première ligne établit que le fichier est conforme à la version 1.0 de XML. XML ressemble beaucoup à HTML (notez que la syntaxe pour les commentaires est identique), à l'exception du fait que l'on peut créer ses propres noms de balises et que le format doit se conformer strictement aux règles XML. Par exemple, une balise ouvrante comme <project doit soit se fermer dans la balise en plaçant un diviser (slash) avant le caractère supérieur de fermeture (/>), soit avoir une balise fermante correspondante comme celle que l'on voit à la fin du fichier (</project>). On peut avoir des attributs à l'intérieur d'une balise, mais les valeurs des attributs doivent être entourées de guillemets droits (quote). XML permet un formatage libre, mais l'indentation que l'on voit ici est typique.
Chaque buildfile permet de gérer un seul projet décrit par sa balise <project>. Le projet a un attribut optionnel name qui est utilisé lors de l'affichage d'information concernant la construction. L'attribut default est requis et réfère à la target (cible) qui est exécutée lorsque l'on tape juste ant sur la ligne de commande et qu'aucun nom de target n'est spécifié. Le répertoire de référence basedir peut être utilisé à d'autres endroits dans le buildfile.
Une target a des dépendances et des tâches. Les dépendances disent « qu'elles sont les autres target qui doivent être exécutées avant que celle-ci puisse l'être » ? On remarque que la target par défaut à exécuter est c02.run et que cette target dépend à son tour de c02.build. Ainsi, la target c02.build doit être exécutée avant que c02.run puisse être exécutée. Partager le buildfile de cette manière ne le rend pas seulement plus facile à comprendre, mais il permet aussi de choisir ce que l'on souhaite exécuter via la ligne de commande Ant ; si l'on écrit 'ant c02.build,' alors il compilera le code, mais si l'on écrit 'ant co2.run' (ou, grâce à la target par défaut, juste 'ant') alors il s'assurera d'abord que tout a été construit et il exécutera ensuite les exemples.
Donc pour que la construction du projet soit réussie, les target c02.build et c02.run doivent d'abord s'exécuter dans cet ordre avec succès. La target c02.build contient une seule tâche, qui est une commande permettant de mettre les choses à jour. Cette tâche exécute la compilateur javac sur tous les fichiers Java dans le répertoire de base courant. Remarquez la syntaxe ${} utilisée pour produire la valeur d'une variable précédemment définie et que l'orientation des séparateurs de répertoire dans les chemins n'est pas importante, puisqu’Ant les compense seul selon le système d'exploitation sur lequel il est exécuté. L'attribut classpath donne une liste de répertoire à ajouter au classpath de Ant et source spécifie le compilateur à utiliser (ceci n'est actuellement pris en compte que pour le JDK 1.4 et supérieur). Remarquez que le compilateur Java est responsable d'ordonner les dépendances entre les classes elles-mêmes afin de ne pas spécifier les dépendances de manière explicite comme on doit le faire avec make et le C/C++ (cela épargne beaucoup d'effort).
Pour exécuter les programmes dans le répertoire (qui, dans ce cas, est le seul programme HelloDate), ce buildfile utilise une tâche appelée antcall. Cette tâche invoque récursivement Ant ou une autre target, qui dans ce cas utilise juste java pour exécuter le programme. Notez que la tâche java a un attribut taskname ; cet attribut est actuellement disponible pour toutes les tâches et est utilisé lorsque Ant sort des informations de log.
Comme on peut s'y attendre, la balise java a aussi des options pour établir le nom de la classe à exécuter et le classpath. De plus, les attributs
fork="true"
failonerror="true"
disent à Ant de démarrer un nouveau processus fils pour exécuter le programme et d'arrêter le processus Ant de construction si le programme échoue. Vous pouvez regarder toutes les différentes tâches et leurs attributs dans la documentation venant avec le téléchargement de Ant.
La dernière target se trouve typiquement dans tout buildfile; elle permet d'écrire ant clean et ceci supprime tous les fichiers qui ont été créés durant le processus de construction. À chaque fois que vous créez un buildfile, incluez par prudence une target clean car vous êtes la personne qui en sait le plus sur ce qui peut être supprimé et sur ce qui doit être conservé.
La target clean introduit de nouvelles syntaxes. On peut supprimer des éléments seuls avec la version en ligne de cette tâche, comme ceci :
<delete
file
=
"${basedir}/HelloDate.class"
/>
La version multi lignes de cette tâche permet de spécifier un fileset, qui est une description plus complexe d'un ensemble de fichiers et qui peut spécifier les fichiers à inclure et exclure en utilisant des jokers. Dans cet exemple, les ensembles de fichiers à supprimer inclus tous les fichiers qui ont une extension .class dans ce répertoire et ses sous répertoires et tous les fichiers du répertoire courant dont le nom finit par Output.txt.
Le buildfile montré ici est assez simple. À l'intérieur de l'arbre des sources du livre (qui est téléchargeable à partir de www.BruceEckel.com), vous trouverez des buildfile plus complexes. Aussi, Ant est capable de faire bien plus que ce que nous en utilisons dans ce livre. Pour des détails complets sur ses capacités, regardez la documentation venant avec l'installation de Ant.
XV-C-3-a. Les extensions de Ant▲
Ant vient avec une API d'extension qui permet de créer ses propres tâches en les écrivant en Java. Vous trouverez l'ensemble des détails dans la documentation officielle de Ant et dans les livres publiés sur Ant.
Comme alternative, on peut écrire un simple programme et l'appeler à partir de Ant; de cette manière, il n'est pas nécessaire d'apprendre l'API d'extension. Par exemple, pour compiler le code de ce livre, on doit vérifier que la version que l'utilisateur exécuté est le JDK 1.4 ou supérieur, donc nous avons créé le programme suivant :
//: com:bruceeckel:tools:CheckVersion.java
// {RunByHand}
package
com.bruceeckel.tools;
public
class
CheckVersion {
public
static
void
main
(
String[] args) {
String version =
System.getProperty
(
"java.version"
);
char
minor =
version.charAt
(
2
);
char
point =
version.charAt
(
4
);
if
(
minor <
'4'
||
point <
'1'
)
throw
new
RuntimeException
(
"JDK 1.4.1 or higher "
+
"is required to run the examples in this book."
);
System.out.println
(
"JDK version "
+
version +
" found"
);
}
}
///:~
Le programme utilise simplement System.getProperty( ) pour découvrir la version Java et il lève une exception si elle n'est pas au moins la 1.4. Lorsque Ant rencontre l'exception, il s'arrête. Maintenant, vous pouvez inclure ce qui suit dans tout buildfile où vous souhaitez vérifier le numéro de version :
<java
taskname
=
"CheckVersion"
classname
=
"com.bruceeckel.tools.CheckVersion"
classpath
=
"${basedir}"
fork
=
"true"
failonerror
=
"true"
/>
En utilisant cette approche pour ajouter des outils, vous pouvez les écrire et les tester rapidement, et s'ils le justifient, vous pouvez investir dans l'effort supplémentaire d'en écrire une extension Ant.
XV-C-4. Contrôle de version avec CVS▲
Un système de contrôle de révision est une classe d'outils qui a été développée sur beaucoup d'années afin d'aider à conduire des projets comprenant de grandes équipes de programmeurs. Il s'est aussi avéré comme étant fondamental au succès de pratiquement tous les projets open source, car les équipes open source sont presque toujours globalement distribuées via l'Internet. Donc même s'il n'y a que deux personnes à travailler sur un projet, elles bénéficieront de l'usage d'un système de contrôle de révision.
Le système de contrôle de révision standard de facto pour les projets open source est appelé Concurrent Versions System (CVS), disponible à www.cvshome.org. Puisqu'il est open source et que tellement de gens savent l'utiliser, CVS est aussi un choix habituel pour les projets fermés. Certains projets utilisent même CVS comme une manière de distribuer leur système. CVS possède les bénéfices usuels d'un projet open source populaire : le code a été minutieusement revu, il est disponible pour lecture et modification et les défauts sont rapidement corrigés.
CVS garde votre code dans un répertoire sur un serveur. Ce serveur peut être sur un réseau local, mais il est typiquement disponible sur Internet de façon à ce que les personnes de l'équipe puissent obtenir les mises à jour sans être obligées de se trouver à un endroit particulier. Pour se connecter à CVS, on doit avoir un nom et un mot de passe dédié, il y a donc un niveau raisonnable de sécurité. Pour plus de sécurité, on peut utiliser le protocole ssh (bien que ce soient des outils Linux, ils sont disponibles sous Windows en utilisant Cygwin - voir www.cygwin.com). Certains environnements de développement (comme l'éditeur gratuit Eclipse ; voir www.eclipse.org) fournissent une excellente intégration avec CVS.
Une fois que le répertoire est initialisé par votre administrateur système, les membres de l'équipe peuvent obtenir une copie de l'arbre du code en la récupérant (check out). Par exemple, une fois que votre machine est connectée sur le serveur CVS approprié (dont les détails sont omis ici), vous pouvez effectuer la récupération initiale via la ligne de commande de cette manière :
cvs -z5 co TIJ3
Ceci permet la connexion au serveur CVS et la négociation de la récupération ('co') du répertoire de code appelé TIJ3. L'argument '-z5' dit aux programmes CVS des deux côtés de communiquer en utilisant la compression gzip de niveau 5 afin d'accélérer le transfert sur le réseau.
Une fois que cette commande est finie, on a une copie du répertoire de code sur la machine locale. De plus, on voit que chaque dossier dans le répertoire a un sous-dossier additionnel appelé CVS. C'est là où sont stockées toutes les informations CVS à propos des fichiers de ce dossier.
Maintenant que vous avez votre propre copie du répertoire CVS, vous pouvez effectuer des changements aux fichiers dans le but de développer le projet. Typiquement, ces changements incluront des corrections et l'addition de fonctionnalités couplées avec du code de test et des buildfile modifiés afin de compiler et exécuter les tests. Vous verrez qu'il est très mal vu de soumettre du code qui ne passe pas correctement tous les tests, puisqu’ensuite tout le monde dans l'équipe récupéra le code cassé (et donc échouera dans le processus de construction).
Lorsque vous avez effectué vos améliorations et que vous êtes prêts à les soumettre, vous devez passer par un processus en deux étapes qui est le cœur de la synchronisation CVS du code. Premièrement, vous mettez à jour votre répertoire local pour le synchroniser avec le répertoire CVS principal en allant à la racine de votre répertoire local et en exécutant cette commande :
cvs update -dP
À ce moment, vous n'êtes pas tenu de vous loguer car le sous-dossier CVS garde les informations de login pour le répertoire distant qui lui-même garde l'information de signature de votre machine comme une deuxième sécurité lors de la vérification de votre identité.
Le drapeau '-dP' est optionnel ; '-d' indique à CVS de créer tout nouveau dossier à votre machine locale qui aurait pu être ajouté au répertoire principal et '-P' indique à CVS de supprimer tout dossier qui a été vidé sur le répertoire principal. Aucune de ces deux choses n'est exécutée par défaut.
L'effet principal de update est vraiment intéressant. Vous devriez exécuter update régulièrement, et pas juste avant de soumettre, car cela synchronise votre répertoire local avec le répertoire central. Si CVS trouve un fichier dans le répertoire central qui est modifié par rapport au fichier dans votre répertoire local, il apporte les changements sur votre machine locale. Malgré tout, il ne se contente pas de copier les fichiers, mais effectue plutôt une comparaison ligne à ligne des fichiers et ajoute les changements du répertoire principal dans votre version locale. Si vous avez fait des changements à un fichier et que quelqu'un d'autre a effectué des changements au même fichier, CVS ajoutera les changements en une fois, pour peu que les changements n'aient pas été effectués sur les mêmes lignes de code (CVS compare le contenu des lignes et pas simplement les numéros de ligne, donc même si les numéros de lignes changent, il sera capable de synchroniser proprement). Ainsi, vous pouvez travailler sur le même fichier qu'une autre personne, et lorsque vous effectuez un update, tous les changements soumis par l'autre personne au répertoire central seront fusionnés avec vos changements.
Bien sûr, il est possible que deux personnes aient fait des changements aux mêmes lignes du même fichier. Ceci est un accident dû à un manque de communication ; normalement, vous vous dites ce sur quoi vous travaillez afin de ne pas vous marcher sur les pieds (aussi, si les fichiers sont si grands qu'il est normal pour deux personnes de travailler sur des parties différentes du même fichier, on peut considérer la possibilité de diviser les gros fichiers en des fichiers plus petits pour une conduite de projet plus facile). Si cela arrive, CVS remarque simplement le conflit et vous force à le résoudre par modification des lignes de code en conflit.
Notez qu'aucun fichier de votre machine n'est déplacé dans le répertoire principal durant un update. L'update apporte seulement les fichiers modifiés du répertoire principal à votre machine et y greffe toutes les modifications que vous auriez faites. Donc comment vos modifications peuvent être ajoutées au répertoire central ? C'est la deuxième étape : le commit.
Lorsque vous tapez
cvs commit
CVS va démarrer votre éditeur par défaut et vous demandera d'écrire une description de votre modification. Cette description sera ajoutée au répertoire afin que les autres puissent savoir ce qui a changé. Après ceci, vos fichiers modifiés seront placés dans le répertoire central afin d'être disponible pour toute personne à son prochain update.
CVS a d'autres possibilités, mais récupérer (check out), mettre à jour (update) et soumettre (commit) seront les actions que vous ferez la plupart du temps. Pour des informations détaillées sur CVS, des livres sont disponibles et le site Web principal de CVS contient une documentation complète : www.cvshome.org. De plus, vous pouvez chercher sur Internet en utilisant Google ou d'autres moteurs de recherche ; il existe quelques bonnes introductions condensées à CVS avec lesquelles vous pouvez démarrer sans vous enliser dans trop de détails (le « tutoriel CVS pour Gentoo Linux » par Daniel Robbins (www.gentoo.org/doc/cvs-tutorial.html) est particulièrement direct).
XV-C-5. Construire quotidiennement▲
En incorporant la compilation et les tests dans les buildfile, on peut suivre la pratique d'exécuter un processus de construction quotidien, recommandée par les partisans de la programmation extrême (XP) et d'autres. Sans tenir compte du nombre de fonctionnalités actuellement implémentées, on garde le système dans un état dans lequel il peut être construit avec succès, de telle manière que si quelqu'un récupère les sources et exécute Ant, le buildfile effectuera toute la compilation et exécutera les tests sans échouer.
C'est une technique puissante. Cela signifie que vous avez toujours, comme base, un système qui compile et qui passe tous ses tests. À tout moment, on peut voir quel est l'état réel du processus de développement par examen des fonctionnalités qui sont actuellement implémentées dans le système exécuté. Un des aspects de cette approche permettant d'avancer plus vite est que personne n'a à perdre son temps à présenter un rapport expliquant où en est le système ; chacun peut le voir par lui-même en récupérant l'état courant du programme et en l'exécutant.
Exécuter une construction quotidienne, ou plus souvent, assure aussi que si quelqu'un (nous supposons de manière accidentelle) soumet des changements qui font échouer des tests, on le saura rapidement, avant que ces bogues aient une chance de propager d'autres problèmes dans le système. Ant a même une tâche qui envoie un email, car beaucoup d'équipes mettent en place leur buildfile comme un job cron(97) pour être exécuté automatiquement chaque jour, ou même plusieurs fois par jour et pour envoyer un email s'il échoue. Il existe aussi un outil open source qui construit automatiquement et fournit une page Web montrant le statut du projet ; voir http://cruisecontrol.sourceforge.net.
XV-D. Loguer▲
Loguer est le processus consistant à rapporter de l'information concernant un programme qui s'exécute. Dans un programme sans bogues, cette information peut être un statut ordinaire sous forme de données qui décrivent les progrès du programme (par exemple, si on a un programme d'installation, on peut loguer les étapes de l'installation, les dossiers où sont stockés les fichiers, les valeurs de démarrage du programme, etc.).
Loguer est aussi très utile durant le débogage. Sans logues, on peut essayer de disséquer le comportement du programme en insérant des instructions println( ). Beaucoup d'exemples dans ce livre utilisent cette technique très simple et en l'absence d'un débogueur (un sujet qui sera introduit bientôt), c'est tout ce que l'on a. Malgré tout, une fois que l'on décide que le programme marche correctement, on enlèvera probablement les instructions println( ). Alors si l'on rencontre de nouveaux bogues, on peut avoir besoin de les remettre. Ce serait bien meilleur si l'on pouvait mettre un type d'instructions de sortie, qui ne seraient utilisées que lorsqu'elles sont nécessaires.
Avant la disponibilité de l'API de logue du JDK 1.4, les programmeurs utilisaient souvent une technique basée sur le fait que le compilateur Java optimisera toujours le code qui n'est pas appelé en l'enlevant. Si debug est un booléen statique final et que vous dites :
if
(
debug) {
System.out.println
(
"Debug info"
);
}
alors lorsque debug est faux, le compilateur supprimera complètement le code entre les accolades (ainsi le code lorsqu'il n'est pas utilisé ne provoque aucun surcoût à l'exécution). En utilisant cette technique, on peut placer des traces à travers le programme et les activer ou désactiver facilement. Malgré tout, un inconvénient de cette technique est que l'on doit recompiler le code de façon à activer ou désactiver les instructions de trace, alors qu'il est plus pratique de pouvoir activer les traces sans recompiler le programme, en utilisant un fichier de configuration que l'on peut changer afin de modifier les propriétés de logue.
L'API de logue du JDK 1.4 fournit un moyen plus sophistiqué de rapporter de l'information concernant un programme avec presque la même efficacité que la technique de l'exemple précédent. Pour une information de logue très simple, on peut faire quelque chose comme ceci :
//: c15:InfoLogging.java
import
com.bruceeckel.simpletest.*;
import
java.util.logging.*;
import
java.io.*;
public
class
InfoLogging {
private
static
Test monitor =
new
Test
(
);
private
static
Logger logger =
Logger.getLogger
(
"InfoLogging"
);
public
static
void
main
(
String[] args) {
logger.info
(
"Logging an INFO-level message"
);
monitor.expect
(
new
String[] {
"%% .* InfoLogging main"
,
"INFO: Logging an INFO-level message"
}
);
}
}
///:~
La sortie durant une exécution du programme est :
Jul 7, 2002 6:59:46 PM InfoLogging main
INFO: Logging an INFO-level message
Notez que le système de logue a détecté le nom de la classe et le nom de la méthode desquels le message de logue est originaire. Il n'est pas garanti que ces noms seront corrects, donc ne vous basez pas sur leurs exactitudes. Si vous voulez vous assurer que les noms de la classe et de la méthode soient corrects, vous pouvez utiliser une méthode plus complexe pour loguer le message, comme ceci :
//: c15:InfoLogging2.java
// Garantir les bons noms de classe et méthode
import
com.bruceeckel.simpletest.*;
import
java.util.logging.*;
import
java.io.*;
public
class
InfoLogging2 {
private
static
Test monitor =
new
Test
(
);
private
static
Logger logger =
Logger.getLogger
(
"InfoLogging2"
);
public
static
void
main
(
String[] args) {
logger.logp
(
Level.INFO, "InfoLogging2"
, "main"
,
"Logging an INFO-level message"
);
monitor.expect
(
new
String[] {
"%% .* InfoLogging2 main"
,
"INFO: Logging an INFO-level message"
}
);
}
}
///:~
La méthode logp( ) prend comme arguments le niveau de logue (vous en apprendrez plus sur ceci plus loin), le nom de la classe et le nom de la méthode, et la chaîne de logue. Vous pouvez voir que c'est beaucoup plus simple de s'appuyer sur l'approche automatique si les noms de la classe et de la méthode rapportés dans les logues ne sont pas critiques.
XV-D-1. Les niveaux de logue▲
L'API de logue fournit de multiples niveaux de rapport et la capacité de changer le niveau durant l'exécution d'un programme. Ainsi, on peut fixer dynamiquement le niveau de logue à chacun des états suivants :
Niveau |
Effet |
Valeur Numérique |
---|---|---|
OFF |
Aucun message de logue n'est affiché. |
Integer.MAX_VALUE |
SEVERE |
Seuls les messages de logue de niveau SEVERE sont rapportés. |
1000 |
WARNING |
Seuls les messages de logue de niveau WARNING et SEVERE sont rapportés. |
900 |
INFO |
Seuls les messages de logue de niveau INFO et au-dessus sont rapportés. |
800 |
CONFIG |
Seuls les messages de logue de niveau CONFIG et au-dessus sont rapportés. |
700 |
FINE |
Seuls les messages de logue de niveau FINE et au-dessus sont rapportés. |
500 |
FINER |
Seuls les messages de logue de niveau FINER et au-dessus sont rapportés. |
400 |
FINEST |
Seuls les messages de logue de niveau FINEST et au-dessus sont rapportés. |
300 |
ALL |
Tous les messages de logue sont rapportés. |
Integer.MIN_VALUE |
On peut même hériter de java.util.Logging.Level (qui a des constructeurs protected) et définir notre propre niveau. Celui-ci pourrait, par exemple, avoir une valeur inférieure à 300 et donc le niveau serait inférieur à FINEST. Donc les messages de logue de notre nouveau niveau n'apparaîtraient pas lorsque le niveau est à FINEST.
On peut voir les effets de l'essai de différents niveaux de logue dans l'exemple suivant :
//: c15:LoggingLevels.java
import
com.bruceeckel.simpletest.*;
import
java.util.logging.Level;
import
java.util.logging.Logger;
import
java.util.logging.Handler;
import
java.util.logging.LogManager;
public
class
LoggingLevels {
private
static
Test monitor =
new
Test
(
);
private
static
Logger
lgr =
Logger.getLogger
(
"com"
),
lgr2 =
Logger.getLogger
(
"com.bruceeckel"
),
util =
Logger.getLogger
(
"com.bruceeckel.util"
),
test =
Logger.getLogger
(
"com.bruceeckel.test"
),
rand =
Logger.getLogger
(
"random"
);
private
static
void
logMessages
(
) {
lgr.info
(
"com : info"
);
lgr2.info
(
"com.bruceeckel : info"
);
util.info
(
"util : info"
);
test.severe
(
"test : severe"
);
rand.info
(
"random : info"
);
}
public
static
void
main
(
String[] args) {
lgr.setLevel
(
Level.SEVERE);
System.out.println
(
"com level: SEVERE"
);
logMessages
(
);
util.setLevel
(
Level.FINEST);
test.setLevel
(
Level.FINEST);
rand.setLevel
(
Level.FINEST);
System.out.println
(
"individual loggers set to FINEST"
);
logMessages
(
);
lgr.setLevel
(
Level.SEVERE);
System.out.println
(
"com level: SEVERE"
);
logMessages
(
);
monitor.expect
(
"LoggingLevels.out"
);
}
}
///:~
Les premières lignes de main( ) sont nécessaires, car par défaut le niveau de logue des messages est INFO et supérieur (plus grave). Si on ne change pas ceci, alors les messages de niveau CONFIG et inférieurs ne seront pas rapportés (essayez d'enlever les lignes pour voir ce qui se passe).
On peut avoir de multiples objets de logue dans le programme et ces logueurs sont organisés en un arbre hiérarchique, qui peut être associé programmatiquement avec l'espace de nom du package. Les logueurs enfants gardent trace de leur parent immédiat et par défaut passent leurs logues à leur parent.
Le logueur « racine » est toujours créé par défaut et est la base de l'arbre des objets de logue. On peut obtenir une référence au logueur racine en appelant la méthode statique Logger.getLogger(""). Notez que la méthode prend une chaîne vide plutôt qu'aucun argument.
Chaque objet Logger peut avoir un ou plusieurs objets Handler associés. Chaque objet Handler fournit une stratégie(98) pour publier les informations de logue, qui sont contenues dans des objets LogRecord. Pour créer un nouveau type de Handler, on hérite simplement de la classe Handler et on redéfinit la méthode publish( ) (ainsi que flush( ) et close( ), afin de prendre en charge tout flux qui pourrait être utilisé dans le Handler).
Le logueur racine a toujours un gestionnaire (handler) par défaut, qui envoie le flux de sortie à la console. Afin d'accéder aux gestionnaires, on appelle getHandlers( ) sur l'objet Logger. Dans l'exemple précédent on sait qu'il n'y a qu'un seul gestionnaire donc on n'a pas besoin techniquement d'itérer sur la liste, mais il est en général plus sûr de le faire, car quelqu'un d'autre pourrait avoir ajouté d'autres gestionnaires au logueur racine. Le niveau par défaut de tout gestionnaire est INFO, donc afin de voir tous les messages, nous fixons le niveau à ALL (qui est identique à FINEST).
Le tableau des niveaux permet de tester facilement toutes les valeurs de Level. Le logueur est fixé successivement à chaque valeur et tous les différents niveaux de logue sont essayés. Dans la sortie, on peut voir que seuls les messages qui sont du niveau courant de logue ou d'un niveau plus sévère sont rapportés.
XV-D-2. Enregistrements de logue▲
Un LogRecord (enregistrement de logue) est un exemple d'objet Message, (99) dont le travail est simplement de transporter l'information d'un endroit à un autre. Toutes les méthodes dans LogRecord sont des accesseurs (getters) et des mutateurs (setters). Voici un exemple qui dépose toute l'information stockée dans LogRecord par utilisation de méthodes accesseurs :
//: c15:PrintableLogRecord.java
// Override LogRecord toString()
import
com.bruceeckel.simpletest.*;
import
java.util.ResourceBundle;
import
java.util.logging.*;
public
class
PrintableLogRecord extends
LogRecord {
private
static
Test monitor =
new
Test
(
);
public
PrintableLogRecord
(
Level level, String str) {
super
(
level, str);
}
public
String toString
(
) {
String result =
"Level<"
+
getLevel
(
) +
">
\n
"
+
"LoggerName<"
+
getLoggerName
(
) +
">
\n
"
+
"Message<"
+
getMessage
(
) +
">
\n
"
+
"CurrentMillis<"
+
getMillis
(
) +
">
\n
"
+
"Params"
;
Object[] objParams =
getParameters
(
);
if
(
objParams ==
null
)
result +=
"<null>
\n
"
;
else
for
(
int
i =
0
; i <
objParams.length; i++
)
result +=
" Param # <"
+
i +
" value "
+
objParams[i].toString
(
) +
">
\n
"
;
result +=
"ResourceBundle<"
+
getResourceBundle
(
)
+
">
\n
ResourceBundleName<"
+
getResourceBundleName
(
)
+
">
\n
SequenceNumber<"
+
getSequenceNumber
(
)
+
">
\n
SourceClassName<"
+
getSourceClassName
(
)
+
">
\n
SourceMethodName<"
+
getSourceMethodName
(
)
+
">
\n
Thread Id<"
+
getThreadID
(
)
+
">
\n
Thrown<"
+
getThrown
(
) +
">"
;
return
result;
}
public
static
void
main
(
String[] args) {
PrintableLogRecord logRecord =
new
PrintableLogRecord
(
Level.FINEST, "Simple Log Record"
);
System.out.println
(
logRecord);
monitor.expect
(
new
String[] {
"Level<FINEST>"
,
"LoggerName<null>"
,
"Message<Simple Log Record>"
,
"%% CurrentMillis<.+>"
,
"Params<null>"
,
"ResourceBundle<null>"
,
"ResourceBundleName<null>"
,
"SequenceNumber<0>"
,
"SourceClassName<null>"
,
"SourceMethodName<null>"
,
"Thread Id<10>"
,
"Thrown<null>"
}
);
}
}
///:~
PrintableLogRecord est simplement une extension de LogRecord qui redéfinit toString( ) afin d'appeler toutes les méthodes accesseurs disponibles dans LogRecord.
XV-D-3. Gestionnaires▲
Comme noté précédemment, on peut créer facilement son propre gestionnaire par héritage de Handler et définir publish( ) afin d'exécuter les opérations désirées. Malgré tout, il existe des gestionnaires prédéfinis qui satisferont probablement vos besoins sans nécessiter de travail supplémentaire :
StreamHandler |
Écrit des enregistrements formatés dans un OutputStream |
ConsoleHandler |
Écrit des enregistrements formatés vers System.err |
FileHandler |
Écrit des enregistrements de logue soit dans un fichier unique, soit dans un ensemble de fichiers rotatifs |
SocketHandler |
Écrit des enregistrements formatés vers des ports TCP distants |
MemoryHandler |
Conserve des enregistrements de logue en mémoire |
Par exemple, on souhaite souvent stocker les logues dans un fichier. FileHandler rend cela facile :
//: c15:LogToFile.java
// {Clean: LogToFile.xml,LogToFile.xml.lck}
import
com.bruceeckel.simpletest.*;
import
java.util.logging.*;
public
class
LogToFile {
private
static
Test monitor =
new
Test
(
);
private
static
Logger logger =
Logger.getLogger
(
"LogToFile"
);
public
static
void
main
(
String[] args) throws
Exception {
logger.addHandler
(
new
FileHandler
(
"LogToFile.xml"
));
logger.info
(
"A message logged to the file"
);
monitor.expect
(
new
String[] {
"%% .* LogToFile main"
,
"INFO: A message logged to the file"
}
);
}
}
///:~
Lorsque l'on exécute ce programme, on note deux choses. Premièrement, même si l'on envoie la sortie vers un fichier, on la voit toujours dans la sortie console. C'est dû au fait que le message est converti en LogRecord, qui est d'abord utilisé par l'objet logueur local, qui le passe à ses propres gestionnaires. À ce moment, le LogRecord est passé à l'objet parent, qui possède ses propres gestionnaires. Le processus continue jusqu'à ce que le logueur racine soit atteint. Le logueur racine est défini avec un ConsoleHandler par défaut, donc le message apparaît aussi bien à l'écran que dans le fichier de logue (on peut désactiver ce comportement par défaut en appelant setUseParentHandlers(false)).
La deuxième chose que l'on remarque est que le contenu du fichier de logue est au format XML, ce qui ressemblera à quelque chose comme ça :
<?xml version="1.0" standalone="no"?>
<!DOCTYPE log SYSTEM
"logger.dtd"
>
<log>
<record>
<date>
2002-07-08T12:18:17</date>
<millis>
1026152297750</millis>
<sequence>
0</sequence>
<logger>
LogToFile</logger>
<level>
INFO</level>
<class>
LogToFile</class>
<method>
main</method>
<thread>
10</thread>
<message>
A message logged to the file</message>
</record>
</log>
Le formatage par défaut de la sortie pour un FileHandler est du XML. Si l'on veut changer le format, on doit attacher un objet Formatter différent au gestionnaire. Ici, on utilise un SimpleFormatter pour le fichier afin d'obtenir en sortie un format texte simple :
//: c15:LogToFile2.java
// {Clean: LogToFile2.txt,LogToFile2.txt.lck}
import
com.bruceeckel.simpletest.*;
import
java.util.logging.*;
public
class
LogToFile2 {
private
static
Test monitor =
new
Test
(
);
private
static
Logger logger =
Logger.getLogger
(
"LogToFile2"
);
public
static
void
main
(
String[] args) throws
Exception {
FileHandler logFile=
new
FileHandler
(
"LogToFile2.txt"
);
logFile.setFormatter
(
new
SimpleFormatter
(
));
logger.addHandler
(
logFile);
logger.info
(
"A message logged to the file"
);
monitor.expect
(
new
String[] {
"%% .* LogToFile2 main"
,
"INFO: A message logged to the file"
}
);
}
}
///:~
Le fichier LogToFile2.txt ressemblera à ceci :
Jul 8, 2002 12:35:17 PM LogToFile2 main
INFO: A message logged to the file
XV-D-3-a. Gestionnaires multiples▲
On peut enregistrer de multiples gestionnaires avec chaque objet Logger. Lorsqu'une requête de logue arrive au logueur, il notifie tous ses gestionnaires enregistrés, (100) tant que le niveau de logue pour le logueur est supérieur ou égal à la requête de logue. Chaque gestionnaire, à son tour, possède son propre niveau de logue ; si le niveau de l'enregistrement de logue est supérieur ou égal au niveau du gestionnaire, alors le gestionnaire publie l'enregistrement.
Voici un exemple où l'on ajoute un FileHandler et un ConsoleHandler à l'objet Logger :
//: c15:MultipleHandlers.java
// {Clean: MultipleHandlers.xml,MultipleHandlers.xml.lck}
import
com.bruceeckel.simpletest.*;
import
java.util.logging.*;
public
class
MultipleHandlers {
private
static
Test monitor =
new
Test
(
);
private
static
Logger logger =
Logger.getLogger
(
"MultipleHandlers"
);
public
static
void
main
(
String[] args) throws
Exception {
FileHandler logFile =
new
FileHandler
(
"MultipleHandlers.xml"
);
logger.addHandler
(
logFile);
logger.addHandler
(
new
ConsoleHandler
(
));
logger.warning
(
"Output to multiple handlers"
);
monitor.expect
(
new
String[] {
"%% .* MultipleHandlers main"
,
"WARNING: Output to multiple handlers"
,
"%% .* MultipleHandlers main"
,
"WARNING: Output to multiple handlers"
}
);
}
}
///:~
Lorsque l'on exécute ce programme, on remarque que la sortie console se produit deux fois ; c'est parce que le comportement par défaut du logueur racine est toujours actif. Si vous voulez le désactiver, faites un appel à setUseParentHandlers(false) :
//: c15:MultipleHandlers2.java
// {Clean: MultipleHandlers2.xml,MultipleHandlers2.xml.lck}
import
com.bruceeckel.simpletest.*;
import
java.util.logging.*;
public
class
MultipleHandlers2 {
private
static
Test monitor =
new
Test
(
);
private
static
Logger logger =
Logger.getLogger
(
"MultipleHandlers2"
);
public
static
void
main
(
String[] args) throws
Exception {
FileHandler logFile =
new
FileHandler
(
"MultipleHandlers2.xml"
);
logger.addHandler
(
logFile);
logger.addHandler
(
new
ConsoleHandler
(
));
logger.setUseParentHandlers
(
false
);
logger.warning
(
"Output to multiple handlers"
);
monitor.expect
(
new
String[] {
"%% .* MultipleHandlers2 main"
,
"WARNING: Output to multiple handlers"
}
);
}
}
///:~
Maintenant, on ne voit qu'un seul message dans la console.
XV-D-3-b. Écrire ses propres Gestionnaires▲
On peut facilement écrire des gestionnaires personnalisés par héritage de la classe Handler. Pour faire ceci, on ne doit pas simplement implémenter la méthode publish( ) (qui effectue le rapport), mais aussi flush( ) et close( ), qui assurent que le flux utilisé pour le rapport est correctement nettoyé. Voici un exemple où l'on stocke l'information venant de LogRecord dans un autre objet (une List de String). À la fin du programme, l'objet est imprimé dans la console :
//: c15:CustomHandler.java
// How to write custom handler
import
com.bruceeckel.simpletest.*;
import
java.util.logging.*;
import
java.util.*;
public
class
CustomHandler {
private
static
Test monitor =
new
Test
(
);
private
static
Logger logger =
Logger.getLogger
(
"CustomHandler"
);
private
static
List strHolder =
new
ArrayList
(
);
public
static
void
main
(
String[] args) {
logger.addHandler
(
new
Handler
(
) {
public
void
publish
(
LogRecord logRecord) {
strHolder.add
(
logRecord.getLevel
(
) +
":"
);
strHolder.add
(
logRecord.getSourceClassName
(
)+
":"
);
strHolder.add
(
logRecord.getSourceMethodName
(
)+
":"
);
strHolder.add
(
"<"
+
logRecord.getMessage
(
) +
">"
);
strHolder.add
(
"
\n
"
);
}
public
void
flush
(
) {}
public
void
close
(
) {}
}
);
logger.warning
(
"Logging Warning"
);
logger.info
(
"Logging Info"
);
System.out.print
(
strHolder);
monitor.expect
(
new
String[] {
"%% .* CustomHandler main"
,
"WARNING: Logging Warning"
,
"%% .* CustomHandler main"
,
"INFO: Logging Info"
,
"[WARNING:, CustomHandler:, main:, "
+
"<Logging Warning>, "
,
", INFO:, CustomHandler:, main:, <Logging Info>, "
,
"]"
}
);
}
}
///:~
La sortie console vient du logueur racine. Lorsque l'ArrayList est imprimée, on peut voir que seules les informations sélectionnées ont été capturées dans l'objet.
XV-D-4. Filtres▲
Lorsque l'on écrit le code qui envoie un message de logue à l'objet Logger, on décide souvent, au moment où l'on écrit le code, du niveau de logue du message (l'API de logue permet d'inventer des systèmes plus complexes où le niveau du message peut être déterminé dynamiquement, mais ceci est peu commun en pratique). L'objet Logger a un niveau qui peut être fixé afin de décider quels niveaux de message accepter; tous les autres seront ignorés. On peut y penser comme étant une fonctionnalité de filtrage basique et c'est souvent tout ce dont on a besoin.
Parfois, malgré tout, on a besoin de filtres plus sophistiqués afin de décider si l'on accepte ou rejette un message, sur la base de quelque chose de plus que le niveau courant de logue. Pour accomplir ceci, on peut écrire des objets Filter personnalisés. Filter est une interface qui comporte une seule méthode, boolean isLoggable(LogRecord record), qui décide si oui ou non ce LogRecord est suffisamment intéressant pour être rapporté.
Une fois que Filter est créé, on l'enregistre avec soit un Logger, soit un Handler en utilisant la méthode setFilter( ). Par exemple, supposons que l'on aimerait ne loguer que des rapports à propos de Ducks :
//: c15:SimpleFilter.java
import
com.bruceeckel.simpletest.*;
import
java.util.logging.*;
public
class
SimpleFilter {
private
static
Test monitor =
new
Test
(
);
private
static
Logger logger =
Logger.getLogger
(
"SimpleFilter"
);
static
class
Duck {}
;
static
class
Wombat {}
;
static
void
sendLogMessages
(
) {
logger.log
(
Level.WARNING,
"A duck in the house!"
, new
Duck
(
));
logger.log
(
Level.WARNING,
"A Wombat at large!"
, new
Wombat
(
));
}
public
static
void
main
(
String[] args) {
sendLogMessages
(
);
logger.setFilter
(
new
Filter
(
) {
public
boolean
isLoggable
(
LogRecord record
) {
Object[] params =
record
.getParameters
(
);
if
(
params ==
null
)
return
true
; // No parameters
if
(
record
.getParameters
(
)[0
] instanceof
Duck)
return
true
; // Only log Ducks
return
false
;
}
}
);
logger.info
(
"After setting filter.."
);
sendLogMessages
(
);
monitor.expect
(
new
String[] {
"%% .* SimpleFilter sendLogMessages"
,
"WARNING: A duck in the house!"
,
"%% .* SimpleFilter sendLogMessages"
,
"WARNING: A Wombat at large!"
,
"%% .* SimpleFilter main"
,
"INFO: After setting filter.."
,
"%% .* SimpleFilter sendLogMessages"
,
"WARNING: A duck in the house!"
}
);
}
}
///:~
Avant d'activer le Filter, les messages à propos de Duck et Wombat sont rapportés. Le Filter est créé comme une classe anonyme interne qui regarde le paramètre LogRecord pour voir si un Duck a été passé comme paramètre supplémentaire à la méthode log( ). Si c'est le cas, il retourne vrai pour indiquer que le message doit être traité.
Notez que la signature de getParameters( ) indique qu'elle retournera un Object[]. Malgré tout, si aucun argument supplémentaire n'avait été passé à la méthode log( ), getParameters( ) aurait retourné null (en violation de sa signature - une mauvaise pratique de programmation). Donc au lieu d'assumer qu'un tableau est retourné (comme promis) et de vérifier qu'il est de taille zéro, on doit vérifier une valeur nulle. Si on ne fait pas ceci correctement, alors l'appel à logger.info( ) entraînera l'envoi d'une exception.
XV-D-5. Formateurs▲
Un Formatter est une manière d'insérer une opération de formatage dans les étapes de traitement d'un Handler. Si l'on enregistre un objet Formatter auprès d'un Handler, alors avant que le LogRecord ne soit publié par le Handler, il est d'abord envoyé au Formatter. Après formatage, le LogRecord est retourné au Handler, qui le publie alors.
Pour écrire un Formatter personnalisé, étendez la classe Formatter et redéfinissez format(LogRecord record). Enregistrez alors le Formatter auprès du Handler en appelant setFormatter( ) , comme montré ici :
//: c15:SimpleFormatterExample.java
import
com.bruceeckel.simpletest.*;
import
java.util.logging.*;
import
java.util.*;
public
class
SimpleFormatterExample {
private
static
Test monitor =
new
Test
(
);
private
static
Logger logger =
Logger.getLogger
(
"SimpleFormatterExample"
);
private
static
void
logMessages
(
) {
logger.info
(
"Line One"
);
logger.info
(
"Line Two"
);
}
public
static
void
main
(
String[] args) {
logger.setUseParentHandlers
(
false
);
Handler conHdlr =
new
ConsoleHandler
(
);
conHdlr.setFormatter
(
new
Formatter
(
) {
public
String format
(
LogRecord record
) {
return
record
.getLevel
(
) +
" : "
+
record
.getSourceClassName
(
) +
" -:- "
+
record
.getSourceMethodName
(
) +
" -:- "
+
record
.getMessage
(
) +
"
\n
"
;
}
}
);
logger.addHandler
(
conHdlr);
logMessages
(
);
monitor.expect
(
new
String[] {
"INFO : SimpleFormatterExample -:- logMessages "
+
"-:- Line One"
,
"INFO : SimpleFormatterExample -:- logMessages "
+
"-:- Line Two"
}
);
}
}
///:~
Souvenez-vous qu'un logueur comme myLogger possède un gestionnaire par défaut qu'il obtient de son logueur parent (le logueur racine dans ce cas). Ici, nous désactivons le gestionnaire par défaut en appelant setUseParentHandlers(false) et en ajoutant un gestionnaire console à la place. Le nouveau Formatter est créé comme une classe anonyme interne dans l'instruction setFormatter( ). L'instruction redéfinie format( ) extrait une partie de l'information du LogRecord et la formate en une chaîne.
XV-D-6. Exemple: Envoyez un rapport de logue par email▲
Actuellement, vous pouvez faire en sorte qu'un de vos gestionnaires de logue envoie un email afin d'être averti automatiquement lors de problèmes importants. L'exemple suivant utilise l'API JavaMail pour développer un agent de mail utilisateur qui envoie un email.
L'API JavaMail est un ensemble de classes qui s'interface au protocole de mail sous-jacent (IMAP, POP, SMTP). On peut mettre au point un mécanisme de notification pour des conditions exceptionnelles dans le code exécuté en enregistrant un Handler additionnel pour envoyer un email.
//: c15:EmailLogger.java
// {RunByHand} Must be connected to the Internet
// {Depends: mail.jar,activation.jar}
import
java.util.logging.*;
import
java.io.*;
import
java.util.Properties;
import
javax.mail.*;
import
javax.mail.internet.*;
public
class
EmailLogger {
private
static
Logger logger =
Logger.getLogger
(
"EmailLogger"
);
public
static
void
main
(
String[] args) throws
Exception {
logger.setUseParentHandlers
(
false
);
Handler conHdlr =
new
ConsoleHandler
(
);
conHdlr.setFormatter
(
new
Formatter
(
) {
public
String format
(
LogRecord record
) {
return
record
.getLevel
(
) +
" : "
+
record
.getSourceClassName
(
) +
":"
+
record
.getSourceMethodName
(
) +
":"
+
record
.getMessage
(
) +
"
\n
"
;
}
}
);
logger.addHandler
(
conHdlr);
logger.addHandler
(
new
FileHandler
(
"EmailLoggerOutput.xml"
));
logger.addHandler
(
new
MailingHandler
(
));
logger.log
(
Level.INFO,
"Testing Multiple Handlers"
, "SendMailTrue"
);
}
}
// A handler that sends mail messages
class
MailingHandler extends
Handler {
public
void
publish
(
LogRecord record
) {
Object[] params =
record
.getParameters
(
);
if
(
params ==
null
) return
;
// Send mail only if the parameter is true
if
(
params[0
].equals
(
"SendMailTrue"
)) {
new
MailInfo
(
"bruce@theunixman.com"
,
new
String[] {
"bruce@theunixman.com"
}
,
"smtp.theunixman.com"
, "Test Subject"
,
"Test Content"
).sendMail
(
);
}
}
public
void
close
(
) {}
public
void
flush
(
) {}
}
class
MailInfo {
private
String fromAddr;
private
String[] toAddr;
private
String serverAddr;
private
String subject;
private
String message;
public
MailInfo
(
String from, String[] to,
String server, String subject, String message) {
fromAddr =
from;
toAddr =
to;
serverAddr =
server;
this
.subject =
subject;
this
.message =
message;
}
public
void
sendMail
(
) {
try
{
Properties prop =
new
Properties
(
);
prop.put
(
"mail.smtp.host"
, serverAddr);
Session session =
Session.getDefaultInstance
(
prop, null
);
session.setDebug
(
true
);
// Create a message
Message mimeMsg =
new
MimeMessage
(
session);
// Set the from and to address
Address addressFrom =
new
InternetAddress
(
fromAddr);
mimeMsg.setFrom
(
addressFrom);
Address[] to =
new
InternetAddress[toAddr.length];
for
(
int
i =
0
; i <
toAddr.length; i++
)
to[i] =
new
InternetAddress
(
toAddr[i]);
mimeMsg.setRecipients
(
Message.RecipientType.TO,to);
mimeMsg.setSubject
(
subject);
mimeMsg.setText
(
message);
Transport.send
(
mimeMsg);
}
catch
(
Exception e) {
throw
new
RuntimeException
(
e);
}
}
}
///:~
MailingHandler est un des Handlers enregistrés auprès du logueur. Pour envoyer un email, MailingHandler utilise l'objet MailInfo. Lorsqu'un message de logue est envoyé avec le paramètre additionnel « SendMailTrue », MailingHandler envoie un email.
L'objet MailInfo contient l'état d'information nécessaire requis pour envoyer un email, tel que l'adresse vers, l'adresse depuis et le sujet. Cet état d'information est fourni à l'objet MailInfo à travers le constructeur lorsqu'il est instancié.
Pour envoyer un email, on doit d'abord établir une Session avec le serveur Simple Mail Transfer Protocol (SMTP). C'est fait en passant l'adresse du serveur à l'intérieur d'un objet Properties, dans une propriété appelée mail.smtp.host. On établit une session en appelant Session.getDefaultInstance( ), en lui passant l'objet Properties comme premier argument. Le deuxième argument est une instance d'Authenticator qui peut être utilisée pour authentifier l'utilisateur. Passer une valeur null à la place d'Authenticator spécifie qu'il n'y a pas d'authentification. Si le drapeau de débogage est activé dans l'objet Properties, les informations concernant la communication entre le serveur SMTP et le programme seront imprimées.
MimeMessage est une abstraction d'un message email Internet qui étend la classe Message. Elle construit un message qui respecte le format MIME (Multipurpose Internet Mail Extensions). Un MimeMessage est construit en lui passant une instance de Session. On peut fixer les adresses de et vers en créant une instance de la classe InternetAddress (une sous-classe de Address). On envoie le message en utilisant l'appel statique Transport.send( ) de la classe abstraite Transport. Une implémentation de Transport utilise un protocole spécifique (en général SMTP) pour communiquer avec le serveur et pour envoyer le message.
XV-D-7. Contrôler les niveaux de logue par les espaces de noms▲
Bien que cela ne soit pas obligatoire, il est recommandé de donner à un logueur le nom de la classe dans laquelle il est utilisé. Cela permet de manipuler le niveau de logue des groupes de logueurs se trouvant dans la même hiérarchie de package, avec la granularité de la structure des dossiers du package. Par exemple, on peut modifier tous les niveaux de logue de tous les packages dans com, ou juste ceux dans com.bruceeckel, ou juste ceux dans com.bruceeckel.util, comme montré dans l'exemple suivant :
//: c15:LoggingLevelManipulation.java
import
com.bruceeckel.simpletest.*;
import
java.util.logging.Level;
import
java.util.logging.Logger;
import
java.util.logging.Handler;
import
java.util.logging.LogManager;
public
class
LoggingLevelManipulation {
private
static
Test monitor =
new
Test
(
);
private
static
Logger
lgr =
Logger.getLogger
(
"com"
),
lgr2 =
Logger.getLogger
(
"com.bruceeckel"
),
util =
Logger.getLogger
(
"com.bruceeckel.util"
),
test =
Logger.getLogger
(
"com.bruceeckel.test"
),
rand =
Logger.getLogger
(
"random"
);
static
void
printLogMessages
(
Logger logger) {
logger.finest
(
logger.getName
(
) +
" Finest"
);
logger.finer
(
logger.getName
(
) +
" Finer"
);
logger.fine
(
logger.getName
(
) +
" Fine"
);
logger.config
(
logger.getName
(
) +
" Config"
);
logger.info
(
logger.getName
(
) +
" Info"
);
logger.warning
(
logger.getName
(
) +
" Warning"
);
logger.severe
(
logger.getName
(
) +
" Severe"
);
}
static
void
logMessages
(
) {
printLogMessages
(
lgr);
printLogMessages
(
lgr2);
printLogMessages
(
util);
printLogMessages
(
test);
printLogMessages
(
rand);
}
static
void
printLevels
(
) {
System.out.println
(
" -- printing levels -- "
+
lgr.getName
(
) +
" : "
+
lgr.getLevel
(
)
+
" "
+
lgr2.getName
(
) +
" : "
+
lgr2.getLevel
(
)
+
" "
+
util.getName
(
) +
" : "
+
util.getLevel
(
)
+
" "
+
test.getName
(
) +
" : "
+
test.getLevel
(
)
+
" "
+
rand.getName
(
) +
" : "
+
rand.getLevel
(
));
}
public
static
void
main
(
String[] args) {
printLevels
(
);
lgr.setLevel
(
Level.SEVERE);
printLevels
(
);
System.out.println
(
"com level: SEVERE"
);
logMessages
(
);
util.setLevel
(
Level.FINEST);
test.setLevel
(
Level.FINEST);
rand.setLevel
(
Level.FINEST);
printLevels
(
);
System.out.println
(
"individual loggers set to FINEST"
);
logMessages
(
);
lgr.setLevel
(
Level.FINEST);
printLevels
(
);
System.out.println
(
"com level: FINEST"
);
logMessages
(
);
monitor.expect
(
"LoggingLevelManipulation.out"
);
}
}
///:~
Comme on peut le voir dans ce code, si l'on passe à getLogger( ) une chaîne représentant un espace de nom, le Logger résultant contrôlera les niveaux de sécurité de l'espace de nom ; c'est-à-dire que tous les packages dans l'espace de nom seront affectés par des changements faits au niveau de la sévérité du logueur.
Chaque Logger garde une trace des ses Logger ancêtres. Si un logueur enfant a déjà un niveau de logue fixé, alors ce niveau est utilisé à la place du niveau de logue du parent. Changer le niveau de logue du parent n'affecte pas le niveau de logue de l'enfant dès lors que l'enfant possède déjà son propre niveau de logue.
Bien que le niveau des logueurs individuels soit fixé à FINEST, seuls les messages avec un niveau de logue égal ou plus sévère que INFO seront imprimés, car nous utilisons le ConsoleHandler du logueur racine, qui est à INFO.
Puisqu'il n'est pas dans le même espace de nom, le niveau de logue de random n'est pas affecté lorsque le niveau de logue du logueur com ou com.bruceeckel est changé.
XV-D-8. Pratiques de Logue pour de Grands Projets▲
Au premier coup d'œil, l'API Java de logue semble plutôt trop sophistiquée pour la plupart des problèmes de programmation. Les fonctionnalités et capacités supplémentaires ne semblent pas pratiques tant que l'on n'a pas démarré la construction de projets plus grands. Dans cette section, nous regarderons ces fonctionnalités et nous recommanderons des manières de les utiliser. Si vous n'utilisez les logues que sur de petits projets, vous n'aurez probablement pas besoin d'utiliser ces fonctionnalités.
XV-D-8-a. Fichiers de configuration▲
Le fichier suivant montre comment on peut configurer des logueurs dans un projet en utilisant un fichier de propriétés :
//:! c15:log.prop
#### Configuration File ####
# Global Params
# Handlers installed for the root logger
handlers
=
java.util.logging.ConsoleHandler java.util.logging.FileHandler
# Level for root logger-is used by any logger
# that does not have its level set
.level= FINEST
# Initialization class-the public default constructor
# of this class is called by the Logging framework
config = ConfigureLogging
# Configure FileHandler
# Logging file name - %u specifies unique
java.util.logging.FileHandler.pattern = java%g.log
# Write 100000 bytes before rotating this file
java.util.logging.FileHandler.limit = 100000
# Number of rotating files to be used
java.util.logging.FileHandler.count = 3
# Formatter to be used with this FileHandler
java.util.logging.FileHandler.formatter = java.util.logging.SimpleFormatter
# Configure ConsoleHandler
java.util.logging.ConsoleHandler.level = FINEST
java.util.logging.ConsoleHandler.formatter = java.util.logging.SimpleFormatter
# Set Logger Levels #
com.level=SEVERE
com.bruceeckel.level = FINEST
com.bruceeckel.util.level = INFO
com.bruceeckel.test.level = FINER
random.level= SEVERE
///:~
Le fichier de configuration permet d'associer des gestionnaires au logueur racine. La propriété handlers spécifie la liste des gestionnaires, séparés par une virgule, que l'on souhaite enregistrer auprès du logueur racine. Ici, nous enregistrons le FileHandler et le ConsoleHandler auprès du logueur racine. La propriété .level spécifie le niveau par défaut du logueur. Ce niveau est utilisé par tous les logueurs qui sont enfants du logueur racine et qui n'ont pas leur propre niveau spécifié. Notez que, sans fichier de propriétés, le niveau de logue par défaut du logueur racine est INFO. Ceci est dû au fait qu'en l'absence d'un fichier de configuration personnalisé, la machine virtuelle utilise la configuration du fichier JAVA_HOME\jre\lib\logging.properties.
XV-D-8-b. Fichiers de logue rotatifs▲
Le fichier de configuration précédent génère des fichiers de logue rotatifs, qui sont utilisés pour prévenir le fait qu'un fichier de logue devienne trop grand. En fixant la valeur FileHandler.limit, on donne le nombre maximum d'octets alloué à un fichier de logue avant que le suivant ne commence à être rempli. FileHandler.count détermine le nombre de fichiers de logue rotatifs à utiliser ; le fichier de configuration montré ici spécifie trois fichiers. Si les trois fichiers sont remplis à leur maximum, alors le premier fichier commencera à nouveau à être rempli, par écrasement de son ancien contenu.
D'une autre manière, toutes les sorties peuvent être mises dans un seul fichier en donnant à FileHandler.count une valeur de un (les paramètres de FileHandler sont expliqués en détail dans la documentation du JDK).
Pour que le programme suivant utilise le fichier de configuration précédent, on doit spécifier le paramètre java.util.logging.config.file sur la ligne de commande :
java -Djava.util.logging.config.file=log.prop ConfigureLogging
Le fichier de configuration ne peut modifier que le logueur racine. Si l'on souhaite ajouter des filtres et des gestionnaires à d'autres logueurs, on doit écrire le code nécessaire dans un fichier Java, comme noté dans le constructeur :
//: c15:ConfigureLogging.java
// {JVMArgs: -Djava.util.logging.config.file=log.prop}
// {Clean: java0.log,java0.log.lck}
import
com.bruceeckel.simpletest.*;
import
java.util.logging.*;
public
class
ConfigureLogging {
private
static
Test monitor =
new
Test
(
);
static
Logger lgr =
Logger.getLogger
(
"com"
),
lgr2 =
Logger.getLogger
(
"com.bruceeckel"
),
util =
Logger.getLogger
(
"com.bruceeckel.util"
),
test =
Logger.getLogger
(
"com.bruceeckel.test"
),
rand =
Logger.getLogger
(
"random"
);
public
ConfigureLogging
(
) {
/* Set Additional formatters, Filters and Handlers for
the loggers here. You cannot specify the Handlers
for loggers except the root logger from the
configuration file. */
}
public
static
void
main
(
String[] args) {
sendLogMessages
(
lgr);
sendLogMessages
(
lgr2);
sendLogMessages
(
util);
sendLogMessages
(
test);
sendLogMessages
(
rand);
monitor.expect
(
"ConfigureLogging.out"
);
}
private
static
void
sendLogMessages
(
Logger logger) {
System.out.println
(
" Logger Name : "
+
logger.getName
(
) +
" Level: "
+
logger.getLevel
(
));
logger.finest
(
"Finest"
);
logger.finer
(
"Finer"
);
logger.fine
(
"Fine"
);
logger.config
(
"Config"
);
logger.info
(
"Info"
);
logger.warning
(
"Warning"
);
logger.severe
(
"Severe"
);
}
}
///:~
Le résultat de la configuration sera que la sortie sera envoyée aux fichiers nommés java0.log, java1.log et java2.log dans le dossier à partir duquel le programme est exécuté.
XV-D-8-c. Pratiques recommandées▲
Bien que cela ne soit pas obligatoire, on doit généralement considérer le fait d'utiliser un logueur pour chaque classe, en suivant le standard de fixer le nom du logueur comme identique au nom complètement qualifié de la classe. Comme montré précédemment, cela permet un contrôle plus fin des logues grâce à la capacité de les activer ou de les désactiver sur la base des espaces de nom.
Si l'on ne fixe pas le niveau de logue par classe individuelle dans le package, alors les classes individuelles recevront le niveau de logue par défaut pour le package (en supposant que l'on nomme les logueurs conformément à leur package et à leur classe).
Si l'on contrôle le niveau de logue dans un fichier de configuration au lieu de le changer dynamiquement dans le code, alors on peut modifier les niveaux de logue sans recompiler le code. La recompilation n'est pas toujours une option lorsque le système est déployé ; souvent, seuls les fichiers class sont envoyés à l'environnement de destination.
Parfois, il existe une exigence d'exécuter du code afin d'effectuer des activités d'initialisation telles qu'ajouter des Handlers, Filters et Formatters aux logueurs. Cela peut être effectué en fixant la propriété config dans le fichier de propriétés. On peut avoir de multiples classes dont l'initialisation peut être faite en utilisant la propriété config. Ces classes doivent être spécifiées en utilisant des valeurs séparées par un espace, comme ceci :
config =
ConfigureLogging1 ConfigureLogging2 Bar Baz
Les classes spécifiées de cette manière verront leur constructeur par défaut invoqué.
XV-D-9. Résumé▲
Bien que cela ait été une introduction plutôt minutieuse à l'API de logue, celle-ci n'inclut pas tout. Par exemple, nous n'avons pas parlé du LogManager ou des détails des nombreux gestionnaires disponibles, tels que MemoryHandler, FileHandler, ConsoleHandler, etc. Vous devriez aller voir la documentation du JDK pour de plus amples détails.
XV-E. Déboguer▲
Bien qu'une utilisation judicieuse d'instructions System.out ou d'informations de logue peut donner une vision de valeur concernant le comportement d'un programme, (101) pour des problèmes difficiles, cette approche devient lourde et consommatrice de temps. De plus, on peut avoir besoin de regarder plus profondément dans le programme que ce que les instructions d'impression permettent. Pour ceci, on a besoin d'un débogueur.
En plus d'être plus rapide et de montrer plus facilement de l'information que ce que peuvent produire les instructions d'impression, un débogueur fixera aussi des points d'arrêts et arrêtera le programme lorsqu'il atteindra ces points d'arrêts. Un débogueur peut aussi montrer l'état d'un programme à n'importe quel instant, voir les valeurs des variables qui vous intéressent, avancer dans le programme ligne par ligne, se connecter à un programme exécuté à distance, et plus. Particulièrement lorsque l'on commence à construire des systèmes plus grands (où les bogues peuvent être facilement enterrés profondément), cela vaut le coup de se familiariser avec les débogueurs.
XV-E-1. Déboguer avec JDB▲
Le Débogueur Java (JDB) est un débogueur en ligne de commande qui vient avec le JDK. JDB est au moins conceptuellement un descendant du débogueur GNU (GDB, qui était inspiré par le débogueur original Unix DB), en terme des instructions de débogage et d'interface de ligne de commande. JDB est utile pour apprendre le débogage et pour effectuer les tâches de débogage les plus simples, et il est utile de savoir qu'il est toujours disponible lorsque le JDK est installé. Malgré tout, pour des projets plus grands, vous aurez probablement envie d'utiliser un débogueur graphique, décrit plus loin.
Supposons que vous ayez écrit le programme suivant :
//: c15:SimpleDebugging.java
// {ThrowsException}
public
class
SimpleDebugging {
private
static
void
foo1
(
) {
System.out.println
(
"In foo1"
);
foo2
(
);
}
private
static
void
foo2
(
) {
System.out.println
(
"In foo2"
);
foo3
(
);
}
private
static
void
foo3
(
) {
System.out.println
(
"In foo3"
);
int
j =
1
;
j--
;
int
i =
5
/
j;
}
public
static
void
main
(
String[] args) {
foo1
(
);
}
}
///:~
Si on regarde foo3( ), le problème est évident ; on divise par zéro. Mais supposons que ce code est enterré dans un grand programme (comme cela est impliqué ici par la séquence des appels) et que l'on ne sait pas où commencer à chercher l'origine du problème. Lorsque cela se produit, l'exception levée donnera suffisamment d'information pour localiser le problème (c'est juste une des bonnes choses avec les exceptions). Mais supposons que le problème est plus difficile que ça, et que l'on ait besoin de creuser plus profondément et d'obtenir plus d'information que ce qu'une exception fournit.
Pour exécuter JDB, on doit dire au compilateur avec le drapeau -g de générer de l'information de débogage lors de la compilation de SimpleDebugging.java. Alors, on peut commencer à déboguer le programme avec la ligne de commande :
jdb SimpleDebugging
Cela déclenche JDB et une fenêtre de commande apparaît. On peut voir la liste des commandes JDB disponibles en tapant « ? ».
Voici une trace de débogage interactive qui montre comment remonter la piste d'un problème.
Initializing jdb ...
> catch Exception
> indique que JDB attend une commande et les commandes tapées par l'utilisateur sont montrées en gras. La commande catch Exception entraîne la pose d'un point d'arrêt à chaque endroit où se déclenche une exception (malgré tout, le débogueur s'arrêtera de toute façon, même si l'on n'entre pas explicitement cette commande - les exceptions sont des points d'arrêt par défaut dans JDB).
Deferring exception catch Exception.
It will be set after the class is loaded.
> run
Maintenant, le programme s'exécutera jusqu'au prochain point d'arrêt, qui est dans ce cas l'endroit où l'exception apparaît. Voici le résultat de la commande run :
run SimpleDebugging
>
VM Started: In foo1
In foo2
In foo3
Exception occurred: java.lang.ArithmeticException (uncaught)"thread=main", SimpleDebugging.foo3(), line=18 bci=15
18 int i = 5 / j;
Le programme s'exécute jusqu'à la ligne 18, où l'exception est générée, mais JDB ne sort pas quand il atteint l'exception. Le débogueur montre aussi la ligne de code qui génère l'exception. On peut lister le point où l'exécution s'arrête dans le programme source par la commande list comme montré ici :
main[1] list
14 private static void foo3() {
15 System.out.println("In foo3");
16 int j = 1;
17 j--;
18 => int i = 5 / j;
19 }
20
21 public static void main(String[] args) {
22 foo1();
23 }
Le pointeur (« => ») dans le listing montre le point courant d'où l'exécution reprendra. On pourrait reprendre l'exécution avec la commande cont (continue). Mais faire ceci ferait sortir JDB à l'exception, en imprimant la pile de la trace.
La commande locals montre la valeur de toutes les variables locales :
main[1] locals
Method arguments:
Local variables:
j = 0
On peut voir que la valeur de j=0 est la cause de l'exception.
La commande wherei imprime la pile d'appels dans la méthode pour le thread courant :
main[1] wherei
[1] SimpleDebugging.foo3 (SimpleDebugging.java:18), pc = 15
[2] SimpleDebugging.foo2 (SimpleDebugging.java:11), pc = 8
[3] SimpleDebugging.foo1 (SimpleDebugging.java:6), pc = 8
[4] SimpleDebugging.main (SimpleDebugging.java:22), pc = 0
Chaque ligne après la commande wherei représente un appel de méthode et le point où l'appel retournera (qui est montré par la valeur du compteur programme pc). Ici, la séquence des appels était main( ), foo1( ), foo2( ) et foo3( ). On peut remonter la pile d'appels au moment où l'appel a été fait à foo3( ) grâce à la commande pop :
main[1] pop
main[1] wherei
[1] SimpleDebugging.foo2 (SimpleDebugging.java:11), pc = 8
[2] SimpleDebugging.foo1 (SimpleDebugging.java:6), pc = 8
[3] SimpleDebugging.main (SimpleDebugging.java:22), pc = 0
On peut faire à nouveau avancer JDB à travers l'appel à foo3( ) avec la commande reenter :
main[1] reenter
>
Step completed: "thread=main", SimpleDebugging.foo3(), line=15 bci=0
System.out.println("In foo3");
La commande list nous montre que l'exécution commence au début de foo3( ) :
main[1] list
11 foo3();
12 }
13
14 private static void foo3() {
15 => System.out.println("In foo3");
16 int j = 1;
17 j--;
18 int i = 5 / j;
19 }
20
JDB permet aussi de modifier la valeur des variables locales. La division par zéro qui était causée la dernière fois en exécutant ce morceau de code peut être évitée en changeant la valeur de j. On peut faire ceci directement dans le débogueur, afin de continuer à déboguer le programme sans revenir en arrière et changer le fichier source. Avant de modifier la valeur de j, on doit exécuter le programme jusqu'à la ligne 16 puisque c'est l'endroit où j est déclaré.
main[1] step
> In foo3
Step completed: "thread=main", SimpleDebugging.foo3(), line=16 bci=8
16 int j = 1;
main[1] step
>
Step completed: "thread=main", SimpleDebugging.foo3(), line=17 bci=10
17 j--;
main[1] list
13
14 private static void foo3() {
15 System.out.println("In foo3");
16 int j = 1;
17 => j--;
18 int i = 5 / j;
19 }
20
21 public static void main(String[] args) {
22 foo1();
A ce point, j est défini et on peut modifier sa valeur afin d'éviter l'exception.
main[1] set j=6
j=6 = 6
main[1] next
>
Step completed: "thread=main", SimpleDebugging.foo3(), line=18 bci=13
18 int i = 5 / j;
main[1] next
>
Step completed: "thread=main", SimpleDebugging.foo3(), line=19 bci=17
19 }
main[1] next
>
Step completed: "thread=main", SimpleDebugging.foo2(), line=12 bci=11
12 }
main[1] list
8
9 private static void foo2() {
10 System.out.println("In foo2");
11 foo3();
12 => }
13
14 private static void foo3() {
15 System.out.println("In foo3");
16 int j = 1;
17 j--;
main[1] next
>
Step completed: "thread=main", SimpleDebugging.foo1(), line=7 bci=11
7 }
main[1] list
3 public class SimpleDebugging {
4 private static void foo1() {
5 System.out.println("In foo1");
6 foo2();
7 => }
8
9 private static void foo2() {
10 System.out.println("In foo2");
11 foo3();
12 }
main[1] next
>
Step completed: "thread=main", SimpleDebugging.main(), line=23 bci=3
23 }
main[1] list
19 }
20
21 public static void main(String[] args) {
22 foo1();
23 => }
24 } ///:~
main[1] next
>
The application exited
next exécute une ligne à la fois. On peut voir que l'exception est évitée et on peut continuer à avancer dans le programme. list est utilisé pour montrer la position dans le programme à partir de laquelle l'exécution sera faite.
XV-E-2. Débogueurs graphiques▲
Utiliser un débogueur en ligne de commande comme JDB n'est pas très pratique. On doit utiliser des commandes explicites pour faire des choses comme regarder l'état des variables (locales, globales), lister le point d'exécution dans le code source (list), montrer les thread dans le système (threads), modifier les points d'arrêt (stop in, stop at), etc. Un débogueur graphique permet de faire toutes ces choses en quelques clics et de voir tous les détails du programme débogué sans utiliser de commandes explicites.
Ainsi, bien que vous pouviez vouloir commencer à expérimenter avec JDB, vous trouverez probablement plus productif d'apprendre à utiliser un débogueur graphique afin de remonter rapidement la piste de vos bogues. Durant le développement de l'édition de ce livre, nous avons commencé à utiliser l'éditeur et environnement de développement Eclipse d'IBM, qui contient un très bon débogueur graphique pour Java. Eclipse est bien conçu et implémenté et on peut le télécharger gratuitement à partir de www.Eclipse.org (c'est un outil gratuit, pas une démo ou un partagiciel - merci à IBM d'investir de l'argent, du temps et des efforts pour le rendre disponible à tout le monde).
D'autres outils de développement gratuits ont aussi un débogueur graphique, tels Netbeans de Sun et la version gratuite de JBuilder de Borland.
XV-F. Profiler et optimiser▲
« Nous devrions oublier les petites améliorations, disons environ 97% du temps : les optimisations prématurées sont la racine de tous les maux. » - Donald Knuth
Bien que l'on devrait toujours garder cette citation à l'esprit, spécialement lorsque l'on se découvre soi-même sur la pente glissante de l'optimisation prématurée, parfois on doit déterminer où le programme passe tout son temps, afin de voir si l'on peut améliorer les performances de ces sections.
Un profileur rassemble de l'information qui permet de voir quelles parties du programme consomment de la mémoire et quelles méthodes consomment le maximum de temps. Certains profileurs permettent même de désactiver le ramasse-miettes afin d'aider à déterminer des organisations de l'allocation mémoire.
Un profileur peut être un outil utile à la détection des blocages entre thread.
XV-F-1. Tracer la consommation mémoire▲
Voici un exemple de données qu'un profileur peut montrer concernant l'usage de la mémoire:
- Nombre d'allocations d'objet pour un type spécifique.
- Endroits où les allocations d'objet sont mis en place.
- Méthodes impliquées dans l'allocation des instances de cette classe.
- Objets résiduels : des objets qui sont alloués, pas utilisés et non collectés par le ramasse-miettes. Ils continuent à faire grossir le tas de la JVM et représentent des fuites mémoire, ce qui peut entraîner une erreur de manque de mémoire ou un coût excessif d'exécution du ramasse-miettes.
- Allocation excessive d'objets temporaires, ce qui augmente le travail du ramasse-miettes et ainsi réduit les performances de l'application.
- Échec lors de la libération d'instances ajoutées à une collection et non supprimées (c'est un cas spécial d'objets résiduels).
XV-F-2. Tracer l'usage de la CPU▲
Les profileurs gardent aussi la trace du temps passé par la CPU dans les différentes parties du code. Ils peuvent nous dire :
- Le nombre de fois qu'une méthode a été invoquée.
- Le pourcentage du temps CPU utilisé par chaque méthode. Si cette méthode appelle d'autres méthodes, le profileur peut nous dire la portion de temps passé dans ces autres méthodes.
- Temps total absolu passé dans chaque méthode, incluant le temps passé à attendre les E/S, blocages, etc. Ce temps dépend des ressources disponibles du système.
De cette manière, on peut décider quelles sections du code ont besoin d'être optimisées.
XV-F-3. Couverture de test▲
La couverture de test montre les lignes de code qui ne sont pas exécutées durant un test. Cela peut attirer l'attention sur le code qui n'est pas utilisé et qui de ce fait est un candidat à la suppression ou à la modification.
Pour obtenir les informations concernant la couverture de test pour SimpleDebugging.java, utilisez la commande :
java -Xrunjcov:type=M SimpleDebugging
Comme essai, essayez de mettre des lignes de code qui ne seront pas exécutées à l'intérieur de SimpleDebugging.java (vous devrez être un peu malin, car le compilateur peut détecter les lignes de code inatteignables).
XV-F-4. Interface de profilage de la JVM▲
L'agent de profilage communique à la JVM les évènements pour lesquels il est intéressé. L'interface de profilage de la JVM supporte les évènements suivants :
- Entrée et sortie d'une méthode
- Allouer, bouger et libérer un objet
- Créer et supprimer une zone du tas
- Commencer et finir un cycle pour le ramasse-miettes
- Allouer et libérer une référence globale JNI
- Allouer et libérer une référence globale faible JNI
- Charger et décharger une méthode compilée
- Commencer et finir un thread
- Fichier de données classe prêt pour l'instrumentation
- Charger et décharger une classe
- Pour un moniteur Java sous contention: En attente pour entrer, entré et sorti
- Pour un moniteur brut sous contention: En attente pour entrer, entré et sorti
- Pour un moniteur Java qui n'est pas sous contention: En attente, attendu
- Etat des moniteurs
- Etat du tas
- Etat des objets
- Requête pour obtenir ou modifier les données de profilage
- Initialisation et arrêt de la JVM
Durant le profilage, la JVM envoie ces évènements à l'agent de profilage, qui transfère alors l'information désirée à la façade de profilage, qui, si on le souhaite, peut être un processus exécuté sur une autre machine.
XV-F-5. Utiliser HPROF▲
L'exemple dans cette section montre comment on peut exécuter le profileur qui accompagne le JDK. Bien que l'information venant de ce profileur est dans la forme un peu brute de fichiers textes plutôt qu'une représentation graphique comme fournie par beaucoup de profileurs commerciaux, il donne toujours une aide précieuse pour déterminer les caractéristiques d'un programme.
On exécute le profileur en passant un argument supplémentaire à la JVM lorsque l'on invoque le programme. Cet argument doit être une chaîne unique, sans aucun espace après les virgules, comme ceci (bien que ce devrait être sur une seule ligne, cela a été découpé dans le livre) :
java -Xrunhprof:heap=sites,cpu=samples,depth=10,monitor=y,thread=y,doe=y ListPerformance
- heap=sites dits au profileur d'écrire l'information concernant l'utilisation de la mémoire sur le tas, indiquant où elle était allouée.
- cpu=samples dit au profileur d'effectuer un échantillon afin de déterminer l'usage de la CPU.
- depth=10 indique la profondeur de trace pour les threads.
- thread=y dit au profileur d'identifier les threads sur la pile des traces.
- doe=y dit au profileur de produire un fichier de données de profilage en sortie.
Le listing suivant contient seulement une portion du fichier produit par HPROF. Le fichier de sortie est créé dans le répertoire courant et est appelé java.hprof.txt.
Le début de java.hprof.txt décrit les détails des sections suivantes du fichier. Les données produites par le profileur sont dans différentes sections ; par exemple, TRACE représente une section de trace dans le fichier. On peut voir beaucoup de sections de TRACE, chacune numérotée afin d'être référencée plus tard.
La section SITES montre les sites d'allocation mémoire. La section a plusieurs lignes, ordonnées par le nombre d'octets qui sont alloués et qui sont référencés - les octets vivants. La mémoire est listée en octets. La colonne self représente le pourcentage de mémoire pris par ce site, la colonne suivante accum représente le pourcentage de mémoire cumulative. Les colonnes live bytes et live objects représentent le nombre d'octets vivants pour ce site et le nombre d'objets qui ont été créés et qui consomment ces octets. allocated bytes et objects représentent le nombre total d'objets et d'octets instanciés, incluant ceux utilisés et ceux qui ne sont plus en usage. La différence entre le nombre d'octets listés comme alloués et comme vivants représente les octets qui peuvent être nettoyés par le ramasse-miettes. La colonne trace référence une TRACE présente dans le fichier. La première ligne réfère à la trace 668 comme montré ci-dessous. name représente la classe où l'instance est créée.
SITES BEGIN (ordered by live bytes) Thu Jul 18 11:23:06 2002
percent live alloc'ed stack class
rank self accum bytes objs bytes objs trace name
1 59.10% 59.10% 573488 3 573488 3 668 java.lang.Object
2 7.41% 66.50% 71880 543 72624 559 1 [C
3 7.39% 73.89% 71728 3 82000 10 649 java.lang.Object
4 5.14% 79.03% 49896 232 49896 232 1 [B
5 2.53% 81.57% 24592 310 24592 310 1 [S
TRACE 668: (thread=1)
java.util.Vector.ensureCapacityHelper(Vector.java:222)
java.util.Vector.insertElementAt(Vector.java:564)
java.util.Vector.add(Vector.java:779)
java.util.AbstractList$ListItr.add(AbstractList.java:495)
ListPerformance$3.test(ListPerformance.java:40)
ListPerformance.test(ListPerformance.java:63)
ListPerformance.main(ListPerformance.java:93)
Cette trace montre la séquence des appels de méthode qui alloue la mémoire. Si l'on parcourt la trace en suivant les numéros de ligne, on voit qu'une allocation d'objet se déroule à la ligne 222 de Vector.java :
elementData =
new
Object[newCapacity];
Ceci aide à découvrir les parties du programme qui utilisent beaucoup de mémoire (59,10 % dans ce cas).
Notez que [C dans SITE 1 représente le type primitif char. C'est une représentation interne de la JVM pour les types primitifs.
XV-F-6. Performance des thread▲
La section CPU SAMPLES montre l'utilisation du CPU. Voici une partie des traces de cette section.
SITES END
CPU SAMPLES BEGIN (total = 514) Thu Jul 18 11:23:06 2002
rank self accum count trace method
1 28.21% 28.21% 145 662 java.util.AbstractList.iterator
2 12.06% 40.27% 62 589 java.util.AbstractList.iterator
3 10.12% 50.39% 52 632 java.util.LinkedList.listIterator
4 7.00% 57.39% 36 231 java.io.FileInputStream.open
5 5.64% 63.04% 29 605 ListPerformance$4.test
6 3.70% 66.73% 19 636 java.util.LinkedList.addBefore
L'organisation de ce listing est similaire à l'organisation des listings de SITES. Les lignes sont triées par utilisation CPU. La ligne en haut présente l'utilisation CPU maximale, comme indiqué par la colonne self. La colonne accum liste l'utilisation cumulative du CPU. Le champ count spécifie le nombre d'activations de cette trace. Les deux colonnes suivantes spécifient le numéro de la trace et la méthode concernée.
Regardons la première ligne de la section CPU SAMPLES. 28,12 % du temps total CPU a été utilisé dans la méthode java.util.AbstractList.iterator( ) et elle a été appelée 145 fois. Les détails de cet appel peuvent être vus en regardant au numéro de trace 662 :
TRACE 662: (thread=1)
java.util.AbstractList.iterator(AbstractList.java:332)
ListPerformance$2.test(ListPerformance.java:28)
ListPerformance.test(ListPerformance.java:63)
ListPerformance.main(ListPerformance.java:93)
On peut en déduire qu'itérer sur une liste prend une portion de temps significative.
Pour de grands projets, il est souvent plus utile d'avoir l'information représentée sous forme graphique. Un certain nombre de profileurs produisent de une représentation graphique, mais la couverture de ce sujet est au-delà de la portée de ce livre.
XV-F-7. Guide d'optimisation▲
- Évitez de sacrifier la lisibilité du code à la performance.
- La performance ne devrait pas être considérée de manière isolée. Évaluez la somme totale d'efforts requise en regard des gains.
- La performance peut être un souci sur de grands projets, mais souvent, ce n'est pas un problème pour les petits projets.
- Avoir un programme qui marche devrait être une priorité plus grande que de plonger dans les performances d'un programme. Une fois que l'on a un programme qui marche, on peut utiliser le profileur pour le rendre plus efficace. La performance ne devrait être prise en compte durant le processus initial de conception/développement que s’il a été déterminé que c'est un facteur critique.
- Ne pas faire d'hypothèses sur l'origine des goulots d'étranglement. Exécutez un profileur pour obtenir les données.
- À chaque fois que c'est possible, essayez de vous débarrasser d'une instance en la fixant à null. C'est parfois une indication utile pour le ramasse-miettes.
- La taille du programme est importante. L'optimisation de la performance est en général valable seulement lorsque la taille du projet est grande, lorsqu'il est exécuté durant longtemps et que la vitesse est un problème.
- Les variables static final peuvent être optimisées par la JVM pour améliorer la vitesse d'un programme. Les constantes d'un programme devraient donc être déclarées static et final.
XV-G. Les doclets▲
Bien qu'il puisse être un peu surprenant de considérer un outil qui était développé pour le support à la documentation comme quelque chose qui aide à pister les problèmes dans les programmes, les doclets peuvent vous être utiles d'une manière surprenante. Puisqu'un doclet est un crochet pour le parseur javadoc, il a de l'information disponible pour ce parseur. Avec ceci, on peut examiner de manière programmatique les noms des classes, les noms des champs et les signatures de méthode dans le code et marquer les problèmes potentiels.
Le processus de production de la documentation JDK à partir des fichiers sources Java implique le parsing du fichier source et le formatage de ce fichier parsé par utilisation de doclets standards. On peut écrire un doclet personnalisé pour customiser le formatage des commentaires javadoc. Malgré tout, les doclets permettent bien plus que de simplement formater le commentaire, car un doclet a à sa disposition plus d'information sur le fichier source que ce qui est parsé.
On peut extraire de l'information sur tous les membres de la classe : champs, constructeurs, méthodes et les commentaires associés à chacun des membres (hélas, le corps des méthodes n'est pas disponible). Les détails des membres sont encapsulés à l'intérieur d'objets spéciaux, qui contiennent des informations sur les propriétés du membre (privé, statique, final etc.) Cette information peut être utile pour détecter du code mal écrit, tel que les variables de membre qui devraient être privées, mais sont publiques, les paramètres de méthode sans commentaires et les identifiants qui ne respectent pas les conventions de nommage.
Javadoc ne peut pas détecter toutes les erreurs de compilation. Il repérera les erreurs de syntaxe, telles que les accolades non fermées, mais il ne détectera pas les erreurs sémantiques. L'approche la plus sûre est d'exécuter le compilateur Java sur le code avant d'essayer d'utiliser un outil de traitement des doclets.
Le mécanisme de parsing fournit par javadoc parse le fichier source en entier et le stocke en mémoire dans un objet de la classe RootDoc. Le point d'entrée pour le doclet soumis à javadoc est start(RootDoc doc). C'est comparable au main(String[] args) normal d'un programme Java. On peut parcourir l'objet RootDoc et extraire l'information nécessaire. L'exemple suivant montre comment écrire un doclet simple ; celui-ci imprime juste tous les membres de chaque classe parsée :
//: c15:PrintMembersDoclet.java
// Doclet qui affiche tous les membres de la classe.
import
com.sun.javadoc.*;
public
class
PrintMembersDoclet {
public
static
boolean
start
(
RootDoc root) {
ClassDoc[] classes =
root.classes
(
);
processClasses
(
classes);
return
true
;
}
private
static
void
processClasses
(
ClassDoc[] classes) {
for
(
int
i =
0
; i <
classes.length; i++
) {
processOneClass
(
classes[i]);
}
}
private
static
void
processOneClass
(
ClassDoc cls) {
FieldDoc[] fd =
cls.fields
(
);
for
(
int
i =
0
; i <
fd.length; i++
)
processDocElement
(
fd[i]);
ConstructorDoc[] cons =
cls.constructors
(
);
for
(
int
i =
0
; i <
cons.length; i++
)
processDocElement
(
cons[i]);
MethodDoc[] md =
cls.methods
(
);
for
(
int
i =
0
; i <
md.length; i++
)
processDocElement
(
md[i]);
}
private
static
void
processDocElement
(
Doc dc) {
MemberDoc md =
(
MemberDoc)dc;
System.out.print
(
md.modifiers
(
));
System.out.print
(
" "
+
md.name
(
));
if
(
md.isMethod
(
))
System.out.println
(
"()"
);
else
if
(
md.isConstructor
(
))
System.out.println
(
);
}
}
///:~
On peut utilise le doclet pour imprimer les membres de cette manière :
javadoc -doclet PrintMembersDoclet -private PrintMembersDoclet.java
Ceci invoque javadoc pour le dernier argument dans la commande, ce qui signifie qu'il parsera le fichier PrintMembersDoclet.java. L'option -doclet indique à javadoc d'utiliser le doclet personnalisé PrintMembersDoclet. La balise -private dit à javadoc d'imprimer aussi les membres privés (par défaut, seuls les membres protégés et publics sont imprimés).
RootDoc contient une collection de ClassDoc qui détient toute l'information sur la classe. Les classes telles que MethodDoc, FieldDoc, and ConstructorDoc contiennent respectivement l'information concernant les méthodes, champs et constructeurs. La méthode processOneClass( ) extrait la liste de ces membres et les imprime.
On peut aussi créer des taglets qui permettent d'implémenter des balises javadoc personnalisées. La documentation du JDK présente un exemple qui implémente une balise @todo qui affiche son texte en jaune dans la javadoc résultante en sortie. Cherchez « taglet » dans la documentation du JDK pour plus de détails.
XV-H. Résumé▲
Ce chapitre a introduit ce que je suis venu à considérer comme étant la question la plus essentielle en programmation, au-delà de la syntaxe du langage et des problèmes de conception : Comment s'assurer que le code est correct et comment le garder dans cet état ?
L'expérience récente a montré que l'outil le plus utile et pratique à l'heure actuelle est le test unitaire qui, comme cela est montré dans ce chapitre, peut être combiné très efficacement avec la Conception par Contrat. Il existe aussi d'autres types de tests, comme les tests de conformité vérifiant comment les cas d'utilisation / histoires utilisateur ont été implémentés. Mais pour certaines raisons, nous avons dans le passé relégué les tests comme devant être fait plus tard par quelqu'un d'autre. La Programmation Extrême (XP) insiste sur le fait que les tests unitaires devraient être écrits avant le code ; on crée le framework de test pour la classe et ensuite la classe elle-même (en une ou deux occasions, je l'ai fait avec succès, mais je suis généralement satisfait si le test apparaît à un moment durant la phase initiale de codage). Il existe toujours de la résistance à tester, habituellement par ceux qui ne l'on pas essayé et croient qu'ils peuvent écrire du bon code sans tester. Mais plus j'ai d'expérience, plus je me répète à moi-même :
Si ce n'est pas testé, ça ne marche pas.
C'est un mantra bénéfique, spécialement lorsque l'on pense aux coins coupants. Plus on apprend de ses bogues, plus on est attaché à augmenter la sécurité fournie par les tests faits soi-même.
Les systèmes de construction (Ant en particulier) et de contrôle de version (CVS) furent aussi introduits dans ce chapitre, car ils fournissent une structure pour vos projets et leurs tests. Pour moi, le but premier de la programmation extrême est la vélocité - la capacité à faire avancer rapidement votre projet (mais de manière confiante), et de le modifier rapidement lorsque l'on on s'aperçoit qu'il peut être amélioré. La vélocité nécessite une structure de support pour avoir confiance dans le fait que les choses ne vont pas s'écrouler lorsque l'on commence à faire de grands changements au projet. Cela inclut un dépôt sûr, qui permette de revenir à n'importe quelle version antérieure et un système automatique de construction qui, une fois configuré, garantit que le projet peut être compilé et testé en une seule étape.
Une fois que l'on a des raisons de croire que le programme est sain, loguer fournit une manière de surveiller son pouls et même (comme montré dans ce chapitre) de mailer automatiquement si quelque chose commence à aller mal. Lorsque cela arrive, le débogage et le profilage aident à pister les bogues et les problèmes de performance.
Peut-être est-ce dans la nature de la programmation que de vouloir une réponse unique, claire et concrète. Après tout, nous travaillons avec des 0 et des 1, qui n'ont pas de limites floues (ils en ont actuellement, mais les ingénieurs en électronique sont allés très loin afin de nous donner le modèle que nous voulons). Lorsque l'on en arrive aux solutions, c'est génial de croire qu'il n'existe qu'une réponse unique. Mais j'ai compris qu'il y a des limites à toute technique et comprendre ces limitations est beaucoup plus puissant que toute autre approche, car cela permet d'utiliser une méthode lorsqu'elle a de grands avantages et de la combiner avec d'autres approches lorsqu'elle présente des faiblesses. Par exemple dans ce chapitre, la Conception par Contrat a été présentée en combinaison avec les tests unitaires boîte blanche et alors que je créais l'exemple, j'ai découvert que les deux travaillant de concert étaient beaucoup plus utiles que l'un sans l'autre.
Je me suis aperçu que cette idée est vraie non seulement pour résoudre des problèmes, mais aussi pour la construction de systèmes dès le départ. Par exemple, utiliser un seul langage de programmation ou un seul outil pour résoudre un problème est attirant du point de vue de la consistance, mais j'ai souvent découvert que je pouvais résoudre certains problèmes bien plus rapidement et efficacement en utilisant le langage de programmation Python plutôt que Java, au bénéfice général du projet. Peut-être découvrirez vous aussi que Ant fonctionne bien à certains endroits et que make est plus utile à d'autres. Ou, si vos clients sont sur des plateformes Windows, cela peut être une bonne idée de prendre la décision radicale d'utiliser Delphi ou Visual Basic pour développer des programmes clients plus rapidement que ce qu'il est possible de faire en Java. La chose importante est de garder un esprit ouvert et de se souvenir que l'on essaye d'obtenir des résultats et pas nécessairement d'utiliser un outil ou une technique définis. Cela peut être difficile, mais si vous vous rappelez que les chances d'échec du projet sont particulièrement élevées et que vos chances de succès sont proportionnellement faibles, vous pourriez être un peu plus ouvert aux solutions qui pourraient s'avérer plus productives. Une de mes phrases favorites de la programmation extrême (et une que je transgresse souvent pour des raisons idiotes) est « faites la chose la plus simple possible qui puisse marcher ». La plupart du temps, l'approche la plus simple et la plus expéditive, si vous pouvez la trouver, est la meilleure.
XV-I. Exercices▲
- Créez une classe contenant une clause static qui lève une exception si les assertions sont désactivées. Démontrez que ce test marche correctement.
- Modifiez l'exercice précédent pour utiliser l'approche dans LoaderAssertions.java afin d'activer les assertions au lieu de lever une exception. Démontrez que cela marche correctement.
- Dans LoggingLevels.java, décommentez le code qui fixe le niveau de sévérité des gestionnaires du logueur racine et vérifiez que les messages de niveau CONFIG et inférieurs ne sont pas rapportés.
- Héritez de java.util.Logging.Level et définissez votre propre niveau avec une valeur inférieure à FINEST. Modifiez LoggingLevels.java afin d'utiliser votre nouveau niveau et montrez que les messages de votre niveau n'apparaîtront pas lorsque le niveau de sévérité est FINEST.
- Associez un FileHandler au logueur racine.
- Modifiez le FileHandler afin qu'il formate la sortie vers un fichier texte simple.
- Modifiez MultipleHandlers.java afin qu'il génère une sortie au format texte brut au lieu du XML.
- Modifiez LoggingLevels.java pour fixer différents niveaux de logue pour les gestionnaires associés au logueur racine.
- Écrivez un programme simple qui fixe le niveau de logue du logueur racine sur la base d'un argument de la ligne de commande.
- Écrivez un exemple utilisant des formateurs et des gestionnaires pour sortir un fichier de logue au format HTML.
- Écrivez un exemple utilisant des gestionnaires et des filtres pour loguer des messages avec tous les niveaux de sévérité supérieurs à INFO dans un fichier et tous les niveaux de sévérité (inférieurs à INFO inclus) dans un autre fichier. Les fichiers doivent être écrits en texte simple.
- Modifiez log.prop afin d'ajouter une classe d'initialisation supplémentaire qui initialise un Formatter personnalisé pour le logueur com.
- Exécutez JDB sur SimpleDebugging.java, mais n'utilisez pas la commande catch Exception. Montrez qu'il capture toujours l'exception.
- Ajoutez une référence non initialisée à SimpleDebugging.java (vous devrez le faire d'une manière telle que le compilateur ne capture pas l'erreur !) et utilisez JDB pour pister le problème.
- Effectuez l'expérience décrite dans la section « Couverture de test ».
- Créez un doclet qui affiche les identifiants qui pourraient ne pas suivre les conventions de nommage Java en vérifiant comment les lettres majuscules sont utilisées pour ces identifiants.