WEB3DEV

Cover image for Crie uma API REST em Rust que Você Não Teria (Muita?) Vergonha de Implantar em Produção
Isabela Curado Nehme
Isabela Curado Nehme

Posted on

Crie uma API REST em Rust que Você Não Teria (Muita?) Vergonha de Implantar em Produção

https://miro.medium.com/v2/resize:fit:720/format:webp/1*YfLG-jpFXo0afGvFu7BAmA.png

Pontos que abordarei nesta publicação:

  • Arquitetura da aplicação;
  • Configuração da aplicação;
  • Estado da aplicação (Diesel ORM e PostgreSQL);
  • API REST (servidor Axum);
  • Chamando de API externa (cliente HTTP Reqwest);
  • Rastreamento (OpenTelemetry e Jaeger);
  • Desligamento normal (manipulação do SIGTERM).

O código fonte está disponível neste repositório no Github: todoservice

Por que Rust?

Que bom que você perguntou! Rust é uma linguagem estática moderna que roda nativamente em bare metal (serviço de cloud que permite aos usuários alugar um servidor físico de locatário único junto ao provedor de cloud). Não precisa de coletor de lixo nem de ambiente virtual para rodar. É muito rápido e ocupa pouco espaço na memória de tempo de execução em comparação com linguagens coletadas como lixo. O compilador do Rust faz análises estáticas para garantir a segurança da memória em tempo de execução. Usando o Rust de forma regular, garante que não há ponteiros nulos em tempo de execução. E se você já implantou o código em produção, certamente saberá o valor de qualquer linguagem de programação que possa garantir a segurança do acesso à memória, detectando esses erros em tempo de compilação.

Agora que isso foi resolvido, vamos nos aprofundar em nosso todoservice.

Arquitetura da Aplicação

O todoservice expõe seis pontos de extremidade REST:

  • Criar item Todo (tarefa);
  • Excluir item Todo;
  • Obter item Todo por ID;
  • Marcar Todo como concluído;
  • Obter todos os itens Todo;
  • Criar um Todo aleatório.

Os Todos são armazenados no banco de dados PostgresSQL em uma tabela todos:

CREATE TABLE todos (
  id SERIAL PRIMARY KEY,
  title VARCHAR NOT NULL,
  body TEXT NOT NULL,
  completed BOOLEAN NOT NULL DEFAULT FALSE
)
Enter fullscreen mode Exit fullscreen mode

Configuração da Aplicação

A configuração padrão ou básica da aplicação é armazenada em um arquivo json:

config/default.json

{
  "environment": "development",
  "server": {
    "host": "0.0.0.0",
    "port": 8080
  },
  "database": {
    "host": "localhost",
    "port": 5432,
    "name": "tododb",
    "user": "todouser",
    "password": "todopassword"
  },
  "logger": {
    "level": "DEBUG"
  },
  "tracing": {
    "host": "http://localhost:4317"
  },
  "service": {
    "name": "todoservice"
  }
}
Enter fullscreen mode Exit fullscreen mode

As substituições específicas do ambiente são armazenadas em um arquivo separado. Exemplo:

config/production.json

{
  "environment": "production",
  "logger": {
    "level": "INFO"
  }
}
Enter fullscreen mode Exit fullscreen mode

Primeiro carregamos default.json e depois usamos a variável de ambiente ENV para carregar o arquivo de configuração específico do ambiente e sobrepô-lo à configuração base. O resultado da fusão é a configuração que o aplicativo utilizará. Também temos a chance de substituir qualquer item de configuração do arquivo usando variáveis ​​de ambiente.

src/config.rs

