通过 Cloud Function 发送给一组收件人时,FCM 非常缓慢且不可靠

FCM very slow and unreliable when sending to a group of recipients through Cloud Function

我有以下功能:

  1. 监听文档(短信)创建
  2. 抓取群聊成员ID
  3. 获取每个成员的 FCM 代币
  4. 使用 for 循环,向群组成员发送消息
exports.sendChatMessage = functions.firestore
  .document("chats/{mealID}/messages/{messageID}")
  .onCreate((snap, context) => {
    const data = snap.data();
    const mealID = context.params.mealID;
    const senderID = data.senderID;
    const senderName = data.senderName;
    const messageContent = data.content;

    var docRef = db.collection("chats").doc(mealID);
    docRef
      .get()
      .then((doc) => {
        if (doc.exists) {
          const docData = doc.data();
          const mealName = docData.name;
          const userStatus = docData.userStatus;
          var users = docData.to;
          var eligibleUsers = users.filter(
            (user) => userStatus[user] == "accepted"
          );
          eligibleUsers.push(docData.from);

          // get fcmTokens from eligibleUsers and send the messagme
          db.collection("users")
            .where("uid", "in", eligibleUsers)
            .get()
            .then((snapshot) => {
              var fcmTokens = [];
              var thumbnailPicURL = "";
              // get thumbnailpic of the sender and collect fcmTokens
              snapshot.forEach((doc) => {
                if (doc.data().uid == senderID) {
                  thumbnailPicURL =
                    doc.data().thumbnailPicURL == null
                      ? "https://i.imgur.com/8wSudUk.png"
                      : doc.data().thumbnailPicURL;
                } else {
                  fcmTokens.push(doc.data().fcmToken);
                }
              });

              // send the message fcmTokens
              fcmTokens.forEach((token) => {
                if (token != "") {
                  const fcmMessage = {
                    message: {
                      token: token,
                      notification: {
                        title: mealName,
                        body: senderName + ": " + messageContent,
                        image: thumbnailPicURL,
                      },
                      apns: {
                        payload: {
                          aps: {
                            category: "MESSAGE_RECEIVED",
                          },
                          MEAL_ID: mealID,
                        },
                      },
                    },
                  };
                  tokenManger.sendFcmMessage(fcmMessage);
                }
              });
              return true;
            });
        } else {
          // doc.data() will be undefined in this case
          console.log("No such document!");
          return false;
        }
      })
      .catch((error) => {
        console.log("Error getting document:", error);
        return false;
      });
    return true;
  });

我的发送函数来自一个帮助文件,该文件使用 HTTP V1 protocol 构建发送请求:

const { google } = require("googleapis");
const https = require("https");
const MESSAGING_SCOPE = "https://www.googleapis.com/auth/firebase.messaging";
const SCOPES = [MESSAGING_SCOPE];
const PROJECT_ID = MY_PROJECT_ID;
const HOST = "fcm.googleapis.com";
const PATH = "/v1/projects/" + PROJECT_ID + "/messages:send";

exports.getAccessToken = () => {
  return new Promise(function (resolve, reject) {
    const key = require("./service-account.json");
    var jwtClient = new google.auth.JWT(
      key.client_email,
      null,
      key.private_key,
      SCOPES,
      null
    );
    jwtClient.authorize(function (err, tokens) {
      if (err) {
        reject(err);
        return;
      }
      resolve(tokens.access_token);
    });
  });
};

//send message
exports.sendFcmMessage = (fcmMessage) => {
  this.getAccessToken().then(function (accessToken) {
    var options = {
      hostname: HOST,
      path: PATH,
      method: "POST",
      headers: {
        Authorization: "Bearer " + accessToken,
      },
      // … plus the body of your notification or data message
    };
    var request = https.request(options, function (resp) {
      resp.setEncoding("utf8");
      resp.on("data", function (data) {
        console.log("Message sent to Firebase for delivery, response:");
        console.log(data);
      });
    });
    request.on("error", function (err) {
      console.log("Unable to send message to Firebase");
      console.log(err);
    });
    request.write(JSON.stringify(fcmMessage));
    request.end();
  });
};

