RedisOM for Node.js

了解如何使用 Redis Stack 和 Node.js 进行构建

本教程将向您展示如何使用 Node.js 和 Redis Stack 构建一个 API。

我们将使用 ExpressRedis OM 来完成此操作,我们假设您对 Express 有基本的了解。

我们将构建的 API 是一个简单且相对 RESTful 的 API,用于读取、写入和查找人员数据:名字、姓氏、年龄等。我们还会添加一个简单的位置跟踪功能,增加一些趣味性。

但在开始编码之前,让我们先描述一下 Redis OM 是什么

前提条件

与任何软件相关的事情一样,您需要安装一些依赖项才能开始

  • Node.js 14.8+:在本教程中,我们使用了 JavaScript 的顶层 await 功能,该功能在 Node 14.8 中引入。因此,请确保您使用的是该版本或更高版本。
  • Redis Stack:您需要一个 Redis Stack 版本,可以在您的机器上本地运行,也可以在云中运行。
  • Redis Insight:我们将使用它来查看 Redis 内部,并确保我们的代码正在按我们预期的方式运行。

启动代码

我们不会完全从零开始编码。相反,我们为您提供了一些启动代码。请将其克隆到您方便的文件夹中

git clone [email protected]:redis-developer/express-redis-om-workshop.git

获取启动代码后,我们来稍微探索一下。打开根目录下的 server.js,我们看到一个简单的 Express 应用,它使用 Dotenv 进行配置,使用 Swagger UI Express 测试我们的 API。

import 'dotenv/config'

import express from 'express'
import swaggerUi from 'swagger-ui-express'
import YAML from 'yamljs'

/* create an express app and use JSON */
const app = new express()
app.use(express.json())

/* set up swagger in the root */
const swaggerDocument = YAML.load('api.yaml')
app.use('/', swaggerUi.serve, swaggerUi.setup(swaggerDocument))

/* start the server */
app.listen(8080)

随同此文件的是 api.yaml,它定义了我们将要构建的 API,并提供了 Swagger UI Express 渲染其 UI 所需的信息。除非您想添加其他路由,否则无需修改它。

persons 文件夹包含一些 JSON 文件和一个 shell 脚本。JSON 文件是示例人员数据——都是音乐家,因为这样更有趣——您可以将其加载到 API 中进行测试。shell 脚本——load-data.sh——将使用 curl 把所有 JSON 文件加载到 API 中。

有两个空文件夹:omroutersom 文件夹将存放所有 Redis OM 代码。routers 文件夹将存放我们所有 Express 路由的代码。

配置和运行

启动代码虽然有点单薄,但完全可以运行。我们先配置并运行它,确保它能正常工作,然后再继续编写实际代码。首先,获取所有依赖项

npm install

然后,在根目录下设置一个 .env 文件供 Dotenv 使用。根目录下有一个 sample.env 文件,您可以复制并修改它。

cp sample.env .env

.env 文件的内容如下所示

# Put your local Redis Stack URL here. Want to run in the
# cloud instead? Sign up at https://redis.com/try-free/.
REDIS_URL=redis://localhost:6379

很有可能这些内容已经是正确的。但是,如果您的特定环境需要更改 REDIS_URL(例如,您在云中运行 Redis Stack),现在就是时候进行更改了。完成后,您应该能够运行应用了

npm start

导航到 http://localhost:8080 并查看 Swagger UI Express 创建的客户端。由于我们还没有实现任何路由,所有功能都还不能用。但是,您可以尝试一下并看到它们失败!

启动代码可以运行了。我们来添加一些 Redis OM 代码,让它真正做点事情!

设置客户端

首先,让我们设置一个 **客户端(client)**。Client 类是知道如何代表 Redis OM 与 Redis 对话的东西。一种选择是将客户端放在自己的文件中并导出它。这可以确保应用程序只有一个 Client 实例,从而只有一个与 Redis Stack 的连接。由于 Redis 和 JavaScript 都(或多或少)是单线程的,这使得工作非常整洁。

我们来创建第一个文件。在 om 文件夹中添加一个名为 client.js 的文件,并添加以下代码

import { Client } from 'redis-om'

/* pulls the Redis URL from .env */
const url = process.env.REDIS_URL

/* create and open the Redis OM Client */
const client = await new Client().open(url)

export default client

还记得我们之前提到的 顶层 await 吗?它就在这里!

注意,我们正在从一个环境变量中获取 Redis URL。它是由 Dotenv 放置在那里并从我们的 .env 文件中读取的。如果我们没有 .env 文件或 .env 文件中没有 REDIS_URL 属性,这段代码会很高兴地从 实际的 环境变量中读取这个值。

