Mis à jour le 04/12/2018
  • 50 heures
  • Difficile

Ce cours est visible gratuitement en ligne.

Ce cours existe en livre papier.

Vous pouvez obtenir un certificat de réussite à l'issue de ce cours.

Vous pouvez être accompagné et mentoré par un professeur particulier par visioconférence sur ce cours.

J'ai tout compris !

Communiquez en réseau avec son programme

Connectez-vous ou inscrivez-vous gratuitement pour bénéficier de toutes les fonctionnalités de ce cours !

Ah... Le réseau...
C'est un peu le fantasme de la plupart des nouveaux programmeurs : arriver à faire en sorte que son programme puisse communiquer à travers le réseau, que ce soit en local (entre 2 PC chez vous) ou sur internet.

Pourtant, c'est un sujet complexe parce que... il ne suffit pas seulement de savoir programmer en C++, il faut aussi beaucoup de connaissances théoriques sur le fonctionnement du réseau. Les couches d'abstraction, TCP/IP, UDP, Sockets... peut-être avez-vous entendu ces mots-là mais sauriez-vous vraiment les définir ?

Cependant, vous êtes nombreux à m'avoir demandé de faire un chapitre traitant du réseau. Face à la demande, j'ai finalement accepté de faire une exception.
Exceptionnellement donc, nous n'allons pas vraiment parler que de GUI, nous allons aussi parler de réseau.

Seulement voilà, comme je vous l'ai dit, pour bien faire il faudrait un tutoriel complet que je n'ai ni le temps ni les moyens de rédiger. Du coup, j'ai finalement trouvé un compromis : on va faire une sorte de chapitre-TP. Il y aura de la théorie et de la pratique à la fois.
Nous ne verrons pas tout, nous nous concentrerons sur l'architecture réseau la plus classique (client / serveur). Cela vous donnera les bases pour comprendre un peu comment ça marche, et puis après il ne tiendra plus qu'à vous d'adapter ces exemples à vos programmes.

C'est un chapitre-TP ? Mais alors, quel est le sujet du TP ?

Le sujet du "chapitre-TP" sera la réalisation d'un logiciel de Chat en réseau. Vous pourrez aussi bien communiquer en réseau local (entre vos PC à la maison) qu'entre plusieurs PC via internet.

On y va ? :)
On va devoir commencer par un petit cours théorique, absolument indispensable pour comprendre la suite de ce chapitre !

Comment communique-t-on en réseau ?

Voilà une bien bonne question !
A laquelle... je pourrais vous répondre par une encyclopédie en 12 volumes, et encore je n'aurais pas tout expliqué. :p

Nous allons donc voir les notions théoriques de base sur le réseau de façon light et ludique. A partir de là, nous pourrons voir comment on utilise ces connaissances en pratique avec Qt pour réaliser un Chat en réseau.

Pour nos exemples, nous allons imaginer 2 utilisateurs. Appelons-les... par exemple Patrice et Ludovic.
Patrice et Ludovic ont chacun un ordinateur et ils voudraient communiquer entre eux.

Communication en réseau
Communication en réseau

Comment faire ? Comment communiquer, sachant qu'il y a des centaines, des milliers d'autres ordinateurs sur le réseau ?
Et comment peuvent-ils se faire comprendre entre eux, faut-il qu'ils parlent le même langage ?

Pour que vous puissiez avoir 2 programmes qui communiquent entre eux via le réseau, il vous faut 3 choses :

  1. Connaître l'adresse IP identifiant l'autre ordinateur.

  2. Utiliser un port libre et ouvert.

  3. Utiliser le même protocole de transmission des données.

Si tous ces éléments sont réunis, c'est bon. :)
Voyons voir comment faire pour avoir tout ça...

1/ L'adresse IP : identification des machines sur le réseau

La première chose qui devrait vous préoccuper, c'est de savoir comment les ordinateurs font pour se reconnaître entre eux sur un réseau.
Comment fait Patrice pour envoyer un message à Ludovic et seulement à lui ?

Qu'est-ce qu'une IP ?

Il faut savoir que chaque ordinateur est identifié sur le réseau par ce qu'on appelle une adresse IP. C'est une série de nombres, par exemple :

85.215.27.118

Cette adresse représente un ordinateur. Lorsque vous connaissez l'adresse IP de la personne avec qui vous voulez communiquer, vous savez déjà au moins vers qui vous vous dirigez. :p

Mais voilà, le problème, parce que sinon ça serait trop simple, c'est qu'un ordinateur peut avoir non pas une mais plusieurs IP.
En général aujourd'hui, on peut considérer qu'un ordinateur a en moyenne 3 IP :

  • Une IP interne : c'est le localhost, aussi appelé loopback. C'est une IP qui sert pour communiquer à soi-même. Pas très utile vu qu'on n'emprunte pas le réseau du coup, mais ça nous sera très pratique pour les tests vous verrez.
    Exemple : 127.0.0.1

  • Une IP du réseau local : si vous avez plusieurs ordinateurs en réseau chez vous, ils peuvent communiquer entre eux sans passer par internet grâce à ces IP. Elles sont propres au réseau de votre maison.
    Exemple : 192.168.0.3

  • Une IP internet : c'est l'IP utilisée pour communiquer avec tous les autres ordinateurs de la planète qui sont connectés à internet. :-°
    Exemple : 86.79.12.105

Patrice et Ludovic ont donc plusieurs IP, selon le niveau auquel on se place :

Adresses IP

Si je vous raconte ça, c'est parce que nous aurons besoin d'utiliser l'une ou l'autre de ces IP en fonction de la distance qui sépare Patrice de Ludovic.

Si Patrice et Ludovic sont dans une même maison, reliés par un réseau local, nous utiliserons une IP du réseau local (en rouge sur mon schéma).
Si Patrice et Ludovic sont reliés par internet, nous utiliserons leur adresse internet (en vert).

Pour ce qui est de l'adresse localhost, elle peut nous servir pour "simuler" le fonctionnement du réseau. Si Patrice envoie un message à 127.0.0.1, celui-ci va immédiatement lui revenir. Cela peut nous être donc utile si on ne veut pas déranger notre ami Ludovic toutes les 5 minutes pour tester la dernière version de notre programme. :p

Retrouver son adresse IP

Comment je connais mon IP ? Ou plutôt mes IP ?
Et comment je sais laquelle correspond au réseau local, et laquelle correspond à celle d'internet ?

La méthode dépend de l'IP que vous recherchez.

  • Pour l'IP interne : pas besoin d'aller chercher plus loin, à coup sûr c'est 127.0.0.1 (ou son équivalent texte "localhost").

  • Pour l'IP locale : pour la retrouver tout dépend de votre système d'exploitation.

    • Sous Windows, ouvrez une invite de commande (par exemple celui que vous utilisez avec Qt pour compiler) et tapezipconfig
      Il est possible que vous ayez plusieurs réponses, en fonction des moyens de connexion disponibles (câble ethernet, wifi...). En tout cas, l'une des IP que l'on vous donne est la bonne (à la ligne "Adresse IPv4").

    • Sous Linux ou Mac OS, c'est le même principe dans une console mais pas la même commande :ifconfig
      L'adresse est en général de la forme "192.168.XXX.XXX", mais cela peut être parfois différent.

  • Pour l'IP internet : le plus simple est probablement d'aller sur un site web qui est capable de vous la donner, comme par exemple www.whatismyip.com !

Maintenant que vous connaissez l'adresse IP de votre interlocuteur, alors vous allez pouvoir communiquer avec lui... ou presque. Le problème, c'est qu'il y a plusieurs portes d'entrée sur chaque ordinateur. C'est ce qu'on appelle les ports.

2/ Les ports : différents moyens d'accès à un même ordinateur

Un ordinateur connecté à un réseau reçoit beaucoup de messages en même temps.
Par exemple, si vous allez sur un site web en même temps que vous récupérez vos mails, des données différentes vont vous arriver simultanément.

Pour ne pas confondre ces données et organiser tout ce bazar, on a inventé le concept de port.
Un port est un nombre compris entre 0 et 65 535. Voici quelques ports célèbres :

  • 21 : utilisé par les logiciels FTP pour envoyer et recevoir des fichiers.

  • 80 : utilisé pour naviguer sur le web par votre navigateur (par exemple Firefox, ou plutôt zNavigo :-° ).

  • 110 : utilisé pour la réception de mails.

Imaginez que ces ports sont autant de portes d'entrée à votre ordinateur :

Ports
Ports

Si on veut faire un programme qui communique avec Ludovic, il va falloir choisir un port qui ne soit pas déjà utilisé par un autre programme.

La plupart des ports dont les numéros sont inférieurs à 1 024 sont déjà réservés par votre machine. Nous ferons donc en sorte de préférence dans notre programme d'utiliser un numéro de port compris entre 1 024 et 65 535.

3/ Le protocole : transmettre des données avec le même "langage"

Bon, nous savons désormais 2 choses :

  • Chaque ordinateur est identifié par une adresse IP.

  • On peut accéder à une IP via des milliers de ports différents.

