Начало работы со стеком JavaScript Express и ES6+

Опубликовано: 2022-03-10
Краткое резюме ↬ Базовое введение в процесс разработки серверных веб-приложений с помощью Express — обсуждение передовых функций JavaScript ES6+, шаблона проектирования Factory, операций MongoDB CRUD, серверов и портов, а также будущего с корпоративными многоуровневыми архитектурными шаблонами для проектов TypeScript.

Эта статья является второй частью в серии, первая часть находится здесь, в ней представлены базовые и (надеюсь) интуитивно понятные сведения о Node.js, JavaScript ES6+, функциях обратного вызова, функциях стрелок, API, протоколе HTTP, JSON, MongoDB и более.

В этой статье мы будем опираться на навыки, полученные в предыдущей статье, и узнаем, как реализовать и развернуть базу данных MongoDB для хранения информации о пользовательском списке книг, создадим API с Node.js и инфраструктурой веб-приложений Express для предоставления доступа к этой базе данных. выполнять над ним операции CRUD и многое другое. Попутно мы обсудим деструктурирование объектов ES6, сокращение объектов ES6, синтаксис Async/Await, оператор распространения, а также кратко рассмотрим CORS, политику того же происхождения и многое другое.

В следующей статье мы реорганизуем нашу кодовую базу, чтобы разделить проблемы, используя трехуровневую архитектуру и достигнув инверсии управления через внедрение зависимостей, мы выполним безопасность и контроль доступа на основе веб-токена JSON и аутентификации Firebase, узнаем, как безопасно хранить пароли и использовать AWS Simple Storage Service для хранения пользовательских аватаров с помощью буферов и потоков Node.js, одновременно используя PostgreSQL для сохранения данных. Попутно мы перепишем нашу кодовую базу с нуля на TypeScript, чтобы изучить классические концепции ООП (такие как полиморфизм, наследование, композиция и т. д.) и даже шаблоны проектирования, такие как фабрики и адаптеры.

Еще после прыжка! Продолжить чтение ниже ↓

Слово предупреждения

Сегодня существует проблема с большинством статей, посвященных Node.js. Большинство из них, но не все, не идут дальше описания того, как настроить экспресс-маршрутизацию, интегрировать Mongoose и, возможно, использовать аутентификацию веб-токена JSON. Проблема в том, что они не говорят ни об архитектуре, ни о лучших практиках безопасности, ни о принципах чистого кодирования, ни о соответствии ACID, ни о реляционных базах данных, ни о пятой нормальной форме, ни о теореме CAP, ни о транзакциях. Либо предполагается, что вы знаете обо всем, что приходит, либо что вы не будете создавать проекты, достаточно крупные или популярные, чтобы гарантировать наличие вышеупомянутых знаний.

Похоже, что есть несколько разных типов разработчиков Node — среди прочих, некоторые новички в программировании в целом, а другие имеют долгую историю корпоративной разработки с использованием C# и .NET Framework или Java Spring Framework. Большинство статей относятся к первой группе.

В этой статье я собираюсь сделать именно то, что, как я только что сказал, делает слишком много статей, но в следующей статье мы собираемся полностью реорганизовать нашу кодовую базу, что позволит мне объяснить такие принципы, как внедрение зависимостей, три- Многоуровневая архитектура (контроллер/служба/репозиторий), сопоставление данных и активная запись, шаблоны проектирования, модульное, интеграционное и мутационное тестирование, принципы SOLID, единица работы, кодирование с учетом интерфейсов, лучшие практики безопасности, такие как HSTS, CSRF, NoSQL и SQL Injection Профилактика и так далее. Мы также перейдем с MongoDB на PostgreSQL, используя простой построитель запросов Knex вместо ORM, что позволит нам построить собственную инфраструктуру доступа к данным и ближе познакомиться со структурированным языком запросов, различными типами отношений (One- к одному, многие ко многим и т. д.) и многое другое. Таким образом, эта статья должна понравиться новичкам, а следующие несколько предназначены для более опытных разработчиков, стремящихся улучшить свою архитектуру.

В этом случае мы будем беспокоиться только о сохранении данных книги. Мы не будем заниматься аутентификацией пользователей, хешированием паролей, архитектурой или чем-то подобным. Все это будет в следующих и последующих статьях. На данный момент, и в основном, мы просто создадим метод, с помощью которого клиент сможет взаимодействовать с нашим веб-сервером через протокол HTTP для сохранения информации о книгах в базе данных.

Примечание : я намеренно сделал это предельно простым и, возможно, не таким практичным, потому что эта статья сама по себе очень длинная, так как я позволил себе отклониться, чтобы обсудить дополнительные темы. Таким образом, мы будем постепенно улучшать качество и сложность API в этой серии, но опять же, поскольку я рассматриваю это как одно из ваших первых знакомств с Express, я намеренно делаю вещи предельно простыми.

  1. Деструктуризация объекта ES6
  2. Сокращение объекта ES6
  3. Оператор спреда ES6 (...)
  4. Приближается...

Деструктуризация объекта ES6

Деструктуризация объектов ES6 или синтаксис присваивания деструктуризации — это метод извлечения или распаковки значений из массивов или объектов в их собственные переменные. Мы начнем со свойств объекта, а затем обсудим элементы массива.

 const person = { name: 'Richard P. Feynman', occupation: 'Theoretical Physicist' }; // Log properties: console.log('Name:', person.name); console.log('Occupation:', person.occupation);

Такая операция довольно примитивна, но может быть несколько хлопотной, учитывая, что нам нужно везде ссылаться на person.something . Предположим, что в нашем коде было еще 10 мест, где нам нужно было это сделать — это довольно быстро стало бы довольно трудным. Для краткости можно было бы присвоить эти значения их собственным переменным.

 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);

Возможно, это выглядит разумно, но что, если бы у нас было 10 других свойств, вложенных в объект person ? Было бы много ненужных строк только для того, чтобы присвоить значения переменным — и в этот момент мы в опасности, потому что, если свойства объекта мутируют, наши переменные не будут отражать это изменение (помните, только ссылки на объект неизменны с const присваиванием, а не свойства объекта), поэтому, по сути, мы больше не можем синхронизировать «состояние» (и я использую это слово вольно). Здесь может сыграть роль передача по ссылке и передача по значению, но я не хочу слишком далеко отходить от темы этого раздела.

Разрушение объектов ES6 в основном позволяет нам сделать это:

 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);

Мы не создаем новый объект/литерал объекта, мы распаковываем свойства name и occupation из исходного объекта и помещаем их в свои собственные переменные с тем же именем. Имена, которые мы используем, должны совпадать с именами свойств, которые мы хотим извлечь.

Опять же, синтаксис const { a, b } = someObject; в частности, говорится, что мы ожидаем, что какое-то свойство a и какое-то свойство b будут существовать внутри someObject (т. е someObject может быть { a: 'dataA', b: 'dataB' } , например), и что мы хотим поместить любые значения этих ключей/свойств в const переменных с тем же именем. Вот почему приведенный выше синтаксис предоставит нам две переменные const a = someObject.a и const b = someObject.b .

Это означает, что у деструктуризации объектов есть две стороны. Сторона «Шаблон» и сторона «Источник», где сторона const { a, b } (левая сторона) — это шаблон , а сторона someObject (правая сторона) — исходная сторона, что имеет смысл — мы определяем структуру или «шаблон» слева, который отражает данные на «исходной» стороне.

Опять же, чтобы было понятно, вот несколько примеров:

 // ----- 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

В случае вложенных свойств зеркально отразите ту же структуру в своем задании на удаление:

 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

Как видите, свойства, которые вы решите извлечь, являются необязательными, и для распаковки вложенных свойств просто отразите структуру исходного объекта (источника) на стороне шаблона вашего синтаксиса деструктурирования. Если вы попытаетесь деструктурировать свойство, не существующее в исходном объекте, это значение будет неопределенным.

Мы можем дополнительно деструктурировать переменную без ее предварительного объявления — присваивание без объявления — используя следующий синтаксис:

 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

Перед выражением ставится точка с запятой, чтобы случайно не создать IIFE (выражение с немедленным вызовом функции) с функцией в предыдущей строке (если такая функция существует), а круглые скобки вокруг оператора присваивания необходимы для остановить JavaScript от обработки левой стороны (шаблона) как блока.

Очень распространенный вариант использования деструктуризации существует в аргументах функции:

 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);

Как видите, мы могли просто использовать обычный синтаксис деструктурирования, к которому мы сейчас привыкли, внутри функции, например:

 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);

Но размещение указанного синтаксиса внутри сигнатуры функции автоматически выполняет деструктуризацию и экономит нам строку.

Реальный пример использования этого в 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> );

В отличие от:

 import React from 'react'; export default props => ( <div> <h1>{props.titleText}</h1> <h3>{props.secondaryText}</h3> </div> );

В обоих случаях мы также можем установить значения по умолчанию для свойств:

 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

Как видите, в случае, если name отсутствует при деструктуризации, мы предоставляем ему значение по умолчанию. Мы можем сделать это и с предыдущим синтаксисом:

 const { a, b, c = 'Default' } = { a: 'dataA', b: 'dataB' }; console.log(a); // dataA console.log(b); // dataB console.log(c); // Default

Массивы также могут быть деструктурированы:

 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

Практическая причина деструктуризации массива связана с React Hooks. (И есть много других причин, я просто использую React в качестве примера).

 import React, { useState } from "react"; export default () => { const [buttonText, setButtonText] = useState("Default"); return ( <button onClick={() => setButtonText("Toggled")}> {buttonText} </button> ); }

Обратите внимание, что useState деструктурируется при экспорте, а функции/значения массива деструктурируются при использовании хука useState . Опять же, не волнуйтесь, если вышеизложенное не имеет смысла — вам нужно понимать React — и я просто использую его в качестве примера.

