Разработка Offline First приложения

Тимофей Лавренюк

О себе

Из Одессы

Более 7 лет в веб-разработке

Есть опыт создания нативных приложений

"Подсел" на прогрессивные web-приложения

Предыстория

Offline Mode

Ядро

RPC Server

Архитектура

Ядро

Криптография

Работа с локальной БД

Работа с PDF

Общение с RPC-сервером

А что умеет?

Настал 2017 год

Нужна web-версия...

СРОЧНО НУЖНА

WEB-ВЕРСИЯ !!!11

WEB-Версия

Не было режима автора

Зависило от интернет соединения

Был PHP

Старая WEB-версия

Ядро

SPA

REST API

RPC Server

Старая архитектура

Offline First web-приложение

Задача:

Написать веб приложение так, как будто нет интернет соединения

Есть режим автора

Работает в Offline

Не уступает нативным клиентам

Новая WEB-версия

Ядро

SPA

Proxy

RPC Server

Архитектура веб приложения

Статика

Данные пользователя

Файлы

Что хранить в Offline?

Статика

Service Worker

{
  "globDirectory": "dist",
  "globPatterns": [
    "index.html",
    "*.js",
    "assets/**/*.{png,svg}",
    "assets/*.{png,svg}"
  ],
  "swSrc": "src/service-workers/main.worker.js",
  "swDest": "dist/service-worker.js"
}

workbox.config.js

{
  "assets": [
      "src/assets",
      "src/service-workers",
      "src/manifest.json",
      {
        "glob": "workbox-sw.js",
        "input": "node_modules/workbox-sw/build",
        "output": "./workbox-3.5.0"
      },
      {
        "glob": "workbox-core.dev.js",
        "input": "node_modules/workbox-core/build/",
        "output": "./workbox-3.5.0"
      },
      {
        "glob": "workbox-precaching.dev.js",
        "input": "node_modules/workbox-precaching/build/",
        "output": "./workbox-3.5.0"
      }
    ],
}

angular.json

importScripts('workbox-3.5.0/workbox-sw.js');

workbox.setConfig({
  debug: true,
  modulePathPrefix: 'workbox-3.5.0/'
});
workbox.skipWaiting();
workbox.clientsClaim();
workbox.precaching.precacheAndRoute([]);

main.worker.js

workbox injectManifest
workbox.precaching.precacheAndRoute([
  {
    "url": "index.html",
    "revision": "4f8109353581284e76b88e568d642376"
  },
  {
    "url": "0.js",
    "revision": "2af14762103b4c1f18e620bd30a53d2e"
  },
  {
    "url": "main.js",
    "revision": "ef2fb7f5913e6614c9ee3621ad299d1b"
  }
])

service-worker.js

Статика

Данные пользователя

Service Worker

Persistent Storage

 CacheStorage

Нет гибкости

File API

FileWriter deprecated

Cookies

WATTT??

WebSQL

Deprecated

IndexedDB

Большой лимит

Хорошая поддержка

Не Deprecated

LocalStorage

Небольшой лимит

IndexedDB

объектно-ориентированная база данных

хранит обьекты, проиндексированные с ключом

выполнение операций происходит асинхронно

имеет жутко неудобное низкоуровневое API

умеет хранить JS-объекты и блобы

Поддержка

Debug

cd ~Library/Application\ Support/Firefox/Profiles/xxxxxxxx.default/

 storage/default/https+++app.keepsolid.com/idb

~Library/Safari/Databases/___IndexedDB/http_localhost_4200/KEEPSOLID_SIGN_DB

IndexedDB в бою

1. Открыть или создать базу

var open = indexedDB.open("MyDatabase", 1);

2. Создать Schema

open.onupgradeneeded = () => {
    const db = open.result;
    const store = db.createObjectStore("MyObjectStore", {keyPath: "id"});
    const index = store.createIndex("NameIndex", ["name.last", "name.first"]);
};

4. Запросить данные

const getJohn = store.get(12345); // по id
const getBob = index.get(["Smith", "Bob"]); // через index

3. Создать транзакцию

open.onsuccess = () => {
    const db = open.result
    const tx = db.transaction("MyObjectStore", "readwrite");
    const store = tx.objectStore("MyObjectStore");
    const index = store.index("NameIndex");
}

5. Получить данные

getJohn.onsuccess = () => {
    console.log(getJohn.result.name.first);  // => "John"
};
getBob.onsuccess = () => {
    console.log(getBob.result.name.first);   // => "Bob"
};

5. Закрыть транзакцию

