Cartouche

Champ Valeur
Auteur·e Élise
Édition 2024-09-06
Durée 2 séances (un jour)
Taille des équipes 1 personne
Rendu via git, dépôt $YEAR_rust_c5, droits en lecture à delivery_collector

Règlement

Prenez connaissance du règlement ici

L’intégralité de la bibliothèque standard est autorisée. Vous êtes encouragés à vous servir des outils qu’elle vous propose s’ils résolvent un de vos problèmes. Votre objectif c’est d’éviter de ré-inventer la roue quand ça n’est pas nécessaire.

Règles d’évaluation spécifiques

Vous serez évalués sur le dernier exercice rendu, sauf pour l’étape 5. Pensez donc à faire des commit à chaque fois que vous terminez un exercice, de sorte à pouvoir revenir dessus avant l’heure du rendu, au cas où vos dernières réalisations ne fonctionnent pas.

Les branches peuvent aussi vous aider à vous y retrouver.

Une étape pour être validée, doit être accompagnée d’un main de démonstration fonctionnel.

Barème

Critère Points Accessibles
Étape 1 installation 3
Étape 2 initialisation du serveur 5
Étape 3 echo 5
Étape 4 echo multi client 5
Étape 5 Pauline main 6
Étape 6 Pauline struct 6
Étape 7 clavardage 5
Étape 8 nickname 5
Total des critères 40
Malus par lignes en faute -0,5
Malus dépôt sale -5
Total N/A

Introduction

Le rust est particulièrement utilisé en programmation réseau, là où l’enjeu c’est de pouvoir servir un maximum de client en utilisant le moins de ressources possibles, car les resources, c’est de l’argent. Et les clients, c’est de l’argent aussi mais dans l’autre sens.

L’état de std::net

La bibliothèque standard propose quelques outils pour travailler en réseau, dans le namespace net. Pour un programme qui interagit avec un serveur ou attend une connexion, ces outils sont suffisants. Ils sont même capable d’être non-bloquants, même si leur faiblesse réside dans la gestion de plusieurs clients. La raison : si vous avez plusieurs connexions TCP et un listener, vous activement vérifier pour chacun s’il y a quelque chose à lire. Ça signifie soit :

  • 100% d’utilisation CPU sur un fil d’exécution
  • d’attendre sur chaque connexion pour éviter la surcharge, mais avoir un programme de plus en plus lent et moins réactif
  • de faire std::thread::sleep votre programme, ce qui affecte aussi vous réactivité.

mio

La solution nous l’avons déjà vu en C, c’est d’utiliser un multiplexeur comme poll. Il n’en existe pas d’équivalent dans la bibliothèque standard rust, mais il y a un crate qui en est capable : mio.

Ce crate rajoute des fonctionnalités aux structures std::net et les publie dans mio::net. Par défaut, tout est configuré pour être non-bloquant.

Il publie aussi des structures Poll et Events. À la structure Poll on peut register des TcpStream, TcpListener, UdpSocket, etc. Ensuite un appel à la méthode poll permet de savoir sur quel “source” il y a quelque chose à lire. L’avantage de Poll, c’est que si on lui demande d’attendre jusqu’à une certaine durée, il arrêtera d’attendre dès qu’un événement s’est produit. Pas besoin de sleep, lorsque poll attend, votre programme ne consomme virtuellement plus aucune ressource de calcul.

Ce sujet

Ce que l’on va faire aujourd’hui, c’est reproduire un serveur de clavardage avec ces outils. Nous allons créer une structure ChatServer et lui ajouter des champs et méthodes au fur et a mesure.

Étape 1 : installation

Ajoutez les crates suivants à votre projet :

cargo add mio --features os-poll,net
cargo add derive_more --features from

Le crate derive_more est pratique lorsqu’on veut stocker différent types d’erreurs dans une même énumération, vous allez voir c’est super pratique.

Étape 2 : serveur qui echo

Écrivez une structure Server avec un champ std::net::TcpListener. Écrivez la fonction new dans son implémentation qui permet d’initialiser le serveur. Elle doit prendre en paramètre une adresse IP et un port dans une string slice au format ip:port. Elle doit renvoyer un Result, avec en cas de réussite Self sinon une enum qui contient l’erreur qui s’est produite.

Il est possible à partir d’une string slice d’obtenir une std::net::SockAdd ou une std::net::AddrParseError à l’aide du trait FromStr :

