Leodario.com

Leodario.com – Tudo sobre Tecnologia

Aula 55 – API JavaScript, Node.js e Express – Transactions – ACID properties

Imagem destacada da aula de API

Introdução

Olá, futuro mestre das APIs! Seja muito bem-vindo à Aula 55, onde desvendaremos um dos pilares mais fundamentais na construção de sistemas robustos e confiáveis: as Transações e suas propriedades ACID. Prepare-se para elevar o nível da sua programação.

Para começarmos, imagine que você está realizando uma transferência bancária online. Você deseja mover R$100 da sua conta para a conta de um amigo. Esta operação, que parece simples, na verdade envolve duas etapas cruciais: 1) debitar R$100 da sua conta e 2) creditar R$100 na conta do seu amigo. O que aconteceria se, após o débito da sua conta, o sistema falhasse antes de creditar o dinheiro na conta do seu amigo? O dinheiro simplesmente sumiria no limbo digital, certo? Isso seria um desastre!

É exatamente para evitar cenários como este que as transações são indispensáveis em APIs modernas. Elas garantem que um conjunto de operações de banco de dados seja tratado como uma única unidade lógica, um “tudo ou nada”. Ou todas as operações são concluídas com sucesso, ou nenhuma delas é. Isso é essencial para manter a integridade e a consistência dos dados.

Nesta aula, você aprenderá o conceito técnico das transações, entenderá profundamente as propriedades ACID (Atomicidade, Consistência, Isolamento e Durabilidade), e, o mais relevante, como implementá-las de forma prática e segura em suas APIs Node.js e Express, utilizando um banco de dados relacional. Vamos contextualizar tudo isso para o ambiente Node.js/Express, demonstrando como orquestrar essas operações vitais em seus endpoints.

Conceito Fundamental

No cerne da gestão de dados confiáveis, encontramos as Transações de Banco de Dados. Uma transação é definida como uma sequência de uma ou mais operações lógicas sobre um banco de dados que é executada como uma unidade indivisível. Para que um sistema de banco de dados possa garantir a confiabilidade mesmo diante de falhas, ele deve aderir a um conjunto de propriedades que são coletivamente conhecidas pelo acrônimo ACID.

As propriedades ACID são um contrato que o sistema de gerenciamento de banco de dados (SGBD) cumpre para assegurar que cada transação de dados seja processada de uma maneira confiável. Vamos explorá-las:

    • Atomicidade (Atomicity): Esta é a propriedade “tudo ou nada”. Se qualquer parte da transação falhar, a transação inteira é abortada, e o banco de dados retorna ao seu estado original antes do início da transação. É como uma operação química onde todos os reagentes são necessários para um produto final; se um reagente estiver faltando, a reação não ocorre e nada é produzido. No nosso exemplo bancário, ou o dinheiro é debitado e creditado, ou nenhuma das duas coisas acontece.
    • Consistência (Consistency): Uma transação deve levar o banco de dados de um estado válido para outro estado válido. Isso significa que todas as regras, restrições (constraints), gatilhos (triggers) e integridade referencial do banco de dados devem ser mantidas antes e depois da transação. A transação não pode violar nenhuma regra pré-estabelecida.
    • Isolamento (Isolation): Esta propriedade garante que a execução de transações concorrentes produza o mesmo resultado que se elas tivessem sido executadas sequencialmente. Em outras palavras, cada transação deve ser executada de forma isolada, sem interferência de outras transações em andamento. Existem diferentes níveis de isolamento (leitura suja, leitura repetida, serializável), cada um com seus próprios trade-offs entre consistência e performance.
    • Durabilidade (Durability): Uma vez que uma transação é confirmada (commitada), suas modificações são permanentes e devem sobreviver a quaisquer falhas subsequentes do sistema (como quedas de energia ou travamentos do servidor). Os dados são gravados de forma persistente no armazenamento não volátil.

A terminologia da indústria é clara: você inicia uma transação (BEGIN TRANSACTION), executa suas operações, e então confirma (COMMIT) as mudanças se tudo correr bem, ou reverte (ROLLBACK) as mudanças se algo falhar. Isso é o cerne da gestão de transações.

Casos de Uso Reais e Vantagens