impl Configurations {
    pub fn new() -> Result<Self, ConfigError> {
        let env = env::var("ENV").unwrap_or_else(|_| "development".into());

        let mut builder = Config::builder()
            .add_source(File::with_name("config/default"))
            .add_source(File::with_name(&format!("config/{env}")).required(false))
            .add_source(File::with_name("config/local").required(false))
            .add_source(Environment::default().separator("__"));

        // Permite a substituição de definições a partir de variáveis de ambiente
        if let Ok(port) = env::var("PORT") {
            builder = builder.set_override("server.port", port)?;
        }
        if let Ok(log_level) = env::var("LOG_LEVEL") {
            builder = builder.set_override("logger.level", log_level)?;
        }

        builder
            .build()?
            // Desserializa (e assim congela) toda a configuração.
            .try_deserialize()
    }
}
Enter fullscreen mode Exit fullscreen mode

No código acima estou mostrando como os arquivos de configuração são carregados e combinados. Você pode juntar quantos arquivos de configuração desejar com várias regras. Também estou mostrando como PORT e LOG_LEVEL são substituídos se forem definidos como variáveis ​​de ambiente.

Estado da Aplicação (Diesel ORM e PostgreSQL)

Estou usando Diesel e PostgreSQL para armazenar o estado da aplicação no banco de dados. O Diesel facilita o manuseio das migrações de banco de dados e gera código para nosso caso de uso CRUD (Create, Read, Update, Delete - Criar, Ler, Atualizar, Excluir). Ele também fornece um pool de conexões para tornar o acesso ao banco de dados mais eficiente sob carga pesada em um ambiente multithread, quando várias threads são executadas em um processo ao mesmo tempo.

src/database.rs

pub struct AppState {
    pub pool: Pool<ConnectionManager<PgConnection>>,
}

pub fn get_connection_pool(config: &Configurations) -> AppState {
    let url = get_database_url(config);
    let manager = ConnectionManager::<PgConnection>::new(url);

    let pool = Pool::builder()
        .test_on_check_out(true)
        .build(manager)
        .expect("Could not build connection pool");

    AppState { pool }
}
Enter fullscreen mode Exit fullscreen mode

Eu encapsulo o pool de conexões na struct AppState e chamo get_connection_pool(&config) para criar o pool. Em seguida, no roteador, eu inicializo ele e passo o pool Router::new()...with_state(Arc::new(state)). Observe que inicializo um ARC com o estado e depois passo-o para o Router (roteador). ARC em Rust é Atomically Reference Counted, que é um ponteiro de contagem de referência seguro para threads, que nos permite compartilhar com segurança o pool de banco de dados no servidor HTTP multithread que iremos criar em breve.

API REST (Servidor Axum)

O Axum é um servidor da Web modular multithread e moderno. Nesta aplicação, aproveitaremos sua ergonomia e facilidade de uso, suporte para processamento assíncrono e multithreading, desligamento normal e rastreamento.

Criando o servidor e escutando na porta:

src/main.rs

let app_state = database::get_connection_pool(&config);
    let app = app::create_app(app_state);

    let address: SocketAddr = format!("{}:{}", config.server.host, config.server.port)
        .parse()
        .expect("Unable to parse socket address");

    axum::Server::bind(&address)
        .serve(app.into_make_service())
        .with_graceful_shutdown(...)
        .await
        .expect("Failed to start server");
}
Enter fullscreen mode Exit fullscreen mode

Criando as rotas e anexando o estado da aplicação e a camada de rastreamento às rotas:

src/app.rs

pub fn create_app(state: AppState) -> Router {
    Router::new()
        .route("/todo/:todo_id", get(get_todo))
        .route("/todo/:todo_id", delete(delete_todo))
        .route("/todo/:todo_id", put(complete_todo))
        .route("/todo", post(create_todo))
        .route("/todo", get(get_all_todos))
        .route("/todo/random", post(create_random_todo))
        .with_state(Arc::new(state))
        .layer(TraceLayer::new_for_http())
}
Enter fullscreen mode Exit fullscreen mode

Vamos mergulhar em uma das rotas para ver como tudo funciona junto. Neste caso, vamos olhar para .route("/todo", post(create_todo)) que aceita um item todo como corpo da solicitação json e o insere no banco de dados PostgreSQL usando Diesel.

