Técnicas de programación generalizadas en Rust: cómo traducimos Exonum de Iron a actix-web

El ecosistema de Rust aún no se ha asentado por completo. A menudo aparecen nuevas bibliotecas, que son notablemente mejores que sus predecesoras, y los marcos previamente populares se vuelven obsoletos. Esto es exactamente lo que sucedió con el framework web Iron que utilizamos al desarrollar Exonum.

Actix-web fue elegido como reemplazo de Iron. Además, contaré cómo portamos el código existente a una nueva solución utilizando técnicas de programación generalizadas.


Imagen de ulleo PD

Cómo usamos hierro


En Exonum, el marco de hierro se utilizó sin abstracciones. Instalamos manejadores en ciertos recursos, recibimos parámetros de consulta al analizar URL usando métodos auxiliares y devolvimos el resultado simplemente como una cadena.

Todo se parecía a esto:

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"); } 

Además, se utilizaron algunos complementos de middleware en forma de encabezados CORS. Para combinar todos los controladores en una sola API, utilizamos mount.

¿Por qué tuviste que abandonarlo?


El hierro era un buen caballo de batalla con muchas adiciones. Sin embargo, fue escrito en aquellos tiempos lejanos, cuando no existían proyectos como futuros y tokio .

La arquitectura de hierro proporciona un procesamiento de solicitud síncrono, por lo que se ajusta fácilmente en los omóplatos con una gran cantidad de conexiones abiertas simultáneamente. Para que Iron sea escalable, debe hacerse asíncrono. Para hacer esto, era necesario repensar y reescribir todo el marco, pero los desarrolladores gradualmente abandonaron el trabajo en él.

¿Por qué cambiamos a actix-web?


Este es un marco popular que ocupa un lugar destacado en los puntos de referencia TechEmpower . Al mismo tiempo, él, a diferencia de Iron, se está desarrollando activamente. Actix-web tiene una API bien diseñada y una implementación de alta calidad basada en el marco del actor actix. El grupo de subprocesos procesa las solicitudes de forma asíncrona y, si el procesamiento genera pánico, el actor se reinicia automáticamente.

Por supuesto, actix-web tenía fallas, por ejemplo, contenía una gran cantidad de código inseguro. Pero luego fue reescrito en Safe Rust, que resolvió este problema.

El cambio a actix resolvió el problema de estabilidad. El backend de hierro podría caerse por una gran cantidad de conexiones. En general, la nueva API es una solución más simple, más productiva y unificada. Será más fácil para los usuarios y desarrolladores usar la interfaz del software, y su velocidad aumentará.

Lo que queremos de un framework web


Fue importante para nosotros no solo cambiar Iron a actix-web, sino también crear una base para el futuro: elaborar una nueva arquitectura API para abstraernos de un marco web específico. Esto le permitirá crear controladores, casi sin pensar en los detalles de la web y transferirlos a cualquier backend. Esto se puede hacer escribiendo una interfaz que opere en tipos y tipos básicos.

Para comprender cómo se ve esta interfaz, definamos qué es una API HTTP:

  • Las solicitudes las realizan exclusivamente los clientes, y el servidor solo las responde (no actúa como iniciador).
  • Las solicitudes son leídas y modificadas.
  • Como resultado de la consulta, el servidor devuelve una respuesta en la que, si tiene éxito, se contienen los datos deseados y, en caso de error, información al respecto.

Si analizamos todas las capas de abstracción, resulta que cualquier solicitud HTTP es solo una llamada de función:

 fn request(context: &ServiceContext, query: Query) -> Result<Response, ServiceError> 

Todo lo demás puede considerarse extensiones de esta entidad básica. Por lo tanto, para ignorar la implementación específica del marco web, necesitamos escribir controladores en un estilo similar al ejemplo anterior.

Rasgo de punto final para el procesamiento de solicitudes HTTP generalizadas

Puede seguir el camino más simple y directo y declarar un rasgo de punto final,
Describiendo la implementación de consultas específicas:

 // ,   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>; } 

Después de eso, deberá implementar este controlador en un marco específico. Digamos que para actix-web se ve así:

 //    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>, } 

Puede usar estructuras para pasar parámetros de solicitud a través del contexto. Actix-web puede deserializar parámetros automáticamente usando serde. Por ejemplo, a = 15 & b = hola se deserializa en una estructura de la siguiente forma:

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

Esto es coherente con el tipo de solicitud asociado del rasgo de punto final.

Ahora escribamos un adaptador que envuelva una implementación específica de Endpoint en RequstHandler para actix-web. Tenga en cuenta que la información sobre los tipos de Solicitud y Respuesta se pierde en el proceso. Esta técnica se llama borrado de tipo. Su tarea es convertir la programación estática en dinámica.

 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>, } } } 

En esta etapa, puede agregar controladores para solicitudes POST y detenerlos, ya que creamos un rasgo que se abstrae de los detalles de implementación. Sin embargo, todavía no es demasiado ergonómico.

Problemas de tipo

Al escribir un controlador, se genera una gran cantidad de código auxiliar:

 //    . 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); 

Idealmente, me gustaría poder pasar un cierre normal como controlador, reduciendo la cantidad de ruido de sintaxis en un orden de magnitud:

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