另请注意,.open() 方法会方便地返回 this。这个 `this`(我还能再说一遍 `this` 吗?我刚刚就说了!)允许我们将客户端的实例化与客户端的打开链式调用起来。如果您不喜欢这种方式,您总是可以像这样编写它

/* create and open the Redis OM Client */
const client = new Client()
await client.open(url)

实体、模式和仓库

现在我们有了一个连接到 Redis 的客户端,我们需要开始映射一些人员数据。为此,我们需要定义一个 Entity 和一个 Schema。首先,在 om 文件夹中创建一个名为 person.js 的文件,并从 client.js 导入 client,从 Redis OM 导入 EntitySchema 类。

import { Entity, Schema } from 'redis-om'
import client from './client.js'

实体

接下来,我们需要定义一个 **实体(entity)**。Entity 是您在使用数据时保存数据的类——它是被映射的对象。它是您创建、读取、更新和删除的对象。任何继承自 Entity 的类都是一个实体。我们将用一行代码定义我们的 Person 实体

/* our entity */
class Person extends Entity {}

模式

一个 **模式(schema)** 定义了实体上的字段、它们的类型以及它们如何在 Redis 内部进行映射。默认情况下,实体映射到 JSON 文档。让我们在 person.js 中创建我们的 Schema

/* create a Schema for Person */
const personSchema = new Schema(Person, {
  firstName: { type: 'string' },
  lastName: { type: 'string' },
  age: { type: 'number' },
  verified: { type: 'boolean' },
  location: { type: 'point' },
  locationUpdated: { type: 'date' },
  skills: { type: 'string[]' },
  personalStatement: { type: 'text' }
})

当您创建一个 Schema 时,它会修改您传递给它的 Entity 类(在我们的例子中是 Person),为其定义的属性添加 getter 和 setter。getter 和 setter 接受和返回的类型由上面所示的类型参数定义。有效值包括:stringnumberbooleanstring[]datepointtext

前三种类型完全符合您的预期——它们定义了一个类型为 StringNumberBoolean 的属性。string[] 也和您想的一样,它专门定义了一个 Array 类型的字符串数组。

date 有点不同,但或多或少还是您预期的。它定义了一个返回 Date 对象的属性,不仅可以使用 Date 对象设置,也可以使用包含 ISO 8601 日期格式的 String 或以毫秒为单位的 UNIX epoch timeNumber 来设置。

一个 point 定义了地球上的某个点,包含经度和纬度。它创建一个属性,该属性返回并接受一个包含 longitudelatitude 属性的简单对象。像这样

let point = { longitude: 12.34, latitude: 56.78 }

一个 text 字段很像 string。如果您只是读取和写入对象,它们是相同的。但如果您想对它们进行搜索,它们则非常、非常不同。我们稍后会详细讨论搜索,但简而言之:string 字段只能匹配其完整值——不支持部分匹配——最适合用作键;而 text 字段则启用了全文搜索,并针对人类可读文本进行了优化。

仓库

现在我们拥有了创建 **仓库(repository)** 所需的所有部分。Repository 是 Redis OM 的主要接口。它为我们提供了读取、写入和移除特定 Entity 的方法。在 person.js 中创建一个 Repository 并确保它被导出,因为我们在开始实现 API 时会需要它。

/* use the client to create a Repository just for Persons */
export const personRepository = new Repository(personSchema, client)

我们几乎完成了仓库的设置。但我们还需要创建一个索引,否则将无法搜索。我们可以通过调用 .createIndex() 来完成。如果索引已经存在并且是相同的,这个函数将什么也不做。如果不同,它会删除旧索引并创建一个新索引。将对 .createIndex() 的调用添加到 person.js

/* create the index for Person */
await personRepository.createIndex()

person.js 的代码就到这里,这也是我们开始使用 Redis OM 与 Redis 通信所需的所有代码。以下是完整的代码

import { Entity, Schema } from 'redis-om'
import client from './client.js'

/* our entity */
class Person extends Entity {}

/* create a Schema for Person */
const personSchema = new Schema(Person, {
  firstName: { type: 'string' },
  lastName: { type: 'string' },
  age: { type: 'number' },
  verified: { type: 'boolean' },
  location: { type: 'point' },
  locationUpdated: { type: 'date' },
  skills: { type: 'string[]' },
  personalStatement: { type: 'text' }
})

/* use the client to create a Repository just for Persons */
export const personRepository = client.fetchRepository(personSchema)

/* create the index for Person */
await personRepository.createIndex()

现在,我们在 Express 中添加一些路由。

设置人员路由器

