使用 Firebase 函数和 FaunaDB 构建 100% 无服务器 REST API

❥ 赞助商

独立和企业 Web 开发人员都在努力将无服务器架构推向现代应用程序。无服务器架构通常具有良好的可扩展性,避免了对服务器配置的需求,最重要的是易于设置且成本低廉!这就是我相信云计算的下一步发展将是无服务器的原因,因为它使开发人员能够专注于编写应用程序。

考虑到这一点,让我们使用 100% 无服务器技术构建一个 REST API(因为我们还会继续这样做吗?)。

我们将使用 Firebase 云函数FaunaDB 来完成这项工作,后者是一个具有原生 GraphQL 的全球分布式无服务器数据库。

熟悉 Firebase 的人知道,Google 的无服务器应用程序构建工具还提供多种数据存储选项:Firebase 实时数据库和 Cloud Firestore。两者都是 FaunaDB 的有效替代方案,并且实际上都是无服务器的。

但是,当 Firestore 提供类似的承诺并且可与 Google 的工具包一起使用时,为什么要选择 FaunaDB 呢?由于我们的应用程序非常简单,因此影响不大。主要区别在于,一旦我的应用程序增长并添加了多个集合,那么 FaunaDB 仍然可以在多个集合之间提供一致性,而 Firestore 则不能。在这种情况下,我的选择基于 FaunaDB 的其他一些巧妙优势,这些优势将在您阅读时发现——FaunaDB 的慷慨免费套餐也没有什么坏处。😉

在这篇文章中,我们将介绍

  • 安装 Firebase CLI 工具
  • 使用托管和云函数功能创建 Firebase 项目
  • 将 URL 路由到云函数
  • 使用 Express 构建三个 REST API 调用
  • 建立一个 FaunaDB 集合来跟踪您(我)最喜欢的电子游戏
  • 创建 FaunaDB 文档,使用 FaunaDB 的 JavaScript 客户端 API 访问它们,并执行基本和中级查询
  • 当然还有更多!

设置本地 Firebase 函数项目

在此步骤中,您需要 Node v8 或更高版本。在您的机器上全局安装 firebase-tools

$ npm i -g firebase-tools

然后使用此命令登录 Firebase

$ firebase login

为您的项目创建一个新目录,例如 mkdir serverless-rest-api,然后导航到其中。

通过执行 firebase init 在您的新目录中创建一个 Firebase 项目。

在提示时选择函数和托管。

当气泡出现时,选择“函数”和“托管”,创建一个全新的 Firebase 项目,选择 JavaScript 作为您的语言,并为剩余的选项选择是 (y)。

创建一个新项目,然后选择 JavaScript 作为您的云函数语言。

完成后,进入 functions 目录,这是您的代码所在位置,也是您将添加一些 NPM 包的位置。

您的 API 需要 Express、CORS 和 FaunaDB。使用以下命令安装所有内容

$ npm i cors express faunadb

使用 NodeJS 和 Firebase 云函数设置 FaunaDB

在您使用 FaunaDB 之前,您需要 注册一个帐户

登录后,转到您的 FaunaDB 控制台并创建您的第一个数据库,将其命名为“游戏”。

您会注意到,您可以在其他数据库中创建数据库。因此,您可以创建一个用于开发的数据库,一个用于生产的数据库,甚至可以为每个单元测试套件创建一个小型数据库。现在,我们只需要“游戏”,所以让我们继续。

创建一个新数据库,并将其命名为“游戏”。

然后切换到**集合**选项卡,并创建您的第一个名为“游戏”的集合。集合将包含您的文档(在本例中为游戏),相当于其他数据库中的表格——不用担心付款详细信息,Fauna 提供一个慷慨的免费套餐,您在本教程中执行的读写操作绝对不会超过该免费套餐。您随时可以在 FaunaDB 控制台中监控您的使用情况。

为了这个 API 的目的,请确保将您的集合命名为“游戏”,因为我们将使用这个小巧的 API 来跟踪您(我)最喜欢的电子游戏。

在您的游戏数据库中创建一个集合,并将其命名为“游戏”。

