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.
Вот как будет выглядеть веб-интерфейс по завершении этого практического занятия:

Что вы узнаете
- Облачные функции
- Облачный Firestore
- Cloud Run
- App Engine
2. Настройка и требования
Настройка среды для самостоятельного обучения
- Войдите в консоль Google Cloud и создайте новый проект или используйте существующий. Если у вас еще нет учетной записи Gmail или Google Workspace, вам необходимо ее создать .



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

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

Эта виртуальная машина содержит все необходимые инструменты разработки. Она предоставляет постоянный домашний каталог объемом 5 ГБ и работает в облаке Google, что значительно повышает производительность сети и аутентификацию. Вся работа в этом практическом задании может выполняться в браузере. Вам не нужно ничего устанавливать.
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
Эти 3 указателя соответствуют поискам, которые мы будем проводить по автору или языку, сохраняя при этом порядок в коллекции с помощью обновляемого поля.
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. Функция-конечная точка для импорта данных образцов книг.
В первом разделе мы реализуем конечную точку, которая будет использоваться для импорта данных образцов книг. Для этой цели мы будем использовать Cloud Functions.
Изучите код
Начнём с изучения файла 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 используется платформа Functions Framework, которая предоставляет команду для локального запуска функции с помощью следующей инструкции:
$ 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 указывающий на сбой. В противном случае мы можем вернуть ответ OK с кодом состояния 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:

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

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

Вы также можете получить 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содержащая название книги. -
year—Integerзначение, указывающее год публикации книги.
Пример содержимого книги:
{
"isbn": "9780435272463",
"author": "Chinua Achebe",
"language": "English",
"pages": 209,
"title": "Things Fall Apart",
"year": 1958
}
GET /books
Получите список всех книг, при необходимости отфильтрованный по автору и/или языку, с постраничной навигацией по окнам с 10 результатами за раз.
Грузоподъемность корпуса: отсутствует.
Параметры запроса:
-
author(необязательно) — фильтрует список книг по автору. -
language(необязательно) — фильтрует список книг по языку. -
page(необязательно, по умолчанию = 0) — указывает ранг страницы результатов, которые необходимо вернуть.
Возвращает: JSON-массив объектов книг.
Коды состояния:
-
200— когда запрос успешно получает список книг, -
400— в случае ошибки.
POST /books и POST /books/{isbn}
Отправьте новый файл с данными о книге, либо с параметром пути isbn (в этом случае код isbn в файле с данными о книге не требуется), либо без него (в этом случае код isbn должен присутствовать в файле с данными о книге).
Груз на теле: объект в виде книги.
Параметры запроса: отсутствуют.
Возвращает: ничего.
Коды состояния:
-
201— когда книга успешно сохранена, -
406— если кодisbnнедействителен, -
400— в случае ошибки.
GET /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
Начнём с рассмотрения 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 "slim" . Работаем в каталоге /usr/src/app . Копируем файл package.json (подробности ниже), который, помимо прочего, определяет наши зависимости. Устанавливаем зависимости с помощью npm install , копируя исходный код. Наконец, указываем, как должно запускаться приложение, с помощью команды node index.js .
package.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'],
}));
Для реализации REST API мы используем Express в качестве веб-фреймворка. Для анализа JSON-данных, передаваемых через наш API, мы используем модуль body-parser .
Модуль 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 , а в этом разделе мы отсортируем список книг по дате последнего обновления (последнее обновление идет первым). Также мы добавим пагинацию, задав параметр limit (количество возвращаемых элементов) и offset (начальная точка, с которой будет возвращаться следующая группа книг).
Мы выполняем запрос, получаем снимок данных и помещаем эти результаты в массив 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 /booksandPOST /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 в случае неудачи.
При успешном возврате мы также устанавливаем заголовок Location, чтобы указать клиенту API, где находится вновь созданный ресурс. Заголовок будет выглядеть следующим образом:
Location: /books/9781234567898
GET /books/:isbn
Давайте найдем в Firestore книгу, идентифицированную по ISBN.
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 теперь отображается в списке:

Последний шаг, который мы здесь предпримем, — это получение 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-запросы (используя клиентский Fetch API).
Наше приложение, хотя и развернуто на среде выполнения Node.JS App Engine, в основном состоит из статических ресурсов! Бэкенд-кода немного, так как большая часть взаимодействия с пользователем будет происходить в браузере через клиентский JavaScript. Мы не будем использовать какие-либо сложные фронтенд-фреймворки на JavaScript, а просто будем использовать "чистый" JavaScript с несколькими веб-компонентами для пользовательского интерфейса, используя библиотеку веб-компонентов Shoelace :
- Поле выбора языка книги:

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

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

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

Конфигурационный файл app.yaml
Давайте начнём с изучения кода этого приложения App Engine, рассмотрев его конфигурационный файл app.yaml . Этот файл является специфическим для App Engine и позволяет настраивать такие параметры, как переменные среды, различные «обработчики» приложения или указывать, что некоторые ресурсы являются статическими и будут обслуживаться встроенной CDN App Engine.
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 ( /.* ) в наше приложение 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
Файл package.json Node.JS
{
"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, а также от модуля NPM isbn3 для проверки ISBN-кодов книг.
В зависимостях для разработки мы будем использовать модуль nodemon для отслеживания изменений файлов. Хотя мы можем запустить наше приложение локально с помощью npm start , внести некоторые изменения в код, остановить приложение с помощью ^C , а затем перезапустить его, это довольно утомительно. Вместо этого мы можем использовать следующую команду, чтобы приложение автоматически перезагружалось/перезапускалось при внесении изменений:
$ npm run dev
Код Node.js в файле index.js
const express = require('express');
const app = express();
app.use(express.static('public'));
const bodyParser = require('body-parser');
app.use(bodyParser.json());
Нам требуется веб-фреймворк Express. Мы указываем, что каталог public содержит статические ресурсы, которые могут быть доступны (по крайней мере, при локальном запуске в режиме разработки) с помощью 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 нашего REST API Cloud RUN. Таким образом, клиентский 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 на Node.js, который возвращает переменную окружения, установленную нами изначально в 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— строка с указанием языка для фильтрации по письменному языку.
Затем мы используем API Fetch для получения 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 указывает, есть ли ещё книги для загрузки (в заголовке Link будет указан next URL).
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
Примерно через минуту приложение должно быть развернуто.
Приложение будет доступно по адресу: https://${GOOGLE_CLOUD_PROJECT}.appspot.com .
Изучение пользовательского интерфейса нашего веб-приложения на платформе App Engine.
Теперь вы можете:
- Нажмите кнопку
[More books...], чтобы загрузить больше книг. - Выберите определенный язык, чтобы просмотреть книги только на этом языке.
- Вы можете очистить выделение, нажав на маленький крестик в поле выбора, чтобы вернуться к списку всех книг.
10. Уборка (необязательно)
Если вы не планируете сохранять приложение, вы можете освободить ресурсы, чтобы сэкономить средства и в целом ответственно относиться к облачным сервисам, удалив весь проект:
gcloud projects delete ${GOOGLE_CLOUD_PROJECT}
11. Поздравляем!
Благодаря Cloud Functions, App Engine и Cloud Run мы создали набор сервисов для предоставления доступа к различным конечным точкам Web API и веб-интерфейсу, позволяющим хранить, обновлять и просматривать библиотеку книг, при этом следуя некоторым хорошим шаблонам проектирования для разработки REST API.
Что мы рассмотрели
- Облачные функции
- Облачный Firestore
- Cloud Run
- App Engine
Идем дальше
Если вы хотите подробнее изучить этот конкретный пример и расширить его, вот список вещей, которые вы, возможно, захотите исследовать:
- Воспользуйтесь API Gateway , чтобы предоставить единый API-интерфейс для функции импорта данных и контейнера REST API, добавить такие функции, как обработка ключей API для доступа к API, или определить ограничения скорости для потребителей API.
- Разверните модуль Swagger-UI в приложении App Engine, чтобы задокументировать REST API и предоставить тестовую площадку для его работы.
- На стороне клиента, помимо существующих возможностей просмотра, добавьте дополнительные экраны для редактирования данных и создания новых записей о книгах. Кроме того, поскольку мы используем базу данных Cloud Firestore, воспользуйтесь ее функцией обновления данных о книгах в режиме реального времени по мере внесения изменений.