简体   繁体   English

在 Service Worker 离线时处理文件上传

[英]Handling File Uploads When Offline With Service Worker

We have a web app (built using AngularJS) that we're gradually adding PWA 'features' too (service worker, launchable, notifications, etc).我们有一个 Web 应用程序(使用 AngularJS 构建),我们也在逐渐添加 PWA 的“功能”(服务工作者、可启动、通知等)。 One of the features our web app has is the ability to complete a web form while offline.我们的网络应用程序具有的功能之一是能够在离线时完成网络表单。 At the moment, we store the data in IndexedDB when offline, and simply encourage the user to push that data to the server once they're online ("This form is saved to your device. Now you're back online, you should save it to the cloud...").目前,我们在离线时将数据存储在 IndexedDB 中,并简单地鼓励用户在线后将该数据推送到服务器(“此表单已保存到您的设备。现在您重新在线,您应该保存它到云端...”)。 We will do this automatically at some point, but that's not necessary at the moment.我们会在某个时候自动执行此操作,但目前没有必要。

We are adding a feature to these web forms, whereby the user will be able to attach files (images, documents) to the form, perhaps at several points throughout the form.我们正在向这些 Web 表单添加一项功能,用户可以借此将文件(图像、文档)附加到表单中,可能是在整个表单的多个位置。

My question is this - is there a way for service worker to handle file uploads?我的问题是 - Service Worker 有没有办法处理文件上传? To somehow - perhaps - store the path to the file to be uploaded, when offline, and push that file up once the connection has been restored?以某种方式 - 也许 - 在离线时存储要上传的文件的路径,并在连接恢复后推送该文件? Would this work on mobile devices, as do we have access to that 'path' on those devices?这是否适用于移动设备,因为我们可以在这些设备上访问该“路径”吗? Any help, advice or references would be much appreciated.任何帮助、建议或参考将不胜感激。

When the user selects a file via an <input type="file"> element, we are able to get the selected file(s) via fileInput.files .当用户通过<input type="file">元素选择文件时,我们可以通过fileInput.files获取所选文件。 This gives us a FileList object, each item in it being a File object representing the selected file(s).这为我们提供了一个FileList对象,其中的每个项目都是一个File对象,代表所选文件。 FileList and File are supported by HTML5's Structured Clone Algorithm . HTML5 的Structured Clone Algorithm支持FileListFile

When adding items to an IndexedDB store, it creates a structured clone of the value being stored.将项目添加到 IndexedDB 存储时,它会创建所存储值的结构化克隆。 Since FileList and File objects are supported by the structured clone algorithm, this means that we can store these objects in IndexedDB directly.由于结构化克隆算法支持FileListFile对象,这意味着我们可以直接将这些对象存储在 IndexedDB 中。

To perform those file uploads once the user goes online again, you can use the Background Sync feature of service workers.要在用户再次上线后执行这些文件上传,您可以使用 Service Workers 的后台同步功能。 Here's an introductory article on how to do that.这是关于如何做到这一点的介绍性文章 There are a lot of other resources for that as well.还有很多其他资源。

In order to be able to include file attachments in your request once your background sync code runs, you can use FormData .为了在后台同步代码运行后能够在您的请求中包含文件附件,您可以使用FormData FormData s allow adding File objects into the request that will be sent to your backend, and it is available from within the service worker context. FormData允许将File对象添加到将发送到后端的请求中,并且它可以在 service worker 上下文中使用。

One way to handle file uploads/deletes and almost everything, is by keeping track of all the changes made during the offline requests.处理文件上传/删除和几乎所有内容的一种方法是跟踪离线请求期间所做的所有更改。 We can create a sync object with two arrays inside, one for pending files that will need to be uploaded and one for deleted files that will need to be deleted when we'll get back online.我们可以创建一个包含两个数组的sync对象,一个用于需要上传的待处理文件,另一个用于在我们重新上线时需要删除的已删除文件。

tl;dr tl;博士

