web-dev-qa-db-ja.com

ServiceWorkerでオフラインの場合のファイルアップロードの処理

PWAの機能(Service Worker、起動可能、通知など)も徐々に追加しているWebアプリ(AngularJSを使用して構築)があります。私たちのウェブアプリが持っている機能の1つは、オフラインでウェブフォームに記入する機能です。現時点では、オフライン時にIndexedDBにデータを保存し、オンラインになったらそのデータをサーバーにプッシュするようユーザーに促します(「このフォームはデバイスに保存されます。オンラインに戻ったので、保存する必要がありますクラウドに...」)。これはいつか自動的に行われますが、現時点では必要ありません。

これらのWebフォームに機能を追加します。これにより、ユーザーは、おそらくフォーム全体のいくつかのポイントで、ファイル(画像、ドキュメント)をフォームに添付できるようになります。

私の質問はこれです-ServiceWorkerがファイルのアップロードを処理する方法はありますか?どういうわけか-おそらく-オフライン時にアップロードするファイルへのパスを保存し、接続が復元されたらそのファイルをプッシュアップしますか?これらのデバイスでその「パス」にアクセスできるので、これはモバイルデバイスで機能しますか?任意のヘルプ、アドバイスまたは参照をいただければ幸いです。

14
user7043436

ユーザーが<input type="file">要素を介してファイルを選択すると、選択したファイルをfileInput.filesを介して取得できます。これにより、FileListオブジェクトが得られ、その中の各アイテムは、選択したファイルを表すFileオブジェクトになります。 FileListFileは、HTML5の 構造化クローンアルゴリズム でサポートされています。

IndexedDBストアにアイテムを追加すると、格納されている値の構造化クローンが作成されます。 FileListおよびFileオブジェクトは構造化クローンアルゴリズムでサポートされているため、これらのオブジェクトをIndexedDBに直接格納できることを意味します。

ユーザーが再びオンラインになったときにこれらのファイルのアップロードを実行するには、ServiceWorkerのバックグラウンド同期機能を使用できます。これが 紹介記事 その方法についてです。そのための他のリソースもたくさんあります。

バックグラウンド同期コードの実行後に添付ファイルをリクエストに含めることができるようにするには、 FormData を使用できます。 FormDatasを使用すると、バックエンドに送信されるリクエストにFileオブジェクトを追加でき、ServiceWorkerコンテキスト内から利用できます。

10
Arnelle Balane

ファイルのアップロード/削除およびほとんどすべてを処理する1つの方法は、オフライン要求中に行われたすべての変更を追跡することです。 2つの配列を含むsyncオブジェクトを作成できます。1つはアップロードが必要な保留中のファイル用で、もう1つはオンラインに戻ったときに削除する必要がある削除済みファイル用です。

tl; dr

重要なフェーズ


  1. サービスワーカーのインストール


    • 静的データに加えて、アップロードされたファイルのメインリストとして動的データをフェッチするようにします(この例では/uploadsGETはファイルとともにJSONデータを返します)。

      Service Worker Install

  2. サービスワーカーフェッチ


    • Service Worker fetchイベントの処理で、フェッチが失敗した場合は、ファイルリストのリクエスト、サーバーにファイルをアップロードするリクエスト、サーバーからファイルを削除するリクエストを処理する必要があります。これらのリクエストがない場合は、デフォルトのキャッシュから一致するものを返します。

      • リストGET
        リストのキャッシュされたオブジェクトを取得します(この場合は/uploads)およびsyncオブジェクト。 concatファイルを含むデフォルトのリストファイルをpendingし、deletedファイルを削除すると、サーバーが返すように、JSON結果を含む新しい応答オブジェクトが返されます。
      • Uloading PUT
        キャッシュされたリストファイルとsyncpendingファイルをキャッシュから取得します。ファイルが存在しない場合は、そのファイルの新しいキャッシュエントリを作成し、リクエストのmimeタイプとblobを使用して、新しいResponseオブジェクトを作成します。デフォルトのキャッシュに保存されます。
      • DELETEを削除します
        キャッシュされたアップロードをチェックインし、ファイルが存在する場合は、リスト配列とキャッシュされたファイルの両方からエントリを削除します。ファイルが保留中の場合は、エントリをpending配列から削除します。それ以外の場合は、まだdeleted配列にない場合は、追加します。最後に、リスト、ファイル、同期オブジェクトキャッシュを更新します。

      Service Worker Fetch

  3. 同期


    • onlineイベントがトリガーされると、サーバーとの同期を試みます。 syncキャッシュを読み取ります。

      • 保留中のファイルがある場合は、キャッシュから各ファイルResponseオブジェクトを取得し、PUTfetchリクエストをサーバーに送り返します。
      • 削除されたファイルがある場合は、ファイルごとにDELETEfetchリクエストをサーバーに送信します。
      • 最後に、syncキャッシュオブジェクトをリセットします。

      Synching to server