L'IP, vous savez la retrouver. Le port, il faudra en choisir un qui soit libre (nous verrons comment en pratique plus tard).
Vous êtes donc maintenant en mesure d'établir une connexion avec un ordinateur distant, car vous avez les 2 éléments nécessaires : une IP et un port.

Il reste maintenant à envoyer des données à l'ordinateur distant pour que les 2 programmes puissent "parler" entre eux. Et ça mine de rien, ce n'est pas simple. En effet, il faut que les 2 programmes parlent la même langue, le même protocole. Il faut qu'ils communiquent de la même façon.

Exemple de la vie courante : vous dites "Bonjour" lorsque vous commencez à parler à quelqu'un, et "Au revoir" lorsque vous partez. Eh bien pour les ordinateurs c'est pareil !

Les différents niveaux des protocoles de communication

Il existe des centaines de protocoles de communication différents. Ceux-ci peuvent être très simples comme très complexes, selon si vous discutez à un "haut niveau" ou à un "bas niveau". On peut donc les ranger dans 2 catégories :

  • Protocoles de haut niveau : par exemple le protocole FTP, qui utilise le port 21 pour envoyer et recevoir des fichiers, est un système d'échange de données de haut niveau. Son mode de fonctionnement est déjà écrit et documenté. Il est donc assez facile à utiliser, mais on ne peut pas lui rajouter des possibilités.

  • Protocoles de bas niveau : par exemple le protocole TCP. Il est utilisé par les programmes pour lesquels aucun protocole de haut niveau ne convient. Vous devrez manipuler les données qui transitent sur le réseau octet par octet. C'est plus difficile, mais vous pouvez faire tout ce que vous voulez.

Protocoles
Protocoles

Les protocoles de haut niveau utilisent des ports bien connus et déjà définis.
Les protocoles de bas niveau peuvent emprunter n'importe quel port, sont beaucoup plus flexibles, mais le problème c'est qu'il faut définir tout leur fonctionnement.

Nous n'allons pas créer un logiciel de mails, ni un client FTP. Nous allons inventer notre propre technique de discussion pour notre programme, notre propre protocole basé sur un protocole de bas niveau... Nous allons donc travailler à bas niveau.

Mauvaise nouvelle : ça va être plus difficile. :D
Bonne nouvelle : ça va être intéressant techniquement.

Les protocoles de bas niveau TCP et UDP

Il faut savoir que les données s'envoient sur le réseau par petits bouts. On parle de paquets, qui peuvent être chacun découpés en sous-paquets :

Paquets sur le réseau

Par exemple, imaginons que Patrice envoie à Ludovic le message : "Salut Ludovic, comment ça va ?". Le message ne sera peut-être pas envoyé d'un seul coup, il sera probablement découpé en plus petits paquets. Par exemple, on peut imaginer qu'il y aura 4 sous-paquets (j'invente, car le découpage sera peut-être différent) :

  1. Sous-paquet 1 : "Salut Ludov"

  2. Sous-paquet 2 : "ic, co"

  3. Sous-paquet 3 : "mment ça v"

  4. Sous-paquet 4 : "a ?"

On peut envoyer ces paquets de plusieurs façons différentes, tout dépend du protocole de bas niveau que l'on utilise :

  • Protocole TCP : le plus classique. Il nécessite d'établir une connexion au préalable entre les ordinateurs. Il y a un système de contrôle qui permet de demander à renvoyer un paquet au cas où l'un d'entre eux se serait perdu sur le réseau (ça arrive :-° ). Par conséquent, avec TCP on est sûr que tous les paquets arrivent à destination, et dans le bon ordre.
    En contrepartie de ces contrôles sécurisants, l'envoi des données est plus lent qu'avec UDP.

  • Protocole UDP : il ne nécessite pas d'établir de connexion au préalable et il est très rapide. En revanche, il n'y a aucun contrôle ce qui fait qu'un paquet de données peut très bien se perdre sans qu'on en soit informé, ou les paquets peuvent arriver dans le désordre !

Il va falloir choisir l'un de ces 2 protocoles.
Pour moi, le choix est tout fait : ce sera TCP. En effet, nous allons réaliser un Chat et nous ne pouvons pas nous permettre que des messages (ou des bouts de messages) n'arrivent pas à destination, sinon la conversation pourrait devenir difficile à suivre et on risquerait de recevoir des messages comme : "Salut Ludovmment ça va ?" :-°

Mais alors, du coup tout le monde utilise TCP pour être sûr que le paquet arrive à destination non ? Qui peut bien être assez fou pour utiliser UDP ?

Certaines applications complexes qui utilisent beaucoup le réseau peuvent être amenées à utiliser UDP. Je pense par exemple aux jeux vidéo.

Prenez un jeu de stratégie comme Starcraft, ou un FPS comme Quake par exemple : il peut y avoir des dizaines d'unités qui se déplacent sur la carte en même temps. Il faut en continu envoyer la nouvelle position des unités qui se déplacent à tous les ordinateurs de la partie. On a donc besoin d'un protocole rapide comme UDP, et si un paquet se perd ce n'est pas grave : vu que la position des joueurs est rafraîchie plusieurs fois par seconde, ça ne se verra pas.

L'architecture du projet de Chat avec Qt

Nous venons de voir quelques petites notions théoriques sur le réseau, mais il va encore falloir préciser quelle est l'architecture réseau de notre programme de Chat.

Une architecture réseau ? Qu'est-ce que c'est que ça ? o_O

Jusqu'ici, nous avons supposé un cas très simple : il n'y avait que 2 ordinateurs (celui de Patrice et celui de Ludovic). Le problème, c'est que notre programme de Chat doit permettre à plus de 2 personnes de discuter en même temps. Imaginons qu'une troisième personne appelée Vincent arrive sur le Chat. Vous le placez où sur le schéma ? Au milieu entre les 2 autres compères ? :lol:

Les architectures réseau

Pour faire simple, on a 2 architectures possibles pour résoudre le problème :

  • Architecture client / serveur
    Architecture client / serveur

    Une architecture client / serveur : c'est l'architecture réseau la plus classique et la plus simple à mettre en oeuvre. Les machines des utilisateurs (Patrice, Ludovic, Vincent...) sont appelées des "clients". En plus de ces machines, on utilise un autre ordinateur (appelé "serveur") qui va se charger de répartir les communications entre les clients.

  • Architecture Peer-To-Peer
    Architecture Peer-To-Peer

    Une architecture Peer-To-Peer (P2P) : ce mode plus complexe est dit décentralisé, car il n'y a pas de serveur. Chaque client peut communiquer directement avec un autre client. C'est plus direct, ça évite d'encombrer un serveur, mais c'est plus délicat à mettre en place.

Nous, nous allons utiliser une architecture client / serveur, la plus simple.

Il va en fait falloir faire non pas un mais deux projets :

  • Un projet "serveur" : pour créer le programme qui va répartir les messages entre les clients.

  • Un projet "client" : pour chaque client qui participera au Chat.

Principe de fonctionnement du Chat

Le principe du Chat est simple : une personne écrit un message, et tout le monde reçoit ce message sur son écran.

Les choses se passent en 2 temps :

  • Vincent envoie un message au serveur
    Vincent envoie un message au serveur

    Un client envoie un message au serveur.

  • Le serveur renvoie le message à tout le monde (y compris Vincent)
    Le serveur renvoie le message à tout le monde (y compris Vincent)

    Le serveur renvoie ce message à tous les clients pour qu'il s'affiche sur leur fenêtre.

Pourquoi le serveur renverrait-il le message à Vincent, vu que c'est lui qui l'a envoyé ?

On peut gérer les choses de plusieurs manières. On pourrait s'arranger pour que le serveur n'envoie pas le message à Vincent pour éviter un trafic réseau inutile, mais cela compliquerait un petit peu le programme.
Il est plus simple de faire en sorte que le serveur renvoie le message à tout le monde sans distinction. Vincent verra donc son message s'afficher sur son écran de discussion uniquement quand le serveur l'aura reçu et le lui aura renvoyé. Cela permet de vérifier en outre que la communication sur le réseau fonctionne correctement.

Structure des paquets

Les messages qui circuleront sur le réseau seront placés dans des paquets. C'est à nous de définir la structure des paquets que l'on veut envoyer.

Par exemple, quand Vincent va envoyer un message, un paquet va être créé avant d'être envoyé sur le réseau. Voici la structure de paquet que je propose pour notre programme de Chat :

Structure du paquet

Le paquet est constitué de 2 parties :

  • tailleMessage : un nombre entier qui sert à indiquer la taille du message qui suit. Cela permet au serveur de connaître la taille totale du message envoyé, pour qu'il puisse savoir quand il a reçu le message en entier.

  • message : c'est le message envoyé par le client. Ce message sera de type QString (ça c'est simple, vous connaissez !).

Pourquoi envoie-t-on la taille du message en premier ? On ne pourrait pas envoyer le message tout court ?