切换到**安全**选项卡,并创建一个新**密钥**,将其命名为“个人密钥”。密钥有三种不同的类型:管理员/服务器/客户端。管理员密钥用于管理多个数据库,服务器密钥通常用于后端,允许您管理一个数据库。最后,客户端密钥用于不可信的客户端,例如您的浏览器。由于我们将使用此密钥在无服务器后端环境中访问一个 FaunaDB 数据库,因此选择“服务器密钥”。

在安全选项卡下,创建一个新密钥。将其命名为“个人密钥”。

将密钥保存到某个地方,您很快就会用到它。

使用 Firebase 函数构建 Express REST API

Firebase 函数可以直接响应外部 HTTPS 请求,并且函数将标准 Node 请求和响应对象传递给您的代码——很棒。这使得 Google 的云函数请求可以访问 Express 等中间件。

打开 functions 目录中的 index.js,**清除预先填充的代码**,并添加以下内容以启用 Firebase 函数

const functions = require('firebase-functions')
const admin = require('firebase-admin')
admin.initializeApp(functions.config().firebase)

导入 FaunaDB 库并使用您在上一步中生成的密钥进行设置

admin.initializeApp(...)
 
const faunadb = require('faunadb')
const q = faunadb.query
const client = new faunadb.Client({
  secret: 'secrety-secret...that’s secret :)'
})

然后创建一个基本的 Express 应用程序并启用 CORS 以支持跨域请求

const client = new faunadb.Client({...})
 
const express = require('express')
const cors = require('cors')
const api = express()
 
// Automatically allow cross-origin requests
api.use(cors({ origin: true }))

您已经准备好创建您的第一个 Firebase 云函数,这和添加以下导出一样简单

api.use(cors({...}))
 
exports.api = functions.https.onRequest(api)

这将创建一个名为“api”的云函数,并将所有请求直接传递给您的 api express 服务器。

将 API URL 路由到 Firebase HTTPS 云函数

如果您现在部署,您的函数的公共 URL 将类似于:https://project-name.firebaseapp.com/api。如果我说这对于访问点来说是一个笨拙的名称(我确实说了,因为我写了这个……谁想出了这个毫无用处的短语?)。

为了解决这个问题,您将使用 Firebase 的托管选项将 URL 通配符重新路由到您的新函数。

打开 firebase.json,并将以下部分添加到“ignore”数组的正下方

"ignore": [...],
"rewrites": [
  {
    "source": "/api/v1**/**",
    "function": "api"
  }
]

此设置将所有 /api/v1/... 请求分配给您的全新函数,使其可以通过人类不会介意在文本编辑器中输入的域名访问。

有了这个,您就可以测试您的 API 了。您的 API 什么也不做!

使用 Express 和 Firebase 函数响应 API 请求

在您在本地运行函数之前,让我们让您的 API 有点事情做。

将此简单的路由添加到您的 index.js 文件中,位于您的导出语句的正上方

api.get(['/api/v1', '/api/v1/'], (req, res) => {
  res
    .status(200)
    .send(`<img src="https://media.giphy.com/media/hhkflHMiOKqI/source.gif">`)
})
 
exports.api = ...

保存您的 index.js 文件,打开命令行,然后更改到 functions 目录。

如果您全局安装了 Firebase,您可以通过输入以下内容来运行您的项目:firebase serve

此命令从您的机器运行托管和函数环境。

如果 Firebase 而是本地安装在您的项目目录中,请打开 package.json,从您的 serve 命令中删除 --only functions 参数,然后从命令行运行 npm run serve

在浏览器中访问 localhost:5000/api/v1/。如果一切都设置正确,您将看到来自我最喜欢的电影之一的 GIF。

如果它也不是您最喜欢的电影之一,我不会放在心上,但我会说您可以阅读其他教程,Bethany。

现在您可以保留运行的托管和函数模拟器。它们将在您编辑 index.js 文件时自动更新。很酷,对吧?

FaunaDB 索引

为了查询您的游戏集合中的数据,FaunaDB 需要一个 索引

索引通常优化各种数据库中的查询性能,但在 FaunaDB 中,它们是强制性的,您必须提前创建它们。

