Interface en ligne de commande et entrées / sorties
Concepts
Les concepts abordés dans cet exemple sont:
Discussion
L'écosystème de Rust contient énormément de librairies ergonomiques et efficaces. Dans ce chapitre, nous
parlerons de clap, une librairie pour construire des interfaces en ligne de commande.
Nous en profiterons pour discuter également les entrées / sorties et en particulier comment écrire dans des
fichiers. Finalement, nous verrons également avoir une gestion d'erreur un peu plus ergonomique à
l'aide de l'opérateur ?
et de la fonction map_err()
.
Vous pouvez trouver plus d'informations aux liens suivants:
- Tutoriel pour le pattern
builder
- Tutoriel pour le pattern
derive
- Chapitre du livre sur les entrées sorties
- Command Line Applications in Rust
L'interface à la ligne de commande et l'utilisation de librairies externes
Dans cette section nous allons voir une façon différente de lire la ligne de commande (par rapport à ce que nous avons fait dans la partie 07).
Cette façon de faire est trop complexe pour construire une vraie application et rajouterait beaucoup d'efforts à chaque fois qu'on veut en reconstruire une: elle demanderait un parsing long et fastidieux de la ligne de commande manuel. La librairie clap, la librairie de CLI la plus populaire pour Rust, nous permet de de construire une interface pour un programme avec arguments nommés, optionnel, et un menu d'aide de façon élégante et une bonne gestion des erreurs.
Afin d'utiliser une librairie externe, il faut l'ajouter comme dépendance dans le fichier Cargo.toml
de notre projet. Pour ce faire, il y a deux méthodes, et nous allons voir comment cela fonctionne pour clap
:
- Ajouter la ligne
clap = { version = "4.4.0", features = ["derive"] }
sous l'entrée [dependecies]
.
- Utiliser l'outil
cargo
qui le fait pour nous
cargo add clap --features derive
Il y a énormément de fonctionnalités dans l'outil cargo.
Ces deux façons sont équivalentes. Lors de l'ajout manuel, on doit choisir la version manuellement qu'on veut mettre dans
le fichier Cargo.toml
(cela permet de figer une version) ou on peut remplacer 4.4.0
par *
pour avoir toujours
la dernière version de la crate
à utiliser. Cependant cette façon de faire n'est pas recommandée, car
cela peut "casser" la compilation lors d'une mise à jour majeure (ou avoir des effets de sécurité
indésirables).
On note également, qu'on a un champs features
qui est optionnel, mais qui ici est mis à derive
.
Le langage Rust permet d'omettre une partie des fonctionnalités d'une librairies qui sont ajoutées
à l'aide d'annotations lorsque la feature
est activée. Nous n'entrerons pas dans les détails de ces annotations,
mais avons besoin de la feature derive
pour compiler notre code.
Nous pouvons à présent commencer à écrire nos fonctions pour lire la ligne de commande
à l'aide de la librairie clap
. Nous allons voir deux façons différentes
de créer une telle interface avec la librairie: les pattern builder
et derive
(c'est pour
ce dernier que nous avons besoin de la feature derive
).
Le but de cette interface à la ligne de commande est pour l'utilisateur·trice de pouvoir choisir les options suivantes pour notre petit programme de calcul de minimum dans une liste.
- Entrer à la main une liste de nombres.
- Créer une liste de
count
nombres aléatoires qui seront lus depuis/dev/urandom
. - Écrire la liste de nombres et le minimum de la liste dans un fichier de sortie (en en fournissant le nom) ou sur l'écran.
Il faut noter que l'option 1 et 2 son mutuellement exclusives. L'option 3 écrira dans un fichier uniquement si un nom de fichier est fourni par l'utilisateur·trice.
Il est fondamental que si les entrées sont mal formatées (on ne donne pas des nombres p.ex.) ou si on essaie d'utiliser les options 1 et 2 en même temps, on ait un bon traitement de l'erreur et un message d'erreur lisible.
Des tentatives d'exécution typiques seraient
$ cargo run -- --numbers 1 2 3 4 5 6
où on donne une liste de nombres après l'option --numbers
ou encore
$ cargo run -- --output fichier.txt --numbers 1 2 3 4 5 6
où --output
permet de spécifier un nom de fichier. En revanche, on doit avoir une erreur
si on essaie de faire
$ cargo run -- --count 10 --numbers 1 2 3 4 5 6
car on ne veut pas pouvoir générer deux listes de nombres, mais une seule.
Ainsi on a trois arguments possibles et tous sont optionnels, mais deux sont exclusifs.
Le builder
pattern
Nous allons voir à présent comment construire une interface en ligne de commande à proprement parler avec clap
. Pour ce faire et comprendre le fonctionnement interne de la librairie
nous allons d'abord étudier le builder
pattern, qui consiste à construire l'interface
à l'aide de fonctions qui vont construire petit à petit notre application.
La fonction qui va faire tourner notre application se trouve dans src/io.rs
et a la signature suivante
pub fn read_command_line_builder() -> Result<(), String>
On remarque qu'elle ne prend aucun argument en paramètre et qu'elle retourne un Result<(), String>
. En d'autres termes, si tout s'est bien passé, nous ne retournons "rien". Dans l'éventualité
où quelque chose ne s'est pas passé comme prévu, nous retournons une chaîne de caractères
qui contiendra un message d'erreur.
Le création de la "commande" se trouve dans le code
let matches =
Command::new(COMMAND)
.author(AUTHOR)
.version(VERSION)
.arg(
Arg::new("numbers") // id
.short('n') // version courte -n
.long("numbers") // ou longue --numbers
.help("A list of i32 numbers") // l'aide
.num_args(1..) // combien il y a d'entrées
.allow_negative_numbers(true) // on peut avoir des négatifs
.value_parser(value_parser!(i32)) // on veut s'assurer que ça soit des nombres
.required(false), // optionnel
)
.arg(
Arg::new("count")
.short('c')
.long("count")
.help("How many random numbers we want?")
.value_parser(value_parser!(usize))
.conflicts_with("numbers") // impossible d'avoir -c et -n
.required(false),
)
.arg(
Arg::new("output")
.short('o')
.long("output")
.help("Should we write output in a file?")
.required(false),
)
.get_matches();
Ici nous effectuons diverses opérations. Nous commençons par créer une nouvelle commande
dont le nom est cli
1
const COMMAND: &str = "cli";
const AUTHOR: &str = "Orestis Malaspinas";
const VERSION: &str = "0.1.0";
Command::new(COMMAND)
.author(AUTHOR)
.version(VERSION)
avec différents composants optionnels, comme le nom de l'auteur de l'application, sa version, etc. Cela permet maintenant d'ajouter les arguments sur cette application.
Comme discuté plus haut nous voulons trois arguments (numbers
, output
, et count
) qui sont ajouté dans un ordre
qui n'a aucune importance.
Command::new(COMMAND)
.author(AUTHOR)
.version(VERSION)
L'appel à la méthode .arg()
nous permet d'ajouter un nouvel argument, créé avec l'appel à
Arg::new(id)
où id
est une chaîne de caractères qui permet d'identifier de façon unique l'argument.
Puis viennent toutes les propriétés de notre argument:
short('n')
: l'option peut être nommée-n
,long("numbers")
: l'option peut être nommée--numbers
, permettant d'appeler le programme avec
$ cargo run -- --numbers 1 2 3 4 5 6
$ cargo run -- -n 1 2 3 4 5 6
help("A list of i32 numbers")
: le message d'aide si nous appelons
⋊> ~/g/p/r/c/r/cli on 25-cli-i-o ⨯ cargo run -- --help
Finished dev [unoptimized + debuginfo] target(s) in 0.01s
Running `target/debug/cli --help`
Usage: cli [OPTIONS]
Options:
-n, --numbers <numbers>... A list of i32 numbers
-c, --count <count> How many random numbers we want?
-o, --output <output> Should we write output in a file?
-h, --help Print help
-V, --version Print version
num_args(1..)
: qui permet d'avoir plusieurs valeurs dans l'argument et savons qu'il doit y en avoir plus d'un. Sans cet argument, l'appel
$ cargo run -- --numbers 1 2 3 4 5 6
considérerait le 2
comme la valeur d'un autre argument et ne ferait pas partie de numbers
.
Puis viennent encore
allow_negative_numbers(true)
: pour autoriser les nombres négatifs (sinon-
est parsé comme nouvel argument)value_parser(value_parser!(i32))
: on ne veut que desi32
(les nombres à virgules, les lettres, etc sont automatiquement rejetées et un message d'erreur est affiché)required(false)
: est-ce que l'argument est obligatoire (ici ce n'est pas le cas)? En d'autres termes est-ce l'exécution suivante est valide?
$ cargo run -- # sans option numbers
Si l'argument est required(true)
alors il est nécessaire de spécifier l'option sinon on aura un message d'erreur.
Dans la suite du code on crée encore deux arguments count
et output
. Nous avons déjà couvert
les différentes fonctions appelée, à l'exception d'une:
conflicts_with("numbers")
: ici nous spécifions que l'argumentcount
ne peut pas être présent en même temps que l'argumentnumbers
(peu importe l'ordre d'appel). Ainsi si nous essayons d'exécuter
$ cargo run -- --numbers 1 2 3 4 5 6 -c 6
nous aurons le message d'erreur
error: the argument '--numbers <numbers>...' cannot be used with '--count <count>'
Usage: cli --numbers <numbers>...
For more information, try '--help'
Après avoir construit les arguments, nous devons appeler la fonction get_matches()
qui
termine la construction de la commande et vérifie s'il n'y a pas d'arguments qui sont contradictoires (un message d'erreur sera produit à l'exécution si cela est le cas).
Lorsque ce code est exécuté, notre programme peut maintenant parser la ligne de commande lorsqu'il est exécuté. A nous maintenant d'utiliser correctement les différents arguments. Ici, nous devons traiter deux "groupes" d'arguments:
numbers
etcount
qui sont deux options exclusives,output
qui est optionnel également.
Le traitement de
let numbers = if let Some(count) =
matches.get_one::<usize>("count")
{
read_from_urandom(*count)?
} else if let Some(numbers) =
matches.get_many::<i32>("numbers")
{
numbers.copied().collect()
} else {
Vec::new()
};
crée la liste de nombre que nous voulons avoir pour calculer le minimum.
Comme les deux arguments sont optionnels, nous voyons que pour les déstructurer,
il faut passer par une construction if let Some() = ...
.
Dans le cas de l'argument count
nous savons que nous voulons un usize
dont l'identifiant
est "count"
. Si l'argument est présent,
matches.get_one::<usize>("count")
retourne Some(&usize)
(nous obtenons une référence vers l'argument)
et nous appellerons la fonction read_from_urandom()
(que nous discuterons plus bas). Sinon, nous devons vérifier si l'argument numbers
est présents
et quelles valeurs lui sont assignées. Ainsi, si l'argument est présent
matches.get_many::<i32>("numbers")
retournera un nombre arbitraire de références d'entiers, qui seront ensuite
transformés en Vec<i32>
à l'aide de la ligne
numbers.copied().collect()
qui commence par faire une copie des valeurs de la liste de références pour pouvoir
en devenir les propriétaires, puis les met dans un Vec<i32>
(il faut noter que le type
de numbers
est inféré grâce au reste du code). A la fin de ce if let Some() = ...
nous retournons un Vec<i32>
(qui peut être vide)
qui sera utilisé dans la suite de la fonction.
Il nous reste à décider si nous allons écrire les sorties de notre programme (la liste de nombre et son minimum) dans un fichier ou dans la sortie standard à l'aide du code
if let Some(output) =
matches.get_one::<String>("output")
{
write_to_file(output, &numbers)?;
} else {
println!("Among the Somethings in the list:");
print_tab(&numbers);
println!("{}", find_min(&numbers).to_string());
}
Rien de très nouveau ici, si "output"
est présent
matches.get_one::<String>("output")
nous pouvons déstructurer le retour de la fonction et obtenir le nom du fichier
dans lequel nous allons écrire dans la fonction write_to_file()
ou le cas échant
écrire dans la sortie standard .
Gestion d'erreur un peu simplifiée
Aussi bien les appels à read_from_urandom()
que write_to_file()
sont suivis d'un ?
.
Ces fonctions doivent manipuler des fichiers et peuvent donc échouer à n'importe quel moment
(si le fichier n'existe pas, s'il ne peut être créé, etc). Elles retournent
donc des Result
. L'opérateur ?
en Rust est très pratique. Il est utilisé pour répercuter
les erreurs de façon courte dans les fonctions. En gros il répond à la question:
"Est-ce que la fonction a retourné Ok()
ou Err()
?" Si la réponse est Ok()
il retourne ce
qui est contenu dans le Ok()
qui peut être assigné à une variable (ou retourné de la fonction).
En revanche si la réponse est Err()
, on retourne l'erreur de la fonction courante.
Cet opérateur permet d'éviter d'alourdi le code avec du pattern matching à chaque appel
qui peut échouer et est très utilisé dans le code Rust.
Le derive
pattern
Nous avons vu en grand détail comment construire une commande avec un builder
design. Nous allons voir à présent de façon très succincte comment faire la même
chose avec le pattern derive
. Ici, tout le code écrit plus haut sera généré
pour nous à l'aide de macro
Rust, et nous avons uniquement besoin de spécifier
ce qui doit être généré. Afin de créer une interface en ligne de commande nous devons uniquement
créer une struct
annotée
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
struct CliMin {
#[arg(short, long, help = "A list of i32 numbers", num_args=1.., allow_negative_numbers=true, value_parser = clap::value_parser!(i32))]
numbers: Option<Vec<i32>>,
#[arg(short, long, help = "How many random numbers we want?", value_parser = clap::value_parser!(usize), conflicts_with = "numbers")]
count: Option<usize>,
#[arg(short, long, help = "Filename for writing the numbers.")]
output: Option<String>,
}
qui contient différent champs qui sont annotés (ou pas).
La ligne
#[derive(Parser)]
va dire au compilateur de Rust de générer automatiquement tout le parsing d'arguments
en fonction de ce qu'il va trouver dans la structure en dessous, ici CliMin
.
Puis vient la commande préprocesseur
#[command(author, version, about, long_about = None)]
qui va créer la nouvelle commande à proprement parler. Elle correspond à
Command::new(COMMAND)
.author(AUTHOR)
.version(VERSION)
A la différence de cette ligne où on spécifie explicitement l'auteur, etc., ici,
le contenu du champs author, version, etc. est directement récupéré du Cargo.toml
.
Ensuite la structure CliMin
possède trois membres: numbers
, count
, et output
qui sont annotés avec un #[arg()]
.
#[arg(short, long, help = "A list of i32 numbers", num_args=1.., allow_negative_numbers=true, value_parser = clap::value_parser!(i32))]
numbers: Option<Vec<i32>>,
Cette annotation va dire au compilateur de générer
automatiquement le code pour toutes les paires clés valeurs se trouvant entre les parenthèses.
Si une valeur est absente alors un comportement par défaut est appliqué.
Ainsi, long
va générer automatiquement que l'option en version longue pour la variable numbers
est --numbers
. De façon similaire, par défaut la version short
de
numbers sera -n
(la première lettre de numbers
). Cette façon de faire par défaut permet de réduire la quantité de code. Par contre, elle est également dangereuse, car si deux
champs commencent par le même nom, seul le premier aura le short
qui lui correspondra.
Le reste des arguments correspondent à toutes les méthodes de la version builder
vues dans le chapitre précédent. Il y a deux grandes différences:
- On ne trouve pas
d'équivalent à
required(false)
. En fait, l'obligation ou non de spécifier un argument est directement inféré par le type de l'argument: si c'est uneOption
alors l'argument est... optionnel. numbers
est directement parsé enVec<i32>
, pas besoin de faire des conversions.
Le reste du code est relativement trivial. Pour utiliser notre interface en ligne de commande
pub fn read_command_line_derive() -> Result<(), String> {
let cli = CliMin::parse();
let numbers = if let Some(count) = cli.count {
read_from_urandom(count)?
} else if let Some(numbers) = cli.numbers {
numbers
} else {
Vec::new()
};
if let Some(output) = cli.output {
write_to_file(&output, &numbers)?;
} else {
println!("Among the Somethings in the list:");
print_tab(&numbers);
println!("{}", find_min(&numbers).to_string());
}
Ok(())
}
Il faut appeler la fonction parse()
let cli = CliMin::parse();
et les champs cli.cout
, cli.numbers
, et cli.output
seront automatiquement assignés
aux valeurs dans la cli
si elles sont compatibles avec les formats spécifiés dans les
arguments correspondants. Sinon des erreurs seront affichées.
Les entrées / sorties
Il y a deux fonctions qui gèrent la lecture / écriture de fichiers dans notre programme.
- La fonction
read_from_urandom()
permet de lire le fichier/dev/urandom
qui contient dans les systèmes unix des "vrais" nombres aléatoires générés par le bruit du système. - La fonction
write_to_file()
qui permet d'écrire la liste de nombre, ainsi que le minimum dans un fichier. Nous allons brièvement discuter ces fonctions, afin de comprendre un peu mieux comment faire des entrées / sorties depuis le disque en Rust.
Lecture de fichier
Pour la lecture de fichier, nous nous concentrons sur la fonction
fn read_from_urandom(count: usize) -> Result<Vec<i32>, String> {
let file = File::open("/dev/urandom").map_err(|_| "Could not open /dev/urandom")?;
let mut buf_reader = BufReader::new(file);
let mut numbers = vec![0; count * 4];
buf_reader
.read_exact(&mut numbers)
.map_err(|_| "Could not read numbers")?;
Ok(numbers
.chunks(4)
.map(|i| i32::from_be_bytes(i.try_into().unwrap()))
.collect::<Vec<_>>())
}
L'ouverture d'un fichier en lecture se fait avec la fonction File::open()
qui peut échouer si le fichier n'existe pas par exemple. Dans le cas d'une
erreur, nous nous empressons de convertir l'erreur dans un message d'erreur
avec la fonction map_err()
qui prend en argument une fonction anonyme qui a pour argument
ce qui est encapsulé dans le type Err()
et qui retourne une nouvelle valeur qui sera
encapsulée dans une nouvelle Err()
. Cette façon de faire n'est pas très idiomatique
pour Rust, mais elle nous satisfait pour le moment, afin d'avoir des types de retour homogènes
et de pouvoir utiliser l'opérateur ?
(voir plus haut).
Comme nous lisons dans le fichier /dev/urandom
qui est un flux continu d'octets, nous définissons
une mémoire tampon sur le fichier et allons lire exactement 4 * count
fois octets, soit
exactement l'équivalent de count
entiers 32-bits soit i32
let mut buf_reader = BufReader::new(file);
let mut numbers = vec![0; count * 4];
buf_reader
.read_exact(&mut numbers)
.map_err(|_| "Could not read numbers")?;
Finalement, notre mémoire tableau numbers
(qui est rien d'autre qu'une suite d'octets)
est convertie en Vec<i32>
grâce à la puissance des itérateurs.
Ok(numbers
.chunks(4)
.map(|i| i32::from_be_bytes(i.try_into().unwrap()))
.collect::<Vec<_>>())
Dans un premier temps
le tableau est découpé en tranches de 4 éléments grâce à la méthode .chunks(4)
(l'itérateur est maintenant une suite
d'itérateurs de 4 éléments). Puis chacun des éléments de l'itérateur (soit 4 octets)
est transformé en i32
, grâce à la méthode map(|i| i32::from_be_bytes(i.try_into().unwrap()))
.
Il faut noter qu'ici nous utilisons la fonction try_into()
qui peut échouer si nous n'avons pas
4 octets à disposition quand nous faisons la conversion. Ici, par construction cela ne peut pas se produire et pouvons unwrap()
le résultat. Finalement, à l'aide de collect()
,
nous créons un Vec<i32>
à partir de l'itérateur obtenu et l'encapsulons dans un Ok()
,
car le résultat de notre fonction est un succès, si tout s'est bien passé.
Écriture dans un fichier
Pour l'écriture dans un fichier, nous nous concentrons sur la fonction
fn write_to_file(output: &str, numbers: &[i32]) -> Result<(), String> {
let mut file = File::create(output).map_err(|_| format!("Failed to create {output}"))?;
writeln!(file, "Among the Somethings in the list:")
.map_err(|_| "Failed to write header into file.")?;
for n in numbers {
write!(file, "{n} ").map_err(|_| format!("Failed to write {n} into file."))?;
}
writeln!(file,).map_err(|_| "Failed to write carriage return into file.")?;
writeln!(file, "{}", find_min(numbers).to_string())
.map_err(|_| "Failed to write minimum value into file.")?;
Ok(())
}
Nous commençons par créer un fichier à l'aide de la fonction File::create()
qui prend en argument le chemin où créer le fichier.
let mut file = File::create(output).map_err(|_| format!("Failed to create {output}"))?;
Si un fichier existe déjà
il est écrasé par défaut. Si la création est impossible, une erreur est retournée.
De plus le fichier est ouvert en mode écriture. Il faut noter que nous allons modifier le fichier, et il est donc mutable.
Ainsi, nous pouvons écrire dans le fichier à l'aide des macros write!
et writeln!
qui s'utilisent comme print!
et println!
à l'exception qu'elles prennent
des fichier en argument et retournent une erreur en cas d'échec.
writeln!(file, "Among the Somethings in the list:")
.map_err(|_| "Failed to write header into file.")?;
for n in numbers {
write!(file, "{n} ").map_err(|_| format!("Failed to write {n} into file."))?;
}
writeln!(file,).map_err(|_| "Failed to write carriage return into file.")?;
writeln!(file, "{}", find_min(numbers).to_string())
.map_err(|_| "Failed to write minimum value into file.")?;
A nouveau, toutes les erreurs sont transformées en messages (des chaînes de caractères) pour simplifier les concepts abordés dans ce code.
Cet identifiant permet d'identifier de façon unique la commande dans le cas où nous en créerions plusieurs dans la même application ce qui n'est pas le cas ici.