Il faut savoir que le protocole TCP va découper le paquet en sous-paquets avant de l'envoyer sur le réseau. Il n'enverra peut-être pas tout d'un coup. Par exemple, notre paquet pourrait être découpé comme ceci :

Séparation en sous-paquets

On n'a aucun contrôle sur la taille de ces sous-paquets, et il n'y a aucun moyen de savoir à l'avance comment ça va être découpé.
Le problème, c'est que le serveur va recevoir ces paquets petit à petit, et non pas tout d'un coup. Il ne peut pas savoir quand la totalité du message a été reçue.

Bon, il faut qu'on arrive à savoir quand on a reçu le message en entier, et donc quand ce n'est plus la peine d'attendre de nouveaux sous-paquets.
Pour résoudre ce problème, on envoie la taille du message dans un premier temps. Lorsque la taille du message a été reçue, on va attendre que le message soit au complet. On se base sur tailleMessage pour savoir combien d'octets il nous reste à recevoir.

Lorsqu'on a récupéré tous les octets restants du paquet, on sait que le paquet est au complet, et cela veut dire qu'on a donc reçu le message entier.

Bon, c'est pas simple, mais je vous avais prévenu hein ! :D

Réalisation du serveur

Comme je vous l'ai dit, nous allons devoir réaliser 2 projets :

  • Un projet "client"

  • Un projet "serveur"

Nous commençons par le serveur.

Création du projet

Créez un nouveau projet constitué de 3 fichiers :

  • main.cpp

  • FenServeur.cpp

  • FenServeur.h

Editez le fichier .pro pour demander à Qt de rajouter la gestion du réseau :

TEMPLATE = app
QT += widgets network
DEPENDPATH += .
INCLUDEPATH += .

# Input
HEADERS += FenServeur.h
SOURCES += FenServeur.cpp main.cpp

AvecQT += widgets network, Qt sait que le projet va utiliser le réseau et peut préparer un makefile approprié.

La fenêtre du serveur

Le serveur est une application qui tourne en tâche de fond. Normalement, rien ne nous oblige à créer une fenêtre pour ce projet, mais on va quand même en faire une pour que l'utilisateur puisse arrêter le serveur en fermant la fenêtre.

Notre fenêtre sera toute simple, elle affichera le texte "Le serveur a été lancé sur le port XXXX" et un bouton "Quitter".

Fenêtre du serveur

Construire la fenêtre sera donc très simple, la vraie difficulté sera de faire toute la gestion du réseau derrière.

main.cpp

Les main sont toujours très simples et classiques avec Qt :

#include <QApplication>
#include "FenServeur.h"

int main(int argc, char* argv[])
{
    QApplication app(argc, argv);

    FenServeur fenetre;
    fenetre.show();

    return app.exec();
}

FenServeur.h

Voici maintenant le header de la fenêtre du serveur :

#ifndef HEADER_FENSERVEUR
#define HEADER_FENSERVEUR

#include <QtWidgets>
#include <QtNetwork>


class FenServeur : public QWidget
{
    Q_OBJECT

    public:
        FenServeur();
        void envoyerATous(const QString &message);

    private slots:
        void nouvelleConnexion();
        void donneesRecues();
        void deconnexionClient();

    private:
        QLabel *etatServeur;
        QPushButton *boutonQuitter;

        QTcpServer *serveur;
        QList<QTcpSocket *> clients;
        quint16 tailleMessage;
};

#endif

Notre fenêtre hérite de QWidget, ce qui nous permet de créer une fenêtre simple. Elle est constituée comme vous le voyez d'un QLabel et d'un QPushButton comme prévu.

En plus de ça, j'ai rajouté d'autres attributs spécifiques à la gestion du réseau :

  • QTcpServer *serveur: c'est l'objet qui représente le serveur sur le réseau.

  • QList<QTcpSocket *> clients: c'est un tableau qui contient la liste des clients connectés. On aurait pu utiliser un tableau classique, mais on va passer par une QList, un tableau de taille dynamique. En effet, on ne connaît pas à l'avance le nombre de clients qui se connecteront. Chaque QTcpSocket de ce tableau représentera une connexion à un client.

  • quint16 tailleMessage: ce quint16 sera utilisé dans le code pour se "souvenir" de la taille du message que le serveur est en train de recevoir. Nous en avons déjà parlé et nous en reparlerons plus loin.

Voilà, à part ces attributs, on note que la classe est constituée de plusieurs méthodes (dont des slots) :

  • Le constructeur : il initialise les widgets sur la fenêtre et initialise aussi le serveur (QTcpServer) pour qu'il démarre.

  • envoyerATous() : une méthode à nous qui se charge d'envoyer à tous les clients connectés le message passé en paramètre.

  • Slot nouvelleConnexion() : appelé lorsqu'un nouveau client se connecte.

  • Slot donneesRecues() : appelé lorsque le serveur reçoit des données. Attention, c'est là que c'est délicat, car ce slot est appelé à chaque sous-paquet reçu. Il faudra "attendre" d'avoir reçu le nombre d'octets indiqués dans tailleMessage avant de pouvoir considérer qu'on a reçu le message entier.

  • Slot deconnexionClient() : appelé lorsqu'un client se déconnecte.

Implémentons ces méthodes en coeur, dans la joie et la bonne humeur ! :D

FenServeur.cpp

Le constructeur

Le constructeur se charge de placer les widgets sur la fenêtre et de faire démarrer le serveur via le QTcpServer :

FenServeur::FenServeur()
{
    // Création et disposition des widgets de la fenêtre
    etatServeur = new QLabel;
    boutonQuitter = new QPushButton(tr("Quitter"));
    connect(boutonQuitter, SIGNAL(clicked()), qApp, SLOT(quit()));

    QVBoxLayout *layout = new QVBoxLayout;
    layout->addWidget(etatServeur);
    layout->addWidget(boutonQuitter);
    setLayout(layout);

    setWindowTitle(tr("ZeroChat - Serveur"));

    // Gestion du serveur
    serveur = new QTcpServer(this);
    if (!serveur->listen(QHostAddress::Any, 50885)) // Démarrage du serveur sur toutes les IP disponibles et sur le port 50585
    {
        // Si le serveur n'a pas été démarré correctement
        etatServeur->setText(tr("Le serveur n'a pas pu être démarré. Raison :<br />") + serveur->errorString());
    }
    else
    {
        // Si le serveur a été démarré correctement
        etatServeur->setText(tr("Le serveur a été démarré sur le port <strong>") + QString::number(serveur->serverPort()) + tr("</strong>.<br />Des clients peuvent maintenant se connecter."));
        connect(serveur, SIGNAL(newConnection()), this, SLOT(nouvelleConnexion()));
    }

    tailleMessage = 0;
}

J'ai fait en sorte de bien commenter mes codes sources pour vous aider du mieux possible à comprendre ce qui se passe.
Vous voyez bien une première étape où on dispose les widgets sur la fenêtre (classique, rien de nouveau) et une seconde étape où on démarre le serveur.

Quelques précisions sur la seconde étape, la plus intéressante pour ce chapitre. On crée un nouvel objet de type QTcpServer dans un premier temps (ligne 16). On lui passe en paramètre this, un pointeur vers la fenêtre, pour faire en sorte que la fenêtre soit le parent du QTcpServer. Cela permet de faire en sorte que le serveur soit automatiquement détruit lorsqu'on quitte la fenêtre.

Ensuite, on essaie de démarrer le serveur grâce àserveur->listen(QHostAddress::Any, 50885). Il y a 2 paramètres :

  • L'IP : c'est l'IP sur laquelle le serveur "écoute" si de nouveaux clients arrivent. Comme je vous l'avais dit, un ordinateur peut avoir plusieurs IP : une IP interne (127.0.0.1), une IP pour le réseau local, une IP sur internet, etc. La mention QHostAddress::Any autorise toutes les connexions : internes (clients connectés sur la même machine), locales (clients connectés sur le même réseau local) et externes (clients connectés via internet).

  • Le port : c'est le numéro du port sur lequel on souhaite lancer le serveur. J'ai choisi un numéro au hasard, compris entre 1 024 et 65 535. J'aurais aussi pu omettre ce paramètre, dans ce cas le serveur aurait choisi un port libre au hasard. N'hésitez pas à changer la valeur si le port n'est pas libre chez vous.

La méthode listen() renvoie un booléen : vrai si le serveur a bien pu se lancer, faux s'il y a eu un problème. On affiche un message en conséquence sur la fenêtre du serveur.
Si le démarrage du serveur a fonctionné, on connecte le signal newConnection() vers notre slot personnalisé nouvelleConnexion() pour traiter l'arrivée d'un nouveau client sur le serveur.

Si tout va bien, la fenêtre suivante devrait donc s'ouvrir :

Fenêtre du serveur

S'il y a une erreur, vous aurez un message d'erreur adapté. Par exemple, essayez de lancer une seconde fois le serveur alors qu'un autre serveur tourne déjà :

Serveur cassé

