Architecture Client/Serveur en Java avec les sockets

Romain Guy (Gfx)

Vous avez sûrement déjà utilisé Internet ou un simple réseau local. Dans un tel environnement, les applications communiquent entre elles par le biais d'objets appelés sockets. Nous allons donc apprendre à manipuler ces sockets…

- Titre : Architecture Client/Serveur en Java avec les sockets

- Auteur : Romain Guy (Gfx)

- Parution : 16 août 2006

- Mise à jour : 9 novembre 2019

- Licence : Cette création est mise à disposition sous un contrat Creative Commons (Paternité - Partage des Conditions Initiales à l'Identique).

Sommaire

- I. Introduction

- II. Le client

- II-A. Introduction

- II-B. Les sockets

- II-C. Le protocole SMTP

- II-D. Autres usages

- II-E. Screenshots

- III. Le serveur

- III-A. Introduction

- III-B. Le socket serveur

- III-C. Définition du serveur

- III-D. Implémentation de ServerSocket

- III-E. Une réponse pour la 259.162.11.20, une !

- III-F. Screenshots

- IV. Les sources

I - Introduction

Dans ce tutoriel, vous allez apprendre tout ce qu'il faut savoir pour débuter en programmation réseau. Vous allez apprendre ce que sont les sockets et comment les manipuler, vous allez découvrir la création d'un client et celle d'un serveur.

II - Le client

II-A - Introduction

Dans cette partie, nous allons maintenant traiter de la conception et la création d'un client en réseau avec les sockets. On va apprendre ce que sont les sockets, leur utilité et comment les utiliser, on va aussi apprendre à envoyer un mail avec lesdits sockets.

II-B - Les sockets

Concrètement, qu'est-ce qu'un socket ? Un socket est une sorte de point d'ancrage pour les protocoles de transmission de données comme TCP/IP. Les sockets sont des objets permettant la gestion de deux flux de données : un flux d'entrée (InputStream), garantissant la réception des données, et un flux de sortie (OutputStream), servant à envoyer les données. En Java, nous distinguerons deux types de sockets : les sockets simples (dits « clients ») et les sockets serveurs. Dans cette partie, nous nous pencherons uniquement sur les sockets clients en écrivant un logiciel simple permettant d'envoyer des e-mails. Un socket client est tout simplement un socket qui va se connecter sur un socket serveur pour lui demander d'effectuer des tâches. Netscape utilise des sockets clients par exemple…
Mais avant de passer à la pratique, analysons la classe Socket du package java.net de Java. Cette classe contient divers constructeurs dont seul un nous intéresse :

Socket(String host, int port)

Comme vous le voyez, le constructeur du socket attend deux arguments : une chaîne de caractères et un entier. Le premier argument définit l'adresse IP du serveur sur lequel nous désirons nous connecter. Cette adresse peut prendre la forme classique X.X.X.X (par exemple 127.0.0.1 pour votre propre machine) ou vous pouvez utiliser un nom (localhost est équivalent à 127.0.0.1). Le deuxième argument permet de définir le port sur lequel nous allons nous connecter. En effet, une même machine est tout à fait susceptible d'héberger plusieurs serveurs logiciels. Un serveur Web type proposera ainsi un serveur Telnet, un serveur de mail et un serveur Web (voire un serveur FTP). Or, tous ces serveurs utilisent la même adresse IP. Il nous faut donc les distinguer, et c'est là que le numéro de port intervient. Celui-ci est un nombre positif pouvant prendre n'importe quelle valeur. Cependant, quelques numéros sont « réservés »: 25 pour le protocole SMTP (envoi de mails), 23 pour le Telnet, 8080 pour le protocole HTTP (serveur Web)… Notre but étant de pouvoir envoyer des mails, nous spécifierons donc le port 25 par défaut.

II-C - Le protocole SMTP

Envoyer des e-mails est un jeu d'enfant. Nous avons besoin de l'adresse du serveur de mail (par exemple smtp.free.fr), du numéro de port, de l'e-mail de l'expéditeur et de l'e-mail du destinataire. La gestion du protocole SMTP nécessitera l'emploi des deux flux d'entrée et de sortie du socket client. Commençons tout d'abord par nous connecter au serveur en construisant une nouvelle instance de l'objet Socket.

public boolean sendMail(String host, int port, String sender, String receiver)
{
Socket smtpPipe;

try
{
smtpPipe = new Socket(host, port);
if (smtpPipe == null)
return false;
} catch (IOException ioe) { return false; }
return true;
}

Par la suite, nous admettrons que tout le code source sera tapé dans le bloc try/catch. Si tout s'est bien passé lors de la connexion, la méthode sendMail() retourne la valeur vraie. Sinon, la valeur fausse est retournée. Nous ferons grand usage de ceci par la suite, lors de la lecture des réponses du serveur. Maintenant, nous avons besoin de récupérer les flux permettant l'échange d'informations. Les données étant sous forme de texte, nous allons encapsuler les flux dans les objets BufferedReader et OutputStreamWriter du package java.io qui nous faciliteront la tâche. Le code à rajouter est :

BufferedReader in = new BufferedReader(new InputStreamReader(smtpPipe.getInputStream()));
OutputStreamWriter out = new OutputStreamWriter(smtpPipe.getOutputStream());
if (in == null || out == null)
return false;

Dès lors, un simple appel de in.readLine() permettra de recevoir une ligne depuis le serveur et out.write(String + "\r\n") permettra d'envoyer des données. Nous sommes connectés, flux prêts à servir, il ne nous reste plus qu'à suivre le protocole SMTP pas à pas :

[lecture]
[envoyer: HELO nom de la machine de l'expéditeur]
[envoyer: MAIL FROM:<expéditeur>]
[envoyer: RCPT TO:<destinataire>]
[envoyer: DATA]
[envoyer: en tête]
[envoyer: corps du mail]
[envoyer: .]
[envoyer: QUIT]

À noter qu'après chaque envoi, nous faisons également une lecture. En effet, chaque étape effectuée provoque l'envoi d'une réponse de la part du serveur indiquant si l'on peut continuer ou non. Voici le code type d'une étape :

command = "MAIL FROM:<" + sender + ">";
out.write(command + "\r\n");
out.flush();
trace(command);
trace(response = in.readLine());
if (!response.startsWith("250"))
return error("Expéditeur inconnu");

Ce bout de code correspond à l'étape d'identification auprès du serveur. Ici command et response sont deux objets String utilisés tout au long de l'envoi. La première ligne crée le message à envoyer au serveur. Les deux suivantes servent à effectuer l'envoi (flush() permettant de « vider » le flux pour s'assurer que tout a été envoyé). Ensuite les méthodes trace() permettent d'afficher notre dialogue avec le serveur. Notez que lors du second appel de trace() nous lisons une ligne depuis le flux d'entrée. Ensuite, cette ligne est vérifiée. À ce stade, si la réponse du serveur ne commence pas par « 250 », le serveur a rejeté l'expéditeur. Par exemple, si vous avez utilisé une adresse e-mail ne portant pas le même nom de domaine que le serveur (ne marche que sur smtp.free.fr). Pour suivre en détail chaque étape, reportez-vous au code source du logiciel « Login Mailer » disponible à la fin de cet article. Celui-ci est suffisamment documenté pour que vous puissiez saisir aisément le fonctionnement de la méthode sendMail(). Il convient tout de même de faire attention à la dernière étape de l'envoi de mail. Vous noterez que l'on envoie un simple point (".") pour signifier que l'on a terminé. Mais que se passera-t-il si le corps du mail contient une ligne avec pour seul texte, un point ? La connexion sera close par le serveur. Le protocole SMTP offre cependant un moyen de remédier à cela en envoyant un point d'exclamation à la place. Ici encore, reportez-vous au code source de l'application LoginMail.

II-D - Autres usages

Ainsi, en utilisant les flux d'entrée et de sortie, il est possible, et ce très facilement, de faire communiquer deux logiciels. L'utilisation d'un socket client se retrouve dans énormément d'applications comme ICQ, IRC, les browsers Web ou même les jeux !! De la sorte, rien ne vous empêche de créer votre propre client pour un autre serveur… cela ne vous demandera que de connaître le « protocole » du serveur convoité. Faites attention cependant aux flux que vous utilisez. Ici, nous avons encapsulé nos flux dans des objets rendant plus pratique l'envoi et la réception de chaînes de caractères (on aurait pu arguer en faveur de l'objet PrintWriter plutôt que OutputStreamWriter, mais la méthode println() de PrintWriter envoie le caractère de fin de ligne "\n" alors que SMTP attend "\r\n"). Or, certains serveurs, notamment les serveurs de jeux, sont extrêmement exigeants en termes de vitesse de réception et d'envoi. Dans ce genre de cas, il ne faut surtout pas utiliser des Strings, mais plutôt des entiers (type int), voire des primitifs de type byte. Si vous vous retrouvez dans cette situation, conservez tout simplement les flux de base du socket que vous récupérerez ainsi :

InputStream in = socket.getInputStream();
OutputStream out = socket.getOutputStream();

L'utilisation conjointe des méthodes read() (qui renvoie un entier) et write(byte) couvriront alors tous vos besoins.

II-E - Screenshots

L'adressage par IP et numéro de port

Telnet…

…mails…

…chat…

…téléchargement, les sockets sont partout !

III - Le serveur

III-A - Introduction

Chose promise, chose due. Les arcanes des sockets clients n'ayant plus de secrets pour nous, il ne nous reste plus qu'à apprendre comment programmer nous-mêmes ces fameux serveurs sur lesquels nous connections nos logiciels clients.
Avant d'aborder l'aspect technique du sujet, intéressons-nous tout d'abord au fonctionnement général d'un serveur logiciel. Nous savons que les clients contiennent des routines ouvrant une connexion sur le port d'un serveur pour y demander et récupérer des informations. Un serveur logiciel aura donc pour but d'ouvrir un port sur la machine hôte et de gérer tous les clients se connectant à ce port. Cette gestion peut varier d'un type de serveur à un autre : simple réponse (un serveur HTTP), distribution aux autres clients (par exemple pour un jeu), mise en relation directe des clients (ICQ ou IRC en mode DCC), et ainsi de suite…

III-B - Le socket serveur

À l'instar des sockets que nous nommerons "simples", c'est-à-dire les sockets clients, les sockets serveurs proposent un flux d'entrée et un flux de sortie. Pour employer un socket serveur en Java, il suffit d'utiliser une instance de la classe ServerSocket du package java.net. La classe ServerSocket offre trois constructeurs parmi lesquels un seul nous intéresse :

ServerSocket(int port)

Comme vous pouvez le constater, le constructeur du ServerSocket n'attend qu'un seul et unique argument : un entier. Cet entier joue un peu le même rôle que pour les sockets simples. Il permet de spécifier un port. Mais dans ce cas, cet entier définira le numéro d'identification du port à créer. Vous êtes par ailleurs entièrement libre du choix du numéro du port dès lors que celui-ci est compris dans les limites permises par le type int (de 0 à 2 147 483 647, c'est-à-dire 2 ^ 32 / 2 – 1, car nous ne pouvons utiliser que des entiers). Toutes les applications réseau utilisant des ports spécifiques, il vous faudra faire attention à ne pas utiliser de numéro déjà pris. Si votre serveur est destiné à être utilisé sur une machine personnelle, il est peu probable que l'utilisation des ports 25 (envoi de mails) ou 8080 (HTTP) soit gênante. Cependant, ceci est vivement déconseillé, surtout étant donné que la plage de valeurs allouées est largement suffisante pour que vous puissiez y trouver votre bonheur. Dans notre exemple, nous utiliserons le port numéro 1705.
Afin d'illustrer cet article, nous allons concevoir un logiciel permettant de répondre à des questionnaires simples via un réseau. Ces questionnaires seront une succession de questions à choix multiples pour lesquelles chaque bonne réponse donnera un point. Le logiciel se nomme LoginQuiz et se trouve dans les sources disponibles à la fin de l'article. La particularité de LoginQuiz est d'utiliser le format XML pour la définition de ses questionnaires. Une grande partie du code est ainsi propre à la création des questionnaires depuis des fichiers XML. Nous n'aborderons pas cet aspect de LoginQuiz, mais sachez que les fichiers Answer.java, Question.java, QuestionSetHandler.java et QuestionSetReader.java sont dédiés à cette tâche. Toute la partie réseau de notre application est facilitée, comme nous allons le voir, par l'utilisation d'une API nommée Caffeine.

III-C - Définition du serveur

Implémenter un serveur demande, outre du code, un minimum de réflexion quant à la manière dont le serveur transmet et reçoit les informations. Notre application ne nécessitant pas d'envois rapides et répétés (à l'inverse de Quake 3 par exemple) de données, nous nous contenterons d'un système fort simple dont voici la description :

