Bonjour,
Je travaille sur un petit projet de jeu sur mon temps libre, et je me casse un peu les dents sur l'architecture du code au niveau du passage d'information entre les clients et le serveur. Je me repose sur la SFML pour la gestion bas niveau du réseau. Si vous ne connaissez pas, l'essentiel est que je dispose d'une classe sf::Packet qui peut sérialiser/dé-sérialiser n'importe quel type de POD pour les acheminer sur le réseau. Un exemple simplifié :
Code : Sélectionner tout - Visualiser dans une fenêtre à part
1
2
3
4
5
6 // Alice int i = 5; double d = 3.14; sf::Packet p; p << i << d; socket.send(p);Le principal problème à ce stade est de s'assurer que Alice remplisse correctement le paquet avant de l'envoyer, et que Bob essaye seulement d'en extraire ce qu'il contient. J'ai donc écrit un framework qui automatise le tout.
Code : Sélectionner tout - Visualiser dans une fenêtre à part
1
2
3
4
5
6 // Bob int i; double d; sf::Packet p; socket.receive(p); p >> i >> d;
Dans ce cadre, Alice peut :
- envoyer un message informatif à Bob, sans rien attendre en retour (send_message())
- envoyer une requête à Bob, et enregistrer un callback à exécuter quand la réponse de Bob arrivera (send_request())
Et Bob peut :
- enregistrer un callback a exécuter quand il reçoit un message particulier (watch_message())
- enregister un callback a exécuter quand il reçoit une requête (le callback a alors la responsabilité d'émettre une réponse) (watch_request())
Bien sûr, Alice peut aussi faire ce que Bob fait, et réciproquement...
Pour l'automatisation, je dois créer une classe pour chaque type de message/requête. Elle porte en elle :
- l'identifiant unique correspondant à ce type de paquet (il sert à trouver quel callback appeler une fois le paquet reçu)
- la liste des types des objets qui sont envoyés dans le paquet
Un exemple :
Du coup, quand Alice appelle send_message<client_connected>(12, "toto"), le framework vérifie à la compilation que les arguments correspondent à ceux de la liste client_connected::types, et les envoie. De son côté, Bob peut décider d'effectuer une action à la réception de ce message. Il va alors appeler watch_message<client_connected>([](std::size_t id, std::string name) { ... }); et, là aussi, le framework vérifie à la compilation que les arguments de la fonction sont compatibles avec le contenu attendu du paquet. Le reste est fait sous le capot (réception du paquet, dispatch vers le bon callback, dé-sérialisation des éléments, etc.).
Code : Sélectionner tout - Visualiser dans une fenêtre à part
1
2
3
4
5
6
7
8
9
10
11
12
13
14 template<typename ... Args> struct type_list {}; struct client_connected { // L'ID doit être unique dans tout le programme et constant // d'une unité de compilation à une autre (et bien sûr d'une // exécution à une autre) static const std::uint16_t id = 486; using types = type_list< std::size_t, // id du client std::string // nom du client >; };
Je suis assez content du résultat, mais il reste une ombre au tableau : attribuer un ID unique à chaque type de message/requête. Je peux bien sûr faire ça à la main (commencer à 0, puis donner 1 au second message que je vais créer, etc.), mais ça pose des problèmes de maintenance évidents : si je me trompe et donne le même ID à deux types de paquets différents je n'ai aucun mécanisme pour le détecter. Si je décide de supprimer un paquet, alors son ID sera inutilisé (il faudra que je pense à le redonner à un futur nouveau paquet). Bref je voudrai éviter ça.
Une des solutions temporaire que j'ai trouvé consiste à déclarer tous les messages dans un même header, et d'utiliser la macro __LINE__ pour générer un ID unique. Ça pose aussi son lot de problème : les valeurs retournées par __LINE__ sont certes uniques, mais elles ne sont pas très optimales (il y a plein de trous : on passe de 26 à 42 par exemple), et surtout ça me force à tout déclarer au même endroit, ce qui est très moche en terme de localité du code (au niveau conceptuel et temps de compilation). Par exemple avec client_connected je préfère que le type de l'ID du client soit un typedef, using client_id_t = std::size_t, qui soit utilisé dans le reste du code. Mais du coup je dois le déclarer dans ce header, et tout le monde va en profiter sans forcément en avoir besoin...
Une autre option serait de déclarer dans un header commun une grosse énumération avec tous les messages possibles :
Puis d'écrire :
Code : Sélectionner tout - Visualiser dans une fenêtre à part
1
2
3
4
5 enum message_id_t : std::uint16_t { client_connected_id, client_disconnected_id, ... };
Mais ça veut dire que je dois "déclarer" le paquet à deux endroits différents. Le code se répète un peu, et je peux me planter dans l'affectation de l'ID (si je copie/colle le code d'un autre paquet et oublie de changer l'ID par exemple). Ça ne me plaît pas beaucoup non plus.
Code : Sélectionner tout - Visualiser dans une fenêtre à part
1
2
3
4 struct client_connected { static const std::uint16_t id = client_connected_id; using types = type_list<std::size_t, std::string>; };
Ma seule solution est-elle de recourir à un outil externe qui va générer ces ID à ma place à la compilation, en lisant mon code source ? Si oui, connaissez-vous de bons outils portables (et si possible légers) qui permettent de faire ça proprement ?
Partager