使用 Web 推送库发送消息

Matt Gaunt

使用 Web 推送时遇到的一个难题是,触发推送消息非常“麻烦”。如需触发推送消息,应用需要按照网络推送协议向推送服务发出 POST 请求。如需在所有浏览器中使用推送功能,您需要使用 VAPID(也称为应用服务器密钥),这基本上需要设置一个值为证明您的应用可以向用户发送消息的标头。如需通过推送消息发送数据,需要对数据进行加密,并添加特定标头,以便浏览器能够正确解密消息。

触发推送的主要问题在于,如果您遇到问题,很难诊断问题。随着时间的推移和浏览器支持范围的扩大,这种情况有所改善,但远非易事。因此,我强烈建议您使用库来处理推送消息的加密、格式设置和触发。

如果您真的想了解这些库在执行什么操作,我们将在下一部分介绍。目前,我们将介绍如何管理订阅以及如何使用现有的 Web 推送库发出推送请求。

在本部分中,我们将使用 web-push Node 库。其他语言会有所不同,但差异不会太大。我们正在考虑使用 Node,因为它是 JavaScript,应该是最适合读者的。

我们将完成以下步骤:

  1. 将订阅发送到我们的后端并进行保存。
  2. 检索已保存的订阅并触发推送消息。

保存订阅

从数据库中保存和查询 PushSubscription 的方式因服务器端语言和数据库选择而异,但查看实现方式示例可能会有所帮助。

在演示网页中,PushSubscription 是通过发出简单的 POST 请求发送到后端的:

function sendSubscriptionToBackEnd(subscription) {
  return fetch('/api/save-subscription/', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(subscription),
  })
    .then(function (response) {
      if (!response.ok) {
        throw new Error('Bad status code from server.');
      }

      return response.json();
    })
    .then(function (responseData) {
      if (!(responseData.data && responseData.data.success)) {
        throw new Error('Bad response from server.');
      }
    });
}

演示中的 Express 服务器具有与 /api/save-subscription/ 端点匹配的请求监听器:

