ADN Open CIS
Сообщество программистов Autodesk в СНГ

31/01/2021

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-у) находятся в конце файла:

Код - JavaScript: [Выделить]
  1. async function initServiceWorker() {
  2.     try {
  3.         const registration = await navigator.serviceWorker.register('/service-worker.js');
  4.         console.log('Service worker registered', registration.scope);
  5.     } catch (err) {
  6.         console.error('Could not register service worker', err);
  7.     }
  8. }
  9.  
  10. function submitWorkerTask(task) {
  11.     return navigator.serviceWorker.ready.then(function(req) {
  12.         return new Promise(function(resolve, reject) {
  13.             const channel = new MessageChannel();
  14.             channel.port1.onmessage = function(event) {
  15.                 if (event.data.error) {
  16.                     reject(event.data);
  17.                 } else {
  18.                     resolve(event.data);
  19.                 }
  20.             };
  21.             req.active.postMessage(task, [channel.port2]);
  22.         });
  23.     });
  24. }

Функция submitWorkerTask используется для отправки service worker-у задач следующих 3 типов:

- получить список всех URN-ов в кэше (используется в UI для определения, какие из моделей уже есть в кэше)

- кэшировать заданную модель (по её URN)

- удалить модель из кэша (по URN)

А вся магия живет в public/service-worker.js. Код обрабатывает события жизненного цикла service worker-а, описанного выше.

В событии "install" мы кэшируем известные статические данные и API. Мы используем встроенную функцию self.skipWaiting() для активации worker-а сразу же, как только это возможно без ожидания завершения работы старых worker-ов.

Код - JavaScript: [Выделить]
  1. async function installAsync(event) {
  2.     self.skipWaiting();
  3.     const cache = await caches.open(CACHE_NAME);
  4.     await cache.addAll(STATIC_URLS);
  5.     await cache.addAll(API_URLS);
  6. }

В событии "activate" мы запрашиваем контроль над всеми экземплярами приложения, которые могут быть запущены на других вкладках.

Код - JavaScript: [Выделить]
  1. async function activateAsync() {
  2.     const clients = await self.clients.matchAll({ includeUncontrolled: true });
  3.     console.log('Claiming clients', clients.map(client => client.url).join(','));
  4.     await self.clients.claim();
  5. }

При перехвате запросов в событии "fetch", мы возвращаем кэшированный результат запроса, если такой существует (за исключением запроса GET /api/token). Так как токен доступа имеет время жизни, мы сначала пытаемся получить новый токен, возвращая кэшированный только в случае неудачи выполнения этого запроса.

Код - JavaScript: [Выделить]
  1. async function fetchAsync(event) {
  2.     // При запросе токена доступа сначала пытаемся получить свежий токен
  3.     if (event.request.url.endsWith('/api/token')) {
  4.         try {
  5.             const response = await fetch(event.request);
  6.             return response;
  7.         } catch(err) {
  8.             console.log('Could not fetch new token, falling back to cache.', err);
  9.         }
  10.     }
  11.  
  12.     // Если в кэше есть результаты запроса, о возвращаем их
  13.     const match = await caches.match(event.request.url, { ignoreSearch: true });
  14.     if (match) {
  15.         // Пытаемся также обновить кэш
  16.         if (STATIC_URLS.includes(event.request.url) || API_URLS.includes(event.request.url)) {
  17.             caches.open(CACHE_NAME)
  18.                 .then((cache) => cache.add(event.request))
  19.                 .catch((err) => console.log('Cache not updated, but that\'s ok...', err));
  20.         }
  21.         return match;
  22.     }
  23.  
  24.     return fetch(event.request);
  25. }

И, наконец, используя событие "message", выполняем "задачи" от клиентского приложения

Код - JavaScript: [Выделить]
  1. async function messageAsync(event) {
  2.     switch (event.data.operation) {
  3.         case 'CACHE_URN':
  4.             try {
  5.                 const urls = await cacheUrn(event.data.urn, event.data.access_token);
  6.                 event.ports[0].postMessage({ status: 'ok', urls });
  7.             } catch(err) {
  8.                 event.ports[0].postMessage({ error: err.toString() });
  9.             }
  10.             break;
  11.         case 'CLEAR_URN':
  12.             try {
  13.                 const urls = await clearUrn(event.data.urn);
  14.                 event.ports[0].postMessage({ status: 'ok', urls });
  15.             } catch(err) {
  16.                 event.ports[0].postMessage({ error: err.toString() });
  17.             }
  18.             break;
  19.         case 'LIST_CACHES':
  20.             try {
  21.                 const urls = await listCached();
  22.                 event.ports[0].postMessage({ status: 'ok', urls });
  23.             } catch(err) {
  24.                 event.ports[0].postMessage({ error: err.toString() });
  25.             }
  26.             break;
  27.     }
  28. }
  29.  
  30. async function cacheUrn(urn, access_token) {
  31.     console.log('Caching', urn);
  32.     // Сначала запрашиваем у нашего сервера список производных по URN и URL-ы файлов    const baseUrl = 'https://developer.api.autodesk.com/derivativeservice/v2';
  33.     const res = await fetch(`/api/models/${urn}/files`);
  34.     const derivatives = await res.json();
  35.     // Подготавливаем запросы для того, чтобы кэшировать все URL-ы
  36.     const cache = await caches.open(CACHE_NAME);
  37.     const options = { headers: { 'Authorization': 'Bearer ' + access_token } };
  38.     const fetches = [];
  39.     const manifestUrl = `${baseUrl}/manifest/${urn}`;
  40.     fetches.push(fetch(manifestUrl, options).then(resp => cache.put(manifestUrl, resp)).then(() => manifestUrl));
  41.     for (const derivative of derivatives) {
  42.         const derivUrl = baseUrl + '/derivatives/' + encodeURIComponent(derivative.urn);
  43.         fetches.push(fetch(derivUrl, options).then(resp => cache.put(derivUrl, resp)).then(() => derivUrl));
  44.         for (const file of derivative.files) {
  45.             const fileUrl = baseUrl + '/derivatives/' + encodeURIComponent(derivative.basePath + file);
  46.             fetches.push(fetch(fileUrl, options).then(resp => cache.put(fileUrl, resp)).then(() => fileUrl));
  47.         }
  48.     }
  49.     // Запрашиваем и кэшируем все URL-ы параллельно
  50.     const urls = await Promise.all(fetches);
  51.     return urls;
  52. }
  53.  
  54. async function clearUrn(urn) {
  55.     console.log('Clearing cache', urn);
  56.     const cache = await caches.open(CACHE_NAME);
  57.     const requests = (await cache.keys()).filter(req => req.url.includes(urn));
  58.     await Promise.all(requests.map(req => cache.delete(req)));
  59.     return requests.map(req => req.url);
  60. }
  61.  
  62. async function listCached() {
  63.     console.log('Listing caches');
  64.     const cache = await caches.open(CACHE_NAME);
  65.     const requests = await cache.keys();
  66.     return requests.map(req => req.url);
  67. }

Вот, собственно и всё. Поэкспериментировать вы можете, открыв ссылку https://forge-offline.herokuapp.com в Вашем любимом современном браузере. Откройте инструменты разработчика и попробуйте закэшировать модель, нажав ☆ рядом с именем модели.

Или посмотрите видео: https://youtu.be/JGLytRddYiw

Источник: https://forge.autodesk.com/blog/disconnected-workflows

Автор перевода: Александр Игнатович
Опубликовано 31.01.2021