Primeiro, definimos a struct NewTodo que usaremos para desserializar o corpo Json e, em seguida, usaremos a struct para inserir o registro no banco de dados.

src/models.rs

#[derive(serde::Deserialize, Insertable, Debug)]
#[diesel(table_name = crate::schema::todos)]
pub struct NewTodo {
    pub title: String,
    pub body: String,
}
Enter fullscreen mode Exit fullscreen mode

src/app.rs

#[instrument]
async fn create_todo(
    State(state): State<Arc<AppState>>,
    Json(new_todo): Json<NewTodo>,
) -> Result<Json<Todo>, (StatusCode, String)> {
    let mut conn = state.pool.get().map_err(internal_error)?;

    info!("Creating Todo {:?}", &new_todo);

    let res = diesel::insert_into(todos::table)
        .values(&new_todo)
        .returning(Todo::as_returning())
        .get_result(&mut conn)
        .map_err(internal_error)?;

    Ok(Json(res))
}
Enter fullscreen mode Exit fullscreen mode

Na função create_todo, o async informa ao compilador que esta função é assíncrona e roda em uma thread e retorna um futuro que precisa ser completado (usando await no Rust). Essa função leva dois argumentos que o servidor Axum passará para a função, o state da aplicação, que é o pool de conexão do banco de dados que usaremos para obter uma conexão para interagir com o banco de dados, e o segundo argumento é o corpo JSON desserializado na struct NewTodo. Depois de obtermos a conexão, usamos ela para inserir o registro no banco de dados.

A derivação Insertable na struct NewTodo possibilita usarmos a struct com a API do Diesel. E a derivação serde::Deserialize permite que Axum seja capaz de desserializar automaticamente o corpo Json para a struct e passá-lo como argumento. O Ok(Json(res)) equivale a retornar HTTP 200 com a struct serializada Todo como Json, conforme sugerido pelo tipo de retorno da função Result&lt;Json&lt;Todo>, (StatusCode, String)>.

Chamando uma API Externa (Cliente HTTP Reqwest)

O ponto de extremidade que gera um Todo aleatório chama uma API externa para obter uma atividade aleatória, usamos isso para criar um item Todo aleatório.
Estamos usando reqwest para fazer uma chamada HTTP REST externa assíncrona para obter uma atividade aleatória. E, em seguida, armazená-la como um novo item Todo no banco de dados.

Aqui eu costumo usar request::get para fazer a chamada externa, e devo mencionar que isso, do jeito que está, não é eficiente porque a cada chamada tem que inicializar o cliente http antes de fazer a solicitação propriamente dita. Deixei assim intencionalmente para mostrar o tempo que leva para inicializar o cliente. Falaremos sobre ele, no que se refere ao rastreamento, na próxima seção. Deixo a critério do leitor criar um cliente Reqwest quando a aplicação for inicializada e passar o cliente, por exemplo, no objeto state (de estado) para o router (roteador) e usar o cliente já inicializado para fazer as chamadas http.

src/app.rs

#[instrument]
async fn create_random_todo(
    State(state): State<Arc<AppState>>,
) -> Result<Json<Todo>, (StatusCode, String)> {
    let random_activity: Activity = reqwest::get("https://www.boredapi.com/api/activity")
        .await
        .map_err(internal_error)?
        .json()
        .await
        .map_err(internal_error)?;

    info!("Got: {:?}", random_activity);

    let new_todo = NewTodo {
        title: random_activity.activity,
        body: random_activity.activity_type,
    };

    let mut conn = state.pool.get().map_err(internal_error)?;

    info!("Creating random Todo {:?}", &new_todo);

    let res = diesel::insert_into(todos::table)
        .values(&new_todo)
        .returning(Todo::as_returning())
        .get_result(&mut conn)
        .map_err(internal_error)?;

   Ok(Json(res))
}
Enter fullscreen mode Exit fullscreen mode

Rastreamento (OpenTelemetry e Jaeger)

Esta é a parte divertida. Estou usando o OpenTelemetry tanto para registro como para rastreamento e mostrarei como podemos rastrear funções na interface de usuário do Jaeger.