我们来创建一个真正的 RESTful API,将 CRUD 操作分别映射到 PUT、GET、POST 和 DELETE。我们将使用 Express Routers 来实现,这样可以使我们的代码整洁有序。在 routers 文件夹中创建一个名为 person-router.js 的文件,并在其中从 Express 导入 Router,从 person.js 导入 personRepository。然后创建并导出一个 Router

import { Router } from 'express'
import { personRepository } from '../om/person.js'

export const router = Router()

完成导入和导出后,我们将路由器绑定到 Express 应用。打开 server.js 并导入我们刚刚创建的 Router

/* import routers */
import { router as personRouter } from './routers/person-router.js'

然后将 personRouter 添加到 Express 应用中

/* bring in some routers */
app.use('/person', personRouter)

您的 server.js 现在应该看起来像这样

import 'dotenv/config'

import express from 'express'
import swaggerUi from 'swagger-ui-express'
import YAML from 'yamljs'

/* import routers */
import { router as personRouter } from './routers/person-router.js'

/* create an express app and use JSON */
const app = new express()
app.use(express.json())

/* bring in some routers */
app.use('/person', personRouter)

/* set up swagger in the root */
const swaggerDocument = YAML.load('api.yaml')
app.use('/', swaggerUi.serve, swaggerUi.setup(swaggerDocument))

/* start the server */
app.listen(8080)

现在我们可以添加创建、读取、更新和删除人员的路由了。回到 person-router.js 文件,这样我们就可以开始操作了。

创建人员

我们首先创建一个人员,因为在对人员进行读取、写入或删除之前,您需要 Redis 中有人员数据。在下方添加 PUT 路由。这个路由将调用 .createAndSave() 从请求体创建 Person 并立即将其保存到 Redis 中

router.put('/', async (req, res) => {
  const person = await personRepository.createAndSave(req.body)
  res.send(person)
})

注意,我们还返回了新创建的 Person。我们通过使用 Swagger UI 实际调用 API 来看看它的样子。在浏览器中访问 http://localhost:8080 并尝试一下。Swagger 中的默认请求体适合测试。您应该看到这样的响应

{
  "entityId": "01FY9MWDTWW4XQNTPJ9XY9FPMN",
  "firstName": "Rupert",
  "lastName": "Holmes",
  "age": 75,
  "verified": false,
  "location": {
    "longitude": 45.678,
    "latitude": 45.678
  },
  "locationUpdated": "2022-03-01T12:34:56.123Z",
  "skills": [
    "singing",
    "songwriting",
    "playwriting"
  ],
  "personalStatement": "I like piña coladas and walks in the rain"
}

这与我们提供的数据完全一致,只有一个例外:entityId。Redis OM 中的每个实体都有一个实体 ID,正如您可能猜到的那样,它是该实体的唯一 ID。它在我们调用 .createAndSave() 时随机生成。您的 ID 会有所不同,请记下它。

您可以使用 Redis Insight 在 Redis 中看到这个新创建的 JSON 文档。启动 Redis Insight,您应该会看到一个键,名称类似于 Person:01FY9MWDTWW4XQNTPJ9XY9FPMN。键中的 Person 部分来自我们实体的类名,字母和数字序列是生成的实体 ID。单击它查看您创建的 JSON 文档。

您还会看到一个名为 Person:index:hash 的键。这是一个唯一值,Redis OM 使用它来判断在调用 .createIndex() 时是否需要重新创建索引。您可以安全地忽略它。

读取人员

创建完成后,我们来添加一个 GET 路由以读取这个新创建的 Person

router.get('/:id', async (req, res) => {
  const person = await personRepository.fetch(req.params.id)
  res.send(person)
})

这段代码从路由中使用的 URL 中提取一个参数——我们之前收到的 entityId。它使用 personRepository 上的 .fetch() 方法,通过该 entityId 检索 Person。然后,它返回该 Person

我们也在 Swagger 中测试一下。您应该会得到完全相同的响应。实际上,由于这是一个简单的 GET 请求,我们应该可以直接在浏览器中加载 URL。通过导航到 http://localhost:8080/person/01FY9MWDTWW4XQNTPJ9XY9FPMN 并将实体 ID 替换为您自己的,来测试一下。

现在我们可以读取和写入了,我们来实现剩下的 HTTP 动词。REST... 懂了吗?

更新人员

我们来添加使用 POST 路由更新人员的代码

router.post('/:id', async (req, res) => {

  const person = await personRepository.fetch(req.params.id)

  person.firstName = req.body.firstName ?? null
  person.lastName = req.body.lastName ?? null
  person.age = req.body.age ?? null
  person.verified = req.body.verified ?? null
  person.location = req.body.location ?? null
  person.locationUpdated = req.body.locationUpdated ?? null
  person.skills = req.body.skills ?? null
  person.personalStatement = req.body.personalStatement ?? null

  await personRepository.save(person)

  res.send(person)
})

