Métodos genéricos em ferrugem: como o exonum passou do ferro para o Actix-web

O ecossistema Rust ainda está crescendo. Como resultado, novas bibliotecas com funcionalidade aprimorada são frequentemente lançadas na comunidade de desenvolvedores, enquanto as bibliotecas mais antigas se tornam obsoletas. Quando projetamos o Exonum inicialmente, usamos a estrutura da web do Iron. Neste artigo, descrevemos como transportamos a estrutura do Exonum para o actix-web usando programação genérica.



Exonum em ferro


Na plataforma Exonum, a estrutura Iron foi usada sem abstrações. Instalamos manipuladores para certos recursos e obtivemos parâmetros de solicitação analisando URLs usando métodos auxiliares; o resultado foi retornado simplesmente na forma de uma string.

O processo parecia (aproximadamente) o seguinte:

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

Além disso, usamos alguns plugins de middleware na forma de cabeçalhos CORS. Usamos mount para mesclar todos os manipuladores em uma única API.

Nossa decisão de nos afastar do ferro


O ferro era uma boa biblioteca, com muitos plugins. No entanto, foi escrito nos dias em que projetos como futuros e tokio não existiam.

A arquitetura do Iron envolve o processamento de solicitações síncronas, que podem ser facilmente afetadas por um grande número de conexões abertas simultaneamente. Para ser escalável, o Iron precisava se tornar assíncrono, o que envolveria repensar e reescrever toda a estrutura. Como resultado, vimos uma mudança gradual do uso do Iron por engenheiros de software.

Por que escolhemos o Actix-Web


O Actix-web é uma estrutura popular que se destaca nos benchmarks do TechEmpower . Possui uma comunidade de desenvolvedores ativa, diferentemente do Iron, e possui uma API bem projetada e implementação de alta qualidade com base na estrutura do ator actix. As solicitações são processadas de forma assíncrona pelo pool de encadeamentos; se o processamento de solicitações entrar em pânico, o ator será reiniciado automaticamente.

Anteriormente, surgiram preocupações de que o actix-web continha muitos códigos inseguros. No entanto, a quantidade de código não seguro foi significativamente reduzida quando a estrutura foi reescrita em uma linguagem de programação segura - Rust. Os engenheiros da Bitfury revisaram eles mesmos esse código e se sentem confiantes em sua estabilidade a longo prazo.

Para a estrutura do Exonum, mudar para actix resolveu o problema da estabilidade da operação. A estrutura do Iron pode falhar se houver um grande número de conexões. Também descobrimos que a API actix-web é mais simples, mais produtiva e mais unificada. Estamos confiantes de que os usuários e desenvolvedores terão mais facilidade com a interface de programação Exonum, que agora pode operar mais rapidamente graças ao design do actix-web.

O que exigimos de uma estrutura da Web


Durante esse processo, percebemos que era importante não apenas mudar as estruturas, mas também criar uma nova arquitetura de API independente de qualquer estrutura da web específica. Essa arquitetura permitiria criar manipuladores, com pouca ou nenhuma preocupação com especificações da Web, e transferi-los para qualquer back-end. Essa concepção pode ser implementada escrevendo um frontend que aplicaria tipos e características básicas.

Para entender como essa interface precisa ser, vamos definir o que realmente é uma API HTTP:

  • Os pedidos são feitos exclusivamente pelos clientes; o servidor apenas responde a eles (o servidor não inicia solicitações).
  • Solicita dados de leitura ou alteração de dados.
  • Como resultado do processamento da solicitação, o servidor retorna uma resposta, que contém os dados necessários, em caso de êxito; ou informações sobre o erro, em caso de falha.

Se formos analisar todas as camadas de abstração, qualquer solicitação HTTP é apenas uma chamada de função:

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

Tudo o resto pode ser considerado uma extensão dessa entidade básica. Portanto, para ser independente de uma implementação específica de uma estrutura da web, precisamos escrever manipuladores em um estilo semelhante ao exemplo acima.

Trait `Endpoint` para processamento genérico de solicitações HTTP


A abordagem mais simples e direta seria declarar a característica `Endpoint`, que descreve as implementações de solicitações específicas:

 // A trait describing GET request handlers. It should be possible to call each of the handlers from any freed // thread. This requirement imposes certain restrictions on the trait. Parameters and request results are // configured using associated types. 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>; } 

