Labo 1 - Architecture Logicielle de la donnée
Structure générale
Respectueusement des conventions de Go, la structure du projet se
divise en un package cmd ne faisant qu’utiliser les
packages définis dans internal.
/cmd/contient les main packages, actuellement uniquementserver/internal/contient les packages utilisés par l’exécutableserver./internal/transport/abstrait la couche réseau en un API simple/internal/server/est responsable de la partie applicative, indépendante du réseau – écoute d’entrées sur stdin et affichage des messages reçus./internal/logging/offre une structure simplifiant la création de logs./internal/utils/contient des structures d’aide variées.
Architecture en couches
Dans un soucis de séparation des préoccupations, ainsi que pour faciliter l’extension dans le futur, nous choisissons une architecture en couches, dont il en existe pour l’instant deux : Transport et Server.
Transport
La couche transport occulte la complexité du réseau
derrière une abstraction qui prend la forme d’une interface
NetworkInterface, définie dans
networkInterface.go. Cela permettra un changement de
protocole de communication sans effets pour les couches utilisatrices du
réseau.
Abstraction
L’interface NetworkInterface définit tout objet capable
d’envoyer et recevoir des octets de la part de processus identifiés par
une adresse IP. L’utilisation d’octets permet à cette couche d’être
indépendante de son contexte d’utilisation.
La NetworkInterface utilise un modèle de souscription
pour la réception de messages. Elle définit pour cela une interface
MessageHandler décrivant tout objet offrant une méthode
HandleNetworkMessage(*Message) (wasHanlded bool) qui
retourne un booléen ssi le message reçu a été traité. La
NetworkInterface est alors responsable de partager chaque
message reçu à tous les souscrits à travers cette méthode, jusqu’à ce
que l’un d’eux affirme l’avoir traité. Elle peut donc supposer que tout
message n’appartient qu’à un seul souscrit.
Les méthodes d’une NetworkInterface sont les suivantes
:
Send(addr Address, payload []byte) error.RegisterHandler(MessageHandler) HandlerIdpour souscrire unMessageHandler.UnregisterHandler(HandlerId)pour résilier une souscription à l’aide de l’identifiant obtenu à la souscription.Close()pour fermer toute connexion et goroutine en cours.
Nous n’offrons pour l’instant qu’une seul implémentation de cette
interface, UDP, que nous décrivons dans la suite de cette
section.
État interne
L’état interne d’une instance de UDP, c’est à dire toute
donnée dont dépend le comportement de l’instance et qui peut changer au
cours de l’exécution, se réduit aux valeurs suivantes.
- La connexion UDP d’écoute de messages reçus.
- La liste des souscrits à la réception des messages.
- La liste des voisins connus et leur connexion associée.
Il est important de déterminer ces états puisque, par leur variabilité à travers le temps, il est nécessaire d’en prévenir tout accès concurrent, ce qui est garanti par le choix des goroutines.
Goroutines principales
Nous analysons ici les contraintes techniques auxquelles nous faisons face, et les goroutines permettant de les satisfaire.
Les événements auxquels cette couche doit répondre sont les suivants, associés aux états auxquels elles doivent avoir accès
- Réception de message - accès à la liste des souscrits.
- Demande de souscription ou résiliation aux réceptions - modification de la liste des souscrits
- Demande d’envoi de message - accès à la liste des voisins connus, et modification potentielle si le voisin demandé n’est pas encore connu.
- Demande de clôture de l’interface réseau - accès à la liste des voisins connus et leur connexion associée, ainsi que la connexion d’écoute de messages reçus.
Étant donné qu’aucune paire de ces événements n’a besoin de pouvoir
être exécutée en parallèle, nous optons pour la solution simple de
regrouper leur gestion en une seule goroutine, handleState.
Ainsi, tous les événements seront traités séquentiellement, évitant donc
tout risque d’accès concurrent aux variables d’état. Afin d’éviter toute
erreur lors de l’implémentation, ces variables d’état sont locales à la
goroutine, et non des attributs de la struct UDP.
La gestion de la clôture de l’interface réseau se fait à l’aide d’une
unique channel, closeChan, qui sera clôturée au moment
d’une demande de clôture. Elle pourra ainsi être surveillée par toutes
les goroutines pour détecter leur nécessité de s’interrompre.
Une seconde goroutine, listenIncomingMessages, est
responsable d’écouter les messages reçus sur UDP, et les transmettre à
handleState pour envoi aux souscrits. Celle-ci génère une
petite goroutine écoutant simplement closeChan et clôturant
la connexion UDP d’écoute, permettant de notifier la goroutine
principale en faisant échouer l’écoute.
Enfin, afin d’éviter de recréer une connexion au même voisin à chaque
envoi, la goroutine handleState crée une goroutine pour
chaque voisin auquel une demande d’envoi a été faite. Cette dernière est
responsable de la connexion avec ce voisin, et une channel créée et
maintenue par handleState lui est fournie. Cette channel
sera utilisée par handleState pour informer la goroutine du
message à envoyer, puis clôturée pour indiquer la fin de programme.