tx.oncomplete = function() {
    db.close();
};

Boilerblate!!!1

Недостатки чистого IndexedDB

  • Тяжело поддерживать
  • Не поддержки JOIN'ов
  • Нельзя частично обновить документ
  • Скудная сортировка
  • Auto-commit транзакций

"Никто не использует IndexedDB в чистом виде"

- Все JS-разработчики

Библиотеки

Обертки

  • Idb
  • ZangoDb
  • MiniMongo
  • jsStore
  • PouchDB
  • Dexie
  • LocalForage

DB Engine

  • YDN-DB
  • AlaSQL
  • Lovefield

SQL-подобный API

  • select, insert, update, delete
  • group by, order by, limit, skip
  • join

Кроссбраузерность

  • Chrome
  • Firefox
  • IE 11+, Edge
  • Safari 10+

Отличная производительность

  • Оптимизация и анализ запросов

Создание Schema

const schemaBuilder = lf.schema.create('KEEPSOLID_SIGN_DB', 1.0);

schemaBuilder.createTable('Documents')
    .addColumn('id', lf.Type.STRING)
    .addColumn('parentId', lf.Type.OBJECT)
    .addColumn('type', lf.Type.STRING)
    .addColumn('signOrder', lf.Type.NUMBER)
    .addColumn('encryptionKey', lf.Type.STRING)
    .addIndex('idxSignOrder', ['signOrder'], false, lf.Order.DESC);
    .addPrimaryKey(['id']);

schemaBuilder.connect().then((db) => {
  // Можно работать с базой
});

Типы полей

  • String
  • Number
  • Integer (32bit)
  • Boolean
  • Object
  • Date
  • Array Buffer

SQL-подобный API

SQL
Lovefield
SELECT *
  FROM Documents
  WHERE type = "TEMPLATE"
Database
  .select()
  .from(document)
  .where(document.type.eq('TEMPLATE'))
  .exec()
    
SELECT encryptionKey
  FROM Documents
  WHERE signOrder >= 1
  ORDER BY signOrder DESC
  LIMIT 10
Database
  .select(document.encryptionKey)
  .from(document)
  .where(document.signOrder.gte(1))
  .orderBy(document.signOrder, lf.Order.DESC)
  .limit(10)
  .exec()
    
SQL
Lovefield
SELECT *
  FROM Documents d, Files f
  WHERE d.fileId = f.id
  AND d.id = '123'
Database
  .select()
  .from(document, file)
  .where(lf.op.and(
     document.fileId.eq(file.id),
     document.id.eq('123'),
  ))
  .exec()
    
SELECT * FROM document
  INNER JOIN file
  ON document.fileId = file.id
  WHERE document.id = '123'
Database
   .select()
   .from(document)
   .innerJoin(
     file,
     document.fileId.eq(file.id)
   )
   .where(document.id.eq('123'))
   .exec()
function idb_and (index1, keyRange1, index2, keyRange2, onfound, onfinish) {
    var openCursorRequest1 = index1.openCursor(keyRange1);
    var openCursorRequest2 = index2.openCursor(keyRange2);
 
    assert(index1.objectStore === index2.objectStore);
    var primKey = index1.objectStore.keyPath;
    
    var set = {};
    var resolved = 0;
 
    function complete() {
        if (++resolved === 2) onfinish();
    }
 
    function union(item) {
        var key = JSON.stringify(item[primKey]);
        if (!set.hasOwnProperty(key)) {
            set[key] = true;
            onfound(item);
        }
    }
 
    openCursorRequest1.onsuccess = function (event) {
        var cursor = event.target.result;
        if (cursor) {
            union(cursor.value);
        } else {
            complete();
        }
    }
 
    openCursorRequest2.onsuccess = function (event) {
        var cursor = event.target.result;
        if (cursor) {
            union(cursor.value);
        } else {
            complete();
        }
    }
}

Чистый API

Интересные особенности

  • Позволяет задавать Schema в YAML (SPAC)
  • Типы Storage: IndexedDB, Memory, Firebase
  • Поддерживает Import/Export в Javascript-объект
  • Поддерживает Data Observation

Data Observation

var query = db.select()
              .from(documents)
              .where(documents.id.eq('1'));

var handler = function(changes) {
  // Будет вызываться всегда, когда происходит изменение данных
};
db.observe(query, handler);

db.update(documents)
  .set(documents.title, 'New Title')
  .where(documents.id.eq('1'))
  .exec();

db.unobserve(query, handler);

Нюансы