Agora precisamos implementar esse manipulador em uma estrutura específica. Por exemplo, no actix-web, ele se parece com o seguinte:

 // Response type in actix-web. Note that they are asynchronous, even though `Endpoint` assumes that // processing is synchronous. type FutureResponse = actix_web::FutureResponse<HttpResponse, actix_web::Error>; // A raw request handler for actix-web. This is what the framework ultimately works with. The handler // receives parameters from an arbitrary context, through which the request parameters are passed. type RawHandler = dyn Fn(HttpRequest<Context>) -> FutureResponse + 'static + Send + Sync; // For convenience, let's put everything we need from the handler into a single structure. #[derive(Clone)] struct RequestHandler { /// The name of the resource. pub name: String, /// HTTP method. pub method: actix_web::http::Method, /// The raw handler. Note that it will be used from multiple threads. pub inner: Arc<RawHandler>, } 

Podemos usar estruturas para passar parâmetros de solicitação pelo contexto. Actix-web pode desserializar automaticamente parâmetros usando serde. Por exemplo, a = 15 & b = hello é desserializado em uma estrutura como esta:

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

Essa funcionalidade de desserialização concorda bem com o tipo associado Request da característica `Endpoint`.

A seguir, vamos criar um adaptador que agrupe uma implementação específica do `Endpoint` em um RequestHandler para o actix-web. Preste atenção ao fato de que, ao fazer isso, as informações sobre os tipos de solicitação e resposta desaparecem. Essa técnica é chamada de apagamento de tipo - transforma o envio estático em dinâmico.

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

Nesse estágio, basta adicionar manipuladores para solicitações POST, pois criamos uma característica independente dos detalhes da implementação. No entanto, descobrimos que essa solução não era suficientemente avançada.

As desvantagens do traço `endpoint`


Uma grande quantidade de código auxiliar é gerada quando um manipulador é gravado:

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

Idealmente, precisamos ser capazes de aprovar um fechamento simples como manipulador, reduzindo assim significativamente a quantidade de ruído sintático.

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

Abaixo discutiremos como isso pode ser feito.

Imersão leve em programação genérica


Precisamos adicionar a capacidade de gerar automaticamente um adaptador que implemente a característica `Endpoint` com os tipos associados corretos. A entrada consistirá apenas em um fechamento com um manipulador de solicitações HTTP.

Os argumentos e o resultado do fechamento podem ter tipos diferentes, portanto, temos que trabalhar com métodos que sobrecarregam aqui. O Rust não suporta sobrecarga diretamente, mas permite que ele seja emulado usando os traços `Into` e` From`.

Além disso, o tipo retornado do valor de fechamento não precisa corresponder ao valor retornado da implementação do `Endpoint`. Para manipular esse tipo, ele deve ser extraído do tipo de fechamento recebido.

Buscando tipos da característica `Fn`


No Rust, cada fechamento tem seu próprio tipo único, que não pode ser indicado explicitamente no programa. Para manipulações com fechamentos, usamos a característica `Fn`. A característica contém a assinatura da função com os tipos dos argumentos e do valor retornado, no entanto, a recuperação desses elementos separadamente não é fácil.

A idéia principal é usar uma estrutura auxiliar da seguinte forma:

 /// Simplified example of extracting types from an F closure: Fn(A) -> B. struct SimpleExtractor<A, B, F> { // The original function. inner: F, _a: PhantomData<A>, _b: PhantomData<B>, } 

Temos que usar PhantomData, pois o Rust exige que todos os parâmetros genéricos sejam indicados na definição da estrutura. No entanto, o tipo de fechamento ou função F em si não é genérico (embora implemente uma característica genérica de `Fn`). Os parâmetros de tipo A e B não são usados ​​diretamente nele.

