Techniques de programmation généralisées dans Rust: comment nous avons traduit Exonum de Iron en actix-web

L'écosystème de Rust n'est pas encore complètement installé. De nouvelles bibliothèques y apparaissent souvent, qui sont nettement meilleures que leurs prédécesseurs, et les frameworks précédemment populaires deviennent obsolètes. C'est exactement ce qui s'est produit avec le framework web Iron que nous avons utilisé lors du développement d'Exonum.

Actix-web a été choisi pour remplacer Iron. De plus, je dirai comment nous avons porté le code existant vers une nouvelle solution en utilisant des techniques de programmation généralisées.


Image de ulleo PD

Comment nous avons utilisé le fer


Chez Exonum, le framework Iron a été utilisé sans aucune abstraction. Nous avons installé des gestionnaires sur certaines ressources, reçu des paramètres de requête en analysant les URL à l'aide de méthodes auxiliaires et renvoyé le résultat simplement sous forme de chaîne.

Tout ressemblait à ceci:

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, certains modules complémentaires de middleware sous la forme d'en-têtes CORS ont été utilisés. Pour combiner tous les gestionnaires en une seule API, nous avons utilisé mount.

Pourquoi as-tu dû l'abandonner


Le fer était un bon cheval de bataille avec de nombreux ajouts. Cependant, il a été écrit dans ces temps lointains, lorsque des projets tels que les futurs et le tokio n'existaient pas.

L'architecture en fer permet un traitement synchrone des demandes, de sorte qu'il s'adapte facilement sur les omoplates avec un grand nombre de connexions ouvertes simultanément. Pour qu'Iron devienne évolutif, il doit être rendu asynchrone. Pour ce faire, il a fallu repenser et réécrire l'intégralité du framework, mais les développeurs ont progressivement abandonné le travail sur celui-ci.

Pourquoi nous sommes passés à Actix-Web


Il s'agit d'un framework populaire qui occupe une place de choix dans les benchmarks TechEmpower . Dans le même temps, contrairement à Iron, il se développe activement. Actix-web possède 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, et si le traitement conduit à la panique, l'acteur redémarre automatiquement.

Bien sûr, actix-web avait des défauts, par exemple, il contenait une grande quantité de code dangereux. Mais plus tard, il a été réécrit dans Safe Rust, ce qui a résolu ce problème.

Le passage à actix a résolu le problème de stabilité. Iron-backend pourrait être abandonné par un grand nombre de connexions. En général, la nouvelle API est une solution plus simple, plus productive et unifiée. Il deviendra plus facile pour les utilisateurs et les développeurs d'utiliser l'interface logicielle, et sa vitesse augmentera.

Ce que nous voulons d'un framework web


Il était important pour nous non seulement de changer Iron en actix-web, mais de jeter les bases de l'avenir - d'élaborer une nouvelle architecture d'API pour l'abstrait d'un cadre Web spécifique. Cela vous permettra de créer des gestionnaires, presque sans penser aux spécificités du Web et de les transférer vers n'importe quel backend. Cela peut être fait en écrivant une interface qui fonctionnerait sur les types et types de base.

Pour comprendre à quoi ressemble cette interface, définissons ce qu'est une API HTTP:

  • Les demandes sont faites exclusivement par les clients et le serveur n'y répond que (il n'agit pas en tant qu'initiateur).
  • Les demandes sont lues et modifiées.
  • À la suite de la requête, le serveur renvoie une réponse qui contient les données souhaitées en cas de succès et, en cas d'erreur, des informations à ce sujet.

Si nous analysons 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'ignorer l'implémentation spécifique du framework web, nous devons écrire des gestionnaires dans un style similaire à l'exemple ci-dessus.

Caractéristique de point de terminaison pour le traitement généralisé des requêtes HTTP

Vous pouvez suivre la voie la plus simple et la plus directe et déclarer un trait de point final,
décrivant l'implémentation de requêtes spécifiques:

 // ,   GET .      //    ,      . //         . trait Endpoint: Sync + Send + 'static { type Request: DeserializeOwned + 'static; type Response: Serialize + 'static; fn handle(&self, context: &Context, request: Self::Request) -> Result<Self::Response, io::Error>; } 

Après cela, vous devrez implémenter ce gestionnaire dans un cadre spécifique. Disons que pour actix-web, cela ressemble à ceci:

 //    actix-web.  ,   , //  `Endpoint`   . type FutureResponse = actix_web::FutureResponse<HttpResponse, actix_web::Error>; // «»    actix-web.      //   .     , //     . type RawHandler = dyn Fn(HttpRequest<Context>) -> FutureResponse + 'static + Send + Sync; //   ,     ,     . #[derive(Clone)] struct RequestHandler { ///  . pub name: String, /// HTTP . pub method: actix_web::http::Method, ///  .  ,       . pub inner: Arc<RawHandler>, } 

