Семинар по бессерверным веб-API

1. Обзор

Цель этой лаборатории — получить опыт работы с «бессерверными» сервисами, предлагаемыми Google Cloud Platform:

  • Облачные функции — для развертывания небольших единиц бизнес-логики в виде функций, которые реагируют на различные события (сообщения Pub/Sub, новые файлы в облачном хранилище, HTTP-запросы и т. д.),
  • App Engine — для развертывания и обслуживания веб-приложений, веб-API, мобильных серверных частей, статических ресурсов с возможностью быстрого масштабирования вверх и вниз.
  • Cloud Run — для развертывания и масштабирования контейнеров, которые могут содержать любой язык, среду выполнения или библиотеку.

И узнать, как использовать преимущества этих бессерверных сервисов для развертывания и масштабирования веб-интерфейсов и REST API, а также познакомиться с некоторыми хорошими принципами проектирования RESTful.

В этом мастер-классе мы создадим проводник книжной полки, состоящий из:

  • Облачная функция: импортировать исходный набор данных книг, доступных в нашей библиотеке, в базу данных документов Cloud Firestore ,
  • Контейнер Cloud Run: который будет предоставлять REST API для содержимого нашей базы данных.
  • Веб-интерфейс App Engine: для просмотра списка книг с помощью вызова нашего REST API.

Вот как будет выглядеть веб-интерфейс в конце этой лаборатории кода:

705e014da0ca5e90.png

Что вы узнаете

  • Облачные функции
  • Облачный пожарный магазин
  • Облачный бег
  • Механизм приложений

2. Настройка и требования

Самостоятельная настройка среды

  1. Войдите в Google Cloud Console и создайте новый проект или повторно используйте существующий. Если у вас еще нет учетной записи Gmail или Google Workspace, вам необходимо ее создать .

295004821bab6a87.png

37d264871000675d.png

96d86d3d5655cdbe.png

  • Имя проекта — это отображаемое имя для участников этого проекта. Это строка символов, не используемая API Google. Вы всегда можете обновить его.
  • Идентификатор проекта уникален для всех проектов Google Cloud и является неизменяемым (невозможно изменить после его установки). Cloud Console автоматически генерирует уникальную строку; обычно тебя не волнует, что это такое. В большинстве лабораторий кода вам потребуется указать идентификатор проекта (обычно идентифицируемый как PROJECT_ID ). Если вам не нравится сгенерированный идентификатор, вы можете создать другой случайный идентификатор. Альтернативно, вы можете попробовать свой собственный и посмотреть, доступен ли он. Его нельзя изменить после этого шага и он сохраняется на протяжении всего проекта.
  • К вашему сведению, есть третье значение — номер проекта , которое используют некоторые API. Подробнее обо всех трех этих значениях читайте в документации .
  1. Затем вам необходимо включить выставление счетов в Cloud Console, чтобы использовать облачные ресурсы/API. Прохождение этой кодовой лаборатории не будет стоить много, если вообще что-то стоить. Чтобы отключить ресурсы и избежать выставления счетов за пределами этого руководства, вы можете удалить созданные вами ресурсы или удалить проект. Новые пользователи Google Cloud имеют право на участие в программе бесплатной пробной версии стоимостью 300 долларов США .

Запустить Cloud Shell

Хотя Google Cloud можно управлять удаленно с вашего ноутбука, в этой лаборатории вы будете использовать Google Cloud Shell , среду командной строки, работающую в облаке.

В Google Cloud Console щелкните значок Cloud Shell на верхней правой панели инструментов:

84688aa223b1c3a2.png

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

320e18fedb7fbe0.png

Эта виртуальная машина оснащена всеми необходимыми инструментами разработки. Он предлагает постоянный домашний каталог объемом 5 ГБ и работает в Google Cloud, что значительно повышает производительность сети и аутентификацию. Всю работу в этой лаборатории кода можно выполнять в браузере. Вам не нужно ничего устанавливать.

3. Подготовьте среду и включите облачные API.

Чтобы использовать различные сервисы, которые нам понадобятся в этом проекте, мы включим несколько API. Мы сделаем это, запустив следующую команду в Cloud Shell:

$ gcloud services enable \
      appengine.googleapis.com \
      cloudbuild.googleapis.com \
      cloudfunctions.googleapis.com \
      compute.googleapis.com \
      firestore.googleapis.com \
      run.googleapis.com

Через некоторое время вы должны увидеть успешное завершение операции:

Operation "operations/acf.5c5ef4f6-f734-455d-b2f0-ee70b5a17322" finished successfully.

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

$ export REGION=europe-west3

Поскольку мы будем хранить данные в базе данных Cloud Firestore, нам нужно будет создать базу данных:

$ gcloud app create --region=${REGION}
$ gcloud firestore databases create --location=${REGION}

Позже в этой лаборатории кода, при реализации REST API, нам нужно будет сортировать и фильтровать данные. Для этой цели мы создадим три индекса:

$ gcloud firestore indexes composite create --collection-group=books \
      --field-config field-path=language,order=ascending \
      --field-config field-path=updated,order=descending 

$ gcloud firestore indexes composite create --collection-group=books \
      --field-config field-path=author,order=ascending \
      --field-config field-path=updated,order=descending 

Эти три индекса соответствуют поискам, которые мы будем выполнять по автору или языку, сохраняя при этом порядок в коллекции с помощью обновляемого поля.

4. Получите код

Получите код из следующего репозитория Github:

$ git clone https://github.com/glaforge/serverless-web-apis

Код приложения написан с использованием Node.JS.

У вас будет следующая структура папок, актуальная для этой лабораторной работы:

serverless-web-apis
 |
 ├── data
 |   ├── books.json
 |
 ├── function-import
 |   ├── index.js
 |   ├── package.json
 |
 ├── run-crud
 |   ├── index.js
 |   ├── package.json
 |   ├── Dockerfile
 |
 ├── appengine-frontend
     ├── public
     |   ├── css/style.css
     |   ├── html/index.html
     |   ├── js/app.js
     ├── index.js
     ├── package.json
     ├── app.yaml