它在模拟器中运行良好,但一旦部署,就会有明显的延迟(~3 分钟):

我还注意到控制台显示云函数在 sendFcmMessage 记录成功消息之前完成执行。

我在网上做了一些调查,看来这可能与 Promise 的使用有关,但我不确定这是唯一的原因还是与我的 -循环。

我发现罪魁祸首是我对 db 的查询。就像@samthecodingman 评论的那样,我正在创建浮动的 Promises。

最初,我有这样的代码:

db.collection("users")
            .where("uid", "in", eligibleUsers)
            .get()
            .then((snapshot) => {...}

我需要做的就是return那个电话:

return db.collection("users")
            .where("uid", "in", eligibleUsers)
            .get()
            .then((snapshot) => {...}

虽然还不是即时配送,但现在快多了。

问题

总结一下这个问题,您正在创建“浮动承诺”或启动其他异步任务(如 sendFcmMessage 中),而您没有 return 承诺,因为它们使用回调。

在部署的函数中,一旦函数 return 的结果或 Promise 链解析,所有进一步的操作都应被视为永远不会执行 as documented here。 “非活动”功能可能随时终止,受到严重限制,您进行的任何网络调用(如在数据库中设置数据或调用 FCM)可能永远不会执行。

当您在记录其他消息之前看到函数完成日志消息(“函数执行已...”)时,表明您没有正确链接承诺。当您看到这个时,您需要查看您的代码 运行 并确认您是否有任何“浮动承诺”或正在使用 callback-based API。一旦您将 callback-based API 更改为使用 promises,然后确保它们都正确链接在一起,您应该会看到性能的显着提升。

修复

正在将消息数据发送到 FCM

在您的 tokenManger 文件中,getAccessToken() 可以稍微修改,sendFcmMessage 应该转换为 return a Promise:

exports.getAccessToken = () => {
  return new Promise(function (resolve, reject) {
    const key = require("./service-account.json");
    const jwtClient = new google.auth.JWT(
      key.client_email,
      null,
      key.private_key,
      SCOPES,
      null
    );
    jwtClient.authorize(
      (err, tokens) => err ? reject(err) : resolve(tokens.access_token)
    );
  });
};

//send message
exports.sendFcmMessage = (fcmMessage) => {
  // CHANGED: return the Promise
  return this.getAccessToken().then(function (accessToken) {
    const options = {
      hostname: HOST,
      path: PATH,
      method: "POST",
      headers: {
        Authorization: "Bearer " + accessToken,
      },
      // … plus the body of your notification or data message
    };
    // CHANGED: convert to Promise:
    return new Promise((resolve, reject) => {
      const request = https.request(options, (resp) => {
        resp.setEncoding("utf8");
        resp.on("data", resolve);
        resp.on("error", reject);
      });
      request.on("error", reject);
      request.write(JSON.stringify(fcmMessage));
      request.end();
    });
  });
};

但是,上面的代码是为 googleapis ^52.1.0google-auth-library ^6.0.3 构建的。这些模块的现代版本分别是 v92.0.0v7.11.0。这意味着您应该真正更新代码以使用这些更高版本,如下所示:

// Import JWT module directly
const { JWT } = require('google-auth-library');
// FIREBASE_CONFIG is a JSON string available in Cloud Functions
const PROJECT_ID = JSON.parse(process.env.FIREBASE_CONFIG).projectId;
const FCM_ENDPOINT = `https://fcm.googleapis.com/v1/projects/${PROJECT_ID}/messages:send`;
const FCM_SCOPES = ["https://www.googleapis.com/auth/firebase.messaging"];

exports.sendFcmMessage = (fcmMessage) => {
  const key = require("./service-account.json"); // consider moving outside of function (so it throws an error during deployment if its missing)
  const client = new JWT({
    email: key.client_email,
    key: key.private_key,
    scopes: FCM_SCOPES
  });
  return client.request({ // <-- this uses `gaxios`, Google's fork of `axios` built for Promise-based APIs
    url: FCM_ENDPOINT,
    method: "POST",
    data: fcmMessage
  });
}

更好的是,只需使用 messaging APIs provided by the Firebase Admin SDKs 为您处理细节。只需根据需要向其提供消息和令牌即可。

import { initializeApp } from "firebase-admin/app";
import { getMessaging } from "firebase-admin/messaging";

initializeApp(); // initializes using default credentials provided by Cloud Functions
const fcm = getMessaging();

fcm.send(message) // send to one (uses the given token)
fcm.sendAll(messagesArr) // send to many at once (each message uses the given token)
fcm.sendMulticast(message) // send to many at once (uses a `tokens` array instead of `token`)

云函数

更新主云功能,您将获得:

exports.sendChatMessage = functions.firestore
  .document("chats/{mealID}/messages/{messageID}")
  .onCreate((snap, context) => {
    const mealID = context.params.mealID;
    const { senderID, senderName, content: messageContent } = snap.data();

    const docRef = db.collection("chats").doc(mealID);
    
    /* --> */ return docRef
      .get()
      .then((doc) => {
        if (!doc.exists) { // CHANGED: Fail fast and avoid else statements
          console.log(`Could not find "chat:${mealID}"!`);
          return false;
        }

        const { userStatus, to: users, name: mealName, from: fromUser } = doc.data();
        const eligibleUsers = users.filter(
          (user) => userStatus[user] == "accepted"
        );
        eligibleUsers.push(fromUser);

        // get fcmTokens from eligibleUsers and send the message
        /* --> */ return db.collection("users")
          .where("uid", "in", eligibleUsers) // WARNING: This will only work for up to 10 users! You'll need to break it up into chunks of 10 if there are more.
          .get()
          .then(async (snapshot) => {
            const fcmTokens = [];
            let thumbnailPicURL = "";
            // get thumbnailpic of the sender and collect fcmTokens
            snapshot.forEach((doc) => {
              if (doc.get("uid") == senderID) {
                thumbnailPicURL = doc.get("thumbnailPicURL"); // update with given thumbnail pic
              } else {
                fcmTokens.push(doc.get("fcmToken"));
              }
            });

            const baseMessage = {
              notification: {
                title: mealName,
                body: senderName + ": " + messageContent,
                image: thumbnailPicURL || "https://i.imgur.com/8wSudUk.png", // CHANGED: specified fallback image here
              },
              apns: {
                payload: {
                  aps: {
                    category: "MESSAGE_RECEIVED",
                  },
                  MEAL_ID: mealID,
                },
              }
            }

            // log error if fcmTokens empty?

            // ----- OPTION 1 -----
            // send the message to each fcmToken
            const messagePromises = fcmTokens.map((token) => {
              if (!token) // handle "" and undefined
                return; // skip
              
              /* --> */ return tokenManger
                .sendFcmMessage({
                  message: { ...baseMessage, token }
                })
                .catch((err) => { // catch the error here, so as many notifications are sent out as possible
                  console.error(`Failed to send message to "fcm:${token}"`, err);
                })
            });

            await Promise.all(messagePromises); // wait for all messages to be sent out
            // --------------------
            
            // ----- OPTION 2 -----
            // send the message to each fcmToken
            await getMessaging().sendAll(
              fcmTokens.map((token) => ({ ...baseMessage, token }))
            );
            // --------------------

            return true;
          })
          .catch((error) => {
            console.log("Error sending messages:", error);
            return false;
          });
      })
      .catch((error) => {
        console.log("Error getting document:", error);
        return false;
      });
  });