É essa restrição do sistema do tipo Rust que nos impede de aplicar uma estratégia mais simples implementando a característica `Endpoint 'diretamente para fechamentos:

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

No caso acima, o compilador retorna um erro:

 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 

A estrutura auxiliar SimpleExtractor torna possível descrever a conversão de `From`. Essa conversão nos permite salvar qualquer função e extrair os tipos de seus 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, } } } 

O código a seguir é compilado com êxito:

 #[derive(Deserialize)] struct Query { a: i32, b: String, }; // Verification of the ordinary structure. fn my_handler(_: &Context, q: Query) -> String { format!("{} has {} apples.", qb, qa) } let fn_extractor = SimpleExtractor::from(my_handler); // Verification of the closure. 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); 

Tipos de Especialização e Marcador


Agora, temos uma função com tipos de argumento explicitamente parametrizados, que podem ser usados ​​no lugar da característica `Endpoint`. Por exemplo, podemos implementar facilmente a conversão de SimpleExtractor em RequestHandler. Ainda assim, essa não é uma solução completa. De alguma forma, precisamos distinguir entre os manipuladores de solicitações GET e POST no nível do tipo (e entre manipuladores síncronos e assíncronos). Nesta tarefa, os tipos de marcadores vêm em nosso auxílio.

Primeiro, vamos reescrever o SimpleExtractor para que ele possa distinguir entre resultados síncronos e assíncronos. Ao mesmo tempo, implementaremos a característica `From` para cada um dos casos. Observe que características podem ser implementadas para variantes específicas de estruturas genéricas.

 /// Generic handler for HTTP-requests. pub struct With<Q, I, R, F> { /// A specific handler function. pub handler: F, /// Structure type containing the parameters of the request. _query_type: PhantomData<Q>, /// Type of the request result. _item_type: PhantomData<I>, /// Type of the value returned by the handler. /// Note that this value can differ from the result of the request. _result_type: PhantomData<R>, } // Implementation of an ordinary synchronous returned value. 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, } } } // Implementation of an asynchronous request handler. 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, } } } 

Agora precisamos declarar a estrutura que combinará o manipulador de solicitações com seu nome e tipo:

 #[derive(Debug)] pub struct NamedWith<Q, I, R, F, K> { /// The name of the handler. pub name: String, /// The handler with the extracted types. pub inner: With<Q, I, R, F>, /// The type of the handler. _kind: PhantomData<K>, } 

Em seguida, declaramos várias estruturas vazias que atuarão como tipos de marcador. Os marcadores nos permitem implementar para cada manipulador seu próprio código para converter o manipulador no RequestHandler descrito anteriormente.

 /// A handler that does not change the state of the service. In HTTP, GET-requests correspond to this // handler. pub struct Immutable; /// A handler that changes the state of the service. In HTTP, POST, PUT, UPDATE and other similar //requests correspond to this handler, but for the current case POST will suffice. pub struct Mutable; 

Agora podemos definir quatro implementações diferentes da característica `From` para todas as combinações de parâmetros de modelo R e K (o valor retornado do manipulador e o tipo de solicitação).

 // Implementation of a synchronous handler of GET requests. 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>, } } } // Implementation of a synchronous handler of POST requests. 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>, } } } // Implementation of an asynchronous handler of GET requests. 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>, } } } // Implementation of an asynchronous handler of POST requests. 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 o back-end


A etapa final é criar uma fachada que aceite fechamentos e adicione-os ao back-end correspondente. Nesse caso, temos um único back-end - actix-web. No entanto, existe o potencial de implementação adicional por trás da fachada. Por exemplo: um gerador de especificações Swagger.

 pub struct ServiceApiScope { actix_backend: actix::ApiBuilder, } impl ServiceApiScope { /// This method adds an Immutable handler to all backends. pub fn endpoint<Q, I, R, F, E>(&mut self, name: &'static str, endpoint: E) -> &mut Self where // Here we list the typical restrictions which we have encountered earlier: Q: DeserializeOwned + 'static, I: Serialize + 'static, F: Fn(&ServiceApiState, Q) -> R + 'static + Clone, E: Into<With<Q, I, R, F>>, // Note that the list of restrictions includes the conversion from NamedWith into RequestHandler // we have implemented earlier. RequestHandler: From<NamedWith<Q, I, R, F, Immutable>>, { self.actix_backend.endpoint(name, endpoint); self } /// A similar method for Mutable handlers. 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 como os tipos dos parâmetros da solicitação, o tipo do resultado da solicitação e a sincronia / assincronia do manipulador são derivados automaticamente de sua assinatura. Além disso, precisamos especificar explicitamente o nome e o tipo da solicitação.

Desvantagens da abordagem


A abordagem descrita acima, apesar de ser bastante eficaz, tem suas desvantagens. Em particular, os métodos endpoint e endpoint_mut devem considerar as peculiaridades de implementação de back-end específicos . Essa restrição nos impede de adicionar back-ends em movimento, embora essa funcionalidade raramente seja necessária.

Outra questão é que não podemos definir a especialização de um manipulador sem argumentos adicionais . Em outras palavras, se escrevermos o código a seguir, ele não será compilado, pois está em conflito com a implementação genérica 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, solicitações que não possuem nenhum parâmetro ainda devem aceitar a cadeia JSON nula, que é desserializada em (). Esse problema pode ser resolvido pela especialização no estilo C ++, mas, por enquanto, está disponível apenas na versão noturna do compilador e não está claro quando ele se tornará um recurso estável.

Da mesma forma, o tipo do valor retornado não pode ser especializado . Mesmo que a solicitação não implique um certo tipo de valor retornado, ela ainda passará JSON com nulo.

A decodificação da consulta de URL nas solicitações GET também impõe algumas restrições não óbvias ao tipo de parâmetros , mas esse problema está relacionado às peculiaridades da implementação codificada por serde-url.

Conclusão


Como descrito acima, implementamos uma API aprimorada, que permite uma criação simples e clara de manipuladores, sem a necessidade de se preocupar com detalhes da Web. Esses manipuladores podem trabalhar com qualquer back-end ou mesmo com vários back-end simultaneamente.





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


All Articles