Réception:
? : le client demande l'envoi de la question suivante
$answer : le client propose la réponse answer et en demande la vérification

Envoi:
% : code terminal, le questionnaire est fini
$0 : mauvaise réponse
$1 : bonne réponse
?libellé#ID1$réponse1#ID2$réponse2... : libellé est la question elle-même,
IDx représente l'identifiant de la réponse (transmis pour vérification) et
réponseX est le libellé de la réponse

Hormis l'encodage de la question, notre "protocole" est vraiment très simple. L'intérêt de séparer chaque réponse par le caractère # permettra au client de les séparer très facilement en utilisant l'objet java.util.StringTokenizer. Penchons-nous maintenant sur le cœur du serveur qui est inscrit dans le fichier QuestionSetServer.java. La création du serveur passe par le constructeur CaffeineServer(int port, String nom, String motDePasse, int nombreConnexionsMax) de la classe parente. Ici, nous utilisons donc le port 1705, n'utilisons pas de mot de passe et définissons 25 comme étant le nombre maximum de clients. La seule autre particularité de cette classe est la méthode serverEvent(ServerEvent evt) appelée à chaque réception de données. Nous allons maintenant laisser un instant cette classe pour regarder de plus près la fameuse CaffeineServer.

III-D - Implémentation de ServerSocket