コードの実装


(インラインコメントをお読みください)

ServiceWorkerのインストール

const cacheName = 'pwasndbx';
const syncCacheName = 'pwasndbx-sync';
const pendingName = '__pending';
const syncName = '__sync';

const filesToCache = [
  '/',
  '/uploads',
  '/styles.css',
  '/main.js',
  '/utils.js',
  '/favicon.ico',
  '/manifest.json',
];

/* Start the service worker and cache all of the app's content */
self.addEventListener('install', function(e) {
  console.log('SW:install');

  e.waitUntil(Promise.all([
    caches.open(cacheName).then(async function(cache) {
      let cacheAdds = [];

      try {
        // Get all the files from the uploads listing
        const res = await fetch('/uploads');
        const { data = [] } = await res.json();
        const files = data.map(f => `/uploads/${f}`);

        // Cache all uploads files urls
        cacheAdds.Push(cache.addAll(files));
      } catch(err) {
        console.warn('PWA:install:fetch(uploads):err', err);
      }

      // Also add our static files to the cache
      cacheAdds.Push(cache.addAll(filesToCache));
      return Promise.all(cacheAdds);
    }),
    // Create the sync cache object
    caches.open(syncCacheName).then(cache => cache.put(syncName, jsonResponse({
      pending: [], // For storing the penging files that later will be synced
      deleted: []  // For storing the files that later will be deleted on sync
    }))),
  ])
  );
});

サービスワーカーフェッチ

self.addEventListener('fetch', function(event) {
  // Clone request so we can consume data later
  const request = event.request.clone();
  const { method, url, headers } = event.request;

  event.respondWith(
    fetch(event.request).catch(async function(err) {
      const { headers, method, url } = event.request;

      // A custom header that we set to indicate the requests come from our syncing method
      // so we won't try to fetch anything from cache, we need syncing to be done on the server
      const xSyncing = headers.get('X-Syncing');

      if(xSyncing && xSyncing.length) {
        return caches.match(event.request);
      }

      switch(method) {
        case 'GET':
          // Handle listing data for /uploads and return JSON response
          break;
        case 'PUT':
          // Handle upload to cache and return success response
          break;
        case 'DELETE':
          // Handle delete from cache and return success response
          break;
      }

      // If we meet no specific criteria, then lookup to the cache
      return caches.match(event.request);
    })
  );
});

function jsonResponse(data, status = 200) {
  return new Response(data && JSON.stringify(data), {
    status,
    headers: {'Content-Type': 'application/json'}
  });
}

Service Worker FetchリストGET

if(url.match(/\/uploads\/?$/)) { // Failed to get the uploads listing
  // Get the uploads data from cache
  const uploadsRes = await caches.match(event.request);
  let { data: files = [] } = await uploadsRes.json();

  // Get the sync data from cache
  const syncRes = await caches.match(new Request(syncName), { cacheName: syncCacheName });
  const sync = await syncRes.json();

  // Return the files from uploads + pending files from sync - deleted files from sync
  const data = files.concat(sync.pending).filter(f => sync.deleted.indexOf(f) < 0);

  // Return a JSON response with the updated data
  return jsonResponse({
    success: true,
    data
  });
}

