O ecossistema Rust ainda não está totalmente estabelecido. Novas bibliotecas geralmente aparecem nele, que são notavelmente melhores que seus predecessores, e estruturas anteriormente populares se tornam obsoletas. Foi exatamente o que aconteceu com a estrutura da web do Iron usada no desenvolvimento do Exonum.
O Actix-web foi escolhido como um substituto para o Iron. Além disso, mostrarei como transportamos o código existente para uma nova solução usando técnicas de programação generalizada.
Imagem do ulleo PDComo usamos o ferro
No Exonum, a estrutura Iron foi usada sem abstrações. Instalamos manipuladores em certos recursos, recebemos parâmetros de consulta analisando URLs usando métodos auxiliares e retornamos o resultado simplesmente como uma string.
Tudo parecia algo assim:
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, alguns complementos de middleware na forma de cabeçalhos CORS foram usados. Para combinar todos os manipuladores em uma única API, usamos mount.
Por que você teve que abandoná-lo
O ferro era um bom cavalo de batalha com muitas adições. No entanto, foi escrito naqueles tempos distantes, quando projetos como o futuro e o
tokio não existiam.
A arquitetura Iron fornece processamento síncrono de solicitações, para que ele se encaixe facilmente nos ombros com um grande número de conexões abertas simultaneamente. Para que o Iron se torne escalável, ele deve ser assíncrono. Para fazer isso, foi necessário repensar e reescrever toda a estrutura, mas os desenvolvedores abandonaram gradualmente o trabalho nela.
Por que mudamos para o actix-web
Essa é uma estrutura popular que ocupa um alto nível
nos benchmarks do TechEmpower . Ao mesmo tempo, ele, ao contrário de Iron, está se desenvolvendo ativamente. O Actix-web 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 conjunto de encadeamentos e, se o processamento levar ao pânico, o ator será reiniciado automaticamente.
Obviamente, o actix-web apresentava falhas, por exemplo, continha uma grande quantidade de código não seguro. Mas depois foi reescrito no Safe Rust, que resolveu esse problema.
Mudar para actix resolveu o problema de estabilidade. O back-end de ferro pode ser descartado por um grande número de conexões. Em geral, a nova API é uma solução mais simples, mais produtiva e unificada. Ficará mais fácil para os usuários e desenvolvedores usar a interface do software, e sua velocidade aumentará.
O que queremos de uma estrutura da web
Era importante para nós não apenas mudar o Iron para o actix-web, mas criar uma base para o futuro - elaborar uma nova arquitetura de API para abstrair de uma estrutura da web específica. Isso permitirá que você crie manipuladores, quase sem pensar em detalhes da Web, e transfira-os para qualquer back-end. Isso pode ser feito escrevendo um front-end que funcionaria com tipos e tipos básicos.
Para entender como esse frontend se parece, vamos definir o que é qualquer API HTTP:
- As solicitações são feitas exclusivamente pelos clientes e o servidor apenas as responde (não atua como o iniciador).
- Os pedidos são lidos e modificados.
- Como resultado da consulta, o servidor retorna uma resposta que contém os dados desejados com êxito e, em caso de erro, informações sobre ele.
Se analisarmos 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 ignorar a implementação específica da estrutura da web, precisamos escrever manipuladores em um estilo semelhante ao exemplo acima.
Característica do terminal para processamento de solicitação HTTP generalizado
Você pode seguir o caminho mais simples e direto e declarar uma característica do Endpoint,
descrevendo a implementação de consultas específicas:
Depois disso, você precisará implementar esse manipulador em uma estrutura específica. Digamos que, para o actix-web, seja algo assim:
Você pode 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 da seguinte forma:
#[derive(Deserialize)] struct SimpleQuery { a: i32, b: String, }
Isso é consistente com o tipo de solicitação associado da característica do ponto de extremidade.
Agora vamos escrever um adaptador que agrupe uma implementação específica do Endpoint no RequstHandler para o actix-web. Observe que as informações sobre os tipos de solicitação e resposta são perdidas no processo. Essa técnica é chamada de apagamento de tipo. Sua tarefa é transformar o agendamento 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, você pode adicionar manipuladores para solicitações POST e parar, pois criamos uma característica que é abstraída dos detalhes da implementação. No entanto, ainda não é muito ergonômico.
Digite problemas
Ao escrever um manipulador, muito código auxiliar é gerado:
Idealmente, eu gostaria de poder aprovar um fechamento normal como manipulador, reduzindo a quantidade de ruído de sintaxe em uma ordem de magnitude:
let elements = elements.clone(); actix_backend.endpoint("/v1/elements_count", move || { Ok(elements.borrow().len()) });
Falarei sobre como fazer isso mais tarde.
Fácil imersão na programação geral
Precisamos perceber a capacidade de gerar automaticamente um adaptador que implemente o Endpoint com os tipos associados corretos. Nesse caso, apenas um fechamento com o manipulador de solicitações HTTP será alimentado na entrada.
Os argumentos e o resultado do fechamento podem ser de vários tipos, então aqui você precisa trabalhar com a sobrecarga do método. 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 de valor de retorno do fechamento não precisa corresponder ao valor de retorno da implementação do Terminal. Para manipular esse tipo, ele deve ser extraído do tipo de fechamento recebido.
Extração de tipos do tipo Fn
No Rust, cada fechamento tem seu próprio tipo exclusivo, que não pode ser explicitamente escrito no programa. Para manipular fechamentos, existe um tipo Fn. Ele contém uma assinatura de função com tipos de argumentos e valor de retorno, no entanto, extraí-los individualmente não é tão simples.
A idéia principal é usar uma estrutura auxiliar da seguinte forma:
Somos forçados a usar o PhantomData, pois o Rust exige que todos os parâmetros de generalização estejam na definição da estrutura. No entanto, o tipo específico de fechamento ou função F não é generalizado (embora implemente o tipo generalizado Fn). Os parâmetros de tipo A e B não são usados diretamente nele.
É essa limitação do sistema do tipo Rust que não permite o uso de uma estratégia mais simples - implementar diretamente a característica do Endpoint 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> {
O compilador nesse caso 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 do SimpleExtractor torna possível descrever a transformação De. Ele 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, };
Tipos de especialização e marcador
Agora, temos uma função com tipos de argumento explicitamente parametrizados, adequados para uso no lugar do traço Endpoint. Por exemplo, podemos implementar facilmente a conversão de SimpleExtractor em RequestHandler. Mas ainda assim, essa não é uma solução completa. Também precisamos distinguir de alguma forma entre manipuladores de solicitação GET e solicitações POST no nível de tipo (e manipuladores síncronos dos assíncronos). Os chamados tipos de marcadores nos ajudarão nisso.
Primeiro, reescrevemos o SimpleExtractor para que ele possa distinguir entre resultados síncronos e assíncronos. Ao mesmo tempo, implementamos a característica From para cada um dos casos. Observe que os traços podem ser implementados para variantes específicas de estruturas generalizadas.
Agora precisamos declarar uma estrutura na qual combinar o manipulador de solicitações com seu nome e variedade:
#[derive(Debug)] pub struct NamedWith<Q, I, R, F, K> {
Depois, você pode declarar várias estruturas vazias que atuarão como tipos de marcador. Os marcadores permitem implementar para cada manipulador seu próprio código de conversão no RequestHandler descrito anteriormente.
Agora podemos definir quatro implementações diferentes do tipo From para todas as combinações dos parâmetros do modelo R e K (valor de retorno do manipulador e tipo de solicitação).
"Fachada" para o back-end
Agora, por tudo isso, resta escrever uma “fachada”, que levaria fechamentos e os adicionaria ao back-end correspondente. No nosso caso, existe apenas um back-end - actix-web - mas, atrás da fachada, você pode ocultar quaisquer implementações adicionais que desejar, por exemplo, um gerador de especificações Swagger.
pub struct ServiceApiScope { actix_backend: actix::ApiBuilder, } impl ServiceApiScope {
Observe como os tipos de parâmetros de solicitação, o tipo de seu resultado e o sincronismo / assincronia do manipulador são derivados automaticamente de sua assinatura. Além disso, você deve especificar explicitamente o nome da solicitação, bem como seu tipo.
Desvantagens da abordagem
Essa abordagem ainda tem suas desvantagens. Em particular,
endpoint e endpoint_mut devem conhecer as especificidades da implementação de back-end específicos . Isso não nos permite adicionar back-end em tempo real, mas essa funcionalidade raramente é necessária.
Outro problema é que
você não pode definir a especialização para um manipulador sem argumentos adicionais. Em outras palavras, se escrevermos o código a seguir, ele não será compilado, porque entra em conflito com a implementação 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, solicitações que não possuem parâmetros ainda devem aceitar uma cadeia JSON nula, que é desserializada para (). Esse problema pode ser resolvido por especialização no estilo de C ++, mas até agora está disponível apenas na versão noturna do compilador e não é claro quando "estabiliza".
Da mesma forma, você não pode especializar o tipo do valor de retorno. Mesmo que a solicitação não a implique, ela sempre retornará JSON com nulo.
A consulta de URL de decodificação em solicitações GET também impõe algumas restrições não óbvias ao tipo de parâmetros, mas esses já são recursos da implementação do codificado por serde-url.
Conclusão
Assim, implementamos uma API que permite criar manipuladores de maneira fácil e fácil, quase sem pensar em detalhes da web. Posteriormente, eles podem ser transferidos para qualquer back-end ou até mesmo usar vários back-end ao mesmo tempo.