Développement d'un serveur Java multi-thread simple

Cet article explique la réalisation pas à pas d'un serveur multithread en java utilisant les sockets. Ce serveur se chargera de broadcaster les messages lui arrivant à tous les clients qui y sont connectés.

Article lu   fois.

L'auteur

Profil ProSite personnel

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

Introduction

Nous allons, dans cet article, tenter de comprendre les mécanismes de base de la communication par sockets via la construction d'un serveur java multiclient. Ce serveur s'attachera à simplement rediriger les flux arrivant d'un client connecté vers tous les autres et ceci indépendemment du type du client et du sytème l'hébergeant. En effet les sockets sont une couche suffisamment bas niveau pour être inclus dans la plupart des systèmes et pour être supportés par une majorité des langages de programmation. Voici les fonctionnalités du serveur java que nous allons programmer, baptisé Blablaserveur : - serveur en java, donc multiplateforme, - multithread, avec donc la possibilité d'accepter plusieurs clients simultanément, - action du serveur : reçoit un message du client, l'envoie alors à tous les autres connectés, - possibilité d'interroger le serveur à travers des commandes serveurs. Tout ceci peut sembler peu, mais ça implique une universalité du serveur. En plus d'être multiplateforme, il va s'avérer trés simple de mettre en relation des clients de différents langages. Ainsi, il sera possible de faire communiquer des clients de tous types entre eux (flash, java, c#, c++, delphi, ...). L'inconvénient sera la sécurité puisque n'importe qui pourra développer un client pour "géner" votre application. Mais il ne s'agit pas non plus de faire un serveur IRC, et BlablaServeur pourra convenir pour de petites applications.

1. Le fonctionnement

Voici un aperçu de l'architecture de l'application que nous allons développer :

Image non disponible

D'abord, définition : qu'est ce qu'un Thread ? Il s'agit d'un ensemble d'instructions qui va se partager le processeur avec d'autres "morceaux" du programme. On a ainsi l'impression que plusieurs actions sont effectuées en parallèle. Ici, nous aurons trois types de thread :

  • le thread principal : il va attendre les connexions en écoutant le port ouvert pour le serveur. A chaque connexion il va lancer un nouveau thread de type client.
  • le thread client : il y en a donc un associé à chaque client connecté, il est chargé d'attendre l'arrivée d'un message. A chaque fois, il enverra le message reçu à tous les clients connectés.
  • le thread des commandes : il attend que l'admin du serveur tape des commandes dans la console pour faire une action (quitter le serveur par exemple).

Et en pratique, on fait ça comment ?

2. Le développement

Le langage choisi ici est le java, simplement parce qu'on pourra lancer le serveur aussi facilement sous Unix, que sous Windows ou que sous Mac (et aussi parce que les sockets sont assez simples à utiliser en java).

Nous allons créer 3 classes :

BlablaServ, correspond au thread principal. Contient les méthodes suivantes :

  • public static void main(String args[])
  • static private void printWelcome(Integer port)
  • synchronized public void sendAll(String message,String sLast)
  • synchronized public void delClient(int i)
  • synchronized public int addClient(PrintWriter out)
  • synchronized public int getNbClients()

BlablaThread, correspond au thread client. Contient les méthodes suivantes :

  • BlablaThread(Socket s, BlablaServ blablaServ)
  • public void run()

Commandes, correspond au thread des commandes. Contient les méthodes suivantes :

  • Commandes(BlablaServ blablaServ)
  • public void run()

Créez un répertoire "blabla" et placez y trois fichiers de type textes vides : BlablaServ.java, BlablaThread.java, Commandes.java.

Dans les explications nous ne détaillerons pas trop le code, il semble suffisamment commenté pour comprendre chaque ligne.

La classe BlablaServ

Mettez dans le fichier BlablaServ.java, le code suivant :