let res_sockaddr: Result<std::net::SocketAddr, _> = "IP:PORT".parse();
// ou
let res_sockaddr = std::net::SocketAddr::from_str("IP:PORT");

Voici l’énumération que vous devez renvoyer en cas d’erreur :

#[derive(Debug, derive_more::From)]
pub enum NewServerError {
    #[from]
    Parsing(std::net::AddrParseError),
    #[from]
    Net(std::io::Error),
}

L’utilisation de derive_more::From et du décorateur #[from] permet la création d’une instance de l’énumération NewServerError à partir d’un AddrParseError ou d’une std::io::Error. Ainsi il est possible de faire :

use std::str::FromStr;

// Transformation d'une `std::net::AddrParseError` en notre `NewServerError`
// via `.into()`.
fn example_a() {
	let error = match std::net::SocketAddr::from_str("patate") {
		Ok(_) => panic!("impossible que ça réussisse avec patate en argument"),
		Err(error) => error,
	};
	let new_server_error: NewServerError = error.into();
	if let NewServerError::Parsing(_parsing_error) = new_server_error {
		println!("on a la variante que l'on voulait");
	} else {
		eprintln!("on a la mauvaise variante.");
	}
}

// Transformation d'une `std::net::AddrParseError` en `Err(NewServerError)`
// via l'operateur `?`.
fn example_b() -> Result<(), NewServerError> {
	let sock_addr = std::net::SocketAddr::from_str("patate")?;
	// Cette ligne là n'est jamais appellée car on fait échouer
	// `from_str` exprès pour le test :
	Ok(())
}

// On test l'exemple_b qui, vu de l'extérieur, aurait pu réussir, échouer au
// parsing ou échouer au listen. En l'occurence on sait qu'il va toujours
// échouer au parsing et nous donner un `NewServerError::Parsing(parse_error)`.
fn test_example_b() {
	match example_b() {
		Ok(_) => println!("parsing success, shoudn't happen here"),
		Err(NewServerError::Parsing(parse_error)) => {
			eprintln!("error while parsing \"{parse_error}\"");
		}
		Err(NewServerError::Net(listen_error)) => {
			eprintln!("error while listening \"{listen_error}\"");
		}
	}
}

Exemple de code généré par #[from] :

impl std::convert::From<std::io::Error> for NewServerError {
	fn from(source: std::io::Error) -> Self {
		Self::Net(source)
	}
}

Si vous êtes sur Windows

Sur windows, la fermeture d’une connection provoque souvent un ConnectionReset. En groupant la lecture et l’écriture dans la même fonction, vous pouvez d’un seul match vérifier si la connexion a été réinitialisée, et considérer qu’il s’agit d’une déconnexion comme sur linux, en renvoyant une taille lue de 0 octets.

use std::io::{Read, Write};

fn main() {
    let listener = std::net::TcpListener::bind("127..0.0.1:1234").unwrap();
    let (mut client, _) = listener.accept().unwrap();

    echo_once(&mut client).unwrap();
}

fn echo_once(client: &mut std::net::TcpStream) -> Result<usize, std::io::Error> {
    let mut buf = [0 as u8; 50];
    let size_read = match read_and_write(client, &mut buf) {
        Ok(val) => val,
        Err(err) if err.kind() == std::io::ErrorKind::ConnectionReset => 0,
        Err(err) => return Err(err),
    };
    Ok(size_read)
}

fn read_and_write(
    client: &mut std::net::TcpStream,
    buf: &mut [u8],
) -> Result<usize, std::io::Error> {
    let read_size = client.read(buf)?;
    Ok(client.write(&buf[..read_size])?)
}

Étape 3 : echo echo echo

Écrivez la méthode : accept_and_echo, elle doit accepter un client, lire sur son stream et lui écrire ce qu’elle a lu. La fonction quitte lorsque le client se déconnecte. On sait qu’un client est déconnecté dès lors qu’on fait une lecture de 0 octet.

La fonction peut échouer de trois façons principales :

  • elle n’arrive pas à accepter un client ;
  • elle n’arrive pas à lire ce que le client dit ;
  • elle n’arrive pas à écrire au client.

Gérez ces différentes erreurs à la façon de la méthode new. La déconnexion du client au moment d’une tentative de lecture n’est pas une erreur.

En cas de réussite, la fonction renvoie le nombre total d’octets lus dans la variante Ok du Result.

Étape 4 : echo sur plusieurs clients

