035.3 Lição 1
Certificação: |
Web Development Essentials |
---|---|
Versão: |
1.0 |
Tópico: |
035 Programação do servidor NodeJS |
Objetivo: |
035.3 Noções básicas de SQL |
Lição: |
1 de 1 |
Introdução
Embora seja possível escrever suas próprias funções para implementar um armazenamento persistente, costuma ser mais conveniente usar um sistema de gerenciamento de banco de dados para acelerar o desenvolvimento e garantir melhor segurança e estabilidade aos dados formatados em tabela. A estratégia mais popular para armazenar dados organizados em tabelas inter-relacionadas, especialmente quando essas tabelas são pesadamente consultadas e atualizadas, é instalar um banco de dados relacional que suporte Structured Query Language (SQL), uma linguagem voltada para bancos de dados relacionais. O Node.js oferece suporte a diversos sistemas de gerenciamento de banco de dados SQL. Seguindo os princípios de portabilidade e execução do espaço de usuário adotados pelo Node.js Express, o SQLite é uma boa escolha para o armazenamento persistente dos dados usados por este tipo de servidor HTTP.
SQL
A Structured Query Language é específica aos bancos de dados. As operações de escrita e leitura são expressas em frases chamadas declarações e consultas. Tanto as declarações quanto as consultas são compostas de cláusulas, que definem as condições para a execução da operação.
Nomes e endereços de email, por exemplo, podem ser armazenados em uma tabela de banco de dados contendo os campos name
e email
. Um banco de dados pode conter múltiplas tabelas e, portanto, cada tabela deve ter um nome exclusivo. Se usarmos o nome contacts
para a tabela de nomes e emails, um novo registro poderá ser inserido com a seguinte declaração:
INSERT INTO contacts (name, email) VALUES ("Carol", "carol@example.com");
Esta declaração de inserção é composta pela cláusula INSERT INTO
, que define a tabela e os campos onde os dados serão inseridos. A segunda cláusula, VALUES
, define os valores a inserir. Não é necessário colocar as cláusulas em maiúsculas, mas costumamos fazer isso para ser mais fácil reconhecer as palavras-chave do SQL em uma declaração ou consulta.
Uma consulta na tabela de contatos seria feita de maneira semelhante, mas usando a cláusula SELECT
:
SELECT email FROM contacts;
dave@example.com
carol@example.com
Neste caso, a cláusula SELECT email
seleciona um campo dentre as entradas da tabela contacts
. A cláusula WHERE
restringe a consulta a linhas específicas:
SELECT email FROM contacts WHERE name = "Dave";
dave@example.com
O SQL tem muitas outras cláusulas, que veremos em seções posteriores. Antes disso, é necessário entender como é possível integrar o banco de dados SQL com o Node.js.
SQLite
O SQLite é provavelmente a solução mais simples para incorporar recursos de banco de dados SQL a um aplicativo. Ao contrário de outros sistemas populares de gerenciamento de banco de dados, o SQLite não é um servidor de banco de dados ao qual um cliente se conecta. Em vez disso, o SQLite fornece um conjunto de funções que permitem ao desenvolvedor criar um banco de dados como um arquivo convencional. No caso de um servidor HTTP implementado com o Node.js Express, esse arquivo geralmente está localizado no mesmo diretório que o script do servidor.
Antes de usar o SQLite em Node.js, é necessário instalar o módulo sqlite3
. Execute o seguinte comando no diretório de instalação do servidor, ou seja, o diretório que contém o script Node.js a ser executado.
$ npm install sqlite3
Esteja ciente de que existem diversos módulos que suportam o SQLite, como better-sqlite3
, cujo uso é sutilmente diferente de sqlite3
. Os exemplos nesta lição valem para o módulo sqlite3
e podem não funcionar como esperado caso você escolha outro módulo.
Abrindo o banco de dados
Para demonstrar como um servidor Node.js Express pode trabalhar com um banco de dados SQL, vamos escrever um script que armazena e exibe as mensagens enviadas por um cliente identificado por um cookie. As mensagens são enviadas pelo cliente através do método HTTP POST e a resposta do servidor pode ser formatada em JSON ou HTML (a partir de um template), dependendo do formato solicitado pelo cliente. Esta lição não entrará em detalhes sobre o uso de métodos HTTP, cookies e modelos. Os trechos de código mostrados presumem que você já tenha um servidor Node.js Express em que esses recursos estão configurados e disponíveis.
A forma mais simples de armazenar as mensagens enviadas pelo cliente é colocá-las em uma matriz global, onde cada mensagem enviada anteriormente é associada a uma chave de identificação única para cada cliente. Essa chave pode ser enviada ao cliente na forma de um cookie, que é apresentado ao servidor em solicitações futuras para recuperar as mensagens anteriores.
Todavia, essa abordagem tem um ponto fraco: como as mensagens são armazenadas apenas em uma matriz global, todas serão perdidas quando a sessão atual do servidor for encerrada. Essa é uma das vantagens de se trabalhar com bancos de dados, pois os dados são armazenados de forma persistente e não são perdidos se o servidor for reiniciado.
Usando o arquivo index.js
como script do servidor principal, podemos incorporar o módulo sqlite3
e indicar o arquivo que serve de banco de dados, da seguinte forma:
const sqlite3 = require('sqlite3')
const db = new sqlite3.Database('messages.sqlite3');
Se ele ainda não existir, o arquivo messages.sqlite3
será criado no mesmo diretório do arquivo index.js
. Dentro deste único arquivo, serão armazenadas todas as estruturas e dados respectivos. Todas as operações de banco de dados realizadas no script serão intermediadas pela constante db
, que é o nome dado ao novo objeto sqlite3
que abre o arquivo messages.sqlite3
.
Estrutura de uma tabela
Nenhum dado pode ser inserido no banco de dados até que pelo menos uma tabela seja criada. As tabelas são criadas com a declaração CREATE TABLE
:
db.run('CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY AUTOINCREMENT, uuid CHAR(36), message TEXT)')
O método db.run()
é usado para executar declarações SQL no banco de dados. A declaração em si é escrita como um parâmetro para o método. Embora as declarações SQL devam terminar com um ponto e vírgula quando inseridas em um processador de linha de comando, o ponto e vírgula é opcional nas declarações passadas como parâmetros em um programa.
Como o método run
será executado toda vez que o script for executado com node index.js
, a declaração SQL inclui a cláusula condicional IF NOT EXISTS
para evitar erros em execuções futuras quando a tabela messages
já existir.
Os campos que compõem a tabela messages
são id
, uuid
e message
. O campo id
é um número inteiro único usado para identificar cada entrada na tabela, por isso é criado como PRIMARY KEY
, ou chave primária. As chaves primárias não podem ser nulas e não pode haver duas chaves primárias idênticas na mesma tabela. Assim, quase toda tabela SQL possui uma chave primária para rastrear o conteúdo da tabela. Embora seja possível escolher explicitamente o valor da chave primária de um novo registro (desde que ela ainda não exista na tabela), é conveniente que a chave seja gerada automaticamente. O sinalizador AUTOINCREMENT
no campo id
é usado para esse fim.
Note
|
A configuração explícita de chaves primárias no SQLite é opcional, porque o próprio SQLite cria uma chave primária automaticamente. Conforme declarado na documentação do SQLite: “No SQLite, as linhas da tabela normalmente incluem um |
Os campos uuid
e message
armazenam, respectivamente, a identificação do cliente e o conteúdo da mensagem. Um campo do tipo CHAR(36)
armazena uma quantidade fixa de 36 caracteres e um campo do tipo TEXT
armazena textos de comprimento arbitrário.
Inserção de dados
A principal função do nosso servidor de exemplo é armazenar mensagens vinculadas ao cliente que as enviou. O cliente envia a mensagem no campo message
no corpo da solicitação enviada com o método HTTP POST. A identificação do cliente está em um cookie chamado uuid
. Munidos dessas informações, podemos escrever a rota para o Express inserir novas mensagens no banco de dados:
app.post('/', (req, res) => {
let uuid = req.cookies.uuid
if ( uuid === undefined )
uuid = uuidv4()
// Insert new message into the database
db.run('INSERT INTO messages (uuid, message) VALUES (?, ?)', uuid, req.body.message)
// If an error occurrs, err object contains the error message.
db.all('SELECT id, message FROM messages WHERE uuid = ?', uuid, (err, rows) => {
let expires = new Date(Date.now());
expires.setDate(expires.getDate() + 30);
res.cookie('uuid', uuid, { expires: expires })
if ( req.headers.accept == "application/json" )
res.json(rows)
else
res.render('index', {title: "My messages", rows: rows})
})
})
Desta vez, o método db.run()
executa uma declaração de inserção, mas note que uuid
e req.body.message
não são escritos diretamente na linha da declaração. Em vez disso, os valores foram substituídos por pontos de interrogação. Cada ponto de interrogação corresponde a um parâmetro que segue a declaração SQL no método db.run()
.
O uso de pontos de interrogação como espaços reservados na declaração que é executada no banco de dados permite ao SQLite distinguir mais facilmente entre os elementos estáticos da declaração e seus dados variáveis. Esta estratégia permite que o SQLite escape ou sanitize o conteúdo da variável que faz parte da declaração, evitando uma falha de segurança comum chamada injeção de SQL. Nesse ataque, usuários mal-intencionados inserem declarações SQL nos dados variáveis, na esperança de que essas declarações sejam executadas inadvertidamente; a sanitização impede o ataque, desativando caracteres perigosos nos dados.
Consultas
Como mostrado no código de exemplo, nossa intenção é usar a mesma rota para inserir novas mensagens no banco de dados e gerar a lista de mensagens enviadas anteriormente. O método db.all()
retorna a coleção de todas as entradas na tabela que correspondem aos critérios definidos na consulta.
Ao contrário das declarações executadas por db.run()
, db.all()
gera uma lista de registros que são tratados pela função de seta designada no último parâmetro:
(err, rows) => {}
Esta função, por sua vez, leva dois parâmetros: err
e rows
. O parâmetro err
será usado se ocorrer um erro que impeça a execução da consulta. Se ela for bem-sucedida, todos os registros ficam disponíveis na matriz rows
, onde cada elemento é um objeto correspondente a um único registro da tabela. As propriedades deste objeto correspondem aos nomes dos campos indicados na consulta: uuid
e message
.
A matriz rows
é uma estrutura de dados JavaScript. Como tal, pode ser usada para gerar respostas com métodos fornecidos pelo Express, como res.json()
e res.render()
. Quando renderizado dentro de um modelo EJS, um loop convencional pode listar todos os registros:
<ul>
<% rows.forEach( (row) => { %>
<li><strong><%= row.id %></strong>: <%= row.message %></li>
<% }) %>
</ul>
Em vez de preencher a matriz rows
com todos os registros retornados pela consulta, em alguns casos pode ser mais conveniente tratar cada registro individualmente com o método db.each()
. A sintaxe do método db.each()
é semelhante à do método db.all()
, mas o parâmetro row
em (err, row) ⇒ {}
corresponde a um único registro por vez.
Alterando o conteúdo do banco de dados
Até agora, nosso cliente só pode adicionar e consultar mensagens no servidor. Como o cliente agora conhece o id
das mensagens enviadas anteriormente, podemos implementar uma função para modificar um registro específico. A mensagem modificada também pode ser enviada para uma rota configurada com o método HTTP POST, mas desta vez com um parâmetro de rota para capturar o id
fornecido pelo cliente no caminho da solicitação:
app.post('/:id', (req, res) => {
let uuid = req.cookies.uuid
if ( uuid === undefined ){
uuid = uuidv4()
// 401 Unauthorized
res.sendStatus(401)
}
else {
// Update the stored message
// using named parameters
let param = {
$message: req.body.message,
$id: req.params.id,
$uuid: uuid
}
db.run('UPDATE messages SET message = $message WHERE id = $id AND uuid = $uuid', param, function(err){
if ( this.changes > 0 )
{
// A 204 (No Content) status code means the action has
// been enacted and no further information is to be supplied.
res.sendStatus(204)
}
else
res.sendStatus(404)
})
}
})
Esta rota demonstra como usar as cláusulas UPDATE
e WHERE
para modificar um registro existente. Uma diferença importante em relação aos exemplos anteriores é o uso de parâmetros nomeados, nos quais os valores são agrupados em um único objeto (param
) e passados para o método db.run()
ao invés de especificar cada valor por si mesmo. Neste caso, os nomes dos campos (precedidos por $
) são as propriedades do objeto. Os parâmetros nomeados permitem o uso de nomes de campo (precedidos por $
) como espaços reservados ao invés de pontos de interrogação.
Uma declaração como a do exemplo não causará nenhuma modificação no banco de dados se a condição imposta pela cláusula WHERE
não encontrar uma correspondência entre os registros da tabela. Para avaliar se algum registro foi modificado pela declaração, uma função de retorno de chamada pode ser usada como o último parâmetro do método db.run()
. Dentro da função, o número de registros alterados pode ser consultado em this.changes
. Note que as funções de seta não podem ser usadas neste caso, porque apenas as funções regulares na forma function(){}
definem o objeto this
.
A remoção de um registro é muito semelhante à sua modificação. Podemos, por exemplo, continuar usando o parâmetro de rota :id
para identificar a mensagem a ser excluída, mas desta vez em uma rota invocada pelo método HTTP DELETE do cliente:
app.delete('/:id', (req, res) => {
let uuid = req.cookies.uuid
if ( uuid === undefined ){
uuid = uuidv4()
res.sendStatus(401)
}
else {
// Named parameters
let param = {
$id: req.params.id,
$uuid: uuid
}
db.run('DELETE FROM messages WHERE id = $id AND uuid = $uuid', param, function(err){
if ( this.changes > 0 )
res.sendStatus(204)
else
res.sendStatus(404)
})
}
})
Os registros são excluídos de uma tabela com a cláusula DELETE FROM
. Mais uma vez, usamos a função de retorno de chamada para avaliar quantas entradas foram removidas da tabela.
Fechando o banco de dados
Uma vez definido, o objeto db
pode ser referenciado a qualquer momento durante a execução do script, pois o arquivo de banco de dados permanece aberto durante a sessão atual. Não é comum fechar o banco de dados durante a execução do script.
No entanto, vale a pena instaurar uma função para fechar o banco de dados, de forma a evitar o seu fechamento abrupto quando o processo do servidor é concluído. Embora improvável, o encerramento abrupto do banco de dados pode resultar em inconsistências caso os dados da memória ainda não estejam registrados no arquivo. Por exemplo, pode ocorrer perda de dados com um desligamento abrupto do banco de dados se o script for encerrado pelo usuário pressionando o atalho de teclado Ctrl+C.
Nesse caso do Ctrl+C, o método process.on()
pode interceptar os sinais enviados pelo sistema operacional e executar um desligamento ordenado do banco de dados e do servidor:
process.on('SIGINT', () => {
db.close()
server.close()
console.log('HTTP server closed')
})
O atalho Ctrl+C invoca o sinal do sistema operacional SIGINT
, que encerra um programa em primeiro plano no terminal. Antes de encerrar o processo ao receber o sinal SIGINT
, o sistema invoca a função de retorno de chamada (o último parâmetro no método process.on()
). Dentro da função de retorno de chamada, podemos colocar qualquer código de limpeza, em particular o método db.close()
, para fechar o banco de dados, e server.close()
, que fecha graciosamente a própria instância do Express.
Exercícios Guiados
-
Qual é a finalidade de uma chave primária em uma tabela de banco de dados SQL?
-
Qual a diferença entre as consultas usando
db.all()
edb.each()
? -
Por que é importante usar espaços reservados e não incluir dados enviados pelo cliente diretamente em uma declaração ou consulta SQL?
Exercícios Exploratórios
-
Qual método do módulo
sqlite3
pode ser usado para retornar apenas uma entrada da tabela, mesmo se a consulta corresponder a diversas entradas? -
Suponha que a matriz
rows
foi passada como um parâmetro para uma função de retorno de chamada e contém o resultado de uma consulta feita comdb.all()
. Como um campo chamadoprice
, presente na primeira posição derows
, pode ser referenciado dentro da função de retorno de chamada? -
O método
db.run()
executa declarações de modificação do banco de dados, comoINSERT INTO
. Depois de inserir um novo registro em uma tabela, como seria possível recuperar a chave primária do registro recém-inserido?
Resumo
Esta lição trata do uso básico de bancos de dados SQL em aplicativos Node.js Express. O módulo sqlite3
oferece uma maneira simples de armazenar dados persistentes em um banco de dados SQLite, onde um único arquivo contém todo o banco de dados e não requer um servidor de banco de dados especializado. Esta lição aborda os seguintes conceitos e procedimentos:
-
Como estabelecer uma conexão de banco de dados a partir do Node.js.
-
Como criar uma tabela simples e o papel das chaves primárias.
-
Uso da declaração SQL
INSERT INTO
para adicionar novos dados de dentro do script. -
Consultas SQL usando os métodos padrão do SQLite e as funções de retorno de chamada.
-
Alteração de dados no banco de dados usando as declarações SQL
UPDATE
eDELETE
.
Respostas aos Exercícios Guiados
-
Qual é a finalidade de uma chave primária em uma tabela de banco de dados SQL?
A chave primária é o campo de identificação exclusivo para cada registro em uma tabela de banco de dados.
-
Qual a diferença entre as consultas usando
db.all()
edb.each()
?O método
db.all()
invoca a função de retorno de chamada com uma única matriz contendo todas as entradas correspondentes à consulta. O métododb.each()
invoca a função de retorno de chamada para cada linha de resultado. -
Por que é importante usar espaços reservados e não incluir dados enviados pelo cliente diretamente em uma declaração ou consulta SQL?
Com espaços reservados, os dados enviados pelo usuário são escapados antes de serem incluídos na consulta ou declaração. Isso dificulta os ataques de injeção de SQL, nos quais as declarações SQL são postas dentro de dados variáveis em uma tentativa de executar operações arbitrárias no banco de dados.
Respostas aos Exercícios Exploratórios
-
Qual método do módulo
sqlite3
pode ser usado para retornar apenas uma entrada da tabela, mesmo se a consulta corresponder a diversas entradas?O método
db.get()
tem a mesma sintaxe dedb.all()
, mas retorna apenas a primeira entrada correspondente à consulta. -
Suponha que a matriz
rows
foi passada como um parâmetro para uma função de retorno de chamada e contém o resultado de uma consulta feita comdb.all()
. Como um campo chamadoprice
, presente na primeira posição derows
, pode ser referenciado dentro da função de retorno de chamada?Cada item em
rows
é um objeto cujas propriedades correspondem aos nomes dos campos do banco de dados. Portanto, o valor do campoprice
no primeiro resultado está emrows[0].price
. -
O método
db.run()
executa declarações de modificação do banco de dados, comoINSERT INTO
. Depois de inserir um novo registro em uma tabela, como seria possível recuperar a chave primária do registro recém-inserido?Uma função regular na forma
function(){}
pode ser usada como a função de retorno de chamada do métododb.run()
. Dentro dele, a propriedadethis.lastID
contém o valor da chave primária do último registro inserido.