这段代码使用 entityIdpersonRepository 中获取 Person,就像我们之前的路由所做的一样。但是,现在我们根据请求体中的属性更改所有属性。如果其中任何属性缺失,我们就将其设置为 null。然后,我们调用 .save() 并返回更改后的 Person

我们也在 Swagger 中测试一下,何乐而不为?进行一些更改。尝试删除一些字段。更改后,您读取它时会得到什么?

删除人员

删除——我的最爱!孩子们记住,删除就是 100% 的压缩。删除路由和读取路由一样直接,但破坏性大得多

router.delete('/:id', async (req, res) => {
  await personRepository.remove(req.params.id)
  res.send({ entityId: req.params.id })
})

我想我们也应该测试一下这个。加载 Swagger 并运行路由。您应该会得到包含刚刚移除的实体 ID 的 JSON 响应

{
  "entityId": "01FY9MWDTWW4XQNTPJ9XY9FPMN"
}

就这样,它消失了!

所有 CRUD 操作

快速检查一下您目前编写的代码。以下是您的 person-router.js 文件应该包含的全部内容

import { Router } from 'express'
import { personRepository } from '../om/person.js'

export const router = Router()

router.put('/', async (req, res) => {
  const person = await personRepository.createAndSave(req.body)
  res.send(person)
})

router.get('/:id', async (req, res) => {
  const person = await personRepository.fetch(req.params.id)
  res.send(person)
})

router.post('/:id', async (req, res) => {

  const person = await personRepository.fetch(req.params.id)

  person.firstName = req.body.firstName ?? null
  person.lastName = req.body.lastName ?? null
  person.age = req.body.age ?? null
  person.verified = req.body.verified ?? null
  person.location = req.body.location ?? null
  person.locationUpdated = req.body.locationUpdated ?? null
  person.skills = req.body.skills ?? null
  person.personalStatement = req.body.personalStatement ?? null

  await personRepository.save(person)

  res.send(person)
})

router.delete('/:id', async (req, res) => {
  await personRepository.remove(req.params.id)
  res.send({ entityId: req.params.id })
})

CRUD 已完成,我们来做一些搜索。为了进行搜索,我们需要有数据可供搜索。还记得那个包含所有 JSON 文档和 load-data.sh shell 脚本的 persons 文件夹吗?是时候使用它了。进入那个文件夹并运行脚本

cd persons
./load-data.sh

您应该会得到一个相当详细的响应,其中包含来自 API 的 JSON 响应以及您加载的文件名称。像这样

{"entityId":"01FY9Z4RRPKF4K9H78JQ3K3CP3","firstName":"Chris","lastName":"Stapleton","age":43,"verified":true,"location":{"longitude":-84.495,"latitude":38.03},"locationUpdated":"2022-01-01T12:00:00.000Z","skills":["singing","football","coal mining"],"personalStatement":"There are days that I can walk around like I'm alright. And I pretend to wear a smile on my face. And I could keep the pain from comin' out of my eyes. But sometimes, sometimes, sometimes I cry."} <- chris-stapleton.json
{"entityId":"01FY9Z4RS2QQVN4XFYSNPKH6B2","firstName":"David","lastName":"Paich","age":67,"verified":false,"location":{"longitude":-118.25,"latitude":34.05},"locationUpdated":"2022-01-01T12:00:00.000Z","skills":["singing","keyboard","blessing"],"personalStatement":"I seek to cure what's deep inside frightened of this thing that I've become"} <- david-paich.json
{"entityId":"01FY9Z4RSD7SQMSWDFZ6S4M5MJ","firstName":"Ivan","lastName":"Doroschuk","age":64,"verified":true,"location":{"longitude":-88.273,"latitude":40.115},"locationUpdated":"2022-01-01T12:00:00.000Z","skills":["singing","dancing","friendship"],"personalStatement":"We can dance if we want to. We can leave your friends behind. 'Cause your friends don't dance and if they don't dance well they're no friends of mine."} <- ivan-doroschuk.json
{"entityId":"01FY9Z4RSRZFGQ21BMEKYHEVK6","firstName":"Joan","lastName":"Jett","age":63,"verified":false,"location":{"longitude":-75.273,"latitude":40.003},"locationUpdated":"2022-01-01T12:00:00.000Z","skills":["singing","guitar","black eyeliner"],"personalStatement":"I love rock n' roll so put another dime in the jukebox, baby."} <- joan-jett.json
{"entityId":"01FY9Z4RT25ABWYTW6ZG7R79V4","firstName":"Justin","lastName":"Timberlake","age":41,"verified":true,"location":{"longitude":-89.971,"latitude":35.118},"locationUpdated":"2022-01-01T12:00:00.000Z","skills":["singing","dancing","half-time shows"],"personalStatement":"What goes around comes all the way back around."} <- justin-timberlake.json
{"entityId":"01FY9Z4RTD9EKBDS2YN9CRMG1D","firstName":"Kerry","lastName":"Livgren","age":72,"verified":false,"location":{"longitude":-95.689,"latitude":39.056},"locationUpdated":"2022-01-01T12:00:00.000Z","skills":["poetry","philosophy","songwriting","guitar"],"personalStatement":"All we are is dust in the wind."} <- kerry-livgren.json
{"entityId":"01FY9Z4RTR73HZQXK83JP94NWR","firstName":"Marshal","lastName":"Mathers","age":49,"verified":false,"location":{"longitude":-83.046,"latitude":42.331},"locationUpdated":"2022-01-01T12:00:00.000Z","skills":["rapping","songwriting","comics"],"personalStatement":"Look, if you had, one shot, or one opportunity to seize everything you ever wanted, in one moment, would you capture it, or just let it slip?"} <- marshal-mathers.json
{"entityId":"01FY9Z4RV2QHH0Z1GJM5ND15JE","firstName":"Rupert","lastName":"Holmes","age":75,"verified":true,"location":{"longitude":-2.518,"latitude":53.259},"locationUpdated":"2022-01-01T12:00:00.000Z","skills":["singing","songwriting","playwriting"],"personalStatement":"I like piña coladas and taking walks in the rain."} <- rupert-holmes.json