Ici, comme le port 50885 est déjà utilisé par un 1er serveur, notre 2nd serveur n'a pas le droit de démarrer sur ce port. D'où l'erreur. ;)

Slot nouvelleConnexion()

Ce slot est appelé dès qu'un nouveau client se connecte au serveur :

void FenServeur::nouvelleConnexion()
{
    envoyerATous(tr("<em>Un nouveau client vient de se connecter</em>"));

    QTcpSocket *nouveauClient = serveur->nextPendingConnection();
    clients << nouveauClient;

    connect(nouveauClient, SIGNAL(readyRead()), this, SLOT(donneesRecues()));
    connect(nouveauClient, SIGNAL(disconnected()), this, SLOT(deconnexionClient()));
}

On envoie à tous les clients déjà connectés un message comme quoi un nouveau client vient de se connecter. On verra le contenu de la méthode envoyerATous() un peu plus loin.

Chaque client est représenté par un QTcpSocket. Pour récupérer la socket correspondant au nouveau client qui vient de se connecter, on appelle la méthode nextPendingConnection() du QTcpServer. Cette méthode retourne la QTcpSocket du nouveau client.

Comme je vous l'ai dit, on conserve la liste des clients connectés dans un tableau, appelé clients.
Ce tableau est géré par la classe QList qui est très simple d'utilisation. On ajoute le nouveau client à la fin du tableau très facilement, comme ceci :

clients << nouveauClient;

(vive la surcharge de l'opérateur << :D )

On connecte ensuite les signaux que peut envoyer le client à des slots. On va gérer 2 signaux :

  • readyRead() : signale que le client a envoyé des données. Ce signal est émis pour chaque sous-paquet reçu. Lorsqu'un client enverra un message, ce signal pourra donc être émis plusieurs fois jusqu'à ce que tous les sous-paquets soient arrivés.
    C'est notre slot personnalisé donneesRecues() (qui sera coton à écrire :D ) qui traitera les sous-paquets.

  • disconnected() : signale que le client s'est déconnecté. Notre slot se chargera d'informer les autres clients de son départ et de supprimer la QTcpSocket correspondante dans la liste des clients connectés.

Slot donneesRecues()

Voilà sans aucun doute LE point le plus délicat de ce chapitre. C'est un slot qui va être appelé à chaque fois qu'on reçoit un sous-paquet d'un des clients.

On a au moins 2 problèmes pas évidents à résoudre :

  • Comme on va recevoir plusieurs sous-paquets, il va falloir "attendre" d'avoir tout reçu avant de pouvoir dire qu'on a reçu le message en entier.

  • C'est le même slot qui est appelé quel que soit le client qui a envoyé un message. Du coup, comment savoir quel est le client à l'origine du message pour récupérer les données ?

Il faut utiliser l'objet QTcpSocket du client pour récupérer les sous-paquets qui ont transité par le réseau. Le problème, c'est qu'on a connecté les signaux de tous les clients à un même slot :

Plusieurs signaux connectés à un slot
Plusieurs signaux connectés à un slot

Comment le slot sait-il dans quelle QTcpSocket lire les données ?

Vous ne pouviez pas trop le deviner, et à vrai dire je ne savais pas moi-même comment faire avant d'écrire ce chapitre :D .

Il se trouve que j'ai découvert qu'on pouvait appeler la méthode sender() de QObject dans le slot pour retrouver un pointeur vers l'objet à l'origine du message. Très pratique ! :)
Nouveau problème : cette méthode renvoie systématiquement un QObject (classe générique de Qt) car elle ne sait pas à l'avance de quel type sera l'objet. Notre objet QTcpSocket sera donc représenté par un QObject.
Pour le transformer à nouveau en QTcpSocket, il faudra forcer sa conversion à l'aide de la méthode qobject_cast().

En résumé, pour obtenir un pointeur vers la bonne QTcpSocket à l'origine du signal, il faudra écrire :

QTcpSocket *socket = qobject_cast<QTcpSocket *>(sender());

Ce qui, schématiquement, revient à faire ceci :

Sélection du signal et retransformation en QTcpSocket
Sélection du signal et retransformation en QTcpSocket
  1. On utilise sender() pour déterminer l'objet à l'origine du signal.

  2. Comme sender() renvoie systématiquement un QObject, il faut le transformer à nouveau en QTcpSocket. Pour cela, on passe l'objet en paramètre à la méthode qobject_cast(), on indiquant entre les chevrons le type de retour que l'on souhaite obtenir : <QTcpSocket *>.

Il se peut que le qobject_cast() n'ait pas fonctionné (par exemple parce que l'objet n'était pas de type QTcpSocket contrairement à ce qu'on attendait). Dans ce cas, il renvoie 0. Il faut que l'on teste si le qobject_cast() a fonctionné avant d'aller plus loin.
On va faire un return qui va arrêter la méthode s'il y a eu un problème :

QTcpSocket *socket = qobject_cast<QTcpSocket *>(sender());
if (socket == 0) // Si par hasard on n'a pas trouvé le client à l'origine du signal, on arrête la méthode
    return;

On peut ensuite travailler à récupérer les données. On commence par créer un flux de données pour lire ce que contient la socket :

QDataStream in(socket);

Notre objet "in" va nous permettre de lire le contenu du sous-paquet que vient de recevoir la socket du client.

C'est maintenant que l'on va utiliser l'entier tailleMessage défini en tant qu'attribut de la classe. Si lors de l'appel au slot ce tailleMessage vaut 0, cela signifie qu'on est en train de recevoir le début d'un nouveau message.
On demande à la socket combien d'octets ont été reçus dans le sous-paquet grâce à la méthode bytesAvailable(). Si on a reçu moins d'octets que la taille d'un quint16, on arrête la méthode de suite. On attendra le prochain appel de la méthode pour vérifier à nouveau si on a reçu assez d'octets pour récupérer la taille du message.

if (tailleMessage == 0) // Si on ne connaît pas encore la taille du message, on essaie de la récupérer
{
    if (socket->bytesAvailable() < (int)sizeof(quint16)) // On n'a pas reçu la taille du message en entier
         return;

    in >> tailleMessage; // Si on a reçu la taille du message en entier, on la récupère
}

La ligne 6 est exécutée uniquement si on a reçu assez d'octets. En effet, le return a arrêté la méthode avant si ce n'était pas le cas.
On récupère donc la taille du message et on la stocke. On la "retient" pour la suite des opérations.

Pour bien comprendre ce code, il faut se rappeler que le paquet est découpé en sous-paquets :

Séparation en sous-paquets
Séparation en sous-paquets

Notre slot est appelé à chaque fois qu'un sous-paquet a été reçu.

On vérifie si on a reçu assez d'octets pour récupérer la taille du message (première section en gris foncé). La taille de la première section "tailleMessage" peut être facilement retrouvée grâce à l'opérateur sizeof() que vous avez probablement déjà utilisé.
Si on n'a pas reçu assez d'octets, on arrête la méthode (return). On attendra que le slot soit à nouveau appelé et on vérifiera alors cette fois si on a reçu assez d'octets.

Maintenant la suite des opérations. On a reçu la taille du message. On va maintenant essayer de récupérer le message lui-même :

// Si on connaît la taille du message, on vérifie si on a reçu le message en entier
if (socket->bytesAvailable() < tailleMessage) // Si on n'a pas encore tout reçu, on arrête la méthode
    return;

Le principe est le même. On regarde le nombre d'octets reçus, et si on en a moins que la taille annoncée du message, on arrête (return).

Si tout va bien, on peut passer à la suite de la méthode. Si ces lignes s'exécutent, c'est qu'on a reçu le message en entier, donc qu'on peut le récupérer dans une QString :

// Si ces lignes s'exécutent, c'est qu'on a reçu tout le message : on peut le récupérer !
QString message;
in >> message;

Notre QString "message" contient maintenant le message envoyé par le client !

Ouf ! Le serveur a reçu le message du client !
Mais ce n'est pas fini : il faut maintenant renvoyer le message à tous les clients comme je vous l'avais expliqué. Pour reprendre notre exemple, Vincent vient d'envoyer un message au serveur, celui-ci l'a récupéré et s'apprête à le renvoyer à tout le monde.

L'envoi du message à tout le monde se fait via la méthode envoyerATous dont je vous ai déjà parlé et qu'il va falloir écrire.

// 2 : on renvoie le message à tous les clients
envoyerATous(message);

On a presque fini. Il manque juste une petite chose : remettre tailleMessage à 0 pour que l'on puisse recevoir de futurs messages d'autres clients :

// 3 : remise de la taille du message à 0 pour permettre la réception des futurs messages
tailleMessage = 0;

Si on n'avait pas fait ça, le serveur aurait cru lors du prochain sous-paquet reçu que le nouveau message est de la même longueur que le précédent, ce qui n'est certainement pas le cas. ;)

Bon, résumons le slot en entier :