Service Worker FetchUloading PUT

// Get our custom headers
const filename = headers.get('X-Filename');
const mimetype = headers.get('X-Mimetype');

if(filename && mimetype) {
  // Get the uploads data from cache
  const uploadsRes = await caches.match('/uploads', { cacheName });
  let { data: files = [] } = await uploadsRes.json();

  // Get the sync data from cache
  const syncRes = await caches.match(new Request(syncName), { cacheName: syncCacheName });
  const sync = await syncRes.json();

  // If the file exists in the uploads or in the pendings, then return a 409 Conflict response
  if(files.indexOf(filename) >= 0 || sync.pending.indexOf(filename) >= 0) {
    return jsonResponse({ success: false }, 409);
  }

  caches.open(cacheName).then(async (cache) => {
    // Write the file to the cache using the response we cloned at the beggining
    const data = await request.blob();
    cache.put(`/uploads/${filename}`, new Response(data, {
      headers: { 'Content-Type': mimetype }
    }));

    // Write the updated files data to the uploads cache
    cache.put('/uploads', jsonResponse({ success: true, data: files }));
  });

  // Add the file to the sync pending data and update the sync cache object
  sync.pending.Push(filename);
  caches.open(syncCacheName).then(cache => cache.put(new Request(syncName), jsonResponse(sync)));

  // Return a success response with fromSw set to tru so we know this response came from service worker
  return jsonResponse({ success: true, fromSw: true });
}

Service Worker FetchDELETEの削除

// Get our custom headers
const filename = headers.get('X-Filename');

if(filename) {
  // Get the uploads data from cache
  const uploadsRes = await caches.match('/uploads', { cacheName });
  let { data: files = [] } = await uploadsRes.json();

  // Get the sync data from cache
  const syncRes = await caches.match(new Request(syncName), { cacheName: syncCacheName });
  const sync = await syncRes.json();

  // Check if the file is already pending or deleted
  const pendingIndex = sync.pending.indexOf(filename);
  const uploadsIndex = files.indexOf(filename);

  if(pendingIndex >= 0) {
    // If it's pending, then remove it from pending sync data
    sync.pending.splice(pendingIndex, 1);
  } else if(sync.deleted.indexOf(filename) < 0) {
    // If it's not in pending and not already in sync for deleting,
    // then add it for delete when we'll sync with the server
    sync.deleted.Push(filename);
  }

  // Update the sync cache
  caches.open(syncCacheName).then(cache => cache.put(new Request(syncName), jsonResponse(sync)));

  // If the file is in the uplods data
  if(uploadsIndex >= 0) {
    // Updates the uploads data
    files.splice(uploadsIndex, 1);
    caches.open(cacheName).then(async (cache) => {
      // Remove the file from the cache
      cache.delete(`/uploads/${filename}`);
      // Update the uploads data cache
      cache.put('/uploads', jsonResponse({ success: true, data: files }));
    });
  }

  // Return a JSON success response
  return jsonResponse({ success: true });
}

同期

// Get the sync data from cache
const syncRes = await caches.match(new Request(syncName), { cacheName: syncCacheName });
const sync = await syncRes.json();

// If the are pending files send them to the server
if(sync.pending && sync.pending.length) {
  sync.pending.forEach(async (file) => {
    const url = `/uploads/${file}`;
    const fileRes = await caches.match(url);
    const data = await fileRes.blob();

    fetch(url, {
      method: 'PUT',
      headers: {
        'X-Filename': file,
        'X-Syncing': 'syncing' // Tell SW fetch that we are synching so to ignore this fetch
      },
      body: data
    }).catch(err => console.log('sync:pending:PUT:err', file, err));
  });
}