Key phases关键阶段


  1. Service Worker Installation Service Worker 安装


    • Along with static data, we make sure to fetch dynamic data as the main listing of our uploaded files (in the example case /uploads GET returns JSON data with the files).除了静态数据,我们确保获取动态数据作为我们上传文件的主要列表(在示例中/uploads GET返回带有文件的 JSON 数据)。

      服务工作者安装

  2. Service Worker Fetch服务工作者获取


    • Handling the service worker fetch event, if the fetch fails, then we have to handle the requests for the files listing, the requests that upload a file to the server and the request that deletes a file from the server.处理 service worker fetch事件,如果 fetch 失败,那么我们必须处理文件列表的请求、上传文件到服务器的请求和从服务器删除文件的请求。 If we don't have any of these requests, then we return a match from the default cache.如果我们没有这些请求中的任何一个,那么我们会从默认缓存中返回一个匹配项。

      • Listing GET列表GET
        We get the cached object of the listing (in our case /uploads ) and the sync object.我们得到列表的缓存对象(在我们的例子中是/uploads )和sync对象。 We concat the default listing files with the pending files and we remove the deleted files and we return new response object with a JSON result as the server would have returned it.我们concat默认列表文件与pending文件,我们删除deleted文件,我们返回新响应对象与作为服务器将返回它的JSON结果。
      • Uloading PUT上传PUT
        We get the cached listing files and the sync pending files from the cache.我们从缓存中获取缓存的列表文件和sync pending文件。 If the file isn't present, then we create a new cache entry for that file and we use the mime type and the blob from the request to create a new Response object that it will be saved to the default cache.如果文件不存在,那么我们为该文件创建一个新的缓存条目,我们使用 mime 类型和请求中的blob创建一个新的Response对象,它将被保存到默认缓存中。
      • Deleting DELETE删除DELETE
        We check in the cached uploads and if the file is present we delete the entry from both the listing array and the cached file.我们检查缓存的上传,如果文件存在,我们从列表数组和缓存文件中删除条目。 If the file is pending we just delete the entry from the pending array, else if it's not already in the deleted array, then we add it.如果文件处于待处理状态,我们只需从pending数组中删除该条目,否则如果它不在已deleted数组中,则我们添加它。 We update listing, files and sync object cache at the end.我们在最后更新列表、文件和同步对象缓存。

      服务工作者获取

  3. Syncing同步


    • When the online event gets triggered, we try to synchronize with the server.online事件被触发时,我们尝试与服务器同步。 We read the sync cache.我们读取sync缓存。

      • If there are pending files, then we get each file Response object from cache and we send a PUT fetch request back to the server.如果有待处理的文件,那么我们从缓存中获取每个文件Response对象,然后将PUT fetch请求发送回服务器。
      • If there are deleted files, then we send a DELETE fetch request for each file to the server.如果有被删除的文件,那么我们向服务器发送每个文件的DELETE fetch请求。
      • Finally, we reset the sync cache object.最后,我们重置sync缓存对象。

      同步到服务器

Code implementation代码实现


(Please read the inline comments) (请阅读内嵌评论)

Service Worker Install服务工作者安装

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
    }))),
  ])
  );
});

Service Worker Fetch服务工作者获取

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 Listing GET Service Worker 获取列表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 Fetch Uloading PUT Service Worker 获取Uloading 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 Fetch Deleting DELETE Service Worker Fetch删除DELETE

// 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 });
}

Synching同步

// 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: []
})));

Example PWA示例 PWA


I have created a PWA example that implements all these, which you can find and test here .我创建了一个实现所有这些的 PWA 示例,您可以在此处找到并测试。 I have tested it using Chrome and Firefox and using Firefox Android on a mobile device.我已经使用 Chrome 和 Firefox 以及在移动设备上使用 Firefox Android 对其进行了测试。

You can find the full source code of the application ( including an express server ) in this Github repository: https://github.com/clytras/pwa-sandbox .您可以在此 Github 存储库中找到该应用程序的完整源代码(包括一个express服务器): https : //github.com/clytras/pwa-sandbox

The Cache API is designed to store a request (as the key) and a response (as the value) in order to cache a content from the server, for the web page.缓存 API 旨在存储请求(作为键)和响应(作为值),以便缓存来自服务器的网页内容。 Here, we're talking about caching user input for future dispatch to the server.在这里,我们讨论的是缓存用户输入以备将来分派到服务器。 In other terms, we're not trying to implement a cache , but a message broker , and that's not currently something handled by the Service Worker spec ( Source ).换句话说,我们不是要实现缓存,而是要实现消息代理,而目前 Service Worker 规范 ( Source ) 无法处理这些内容。

You can figure it out by trying this code:您可以通过尝试以下代码来弄清楚:

HTML: HTML:

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

JavaScript: 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' }));
});

Service Worker:服务人员:

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();
    }));
});

Which throws:哪个抛出:

TypeError: Request method 'POST' is unsupported类型错误:不支持请求方法“POST”

