24 de Janeiro de 2023
Como desenvolvedores corporativos, muitas vezes nos encontramos resolvendo problemas similares de um projeto para o outro. Sem dúvida, trabalhar com banco de dados é um desses. Neste tutorial, mostrarei a você uma maneira de consumir uma API REST paginada com tokio e mantê-la no Postgres. Apesar da Rust ser uma linguagem relativamente nova, já existem muitos crates para manipulação de banco de dados para escolher. Aqui vamos trabalhar com sqlx.
Pré-requisitos
Estou usando a versão 1.66.0 da Rust. Este tutorial pressupõe conhecimento prévio de Rust e se concentra mais no uso prático da linguagem. Verifique a seção Background no meu tutorial anterior para entender melhor a API que estamos trabalhando. Mas, em resumo, é com a ajuda da API ImmutableX que podemos buscar todos os eventos de cunhagem que já aconteceram na blockchain.
Como sempre, as fontes do projeto estão disponíveis no GitHub, encontre o link no final da página.
Existem 3 partes neste tutorial: Postgres e configuração do ambiente, leitura da API e trabalhando com Postgres.
Parte 1. Postgres e configuração do ambiente
Esta seção não é necessária para a parte de codificação Rust deste tutorial. No entanto, para fins de integridade, também gostaria de mostrar a você como configurar seu ambiente local (e potencialmente de produção). Mas, se você estiver interessado apenas na linguagem, sinta-se à vontade para pular para o próximo capítulo.
Para obter um ambiente local sem complicações e reproduzível, usaremos o docker-compose:
services:
db:
image: postgres:14.4
restart: 'no'
environment:
POSTGRES_PASSWORD: notsecure
POSTGRES_USER: data-loader-local
POSTGRES_DB: illuvium-land
ports:
- '5432:5432'
Ainda por cima, teremos dois scripts bash para simplificar ainda mais o processo:
#!/bin/bash
SCRIPT_PATH=$(dirname $(realpath -s $0))
echo "Starting environment..."
docker-compose -p data-loader -f $SCRIPT_PATH/docker-compose.yaml up -d --build
echo
printf "Waiting for DB"
while ! curl http://localhost:5432/ 2>&1 | grep '52' > /dev/null ; do
printf "."
sleep 1
done
echo
echo "Everything is up and running"
#!/bin/bash
SCRIPT_PATH=$(dirname $(realpath -s $0))
echo "Stopping environment..."
docker-compose -p data-loader -f $SCRIPT_PATH/docker-compose.yaml down
Agora, você pode executar o script start-local-environment.sh
e ele irá buscar o container e iniciá-lo:
Para finalizar a configuração do ambiente, temos uma decisão importante a fazer - como migrar o banco de dados? Existem muitas opções disponíveis para essa tarefa e a escolha final é sua. Se você preferir ficar com as ferramentas escritas em Rust, posso sugerir a refinery, mas aqui vou com a flyway
, devido ao seu suporte docker-compose
fora do comum.
services:
db:
image: postgres:14.4
restart: 'no'
environment:
POSTGRES_PASSWORD: notsecure
POSTGRES_USER: data-loader-local
POSTGRES_DB: illuvium-land
ports:
- '5432:5432'
flyway:
image: flyway/flyway:7.5.1
command: -url=jdbc:postgresql://db/illuvium-land -schemas=public -user=data-loader-local -password=notsecure -connectRetries=60 migrate
volumes:
- ./migrations:/flyway/sql
depends_on:
- db
Observação! Como você pode ver, todas as credenciais DB (data base ou banco de dados) são intencionalmente codificadas. Uma vez que é o seu ambiente de desenvolvimento local (ou talvez o pipeline de CI), é considerado certo fazê-lo dessa maneira. Obviamente, para um código de infraestrutura de produção, você deve usar um local seguro para armazenar essas credenciais.
A última parte é criar a pasta migrations
onde todos os futuros scripts de migração irão residir:
O ambiente local está agora completo!
Parte 2. Leitura de API
Como mencionado anteriormente, para ler as cunhagens da API o crate tokio
será usado. O que difere este tutorial do anterior é que, neste caso, estaremos lendo de forma paginada. Primeiro de tudo, devemos criar um modelo:
use serde::{Deserialize};
#[derive(Deserialize, Debug)]
pub struct Mint {
pub result: Vec<TheResult>,
pub cursor: String,
}
#[derive(Deserialize, Debug)]
pub struct TheResult {
#[serde(rename = "timestamp")]
pub minted_on: String,
pub transaction_id: i32,
pub status: String,
#[serde(rename = "user")]
pub wallet: String,
pub token: Token,
}
#[derive(Deserialize, Debug)]
pub struct Token {
#[serde(rename = "type")]
pub the_type: String,
pub data: Data,
}
#[derive(Deserialize, Debug)]
pub struct Data {
pub token_id: String,
}
Vamos processar apenas os campos que são usados posteriormente, portanto, nem todos os campos que a API expõe. Agora, vamos criar um módulo responsável por buscar os dados. Para fazer uso da paginação de resposta, o campo cursor
será utilizado. Esse campo é retornado na resposta e seu valor aponta para a página anterior, se existir uma. Caso contrário, é uma string vazia.
use log::{error, info};
use serde::de::DeserializeOwned;
use crate::model::mint::Mint;
const MINTS_URL: &str = "https://api.x.immutable.com/v1/mints?token_address=0x9e0d99b864e1ac12565125c5a82b59adea5a09cd&page_size=200";
#[tokio::main]
pub async fn read() {
let mut cursor = None;
loop {
cursor = execute_and_get_cursor(cursor).await;
if cursor.is_none() {
break;
} else {
info!("Current cursor: {}", cursor.clone().unwrap());
}
}
}
async fn execute_and_get_cursor(cursor: Option<String>) -> Option<String> {
let url = if cursor.is_some() { MINTS_URL.to_owned() + "&cursor=" + cursor.unwrap().as_str() } else { String::from(MINTS_URL) };
let response = fetch_api_response::<Mint>(url.as_str()).await;
match response {
Ok(mint) => {
info!("Processing mint response");
if !mint.result.is_empty() {
// será salvo no banco de dados mais tarde
info!("{:?}", mint.result);
}
if !mint.cursor.is_empty() {
return Some(mint.cursor);
}
None
}
Err(e) => {
error!("Mints API response cannot be parsed! {}", e);
None
}
}
}
async fn fetch_api_response<T: DeserializeOwned>(endpoint: &str) -> reqwest::Result<T> {
let result = reqwest::get(endpoint)
.await?.json::<T>()
.await?;
return Ok(result);
}
Em algumas outras linguagens, existe um tipo de loop do..while
que é idiomaticamente equivalente a
loop {
call_function();
if condition {
break;
}
}
que foi implementado com a ajuda do tipo Option
.
Mais uma vez, gostaria de enfatizar a importância de saber lidar adequadamente com os erros. Você deve limitar o uso da função unwrap
para ambos teste/prototipagem ou quando não ter o resultado é o motivo “válido” para travar o aplicativo - embora aqui eu ainda recomendaria usar expect
para especificar um erro exato. Ao seguir isso, seu aplicativo executará de forma confiável e provavelmente livre de pane.
Se você executar o aplicativo agora, ele esperançosamente produzirá vários logs. É hora de seguir para a camada de persistência do aplicativo.
Parte 3. Trabalhando com Postgres
Já conhecemos o modelo para manter - mint
. Assim, podemos criar um script de migração para ele:
CREATE table mint
(
transaction_id integer PRIMARY KEY,
status varchar(50),
wallet varchar(255),
token_type varchar(15),
token_id varchar(15),
minted_on timestamp
);
Reinicie o ambiente com os scripts bash anteriormente e você deve ver duas tabelas criadas:
Agora vamos criar um módulo responsável por lidar com o banco de dados - db_handler.rs
. Eu escolhi sqlx
porque prefiro ter total controle sobre os scripts SQL em vez de transferi-los para um ORM (Object-Relational Mapping ou mapeamento objeto-relacional). Da seção anterior, sabemos que a API é consumida em um loop, portanto, devemos open_connection
ao banco de dados antes do início do loop save_mints
e close_connection
, uma vez que todos os mints
estiverem salvos. Com tudo isso, podemos definir aquelas três funções para finalizar o mints_reader.rs
:
...
#[tokio::main]
pub async fn read() {
let pool = db_handler::open_connection().await;
let mut cursor = None;
loop {
cursor = execute_and_get_cursor(cursor, &pool).await;
if cursor.is_none() {
break;
} else {
info!("Current cursor: {}", cursor.clone().unwrap());
}
}
db_handler::close_connection(pool).await;
}
async fn execute_and_get_cursor(cursor: Option<String>, pool: &Pool<Postgres>) -> Option<String> {
let url = if cursor.is_some() { MINTS_URL.to_owned() + "&cursor=" + cursor.unwrap().as_str() } else { String::from(MINTS_URL) };
let response = fetch_api_response::<Mint>(url.as_str()).await;
match response {
Ok(mint) => {
info!("Processing mint response");
if !mint.result.is_empty() {
db_handler::save_mints(mint.result, pool).await;
}
if !mint.cursor.is_empty() {
return Some(mint.cursor);
}
None
}
...
}
}
...
Em seguida, vamos implementar as funções que estão faltando - começando com gerenciamento de conexão. Para conectar ao banco de dados as credenciais esperadas devem ser fornecidas e existem várias maneiras de fazê-lo. Neste tutorial, usaremos o crate dotenvy que depende do arquivos .env
estar presente e preenchido com os valores esperados. Seguindo a documentação oficial da sqlx
, a implementação será como a seguir:
pub async fn open_connection() -> Pool<Postgres> {
let options = PgConnectOptions::new()
.host(env::var("DB_HOST").expect("DB_HOST should be set").as_str())
.port(env::var("DB_PORT").expect("DB_PORT should be set").parse().expect("DB_PORT should be a valid u16 value"))
.database(env::var("DB_DATABASE").expect("DB_DATABASE should be set").as_str())
.username(env::var("DB_USERNAME").expect("DB_USERNAME should be set").as_str())
.password(env::var("DB_PASSWORD").expect("DB_PASSWORD should be set").as_str())
.disable_statement_logging()
.clone();
PgPoolOptions::new()
.max_connections(5)
.connect_with(options)
.await
.expect("DB is not accessible!")
}
pub async fn close_connection(pool: Pool<Postgres>) {
pool.close().await;
}
Por último, vamos ter o código para salvar os dados mints
no DB (data base ou banco de dados). Cada resposta da API retorna 200 valores e poderíamos inseri-los um a um. Mas, felizmente, não precisamos, já que a sqlx
tem um método muito útil QueryBuilder::push_values que gera uma consulta de inserção em massa.
pub async fn save_mints(mint_result: Vec<TheResult>, connection: &Pool<Postgres>) {
let mut query_builder: QueryBuilder<Postgres> = QueryBuilder::new(
"insert into mint (transaction_id, status, wallet, token_type, token_id, minted_on) "
);
query_builder.push_values(mint_result, |mut builder, res| {
builder.push_bind(res.transaction_id)
.push_bind(res.status.clone())
.push_bind(res.wallet.clone())
.push_bind(res.token.the_type.clone())
.push_bind(res.token.data.token_id.clone())
.push_bind(DateTime::parse_from_rfc3339(&res.minted_on).unwrap());
});
let query = query_builder.build();
match query.execute(connection)
.await {
Ok(result) => {
info!("Inserted {} rows", result.rows_affected())
}
Err(e) => {
error!("Couldn't insert values due to {}", e)
}
}
}
Para simplificar, todos os possíveis erros na inserção são tratados da mesma maneira. Caso você precise distinguir uns erros dos outros, os códigos de erro retornados devem corresponder adequadamente. Para saber mais - verifique a documentação oficial sqlx
sobre DatabaseErrors (ou erros de banco de dados).
Mais tarde
Saber como trabalhar com banco de dados é uma habilidade essencial que cada desenvolvedor deveria ter em seu conjunto de ferramentas. Com uma abundância de crates, a Rust oferece uma grande variedade de formas de alcançar isso. A escolha é sua.
Todos os códigos podem ser encontrados neste repositório GitHub.
Para mais tutoriais sobre Rust, certifique-se de verificar Rust: How to consume REST API (ou Rust: como consumir a API REST) e Rust: How to create Telegram bot (ou Rust: como criar um bot do Telegram).
Agradecimentos especiais ao meu querido amigo e camarada Andrei Kochemirovskii pela revisão dos códigos e contribuições valiosas.
Apoio
Se você gostou do conteúdo que leu e deseja apoiar o autor, muito obrigado!
Aqui está minha carteira Ethereum para gorjetas:
0xB34C2BcE674104a7ca1ECEbF76d21fE1099132F0
Esse artigo foi escrito pela Pudding Entertainment e traduzido por Isabela Curado Nehme. Seu original pode ser lido aqui.
Latest comments (0)