void FenServeur::donneesRecues()
{
    // 1 : on reçoit un paquet (ou un sous-paquet) d'un des clients

    // On détermine quel client envoie le message (recherche du QTcpSocket du client)
    QTcpSocket *socket = qobject_cast<QTcpSocket *>(sender());
    if (socket == 0) // Si par hasard on n'a pas trouvé le client à l'origine du signal, on arrête la méthode
        return;

    // Si tout va bien, on continue : on récupère le message
    QDataStream in(socket);

    if (tailleMessage == 0) // Si on ne connaît pas encore la taille du message, on essaie de la récupérer
    {
        if (socket->bytesAvailable() < (int)sizeof(quint16)) // On n'a pas reçu la taille du message en entier
             return;

        in >> tailleMessage; // Si on a reçu la taille du message en entier, on la récupère
    }

    // Si on connaît la taille du message, on vérifie si on a reçu le message en entier
    if (socket->bytesAvailable() < tailleMessage) // Si on n'a pas encore tout reçu, on arrête la méthode
        return;


    // Si ces lignes s'exécutent, c'est qu'on a reçu tout le message : on peut le récupérer !
    QString message;
    in >> message;


    // 2 : on renvoie le message à tous les clients
    envoyerATous(message);

    // 3 : remise de la taille du message à 0 pour permettre la réception des futurs messages
    tailleMessage = 0;
}

J'espère avoir été clair, car ce slot n'est pas simple et pas très facile à lire je dois bien avouer. La clé, le truc à comprendre, c'est que chaque return arrête la méthode. Le slot sera à nouveau appelé au prochain sous-paquet reçu, donc ces instructions s'exécuteront probablement plusieurs fois pour un message.

Si la méthode arrive à s'exécuter jusqu'au bout, c'est qu'on a reçu le message en entier. :)

Slot deconnexionClient()

Ce slot est appelé lorsqu'un client se déconnecte.

On va envoyer un message à tous les clients encore connectés pour qu'ils sachent qu'un client vient de partir. Puis, on supprime la QTcpSocket correspondant au client dans notre tableau QList. Ainsi, le serveur "oublie" ce client, il ne considère plus qu'il fait partie des connectés.

Voici le slot en entier :

void FenServeur::deconnexionClient()
{
    envoyerATous(tr("<em>Un client vient de se déconnecter</em>"));

    // On détermine quel client se déconnecte
    QTcpSocket *socket = qobject_cast<QTcpSocket *>(sender());
    if (socket == 0) // Si par hasard on n'a pas trouvé le client à l'origine du signal, on arrête la méthode
        return;

    clients.removeOne(socket);

    socket->deleteLater();
}

Comme plusieurs signaux sont connectés à ce slot, on ne sait pas quel est le client à l'origine de la déconnexion. Pour le retrouver, on utilise la même technique que pour le slot donneesRecues(), je ne la réexplique donc pas.

La méthode removeOne() de QList permet de supprimer le pointeur vers l'objet dans le tableau. Notre liste des clients est maintenant à jour.

Il ne reste plus qu'à finir de supprimer l'objet lui-même (nous venons seulement de supprimer le pointeur de la QList là).
Pour supprimer l'objet, il faudrait faire undelete client;. Petit problème : si on supprime l'objet à l'origine du signal, on risque de faire bugger Qt. Heureusement tout a été prévu : on a juste à appeler deleteLater() (qui signifie "supprimer plus tard") et Qt se chargera de faire le delete lui-même un peu plus tard, lorsque notre slot aura fini de s'exécuter.

Méthode envoyerATous()

Ah, cette fois ce n'est pas un slot. ;)
C'est juste une méthode que j'ai décidé d'écrire dans la classe pour bien séparer le code, et aussi parce qu'on en a besoin plusieurs fois (vous avez remarqué que j'ai appelé cette méthode plusieurs fois dans les codes précédents non ?).

Dans le slot donneesRecues, nous recevions un message. Là, nous voulons au contraire en envoyer un, et ce à tous les clients connectés (tous les clients présents dans la QList).

void FenServeur::envoyerATous(const QString &message)
{
    // Préparation du paquet
    QByteArray paquet;
    QDataStream out(&paquet, QIODevice::WriteOnly);

    out << (quint16) 0; // On écrit 0 au début du paquet pour réserver la place pour écrire la taille
    out << message; // On ajoute le message à la suite
    out.device()->seek(0); // On se replace au début du paquet
    out << (quint16) (paquet.size() - sizeof(quint16)); // On écrase le 0 qu'on avait réservé par la longueur du message


    // Envoi du paquet préparé à tous les clients connectés au serveur
    for (int i = 0; i < clients.size(); i++)
    {
        clients[i]->write(paquet);
    }

}

Quelques explications bien sûr. :)
On crée un QByteArray "paquet" qui va contenir le paquet à envoyer sur le réseau. La classe QByteArray représente une suite d'octets quelconque.

On utilise un QDataStream comme tout à l'heure pour écrire dans le QByteArray facilement. Cela va nous permettre d'utiliser l'opérateur "<<".

Ce qui est particulier, c'est qu'on écrit d'abord le message (QString) et ensuite on calcule sa taille qu'on écrit au début du message.

Voilà ce qu'on fait sur le paquet dans l'ordre :

  1. On écrit le nombre 0 de type quint16 pour "réserver" de la place.

  2. On écrit à la suite le message, de type QString. Le message a été reçu en paramètre de la méthode envoyerATous().

  3. On se replace au début du paquet (comme si on remettait le curseur au début d'un texte dans un traitement de texte).

  4. Paquet
    Paquet

    On écrase le 0 qu'on avait écrit pour réserver de la place par la bonne taille du message. Cette taille est calculée via une simple soustraction : la taille du message est égale à la taille du paquet moins la taille réservée pour le quint16.

Notre paquet est prêt. Nous allons l'envoyer à tous les clients grâce à la méthode write() du socket.
Pour cela, on fait une boucle sur la QList, et on envoie le message à chaque client.

Et voilà, le message est parti ! :)

FenServeur.cpp en entier

Voici le contenu du fichier FenServeur.cpp que je viens de décortiquer en entier :

#include "FenServeur.h"

FenServeur::FenServeur()
{
    // Création et disposition des widgets de la fenêtre
    etatServeur = new QLabel;
    boutonQuitter = new QPushButton(tr("Quitter"));
    connect(boutonQuitter, SIGNAL(clicked()), qApp, SLOT(quit()));

    QVBoxLayout *layout = new QVBoxLayout;
    layout->addWidget(etatServeur);
    layout->addWidget(boutonQuitter);
    setLayout(layout);

    setWindowTitle(tr("ZeroChat - Serveur"));

    // Gestion du serveur
    serveur = new QTcpServer(this);
    if (!serveur->listen(QHostAddress::Any, 50885)) // Démarrage du serveur sur toutes les IP disponibles et sur le port 50585
    {
        // Si le serveur n'a pas été démarré correctement
        etatServeur->setText(tr("Le serveur n'a pas pu être démarré. Raison :<br />") + serveur->errorString());
    }
    else
    {
        // Si le serveur a été démarré correctement
        etatServeur->setText(tr("Le serveur a été démarré sur le port <strong>") + QString::number(serveur->serverPort()) + tr("</strong>.<br />Des clients peuvent maintenant se connecter."));
        connect(serveur, SIGNAL(newConnection()), this, SLOT(nouvelleConnexion()));
    }

    tailleMessage = 0;
}

void FenServeur::nouvelleConnexion()
{
    envoyerATous(tr("<em>Un nouveau client vient de se connecter</em>"));

    QTcpSocket *nouveauClient = serveur->nextPendingConnection();
    clients << nouveauClient;

    connect(nouveauClient, SIGNAL(readyRead()), this, SLOT(donneesRecues()));
    connect(nouveauClient, SIGNAL(disconnected()), this, SLOT(deconnexionClient()));
}

void FenServeur::donneesRecues()
{
    // 1 : on reçoit un paquet (ou un sous-paquet) d'un des clients

    // On détermine quel client envoie le message (recherche du QTcpSocket du client)
    QTcpSocket *socket = qobject_cast<QTcpSocket *>(sender());
    if (socket == 0) // Si par hasard on n'a pas trouvé le client à l'origine du signal, on arrête la méthode
        return;

    // Si tout va bien, on continue : on récupère le message
    QDataStream in(socket);

    if (tailleMessage == 0) // Si on ne connaît pas encore la taille du message, on essaie de la récupérer
    {
        if (socket->bytesAvailable() < (int)sizeof(quint16)) // On n'a pas reçu la taille du message en entier
             return;

        in >> tailleMessage; // Si on a reçu la taille du message en entier, on la récupère
    }

    // Si on connaît la taille du message, on vérifie si on a reçu le message en entier
    if (socket->bytesAvailable() < tailleMessage) // Si on n'a pas encore tout reçu, on arrête la méthode
        return;


    // Si ces lignes s'exécutent, c'est qu'on a reçu tout le message : on peut le récupérer !
    QString message;
    in >> message;


    // 2 : on renvoie le message à tous les clients
    envoyerATous(message);

    // 3 : remise de la taille du message à 0 pour permettre la réception des futurs messages
    tailleMessage = 0;
}