BlablaServ.java
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
48.
49.
50.
51.
52.
53.
54.
55.
56.
57.
58.
59.
60.
61.
62.
63.
64.
65.
66.
67.
68.
69.
70.
71.
72.
73.
74.
75.
76.
77.
78.
79.
80.
81.
82.
83.
84.
85.
86.
87.
88.
89.
90.

import java.net.*;
import java.io.*;
import java.util.*;

//** Classe principale du serveur, gère les infos globales **
public class BlablaServ
{
  private Vector _tabClients = new Vector(); // contiendra tous les flux de sortie vers les clients
  private int _nbClients=0; // nombre total de clients connectés

  //** Methode : la première méthode exécutée, elle attend les connections **
  public static void main(String args[])
  {
    BlablaServ blablaServ = new BlablaServ(); // instance de la classe principale
    try
    {
      Integer port;
      if(args.length<=0) port=new Integer("18000"); // si pas d'argument : port 18000 par défaut
      else port = new Integer(args[0]); // sinon il s'agit du numéro de port passé en argument

      new Commandes(blablaServ); // lance le thread de gestion des commandes

      ServerSocket ss = new ServerSocket(port.intValue()); // ouverture d'un socket serveur sur port
      printWelcome(port);
      while (true) // attente en boucle de connexion (bloquant sur ss.accept)
      {
        new BlablaThread(ss.accept(),blablaServ); // un client se connecte, un nouveau thread client est lancé
      }
    }
    catch (Exception e) { }
  }

  //** Methode : affiche le message d'accueil **
  static private void printWelcome(Integer port)
  {
    System.out.println("--------");
    System.out.println("BlablaServeur : Par Minosis - Julien Defaut");
    System.out.println("Copyright : 2004 - Minosis.com");
    System.out.println("Derniere version : 10/04/2004");
    System.out.println("--------");
    System.out.println("Demarre sur le port : "+port.toString());
    System.out.println("--------");
    System.out.println("Quitter : tapez \"quit\"");
    System.out.println("Nombre de connectes : tapez \"total\"");
    System.out.println("--------");
  }


  //** Methode : envoie le message à tous les clients **
  synchronized public void sendAll(String message,String sLast)
  {
    PrintWriter out; // declaration d'une variable permettant l'envoi de texte vers le client
    for (int i = 0; i < _tabClients.size(); i++) // parcours de la table des connectés
    {
      out = (PrintWriter) _tabClients.elementAt(i); // extraction de l'élément courant (type PrintWriter)
      if (out != null) // sécurité, l'élément ne doit pas être vide
      {
      	// ecriture du texte passé en paramètre (et concaténation d'une string de fin de chaine si besoin)
        out.print(message+sLast);
        out.flush(); // envoi dans le flux de sortie
      }
    }
  }

  //** Methode : détruit le client no i **
  synchronized public void delClient(int i)
  {
    _nbClients--; // un client en moins ! snif
    if (_tabClients.elementAt(i) != null) // l'élément existe ...
    {
      _tabClients.removeElementAt(i); // ... on le supprime
    }
  }

  //** Methode : ajoute un nouveau client dans la liste **
  synchronized public int addClient(PrintWriter out)
  {
    _nbClients++; // un client en plus ! ouaaaih
    _tabClients.addElement(out); // on ajoute le nouveau flux de sortie au tableau
    return _tabClients.size()-1; // on retourne le numéro du client ajouté (size-1)
  }

  //** Methode : retourne le nombre de clients connectés **
  synchronized public int getNbClients()
  {
    return _nbClients; // retourne le nombre de clients connectés
  }

}

La fonction statique main() est exécutée en premier au lancement de l'application. Les paramètres passés au démarrage de l'application se trouvent stockés dans le tableau de strings args[]. Le seul argument que nous gérons est le numéro de port. Si l'argument est absent, alors le port sera 18000. Si le port est déjà pris par une autre application, ou un problème survient lors de la création du ServerSoket, une exception est levée et un message d'erreur est affiché.

Juste après l'instanciation du ServerSocket, un message d'accueil est lancé ainsi que le thread d'attente des commandes.