Les deux parties de la classe CaffeineServer que nous allons étudier sont les méthodes startServer() et getClient(). La première méthode prend en charge la création du serveur même :

try
{
server = new ServerSocket(serverPort);
server.setSoTimeout(1000);
} catch (IOException ioe) {
System.err.println("[Cannot initialize Server]\n" + ioe);
System.exit(1);
}

L'instanciation d'un socket serveur est très simple et demande une seule ligne de code. La deuxième ligne permet de définir un délai au bout duquel une tentative de lecture des sockets connectés est considérée comme un échec. Cela pour éviter de bloquer le serveur en cas de problèmes de transmission. Vous constaterez également que lors de la création d'un serveur, une exception peut être rencontrée, auquel cas nous arrêtons tout. La suite de la méthode startServer() n'offre que peu d'intérêt si ce n'est de montrer de quelle manière il est possible de connaître l'adresse IP de la machine. La seconde méthode, getClient() est placée dans la boucle d'un thread et attend tout simplement que quelqu'un daigne bien tenter de se connecter au serveur :

client = server.accept();
new Authorizer(client, password);

Ces deux lignes font tout le travail. L'appel à server.accept() va effectivement attendre qu'un client se connecte. Si tel est le cas, nous récupérons alors un objet socket. Ensuite, nous créons un nouvel objet Authorizer. Cet objet un peu spécial est une classe interne de CaffeineServer. Cette classe va démarrer un thread qui se chargera d'administrer la demande de connexion. L'usage d'un thread permet d'éviter au serveur de bloquer les demandes des autres clients en attendant la résolution de la demande en cours. Comme nous n'utilisons pas de mot de passe, la seule partie d'Authorizer qui nous est utile est la suivante :