Изменение Schema

Вывод ошибок

Constraint error: (202) Attempted to insert NULL value to non-nullable field Documents.signOrder.

Как мы прикрутили Lovefield в проект

Data Layer

UI

Redux

Web Worker

Indexed DB

Почему Web Worker?

Меньше нагрузка на Main Thread

Производительность

const document = {
    id: 1,
    name: 'Test'
}
myWorker.postMessage(document);

1) Клонирование (Structured Clone Algorithm)

2) Сериализация

const newDocument = clone({
    id: 1,
    name: 'Test'
})
myWorker.postMessage(JSON.stringify(newDocument));

Transferable Objects

передача данных от одного контекста другому

ArrayBuffer

Encoding API

const encoder = TextEncoder('utf-8');
const decoder = TextDecoder('utf-8');

const uInt8Array = encoder.encode('Some string');
const decodedString = decoder.decode(uInt8Array);
var uInt8Array = stringToUintArray(JSON.stringify(result));

myWorker.postMessage(uInt8Array.buffer, [uInt8Array.buffer]);

Производительность String => ArrayBuffer

Меньше RAM расходуется

Передача ArrayBuffer очень быстрая

но не все так плохо...

Хранение файлов

ArrayBuffer

FileReader API

var fileReader = new FileReader();
fileReader.onload = (event) => {
    const arrayBuffer = event.target.result;
};
fileReader.readAsArrayBuffer(blob);
const blob = new Blob([arrayBuffer]);

const objectUrl = URL.createObjectURL(blob);

blob:https://app.keepsolid.com/d45b2410-ad60-45be-9359-3f43524698db

SharedArrayBuffer

Shared Data

SharedArrayBuffer

Main Thread

SharedArrayBuffer

Web Worker Thread

const sharedBuffer = new SharedArrayBuffer(2);
const arr = new Int8Array(sharedBuffer);
arr[0] = 42;

myWorker.postMessage(sharedBuffer);

НО...

Пока Deprecated

Должны вернуться в V8 в конце 2018

Выводы

  • Передача данных в Web Worker не всегда эффективна
  • Если требуется много манипулировать с данными, но имеет смысл использовать Web Worker

Коомуникация с Web Worker

Effect

Action

Redux

Web Worker

Success/Failure Action

Comutter

Redux Devtools

Ограниченный размер Storage

Браузер Лимит
Chrome < 6%
Firefox < 10%
Safari < 50mb
Edge В зависимости от обьема HDD
IE11 < 250mb

Свободное место делится между всеми типами Storage 

  • IndexedDB
  • LocalStorage
  • Cache
  • ...

Как быть готовым к превышению лимита?

Storage Quota Estimate API

navigator.storage.estimate().then(estimate => {
  // estimate.quota - сколько доступно памяти
  // estimate.usage - сколько байт хранилища используется
});

Поддержка

Private Mode

indexedDB == null

Storage Usage == 0

Все работает

Все работает

Очистка Storage'а браузером

Как предотвратить очистку?

Браузер Метод
Chrome LRU
Firefox LRU
Safari Отсутствует
Edge Отсутствует

Persistent Storage API

navigator.storage.persist().then(function(persistent) {
    if (persistent)
     // Браузер не будет чистить Storage
    else
     // Браузер очистит Storage в случае нехватки памяти
}

Chrome одобрит, если:

  • Сайт добавлен в закладки
  • Сайт добавлен на Homescreen
  • Пользователь активно взаимодействует с приложением 
  • Включены Push-уведомления

Firefox

Поддержка

Определение Offline'а

Online- и Offline-события

window.addEventListener('online',  () => {
    // Есть интернет соединение
});

window.addEventListener('offline', () => {
    // Отсутствует интернет соединение
});

Поддержка

Network Information API

navigator.onLine //true, false

navigator.effectiveType // 2g, 3g, 4g

Поддержка

Но

Online Подключение к WiFi

Ping Pong

Будущее

Persistence Storage

Writable files

File and Directory Entries API

Draft

In development

IndexedDB 2.0

Планируемые улучшения Indexed DB 

  • Promises
  • Observers
  • Простое API
  • Улучшенные запросы (Больше примитивов, Query Language, Free text search)

+ Бонус

Windows-версия KeepSolid Sign

Выводы

Offline first был давно на нативных платформах

и постепенно становится актуальным в вебе

Offline First приложение – это

Persistent Storage

Browser APIs

Архитектура

Service Worker

Спасибо за внимание

Тимофей Лавренюк

@geek_timofey