Hablaré sobre cómo hacer esto más tarde.

Fácil inmersión en programación general.


Necesitamos darnos cuenta de la capacidad de generar automáticamente un adaptador que implemente Endpoint con los tipos asociados correctos. En este caso, solo se enviará a la entrada un cierre con el controlador de solicitud HTTP.

Los argumentos y el resultado del cierre pueden ser de varios tipos, por lo que aquí debe trabajar con la sobrecarga de métodos. Rust no admite la sobrecarga directamente, pero permite que se emule utilizando los rasgos Into y From.

Además, el tipo de valor de retorno del cierre no tiene que coincidir con el valor de retorno de la implementación de Endpoint. Para manipular este tipo, debe extraerse del tipo de cierre recibido.

Extracción de tipos del tipo Fn

En Rust, cada cierre tiene su propio tipo único, que no se puede escribir explícitamente en el programa. Para manipular cierres, hay un tipo Fn. Contiene una firma de función con tipos de argumentos y valor de retorno, sin embargo, extraerlos individualmente no es tan simple.

La idea principal es utilizar una estructura auxiliar de la siguiente forma:

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

Nos vemos obligados a usar PhantomData ya que Rust requiere que todos los parámetros de generalización estén en la definición de la estructura. Sin embargo, el tipo específico de cierre o función F no está generalizado (aunque implementa el tipo generalizado Fn). Los parámetros de tipo A y B no se usan directamente en él.

Es esta limitación del sistema de tipo Rust lo que no permite el uso de una estrategia más simple: implementar el rasgo Endpoint para cierres directamente:

 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> { // ... } } 

El compilador en este caso devuelve un error:

 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 estructura auxiliar de SimpleExtractor le permite describir la transformación From. Le permite guardar cualquier función y extraer los tipos de sus argumentos:

 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, } } } 

El siguiente código se compila correctamente:

 #[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); 

Especialización y tipos de marcadores

Ahora tenemos una función con tipos de argumento con parámetros explícitos, adecuada para su uso en lugar del rasgo de punto final. Por ejemplo, podemos implementar fácilmente la conversión de SimpleExtractor a RequestHandler. Pero aún así, esta no es una solución completa. También necesitamos distinguir de alguna manera entre los manejadores de solicitudes GET y las solicitudes POST a nivel de tipo (y los manejadores síncronos de los asíncronos). Los llamados tipos de marcadores nos ayudarán con esto.

Primero, reescribimos SimpleExtractor para que pueda distinguir entre resultados síncronos y asíncronos. Al mismo tiempo, implementamos el rasgo From para cada uno de los casos. Tenga en cuenta que los rasgos se pueden implementar para variantes específicas de estructuras generalizadas.

 ///   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, } } } 

Ahora necesitamos declarar una estructura en la cual combinar el manejador de solicitudes con su nombre y variedad:

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

Después puede declarar varias estructuras vacías que actuarán como tipos de marcadores. Los marcadores le permiten implementar para cada controlador su propio código de conversión al RequestHandler descrito anteriormente.

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

Ahora podemos definir cuatro implementaciones diferentes del tipo From para todas las combinaciones de los parámetros de plantilla R y K (valor de retorno del manejador y tipo de solicitud).

 //     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>, } } } 

"Fachada" para el backend

Ahora, para todo esto, queda escribir una "fachada", que tomaría cierres y los agregaría al backend correspondiente. En nuestro caso, solo hay un backend, actix-web, pero detrás de la fachada puede ocultar cualquier implementación adicional que desee, por ejemplo, un generador de especificaciones 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 } 

Observe cómo los tipos de parámetros de solicitud, el tipo de su resultado, así como el sincronismo / asincronía del controlador se derivan automáticamente de su firma. Además, debe especificar explícitamente el nombre de la solicitud, así como su tipo.

Desventajas del enfoque


Este enfoque todavía tiene sus inconvenientes. En particular, endpoint y endpoint_mut deben conocer los detalles de la implementación de backends específicos . Esto no nos permite agregar backends sobre la marcha, pero rara vez se necesita dicha funcionalidad.

Otro problema es que no puede definir la especialización para un controlador sin argumentos adicionales. En otras palabras, si escribimos el siguiente código, no se compilará porque entra en conflicto con la implementación generalizada existente:

 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, } } } 

Como resultado, las solicitudes que no tienen parámetros aún deben aceptar una cadena JSON nula, que se deserializa a (). Este problema podría resolverse mediante la especialización en el estilo de C ++, pero hasta ahora solo está disponible en la versión nocturna del compilador y no está claro cuándo se "estabiliza".

Del mismo modo, no puede especializar el tipo del valor de retorno. Incluso si la solicitud no lo implica, siempre devolverá JSON con nulo.

La consulta de decodificación de URL en solicitudes GET también impone algunas restricciones obvias sobre el tipo de parámetros, pero estas ya son características de la implementación de serde-urlencoded.

Conclusión


Por lo tanto, implementamos una API que le permite crear fácilmente y fácilmente manejadores, casi sin pensar en detalles web. Más tarde se pueden transferir a cualquier backends o incluso usar varios backends al mismo tiempo.

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


All Articles