使用 Firebase 的无服务器应用程序

2025-06-09

使用 Firebase 的无服务器应用程序

TLDR;

如果您一直在阅读,您就会知道我正在构建一个小部件,以便在博客文章中提供一些有趣的互动,以支持4C社区。

本文将介绍如何使用 FireStore 在 Firebase 中构建数据模型。我将介绍安全规则以及如何使用 Cloud Functions 创建 API。

动机

我正在描述构建下面交互式小部件的过程,投票并看看其他人是如何看待无服务器的

在下面投票!

4C 小部件海报

要求

我们的小部件需要以下内容:

  • 用户可以创建内容创建者帐户
  • 通过帐户,用户可以提供显示名称、头像、HTML 个人简介和个人资料网站的 URL
  • 有了账户,用户可以创建“文章”或“评论”
  • 文章允许用户指定其某篇帖子的 URL,并通过小部件跟踪和增强该帖子。文章将在小部件的其他实例上推荐
  • 评论允许用户创建小部件的独特配置,并将其嵌入到评论或帖子的其他部分
  • 评论和文章允许内容创建者配置要显示的小部件
  • 当显示小部件时,系统将跟踪该配置的浏览次数和唯一访问用户数
  • 小部件能够为读者提供与内容互动的成就和积分
  • 小部件可能会提供额外的响应式和交互功能,供插件开发者打造出色的体验。例如,进行投票或提供测验。小部件框架将提供一种强大且安全的方法来处理这些响应。

建筑学

我决定仅使用 Firebase 构建小部件后端框架。我选择使用 Firebase 身份验证、Firestore 作为数据库、Firebase 存储以及 Firebase Functions 提供 API。

我使用 Firebase Hosting 来托管该小部件。

Firebase 身份验证

小部件的所有用户都已登录,但除非您是内容创建者,否则这是一个匿名登录,它用于跟踪您的积分以及您对创建小部件体验的插件提供的答案。

内容创建者可以使用电子邮件、Github 或 Google 登录,创建一个可以访问网站管理区域的帐户。这些用户可以创建小部件的配置,以适应他们正在创建的内容。

Firestore

所有数据都存储在 Firestore 中,下文将介绍结构、安全性和表格的选择。Firestore 易于使用,但每次读取数据都需要付费,因此成本会迅速增加。在我使用该小部件发布内容的大多数日子里,数据量都超过了 5 万的免费限制。我将进一步详细介绍我如何尽力解决这个问题。

值得注意的是,Firestore 没有任何内置的聚合查询,这对于需要执行报告的小部件来说非常有限。聚合通常需要通过在写入数据时更新计数器来创建,读取大量数据进行报告会很快变得非常昂贵。

Firebase 函数

Firebase 的 Functions 功能允许您创建 API,还可以创建在数据更新时执行操作的“触发器”。我使用了这两种技术来创建这个小部件。

Firebase 存储

我不需要存储太多数据,但我允许用户上传头像,并将其存储在 Firebase 存储中(以用户 ID 为键的文件中)。就这样。

Firebase 托管

该小部件框架构建为一个 React 应用,并部署到 Firebase Hosting,后者为其提供管理界面和运行时界面。这里没什么可说的,只是我使用了规则,通过编写每个子路径来读取 index.html,以确保它作为 SPA 能够正常工作。