有点乱,但如果您没有看到这些,那么它就没工作!

现在我们有一些数据了,再添加一个路由器来存放我们要添加的搜索路由。在 routers 文件夹中创建一个名为 search-router.js 的文件,并像我们在 person-router.js 中那样进行导入和导出设置。

import { Router } from 'express'
import { personRepository } from '../om/person.js'

export const router = Router()

像导入 personRouter 一样将 Router 导入到 server.js

/* import routers */
import { router as personRouter } from './routers/person-router.js'
import { router as searchRouter } from './routers/search-router.js'

然后将 searchRouter 添加到 Express 应用中

/* bring in some routers */
app.use('/person', personRouter)
app.use('/persons', searchRouter)

路由器已绑定,现在我们可以添加一些路由了。

搜索所有内容

我们将为新路由器添加多种搜索。但首先是最简单的一个,因为它只会返回所有内容。请将以下代码添加到 search-router.js

router.get('/all', async (req, res) => {
  const persons = await personRepository.search().return.all()
  res.send(persons)
})

在这里,我们看到如何开始和结束一个搜索。搜索的开始方式就像 CRUD 操作的开始方式一样——在 Repository 上进行。但不是调用 .createAndSave().fetch().save().remove(),我们调用的是 .search()。与其他所有方法不同,.search() 并不会就此结束。相反,它允许您构建一个查询(您将在下一个示例中看到),然后通过调用 .return.all() 来解析它。

有了这个新路由,进入 Swagger UI 并运行 /persons/all 路由。您应该看到您使用 shell 脚本添加的所有人员,以 JSON 数组的形式呈现。

在上面的示例中,没有指定查询——我们没有构建任何内容。如果您这样做,您只会得到所有内容。这有时正是您想要的。但大多数时候不是。如果您只是返回所有内容,那就不算是真正的搜索。所以我们来添加一个路由,它允许我们按姓氏查找人员。添加以下代码

router.get('/by-last-name/:lastName', async (req, res) => {
  const lastName = req.params.lastName
  const persons = await personRepository.search()
    .where('lastName').equals(lastName).return.all()
  res.send(persons)
})

在这个路由中,我们指定了一个要过滤的字段和一个它需要等于的值。调用 .where() 中的字段名是在我们的模式中指定的字段的名称。这个字段被定义为 string 类型,这很重要,因为字段的类型决定了可用于查询它的方法。

对于 string 类型,只有 .equals() 方法,它将针对整个字符串的值进行查询。为了方便起见,它被别名为 .eq().equal().equalTo()。您甚至可以通过调用 .is.does 添加一些语法糖,它们实际上不做任何事情,只是让您的代码更漂亮。像这样

const persons = await personRepository.search().where('lastName').is.equalTo(lastName).return.all()
const persons = await personRepository.search().where('lastName').does.equal(lastName).return.all()

您还可以通过调用 .not 来反转查询

const persons = await personRepository.search().where('lastName').is.not.equalTo(lastName).return.all()
const persons = await personRepository.search().where('lastName').does.not.equal(lastName).return.all()