Puis une boucle infinie attend les connexions des clients. Dès qu'une requète arrive, ss.accept() est débloqué et une nouvelle instance de BlablaThread est créée. L'objet BlablaServer est passé en paramètre pour pouvoir utiliser, dans les threads clients, ses méthodes de gestion du vecteur _tabClients. On observe que ces méthodes sont "synchronised", cela signifie que chaque thread, voulant en exécuter une, devra attendre qu'un thread ait fini de l'utiliser. Ceci évite ainsi des accés simultanés aux variables communes (_tabClients et _nbClients).

La classe BlablaThread

Mettez dans le fichier BlablaThread.java, le code suivant :

BlablaThread.java
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
48.
49.
50.
51.
52.
53.
54.
55.
56.
57.
58.
59.
60.
61.
62.
63.
64.
65.
66.
67.
68.
69.
70.
71.
72.
73.
74.
75.
76.
77.
78.

import java.net.*;
import java.io.*;

//** Classe associée à chaque client **
//** Il y aura autant d'instances de cette classe que de clients connectés **
//implémentation de l'interface Runnable (une des 2 méthodes pour créer un thread)
class BlablaThread implements Runnable
{
  private Thread _t; // contiendra le thread du client
  private Socket _s; // recevra le socket liant au client
  private PrintWriter _out; // pour gestion du flux de sortie
  private BufferedReader _in; // pour gestion du flux d'entrée
  private BlablaServ _blablaServ; // pour utilisation des méthodes de la classe principale
  private int _numClient=0; // contiendra le numéro de client géré par ce thread

  //** Constructeur : crée les éléments nécessaires au dialogue avec le client **
  BlablaThread(Socket s, BlablaServ blablaServ) // le param s est donnée dans BlablaServ par ss.accept()
  {
    _blablaServ=blablaServ; // passage de local en global (pour gestion dans les autres méthodes)
    _s=s; // passage de local en global
    try
    {
      // fabrication d'une variable permettant l'utilisation du flux de sortie avec des string
      _out = new PrintWriter(_s.getOutputStream());
      // fabrication d'une variable permettant l'utilisation du flux d'entrée avec des string
      _in = new BufferedReader(new InputStreamReader(_s.getInputStream()));
      // ajoute le flux de sortie dans la liste et récupération de son numero
      _numClient = blablaServ.addClient(_out);
    }
    catch (IOException e){ }

    _t = new Thread(this); // instanciation du thread
    _t.start(); // demarrage du thread, la fonction run() est ici lancée
  }

  //** Methode :  exécutée au lancement du thread par t.start() **
  //** Elle attend les messages en provenance du serveur et les redirige **
  // cette méthode doit obligatoirement être implémentée à cause de l'interface Runnable
  public void run()
  {
    String message = ""; // déclaration de la variable qui recevra les messages du client
    // on indique dans la console la connection d'un nouveau client
    System.out.println("Un nouveau client s'est connecte, no "+_numClient);
    try
    {
      // la lecture des données entrantes se fait caractère par caractère ...
      // ... jusqu'à trouver un caractère de fin de chaine
      char charCur[] = new char[1]; // déclaration d'un tableau de char d'1 élement, _in.read() y stockera le char lu
      while(_in.read(charCur, 0, 1)!=-1) // attente en boucle des messages provenant du client (bloquant sur _in.read())
      {
      	// on regarde si on arrive à la fin d'une chaine ...
        if (charCur[0] != '\u0000' && charCur[0] != '\n' && charCur[0] != '\r')
                message += charCur[0]; // ... si non, on concatène le caractère dans le message
        else if(!message.equalsIgnoreCase("")) // juste une vérification de principe
        {
          if(charCur[0]=='\u0000') // le dernier caractère était '\u0000' (char de terminaison nulle)
          	// on envoi le message en disant qu'il faudra concaténer '\u0000' lors de l'envoi au client
            _blablaServ.sendAll(message,""+charCur[0]);
          else _blablaServ.sendAll(message,""); // sinon on envoi le message à tous
          message = ""; // on vide la chaine de message pour qu'elle soit réutilisée
        }
      }
    }
    catch (Exception e){ }
    finally // finally se produira le plus souvent lors de la deconnexion du client
    {
      try
      {
      	// on indique à la console la deconnexion du client
        System.out.println("Le client no "+_numClient+" s'est deconnecte");
        _blablaServ.delClient(_numClient); // on supprime le client de la liste
        _s.close(); // fermeture du socket si il ne l'a pas déjà été  cause de l'exception levée plus haut)
      }
      catch (IOException e){ }
    }
  }
}