// firebase.json
{
  ...
  "hosting": {
     "public": "build",
     "ignore": [
         "firebase.json",
         "**/.*",
         "**/node_modules/**"
     ],
     "rewrites": [
         {
             "source": "**",
             "destination": "/index.html"
         }
     ]
}
Enter fullscreen mode Exit fullscreen mode

数据模型

为了支持这些要求,我提出了这个数据模型:

数据模型图

用户可写集合

该模型的核心是内容创建者可以写入的集合:

用户可写集合

所有其他集合都需要登录用户(匿名也可以)并且是只读的。

ID

集合中仅使用了 3 种 ID 类型。articleId 由nanoid在添加新文章时生成;user.uid来自 Firebase Auth;是tag一个文本字符串;还有一些特殊的以 开头的 ID,__但除此之外,它们都来自用户规范。

用户

Firebase 生成的用户记录也用于填充集合中我自己的记录。 和 的userprofiles数据每次发生变化时都会被复制过来。displayNamephotoURLemail

此外,此集合中的条目包括一个description用于传记和一个profileURL可选包含可链接到某处(如果在小部件中显示用户的头像时单击该头像)的条目。

文章

用户可以创建文章。评论是将comment字段设置为 的文章true

用户只能创建、更新和删除他们自己的userarticles子集合中的文章articles

当 userarticles/article 被保存时,Firebase 函数触发器会将记录复制到主articles表。出于安全考虑,系统管理员可以禁止主articles集合中的某篇文章,该函数可确保用户无法覆盖该文章。此外,当用户删除文章时,该文章不会从主集合中删除,但enabled标志会被设置为false

一篇文章包含一些关于原始帖子的元信息(如果它不是评论),以便当其他用户显示小部件时可以使用它来推荐该文章。

我们稍后会详细了解触发器,因为它:

  • 清理所有 HTML 内容
  • 在“计数”和“响应”集合中创建其他条目,并使这些条目中的核心字段保持最新。

文章回复信息

当我第一次将数据模型放在一起时,我将“计数”信息和“响应”放在一个集合中,然而,这被证明是昂贵的,因为它导致小部件的所有当前正在运行的实例在有人查看文章时重新绘制。

我希望的是,当你查看投票结果时,如果另一个用户投票,你的屏幕会立即更新。但如果另一个用户只看到了投票,还没有互动,那么更新就毫无意义了。通过分离“计数”和“回复”,我能够显著减少读取量,并降低系统成本。

Firebase 具有出色的onSnapshot功能,可以实时通知您表格的写入情况,这在您进行交互时提供了令人兴奋的分数更新动画,并且让您可以欣赏到随着其他人投票而变化的投票结果。onSnapshot适用于个人记录和集合。

下方您可以看到用于跟踪文章互动情况的各种表格。云状图显示了写入这些表格的 Functions API 调用:

响应表

计数

Counts 包含所有唯一访客 ID 的列表,并使用它来跟踪唯一访客计数以及总浏览次数。

Counts 也包含一份副本,responseCount以便可以通过读取单个记录将其报告给内容创建者。

在 Firebase 中保存读取的技巧是同步数据,以便您可以一次性读取所有数据。

回应

响应集合中响应的内容由插件作者决定。只有投票和测验等互动插件才需要使用这些功能。响应集合包含许多 API 调用,用于确保各个用户的响应彼此独立,从而提供一种非常强大的交互方式。

插件作者使用这些数据来呈现他们的用户界面并使用respondrespondUnique方法对其进行更新。

标签

标签表是计数器的集合,它们用于跟踪与文章和评论相关的标签的流行度,以及跟踪其他内容,例如小部件管理的所有 4C 内容的总浏览量。

Firebase 对并发性和写入速度(每秒仅更新 1 条记录)有相当严格的限制,因此快速变化的计数器最终会被“分片”到多个条目中。就此小部件而言,我们将总视图分片为 20 个独立的键,然后将这 20 个键的值相加,得到总答案。在这种情况下,分片只是一个标签名称,并在其末尾添加一个介于 0 到 19 之间的随机数。

用户评分

唯一的另一个集合包含用户的分数。它还包含用户所取得的成就的列表。

查看和与内容互动会自动获得分数。插件作者还可以根据设计添加其他功能,例如,测验中答对题目即可获得分数。

分数表

 加强安全

该应用采用了多种方法来加强安全性。App Check 和 Recaptcha v3.0 的集成旨在阻止对 API 函数的非法调用,而 Firestore 访问规则的定义则能够阻止恶意用户写入不该写入的内容。

Firestore 规则按顺序应用,最后一条规则禁止所有读取和写入:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /responses/{document=**} {
      allow read: if request.auth != null;
      allow write: if false;
    }
    match /counts/{document=**} {
      allow read: if request.auth != null;
      allow write: if false;
    }
    match /tags/{document=**} {
      allow read: if request.auth != null;
      allow write: if false;
    }
    match /articles/{document=**} {
        allow read: if request.auth != null;
      allow write: if false;
    }
    match /userarticles/{userId}/{document=**} {
        allow read: if request.auth != null;
      allow update, delete: if request.auth != null && request.auth.uid == userId;
      allow create: if request.auth != null  && request.auth.uid == userId;
    }
    match /scores/{userId} {
      allow read: if request.auth != null;
      allow write: if false;
    }
    match /userprofiles/{userId} {
        allow read: if request.auth != null;
      allow update, delete: if request.auth != null && request.auth.uid == userId;
      allow create: if request.auth != null;
    }
    match /{document=**} {
      allow read, write: if false;
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

云函数没有应用这些规则,因此它们可用于写入只读表。

触发器

源代码(可在 GitHub 上获取)应用了许多触发器函数,但最有趣的是文章的创建或更新。Firestore 的 onWrite 函数涵盖了创建、更新和删除的全部操作:


    exports.createArticle = functions.firestore
        .document("userarticles/{userId}/articles/{articleId}")
        .onWrite(async (change, context) => {
Enter fullscreen mode Exit fullscreen mode

这里我们说我们希望每次用户写文章时都运行此功能。

            if (!change.after.exists) {
                const id = change.before.data().uid
                await db
                    .collection("responses")
                    .doc(id)
                    .set({ enabled: false }, { merge: true })
                await db
                    .collection("counts")
                    .doc(id)
                    .set({ enabled: false }, { merge: true })
                return
            }
Enter fullscreen mode Exit fullscreen mode

如果之后不存在则表示记录已被删除,我们会将此信息告知响应和集合。

            const data = change.after.data()
            sanitizeAll(data)
            data.comment = data.comment || false
            delete data.banned
            await change.after.ref.set(data)
Enter fullscreen mode Exit fullscreen mode

这里我们过滤 HTML 并设置注释标志(对于 Firestore 查询来说,null 不够好,因为 false 必须显式设置)。我们也不允许传入的记录更改banned主文章的属性。

上面的最后一行将数据写回到用户的记录副本中。

            await db
                .collection("articles")
                .doc(data.uid)
                .set(data, { merge: true })
Enter fullscreen mode Exit fullscreen mode

这是现在正在写的主文章记录。

接下来我们设置响应和计数,如果它们已经存在则更新它们:

            const responseRef = db.collection("responses").doc(data.uid)
            const responseSnap = await responseRef.get()
            if (responseSnap.exists) {
                await responseRef.set(
                    {
                        processedTags: data.processedTags || [],
                        author: data.author,
                        enabled: data.enabled,
                        comment: data.comment || false
                    },
                    { merge: true }
                )
            } else {
                await responseRef.set({
                    types: [],
                    enabled: data.enabled,
                    created: Date.now(),
                    author: data.author,
                    comment: data.comment || false,
                    responses: {},
                    processedTags: data.processedTags || []
                })
            }

            const countRef = db.collection("counts").doc(data.uid)
            const countSnap = await countRef.get()
            if (countSnap.exists) {
                await countRef.set(
                    {
                        processedTags: data.processedTags || [],
                        author: data.author,
                        enabled: data.enabled,
                        comment: data.comment || false
                    },
                    { merge: true }
                )
            } else {
                await countRef.set({
                    enabled: data.enabled,
                    created: Date.now(),
                    author: data.author,
                    visits: 0,
                    comment: data.comment || false,
                    uniqueVisits: 0,
                    lastUniqueVisit: 0,
                    lastUniqueDay: 0,
                    recommends: 0,
                    clicks: 0,
                    processedTags: data.processedTags || []
                })
            }
        })
}
Enter fullscreen mode Exit fullscreen mode

结论

Firebase 足够灵活,可以构建这个小部件,但它的报告功能非常有限,而且必须谨慎操作,以避免读取大量数据带来的成本。“推荐”一文将在下次介绍,但这是导致读取使用率下降的一个重要原因。

鏂囩珷鏉ユ簮锛�https://dev.to/miketalbot/creating-a-serverless-application-with-firebase-5d31
PREV
Coding solutions with AI The world changed Solving problems with AI A simple example
NEXT
使用事件钩子在 React 中构建客户端路由器(pt1:事件)