Хотя в деструктуризации объектов ES6 есть еще кое-что, здесь я раскрою еще одну тему: деструктурирование переименования, которое полезно для предотвращения коллизий областей или переменных теней и т. д. Предположим, мы хотим деструктурировать свойство с name из объекта с именем person , но в области видимости уже есть переменная по имени name . Мы можем переименовать на лету через двоеточие:

 // 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.

Наконец, мы также можем установить значения по умолчанию с переименованием:

 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

Как видите, в этом случае name от person ( person.name ) будет переименовано в personName и установлено значение по умолчанию Anonymous , если оно не существует.

И, конечно же, то же самое можно сделать в сигнатурах функций:

 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

Сокращение объекта ES6

Предположим, у вас есть следующая фабрика: (мы рассмотрим фабрики позже)

 const createPersonFactory = (name, location, position) => ({ name: name, location: location, position: position });

Можно использовать эту фабрику для создания объекта person следующим образом. Также обратите внимание, что фабрика неявно возвращает объект, о чем свидетельствуют круглые скобки вокруг скобок функции стрелки.

 const person = createPersonFactory('Jamie', 'Texas', 'Developer'); console.log(person); // { ... }

Это то, что мы уже знаем из литерального синтаксиса объекта ES5. Обратите внимание, однако, что в фабричной функции значение каждого свойства совпадает с именем самого идентификатора свойства (ключа). То есть — location: location или name: name . Оказалось, что это довольно распространенное явление среди JS-разработчиков.

Используя сокращенный синтаксис из ES6, мы можем добиться того же результата, переписав фабрику следующим образом:

 const createPersonFactory = (name, location, position) => ({ name, location, position }); const person = createPersonFactory('Jamie', 'Texas', 'Developer'); console.log(person);

Производство вывода:

 { name: 'Jamie', location: 'Texas', position: 'Developer' }

Важно понимать, что мы можем использовать это сокращение только тогда, когда объект, который мы хотим создать, создается динамически на основе переменных, где имена переменных совпадают с именами свойств, которым мы хотим присвоить переменные.

Тот же самый синтаксис работает со значениями объекта:

 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);

Производство вывода:

 { name: 'Jamie', location: 'Texas', position: 'Developer', extra: { interests: [ 'Mathematics', 'Quantum Mechanics', 'Spacecraft Launch Systems' ], favoriteLanguages: [ 'JavaScript', 'C#' ] } }

В качестве последнего примера, это работает и с литералами объектов:

 const id = '314159265358979'; const name = 'Archimedes of Syracuse'; const location = 'Syracuse'; const greatMathematician = { id, name, location };

Оператор спреда ES6 (…)

Оператор Spread позволяет нам делать множество вещей, некоторые из которых мы обсудим здесь.

Во-первых, мы можем распространить свойства одного объекта на другой объект:

 const myObjOne = { a: 'a', b: 'b' }; const myObjTwo = { ...myObjOne }:

В результате все свойства myObjOne в myObjTwo , так что myObjTwo теперь { a: 'a', b: 'b' } . Мы можем использовать этот метод, чтобы переопределить предыдущие свойства. Предположим, пользователь хочет обновить свою учетную запись:

 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' } */

То же самое можно сделать с массивами:

 const apollo13Astronauts = ['Jim', 'Jack', 'Fred']; const apollo11Astronauts = ['Neil', 'Buz', 'Michael']; const unionOfAstronauts = [...apollo13Astronauts, ...apollo11Astronauts]; console.log(unionOfAstronauts); // ['Jim', 'Jack', 'Fred', 'Neil', 'Buz, 'Michael'];

Обратите внимание, что мы создали объединение обоих наборов (массивов), распределив массивы в новый массив.

В операторе Rest/Spread есть еще много чего, но это выходит за рамки этой статьи. Его можно использовать, например, для получения нескольких аргументов функции. Если вы хотите узнать больше, просмотрите документацию MDN здесь.

ES6 асинхронный/ожидание

Async/Await — это синтаксис, облегчающий цепочку промисов.

Зарезервированное ключевое слово await позволяет вам «ожидать» выполнения промиса, но его можно использовать только в функциях, помеченных ключевым словом async . Предположим, у меня есть функция, которая возвращает обещание. В новой async функции я могу await результата этого обещания вместо использования .then и .catch .

 // 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();

Здесь следует отметить несколько вещей. Когда мы используем await в async функции, только разрешенное значение попадает в переменную с левой стороны. Если функция отклоняется, это ошибка, которую мы должны отловить, как мы вскоре увидим. Кроме того, любая функция, помеченная как async , по умолчанию возвращает обещание.

Предположим, мне нужно было сделать два вызова API, один с ответом от первого. Используя обещания и цепочку обещаний, вы можете сделать это следующим образом:

 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 */

Здесь происходит то, что мы сначала вызываем makeAPICall передавая ему /whatever , что регистрируется в первый раз. Обещание разрешается с этим значением. Затем мы снова вызываем makeAPICall , передавая ему /whatever second call , который регистрируется, и снова обещание разрешается с этим новым значением. Наконец, мы берем это новое значение /whatever second call , с которым только что разрешилось промис, и сами регистрируем его в окончательном журнале, добавляя logged в конце. Если это не имеет смысла, вам следует изучить цепочку обещаний.

Используя async / await , мы можем выполнить рефакторинг следующим образом:

 const main = async () => { const resultOne = await makeAPICall('/whatever'); const resultTwo = await makeAPICall(resultOne + ' second call'); console.log(resultTwo + ' logged'); };

Вот что произойдет. Вся функция перестанет выполняться с самого первого оператора await до тех пор, пока обещание из первого вызова makeAPICall не будет разрешено, после разрешения разрешенное значение будет помещено в resultOne . Когда это произойдет, функция переместится ко второму оператору await , снова остановившись прямо здесь на время установления промиса. Когда обещание разрешается, результат разрешения будет помещен в resultTwo . Если идея о выполнении функции кажется блокирующей, не бойтесь, она по-прежнему асинхронна, и я объясню почему через минуту.

Это только изображает «счастливый» путь. В случае, если одно из обещаний отклонено, мы можем поймать это с помощью try/catch, потому что, если обещание отклонено, будет выброшена ошибка, которая будет той же ошибкой, с которой было отклонено обещание.

 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) } };

Как я уже говорил, любая функция, объявленная async , вернет обещание. Итак, если вы хотите вызвать асинхронную функцию из другой функции, вы можете использовать обычные await или ожидание, если вы объявите вызывающую функцию async . Однако, если вы хотите вызвать async функцию из кода верхнего уровня и дождаться ее результата, вам придется использовать .then и .catch .

Например:

 const returnNumberOne = async () => 1; returnNumberOne().then(value => console.log(value)); // 1

Или вы можете использовать выражение немедленно вызываемой функции (IIFE):

 (async () => { const value = await returnNumberOne(); console.log(value); // 1 })();

Когда вы используете await в async функции, выполнение функции остановится на этом операторе await до тех пор, пока обещание не будет установлено. Однако все остальные функции могут свободно выполняться, поэтому дополнительные ресурсы ЦП не выделяются и поток никогда не блокируется. Я повторю это еще раз — операции в этой конкретной функции в это конкретное время будут остановлены до тех пор, пока обещание не установится, но все остальные функции могут выполняться свободно. Рассмотрим веб-сервер HTTP — для каждого запроса все функции могут запускаться одновременно для всех пользователей по мере выполнения запросов, просто синтаксис async/await создаст иллюзию синхронности и блокировки операции, чтобы сделать обещает легче работать, но опять же, все останется красивым и асинхронным.

Это не все, что нужно знать об async / await , но это должно помочь вам понять основные принципы.

Классические ООП-фабрики

Теперь мы собираемся покинуть мир JavaScript и войти в мир Java . Может наступить время, когда процесс создания объекта (в данном случае экземпляра класса — опять же, Java) будет довольно сложным или когда мы хотим, чтобы разные объекты создавались на основе ряда параметров. Примером может быть функция, которая создает разные объекты ошибок. Фабрика — это распространенный шаблон проектирования в объектно-ориентированном программировании и, по сути, функция, которая создает объекты. Чтобы изучить это, давайте перейдем от JavaScript к миру Java. Это будет иметь смысл для разработчиков, которые пришли из классического ООП (т. е. не прототипа) со статически типизированным языком. Если вы не относитесь к числу таких разработчиков, смело пропустите этот раздел. Это небольшое отклонение, и поэтому, если следование здесь прерывает ваш поток JavaScript, опять же, пожалуйста, пропустите этот раздел.

Распространенный шаблон создания, Factory Pattern, позволяет нам создавать объекты, не раскрывая требуемую бизнес-логику для выполнения указанного создания.

Предположим, мы пишем программу, позволяющую визуализировать примитивные формы в n-мерном пространстве. Например, если мы предоставим куб, мы увидим двухмерный куб (квадрат), трехмерный куб (куб) и четырехмерный куб (тессеракт или гиперкуб). Вот как это можно сделать тривиально, за исключением фактической части рисования, на 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. } }

Как видите, мы определяем интерфейс, который определяет метод рисования фигуры. Имея разные классы, реализующие интерфейс, мы можем гарантировать, что все фигуры могут быть отрисованы (поскольку все они должны иметь переопределяемый метод draw в соответствии с определением интерфейса). Учитывая, что эта фигура рисуется по-разному в зависимости от размеров, в которых она просматривается, мы определяем вспомогательные классы, которые реализуют интерфейс для выполнения интенсивной работы графического процессора по моделированию n-мерного рендеринга. ShapeFactory выполняет работу по созданию экземпляра правильного класса — метод createShape является фабрикой, и, как и определение выше, это метод, который возвращает объект класса. Возвращаемый тип createShape — это интерфейс IShape , поскольку интерфейс IShape является базовым типом всех фигур (поскольку у них есть метод draw ).

Этот пример Java довольно тривиален, но вы можете легко увидеть, насколько полезным он становится в больших приложениях, где настройка для создания объекта может быть не такой простой. Примером этого может быть видеоигра. Допустим, пользователю предстоит пережить разных врагов. Абстрактные классы и интерфейсы могут использоваться для определения основных функций, доступных для всех врагов (и методов, которые могут быть переопределены), возможно, с использованием шаблона делегирования (предпочитайте композицию наследованию, как предложила Банда четырех, чтобы вы не были привязаны к расширению один базовый класс и упростить тестирование/моделирование/DI). Для вражеских объектов, созданных разными способами, интерфейс разрешал бы создание фабричных объектов, полагаясь на универсальный тип интерфейса. Это было бы очень актуально, если бы противник создавался динамически.

Другой пример — функция-конструктор. Предположим, мы используем шаблон делегирования, чтобы делегат класса работал с другими классами, которые учитывают интерфейс. Мы могли бы поместить статический метод build в класс, чтобы он построил свой собственный экземпляр (при условии, что вы не используете контейнер/фреймворк для внедрения зависимостей). Вместо того, чтобы вызывать каждый сеттер, вы можете сделать это:

 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()); } }

Я объясню шаблон делегирования в следующей статье, если вы с ним не знакомы — в основном, через композицию и с точки зрения объектного моделирования он создает отношение «имеет-а» вместо «есть-а». отношения, как вы получили бы с наследованием. Если у вас есть класс Mammal и класс Dog , а Dog расширяет Mammal , то Dog is-a Mammal . Принимая во внимание, что если у вас есть класс Bark , и вы только что передали экземпляры Bark в конструктор Dog , то у Dog has-a Bark . Как вы можете себе представить, это особенно упрощает модульное тестирование, поскольку вы можете внедрять макеты и утверждать факты о макетах, пока макет соблюдает контракт интерфейса в тестовой среде.

Приведенный выше static фабричный метод «сборки» просто создает новый объект User и передает конкретный MessageService . Обратите внимание, как это следует из приведенного выше определения — не раскрывая бизнес-логику для создания объекта класса, или, в данном случае, не раскрывать создание службы обмена сообщениями вызывающей стороне фабрики.

Опять же, это не обязательно то, как вы будете делать что-то в реальном мире, но оно довольно хорошо представляет идею фабричной функции/метода. Например, вместо этого мы могли бы использовать контейнер внедрения зависимостей. Теперь вернемся к JavaScript.

Начиная с экспресса

Express — это платформа веб-приложений для узла (доступна через модуль NPM), которая позволяет создавать веб-сервер HTTP. Важно отметить, что Express — не единственная платформа для этого (существуют Koa, Fastify и т. д.), и что, как показано в предыдущей статье, Node может функционировать без Express как автономная сущность. (Express — это просто модуль, разработанный для Node — Node может делать много вещей без него, хотя Express популярен для веб-серверов).

Опять же, позвольте мне сделать очень важное различие. Существует дихотомия между Node/JavaScript и Express. Node, среда выполнения/среда, в которой вы запускаете JavaScript, может делать много вещей, например, позволяя вам создавать приложения React Native, настольные приложения, инструменты командной строки и т. д. Node/JS для создания веб-серверов, а не для работы с низкоуровневой сетью Node и HTTP API. Вам не нужен Express для создания веб-сервера.

Прежде чем приступить к этому разделу, если вы не знакомы с HTTP и HTTP-запросами (GET, POST и т. д.), я рекомендую вам прочитать соответствующий раздел моей предыдущей статьи, ссылка на которую приведена выше.

Используя Express, мы настроим различные маршруты, к которым могут быть отправлены HTTP-запросы, а также связанные конечные точки (которые являются функциями обратного вызова), которые будут срабатывать при выполнении запроса к этому маршруту. Не волнуйтесь, если маршруты и конечные точки в настоящее время не имеют смысла — я объясню их позже.

В отличие от других статей, я буду писать исходный код по ходу дела, построчно, а не сбрасывать всю кодовую базу в один фрагмент, а объяснять позже. Давайте начнем с открытия терминала (я использую Terminus поверх Git Bash в Windows — это хороший вариант для пользователей Windows, которым нужна оболочка Bash без настройки подсистемы Linux), настройки шаблона нашего проекта и его открытия. в коде Visual Studio.

 mkdir server && cd server touch server.js npm init -y npm install express code .

Внутри файла server.js я начну с запроса express с помощью функции require() .

 const express = require('express');

require('express') говорит Node выйти и получить модуль Express, который мы установили ранее, который в данный момент находится в папке node_modules (для этого npm install создает папку node_modules и помещает туда модули и их зависимости). По соглашению и при работе с Express мы вызываем переменную, которая содержит возвращаемый результат из require('express') express , хотя она может называться как угодно.

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 appapp 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”.

Номер порта идентифицирует конкретную службу, работающую на сервере. SSH или Secure Shell, который разрешает удаленный доступ к устройству, обычно работает на порту 22. FTP или протокол передачи файлов (который может, например, использоваться с FTP-клиентом для передачи статических ресурсов на сервер) обычно работает на Порт 21. Таким образом, мы могли бы сказать, что порты — это определенные комнаты внутри каждого дома в нашей аналогии выше, поскольку комнаты в домах предназначены для разных вещей — спальня для сна, кухня для приготовления пищи, столовая для употребления упомянутых выше продуктов. еда и т. д., точно так же, как порты соответствуют программам, выполняющим определенные услуги. Для нас веб-серверы обычно работают на порту 80, хотя вы можете указать любой номер порта по своему желанию, если они не используются какой-либо другой службой (они не могут конфликтовать).

Чтобы получить доступ к веб-сайту, вам нужен IP-адрес сайта. Несмотря на это, мы обычно обращаемся к веб-сайтам через URL-адрес. За кулисами DNS или сервер доменных имен преобразует этот URL-адрес в IP-адрес, позволяя браузеру сделать запрос GET на сервер, получить HTML и отобразить его на экране. 8.8.8.8 — это адрес одного из общедоступных DNS-серверов Google. Вы можете представить, что преобразование имени хоста в IP-адрес через удаленный DNS-сервер потребует времени, и вы будете правы. Чтобы уменьшить задержку, операционные системы имеют DNS-кэш — временную базу данных, в которой хранится информация о поиске DNS, тем самым уменьшая частоту, с которой должны выполняться указанные запросы. Кэш преобразователя DNS можно просмотреть в Windows с помощью команды CMD ipconfig /displaydns и очистить с помощью команды ipconfig /flushdns .

На сервере Unix более распространенные порты с меньшим номером, например 80, требуют привилегий корневого уровня ( увеличены , если вы пришли из Windows). По этой причине мы будем использовать порт 3000 для нашей разработки, но позволим серверу выбирать номер порта (все, что доступно) при развертывании в нашей производственной среде.

Наконец, обратите внимание, что мы можем вводить IP-адреса непосредственно в строке поиска Google Chrome, минуя механизм разрешения DNS. Например, набрав 216.58.194.36 , вы попадете на Google.com. В нашей среде разработки при использовании нашего собственного компьютера в качестве нашего сервера разработки мы будем использовать локальный хост и порт 3000. Адрес имеет формат hostname:port , поэтому наш сервер будет работать на localhost localhost:3000 . Localhost, или 127.0.0.1 , является петлевым адресом и означает адрес «этого компьютера». Это имя хоста, и его IPv4-адрес разрешается в 127.0.0.1 . Попробуйте пропинговать localhost на вашем компьютере прямо сейчас. Вы можете получить обратно ::1 — адрес обратной связи IPv6 или 127.0.0.1 обратно — адрес обратной связи IPv4. IPv4 и IPv6 — это два разных формата IP-адресов, связанных с разными стандартами — некоторые адреса IPv6 можно преобразовать в IPv4, но не все.

Возвращение в экспресс

Я упоминал HTTP-запросы, глаголы и коды состояния в своей предыдущей статье «Начало работы с узлом: введение в API, HTTP и ES6+ JavaScript». Если у вас нет общего представления о протоколе, смело переходите к разделу «HTTP и HTTP-запросы» этой статьи.

Чтобы получить представление об Express, мы просто настроим наши конечные точки для четырех основных операций, которые мы будем выполнять с базой данных — Create, Read, Update и Delete, известных под общим названием CRUD.

Помните, что мы получаем доступ к конечным точкам по маршрутам в URL-адресе. То есть, хотя слова «маршрут» и «конечная точка» обычно используются взаимозаменяемо, конечная точка технически является функцией языка программирования (например, стрелочными функциями ES6), которая выполняет некоторую операцию на стороне сервера, а маршрут — это то, за чем расположена конечная точка. из . Мы указываем эти конечные точки как функции обратного вызова, которые Express будет запускать, когда от клиента будет сделан соответствующий запрос к маршруту , за которым живет конечная точка. Вы можете вспомнить вышеизложенное, осознав, что именно конечные точки выполняют функцию, а маршрут — это имя, которое используется для доступа к конечным точкам. Как мы увидим, один и тот же маршрут может быть связан с несколькими конечными точками с помощью разных глаголов HTTP (аналогично перегрузке методов, если вы пришли из классического опыта ООП с полиморфизмом).

Имейте в виду, что мы следуем архитектуре REST (Representational State Transfer), разрешая клиентам делать запросы к нашему серверу. В конце концов, это REST или RESTful API. Конкретные запросы , сделанные для определенных маршрутов , активируют определенные конечные точки , которые будут выполнять определенные действия . Примером такой «вещи», которую может делать конечная точка, является добавление новых данных в базу данных, удаление данных, обновление данных и т. д.

Express знает, какую конечную точку активировать, потому что мы явно указываем ему метод запроса (GET, POST и т. д.) и маршрут — мы определяем, какие функции активировать для конкретных комбинаций вышеперечисленного, и клиент делает запрос, указывая маршрут и способ. Проще говоря, с помощью Node мы скажем Express: «Эй, если кто-то сделает GET-запрос к этому маршруту, запустите эту функцию (используйте эту конечную точку)». Все может быть сложнее: «Экспресс, если кто-то делает запрос GET для этого маршрута, но он не отправляет действительный токен носителя авторизации в заголовке своего запроса, тогда, пожалуйста, ответьте HTTP 401 Unauthorized . Если у них действительно есть действующий токен на предъявителя, отправьте любой защищенный ресурс, который они искали, активировав конечную точку. Большое спасибо и хорошего дня». Действительно, было бы неплохо, если бы языки программирования могли быть на таком высоком уровне без утечек двусмысленности, но, тем не менее, они демонстрируют основные концепции.

Помните, что конечная точка в некотором роде живет за маршрутом. Поэтому крайне важно, чтобы клиент указал в заголовке запроса, какой метод он хочет использовать, чтобы Express мог понять, что делать. Запрос будет сделан по определенному маршруту, который клиент укажет (вместе с типом запроса) при обращении к серверу, что позволит Express делать то, что ему нужно, а нам делать то, что нам нужно, когда Express запускает наши обратные вызовы. . Вот к чему все сводится.