src/main.rs

fn init_tracer(config: &Configurations) -> Result<opentelemetry_sdk::trace::Tracer, TraceError> {
    opentelemetry_otlp::new_pipeline()
        .tracing()
        .with_exporter(
            opentelemetry_otlp::new_exporter()
                .tonic()
                .with_endpoint(config.tracing.host.clone()),
        )
        .with_trace_config(
            sdktrace::config().with_resource(Resource::new(vec![KeyValue::new(
                "service.name",
                config.service.name.clone(),
            )])),
        )
        .install_batch(runtime::Tokio)
}

#[tokio::main]
async fn main() {
...
// inicializar o rastreamento
    let tracer = init_tracer(&config).expect("Failed to initialize tracer.");
    let fmt_layer = tracing_subscriber::fmt::layer();
    tracing_subscriber::registry()
        .with(tracing_subscriber::EnvFilter::from(&config.logger.level))
        .with(fmt_layer)
        .with(tracing_opentelemetry::layer().with_tracer(tracer))
        .init();
...
}
Enter fullscreen mode Exit fullscreen mode

Cada instrumento de função é simplesmente anotado com #[instrument]. Isso instrui o OpenTelemetry a criar um novo span sempre que esta função for inserida. Para criar eventos ou pontos de instrumentação, podemos simplesmente usar o registro regular, como info!, error!... etc.

Criamos o rastreador e fornecemos a ele o host e a porta do Jaeger para enviar os rastreamentos, e anexamos um registrador para mostrar os logs da aplicação no console também. Por último, instalamos o rastreador como lote, o que torna mais eficiente o envio dos rastreios.

https://miro.medium.com/v2/resize:fit:720/format:webp/1*cEQbi6dYP7EmNduOJEkU3w.png

Criar Todo

https://miro.medium.com/v2/resize:fit:720/format:webp/1*pmuQG72cYNG4qtWbD2z5uA.png

Criar Todo aleatórias

Veja aqui na criação de tarefa aleatória que demorou mais de 208 ms para obter a resposta da solicitação de saída. Isso pode ser melhorado drasticamente removendo a inicialização do cliente desta função conforme descrito acima.

Desligamento Normal (Manipulação do SIGTERM)

Por último, mas não menos importante, queremos lidar com o desligamento normal. Fazemos isso em duas etapas.

Primeiro, configuramos o canal de tratamento do sinal e, quando recebemos o sinal do sistema operacional no qual estamos interessados, enviamos um sinal para a extremidade receptora do canal.

src/shutdown.rs

pub fn register() -> Receiver<()> {
    let signals = Signals::new([SIGHUP, SIGTERM, SIGINT, SIGQUIT]).unwrap();
    signals.handle();
    let (tx, rx): (Sender<()>, Receiver<()>) = oneshot::channel();
    tokio::spawn(handle_signals(signals, tx));
    rx
}

async fn handle_signals(mut signals: Signals, tx: Sender<()>) {
    while let Some(signal) = signals.next().await {
        match signal {
            SIGHUP => {
                // Recarrega a configuração, reabre o arquivo de registro...etc
            }
            SIGTERM | SIGINT | SIGQUIT => {
                // Desliga normalmente/graciosamente
                let _ = tx.send(());
                return;
            }
            _ => unreachable!(),
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Segundo, esperamos no canal receptor. Quando o sinal é recebido, desligamos tudo.

let rx = shutdown::register();

...
.with_graceful_shutdown(async {
    rx.await.ok(); // ISso irá bloquear até ser recebido um sinal de encerramento
    info!("Handling graceful shutdown");
    info!("Close resources, drain and shutdown event handler... etc");
    shutdown_tracer_provider();
})
...
Enter fullscreen mode Exit fullscreen mode

Obrigado por ler e espero que tenham gostado!

Este artigo foi escrito por Dex e traduzido por Isabela Curado Nehme. Seu original pode ser lido aqui.

Latest comments (0)