addClient(new CaffeineSocket(CaffeineServer.this, client));

Ce code va simplement créer un nouvel objet CaffeineSocket et l'ajouter à notre liste de clients. CaffeineSocket est un objet gérant entièrement les sockets clients. En effet, cette classe va créer, de la même manière que nous l'avions fait pour notre expéditeur de mails, des flux d'E/S pour faciliter la communication avec le client. Cet objet crée aussi un thread dans lequel nous nous contentons d'attendre des données avant de les transmettre au serveur :

if ((request = reader.readLine()) != null)
parent.fireEvent(request, this);

Nous n'irons pas plus avant dans les explications concernant la lecture de données puisque c'est exactement la même méthode que celle utilisée pour les sockets clients. Sachez seulement que l'appel à fireEvent() est celui qui permettra à la classe QuestionSetServer de recevoir les données par le biais de la méthode serverEvent().

III-E - Une réponse pour la 259.162.11.20, une !

Revenons maintenant à la classe QuestionSetServer. Celle-ci, grâce au système d'événements mis en place par l'API Caffeine, n'a pas à se soucier de la lecture des données en provenance des clients, mais seulement de leur envoi. Chaque expédition de données est réalisée ainsi :

client.getOut().println("données");
client.getOut().flush();

L'objet client désigne un objet CaffeineSocket, la méthode getOut() renvoie un flux PrintWriter que nous utilisons exactement comme pour l'envoi de mail en faisant appel à println(). La méthode flush() permet de s'assurer que tous les caractères du message ont été effectivement envoyés. L'exemple proposé ici, LoginQuiz, étant un petit peu complexe, une étude attentive du code source, commenté, vous permettra de saisir toutes les, rares, subtilités et notamment d'approfondir votre expérience des sockets clients.

III-F - Screenshots

Deux clients répondant au questionnaire

Un questionnaire écrit en XML

Test de notre protocole sous Telnet

Un autre exemple de serveur

IV - Les sources

- Le logiciel LoginMail
- Le logiciel LoginQuiz