En passant par la création, la mise en cache d’un squelette dédié aux contrôles et les tests d’intégrités des entrées, la gestion des fichiers de configuration a toujours été1, plus ou moins, fastidieuse en fonction de l’écosystème avec lequel nous travaillons.
Cependant, il semblerait que toml-rs
fasse exception et facilite plutôt les choses, puisqu’elle vient directement s’appuyer sur le typage statique du compilateur pour effectuer automatiquement ces tests pour nous.
Si ça vous intéresse toujours, passons à la suite !
-
Selon moi. ↩
- Dépendances requises
- Apparition du format
- Et pour toml-rs ?
- Décodage
- Encodage
- Les tables imbriquées
- Annexe
Dépendances requises
Avant toute chose, assurez-vous d’avoir déclaré dans votre Cargo.toml
:
- La bibliothèque
toml
, bien entendu; serde_derive
, qui est une crate contenant les outils destinés à l’implémentation automatique des structures hôtes1;serde
.
1 2 3 4 | [dependencies] toml = "0.4" serde_derive = "1.0.37" serde = "1.0.37" |
-
Les structures dont on souhaite encoder ou décoder les données. ↩
Apparition du format
TOML est un langage dédié à la représentation des données et vient s’ajouter aux formats "humainement intelligibles" tels que JSON et YAML. Voici un exemple de fichier toml:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | # This is a TOML document. title = "TOML Example" [owner] name = "Tom Preston-Werner" dob = 1979-05-27T07:32:00-08:00 # First class dates [database] server = "192.168.1.1" ports = [ 8001, 8001, 8002 ] connection_max = 5000 enabled = true [servers] # Indentation (tabs and/or spaces) is allowed but not required [servers.alpha] ip = "10.0.0.1" dc = "eqdc10" [servers.beta] ip = "10.0.0.2" dc = "eqdc10" [clients] data = [ ["gamma", "delta"], [1, 2] ] # Line breaks are OK when inside arrays hosts = [ "alpha", "omega" ] |
Il fut créé et publié par Tom Preston-Werner, le 23 février 2013.
Bien que les spécifications rédigées par ce dernier sont encore jugées immatures, TOML dispose aujourd’hui d’une place importante au sein de l’écosystème Rust et des composants officiels.
Et pour toml-rs ?
toml-rs
est donc une implémentation des spécifications dont je parlais dans la précédente section. Cette bibliothèque est destinée au décodage (dé-serialisation) ainsi qu’à l’encodage (sérialisation) de fichiers .toml
, tout simplement.
Pour proposer cette solution, son auteur s’est basé sur le framework serde
, spécialisé dans la sérialisation/dé-sérialisation des objets. Vous remarquerez très fréquemment l’utilisation de Serialize
et Deserialize
qui sont des traits provenant de ce fameux framework. Ils sont dérivés (entendez ici implémentés automatiquement) sur les structures à sérialiser ou celles qui recevront le résultat d’une dé-sérialisation.
Décodage
Pour le décodage, toml-rs
vous propose la fonction toml::de::from_str
et, pour rester dans un cas d’utilisation plutôt simple, je me servirai d’un des exemples de la documentation. Je vous invite à prêter une attention particulière aux quelques commentaires ajoutés dans l’exemple.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | #[macro_use] extern crate serde_derive; extern crate toml; // Le scope racine de votre fichier toml. Contiendra les tables // s'il y en a. #[derive(Deserialize)] struct Config { title: String, owner: Owner, } // Une table, non optionnelle, présente dans le scope racine. #[derive(Deserialize)] struct Owner { name: String, } fn main() { let config: Config = toml::from_str(r#" title = 'TOML Example' [owner] name = 'Lisa' "#).unwrap(); assert_eq!(config.title, "TOML Example"); assert_eq!(config.owner.name, "Lisa"); } |
Les arguments optionnels
Comme on peut le constater, le fichier de configuration est représenté par la structure Config
. Chaque champ de cette dernière peut être soit une variable contenue dans le scope racine, soit une table contenant elle-même d’autres champs (variables). Il est alors possible de rendre ces tables optionnelles.
En reprenant l’exemple précédent, si nous voulions rendre la table Owner
optionnelle, il suffirait de la déclarer comme telle dans le code. Ce qui donnerait quelque chose comme ceci:
1 2 3 4 5 6 7 | #[derive(Deserialize)] // Le scope racine de votre fichier toml. Contiendra les tables // s'il y en a. struct Config { title: String, owner: Option<Owner>, } |
Suite à cette modification, rien ne nous empêcherait d’écrire le toml suivant:
1 2 3 4 5 6 7 8 9 10 11 | fn main() { let config: Config = toml::from_str(r#" title = 'TOML Example' "#).unwrap(); // Nous pouvons nous permettre de ne pas // déclarer la table `[owner]`, puisque désormais optionnelle. assert_eq!(config.title, "TOML Example"); // Cette assertion est également modifiée. // `config.owner` étant wrappé dans un conteneur // nous pouvons tester si la table existe ou non. assert_eq!(config.owner, None); } |
J’insiste sur le fait que les champs peuvent également être optionnels. Nous aurions très bien pu
déclarer Owner.name
comme un champ optionnel, de la même manière que la table Owner
elle-même.
1 2 3 4 | #[derive(Deserialize)] struct Owner { name: Option<String>, } |
Mais ça n’aurait que très peu d’intérêt, en l’occurrence.
Décoder à partir d’un fichier
Vous avez sans doute remarqué que toml
fait abstraction de la provenance des ressources que ses outils vont traiter (i.e. les fonctions dédiées au parsing n’acceptent rien d’autres que des chaînes de caractères et des octets). Les deux fonctions proposées sont:
&str
, par le biais detoml::de::from_str
;&[u8]
, par le biais detoml::de::from_slice
.
Dans les deux cas, si vous souhaitez récupérer du toml depuis un fichier (ou un stream quelconque), il faudra se débrouiller.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | let meat = { let mut file_path: path::PathBuf = path::PathBuf::new(); // On admettra ici que votre fichier est à la racine du projet. file_path.push(env::current_dir().unwrap().as_path()); file_path.push("my_file.toml"); let mut configuration_file: fs::File = fs::OpenOptions::new() .read(true) .open(file_path.as_path()) .expect("Cannot open the configuration file"); // tip: Si vous souhaitez récupérer le contenu de votre fichier // sous forme d'octets, il suffit de remplacer la ligne 16 par celle-ci: // `let mut raw_content: Vec<u8>: vec![];` let mut stringified_content: String = String::new(); // Vous devrez également modifier la méthode utilisée ici par: // `configuration_file.read_to_end(&mut raw_content)` match configuration_file.read_to_string(&mut stringified_content) { Ok(bytes) => println!("{} bytes has been appended to buffer.", bytes), Err(error) => panic!( " The data in this stream is not valid UTF-8.\n See error: '{}' ", error ), } stringified_content }; // Remplacez ici `from_str` par `from_slice` // et modifiez votre paramètre en conséquence. let config_content: Config = toml::from_str(meat.as_str()) .expect("Something went wrong"); |
Par ailleurs, vous pouvez consulter l’annexe pour y retrouver une fonction se chargeant d’effectuer ces traitements pour vous.
Encodage
L’encodage se fait tout aussi simplement que le décodage grâce à toml::to_string
. Si l’on reprend les structures précédentes, cela donnerait…
1 2 3 4 5 6 7 8 9 10 11 | // Attention à bien dériver le trait `Serialize`. #[derive(Debug, Serialize)] struct Config { title: String, owner: Owner, } // Attention à bien dériver le trait `Serialize` ici aussi. #[derive(Debug, Serialize)] struct Owner { name: String, } |
1 2 3 4 5 6 7 8 9 | let meat = Config { title: "Here's my configuration title!".to_owned(), owner: Owner { name: "Songbird".to_owned(), }, }; let config_content: String = toml::to_string(&meat) .expect("Something went wrong"); |
…c’est tout ! Sauf si vous voulez écrire le dump dans un de vos fichiers, encore une fois.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | let meat = Config { title: "Here's my configuration title!".to_owned(), owner: Owner { name: "Songbird".to_owned(), }, }; let config_content: String = toml::to_string(&meat) .expect("Something went wrong"); let mut file_path: path::PathBuf = path::PathBuf::new(); file_path.push(env::current_dir().unwrap().as_path()); file_path.push("a_file.toml"); let mut configuration_file: fs::File = fs::OpenOptions::new() .write(true) .create(true) .open(file_path.as_path()) .expect("Cannot open the configuration file"); // On récupère ce que `toml` vient de produire. let bytes: &[u8] = &config_content.into_bytes(); configuration_file .write_all(bytes) .expect("An error was occurred"); |
Les tables imbriquées
Dans des scénarios plus "complexes", vous pourriez avoir besoin d’imbriquer des tables, comme le stipulent les spécifications du langage. Rien de plus simple !
Mettons:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 | extern crate toml; #[macro_use] extern crate serde_derive; #[derive(Debug, Serialize)] struct ConfigFile { owner: Owner, // Le fichier ne peut contenir qu'une seule table `[team]`. team: Option<Team>, } #[derive(Debug, Serialize)] struct Owner { // Champ obligatoire. name: String, // Champ obligatoire. mail: String, } #[derive(Debug, Serialize)] struct Team { // La table `[team]` peut disposer de // plusieurs sous-tables `[[team.members]]`. members: Vec<Member>, } #[derive(Debug, Serialize)] struct Member { // Champ obligatoire. name: String, // Champ obligatoire. job: String, // Champ optionnel. mail: Option<String>, } let meat = ConfigFile { owner: Owner { name: "Songbird".to_owned(), mail: "johndoe@johndoe.com".to_owned(), }, team: Some(Team { members: vec![ Member { name: "Alice".to_owned(), job: "Tech leader".to_owned(), mail: Some("resp@abc.xyz".to_owned()), }, Member { name: "Bob".to_owned(), job: "Developer".to_owned(), mail: None, }, Member { name: "John".to_owned(), job: "Community Manager".to_owned(), mail: None, }, ], }), }; |
Cette fois-ci, je vous épargne le code pour écrire tout ceci dans un fichier, passons au résultat:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | [owner] name = "Songbird" mail = "johndoe@johnedoe.com" [[team.members]] name = "Alice" job = "Tech leader" mail = "resp@abc.xyz" [[team.members]] name = "Bob" job = "Developer" [[team.members]] name = "John" job = "Community Manager" |
La macro toml!
Toutefois, on notera rapidement quelque chose: l’écriture commence à être plutôt lourde. En réponse à cela, la bibliothèque dispose d’une macro modestement nommée toml!
. Ce qui nous donnera, après modification:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | #[macro_use] extern crate toml; #[macro_use] extern crate serde_derive; // Certains de ces imports sont à supprimer si // vous n'effectuez aucune opération d'entrée/sortie. use std::{convert, env, fs, io, io::prelude::*, path}; let meat = toml! { [owner] name = "Songbird" mail = "johndoe@johnedoe.com" [[team.members]] name = "Alice" job = "Tech leader" mail = "resp@abc.xyz" [[team.members]] name = "Bob" job = "Developer" [[team.members]] name = "John" job = "Community Manager" }; |
Le dump sera strictement le même.
Bien qu’il existe une manière plus simple de rédiger du toml, les structures précédemment déclarées ne deviennent pas inutiles. Elles vous permettront de vous assurer de l’intégrité de vos fichiers dans le cas d’une éventuelle modification effectuée par un utilisateur.
Annexe
J’ai écrit deux fonctions, plutôt basiques, permettant de rendre l’écriture (ou la lecture) des fichiers toml moins rébarbative. Grâce à std::convert::AsRef
, elles supportent tous les types ayant implémenté le trait pour la structure std::path::Path
.
Ces fonctions n’ont pas d’autres prétentions que de servir d’exemples ou d’outils "de départ" pour tester de petites choses de votre côté.
read_toml_file(path, buffer)
Lit le fichier path
, stocke le contenu du fichier dans le tampon buffer
et renvoie une instance de la structure censée représenter votre fichier toml. Concernant les erreurs vous ne pourrez rattraper que celles lancées par toml::from_str
. C’est le point noir de cette fonction, vous ne pourrez vous en servir que pour empêcher l’exécution d’une partie, plus importante, de votre programme (par exemple dans le cas où vous ne souhaitez pas que l’utilisateur final se serve de votre programme, sans avoir créé un fichier de configuration au préalable). C’est à modifier en fonction des besoins.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | fn read_toml_file<'a, 'de, P: ?Sized, T>(path: &'a P, buffer: &'de mut String) -> Result<T, Error> where P: convert::AsRef<path::Path>, T: Deserialize<'de>, { let mut configuration_file: fs::File = fs::OpenOptions::new() .read(true) .open(path) .expect("Cannot read the configuration file"); match configuration_file.read_to_string(buffer) { Ok(bytes) => { println!("{} bytes has been appended to buffer.", bytes); toml::from_str(buffer.as_str()) } Err(error) => panic!( " The data in this stream is not valid UTF-8.\n See error: '{}' ", error ), } } |
Exemple
1 2 3 4 5 6 7 8 | let mut file_path: path::PathBuf = path::PathBuf::new(); file_path.push(env::current_dir().unwrap().as_path()); file_path.push("my_file.toml"); let mut buffer = String::new(); let config_content: Config = read_toml_file(&file_path, &mut buffer) .expect("Something went wrong"); println!("Your config object: {:#?}", config_content); |
Résultat
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | 246 bytes has been appended to buffer. Your config object: Config { projects: [ ProjectConfig { project_home: "my/home/", project_root: "my_directory", subroots: Some( [ SubrootConfig { path: "my/path/file" }, SubrootConfig { path: "my/path/file" } ] ) }, ProjectConfig { project_home: "another/home/", project_root: "another_directory", subroots: None } ] } |
Les structures ne sont pas identiques à celles présentées tout au long du billet, mais le principe est le même.
create_toml_file(path, content)
Écrit le contenu content
dans le chemin path
soumis. content
est transféré à l’appel de cette fonction.
Rien d’extraordinaire pour celle-ci, elle aurait très bien pu s’appeler create_file
puisqu’elle nous épargne simplement les lignes de préparation des accès et de l’écriture. Toutes les erreurs lancées par cette fonction sont gérables, en revanche.
1 2 3 4 5 6 7 8 9 10 11 12 13 | fn create_toml_file<'a, P: ?Sized>(path: &'a P, content: String) -> io::Result<()> where P: convert::AsRef<path::Path>, { let mut toml_file: fs::File = fs::OpenOptions::new() .write(true) .create(true) .open(path)?; let bytes: &[u8] = &content.into_bytes(); toml_file.write_all(bytes) } |
Exemple
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | let meat: toml::Value = toml! { [owner] name = "Songbird" mail = "johndoe@johnedoe.com" [[team.members]] name = "Alice" job = "Tech leader" mail = "resp@abc.xyz" [[team.members]] name = "Bob" job = "Developer" [[team.members]] name = "John" job = "Community Manager" }; let config_content: String = toml::to_string(&meat) .expect("Something went wrong"); let mut file_path: path::PathBuf = path::PathBuf::new(); file_path.push(env::current_dir().unwrap().as_path()); file_path.push("a_file.toml"); assert!(create_toml_file(&file_path, config_content).is_ok()); |
Il me semble que nous en ayons terminé !
Globalement, la seule chose à retenir est d’utiliser systématiquement1 la macro toml!
pour écrire le squelette initial du fichier et déclarer les structures respectives à chaque table uniquement pour la lecture et la vérification des entrées. Amusez-vous bien !
Les logos Rust et Cargo (aux formats matriciel et vectoriel) sont la propriété de Mozilla et sont distribués selon les termes de la licence Creative Commons Attribution (CC-BY).
-
Sauf si vous avez une bonne raison de ne pas le faire. ↩