在所有这些情况下,调用 .return.all() 会执行我们在它和调用 .search() 之间构建的查询。我们也可以对其他字段类型进行搜索。我们来添加一些路由,以在 numberboolean 字段上进行搜索

router.get('/old-enough-to-drink-in-america', async (req, res) => {
  const persons = await personRepository.search()
    .where('age').gte(21).return.all()
  res.send(persons)
})

router.get('/non-verified', async (req, res) => {
  const persons = await personRepository.search()
    .where('verified').is.not.true().return.all()
  res.send(persons)
})

这个 number 字段用于根据年龄过滤人员,年龄大于或等于 21 岁。同样,这里也有别名和语法糖

const persons = await personRepository.search().where('age').is.greaterThanOrEqualTo(21).return.all()

但也有更多查询方式

const persons = await personRepository.search().where('age').eq(21).return.all()
const persons = await personRepository.search().where('age').gt(21).return.all()
const persons = await personRepository.search().where('age').gte(21).return.all()
const persons = await personRepository.search().where('age').lt(21).return.all()
const persons = await personRepository.search().where('age').lte(21).return.all()
const persons = await personRepository.search().where('age').between(21, 65).return.all()

这个 boolean 字段用于根据人员的验证状态进行搜索。它已经包含了一些语法糖。注意,这个查询将匹配缺失值或 false 值。这就是为什么我指定了 .not.true()。您也可以在 boolean 字段上调用 .false() 以及所有 .equals 的变体。

const persons = await personRepository.search().where('verified').true().return.all()
const persons = await personRepository.search().where('verified').false().return.all()
const persons = await personRepository.search().where('verified').equals(true).return.all()

好了,我们已经创建了一些路由,但我还没有告诉您去测试它们。也许您无论如何都测试过了。如果是这样,很好,您真是个反叛者。对于其他人,为什么不现在就用 Swagger 测试一下呢?而且,将来,想测试时就测试。哎呀,您甚至可以按照提供的语法创建自己的路由并尝试一下。别让我告诉您该怎么生活。

当然,只查询一个字段是永远不够的。没问题,Redis OM 可以处理 .and().or(),就像这个路由中一样

router.get('/verified-drinkers-with-last-name/:lastName', async (req, res) => {
  const lastName = req.params.lastName
  const persons = await personRepository.search()
    .where('verified').is.true()
      .and('age').gte(21)
      .and('lastName').equals(lastName).return.all()
  res.send(persons)
})

这里,我只是展示了 .and() 的语法,但当然,您也可以使用 .or()

如果您在模式中定义了一个类型为 text 的字段,您就可以对它执行全文搜索。text 字段的搜索方式与 string 字段不同。string 只能与 .equals() 比较,并且必须匹配整个字符串。对于 text 字段,您可以在字符串中查找单词。

一个 text 字段针对人类可读文本进行了优化,例如文章或歌词。它相当智能。它明白某些单词(例如 'a'、'an' 或 'the')很常见,并忽略它们。它了解单词在语法上的相似性,因此如果您搜索 'give',它也会匹配 'gives'、'given'、'giving' 和 'gave'。而且它会忽略标点符号。

我们来添加一个对我们的 personalStatement 字段进行全文搜索的路由

router.get('/with-statement-containing/:text', async (req, res) => {
  const text = req.params.text
  const persons = await personRepository.search()
    .where('personalStatement').matches(text)
      .return.all()
  res.send(persons)
})

注意 .matches() 函数的使用。这是唯一适用于 text 字段的函数。它接受一个字符串,该字符串可以是一个或多个(由空格分隔的)您想查询的单词。我们来试一下。在 Swagger 中,使用此路由搜索单词“walk”。您应该得到以下结果

[
  {
    "entityId": "01FYC7CTR027F219455PS76247",
    "firstName": "Rupert",
    "lastName": "Holmes",
    "age": 75,
    "verified": true,
    "location": {
      "longitude": -2.518,
      "latitude": 53.259
    },
    "locationUpdated": "2022-01-01T12:00:00.000Z",
    "skills": [
      "singing",
      "songwriting",
      "playwriting"
    ],
    "personalStatement": "I like piña coladas and taking walks in the rain."
  },
  {
    "entityId": "01FYC7CTNBJD9CZKKWPQEZEW14",
    "firstName": "Chris",
    "lastName": "Stapleton",
    "age": 43,
    "verified": true,
    "location": {
      "longitude": -84.495,
      "latitude": 38.03
    },
    "locationUpdated": "2022-01-01T12:00:00.000Z",
    "skills": [
      "singing",
      "football",
      "coal mining"
    ],
    "personalStatement": "There are days that I can walk around like I'm alright. And I pretend to wear a smile on my face. And I could keep the pain from comin' out of my eyes. But sometimes, sometimes, sometimes I cry."
  }
]