TypeError: Request method 'PUT' is unsupported类型错误:不支持请求方法“PUT”

TypeError: Request method 'PATCH' is unsupported类型错误:不支持请求方法“PATCH”

Since, the Cache API can't be used, and following the Google guidelines , IndexedDB is the best solution as a data store for ongoing requests.由于无法使用缓存 API,并且遵循Google 指南,IndexedDB 是作为持续请求的数据存储的最佳解决方案。 Then, the implementation of a message broker is the responsibility of the developer, and there is no unique generic implementation that will cover all of the use cases.然后,消息代理的实现是开发人员的责任,并且没有覆盖所有用例的唯一通用实现。 There are many parameters that will determine the solution:有许多参数将决定解决方案:

  • Which criteria will trigger the use of the message broker instead of the network?哪些标准会触发使用消息代理而不是网络? window.navigator.onLine ? window.navigator.onLine A certain timeout?某个超时? Other?其他?
  • Which criteria should be used to start trying to forward ongoing requests on the network?应该使用哪个标准来开始尝试在网络上转发正在进行的请求? self.addEventListener('online', ...) ? self.addEventListener('online', ...) navigator.connection ? navigator.connection ?
  • Should requests respect the order or should they be forwarded in parallel?请求应该尊重顺序还是应该并行转发? In other terms, should they be considered as dependent on each other, or not?换句话说,它们是否应该被视为相互依赖?
  • If run in parallel, should they be batched to prevent a bottleneck on the network?如果并行运行,是否应该将它们分批处理以防止网络出现瓶颈?
  • In case the network is considered available, but the requests still fail for some reason, which retry logic to implement?如果网络被认为可用,但由于某种原因请求仍然失败,要实现哪种重试逻辑? Exponential backoff ?指数退避? Other?其他?
  • How to notify the user that their actions are in a pending state while they are?如何通知用户他们的操作处于挂起状态?
  • ... ...

This is really very broad for a single StackOverflow answer.对于单个 StackOverflow 答案来说,这确实非常广泛。

That being said, here is a minimal working solution:话虽如此,这是一个最小的工作解决方案:

HTML: HTML:

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

JavaScript: 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', function () {
  fetch('api');
});

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

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

Service Worker:服务人员:

self.importScripts('https://unpkg.com/idb@5.0.1/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);

Some words about the solution: it allows to cache the PUT request for future GET requests, and it also stores the PUT request into an IndexedDB database for future sync.关于解决方案的一些话:它允许缓存 PUT 请求以供将来的 GET 请求使用,并且还将 PUT 请求存储到 IndexedDB 数据库中以备将来同步。 About the key, I was inspired by Angular's TransferHttpCacheInterceptor which allows to serialize backend requests on the server-side rendered page for use by the browser-rendered page.关于关键,我的灵感来自 Angular 的TransferHttpCacheInterceptor ,它允许在服务器端呈现的页面上序列化后端请求,以供浏览器呈现的页面使用。 It uses <verb>.<url> as the key.它使用<verb>.<url>作为键。 That supposes a request will override another request with the same verb and URL.假设一个请求将覆盖另一个具有相同动词和 URL 的请求。

This solution also supposes that the backend does not return 204 No content as a response of a PUT request, but 200 with the entity in the body.该解决方案还假设后端不返回204 No content作为 PUT 请求的响应,而是返回200与主体中的实体。

I was also stumbling upon it lately.我最近也在绊倒它。 Here is what I am doing to store in index db and return response when offline.这是我在索引数据库中存储并在离线时返回响应的操作。

const storeFileAndReturnResponse = async function (request, urlSearchParams) {
  let requestClone = request.clone();

  let formData = await requestClone.formData();

  let tableStore = "fileUploads";

  let fileList = [];
  let formDataToStore = [];
  //Use formData.entries to iterate collection - this assumes you used input type= file
  for (const pair of formData.entries()) {
    let fileObjectUploaded = pair[1];
    //content holds the arrayBuffer (blob) of the uploaded file
    formDataToStore.push({
      key: pair[0],
      value: fileObjectUploaded,
      content: await fileObjectUploaded.arrayBuffer(),
    });

    let fileName = fileObjectUploaded.name;
    fileList.push({
      fileName: fileName,
    });
  }

  let payloadToStore = {
    parentId: parentId,
    fileList: fileList,
    formDataKeyValue: formDataToStore,
  };
  (await idbContext).put(tableStore, payloadToStore);

  return {
    UploadedFileList: fileList,
  };
};

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM