javascript

Пишем симпатичные Node.js-API с использованием async/await и базы данных Firebase

  • вторник, 11 июля 2017 г. в 03:12:22
https://habrahabr.ru/company/ruvds/blog/332768/
  • Node.JS
  • JavaScript
  • Блог компании RUVDS.com


Мы уже рассказывали об основах работы с async/await в Node.js, и о том, как использование этого нового механизма позволяет сделать код лучше. Сегодня поговорим о том, как создавать, используя async/await, RESTful API, взаимодействующие с базой данных Firebase. Особое внимание обратим на то, как писать красивый, удобный и понятный асинхронный код. Можете прямо сейчас попрощаться с адом коллбэков.



Для того, чтобы проработать этот материал, у вас должны быть установлены Node.js и Firebase Admin SDK. Если это не так — вот официальное руководство по настройке рабочей среды.

Запись данных


В примерах мы будем рассматривать фрагменты кода API, которое представляет собой серверную часть приложения, предназначенного для работы со словами. Пользователь приложения, найдя новое слово и желая сохранить его для того, чтобы позже выучить, может сохранить его в базу. Когда дело дойдёт до заучивания слова, его можно запросить из базы.

Создадим конечную точку POST, которая будет сохранять слова в базу данных:

// Зависимости
const admin = require('firebase-admin');
const express = require('express');

// Настройка
const db = admin.database();
const router = express.Router();

// Вспомогательные средства
router.use(bodyParser.json());

// API
router.post('/words', (req, res) => {
  const {userId, word} = req.body;
  db.ref(`words/${userId}`).push({word});
  res.sendStatus(201);
});

Тут всё устроено очень просто, без излишеств. Мы принимаем идентификатор пользователя (userId) и слово (word), затем сохраняем слово в коллекции words.

Однако, даже в таком вот простом примере кое-чего не хватает. Мы забыли об обработке ошибок. А именно, мы возвращаем код состояния 201 даже в том случае, если слово в базу сохранить не удалось.

Добавим в наш код обработку ошибок:

// API
router.post('/words', (req, res) => {
  const {userId, word} = req.body;
  db.ref(`words/${userId}`).push({word}, error => {
    if (error) {
      res.sendStatus(500);
      // Логируем сообщение об ошибке во внешний сервис, например, в Sentry
    } else {
      res.sendStatus(201);
    }
  };
});

Теперь, когда конечная точка возвращает правильные коды состояний, клиент может вывести подходящее сообщение для пользователя. Например, что-то вроде: «Слово успешно сохранено», или: «Слово сохранить не удалось, попробуйте ещё раз».

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

Чтение данных


Итак, в базу данных Firebase мы уже кое-что записали. Попробуем теперь чтение. Сначала — вот как будет выглядеть конечная точка GET, созданная с использованием традиционного подхода с применением промисов:

// API
router.get('/words', (req, res) => {
  const {userId} = req.query;
  db.ref(`words/${userId}`).once('value')
    .then( snapshot => {
      res.send(snapshot.val());
    });
});

Тут, чтобы не перегружать пример, опущена обработка ошибок.

Пока код выглядит не таким уж и сложным. Взглянем теперь на реализацию того же самого с использованием async/await:

// API
router.get('/words', async (req, res) => {
  const {userId} = req.query;
  const wordsSnapshot = await db.ref(`words/${userId}`).once('value');
  res.send(wordsSnapshot.val())
});

Здесь, опять же, нет обработки ошибок. Обратите внимание на ключевое слово async, добавленное перед параметрами (res, req) стрелочной функции, и на ключевое слово await, которое предшествует выражению db.ref().

Метод db.ref() возвращает промис. Это означает, что тут мы можем задействовать await для того, чтобы «приостановить» выполнение скрипта. Ключевое слово await можно использовать с любыми промисами.

Метод res.send(), расположенный в конце функции, будет вызван только после того, как разрешится промис db.ref().

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

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

const example = require('example-library');

example.firstAsyncRequest()
  .then( fistResponse => {
    example.secondAsyncRequest(fistResponse)
      .then( secondResponse => {
        example.thirdAsyncRequest(secondResponse)
          .then( thirdAsyncResponse => {
            // Безумие продолжается
          });
      });
  });

Не очень-то хорошо получилось. Такие конструкции ещё называют «пирамидами ужаса» (pyramid of doom). А если сюда ещё добавить обработку ошибок…

Теперь перепишем этот код с использованием async/await:

const example = require('example-library');

const runDemo = async () => {
  const fistResponse = await example.firstAsyncRequest();
  const secondResponse = await example.secondAsyncRequest(fistResponse);
  const thirdAsyncRequest = await example.thirdAsyncRequest(secondResponse);
};

runDemo();

Никаких ужасов тут теперь нет. Более того, все выражения с ключевым словом await можно обернуть в один блок try/catch для обработки любых ошибок:

const example = require('example-library');

const runDemo = async () => {
  try {
    const fistResponse = await example.firstAsyncRequest();
    const secondResponse = await example.secondAsyncRequest(fistResponse);
    const thirdAsyncRequest = await example.thirdAsyncRequest(secondResponse);
  }
  catch (error) {
    // Обработка ошибок
  }
};

runDemo();

Такой код выглядит вполне достойно. Теперь поговорим о параллельных запросах и async/await.

Параллельные запросы и async/await


Что если нужно одновременно прочитать из базы данных множество записей? На самом деле — ничего особенно сложного тут нет. Достаточно использовать метод Promise.all() для параллельного выполнения запросов:

// API
router.get('/words', async (req, res) => {
  const wordsRef = db.ref(`words`).once('value');
  const usersRef = db.ref(`users`).once('value');
  const values = await Promise.all([wordsRef, usersRef]);
  const wordsVal = values[0].val();
  const userVal = values[1].val();
  res.sendStatus(200);
});

Примечания о работе с Firebase


Создавая конечную точку API, которая будет возвращать то, что получено из базы данных Firebase, постарайтесь не возвращать весь snapshot.val(). Это может вызвать проблемы с разбором JSON на клиенте.

Например, на стороне клиента есть такой код:

fetch('https://your-domain.com/api/words')
  .then( response => response.json())
  .then( json => {
    // Обработка данных
  })
  .catch( error => {
    // Обработка ошибок
  });

То, что будет в snapshot.val(), возвращённом Firebase, может оказаться либо JSON-объектом, либо значением null, если ни одной записи найти не удалось. Если возвратить null, тогда json.response() в вышеприведённом коде выдаст ошибку, так как он попытается этот null, не являющийся объектом, разобрать.

Чтобы от этого защититься, можно использовать Object.assign() для того, чтобы всегда возвращать клиенту объект:

// API
router.get('/words', async (req, res) => {
  const {userId} = req.query;
  const wordsSnapshot = await db.ref(`words/${userId}`).once('value');
  
  // Плохо
  res.send(wordsSnapshot.val())
  
  // Хорошо
  const response = Object.assign({}, snapshot.val());
  res.send(response);
});

Итоги


Как видите, конструкция async/await помогает избежать ада коллбэков и прочих неприятностей, делая код понятнее и облегчая обработку ошибок. Если вы хотите взглянуть на реальный проект, построенный с применением Node.js и базы данных Firebase, вот Vocabify — приложение, которое разработал автор этого материала. Оно предназначено для запоминания новых слов.

Уважаемые читатели! Используете ли вы async/await в своих проектах на Node.js?