app.post('/api/save-subscription/', function (req, res) {

在此路线中,我们验证订阅只是为了确保请求正常且不包含垃圾内容:

const isValidSaveRequest = (req, res) => {
  // Check the request body has at least an endpoint.
  if (!req.body || !req.body.endpoint) {
    // Not a valid subscription.
    res.status(400);
    res.setHeader('Content-Type', 'application/json');
    res.send(
      JSON.stringify({
        error: {
          id: 'no-endpoint',
          message: 'Subscription must have an endpoint.',
        },
      }),
    );
    return false;
  }
  return true;
};

如果订阅有效,我们需要将其保存并返回适当的 JSON 响应:

return saveSubscriptionToDatabase(req.body)
  .then(function (subscriptionId) {
    res.setHeader('Content-Type', 'application/json');
    res.send(JSON.stringify({data: {success: true}}));
  })
  .catch(function (err) {
    res.status(500);
    res.setHeader('Content-Type', 'application/json');
    res.send(
      JSON.stringify({
        error: {
          id: 'unable-to-save-subscription',
          message:
            'The subscription was received but we were unable to save it to our database.',
        },
      }),
    );
  });

此演示使用 nedb 存储订阅,它是一个简单的基于文件的数据库,但您也可以使用自己选择的任何数据库。我们之所以使用此方法,是因为它无需任何设置。在生产环境中,您需要使用更可靠的服务。(我倾向于坚持使用老旧的 MySQL。)

function saveSubscriptionToDatabase(subscription) {
  return new Promise(function (resolve, reject) {
    db.insert(subscription, function (err, newDoc) {
      if (err) {
        reject(err);
        return;
      }

      resolve(newDoc._id);
    });
  });
}

发送推送消息

在发送推送消息时,我们最终需要某个事件来触发向用户发送消息的过程。常见的方法是创建一个管理页面,以便您配置和触发推送消息。不过,您可以创建一个在本地运行的程序,或者使用任何其他允许访问 PushSubscription 列表并运行代码以触发推送消息的方法。

我们的演示版有一个“类似于管理”的页面,可让您触发推送。由于这只是一个演示,因此它是一个公开页面。

我将介绍演示操作所涉及的每个步骤。这些都是入门级步骤,因此所有人(包括刚开始接触 Node 的用户)都可以跟着学。

在讨论用户订阅时,我们介绍了如何向 subscribe() 选项添加 applicationServerKey。我们需要在后端使用此私钥。

在演示中,这些值会以如下方式添加到我们的 Node 应用中(我知道这段代码很无聊,但只是想让您知道没有任何魔法):

const vapidKeys = {
  publicKey:
    'BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U',
  privateKey: 'UUxI4O8-FbRouAevSmBQ6o18hgE4nSG3qwvJTfKc-ls',
};

接下来,我们需要为 Node 服务器安装 web-push 模块:

npm install web-push --save

然后,在我们的 Node 脚本中,我们需要 web-push 模块,如下所示:

const webpush = require('web-push');

现在,我们可以开始使用 web-push 模块了。首先,我们需要向 web-push 模块告知我们的应用服务器密钥。(请注意,它们也称为 VAPID 密钥,因为这是规范的名称。)

const vapidKeys = {
  publicKey:
    'BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U',
  privateKey: 'UUxI4O8-FbRouAevSmBQ6o18hgE4nSG3qwvJTfKc-ls',
};

webpush.setVapidDetails(
  'mailto:web-push-book@gauntface.com',
  vapidKeys.publicKey,
  vapidKeys.privateKey,
);

请注意,我们还添加了“mailto:”字符串。此字符串需要是网址或 mailto 电子邮件地址。这条信息实际上会作为触发推送的请求的一部分发送到 Web 推送服务。这样做的原因是,如果网站推送服务需要与发件人联系,他们可以使用这些信息与对方联系。

至此,web-push 模块已可供使用,下一步是触发推送消息。

该演示使用模拟的管理控制台来触发推送消息。

“管理”页面的屏幕截图。

点击“触发推送消息”按钮会向 /api/trigger-push-msg/ 发出 POST 请求,这会触发后端发送推送消息,因此我们需要在 Express 中为此端点创建路由:

app.post('/api/trigger-push-msg/', function (req, res) {

收到此请求后,我们会从数据库中提取订阅,并针对每个订阅触发推送消息。

return getSubscriptionsFromDatabase().then(function (subscriptions) {
  let promiseChain = Promise.resolve();

  for (let i = 0; i < subscriptions.length; i++) {
    const subscription = subscriptions[i];
    promiseChain = promiseChain.then(() => {
      return triggerPushMsg(subscription, dataToSend);
    });
  }

  return promiseChain;
});

然后,函数 triggerPushMsg() 可以使用 web-push 库向所提供的订阅发送消息。

const triggerPushMsg = function (subscription, dataToSend) {
  return webpush.sendNotification(subscription, dataToSend).catch((err) => {
    if (err.statusCode === 404 || err.statusCode === 410) {
      console.log('Subscription has expired or is no longer valid: ', err);
      return deleteSubscriptionFromDatabase(subscription._id);
    } else {
      throw err;
    }
  });
};

调用 webpush.sendNotification() 会返回一个 promise。如果消息成功发送,promise 将解析,我们无需执行任何操作。如果 promise 被拒绝,您需要检查错误,因为它会告知您 PushSubscription 是否仍然有效。

如需确定推送服务的错误类型,最好查看状态代码。不同的推送服务会显示不同的错误消息,其中有些消息比其他消息更有帮助。

在此示例中,它会检查状态代码 404410,它们分别是“未找到”和“已不存在”的 HTTP 状态代码。如果我们收到其中任一错误,则表示订阅已过期或不再有效。在这些情况下,我们需要从数据库中移除订阅。

如果发生其他错误,我们只需 throw err,这会导致 triggerPushMsg() 返回的 promise 被拒绝。

在下一部分中,我们将更详细地介绍 Web 推送协议,并介绍一些其他状态代码。

循环遍历订阅后,我们需要返回 JSON 响应。

.then(() => {
res.setHeader('Content-Type', 'application/json');
    res.send(JSON.stringify({ data: { success: true } }));
})
.catch(function(err) {
res.status(500);
res.setHeader('Content-Type', 'application/json');
res.send(JSON.stringify({
    error: {
    id: 'unable-to-send-messages',
    message: `We were unable to send messages to all subscriptions : ` +
        `'${err.message}'`
    }
}));
});

我们已经介绍了主要的实现步骤:

  1. 创建一个 API 来将订阅从网页发送到后端,以便后端将其保存到数据库。
  2. 创建一个 API 来触发推送消息的发送(在本例中,是从模拟的管理控制台中调用的 API)。
  3. 从后端检索所有订阅,并使用某个 Web 推送库向每个订阅发送消息。

无论您的后端是 Node、PHP、Python 还是其他语言,实现推送的步骤都是相同的。

接下来,我们来看看这些 Web 推送库究竟在为我们做些什么。

下一步做什么

Codelab