L'écosystème de Rust est toujours en croissance. En conséquence, de nouvelles bibliothèques avec des fonctionnalités améliorées sont fréquemment publiées dans la communauté des développeurs, tandis que les anciennes bibliothèques deviennent obsolètes. Lorsque nous avons initialement conçu Exonum, nous avons utilisé le framework Web Iron. Dans cet article, nous décrivons comment nous avons porté le framework Exonum sur actix-web en utilisant une programmation générique.
Exonum sur fer
Dans la plateforme Exonum, le framework Iron a été utilisé sans aucune abstraction. Nous avons installé des gestionnaires pour certaines ressources et obtenu des paramètres de demande en analysant les URL à l'aide de méthodes auxiliaires; le résultat a été renvoyé simplement sous la forme d'une chaîne.
Le processus ressemblait (approximativement) à ce qui suit:
fn set_blocks_response(self, router: &mut Router) { let blocks = move |req: &mut Request| -> IronResult<Response> { let count: usize = self.required_param(req, "count")?; let latest: Option<u64> = self.optional_param(req, "latest")?; let skip_empty_blocks: bool = self.optional_param(req, "skip_empty_blocks")? .unwrap_or(false); let info = self.blocks(count, latest.map(Height), skip_empty_blocks)?; self.ok_response(&::serde_json::to_value(info).unwrap()) }; router.get("/v1/blocks", blocks, "blocks"); }
De plus, nous avons utilisé certains plugins middleware sous la forme d'en-têtes CORS. Nous avons utilisé mount pour fusionner tous les gestionnaires en une seule API.
Notre décision de nous éloigner du fer
Iron était une bonne bibliothèque, avec de nombreux plugins. Cependant, il a été écrit à l'époque où des projets tels que futures et
tokio n'existaient pas.
L'architecture d'Iron implique un traitement synchrone des requêtes, qui peut être facilement affecté par un grand nombre de connexions ouvertes simultanément. Pour être évolutif, Iron devait devenir asynchrone, ce qui impliquerait de repenser et de réécrire l'ensemble du framework. En conséquence, nous avons constaté un abandon progressif de l'utilisation d'Iron par les ingénieurs logiciels.
Pourquoi nous avons choisi Actix-Web
Actix-web est un framework populaire qui occupe une place de
choix dans les benchmarks TechEmpower . Il a une communauté de développeurs active, contrairement à Iron, et il a une API bien conçue et une implémentation de haute qualité basée sur le framework d'actix actix. Les demandes sont traitées de manière asynchrone par le pool de threads; si le traitement des demandes panique, l'acteur est automatiquement redémarré.
Auparavant, des inquiétudes ont été exprimées quant au fait qu'actix-web contenait beaucoup de code dangereux. Cependant, la quantité de code non sécurisé a été considérablement réduite lorsque le framework a été réécrit dans un langage de programmation sûr - Rust. Les ingénieurs de Bitfury ont eux-mêmes revu ce code et ont confiance en sa stabilité à long terme.
Pour le framework Exonum, le passage à actix a résolu le problème de la stabilité des opérations. Le framework Iron pourrait échouer s'il y avait un grand nombre de connexions. Nous avons également constaté que l'API actix-web est plus simple, plus productive et plus unifiée. Nous sommes convaincus que les utilisateurs et les développeurs auront plus de facilité à utiliser l'interface de programmation Exonum, qui peut désormais fonctionner plus rapidement grâce à la conception d'actix-web.
Ce que nous exigeons d'un framework Web
Au cours de ce processus, nous avons réalisé qu'il était important pour nous de ne pas simplement changer de framework, mais également de concevoir une nouvelle architecture API indépendante de tout framework web spécifique. Une telle architecture permettrait de créer des gestionnaires, sans se soucier des spécificités Web et de les transférer vers n'importe quel backend. Cette conception peut être mise en œuvre en écrivant un frontend qui appliquerait les types et les traits de base.
Pour comprendre à quoi doit ressembler cette interface, définissons ce qu'est vraiment une API HTTP:
- Les demandes sont faites exclusivement par les clients; le serveur n'y répond que (le serveur n'initie pas de requêtes).
- Demande de lire ou de modifier des données.
- À la suite du traitement de la demande, le serveur renvoie une réponse, qui contient les données requises, en cas de succès; ou des informations sur l'erreur, en cas d'échec.
Si nous devons analyser toutes les couches d'abstraction, il s'avère que toute requête HTTP n'est qu'un appel de fonction:
fn request(context: &ServiceContext, query: Query) -> Result<Response, ServiceError>
Tout le reste peut être considéré comme une extension de cette entité de base. Ainsi, afin d'être indépendant d'une implémentation spécifique d'un framework web, nous devons écrire des gestionnaires dans un style similaire à l'exemple ci-dessus.
Trait «Endpoint» pour le traitement générique des requêtes HTTP
L'approche la plus simple et la plus directe serait de déclarer le trait «Endpoint», qui décrit les implémentations de requêtes spécifiques:
Nous devons maintenant implémenter ce gestionnaire dans un cadre spécifique. Par exemple, dans actix-web, cela ressemble à ceci:
Nous pouvons utiliser des structures pour passer des paramètres de demande à travers le contexte. Actix-web peut désérialiser automatiquement les paramètres à l'aide de serde. Par exemple, a = 15 & b = hello est désérialisé en une structure comme celle-ci:
#[derive(Deserialize)] struct SimpleQuery { a: i32, b: String, }
Cette fonctionnalité de désérialisation s'accorde bien avec le type de requête associé du trait `Endpoint`.
Ensuite, imaginons un adaptateur qui encapsule une implémentation spécifique de «Endpoint» dans un RequestHandler pour actix-web. Faites attention au fait que, ce faisant, les informations sur les types de demande et de réponse disparaissent. Cette technique est appelée effacement de type - elle transforme la répartition statique en dynamique.
impl RequestHandler { fn from_endpoint<E: Endpoint>(name: &str, endpoint: E) -> RequestHandler { let index = move |request: HttpRequest<Context>| -> FutureResponse { let context = request.state(); let future = Query::from_request(&request, &()) .map(|query: Query<E::Request>| query.into_inner()) .and_then(|query| endpoint.handle(context, query).map_err(From::from)) .and_then(|value| Ok(HttpResponse::Ok().json(value))) .into_future(); Box::new(future) }; Self { name: name.to_owned(), method: actix_web::http::Method::GET, inner: Arc::from(index) as Arc<RawHandler>, } } }
À ce stade, il suffirait d'ajouter des gestionnaires pour les requêtes POST, car nous avons créé un trait indépendant des détails de l'implémentation. Cependant, nous avons constaté que cette solution n'était pas assez avancée.
Les inconvénients du caractère «Endpoint»
Une grande quantité de code auxiliaire est générée lorsqu'un gestionnaire est écrit:
Idéalement, nous devons pouvoir passer une simple fermeture en tant que gestionnaire, réduisant ainsi considérablement la quantité de bruit syntaxique.
let elements = elements.clone(); actix_backend.endpoint("/v1/elements_count", move || { Ok(elements.borrow().len()) });
Ci-dessous, nous verrons comment cela peut être fait.
Immersion légère dans la programmation générique
Nous devons ajouter la possibilité de générer automatiquement un adaptateur qui implémente le trait `Endpoint` avec les types associés corrects. L'entrée consistera uniquement en une fermeture avec un gestionnaire de requêtes HTTP.
Les arguments et le résultat de la fermeture peuvent avoir différents types, nous devons donc travailler avec des méthodes de surcharge ici. Rust ne prend pas directement en charge la surcharge mais permet de l'émuler en utilisant les traits `Into` et` From`.
De plus, le type renvoyé de la valeur de fermeture ne doit pas nécessairement correspondre à la valeur renvoyée de l'implémentation `Endpoint`. Pour manipuler ce type, il doit être extrait du type de la fermeture reçue.
Récupération des types à partir du caractère `Fn`
À Rust, chaque fermeture a son propre type unique, qui ne peut pas être explicitement indiqué dans le programme. Pour les manipulations avec fermetures, nous utilisons le trait `Fn`. Le trait contient la signature de la fonction avec les types d'arguments et de la valeur retournée, cependant, récupérer ces éléments séparément n'est pas facile à faire.
L'idée principale est d'utiliser une structure auxiliaire de la forme suivante:
Nous devons utiliser PhantomData, car Rust exige que tous les paramètres génériques soient indiqués dans la définition de la structure. Cependant, le type de fermeture ou de fonction F lui-même n'est pas générique (bien qu'il implémente un trait générique "Fn"). Les paramètres de type A et B n'y sont pas directement utilisés.
C'est cette restriction du système de type Rust qui nous empêche d'appliquer une stratégie plus simple en implémentant directement le trait `Endpoint` pour les fermetures:
impl<A, B, F> Endpoint for F where F: Fn(&Context, A) -> B { type Request = A; type Response = B; fn handle(&self, context: &Context, request: A) -> Result<B, io::Error> {
Dans le cas ci-dessus, le compilateur renvoie une erreur:
error[E0207]: the type parameter `A` is not constrained by the impl trait, self type, or predicates --> src/main.rs:10:6 | 10 | impl<A, B, F> Endpoint for F where F: Fn(&Context, A) -> B { | ^ unconstrained type parameter
La structure auxiliaire SimpleExtractor permet de décrire la conversion de `From`. Cette conversion nous permet d'enregistrer n'importe quelle fonction et d'extraire les types de ses arguments:
impl<A, B, F> From<F> for SimpleExtractor<A, B, F> where F: Fn(&Context, A) -> B, A: DeserializeOwned, B: Serialize, { fn from(inner: F) -> Self { SimpleExtractor { inner, _a: PhantomData, _b: PhantomData, } } }
Le code suivant se compile avec succès:
#[derive(Deserialize)] struct Query { a: i32, b: String, };
Spécialisation et types de marqueurs
Nous avons maintenant une fonction avec des types d'arguments explicitement paramétrés, qui peuvent être utilisés à la place du trait `Endpoint`. Par exemple, nous pouvons facilement implémenter la conversion de SimpleExtractor en RequestHandler. Pourtant, ce n'est pas une solution complète. Nous devons en quelque sorte faire la distinction entre les gestionnaires pour les demandes GET et POST au niveau du type (et entre les gestionnaires synchrones et asynchrones). Dans cette tâche, les types de marqueurs nous sont utiles.
Tout d'abord, réécrivons SimpleExtractor afin qu'il puisse faire la distinction entre les résultats synchrones et asynchrones. Dans le même temps, nous mettrons en œuvre le trait «De» pour chacun des cas. Notez que les traits peuvent être implémentés pour des variantes spécifiques de structures génériques.
Maintenant, nous devons déclarer la structure qui combinera le gestionnaire de requêtes avec son nom et son type:
#[derive(Debug)] pub struct NamedWith<Q, I, R, F, K> {
Ensuite, nous déclarons plusieurs structures vides qui agiront comme types de marqueurs. Les marqueurs nous permettront d'implémenter pour chaque gestionnaire leur propre code pour convertir le gestionnaire en RequestHandler décrit précédemment.
Nous pouvons maintenant définir quatre implémentations différentes du trait `From` pour toutes les combinaisons de paramètres de modèle R et K (la valeur retournée du gestionnaire et le type de la demande).
Façade pour le backend
La dernière étape consiste à concevoir une façade qui accepterait les fermetures et les ajouterait dans le backend correspondant. Dans le cas donné, nous avons un seul backend - actix-web. Cependant, il existe un potentiel de mise en œuvre supplémentaire derrière la façade. Par exemple: un générateur de spécifications Swagger.
pub struct ServiceApiScope { actix_backend: actix::ApiBuilder, } impl ServiceApiScope {
Notez comment les types des paramètres de demande, le type du résultat de la demande et la synchronisation / asynchronie du gestionnaire sont dérivés automatiquement de sa signature. De plus, nous devons spécifier explicitement le nom et le type de la demande.
Inconvénients de l'approche
L'approche décrite ci-dessus, bien qu'elle soit assez efficace, a ses inconvénients. En particulier, les
méthodes endpoint et endpoint_mut devraient prendre en compte les particularités d'implémentation de backends spécifiques . Cette restriction nous empêche d'ajouter des backends lors de vos déplacements, bien que cette fonctionnalité soit rarement requise.
Un autre problème est que
nous ne pouvons pas définir la spécialisation d'un gestionnaire sans arguments supplémentaires . En d'autres termes, si nous écrivons le code suivant, il ne sera pas compilé car il est en conflit avec l'implémentation générique existante:
impl<(), I, F> From<F> for With<(), I, Result<I>, F> where F: Fn(&ServiceApiState) -> Result<I>, { fn from(handler: F) -> Self { Self { handler, _query_type: PhantomData, _item_type: PhantomData, _result_type: PhantomData, } } }
Par conséquent, les demandes qui n'ont aucun paramètre doivent toujours accepter la chaîne JSON null, qui est désérialisée en (). Ce problème pourrait être résolu par une spécialisation dans le style C ++, mais pour l'instant il n'est disponible que dans la version nocturne du compilateur et il n'est pas clair quand il deviendra une fonctionnalité stable.
De même,
le type de la valeur retournée ne peut pas être spécialisé . Même si la demande n'implique pas un certain type de la valeur retournée, elle passera quand même JSON avec null.
Le décodage de la requête URL dans les requêtes GET impose également des restrictions non évidentes sur le type de paramètres , mais ce problème concerne plutôt les particularités de l'implémentation serde-urlencoded.
Conclusion
Comme décrit ci-dessus, nous avons mis en œuvre une API améliorée, qui permet une création simple et claire des gestionnaires, sans avoir à se soucier des spécificités du Web. Ces gestionnaires peuvent fonctionner avec n'importe quel backend ou même avec plusieurs backends simultanément.