void FenServeur::deconnexionClient()
{
    envoyerATous(tr("<em>Un client vient de se déconnecter</em>"));

    // On détermine quel client se déconnecte
    QTcpSocket *socket = qobject_cast<QTcpSocket *>(sender());
    if (socket == 0) // Si par hasard on n'a pas trouvé le client à l'origine du signal, on arrête la méthode
        return;

    clients.removeOne(socket);

    socket->deleteLater();
}

void FenServeur::envoyerATous(const QString &message)
{
    // Préparation du paquet
    QByteArray paquet;
    QDataStream out(&paquet, QIODevice::WriteOnly);

    out << (quint16) 0; // On écrit 0 au début du paquet pour réserver la place pour écrire la taille
    out << message; // On ajoute le message à la suite
    out.device()->seek(0); // On se replace au début du paquet
    out << (quint16) (paquet.size() - sizeof(quint16)); // On écrase le 0 qu'on avait réservé par la longueur du message


    // Envoi du paquet préparé à tous les clients connectés au serveur
    for (int i = 0; i < clients.size(); i++)
    {
        clients[i]->write(paquet);
    }

}

Lancement du serveur

Bonne nouvelle, devinez quoi : notre projet "serveur" est terminé !
Nous avons fait le plus dur, l'implémentation du serveur dans FenServeur.cpp. Compilez, et lancez le serveur ainsi créé.

Vous risquez d'avoir une alerte de votre pare-feu (firewall). Par exemple sous Windows :

Firewall

En effet, notre programme va communiquer sur le réseau. Le pare-feu nous demande si nous voulons autoriser notre programme à le faire : répondez oui en cliquant sur "Débloquer".

Notre serveur est maintenant lancé :

Fenêtre du serveur

Bravo ! :D
Laissez ce programme tourner en fond sur votre ordinateur (vous pouvez réduire la fenêtre). Il va servir à faire la communication entre les différents clients.

Bon, mauvaise nouvelle chers auditeurs : nous avons beaucoup sué, mais nous avons fait seulement 50% du travail !
Il faut maintenant s'attaquer au projet "client" pour réaliser le programme qui sera utilisé par tous les clients pour chatter. Heureusement, nous avons déjà fait le plus dur en analysant le slot donneesRecues, on devrait donc aller un peu plus vite. :)

Réalisation du client

Si la fenêtre du serveur était toute simple, il en va autrement pour la fenêtre du client.
En effet, autant créer une fenêtre pour le serveur était facultatif, autant pour le client il faut bien qu'il ait une fenêtre pour écrire ses messages. :D

Voici la fenêtre de client que l'on veut coder :

Chat côté client
Chat côté client

Nous aurons 3 fichiers à nouveau :

  • main.cpp

  • FenClient.h

  • FenClient.cpp

Dessin de la fenêtre avec Qt Designer

Bon, la réalisation de cette fenêtre ne nous intéresse pas vraiment. C'est une fenêtre tout ce qu'il y a de plus classique.
Pour gagner du temps, je vous propose de créer l'interface de la fenêtre du client via Qt Designer. Ce programme a justement été conçu pour gagner du temps pour ceux qui savent déjà coder la fenêtre à la main, ce qui est notre cas.

J'ouvre donc Qt Designer et je dessine la fenêtre suivante :

Fenêtre du client sous Qt Designer
Fenêtre du client sous Qt Designer

Je prends soin à bien donner un nom correct à chacun des widgets via la propriété objectName (sauf pour les QLabel, car on n'aura pas à les réutiliser ceux-là donc on s'en moque un peu ^^ ).
Je veille aussi à utiliser des layouts partout sur ma fenêtre pour la rendre redimensionnable sans problème. Je sélectionne plusieurs widgets à la fois et je clique sur un des boutons de la barre d'outils en haut pour les assembler selon un layout (notez que je n'utilise jamais les layouts de la widget box à gauche).

Pour vous faire gagner du temps si vous ne voulez pas redessiner la fenêtre sous Qt Designer, voici le fichier FenClient.ui que j'ai généré :

Télécharger FenClient.ui
(faites clic droit / enregistrer sous)

Client.pro

J'ai mis à jour le fichier .pro pour indiquer qu'il y avait un UI dans le projet et qu'on utilisait le module network de Qt.
Vérifiez donc que votre .pro ressemble au mien :

TEMPLATE = app
QT += widgets network
TARGET = 
DEPENDPATH += .
INCLUDEPATH += .
 
# Input
HEADERS += FenClient.h
FORMS += FenClient.ui
SOURCES += FenClient.cpp main.cpp

main.cpp

Revenons au code. Le main est toujours très simple et sans originalité : il ouvre la fenêtre.

#include <QApplication>
#include "FenClient.h"

int main(int argc, char* argv[])
{
    QApplication app(argc, argv);

    FenClient fenetre;
    fenetre.show();

    return app.exec();
}

FenClient.h

Notre fenêtre utilise un fichier généré avec Qt Designer. Direction le chapitre sur Qt Designer si vous avez oublié comment se servir d'une fenêtre générée dans son code.

Je vais ici fonctionner un peu différemment en utilisant un héritage multiple, une notion que nous n'avons pas vue précédemment mais qui est assez simple à comprendre : la classe va hériter de 2 classes. L'intérêt de ce double héritage est d'éviter de devoir mettre le préfixeui->partout dans le code. Bien sûr, vous pouvez faire comme vous avez appris dans le chapitre Qt Designer si cela vous perturbe.

#ifndef HEADER_FENCLIENT
#define HEADER_FENCLIENT

#include <QtWidgets>
#include <QtNetwork>
#include "ui_FenClient.h"


class FenClient : public QWidget, private Ui::FenClient
{
    Q_OBJECT

    public:
        FenClient();

    private slots:
        void on_boutonConnexion_clicked();
        void on_boutonEnvoyer_clicked();
        void on_message_returnPressed();
        void donneesRecues();
        void connecte();
        void deconnecte();
        void erreurSocket(QAbstractSocket::SocketError erreur);

    private:
        QTcpSocket *socket; // Représente le serveur
        quint16 tailleMessage;
};

#endif

Notez qu'on inclut ui_FenClient.h pour réutiliser la fenêtre générée.

Notre fenêtre comporte pas mal de slots qu'il va falloir implémenter, heureusement ils seront assez simples. On utilise les autoconnect pour les 3 premiers d'entre eux pour gérer les évènements de la fenêtre :

  • on_boutonConnexion_clicked() : appelé lorsqu'on clique sur le bouton "Connexion" et qu'on souhaite donc se connecter au serveur.

  • on_boutonEnvoyer_clicked() : appelé lorsqu'on clique sur "Envoyer" pour envoyer un message dans le Chat.

  • on_message_returnPressed() : appelé lorsqu'on appuie sur la touche "Entrée" lorsqu'on rédige un message. Comme cela revient au même que on_boutonEnvoyer_clicked(), on appellera cette méthode directement pour éviter d'avoir à écrire 2 fois le même code.

  • donneesRecues() : appelé lorsqu'on reçoit un sous-paquet du serveur. Ce slot sera très similaire à celui du serveur qui possède le même nom.

  • connecte() : appelé lorsqu'on vient de réussir à se connecter au serveur.

  • deconnecte() : appelé lorsqu'on vient de se déconnecter du serveur.

  • erreurSocket(QAbstractSocket::SocketError erreur) : appelé lorsqu'il y a eu une erreur sur le réseau (connexion au serveur impossible par exemple).

En plus de ça, on a 2 attributs à manipuler :

  • QTcpSocket *socket : une socket qui représentera la connexion au serveur. On utilisera cette socket pour envoyer des paquets au serveur, par exemple lorsque l'utilisateur veut envoyer un message.

  • quint16 tailleMessage : permet à l'objet de se "souvenir" de la taille du message qu'il est en train de recevoir dans son slot donneesRecues(). Il a la même utilité que sur le serveur.

FenClient.cpp

Maintenant implémentons tout ce beau monde !
Courage, après ça c'est fini vous allez pouvoir savourer votre Chat ! :D

Le constructeur

Notre constructeur se doit d'appeler setupUi() dès le début pour mettre en place les widgets sur la fenêtre. C'est justement là qu'on gagne du temps grâce à Qt Designer : on n'a pas à coder le placement des widgets sur la fenêtre. :D

FenClient::FenClient()
{
    setupUi(this);

    socket = new QTcpSocket(this);
    connect(socket, SIGNAL(readyRead()), this, SLOT(donneesRecues()));
    connect(socket, SIGNAL(connected()), this, SLOT(connecte()));
    connect(socket, SIGNAL(disconnected()), this, SLOT(deconnecte()));
    connect(socket, SIGNAL(error(QAbstractSocket::SocketError)), this, SLOT(erreurSocket(QAbstractSocket::SocketError)));

    tailleMessage = 0;
}