В примерах кода ранее мы вызывали функцию listen , которая была доступна в app , передавая ей порт и обратный вызов. само app , если вы помните, является возвращаемым результатом вызова express -переменной как функции (то есть, express() ), а express -переменная — это то, что мы назвали возвращаемым результатом запроса 'express' из нашей папки node_modules . Точно так же, как listen вызывается в app , мы указываем конечные точки HTTP-запроса, вызывая их в app . Давайте посмотрим на GET:

 app.get('/my-test-route', () => { // ... });

Первый параметр — это string , и это маршрут, по которому будет жить конечная точка. Функция обратного вызова является конечной точкой. Я повторю это еще раз: функция обратного вызова — второй параметр — это конечная точка , которая срабатывает, когда делается запрос HTTP GET на любой маршрут, который мы указываем в качестве первого аргумента (в данном случае /my-test-route ).

Теперь, прежде чем мы приступим к работе с Express, нам нужно узнать, как работают маршруты. Маршрут, который мы указываем в виде строки, будет вызываться путем отправки запроса на www.domain.com/the-route-we-chose-earlier-as-a-string . В нашем случае домен — localhost:3000 , что означает, что для запуска функции обратного вызова выше мы должны сделать запрос GET на localhost:3000/my-test-route . Если бы мы использовали другую строку в качестве первого аргумента выше, URL-адрес должен был бы отличаться, чтобы соответствовать тому, что мы указали в JavaScript.

Говоря о таких вещах, вы, вероятно, услышите о Glob Patterns. Можно сказать, что все маршруты нашего API расположены по адресу localhost:3000/** Glob Pattern, где ** — это подстановочный знак, означающий любой каталог или подкаталог (обратите внимание, что маршруты не являются каталогами), для которого root является родительским — то есть все.

Давайте продолжим и добавим оператор журнала в эту функцию обратного вызова, чтобы в целом мы имели:

 // 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}.`) });

Мы запустим наш сервер, выполнив node server/server.js (при этом Node установлен в нашей системе и доступен глобально из системных переменных среды) в корневом каталоге проекта. Как и раньше, вы должны увидеть в консоли сообщение о том, что сервер запущен. Теперь, когда сервер запущен, откройте браузер и перейдите на localhost:3000 в адресной строке.

Вас должно приветствовать сообщение об ошибке, в котором говорится, что Cannot GET / . Нажмите Ctrl + Shift + I в Windows в Chrome, чтобы просмотреть консоль разработчика. Там вы должны увидеть, что у нас есть 404 (ресурс не найден). В этом есть смысл — мы только сказали серверу, что делать, когда кто-то посещает localhost:3000/my-test-route . Браузеру нечего отображать на localhost:3000 (что эквивалентно localhost:3000/ с косой чертой).

Если вы посмотрите на окно терминала, где работает сервер, новых данных быть не должно. Теперь посетите localhost:3000/my-test-route в адресной строке вашего браузера. Вы можете увидеть ту же ошибку в консоли Chrome (поскольку браузер кэширует содержимое и по-прежнему не имеет HTML для отображения), но если вы посмотрите на свой терминал, где работает серверный процесс, вы увидите, что функция обратного вызова действительно сработала. и сообщение журнала действительно было зарегистрировано.

Выключите сервер с помощью Ctrl + C.

Теперь давайте дадим браузеру что-то для отображения, когда GET-запрос делается для этого маршрута, чтобы мы могли потерять сообщение Cannot GET / . Я возьму наш app.get() из предыдущего и добавлю в функцию обратного вызова два аргумента. Помните, что функция обратного вызова, которую мы передаем, вызывается Express за кулисами, и Express может добавлять любые аргументы, которые ему нужны. На самом деле это добавляет два (ну, технически три, но мы увидим это позже), и хотя они оба чрезвычайно важны, нас пока не волнует первый. Второй аргумент называется res , сокращенно от response , и я получу к нему доступ, установив undefined в качестве первого параметра:

 app.get('/my-test-route', (undefined, res) => { console.log('A GET Request was made to /my-test-route.'); });

Опять же, мы можем называть аргумент res как угодно, но res является условным при работе с Express. res на самом деле является объектом, и для него существуют различные методы отправки данных обратно клиенту. В этом случае я собираюсь получить доступ к функции send(...) , доступной в res , чтобы отправить обратно HTML, который будет отображаться браузером. Однако мы не ограничены отправкой обратно HTML и можем выбрать отправку текста, объекта JavaScript, потока (потоки особенно красивы) или чего-то еще.

 app.get('/my-test-route', (undefined, res) => { console.log('A GET Request was made to /my-test-route.'); res.send('<h1>Hello, World!</h1>'); });

Если вы выключите сервер, а затем снова включите его, а затем обновите свой браузер по маршруту /my-test-route , вы увидите, что HTML будет отображаться.

Вкладка «Сеть» инструментов разработчика Chrome позволит вам увидеть этот запрос GET более подробно, поскольку он относится к заголовкам.

На этом этапе нам будет полезно начать изучать Express Middleware — функции, которые могут запускаться глобально после того, как клиент делает запрос.

Экспресс промежуточное ПО

Express предоставляет методы, с помощью которых можно определить собственное ПО промежуточного слоя для вашего приложения. Действительно, смысл Express Middleware лучше всего определяется в Express Docs здесь)

Функции промежуточного ПО — это функции, которые имеют доступ к объекту запроса ( req ), объекту ответа ( res ) и следующей функции промежуточного ПО в цикле запроса-ответа приложения. Следующая функция промежуточного программного обеспечения обычно обозначается переменной с именем next .

Функции промежуточного ПО могут выполнять следующие задачи:

  • Выполнить любой код.
  • Внесите изменения в объекты запроса и ответа.
  • Завершите цикл запрос-ответ.
  • Вызовите следующую промежуточную функцию в стеке.

Другими словами, промежуточная функция — это пользовательская функция, которую мы (разработчик) можем определить, и которая будет действовать как посредник между получением запроса Express и запуском нашей соответствующей функции обратного вызова. Например, мы можем создать функцию log , которая будет регистрировать каждый раз, когда делается запрос. Обратите внимание, что мы также можем сделать так, чтобы эти промежуточные функции срабатывали после того , как сработает наша конечная точка, в зависимости от того, где вы поместите ее в стек — мы увидим это позже.

Чтобы указать собственное промежуточное ПО, мы должны определить его как функцию и передать в 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().

Все вместе теперь имеем:

 // 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}.`) });

Если вы снова сделаете запросы через браузер, вы должны увидеть, что ваша промежуточная функция срабатывает и записывает временные метки. Чтобы стимулировать эксперименты, попробуйте удалить вызов next функции и посмотрите, что произойдет.

Функция обратного вызова промежуточного слоя вызывается с тремя аргументами: req , res и next . req — это параметр, который мы пропустили при построении обработчика GET ранее, и это объект, содержащий информацию о запросе, такую ​​как заголовки, настраиваемые заголовки, параметры и любое тело, которое могло быть отправлено клиентом (например, вы делаете с запросом POST). Я знаю, что здесь мы говорим о промежуточном программном обеспечении, но и конечные точки, и функция промежуточного программного обеспечения вызываются с помощью req и res . req и res будут одинаковыми (если только один или другой не изменит его) как в промежуточном программном обеспечении, так и в конечной точке в рамках одного запроса от клиента. Это означает, например, что вы можете использовать функцию промежуточного программного обеспечения для очистки данных, удаляя любые символы, которые могут быть направлены на выполнение SQL- или NoSQL-инъекций, а затем передавая безопасный req на конечную точку.

res , как было показано ранее, позволяет отправлять данные обратно клиенту несколькими различными способами.

next — это функция обратного вызова, которую вы должны выполнить, когда промежуточное ПО закончит свою работу, чтобы вызвать следующую функцию промежуточного ПО в стеке или конечной точке. Обязательно обратите внимание, что вам придется вызывать это в блоке then любых асинхронных функций, которые вы запускаете в промежуточном программном обеспечении. В зависимости от вашей асинхронной операции вы можете или не хотите вызывать ее в блоке catch . То есть функция myMiddleware срабатывает после того, как запрос сделан от клиента, но до того, как сработает функция конечной точки запроса. Когда мы выполняем этот код и делаем запрос, вы должны увидеть сообщение Middleware has fired... перед сообщением A GET Request was made to... в консоли. Если вы не вызовете next() , последняя часть никогда не запустится — ваша функция конечной точки для запроса не сработает.

Обратите также внимание, что я мог бы определить эту функцию анонимно как таковую (соглашение, которого я буду придерживаться):

 app.use((req, res, next) => { console.log(`Middleware has fired at time ${Date().now}`); next(); });

Для тех, кто плохо знаком с JavaScript и ES6, если описанное выше не имеет непосредственного смысла, приведенный ниже пример должен помочь. Мы просто определяем функцию обратного вызова (анонимную функцию), которая принимает другую функцию обратного вызова ( next ) в качестве аргумента. Мы называем функцию, которая принимает аргумент функции, функцией высшего порядка. Взгляните на это так, как показано ниже — это базовый пример того, как исходный код Express может работать за кулисами:

 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.');

Сначала мы вызываем use , который принимает myMiddleware в качестве аргумента. myMiddleware сама по себе является функцией, которая принимает три аргумента — req , res и next . Внутри use myMiddlware и передаются эти три аргумента. next — это функция, определенная в use . myMiddleware определяется как callback в методе use . Если бы я поместил use в этом примере на объект с именем app , мы могли бы полностью имитировать настройку Express, хотя и без каких-либо сокетов или сетевого подключения.

В этом случае и myMiddleware , и callback являются функциями высшего порядка, поскольку они оба принимают функции в качестве аргументов.

Если вы выполните этот код, вы увидите следующий ответ:

 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.

Обратите внимание, что я мог бы также использовать анонимные функции для достижения того же результата:

 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.');

Мы надеемся, что с этим решено, теперь мы можем вернуться к реальной задаче — настройке нашего промежуточного программного обеспечения.

Дело в том, что вам обычно приходится отправлять данные через HTTP-запрос. Для этого у вас есть несколько различных вариантов — отправка параметров запроса URL, отправка данных, которые будут доступны в объекте req , о котором мы узнали ранее, и т. д. Этот объект доступен не только в обратном вызове для вызова app.use() , но и в любую конечную точку. Раньше мы использовали undefined в качестве наполнителя, поэтому мы могли сосредоточиться на res для отправки HTML обратно клиенту, но теперь нам нужен доступ к нему.

 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. });

Запросы HTTP POST могут потребовать, чтобы мы отправили объект тела на сервер. Если у вас есть форма на клиенте, и вы берете имя пользователя и адрес электронной почты, вы, скорее всего, отправите эти данные на сервер в теле запроса.

Давайте посмотрим, как это может выглядеть на стороне клиента:

 <!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>

На стороне сервера:

 app.post('/email-list', (req, res) => { // What do we now? // How do we access the values for the user's name and email? });

Чтобы получить доступ к имени пользователя и электронной почте, нам придется использовать промежуточное программное обеспечение определенного типа. Это поместит данные объекта с именем body в req . Body Parser был популярным методом для этого, доступным разработчикам Express в виде отдельного модуля NPM. Теперь Express поставляется с предварительно упакованным собственным промежуточным программным обеспечением для этого, и мы назовем его так:

 app.use(express.urlencoded({ extended: true }));

Теперь мы можем сделать:

 app.post('/email-list', (req, res) => { console.log('User Name: ', req.body.nameInput); console.log('User Email: ', req.body.emailInput); });

Все, что он делает, это принимает любой пользовательский ввод, который отправляется от клиента, и делает его доступным в объекте body req . Обратите внимание, что в req.body у нас теперь есть nameInput и emailInput , которые являются именами input тегов в HTML. Теперь эти определяемые клиентом данные следует считать опасными (никогда, никогда не доверяйте клиенту) и их необходимо очистить, но мы рассмотрим это позже.

Еще один тип промежуточного программного обеспечения, предоставляемого Express, — это express.json() . express.json используется для упаковки любых полезных данных JSON, отправленных в запросе от клиента, на req.body , а express.urlencoded упаковывает любые входящие запросы со строками, массивами или другими данными в кодировке URL на req.body . Короче говоря, оба манипулируют req.body , но .json() предназначен для полезных данных JSON, а .urlencoded() предназначен, среди прочего, для параметров запроса POST.

Другими словами, входящие запросы с заголовком Content-Type: application/json (например, указание тела POST с помощью API-интерфейса fetch ) будут обрабатываться с помощью express.json() , а запросы с заголовком Content-Type: application/x-www-form-urlencoded (например, HTML-формы) будет обрабатываться с помощью express.urlencoded() . Надеюсь, теперь это имеет смысл.

Запускаем наши CRUD-маршруты для MongoDB

Примечание . При выполнении запросов PATCH в этой статье мы не будем следовать спецификации JSONPatch RFC — проблема, которую мы исправим в следующей статье этой серии.

Учитывая, что мы понимаем, что указываем каждую конечную точку, вызывая соответствующую функцию в app , передавая ей маршрут и функцию обратного вызова, содержащую объекты запроса и ответа, мы можем приступить к определению наших CRUD-маршрутов для Bookshelf API. Действительно, учитывая, что это вводная статья, я не буду полностью следовать спецификациям HTTP и REST и не буду пытаться использовать максимально чистую архитектуру. Это будет в следующей статье.

Я открою файл 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. // ... // Binding our application to port 3000. app.listen(PORT, () => console.log(`Server is up on port ${PORT}.`));

Считайте, что весь последующий код занимает // ... часть файла выше.

Чтобы определить наши конечные точки и поскольку мы создаем REST API, мы должны обсудить правильный способ именования маршрутов. Опять же, вы должны взглянуть на раздел HTTP моей предыдущей статьи для получения дополнительной информации. Мы имеем дело с книгами, поэтому все маршруты будут располагаться после /books (соглашение об именах во множественном числе является стандартным).

Запрос Маршрут
СООБЩЕНИЕ /books
ПОЛУЧАТЬ /books/id
ПЛАСТЫРЬ /books/id
УДАЛЯТЬ /books/id

Как видите, идентификатор не нужно указывать при публикации книги, потому что мы (точнее, MongoDB) будем генерировать его для нас автоматически на стороне сервера. Книги GETting, PATCHing и DELETing потребуют, чтобы мы передали этот идентификатор нашей конечной точке, что мы обсудим позже. А пока давайте просто создадим конечные точки:

 // 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}`); });

Синтаксис :id сообщает Express, что id — это динамический параметр, который будет передан в URL-адресе. У нас есть доступ к нему в объекте params , который доступен в req . Я знаю, что «у нас есть доступ к нему по req » звучит как магия, а магия (которой не существует) опасна в программировании, но вы должны помнить, что Express — это не черный ящик. Это проект с открытым исходным кодом, доступный на GitHub под лицензией MIT. Вы можете легко просмотреть его исходный код, если хотите увидеть, как параметры динамического запроса помещаются в объект req .

Все вместе теперь у нас есть следующее в нашем файле 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}.`));

Запустите сервер, запустив node server.js из терминала или командной строки, и зайдите в свой браузер. Откройте консоль разработки Chrome и на панели URL-адресов (унифицированный указатель ресурсов) перейдите на localhost:3000/books . Вы уже должны увидеть в терминале вашей ОС индикатор того, что сервер запущен, а также оператор журнала для GET.

До сих пор мы использовали веб-браузер для выполнения запросов GET. Это хорошо для начала, но мы быстро обнаружим, что существуют лучшие инструменты для тестирования маршрутов API. Действительно, мы могли бы вставлять вызовы fetch прямо в консоль или использовать какой-нибудь онлайн-сервис. В нашем случае и для экономии времени мы будем использовать cURL и Postman. В этой статье я использую оба варианта (хотя вы можете использовать и тот, и другой), так что я могу представить их, если вы их не использовали. cURL — это библиотека (очень и очень важная библиотека) и инструмент командной строки, предназначенный для передачи данных по различным протоколам. Postman — это инструмент с графическим интерфейсом для тестирования API. Выполнив соответствующие инструкции по установке обоих инструментов в вашей операционной системе, убедитесь, что ваш сервер все еще работает, а затем выполните следующие команды (одну за другой) в новом терминале. Важно, чтобы вы вводили их и выполняли по отдельности, а затем смотрели сообщение журнала в отдельном терминале с вашего сервера. Также обратите внимание, что стандартный символ комментария языка программирования // не является допустимым символом в Bash или MS-DOS. Вам придется опустить эти строки, и я использую их здесь только для описания каждого блока команд 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

Как видите, идентификатор, передаваемый в качестве параметра URL, может иметь любое значение. Флаг -X указывает тип HTTP-запроса (его можно опустить для GET), и мы указываем URL-адрес, на который после этого будет сделан запрос. Я продублировал каждый запрос три раза, позволяя вам увидеть, что все по-прежнему работает, используете ли вы имя хоста localhost , адрес IPv4 ( 127.0.0.1 ), на который разрешается localhost , или адрес IPv6 ( ::1 ), на который разрешается localhost . . Обратите внимание, что cURL требует заключения IPv6-адресов в квадратные скобки.

Теперь мы в приличном месте — у нас есть простая структура наших маршрутов и конечных точек. Сервер работает правильно и принимает HTTP-запросы, как мы и ожидали. Вопреки тому, что вы могли ожидать, на этом этапе осталось немного времени — нам просто нужно настроить нашу базу данных, разместить ее (используя базу данных как услугу — MongoDB Atlas) и сохранить в ней данные (и выполнять проверку и создавать ответы об ошибках).

Настройка рабочей базы данных MongoDB

Чтобы настроить производственную базу данных, мы перейдем на домашнюю страницу MongoDB Atlas и зарегистрируем бесплатную учетную запись. После этого создайте новый кластер. Вы можете сохранить настройки по умолчанию, выбрав применимый регион тарифного плана. Затем нажмите кнопку «Создать кластер». Создание кластера займет некоторое время, после чего вы сможете получить URL-адрес и пароль своей базы данных. Обратите на это внимание, когда увидите их. Сейчас мы жестко закодируем их, а позже сохраним в переменных среды в целях безопасности. Чтобы получить помощь в создании кластера и подключении к нему, я отсылаю вас к документации MongoDB, в частности к этой странице и этой странице, или вы можете оставить комментарий ниже, и я постараюсь помочь.

Создание модели мангуста

Рекомендуется иметь представление о значениях документов и коллекций в контексте NoSQL (не только SQL — язык структурированных запросов). Для справки вы можете прочитать как краткое руководство по Mongoose, так и раздел MongoDB в моей предыдущей статье.

Теперь у нас есть база данных, готовая к CRUD-операциям. Mongoose — это модуль Node (или ODM — Object Document Mapper), который позволит нам выполнять эти операции (абстрагируя некоторые сложности), а также настраивать схему или структуру коллекции базы данных.

Важный отказ от ответственности: существует много споров вокруг ORM и таких шаблонов, как Active Record или Data Mapper. Некоторые разработчики клянутся ORM, а другие ругают их (полагая, что они мешают). Также важно отметить, что ORM во многом абстрагируются, например, пул соединений, соединения сокетов, обработка и т. д. Вы можете легко использовать встроенный драйвер MongoDB (еще один модуль NPM), но это потребует гораздо больше работы. Хотя перед использованием ORM рекомендуется поиграть с родным драйвером, я опускаю здесь родной драйвер для краткости. Для сложных операций SQL в реляционной базе данных не все ORM будут оптимизированы для скорости запросов, и вам может понадобиться написать собственный необработанный SQL. 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:

  1. Заголовок

  2. ISBN Number

  3. Автор

    1. Имя

    2. Фамилия

  4. Publishing Date

  5. 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. Вот пример:

 // ... 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.

  1. title will be of type String , it's a required field, and we'll trim any whitespace.
  2. isbn will be of type String , it's a required field, it must match the validator, and we'll trim any whitespace.
  3. author is of type object containing a required, trimmed, string firstName and a required, trimmed, string lastName.
  4. publishingDate is of type String (although we could make it of type Date or Number for a Unix timestamp.
  5. finishedReading is a required boolean that will default to false 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 });

Это подключится к базе данных. Мы предоставляем URL-адрес, который мы получили из панели мониторинга MongoDB Atlas, а объект, переданный в качестве второго параметра, указывает функции, которые следует использовать, чтобы, среди прочего, предотвратить предупреждения об устаревании.

Mongoose, который использует основной драйвер MongoDB за кулисами, должен пытаться идти в ногу с критическими изменениями, внесенными в драйвер. В новой версии драйвера механизм, используемый для анализа URL-адресов подключения, был изменен, поэтому мы передаем флаг useNewUrlParser: true , чтобы указать, что мы хотим использовать последнюю версию, доступную в официальном драйвере.

По умолчанию, если вы устанавливаете индексы (и они называются «индексами», а не «индексами») (которые мы не будем рассматривать в этой статье) для данных в вашей базе данных, Mongoose использует ensureIndex() , доступную из собственного драйвера. MongoDB объявила эту функцию устаревшей в пользу createIndex() , поэтому установка флага useCreateIndex в значение true укажет Mongoose использовать метод createIndex() из драйвера, который не является устаревшей функцией.

Первоначальная версия Mongoose findOneAndUpdate (которая представляет собой метод поиска документа в базе данных и его обновления) предшествует версии Native Driver. То есть findOneAndUpdate() изначально не была функцией собственного драйвера, а была предоставлена ​​Mongoose, поэтому Mongoose пришлось использовать findAndModify предоставляемый драйвером за кулисами, для создания функциональности findOneAndUpdate . Теперь, когда драйвер обновлен, он содержит собственную такую ​​функцию, поэтому нам не нужно использовать findAndModify . Это может не иметь смысла, и это нормально — это не важная информация в масштабе вещей.

Наконец, MongoDB отказалась от своей старой системы мониторинга сервера и движка. Мы используем новый метод с useUnifiedTopology: true .

На данный момент у нас есть способ подключения к базе данных. Но вот в чем дело — это не масштабируемо и не эффективно. Когда мы пишем модульные тесты для этого API, модульные тесты будут использовать свои собственные тестовые данные (или фикстуры) в своих собственных тестовых базах данных. Итак, нам нужен способ создавать соединения для разных целей — некоторые для тестовых сред (которые мы можем раскручивать и отключать по желанию), другие для сред разработки и третьи для производственных сред. Для этого мы построим завод. (Помните это из предыдущего?)

Подключение к Mongo — создание реализации фабрики JS

Действительно, объекты Java совсем не аналогичны объектам JavaScript, и, следовательно, то, что мы знаем выше из фабричного шаблона проектирования, не будет применяться. Я просто привел это в качестве примера, чтобы показать традиционный шаблон. Чтобы получить объект в Java, C#, C++ и т. д., мы должны создать экземпляр класса. Это делается с помощью new ключевого слова, которое указывает компилятору выделить память для объекта в куче. В C++ это дает нам указатель на объект, который мы должны очистить сами, чтобы не было зависающих указателей или утечек памяти (в C++ нет сборщика мусора, в отличие от Node/V8, построенного на C++). выше не нужно делать — нам не нужно создавать экземпляр класса для получения объекта — объект — это просто {} . Некоторые люди скажут, что все в JavaScript является объектом, хотя технически это неверно, поскольку примитивные типы не являются объектами.

По вышеуказанным причинам наша фабрика JS будет проще, придерживаясь расплывчатого определения фабрики как функции, которая возвращает объект (объект JS). Поскольку функция является объектом (поскольку function наследуется от object через прототипное наследование), наш приведенный ниже пример будет соответствовать этому критерию. Чтобы реализовать фабрику, я создам новую папку внутри server с именем db . В db я создам новый файл с именем mongoose.js . Этот файл будет устанавливать соединения с базой данных. Внутри mongoose.js я создам функцию connectionFactory и экспортирую ее по умолчанию:

 // 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;

Используя сокращение, предоставленное ES6 для стрелочных функций, которые возвращают один оператор в той же строке, что и сигнатура метода, я упрощу этот файл, избавившись от определения connectionFactory и просто экспортировав фабрику по умолчанию:

 // 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 });

Теперь все, что нужно сделать, это запросить файл и вызвать экспортируемый метод, например:

 const connectionFactory = require('./db/mongoose'); connectionFactory(); // OR require('./db/mongoose')();

Вы можете инвертировать управление, предоставив свой URL-адрес MongoDB в качестве параметра фабричной функции, но мы собираемся динамически изменять URL-адрес как переменную среды в зависимости от среды.

Преимущество создания нашего соединения как функции заключается в том, что мы можем вызывать эту функцию позже в коде для подключения к базе данных из файлов, предназначенных для производства, а также файлов, предназначенных для локального и удаленного интеграционного тестирования как на устройстве, так и с удаленным конвейером CI/CD. /сборка сервера.

Создание наших конечных точек

Теперь мы начинаем добавлять очень простую логику, связанную с CRUD, к нашим конечным точкам. Как указывалось ранее, необходимо короткое заявление об отказе от ответственности. Методы, с помощью которых мы здесь реализуем нашу бизнес-логику, не являются теми, которые вы должны отражать для чего-либо, кроме простых проектов. Подключение к базам данных и выполнение логики непосредственно в конечных точках осуждается (и должно вызываться), поскольку вы теряете возможность замены сервисов или СУБД без необходимости выполнять рефакторинг всего приложения. Тем не менее, учитывая, что это статья для начинающих, я использую здесь эти плохие методы. В следующей статье этой серии мы обсудим, как мы можем повысить как сложность, так и качество нашей архитектуры.

А пока давайте вернемся к нашему файлу server.js и убедимся, что у нас обоих одинаковая отправная точка. Обратите внимание, что я добавил оператор require для нашей фабрики соединений с базой данных и импортировал модель, которую мы экспортировали из ./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}.`));

Я начну с app.post() . У нас есть доступ к модели Book , потому что мы экспортировали ее из файла, в котором мы ее создали. Как указано в документации Mongoose, Book можно построить. Чтобы создать новую книгу, мы вызываем конструктор и передаем данные книги следующим образом:

 const book = new Book(bookData);

В нашем случае у нас будет bookData в качестве объекта, отправленного в запросе, который будет доступен на req.body.book . Помните, что промежуточное ПО express.json() будет помещать любые данные JSON, которые мы отправляем, в req.body . Мы должны отправить JSON в следующем формате:

 { "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 } }

Это означает, что JSON, который мы пропускаем, будет проанализирован, и весь объект JSON (первая пара фигурных скобок) будет помещен в req.body промежуточным программным обеспечением express.json express.json() . Единственным свойством нашего объекта JSON является book , поэтому объект book будет доступен на req.body.book .

На этом этапе мы можем вызвать функцию конструктора модели и передать наши данные:

 app.post('/books', async (req, res) => { // <- Notice 'async' const book = new Book(req.body.book); await book.save(); // <- Notice 'await' });

Обратите внимание на несколько вещей здесь. Вызов метода save для экземпляра, который мы получаем в результате вызова функции конструктора, сохранит объект req.body.book в базе данных тогда и только тогда, когда он соответствует схеме, которую мы определили в модели Mongoose. Акт сохранения данных в базе данных является асинхронной операцией, и этот метод save() возвращает обещание, выполнение которого мы очень ожидаем. Вместо того, чтобы связывать вызов .then() , я использую синтаксис ES6 Async/Await, что означает, что я должен сделать функцию обратного вызова для app.post async .

book.save() отклонит ValidationError , если объект, отправленный клиентом, не соответствует определенной нами схеме. Наша текущая настройка приводит к очень ненадежному и плохо написанному коду, поскольку мы не хотим, чтобы наше приложение зависало в случае сбоя проверки. Чтобы исправить это, я окружу опасную операцию предложением try/catch . В случае ошибки я верну HTTP 400 Bad Request или HTTP 422 Unprocessable Entity. Существует некоторое количество споров о том, что использовать, поэтому я буду придерживаться 400 для этой статьи, поскольку она более общая.

 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' }); } });

Обратите внимание, что я использую сокращение объекта ES6, чтобы просто вернуть объект book обратно клиенту в случае успеха с помощью res.send({ book }) — это будет эквивалентно res.send({ book: book }) . Я также возвращаю выражение, чтобы убедиться, что моя функция завершается. В блоке catch я явно устанавливаю статус 400 и возвращаю строку «ValidationError» в свойстве error объекта, который отправляется обратно. 201 — это код состояния успешного пути, означающий «СОЗДАНО».

Действительно, это тоже не лучшее решение, потому что мы не можем быть уверены, что причиной сбоя был неверный запрос на стороне клиента. Возможно, мы потеряли соединение (предположительно, соединение с сокетом было прервано, поэтому временное исключение) с базой данных, и в этом случае мы, вероятно, должны вернуть ошибку 500 Internal Server. Способ проверить это — прочитать объект ошибки e и выборочно вернуть ответ. Давайте сделаем это сейчас, но, как я уже говорил несколько раз, в следующей статье будет обсуждаться правильная архитектура с точки зрения маршрутизаторов, контроллеров, служб, репозиториев, настраиваемых классов ошибок, настраиваемого промежуточного программного обеспечения ошибок, настраиваемых ответов на ошибки, данных модели базы данных/объекта домена. сопоставление и разделение команд и запросов (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' }); } } });

Откройте Postman (при условии, что он у вас есть, в противном случае загрузите и установите его) и создайте новый запрос. Мы будем делать POST-запрос к localhost:3000/books . На вкладке «Тело» в разделе «Запрос почтальона» я выберу «сырой» переключатель и выберу «JSON» в раскрывающемся списке справа. Это продолжится и автоматически добавит заголовок Content-Type: application/json к запросу. Затем я скопирую и вставлю объект Book JSON ранее в текстовую область основного текста. Вот что у нас есть:

Графический интерфейс Postman, заполненный данными ответа из запроса POST.
Ответ полезной нагрузки JSON на наш запрос POST. (Большой превью)

После этого я нажму кнопку отправки, и вы должны увидеть ответ 201 Created в разделе «Ответ» Postman (нижняя строка). Мы видим это, потому что мы специально попросили Express ответить 201 и объектом Book — если бы мы только что сделали res.send() без кода состояния, express автоматически ответил бы 200 OK. Как видите, объект Book теперь сохранен в базе данных и возвращен клиенту в качестве ответа на запрос POST.

Графический интерфейс Postman, заполненный данными для запроса POST.
Данные для заполнения полей Postman для нашего запроса POST. (Большой превью)

Если вы просмотрите коллекцию книг базы данных через MongoDB Atlas, вы увидите, что книга действительно была сохранена.

Вы также можете сказать, что MongoDB вставила поля __v и _id . Первое представляет версию документа, в данном случае 0, а второе — ObjectID документа, который автоматически генерируется MongoDB и гарантирует низкую вероятность коллизий.

Краткое изложение того, что мы рассмотрели до сих пор

Мы уже многое рассмотрели в статье. Давайте сделаем короткую передышку, просмотрев краткое резюме, прежде чем вернуться, чтобы закончить Express API.

Мы узнали о деструктурировании объектов ES6, сокращенном синтаксисе объектов ES6, а также об операторе ES6 Rest/Spread. Все три из них позволяют нам сделать следующее (и многое другое, как обсуждалось выше):

 // 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

Мы также рассмотрели Express, промежуточное ПО Expess, серверы, порты, IP-адресацию и т. д. Все стало интересно, когда мы узнали, что существуют методы, доступные для возвращаемого результата из require('express')(); с именами HTTP-команд, такими как app.get и app.post .

Если эта часть require('express')() не имеет для вас смысла, я имел в виду следующее:

 const express = require('express'); const app = express(); app.someHTTPVerb

Это должно иметь смысл так же, как мы запускали фабрику соединений для Mongoose.

Каждый обработчик маршрута, который является функцией конечной точки (или функцией обратного вызова), передается в объекте req и объекте res из Express за кулисами. (Технически они также получают next , как мы увидим через минуту). req содержит данные, относящиеся к входящему запросу от клиента, такие как заголовки или любой отправленный JSON. res — это то, что позволяет нам возвращать ответы клиенту. next функция также передается в обработчики.

С Mongoose мы увидели, как мы можем подключаться к базе данных двумя способами — примитивным способом и более продвинутым/практичным способом, который заимствует из фабричного шаблона. В конечном итоге мы будем использовать это, когда будем обсуждать модульное и интеграционное тестирование с Jest (и мутационное тестирование), потому что это позволит нам запустить тестовый экземпляр БД, заполненный начальными данными, против которых мы можем запускать утверждения.

После этого мы создали объект схемы Mongoose и использовали его для создания модели, а затем узнали, как мы можем вызвать конструктор этой модели, чтобы создать ее новый экземпляр. В экземпляре доступен метод save (среди прочего), который является асинхронным по своей природе и который проверяет, соответствует ли переданная нами структура объекта схеме, разрешает обещание, если оно соответствует, и отклоняет обещание с помощью ValidationError , если Это не. В случае разрешения новый документ сохраняется в базе данных, и мы отвечаем HTTP 200 OK/201 CREATED, в противном случае мы перехватываем возникшую ошибку в нашей конечной точке и возвращаем клиенту HTTP 400 Bad Request.

По мере того как мы будем продолжать создавать наши конечные точки, вы узнаете больше о некоторых методах, доступных в модели и экземпляре модели.

Завершение наших конечных точек

Завершив конечную точку POST, давайте обработаем GET. Как я упоминал ранее, синтаксис :id внутри маршрута позволяет Express узнать, что id является параметром маршрута, доступным из req.params . Вы уже видели, что когда вы сопоставляете какой-либо идентификатор для параметра «подстановочный знак» в маршруте, он печатается на экране в ранних примерах. Например, если вы сделали запрос GET к «/books/test-id-123», то req.params.id будет строкой test-id-123 , потому что имя параметра было id при наличии маршрута как HTTP GET /books/:id .

Итак, все, что нам нужно сделать, это получить этот идентификатор из объекта req и проверить, имеет ли какой-либо документ в нашей базе данных такой же идентификатор — это очень легко сделать с помощью Mongoose (и собственного драйвера).

 app.get('/books/:id', async (req, res) => { const book = await Book.findById(req.params.id); console.log(book); res.send({ book }); });

Вы можете видеть, что в нашей модели доступна функция, которую мы можем вызвать, чтобы найти документ по его идентификатору. За кулисами Mongoose преобразует любой идентификатор, который мы передаем в findById , в тип поля _id в документе или, в данном случае, в ObjectId . Если будет найден совпадающий идентификатор (а будет найден только один, поскольку ObjectId имеет крайне низкую вероятность коллизии), этот документ будет помещен в константную переменную нашей book . Если нет, book будет нулевой — факт, который мы будем использовать в ближайшем будущем.

А пока давайте перезапустим сервер (вы должны перезапустить сервер, если вы не используете nodemon ) и убедитесь, что у нас все еще есть один книжный документ из предыдущей коллекции Books . Идите вперед и скопируйте идентификатор этого документа, выделенную часть изображения ниже:

ObjectID документа книги
Пример ObjectID для предстоящего запроса GET. (Большой превью)

И используйте его, чтобы сделать запрос GET к /books/:id с Postman следующим образом (обратите внимание, что данные тела просто остались от моего предыдущего запроса POST. На самом деле они не используются, несмотря на то, что они изображены на изображении ниже) :

Графический интерфейс Postman, заполненный данными для запроса GET.
URL-адрес API и данные Postman для запроса GET. (Большой превью)

После этого вы должны получить документ книги с указанным идентификатором обратно в раздел ответа почтальона. Обратите внимание, что ранее с маршрутом POST, который предназначен для «POST» или «проталкивания» новых ресурсов на сервер, мы ответили 201 Created — потому что был создан новый ресурс (или документ). В случае GET ничего нового не было создано — мы просто запросили ресурс с определенным идентификатором, поэтому мы получили код состояния 200 OK вместо 201 Created.

Как это принято в области разработки программного обеспечения, необходимо учитывать крайние случаи — пользовательский ввод по своей сути небезопасен и ошибочен, и наша работа, как разработчиков, заключается в том, чтобы быть гибкими в отношении типов входных данных, которые нам могут быть предоставлены, и реагировать на них. соответственно. Что мы делаем, если пользователь (или вызывающий API) передает нам некоторый идентификатор, который нельзя преобразовать в ObjectID MongoDB, или идентификатор, который может быть преобразован, но не существует?

В первом случае Mongoose CastError , что понятно, потому что если мы предоставим идентификатор вроде math-is-fun , то это, очевидно, не то, что можно привести к ObjectID, а приведение к ObjectID — это именно то, что Мангуст делает под капотом.

В последнем случае мы могли бы легко решить проблему с помощью Null Check или Guard Clause. В любом случае, я собираюсь отправить ответ HTTP 404 Not Found Response. Я покажу вам несколько способов сделать это, плохой способ, а затем лучший способ.

Во-первых, мы могли бы сделать следующее:

 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' }); } });

Это работает, и мы можем использовать это просто отлично. Я ожидаю, что оператор await Book.findById() вызовет Mongoose CastError , если строка идентификатора не может быть приведена к ObjectID, что приведет к выполнению блока catch . Если его можно привести, но соответствующий ObjectID не существует, тогда book будет иметь значение null , а Null Check выдаст ошибку, снова запустив блок catch . Внутри catch мы просто возвращаем 404. Здесь есть две проблемы. Во-первых, даже если Книга найдена, но возникает какая-то другая неизвестная ошибка, мы отправляем обратно 404 вместо того, чтобы, вероятно, дать клиенту общий код 500. Во-вторых, мы на самом деле не различаем, является ли отправленный идентификатор действительным, но не существует, или это просто плохой идентификатор.

Итак, вот еще один способ:

 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' }); } } });

Хорошая вещь в этом заключается в том, что мы можем обрабатывать все три случая 400, 404 и общий 500. Обратите внимание, что после Null Check на book я использую ключевое слово return в своем ответе. Это очень важно, потому что мы хотим убедиться, что выходим из обработчика маршрута именно там.

Некоторые другие варианты могут заключаться в том, чтобы мы могли проверить, может ли id в req.params быть приведен к ObjectID явно, в отличие от разрешения Mongoose неявно приводить с помощью mongoose.Types.ObjectId.isValid('id); , но есть пограничный случай с 12-байтовыми строками, из-за которого это иногда работает неожиданно.

Мы могли бы сделать указанное повторение менее болезненным с помощью Boom , например, библиотеки ответов HTTP, или мы могли бы использовать промежуточное ПО для обработки ошибок. Мы также можем преобразовать Mongoose Errors во что-то более читабельное с помощью Mongoose Hooks/Middleware, как описано здесь. Дополнительным вариантом может быть определение пользовательских объектов ошибок и использование глобального промежуточного программного обеспечения экспресс-обработки ошибок, однако я приберегу это для следующей статьи, в которой мы обсудим лучшие архитектурные методы.

В конечной точке для PATCH /books/:id мы ожидаем, что будет передан объект обновления, содержащий обновления для рассматриваемой книги. В этой статье мы разрешим обновление всех полей, но в будущем я покажу, как мы можем запретить обновление определенных полей. Кроме того, вы увидите, что логика обработки ошибок в нашей конечной точке PATCH будет такой же, как и в нашей конечной точке GET. Это признак того, что мы нарушаем принципы DRY, но опять же, мы коснемся этого позже.

Я собираюсь ожидать, что все обновления доступны для объекта updates req.body (это означает, что клиент отправит JSON, содержащий объект updates ) и будет использовать функцию Book.findByAndUpdate со специальным флагом для выполнения обновления.

 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' }); } } });

Обратите внимание на несколько вещей здесь. Сначала мы удаляем структуру id из req.params и updates из req.body .

В модели Book доступна функция с именем findByIdAndUpdate , которая принимает идентификатор рассматриваемого документа, выполняемые обновления и необязательный объект параметров. Обычно Mongoose не выполняет повторную проверку для операций обновления, поэтому флаг runValidators: true , который мы передаем, поскольку объект options заставляет его это делать. Кроме того, начиная с Mongoose 4, Model.findByIdAndUpdate больше не возвращает измененный документ, а вместо этого возвращает исходный документ. new: true (который по умолчанию равен false) переопределяет это поведение.

Наконец, мы можем построить нашу конечную точку DELETE, которая очень похожа на все остальные:

 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' }); } } });

На этом наш примитивный API готов, и вы можете протестировать его, отправив HTTP-запросы ко всем конечным точкам.

Краткое заявление об архитектуре и о том, как мы ее исправим

С архитектурной точки зрения код, который у нас есть, довольно плохой, он беспорядочный, он не СУХОЙ, он не НАДЕЖНЫЙ, на самом деле его можно даже назвать отвратительным. Эти так называемые «обработчики маршрутов» делают гораздо больше, чем просто «обрабатывают маршруты» — они напрямую взаимодействуют с нашей базой данных. Это означает, что здесь нет абсолютно никакой абстракции.

Посмотрим правде в глаза, большинство приложений никогда не будут такими маленькими, или вы, вероятно, сможете обойтись без серверных архитектур с базой данных Firebase. Может быть, как мы увидим позже, пользователи хотят иметь возможность загружать аватары, цитаты и фрагменты из своих книг и т. д. Может быть, мы хотим добавить функцию живого чата между пользователями с помощью WebSockets, и давайте даже заявим, что мы откроем наше приложение, чтобы пользователи могли брать книги друг у друга за небольшую плату — и в этот момент нам нужно рассмотреть интеграцию платежей с API Stripe и логистику доставки с API Shippo.

Предположим, мы продолжим нашу текущую архитектуру и добавим все эти функции. Эти обработчики маршрутов, также известные как действия контроллера, в конечном итоге будут очень, очень большими с высокой цикломатической сложностью . Такой стиль кодирования может подойти нам на первых порах, но что, если мы решим, что наши данные являются ссылочными, и поэтому PostgreSQL является лучшим выбором для базы данных, чем MongoDB? Теперь нам нужно провести рефакторинг всего нашего приложения, удалив Mongoose, изменив наши контроллеры и т. д. Все это может привести к потенциальным ошибкам в остальной части бизнес-логики. Другим таким примером может быть решение о том, что AWS S3 слишком дорог, и мы хотим перейти на GCP. Опять же, это требует рефакторинга всего приложения.

Хотя существует множество мнений об архитектуре, от предметно-ориентированного проектирования, разделения ответственности командных запросов и источников событий до разработки через тестирование, SOILD, многоуровневой архитектуры, луковой архитектуры и т. д., мы сосредоточимся на реализации простой многоуровневой архитектуры в будущие статьи, состоящие из контроллеров, служб и репозиториев и использующие шаблоны проектирования, такие как композиция, адаптеры/оболочки и инверсия управления через внедрение зависимостей. Хотя в некоторой степени это можно было бы выполнить с помощью JavaScript, мы также рассмотрим варианты TypeScript для достижения этой архитектуры, что позволит нам использовать парадигмы функционального программирования, такие как либо монады, в дополнение к концепциям ООП, таким как дженерики.

На данный момент мы можем внести два небольших изменения. Поскольку наша логика обработки ошибок очень похожа в блоке catch всех конечных точек, мы можем извлечь ее в пользовательскую функцию ПО промежуточного слоя для экспресс-обработки ошибок в самом конце стека.

Очистка нашей архитектуры

В настоящее время мы повторяем очень большое количество логики обработки ошибок на всех наших конечных точках. Вместо этого мы можем создать функцию ПО промежуточного слоя для экспресс-обработки ошибок, которая представляет собой функцию ПО промежуточного слоя для экспресс-обработки, которая вызывается с ошибкой, объектами req и res и следующей функцией.

А пока давайте создадим эту промежуточную функцию. Все, что я собираюсь сделать, это повторить ту же логику обработки ошибок, к которой мы привыкли:

 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' }); } });

Похоже, это не работает с ошибками Mongoose, но в целом вместо использования if/else if/else для определения экземпляров ошибки вы можете переключить конструктор ошибки. Однако я оставлю то, что у нас есть.

В синхронном обработчике конечной точки/маршрута, если вы выдаете ошибку, Express поймает ее и обработает без дополнительной работы с вашей стороны. К сожалению, это не так для нас. Мы имеем дело с асинхронным кодом. Чтобы делегировать обработку ошибок в Express с обработчиками асинхронных маршрутов, мы сами ловим ошибку и передаем ее в next() .

Итак, я просто позволю next быть третьим аргументом в конечной точке и удалю логику обработки ошибок в блоках catch в пользу простой передачи экземпляра ошибки в next как таковой:

 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) } });

Если вы сделаете это со всеми обработчиками маршрутов, вы должны получить следующий код:

 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}.`));

Двигаясь дальше, было бы целесообразно отделить наше промежуточное ПО для обработки ошибок в другой файл, но это тривиально, и мы увидим это в следующих статьях этой серии. Кроме того, мы могли бы использовать модуль NPM с именем express-async-errors , чтобы нам не приходилось вызывать next в блоке catch, но опять же, я пытаюсь показать вам, как все делается официально.

Несколько слов о CORS и единой политике происхождения

Предположим, ваш веб-сайт обслуживается из домена myWebsite.com но ваш сервер находится в myOtherDomain.com/api . CORS расшифровывается как Cross-Origin Resource Sharing и представляет собой механизм, с помощью которого могут выполняться междоменные запросы. В приведенном выше случае, поскольку код JS сервера и внешнего интерфейса находится в разных доменах, вы будете делать запрос из двух разных источников, что обычно ограничивается браузером по соображениям безопасности и смягчается путем предоставления определенных заголовков HTTP.

Политика того же источника — это то, что выполняет эти вышеупомянутые ограничения — веб-браузер разрешает выполнять запросы только в одном и том же источнике.

Мы коснемся CORS и SOP позже, когда будем создавать внешний интерфейс Webpack для нашего Book API с React.

Заключение и что дальше

Мы многое обсудили в этой статье. Возможно, это было не совсем практично, но мы надеемся, что вам стало удобнее работать с функциями JavaScript Express и ES6. Если вы новичок в программировании и Node — это первый путь, по которому вы вступаете, надеюсь, ссылки на языки со статическими типами, такие как Java, C++ и C#, помогли выделить некоторые различия между JavaScript и его статическими аналогами.

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
  • Услуги
  • Репозитории
  • Отображение данных
  • 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
  • Проверка
  • Decorators
  • Logging and Logging Levels
  • Unit Tests, Integration Tests (E2E), and Mutation Tests
  • The Structured Query Language
  • связи
  • HTTP/Express Security Best Practices
  • Node Best Practices
  • OWASP Security Best Practices
  • И больше.

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.