Transações são vitais em sistemas de e-commerce (garantir que um item seja removido do estoque apenas se o pagamento for bem-sucedido), sistemas de reservas (um assento não pode ser reservado por duas pessoas ao mesmo tempo), e claro, em sistemas financeiros. Elas se integram profundamente com bancos de dados relacionais como PostgreSQL, MySQL, SQL Server, e podem ser abstraídas por ORMs (Object-Relational Mappers) como Sequelize, TypeORM ou Prisma, que oferecem interfaces mais amigáveis para gerenciar estas operações.

As principais vantagens incluem a garantia de integridade de dados, a robustez do sistema frente a erros e a facilidade de recuperação. Por outro lado, o gerenciamento de transações pode introduzir um certo overhead de performance devido ao bloqueio de recursos e à complexidade inerente de coordenar múltiplas operações, além de não ser um conceito nativo para muitos bancos de dados NoSQL.

Implementação Prática

Agora que compreendemos a teoria, vamos construir uma API Node.js/Express que demonstra a implementação de transações ACID. Usaremos PostgreSQL como nosso banco de dados relacional, uma escolha robusta e comum no ambiente enterprise.

Primeiro, certifique-se de ter o Node.js instalado e um servidor PostgreSQL configurado e rodando. Você precisará de um banco de dados e de um usuário com permissões adequadas. Para o HostGator Plano M, o Node.js é suportado, e você pode configurar um banco de dados PostgreSQL ou MySQL através do cPanel, obtendo as credenciais necessárias para a conexão.

Vamos criar um exemplo de transferência bancária simplificada entre duas contas.

Estrutura do Projeto


meu-app-transacoes/
├── src/
│   ├── config/
│   │   └── db.js
│   ├── routes/
│   │   └── financialRoutes.js
│   └── app.js
└── package.json

Passo 1: Inicialize o projeto e instale as dependências


mkdir meu-app-transacoes
cd meu-app-transacoes
npm init -y
npm install express pg dotenv winston

dotenv será para variáveis de ambiente e winston para logging profissional (embora usaremos console.log para simplificar o exemplo, mas faremos menção). pg é o cliente PostgreSQL.

Passo 2: Configuração do Banco de Dados (src/config/db.js)

Crie o arquivo .env na raiz do projeto:


DB_HOST=localhost
DB_PORT=5432
DB_USER=seu_usuario
DB_PASSWORD=sua_senha
DB_DATABASE=seu_banco_de_dados

Dica HostGator Plano M: No cPanel, você encontrará os detalhes do “Host”, “Porta” (geralmente 3306 para MySQL, mas pode ser 5432 para PostgreSQL se disponível e configurado), “Usuário”, “Senha” e “Nome do Banco de Dados”. Ajuste estas variáveis de ambiente conforme seu ambiente HostGator.

src/config/db.js:


require('dotenv').config(); // Carrega as variáveis de ambiente

const { Pool } = require('pg'); // Importa a classe Pool do pacote 'pg'

// Configurações do pool de conexões com o banco de dados PostgreSQL const pool = new Pool({ user: process.env.DB_USER, host: process.env.DB_HOST, database: process.env.DB_DATABASE, password: process.env.DB_PASSWORD, port: process.env.DB_PORT, max: 10, // Número máximo de clientes ociosos no pool idleTimeoutMillis: 30000, // Tempo de vida de um cliente ocioso antes de ser desconectado connectionTimeoutMillis: 2000, // Tempo máximo para adquirir um cliente do pool });

// Listener para erros no pool de conexões pool.on('error', (err, client) => { console.error('Erro inesperado no cliente ou pool de banco de dados', err); // Loga o erro // Recomenda-se um logger mais robusto como Winston ou Pino em produção process.exit(-1); // Encerra o processo em caso de erro crítico no pool });

// Função para obter um cliente do pool de forma assíncrona async function getClient() { try { const client = await pool.connect(); // Tenta conectar e obter um cliente // console.info('Cliente de banco de dados obtido com sucesso.'); // Log para depuração return client; // Retorna o cliente conectado } catch (error) { console.error('Falha ao obter cliente do pool de banco de dados:', error.message); // Loga a falha throw new Error('Falha ao conectar ao banco de dados.'); // Lança um erro para ser tratado } }

