Forge Viewer: реализуем сценарий offline-работы web-приложения
Несмотря на то, что Forge - это облачная платформа, для некоторых приложений, построенных на ней, может потребоваться реализация сценариев, в которых соединение с интернетом временно недоступно. Например, подумайте о приложении для рецензирования моделей CAD. Было бы здорово, если бы Вы могли поработать с несколькими CAD файлами в самолете и позже синхронизировать Вашу работу, когда вернетесь в online? В этой статье мы рассмотрим один из возможных подходов для реализации подобного функционала с использованием HTML5 API. Что ж, начнем с краткого обзора технологий, которые мы будем использовать, затем построим стратегию их использования в нашем приложении и, в конце концов, посмотрим на финальный пример такого приложения.
Технологии
Существуют разные способы сохранения данных web-приложений на Вашем устройстве. В нашем приложении мы воспользуемся несколькими новыми API, используемыми Progressive Web Applications (так же на русском): Service workers, cache и Channel Messaging.
Несмотря на то, что эти API достаточно новые, они поддерживаются всеми современными браузерами. На сайте Jake Archibald-а доступна подробная информация о поддержке тех или иных функций конкретными версиями основных популярных браузеров.
Service Worker это особый тип Web Worker-ов, работающих как proxy-серверы web приложений. Когда web-приложение регистрирует service worker, этот service worker может перехватывать сетевые запросы от (потенциально) нескольких экземпляров приложения (например, с разных вкладок или окон браузера) и возвращать кэшированые данные (или даже сгенерированные самим service worker-ом при необходимости). Помимо этого, service worker имеет доступ к другим современным API браузера, таким как IndexedDB, Channel Messaging или Push API.
Типичный жизненный цикл service worker-а выглядит следующим образом:
- web-приложение регистрирует свои service worker-ы
- браузер загружает и выполняет скрипт самого worker-а
- worker получает событие "install" (используется для однократной установки и загрузки ресурсов)
- браузер ждет завершения остальных экземпляров service worker-ов приложения (потенциально, это могут быть старые версии service worker-ов)
- worker получает событие "activate" (используется, например, для очистки кэша)
- worker начинает получать события "fetch" (для перехвата сетевых запросов) и "message" (для взаимодействия с web-приложением)
Cache - это хранилище создаваемое браузером для конкретного URL, подобное Local Storage или IndexedDB. Оно состоит из объектов с уникальными именами, и каждый из этих объектов содержит пару HTTP Request/Response.
Channel Messaging позволяет скриптам, работающим в разных контекстах (например, между основным документом и iframe, между двумя различными web worker-ами, между основным документом и service worker-ом, и т.д.) обмениваться информацией путем отправки сообщений в двусторонний канал.
Стратегия кэширования
Кэширование статичных данных и конечных точек API достаточно просто и прямолинейно. Мы можем кэшировать их все при установке service worker-а. Потом service worker при запросах к API может возвращать кэшированный результат сразу же, при необходимости обновляя данные кэша в фоновом режиме.
Кэширование моделей может быть несколько сложнее. Обычно для каждой модели создаются несколько производных, которые в свою очередь ссылаются на дополнительные ресурсы. Нам нужен способ определения принадлежности всех этих ресурсов к модели. В нашем приложении сервер предоставляет метод, который по переданному URN определяет URL-s всех derivative-ов модели и других, связанных с моделью данных (я вдохновился кодом https://extract.autodesk.io). При создании кэша конкретной модели, service worker может использовать этот метод для получения всех необходимых URL-ов без использования viewer-а для чего бы то ни было.
Пример приложения
Мы подготовили пример приложения Forge Viewer-а, которое позволяет его пользователям выборочно кэшировать модели, созданные Model Derivative API.
Его исходный код доступен здесь: https://github.com/petrbroz/forge-disconnected
Попробовать вживую можно здесь: https://forge-offline.herokuapp.com.
Теперь рассмотрим самые интересные моменты в его реализации.
Мы используем простое приложение Express на бэкенде, которое помимо статического содержимого из папки public предоставляет следующие три API метода:
- GET /api/token - возвращает 2-legged токен авторизации для viewer-а
- GET /api/models - возвращает список моделей, доступных для просмотра
- GET /api/models/:urn/files - возвращает всю информацию о производных и данных, относящихся к заданному URN модели
На фронтэнде, самыми важными файлами являются public/javascript/main.js и public/service-worker.js.
Большая часть кода в public/javascript/main.js - это просто настройка Forge Viewer-а, UI и взаимодействие с пользователем. Две очень важные функции initServiceWorker (регистрирует service worker) и submitWorkerTask (отправляет сообщения worker-у) находятся в конце файла:
- async function initServiceWorker() {
- try {
- const registration = await navigator.serviceWorker.register('/service-worker.js');
- console.log('Service worker registered', registration.scope);
- } catch (err) {
- console.error('Could not register service worker', err);
- }
- }
- function submitWorkerTask(task) {
- return navigator.serviceWorker.ready.then(function(req) {
- return new Promise(function(resolve, reject) {
- const channel = new MessageChannel();
- channel.port1.onmessage = function(event) {
- if (event.data.error) {
- reject(event.data);
- } else {
- resolve(event.data);
- }
- };
- req.active.postMessage(task, [channel.port2]);
- });
- });
- }
Функция submitWorkerTask используется для отправки service worker-у задач следующих 3 типов:
- получить список всех URN-ов в кэше (используется в UI для определения, какие из моделей уже есть в кэше)
- кэшировать заданную модель (по её URN)
- удалить модель из кэша (по URN)
А вся магия живет в public/service-worker.js. Код обрабатывает события жизненного цикла service worker-а, описанного выше.
В событии "install" мы кэшируем известные статические данные и API. Мы используем встроенную функцию self.skipWaiting() для активации worker-а сразу же, как только это возможно без ожидания завершения работы старых worker-ов.
- async function installAsync(event) {
- self.skipWaiting();
- const cache = await caches.open(CACHE_NAME);
- await cache.addAll(STATIC_URLS);
- await cache.addAll(API_URLS);
- }
В событии "activate" мы запрашиваем контроль над всеми экземплярами приложения, которые могут быть запущены на других вкладках.
- async function activateAsync() {
- const clients = await self.clients.matchAll({ includeUncontrolled: true });
- console.log('Claiming clients', clients.map(client => client.url).join(','));
- await self.clients.claim();
- }
При перехвате запросов в событии "fetch", мы возвращаем кэшированный результат запроса, если такой существует (за исключением запроса GET /api/token). Так как токен доступа имеет время жизни, мы сначала пытаемся получить новый токен, возвращая кэшированный только в случае неудачи выполнения этого запроса.
- async function fetchAsync(event) {
- // При запросе токена доступа сначала пытаемся получить свежий токен
- if (event.request.url.endsWith('/api/token')) {
- try {
- const response = await fetch(event.request);
- return response;
- } catch(err) {
- console.log('Could not fetch new token, falling back to cache.', err);
- }
- }
- // Если в кэше есть результаты запроса, о возвращаем их
- const match = await caches.match(event.request.url, { ignoreSearch: true });
- if (match) {
- // Пытаемся также обновить кэш
- if (STATIC_URLS.includes(event.request.url) || API_URLS.includes(event.request.url)) {
- caches.open(CACHE_NAME)
- .then((cache) => cache.add(event.request))
- .catch((err) => console.log('Cache not updated, but that\'s ok...', err));
- }
- return match;
- }
- return fetch(event.request);
- }
И, наконец, используя событие "message", выполняем "задачи" от клиентского приложения
- async function messageAsync(event) {
- switch (event.data.operation) {
- case 'CACHE_URN':
- try {
- const urls = await cacheUrn(event.data.urn, event.data.access_token);
- event.ports[0].postMessage({ status: 'ok', urls });
- } catch(err) {
- event.ports[0].postMessage({ error: err.toString() });
- }
- break;
- case 'CLEAR_URN':
- try {
- const urls = await clearUrn(event.data.urn);
- event.ports[0].postMessage({ status: 'ok', urls });
- } catch(err) {
- event.ports[0].postMessage({ error: err.toString() });
- }
- break;
- case 'LIST_CACHES':
- try {
- const urls = await listCached();
- event.ports[0].postMessage({ status: 'ok', urls });
- } catch(err) {
- event.ports[0].postMessage({ error: err.toString() });
- }
- break;
- }
- }
- async function cacheUrn(urn, access_token) {
- console.log('Caching', urn);
- // Сначала запрашиваем у нашего сервера список производных по URN и URL-ы файлов const baseUrl = 'https://developer.api.autodesk.com/derivativeservice/v2';
- const res = await fetch(`/api/models/${urn}/files`);
- const derivatives = await res.json();
- // Подготавливаем запросы для того, чтобы кэшировать все URL-ы
- const cache = await caches.open(CACHE_NAME);
- const options = { headers: { 'Authorization': 'Bearer ' + access_token } };
- const fetches = [];
- const manifestUrl = `${baseUrl}/manifest/${urn}`;
- fetches.push(fetch(manifestUrl, options).then(resp => cache.put(manifestUrl, resp)).then(() => manifestUrl));
- for (const derivative of derivatives) {
- const derivUrl = baseUrl + '/derivatives/' + encodeURIComponent(derivative.urn);
- fetches.push(fetch(derivUrl, options).then(resp => cache.put(derivUrl, resp)).then(() => derivUrl));
- for (const file of derivative.files) {
- const fileUrl = baseUrl + '/derivatives/' + encodeURIComponent(derivative.basePath + file);
- fetches.push(fetch(fileUrl, options).then(resp => cache.put(fileUrl, resp)).then(() => fileUrl));
- }
- }
- // Запрашиваем и кэшируем все URL-ы параллельно
- const urls = await Promise.all(fetches);
- return urls;
- }
- async function clearUrn(urn) {
- console.log('Clearing cache', urn);
- const cache = await caches.open(CACHE_NAME);
- const requests = (await cache.keys()).filter(req => req.url.includes(urn));
- await Promise.all(requests.map(req => cache.delete(req)));
- return requests.map(req => req.url);
- }
- async function listCached() {
- console.log('Listing caches');
- const cache = await caches.open(CACHE_NAME);
- const requests = await cache.keys();
- return requests.map(req => req.url);
- }
Вот, собственно и всё. Поэкспериментировать вы можете, открыв ссылку https://forge-offline.herokuapp.com в Вашем любимом современном браузере. Откройте инструменты разработчика и попробуйте закэшировать модель, нажав ☆ рядом с именем модели.
Или посмотрите видео: https://youtu.be/JGLytRddYiw
Источник: https://forge.autodesk.com/blog/disconnected-workflows
Опубликовано 31.01.2021