Le socket créé par ss.accept() est ici reçu en paramètre, à partir de _s, on crée ainsi les buffers gérant le flux d'entrée et le flux de sortie.

Le thread est ensuite lancé (méthode run()). Une attente en boucle des données a lieu. Dès qu'un caractère arrive en entrée, il est concaténé à la chaine _message. Le message est envoyé dés qu'un caractère de fin de chaine est détecté. Si il s'agit de '\u0000', celui ci sera concaténé au message lors de l'envoi (méthode sendAll()). Ceci est nécessaire, essentiellement à cause des clients Flash pour lesquels le message reçu doit se finir par le zéro terminal (cf doc Flash partie onXML). Les autres types de clients sont, en général, plus tolérants. Au moins, BlablaServeur fonctionnera indépendamment du type de clients.

Enfin, aprés tout événement entraînant la fin de la boucle, le bloc finally{} sera exécuté pour mettre fin proprement à l'existence du thread client.

La classe Commandes

Mettez dans le fichier Commandes.java, le code suivant :

Commandes.java
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
48.
49.
50.
51.
52.

import java.io.*;

//** Classe qui gère les commandes tapées dans la console **
// implémentation de l'interface Runnable (une des 2 méthodes pour créer un thread)
class Commandes implements Runnable
{
  BlablaServ _blablaServ; // pour utilisation des méthodes de la classe principale
  BufferedReader _in; // pour gestion du flux d'entrée (celui de la console)
  String _strCommande=""; // contiendra la commande tapée
  Thread _t; // contiendra le thread

  //** Constructeur : initialise les variables nécessaires **
  Commandes(BlablaServ blablaServ)
  {
    _blablaServ=blablaServ; // passage de local en global
    // le flux d'entrée de la console sera géré plus pratiquement dans un BufferedReader
    _in = new BufferedReader(new InputStreamReader(System.in));
    _t = new Thread(this); // instanciation du thread
    _t.start(); // demarrage du thread, la fonction run() est ici lancée
  }

  //** Methode : attend les commandes dans la console et exécute l'action demandée **
  public void run() // cette méthode doit obligatoirement être implémentée à cause de l'interface Runnable
  {
    try
    {
      // si aucune commande n'est tapée, on ne fait rien (bloquant sur _in.readLine())
      while ((_strCommande=_in.readLine())!=null)
      {
        if (_strCommande.equalsIgnoreCase("quit")) // commande "quit" detectée ...
          System.exit(0); // ... on ferme alors le serveur
        else if(_strCommande.equalsIgnoreCase("total")) // commande "total" detectée ...
        {
          // ... on affiche le nombre de clients actuellement connectés
          System.out.println("Nombre de connectes : "+_blablaServ.getNbClients());
          System.out.println("--------");
        }
        else
        {
          // si la commande n'est ni "total", ni "quit", on informe l'utilisateur et on lui donne une aide
          System.out.println("Cette commande n'est pas supportee");
          System.out.println("Quitter : \"quit\"");
          System.out.println("Nombre de connectes : \"total\"");
          System.out.println("--------");
        }
        System.out.flush(); // on affiche tout ce qui est en attente dans le flux
      }
    }
    catch (IOException e) {}
  }
}

Cette classe n'est pas indispensable dans le fonctionnement du serveur mais apporte la possibilité d'une administration propre même si minimaliste.

