Lorsque j’ai commencé à travailler sur ce post, j’avais une autre idée en tête : je voulais comparer l’expérience développeur et les performances de Spring Boot et GraalVM avec Rust sur une application API HTTP de démonstration. Malheureusement, le processeur M1 de mon MacBook Pro avait d’autres idées.
J’ai donc changé mon plan initial. J’écrirai sur l’expérience du développeur dans le développement de l’application ci-dessus dans Rust, par rapport à ce à quoi je suis habitué avec Spring Boot.
L’exemple d’application
Comme tout projet pour animaux de compagnie, l’application a une portée limitée. J’ai conçu un simple CRUD API HTTP. Les données sont stockées dans PostgreSQL.
Lorsque l’on conçoit une application sur la JVM, la première et unique décision de conception est de choisir le framework : il y a quelques années, c’était Spring Boot. De nos jours, le choix se porte principalement entre Spring Boot, Quarkus et Micronaut. Dans de nombreux cas, ils reposent tous sur les mêmes bibliothèques sous-jacentes, par exemple, la journalisation ou les pools de connexions.
La rouille est beaucoup plus jeune ; par conséquent, l’écosystème n’a pas encore mûri. Pour chaque fonctionnalité, il faut choisir précisément la bibliothèque à utiliser – ou l’implémenter. Pire encore, il faut comprendre qu’il existe une telle fonctionnalité. Voici ceux que j’ai recherchés :
- Accès réactif à la base de données
- Regroupement des connexions à la base de données
- Mappage des lignes aux structures
- Points de terminaison Web
- Sérialisation JSON
- Configuration à partir de différentes sources, par exemple YAML, variables d’environnement, etc.
Cadre Web
Le choix du framework web est le plus critique. Je dois admettre que je n’avais aucune idée préalable de ces bibliothèques. J’ai regardé autour de moi et je suis tombé sur quel framework Web Rust choisir en 2022. Après avoir lu le post, j’ai décidé de suivre la conclusion et j’ai choisi axum
:
- Acheminez les demandes vers les gestionnaires avec une API sans macro
- Analyse déclarative des requêtes à l’aide d’extracteurs
- Modèle de gestion des erreurs simple et prévisible
- Générez des réponses avec un minimum de passe-partout
- Tirez pleinement parti de l’écosystème tour et tour-http de middleware, de services et d’utilitaires.
En particulier, le dernier point est ce qui définit
axum
en dehors des autres cadres.axum
n’a pas son propre système middleware mais utilise à la place tower::Service. Cela signifie qu’axum obtient gratuitement les délais d’expiration, le traçage, la compression, l’autorisation, etc. Il vous permet également de partager des intergiciels avec des applications écrites en utilisant hyper ou tonic.– documentation de la caisse axum
axum
utilise le Tokio asynchrone bibliothèque en dessous. Pour une utilisation basique, il nécessite deux caisses :
[dependencies]
axum = "0.6"
tokio = { version = "1.23", features = ["full"] }
axum
Le routeur de Spring ressemble beaucoup au Kotlin Routes DSL de Spring :
let app = Router::new()
.route("/persons", get(get_all)) //1
.route("/persons/:id", get(get_by_id)) //1//2
async fn get_all() -> Response { ... }
async fn get_by_id(Path(id): Path<Uuid>) -> Response { ... }
- Une route est définie par le chemin et une référence de fonction.
- Une route peut avoir des paramètres de chemin.
axum
peut déduire des paramètres et les lier.
Objets partagés
Un problème couramment rencontré dans les projets logiciels est le partage d’un « objet » avec d’autres. Nous avons établi il y a longtemps qu’il y avait de meilleures idées que de partager des variables globales.
Spring Boot (et les frameworks JVM similaires) le résout avec l’injection de dépendance d’exécution. Les objets sont créés par le framework, stockés dans un contexte et injectés dans d’autres objets au démarrage de l’application. D’autres frameworks font l’injection de dépendances au moment de la compilation, par exemple, Dagger 2.
Rust n’a ni runtime ni objets. L’injection de dépendance configurable n’est pas « une chose ». Mais nous pouvons créer une variable et l’injecter manuellement là où c’est nécessaire. Dans Rust, c’est un problème à cause de la possession:
La propriété est un ensemble de règles qui régissent la façon dont un programme Rust gère la mémoire. Tous les programmes doivent gérer la façon dont ils utilisent la mémoire d’un ordinateur pendant leur exécution. Certains langages ont une récupération de place qui recherche régulièrement la mémoire non utilisée pendant l’exécution du programme ; dans d’autres langages, le programmeur doit explicitement allouer et libérer la mémoire. Rust utilise une troisième approche : la mémoire est gérée via un système de propriété avec un ensemble de règles que le compilateur vérifie. Si l’une des règles n’est pas respectée, le programme ne se compilera pas. Aucune des caractéristiques de propriété ne ralentira votre programme pendant son exécution.
– « Qu’est-ce que la propriété ? »
axum
fournit un wrapper dédié, l’extracteur d’état, pour réutiliser les variables dans différentes portées.
struct AppState { //1
...
}
impl AppState {
fn create() -> Arc<AppState> { //2
Arc::new(AppState { ... })
}
}
let app_state = AppState::create();
let app = Router::new()
.route("/persons", get(get_all))
.with_state(Arc::clone(&app_state)); //3
async fn get_all(State(state): State<Arc<AppState>>) -> Response { //4
... //5
}
- Créer le
struct
à partager. - Créer un nouveau
struct
enveloppé dans une référence atomique comptée. - Partagez la référence avec toutes les fonctions de routage, par exemple,
get_all
. - Passe le
state
. - Utilise le!
Sérialisation JSON automatisée
Les frameworks Web JVM modernes sérialisent automatiquement les objets en JSON avant de les envoyer. La bonne chose est que axum
fait de même. Il s’appuie sur Serde. Tout d’abord, nous ajoutons le serde
et serde_json
dépendances de caisse :
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
Ensuite, nous annotons notre struct
avec le derive(Serialize)
macro :
#[derive(Serialize)]
struct Person {
first_name: String,
last_name: String,
}
Enfin, nous retournons le struct
enveloppé dans un Json
et le code d’état HTTP dans un axum
Response
.
async fn get_test() -> impl IntoResponse { //1
let person = Person { //2
first_name: "John".to_string(),
last_name: "Doe".to_string()
};
(StatusCode::OK, Json(person)) //3
}
- Le tuple
(StatusCode, Json)
est automatiquement converti en unResponse
. - Créer le
Person
. - Retourne le tuple.
Lors de l’exécution, axum
sérialise automatiquement le struct
en JSON :
{"first_name":"Jane","last_name":"Doe"}
Accès à la base de données
Pendant longtemps, j’ai utilisé la base de données MySQL pour mes démos, mais j’ai commencé à lire beaucoup de bonnes choses sur PostgreSQL et j’ai décidé de changer. J’avais besoin d’une bibliothèque asynchrone compatible avec Tokio : c’est exactement ce que fait le crate tokio_postgres.
Le problème avec la caisse est qu’elle crée des connexions directes à la base de données. J’ai cherché une caisse de pool de connexion et je suis tombé sur deadpool
(sic):
Deadpool est un pool asynchrone simple et mort pour les connexions et les objets de tout type.
– Dead Pool
Deadpool propose deux implémentations distinctes :
- Un pool non géré : le développeur a le contrôle total – et la responsabilité – sur le cycle de vie des objets mis en pool.
- Un pool géré : La caisse crée et recycle les objets selon les besoins.
Des implémentations plus spécialisées de ce dernier s’adressent à différentes bases de données ou « pilotes », par exemple, Redis et … tokio-postgres
. On peut configurer Deadpool directement ou s’en remettre à la caisse de configuration qu’il prend en charge. Cette dernière caisse permet plusieurs alternatives de configuration :
Config organise des configurations hiérarchiques ou en couches pour les applications Rust.
Config vous permet de définir un ensemble de paramètres par défaut, puis de les étendre en fusionnant la configuration à partir de diverses sources :
- Variables d’environnement
- Littéraux de chaîne dans des formats bien connus
- Une autre instance de configuration
- Fichiers : TOML, JSON, YAML, INI, RON, JSON5 et fichiers personnalisés définis avec le trait Format
- Remplacement manuel, programmatique (via un
.set
méthode sur l’instance Config)De plus, Config prend en charge :
- Visualisation en direct et relecture des fichiers de configuration
- Accès approfondi à la configuration fusionnée via une syntaxe de chemin
- Désérialisation via serde de la configuration ou de tout sous-ensemble défini via un chemin
– Configuration de la caisse
Pour créer la configuration de base, il faut créer une structure dédiée et utiliser le crate :
#[derive(Deserialize)] //1
struct ConfigBuilder {
postgres: deadpool_postgres::Config, //2
}
impl ConfigBuilder {
async fn from_env() -> Result<Self, ConfigError> { //3
Config::builder()
.add_source(
Environment::with_prefix("POSTGRES") //4
.separator("_") //4
.keep_prefix(true) //5
.try_parsing(true),
)
.build()?
.try_deserialize()
}
}
let cfg_builder = ConfigBuilder::from_env().await.unwrap(); //6
- Le
Deserialize
macro est obligatoire. - Le champ devoir correspondre au préfixe d’environnement (voir ci-dessous).
- La fonction est
async
et renvoie unResult
. - Lire à partir des variables d’environnement dont le nom commence par
POSTGRES_
. - Conservez le préfixe dans la carte de configuration.
- Apprécier!
Notez que les variables d’environnement doivent être conformes à ce que Deadpool Config
attend. Voici ma configuration dans Docker Compose :
Variable d’environnement | Valeur |
---|---|
POSTGRES_HOST |
"postgres" |
POSTGRES_PORT |
5432 |
POSTGRES_USER |
"postgres" |
POSTGRES_PASSWORD |
"root" |
POSTGRES_DBNAME |
"app" |
Une fois que nous avons initialisé la configuration, nous pouvons créer le pool :
struct AppState {
pool: Pool, //1
}
impl AppState {
async fn create() -> Arc<AppState> { //2
let cfg_builder = ConfigBuilder::from_env().await.unwrap(); //3
let pool = cfg_builder //4
.postgres
.create_pool(
Some(deadpool_postgres::Runtime::Tokio1),
...