module.exports = { pool, // Exporta o pool para ser usado diretamente, se necessário getClient // Exporta a função para obter um cliente avulso };

Passo 3: Criação das Tabelas

Conecte-se ao seu banco de dados PostgreSQL (via psql, DBeaver, pgAdmin, etc.) e execute estes comandos para criar as tabelas accounts:


-- Tabela para armazenar informações das contas
CREATE TABLE IF NOT EXISTS accounts (
    id SERIAL PRIMARY KEY,
    owner_name VARCHAR(100) NOT NULL,
    balance NUMERIC(15, 2) NOT NULL DEFAULT 0.00 CHECK (balance >= 0)
);

-- Inserir algumas contas para teste INSERT INTO accounts (owner_name, balance) VALUES ('Alice Silva', 1000.00), ('Bruno Souza', 500.00), ('Carlos Oliveira', 200.00);

Passo 4: Definição das Rotas Financeiras (src/routes/financialRoutes.js)

Aqui implementaremos o endpoint de transferência com transação.


const express = require('express');
const { getClient } = require('../config/db'); // Importa a função para obter um cliente
const router = express.Router(); // Cria um novo roteador Express

// Middleware simples para validação de entrada function validateTransferInput(req, res, next) { const { fromAccountId, toAccountId, amount } = req.body;

if (!fromAccountId || !toAccountId || !amount) { return res.status(400).json({ message: 'Dados de transferência incompletos.' }); } if (isNaN(amount) || parseFloat(amount) <= 0) { return res.status(400).json({ message: 'O valor da transferência deve ser um número positivo.' }); } if (fromAccountId === toAccountId) { return res.status(400).json({ message: 'Não é possível transferir para a mesma conta.' }); } next(); // Se a validação passar, prossegue para a próxima função (o handler da rota) }

/* Rota para realizar uma transferência bancária entre contas. Demonstra as propriedades ACID utilizando uma transação de banco de dados. / router.post('/transfer', validateTransferInput, async (req, res) => { // Extrai os dados da requisição const { fromAccountId, toAccountId, amount } = req.body; const transferAmount = parseFloat(amount); // Converte o valor para float

let client; // Declara a variável client fora do try para que seja acessível no finally

try { client = await getClient(); // Obtém um cliente de conexão do pool await client.query('BEGIN'); // INICIA A TRANSAÇÃO

// 1. Verificar saldo da conta de origem e bloquear a linha para outras transações (SELECT ... FOR UPDATE) const fromAccountResult = await client.query( 'SELECT balance FROM accounts WHERE id = $1 FOR UPDATE', // FOR UPDATE bloqueia a linha [fromAccountId] );

if (fromAccountResult.rows.length === 0) { await client.query('ROLLBACK'); // Reverte se a conta de origem não existir console.warn(Tentativa de transferência: Conta de origem ${fromAccountId} não encontrada.); return res.status(404).json({ message: 'Conta de origem não encontrada.' }); }

const fromAccountBalance = parseFloat(fromAccountResult.rows[0].balance);

if (fromAccountBalance < transferAmount) { await client.query('ROLLBACK'); // Reverte se não houver saldo suficiente console.warn(Tentativa de transferência: Saldo insuficiente na conta ${fromAccountId}.); return res.status(400).json({ message: 'Saldo insuficiente na conta de origem.' }); }

// 2. Debitar o valor da conta de origem await client.query( 'UPDATE accounts SET balance = balance - $1 WHERE id = $2', [transferAmount, fromAccountId] );

// 3. Verificar se a conta de destino existe (não precisamos de FOR UPDATE aqui, pois apenas atualizamos) const toAccountResult = await client.query( 'SELECT id FROM accounts WHERE id = $1', [toAccountId] );

if (toAccountResult.rows.length === 0) { await client.query('ROLLBACK'); // Reverte se a conta de destino não existir console.warn(Tentativa de transferência: Conta de destino ${toAccountId} não encontrada.); return res.status(404).json({ message: 'Conta de destino não encontrada.' }); }

// 4. Creditar o valor na conta de destino await client.query( 'UPDATE accounts SET balance = balance + $1 WHERE id = $2', [transferAmount, toAccountId] );

await client.query('COMMIT'); // CONFIRMA A TRANSAÇÃO (todas as operações foram bem-sucedidas) console.info(Transferência de ${transferAmount} de ${fromAccountId} para ${toAccountId} realizada com sucesso.); res.status(200).json({ message: 'Transferência realizada com sucesso!' });

} catch (error) { // Se algo der errado em qualquer ponto, o bloco catch é ativado if (client) { // Verifica se um cliente foi obtido antes de tentar o rollback await client.query('ROLLBACK'); // REVERTE A TRANSAÇÃO (nenhuma operação é persistida) console.error('Erro durante a transferência, transação revertida:', error.message); } else { console.error('Erro fatal: Falha ao obter cliente de banco de dados ou erro antes do BEGIN:', error.message); } res.status(500).json({ message: 'Erro interno no servidor ao processar a transferência.' }); } finally { if (client) { client.release(); // Sempre libera o cliente de volta para o pool // console.info('Cliente de banco de dados liberado.'); } } });

// Rota para consultar o saldo de uma conta (fora de transação, leitura simples) router.get('/account/:id/balance', async (req, res) => { const { id } = req.params; let client; try { client = await getClient(); const result = await client.query('SELECT owner_name, balance FROM accounts WHERE id = $1', [id]); if (result.rows.length === 0) { return res.status(404).json({ message: 'Conta não encontrada.' }); } res.status(200).json(result.rows[0]); } catch (error) { console.error(Erro ao consultar saldo da conta ${id}:, error.message); res.status(500).json({ message: 'Erro interno do servidor.' }); } finally { if (client) { client.release(); } } });

module.exports = router; // Exporta o roteador para ser usado pelo app Express

Passo 5: Aplicação Express (src/app.js)


const express = require('express');
const financialRoutes = require('./routes/financialRoutes'); // Importa as rotas financeiras

const app = express(); // Inicializa a aplicação Express const PORT = process.env.PORT || 3000; // Define a porta do servidor

app.use(express.json()); // Middleware para parsear JSON no corpo das requisições

// Middleware de log de requisições (exemplo simples) app.use((req, res, next) => { console.log([${new Date().toISOString()}] ${req.method} ${req.url}); next(); });

// Adiciona as rotas financeiras à aplicação app.use('/api', financialRoutes);

// Rota padrão para verificar se a API está funcionando app.get('/', (req, res) => { res.send('API de Transações está online!'); });

// Handler de erro genérico app.use((err, req, res, next) => { console.error('Erro não tratado:', err.stack); res.status(500).json({ message: 'Ocorreu um erro inesperado no servidor.' }); });

// Inicia o servidor app.listen(PORT, () => { console.info(Servidor rodando na porta ${PORT}); console.info(Acesse http://localhost:${PORT}); });

Melhores Práticas Enterprise e Configurações HostGator

    • Gerenciamento de Conexões: Usamos pg.Pool, que é uma melhor prática para gerenciar conexões de banco de dados em aplicações Node.js. Ele reutiliza conexões e evita a sobrecarga de abrir e fechar uma nova conexão para cada requisição. Isso é crucial para performance e escalabilidade.
    • Async/Await: O uso de async/await torna o código assíncrono mais legível e gerenciável, facilitando a compreensão do fluxo da transação.
    • Error Handling Otimizado: O bloco try...catch...finally é essencial. O catch garante que um ROLLBACK seja executado em caso de falha, mantendo a atomicidade. O finally assegura que o cliente do banco de dados seja sempre liberado de volta ao pool, independentemente do sucesso ou falha da transação.
    • Validação de Entrada: Incluímos um middleware validateTransferInput para pré-validar os dados da requisição. Isso é uma prática recomendada para evitar processamento desnecessário e erros no banco de dados.
    • Logging Profissional: Embora tenhamos usado console.log/info/warn/error para clareza didática, em um ambiente de produção enterprise, você usaria bibliotecas como Winston ou Pino para um logging mais configurável, com níveis de severidade, rotação de logs e integração com sistemas de monitoramento.
    • Compatibilidade HostGator Plano M: O código apresentado segue padrões Node.js e PostgreSQL. Contanto que o Node.js esteja disponível e você tenha acesso a um banco de dados relacional (PostgreSQL ou MySQL, com as credenciais corretas), este código será funcional. O uso de variáveis de ambiente com dotenv é a forma padrão de gerenciar credenciais, tornando a implantação mais segura e adaptável.

Como Testar

1. Inicie o servidor Node.js:


node src/app.js

2. Consulte os saldos iniciais (Exemplo: Conta 1 (Alice) e Conta 2 (Bruno)):


curl http://localhost:3000/api/account/1/balance

Saída esperada: {"owner_name":"Alice Silva","balance":"1000.00"}

📚 Informações da Aula

Curso: API Completo - Node.js & Express

Tempo estimado: 25 minutos

Pré-requisitos: JavaScript básico

curl http://localhost:3000/api/account/2/balance

Saída esperada: {"owner_name":"Bruno Souza","balance":"500.00"}

3. Realize uma transferência bem-sucedida (Alice transfere 100 para Bruno):


curl -X POST -H "Content-Type: application/json" \
     -d '{"fromAccountId": 1, "toAccountId": 2, "amount": 100}' \
     http://localhost:3000/api/transfer

Saída esperada: {"message":"Transferência realizada com sucesso!"}

4. Verifique os saldos novamente:


curl http://localhost:3000/api/account/1/balance

Saída esperada: {"owner_name":"Alice Silva","balance":"900.00"}

curl http://localhost:3000/api/account/2/balance

Saída esperada: {"owner_name":"Bruno Souza","balance":"600.00"}

5. Tente uma transferência que falhe por saldo insuficiente (Alice tenta transferir 1000 para Bruno, mas só tem 900):


curl -X POST -H "Content-Type: application/json" \
     -d '{"fromAccountId": 1, "toAccountId": 2, "amount": 1000}' \
     http://localhost:3000/api/transfer

Saída esperada: {"message":"Saldo insuficiente na conta de origem."}

6. Verifique que os saldos não foram alterados (devido ao ROLLBACK):


curl http://localhost:3000/api/account/1/balance

Saída esperada: {"owner_name":"Alice Silva","balance":"900.00"}

curl http://localhost:3000/api/account/2/balance

Saída esperada: {"owner_name":"Bruno Souza","balance":"600.00"}

Este exemplo demonstra claramente a atomicidade e consistência garantidas pelas transações.

Exercício Hands-On

Chegou a sua vez de colocar a mão na massa! Este desafio prático consolidará o seu entendimento sobre transações.

Desafio Prático: Compra de Produto

Sua tarefa é criar um novo endpoint na sua API que simule a compra de um produto. Essa operação deve envolver duas etapas essenciais dentro de uma transação única:

    • Decrementar o estoque de um produto.
    • Registrar a compra em uma tabela de pedidos para um usuário específico.

Se o produto não tiver estoque suficiente, a transação deve ser revertida, e nenhuma das operações deve ser persistida.

Passo a Passo da Solução Sugerida

1. Crie as Tabelas Necessárias: No seu banco de dados, adicione as seguintes tabelas:


-- Tabela de Produtos
CREATE TABLE IF NOT EXISTS products (
    id SERIAL PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    price NUMERIC(15, 2) NOT NULL CHECK (price >= 0),
    stock INT NOT NULL DEFAULT 0 CHECK (stock >= 0)
);

-- Tabela de Pedidos CREATE TABLE IF NOT EXISTS orders ( id SERIAL PRIMARY KEY, account_id INT NOT NULL REFERENCES accounts(id), -- Referencia a tabela accounts product_id INT NOT NULL REFERENCES products(id), -- Referencia a tabela products quantity INT NOT NULL CHECK (quantity > 0), order_date TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP );

-- Insere alguns produtos para teste INSERT INTO products (name, price, stock) VALUES ('Livro "Aprendendo APIs"', 50.00, 10), ('Camiseta Node.js', 75.00, 5), ('Caneca "ACID"', 30.00, 2);

2. Adicione uma Nova Rota em src/routes/financialRoutes.js: Crie um endpoint POST /api/buy-product.

3. Implemente a Lógica da Transação:

    • Obtenha um cliente do pool (await getClient()).
    • Inicie a transação (await client.query('BEGIN')).
    • Validação e Bloqueio de Estoque:
      • Consulte o produto pelo product_id, usando SELECT ... FOR UPDATE para bloquear a linha do estoque.
      • Verifique se o produto existe e se o stock é suficiente para a quantity desejada.
      • Se não houver estoque, faça ROLLBACK e retorne uma mensagem de erro.
    • Decrementar Estoque:
      • Atualize a tabela products, decrementando o stock pela quantity comprada.
    • Registrar Pedido:
      • Insira um novo registro na tabela orders, vinculando account_id, product_id e quantity.
    • Confirmar ou Reverter:
      • Se todas as etapas forem bem-sucedidas, faça await client.query('COMMIT').
      • Se ocorrer qualquer erro (capturado pelo catch), faça await client.query('ROLLBACK').
    • Liberar o Cliente: No bloco finally, sempre chame client.release().

Como Testar e Validar

1. Inicie seu servidor Node.js.

2. Consulte o estoque inicial de um produto (ex: produto ID 1, “Livro”):


curl http://localhost:3000/api/product/1/stock # Você precisará criar esta rota GET para consultar o estoque

3. Tente uma compra bem-sucedida (ex: conta ID 1 compra 1 “Livro”):


curl -X POST -H "Content-Type: application/json" \
     -d '{"accountId": 1, "productId": 1, "quantity": 1}' \
     http://localhost:3000/api/buy-product

4. Verifique o estoque do produto e a tabela de orders para confirmar que a transação foi persistida.

5. Tente uma compra que exceda o estoque (ex: tente comprar 20 “Livros” quando há apenas 9 em estoque):


curl -X POST -H "Content-Type: application/json" \
     -d '{"accountId": 1, "productId": 1, "quantity": 20}' \
     http://localhost:3000/api/buy-product

6. Verifique o estoque do produto e a tabela de orders novamente. O estoque não deve ter mudado, e nenhum pedido novo deve ter sido registrado, demonstrando o ROLLBACK.

Troubleshooting de Erros Comuns

    • Falha de Conexão com o Banco de Dados: Verifique suas variáveis de ambiente no arquivo .env (DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_DATABASE). Certifique-se de que o PostgreSQL está rodando e acessível da sua aplicação.
    • ROLLBACK Não Chamado: Certifique-se de que seu catch está tratando a exceção e chamando client.query('ROLLBACK') antes de liberar o cliente.
    • client.release() Faltando: É crucial liberar o cliente de volta ao pool no bloco finally. A falta disso pode esgotar as conexões do seu pool.
    • Deadlock: Em sistemas de alta concorrência, duas transações podem tentar bloquear os mesmos recursos em ordens diferentes, resultando em um deadlock. O SGBD geralmente detecta e reverte uma das transações. Estratégias como ordenar acesso a recursos ou usar níveis de isolamento mais altos podem ajudar.
    • Saldo/Estoque Insuficiente Ignorado: Confirme suas cláusulas if para verificar saldo/estoque antes de realizar as operações de UPDATE.

Próximos Passos Sugeridos

Para aprofundar ainda mais, sugiro que você explore:

    • Níveis de Isolamento de Transações: Pesquise sobre READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ e SERIALIZABLE. Entenda os prós e contras de cada um e quando usá-los.
    • ORMs e Transações: Explore como ORMs populares como Sequelize, TypeORM ou Prisma simplificam o gerenciamento de transações, oferecendo métodos de alto nível (ex: sequelize.transaction()).
    • Padrão Saga para Microserviços: Em arquiteturas de microserviços, onde uma transação pode abranger múltiplos serviços e bancos de dados, o conceito de transações ACID puras é difícil. O padrão Saga é uma alternativa para gerenciar consistência distribuída.

Parabéns por concluir esta aula! Você agora possui um conhecimento sólido e prático sobre transações ACID, uma habilidade inestimável para construir APIs confiáveis e de nível enterprise. Continue praticando e explorando!

🚀 Pronto para a próxima aula?

Continue sua jornada no desenvolvimento de APIs e domine Node.js & Express!

📚 Ver todas as aulas