По мере развития электронной коммерции уровень обслуживания, ожидаемый от потребителей, приводит к необходимости создания более реактивного опыта. Чтобы соответствовать этим ожиданиям, вам понадобится сайт, который показывает актуальную информацию, с надежным отображением наличия последних товаров и скидок, предоставляемых без трения. Для создания этого сайта мы будем использовать Fauna и Ably.
Мы создадим простой сайт со следующими функциями:
- Возможность просмотреть все имеющиеся товары
- Каждый товар может быть просмотрен более подробно, с возможностью просмотра описания, цены и т.д.
- Любые изменения в этих деталях будут отражаться для клиента в режиме реального времени.
- Товары могут быть куплены. Каждая покупка может быть выполнена, например, проверка имеющихся запасов, чтобы убедиться, что пользователи не покупают товары, отсутствующие на складе.
- возможность для покупателя просматривать свои предыдущие заказы.
- Почему именно Fauna?
- Почему Ably?
- Почему Ably + Fauna?
- Создание нашего сайта электронной коммерции
- Создание учетной записи Fauna
- Настройка учетной записи Ably
- Создание сервера
- Создание главной страницы
- Добавление Ably на веб-страницу
- Аутентификация с помощью Ably
- Получение начального состояния продукта из Fauna
- Обновление продуктов в реальном времени
- Отображение сведений о продукте в браузере
- Покупка товаров
- Ссылка на клиента для зарегистрированных пользователей
- Просмотр заказов
- Функциональность просмотра клиентских заказов
- Функциональность заказа от Fauna до Ably
- Заключение
Почему именно Fauna?
Fauna — это распределенная документально-реляционная база данных, предоставляемая в виде глобального API. Помимо того, что она предоставляет значительный бесплатный уровень, ее невероятно легко запустить. Она хорошо масштабируется, в том числе благодаря тому, что является бессерверной, поэтому по мере расширения проектов она помогает сохранить простоту. Fauna обеспечивает глобально распределенные ACID-транзакции по умолчанию и документально-реляционную модель данных, что позволяет использовать меняющиеся шаблоны доступа и динамичные приложения.
Благодаря возможности работы с реляционными данными, запросами и потоковой передачей событий, она идеально подходит для размещения наших продуктов электронной коммерции, клиентов и заказов.
Почему Ably?
Ably — это бессерверное решение pub/sub, использующее протоколы реального времени, такие как WebSockets, для обеспечения бесперебойной связи между устройствами. Уделяя большое внимание надежности, доставке сообщений точно в одно время и согласованности заказа сообщений, Ably отлично сочетается с Fauna.
Почему Ably + Fauna?
И Ably, и Fauna — это бессерверные решения, избавляющие от сложностей хостинга и управления собственными решениями для обмена сообщениями и базами данных соответственно. Оба решения разработаны с учетом масштабируемости. Fauna обеспечивает масштабируемый и быстрый запрос и хранение данных, а Ably идеально подходит для взаимодействия и распространения событий среди клиентов. Потоковая передача событий Fauna позволяет передавать события в реальном времени в Ably для распространения, обеспечивая минимальные задержки в обновлениях, используя сильные стороны обоих сервисов.
Создание нашего сайта электронной коммерции
Первым шагом является создание учетных записей Fauna и Ably.
Создание учетной записи Fauna
Во-первых, вам понадобится учетная запись Fauna для создания новой базы данных. Наполните ее демо-данными, отметив кнопку «Использовать демо-данные» во время этого процесса. Также убедитесь, что регион — ‘Classic (C)’.
Если вы хотите, чтобы база данных находилась в определенном регионе, вам нужно указать этот регион в файле .env
в основании этой директории. Это будет либо eu
, либо us
в качестве значения FAUNA_REGION
.
Как только у вас есть база данных, создайте для нее API-ключ с правами администратора, перейдя на вкладку ‘Security’ в боковой панели и выбрав ‘+ New key’ в новой вкладке.
Сохраните этот ключ где-нибудь, так как мы будем использовать его позже для взаимодействия с Fauna.
Наконец, нам нужно создать новый индекс в Fauna, который мы можем использовать, чтобы гарантировать, что мы не получим клиентов с дублирующимися именами пользователей. На вкладке Shell базы данных Fauna выполните следующий запрос:
CreateIndex({
name: "users_by_username",
permissions: { read: "public"},
source: Collection("users"),
terms: [{field: ["data", "username"]}],
unique: true,
})
Настройка учетной записи Ably
Далее нам нужно настроить аккаунт Ably. Зарегистрируйте учетную запись Ably, если у вас ее нет, затем перейдите в приложение Ably, которое вы собираетесь использовать для этой цели.
В приложении перейдите на вкладку API-ключ и скопируйте значение API-ключа по умолчанию. Сохраните этот API-ключ на потом.
Наконец, нам нужно создать некоторые правила канала, которые позволят сохранять последнее сообщение на канале в течение года. Это полезно для обеспечения постоянного доступа к данным для наших клиентов. В приложении Ably App перейдите на вкладку «Настройки» и раздел «Правила канала». Выберите «Добавить новое правило», установите «Пространство имен» как «app» и отметьте «Сохранять последнее сообщение». Наконец, нажмите ‘Создать правило канала’.
Создание сервера
После создания учетных записей Fauna и Ably нам нужно создать сервер, который мы будем использовать для размещения нашего сайта, обработки аутентификации в Ably и в качестве средства потребления из Fauna для повторного распространения в Ably.
Для этого мы будем использовать Node.js, поэтому убедитесь, что он установлен на вашей системе.
Создайте новую папку и запустите npm init
, чтобы создать новый проект Node.js. Вы можете использовать все значения по умолчанию, кроме установки точки входа как server.js
.
Создав проект, давайте установим модули npm, которые мы будем использовать.
npm install ably faunadb express cookie-parser
Пакеты Ably и Fauna позволят нам взаимодействовать с этими системами соответственно, express.js позволит нам легко разместить сервер, а cookie-parser облегчит нашему серверу проверку cookies нашего клиента.
Теперь создайте файл под названием server.js
. Внутри него добавьте следующее:
/* Start the Express.js web server */
const express = require('express'),
app = express(),
cookieParser = require('cookie-parser');
app.use(cookieParser());
app.use(express.static(__dirname + '/public'));
app.listen(process.env.PORT || 3000);
Здесь мы создали наш сервер express.js, который по умолчанию должен работать на порту 3000. Мы объявили, что через этот порт будет доступна папка под названием public
. Если вы запустите npm run start
в основании вашего проекта, вы должны иметь возможность попытаться получить доступ к серверу. Поскольку в настоящее время у нас нет ничего в папке public
для возврата, он ничего не получит. Давайте исправим это, создав скелет для нашей главной страницы.
Создание главной страницы
На этой странице пользователи будут просматривать товары и покупать их.
Создайте новую папку public
и добавьте в нее index.html
. Добавьте в него следующий код:
<html>
<head>
<link href='https://fonts.googleapis.com/css?family=Actor' rel='stylesheet'>
<link rel="stylesheet" type="text/css" href="/css/index.css">
</head>
<body>
<header>
<a class="home" href="/">
<h1>💸 Buy Things Inc</h1>
</a>
<nav>
<a href="/orders" class="orders">Your orders</a>
<form class="login-form" action="/login" id="panel-anonymous">
<input type="text" name="username" placeholder="Enter your username" class="login-text">
<input type="submit" value="Login" class="login-submit">
</form>
<div id="panel-logged-in">
You are logged in. <a href="/logout">Log out</a>
</div>
</nav>
</header>
<ul id="products" class="items"></ul>
<main class="item">
<div id="loader" class="loader" style="display: none"></div>
<p id="intro-section">Select a product from the sidebar to see details</p>
<div id="item-details" class="item-details" style="display: none">
<img id="item-image" src="http://placekitten.com/600/400" class="item-img">
<div class="item-info">
<h2 id="title" class="item-title"></h2>
<h3 id="description" class="item-description"></h3>
<p class="item-price">£<span id="price"></span></p>
<button onclick="order()" id="buy-button" class="buy">Buy now</button>
<p class="item-stock">Stock: <span id="quantity"></span></p>
<p class="item-stock" id="login-button-hint">Login to add items to your cart</p>
</div>
</div>
</main>
<footer>
<p>Made with <a href="https://www.ably.com">Ably</a> and <a href="https://www.fauna.com">Fauna</a></p>
</footer>
</body>
</html>
Эта страница будет безвкусной, поэтому давайте создадим для нее css-файл. В папку public
добавьте еще одну папку с названием css
, а в нее добавьте index.css
. В этот файл добавьте содержимое этого css-файла с GitHub.
После этого у нас есть базовый сайт. Однако на данном этапе нам не хватает продуктов для отображения. В нашей базе данных Fauna есть все продукты, созданные для нас автоматически как часть тестовых данных, с которыми мы ее инстанцировали, поэтому нам нужно предоставить эти данные нашим клиентам.
Добавление Ably на веб-страницу
Для этого мы настроим клиентов на использование Ably и потребление данных из каналов Ably. Нам нужно, чтобы клиенты знали, какие продукты существуют, и конкретные детали продукта, когда клиент пытается просмотреть продукт.
Для этого мы можем иметь один основной канал products
, который будет содержать самый последний список идентификаторов и названий продуктов, а затем подканалы по схеме product:1
, product:2
и т.д. Это означает, что любой клиент должен быть подписан только на два канала в любое время, общий список продуктов и текущий просматриваемый продукт.
Поскольку мы настроили Ably так, чтобы последнее сообщение в канале сохранялось в пространстве имен app
, мы хотим, чтобы все используемые нами каналы существовали в этом пространстве имен. Это означает, что нам просто нужно добавить ко всем именам каналов app
, так что app:products
и app:product:1
.
Чтобы использовать Ably, нам нужно включить библиотеку Ably на странице. В head добавьте следующую строку:
<script src="https://cdn.ably.io/lib/ably.min-1.js" type="text/javascript"></script>
Затем добавьте следующий код чуть ниже тела:
<script type="text/javascript">
let productId;
// Connect to Ably
const realtime = new Ably.Realtime({ authUrl: '/auth' });
// Instantiate the products channel, specifying rewind=1 so that we will as part of our subscription get the last state on the channel
const productsChannel = realtime.channels.get('app:products', { params: { rewind: '1' } });
let products;
productsChannel.subscribe((msg) => {
const productsContainer = document.getElementById('products');
productsContainer.innerHTML = '';
products = msg.data;
for (let productID in products) {
let activeText = '';
if (productID == productId) activeText = 'active';
let item = document.createElement("li");
item.innerHTML = `<span id="products-${productID}" class="${activeText}" onclick="loadProduct('${productID}')">${products[productID]}</span>`;
productsContainer.appendChild(item);
}
});
let loggedIn = document.cookie.indexOf('username') >= 0;
document.getElementById('panel-anonymous').setAttribute('style', "display: " + (loggedIn ? 'none' : 'flex'));
document.getElementById('panel-logged-in').setAttribute('style', "display: " + (loggedIn ? 'inline' : 'none'));
document.getElementById('buy-button').setAttribute('style', "display: " + (loggedIn ? 'flex' : 'none'));
document.getElementById('login-button-hint').setAttribute('style', "display: " + (loggedIn ? 'none' : 'block'));
</script>
Здесь мы инстанцируем Ably, указывая, что собираемся аутентифицироваться по url (подробнее об этом чуть позже). Затем мы объявляем новый объект Ably Channel, который будет подключаться к каналу app:products
. Мы также указываем в его параметрах, что он должен использовать rewind=1
, функцию Ably, которая позволяет последнему сообщению на канале отправляться в подписку без каких-либо новых сообщений и обновлений, которые могут прийти.
Объявив канал, мы подписываемся на все новые сообщения, которые будут на нем опубликованы. Мы берем эти сообщения и извлекаем данные, которые хотим отобразить на боковой панели.
Аутентификация с помощью Ably
Если бы вы загрузили страницу на этом этапе, то увидели бы ошибку Ably: Auth.requestToken(): вызов подписи запроса токена вернул ошибку
. Это происходит потому, что мы еще не реализовали метод аутентификации для Ably.
Для Ably доступны два основных метода аутентификации: API-ключи и токены. API-ключи — это ключи, которые вы можете найти на странице вашего приложения на сайте Ably.
Они будут существовать до тех пор, пока вы не решите их удалить, и полезны для доверенных устройств, таких как сервер для доступа к Ably. Они не очень подходят для недоверенных клиентов, таких как покупатели; однако по этой причине нет возможности контролировать, кто их использует.
Для клиентов, которым не доверяют, идеально подходят токены. Они недолговечны, могут быть легко отозваны, и, как правило, служба генерирует их выборочно с ограниченными правами.
Здесь, для браузера клиента, мы будем использовать токены. В объявлении Ably вы увидите, что мы использовали authUrl: '/auth'
. Это означает, что клиентская библиотека будет пытаться получить токен по этому url (в нашем случае localhost:3000/auth). Поскольку мы еще не определили конечную точку auth для нашего сервера, он возвращается с пустыми руками.
Давайте создадим эту конечную точку. Во-первых, нам нужно определить ключ Ably API для нашего сервера, который будет использоваться для генерации токенов. Создайте файл .env
в базе проекта и добавьте ABLY_API_KEY=ваш api ключ
, заменив бит ‘ваш api ключ’ на API ключ, который вы получили в разделе Setup Ably Account
.
Далее, давайте начнем использовать Ably на сервере. Добавьте следующее в начало файла server.js
:
require('dotenv').config();
const Ably = require("ably");
const realtime = new Ably.Realtime(process.env.ABLY_API_KEY);
Когда Ably объявлен, давайте используем его для создания конечной точки auth, которая будет возвращать токен. Мы хотим, чтобы было два состояния пользователей: зарегистрированные пользователи, которые могут делать заказы, и анонимные пользователи, которые хотят просто просматривать сайт. Мы можем создать разные токены, чтобы предоставлять только необходимые разрешения для обоих случаев. Добавьте следующее в нижнюю часть файла server.js
:
app.get('/auth', function (req, res) {
var tokenParams;
if (req.cookies.username) {
tokenParams = {
'capability': {
'app:product:*': ['subscribe'],
'app:products': ['subscribe'],
'app:submit_order': ['publish'],
},
'clientId': req.cookies.username
};
} else {
/* Issue a token request with only subscribe privileges for products */
tokenParams = {
'capability': { 'app:product:*': ['subscribe'], 'app:products': ['subscribe'] }
};
}
console.log("Sending signed token request:", JSON.stringify(tokenParams));
realtime.auth.createTokenRequest(tokenParams, function(err, tokenRequest) {
if (err) {
res.status(500).send('Error requesting token: ' + JSON.stringify(err));
} else {
res.setHeader('Content-Type', 'application/json');
res.send(JSON.stringify(tokenRequest));
}
});
});
Здесь мы просто используем имя пользователя клиента, хранящееся в файле cookie, для его идентификации. Это ни в коем случае не безопасный способ аутентификации пользователя, но для данного руководства этого достаточно. Если бы вы когда-нибудь создавали подобный проект в реальности, вы бы захотели реализовать надежную систему аутентификации для подтверждения личности пользователя.
Теперь, если вы запустите все снова с помощью npm run start
, вы должны быть в состоянии загрузить localhost:3000
и не увидеть никаких ошибок в консоли! Однако наша боковая панель все еще пуста, поскольку мы еще не отправили никаких данных в Ably.
Мы можем отправить некоторые данные через curl, чтобы проверить, что все работает. В терминале отправьте следующее сообщение, заменив API_KEY
на ваш ключ API Ably:
curl -X POST https://rest.ably.io/channels/app%3Aproducts/messages -u "API_KEY" -H "Content-Type: application/json" --data '{ "name": "products", "data": {"101": "Pineapple" } }'
Вы должны увидеть, как это мгновенно отразится в боковой панели с появлением продукта ‘Pineapple’.
Теперь давайте позволим пользователям входить и выходить из системы, чтобы они могли получить либо токен анонимного пользователя, либо токен вошедшего пользователя. Добавьте следующие функции в файл server.js
:
app.get('/login', async function (req, res) {
if (req.query['username']) {
res.cookie('username', req.query['username']);
res.redirect('back');
} else {
res.status(500).send('Username is required to login');
}
});
/* Clear the cookie when the user logs outs */
app.get('/logout', function (req, res) {
res.clearCookie('username');
res.redirect('/');
});
Если вы снова запустите сервер и попытаетесь войти в систему в правом верхнем углу с именем пользователя, вы должны увидеть в логах сервера Sending signed token request: {"capability":{"app:product:*":["subscribe"], "app:products":["subscribe"], "app:submit_order":["publish"]}, "clientId": "Tom C"}
.
Получение начального состояния продукта из Fauna
Теперь, когда наша страница товаров подключена к Ably, пришло время правильно сопоставить данные в Ably с данными в Fauna. Для этого нам понадобится использовать две механики:
- Нам нужно будет выполнить первоначальную проверку того, какие товары существуют, с помощью запроса.
- Нам нужно будет проверить состояние этих объектов и проверить любые изменения в них с течением времени.
Чтобы начать работу, давайте создадим папку в базе нашего проекта под названием fauna
, и добавим файл под названием faunaHandler.js
. Здесь давайте настроим наши объекты Ably и Fauna и создадим объект Ably Channel для используемого нами канала продуктов. Нам также нужно добавить новую переменную окружения в наш файл .env
, FAUNADB_API_KEY
. Установите ее в значение ключа API Fauna, который вы получили в разделе Настройка аккаунта Fauna
.
Если вы установили базу данных Fauna в определенном регионе, вам нужно создать переменную окружения FAUNA_REGION
, установленную в eu
или us
, соответственно.
const faunadb = require('faunadb');
const Ably = require('ably');
const ablyClient = new Ably.Realtime(process.env.ABLY_API_KEY);
const productsChannel = ablyClient.channels.get('app:products');
/* Setup Fauna client */
const q = faunadb.query;
let domainRegion = "";
if (process.env.FAUNA_REGION) domainRegion = `${process.env.FAUNA_REGION}.`;
client = new faunadb.Client({
secret: process.env.FAUNADB_API_KEY,
domain: `db.${domainRegion}fauna.com`,
scheme: 'https',
});
После этого мы можем создать наш запрос к Fauna для всех продуктов. Поскольку нам нужны как идентификаторы, так и названия продуктов, мы должны запросить все продукты, а затем получить все их значения:
/* Product functions */
let allProductsQuery = q.Map(
q.Paginate(q.Documents(q.Collection('products'))),
q.Lambda(x => q.Get(x))
);
Выполнив запрос, давайте выполним его и опубликуем результат в Ably:
const allProducts = {};
client.query(allProductsQuery)
.then((products) => {
for (const product of products.data) {
const id = product.ref.value.id;
allProducts[id] = product.data.name;
ablyClient.channels.get(`app:product:${id}`).publish('update', product.data);
}
productsChannel.publish('products', allProducts);
})
.catch((err) => console.error(
'Error: [%s] %s',
err.name,
err.message
));
Этот код должен позволить нам первоначально заполнить канал Ably app:products
объектом с идентификаторами товаров и их названиями. Он также будет публиковать информацию о конкретном продукте в канал для этого продукта, соответствующий шаблону app:product:${productID}
. Давайте потребуем наш файл faunaHandler в файле server.js, а затем запустим все снова. Добавьте следующее в начало файла server.js
, чуть ниже строки require('dotenv').require()
:
require('./fauna/faunaHandler.js');
Запустите сервер с помощью npm run start
и зайдите на localhost:3000
. Вы должны увидеть продукты из вашей базы данных Fauna в боковой панели!
Обновление продуктов в реальном времени
С помощью приведенного выше кода мы можем получить начальное состояние наших продуктов. Однако в настоящее время мы не можем видеть, когда что-то меняется в продуктах, например, добавляется новый продукт или удаляется существующий. Мы можем запрашивать Fauna каждые X секунд, чтобы узнать, произошли ли какие-либо изменения по сравнению с предыдущим состоянием, а затем опубликовать обновленный объект в Ably, если произошли какие-либо изменения.
Мы можем избежать ненужных опросов, когда не было изменений, и избежать задержек в отражении изменений клиентами, используя потоковую передачу событий Fauna. Это позволяет отправлять на наш сервер обновления о конкретных изменениях продукта и коллекции, когда они происходят.
Мы можем использовать опцию Set Streaming, чтобы видеть, когда создается или удаляется документ в коллекции или индексе. Мы можем передавать коллекцию документов Product, однако мы будем получать только события создания и удаления, что означает, что мы не увидим, когда конкретный продукт изменится, например, в количестве.
Вместо этого мы можем создать индекс, который будет проверять изменения во временных метках наших продуктов. Это означает, что индекс будет испускать событие удаления, когда «старый» документ будет удален, а затем событие «создания», когда документ будет создан снова с новой меткой времени. Это позволит нам получать обновления при каждом обновлении наших продуктов.
Чтобы создать этот индекс, зайдите в Fauna Dashboard и в базе данных, которую вы создали для этого проекта, перейдите на вкладку ‘Shell’ в левой части экрана. В оболочке введите следующее:
CreateIndex({
name: "products_by_ts",
source: Collection("products"),
values: [{ field: "ts" }]
})
Выполните команду, и будет создан новый индекс под названием products_by_ts
. Его заполнение может занять несколько минут, но в конечном итоге он должен быть виден на вкладке «Индексы».
Теперь, когда мы создали этот индекс, мы можем попробовать прослушать его на предмет изменений. Добавьте следующее в ваш файл faunaHandler.js
:
function listenForProductChanges () {
const ref = q.Match(q.Index("products_by_ts"));
let stream = client.stream(ref)
.on('set', (set) => {
let productId = set.document.ref.value.id;
if (set.action == 'add') {
client.query(
q.Get(q.Ref(q.Collection('products'), `${productId}`))
)
.then((product) => {
allProducts[productId] = product.data.name;
productsChannel.publish('products', allProducts);
ablyClient.channels.get(`app:product:${productId}`).publish('update', product.data);
});
} else {
delete allProducts[productId];
productsChannel.publish('products', allProducts);
}
})
.on('error', (error) => {
console.log('Error:', error);
stream.close();
setTimeout(() => { listenForProductChanges() }, 1000);
})
.start();
return stream;
}
listenForProductChanges();
Таким образом, у нас есть поток, который слушает любые изменения, будь то добавление, удаление или изменение продуктов. Когда происходит изменение, мы получаем обновление в виде события set
и можем реагировать соответствующим образом.
На данном этапе мы можем попробовать, отражаются ли изменения в Fauna в браузере во время работы сервера. Запустите npm run start
, и откройте localhost:3000
. В Fauna Dashboard для базы данных попробуйте удалить или добавить новый продукт в коллекцию, и он должен немедленно обновиться в браузере. То же самое следует сделать для обновления названия существующего продукта.
Отображение сведений о продукте в браузере
На данном этапе, когда сервер запущен, каждый продукт должен иметь поток событий, поступающий в его собственный канал Ably, например, app:product:201
. Теперь нам нужно обработать это на HTML-странице, чтобы подписаться на эти каналы, когда мы выбираем продукт в боковой панели и выводим результаты на страницу.
В файле index.html
добавьте следующий код в нижнюю часть <script>
, который мы делали:
let productChannel;
// Load a new product to display from Ably, and subscribe to any changes
function loadProduct(productID) {
document.getElementById("intro-section").style.display = "none";
document.getElementById("loader").style.display = "flex";
document.getElementById("item-details").style.display = "none";
if (productChannel != null) {
productChannel.detach();
}
productId = productID;
// Update the url to the new item, so if we refresh the page this item loads again
if (products) {
window.history.pushState('page2', products[productID], '?product=' + productID);
const activeSidebarItem = document.getElementsByClassName('active');
for (let i=0; i < activeSidebarItem.length; i++) {
activeSidebarItem[i].className = activeSidebarItem[0].className.replace("active", "");
}
const newActiveItem = document.getElementById(`products-${productID}`);
newActiveItem.className += "active";
}
productChannel = realtime.channels.get(`app:product:${productID}`, { params: { rewind: '1' } });
productChannel.subscribe((msg) => {
document.getElementById("loader").style.display = "none";
document.getElementById("item-details").style.display = "flex";
document.getElementById("title").textContent = msg.data.name;
document.getElementById("description").textContent = msg.data.description;
document.getElementById("quantity").textContent = msg.data.quantity;
document.getElementById("price").textContent = msg.data.price;
if (msg.data.image) {
document.getElementById("item-image").src = msg.data.image;
} else {
document.getElementById("item-image").src = 'http://placekitten.com/600/400';
}
});
}
// If there is a param for the product to load in the URL, load it
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('product')) {
let productId = urlParams.get('product');
loadProduct(productId);
}
Здесь мы добавили функцию addProduct
, которая вызывается при нажатии на элемент в боковой панели. Это соединит нас с каналом продукта, и последнее сообщение на канале будет использовано для рендеринга продукта. Он будет оставаться подписанным на канал продукта, что означает, что он будет продолжать обновлять пользовательский интерфейс с любыми изменениями продукта.
Чтобы сделать страницу более удобной для обмена конкретными продуктами с другими, мы изменяем параметр product
в URL, и если при загрузке страницы есть параметр product, то сразу загружается продукт.
Наконец, в ваши продукты, хранящиеся в Fauna, можно включить поле image
, которое не установлено по умолчанию. Это должен быть URL-адрес изображения, которое где-то размещено. Если поле изображения не установлено для товара, вместо него будет показана милая кошечка.
Покупка товаров
Теперь пользователи могут видеть, какие товары имеются в наличии, и просматривать конкретные детали товаров, причем все эти данные могут обновляться в режиме реального времени. Однако пользователи, вошедшие в систему, пока не могут покупать товары, которые они просматривают.
В вашей базе данных Fauna, если вы инициализировали ее тестовыми данными, должна быть функция submit_order
. Эта функция принимает заказ от покупателя и проверяет, что у каждого товара, который он хочет получить, осталось достаточно запасов для совершения покупки. Она вычитает заказ из запасов каждого товара и создает новую коллекцию заказов, если запасов достаточно.
Мы можем сделать вызов с помощью клиентской библиотеки Fauna, но мы хотим убедиться, что только клиент, который вошел в систему как конкретный покупатель, может делать заказы для себя. Для этого нам необходимо:
- Создать документ Customer в Fauna для каждой учетной записи, которая входит в систему.
- Определить, какие пользователи запрашивают покупку продуктов, чтобы затем запросить
submit_order
.
Ссылка на клиента для зарегистрированных пользователей
Давайте сначала свяжем клиентов в Fauna с вошедшими в систему пользователями. В разделе Setup Fauna Account
мы уже сделали так, что документы клиента не могут иметь дублирующиеся поля username
, поэтому мы можем быть уверены, что при создании новых клиентов у нас не будет клиентов с дублирующимися именами пользователей.
Благодаря этому мы можем создать новый документ Customer с именем пользователя, когда клиент пытается разместить заказ, и если он скажет, что клиент с таким именем уже существует, мы можем использовать его ID в нашем запросе, чтобы сделать заказ. Добавьте следующее в конец файла faunaHandler.js
:
/* Customer functions */
async function createOrFindCustomer(username) {
/* We're hard-coding the address and other details, but for an actual
solution we'd get these details from the user */
let newCustomerObject = {
"username": username,
"address": {
"street": "0 Fake Street",
"city": "Washington",
"state": "DC",
"zipCode": "00000"
},
"telephone": "208-346-0715",
"creditCard": {
"network": "Visa",
"number": "000000000000"
}
};
// Try to create a new customer
return await new Promise(r => {
createP = client.query(
q.Create(q.Collection('customers'), { data: newCustomerObject })
).then(function(response) {
r(response.ref.value.id);
})
.catch(async () => {
// If an existing user with this username exists, throws an error due to
// uniqueness requirement defined within Fauna. We can find it with our username.
let customerId = await getCustomerIdByUsername(username);
r(customerId);
});
});
}
async function getCustomerIdByUsername(username) {
// Uniqueness requirement should mean there's only 1 user with the username
return await new Promise(r=> {
client.query(
q.Get(q.Match(q.Index('customers_by_username'), username))
).then(function(response) {
r(response.ref.value.id);
}).catch(function(err) {
console.log(err);
});
});
}
Здесь мы используем библиотеку Fauna, чтобы попытаться создать нового клиента, используя переданное имя пользователя. Если это не удается, поскольку клиент с таким именем уже есть, мы находим его по имени пользователя и возвращаем его ID.
Теперь мы можем использовать это для преобразования имен пользователей в их идентификаторы. Когда мы создаем Ably Tokens для клиентов в разделе Authentication with Ably
, мы присваиваем им clientId
, который может быть использован для идентификации пользователей. Поскольку он присваивается им сервером, мы можем доверять, что пользователи с таким идентификатором в Ably являются этими пользователями.
Мы можем использовать этот clientId, чтобы определить, какой пользователь делает запрос на заказ, и таким образом прикрепить его customerId к заказу, который мы отправляем в Fauna. Давайте сделаем так, чтобы этот clientId был customerId. Сначала экспортируем функцию username, чтобы наш сервер мог ее использовать. Добавьте следующее в самый конец файла faunaHandler.js
:
module.exports = createOrFindCustomer;
Далее, в файле server.js
замените существующую строку require('./fauna/faunaHandler.js);
на:
const createOrFindCustomer = require('./fauna/faunaHandler.js');
Там, где clientId назначается при генерации токена аута в файле server.js
, вместо этого задайте следующее значение:
app.get('/auth', async function (req, res) {
let tokenParams;
const customerId = await createOrFindCustomer(req.query['username']);
if (req.cookies.username) {
tokenParams = {
'capability': {
'app:product:*': ['subscribe'],
'app:products': ['subscribe'],
'app:submit_order': ['publish'],
},
'clientId': customerId
};
} else {
/* Issue a token request with only subscribe privileges for products */
tokenParams = {
'capability': { 'app:product:*': ['subscribe'], 'app:products': ['subscribe'] }
};
}
…
Теперь, когда каждый клиент Ably имеет свой идентификатор клиента, привязанный к его соединению, мы можем прослушивать любые сообщения, которые они могут попытаться отправить для заказов. В файле faunaHandler.js
добавьте следующее в самом низу, чуть выше module.exports...
, который мы добавили:
const ordersChannel = ablyClient.channels.get('app:submit_order');
/* Listen for new order requests from clients */
ordersChannel.subscribe((msg) => {
submitOrder(msg.clientId, msg.data);
});
const publicOrderConversion = (({ customer, cart, status, creationDate, deliveryAddress }) =>
({
customer, cart, status, creationDate, deliveryAddress
}));
async function submitOrder(customerId, orders) {
client.query(
q.Call('submit_order', customerId, orders)
)
.then((ret) => {
const publicOrder = publicOrderConversion(ret.data);
let userId = ret.data.customer.value.id;
let orderId = ret.ref.value.id;
ablyClient.channels.get(`app:order:${userId}:${orderId}`).publish('order', publicOrder);
if (!allOrders[customerId]) allOrders[customerId] = [];
allOrders[customerId].push(orderId);
ablyClient.channels.get(`app:orders:${customerId}`).publish(`order`, allOrders[customerId]);
})
.catch((err) => {
console.log(err);
console.error(
'Error: [%s] %s: %s',
err.name,
err.message,
err.errors()[0].description,
)}
);
}
Примечание: В этом файле мы также добавили публикацию в канал заказов в Ably, о котором более подробно будет сказано в разделе «Просмотр заказов».
Таким образом, мы подписались на данные заказа, отправленные клиентами через app:submit_order
канал Ably. Данные заказа будут отправлены в функцию submitOrder
, которая принимает идентификатор клиента пользователя (который является его идентификатором клиента) и отправляет заказ в Fauna.
Теперь нам нужно сделать так, чтобы кнопка «Купить сейчас» на нашем файле index.html
функционировала. Добавьте следующее в скрипт файла index.html
:
const orderChannel = realtime.channels.get('app:submit_order');
function order() {
// customer Id, order
orderChannel.publish("order", [
{
"productId": productId,
"quantity": 1
}
]);
}
Теперь кнопка ‘Купить сейчас’ должна быть полностью функциональной! Запустите сервер снова с помощью npm run start
, загрузите localhost:3000
и попробуйте перейти к товару и купить его. Если все работает, вы должны увидеть, как в браузере уменьшается количество товара на складе, а в терминале появится консольный лог с деталями сделанного заказа!
Просмотр заказов
Теперь, когда покупатели могут размещать заказы, нам имеет смысл добавить возможность просматривать свои заказы. Мы должны быть в состоянии сделать это, используя те же механизмы, что и раньше:
У нас может быть боковая панель со списком заказов клиента.
Клиент сможет нажать на любой заказ в боковой панели, чтобы просмотреть его детали.
Основное различие между тем, что мы сделали для продуктов, и этим заключается в том, что заказы должны быть доступны только конкретному пользователю, который их разместил. Чтобы обеспечить это, мы можем разбить заказы в Ably Channels, которые соответствуют определенным идентификаторам клиентов.
Например, клиент ‘101’ должен иметь список всех своих заказов, размещенных для боковой панели в app:orders:101
. Аналогично, детали каждого заказа будут находиться в канале структуры (для ID заказа 20) app:order:101:20
. При создании Ably Tokens для пользователей, мы можем предоставить им доступ только к каналам, которые соответствуют шаблону app:orders:101
и app:order:101:*
, гарантируя, что они получат доступ только к своим заказам.
Функциональность просмотра клиентских заказов
Добавим эти разрешения к существующему генератору Ably Token в файле server.js
. Измените часть токена в конечной точке auth следующим образом:
let tokenParams;
if (req.cookies.username) {
const customerId = await createOrFindCustomer(req.cookies['username']);
const ordersPattern = `app:orders:${customerId}`;
const orderPattern = `app:order:${customerId}:*`;
tokenParams = {
'capability': {
'app:product:*': ['subscribe'],
'app:products': ['subscribe'],
'app:submit_order': ['publish'],
},
'clientId': customerId
};
tokenParams.capability[orderPattern] = ['subscribe'];
tokenParams.capability[ordersPattern] = ['subscribe'];
} else {
/* Issue a token request with only subscribe privileges for products */
tokenParams = {
'capability': { 'app:product:*': ['subscribe'], 'app:products': ['subscribe'] }
};
}
…
Теперь, когда клиенты, войдя в систему, будут иметь доступ к каналам заказов, соответствующим их идентификатору клиента, нам нужно использовать его со стороны клиента. Давайте добавим новую конечную точку, которую клиенты смогут использовать для просмотра своих заказов, /orders
. Добавьте следующее в файл server.js
:
app.get('/orders', (req, res) => {
res.sendFile(__dirname + '/public/orders.html');
});
Далее, в папку public
добавьте файл orders.html
. В нем добавьте следующий код:
<html>
<head>
<script src="https://cdn.ably.io/lib/ably.min-1.js" type="text/javascript"></script>
<link href='https://fonts.googleapis.com/css?family=Actor' rel='stylesheet'>
<link rel="stylesheet" type="text/css" href="/css/index.css">
</head>
<body>
<header>
<a class="home" href="/">
<h1>💸 Buy Things Inc</h1>
</a>
<nav>
<a class="orders" href="/orders">Your orders</a>
<form class="login-form" action="/login" id="panel-anonymous">
<input type="text" name="username" placeholder="Enter your username" class="login-text">
<input type="submit" value="Login" class="login-submit">
</form>
<div id="panel-logged-in">
You are logged in. <a href="/logout">Log out</a>
</div>
</nav>
</header>
<ul id="orders" class="items"></ul>
<main class="item">
<div id="loader" class="loader" style="display: none"></div>
<p id="intro-section">Select an order from the sidebar to see details</p>
<div id="item-details" class="item-details" style="display: none">
<div class="item-info">
<h2 class="item-title">Order <span id="title"></span></h2>
<h3>Time of purchase: <span id="date"></span></h3>
<h3>Delivery Address</h3>
<p id="delivery-address"></p>
<h3>Ordered items</h3>
<ul id="cart"></ul>
<p>Total price: £<span id="total-price"></span></p>
</div>
</div>
</main>
<footer>
<p>Made with <a href="https://www.ably.com">Ably</a> and <a href="https://www.fauna.com">Fauna</a></p>
</footer>
</body>
<script type="text/javascript">
let orderId;
let orderChannel;
let orders;
let customerId;
const realtime = new Ably.Realtime({ authUrl: '/auth' });
realtime.connection.on('connected', () => {
customerId = realtime.auth.clientId;
console.log(`app:orders:${customerId}`);
const ordersChannel = realtime.channels.get(`app:orders:${customerId}`, { params: { rewind: '1' } });
ordersChannel.subscribe((msg) => {
const ordersContainer = document.getElementById('orders');
ordersContainer.innerHTML = '';
orders = msg.data;
for (let orderID of orders) {
let activeText = '';
if (orderId == orderID) activeText = 'active';
let item = document.createElement("li");
item.innerHTML = `<span id="orders-${orderID}" class="${activeText}" onclick="loadOrder('${orderID}')">${orderID}</span>`;
ordersContainer.appendChild(item);
}
});
});
function loadOrder(orderID) {
document.getElementById("intro-section").style.display = "none";
document.getElementById("loader").style.display = "flex";
document.getElementById("item-details").style.display = "none";
if (orderChannel != null) {
orderChannel.detach();
}
orderId = orderID;
if (orders) {
const activeSidebarItem = document.getElementsByClassName('active');
for (let i=0; i < activeSidebarItem.length; i++) {
activeSidebarItem[i].className = activeSidebarItem[0].className.replace("active", "");
}
const newActiveItem = document.getElementById(`orders-${orderID}`);
newActiveItem.className += "active";
}
orderChannel = realtime.channels.get(`app:order:${customerId}:${orderId}`, { params: { rewind: '1' } });
orderChannel.subscribe((msg) => {
document.getElementById("loader").style.display = "none";
document.getElementById("item-details").style.display = "flex";
document.getElementById("title").textContent = orderId;
document.getElementById("date").textContent = new Date(msg.data.creationDate['@ts']).toLocaleString();
const city = msg.data.deliveryAddress.city;
const state = msg.data.deliveryAddress.state;
const street = msg.data.deliveryAddress.street;
const zipCode = msg.data.deliveryAddress.zipCode;
document.getElementById("delivery-address").textContent = `${street} n ${city} n ${state} n ${zipCode}`;
let list = document.getElementById('cart');
list.innerHTML = "";
const cart = msg.data.cart;
let totalPrice = 0;
for (let i = 0; i < cart.length; i++) {
let item = cart[i];
let entry = document.createElement('li');
totalPrice += item.price;
let productName = item.name || item.product['@ref'].id;
entry.appendChild(document.createTextNode(`product: ${productName} - price: ${item.price} - quantity: ${item.quantity}`));
list.appendChild(entry);
}
document.getElementById("total-price").textContent = totalPrice;
});
}
/* Hide or show the logged in / anonymous panels based on the session cookie */
let loggedIn = document.cookie.indexOf('username') >= 0;
document.getElementById('panel-anonymous').setAttribute('style', "display: " + (loggedIn ? 'none' : 'flex'));
document.getElementById('panel-logged-in').setAttribute('style', "display: " + (loggedIn ? 'inline' : 'none'));
</script>
</html>
С точки зрения функциональности это фактически то же самое, что и код index.html
. Мы заполняем боковую панель на основе содержимого канала app:orders:CUSTOMERID
. Затем, когда заказ выбран, подписываемся на канал app:order:CUSTOMERID:ORDERID
и заполняем поля в правой части страницы.
Функциональность заказа от Fauna до Ably
С этой настройкой нам остается только получить данные в каналы Ably. Опять же, эта работа будет очень похожа на ту, которую мы проделали для сопоставления Fauna с Ably для продуктов. При построении имени канала Ably нам также нужно будет учитывать прикрепленный ID клиента в заказе.
В файле faunaHandler.js
выполним те же действия, что и раньше, для обновления продуктов. Мы сделаем начальную выборку, чтобы посмотреть, какие заказы существуют, а затем обновим каналы Ably с текущим состоянием списка заказов по ID клиента и каждого заказа. Добавьте следующее в файл faunaHandler.js
чуть выше бита module.exports
:
/* Order functions */
const allOrders = {};
let allOrdersQuery = q.Map(
q.Paginate(q.Documents(q.Collection('orders'))),
q.Lambda(x => q.Get(x))
);
client.query(allOrdersQuery)
.then((orders) => {
for (const order of orders.data) {
const orderId = order.ref.value.id;
const customerId = order.data.customer.id;
if (!allOrders[customerId]) allOrders[customerId] = [];
allOrders[customerId].push(orderId);
const publicOrder = publicOrderConversion(order.data);
ablyClient.channels.get(`app:order:${customerId}:${orderId}`).publish('update', publicOrder);
}
for (const [customerId, orders] of Object.entries(allOrders)) {
ablyClient.channels.get(`app:orders:${customerId}`).publish(`order`, orders);
}
})
.catch((err) => console.error(
'Error: [%s] %s',
err.name,
err.message
));
Попробуйте снова запустить сервер с помощью npm run start
, загрузите localhost:3000
и попробуйте купить несколько товаров. Нажмите кнопку orders
в навигационной панели, и, надеюсь, вы увидите, что все ваши заказы отображаются в боковой панели!
Заключение
Теперь у нас есть сайт, на котором пользователи могут войти в систему, просматривать товары, видеть обновления в реальном времени о добавлении и изменении новых товаров и покупать их. Клиенты могут просматривать сделанные ими заказы, также отражающие в реальном времени точную историю покупок каждого пользователя и полностью защищенные только для того, чтобы пользователь мог получить доступ к своим заказам.
С подобным сайтом можно сделать гораздо больше, но, надеюсь, с помощью основных строительных блоков, разработанных здесь, можно создать большинство функций реального времени. Как видно из повторения обновлений продуктов и заказов, многие из тех же шаблонов могут быть использованы для беспрепятственной репликации и взаимодействия между клиентами и системами, такими как Fauna для пользователя.
Весь код из этого блога вы можете найти на GitHub. Если у вас есть вопросы к Ably, вы можете связаться с ними на их Discord, или вы можете связаться с командой Fauna на Discord или здесь.