// If the are deleted files send delete request to the server
if(sync.deleted && sync.deleted.length) {
  sync.deleted.forEach(async (file) => {
    const url = `/uploads/${file}`;

    fetch(url, {
      method: 'DELETE',
      headers: {
        'X-Filename': file,
        'X-Syncing': 'syncing' // Tell SW fetch that we are synching so to ignore this fetch
      }
    }).catch(err => console.log('sync:deleted:DELETE:err', file, err));
  });
}

// Update and reset the sync cache object
caches.open(syncCacheName).then(cache => cache.put(syncName, jsonResponse({
  pending: [],
  deleted: []
})));

PWAの例


これらすべてを実装するPWAの例を作成しました。これを見つけて、テストすることができます ここ 。 ChromeとFirefoxを使用し、モバイルデバイスでFirefox Androidを使用してテストしました。

アプリケーションの完全なソースコード(expressサーバーを含む)は、次のGithubリポジトリにあります: https://github.com/ clytras/pwa-sandbox

5
Christos Lytras

Cache APIは、サーバーからWebページのコンテンツをキャッシュするために、要求(キーとして)と応答(値として)を格納するように設計されています。ここでは、サーバーへの将来のディスパッチのためにユーザー入力をキャッシュすることについて話します。言い換えれば、キャッシュではなくメッセージブローカーを実装しようとしていますが、それは現在、によって処理されているものではありません。 Service Worker仕様( ソース )。

あなたはこのコードを試すことによってそれを理解することができます:

HTML:

_<button id="get">GET</button>
<button id="post">POST</button>
<button id="put">PUT</button>
<button id="patch">PATCH</button>
_

JavaScript:

_if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/service-worker.js', { scope: '/' }).then(function (reg) {
    console.log('Registration succeeded. Scope is ' + reg.scope);
  }).catch(function (error) {
    console.log('Registration failed with ' + error);
  });
};

document.getElementById('get').addEventListener('click', async function () {
  console.log('Response: ', await fetch('50x.html'));
});

document.getElementById('post').addEventListener('click', async function () {
  console.log('Response: ', await fetch('50x.html', { method: 'POST' }));
});

document.getElementById('put').addEventListener('click', async function () {
  console.log('Response: ', await fetch('50x.html', { method: 'PUT' }));
});

document.getElementById('patch').addEventListener('click', async function () {
  console.log('Response: ', await fetch('50x.html', { method: 'PATCH' }));
});
_

サービスワーカー:

_self.addEventListener('fetch', function (event) {
    var response;
    event.respondWith(fetch(event.request).then(function (r) {
        response = r;
        caches.open('v1').then(function (cache) {
            cache.put(event.request, response);
        }).catch(e => console.error(e));
        return response.clone();
    }));
});
_

スローするもの:

TypeError:リクエストメソッド「POST」はサポートされていません

TypeError:リクエストメソッド「PUT」はサポートされていません

TypeError:リクエストメソッド「PATCH」はサポートされていません

Cache APIは使用できないため、 Googleガイドライン に従うと、IndexedDBは進行中のリクエストのデータストアとして最適なソリューションです。次に、メッセージブローカーの実装は開発者の責任であり、すべてのユースケースをカバーする独自の汎用実装はありません。解決策を決定する多くのパラメータがあります:

  • ネットワークの代わりにメッセージブローカーの使用をトリガーする基準はどれですか? _window.navigator.onLine_?特定のタイムアウト?その他?
  • ネットワーク上で進行中のリクエストの転送を開始するには、どの基準を使用する必要がありますか? self.addEventListener('online', ...)? _navigator.connection_?
  • リクエストは注文を尊重する必要がありますか、それとも並行して転送する必要がありますか?言い換えれば、それらは互いに依存していると見なされるべきですか?
  • 並行して実行する場合、ネットワークのボトルネックを防ぐためにバッチ処理する必要がありますか?
  • ネットワークが利用可能であると見なされても、何らかの理由で要求が失敗する場合、どの再試行ロジックを実装しますか? 指数バックオフ ?その他?
  • アクションが保留状態にあることをユーザーに通知するにはどうすればよいですか?
  • .。