作为刚开始使用 FaunaDB 的开发者,这个要求感觉像是一个数字障碍。

“为什么我不能直接查询数据?”我痛苦地皱着眉头,我的右嘴角试图碰到我的眉毛。

我不得不阅读文档并熟悉索引和Fauna 查询语言 (FQL) 的实际工作原理;而 Cloud Firestore 会自动创建索引并提供简单的方法来访问我的数据。这是怎么回事?

典型的数据库只是让你随心所欲地操作,如果你不及时思考:“这是否高效?”或“这将花费我多少读取成本?”,那么从长远来看你可能会遇到问题。Fauna 通过在每次查询时都要求索引来防止这种情况。
当我使用 FQL 创建复杂的查询时,我开始欣赏我在执行它们时的理解程度。而 Firestore 只是给你免费的糖果,并希望你永远不要问它来自哪里,因为它隐藏了所有问题(例如性能,更重要的是:成本)。

基本上,FaunaDB 拥有 NoSQL 数据库的灵活性,并结合了关系型 SQL 数据库预期的性能衰减。

我们将在稍后看到更多关于如何以及为什么的示例。

向 FaunaDB 集合添加文档

打开你的FaunaDB 控制面板 并导航到你的 games 集合。

在这里,点击 新建文档 并将以下 BioShock 标题添加到你的集合中

{
  "title": "BioShock",
  "consoles": [
    "windows",
    "xbox_360",
    "playstation_3",
    "os_x",
    "ios",
    "playstation_4",
    "xbox_one"
  ],
  "release_date": Date("2007-08-21"),
  "metacritic_score": 96
}

{
  "title": "BioShock 2",
  "consoles": [
    "windows",
    "playstation_3",
    "xbox_360",
    "os_x"
  ],
  "release_date": Date("2010-02-09"),
  "metacritic_score": 88
}{
  "title": "BioShock Infinite",
  "consoles": [
    "windows",
    "playstation_3",
    "xbox_360",
    "os_x",
    "linux"
  ],
  "release_date": Date("2013-03-26"),
  "metacritic_score": 94
}

与其他 NoSQL 数据库一样,这些文档是 JSON 风格的文本块,除了少数 Fauna 特定的对象(例如在“release_date”字段中使用的Date)。

现在切换到 Shell 区域并清除你的查询。粘贴以下内容

Map(Paginate(Match(Index("all_games"))),Lambda("ref",Var("ref")))

并点击“运行查询”按钮。你应该看到三个项目列表:指向你刚才创建的文档的引用。

在 Shell 中,清除查询字段,粘贴提供的查询,然后点击“运行查询”。

它有点过时了,但这里介绍了查询的工作原理。

Index("all_games") 创建对 all_games 索引的引用,该索引在您建立集合时 Fauna 自动为您生成。这些默认索引按引用组织,并返回引用作为值。因此,在本例中,我们对索引使用 Match 函数 来返回一个 Set 的引用。由于我们没有进行任何过滤,因此我们将收到 'games' 集合中的所有文档。

然后将从 Match 返回的集合传递给 Paginate。正如你所期望的那样,此函数添加了分页功能(向前、向后、跳过)。最后,你将 Paginate 的结果传递给 Map,它与它的软件对应部分非常相似,允许你对 Set 中的每个元素执行操作并返回一个数组,在本例中,它只是返回 ref(引用 ID)。

如前所述,默认索引只返回引用。我们传递给 Map 的 Lambda 操作从分页后的集合中的每个条目中提取此 ref 字段。结果是一个引用数组。

现在您已经拥有一个引用列表,您可以使用另一个函数 Get 来检索引用背后的数据。

Var("ref")Get 调用包装起来,然后重新运行你的查询,它应该看起来像这样

Map(Paginate(Match(Index("all_games"))),Lambda("ref",Get(Var("ref"))))

您现在看到的不是一个引用数组,而是每个视频游戏文档的内容。

Var("ref")Get 函数包装起来,然后重新运行查询。

现在您已经了解了游戏文档的外观,您可以开始创建 REST 调用,从 POST 开始。

创建无服务器 POST API 请求