Vous pouvez utiliser des structures pour passer des paramètres de demande dans 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 de la forme suivante:

 #[derive(Deserialize)] struct SimpleQuery { a: i32, b: String, } 

Ceci est cohérent avec le type de demande associé de la caractéristique Endpoint.

Écrivons maintenant un adaptateur qui encapsule une implémentation Endpoint spécifique dans RequstHandler pour actix-web. Veuillez noter que les informations sur les types de demande et de réponse sont perdues au cours du processus. Cette technique est appelée effacement de type. Sa tâche consiste à transformer la planification 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, vous pouvez ajouter des gestionnaires pour les requêtes POST et arrêter, car nous avons créé un trait qui est abstrait des détails de l'implémentation. Cependant, il n'est toujours pas trop ergonomique.

Problèmes de type

Lors de l'écriture d'un gestionnaire, beaucoup de code auxiliaire est généré:

 //    . struct ElementCountEndpoint { elements: Rc<RefCell<Vec<Something>>>, } //   Endpoint. impl Endpoint for ElementCountEndpoint { type Request = (); type Result = usize; fn handle(&self, context: &Context, _request: ()) -> Result<usize, io::Error> { Ok(self.elements.borrow().len()) } } //    . let endpoint = ElementCountEndpoint::new(elements.clone()); let handler = RequestHandler::from_endpoint("/v1/element_count", endpoint); actix_backend.endpoint(handler); 

Idéalement, j'aimerais pouvoir passer une fermeture normale en tant que gestionnaire, en réduisant la quantité de bruit de syntaxe d'un ordre de grandeur:

 let elements = elements.clone(); actix_backend.endpoint("/v1/elements_count", move || {   Ok(elements.borrow().len()) }); 

Je parlerai de la façon de procéder plus tard.

Immersion facile dans la programmation générale


Nous devons réaliser la capacité de générer automatiquement un adaptateur qui implémente Endpoint avec les types associés corrects. Dans ce cas, seule une fermeture avec le gestionnaire de requêtes HTTP sera envoyée à l'entrée.

Les arguments et le résultat de la fermeture peuvent être de différents types, vous devez donc ici travailler avec une surcharge de méthode. Rust ne prend pas directement en charge la surcharge, mais permet de l'émuler en utilisant les traits Into et From.

En outre, le type de valeur de retour de la fermeture ne doit pas nécessairement correspondre à la valeur de retour de l'implémentation du point de terminaison. Pour manipuler ce type, il doit être extrait du type de fermeture reçu.

Extraction de types à partir du type Fn

Dans Rust, chaque fermeture a son propre type unique, qui ne peut pas être explicitement écrit dans le programme. Pour manipuler les fermetures, il existe un type Fn. Il contient une signature de fonction avec des types d'arguments et une valeur de retour, cependant, les extraire individuellement n'est pas si simple.

L'idée principale est d'utiliser une structure auxiliaire de la forme suivante:

 ///       F: Fn(A) -> B. struct SimpleExtractor<A, B, F> {   //   .   inner: F,   _a: PhantomData<A>,   _b: PhantomData<B>, } 

Nous sommes obligés d'utiliser PhantomData car Rust nécessite que tous les paramètres de généralisation soient dans la définition de la structure. Cependant, le type spécifique de fermeture ou de fonction F n'est pas généralisé (bien qu'il implémente le type généralisé Fn). Les paramètres de type A et B n'y sont pas directement utilisés.

C'est cette limitation du système de type Rust qui ne permet pas l'utilisation d'une stratégie plus simple - pour implémenter 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 ce cas, 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 d'aide de SimpleExtractor permet de décrire la transformation From. Il vous 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, }; //   . fn my_handler(_: &Context, q: Query) -> String { format!("{} has {} apples.", qb, qa) } let fn_extractor = SimpleExtractor::from(my_handler); //  . let c = 15; let my_closure = |_: &Context, q: Query| -> String { format!("{} has {} apples, but Alice has {}", qb, qa, c) }; let closure_extractor = SimpleExtractor::from(my_closure); 

Spécialisation et types de marqueurs