注意单词“walk”是如何匹配 Rupert Holmes 的个人陈述中包含“walks”的,以及 Chris Stapleton 的个人陈述中包含“walk”的。现在搜索“walk raining”。您会看到这只返回了 Rupert 的条目,即使这两个词的精确文本都没有在他的个人陈述中找到。但它们在语法上是相关的,因此匹配了。这被称为词干提取(stemming),它是 Redis Stack 的一个很酷的功能,Redis OM 利用了它。

如果您搜索“a rain walk”,您仍然会匹配 Rupert 的条目,即使文本中没有单词“a”。为什么?因为它是一个常见词,对搜索没有太大帮助。这些常见词被称为停用词(stop words),这是 Redis Stack 的另一个很酷的功能,Redis OM 可以免费获得。

搜索全球范围

Redis Stack 以及 Redis OM 都支持按地理位置搜索。您指定地球上的一个点、半径和半径单位,它将愉快地返回该范围内的所有实体。我们来添加一个实现此功能的路由

router.get('/near/:lng,:lat/radius/:radius', async (req, res) => {
  const longitude = Number(req.params.lng)
  const latitude = Number(req.params.lat)
  const radius = Number(req.params.radius)

  const persons = await personRepository.search()
    .where('location')
      .inRadius(circle => circle
          .longitude(longitude)
          .latitude(latitude)
          .radius(radius)
          .miles)
        .return.all()

  res.send(persons)
})

这段代码看起来与其他代码有点不同,因为我们定义要搜索的圆的方式是通过一个函数完成的,该函数作为参数传递给 .inRadius 方法

circle => circle.longitude(longitude).latitude(latitude).radius(radius).miles

这个函数所做的就是接受一个已用默认值初始化的 Circle 实例。我们通过调用各种构建器方法来覆盖这些值,以定义搜索的原点(即经度和纬度)、半径以及测量半径的单位。有效单位是 milesmetersfeetkilometers

我们来测试一下这个路由。我知道我们可以在经度 -75.0 和纬度 40.0 附近找到 Joan Jett,这在宾夕法尼亚州东部。所以使用这些坐标和 20 英里的半径。您应该会收到响应

[
  {
    "entityId": "01FYC7CTPKYNXQ98JSTBC37AS1",
    "firstName": "Joan",
    "lastName": "Jett",
    "age": 63,
    "verified": false,
    "location": {
      "longitude": -75.273,
      "latitude": 40.003
    },
    "locationUpdated": "2022-01-01T12:00:00.000Z",
    "skills": [
      "singing",
      "guitar",
      "black eyeliner"
    ],
    "personalStatement": "I love rock n' roll so put another dime in the jukebox, baby."
  }
]

尝试扩大半径,看看您还能找到谁。

添加位置跟踪

我们快到本教程的尾声了,但在结束之前,我想添加我在开头提到的那个位置跟踪部分。如果您已经看到这里,接下来的代码应该很容易理解,因为它并没有做任何我尚未讨论过的事情。

routers 文件夹中添加一个名为 location-router.js 的新文件

import { Router } from 'express'
import { personRepository } from '../om/person.js'

export const router = Router()

router.patch('/:id/location/:lng,:lat', async (req, res) => {

  const id = req.params.id
  const longitude = Number(req.params.lng)
  const latitude = Number(req.params.lat)

  const locationUpdated = new Date()

  const person = await personRepository.fetch(id)
  person.location = { longitude, latitude }
  person.locationUpdated = locationUpdated
  await personRepository.save(person)

  res.send({ id, locationUpdated, location: { longitude, latitude } })
})

在这里,我们调用 .fetch() 来获取一个人,我们正在更新该人员的一些值——使用我们的经度和纬度更新 .location 属性,并使用当前日期和时间更新 .locationUpdated 属性。简单的事情。

要使用这个 Router,请在 server.js 中导入它

/* import routers */
import { router as personRouter } from './routers/person-router.js'
import { router as searchRouter } from './routers/search-router.js'
import { router as locationRouter } from './routers/location-router.js'

并将路由器绑定到一个路径

/* bring in some routers */
app.use('/person', personRouter, locationRouter)
app.use('/persons', searchRouter)

就这样。但这还不够让人满意。它没有展示任何新东西,除了 date 字段的使用。而且,这也不是真正的地理位置 跟踪。它只是显示这些人上次在哪里,没有历史记录。所以我们来添加一些历史记录!