En plus de setupUi(), on fait quelques initialisations supplémentaires indispensables :

  • On crée l'objet de type QTcpSocket qui va représenter la connexion au serveur.

  • On connecte les signaux qu'il est susceptible d'envoyer à nos slots personnalisés.

  • On met tailleMessage à 0 pour permettre la réception de nouveaux messages.

Notez qu'on ne se connecte pas au serveur dans le constructeur. On prépare juste la socket, mais on ne fera la connexion que lorsque le client aura cliqué sur le bouton "Connexion" (il faut bien lui laisser le temps de rentrer l'adresse IP du serveur !).

Slot on_boutonConnexion_clicked()

Ce slot se fait appeler dès que l'on a cliqué sur le bouton "Connexion" en haut de la fenêtre.

// Tentative de connexion au serveur
void FenClient::on_boutonConnexion_clicked()
{
    // On annonce sur la fenêtre qu'on est en train de se connecter
    listeMessages->append(tr("<em>Tentative de connexion en cours...</em>"));
    boutonConnexion->setEnabled(false);

    socket->abort(); // On désactive les connexions précédentes s'il y en a
    socket->connectToHost(serveurIP->text(), serveurPort->value()); // On se connecte au serveur demandé
}
  1. Dans un premier temps, on affiche sur la zone de messages "listeMessages" au centre de la fenêtre que l'on est en train d'essayer de se connecter.

  2. On désactive temporairement le bouton "Connexion" pour empêcher au client de retenter une connexion alors qu'une tentative de connexion est déjà en cours.

  3. Si la socket est déjà connectée à un serveur, on coupe la connexion avec abort(). Si on n'était pas connecté, cela n'aura aucun effet, mais c'est par sécurité pour que l'on ne soit pas connecté à 2 serveurs à la fois.

  4. On se connecte enfin au serveur avec la méthode connectToHost(). On utilise l'IP et le port demandés par l'utilisateur dans les champs de texte en haut de la fenêtre.

Slot on_boutonEnvoyer_clicked()

Ce slot est appelé lorsqu'on essaie d'envoyer un message (le bouton "Envoyer" en bas à droite a été cliqué).

// Envoi d'un message au serveur
void FenClient::on_boutonEnvoyer_clicked()
{
    QByteArray paquet;
    QDataStream out(&paquet, QIODevice::WriteOnly);

    // On prépare le paquet à envoyer
    QString messageAEnvoyer = tr("<strong>") + pseudo->text() +tr("</strong> : ") + message->text();

    out << (quint16) 0;
    out << messageAEnvoyer;
    out.device()->seek(0);
    out << (quint16) (paquet.size() - sizeof(quint16));

    socket->write(paquet); // On envoie le paquet

    message->clear(); // On vide la zone d'écriture du message
    message->setFocus(); // Et on remet le curseur à l'intérieur
}

Ce code est similaire à celui de la méthode envoyerATous() du serveur. Il s'agit d'un envoi de données sur le réseau.

  1. On prépare un QByteArray dans lequel on va écrire le paquet qu'on veut envoyer.

  2. On construit ensuite la QString contenant le message à envoyer. Vous noterez qu'on met le nom de l'auteur et son texte directement dans la même QString. Idéalement, il vaudrait mieux séparer les deux pour avoir un code plus logique et plus modulable, mais cela aurait compliqué le code de ce chapitre bien délicat, donc ça sera dans les améliorations à faire à la fin. ;)

  3. On calcule la taille du message.

  4. On envoie le paquet ainsi créé au serveur en utilisant la socket qui le représente et sa méthode write().

  5. On efface automatiquement la zone d'écriture des messages en bas pour qu'on puisse en écrire un nouveau et on donne le focus à cette zone immédiatement pour que le curseur soit placé dans le bon widget.

Slot on_message_returnPressed()

Ce slot est appelé lorsqu'on a appuyé sur la touche "Entrée" après avoir rédigé un message.
Cela a le même effet qu'un clic sur le bouton "Envoyer", nous appelons donc le slot que nous venons d'écrire :

// Appuyer sur la touche Entrée a le même effet que cliquer sur le bouton "Envoyer"
void FenClient::on_message_returnPressed()
{
    on_boutonEnvoyer_clicked();
}
Slot donneesRecues()

Voilà à nouveau le slot de vos pires cauchemars. :diable:
Il est quasiment identique à celui du serveur (la réception de données fonctionne de la même manière) je ne le réexplique donc pas :

// On a reçu un paquet (ou un sous-paquet)
void FenClient::donneesRecues()
{
    /* Même principe que lorsque le serveur reçoit un paquet :
    On essaie de récupérer la taille du message
    Une fois qu'on l'a, on attend d'avoir reçu le message entier (en se basant sur la taille annoncée tailleMessage)
    */
    QDataStream in(socket);

    if (tailleMessage == 0)
    {
        if (socket->bytesAvailable() < (int)sizeof(quint16))
             return;

        in >> tailleMessage;
    }

    if (socket->bytesAvailable() < tailleMessage)
        return;


    // Si on arrive jusqu'à cette ligne, on peut récupérer le message entier
    QString messageRecu;
    in >> messageRecu;

    // On affiche le message sur la zone de Chat
    listeMessages->append(messageRecu);

    // On remet la taille du message à 0 pour pouvoir recevoir de futurs messages
    tailleMessage = 0;
}

La seule différence ici en fait, c'est qu'on affiche le message reçu dans la zone de Chat à la fin :listeMessages->append(messageRecu);

Slot connecte()

Ce slot est appelé lorsqu'on a réussi à se connecter au serveur.

// Ce slot est appelé lorsque la connexion au serveur a réussi
void FenClient::connecte()
{
    listeMessages->append(tr("<em>Connexion réussie !</em>"));
    boutonConnexion->setEnabled(true);
}

Tout bêtement, on se contente d'afficher "Connexion réussie" dans la zone de Chat pour que le client sache qu'il est bien connecté au serveur.
On réactive aussi le bouton "Connexion" qu'on avait désactivé, pour permettre une nouvelle connexion à un autre serveur.

Slot deconnecte()

Ce slot est appelé lorsqu'on est déconnecté du serveur.

// Ce slot est appelé lorsqu'on est déconnecté du serveur
void FenClient::deconnecte()
{
    listeMessages->append(tr("<em>Déconnecté du serveur</em>"));
}

On affiche juste un message sur la zone de texte pour que le client soit au courant.

Slot erreurSocket()

Ce slot est appelé lorsque la socket a rencontré une erreur.

// Ce slot est appelé lorsqu'il y a une erreur
void FenClient::erreurSocket(QAbstractSocket::SocketError erreur)
{
    switch(erreur) // On affiche un message différent selon l'erreur qu'on nous indique
    {
        case QAbstractSocket::HostNotFoundError:
            listeMessages->append(tr("<em>ERREUR : le serveur n'a pas pu être trouvé. Vérifiez l'IP et le port.</em>"));
            break;
        case QAbstractSocket::ConnectionRefusedError:
            listeMessages->append(tr("<em>ERREUR : le serveur a refusé la connexion. Vérifiez si le programme \"serveur\" a bien été lancé. Vérifiez aussi l'IP et le port.</em>"));
            break;
        case QAbstractSocket::RemoteHostClosedError:
            listeMessages->append(tr("<em>ERREUR : le serveur a coupé la connexion.</em>"));
            break;
        default:
            listeMessages->append(tr("<em>ERREUR : ") + socket->errorString() + tr("</em>"));
    }

    boutonConnexion->setEnabled(true);
}

La raison de l'erreur est passée en paramètre. Elle est de type QAbstractSocket::SocketError (c'est une énumération).

On fait un switch pour afficher un message différent en fonction de l'erreur. Je n'ai pas traité toutes les erreurs possibles, lisez la doc pour connaître les autres raisons d'erreurs que l'on peut gérer.

La plupart des erreurs que je gère ici sont liées à la connexion au serveur. J'affiche un message intelligible en français pour que l'on comprenne la raison de l'erreur.
Le cas "default" est appelé pour les erreurs que je n'ai pas gérées. J'affiche le message d'erreur envoyé par la socket (qui sera peut-être en anglais mais bon c'est mieux que rien).

FenClient.cpp en entier

C'est fini ! :D
Bon, ça n'a pas été trop long ni trop difficile après avoir fait le serveur, avouez. ;)

Voici le code complet de FenClient.cpp :

#include "FenClient.h"

FenClient::FenClient()
{
    setupUi(this);

    socket = new QTcpSocket(this);
    connect(socket, SIGNAL(readyRead()), this, SLOT(donneesRecues()));
    connect(socket, SIGNAL(connected()), this, SLOT(connecte()));
    connect(socket, SIGNAL(disconnected()), this, SLOT(deconnecte()));
    connect(socket, SIGNAL(error(QAbstractSocket::SocketError)), this, SLOT(erreurSocket(QAbstractSocket::SocketError)));

    tailleMessage = 0;
}