您的第一个 API 调用很简单,它展示了 Express 如何与 Cloud Functions 结合使用,允许您通过一种方法为所有路由提供服务。

将此添加到之前的(完美无缺的)API 调用下方

api.get(['/api/v1', '/api/v1/'], (req, res) => {...})
 
api.post(['/api/v1/games', '/api/v1/games/'], (req, res) => {
  let addGame = client.query(
    q.Create(q.Collection('games'), {
      data: {
        title: req.body.title,
        consoles: req.body.consoles,
        metacritic_score: req.body.metacritic_score,
        release_date: q.Date(req.body.release_date)
      }
    })
  )
  addGame
    .then(response => {
      res.status(200).send(`Saved! ${response.ref}`)
      return
    })
    .catch(reason => {
      res.error(reason)
    })
})

请忽略此示例中缺乏输入清理(所有员工在离开工作区前都必须清理输入)。

但如您所见,在 FaunaDB 中创建新文档非常简单。

q 对象充当查询构建器接口,它与 FQL 函数一一对应(在此处查找 FQL 函数的完整列表)。

你执行一个 Create,传入你的集合,并包含直接来自请求正文的数据字段。

client.query 返回一个 Promise,它的成功状态提供对新创建文档的引用。

为了确保它正常工作,你将引用返回给调用者。让我们看看它在行动中的样子。

使用 Postman 和 cURL 在本地测试 Firebase 函数

使用 Postman 或 cURL 对 localhost:5000/api/v1/ 发出以下请求,将 Halo: Combat Evolved 添加到你的游戏列表中(或者你喜欢哪个 Halo,但绝对不是 4、5、Reach、Wars、Wars 2、Spartan…)。

$ curl https://#:5000/api/v1/games -X POST -H "Content-Type: application/json" -d '{"title":"Halo: Combat Evolved","consoles":["xbox","windows","os_x"],"metacritic_score":97,"release_date":"2001-11-15"}'

如果一切顺利,你应该看到一个带有你的请求的引用返回,并且你的 FaunaDB 控制台中会显示一个新文档。

现在您已经拥有一些游戏集合中的数据,让我们学习如何检索它。

使用 REST API 请求检索 FaunaDB 记录

前面我提到过,每个 FaunaDB 查询都需要一个索引,并且 Fauna 会阻止你执行低效的查询。由于我们的下一个查询将返回按游戏主机过滤的游戏,因此我们不能简单地使用传统的 `where` 子句,因为这在没有索引的情况下可能效率低下。在 Fauna 中,我们需要先定义一个允许我们过滤的索引。

要进行过滤,我们需要指定要过滤的术语。术语指的是您期望搜索的文档字段。

导航到你的 FaunaDB 控制面板中的索引,并创建一个新的索引。

将其命名为 games_by_console,设置 data.consoles 作为唯一术语,因为我们将根据主机进行过滤。然后将 data.titleref 设置为值。值按范围索引,但它们也是查询将返回的值。从这个意义上说,索引有点像视图,你可以创建一个返回不同字段组合的索引,并且每个索引可以具有不同的安全性。

为了最大限度地减少请求开销,我们已将响应数据(例如值)限制为标题和引用。

你的屏幕应该类似于这张图

在索引下,使用上述参数创建一个名为 games_by_console 的新索引。

准备好后,点击“保存”。

准备好了索引,你就可以起草你的下一个 API 调用了。

我选择将主机表示为目录路径,其中主机标识符是唯一的参数,例如 /api/v1/console/playstation_3,这并不一定是最佳实践,但也不是最糟糕的——来吧,现在。

将此 API 请求添加到你的 index.js 文件中

api.post(['/api/v1/games', '/api/v1/games/'], (req, res) => {...})
 
api.get(['/api/v1/console/:name', '/api/v1/console/:name/'], (req, res) => {
  let findGamesForConsole = client.query(
    q.Map(
      q.Paginate(q.Match(q.Index('games_by_console'), req.params.name.toLowerCase())),
      q.Lambda(['title', 'ref'], q.Var('title'))
    )
  )
  findGamesForConsole
    .then(result => {
      console.log(result)
      res.status(200).send(result)
      return
    })
    .catch(error => {
      res.error(error)
    })
})