Nous avons maintenant une fonction avec des types d'arguments explicitement paramétrés, pouvant être utilisés à la place du trait Endpoint. Par exemple, nous pouvons facilement implémenter la conversion de SimpleExtractor en RequestHandler. Mais encore, ce n'est pas une solution complète. Nous devons également faire la distinction entre les gestionnaires de requêtes GET et les requêtes POST au niveau du type (et les gestionnaires synchrones des gestionnaires asynchrones). Les soi-disant types de marqueurs nous y aideront.

Tout d'abord, nous réécrivons SimpleExtractor afin qu'il puisse faire la distinction entre les résultats synchrones et asynchrones. Dans le même temps, nous implémentons le trait From pour chacun des cas. Notez que les traits peuvent être implémentés pour des variantes spécifiques de structures généralisées.

 ///   HTTP-. pub struct With<Q, I, R, F> { ///  -. pub handler: F, ///     . _query_type: PhantomData<Q>, ///   . _item_type: PhantomData<I>, ///  ,  . ///  ,       . _result_type: PhantomData<R>, } //   ,   . impl<Q, I, F> From<F> for With<Q, I, Result<I>, F> where F: Fn(&ServiceApiState, Q) -> Result<I>, { fn from(handler: F) -> Self { Self { handler, _query_type: PhantomData, _item_type: PhantomData, _result_type: PhantomData, } } } //     . impl<Q, I, F> From<F> for With<Q, I, FutureResult<I>, F> where F: Fn(&ServiceApiState, Q) -> FutureResult<I>, { fn from(handler: F) -> Self { Self { handler, _query_type: PhantomData, _item_type: PhantomData, _result_type: PhantomData, } } } 

Maintenant, nous devons déclarer une structure dans laquelle combiner le gestionnaire de requêtes avec son nom et sa variété:

 #[derive(Debug)] pub struct NamedWith<Q, I, R, F, K> {   ///  .   pub name: String,   ///    .   pub inner: With<Q, I, R, F>,   ///  .   _kind: PhantomData<K>, } 

Après, vous pouvez déclarer plusieurs structures vides qui agiront comme types de marqueurs. Les marqueurs vous permettent d'implémenter pour chacun des gestionnaires son propre code de conversion vers le RequestHandler décrit précédemment.

 /// ,    .  HTTP   GET-. pub struct Immutable; /// ,   .  HTTP   POST, PUT, UPDATE ///    ,        POST. pub struct Mutable; 

Nous pouvons maintenant définir quatre implémentations différentes du type From pour toutes les combinaisons des paramètres de modèle R et K (valeur de retour du gestionnaire et type de demande).

 //     get . impl<Q, I, F> From<NamedWith<Q, I, Result<I>, F, Immutable>> for RequestHandler where F: Fn(&ServiceApiState, Q) -> Result<I> + 'static + Send + Sync + Clone, Q: DeserializeOwned + 'static, I: Serialize + 'static, { fn from(f: NamedWith<Q, I, Result<I>, F, Immutable>) -> Self { let handler = f.inner.handler; let index = move |request: HttpRequest| -> FutureResponse { let context = request.state(); let future = Query::from_request(&request, &()) .map(|query: Query<Q>| query.into_inner()) .and_then(|query| handler(context, query).map_err(From::from)) .and_then(|value| Ok(HttpResponse::Ok().json(value))) .into_future(); Box::new(future) }; Self { name: f.name, method: actix_web::http::Method::GET, inner: Arc::from(index) as Arc<RawHandler>, } } } //     post . impl<Q, I, F> From<NamedWith<Q, I, Result<I>, F, Mutable>> for RequestHandler where F: Fn(&ServiceApiState, Q) -> Result<I> + 'static + Send + Sync + Clone, Q: DeserializeOwned + 'static, I: Serialize + 'static, { fn from(f: NamedWith<Q, I, Result<I>, F, Mutable>) -> Self { let handler = f.inner.handler; let index = move |request: HttpRequest| -> FutureResponse { let handler = handler.clone(); let context = request.state().clone(); request .json() .from_err() .and_then(move |query: Q| { handler(&context, query) .map(|value| HttpResponse::Ok().json(value)) .map_err(From::from) }) .responder() }; Self { name: f.name, method: actix_web::http::Method::POST, inner: Arc::from(index) as Arc<RawHandler>, } } } //     get . impl<Q, I, F> From<NamedWith<Q, I, FutureResult<I>, F, Immutable>> for RequestHandler where F: Fn(&ServiceApiState, Q) -> FutureResult<I> + 'static + Clone + Send + Sync, Q: DeserializeOwned + 'static, I: Serialize + 'static, { fn from(f: NamedWith<Q, I, FutureResult<I>, F, Immutable>) -> Self { let handler = f.inner.handler; let index = move |request: HttpRequest| -> FutureResponse { let context = request.state().clone(); let handler = handler.clone(); Query::from_request(&request, &()) .map(move |query: Query<Q>| query.into_inner()) .into_future() .and_then(move |query| handler(&context, query).map_err(From::from)) .map(|value| HttpResponse::Ok().json(value)) .responder() }; Self { name: f.name, method: actix_web::http::Method::GET, inner: Arc::from(index) as Arc<RawHandler>, } } } //     post . impl<Q, I, F> From<NamedWith<Q, I, FutureResult<I>, F, Mutable>> for RequestHandler where F: Fn(&ServiceApiState, Q) -> FutureResult<I> + 'static + Clone + Send + Sync, Q: DeserializeOwned + 'static, I: Serialize + 'static, { fn from(f: NamedWith<Q, I, FutureResult<I>, F, Mutable>) -> Self { let handler = f.inner.handler; let index = move |request: HttpRequest| -> FutureResponse { let handler = handler.clone(); let context = request.state().clone(); request .json() .from_err() .and_then(move |query: Q| { handler(&context, query) .map(|value| HttpResponse::Ok().json(value)) .map_err(From::from) }) .responder() }; Self { name: f.name, method: actix_web::http::Method::POST, inner: Arc::from(index) as Arc<RawHandler>, } } } 