为了添加一些历史记录,我们将使用一个 Redis Stream。流(Streams)是一个很大的话题,但如果您不熟悉它们,也不用担心,您可以将它们想象成存储在 Redis 键中的日志文件,其中每个条目代表一个事件。在我们的例子中,事件就是人员移动或签到等等。

但是有一个问题。尽管 Redis Stack 支持流(Streams),但 Redis OM 不支持。那么我们如何在应用程序中利用它们呢?通过使用 Node Redis。Node Redis 是一个用于 Node.js 的低级 Redis 客户端,它允许您访问所有 Redis 命令和数据类型。在内部,Redis OM 正在创建并使用一个 Node Redis 连接。您也可以使用那个连接。或者更确切地说,可以告诉 Redis OM 使用您正在使用的连接。我来向您展示如何做。

使用 Node Redis

打开 om 文件夹中的 client.js。还记得我们如何创建 Redis OM 的 Client 并对其调用 .open() 吗?

const client = await new Client().open(url)

嗯,Client 类还有一个 .use() 方法,它接受一个 Node Redis 连接。修改 client.js,使其使用 Node Redis 打开一个与 Redis 的连接,然后使用 .use() 将其传递给 Redis OM

import { Client } from 'redis-om'
import { createClient } from 'redis'

/* pulls the Redis URL from .env */
const url = process.env.REDIS_URL

/* create a connection to Redis with Node Redis */
export const connection = createClient({ url })
await connection.connect()

/* create a Client and bind it to the Node Redis connection */
const client = await new Client().use(connection)

export default client

就是这样。Redis OM 现在正在使用您创建的连接。注意,我们同时导出了 clientconnection。如果我们想在最新的路由中使用 connection,就必须导出它。

使用流存储位置历史

要向流(Stream)添加一个事件,我们需要使用 XADD 命令。Node Redis 将其暴露为 .xAdd() 方法。因此,我们需要在我们的路由中添加对 .xAdd() 的调用。修改 location-router.js 以导入我们的 connection

import { connection } from '../om/client.js'

然后在路由本身中添加对 .xAdd() 的调用

  ...snip...
  const person = await personRepository.fetch(id)
  person.location = { longitude, latitude }
  person.locationUpdated = locationUpdated
  await personRepository.save(person)

  let keyName = `${person.keyName}:locationHistory`
  await connection.xAdd(keyName, '*', person.location)
  ...snip...

.xAdd() 接受一个键名(key name)、一个事件 ID(event ID)以及一个包含构成事件(即事件数据)的键值对的 JavaScript 对象。对于键名,我们使用 PersonEntity 继承的 .keyName 属性(它将返回类似 Person:01FYC7CTPKYNXQ98JSTBC37AS1 的内容)与一个硬编码值组合构建一个字符串。我们将 * 作为事件 ID 传递,这告诉 Redis 根据当前时间和上一个事件 ID 生成它。我们将位置——包含经度和纬度属性——作为事件数据传递。

现在,无论何时调用此路由,经度和纬度都将被记录,事件 ID 将编码时间。继续使用 Swagger 移动 Joan Jett 几次。

现在,进入 Redis Insight,查看一下流(Stream)。您会在键列表中看到它,但如果您单击它,您会看到一条消息说“此数据类型即将推出!”。如果您没有看到这条消息,恭喜您,您生活在未来!对于生活在过去的我们来说,我们将直接发出原始命令

XRANGE Person:01FYC7CTPKYNXQ98JSTBC37AS1:locationHistory - +

这告诉 Redis 从给定键名——在我们的例子中是 Person:01FYC7CTPKYNXQ98JSTBC37AS1:locationHistory——存储的流(Stream)中获取一个范围的值。接下来的值是起始事件 ID 和结束事件 ID。- 是流的开始。+ 是结束。所以这会返回流中的所有内容

1) 1) "1647536562911-0"
  2) 1) "longitude"
      2) "45.678"
      3) "latitude"
      4) "45.678"
2) 1) "1647536564189-0"
  2) 1) "longitude"
      2) "45.679"
      3) "latitude"
      4) "45.679"
3) 1) "1647536565278-0"
  2) 1) "longitude"
      2) "45.680"
      3) "latitude"
      4) "45.680"

就这样,我们正在跟踪 Joan Jett。

总结

所以,现在您知道如何使用 Express + Redis OM 构建一个由 Redis Stack 提供支持的 API 了。而且,在此过程中,您还获得了一些相当不错的启动代码。划算!如果您想了解更多信息,可以查看 Redis OM 的文档。它涵盖了 Redis OM 的全部功能。

感谢您花时间阅读本文。我衷心希望您觉得它有用。如果您有任何问题,Redis Discord 服务器是迄今为止获得解答的最佳场所。加入服务器并尽情提问吧!

评价此页面
返回顶部 ↑