此查询与你在 SHELL 中用于检索所有游戏的查询类似,但略有修改。此查询与你在 SHELL 中用于检索所有游戏的查询类似,但略有修改。请注意,你的 Match 函数现在有一个第二个参数(req.params.name.toLowerCase()),它是通过 URL 传入的主机标识符。

你刚才创建的索引 games_by_console 其中包含一个术语(主机数组),这对应于我们传递给 match 参数的参数。基本上,Match 函数在索引中搜索你作为第二个参数传递的字符串。下一个有趣的部分是 Lambda 函数。你第一次遇到 Lamba 时,Lambda 的第一个参数是单个字符串“ref”。

但是,games_by_console 索引每个结果返回两个字段,即你之前创建索引时指定的两个值(data.titleref)。所以基本上,我们收到一个包含标题和引用的元组的分页集合,但我们只需要标题。如果你的集合包含多个值,那么你的 lambda 的参数将是一个数组。上面的数组参数([‘title’, ‘ref’])表示第一个值绑定到文本变量 title,第二个值绑定到变量 ref。文本参数。然后,可以通过在查询中进一步使用 Var(‘title’) 来再次检索这些变量。在本例中,"title" 和 "ref" 都由索引返回,你的 Map 与 Lambda 函数映射到此结果列表上,并且只返回每个游戏的标题列表。

在 Fauna 中,查询的组合在执行之前完成。当你编写 var q = q.Match(q.Index('games_by_console'))) 时,该变量只包含一个查询,但尚未执行任何查询。只有当你将查询传递给 client.query(q) 来执行时,它才会执行。你甚至可以将 javascript 变量传递给其他 Fauna FQL 函数来开始组合查询。这是一个很大的优势,因为它在 Fauna 中查询与 Firestore 需要的链式异步查询相比,这种方式更具组合性和非声明性。如果你曾经尝试过在 SQL 中动态生成非常复杂的查询,那么你也会欣赏 FQL 的组合性和非声明性。

保存 index.js 并使用以下命令测试你的 API

$ curl https://#:5000/api/v1/xbox
{"data":["Halo: Combat Evolved"]}

很酷,对吧?但是Match只返回字段完全匹配的文档,这对于用户来说并没有帮助,因为他们可能记不清游戏的完整标题。

虽然 Fauna 还没有提供通过索引进行模糊搜索的功能,但我们可以通过对字符串中的所有单词建立索引来提供类似的功能。或者,如果我们想要真正灵活的模糊搜索,可以使用过滤器语法。请注意,从性能和成本的角度来看,这可能不是一个好主意……但嘿,我们会这样做,因为我们可以,而且它是一个很好的例子,说明了 FQL 的灵活性!

根据搜索字符串过滤 FaunaDB 文档

我们将构建的最后一个 API 调用将允许用户通过名称查找标题。回到你的 FaunaDB 控制台,选择 INDEXES 并点击 NEW INDEX。命名新索引为 games_by_title,并将 Terms 保持为空,你不需要它们。

你将遍历集合中的每个游戏,以查找包含搜索查询的标题,而不是依赖 Match 来将标题与搜索字符串进行比较。

还记得我们提到过索引有点像视图吗?为了根据标题进行过滤,我们需要将 data.title 作为索引返回的值。由于我们使用的是 Match 的结果进行过滤,因此必须确保 Match 返回标题以便我们能够对其进行操作。

添加 data.titleref 作为 Values,将你的屏幕与我的屏幕进行比较。

使用上面的参数创建另一个名为 games_by_title 的索引。

准备好后,点击“保存”。

回到 index.js 中,添加你的第四个也是最后一个 API 调用。

api.get(['/api/v1/console/:name', '/api/v1/console/:name/'], (req, res) => {...})
 