Pour le détail des goroutines et leurs moyens de communication…
Il existe donc trois goroutines principales communiquant par channels.
handleSendsest responsable d’envoyer des messages à une connexion donnée. Une nouvelle est donc créée pour chaque nouvelle connexion. Elles réagissent aux événements suivants :- Demandes d’envoi sur la connexion correspondante (reçues sur
sendChan chan []byte). - Clôture de la channel
sendChancomme un signal de fin d’exécution de la goroutine.
- Demandes d’envoi sur la connexion correspondante (reçues sur
handleStateest la goroutine principale et maintient la liste des souscrits et des voisins connus. Elle réagit aux événements suivants :- Demande d’envoi de bytes à un voisin donné (reçues sur
sendRequests chan struct{Address, []byte}). Crée alors une instance dehandleSendsassociée à ce voisin, et lui transmet la demande. - Demande de souscription d’un handler (reçues sur
registrations chan struct{HandlerId, MessageHandler}, oùHandlerIdest un alias d’uint32etMessageHandlerest tel que défini plus tôt). - Demande de résiliation d’un handler (reçues sur
unregistrations chan HandlerId) - Notification de réception de message (reçues sur
receivedMessages chan Message). Transmet alors le message reçu aux handlers souscrits. - Demande de fin d’exécution de la goroutine (reçue par la clôture
d’une channel
closeChan). Transmet alors la clôture à toutes les goroutineshandleSendsen clôturant leur channelsendChan.
- Demande d’envoi de bytes à un voisin donné (reçues sur
listenIncomingMessagesest responsable de la réception de messages. Elle réagit aux événements suivants :- Réception de messages par la connexion UDP, qu’elle transmet ensuite
à
handleStatepar la channelreceivedMessages - Clôture de la channel
closeChanpour clôturer la connexion UDP et donc la réception de messages.
- Réception de messages par la connexion UDP, qu’elle transmet ensuite
à
Serveur
Le serveur est responsable uniquement de l’écoute d’entrée sur stdin, et l’affichage des messages reçus. Aucun état modifiable majeur n’existe dans cette couche.
Le serveur est une struct offrant deux méthodes.
Startdéclenche l’écoute de stdin et du réseau. Son constructeur,NewServer, prend comme argument une instance deServerConfigdécrivant sa configuration (voir ci-après).Closedéclenche la fermeture du serveur et de l’interface réseau qu’il utilise. Cela se fait à nouveau à l’aide d’une channel,closeChan, dont la fermeture est détectée par toutes les autres goroutines.
Goroutines principales
Il existe une seule goroutine centrale au serveur. Celle-ci est
responsable d’écouter et envoyer sur le réseau les entrées de
l’utilisateur.ice. Elle crée également une goroutine secondaire
responsable uniquement de traduire l’appel bloquant à la lecture IO en
une transmission sur channel, afin de permettre l’utilisation du
select de Go.
La gestion des réceptions se fait par une souscription au
NetworkInterface lors de la construction de la structure.
Aucune goroutine n’est donc nécessaire ici.
Structures additionnelles
Quelques structures supplémentaires permettent de séparer les préoccupations.
ServerConfigpeut être créé à l’aide deNewServerConfig, qui est responsable de lire le fichier de configuration et peupler une instance deServerConfig.messages.gooffre une interfaceMessagedéfinissant les types de messages qui peuvent être encodés avecgob.ChatMessageest une implémentation de cette interface définissant un message de chat.
Modules complémentaires
Quelques autres modules d’aide sont fournis.
Loggerest une structure permettant de logger des messages de différents niveaux d’importance (INFO,WARN,ERR), qui seront affichés dans la console ainsi que, optionellement, dans un fichier.IOStreamest une interface qui abstrait l’écriture et la lecture sur un stream IO. Elle offre les méthodesReadLine(),Println()etPrint(). Elle est implémentée par les structsstdStreampour l’abstraction de stdin/stdout, etmockStreampour une simulation de stream utilisable par les tests.- Le package
utilspropose quelques outils pratiquesOption[T any]implémente l’abstraction Optional.BufferedChanimplémente une abstraction de channel à taille variable.UIDGeneratorimplémente un générateur d’identifiants uniques.