Pour gérer plusieurs clients en simultané sur le même fil d’exécution, il nous faut une façon de ne pas bloquer lorsqu’on essaie de lire ce que dit un client, et c’est possible avec les std::net::TcpStream qui disposent d’une fonction set_nonblocking. Pareil pour le TcpListener.

Dans l’implémentation de votre structure Server ajoutez une méthode cycle_once. Elle doit faire une seule fois les opérations suivantes :

  • accept de nouveaux clients s’ils sont disponibles ;
  • essaye de lire sur chaque client :
    • s’il il y a quelque chose : lui ré-écrire,
    • s’il s’est déconnecté (zéro octets ou ConnectionReset) : retirer le client de la liste de clients,
    • si vous avez l’erreur WouldBlock passez au suivant.

Écrivez aussi la méthode run, qui doit appeler cycle_one en boucle, et attendre 100 millisecondes à chaque tour de boucle.

Si vous avez une erreur de lecture (autre que celle qui vous indique qu’il n’y a rien à lire pour le moment), considérez le client comme déconnecté. Si vous avez une erreur avec accept (autre que celle qui vous indique qu’il n’y a personne à accepter pour le moment), laissez votre serveur tourner avec les clients encore connectés, jusqu’à ce qu’il n’y en ait plus.

Étape 5 : Pauline contre attaque

Rendez un exécutable différent pour cette étape seulement : src/bin/pauline.rs Exécution avec : cargo run --bin pauline

Le plus gros problème de la méthode précédente c’est le temps d’attente fixe. Il est pourtant nécessaire si on ne veut pas surcharger notre processeur pour rien. Et encore, cette méthode fait plus appel au CPU que nécessaire.

Contre ce problème, nous avons mio, ses socket non bloquantes, Events et Poll.

Prenez l’exemple de la page du crate de mio, et adaptez-le pour qu’il puisse echo avec plusieurs clients différents.

Comportement attendu

En boucle dès qu’on a un événement

  • sur le TcpListener/Token(0) :
    • on accepte une connexion,
    • on lui attribue un Token(n) où n est une valeur attribuée à aucune autre connexion,
    • on l’ajoute au Poll,
    • on l’ajoute à une collection ;
  • sur un TcpStream/token().0 > 0 :
    • on lit ce qu’il a a dire et on lui écrit la même chose,
    • si on lit 0 octets alors on le retire de notre collection.

Collection

Collection des TcpStream clients : on peut être tentés de les stocker dans un vecteur, et de se servir de leur position comme valeur à utiliser en Token. Ce qui fonctionnerait (sauf pour 0 qui est déjà le Token du Listener) jusqu’à ce qu’on supprime un client qui n’est pas le dernier. Cela aurait pour effet de décaler tous nos ID et qu’ils ne soit plus en phase avec les Token qu’utilise Poll.

Si on veut associer des valeurs avec une clé stable, qui ne risque pas de bouger, on peut utiliser un dictionnaire (une map). Dans std::collections se trouve le type HashMap qui répond à notre besoin.

Nature des tokens

mio::Token est un tuple qui contient une seule valeur, un usize. On peut accéder à la valeur d’un Token en faisant :

let tok = mio::Token(12);
println!("{}", tok.0);

Étape 6 : Pauline organisée

Travaillez à nouveau dans l’exécutable des étapes 4 et précédentes.

Une fois l’étape précédente réussie, on se retrouve avec un gros main. Lorsqu’on fait des expériences c’est OK. Mais pour que ça soit utile à d’autres projets et pour que ça soit améliorable, le mieux c’est d’écrire une structure et son implémentation.

Créez une structure MultiClientServer. Elle doit avoir pour champ un Poll, un TcpListener et une collection de TcpStream.

Reprenez la fonction new de Server.

Écrivez deux nouvelles méthodes :

  • cycle_once :
    • initialise un mio::Events
    • appelle .poll() avec un temps d’attente max de 100 ms
    • accepte les connexion en attente,
    • répète ce que disent les clients qui parlent,
    • supprime les clients qui se déconnectent ;
  • run :
    • appelle cycle_once en boucle.

Étape 7 : clavardage

Au lieu de répéter les messages à la personne qui les a envoyé : relayez-les à tous les autres clients connectés.

Étape 8 : nickname

Associez à chaque client un nom d’utilisateur. Par défaut un client s’appelle “anon”. À l’aide d’une commande /nick NOM_CHOISI.

Lors du relay des message, préfixez-les par NICKNAME says: .