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 PDCó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:
Después de eso, deberá implementar este controlador en un marco específico. Digamos que para actix-web se ve así:
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:
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:
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, };
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.
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> {
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.
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).
"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 {
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.