"Façade" pour le backend

Maintenant, pour tout cela, il reste à écrire une «façade», qui prendrait des fermetures et les ajouterait au backend correspondant. Dans notre cas, il n'y a qu'un seul backend - actix-web - mais derrière la façade, vous pouvez masquer toutes les implémentations supplémentaires que vous aimez, par exemple, un générateur de spécifications Swagger.

 pub struct ServiceApiScope { actix_backend: actix::ApiBuilder, } impl ServiceApiScope { ///    Immutable    . pub fn endpoint<Q, I, R, F, E>(&mut self, name: &'static str, endpoint: E) -> &mut Self where //     ,      : Q: DeserializeOwned + 'static, I: Serialize + 'static, F: Fn(&ServiceApiState, Q) -> R + 'static + Clone, E: Into<With<Q, I, R, F>>, //  ,          //  NamedWith  RequestHandler. RequestHandler: From<NamedWith<Q, I, R, F, Immutable>>, { self.actix_backend.endpoint(name, endpoint); self } ///    Mutable . pub fn endpoint_mut<Q, I, R, F, E>(&mut self, name: &'static str, endpoint: E) -> &mut Self where Q: DeserializeOwned + 'static, I: Serialize + 'static, F: Fn(&ServiceApiState, Q) -> R + 'static + Clone, E: Into<With<Q, I, R, F>>, RequestHandler: From<NamedWith<Q, I, R, F, Mutable>>, { self.actix_backend.endpoint_mut(name, endpoint); self } 

Notez comment les types de paramètres de requête, le type de son résultat, ainsi que le synchronisme / asynchronie du gestionnaire sont automatiquement dérivés de sa signature. De plus, vous devez spécifier explicitement le nom de la demande, ainsi que son type.

Inconvénients de l'approche


Cette approche a encore ses inconvénients. En particulier, endpoint et endpoint_mut doivent connaître les spécificités de l'implémentation de backends spécifiques . Cela ne nous permet pas d'ajouter des backends à la volée, mais de telles fonctionnalités sont rarement nécessaires.

Un autre problème est que vous ne pouvez pas définir de spécialisation pour un gestionnaire sans arguments supplémentaires. En d'autres termes, si nous écrivons le code suivant, il ne sera pas compilé, car il entre en conflit avec l'implémentation généralisée 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 une chaîne JSON nulle, qui est désérialisée en (). Ce problème pourrait être résolu par une spécialisation dans le style de C ++, mais jusqu'à présent, il n'est disponible que dans la version nocturne du compilateur et on ne sait pas quand il "se stabilise".

De même, vous ne pouvez pas spécialiser le type de la valeur de retour. Même si la demande ne l'implique pas, elle renverra toujours JSON avec null.

Le décodage des requêtes URL dans les requêtes GET impose également des restrictions non évidentes sur le type de paramètres, mais ce sont déjà des fonctionnalités de l'implémentation de serde-urlencoded.

Conclusion


Ainsi, nous avons implémenté une API qui vous permet de créer facilement et facilement des gestionnaires, presque sans penser aux spécificités du Web. Plus tard, ils peuvent être transférés vers n'importe quel backend ou même utiliser plusieurs backends en même temps.

Source: https://habr.com/ru/post/fr429450/


All Articles