Começando com uma pilha JavaScript Express e ES6+
Publicados: 2022-03-10Este artigo é a segunda parte de uma série, com a primeira localizada aqui, que fornece informações básicas e (espero) intuitivas sobre Node.js, ES6+ JavaScript, funções de retorno de chamada, funções de seta, APIs, protocolo HTTP, JSON, MongoDB e mais.
Neste artigo, desenvolveremos as habilidades obtidas no anterior, aprendendo como implementar e implantar um banco de dados MongoDB para armazenar informações da lista de livros do usuário, construir uma API com Node.js e o framework Express Web Application para expor esse banco de dados e realizar operações CRUD nele, e muito mais. Ao longo do caminho, discutiremos a Destruição de Objetos ES6, a Abreviação de Objetos ES6, a sintaxe Async/Await, o Operador de Espalhamento e daremos uma breve olhada no CORS, na Política de Mesma Origem e muito mais.
Em um artigo posterior, refatoramos nossa base de código para separar as preocupações, utilizando a arquitetura de três camadas e alcançando a Inversão de controle via injeção de dependência, executaremos segurança e controle de acesso baseados em JSON Web Token e Firebase Authentication, aprenderemos como armazene senhas e empregue o AWS Simple Storage Service para armazenar avatares de usuários com buffers e fluxos Node.js — ao mesmo tempo em que utiliza o PostgreSQL para persistência de dados. Ao longo do caminho, reescreveremos nossa base de código do zero no TypeScript para examinar os conceitos de POO Clássica (como polimorfismo, herança, composição e assim por diante) e até mesmo padrões de design como fábricas e adaptadores.
Uma palavra de alerta
Há um problema com a maioria dos artigos que discutem o Node.js por aí hoje. A maioria deles, nem todos, não vão além de descrever como configurar o Express Routing, integrar o Mongoose e talvez utilizar a autenticação JSON Web Token. O problema é que eles não falam sobre arquitetura, ou melhores práticas de segurança, ou sobre princípios de codificação limpa, ou conformidade ACID, bancos de dados relacionais, quinta forma normal, o teorema CAP ou transações. Presume-se que você sabe sobre tudo isso que está chegando ou que não estará construindo projetos grandes ou populares o suficiente para garantir esse conhecimento mencionado acima.
Parece haver alguns tipos diferentes de desenvolvedores Node — entre outros, alguns são novos na programação em geral, e outros vêm de uma longa história de desenvolvimento empresarial com C# e o .NET Framework ou o Java Spring Framework. A maioria dos artigos atende ao primeiro grupo.
Neste artigo, vou fazer exatamente o que acabei de dizer que muitos artigos estão fazendo, mas em um artigo de acompanhamento, vamos refatorar totalmente nossa base de código, permitindo-me explicar princípios como injeção de dependência, três Arquitetura de camada (controlador/serviço/repositório), mapeamento de dados e registro ativo, padrões de design, unidade, integração e teste de mutação, princípios SOLID, unidade de trabalho, codificação contra interfaces, melhores práticas de segurança como HSTS, CSRF, NoSQL e SQL Injection Prevenção, etc. Também migraremos do MongoDB para o PostgreSQL, usando o construtor de consultas simples Knex em vez de um ORM — permitindo-nos construir nossa própria infraestrutura de acesso a dados e nos aproximarmos da Linguagem de Consulta Estruturada, os diferentes tipos de relações (One- para um, muitos para muitos, etc.) e muito mais. Este artigo, então, deve agradar aos iniciantes, mas os próximos devem atender a desenvolvedores mais intermediários que desejam melhorar sua arquitetura.
Neste, vamos nos preocupar apenas com a persistência dos dados do livro. Não lidaremos com autenticação de usuário, hash de senha, arquitetura ou qualquer coisa complexa assim. Tudo isso virá nos próximos e futuros artigos. Por enquanto, e basicamente, vamos apenas construir um método para permitir que um cliente se comunique com nosso servidor web através do protocolo HTTP para salvar as informações do livro em um banco de dados.
Nota : Eu intencionalmente o mantive extremamente simples e talvez não tão prático aqui porque este artigo, por si só, é extremamente longo, pois tomei a liberdade de me desviar para discutir tópicos suplementares. Assim, melhoraremos progressivamente a qualidade e a complexidade da API ao longo desta série, mas, novamente, como estou considerando isso como uma de suas primeiras apresentações ao Express, estou intencionalmente mantendo as coisas extremamente simples.
- Desestruturação de Objetos ES6
- Abreviação de Objeto ES6
- Operador de Spread ES6 (...)
- Chegando...
Desestruturação de Objetos ES6
ES6 Object Destructuring, ou Destructuring Assignment Syntax, é um método pelo qual extrair ou descompactar valores de arrays ou objetos em suas próprias variáveis. Começaremos com as propriedades do objeto e então discutiremos os elementos do array.
const person = { name: 'Richard P. Feynman', occupation: 'Theoretical Physicist' }; // Log properties: console.log('Name:', person.name); console.log('Occupation:', person.occupation);
Essa operação é bastante primitiva, mas pode ser um pouco trabalhosa, considerando que temos que continuar referenciando person.something
em todos os lugares. Suponha que houvesse 10 outros lugares em todo o nosso código onde tivéssemos que fazer isso - ficaria bastante árduo e rápido. Um método de brevidade seria atribuir esses valores às suas próprias variáveis.
const person = { name: 'Richard P. Feynman', occupation: 'Theoretical Physicist' }; const personName = person.name; const personOccupation = person.occupation; // Log properties: console.log('Name:', personName); console.log('Occupation:', personOccupation);
Talvez isso pareça razoável, mas e se tivéssemos 10 outras propriedades aninhadas no objeto person
também? Isso seria muitas linhas desnecessárias apenas para atribuir valores a variáveis - nesse ponto estamos em perigo porque se as propriedades do objeto forem alteradas, nossas variáveis não refletirão essa mudança (lembre-se, apenas referências ao objeto são imutáveis com atribuição const
, não as propriedades do objeto), então basicamente, não podemos mais manter “estado” (e estou usando essa palavra vagamente) em sincronia. A passagem por referência versus passagem por valor pode entrar em jogo aqui, mas não quero me afastar muito do escopo desta seção.
A destruição de objetos ES6 basicamente nos permite fazer isso:
const person = { name: 'Richard P. Feynman', occupation: 'Theoretical Physicist' }; // This is new. It's called Object Destructuring. const { name, occupation } = person; // Log properties: console.log('Name:', name); console.log('Occupation:', occupation);
Não estamos criando um novo literal de objeto/objeto, estamos descompactando as propriedades de name
e occupation
do objeto original e colocando-as em suas próprias variáveis de mesmo nome. Os nomes que usamos devem corresponder aos nomes das propriedades que desejamos extrair.
Novamente, a sintaxe const { a, b } = someObject;
está dizendo especificamente que esperamos que alguma propriedade a
e alguma propriedade b
existam dentro de someObject
(ou seja, someObject
poderia ser { a: 'dataA', b: 'dataB' }
, por exemplo) e que queremos colocar quaisquer que sejam os valores dessas chaves/propriedades dentro de variáveis const
com o mesmo nome. É por isso que a sintaxe acima nos forneceria duas variáveis const a = someObject.a
e const b = someObject.b
.
O que isso significa é que existem dois lados da Destruição de Objetos. O lado “Template” e o lado “Source”, onde o lado const { a, b }
(o lado esquerdo) é o template e o lado someObject
(o lado direito) é o lado da fonte — o que faz sentido — estamos definindo uma estrutura ou “modelo” à esquerda que espelha os dados do lado “fonte”.
Novamente, apenas para deixar isso claro, aqui estão alguns exemplos:
// ----- Destructure from Object Variable with const ----- // const objOne = { a: 'dataA', b: 'dataB' }; // Destructure const { a, b } = objOne; console.log(a); // dataA console.log(b); // dataB // ----- Destructure from Object Variable with let ----- // let objTwo = { c: 'dataC', d: 'dataD' }; // Destructure let { c, d } = objTwo; console.log(c); // dataC console.log(d); // dataD // Destructure from Object Literal with const ----- // const { e, f } = { e: 'dataE', f: 'dataF' }; // <-- Destructure console.log(e); // dataE console.log(f); // dataF // Destructure from Object Literal with let ----- // let { g, h } = { g: 'dataG', h: 'dataH' }; // <-- Destructure console.log(g); // dataG console.log(h); // dataH
No caso de propriedades aninhadas, espelhe a mesma estrutura em sua atribuição de destruição:
const person = { name: 'Richard P. Feynman', occupation: { type: 'Theoretical Physicist', location: { lat: 1, lng: 2 } } }; // Attempt one: const { name, occupation } = person; console.log(name); // Richard P. Feynman console.log(occupation); // The entire `occupation` object. // Attempt two: const { occupation: { type, location } } = person; console.log(type); // Theoretical Physicist console.log(location) // The entire `location` object. // Attempt three: const { occupation: { location: { lat, lng } } } = person; console.log(lat); // 1 console.log(lng); // 2
Como você pode ver, as propriedades que você decide extrair são opcionais e, para descompactar propriedades aninhadas, simplesmente espelhe a estrutura do objeto original (a fonte) no lado do modelo de sua sintaxe de desestruturação. Se você tentar desestruturar uma propriedade que não existe no objeto original, esse valor ficará indefinido.
Além disso, podemos desestruturar uma variável sem primeiro declará-la — atribuição sem declaração — usando a seguinte sintaxe:
let name, occupation; const person = { name: 'Richard P. Feynman', occupation: 'Theoretical Physicist' }; ;({ name, occupation } = person); console.log(name); // Richard P. Feynman console.log(occupation); // Theoretical Physicist
Precedemos a expressão com um ponto e vírgula para garantir que não criamos acidentalmente um IIFE (Expressão de Função Imediatamente Invocada) com uma função em uma linha anterior (se existir uma função), e os parênteses ao redor da instrução de atribuição são necessários para pare o JavaScript de tratar seu lado esquerdo (modelo) como um bloco.
Um caso de uso muito comum de desestruturação existe dentro de argumentos de função:
const config = { baseUrl: '<baseURL>', awsBucket: '<bucket>', secret: '<secret-key>' // <- Make this an env var. }; // Destructures `baseUrl` and `awsBucket` off `config`. const performOperation = ({ baseUrl, awsBucket }) => { fetch(baseUrl).then(() => console.log('Done')); console.log(awsBucket); // <bucket> }; performOperation(config);
Como você pode ver, poderíamos ter usado apenas a sintaxe de desestruturação normal com a qual estamos acostumados dentro da função, assim:
const config = { baseUrl: '<baseURL>', awsBucket: '<bucket>', secret: '<secret-key>' // <- Make this an env var. }; const performOperation = someConfig => { const { baseUrl, awsBucket } = someConfig; fetch(baseUrl).then(() => console.log('Done')); console.log(awsBucket); // <bucket> }; performOperation(config);
Mas colocar essa sintaxe dentro da assinatura da função executa a desestruturação automaticamente e nos salva uma linha.
Um caso de uso real disso está em React Functional Components for props
:
import React from 'react'; // Destructure `titleText` and `secondaryText` from `props`. export default ({ titleText, secondaryText }) => ( <div> <h1>{titleText}</h1> <h3>{secondaryText}</h3> </div> );
Ao contrário de:
import React from 'react'; export default props => ( <div> <h1>{props.titleText}</h1> <h3>{props.secondaryText}</h3> </div> );
Em ambos os casos, também podemos definir valores padrão para as propriedades:
const personOne = { name: 'User One', password: 'BCrypt Hash' }; const personTwo = { password: 'BCrypt Hash' }; const createUser = ({ name = 'Anonymous', password }) => { if (!password) throw new Error('InvalidArgumentException'); console.log(name); console.log(password); return { id: Math.random().toString(36) // <--- Should follow RFC 4122 Spec in real app. .substring(2, 15) + Math.random() .toString(36).substring(2, 15), name: name, // <-- We'll discuss this next. password: password // <-- We'll discuss this next. }; } createUser(personOne); // User One, BCrypt Hash createUser(personTwo); // Anonymous, BCrypt Hash
Como você pode ver, caso esse name
não esteja presente quando desestruturado, fornecemos um valor padrão. Podemos fazer isso com a sintaxe anterior também:
const { a, b, c = 'Default' } = { a: 'dataA', b: 'dataB' }; console.log(a); // dataA console.log(b); // dataB console.log(c); // Default
Arrays também podem ser desestruturados:
const myArr = [4, 3]; // Destructuring happens here. const [valOne, valTwo] = myArr; console.log(valOne); // 4 console.log(valTwo); // 3 // ----- Destructuring without assignment: ----- // let a, b; // Destructuring happens here. ;([a, b] = [10, 2]); console.log(a + b); // 12
Uma razão prática para a desestruturação de array ocorre com React Hooks. (E há muitas outras razões, estou usando apenas o React como exemplo).
import React, { useState } from "react"; export default () => { const [buttonText, setButtonText] = useState("Default"); return ( <button onClick={() => setButtonText("Toggled")}> {buttonText} </button> ); }
Observe useState
está sendo desestruturado na exportação e as funções/valores da matriz estão sendo desestruturadas no gancho useState
. Novamente, não se preocupe se o acima não fizer sentido - você teria que entender React - e eu estou apenas usando isso como um exemplo.
Embora haja mais sobre a desestruturação de objetos do ES6, abordarei mais um tópico aqui: Desestruturando a renomeação, que é útil para evitar colisões de escopo ou sombras de variáveis, etc. Suponha que queremos desestruturar uma propriedade chamada name
de um objeto chamado person
, mas já existe uma variável com o nome name
no escopo. Podemos renomear rapidamente com dois pontos:
// JS Destructuring Naming Collision Example: const name = 'Jamie Corkhill'; const person = { name: 'Alan Turing' }; // Rename `name` from `person` to `personName` after destructuring. const { name: personName } = person; console.log(name); // Jamie Corkhill <-- As expected. console.log(personName); // Alan Turing <-- Variable was renamed.
Finalmente, podemos definir valores padrão com renomeação também:
const name = 'Jamie Corkhill'; const person = { location: 'New York City, United States' }; const { name: personName = 'Anonymous', location } = person; console.log(name); // Jamie Corkhill console.log(personName); // Anonymous console.log(location); // New York City, United States
Como você pode ver, neste caso, o name
da person
( person.name
) será renomeado para personName
e definido para o valor padrão de Anonymous
se não existir.
E claro, o mesmo pode ser realizado nas assinaturas de função:
const personOne = { name: 'User One', password: 'BCrypt Hash' }; const personTwo = { password: 'BCrypt Hash' }; const createUser = ({ name: personName = 'Anonymous', password }) => { if (!password) throw new Error('InvalidArgumentException'); console.log(personName); console.log(password); return { id: Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15), name: personName, password: password // <-- We'll discuss this next. }; } createUser(personOne); // User One, BCrypt Hash createUser(personTwo); // Anonymous, BCrypt Hash
Abreviação de Objeto ES6
Suponha que você tenha a seguinte fábrica: (cobriremos as fábricas mais tarde)
const createPersonFactory = (name, location, position) => ({ name: name, location: location, position: position });
Pode-se usar esta fábrica para criar um objeto person
, como segue. Além disso, observe que a fábrica está retornando implicitamente um objeto, evidente pelos parênteses ao redor dos colchetes da Função Arrow.
const person = createPersonFactory('Jamie', 'Texas', 'Developer'); console.log(person); // { ... }
Isso é o que já sabemos da Sintaxe Literal de Objetos ES5. Observe, no entanto, na função de fábrica, que o valor de cada propriedade é o mesmo nome que o próprio identificador de propriedade (chave). Ou seja — location: location
ou name: name
. Acontece que isso era uma ocorrência bastante comum com desenvolvedores JS.
Com a sintaxe abreviada do ES6, podemos obter o mesmo resultado reescrevendo a fábrica da seguinte forma:
const createPersonFactory = (name, location, position) => ({ name, location, position }); const person = createPersonFactory('Jamie', 'Texas', 'Developer'); console.log(person);
Produzindo a saída:
{ name: 'Jamie', location: 'Texas', position: 'Developer' }
É importante perceber que só podemos usar essa abreviação quando o objeto que desejamos criar está sendo criado dinamicamente com base em variáveis, onde os nomes das variáveis são iguais aos nomes das propriedades às quais queremos atribuir as variáveis.
Essa mesma sintaxe funciona com valores de objetos:
const createPersonFactory = (name, location, position, extra) => ({ name, location, position, extra // <- right here. }); const extra = { interests: [ 'Mathematics', 'Quantum Mechanics', 'Spacecraft Launch Systems' ], favoriteLanguages: [ 'JavaScript', 'C#' ] }; const person = createPersonFactory('Jamie', 'Texas', 'Developer', extra); console.log(person);
Produzindo a saída:
{ name: 'Jamie', location: 'Texas', position: 'Developer', extra: { interests: [ 'Mathematics', 'Quantum Mechanics', 'Spacecraft Launch Systems' ], favoriteLanguages: [ 'JavaScript', 'C#' ] } }
Como exemplo final, isso também funciona com literais de objeto:
const id = '314159265358979'; const name = 'Archimedes of Syracuse'; const location = 'Syracuse'; const greatMathematician = { id, name, location };
Operador de Spread ES6 (…)
O Spread Operator nos permite fazer uma variedade de coisas, algumas das quais discutiremos aqui.
Em primeiro lugar, podemos espalhar propriedades de um objeto para outro objeto:
const myObjOne = { a: 'a', b: 'b' }; const myObjTwo = { ...myObjOne }:
Isso tem o efeito de colocar todas as propriedades em myObjOne
em myObjTwo
, de modo que myObjTwo
seja agora { a: 'a', b: 'b' }
. Podemos usar esse método para substituir propriedades anteriores. Suponha que um usuário queira atualizar sua conta:
const user = { name: 'John Doe', email: '[email protected]', password: ' ', bio: 'Lorem ipsum' }; const updates = { password: ' ', bio: 'Ipsum lorem', email: '[email protected]' }; const updatedUser = { ...user, // <- original ...updates // <- updates }; console.log(updatedUser); /* { name: 'John Doe', email: '[email protected]', // Updated password: ' ', // Updated bio: 'Ipsum lorem' } */
const user = { name: 'John Doe', email: '[email protected]', password: ' ', bio: 'Lorem ipsum' }; const updates = { password: ' ', bio: 'Ipsum lorem', email: '[email protected]' }; const updatedUser = { ...user, // <- original ...updates // <- updates }; console.log(updatedUser); /* { name: 'John Doe', email: '[email protected]', // Updated password: ' ', // Updated bio: 'Ipsum lorem' } */
const user = { name: 'John Doe', email: '[email protected]', password: ' ', bio: 'Lorem ipsum' }; const updates = { password: ' ', bio: 'Ipsum lorem', email: '[email protected]' }; const updatedUser = { ...user, // <- original ...updates // <- updates }; console.log(updatedUser); /* { name: 'John Doe', email: '[email protected]', // Updated password: ' ', // Updated bio: 'Ipsum lorem' } */
const user = { name: 'John Doe', email: '[email protected]', password: ' ', bio: 'Lorem ipsum' }; const updates = { password: ' ', bio: 'Ipsum lorem', email: '[email protected]' }; const updatedUser = { ...user, // <- original ...updates // <- updates }; console.log(updatedUser); /* { name: 'John Doe', email: '[email protected]', // Updated password: ' ', // Updated bio: 'Ipsum lorem' } */
O mesmo pode ser feito com arrays:
const apollo13Astronauts = ['Jim', 'Jack', 'Fred']; const apollo11Astronauts = ['Neil', 'Buz', 'Michael']; const unionOfAstronauts = [...apollo13Astronauts, ...apollo11Astronauts]; console.log(unionOfAstronauts); // ['Jim', 'Jack', 'Fred', 'Neil', 'Buz, 'Michael'];
Observe aqui que criamos uma união de ambos os conjuntos (arrays) espalhando os arrays em um novo array.
Há muito mais sobre o Operador Rest/Spread, mas está fora do escopo deste artigo. Ele pode ser usado para obter vários argumentos para uma função, por exemplo. Se você quiser saber mais, veja a documentação do MDN aqui.
ES6 Assíncrono/Aguardar
Async/Await é uma sintaxe para aliviar a dor do encadeamento de promessas.
A palavra reservada await
permite que você “aguarde” o estabelecimento de uma promessa, mas só pode ser usada em funções marcadas com a palavra-chave async
. Suponha que eu tenha uma função que retorna uma promessa. Em uma nova função async
, posso await
o resultado dessa promessa em vez de usar .catch
.then
// Returns a promise. const myFunctionThatReturnsAPromise = () => { return new Promise((resolve, reject) => { setTimeout(() => resolve('Hello'), 3000); }); } const myAsyncFunction = async () => { const promiseResolutionResult = await myFunctionThatReturnsAPromise(); console.log(promiseResolutionResult); }; // Writes the log statement after three seconds. myAsyncFunction();
Há algumas coisas a serem observadas aqui. Quando usamos await
em uma função async
, apenas o valor resolvido entra na variável do lado esquerdo. Se a função rejeitar, é um erro que temos que detectar, como veremos em breve. Além disso, qualquer função marcada como async
, por padrão, retornará uma promessa.
Vamos supor que eu precise fazer duas chamadas de API, uma com a resposta da primeira. Usando promessas e encadeamento de promessas, você pode fazer desta maneira:
const makeAPICall = route => new Promise((resolve, reject) => { console.log(route) resolve(route); }); const main = () => { makeAPICall('/whatever') .then(response => makeAPICall(response + ' second call')) .then(response => console.log(response + ' logged')) .catch(err => console.error(err)) }; main(); // Result: /* /whatever /whatever second call /whatever second call logged */
O que está acontecendo aqui é que primeiro chamamos makeAPICall
passando para ele /whatever
, que é registrado na primeira vez. A promessa é resolvida com esse valor. Em seguida, chamamos makeAPICall
novamente, passando para ele /whatever second call
, que é registrada e, novamente, a promessa é resolvida com esse novo valor. Por fim, pegamos esse novo valor /whatever second call
com a qual a promessa acabou de ser resolvida e o registramos no log final, anexando logged
no final. Se isso não fizer sentido, você deve analisar o encadeamento de promessas.
Usando async
/ await
, podemos refatorar para o seguinte:
const main = async () => { const resultOne = await makeAPICall('/whatever'); const resultTwo = await makeAPICall(resultOne + ' second call'); console.log(resultTwo + ' logged'); };
Aqui está o que vai acontecer. A função inteira parará de ser executada na primeira instrução await
até que a promessa da primeira chamada para makeAPICall
resolvida, após a resolução, o valor resolvido será colocado em resultOne
. Quando isso acontecer, a função passará para a segunda instrução await
, novamente pausando ali pela duração da liquidação da promessa. Quando a promessa for resolvida, o resultado da resolução será colocado em resultTwo
. Se a ideia sobre a execução da função parece um bloqueio, não tenha medo, ainda é assíncrona, e discutirei o porquê em um minuto.
Isso apenas descreve o caminho “feliz”. No caso de uma das promessas ser rejeitada, podemos pegá-la com try/catch, pois se a promessa for rejeitada, um erro será lançado - que será qualquer erro com o qual a promessa foi rejeitada.
const main = async () => { try { const resultOne = await makeAPICall('/whatever'); const resultTwo = await makeAPICall(resultOne + ' second call'); console.log(resultTwo + ' logged'); } catch (e) { console.log(e) } };
Como eu disse anteriormente, qualquer função declarada como async
retornará uma promessa. Portanto, se você quiser chamar uma função assíncrona de outra função, poderá usar promessas normais ou await
se declarar a função de chamada async
. No entanto, se você quiser chamar uma função async
do código de nível superior e aguardar seu resultado, precisará usar .catch
.then
Por exemplo:
const returnNumberOne = async () => 1; returnNumberOne().then(value => console.log(value)); // 1
Ou você pode usar uma Expressão de Função Invocada Imediatamente (IIFE):
(async () => { const value = await returnNumberOne(); console.log(value); // 1 })();
Quando você usa await
em uma função async
, a execução da função parará nessa instrução await até que a promessa seja resolvida. No entanto, todas as outras funções são livres para prosseguir com a execução, portanto, nenhum recurso extra de CPU é alocado nem o encadeamento é bloqueado. Direi isso novamente — as operações nessa função específica naquele momento específico serão interrompidas até que a promessa seja resolvida, mas todas as outras funções estão livres para serem acionadas. Considere um servidor da Web HTTP - por solicitação, todas as funções são livres para serem acionadas para todos os usuários simultaneamente à medida que as solicitações são feitas, é apenas que a sintaxe async/await fornecerá a ilusão de que uma operação é síncrona e bloqueante para tornar promete mais fácil de trabalhar, mas, novamente, tudo permanecerá agradável e assíncrono.
Isso não é tudo o que há para async
/ await
, mas deve ajudá-lo a entender os princípios básicos.
Fábricas OOP clássicas
Agora vamos sair do mundo JavaScript e entrar no mundo Java . Pode chegar um momento em que o processo de criação de um objeto (neste caso, uma instância de uma classe — novamente, Java) seja bastante complexo ou quando queremos que diferentes objetos sejam produzidos com base em uma série de parâmetros. Um exemplo pode ser uma função que cria diferentes objetos de erro. Uma fábrica é um padrão de projeto comum em Programação Orientada a Objetos e é basicamente uma função que cria objetos. Para explorar isso, vamos nos afastar do JavaScript para o mundo do Java. Isso fará sentido para os desenvolvedores que vêm de uma OOP clássica (ou seja, não prototípica), background de linguagem estaticamente tipada. Se você não é um desses desenvolvedores, sinta-se à vontade para pular esta seção. Este é um pequeno desvio e, portanto, se seguir aqui interromper seu fluxo de JavaScript, novamente, pule esta seção.
Um padrão de criação comum, o Factory Pattern nos permite criar objetos sem expor a lógica de negócios necessária para realizar essa criação.
Suponha que estamos escrevendo um programa que nos permite visualizar formas primitivas em n-dimensões. Se fornecermos um cubo, por exemplo, veremos um cubo 2D (um quadrado), um cubo 3D (um cubo) e um cubo 4D (um Tesseract ou Hypercube). Aqui está como isso pode ser feito, trivialmente, e excluindo a parte real do desenho, em Java.
// Main.java // Defining an interface for the shape (can be used as a base type) interface IShape { void draw(); } // Implementing the interface for 2-dimensions: class TwoDimensions implements IShape { @Override public void draw() { System.out.println("Drawing a shape in 2D."); } } // Implementing the interface for 3-dimensions: class ThreeDimensions implements IShape { @Override public void draw() { System.out.println("Drawing a shape in 3D."); } } // Implementing the interface for 4-dimensions: class FourDimensions implements IShape { @Override public void draw() { System.out.println("Drawing a shape in 4D."); } } // Handles object creation class ShapeFactory { // Factory method (notice return type is the base interface) public IShape createShape(int dimensions) { switch(dimensions) { case 2: return new TwoDimensions(); case 3: return new ThreeDimensions(); case 4: return new FourDimensions(); default: throw new IllegalArgumentException("Invalid dimension."); } } } // Main class and entry point. public class Main { public static void main(String[] args) throws Exception { ShapeFactory shapeFactory = new ShapeFactory(); IShape fourDimensions = shapeFactory.createShape(4); fourDimensions.draw(); // Drawing a shape in 4D. } }
Como você pode ver, definimos uma interface que especifica um método para desenhar uma forma. Ao fazer com que as diferentes classes implementem a interface, podemos garantir que todas as formas possam ser desenhadas (pois todas elas devem ter um método de draw
substituível conforme a definição da interface). Considerando que essa forma é desenhada de forma diferente dependendo das dimensões em que é visualizada, definimos classes auxiliares que implementam a interface para executar o trabalho intensivo de simulação de renderização n-dimensional da GPU. ShapeFactory
faz o trabalho de instanciar a classe correta — o método createShape
é uma fábrica e, como a definição acima, é um método que retorna um objeto de uma classe. O tipo de retorno de createShape
é a interface IShape
porque a interface IShape
é o tipo base de todas as formas (porque elas têm um método draw
).
Este exemplo Java é bastante trivial, mas você pode ver facilmente como ele se torna útil em aplicativos maiores, onde a configuração para criar um objeto pode não ser tão simples. Um exemplo disso seria um videogame. Suponha que o usuário tenha que sobreviver a diferentes inimigos. Classes e interfaces abstratas podem ser usadas para definir funções básicas disponíveis para todos os inimigos (e métodos que podem ser substituídos), talvez empregando o padrão de delegação (favorecer composição sobre herança como a Gangue dos Quatro sugeriu para que você não fique preso em estender um classe base única e para facilitar o teste/simulação/DI). Para objetos inimigos instanciados de maneiras diferentes, a interface permitiria a criação de objetos de fábrica enquanto contava com o tipo de interface genérico. Isso seria muito relevante se o inimigo fosse criado dinamicamente.
Outro exemplo é uma função de construtor. Suponha que utilizemos o Padrão de Delegação para que uma classe delegue o trabalho para outras classes que honrem uma interface. Poderíamos colocar um método de build
estático na classe para que ele construísse sua própria instância (supondo que você não estivesse usando um Contêiner/Framework de Injeção de Dependência). Em vez de ter que chamar cada setter, você pode fazer isso:
public class User { private IMessagingService msgService; private String name; private int age; public User(String name, int age, IMessagingService msgService) { this.name = name; this.age = age; this.msgService = msgService; } public static User build(String name, int age) { return new User(name, age, new SomeMessageService()); } }
Explicarei o padrão de delegação em um artigo posterior, se você não estiver familiarizado com ele - basicamente, por meio da composição e em termos de modelagem de objetos, ele cria um relacionamento "tem-a" em vez de um "é-um" relacionamento como você obteria com herança. Se você tem uma classe Mammal
e uma classe Dog
, e Dog
estende Mammal
, então um Dog
é um Mammal
. Considerando que, se você tivesse uma classe Bark
e acabasse de passar instâncias de Bark
para o construtor de Dog
, Dog
teria um Bark
. Como você pode imaginar, isso facilita especialmente o teste de unidade, pois você pode injetar mocks e declarar fatos sobre o mock, desde que o mock honre o contrato de interface no ambiente de teste.
O método de fábrica "build" static
acima simplesmente cria um novo objeto de User
e passa um MessageService
concreto. Observe como isso segue a definição acima - não expondo a lógica de negócios para criar um objeto de uma classe ou, neste caso, não expor a criação do serviço de mensagens ao chamador da fábrica.
Novamente, isso não é necessariamente como você faria as coisas no mundo real, mas apresenta a ideia de uma função/método de fábrica muito bem. Podemos usar um contêiner de injeção de dependência, por exemplo. Agora de volta ao JavaScript.
Começando com Expresso
Express é um Web Application Framework para Node (disponível por meio de um módulo NPM) que permite criar um servidor Web HTTP. É importante notar que o Express não é o único framework para fazer isso (existe Koa, Fastify, etc.), e que, como visto no artigo anterior, o Node pode funcionar sem o Express como uma entidade autônoma. (Express é apenas um módulo que foi projetado para o Node — o Node pode fazer muitas coisas sem ele, embora o Express seja popular para servidores Web).
Mais uma vez, deixe-me fazer uma distinção muito importante. Existe uma dicotomia presente entre Node/JavaScript e Express. Node, o ambiente/tempo de execução no qual você executa o JavaScript, pode fazer muitas coisas — como permitir que você crie aplicativos React Native, aplicativos de desktop, ferramentas de linha de comando etc. Node/JS para construir servidores web em vez de lidar com a rede de baixo nível do Node e APIs HTTP. Você não precisa do Express para construir um servidor web.
Antes de iniciar esta seção, se você não estiver familiarizado com HTTP e HTTP Requests (GET, POST, etc.), então eu o encorajo a ler a seção correspondente do meu artigo anterior, que está no link acima.
Usando o Express, configuraremos diferentes rotas para as quais solicitações HTTP podem ser feitas, bem como os endpoints relacionados (que são funções de retorno de chamada) que serão acionados quando uma solicitação for feita para essa rota. Não se preocupe se as rotas e os endpoints não fizerem sentido no momento - eu os explicarei mais tarde.
Ao contrário de outros artigos, adotarei a abordagem de escrever o código-fonte à medida que avançamos, linha por linha, em vez de despejar toda a base de código em um trecho e depois explicar mais tarde. Vamos começar abrindo um terminal (estou usando o Terminus em cima do Git Bash no Windows - que é uma boa opção para usuários do Windows que desejam um Bash Shell sem configurar o Subsistema Linux), configurando o clichê do nosso projeto e abrindo-o no código do Visual Studio.
mkdir server && cd server touch server.js npm init -y npm install express code .
Dentro do arquivo server.js
, começarei solicitando express
usando a função require()
.
const express = require('express');
require('express')
diz ao Node para sair e obter o módulo Express que instalamos anteriormente, que está atualmente dentro da pasta node_modules
(para isso é o que npm install
faz - crie uma pasta node_modules
e coloque os módulos e suas dependências lá). Por convenção, e ao lidar com Express, chamamos a variável que contém o resultado de retorno de require('express')
express
, embora possa ser chamada de qualquer coisa.
This returned result, which we have called express
, is actually a function — a function we'll have to invoke to create our Express app and set up our routes. Again, by convention, we call this app
— app
being the return result of express()
— that is, the return result of calling the function that has the name express
as express()
.
const express = require('express'); const app = express(); // Note that the above variable names are the convention, but not required. // An example such as that below could also be used. const foo = require('express'); const bar = foo(); // Note also that the node module we installed is called express.
The line const app = express();
simply puts a new Express Application inside of the app
variable. It calls a function named express
(the return result of require('express')
) and stores its return result in a constant named app
. If you come from an object-oriented programming background, consider this equivalent to instantiating a new object of a class, where app
would be the object and where express()
would call the constructor function of the express
class. Remember, JavaScript allows us to store functions in variables — functions are first-class citizens. The express
variable, then, is nothing more than a mere function. It's provided to us by the developers of Express.
I apologize in advance if I'm taking a very long time to discuss what is actually very basic, but the above, although primitive, confused me quite a lot when I was first learning back-end development with Node.
Inside the Express source code, which is open-source on GitHub, the variable we called express
is a function entitled createApplication
, which, when invoked, performs the work necessary to create an Express Application:
A snippet of Express source code:
exports = module.exports = createApplication; /* * Create an express application */ // This is the function we are storing in the express variable. (- Jamie) function createApplication() { // This is what I mean by "Express App" (- Jamie) var app = function(req, res, next) { app.handle(req, res, next); }; mixin(app, EventEmitter.prototype, false); mixin(app, proto, false); // expose the prototype that will get set on requests app.request = Object.create(req, { app: { configurable: true, enumerable: true, writable: true, value: app } }) // expose the prototype that will get set on responses app.response = Object.create(res, { app: { configurable: true, enumerable: true, writable: true, value: app } }) app.init(); // See - `app` gets returned. (- Jamie) return app; }
GitHub: https://github.com/expressjs/express/blob/master/lib/express.js
With that short deviation complete, let's continue setting up Express. Thus far, we have required the module and set up our app
variable.
const express = require('express'); const app = express();
From here, we have to tell Express to listen on a port. Any HTTP Requests made to the URL and Port upon which our application is listening will be handled by Express. We do that by calling app.listen(...)
, passing to it the port and a callback function which gets called when the server starts running:
const PORT = 3000; app.listen(PORT, () => console.log(`Server is up on port {PORT}.`));
We notate the PORT
variable in capital by convention, for it is a constant variable that will never change. You could do that with all variables that you declare const
, but that would look messy. It's up to the developer or development team to decide on notation, so we'll use the above sparsely. I use const
everywhere as a method of “defensive coding” — that is, if I know that a variable is never going to change then I might as well just declare it const
. Since I define everything const
, I make the distinction between what variables should remain the same on a per-request basis and what variables are true actual global constants.
Here is what we have thus far:
const express = require('express'); const app = express(); const PORT = 3000; // We will build our API here. // ... // Binding our application to port 3000. app.listen(PORT, () => { console.log(`Server is up on port ${PORT}.`); });
Let's test this to see if the server starts running on port 3000.
I'll open a terminal and navigate to our project's root directory. I'll then run node server/server.js
. Note that this assumes you have Node already installed on your system (You can check with node -v
).
If everything works, you should see the following in the terminal:
Server is up on port 3000.
Go ahead and hit Ctrl + C
to bring the server back down.
If this doesn't work for you, or if you see an error such as EADDRINUSE
, then it means you may have a service already running on port 3000. Pick another port number, like 3001, 3002, 5000, 8000, etc. Be aware, lower number ports are reserved and there is an upper bound of 65535.
At this point, it's worth taking another small deviation as to understand servers and ports in the context of computer networking. We'll return to Express in a moment. I take this approach, rather than introducing servers and ports first, for the purpose of relevance. That is, it is difficult to learn a concept if you fail to see its applicability. In this way, you are already aware of the use case for ports and servers with Express, so the learning experience will be more pleasurable.
A Brief Look At Servers And Ports
A server is simply a computer or computer program that provides some sort of “functionality” to the clients that talk to it. More generally, it's a device, usually connected to the Internet, that handles connections in a pre-defined manner. In our case, that “pre-defined manner” will be HTTP or the HyperText Transfer Protocol. Servers that use the HTTP Protocol are called Web Servers.
When building an application, the server is a critical component of the “client-server model”, for it permits the sharing and syncing of data (generally via databases or file systems) across devices. It's a cross-platform approach, in a way, for the SDKs of platforms against which you may want to code — be they web, mobile, or desktop — all provide methods (APIs) to interact with a server over HTTP or TCP/UDP Sockets. It's important to make a distinction here — by APIs, I mean programming language constructs to talk to a server, like XMLHttpRequest
or the Fetch
API in JavaScript, or HttpUrlConnection
in Java, or even HttpClient
in C#/.NET. This is different from the kind of REST API we'll be building in this article to perform CRUD Operations on a database.
To talk about ports, it's important to understand how clients connect to a server. A client requires the IP Address of the server and the Port Number of our specific service on that server. An IP Address, or Internet Protocol Address, is just an address that uniquely identifies a device on a network. Public and private IPs exist, with private addresses commonly used behind a router or Network Address Translator on a local network. You might see private IP Addresses of the form 192.168.XXX.XXX
or 10.0.XXX.XXX
. When articulating an IP Address, decimals are called “dots”. So 192.168.0.1
(a common router IP Addr.) might be pronounced, “one nine two dot one six eight dot zero dot one”. (By the way, if you're ever in a hotel and your phone/laptop won't direct you to the AP captive portal, try typing 192.168.0.1 or 192.168.1.1 or similar directly into Chrome).
For simplicity, and since this is not an article about the complexities of computer networking, assume that an IP Address is equivalent to a house address, allowing you to uniquely identify a house (where a house is analogous to a server, client, or network device) in a neighborhood. One neighborhood is one network. Put together all of the neighborhoods in the United States, and you have the public Internet. (This is a basic view, and there are many more complexities — firewalls, NATs, ISP Tiers (Tier One, Tier Two, and Tier Three), fiber optics and fiber optic backbones, packet switches, hops, hubs, etc., subnet masks, etc., to name just a few — in the real networking world.) The traceroute
Unix command can provide more insight into the above, displaying the path (and associated latency) that packets take through a network as a series of “hops”.
Um número de porta identifica um serviço específico em execução em um servidor. SSH, ou Secure Shell, que permite o acesso remoto do shell a um dispositivo, geralmente é executado na porta 22. FTP ou File Transfer Protocol (que pode, por exemplo, ser usado com um cliente FTP para transferir ativos estáticos para um servidor) geralmente é executado em Porta 21. Poderíamos dizer, então, que as portas são cômodos específicos dentro de cada casa em nossa analogia acima, pois os cômodos das casas são feitos para coisas diferentes – um quarto para dormir, uma cozinha para preparar alimentos, uma sala de jantar para consumo do referido alimentos, etc., assim como os portos correspondem a programas que realizam serviços específicos. Para nós, os Servidores Web geralmente rodam na Porta 80, embora você seja livre para especificar o Número da Porta que desejar, desde que não estejam sendo usados por algum outro serviço (eles não podem colidir).
Para acessar um site, você precisa do endereço IP do site. Apesar disso, normalmente acessamos sites por meio de uma URL. Nos bastidores, um DNS, ou Domain Name Server, converte essa URL em um endereço IP, permitindo que o navegador faça uma solicitação GET para o servidor, obtenha o HTML e o renderize na tela. 8.8.8.8
é o endereço de um dos Servidores DNS Públicos do Google. Você pode imaginar que exigir a resolução de um nome de host para um endereço IP por meio de um servidor DNS remoto levará tempo, e você está certo. Para reduzir a latência, os sistemas operacionais têm um cache DNS — um banco de dados temporário que armazena informações de pesquisa de DNS, reduzindo assim a frequência com que essas pesquisas devem ocorrer. O DNS Resolver Cache pode ser visualizado no Windows com o comando ipconfig /displaydns
CMD e removido por meio do comando ipconfig /flushdns
.
Em um servidor Unix, as portas de número inferior mais comuns, como 80, exigem privilégios de nível raiz ( escalonados se você vier de um plano de fundo do Windows). Por esse motivo, usaremos a porta 3000 para nosso trabalho de desenvolvimento, mas permitiremos que o servidor escolha o número da porta (o que estiver disponível) quando implantarmos em nosso ambiente de produção.
Por fim, observe que podemos digitar endereços IP diretamente na barra de pesquisa do Google Chrome, ignorando o mecanismo de resolução de DNS. Digitar 216.58.194.36
, por exemplo, levará você ao Google.com. Em nosso ambiente de desenvolvimento, ao usar nosso próprio computador como nosso servidor de desenvolvimento, usaremos localhost
e porta 3000. Um endereço é formatado como hostname:port
, então nosso servidor estará em localhost:3000
. Localhost, ou 127.0.0.1
, é o endereço de loopback e significa o endereço de “este computador”. É um nome de host e seu endereço IPv4 resolve para 127.0.0.1
. Tente pingar localhost em sua máquina agora. Você pode obter ::1
back — que é o endereço de loopback IPv6 ou 127.0.0.1
back — que é o endereço de loopback IPv4. IPv4 e IPv6 são dois formatos de endereço IP diferentes associados a padrões diferentes — alguns endereços IPv6 podem ser convertidos para IPv4, mas não todos.
Voltando ao Expresso
Mencionei solicitações HTTP, verbos e códigos de status em meu artigo anterior, Introdução ao nó: uma introdução às APIs, HTTP e JavaScript ES6+. Se você não tiver uma compreensão geral do protocolo, sinta-se à vontade para pular para a seção “Solicitações HTTP e HTTP” dessa parte.
Para ter uma ideia do Express, vamos simplesmente configurar nossos terminais para as quatro operações fundamentais que realizaremos no banco de dados — Criar, Ler, Atualizar e Excluir, conhecidas coletivamente como CRUD.
Lembre-se de que acessamos endpoints por rotas na URL. Ou seja, embora as palavras “rota” e “endpoint” sejam comumente usadas de forma intercambiável, um endpoint é tecnicamente uma função de linguagem de programação (como ES6 Arrow Functions) que executa alguma operação do lado do servidor, enquanto uma rota é o que o endpoint está localizado atrás . de . Especificamos esses endpoints como funções de retorno de chamada, que o Express acionará quando a solicitação apropriada for feita do cliente para a rota atrás da qual o endpoint reside. Você pode se lembrar do que foi dito acima percebendo que são os endpoints que executam uma função e a rota é o nome que é usado para acessar os endpoints. Como veremos, a mesma rota pode ser associada a vários endpoints usando diferentes verbos HTTP (semelhante à sobrecarga de métodos se você tiver um histórico de OOP clássico com polimorfismo).
Lembre-se de que estamos seguindo a arquitetura REST (REpresentational State Transfer) permitindo que os clientes façam solicitações ao nosso servidor. Afinal, trata-se de uma API REST ou RESTful. Solicitações específicas feitas a rotas específicas acionarão endpoints específicos que farão coisas específicas. Um exemplo de tal “coisa” que um endpoint pode fazer é adicionar novos dados a um banco de dados, remover dados, atualizar dados etc.
O Express sabe qual endpoint disparar porque informamos explicitamente o método de solicitação (GET, POST, etc.) e a rota — definimos quais funções disparar para combinações específicas dos itens acima e o cliente faz a solicitação, especificando percurso e método. Para simplificar, com o Node, diremos ao Express — “Ei, se alguém fizer uma solicitação GET para esta rota, vá em frente e dispare esta função (use este endpoint)”. As coisas podem ficar mais complicadas: “Expresso, se alguém fizer uma solicitação GET para essa rota, mas não enviar um token de portador de autorização válido no cabeçalho da solicitação, responda com um HTTP 401 Unauthorized
. Se eles possuírem um token de portador válido, envie qualquer recurso protegido que eles estivessem procurando disparando o endpoint. Muito obrigado e tenha um bom dia.” De fato, seria bom se as linguagens de programação pudessem ser de alto nível sem vazar ambiguidade, mas mesmo assim demonstra os conceitos básicos.
Lembre-se, o ponto final, de certa forma, mora atrás da rota. Portanto, é imperativo que o cliente forneça, no cabeçalho da solicitação, qual método deseja usar para que o Express possa descobrir o que fazer. A solicitação será feita para uma rota específica, que o cliente especificará (junto com o tipo de solicitação) ao entrar em contato com o servidor, permitindo que o Express faça o que precisa fazer e nós façamos o que precisamos fazer quando o Express disparar nossos retornos de chamada . É nisso que tudo se resume.
Nos exemplos de código anteriores, chamamos a função listen
que estava disponível em app
, passando para ela uma porta e um retorno de chamada. app
em si, se você se lembra, é o resultado de retorno de chamar a variável express
como uma função (ou seja, express()
), e a variável express
é o que chamamos de resultado de retorno ao exigir 'express'
de nossa pasta node_modules
. Assim como listen
é chamado no app
, especificamos os endpoints de solicitação HTTP chamando-os no app
. Vejamos o GET:
app.get('/my-test-route', () => { // ... });
O primeiro parâmetro é uma string
, e é a rota por trás da qual o endpoint ficará. A função de retorno de chamada é o ponto de extremidade. Direi isso novamente: a função de retorno de chamada — o segundo parâmetro — é o ponto de extremidade que será acionado quando uma solicitação HTTP GET for feita para qualquer rota que especificarmos como o primeiro argumento ( /my-test-route
neste caso).
Agora, antes de trabalharmos mais com o Express, precisamos saber como as rotas funcionam. A rota que especificamos como uma string será chamada fazendo a solicitação para www.domain.com/the-route-we-chose-earlier-as-a-string
. No nosso caso, o domínio é localhost:3000
, o que significa que, para acionar a função de retorno de chamada acima, temos que fazer uma solicitação GET para localhost:3000/my-test-route
. Se usássemos uma string diferente como o primeiro argumento acima, a URL teria que ser diferente para corresponder ao que especificamos em JavaScript.
Ao falar sobre essas coisas, você provavelmente ouvirá falar de Glob Patterns. Poderíamos dizer que todas as rotas da nossa API estão localizadas em localhost:3000/**
Glob Pattern, onde **
é um curinga que significa qualquer diretório ou subdiretório (observe que as rotas não são diretórios) para o qual a raiz é pai — isto é, tudo.
Vamos em frente e adicionar uma instrução de log nessa função de retorno de chamada para que tenhamos:
// Getting the module from node_modules. const express = require('express'); // Creating our Express Application. const app = express(); // Defining the port we'll bind to. const PORT = 3000; // Defining a new endpoint behind the "/my-test-route" route. app.get('/my-test-route', () => { console.log('A GET Request was made to /my-test-route.'); }); // Binding the server to port 3000. app.listen(PORT, () => { console.log(`Server is up on port ${PORT}.`) });
Colocaremos nosso servidor em funcionamento executando node server/server.js
(com o Node instalado em nosso sistema e acessível globalmente a partir de variáveis de ambiente do sistema) no diretório raiz do projeto. Como anteriormente, você deve ver a mensagem de que o servidor está ativo no console. Agora que o servidor está em execução, abra um navegador e visite localhost:3000
na barra de URL.
Você deve ser saudado com uma mensagem de erro informando Cannot GET /
. Pressione Ctrl + Shift + I no Windows no Chrome para visualizar o console do desenvolvedor. Lá, você deve ver que temos um 404
(Recurso não encontrado). Isso faz sentido - nós apenas dissemos ao servidor o que fazer quando alguém visita localhost:3000/my-test-route
. O navegador não tem nada para renderizar em localhost:3000
(que é equivalente a localhost:3000/
com uma barra).
Se você olhar para a janela do terminal onde o servidor está sendo executado, não deve haver novos dados. Agora, visite localhost:3000/my-test-route
na barra de URL do seu navegador. Você pode ver o mesmo erro no console do Chrome (porque o navegador está armazenando o conteúdo em cache e ainda não tem HTML para renderizar), mas se você visualizar seu terminal onde o processo do servidor está sendo executado, verá que a função de retorno de chamada realmente foi acionada e a mensagem de log foi realmente registrada.
Desligue o servidor com Ctrl + C.
Agora, vamos dar ao navegador algo para renderizar quando uma solicitação GET for feita para essa rota para que possamos perder a mensagem Cannot GET /
. Vou pegar nosso app.get()
de antes, e na função de retorno de chamada, vou adicionar dois argumentos. Lembre-se, a função de retorno de chamada que estamos passando está sendo chamada pelo Express nos bastidores, e o Express pode adicionar quaisquer argumentos que desejar. Na verdade, ele adiciona dois (bem, tecnicamente três, mas veremos isso mais tarde), e embora ambos sejam extremamente importantes, não nos importamos com o primeiro por enquanto. O segundo argumento é chamado res
, abreviação de response
, e vou acessá-lo definindo undefined
como o primeiro parâmetro:
app.get('/my-test-route', (undefined, res) => { console.log('A GET Request was made to /my-test-route.'); });
Novamente, podemos chamar o argumento res
como quisermos, mas res
é convenção ao lidar com Express. res
é na verdade um objeto, e sobre ele existem diferentes métodos para enviar dados de volta ao cliente. Neste caso, vou acessar a função send(...)
disponível em res
para enviar de volta o HTML que o navegador irá renderizar. No entanto, não estamos limitados a enviar de volta HTML e podemos optar por enviar de volta texto, um Objeto JavaScript, um fluxo (fluxos são especialmente bonitos) ou qualquer outra coisa.
app.get('/my-test-route', (undefined, res) => { console.log('A GET Request was made to /my-test-route.'); res.send('<h1>Hello, World!</h1>'); });
Se você desligar o servidor e, em seguida, ativá-lo novamente e, em seguida, atualizar seu navegador na /my-test-route
, você verá o HTML sendo renderizado.
A guia Rede das Ferramentas do desenvolvedor do Chrome permitirá que você veja esta solicitação GET com mais detalhes no que diz respeito aos cabeçalhos.
Neste ponto, será bom começar a aprender sobre o Express Middleware — funções que podem ser disparadas globalmente depois que um cliente faz uma solicitação.
Middleware expresso
O Express fornece métodos para definir middleware customizado para seu aplicativo. De fato, o significado de Express Middleware é melhor definido no Express Docs, aqui)
As funções de middleware são funções que têm acesso ao objeto de solicitação (
req
), ao objeto de resposta (res
) e à próxima função de middleware no ciclo de solicitação-resposta do aplicativo. A próxima função de middleware é comumente denotada por uma variável chamadanext
.
As funções de middleware podem executar as seguintes tarefas:
- Execute qualquer código.
- Faça alterações na solicitação e nos objetos de resposta.
- Encerre o ciclo de solicitação-resposta.
- Chame a próxima função de middleware na pilha.
Em outras palavras, uma função de middleware é uma função personalizada que nós (o desenvolvedor) podemos definir e que atuará como um intermediário entre quando o Express recebe a solicitação e quando nossa função de retorno de chamada apropriada é acionada. Podemos fazer uma função de log
, por exemplo, que registrará toda vez que uma solicitação for feita. Observe que também podemos optar por fazer com que essas funções de middleware sejam acionadas depois que nosso endpoint for acionado, dependendo de onde você o colocar na pilha - algo que veremos mais tarde.
Para especificar o middleware personalizado, temos que defini-lo como uma função e passá-lo para app.use(...)
.
const myMiddleware = (req, res, next) => { console.log(`Middleware has fired at time ${Date().now}`); next(); } app.use(myMiddleware); // This is the app variable returned from express().
Todos juntos, temos agora:
// Getting the module from node_modules. const express = require('express'); // Creating our Express Application. const app = express(); // Our middleware function. const myMiddleware = (req, res, next) => { console.log(`Middleware has fired at time ${Date().now}`); next(); } // Tell Express to use the middleware. app.use(myMiddleware); // Defining the port we'll bind to. const PORT = 3000; // Defining a new endpoint behind the "/my-test-route" route. app.get('/my-test-route', () => { console.log('A GET Request was made to /my-test-route.'); }); // Binding the server to port 3000. app.listen(PORT, () => { console.log(`Server is up on port ${PORT}.`) });
Se você fizer as solicitações pelo navegador novamente, deverá ver agora que sua função de middleware está disparando e registrando registros de data e hora. Para estimular a experimentação, tente remover a chamada para a next
função e veja o que acontece.
A função de retorno de chamada de middleware é chamada com três argumentos, req
, res
e next
. req
é o parâmetro que ignoramos ao construir o manipulador GET anteriormente e é um objeto que contém informações sobre a solicitação, como cabeçalhos, cabeçalhos personalizados, parâmetros e qualquer corpo que possa ter sido enviado do cliente (como você faz com uma solicitação POST). Eu sei que estamos falando de middleware aqui, mas tanto os endpoints quanto a função de middleware são chamados com req
e res
. req
e res
serão os mesmos (a menos que um ou outro o altere) no middleware e no endpoint dentro do escopo de uma única solicitação do cliente. Isso significa, por exemplo, que você pode usar uma função de middleware para limpar os dados removendo quaisquer caracteres que possam ter como objetivo executar injeções SQL ou NoSQL e, em seguida, entregando a req
segura ao endpoint.
res
, como visto anteriormente, permite que você envie dados de volta ao cliente de várias maneiras diferentes.
next
é uma função de retorno de chamada que você deve executar quando o middleware terminar de fazer seu trabalho para chamar a próxima função de middleware na pilha ou no terminal. Certifique-se de observar que você terá que chamar isso no bloco then
de qualquer função assíncrona que você acionar no middleware. Dependendo da sua operação assíncrona, você pode ou não querer chamá-la no bloco catch
. Ou seja, a função myMiddleware
é acionada depois que a solicitação é feita do cliente, mas antes que a função de terminal da solicitação seja acionada. Quando executamos este código e fazemos uma solicitação, você deve ver a mensagem Middleware has fired...
antes da mensagem A GET Request was made to...
no console. Se você não chamar next()
, a última parte nunca será executada — sua função de endpoint para a solicitação não será acionada.
Observe também que eu poderia ter definido essa função anonimamente, como tal (uma convenção à qual vou aderir):
app.use((req, res, next) => { console.log(`Middleware has fired at time ${Date().now}`); next(); });
Para quem é novo em JavaScript e ES6, se a maneira como o acima funciona não faz sentido imediato, o exemplo abaixo deve ajudar. Estamos simplesmente definindo uma função de retorno de chamada (a função anônima) que recebe outra função de retorno de chamada ( next
) como argumento. Chamamos uma função que recebe um argumento de função de Função de Ordem Superior. Veja da maneira abaixo - ele mostra um exemplo básico de como o Express Source Code pode funcionar nos bastidores:
console.log('Suppose a request has just been made from the client.\n'); // This is what (it's not exactly) the code behind app.use() might look like. const use = callback => { // Simple log statement to see where we are. console.log('Inside use() - the "use" function has been called.'); // This depicts the termination of the middleware. const next = () => console.log('Terminating Middleware!\n'); // Suppose req and res are defined above (Express provides them). const req = res = null; // "callback" is the "middleware" function that is passed into "use". // "next" is the above function that pretends to stop the middleware. callback(req, res, next); }; // This is analogous to the middleware function we defined earlier. // It gets passed in as "callback" in the "use" function above. const myMiddleware = (req, res, next) => { console.log('Inside the myMiddleware function!'); next(); } // Here, we are actually calling "use()" to see everything work. use(myMiddleware); console.log('Moving on to actually handle the HTTP Request or the next middleware function.');
Chamamos primeiro use
que usa myMiddleware
como argumento. myMiddleware
, por si só, é uma função que recebe três argumentos - req
, res
e next
. Dentro de use
, myMiddlware
é chamado e esses três argumentos são passados. next
é uma função definida em use
. myMiddleware
é definido como callback
no método use
. Se eu tivesse colocado use
, neste exemplo, em um objeto chamado app
, poderíamos ter imitado totalmente a configuração do Express, embora sem nenhum soquete ou conectividade de rede.
Nesse caso, myMiddleware
e callback
são funções de ordem superior, porque ambos recebem funções como argumentos.
Se você executar esse código, verá a seguinte resposta:
Suppose a request has just been made from the client. Inside use() - the "use" function has been called. Inside the middleware function! Terminating Middleware! Moving on to actually handle the HTTP Request or the next middleware function.
Observe que eu também poderia ter usado funções anônimas para obter o mesmo resultado:
console.log('Suppose a request has just been made from the client.'); // This is what (it's not exactly) the code behind app.use() might look like. const use = callback => { // Simple log statement to see where we are. console.log('Inside use() - the "use" function has been called.'); // This depicts the termination of the middlewear. const next = () => console.log('Terminating Middlewear!'); // Suppose req and res are defined above (Express provides them). const req = res = null; // "callback" is the function which is passed into "use". // "next" is the above function that pretends to stop the middlewear. callback(req, res, () => { console.log('Terminating Middlewear!'); }); }; // Here, we are actually calling "use()" to see everything work. use((req, res, next) => { console.log('Inside the middlewear function!'); next(); }); console.log('Moving on to actually handle the HTTP Request.');
Com isso esperançosamente resolvido, agora podemos retornar à tarefa real em mãos - configurar nosso middleware.
O fato é que você normalmente terá que enviar dados por meio de uma solicitação HTTP. Você tem algumas opções diferentes para fazer isso - enviar parâmetros de consulta de URL, enviar dados que estarão acessíveis no objeto req
que aprendemos anteriormente etc. Esse objeto não está disponível apenas no retorno de chamada para chamar app.use()
, mas também para qualquer ponto de extremidade. Usamos undefined
como um preenchimento anteriormente para que pudéssemos nos concentrar em res
para enviar o HTML de volta ao cliente, mas agora precisamos acessá-lo.
app.use('/my-test-route', (req, res) => { // The req object contains client-defined data that is sent up. // The res object allows the server to send data back down. });
As solicitações HTTP POST podem exigir que enviemos um objeto de corpo para o servidor. Se você tiver um formulário no cliente e pegar o nome e o e-mail do usuário, provavelmente enviará esses dados para o servidor no corpo da solicitação.
Vamos dar uma olhada em como isso pode parecer no lado do cliente:
<!DOCTYPE html> <html> <body> <form action="https://localhost:3000/email-list" method="POST" > <input type="text" name="nameInput"> <input type="email" name="emailInput"> <input type="submit"> </form> </body> </html>
Do lado do servidor:
app.post('/email-list', (req, res) => { // What do we now? // How do we access the values for the user's name and email? });
Para acessar o nome e e-mail do usuário, teremos que usar um tipo específico de middleware. Isso colocará os dados em um objeto chamado body
disponível em req
. O Body Parser era um método popular de fazer isso, disponível pelos desenvolvedores do Express como um módulo NPM autônomo. Agora, o Express vem pré-empacotado com seu próprio middleware para fazer isso, e vamos chamá-lo assim:
app.use(express.urlencoded({ extended: true }));
Agora podemos fazer:
app.post('/email-list', (req, res) => { console.log('User Name: ', req.body.nameInput); console.log('User Email: ', req.body.emailInput); });
Tudo o que isso faz é pegar qualquer entrada definida pelo usuário que é enviada do cliente e disponibilizá-la no objeto body
de req
. Observe que em req.body
, agora temos nameInput
e emailInput
, que são os nomes das tags de input
no HTML. Agora, esses dados definidos pelo cliente devem ser considerados perigosos (nunca, nunca confie no cliente) e precisam ser higienizados, mas abordaremos isso mais tarde.
Outro tipo de middleware fornecido pelo express é express.json()
. express.json
é usado para empacotar qualquer carga útil JSON enviada em uma solicitação do cliente para req.body
, enquanto express.urlencoded
empacotará todas as solicitações recebidas com strings, matrizes ou outros dados codificados por URL em req.body
. Em resumo, ambos manipulam req.body
, mas .json()
é para cargas JSON e .urlencoded()
é para, entre outros, parâmetros de consulta POST.
Outra maneira de dizer isso é que solicitações recebidas com um cabeçalho Content-Type: application/json
(como especificar um POST Body com a API de fetch
) serão tratadas por express.json()
, enquanto solicitações com cabeçalho Content-Type: application/x-www-form-urlencoded
(como HTML Forms) será tratado com express.urlencoded()
. Espero que agora faça sentido.
Iniciando nossas rotas CRUD para MongoDB
Observação : ao realizar solicitações de PATCH neste artigo, não seguiremos a especificação JSONPatch RFC — um problema que corrigiremos no próximo artigo desta série.
Considerando que entendemos que especificamos cada endpoint chamando a função relevante em app
, passando para ela a route e uma função callback contendo os objetos request e response, podemos começar a definir nossas Rotas CRUD para a API Bookshelf. De fato, e considerando que este é um artigo introdutório, não terei o cuidado de seguir completamente as especificações HTTP e REST, nem tentarei usar a arquitetura mais limpa possível. Isso virá em um artigo futuro.
Vou abrir o arquivo server.js
que estamos usando até agora e esvaziar tudo para começar a partir da lousa abaixo:
// Getting the module from node_modules. const express = require('express'); // This creates our Express App. const app = express(); // Define middleware. app.use(express.json()); app.use(express.urlencoded({ extended: true )); // Listening on port 3000 (arbitrary). // Not a TCP or UDP well-known port. // Does not require superuser privileges. const PORT = 3000; // We will build our API here. // ... // Binding our application to port 3000. app.listen(PORT, () => console.log(`Server is up on port ${PORT}.`));
Considere todo o código a seguir para ocupar a parte // ...
do arquivo acima.
Para definir nossos endpoints, e porque estamos construindo uma API REST, devemos discutir a maneira correta de nomear rotas. Novamente, você deve dar uma olhada na seção HTTP do meu artigo anterior para obter mais informações. Estamos lidando com livros, então todas as rotas estarão localizadas atrás /books
(a convenção de nomenclatura plural é padrão).
Solicitação | Rota |
---|---|
PUBLICAR | /books |
PEGAR | /books/id |
CORREÇÃO | /books/id |
EXCLUIR | /books/id |
Como você pode ver, um ID não precisa ser especificado ao postar um livro porque nós (ou melhor, MongoDB), o estaremos gerando para nós, automaticamente, do lado do servidor. Os livros GETting, PATCHing e DELETing exigirão que passemos esse ID para nosso endpoint, que discutiremos mais tarde. Por enquanto, vamos simplesmente criar os endpoints:
// HTTP POST /books app.post('/books', (req, res) => { // ... console.log('A POST Request was made!'); }); // HTTP GET /books/:id app.get('/books/:id', (req, res) => { // ... console.log(`A GET Request was made! Getting book ${req.params.id}`); }); // HTTP PATCH /books/:id app.patch('/books/:id', (req, res) => { // ... console.log(`A PATCH Request was made! Updating book ${req.params.id}`); }); // HTTP DELETE /books/:id app.delete('/books/:id', (req, res) => { // ... console.log(`A DELETE Request was made! Deleting book ${req.params.id}`); });
A sintaxe :id
informa ao Express que id
é um parâmetro dinâmico que será passado na URL. Temos acesso a ele no objeto params
que está disponível em req
. Eu sei que “nós temos acesso a ele quando req
” soa como mágica e mágica (que não existe) é perigosa em programação, mas você tem que lembrar que o Express não é uma caixa preta. É um projeto de código aberto disponível no GitHub sob uma licença do MIT. Você pode visualizar facilmente seu código-fonte se quiser ver como os parâmetros de consulta dinâmica são colocados no objeto req
.
Todos juntos, agora temos o seguinte em nosso arquivo server.js
:
// Getting the module from node_modules. const express = require('express'); // This creates our Express App. const app = express(); // Define middleware. app.use(express.json()); app.use(express.urlencoded({ extended: true })); // Listening on port 3000 (arbitrary). // Not a TCP or UDP well-known port. // Does not require superuser privileges. const PORT = 3000; // We will build our API here. // HTTP POST /books app.post('/books', (req, res) => { // ... console.log('A POST Request was made!'); }); // HTTP GET /books/:id app.get('/books/:id', (req, res) => { // ... console.log(`A GET Request was made! Getting book ${req.params.id}`); }); // HTTP PATCH /books/:id app.patch('/books/:id', (req, res) => { // ... console.log(`A PATCH Request was made! Updating book ${req.params.id}`); }); // HTTP DELETE /books/:id app.delete('/books/:id', (req, res) => { // ... console.log(`A DELETE Request was made! Deleting book ${req.params.id}`); }); // Binding our application to port 3000. app.listen(PORT, () => console.log(`Server is up on port ${PORT}.`));
Vá em frente e inicie o servidor, executando o node server.js
a partir do terminal ou da linha de comando e visite seu navegador. Abra o Chrome Development Console e, na barra URL (Uniform Resource Locator), visite localhost:3000/books
. Você já deve ver o indicador no terminal do seu sistema operacional de que o servidor está ativo, bem como a instrução de log para GET.
Até agora, usamos um navegador da Web para realizar solicitações GET. Isso é bom para começar, mas descobriremos rapidamente que existem ferramentas melhores para testar rotas de API. De fato, poderíamos colar as chamadas de fetch
diretamente no console ou usar algum serviço online. No nosso caso, e para economizar tempo, usaremos cURL
e Postman. Eu uso ambos neste artigo (embora você possa usar um ou outro) para que eu possa apresentá-los se você não os tiver usado. cURL
é uma biblioteca (uma biblioteca muito, muito importante) e uma ferramenta de linha de comando projetada para transferir dados usando vários protocolos. Postman é uma ferramenta baseada em GUI para testar APIs. Depois de seguir as instruções de instalação relevantes para ambas as ferramentas em seu sistema operacional, verifique se o servidor ainda está em execução e execute os seguintes comandos (um por um) em um novo terminal. É importante digitá-los e executá-los individualmente e, em seguida, observar a mensagem de log no terminal separado do seu servidor. Além disso, observe que o símbolo de comentário da linguagem de programação padrão //
não é um símbolo válido no Bash ou no MS-DOS. Você terá que omitir essas linhas, e eu as uso aqui apenas para descrever cada bloco de comandos cURL
.
// HTTP POST Request (Localhost, IPv4, IPv6) curl -X POST https://localhost:3000/books curl -X POST https://127.0.0.1:3000/books curl -X POST https://[::1]:3000/books // HTTP GET Request (Localhost, IPv4, IPv6) curl -X GET https://localhost:3000/books/123abc curl -X GET https://127.0.0.1:3000/books/book-id-123 curl -X GET https://[::1]:3000/books/book-abc123 // HTTP PATCH Request (Localhost, IPv4, IPv6) curl -X PATCH https://localhost:3000/books/456 curl -X PATCH https://127.0.0.1:3000/books/218 curl -X PATCH https://[::1]:3000/books/some-id // HTTP DELETE Request (Localhost, IPv4, IPv6) curl -X DELETE https://localhost:3000/books/abc curl -X DELETE https://127.0.0.1:3000/books/314 curl -X DELETE https://[::1]:3000/books/217
Como você pode ver, o ID que é passado como um parâmetro de URL pode ser qualquer valor. O sinalizador -X
especifica o tipo de solicitação HTTP (pode ser omitido para GET) e fornecemos a URL para a qual a solicitação será feita posteriormente. Dupliquei cada solicitação três vezes, permitindo que você veja que tudo ainda funciona se você usar o nome do host localhost
, o endereço IPv4 ( 127.0.0.1
) para o qual o localhost
resolve ou o endereço IPv6 ( ::1
) para o qual o localhost
resolve . Observe que o cURL
requer que os endereços IPv6 sejam agrupados entre colchetes.
Estamos em um lugar decente agora - temos a estrutura simples de nossas rotas e endpoints configuradas. O servidor funciona corretamente e aceita solicitações HTTP como esperamos. Ao contrário do que você pode esperar, não há muito tempo a percorrer neste momento - nós apenas temos que configurar nosso banco de dados, hospedá-lo (usando um banco de dados como serviço - MongoDB Atlas) e persistir os dados nele (e executar validação e criar respostas de erro).
Configurando um banco de dados MongoDB de produção
Para configurar um banco de dados de produção, vamos até a página inicial do MongoDB Atlas e nos inscrevemos para uma conta gratuita. Depois disso, crie um novo cluster. Você pode manter as configurações padrão, escolhendo uma região aplicável ao nível de taxa. Em seguida, clique no botão “Criar cluster”. O cluster levará algum tempo para ser criado e, em seguida, você poderá obter a URL e a senha do banco de dados. Tome nota destes quando os vir. Vamos codificá-los por enquanto e depois armazená-los em variáveis de ambiente para fins de segurança. Para obter ajuda na criação e conexão com um cluster, vou encaminhá-lo para a Documentação do MongoDB, particularmente esta página e esta página, ou você pode deixar um comentário abaixo e tentarei ajudar.
Criando um modelo de mangusto
É recomendável que você tenha uma compreensão dos significados de Documentos e Coleções no contexto de NoSQL (Not Only SQL — Structured Query Language). Para referência, você pode querer ler o Guia de início rápido do Mongoose e a seção MongoDB do meu artigo anterior.
Agora temos um banco de dados pronto para aceitar operações CRUD. Mongoose é um módulo Node (ou ODM — Object Document Mapper) que nos permitirá realizar essas operações (abstraindo algumas das complexidades), bem como configurar o esquema, ou estrutura, da coleção de banco de dados.
Como um aviso importante, há muita controvérsia em torno de ORMs e padrões como Active Record ou Data Mapper. Alguns desenvolvedores juram pelos ORMs e outros juram contra eles (acreditando que eles atrapalham). Também é importante notar que os ORMs abstraem muito, como pool de conexões, conexões de soquete e manuseio, etc. Você poderia facilmente usar o driver nativo do MongoDB (outro módulo NPM), mas daria muito mais trabalho. Embora seja recomendado que você jogue com o driver nativo antes de usar os ORMs, omito o driver nativo aqui por brevidade. Para operações SQL complexas em um banco de dados relacional, nem todos os ORMs serão otimizados para velocidade de consulta e você pode acabar escrevendo seu próprio SQL bruto. ORMs can come into play a lot with Domain-Driven Design and CQRS, among others. They are an established concept in the .NET world, and the Node.js community has not completely caught up yet — TypeORM is better, but it's not NHibernate or Entity Framework.
To create our Model, I'll create a new folder in the server
directory entitled models
, within which I'll create a single file with the name book.js
. Thus far, our project's directory structure is as follows:
- server - node_modules - models - book.js - package.json - server.js
Indeed, this directory structure is not required, but I use it here because it's simple. Allow me to note that this is not at all the kind of architecture you want to use for larger applications (and you might not even want to use JavaScript — TypeScript could be a better option), which I discuss in this article's closing. The next step will be to install mongoose
, which is performed via, as you might expect, npm i mongoose
.
The meaning of a Model is best ascertained from the Mongoose documentation:
Models are fancy constructors compiled from
Schema
definitions. An instance of a model is called a document. Models are responsible for creating and reading documents from the underlying MongoDB database.
Before creating the Model, we'll define its Schema. A Schema will, among others, make certain expectations about the value of the properties provided. MongoDB is schemaless, and thus this functionality is provided by the Mongoose ODM. Let's start with a simple example. Suppose I want my database to store a user's name, email address, and password. Traditionally, as a plain old JavaScript Object (POJO), such a structure might look like this:
const userDocument = { name: 'Jamie Corkhill', email: '[email protected]', password: 'Bcrypt Hash' };
If that above object was how we expected our user's object to look, then we would need to define a schema for it, like this:
const schema = { name: { type: String, trim: true, required: true }, email: { type: String, trim: true, required: true }, password: { type: String, required: true } };
Notice that when creating our schema, we define what properties will be available on each document in the collection as an object in the schema. In our case, that's name
, email
, and password
. The fields type
, trim
, required
tell Mongoose what data to expect. If we try to set the name
field to a number, for example, or if we don't provide a field, Mongoose will throw an error (because we are expecting a type of String
), and we can send back a 400 Bad Request
to the client. This might not make sense right now because we have defined an arbitrary schema
object. However, the fields of type
, trim
, and required
(among others) are special validators that Mongoose understands. trim
, for example, will remove any whitespace from the beginning and end of the string. We'll pass the above schema to mongoose.Schema()
in the future and that function will know what to do with the validators.
Understanding how Schemas work, we'll create the model for our Books Collection of the Bookshelf API. Let's define what data we require:
Título
ISBN Number
Autor
Primeiro nome
Último nome
Publishing Date
Finished Reading (Boolean)
I'm going to create this in the book.js
file we created earlier in /models
. Like the example above, we'll be performing validation:
const mongoose = require('mongoose'); // Define the schema: const mySchema = { title: { type: String, required: true, trim: true, }, isbn: { type: String, required: true, trim: true, }, author: { firstName:{ type: String, required: true, trim: true }, lastName: { type: String, required: true, trim: true } }, publishingDate: { type: String }, finishedReading: { type: Boolean, required: true, default: false } }
default
will set a default value for the property if none is provided — finishedReading
for example, although a required field, will be set automatically to false
if the client does not send one up.
Mongoose also provides the ability to perform custom validation on our fields, which is done by supplying the validate()
method, which attains the value that was attempted to be set as its one and only parameter. In this function, we can throw an error if the validation fails. Aqui está um exemplo:
// ... isbn: { type: String, required: true, trim: true, validate(value) { if (!validator.isISBN(value)) { throw new Error('ISBN is invalid.'); } } } // ...
Now, if anyone supplies an invalid ISBN to our model, Mongoose will throw an error when trying to save that document to the collection. I've already installed the NPM module validator
via npm i validator
and required it. validator
contains a bunch of helper functions for common validation requirements, and I use it here instead of RegEx because ISBNs can't be validated with RegEx alone due to a tailing checksum. Remember, users will be sending a JSON body to one of our POST routes. That endpoint will catch any errors (such as an invalid ISBN) when attempting to save, and if one is thrown, it'll return a blank response with an HTTP 400 Bad Request
status — we haven't yet added that functionality.
Finally, we have to define our schema of earlier as the schema for our model, so I'll make a call to mongoose.Schema()
passing in that schema:
const bookSchema = mongoose.Schema(mySchema);
To make things more precise and clean, I'll replace the mySchema
variable with the actual object all on one line:
const bookSchema = mongoose.Schema({ title:{ type: String, required: true, trim: true, }, isbn:{ type: String, required: true, trim: true, validate(value) { if (!validator.isISBN(value)) { throw new Error('ISBN is invalid.'); } } }, author:{ firstName: { type: String required: true, trim: true }, lastName:{ type: String, required: true, trim: true } }, publishingDate:{ type: String }, finishedReading:{ type: Boolean, required: true, default: false } });
Let's take a final moment to discuss this schema. We are saying that each of our documents will consist of a title, an ISBN, an author with a first and last name, a publishing date, and a finishedReading boolean.
-
title
will be of typeString
, it's a required field, and we'll trim any whitespace. -
isbn
will be of typeString
, it's a required field, it must match the validator, and we'll trim any whitespace. -
author
is of typeobject
containing a required, trimmed,string
firstName and a required, trimmed,string
lastName. -
publishingDate
is of type String (although we could make it of typeDate
orNumber
for a Unix timestamp. -
finishedReading
is a requiredboolean
that will default tofalse
if not provided.
With our bookSchema
defined, Mongoose knows what data and what fields to expect within each document to the collection that stores books. However, how do we tell it what collection that specific schema defines? We could have hundreds of collections, so how do we correlate, or tie, bookSchema
to the Book
collection?
The answer, as seen earlier, is with the use of models. We'll use bookSchema
to create a model, and that model will model the data to be stored in the Book collection, which will be created by Mongoose automatically.
Append the following lines to the end of the file:
const Book = mongoose.model('Book', bookSchema); module.exports = Book;
As you can see, we have created a model, the name of which is Book
(— the first parameter to mongoose.model()
), and also provided the ruleset, or schema, to which all data is saved in the Book collection will have to abide. We export this model as a default export, allowing us to require
the file for our endpoints to access. Book
is the object upon which we'll call all of the required functions to Create, Read, Update, and Delete data which are provided by Mongoose.
Altogether, our book.js
file should look as follows:
const mongoose = require('mongoose'); const validator = require('validator'); // Define the schema. const bookSchema = mongoose.Schema({ title:{ type: String, required: true, trim: true, }, isbn:{ type: String, required: true, trim: true, validate(value) { if (!validator.isISBN(value)) { throw new Error('ISBN is invalid.'); } } }, author:{ firstName: { type: String, required: true, trim: true }, lastName:{ type: String, required: true, trim: true } }, publishingDate:{ type: String }, finishedReading:{ type: Boolean, required: true, default: false } }); // Create the "Book" model of name Book with schema bookSchema. const Book = mongoose.model('Book', bookSchema); // Provide the model as a default export. module.exports = Book;
Connecting To MongoDB (Basics)
Don't worry about copying down this code. I'll provide a better version in the next section. To connect to our database, we'll have to provide the database URL and password. We'll call the connect
method available on mongoose
to do so, passing to it the required data. For now, we are going hardcode the URL and password — an extremely frowned upon technique for many reasons: namely the accidental committing of sensitive data to a public (or private made public) GitHub Repository. Realize also that commit history is saved, and that if you accidentally commit a piece of sensitive data, removing it in a future commit will not prevent people from seeing it (or bots from harvesting it), because it's still available in the commit history. CLI tools exist to mitigate this issue and remove history.
As stated, for now, we'll hard code the URL and password, and then save them to environment variables later. At this point, let's look at simply how to do this, and then I'll mention a way to optimize it.
const mongoose = require('mongoose'); const MONGODB_URL = 'Your MongoDB URL'; mongoose.connect(MONGODB_URL, { useNewUrlParser: true, useCreateIndex: true, useFindAndModify: false, useUnifiedTopology: true });
Isso se conectará ao banco de dados. Fornecemos a URL que obtivemos do painel do MongoDB Atlas, e o objeto passado como o segundo parâmetro especifica recursos a serem usados para, entre outros, evitar avisos de descontinuação.
O Mongoose, que usa o driver nativo do MongoDB principal nos bastidores, precisa tentar acompanhar as alterações feitas no driver. Em uma nova versão do driver, o mecanismo usado para analisar as URLs de conexão foi alterado, portanto, passamos o useNewUrlParser: true
para especificar que queremos usar a versão mais recente disponível do driver oficial.
Por padrão, se você definir índices (e eles são chamados de “índices” e não “índices”) (que não abordaremos neste artigo) em dados em seu banco de dados, o Mongoose usa a função ensureIndex()
disponível no Driver Nativo. O MongoDB preteriu essa função em favor de createIndex()
e, portanto, definir o sinalizador useCreateIndex
como true dirá ao Mongoose para usar o createIndex()
do driver, que é a função não preterida.
A versão original do Mongoose de findOneAndUpdate
(que é um método para localizar um documento em um banco de dados e atualizá-lo) é anterior à versão do driver nativo. Ou seja, findOneAndUpdate()
não era originalmente uma função de driver nativo, mas sim uma função fornecida pelo Mongoose, então o Mongoose teve que usar findAndModify
fornecido nos bastidores pelo driver para criar a funcionalidade findOneAndUpdate
. Com o driver agora atualizado, ele contém sua própria função, então não precisamos usar findAndModify
. Isso pode não fazer sentido, e tudo bem – não é uma informação importante na escala das coisas.
Por fim, o MongoDB preteriu seu antigo sistema de monitoramento de servidor e mecanismo. Usamos o novo método com useUnifiedTopology: true
.
O que temos até agora é uma maneira de se conectar ao banco de dados. Mas aqui está a coisa - não é escalável ou eficiente. Quando escrevemos testes de unidade para esta API, os testes de unidade usarão seus próprios dados de teste (ou acessórios) em seus próprios bancos de dados de teste. Portanto, queremos uma maneira de criar conexões para diferentes propósitos - algumas para ambientes de teste (que podemos criar e desmontar à vontade), outras para ambientes de desenvolvimento e outras para ambientes de produção. Para fazer isso, vamos construir uma fábrica. (Lembra-se disso de antes?)
Conectando-se ao Mongo — Construindo uma implementação de uma fábrica JS
De fato, Objetos Java não são análogos a Objetos JavaScript e, portanto, subsequentemente, o que sabemos acima do Padrão de Design de Fábrica não se aplicará. Eu apenas forneci isso como um exemplo para mostrar o padrão tradicional. Para obter um objeto em Java, ou C#, ou C++, etc., temos que instanciar uma classe. Isso é feito com a palavra-chave new
, que instrui o compilador a alocar memória para o objeto no heap. Em C++, isso nos dá um ponteiro para o objeto que temos que limpar para que não tenhamos ponteiros suspensos ou vazamentos de memória (C++ não tem coletor de lixo, ao contrário do Node/V8 que é construído em C++) Em JavaScript, o acima não precisa ser feito — não precisamos instanciar uma classe para obter um objeto — um objeto é apenas {}
. Algumas pessoas dirão que tudo em JavaScript é um objeto, embora isso tecnicamente não seja verdade porque tipos primitivos não são objetos.
Pelas razões acima, nossa Fábrica JS será mais simples, mantendo a definição solta de uma fábrica sendo uma função que retorna um objeto (um objeto JS). Como uma função é um objeto (para function
herda de object
via herança prototípica), nosso exemplo abaixo atenderá a esse critério. Para implementar a fábrica, criarei uma nova pasta dentro do server
chamada db
. Dentro do db
vou criar um novo arquivo chamado mongoose.js
. Este arquivo fará conexões com o banco de dados. Dentro do mongoose.js
, vou criar uma função chamada connectionFactory
e exportá-la por padrão:
// Directory - server/db/mongoose.js const mongoose = require('mongoose'); const MONGODB_URL = 'Your MongoDB URL'; const connectionFactory = () => { return mongoose.connect(MONGODB_URL, { useNewUrlParser: true, useCreateIndex: true, useFindAndModify: false }); }; module.exports = connectionFactory;
Usando a abreviação fornecida pelo ES6 para Arrow Functions que retornam uma instrução na mesma linha da assinatura do método, simplificarei esse arquivo eliminando a definição connectionFactory
e exportando apenas a fábrica por padrão:
// server/db/mongoose.js const mongoose = require('mongoose'); const MONGODB_URL = 'Your MongoDB URL'; module.exports = () => mongoose.connect(MONGODB_URL, { useNewUrlParser: true, useCreateIndex: true, useFindAndModify: true });
Agora, tudo o que se precisa fazer é exigir o arquivo e chamar o método que é exportado, assim:
const connectionFactory = require('./db/mongoose'); connectionFactory(); // OR require('./db/mongoose')();
Você pode inverter o controle fazendo com que sua URL do MongoDB seja fornecida como um parâmetro para a função de fábrica, mas vamos alterar dinamicamente a URL como uma variável de ambiente com base no ambiente.
Os benefícios de fazer nossa conexão como uma função são que podemos chamar essa função posteriormente no código para conectar ao banco de dados a partir de arquivos destinados à produção e aqueles destinados a testes de integração local e remoto tanto no dispositivo quanto com um pipeline de CI/CD remoto /construir servidor.
Construindo nossos endpoints
Agora começamos a adicionar lógica relacionada a CRUD muito simples aos nossos endpoints. Como dito anteriormente, um breve aviso está em ordem. Os métodos pelos quais implementamos nossa lógica de negócios aqui não são aqueles que você deve espelhar para nada além de projetos simples. Conectar-se a bancos de dados e executar lógica diretamente em endpoints é (e deve ser) desaprovado, pois você perde a capacidade de trocar serviços ou DBMSs sem precisar executar uma refatoração em todo o aplicativo. No entanto, considerando que este é um artigo para iniciantes, emprego essas más práticas aqui. Um artigo futuro desta série discutirá como podemos aumentar a complexidade e a qualidade de nossa arquitetura.
Por enquanto, vamos voltar ao nosso arquivo server.js
e garantir que ambos tenham o mesmo ponto de partida. Observe que adicionei a instrução require
para nossa fábrica de conexões de banco de dados e importei o modelo que exportamos de ./models/book.js
.
const express = require('express'); // Database connection and model. require('./db/mongoose.js'); const Book = require('./models/book.js'); // This creates our Express App. const app = express(); // Define middleware. app.use(express.json()); app.use(express.urlencoded({ extended: true })); // Listening on port 3000 (arbitrary). // Not a TCP or UDP well-known port. // Does not require superuser privileges. const PORT = 3000; // We will build our API here. // HTTP POST /books app.post('/books', (req, res) => { // ... console.log('A POST Request was made!'); }); // HTTP GET /books/:id app.get('/books/:id', (req, res) => { // ... console.log(`A GET Request was made! Getting book ${req.params.id}`); }); // HTTP PATCH /books/:id app.patch('/books/:id', (req, res) => { // ... console.log(`A PATCH Request was made! Updating book ${req.params.id}`); }); // HTTP DELETE /books/:id app.delete('/books/:id', (req, res) => { // ... console.log(`A DELETE Request was made! Deleting book ${req.params.id}`); }); // Binding our application to port 3000. app.listen(PORT, () => console.log(`Server is up on port ${PORT}.`));
Vou começar com app.post()
. Temos acesso ao modelo Book
porque o exportamos do arquivo dentro do qual o criamos. Conforme declarado nos documentos do Mongoose, o Book
é construtível. Para criar um novo livro, chamamos o construtor e passamos os dados do livro, da seguinte forma:
const book = new Book(bookData);
No nosso caso, teremos bookData
como o objeto enviado na requisição, que estará disponível em req.body.book
. Lembre-se, o middleware express.json()
colocará todos os dados JSON que enviarmos em req.body
. Devemos enviar JSON no seguinte formato:
{ "book": { "title": "The Art of Computer Programming", "isbn": "ISBN-13: 978-0-201-89683-1", "author": { "firstName": "Donald", "lastName": "Knuth" }, "publishingDate": "July 17, 1997", "finishedReading": true } }
O que isso significa, então, é que o JSON que deixamos passar será analisado, e todo o objeto JSON (o primeiro par de chaves) será colocado em req.body
pelo middleware express.json()
. A única propriedade em nosso objeto JSON é book
e, portanto, o objeto book
estará disponível em req.body.book
.
Neste ponto, podemos chamar a função construtora do modelo e passar nossos dados:
app.post('/books', async (req, res) => { // <- Notice 'async' const book = new Book(req.body.book); await book.save(); // <- Notice 'await' });
Observe algumas coisas aqui. Chamar o método save
na instância que retornamos ao chamar a função construtora persistirá o objeto req.body.book
no banco de dados se e somente se ele estiver em conformidade com o esquema que definimos no modelo Mongoose. O ato de salvar dados em um banco de dados é uma operação assíncrona, e esse método save()
retorna uma promessa — cuja resolução aguardamos muito. Em vez de encadear em uma chamada .then()
, uso a sintaxe ES6 Async/Await, o que significa que devo fazer a função de retorno de chamada para app.post
async
.
book.save()
irá rejeitar com um ValidationError
se o objeto que o cliente enviou não estiver de acordo com o esquema que definimos. Nossa configuração atual cria um código muito esquisito e mal escrito, pois não queremos que nosso aplicativo falhe no caso de uma falha na validação. Para corrigir isso, colocarei a operação perigosa em uma cláusula try/catch
. Em caso de erro, retornarei uma HTTP 400 Bad Request ou uma HTTP 422 Unprocessable Entity. Há algum debate sobre qual usar, então vou ficar com um 400 para este artigo, pois é mais genérico.
app.post('/books', async (req, res) => { try { const book = new Book(req.body.book); await book.save(); return res.status(201).send({ book }); } catch (e) { return res.status(400).send({ error: 'ValidationError' }); } });
Observe que eu uso o ES6 Object Shorthand apenas para retornar o objeto book
de volta ao cliente no caso de sucesso com res.send({ book })
— isso seria equivalente a res.send({ book: book })
. Eu também retorno a expressão apenas para garantir que minha função seja encerrada. No bloco catch
, defino o status como 400 explicitamente e retorno a string 'ValidationError' na propriedade error
do objeto que é enviado de volta. Um 201 é o código de status do caminho de sucesso que significa “CRIADO”.
Na verdade, essa também não é a melhor solução porque não podemos ter certeza de que o motivo da falha foi uma solicitação incorreta do lado do cliente. Talvez tenhamos perdido a conexão (supostamente uma conexão de soquete descartada, portanto, uma exceção transitória) com o banco de dados, caso em que provavelmente deveríamos retornar um erro 500 Internal Server. Uma maneira de verificar isso seria ler o objeto de erro e
e retornar seletivamente uma resposta. Vamos fazer isso agora, mas como eu disse várias vezes, um artigo de acompanhamento discutirá a arquitetura adequada em termos de roteadores, controladores, serviços, repositórios, classes de erro personalizadas, middleware de erro personalizado, respostas de erro personalizadas, dados de modelo de banco de dados/entidade de domínio mapeamento e Separação de Consulta de Comando (CQS).
app.post('/books', async (req, res) => { try { const book = new Book(req.body.book); await book.save(); return res.send({ book }); } catch (e) { if (e instanceof mongoose.Error.ValidationError) { return res.status(400).send({ error: 'ValidationError' }); } else { return res.status(500).send({ error: 'Internal Error' }); } } });
Vá em frente e abra o Postman (supondo que você o tenha, caso contrário, baixe e instale) e crie uma nova solicitação. Faremos uma solicitação POST para localhost:3000/books
. Na guia “Body” na seção Postman Request, selecionarei o botão de opção “raw” e selecionarei “JSON” no botão suspenso à direita. Isso prosseguirá e adicionará automaticamente o cabeçalho Content-Type: application/json
à solicitação. Em seguida, copiarei e colarei o objeto JSON do livro anterior na área de texto do corpo. Isto é o que temos:
Depois disso, vou clicar no botão enviar e você verá uma resposta 201 Created na seção “Response” do Postman (linha inferior). Vemos isso porque pedimos especificamente ao Express para responder com um 201 e o objeto Book - se tivéssemos feito res.send()
sem código de status, o express
teria respondido automaticamente com um 200 OK. Como você pode ver, o objeto Book agora está salvo no banco de dados e foi retornado ao cliente como a resposta à solicitação POST.
Se você visualizar a coleção de livros do banco de dados por meio do MongoDB Atlas, verá que o livro foi realmente salvo.
Você também pode dizer que o MongoDB inseriu os campos __v
e _id
. O primeiro representa a versão do documento, neste caso, 0, e o último é o ObjectID do documento — que é gerado automaticamente pelo MongoDB e é garantido ter uma baixa probabilidade de colisão.
Um resumo do que cobrimos até agora
Nós cobrimos muito até agora no artigo. Vamos fazer uma breve pausa analisando um breve resumo antes de retornar para concluir a API Express.
Aprendemos sobre a desestruturação de objetos ES6, a sintaxe abreviada de objetos ES6, bem como o operador ES6 Rest/Spread. Todos os três nos permitem fazer o seguinte (e mais, conforme discutido acima):
// Destructuring Object Properties: const { a: newNameA = 'Default', b } = { a: 'someData', b: 'info' }; console.log(`newNameA: ${newNameA}, b: ${b}`); // newNameA: someData, b: info // Destructuring Array Elements const [elemOne, elemTwo] = [() => console.log('hi'), 'data']; console.log(`elemOne(): ${elemOne()}, elemTwo: ${elemTwo}`); // elemOne(): hi, elemTwo: data // Object Shorthand const makeObj = (name) => ({ name }); console.log(`makeObj('Tim'): ${JSON.stringify(makeObj('Tim'))}`); // makeObj('Tim'): { "name": "Tim" } // Rest, Spread const [c, d, ...rest] = [0, 1, 2, 3, 4]; console.log(`c: ${c}, d: ${d}, rest: ${rest}`) // c: 0, d: 1, rest: 2, 3, 4
Também abordamos Express, Expess Middleware, Servidores, Portas, Endereçamento IP, etc. As coisas ficaram interessantes quando descobrimos que existem métodos disponíveis no resultado de retorno de require('express')();
com os nomes dos verbos HTTP, como app.get
e app.post
.
Se essa parte require('express')()
não fazia sentido para você, este era o ponto que eu queria dizer:
const express = require('express'); const app = express(); app.someHTTPVerb
Deve fazer sentido da mesma forma que disparamos a fábrica de conexões antes para o Mongoose.
Cada manipulador de rota, que é a função de ponto de extremidade (ou função de retorno de chamada), é passado em um objeto req
e um objeto res
do Express nos bastidores. (Tecnicamente, eles também são os next
, como veremos em um minuto). req
contém dados específicos da solicitação recebida do cliente, como cabeçalhos ou qualquer JSON enviado. res
é o que nos permite retornar respostas ao cliente. A next
função também é passada para manipuladores.
Com o Mongoose, vimos como podemos nos conectar ao banco de dados com dois métodos - uma maneira primitiva e uma maneira mais avançada/prática que toma emprestado do Factory Pattern. Acabaremos usando isso quando discutirmos Teste de Unidade e Integração com Jest (e teste de mutação) porque isso nos permitirá ativar uma instância de teste do banco de dados preenchida com dados de semente contra os quais podemos executar asserções.
Depois disso, criamos um objeto de esquema Mongoose e o usamos para criar um modelo, e então aprendemos como podemos chamar o construtor desse modelo para criar uma nova instância dele. Disponível na instância está um método save
(entre outros), que é de natureza assíncrona e que verificará se a estrutura do objeto que passamos está de acordo com o esquema, resolvendo a promessa se estiver e rejeitando a promessa com um ValidationError
se isso não. No caso de uma resolução, o novo documento é salvo no banco de dados e respondemos com um HTTP 200 OK/201 CREATED, caso contrário, pegamos o erro lançado em nosso endpoint e retornamos um HTTP 400 Bad Request para o cliente.
À medida que continuamos construindo nossos endpoints, você aprenderá mais sobre alguns dos métodos disponíveis no modelo e na instância do modelo.
Finalizando nossos endpoints
Tendo concluído o POST Endpoint, vamos lidar com GET. Como mencionei anteriormente, a sintaxe :id
dentro da rota permite que o Express saiba que id
é um parâmetro de rota, acessível a partir de req.params
. Você já viu que ao combinar algum ID para o parâmetro “curinga” na rota, ele era impresso na tela nos primeiros exemplos. Por exemplo, se você fez uma solicitação GET para “/books/test-id-123”, então req.params.id
seria a string test-id-123
porque o nome do parâmetro era id
tendo a rota como HTTP GET /books/:id
.
Então, tudo o que precisamos fazer é recuperar esse ID do objeto req
e verificar se algum documento em nosso banco de dados tem o mesmo ID - algo muito fácil com o Mongoose (e o driver nativo).
app.get('/books/:id', async (req, res) => { const book = await Book.findById(req.params.id); console.log(book); res.send({ book }); });
Você pode ver que acessível em nosso modelo é uma função que podemos chamar que encontrará um documento por seu ID. Nos bastidores, o Mongoose converterá qualquer ID que passarmos para findById
para o tipo do campo _id
no documento ou, neste caso, um ObjectId
. Se um ID correspondente for encontrado (e apenas um será encontrado para ObjectId
ter uma probabilidade de colisão extremamente baixa), esse documento será colocado em nossa variável constante de book
. Caso contrário, o book
será nulo - um fato que usaremos em um futuro próximo.
Por enquanto, vamos reiniciar o servidor (você deve reiniciar o servidor, a menos que esteja usando nodemon
) e garantir que ainda tenhamos o documento de um livro anterior dentro da Coleção de Books
. Vá em frente e copie o ID desse documento, a parte destacada da imagem abaixo:
E use-o para fazer uma solicitação GET para /books/:id
com o Postman da seguinte forma (observe que os dados do corpo são apenas sobras da minha solicitação POST anterior. Na verdade, não está sendo usado, apesar de estar representado na imagem abaixo) :
Ao fazer isso, você deve obter o documento do livro com o ID especificado de volta na seção de resposta do Postman. Observe que anteriormente, com a rota POST, projetada para “POST” ou “enviar” novos recursos para o servidor, respondemos com um 201 Created — porque um novo recurso (ou documento) foi criado. No caso de GET, nada de novo foi criado - apenas solicitamos um recurso com um ID específico, portanto, um código de status 200 OK é o que recebemos de volta, em vez de 201 Created.
Como é comum no campo de desenvolvimento de software, os casos extremos devem ser considerados – a entrada do usuário é inerentemente insegura e errônea, e é nosso trabalho, como desenvolvedores, ser flexíveis aos tipos de entrada que podemos receber e responder a eles adequadamente. O que fazemos se o usuário (ou o API Caller) nos passar algum ID que não pode ser convertido para um ObjectID do MongoDB ou um ID que pode ser convertido, mas que não existe?
Para o primeiro caso, o Mongoose lançará um CastError
- o que é compreensível porque, se fornecermos um ID como math-is-fun
, isso obviamente não é algo que pode ser convertido em um ObjectID, e a conversão em um ObjectID é especificamente o que Mongoose está fazendo sob o capô.
Para o último caso, poderíamos facilmente corrigir o problema por meio de uma verificação nula ou uma cláusula de guarda. De qualquer forma, vou enviar de volta uma resposta HTTP 404 Not Found. Vou lhe mostrar algumas maneiras de fazer isso, uma maneira ruim e depois uma maneira melhor.
Em primeiro lugar, poderíamos fazer o seguinte:
app.get('/books/:id', async (req, res) => { try { const book = await Book.findById(req.params.id); if (!book) throw new Error(); return res.send({ book }); } catch (e) { return res.status(404).send({ error: 'Not Found' }); } });
Isso funciona e podemos usá-lo muito bem. Espero que a instrução await Book.findById()
lance um Mongoose CastError
se a string de ID não puder ser convertida em um ObjectID, fazendo com que o bloco catch
seja executado. Se ele puder ser convertido, mas o ObjectID correspondente não existir, então o book
será null
e a Verificação de Nulos lançará um erro, disparando novamente o bloco catch
. Dentro de catch
, acabamos de retornar um 404. Há dois problemas aqui. Primeiro, mesmo que o livro seja encontrado, mas ocorra algum outro erro desconhecido, enviamos de volta um 404 quando provavelmente deveríamos dar ao cliente um genérico genérico 500. Segundo, não estamos realmente diferenciando se o ID enviado é válido, mas inexistente, ou se é apenas uma identificação ruim.
Então, aqui está outra maneira:
const mongoose = require('mongoose'); app.get('/books/:id', async (req, res) => { try { const book = await Book.findById(req.params.id); if (!book) return res.status(404).send({ error: 'Not Found' }); return res.send({ book }); } catch (e) { if (e instanceof mongoose.Error.CastError) { return res.status(400).send({ error: 'Not a valid ID' }); } else { return res.status(500).send({ error: 'Internal Error' }); } } });
O bom disso é que podemos lidar com todos os três casos de um 400, um 404 e um genérico 500. Observe que após o Null Check em book
, eu uso a palavra-chave return
na minha resposta. Isso é muito importante porque queremos ter certeza de que sairemos do manipulador de rotas.
Algumas outras opções podem ser para verificarmos se o id
em req.params
pode ser convertido para um ObjectID explicitamente em vez de permitir que o Mongoose converta implicitamente com mongoose.Types.ObjectId.isValid('id);
, mas há um caso extremo com strings de 12 bytes que faz com que isso às vezes funcione inesperadamente.
Poderíamos tornar essa repetição menos dolorosa com o Boom
, uma biblioteca de resposta HTTP, por exemplo, ou poderíamos empregar o Error Handling Middleware. Também poderíamos transformar Erros do Mongoose em algo mais legível com Ganchos/Middleware do Mongoose, conforme descrito aqui. Uma opção adicional seria definir objetos de erro personalizados e usar o middleware de tratamento de erros expresso global, no entanto, vou guardar isso para um próximo artigo em que discutiremos melhores métodos de arquitetura.
No endpoint para PATCH /books/:id
, esperamos que um objeto de atualização seja passado contendo atualizações para o livro em questão. Para este artigo, permitiremos que todos os campos sejam atualizados, mas no futuro, mostrarei como podemos impedir atualizações de campos específicos. Além disso, você verá que a lógica de tratamento de erros em nosso PATCH Endpoint será a mesma do nosso GET Endpoint. Isso é uma indicação de que estamos violando os Princípios DRY, mas, novamente, falaremos sobre isso mais tarde.
Vou esperar que todas as atualizações estejam disponíveis no objeto updates
de req.body
(o que significa que o cliente enviará JSON contendo um objeto updates
) e usará a função Book.findByAndUpdate
com um sinalizador especial para realizar a atualização.
app.patch('/books/:id', async (req, res) => { const { id } = req.params; const { updates } = req.body; try { const updatedBook = await Book.findByIdAndUpdate(id, updates, { runValidators: true, new: true }); if (!updatedBook) return res.status(404).send({ error: 'Not Found' }); return res.send({ book: updatedBook }); } catch (e) { if (e instanceof mongoose.Error.CastError) { return res.status(400).send({ error: 'Not a valid ID' }); } else { return res.status(500).send({ error: 'Internal Error' }); } } });
Observe algumas coisas aqui. Primeiro desestruturamos o id
de req.params
e updates
de req.body
.
Disponível no modelo Book
está uma função com o nome de findByIdAndUpdate
que recebe o ID do documento em questão, as atualizações a serem executadas e um objeto de opções opcional. Normalmente, o Mongoose não irá re-executar a validação para operações de atualização, então o runValidators: true
sinalizador que passamos quando o objeto de options
o força a fazê-lo. Além disso, a partir do Mongoose 4, Model.findByIdAndUpdate
não retorna mais o documento modificado, mas retorna o documento original. O new: true
(que é false por padrão) substitui esse comportamento.
Finalmente, podemos construir nosso endpoint DELETE, que é bastante semelhante a todos os outros:
app.delete('/books/:id', async (req, res) => { try { const deletedBook = await Book.findByIdAndDelete(req.params.id); if (!deletedBook) return res.status(404).send({ error: 'Not Found' }); return res.send({ book: deletedBook }); } catch (e) { if (e instanceof mongoose.Error.CastError) { return res.status(400).send({ error: 'Not a valid ID' }); } else { return res.status(500).send({ error: 'Internal Error' }); } } });
Com isso, nossa API primitiva está completa e você pode testá-la fazendo requisições HTTP para todos os endpoints.
Um breve aviso sobre arquitetura e como vamos corrigi-lo
Do ponto de vista arquitetônico, o código que temos aqui é muito ruim, é confuso, não é DRY, não é SOLID, na verdade, você pode até chamar de abominável. Esses chamados “Manipuladores de Rotas” estão fazendo muito mais do que apenas “manipular rotas” – eles estão interagindo diretamente com nosso banco de dados. Isso significa que não há absolutamente nenhuma abstração.
Vamos ser sinceros, a maioria dos aplicativos nunca será tão pequena ou você provavelmente poderá se safar com arquiteturas sem servidor com o Firebase Database. Talvez, como veremos mais tarde, os usuários queiram fazer upload de avatares, citações e trechos de seus livros, etc. Talvez queiramos adicionar um recurso de bate-papo ao vivo entre usuários com WebSockets, e vamos até dizer que 'vai abrir nosso aplicativo para permitir que os usuários emprestem livros uns com os outros por uma pequena taxa - nesse ponto, precisamos considerar a integração de pagamento com a API Stripe e a logística de envio com a API Shippo.
Suponha que prossigamos com nossa arquitetura atual e adicionemos toda essa funcionalidade. Esses manipuladores de rotas, também conhecidos como Ações do Controlador, vão acabar sendo muito, muito grandes com uma alta complexidade ciclomática . Esse estilo de codificação pode nos servir bem nos primeiros dias, mas e se decidirmos que nossos dados são referenciais e, portanto, o PostgreSQL é uma escolha de banco de dados melhor do que o MongoDB? Agora temos que refatorar todo o nosso aplicativo, eliminando o Mongoose, alterando nossos controladores, etc., o que pode levar a possíveis bugs no restante da lógica de negócios. Outro exemplo seria decidir que o AWS S3 é muito caro e desejamos migrar para o GCP. Novamente, isso requer uma refatoração em todo o aplicativo.
Embora existam muitas opiniões sobre arquitetura, desde Domain-Driven Design, Command Query Responsibility Segregation e Event Sourcing, até Test-Driven Development, SOILD, Layered Architecture, Onion Architecture e muito mais, vamos nos concentrar na implementação de Layered Architecture simples em artigos futuros, consistindo em Controladores, Serviços e Repositórios, e empregando Padrões de Projeto como Composição, Adaptadores/Wrappers e Inversão de Controle via Injeção de Dependência. Embora, até certo ponto, isso possa ser realizado com JavaScript, examinaremos as opções do TypeScript para obter essa arquitetura também, permitindo empregar paradigmas de programação funcional, como o Both Monads, além de conceitos de OOP, como o Generics.
Por enquanto, há duas pequenas mudanças que podemos fazer. Como nossa lógica de manipulação de erros é bastante semelhante no bloco catch
de todos os endpoints, podemos extraí-la para uma função de middleware de manipulação expressa de erros personalizada no final da pilha.
Limpando nossa arquitetura
Atualmente, estamos repetindo uma quantidade muito grande de lógica de tratamento de erros em todos os nossos endpoints. Em vez disso, podemos construir uma função Express Error Handling Middleware, que é uma Função Express Middleware que é chamada com um erro, os objetos req e res e a próxima função.
Por enquanto, vamos construir essa função de middleware. Tudo o que vou fazer é repetir a mesma lógica de tratamento de erros a que estamos acostumados:
app.use((err, req, res, next) => { if (err instanceof mongoose.Error.ValidationError) { return res.status(400).send({ error: 'Validation Error' }); } else if (err instanceof mongoose.Error.CastError) { return res.status(400).send({ error: 'Not a valid ID' }); } else { console.log(err); // Unexpected, so worth logging. return res.status(500).send({ error: 'Internal error' }); } });
Isso não parece funcionar com erros do Mongoose, mas em geral, em vez de usar if/else if/else
para determinar instâncias de erro, você pode alternar o construtor do erro. Vou deixar o que temos, no entanto.
Em um manipulador de endpoint/rota síncrono , se você lançar um erro, o Express o detectará e o processará sem nenhum trabalho extra necessário de sua parte. Infelizmente, não é o nosso caso. Estamos lidando com código assíncrono . Para delegar o tratamento de erros ao Express com manipuladores de rotas assíncronas, nós mesmos pegamos o erro e o passamos para next()
.
Então, permitirei que next
seja o terceiro argumento no endpoint e removerei a lógica de tratamento de erros nos blocos catch
em favor de apenas passar a instância de erro para next
, como tal:
app.post('/books', async (req, res, next) => { try { const book = new Book(req.body.book); await book.save(); return res.send({ book }); } catch (e) { next(e) } });
Se você fizer isso com todos os manipuladores de rotas, deverá terminar com o seguinte código:
const express = require('express'); const mongoose = require('mongoose'); // Database connection and model. require('./db/mongoose.js')(); const Book = require('./models/book.js'); // This creates our Express App. const app = express(); // Define middleware. app.use(express.json()); app.use(express.urlencoded({ extended: true })); // Listening on port 3000 (arbitrary). // Not a TCP or UDP well-known port. // Does not require superuser privileges. const PORT = 3000; // We will build our API here. // HTTP POST /books app.post('/books', async (req, res, next) => { try { const book = new Book(req.body.book); await book.save(); return res.status(201).send({ book }); } catch (e) { next(e) } }); // HTTP GET /books/:id app.get('/books/:id', async (req, res) => { try { const book = await Book.findById(req.params.id); if (!book) return res.status(404).send({ error: 'Not Found' }); return res.send({ book }); } catch (e) { next(e); } }); // HTTP PATCH /books/:id app.patch('/books/:id', async (req, res, next) => { const { id } = req.params; const { updates } = req.body; try { const updatedBook = await Book.findByIdAndUpdate(id, updates, { runValidators: true, new: true }); if (!updatedBook) return res.status(404).send({ error: 'Not Found' }); return res.send({ book: updatedBook }); } catch (e) { next(e); } }); // HTTP DELETE /books/:id app.delete('/books/:id', async (req, res, next) => { try { const deletedBook = await Book.findByIdAndDelete(req.params.id); if (!deletedBook) return res.status(404).send({ error: 'Not Found' }); return res.send({ book: deletedBook }); } catch (e) { next(e); } }); // Notice - bottom of stack. app.use((err, req, res, next) => { if (err instanceof mongoose.Error.ValidationError) { return res.status(400).send({ error: 'Validation Error' }); } else if (err instanceof mongoose.Error.CastError) { return res.status(400).send({ error: 'Not a valid ID' }); } else { console.log(err); // Unexpected, so worth logging. return res.status(500).send({ error: 'Internal error' }); } }); // Binding our application to port 3000. app.listen(PORT, () => console.log(`Server is up on port ${PORT}.`));
Indo além, valeria a pena separar nosso middleware de tratamento de erros em outro arquivo, mas isso é trivial, e veremos isso em artigos futuros desta série. Além disso, poderíamos usar um módulo NPM chamado express-async-errors
para não precisar chamar next no bloco catch, mas, novamente, estou tentando mostrar como as coisas são feitas oficialmente.
Uma palavra sobre CORS e a mesma política de origem
Suponha que seu site seja servido a partir do domínio myWebsite.com
, mas seu servidor esteja em myOtherDomain.com/api
. CORS significa Compartilhamento de Recursos de Origem Cruzada e é um mecanismo pelo qual as solicitações entre domínios podem ser executadas. No caso acima, como o código JS do servidor e do front-end estão em domínios diferentes, você faria uma solicitação em duas origens diferentes, o que geralmente é restrito pelo navegador por motivos de segurança e mitigado pelo fornecimento de cabeçalhos HTTP específicos.
A Política de Mesma Origem é o que executa essas restrições mencionadas - um navegador da Web só permitirá que as solicitações sejam feitas na mesma origem.
Falaremos sobre CORS e SOP mais tarde quando construirmos um front-end empacotado de Webpack para nossa API de livros com React.
Conclusão e o que vem a seguir
Já discutimos muito neste artigo. Talvez não tenha sido totalmente prático, mas espero que tenha deixado você mais confortável trabalhando com os recursos Express e ES6 JavaScript. Se você é novo em programação e o Node é o primeiro caminho pelo qual você está embarcando, esperamos que as referências a linguagens de tipos estaticamente como Java, C++ e C# tenham ajudado a destacar algumas das diferenças entre JavaScript e suas contrapartes estáticas.
Next time, we'll finish building out our Book API by making some fixes to our current setup with regards to the Book Routes, as well as adding in User Authentication so that users can own books. We'll do all of this with a similar architecture to what I described here and with MongoDB for data persistence. Finally, we'll permit users to upload avatar images to AWS S3 via Buffers.
In the article thereafter, we'll be rebuilding our application from the ground up in TypeScript, still with Express. We'll also move to PostgreSQL with Knex instead of MongoDB with Mongoose as to depict better architectural practices. Finally, we'll update our avatar image uploading process to use Node Streams (we'll discuss Writable, Readable, Duplex, and Transform Streams). Along the way, we'll cover a great amount of design and architectural patterns and functional paradigms, including:
- Controllers/Controller Actions
- Serviços
- Repositories
- Mapeamento de dados
- The Adapter Pattern
- The Factory Pattern
- The Delegation Pattern
- OOP Principles and Composition vs Inheritance
- Inversion of Control via Dependency Injection
- SOLID Principles
- Coding against interfaces
- Data Transfer Objects
- Domain Models and Domain Entities
- Either Monads
- Validação
- Decorators
- Logging and Logging Levels
- Unit Tests, Integration Tests (E2E), and Mutation Tests
- The Structured Query Language
- Relações
- HTTP/Express Security Best Practices
- Node Best Practices
- OWASP Security Best Practices
- E mais.
Using that new architecture, in the article after that, we'll write Unit, Integration, and Mutation tests, aiming for close to 100 percent testing coverage, and we'll finally discuss setting up a remote CI/CD pipeline with CircleCI, as well as Message Busses, Job/Task Scheduling, and load balancing/reverse proxying.
Hopefully, this article has been helpful, and if you have any queries or concerns, let me know in the comments below.