Tornando nosso serviço mais realístico
Agora vamos aplicar uma série de mudanças em nosso servidor para deixá-lo mais robusto. Algumas dessas mudanças incluem sistemas de logs, conteinerizar a aplicação, tornar ela fault tolerante, headers padrões e mais. Para isso, vamos começar com o mais simples e indispensável, o sistema de logs.
Aplicando logs
O primeiro passo para começarmos a entender logs em Rust é darmos uma olhada na crate responsável por isso. A crate que vamos utilizar é a log = "0.4.8", que implementa sua lógica de logs de acordo com a ideia de que um log consiste em um alvo, um nível e um corpo. O alvo é uma string que define o caminho do módulo no qual o requerimento do log é necessário. O nível é a severidade do log, error, warn, info, debug e trace, e o corpo é o conteúdo que o log apresenta.
A crate que vamos utilizar nos disponibiliza cinco macros para isso: error!, warn!, info!, debug!, trace!, dentre as quais error é a mais severa e trace a menos severa. As macros funcionam de forma muito similar ao println!, assim a forma de utilizá-las é bastante intuitiva. Outra questão importante é que o sistema de logs deve ser inicializado apenas uma vez por outra crate, a mais comum delas é a env_logger = "0.9.0". Um exemplo rápido de como ficaria a combinação dessas duas é:
#[macro_use] extern crate log; fn main() { env_logger::init(); info!("starting up"); // ... }
Inicializando o sistema de Logs
Para inicializar nosso sistema de logs, precisamos adicionar a crate env_logger ao nosso [dependencies] do Cargo.toml, o env_logger = "0.9.0". Com a crate disponível, podemos importar o env_logger para o contexto do arquivo main.rs com use env_logger; e inicializá-lo com env_logger::init() conforme o código a seguir:
// ... use env_logger; #[actix_rt::main] async fn main() -> std::io::Result<()> { env_logger::init(); // ... }
Com isso o código parece compilar, mas não conseguimos ver logs no console quando executamos um curl. Isso se deve ao fato de que precisamos informar ao actix_web que queremos que logs de algum nível sejam disponibilizados. Para isso, devemos incluir a linha std::env::set_var("RUST_LOG", "actix_web=info"); antes de env_logger::init(); na função main para habilitar logs de error a info. Além disso, precisamos disponibilizar o middleware Logger com a forma como queremos o log, note que o middleware pertence à crate actix_webem use actix_web::middleware::Logger;:
// ... use actix_web::middleware::Logger; use env_logger; // ... #[actix_web::main] async fn main() -> std::io::Result<()> { std::env::set_var("RUST_LOG", "actix_web=info"); env_logger::init(); create_table().await; HttpServer::new(|| { App::new() .wrap(Logger::new("IP:%a DATETIME:%t REQUEST:\"%r\" STATUS: %s DURATION:%D")) .configure(app_routes) .default_service(web::to(|| HttpResponse::NotFound())) }) .workers(num_cpus::get() - 2) .bind(("localhost", 4004)) .unwrap() .run() .await }
Se fizermos um POST curl agora no endpoint /api/create vamos ver o seguinte log no terminal aonde o servidor está rodando:
[2020-02-08T01:41:32Z INFO actix_web::middleware::logger] IP:127.0.0.1:54089 DATETIME:2020-02-07T22:41:32-03:00 REQUEST:"POST /api/create HTTP/1.1" STATUS: 201 DURATION:33.976000
Note que o formato após os colchetes [...] é igual ao que definimos no middleware de Logger::new("IP:%a DATETIME:%t REQUEST:\"%r\" STATUS: %s DURATION:%D"), assim podemos entender alguns dos parâmetros que estamos passando:
%aé o IP do request.%té o DateTime do request.%ré o método (POSTno caso) seguido do endpoint (/api/create) e o protocolo usado.%sé o status de retorno do request.%Dé a duração total do request, em milisegundos.
Algumas outras variáveis disponíveis nesse middleware são:
%thorário no qual o request começou a ser processado.%Po ID do processo filho que serviu o request.%btamanho da resposta em bytes (inclui os headers).%Tduração do request em segundos com fração float de.06f.%{FOO}iheaders[‘FOO’] do request.%{FOO}oheaders[‘FOO’] da response.%{FOO}evalor da variável de ambienteFOO,os.environ["FOO"]. Algumas outras variávels disponíveis neste middleware são:
Adicionando logs
Para adicionar os logs ao nosso código, vamos utilizar duas macros error! e debug!. Para isso, precisamos adicionar log = "0.4" ao nosso [dependencies] no Cargo.toml. A função de debug deverá nos apoiar com resultados no ambiente de desenvolvimento, enquanto a função de error! será exibir os erros no console. Para isso, usaremos o código use log::{error, debug};. Um bom local para inicializar é no create_table, a primeira função que nosso código executa. Para modo debug, utilize a env std::env::set_var("RUST_LOG", "debug");:
#![allow(unused)] fn main() { use log::{debug, error}; // ... pub async fn create_table() { let client = get_client().await; match client.list_tables().send().await { Ok(list) => { match list.table_names { Some(table_vec) => { if table_vec.len() > 0 { error!("Table already exists and has more then one item"); } else { create_table_input(&client).await } } None => create_table_input(&client).await, }; } Err(_) => { create_table_input(&client).await; } } } async fn create_table_input(client: &Client) { let table_name = TODO_CARD_TABLE.to_string(); let ad = build_attribute_definition(); let ks = build_key_schema(); let pt = build_provisioned_throughput(); match client .create_table() .table_name(table_name) .key_schema(ks) .attribute_definitions(ad) .provisioned_throughput(pt) .send() .await { Ok(output) => { debug!("Table created {:?}", output); } Err(error) => { error!("Could not create table due to error: {:?}", error); } } } }
Outro lugar em que podemos aplicar logs é no arquivo src/todo_api/db/todo.rs, pois as funções de put e get são bastante suscetíveis a erros. Assim podemos modificar o arquivo para:
#![allow(unused)] fn main() { // ... use log::{debug, error}; #[cfg(not(feature = "dynamo"))] pub async fn put_todo(client: &Client, todo_card: TodoCardDb) -> Option<uuid::Uuid> { match client .put_item() .table_name(TODO_CARD_TABLE.to_string()) .set_item(Some(todo_card.clone().into())) .send() .await { Ok(_) => { debug!("item created with id {:?}", todo_card.id); Some(todo_card.id) } Err(e) => { error!("error when creating item {:?}", e); None } } } #[cfg(not(feature = "dynamo"))] pub async fn get_todos(client: &Client) -> Option<Vec<TodoCard>> { // ... match scan_output { Ok(dbitems) => { let res = adapter::scanoutput_to_todocards(dbitems)?.to_vec(); debug!("Scanned {:?} todo cards", dbitems); Some(res) } Err(e) => { error!("Could not scan todocards due to error {:?}", e); None } } } }
Note que nos casos de Err agora estamos logando o motivo com e. O último passo para este momento é adicionar logs aos controllers em src/todo_api_web/controllers/todo.rs:
#![allow(unused)] fn main() { use log::{error}; // ... #[post("/api/create")] pub async fn create_todo(info: web::Json<TodoCard>) -> impl Responder { let id = Uuid::new_v4(); let todo_card = adapter::todo_json_to_db(info, id); let client = get_client().await; match put_todo(&client, todo_card).await { None => { error!("Failed to create todo card"); HttpResponse::BadRequest().body(ERROR_CREATE) } Some(id) => HttpResponse::Created() .content_type(ContentType::json()) .body(serde_json::to_string(&TodoIdResponse::new(id)).expect(ERROR_SERIALIZE)), } } #[get("/api/index")] pub async fn show_all_todo() -> impl Responder { let client = get_client().await; let resp = get_todos(&client).await; match resp { None => { error!("Failed to read todo cards"); HttpResponse::InternalServerError().body(ERROR_READ) } Some(cards) => HttpResponse::Ok() .content_type(ContentType::json()) .body(serde_json::to_string(&TodoCardsResponse { cards }).expect(ERROR_SERIALIZE)), } } }
Note que adicionamos somente a opção de error já que o None => {...} é a única resposta que pode conter diversas razões, pelo fato do Some já estar mapeado em put_todo e get_todos.
Incluindo Docker
Como o foco deste livro não é docker e ele não é um requisito para entender o livro, vou mostrar o código e explicar um pouco o que está acontecendo. Assim vamos começar por um Dockerfile extremamente simples.
FROM rustlang/rust:nightly
RUN mkdir -p /usr/src/app
WORKDIR /usr/src/app
COPY . /usr/src/app
CMD ["cargo", "build", "-q"]
A primeira diretiva, a FROM, tem como objetivo definir a imagem base para nosso contêiner. Nesse caso, estamos utilizando uma versão nightly do Rust, pois a versão stable não era compatível com a versão da minha máquina quando escrevi este livro. Depois disso, temos a diretiva RUN, que executa algum comando, no nosso caso a criação da pasta /usr/src/app, e já definimos essa pasta como o diretório que vamos utilizar com WORKDIR. Depois disso, copiamos todo nosso código para nosso diretório com COPY e executamos um comando do cargo, o build, para construir nossa aplicação, cargo build -q com CMD. Outra opção de Dockerfile com otimização para builds repetidos é:
FROM rust:latest
RUN mkdir -p /usr/src/
WORKDIR /usr/src/
RUN USER=root cargo new --bin app
WORKDIR /app
COPY ./Cargo.lock ./Cargo.lock
COPY ./Cargo.toml ./Cargo.toml
COPY ./tests ./tests
RUN cargo build --release
RUN rm src/*.rs
COPY ./src ./src
CMD ["cargo", "build", "--release"]
O objetivo desse segundo Dockerfile é diminuir o tempo de execução do contêiner ao cachear as dependências do app e somente atualizar o cache a partir do COPY ./src ./src.
Com este container pronto, podemos começar a pensar em como utilizar os dois containers (DynamoDB e todo_server) em conjunto. Faremos isso com docker-compose.yml:
version: '3.8'
services:
dynamodb-local:
command: "-jar DynamoDBLocal.jar -sharedDb -dbPath ./data"
image: amazon/dynamodb-local
container_name: dynamodb-local
ports:
- "8000:8000"
volumes:
- "./docker/dynamodb:/home/dynamodblocal/data"
working_dir: /home/dynamodblocal
web:
build:
context: .
dockerfile: Dockerfile
command: cargo run
ports:
- "4000:4000"
depends_on:
- "dynamodb-local"
links:
- "dynamodb-local"
environment:
# Since we are using dynamodb local, the IAM authentication mechanism is not used at all.
# That is, whichever credentials you provide, it will be accepted
AWS_ACCESS_KEY_ID: 'MYID'
AWS_SECRET_ACCESS_KEY: 'MYSECRET'
AWS_REGION: 'us-east-1'
DYNAMODB_ENDPOINT: 'dynamodb-local'
Nosso docker-compose precisa de duas chaves principais: version, que corresponde à versão do compose, services, que corresponde aos contêineres que vamos rodar. Em services, precisamos declarar dois contêineres web, os quais conterão nossa aplicação e o contêiner dynamodb, que conterá a imagem do DynamoDB e veio (desse tutorial)[https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/DynamoDBLocal.DownloadingAndRunning.html]. O contêiner dynamodb possui as seguintes chaves:
container_name: é o nome do contêiner, no nosso casodynamodb.image: a fonte da imagem que estamos utilizando, no caso do DynamoDB éamazon/dynamodb-local.ports: o mapeamento de portas de dentro do contêiner para fora,8000:8000.volumes: volumes disponíveis para o dynamo utilizar,dynamodata:/home/dynamodblocal/.working_dir: diretório no qual o dynamo executará,/home/dynamodblocal/.command: para inicializar o dynamo"-jar DynamoDBLocal.jar -sharedDb -dbPath .".
Depois disso temos o web que irá rodar a todo API, que não vou repetir algumas chaves:
build: o contexto de criação da imagem,context: .. No caso, estamos passando um dockerfile chamadoDockerfiledockerfile: Dockerfile.command: executamos o comandocargo runpara essa aplicação.environment: para executar o DynamoDB dessa forma precisamos adicionar algumas variáveis de ambiente para que oclientconfigure suas credenciais, de acordo com https://docs.aws.amazon.com/sdk-for-rust/latest/dg/dynamodb-local.html.AWS_ACCESS_KEY_ID=AKIDLOCALSTACKAWS_SECRET_ACCESS_KEY=localstacksecretAWS_REGION=us-east-1DYNAMODB_ENDPOINT=dynamodb-localUsaremos a variável de ambienteDYNAMODB_ENDPOINTpara saber qual address iremos usar quando inicializarmos o dynamodb client na nossa API. Faremos a seguinte mudança na funçãoget_client:
#![allow(unused)] fn main() { // src/todo_api/db/helpers.rs pub async fn get_client() -> Client { let config = aws_config::load_from_env().await; let addr = if let Ok(db_endpoint) = std::env::var("DYNAMODB_ENDPOINT") { format!("http://{}:8000", db_endpoint) } else { "http://0.0.0.0:8000".to_string() }; let dynamodb_local_config = aws_sdk_dynamodb::config::Builder::from(&config) .endpoint_resolver(Endpoint::immutable(addr.parse().expect("Invalid URI"))) .build(); Client::from_conf(dynamodb_local_config) } }
depends_on: define a ordem na qual os serviços devem ser inicializados, assimdynamodbé inicializado antes deweblinks: forma legada de fazer com que dois serviços estejam conectados, atualmente bastaria onetworks, mas coloquei como exemplo. No caso delinksenetworksestarem definidos, é preciso que ambos estejam na mesma rede.
Se tivéssemos as configurações de produção, poderíamos criar a feature compose para utilizar com o docker-compose. Se executarmos o código agora com docker-compose up --build e, em seguida, um curl, tudo voltará a funcionar como antes. Outra coisa que podemos fazer agora é atualizar nosso Makefile para incluir o docker-compose:
db:
docker run -p 8000:8000 amazon/dynamodb-local
test:
cargo test --features "dynamo"
run-local:
cargo run --features "dynamo"
run:
docker-compose up --build
down:
docker-compose down
Headers padrões
Outro ponto que acredito ser importante é o uso de headers para identificar os requests nos logs. Costumo ver o padrão de um header chamado x-request-id cujo valor é um uuid. Para implementarmos esse padrão com o actix, precisamos utilizar um middleware que felizmente a equipe do actix já disponibilizou para nós, o actix_web::middleware::DefaultHeaders. Para isso, precisamos disponibilizá-lo no escopo com use e depois passar essa informação para um wrap. A forma de utilizar esses headers padrões é DefaultHeaders::new().header("X-Version", "0.2"), isto é, criamos um novo header com DefaultHeaders::new() e depois chamamos a função header para adicionar um header com os argumentos-chave e valor do tipo string:
#![allow(unused)] fn main() { // src/main.rs // ... HttpServer::new(|| { App::new() .wrap(DefaultHeaders::new().add(("x-request-id", Uuid::new_v4().to_string()))) .wrap(Logger::new( "IP:%a DATETIME:%t REQUEST:\"%r\" STATUS: %s DURATION:%D", )) .configure(app_routes) }) // ... }
Além disso, precisamos definir o header no Logger, para isso usamos a chave X-REQUEST-ID:%{x-request-id}o após a DURATION, pois somente assim o valor de x-request-id será logado:
#![allow(unused)] fn main() { // ... HttpServer::new(|| { App::new() .wrap(DefaultHeaders::new().add(("x-request-id", Uuid::new_v4().to_string()))) .wrap(Logger::new("IP:%a DATETIME:%t REQUEST:\"%r\" STATUS: %s DURATION:%D X-REQUEST-ID:%{x-request-id}o")) .configure(app_routes) }) // ... }
Um exemplo de resposta seria:
[2020-02-08T23:10:58Z INFO actix_web::middleware::logger] IP:172.21.0.1:52686 DATETIME:2020-02-08T23:10:58Z REQUEST:"POST /api/create HTTP/1.1" STATUS: 201 DURATION:166.921700 X-REQUEST-ID=bd15de62-1ba6-4d43-89ca-4f89418
Adicionando o cliente ao estado da API
Nosso próximo passo vem de uma necessidade de refactor e preparação para o código futuro. Esse refactor consiste em retirar a declaração de let cliente = client(); de todos os códigos envolvendo banco de dados e passá-los como argumentos. Uma das vantagens disso é caso decidamos ter mais clientes de algum tipo de serviço como outros bancos de dados ou S3. Para fazermos isso, vamos criar uma nova struct chamada Clients que conterá o campo dynamo e depois a passaremos como argumento para o HttpServer via função data.
Assim, nosso primeiro passo é descrever a o modelo de Clients em src/todo_api_web/model/http.rs:
#![allow(unused)] fn main() { use aws_sdk_dynamodb::Client; use crate::todo_api::db::helpers::get_client; #[derive(Clone)] pub struct Clients { pub dynamo: Client, } impl Clients { pub async fn new() -> Self { Self { dynamo: get_client().await, } } } }
Agora podemos utilizar a função app_data em HttpServer para passar Clients como argumento. Fazemos isso com Clients::new():
// ... use todo_server::{ todo_api::db::helpers::create_table, todo_api_web::{model::http::Clients, routes::app_routes}, }; #[actix_web::main] async fn main() -> Result<(), std::io::Error> { std::env::set_var("RUST_LOG", "actix_web=info"); env_logger::init(); let client = web::Data::new(Clients::new().await); create_table(&client.dynamo.clone()).await; HttpServer::new(move|| { App::new() .app_data(client.clone()) .wrap(DefaultHeaders::new().add(("x-request-id", Uuid::new_v4().to_string()))) .wrap(Logger::new("IP:%a DATETIME:%t REQUEST:\"%r\" STATUS: %s DURATION:%D X-REQUEST-ID:%{x-request-id}o")) .configure(app_routes) }) .workers(num_cpus::get() - 2) .max_connections(30000) .bind(("0.0.0.0", 4000)) .unwrap() .run() .await } // ...
Com isso temos Clients disponível no nos nossos controllers, para isso adicionamos o estado com state: web::Data<Clients>:
#![allow(unused)] fn main() { // ... use crate::todo_api_web::model::http::Clients; #[post("/api/create")] pub async fn create_todo(state: web::Data<Clients>, info: web::Json<TodoCard>) -> impl Responder { let id = Uuid::new_v4(); let todo_card = adapter::todo_json_to_db(info, id); let client = state.dynamo.clone(); //... } #[get("/api/index")] pub async fn show_all_todo(state: web::Data<Clients>) -> impl Responder { let client = state.dynamo.clone(); //... } }
As funcões put_todo e get_todos ja esperam um argumento do tipo aws_sdk_dynamodb::Client então
não será preciso modificar elas.
Feito isso, devemos adicionar o novo client a todos os testes de integração, pois esse argumento é esperado nas funções de controller. Um exemplo seria:
#![allow(unused)] fn main() { #[actix_web::test] async fn test_todo_cards_count() { let client = web::Data::new(Clients::new().await); let mut app = test::init_service(App::new().app_data(client.clone()).configure(app_routes)).await; //... } }
Serializando o Response
Até o momento estávamos utilizando o formato de criação de HttpResponse da seguinte maneira HttpResponse::Ok().content_type("application/json").body(serde_json::to_string(&struct).expect("Failed to serialize todo cards")), mas existe uma forma que pode simplificar nossa vida por nos permitir delegar a chamada de serde_json. Esse formato substitui o .body(...) por .json(...). A vantagem de se utilizar esse formato é que ele reduz a quantidade de código que nós devemos manter, delegando ao actix essa responsabilidade. Nos capítulos introdutórios do livro, falamos que o actix estava com muita vantagem em relação a outros frameworks nos benchmarks da TechEmpower, porém, no caso de serialização JSON, existem alguns frameworks C/C++ à sua frente, inclusive a crate hyper. O Objetivo de body é principalmente enviar mensagens sem dados estruturados ou estruturados em outros formatos como Edn.
Com esse pequeno refactor, nossos controllers de todo serão modificados para o seguinte formato:
#![allow(unused)] fn main() { // src/todo_web_api/controller/todo.rs // ... #[post("/api/create")] pub async fn create_todo(state: web::Data<Clients>, info: web::Json<TodoCard>) -> impl Responder { let id = Uuid::new_v4(); let todo_card = adapter::todo_json_to_db(info, id); let client = state.dynamo.clone(); match put_todo(&client, todo_card).await { None => { error!("Failed to create todo card {}", ERROR_CREATE); HttpResponse::BadRequest().body(ERROR_CREATE) } Some(id) => HttpResponse::Created() .content_type(ContentType::json()) .json(TodoIdResponse::new(id)), } } #[get("/api/index")] pub async fn show_all_todo(state: web::Data<Clients>) -> impl Responder { let client = state.dynamo.clone(); let resp = get_todos(&client).await; match resp { None => { error!("Failed to read todo cards"); HttpResponse::InternalServerError().body(ERROR_READ) } Some(cards) => HttpResponse::Ok() .content_type(ContentType::json()) .json(TodoCardsResponse { cards }), } } }
Com isso, nosso código está pronto para receber novos clientes e nós podemos começar a pensar em autenticação.