Это соответствующие папки:

  • data — эта папка содержит образцы данных списка из 100 книг.
  • function-import — эта функция предложит конечную точку для импорта образцов данных.
  • run-crud — этот контейнер предоставит веб-API для доступа к данным книги, хранящимся в Cloud Firestore.
  • appengine-frontend — это веб-приложение App Engine отображает простой интерфейс, доступный только для чтения, для просмотра списка книг.

5. Примеры данных книжной библиотеки

В папке data у нас есть файл books.json , содержащий список из ста книг, которые, вероятно, стоит прочитать. Этот документ JSON представляет собой массив, содержащий объекты JSON. Давайте посмотрим на форму данных, которые мы будем принимать с помощью облачной функции:

[
  {
    "isbn": "9780435272463",
    "author": "Chinua Achebe",
    "language": "English",
    "pages": 209,
    "title": "Things Fall Apart",
    "year": 1958
  },
  {
    "isbn": "9781414251196",
    "author": "Hans Christian Andersen",
    "language": "Danish",
    "pages": 784,
    "title": "Fairy tales",
    "year": 1836
  },
  ...
]

Все записи нашей книги в этом массиве содержат следующую информацию:

  • isbn — код ISBN-13, идентифицирующий книгу.
  • author — Имя автора книги.
  • language — разговорный язык, на котором написана книга.
  • pages — количество страниц в книге.
  • title — Название книги.
  • year — год издания книги.

6. Конечная точка функции для импорта данных книги примеров.

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

Изучите код

Начнем с просмотра файла package.json :

{
    "name": "function-import",
    "description": "Import sample book data",
    "license": "Apache-2.0",
    "dependencies": {
        "@google-cloud/firestore": "^4.9.9"
    },
    "devDependencies": {
        "@google-cloud/functions-framework": "^3.1.0"
    },
    "scripts": {
        "start": "npx @google-cloud/functions-framework --target=parseBooks"
    }
}

В зависимостях времени выполнения нам нужен только модуль NPM @google-cloud/firestore для доступа к базе данных и хранения данных нашей книги. Внутри среда выполнения Cloud Functions также предоставляет веб-инфраструктуру Express, поэтому нам не нужно объявлять ее как зависимость.

В зависимостях разработки мы объявляем Functions Framework ( @google-cloud/functions-framework ), которая представляет собой среду выполнения, используемую для вызова ваших функций. Это платформа с открытым исходным кодом, которую вы также можете использовать локально на своем компьютере (в нашем случае внутри Cloud Shell) для запуска функций без развертывания каждый раз при внесении изменений, что улучшает цикл обратной связи при разработке.

Чтобы установить зависимости, используйте команду install :

$ npm install

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

$ npm start

Вы можете использовать Curl или, возможно, веб-предварительный просмотр Cloud Shell для запросов HTTP GET для взаимодействия с этой функцией.

Давайте теперь посмотрим на файл index.js , который содержит логику функции импорта данных нашей книги:

const Firestore = require('@google-cloud/firestore');
const firestore = new Firestore();
const bookStore = firestore.collection('books');

Мы создаем экземпляр модуля Firestore и указываем на коллекцию книг (аналогично таблице в реляционных базах данных).

functions.http('parseBooks', async (req, resp) => {
    if (req.method !== "POST") {
        resp.status(405).send({error: "Only method POST allowed"});
        return;
    }
    if (req.headers['content-type'] !== "application/json") {
        resp.status(406).send({error: "Only application/json accepted"});
        return;
    }
    ... 
})

Мы экспортируем функцию JavaScript parseBooks . Эту функцию мы объявим, когда развернем ее позже.

Следующие несколько инструкций проверяют следующее:

  • Мы принимаем только HTTP-запросы POST и в противном случае возвращаем код состояния 405 указывающий, что другие методы HTTP не разрешены.
  • Мы принимаем только полезные данные application/json и в противном случае отправляем код состояния 406 , чтобы указать, что это неприемлемый формат полезных данных.
    const books = req.body;

    const writeBatch = firestore.batch();

    for (const book of books) {
        const doc = bookStore.doc(book.isbn);
        writeBatch.set(doc, {
            title: book.title,
            author: book.author,
            language: book.language,
            pages: book.pages,
            year: book.year,
            updated: Firestore.Timestamp.now()
        });
    }

Затем мы можем получить полезную нагрузку JSON через body запроса. Мы готовим пакетную операцию Firestore для массового хранения всех книг. Мы перебираем массив JSON, состоящий из сведений о книге, проходя через поля isbn , title , author , language , pages year . Код ISBN книги будет служить ее первичным ключом или идентификатором.

    try {
        await writeBatch.commit();
        console.log("Saved books in Firestore");
    } catch (e) {
        console.error("Error saving books:", e);
        resp.status(400).send({error: "Error saving books"});
        return;
    };

    resp.status(202).send({status: "OK"});

Теперь, когда основная часть данных готова, мы можем подтвердить операцию. Если операция хранения завершается неудачей, мы возвращаем код состояния 400 , сообщающий о сбое. В противном случае мы можем вернуть ответ «ОК» с кодом состояния 202 , указывающим, что запрос на массовое сохранение был принят.

Запуск и тестирование функции импорта

Перед запуском кода мы установим зависимости с помощью:

$ npm install

Чтобы запустить функцию локально, благодаря Functions Framework, мы будем использовать команду start сценария, которую мы определили в package.json :

$ npm start

> start
> npx @google-cloud/functions-framework --target=parseBooks

Serving function...
Function: parseBooks
URL: http://localhost:8080/

Чтобы отправить запрос HTTP POST в вашу локальную функцию, вы можете запустить:

$ curl -d "@../data/books.json" \
       -H "Content-Type: application/json" \
       http://localhost:8080/

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

{"status":"OK"}

Вы также можете перейти в пользовательский интерфейс Cloud Console, чтобы убедиться, что данные действительно хранятся в Firestore:

409982568cebdbf8.png

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

Развертывание функции в облаке

Чтобы развернуть функцию в Cloud Functions, мы будем использовать следующую команду в каталоге function-import :

$ gcloud functions deploy bulk-import \
         --gen2 \
         --trigger-http \
         --runtime=nodejs20 \
         --allow-unauthenticated \
         --max-instances=30
         --region=${REGION} \
         --source=. \
         --entry-point=parseBooks

Мы развертываем функцию с символическим именем bulk-import . Эта функция запускается через HTTP-запросы. Мы используем среду выполнения Node.JS 20. Мы развертываем функцию публично (в идеале мы должны защитить эту конечную точку). Мы указываем регион, в котором мы хотим, чтобы функция находилась. И мы указываем на источники в локальном каталоге и используем parseBooks (экспортированную функцию JavaScript) в качестве точки входа.

Через пару минут или меньше функция будет развернута в облаке. В пользовательском интерфейсе Cloud Console вы должны увидеть появление функции:

c910875d4dc0aaa8.png

В результатах развертывания вы сможете увидеть URL-адрес вашей функции, который соответствует определенному соглашению об именах ( https://${REGION}-${GOOGLE_CLOUD_PROJECT}.cloudfunctions.net/${FUNCTION_NAME} ) и Конечно, вы также можете найти этот URL-адрес триггера HTTP в пользовательском интерфейсе Cloud Console на вкладке триггера:

380ffc46eb56441e.png

Вы также можете получить URL-адрес через командную строку с помощью gcloud :

$ export BULK_IMPORT_URL=$(gcloud functions describe bulk-import \
                                  --region=$REGION \
                                  --format 'value(httpsTrigger.url)')
$ echo $BULK_IMPORT_URL

Давайте сохраним его в переменной среды BULK_IMPORT_URL , чтобы мы могли повторно использовать его для тестирования нашей развернутой функции.

Тестирование развернутой функции

Используя аналогичную команду Curl, которую мы использовали ранее для проверки функции, работающей локально, мы проверим развернутую функцию. Единственным изменением будет URL-адрес:

$ curl -d "@../data/books.json" \
       -H "Content-Type: application/json" \
       $BULK_IMPORT_URL

Опять же, в случае успеха он должен вернуть следующий вывод:

{"status":"OK"}

Теперь, когда наша функция импорта развернута и готова, и мы загрузили образцы данных, пришло время разработать REST API, предоставляющий доступ к этому набору данных.

7. Контракт REST API

Хотя мы не определяем контракт API, используя, например, спецификацию Open API , мы собираемся рассмотреть различные конечные точки нашего REST API.

API обменивается объектами JSON книги, состоящими из:

  • isbn (необязательно) — 13-значная String , представляющая действительный код ISBN,
  • author — непустая String представляющая имя автора книги,
  • language — непустая String , содержащая язык, на котором написана книга,
  • pages — положительное Integer для количества страниц книги,
  • title — непустая String с названием книги,
  • yearInteger значение года публикации книги.

Пример полезной нагрузки книги:

{
    "isbn": "9780435272463",
    "author": "Chinua Achebe",
    "language": "English",
    "pages": 209,
    "title": "Things Fall Apart",
    "year": 1958
  }

ПОЛУЧИТЬ /книги

Получите список всех книг, потенциально отфильтрованный по автору и/или языку и разбитый на страницы по окнам по 10 результатов за раз.

Полезная нагрузка кузова: нет.

Параметры запроса:

  • author (необязательно) — фильтрует список книг по автору,
  • language (необязательно) — фильтрует список книг по языку,
  • page (необязательно, по умолчанию = 0) — указывает рейтинг страницы возвращаемых результатов.

Возвращает: массив JSON объектов книги.

Коды состояния:

  • 200 — когда запросу удалось получить список книг,
  • 400 — если произошла ошибка.

POST/books и POST/books/{isbn}

Опубликуйте новые полезные данные книги либо с параметром пути isbn (в этом случае код isbn не требуется в полезных данных книги), либо без него (в этом случае код isbn должен присутствовать в полезных данных книги).

Полезная нагрузка тела: объект-книга.

Параметры запроса: нет.

Возврат: ничего.

Коды состояния:

  • 201 — когда книга успешно сохранена,
  • 406 — если код isbn недействителен,
  • 400 — если произошла ошибка.

ПОЛУЧИТЬ /books/{isbn}

Извлекает книгу из библиотеки, идентифицируемую ее кодом isbn , передаваемым в качестве параметра пути.

Полезная нагрузка кузова: нет.

Параметры запроса: нет.

Возвращает: объект JSON книги или объект ошибки, если книга не существует.

Коды состояния:

  • 200 — если книга найдена в базе данных,
  • 400 — если произошла ошибка,
  • 404 — если книгу не удалось найти,
  • 406 — если isbn -код недействителен.

PUT /books/{isbn}

Обновляет существующую книгу, идентифицируемую ее номером isbn передаваемым в качестве параметра пути.

Полезная нагрузка тела: объект-книга. Можно передавать только те поля, которые требуют обновления, остальные необязательны.

Параметры запроса: нет.

Возврат: обновленная книга.

Коды состояния:

  • 200 — при успешном обновлении книги,
  • 400 — если произошла ошибка,
  • 406 — если isbn -код недействителен.

УДАЛИТЬ /books/{isbn}

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

Полезная нагрузка кузова: нет.

Параметры запроса: нет.

Возврат: ничего.

Коды состояния:

  • 204 — при успешном удалении книги,
  • 400 — если произошла ошибка.

8. Развертывание и предоставление REST API в контейнере.

Изучите код

Докерфайл

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

FROM node:20-slim
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install --only=production
COPY . ./
CMD [ "node", "index.js" ]

Мы используем «тонкий» образ Node.JS 20. Мы работаем в каталоге /usr/src/app . Мы копируем файл package.json (подробности ниже), который, среди прочего, определяет наши зависимости. Устанавливаем зависимости с помощью npm install , копируя исходный код. Наконец, мы указываем, как должно запускаться это приложение, с помощью команды node index.js .

пакет.json

Далее мы можем просмотреть файл package.json :

{
    "name": "run-crud",
    "description": "CRUD operations over book data",
    "license": "Apache-2.0",
    "engines": {
        "node": ">= 20.0.0"
    },
    "dependencies": {
        "@google-cloud/firestore": "^4.9.9",
        "cors": "^2.8.5",
        "express": "^4.17.1",
        "isbn3": "^1.1.10"
    },
    "scripts": {
        "start": "node index.js"
    }
}

Указываем, что хотим использовать Node.JS 14, как это было с Dockerfile .

Наше веб-приложение API зависит от:

  • Модуль Firestore NPM для доступа к данным книги в базе данных,
  • Библиотека cors для обработки запросов CORS (Cross Origin Resource Sharing), поскольку наш REST API будет вызываться из клиентского кода нашего интерфейса веб-приложения App Engine,
  • Платформа Express, которая будет нашей веб-инфраструктурой для разработки нашего API,
  • А затем модуль isbn3 , который помогает проверять коды ISBN книг.

Также указываем start скрипт, который пригодится для локального запуска приложения, в целях разработки и тестирования.

index.js

Давайте перейдем к сути кода и подробно рассмотрим index.js :

const Firestore = require('@google-cloud/firestore');
const firestore = new Firestore();
const bookStore = firestore.collection('books');

Нам нужен модуль Firestore и мы ссылаемся на коллекцию books , где хранятся данные наших книг.

const express = require('express');
const app = express();
const bodyParser = require('body-parser');
app.use(bodyParser.json());

const querystring = require('querystring');

const cors = require('cors');
app.use(cors({
    exposedHeaders: ['Content-Length', 'Content-Type', 'Link'],
}));

Мы используем Express в качестве веб-фреймворка для реализации нашего REST API. Мы используем модуль body-parser для анализа полезных данных JSON, которыми обмениваются наши API.

Модуль querystring полезен для управления URL-адресами. Это произойдет, когда мы создадим заголовки Link для нумерации страниц (подробнее об этом позже).

Затем настраиваем модуль cors . Мы явно указываем заголовки, которые хотим передать через CORS, поскольку большинство из них обычно удаляются, но здесь мы хотим сохранить обычную длину и тип контента, а также заголовок Link , который мы укажем для нумерации страниц.

const ISBN = require('isbn3');

function isbnOK(isbn, res) {
    const parsedIsbn = ISBN.parse(isbn);
    if (!parsedIsbn) {
        res.status(406)
            .send({error: `Invalid ISBN: ${isbn}`});
        return false;
    }
    return parsedIsbn;
}

Мы будем использовать модуль isbn3 NPM для анализа и проверки кодов ISBN, а также разработаем небольшую служебную функцию, которая будет анализировать коды ISBN и отправлять в ответ код состояния 406 , если коды ISBN недействительны.

  • GET /books

Давайте рассмотрим конечную точку GET /books по частям:

app.get('/books', async (req, res) => {
    try {
        var query = new Firestore().collection('books');

        if (!!req.query.author) {
            console.log(`Filtering by author: ${req.query.author}`);
            query = query.where("author", "==", req.query.author);
        }
        if (!!req.query.language) {
            console.log(`Filtering by language: ${req.query.language}`);
            query = query.where("language", "==", req.query.language);
        }

        const page = parseInt(req.query.page) || 0;

        // - - ✄ - - ✄ - - ✄ - - ✄ - - ✄ - -

    } catch (e) {
        console.error('Failed to fetch books', e);
        res.status(400)
            .send({error: `Impossible to fetch books: ${e.message}`});
    }
});

Мы готовимся выполнить запрос к базе данных, подготовив запрос. Этот запрос будет зависеть от дополнительных параметров запроса для фильтрации по автору и/или по языку. Мы также возвращаем список книг частями по 10 книг.

Если при получении книг возникает ошибка, мы возвращаем ошибку с кодом состояния 400.

Давайте увеличим вырезанную часть этой конечной точки:

        const snapshot = await query
            .orderBy('updated', 'desc')
            .limit(PAGE_SIZE)
            .offset(PAGE_SIZE * page)
            .get();

        const books = [];

        if (snapshot.empty) {
            console.log('No book found');
        } else {
            snapshot.forEach(doc => {
                const {title, author, pages, year, language, ...otherFields} = doc.data();
                const book = {isbn: doc.id, title, author, pages, year, language};
                books.push(book);
            });
        }

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

Мы выполняем запрос, получаем снимок данных и помещаем эти результаты в массив JavaScript, который будет возвращен в конце функции.

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

        var links = {};
        if (page > 0) {
            const prevQuery = querystring.stringify({...req.query, page: page - 1});
            links.prev = `${req.path}${prevQuery != '' ? `?${prevQuery}` : ''}`;
        }
        if (snapshot.docs.length === PAGE_SIZE) {
            const nextQuery = querystring.stringify({...req.query, page: page + 1});
            links.next = `${req.path}${nextQuery != '' ? `?${nextQuery}` : ''}`;
        }
        if (Object.keys(links).length > 0) {
            res.links(links);
        }

        res.status(200).send(books);

Поначалу логика может показаться немного сложной, но мы добавляем предыдущую ссылку, если мы не находимся на первой странице данных. И мы добавляем следующую ссылку, если страница данных заполнена (т. е. содержит максимальное количество книг, определенное константой PAGE_SIZE , при условии, что есть еще одна страница с большим количеством данных). Затем мы используем функцию resource#links() Express, чтобы создать правильный заголовок с правильным синтаксисом.

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

link: </books?page=1>; rel="prev", </books?page=3>; rel="next"
  • POST /books и POST /books/:isbn

Обе конечные точки здесь для создания новой книги. Один передает код ISBN в полезных данных книги, а другой передает его как параметр пути. В любом случае оба вызывают нашу функцию createBook() :

async function createBook(isbn, req, res) {
    const parsedIsbn = isbnOK(isbn, res);
    if (!parsedIsbn) return;

    const {title, author, pages, year, language} = req.body;

    try {
        const docRef = bookStore.doc(parsedIsbn.isbn13);
        await docRef.set({
            title, author, pages, year, language,
            updated: Firestore.Timestamp.now()
        });
        console.log(`Saved book ${parsedIsbn.isbn13}`);

        res.status(201)
            .location(`/books/${parsedIsbn.isbn13}`)
            .send({status: `Book ${parsedIsbn.isbn13} created`});
    } catch (e) {
        console.error(`Failed to save book ${parsedIsbn.isbn13}`, e);
        res.status(400)
            .send({error: `Impossible to create book ${parsedIsbn.isbn13}: ${e.message}`});
    }    
}

Мы проверяем корректность кода isbn , в противном случае возвращаемся из функции (и устанавливаем код состояния 406 ). Мы извлекаем поля книги из полезных данных, переданных в теле запроса. Затем мы собираемся сохранить информацию о книге в Firestore. Возвращает 201 в случае успеха и 400 в случае неудачи.

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

Location: /books/9781234567898
  • GET /books/:isbn

Давайте возьмем книгу, идентифицированную по ISBN, из Firestore.

app.get('/books/:isbn', async (req, res) => {
    const parsedIsbn = isbnOK(req.params.isbn, res);
    if (!parsedIsbn) return;

    try {
        const docRef = bookStore.doc(parsedIsbn.isbn13);
        const docSnapshot = await docRef.get();

        if (!docSnapshot.exists) {
            console.log(`Book not found ${parsedIsbn.isbn13}`)
            res.status(404)
                .send({error: `Could not find book ${parsedIsbn.isbn13}`});
            return;
        }

        console.log(`Fetched book ${parsedIsbn.isbn13}`, docSnapshot.data());

        const {title, author, pages, year, language, ...otherFields} = docSnapshot.data();
        const book = {isbn: parsedIsbn.isbn13, title, author, pages, year, language};

        res.status(200).send(book);
    } catch (e) {
        console.error(`Failed to fetch book ${parsedIsbn.isbn13}`, e);
        res.status(400)
            .send({error: `Impossible to fetch book ${parsedIsbn.isbn13}: ${e.message}`});
    }
});

Как всегда, мы проверяем, действителен ли ISBN. Мы делаем запрос в Firestore, чтобы получить книгу. Свойство snapshot.exists позволяет узнать, действительно ли была найдена книга. В противном случае мы отправляем обратно сообщение об ошибке и код состояния 404 Not Found. Мы извлекаем данные книги и создаем объект JSON, представляющий книгу, которая будет возвращена.

  • PUT /books/:isbn

Мы используем метод PUT для обновления существующей книги.

app.put('/books/:isbn', async (req, res) => {
    const parsedIsbn = isbnOK(req.params.isbn, res);
    if (!parsedIsbn) return;

    try {
        const docRef = bookStore.doc(parsedIsbn.isbn13);
        await docRef.set({
            ...req.body,
            updated: Firestore.Timestamp.now()
        }, {merge: true});
        console.log(`Updated book ${parsedIsbn.isbn13}`);

        res.status(201)
            .location(`/books/${parsedIsbn.isbn13}`)
            .send({status: `Book ${parsedIsbn.isbn13} updated`});
    } catch (e) {
        console.error(`Failed to update book ${parsedIsbn.isbn13}`, e);
        res.status(400)
            .send({error: `Impossible to update book ${parsedIsbn.isbn13}: ${e.message}`});
    }    
});

Мы обновляем updated поле даты и времени, чтобы запомнить, когда мы в последний раз обновляли эту запись. Мы используем стратегию {merge:true} , которая заменяет существующие поля их новыми значениями (в противном случае все поля будут удалены, и будут сохранены только новые поля в полезных данных, удаляя существующие поля из предыдущего обновления или первоначального создания).

Мы также установили заголовок Location , чтобы он указывал на URI книги.

  • DELETE /books/:isbn

Удалить книги довольно просто. Мы просто вызываем метод delete() по ссылке на документ. Мы возвращаем код состояния 204, поскольку не возвращаем никакого контента.

app.delete('/books/:isbn', async (req, res) => {
    const parsedIsbn = isbnOK(req.params.isbn, res);
    if (!parsedIsbn) return;

    try {
        const docRef = bookStore.doc(parsedIsbn.isbn13);
        await docRef.delete();
        console.log(`Book ${parsedIsbn.isbn13} was deleted`);

        res.status(204).end();
    } catch (e) {
        console.error(`Failed to delete book ${parsedIsbn.isbn13}`, e);
        res.status(400)
            .send({error: `Impossible to delete book ${parsedIsbn.isbn13}: ${e.message}`});
    }
});

Запустите сервер Express/Node.

И последнее, но не менее важное: мы запускаем сервер, по умолчанию прослушивая порт 8080 :

const port = process.env.PORT || 8080;
app.listen(port, () => {
    console.log(`Books Web API service: listening on port ${port}`);
    console.log(`Node ${process.version}`);
});

Запуск приложения локально

Чтобы запустить приложение локально, мы сначала установим зависимости с помощью:

$ npm install

И тогда мы можем начать с:

$ npm start

Сервер запустится на localhost и по умолчанию прослушивает порт 8080.

Также можно создать контейнер Docker и запустить образ контейнера с помощью следующих команд:

$ docker build -t crud-web-api .

$ docker run --rm -p 8080:8080 -it crud-web-api

Запуск в Docker также является отличным способом дважды проверить, что контейнеризация нашего приложения будет работать нормально, поскольку мы собираем его в облаке с помощью Cloud Build.

Тестирование API

Независимо от того, как мы запускаем код REST API (напрямую через Node или через образ контейнера Docker), теперь мы можем выполнить к нему несколько запросов.

  • Создайте новую книгу (ISBN в теле):
$ curl -XPOST -d '{"isbn":"9782070368228","title":"Book","author":"me","pages":123,"year":2021,"language":"French"}' \
    -H "Content-Type: application/json" \
    http://localhost:8080/books
  • Создайте новую книгу (ISBN в параметре пути):
$ curl -XPOST -d '{"title":"Book","author":"me","pages":123,"year":2021,"language":"French"}' \
    -H "Content-Type: application/json" \
    http://localhost:8080/books/9782070368228
  • Удалить книгу (ту, которую мы создали):
$ curl -XDELETE http://localhost:8080/books/9782070368228
  • Получить книгу по ISBN:
$ curl http://localhost:8080/books/9780140449136
$ curl http://localhost:8080/books/9782070360536
  • Обновите существующую книгу, изменив только ее название:
$ curl -XPUT \
       -d '{"title":"Book"}' \
       -H "Content-Type: application/json" \      
       http://localhost:8080/books/9780003701203
  • Получить список книг (первые 10):
$ curl http://localhost:8080/books
  • Найдите книги, написанные конкретным автором:
$ curl http://localhost:8080/books?author=Virginia+Woolf
  • Перечислите книги, написанные на английском языке:
$ curl http://localhost:8080/books?language=English
  • Загрузите 4-ю страницу книг:
$ curl http://localhost:8080/books?page=3

Мы также можем объединить параметры запроса author , language и books , чтобы уточнить наш поиск.

Создание и развертывание контейнерного REST API

Поскольку мы рады, что REST API работает согласно плану, сейчас подходящий момент для его развертывания в облаке, в Cloud Run!

Мы собираемся сделать это в два этапа:

  • Сначала создадим образ контейнера с помощью Cloud Build с помощью следующей команды:
$ gcloud builds submit \
         --tag gcr.io/${GOOGLE_CLOUD_PROJECT}/crud-web-api
  • Затем, развернув службу с помощью второй команды:
$ gcloud run deploy run-crud \
         --image gcr.io/${GOOGLE_CLOUD_PROJECT}/crud-web-api \
         --allow-unauthenticated \
         --region=${REGION} \
         --platform=managed

С помощью первой команды Cloud Build создает образ контейнера и размещает его в реестре контейнеров. Следующая команда развертывает образ контейнера из реестра и размещает его в облачном регионе.

Мы можем дважды проверить в пользовательском интерфейсе Cloud Console, что наша служба Cloud Run теперь отображается в списке:

f62fbca02a8127c0.png

Последний шаг, который мы здесь сделаем, — это получить URL-адрес только что развернутой службы Cloud Run с помощью следующей команды:

$ export RUN_CRUD_SERVICE_URL=$(gcloud run services describe run-crud \
                                       --region=${REGION} \
                                       --platform=managed \
                                       --format='value(status.url)')

В следующем разделе нам понадобится URL-адрес нашего REST API Cloud Run, поскольку наш внешний код App Engine будет взаимодействовать с API.

9. Разместите веб-приложение для просмотра библиотеки.

Последняя часть головоломки, которая придаст блеск этому проекту, — это предоставить веб-интерфейс, который будет взаимодействовать с нашим REST API. Для этой цели мы будем использовать Google App Engine с некоторым клиентским кодом JavaScript, который будет вызывать API через запросы AJAX (с использованием API Fetch на стороне клиента).

Наше приложение, хотя и развернуто в среде выполнения Node.JS App Engine, в основном состоит из статических ресурсов! Внутреннего кода не так много, так как большая часть взаимодействия с пользователем будет осуществляться в браузере через клиентский JavaScript. Мы не будем использовать какую-либо причудливую среду JavaScript для внешнего интерфейса, мы просто будем использовать немного «ванильного» Javascript с несколькими веб-компонентами для пользовательского интерфейса, использующими библиотеку веб-компонентов Shoelace :

  • поле выбора для выбора языка книги:

6fb9f741000a2dc1.png

  • компонент карты для отображения сведений о конкретной книге (включая штрих-код, представляющий ISBN книги, с использованием библиотеки JsBarcode ):

3aa21a9e16e3244e.png

  • и кнопка для загрузки дополнительных книг из базы данных:

3925ad81c91bbac9.png

При объединении всех этих визуальных компонентов конечная веб-страница для просмотра нашей библиотеки будет выглядеть следующим образом:

18a5117150977d6.png

Файл конфигурации app.yaml

Давайте начнем погружаться в кодовую базу этого приложения App Engine, рассмотрев его файл конфигурации app.yaml . Это файл, специфичный для App Engine, который позволяет настраивать такие вещи, как переменные среды, различные «обработчики» приложения или указывать, что некоторые ресурсы являются статическими активами, которые будут обслуживаться встроенными средствами App Engine. CDN.

runtime: nodejs14

env_variables:
  RUN_CRUD_SERVICE_URL: CHANGE_ME

handlers:

- url: /js
  static_dir: public/js

- url: /css
  static_dir: public/css

- url: /img
  static_dir: public/img

- url: /(.+\.html)
  static_files: public/html/\1
  upload: public/(.+\.html)

- url: /
  static_files: public/html/index.html
  upload: public/html/index\.html

- url: /.*
  secure: always
  script: auto

Мы указываем, что наше приложение является Node.JS и что мы хотим использовать версию 14.

Затем мы определяем переменную среды, которая указывает на URL-адрес нашей службы Cloud Run. Нам нужно будет обновить заполнитель CHANGE_ME, указав правильный URL-адрес (о том, как это изменить, см. ниже).

После этого мы определяем различные обработчики. Первые три указывают на расположение клиентского кода HTML, CSS и JavaScript в папке public/ и ее подпапках. Четвертый указывает, что корневой URL-адрес нашего приложения App Engine должен указывать на страницу index.html . Таким образом, мы не увидим суффикс index.html в URL-адресе при доступе к корню веб-сайта. И последний — это URL-адрес по умолчанию, который будет перенаправлять все остальные URL-адреса ( /.* ) в наше приложение Node.JS (т. е. «динамическую» часть приложения, в отличие от статических ресурсов, которые мы описали).

Давайте сейчас обновим URL-адрес веб-API службы Cloud Run.

В каталоге appengine-frontend/ выполните следующую команду, чтобы обновить переменную среды, указывающую на URL-адрес нашего REST API на основе Cloud Run:

$ sed -i -e "s|CHANGE_ME|${RUN_CRUD_SERVICE_URL}|" app.yaml

Или вручную измените строку CHANGE_ME в app.yaml , указав правильный URL-адрес:

env_variables:
    RUN_CRUD_SERVICE_URL: CHANGE_ME

Файл Node.JS package.json

{
    "name": "appengine-frontend",
    "description": "Web frontend",
    "license": "Apache-2.0",
    "main": "index.js",
    "engines": {
        "node": "^14.0.0"
    },
    "dependencies": {
        "express": "^4.17.1",
        "isbn3": "^1.1.10"
    },
    "devDependencies": {
        "nodemon": "^2.0.7"
    },
    "scripts": {
        "start": "node index.js",
        "dev": "nodemon --watch server --inspect index.js"
    }
}

Еще раз подчеркнем, что мы хотим запустить это приложение с использованием Node.JS 14. Мы зависим от платформы Express, а также от модуля isbn3 NPM для проверки кодов ISBN книг.

В зависимостях разработки мы собираемся использовать модуль nodemon для отслеживания изменений файлов. Хотя мы можем запустить наше приложение локально с помощью npm start , внести некоторые изменения в код, остановить приложение с помощью ^C , а затем перезапустить его, это немного утомительно. Вместо этого мы можем использовать следующую команду, чтобы приложение автоматически перезагружалось/перезапускалось при изменениях:

$ npm run dev

Код index.js Node.JS

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

app.use(express.static('public'));

const bodyParser = require('body-parser');
app.use(bodyParser.json());

Нам требуется веб-фреймворк Express. Мы указываем, что общедоступный каталог содержит статические ресурсы, которые могут обслуживаться (по крайней мере, при локальном запуске в режиме разработки) static промежуточным программным обеспечением. Наконец, нам нужен body-parser для анализа наших полезных данных JSON.

Давайте посмотрим на пару маршрутов, которые мы определили:

app.get('/', async (req, res) => {
    res.redirect('/html/index.html');
});

app.get('/webapi', async (req, res) => {
    res.send(process.env.RUN_CRUD_SERVICE_URL);
});

Первый из них, соответствующий / будет перенаправлен на index.html в нашем каталоге public/html . Поскольку в режиме разработки мы не работаем в среде выполнения App Engine, маршрутизация URL-адресов App Engine не выполняется. Вместо этого мы просто перенаправляем корневой URL-адрес в HTML-файл.

Вторая конечная точка, которую мы определяем /webapi будет возвращать URL-адрес нашего Cloud RUN REST API. Таким образом, клиентский код JavaScript будет знать, куда обратиться, чтобы получить список книг.

const port = process.env.PORT || 8080;
app.listen(port, () => {
    console.log(`Book library web frontend: listening on port ${port}`);
    console.log(`Node ${process.version}`);
    console.log(`Web API endpoint ${process.env.RUN_CRUD_SERVICE_URL}`);
});

В завершение мы запускаем веб-приложение Express и по умолчанию прослушиваем порт 8080.

Страница index.html

Мы не будем рассматривать каждую строку этой длинной HTML-страницы. Вместо этого давайте выделим некоторые ключевые моменты.

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.0.0-beta.37/dist/themes/base.css">
<script type="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.0.0-beta.37/dist/shoelace.js"></script>

<script src="https://cdn.jsdelivr.net/npm/jsbarcode@3.11.0/dist/barcodes/JsBarcode.ean-upc.min.js"></script>

<script src="/js/app.js"></script>
<link rel="stylesheet" type="text/css" href="/css/style.css">

Первые две строки импортируют библиотеку веб-компонентов Shoelace (скрипт и таблицу стилей).

Следующая строка импортирует библиотеку JsBarcode для создания штрих-кодов ISBN-кодов книг.

Последние строки импортируют наш собственный код JavaScript и таблицу стилей CSS, которые расположены в наших подкаталогах public/ .

В body HTML-страницы мы используем компоненты Shoelace с их тегами пользовательских элементов, например:

<sl-icon name="book-half"></sl-icon>
...

<sl-select id="language-select" placeholder="Select a language..." clearable>
    <sl-menu-item value="English">English</sl-menu-item>
    <sl-menu-item value="French">French</sl-menu-item>
    ...
</sl-select>
...

<sl-button id="more-button" type="primary" size="large">
    More books...
</sl-button>
...

И мы также используем HTML-шаблоны и их возможность заполнения слотов для представления книги. Мы создадим копии этого шаблона для заполнения списка книг и заменим значения в слотах сведениями о книгах:

    <template id="book-card">
        <sl-card class="card-overview">
        ...
            <slot name="author">Author</slot>
            ... 
        </sl-card>
    </template>

Хватит HTML, мы почти закончили проверку кода. Осталась последняя важная часть: клиентский код JavaScript app.js , который взаимодействует с нашим REST API.

Клиентский код JavaScript app.js

Начнем с прослушивателя событий верхнего уровня, который ожидает загрузки содержимого DOM:

document.addEventListener("DOMContentLoaded", async function(event) {
    ...
}

Когда все будет готово, мы можем настроить некоторые ключевые константы и переменные:

    const serverUrlResponse = await fetch('/webapi');
    const serverUrl = await serverUrlResponse.text();
    console.log('Web API endpoint:', serverUrl);
    
    const server = serverUrl + '/books';
    var page = 0;
    var language = '';

Сначала мы получим URL-адрес нашего REST API благодаря нашему коду узла App Engine, который возвращает переменную среды, которую мы изначально установили в app.yaml . Благодаря переменной среды, конечной точке /webapi , вызываемой из клиентского кода JavaScript, нам не пришлось жестко запрограммировать URL-адрес REST API в коде внешнего интерфейса.

Мы также определяем переменные page и language , которые будем использовать для отслеживания нумерации страниц и языковой фильтрации.

    const moreButton = document.getElementById('more-button');
    moreButton.addEventListener('sl-focus', event => {
        console.log('Button clicked');
        moreButton.blur();

        appendMoreBooks(server, page++, language);
    });

Добавляем обработчик событий на кнопку загрузки книг. При нажатии на нее будет вызвана функция appendMoreBooks() .

    const langSelect = document.getElementById('language-select');
    langSelect.addEventListener('sl-change', event => {
        page = 0;
        language = event.srcElement.value;
        document.getElementById('library').replaceChildren();
        console.log(`Language selected: "${language}"`);

        appendMoreBooks(server, page++, language);
    });

То же самое и с полем выбора: мы добавляем обработчик событий для уведомления об изменениях в выборе языка. Как и в случае с кнопкой, мы также вызываем функцию appendMoreBooks() , передавая URL-адрес REST API, текущую страницу и выбранный язык.

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

async function appendMoreBooks(server, page, language) {
    const searchUrl = new URL(server);
    if (!!page) searchUrl.searchParams.append('page', page);
    if (!!language) searchUrl.searchParams.append('language', language);
        
    const response = await fetch(searchUrl.href);
    const books = await response.json();
    ... 
}

Выше мы создаем точный URL-адрес для вызова REST API. Обычно мы можем указать три параметра запроса, но здесь, в этом пользовательском интерфейсе, мы указываем только два:

  • page — целое число, указывающее текущую страницу для нумерации книг,
  • language — языковая строка для фильтрации по письменному языку.

Затем мы используем Fetch API для получения массива JSON, содержащего сведения о нашей книге.

    const linkHeader = response.headers.get('Link')
    console.log('Link', linkHeader);
    if (!!linkHeader && linkHeader.indexOf('rel="next"') > -1) {
        console.log('Show more button');
        document.getElementById('buttons').style.display = 'block';
    } else {
        console.log('Hide more button');
        document.getElementById('buttons').style.display = 'none';
    }

В зависимости от того, присутствует ли в ответе заголовок Link , мы покажем или скроем кнопку [More books...] , поскольку заголовок Link представляет собой подсказку, сообщающую нам, есть ли еще книги для загрузки (будет next URL в заголовке Link ).

    const library = document.getElementById('library');
    const template = document.getElementById('book-card');
    for (let book of books) {
        const bookCard = template.content.cloneNode(true);

        bookCard.querySelector('slot[name=title]').innerText = book.title;
        bookCard.querySelector('slot[name=language]').innerText = book.language;
        bookCard.querySelector('slot[name=author]').innerText = book.author;
        bookCard.querySelector('slot[name=year]').innerText = book.year;
        bookCard.querySelector('slot[name=pages]').innerText = book.pages;
        
        const img = document.createElement('img');
        img.setAttribute('id', book.isbn);
        img.setAttribute('class', 'img-barcode-' + book.isbn)
        bookCard.querySelector('slot[name=barcode]').appendChild(img);

        library.appendChild(bookCard);
        ... 
    }
}

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

JsBarcode('.img-barcode-' + book.isbn).EAN13(book.isbn, {fontSize: 18, textMargin: 0, height: 60}).render();

Чтобы сделать код ISBN немного красивее, мы используем библиотеку JsBarcode для создания красивого штрих-кода, как на задней обложке настоящей книги!

Запуск и тестирование приложения локально

На данный момент достаточно кода, пришло время увидеть приложение в действии. Сначала мы сделаем это локально, в Cloud Shell, прежде чем приступить к реальному развертыванию.

Мы устанавливаем модули NPM, необходимые нашему приложению, с помощью:

$ npm install

И мы либо запускаем приложение обычным способом:

$ npm start

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

$ npm run dev

Приложение работает локально, и мы можем получить к нему доступ из браузера по адресу http://localhost:8080 .

Развертывание приложения App Engine

Теперь, когда мы уверены, что наше приложение работает нормально локально, пришло время развернуть его в App Engine.

Чтобы развернуть приложение, запустим следующую команду:

$ gcloud app deploy -q

Примерно через минуту приложение должно быть развернуто.

Приложение будет доступно по URL-адресу вида: https://${GOOGLE_CLOUD_PROJECT}.appspot.com .

Изучение пользовательского интерфейса нашего веб-приложения App Engine

Теперь вы можете:

  • Нажмите кнопку [More books...] чтобы загрузить больше книг.
  • Выберите определенный язык, чтобы видеть книги только на этом языке.
  • Вы можете отменить выбор с помощью маленького крестика в поле выбора, чтобы вернуться к списку всех книг.

10. Очистка (по желанию)

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

gcloud projects delete ${GOOGLE_CLOUD_PROJECT} 

11. Поздравляем!

Благодаря Cloud Functions, App Engine и Cloud Run мы создали набор сервисов для предоставления доступа к различным конечным точкам веб-API и веб-интерфейсу, а также для хранения, обновления и просмотра библиотеки книг, следуя некоторым хорошим шаблонам проектирования для разработки REST API. путь.

Что мы рассмотрели

  • Облачные функции
  • Облачный пожарный магазин
  • Облачный бег
  • Механизм приложений

Идем дальше

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

  • Воспользуйтесь преимуществами API Gateway , чтобы предоставить общий фасад API для функции импорта данных и контейнера REST API, чтобы добавить такие функции, как обработка ключей API для доступа к API, или определить ограничения скорости для потребителей API.
  • Разверните модуль узла Swagger-UI в приложении App Engine, чтобы документировать и предлагать тестовую площадку для REST API.
  • Во внешнем интерфейсе, помимо существующих возможностей просмотра, добавьте дополнительные экраны для редактирования данных, создания новых записей в книгах. Кроме того, поскольку мы используем базу данных Cloud Firestore, используйте ее функцию в реальном времени для обновления данных книги, отображаемых по мере внесения изменений.