La classe est instanciée dès le chargement du serveur (méthode main() de BlablaServ). Elle crée dès le départ un buffer d'entrée à partir du flux provenant du système (texte tapé dans la console en général, dans une fenêtre Dos sous Windows ou le terminal sous Unix/Linux). L'activité principale (dans run()) est d'attendre les instructions et de les exécuter. La méthode readLine() est ici plus adaptée et plus simple que le read() utilisé dans le thread client. Bien sûr la méthode readLine() n'était pas intéressante dans le thread client puisque le zéro terminal devait être géré.

Deux commandes sont implémentées : le "quit" qui appelle un simple System.exit(0) et le "total" qui informe sur la quantité de clients connectés.

3. La compilation

Le code est écrit, il reste maintenant à compiler nos .java et exécuter notre serveur.

Nous partons du principe que le jdk version 1.4.1 ou plus est installé sur votre machine. Comme il y a peu de packages évolués d'utilisés dans notre code, il ne devrait pas y avoir trop de problème sur des jdk inférieurs. Je n'ai cependant pas testé.

Ouvrez une console (Dos, xTerm, ... selon votre OS). Placez vous dans le repertoire blabla et faites javac *.java. Normalement, un message indique que l'on utilise un élément déprécié de l'API. Il s'agit de fermeture sauvage du thread client _t.stop(). Nous ne tiendrons pas compte de ce message donc son apparition indique que la compilation s'est bien déroulée. 3 fichiers .class avec les même noms que les .java doivent être apparues dans le répertoire "blabla". Dans le cas contraire vérifiez que vous avez effectué toutes les tâches correctement ...

4. Premier démarrage

Après avoir téléchargé le serveur (ou suivi les chapitres précédents), décompressez le zip dans le répertoire de votre choix (nous l'appellerons "blabla/").

Pour lancer le serveur sous n'importe quel système, tapez en ligne de commande dans le répertoire blabla : java -jar BlablaServ.jar 18000. Sous windows, la commande java BlablaServ peut suffire mais je n'ai pas réussi à lancer le serveur de cette manière sous Unix. Un fichier jar exécutable est donc contenu dans le zip et permet d'avoir un serveur qui fonctionne partout. Un jar exécutable est une simple archive zip renommée qui contient les classes ainsi qu'un manifest identifiant la classe principale. Décompressez le jar si vous souhaitez en comprendre la structure.

Encore plus simplement, dans le zip vous trouverez deux fichiers pour lancer le serveur sur le port 18000, éditez les pour changer le port :

Windows : double-cliquez sur BlablaServ.bat

Unix/Linux : tappez sh BlablaServ.sh ou ./BlablaServ.sh

En cas de problème vérifiez que la version de votre JRE d'installée est la 1.4.1 minimum pour être sûr du fonctionnement du serveur. Avec des versions antérieures, le serveur n'a pas été testé mais peut sûrement fonctionner. Pour consulter la version, lancez une fenêtre console (Invite msdos par exemple) et tapez java -version. Si vous voulez installer la version la plus récente du JRE, rendez-vous sur le site de Sun. Vérifiez aussi que le port 18000 n'est pas pris.

Logiquement à ce stade un message apparaît en indiquant que le serveur est démarré sur le port 18000 :

Image non disponible

On voit qu'en tapant le mot "total" (puis Entrer), le nombre de connectés apparaît. Logiquement, pour l'instant le nombre est 0.

Pour quitter proprement le serveur, veillez à taper "quit".

Pour tester le serveur, l'archive propose un programme en Flash, il suffit d'ouvrir le fichier blablachat.swf de l'archive. Vous pouvez choisir l'adresse du serveur. Mais pour modifier le port de connexion 18000 par défaut, éditez blablachat.fla et changez la ligne socket.connect(_root.ipserveur, 18000); avec le port qui vous convient.

Téléchargements

Téléchargez les sources et packages du projet (inclus client Flash de test).

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

  

Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright © 2005 Julien DEFAUT. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.