I. Introduction▲
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 !
II. 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ôtes(2) ;
- serde.
2.
3.
4.
[dependencies]
toml = "0.4"
serde_derive = "1.0.37"
serde = "1.0.37"
III. 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:
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-27
T07: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.
IV. 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.
V. 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.
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"
);
}
V-A. 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:
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:
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.
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.
V-B. 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 de toml::de::from_str ;
- &[u8], par le biais de toml::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.
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'annexeAnnexe pour y retrouver une fonction se chargeant d'effectuer ces traitements pour vous.
VI. 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…
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,
}
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.
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"
);
VII. 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 !
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 :
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"
VII-A. 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:
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.
VIII. 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é.
VIII-A. 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.
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
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
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.
VIII-B. 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.
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
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());
IX. Conclusion▲
Il me semble que nous en ayons terminé !
Globalement, la seule chose à retenir est d'utiliser systématiquement(3) 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).
X. Note de la Rédaction de Developpez.com▲
Nous tenons à remercier f-leb pour la relecture orthographique de ce tutoriel.