// Tentative de connexion au serveur
void FenClient::on_boutonConnexion_clicked()
{
    // On annonce sur la fenêtre qu'on est en train de se connecter
    listeMessages->append(tr("<em>Tentative de connexion en cours...</em>"));
    boutonConnexion->setEnabled(false);

    socket->abort(); // On désactive les connexions précédentes s'il y en a
    socket->connectToHost(serveurIP->text(), serveurPort->value()); // On se connecte au serveur demandé
}

// Envoi d'un message au serveur
void FenClient::on_boutonEnvoyer_clicked()
{
    QByteArray paquet;
    QDataStream out(&paquet, QIODevice::WriteOnly);

    // On prépare le paquet à envoyer
    QString messageAEnvoyer = tr("<strong>") + pseudo->text() +tr("</strong> : ") + message->text();

    out << (quint16) 0;
    out << messageAEnvoyer;
    out.device()->seek(0);
    out << (quint16) (paquet.size() - sizeof(quint16));

    socket->write(paquet); // On envoie le paquet

    message->clear(); // On vide la zone d'écriture du message
    message->setFocus(); // Et on remet le curseur à l'intérieur
}

// Appuyer sur la touche Entrée a le même effet que cliquer sur le bouton "Envoyer"
void FenClient::on_message_returnPressed()
{
    on_boutonEnvoyer_clicked();
}

// On a reçu un paquet (ou un sous-paquet)
void FenClient::donneesRecues()
{
    /* Même principe que lorsque le serveur reçoit un paquet :
    On essaie de récupérer la taille du message
    Une fois qu'on l'a, on attend d'avoir reçu le message entier (en se basant sur la taille annoncée tailleMessage)
    */
    QDataStream in(socket);

    if (tailleMessage == 0)
    {
        if (socket->bytesAvailable() < (int)sizeof(quint16))
             return;

        in >> tailleMessage;
    }

    if (socket->bytesAvailable() < tailleMessage)
        return;


    // Si on arrive jusqu'à cette ligne, on peut récupérer le message entier
    QString messageRecu;
    in >> messageRecu;

    // On affiche le message sur la zone de Chat
    listeMessages->append(messageRecu);

    // On remet la taille du message à 0 pour pouvoir recevoir de futurs messages
    tailleMessage = 0;
}

// Ce slot est appelé lorsque la connexion au serveur a réussi
void FenClient::connecte()
{
    listeMessages->append(tr("<em>Connexion réussie !</em>"));
    boutonConnexion->setEnabled(true);
}

// Ce slot est appelé lorsqu'on est déconnecté du serveur
void FenClient::deconnecte()
{
    listeMessages->append(tr("<em>Déconnecté du serveur</em>"));
}

// Ce slot est appelé lorsqu'il y a une erreur
void FenClient::erreurSocket(QAbstractSocket::SocketError erreur)
{
    switch(erreur) // On affiche un message différent selon l'erreur qu'on nous indique
    {
        case QAbstractSocket::HostNotFoundError:
            listeMessages->append(tr("<em>ERREUR : le serveur n'a pas pu être trouvé. Vérifiez l'IP et le port.</em>"));
            break;
        case QAbstractSocket::ConnectionRefusedError:
            listeMessages->append(tr("<em>ERREUR : le serveur a refusé la connexion. Vérifiez si le programme \"serveur\" a bien été lancé. Vérifiez aussi l'IP et le port.</em>"));
            break;
        case QAbstractSocket::RemoteHostClosedError:
            listeMessages->append(tr("<em>ERREUR : le serveur a coupé la connexion.</em>"));
            break;
        default:
            listeMessages->append(tr("<em>ERREUR : ") + socket->errorString() + tr("</em>"));
    }

    boutonConnexion->setEnabled(true);
}

Test du Chat et améliorations

Nos projets sont terminés !
Nous avons fait le client et le serveur !

Je vous propose de tester le bon fonctionnement du Chat dans un premier temps, et éventuellement de télécharger les projets tous prêts.
Nous verrons ensuite comment vous pouvez améliorer tout cela. :)

Testez le Chat

Avant toute chose, vous voudrez peut-être récupérer le projet tout prêt et zippé pour partir sur la même base que moi.

Télécharger Chat.zip (60 Ko)

Le zip contient un sous-dossier par projet : serveur et client.

Vous pouvez exécuter directement les programmes serveur.exe et client.exe si vous êtes sous Windows (en n'oubliant pas de mettre les DLL de Qt dans le même répertoire). Si vous utilisez un autre OS, vous devrez recompiler le projet (faites un qmake et un make).

Vous pouvez dans un premier temps tester le Chat en interne sur votre propre ordinateur. Je vous propose de lancer :

  • Un serveur

  • Deux clients

Cela va nous permettre de simuler une conversation en interne sur notre ordinateur. Cela utilisera le réseau, mais à l'intérieur de votre propre machine. ;)

J'avoue que c'est un peu curieux, mais si ça fonctionne en interne, ça fonctionnera en réseau local et sur internet sans problème (pour peu que le port soit ouvert). C'est donc une bonne idée de faire ses tests en interne dans un premier temps.

Voici ce que ça donne quand je me parle à moi-même :

Chat en interne
Chat en interne

Comme vous pouvez le voir, tout fonctionne (sauf peut-être mon cerveau :-° ).

Amusez-vous ensuite à tester le programme en réseau local ou sur internet avec des amis. Pensez à chaque fois à vérifier si le port est ouvert. Si vous avez un problème avec le programme, il y a 99% de chances que ça vienne du port.

Voici une petite conversation en réseau local :

Chat côté client
Chat côté client

Je ne l'ai pas testé sur internet mais je sais pertinemment que ça fonctionne. Le principe du réseau est le même partout, que ce soit en interne, en local ou via internet. C'est juste l'IP qui change à chaque fois.

Améliorations à réaliser

Bon, le moins qu'on puisse dire c'est que j'ai bien travaillé dans ce chapitre, maintenant à votre tour de bosser un peu. :p
Voici quelques suggestions d'améliorations que vous pouvez réaliser sur le Chat, plus ou moins difficiles selon le cas :

  • Vous pouvez griser la zone d'envoi des messages ainsi que le bouton "Envoyer" lorsque le client n'est pas connecté.

  • Sur la fenêtre du serveur, il devrait être assez facile d'afficher le nombre de clients qui sont connectés.

  • Plus délicat car ça demande un peu de réorganisation du code : au lieu d'avoir une QList de QTcpSocket, faites une QList d'objets de type Client.
    Il faudra créer une nouvelle classe Client qui va représenter un client. Elle aura des attributs comme : sa QTcpSocket, le pseudo (QString), pourquoi pas l'avatar (QPixmap), etc. A partir de là vous aurez alors beaucoup plus de souplesse dans votre Chat !

  • Vous pourriez alors facilement afficher le pseudo du membre qui vient se connecter. Pour le moment, on a juste "Un client vient de se connecter".

  • Plutôt graphique mais sympa : vous pourriez gérer la mise en forme des messages (gras, rouge...) ainsi que des smilies. Bon après il s'agit pas de recréer MSN (quoique, le principe est tout à fait le même ;) ) donc n'allez pas trop loin dans ce genre de fonctionnalités quand même.

  • Plus délicat, mais très intéressant : essayez d'afficher la liste des clients connectés sur la fenêtre des clients. Vous devriez rajouter un widget QListView pour afficher cette liste, ça vous ferait travailler MVC en plus. :)
    Le plus délicat est de gérer la liste des connectés, car pour le moment le pseudo est directement intégré aux messages qui sont envoyés. Il faudrait essayer de gérer le contenu des paquets un peu différemment, à vous de voir.

  • Actuellement, le serveur est un peu minimal et ne gère pas tous les cas. Par exemple, si 2 clients envoient un message en même temps, il n'y a qu'une seule variable tailleMessage pour 2 messages en cours de réception. Je vous recommande de gérer plutôt 1 tailleMessage par client (vous n'avez qu'à mettre tailleMessage dans la classe Client).

Je m'arrête là pour les suggestions, il y a déjà du travail !

On pourrait aussi imaginer de permettre un Chat en privé entre certains clients, ou encore d'autoriser l'envoi de fichier sur le réseau (le tout étant de récupérer le fichier à envoyer sous forme de QByteArray).

Enfin, n'oubliez pas que le réseau ne se limite pas au Chat. Si vous faites un jeu en réseau par exemple, il faudra non pas envoyer des messages texte, mais plutôt les actions des autres joueurs. Dans ce cas, le schéma des paquets envoyés deviendra un peu plus complexe, mais c'est nécessaire.

A vous d'adapter un peu mon code, vous êtes grands maintenant, au boulot ! ;)

Bon, je crois que c'était mon plus gros chapitre. Il était temps que je m'arrête. :D

J'espère que vous avez apprécié cette partie sur Qt, nous avons vu beaucoup de choses (un peu trop même) et pourtant nous n'avons pas tout vu. :o

Je vous laisse vous entraîner avec le réseau, les widgets, et pour tout le reste n'oubliez pas : il y a la doc ! Et les forums du Site du Zéro aussi, oui oui, c'est vrai.

Exemple de certificat de réussite
Exemple de certificat de réussite