api.get(['/api/v1/games/', '/api/v1/games'], (req, res) => {
  let findGamesByName = client.query(
    q.Map(
      q.Paginate(
        q.Filter(
          q.Match(q.Index('games_by_title')),
          q.Lambda(
            ['title', 'ref'],
            q.GT(
              q.FindStr(
                q.LowerCase(q.Var('title')),
                req.query.title.toLowerCase()
              ),
              -1
            )
          )
        )
      ),
      q.Lambda(['title', 'ref'], q.Get(q.Var('ref')))
    )
  )
  findGamesByName
    .then(result => {
      console.log(result)
      res.status(200).send(result)
      return
    })
    .catch(error => {
      res.error(error)
    })
})

深呼吸,因为我知道这里有很多括号(Lisp 程序员会喜欢这个),但一旦你理解了组件,整个查询就很容易理解了,因为它基本上就像编码一样。

从你看到的第一个新函数 Filter 开始。Filter 与你在编程语言中遇到的过滤器非常相似。它根据 Lambda 函数的结果,将数组或集合缩减为一个子集。

在这个 Filter 中,你将排除任何不包含用户搜索查询的游戏标题。

通过将 FindStr(一个类似于 JavaScript 的 indexOf 的字符串查找函数)的结果与 -1 进行比较来实现这一点,这里非负值表示 FindStr 在游戏标题的小写版本中发现了用户的查询。

此 Filter 的结果将传递给 Map,在 Map 中,每个文档都会被检索并放在最终结果输出中。

现在你可能已经想到显而易见的事实:对四个条目进行字符串比较很便宜,200 万条呢?没那么便宜了。

这是一种执行文本搜索的低效方式,但对于本示例的目的,它足以完成工作。(也许我们应该使用 ElasticSearch 或 Solr 来做这个?)在这种情况下,FaunaDB 非常适合作为中央系统来保护你的数据并将这些数据馈送到搜索引擎,这要归功于时间方面,它允许你向 Fauna 询问:“嘿,给我自时间戳 X 以后的最后更改?”。因此,你可以将 ElasticSearch 设置在它的旁边,并使用 FaunaDB(很快它们将有推送消息)在每次有更改时更新它。凡是做过这件事的人都知道让这样的外部搜索保持最新和正确是多么困难,FaunaDB 使这变得相当容易。

通过搜索“Halo”来测试 API。

$ curl https://#:5000/api/v1/games?title=halo

不要忘记这个 Firebase 优化

许多 Firebase Cloud Functions 代码片段都做了一个非常错误的假设:每次函数调用都是独立于其他调用的。

实际上,Firebase Function 实例可以保持“热”状态一段时间,准备执行后续请求。

这意味着你应该延迟加载你的变量,并缓存结果以帮助减少高峰活动期间的计算时间(和金钱!),以下是如何操作。

let functions, admin, faunadb, q, client, express, cors, api
 
if (typeof api === 'undefined') {
... // dump the existing code here
}
 
exports.api = functions.https.onRequest(api)

使用 Firebase Functions 部署你的 REST API

最后,通过在你的 shell 中运行 firebase deploy 来将你的函数和托管配置部署到 Firebase。

如果没有自定义域名,请在进行 API 请求时参考你的 Firebase 子域名,例如 https://{project-name}.firebaseapp.com/api/v1/

下一步?

FaunaDB 使我成为了一名有责任感的开发者。

在使用其他无模式数据库时,我一开始会有很好的意图,将文档视为使用 DDL 实例化的它们(严格类型、版本号,以及所有内容)。

虽然这让我在短时间内保持井井有条,但不久之后,为了速度,标准就下降了,我的文档就分裂了:留下过时的格式和僵尸数据。

通过强迫我思考如何查询数据,需要哪些索引,以及如何在数据返回到我的服务器之前对其进行最佳操作,我一直很清楚我的文档。

为了帮助我永远保持井井有条,我的索引目录(在 FaunaDB 控制台中)帮助我跟踪我的文档提供的每项内容。

通过将这种广泛的 算术和语言函数 直接整合到查询语言中,FaunaDB 鼓励我最大限度地提高效率,并密切关注我的数据存储策略。考虑到负担得起的定价模型,我宁愿在 FaunaDB 的服务器上运行 10,000 多个数据操作,也不愿意在一个 Cloud Function 上运行。

出于这些原因以及更多原因,我鼓励你看看这些函数,并考虑 FaunaDB 的其他强大功能