これは、単一のStackOverflowの回答に対して非常に広範囲です。

そうは言っても、これが最小限の実用的な解決策です:

HTML:

_<input id="file" type="file">
<button id="sync">SYNC</button>
<button id="get">GET</button>
_

JavaScript:

_if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/service-worker.js', { scope: '/' }).then(function (reg) {
    console.log('Registration succeeded. Scope is ' + reg.scope);
  }).catch(function (error) {
    console.log('Registration failed with ' + error);
  });
};

document.getElementById('get').addEventListener('click', async function () {
  fetch('api');
});

document.getElementById('file').addEventListener('change', function () {
  fetch('api', { method: 'PUT', body: document.getElementById('file').files[0] });
});

document.getElementById('sync').addEventListener('click', async function () {
  navigator.serviceWorker.controller.postMessage('sync');
});
_

サービスワーカー:

_self.importScripts('https://unpkg.com/[email protected]/build/iife/index-min.js');

const { openDB, deleteDB, wrap, unwrap } = idb;

const dbPromise = openDB('put-store', 1, {
    upgrade(db) {
        db.createObjectStore('put');
    },
});

const idbKeyval = {
    async get(key) {
        return (await dbPromise).get('put', key);
    },
    async set(key, val) {
        return (await dbPromise).put('put', val, key);
    },
    async delete(key) {
        return (await dbPromise).delete('put', key);
    },
    async clear() {
        return (await dbPromise).clear('put');
    },
    async keys() {
        return (await dbPromise).getAllKeys('put');
    },
};

self.addEventListener('fetch', function (event) {
    if (event.request.method === 'PUT') {
        let body;
        event.respondWith(event.request.blob().then(file => {
            // Retrieve the body then clone the request, to avoid "body already used" errors
            body = file;
            return fetch(new Request(event.request.url, { method: event.request.method, body }));
        }).then(response => handleResult(response, event, body)).catch(() => handleResult(null, event, body)));

    } else if (event.request.method === 'GET') {
        event.respondWith(fetch(event.request).then(response => {
            return response.ok ? response : caches.match(event.request);
        }).catch(() => caches.match(event.request)));
    }
});

async function handleResult(response, event, body) {
    const getRequest = new Request(event.request.url, { method: 'GET' });
    const cache = await caches.open('v1');
    await idbKeyval.set(event.request.method + '.' + event.request.url, { url: event.request.url, method: event.request.method, body });
    const returnResponse = response && response.ok ? response : new Response(body);
    cache.put(getRequest, returnResponse.clone());
    return returnResponse;
}

// Function to call when the network is supposed to be available

async function sync() {
    const keys = await idbKeyval.keys();
    for (const key of keys) {
        try {
            const { url, method, body } = await idbKeyval.get(key);
            const response = await fetch(url, { method, body });
            if (response && response.ok)
                await idbKeyval.delete(key);
        }
        catch (e) {
            console.warn(`An error occurred while trying to sync the request: ${key}`, e);
        }
    }
}

self.addEventListener('message', sync);
_

ソリューションに関するいくつかの言葉:将来のGET要求のためにPUT要求をキャッシュすることができ、将来の同期のためにPUT要求をIndexedDBデータベースに格納することもできます。キーについては、Angularの TransferHttpCacheInterceptor に触発されました。これにより、サーバー側のレンダリングされたページでバックエンドリクエストをシリアル化して、ブラウザーでレンダリングされたページで使用できるようになります。キーとして_<verb>.<url>_を使用します。これは、リクエストが同じ動詞とURLを持つ別のリクエストをオーバーライドすると想定しています。

このソリューションでは、バックエンドがPUT要求の応答として_204 No content_を返すのではなく、本体にエンティティを含む_200_を返すことも想定しています。

2
Guerric P